]> Repositorios git - classgraph.git/commitdiff
Fix render of tuples and add source-code links
authorJavier Sagredo <[email protected]>
Wed, 6 May 2026 21:35:33 +0000 (23:35 +0200)
committerJavier Sagredo <[email protected]>
Wed, 6 May 2026 21:35:33 +0000 (23:35 +0200)
README.md
data/viewer.css
data/viewer.html
data/viewer.js

index 6942cefb27ff6b6ad798475567720f6694c4bc6f..fb124d6f704a51d409a5b6494070e56e2a6c3c1b 100644 (file)
--- a/README.md
+++ b/README.md
@@ -271,6 +271,21 @@ The instance and family views also have a per-target visibility filter
 in the side panel — one checkbox per item, with `Show all` / `Hide all`
 buttons and a substring search.
 
+### Jumping to source from the panel
+
+The side panel has an `Editor link` block at the top with two settings
+(persisted to `localStorage`):
+
+| Setting | What it does |
+|---|---|
+| **Editor** | Picks a URL scheme — VS Code, VS Code Insiders, Cursor, IntelliJ family, TextMate (`txmt://`), or plain `file://`. Set it to *off* to keep `Defined at` as plain text. |
+| **Source root** | Absolute prefix prepended to relative paths. The plugin records source paths as GHC saw them (usually relative to each package's source dir), so this needs to be set for `vscode://` etc. to resolve them. |
+
+Once both are set, every `Defined at` line in the panel becomes a
+clickable link that opens the file at the right line in your editor.
+Schemes that take a column ( `vscode`, `cursor`, `txmt`) get one;
+`idea` and `file` ignore it.
+
 ## Schema, data flow, design notes
 
 For a deeper walkthrough of where every piece of information comes
index 6632069b48ed8bdfdd974faa2849443485eae4b6..376c4019d0301c1695bd5e889620c6be4e96a339 100644 (file)
@@ -525,6 +525,66 @@ body { display: flex; }
 }
 #selected dd.haddock p { margin: 0 0 8px; }
 #selected dd.haddock p:last-child { margin-bottom: 0; }
+#selected a.src-link {
+  color: #1d4ed8;
+  text-decoration: none;
+  border-bottom: 1px dashed #93c5fd;
+}
+#selected a.src-link:hover {
+  color: #1e3a8a;
+  border-bottom-style: solid;
+}
+
+#editor-settings {
+  margin: 0 0 12px;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+  background: #f1f5f9;
+}
+#editor-settings summary {
+  padding: 8px 12px;
+  font-size: 12px;
+  font-weight: 600;
+  color: #1e3a8a;
+  cursor: pointer;
+  outline: none;
+  user-select: none;
+}
+#editor-settings #editor-summary {
+  font-weight: 400;
+  color: #64748b;
+  margin-left: 4px;
+}
+#editor-settings .editor-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 4px 12px;
+  font-size: 12px;
+}
+#editor-settings .editor-row label {
+  flex: 0 0 86px;
+  color: #475569;
+}
+#editor-settings select,
+#editor-settings input[type="text"] {
+  flex: 1 1 auto;
+  font-size: 12px;
+  padding: 4px 6px;
+  border: 1px solid #cbd5e1;
+  border-radius: 4px;
+  background: #fff;
+}
+#editor-settings input[type="text"] {
+  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+}
+#editor-settings .mute-hint {
+  color: #475569;
+}
+#editor-settings .mute-hint code {
+  background: #e2e8f0;
+  color: #1e293b;
+}
 
 footer { margin-top: 24px; padding-top: 12px; border-top: 1px solid #eee; font-size: 11px; color: #999; }
 
index 4f9284c39b3b5607f82d006722dda2368fd521f4..8b99c446d17557893889c03f0afa7b1e2b51a5dd 100644 (file)
       <p class="hint" id="hint-instance" hidden>Click to highlight; <strong>double-click</strong> a class or family to drill in. The back arrow returns to the hierarchy.</p>
       <p class="hint" id="hint-family" hidden>Each entry is a <code>type instance</code> declaration. <strong>Double-click</strong> the parent class to see its instances.</p>
     </header>
+    <details id="editor-settings">
+      <summary>Editor link <span id="editor-summary"></span></summary>
+      <p class="mute-hint">Make every <em>Defined at</em> line in the right-hand panel a clickable link that jumps to the file at the right line in your editor.</p>
+      <div class="editor-row">
+        <label for="editor-scheme">Editor</label>
+        <select id="editor-scheme">
+          <option value="">— off (plain text) —</option>
+          <option value="vscode">VS Code</option>
+          <option value="vscode-insiders">VS Code Insiders</option>
+          <option value="cursor">Cursor</option>
+          <option value="idea">IntelliJ / IDEA family</option>
+          <option value="txmt">TextMate (txmt://)</option>
+          <option value="file">file:// (no line jump)</option>
+        </select>
+      </div>
+      <div class="editor-row">
+        <label for="editor-root">Source root</label>
+        <input id="editor-root" type="text" placeholder="/abs/path/to/your/checkout" autocomplete="off" spellcheck="false" />
+      </div>
+      <p class="mute-hint">Most schemes (<code>vscode</code>, <code>cursor</code>, <code>idea</code>, …) need <strong>absolute</strong> paths. The plugin records source paths as GHC saw them — usually relative to the package's source dir; prefix that here.</p>
+    </details>
     <details id="mute-filter">
       <summary><span id="mute-summary">Muted classes (0)</span></summary>
       <p class="mute-hint">Use the 🙈 button in the search results to mute noisy superclasses (<code>Show</code>, <code>NoThunks</code>, <code>Typeable</code>, etc.). Muted classes are hidden everywhere — including from instance views' context and superclass edges.</p>
index 917456fe1c1c921657c3894ba1fbe5faa0708521..efb084471547b17f8378bb0b1c61868e70813208 100644 (file)
     return head + ' = ' + renderArg(fi.fiRhs, fi.fiTyVars);
   }
 
+  // Render the argument list of a class/family/instance application as
+  // it would appear in source: space-separated, with each individual arg
+  // self-parenthesising when it's a multi-arg tycon (handled by
+  // renderArg). Multi-param classes thus print as `Foo a b`, not the
+  // tuple-shaped `Foo (a, b)`.
   function renderArgsCompact(args, boundTvs) {
     if (!args || args.length === 0) return '';
-    if (args.length === 1) return renderArg(args[0], boundTvs);
-    return '(' + args.map(a => renderArg(a, boundTvs)).join(', ') + ')';
+    return args.map(a => renderArg(a, boundTvs)).join(' ');
   }
 
   function renderArg(a, boundTvs) {
   // ---------------------------------------------------------------------------
   // Side panel
 
+  // Last node populated into the side panel — kept so settings changes
+  // (e.g. flipping the editor scheme) can re-render the panel without
+  // requiring the user to click the node again.
+  let lastSelectedTarget = null;
+
   function showSelection(target) {
+    lastSelectedTarget = target || null;
     const el = document.getElementById('selected');
     if (!target) { el.innerHTML = '<p class="empty">No selection.</p>'; return; }
     const data = target.data();
     }
   }
 
+  function refreshSelection() {
+    if (lastSelectedTarget) showSelection(lastSelectedTarget);
+  }
+
   function panelButtons(cid, opts) {
     // opts.canPin: whether this entity can be added to the focus set.
     // opts.canMute: whether this entity can be muted.
     return parts.length === 0 ? '' : `<div class="panel-actions">${parts.join('')}</div>`;
   }
 
+  // Editor-link settings: persisted in localStorage. The user picks an
+  // editor scheme (vscode, cursor, idea, …) and an absolute "source root"
+  // prefix; we then turn each `Defined at` row into a clickable link
+  // that opens the file at the right line in that editor. Without a
+  // scheme set we just render plain text.
+  const EDITOR_SCHEME_KEY = 'classgraph.editorScheme';
+  const EDITOR_ROOT_KEY   = 'classgraph.editorRoot';
+
+  function readEditorSettings() {
+    return {
+      scheme: localStorage.getItem(EDITOR_SCHEME_KEY) || '',
+      root:   localStorage.getItem(EDITOR_ROOT_KEY)   || '',
+    };
+  }
+
+  // Build the editor URL for the given file:line:col. Returns null when
+  // no scheme is configured (caller falls back to plain text).
+  function buildEditorUrl(file, line, col) {
+    const { scheme, root } = readEditorSettings();
+    if (!scheme || !file) return null;
+    let abs = file;
+    if (!/^([a-zA-Z]:)?\//.test(file) && root) {
+      // Relative path — prepend the configured root.
+      abs = root.replace(/\/+$/, '') + '/' + file.replace(/^\/+/, '');
+    }
+    const enc = encodeURI(abs);
+    switch (scheme) {
+      case 'vscode':
+      case 'vscode-insiders':
+      case 'cursor':
+        return `${scheme}://file${enc.startsWith('/') ? '' : '/'}${enc}:${line}:${col || 1}`;
+      case 'idea':
+        return `idea://open?file=${encodeURIComponent(abs)}&line=${line}`;
+      case 'txmt':
+        return `txmt://open?url=file://${enc}&line=${line}&column=${col || 1}`;
+      case 'file':
+        return `file://${enc.startsWith('/') ? '' : '/'}${enc}`;
+      default:
+        return null;
+    }
+  }
+
+  // Render a SrcSpanInfo as a `<dd>` cell — clickable when an editor
+  // scheme is configured, plain text otherwise.
+  function renderDefinedAt(src) {
+    if (!src) return '<dd><em>unknown</em></dd>';
+    const text = `${src.ssFile}:${src.ssStartLine}:${src.ssStartCol}`;
+    const url = buildEditorUrl(src.ssFile, src.ssStartLine, src.ssStartCol);
+    if (!url) return `<dd>${escape(text)}</dd>`;
+    return `<dd><a class="src-link" href="${escapeAttr(url)}" title="Open in your editor">${escape(text)}</a></dd>`;
+  }
+
   // Render a Haddock comment as a simple block of paragraphs.
   // Empty strings, plain whitespace, and missing values all collapse to "".
   // We do NOT try to interpret Haddock markup (links, code blocks, etc.) —
       c.ciAssocTypes.map(a => `<dd>${escape(a.qnName)}</dd>`).join('');
     const meths = c.ciMethods.length === 0 ? '<dd><em>none</em></dd>' :
       `<dd>${c.ciMethods.map(escape).join(', ')}</dd>`;
-    const src = c.ciSrc
-      ? `<dd>${escape(c.ciSrc.ssFile)}:${c.ciSrc.ssStartLine}:${c.ciSrc.ssStartCol}</dd>`
-      : '<dd><em>unknown</em></dd>';
+    const src = renderDefinedAt(c.ciSrc);
     const numInsts = (instancesByClass.get(cid) || []).length;
     return `
       <h2>${escape(c.ciName.qnName)}</h2>
       .join('');
     const flav = (typeof f.tfFlavor === 'string')
       ? f.tfFlavor : (f.tfFlavor.tag || JSON.stringify(f.tfFlavor));
+    const src = renderDefinedAt(f.tfSrc);
     return `
       <h2>${escape(f.tfName.qnName)}</h2>
       <p class="pkgmod">${escape(f.tfName.qnPackage)} · ${escape(f.tfName.qnModule)}</p>
         <dt>Flavor</dt><dd>${escape(String(flav))}</dd>
         <dt>Type variables</dt><dd><ul>${tvs}</ul></dd>
         <dt>Result kind</dt><dd>${escape(f.tfResultKind)}</dd>
+        <dt>Defined at</dt>${src}
       </dl>`;
   }
 
     const tvs = inst.iiTyVars.length === 0 ? '<dd><em>none</em></dd>' :
       `<dd><ul>${inst.iiTyVars.map(v =>
         `<li>${escape(v.tvName)}<span style="color:#888"> :: ${escape(v.tvKind)}</span></li>`).join('')}</ul></dd>`;
-    const src = inst.iiSrc
-      ? `<dd>${escape(inst.iiSrc.ssFile)}:${inst.iiSrc.ssStartLine}:${inst.iiSrc.ssStartCol}</dd>`
-      : '<dd><em>unknown</em></dd>';
+    const src = renderDefinedAt(inst.iiSrc);
     return `
       <h2>${head}</h2>
       <p class="pkgmod">${escape(inst.iiClass.qnPackage)} · ${escape(inst.iiClass.qnModule)}</p>
       </dl>`;
   }
 
+  // Append a class/family's args to its name with a leading space, in
+  // applied form: `Foo a b`, not `Foo (a, b)`.
   function renderArgsParens(subTvs, args) {
     if (!args || args.length === 0) return '';
-    return ' (' + args.map(a => renderArg(a, subTvs)).join(', ') + ')';
+    return ' ' + args.map(a => renderArg(a, subTvs)).join(' ');
   }
 
   function renderFamInstPanel(fi) {
     const tvs = fi.fiTyVars.length === 0 ? '<dd><em>none</em></dd>' :
       `<dd><ul>${fi.fiTyVars.map(v =>
         `<li>${escape(v.tvName)}<span style="color:#888"> :: ${escape(v.tvKind)}</span></li>`).join('')}</ul></dd>`;
-    const src = fi.fiSrc
-      ? `<dd>${escape(fi.fiSrc.ssFile)}:${fi.fiSrc.ssStartLine}:${fi.fiSrc.ssStartCol}</dd>`
-      : '<dd><em>unknown</em></dd>';
+    const src = renderDefinedAt(fi.fiSrc);
     return `
       <h2>${head}</h2>
       <p class="pkgmod">${escape(fi.fiFamily.qnPackage)} · ${escape(fi.fiFamily.qnModule)}</p>
     orphanToggle.addEventListener('change', applyOrphanMarker);
   }
 
+  // Editor link settings: select an editor scheme and a source-root
+  // prefix, both persisted to localStorage. Changes refresh the
+  // currently-shown panel so links pick up the new values immediately.
+  (function initEditorSettings() {
+    const schemeSel = document.getElementById('editor-scheme');
+    const rootInput = document.getElementById('editor-root');
+    const summary   = document.getElementById('editor-summary');
+    if (!schemeSel || !rootInput) return;
+    const { scheme, root } = readEditorSettings();
+    schemeSel.value  = scheme;
+    rootInput.value  = root;
+    updateEditorSummary();
+
+    schemeSel.addEventListener('change', () => {
+      localStorage.setItem(EDITOR_SCHEME_KEY, schemeSel.value);
+      updateEditorSummary();
+      refreshSelection();
+    });
+    // Re-render on input rather than blur so the user sees the link
+    // light up as they type the source root.
+    rootInput.addEventListener('input', () => {
+      localStorage.setItem(EDITOR_ROOT_KEY, rootInput.value);
+      updateEditorSummary();
+      refreshSelection();
+    });
+
+    function updateEditorSummary() {
+      if (!summary) return;
+      const s = schemeSel.value;
+      summary.textContent = s ? `(${s})` : '(off)';
+    }
+  })();
+
   // -------------------------------------------------------------------------
   // Side-panel event delegation: toggle Pin/Mute buttons, follow class links.