From 9a22466050835911e09b6c650ba83da0de0fb170 Mon Sep 17 00:00:00 2001 From: Javier Sagredo Date: Wed, 6 May 2026 23:35:33 +0200 Subject: [PATCH] Fix render of tuples and add source-code links --- README.md | 15 ++++++ data/viewer.css | 60 +++++++++++++++++++++++ data/viewer.html | 21 ++++++++ data/viewer.js | 121 ++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 205 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6942cef..fb124d6 100644 --- 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 diff --git a/data/viewer.css b/data/viewer.css index 6632069..376c401 100644 --- a/data/viewer.css +++ b/data/viewer.css @@ -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; } diff --git a/data/viewer.html b/data/viewer.html index 4f9284c..8b99c44 100644 --- a/data/viewer.html +++ b/data/viewer.html @@ -73,6 +73,27 @@ +
+ Editor link +

Make every Defined at line in the right-hand panel a clickable link that jumps to the file at the right line in your editor.

+
+ + +
+
+ + +
+

Most schemes (vscode, cursor, idea, …) need absolute paths. The plugin records source paths as GHC saw them — usually relative to the package's source dir; prefix that here.

+
Muted classes (0)

Use the 🙈 button in the search results to mute noisy superclasses (Show, NoThunks, Typeable, etc.). Muted classes are hidden everywhere — including from instance views' context and superclass edges.

diff --git a/data/viewer.js b/data/viewer.js index 917456f..efb0844 100644 --- a/data/viewer.js +++ b/data/viewer.js @@ -1165,10 +1165,14 @@ 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) { @@ -1240,7 +1244,13 @@ // --------------------------------------------------------------------------- // 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 = '

No selection.

'; return; } const data = target.data(); @@ -1261,6 +1271,10 @@ } } + 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. @@ -1286,6 +1300,58 @@ return parts.length === 0 ? '' : `
${parts.join('')}
`; } + // 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 `
` cell — clickable when an editor + // scheme is configured, plain text otherwise. + function renderDefinedAt(src) { + if (!src) return '
unknown
'; + const text = `${src.ssFile}:${src.ssStartLine}:${src.ssStartCol}`; + const url = buildEditorUrl(src.ssFile, src.ssStartLine, src.ssStartCol); + if (!url) return `
${escape(text)}
`; + return `
${escape(text)}
`; + } + // 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.) — @@ -1327,9 +1393,7 @@ c.ciAssocTypes.map(a => `
${escape(a.qnName)}
`).join(''); const meths = c.ciMethods.length === 0 ? '
none
' : `
${c.ciMethods.map(escape).join(', ')}
`; - const src = c.ciSrc - ? `
${escape(c.ciSrc.ssFile)}:${c.ciSrc.ssStartLine}:${c.ciSrc.ssStartCol}
` - : '
unknown
'; + const src = renderDefinedAt(c.ciSrc); const numInsts = (instancesByClass.get(cid) || []).length; return `

${escape(c.ciName.qnName)}

@@ -1363,6 +1427,7 @@ .join(''); const flav = (typeof f.tfFlavor === 'string') ? f.tfFlavor : (f.tfFlavor.tag || JSON.stringify(f.tfFlavor)); + const src = renderDefinedAt(f.tfSrc); return `

${escape(f.tfName.qnName)}

${escape(f.tfName.qnPackage)} · ${escape(f.tfName.qnModule)}

@@ -1371,6 +1436,7 @@
Flavor
${escape(String(flav))}
Type variables
    ${tvs}
Result kind
${escape(f.tfResultKind)}
+
Defined at
${src} `; } @@ -1388,9 +1454,7 @@ const tvs = inst.iiTyVars.length === 0 ? '
none
' : `
    ${inst.iiTyVars.map(v => `
  • ${escape(v.tvName)} :: ${escape(v.tvKind)}
  • `).join('')}
`; - const src = inst.iiSrc - ? `
${escape(inst.iiSrc.ssFile)}:${inst.iiSrc.ssStartLine}:${inst.iiSrc.ssStartCol}
` - : '
unknown
'; + const src = renderDefinedAt(inst.iiSrc); return `

${head}

${escape(inst.iiClass.qnPackage)} · ${escape(inst.iiClass.qnModule)}

@@ -1403,9 +1467,11 @@ `; } + // 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) { @@ -1415,9 +1481,7 @@ const tvs = fi.fiTyVars.length === 0 ? '
none
' : `
    ${fi.fiTyVars.map(v => `
  • ${escape(v.tvName)} :: ${escape(v.tvKind)}
  • `).join('')}
`; - const src = fi.fiSrc - ? `
${escape(fi.fiSrc.ssFile)}:${fi.fiSrc.ssStartLine}:${fi.fiSrc.ssStartCol}
` - : '
unknown
'; + const src = renderDefinedAt(fi.fiSrc); return `

${head}

${escape(fi.fiFamily.qnPackage)} · ${escape(fi.fiFamily.qnModule)}

@@ -1781,6 +1845,39 @@ 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. -- 2.54.0