From d82955f89c6b219e15b24a167d098c7e388a5ff7 Mon Sep 17 00:00:00 2001 From: zhemingfan Date: Fri, 3 Jul 2026 18:04:45 -0700 Subject: [PATCH] Add inline error decorations surfacing LSP diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostics were validated by the LSP but only visible in the Problems panel and as editor squiggles. Surface them where users actually look: - Preview row markers: a leading status column shows a severity glyph, row tint, and tooltip for every row whose source line has a diagnostic; clicking reveals that line in the editor. Wired into the shared VirtualTable and the VCF preview, covering 13 previews. Non-line previews degrade to no markers. - Editor gutter icons for error/warning lines — the one decoration VS Code does not render from published diagnostics. - A diagnostic navigation bar with error/warning counts (labelled "in view") and next/previous cycling, also driven by F8 / Shift+F8 while a preview is focused, scoped by a biofmtPreviewFocused context key so it never shadows the editor's own F8. - Preview panels now report their viewport, so viewport-aware validation covers what the table shows. Editor and preview viewports are tracked per source and validated as a union via a new VisibleRangeTracker. Adds DiagnosticBridge and DiagnosticGutter services, a shared vscode-free diagnostics module, and webview DiagnosticsContext/RowStatus/DiagnosticNav, with unit tests (diagnostics transform, visible-range union) and component tests (context, row marker, navigation). --- .gitignore | 1 + media/gutter-error.svg | 4 + media/gutter-warning.svg | 5 + package.json | 20 +++ server/src/server.ts | 14 +- server/src/visibleRanges.ts | 46 ++++++ src/extension.ts | 80 ++++++++++- src/services/DiagnosticBridge.ts | 46 ++++++ src/services/DiagnosticGutter.ts | 89 ++++++++++++ src/shared/diagnostics.ts | 85 +++++++++++ test/unit/diagnostics.test.ts | 70 ++++++++++ test/unit/visible-ranges.test.ts | 48 +++++++ webview/src/App.tsx | 19 ++- webview/src/components/DiagnosticNav.test.tsx | 61 ++++++++ webview/src/components/DiagnosticNav.tsx | 87 ++++++++++++ webview/src/components/RowStatus.test.tsx | 47 +++++++ webview/src/components/RowStatus.tsx | 62 ++++++++ webview/src/components/VcfPreview.tsx | 31 +++- webview/src/components/VirtualTable.tsx | 49 ++++++- .../diagnostics/DiagnosticsContext.test.tsx | 53 +++++++ .../src/diagnostics/DiagnosticsContext.tsx | 121 ++++++++++++++++ webview/src/styles.css | 132 ++++++++++++++++++ webview/src/types.ts | 12 +- 23 files changed, 1162 insertions(+), 20 deletions(-) create mode 100644 media/gutter-error.svg create mode 100644 media/gutter-warning.svg create mode 100644 server/src/visibleRanges.ts create mode 100644 src/services/DiagnosticBridge.ts create mode 100644 src/services/DiagnosticGutter.ts create mode 100644 src/shared/diagnostics.ts create mode 100644 test/unit/diagnostics.test.ts create mode 100644 test/unit/visible-ranges.test.ts create mode 100644 webview/src/components/DiagnosticNav.test.tsx create mode 100644 webview/src/components/DiagnosticNav.tsx create mode 100644 webview/src/components/RowStatus.test.tsx create mode 100644 webview/src/components/RowStatus.tsx create mode 100644 webview/src/diagnostics/DiagnosticsContext.test.tsx create mode 100644 webview/src/diagnostics/DiagnosticsContext.tsx diff --git a/.gitignore b/.gitignore index e6f3dc2..da03686 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ npm-debug.log* CLAUDE.md SPEC.md TODO.md +docs/superpowers/ # Temp files *.tmp diff --git a/media/gutter-error.svg b/media/gutter-error.svg new file mode 100644 index 0000000..4d94084 --- /dev/null +++ b/media/gutter-error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/media/gutter-warning.svg b/media/gutter-warning.svg new file mode 100644 index 0000000..b2feef5 --- /dev/null +++ b/media/gutter-warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/package.json b/package.json index 88aee8a..918cbcb 100644 --- a/package.json +++ b/package.json @@ -579,6 +579,26 @@ { "command": "biofmt.navigateToRegion", "title": "BioFmt: Navigate to Overlapping Region" + }, + { + "command": "biofmt.preview.nextError", + "title": "BioFmt: Go to Next Error (Preview)" + }, + { + "command": "biofmt.preview.prevError", + "title": "BioFmt: Go to Previous Error (Preview)" + } + ], + "keybindings": [ + { + "command": "biofmt.preview.nextError", + "key": "f8", + "when": "biofmtPreviewFocused" + }, + { + "command": "biofmt.preview.prevError", + "key": "shift+f8", + "when": "biofmtPreviewFocused" } ], "menus": { diff --git a/server/src/server.ts b/server/src/server.ts index f901dfd..87abb88 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -44,6 +44,7 @@ import type { BioFmtSettings, ValidatorContext } from './validators/types'; import { defaultSettings } from './validators/types'; import { WorkspaceScanner } from './workspace/workspaceScanner'; import { getDiagnosticCodeActions } from './diagnosticActions'; +import { VisibleRangeTracker } from './visibleRanges'; // Create connection using all proposed features const connection = createConnection(ProposedFeatures.all); @@ -51,8 +52,9 @@ const connection = createConnection(ProposedFeatures.all); // Document manager const documents: TextDocuments = new TextDocuments(TextDocument); -// Track visible editor ranges per document for viewport-aware validation -const visibleRanges = new Map(); +// Track visible ranges per document (per source: editor / preview) for +// viewport-aware validation. Validation covers the union across sources. +const visibleRanges = new VisibleRangeTracker(); // Settings let globalSettings: BioFmtSettings = defaultSettings; @@ -105,9 +107,11 @@ connection.onInitialized(() => { } }); -// Receive visible range updates from the client for viewport-aware validation -connection.onNotification('biofmt/visibleRange', (params: { uri: string; ranges: { startLine: number; endLine: number }[] }) => { - visibleRanges.set(params.uri, params.ranges); +// Receive visible range updates from the client for viewport-aware validation. +// `source` distinguishes the text editor's viewport from a preview panel's so +// both are validated (their union) rather than overwriting each other. +connection.onNotification('biofmt/visibleRange', (params: { uri: string; ranges: { startLine: number; endLine: number }[]; source?: string }) => { + visibleRanges.set(params.uri, params.source ?? 'editor', params.ranges); const doc = documents.get(params.uri); if (doc) validateDocument(doc); }); diff --git a/server/src/visibleRanges.ts b/server/src/visibleRanges.ts new file mode 100644 index 0000000..52f22c7 --- /dev/null +++ b/server/src/visibleRanges.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Tracks visible line ranges per document, keyed by source ("editor" or +// "preview"), so viewport-aware validation covers the union of everything the +// user is currently looking at. Without the per-source split, the editor and +// preview viewports would clobber each other (last-writer-wins), causing +// diagnostics to flicker as focus moves between them. + +export interface LineRange { + startLine: number; + endLine: number; +} + +export class VisibleRangeTracker { + private bySource = new Map>(); + + /** Record the visible ranges reported by a given source for a document. */ + set(uri: string, source: string, ranges: LineRange[]): void { + let sources = this.bySource.get(uri); + if (!sources) { + sources = new Map(); + this.bySource.set(uri, sources); + } + sources.set(source, ranges); + } + + /** + * Union of all sources' ranges for a document, or undefined if none reported. + * Returned ranges are not merged/normalized — callers only test line + * membership, for which the flat union is sufficient. + */ + get(uri: string): LineRange[] | undefined { + const sources = this.bySource.get(uri); + if (!sources || sources.size === 0) return undefined; + const union: LineRange[] = []; + for (const ranges of sources.values()) { + for (const r of ranges) union.push(r); + } + return union; + } + + /** Drop all tracked ranges for a document (e.g. on close). */ + delete(uri: string): void { + this.bySource.delete(uri); + } +} diff --git a/src/extension.ts b/src/extension.ts index 3582404..2e00b4c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,9 +17,14 @@ import { clampRowRange, getPreviewLineLimit, normalizePreviewMaxLines } from './ import type { DeclarativeRenderSpec } from './shared/formatSpec'; import { WorkspaceLintLifecycle } from './shared/workspaceLintLifecycle'; import { parseInfoField } from './shared/infoField'; +import { attachDiagnosticBridge, pushDiagnostics } from './services/DiagnosticBridge'; +import { DiagnosticGutter } from './services/DiagnosticGutter'; let client: LanguageClient | undefined; let genomicRegistry: GenomicIndexRegistry | undefined; +// The preview panel that currently has focus, so keyboard commands +// (next/prev error) target the right one. +let activePreviewPanel: vscode.WebviewPanel | undefined; const workspaceLintLifecycle = new WorkspaceLintLifecycle(); // Map of supported language IDs for BioFmt @@ -88,6 +93,11 @@ export async function activate(context: vscode.ExtensionContext): Promise // Start LSP server await startLanguageServer(context); + // Paint gutter icons for error/warning diagnostics in omics editors + context.subscriptions.push( + new DiagnosticGutter(context.extensionPath, (id) => ALL_LANGUAGES.includes(id)) + ); + // Set up cross-format navigation setupGenomicIndex(context); @@ -118,6 +128,7 @@ function registerCommands(context: vscode.ExtensionContext): void { const document = editor.document; const languageId = document.languageId; + const sourceViewColumn = editor.viewColumn; if (!ALL_LANGUAGES.includes(languageId)) { vscode.window.showWarningMessage( @@ -172,6 +183,25 @@ function registerCommands(context: vscode.ExtensionContext): void { // Set up panel content panel.webview.html = getPreviewHtml(panel.webview, context, document); + // Forward this document's diagnostics into the preview so it can render + // row-level error markers; refreshes automatically as diagnostics change. + const diagnosticBridge = attachDiagnosticBridge(panel, document.uri); + + // Track which preview is focused so keyboard error-nav commands hit it, + // and expose a context key for their keybinding `when` clause. + const setActive = (active: boolean) => { + if (active) { + activePreviewPanel = panel; + } else if (activePreviewPanel === panel) { + activePreviewPanel = undefined; + } + void vscode.commands.executeCommand('setContext', 'biofmtPreviewFocused', active); + }; + setActive(panel.active); + const viewStateListener = panel.onDidChangeViewState((e) => + setActive(e.webviewPanel.active) + ); + // Handle messages from webview panel.webview.onDidReceiveMessage( async (message) => { @@ -188,6 +218,30 @@ function registerCommands(context: vscode.ExtensionContext): void { rows, startLine: message.startLine, }); + // Report the preview's viewport so viewport-aware validation + // covers what the table is showing, not just the editor's view. + void client?.sendNotification('biofmt/visibleRange', { + uri: document.uri.toString(), + ranges: [{ startLine: message.startLine, endLine: message.endLine }], + source: 'preview', + }); + break; + } + case 'requestDiagnostics': + pushDiagnostics(panel, document.uri); + break; + case 'revealLine': { + const line = typeof message.line === 'number' ? Math.max(0, message.line) : 0; + const shown = await vscode.window.showTextDocument(document, { + viewColumn: sourceViewColumn, + preserveFocus: false, + }); + const pos = new vscode.Position(line, 0); + shown.selection = new vscode.Selection(pos, pos); + shown.revealRange( + new vscode.Range(pos, pos), + vscode.TextEditorRevealType.InCenter + ); break; } case 'getMetadata': { @@ -222,13 +276,23 @@ function registerCommands(context: vscode.ExtensionContext): void { context.subscriptions ); - // Close preview when document closes; dispose listener when panel closes + // Close preview when document closes; dispose listeners when panel closes const closeListener = vscode.workspace.onDidCloseTextDocument((doc) => { if (doc === document) { panel.dispose(); } }); - panel.onDidDispose(() => closeListener.dispose()); + panel.onDidDispose(() => { + closeListener.dispose(); + diagnosticBridge.dispose(); + viewStateListener.dispose(); + // Only surrender the focus context if this panel actually held it, so + // disposing a background preview can't disable nav on the focused one. + if (activePreviewPanel === panel) { + activePreviewPanel = undefined; + void vscode.commands.executeCommand('setContext', 'biofmtPreviewFocused', false); + } + }); context.subscriptions.push(closeListener); } ); @@ -388,6 +452,16 @@ function registerCommands(context: vscode.ExtensionContext): void { } ); + // Navigate to the next/previous error row in the focused preview. The webview + // owns row order, so the command just forwards intent; F8/Shift+F8 are bound to + // these only while a preview is focused (see package.json `when` clause). + const nextErrorCommand = vscode.commands.registerCommand('biofmt.preview.nextError', () => { + void activePreviewPanel?.webview.postMessage({ command: 'navError', dir: 'next' }); + }); + const prevErrorCommand = vscode.commands.registerCommand('biofmt.preview.prevError', () => { + void activePreviewPanel?.webview.postMessage({ command: 'navError', dir: 'prev' }); + }); + // Warn when a file is too large for VS Code to syntax-highlight. // VS Code silently disables TextMate tokenization above ~20 MB // (editor.largeFileOptimizations). Users see plain white text with no @@ -422,6 +496,8 @@ function registerCommands(context: vscode.ExtensionContext): void { openDiagnosticRuleCommand, openDiagnosticSpecCommand, copyDiagnosticRuleCommand, + nextErrorCommand, + prevErrorCommand, largFileListener ); } diff --git a/src/services/DiagnosticBridge.ts b/src/services/DiagnosticBridge.ts new file mode 100644 index 0000000..93dbb6e --- /dev/null +++ b/src/services/DiagnosticBridge.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import * as vscode from 'vscode'; +import { toPreviewDiagnostics } from '../shared/diagnostics'; + +/** + * Forwards a document's VS Code diagnostics into a preview webview so it can + * render row-level error markers. Pushes the current diagnostics immediately + * and again whenever they change for this document. The pure transform lives in + * `shared/diagnostics` (unit-tested); this module only bridges vscode → webview. + * + * Returns a Disposable that stops forwarding; dispose it when the panel closes. + */ +export function attachDiagnosticBridge( + panel: vscode.WebviewPanel, + uri: vscode.Uri +): vscode.Disposable { + const push = () => { + const diagnostics = toPreviewDiagnostics(vscode.languages.getDiagnostics(uri)); + // postMessage is a no-op if the webview isn't ready yet; the webview also + // sends `requestDiagnostics` on mount to cover that race. + void panel.webview.postMessage({ command: 'diagnostics', diagnostics }); + }; + + const changeSub = vscode.languages.onDidChangeDiagnostics((e) => { + if (e.uris.some((u) => u.toString() === uri.toString())) { + push(); + } + }); + + // Initial push (best-effort; may land before the webview registers its + // listener, which is why the webview re-requests on mount). + push(); + + return { + dispose() { + changeSub.dispose(); + }, + }; +} + +/** Send the current diagnostics for `uri` to the panel (answers requestDiagnostics). */ +export function pushDiagnostics(panel: vscode.WebviewPanel, uri: vscode.Uri): void { + const diagnostics = toPreviewDiagnostics(vscode.languages.getDiagnostics(uri)); + void panel.webview.postMessage({ command: 'diagnostics', diagnostics }); +} diff --git a/src/services/DiagnosticGutter.ts b/src/services/DiagnosticGutter.ts new file mode 100644 index 0000000..9ec423d --- /dev/null +++ b/src/services/DiagnosticGutter.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import * as vscode from 'vscode'; +import * as path from 'path'; + +/** + * Paints gutter icons next to editor lines that have error/warning diagnostics. + * VS Code already renders squiggles, overview-ruler ticks, and minimap marks for + * published diagnostics — the per-line gutter glyph is the one editor decoration + * it does not provide, so that is all this adds. Info/hint severities are left to + * the squiggle to avoid gutter noise. + */ +export class DiagnosticGutter implements vscode.Disposable { + private readonly errorDecoration: vscode.TextEditorDecorationType; + private readonly warningDecoration: vscode.TextEditorDecorationType; + private readonly disposables: vscode.Disposable[] = []; + + constructor( + extensionPath: string, + private readonly isSupportedLanguage: (languageId: string) => boolean + ) { + const icon = (name: string) => + vscode.Uri.file(path.join(extensionPath, 'media', name)); + + this.errorDecoration = vscode.window.createTextEditorDecorationType({ + gutterIconPath: icon('gutter-error.svg'), + gutterIconSize: 'contain', + }); + this.warningDecoration = vscode.window.createTextEditorDecorationType({ + gutterIconPath: icon('gutter-warning.svg'), + gutterIconSize: 'contain', + }); + + this.disposables.push( + this.errorDecoration, + this.warningDecoration, + vscode.languages.onDidChangeDiagnostics((e) => this.onDiagnosticsChanged(e)), + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor) this.refresh(editor); + }), + vscode.window.onDidChangeVisibleTextEditors(() => this.refreshVisible()) + ); + + this.refreshVisible(); + } + + private onDiagnosticsChanged(e: vscode.DiagnosticChangeEvent): void { + const changed = new Set(e.uris.map((u) => u.toString())); + for (const editor of vscode.window.visibleTextEditors) { + if (changed.has(editor.document.uri.toString())) { + this.refresh(editor); + } + } + } + + private refreshVisible(): void { + for (const editor of vscode.window.visibleTextEditors) { + this.refresh(editor); + } + } + + private refresh(editor: vscode.TextEditor): void { + if (!this.isSupportedLanguage(editor.document.languageId)) { + editor.setDecorations(this.errorDecoration, []); + editor.setDecorations(this.warningDecoration, []); + return; + } + + const errors: vscode.DecorationOptions[] = []; + const warnings: vscode.DecorationOptions[] = []; + + for (const diag of vscode.languages.getDiagnostics(editor.document.uri)) { + const line = diag.range.start.line; + const gutterRange = new vscode.Range(line, 0, line, 0); + if (diag.severity === vscode.DiagnosticSeverity.Error) { + errors.push({ range: gutterRange }); + } else if (diag.severity === vscode.DiagnosticSeverity.Warning) { + warnings.push({ range: gutterRange }); + } + } + + editor.setDecorations(this.errorDecoration, errors); + editor.setDecorations(this.warningDecoration, warnings); + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} diff --git a/src/shared/diagnostics.ts b/src/shared/diagnostics.ts new file mode 100644 index 0000000..53dbe52 --- /dev/null +++ b/src/shared/diagnostics.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Shared diagnostic types + transform, used by both the extension (to forward +// VS Code diagnostics into the webview) and the webview (to render row markers). +// Kept free of any `vscode` import so it is unit-testable under Mocha and +// importable from the webview bundle. + +export type PreviewDiagnosticSeverity = 'error' | 'warning' | 'info' | 'hint'; + +export interface PreviewDiagnostic { + /** 0-based start line of the diagnostic. */ + line: number; + /** 0-based end line (equals `line` for single-line diagnostics). */ + endLine: number; + severity: PreviewDiagnosticSeverity; + message: string; + /** Rule code, e.g. "VCF-S005", when present. */ + code?: string; +} + +/** Higher rank = more severe; used to pick the worst diagnostic on a row. */ +export const SEVERITY_RANK: Record = { + error: 3, + warning: 2, + info: 1, + hint: 0, +}; + +// Numeric values of vscode.DiagnosticSeverity, inlined so this module needs no +// `vscode` import. (Error=0, Warning=1, Information=2, Hint=3.) +const SEVERITY_BY_NUMBER: Record = { + 0: 'error', + 1: 'warning', + 2: 'info', + 3: 'hint', +}; + +/** + * Minimal structural shape of a VS Code diagnostic. `vscode.Diagnostic` + * satisfies this, so `toPreviewDiagnostics` accepts the real thing at runtime + * while staying decoupled from the `vscode` module for testing. + */ +export interface RawDiagnostic { + range: { start: { line: number }; end: { line: number } }; + severity?: number; + message: string; + code?: string | number | { value: string | number }; +} + +function codeToString( + code: RawDiagnostic['code'] +): string | undefined { + if (code == null) return undefined; + if (typeof code === 'string') return code; + if (typeof code === 'number') return String(code); + if (typeof code.value === 'string') return code.value; + return String(code.value); +} + +/** Convert VS Code diagnostics into the compact form the webview consumes. */ +export function toPreviewDiagnostics( + diagnostics: readonly RawDiagnostic[] +): PreviewDiagnostic[] { + return diagnostics.map((d) => ({ + line: d.range.start.line, + endLine: d.range.end.line, + // Default missing severity to 'error' (VS Code treats undefined as Error). + severity: d.severity != null ? SEVERITY_BY_NUMBER[d.severity] ?? 'error' : 'error', + message: d.message, + code: codeToString(d.code), + })); +} + +/** The most severe severity among a set of diagnostics, or undefined if empty. */ +export function worstSeverity( + diagnostics: readonly PreviewDiagnostic[] +): PreviewDiagnosticSeverity | undefined { + let worst: PreviewDiagnosticSeverity | undefined; + for (const d of diagnostics) { + if (!worst || SEVERITY_RANK[d.severity] > SEVERITY_RANK[worst]) { + worst = d.severity; + } + } + return worst; +} diff --git a/test/unit/diagnostics.test.ts b/test/unit/diagnostics.test.ts new file mode 100644 index 0000000..1a5d367 --- /dev/null +++ b/test/unit/diagnostics.test.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import * as assert from 'assert'; +import { + toPreviewDiagnostics, + worstSeverity, + type PreviewDiagnostic, + type PreviewDiagnosticSeverity, +} from '../../src/shared/diagnostics'; + +describe('shared/diagnostics', () => { + describe('toPreviewDiagnostics', () => { + it('maps range lines, severity, message, and a string code', () => { + const result = toPreviewDiagnostics([ + { range: { start: { line: 4 }, end: { line: 4 } }, severity: 0, message: 'bad POS', code: 'VCF-S005' }, + ]); + assert.deepStrictEqual(result, [ + { line: 4, endLine: 4, severity: 'error', message: 'bad POS', code: 'VCF-S005' }, + ]); + }); + + it('maps every numeric severity to its name', () => { + const sev = (n: number) => + toPreviewDiagnostics([{ range: { start: { line: 0 }, end: { line: 0 } }, severity: n, message: 'm' }])[0].severity; + assert.strictEqual(sev(0), 'error'); + assert.strictEqual(sev(1), 'warning'); + assert.strictEqual(sev(2), 'info'); + assert.strictEqual(sev(3), 'hint'); + }); + + it('defaults missing severity to error (VS Code treats undefined as Error)', () => { + const r = toPreviewDiagnostics([{ range: { start: { line: 1 }, end: { line: 1 } }, message: 'm' }]); + assert.strictEqual(r[0].severity, 'error'); + }); + + it('stringifies numeric and object codes; leaves undefined undefined', () => { + const num = toPreviewDiagnostics([{ range: { start: { line: 0 }, end: { line: 0 } }, severity: 1, message: 'm', code: 42 }]); + assert.strictEqual(num[0].code, '42'); + const obj = toPreviewDiagnostics([{ range: { start: { line: 0 }, end: { line: 0 } }, severity: 1, message: 'm', code: { value: 'X1' } }]); + assert.strictEqual(obj[0].code, 'X1'); + const none = toPreviewDiagnostics([{ range: { start: { line: 0 }, end: { line: 0 } }, severity: 1, message: 'm' }]); + assert.strictEqual(none[0].code, undefined); + }); + + it('preserves multi-line ranges', () => { + const r = toPreviewDiagnostics([{ range: { start: { line: 3 }, end: { line: 7 } }, severity: 0, message: 'm' }]); + assert.strictEqual(r[0].line, 3); + assert.strictEqual(r[0].endLine, 7); + }); + }); + + describe('worstSeverity', () => { + const d = (severity: PreviewDiagnosticSeverity): PreviewDiagnostic => ({ + line: 0, + endLine: 0, + severity, + message: 'm', + }); + + it('returns undefined for an empty set', () => { + assert.strictEqual(worstSeverity([]), undefined); + }); + + it('picks the most severe regardless of order', () => { + assert.strictEqual(worstSeverity([d('info'), d('error'), d('warning')]), 'error'); + assert.strictEqual(worstSeverity([d('hint'), d('warning'), d('info')]), 'warning'); + assert.strictEqual(worstSeverity([d('info'), d('hint')]), 'info'); + }); + }); +}); diff --git a/test/unit/visible-ranges.test.ts b/test/unit/visible-ranges.test.ts new file mode 100644 index 0000000..801a89c --- /dev/null +++ b/test/unit/visible-ranges.test.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import * as assert from 'assert'; +import { VisibleRangeTracker } from '../../server/src/visibleRanges'; + +describe('VisibleRangeTracker', () => { + it('returns undefined when nothing is tracked', () => { + const t = new VisibleRangeTracker(); + assert.strictEqual(t.get('u'), undefined); + }); + + it('unions ranges across sources for a document', () => { + const t = new VisibleRangeTracker(); + t.set('u', 'editor', [{ startLine: 0, endLine: 10 }]); + t.set('u', 'preview', [{ startLine: 100, endLine: 110 }]); + assert.deepStrictEqual(t.get('u'), [ + { startLine: 0, endLine: 10 }, + { startLine: 100, endLine: 110 }, + ]); + }); + + it('replaces one source without disturbing the others', () => { + const t = new VisibleRangeTracker(); + t.set('u', 'editor', [{ startLine: 0, endLine: 10 }]); + t.set('u', 'preview', [{ startLine: 100, endLine: 110 }]); + t.set('u', 'preview', [{ startLine: 200, endLine: 210 }]); + assert.deepStrictEqual(t.get('u'), [ + { startLine: 0, endLine: 10 }, + { startLine: 200, endLine: 210 }, + ]); + }); + + it('deletes all sources for a document', () => { + const t = new VisibleRangeTracker(); + t.set('u', 'editor', [{ startLine: 0, endLine: 10 }]); + t.set('u', 'preview', [{ startLine: 5, endLine: 6 }]); + t.delete('u'); + assert.strictEqual(t.get('u'), undefined); + }); + + it('isolates documents from each other', () => { + const t = new VisibleRangeTracker(); + t.set('a', 'editor', [{ startLine: 1, endLine: 2 }]); + t.set('b', 'editor', [{ startLine: 3, endLine: 4 }]); + assert.deepStrictEqual(t.get('a'), [{ startLine: 1, endLine: 2 }]); + assert.deepStrictEqual(t.get('b'), [{ startLine: 3, endLine: 4 }]); + }); +}); diff --git a/webview/src/App.tsx b/webview/src/App.tsx index aaefd9a..0138c4e 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -22,7 +22,9 @@ import { GfaPreview } from './components/GfaPreview'; import { FastaPreview } from './components/FastaPreview'; import { FastqPreview } from './components/FastqPreview'; import { FileSizeBanner } from './components/FileSizeBanner'; -import type { DocumentMetadata, MessageFromExtension, VcfHeaderInfo } from './types'; +import { DiagnosticsProvider } from './diagnostics/DiagnosticsContext'; +import { DiagnosticNav } from './components/DiagnosticNav'; +import type { DocumentMetadata, MessageFromExtension, VcfHeaderInfo, PreviewDiagnostic } from './types'; import { getVsCode } from './vscodeApi'; import { getPreviewLineLimit } from '../../src/shared/previewLimits'; import './styles.css'; @@ -44,6 +46,7 @@ export function App() { const loadedCountRef = useRef(0); const [loading, setLoading] = useState(true); const [headerInfo, setHeaderInfo] = useState(null); + const [diagnostics, setDiagnostics] = useState([]); const rowCache = useRef>(new Map()); const pendingRequests = useRef>(new Set()); const flushContiguousRows = useCallback(() => { @@ -70,6 +73,11 @@ export function App() { useEffect(() => { vscode.postMessage({ command: 'getMetadata' }); vscode.postMessage({ command: 'requestRows', startLine: 0, endLine: 500 }); + vscode.postMessage({ command: 'requestDiagnostics' }); + }, []); + + const revealLine = useCallback((line: number) => { + vscode.postMessage({ command: 'revealLine', line }); }, []); // Handle messages from extension @@ -123,6 +131,10 @@ export function App() { case 'headerInfo': setHeaderInfo(message.headerInfo); break; + + case 'diagnostics': + setDiagnostics(message.diagnostics); + break; } } catch (err) { console.error('Error handling message from extension:', err); @@ -417,10 +429,11 @@ export function App() { } return ( - <> + + {preview} - + ); } diff --git a/webview/src/components/DiagnosticNav.test.tsx b/webview/src/components/DiagnosticNav.test.tsx new file mode 100644 index 0000000..489d360 --- /dev/null +++ b/webview/src/components/DiagnosticNav.test.tsx @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import { render, screen, fireEvent } from '@testing-library/react'; +import { DiagnosticNav } from './DiagnosticNav'; +import { DiagnosticsProvider } from '../diagnostics/DiagnosticsContext'; +import type { PreviewDiagnostic } from '../../../src/shared/diagnostics'; + +const diags: PreviewDiagnostic[] = [ + { line: 2, endLine: 2, severity: 'error', message: 'e1' }, + { line: 9, endLine: 9, severity: 'warning', message: 'w1' }, +]; + +describe('DiagnosticNav', () => { + it('renders nothing when there are no diagnostics', () => { + const { container } = render( + {}}> + + + ); + expect(container.firstChild).toBeNull(); + }); + + it('shows error/warning counts labelled "in view"', () => { + const { container } = render( + {}}> + + + ); + expect(container.querySelector('.dn-error')?.textContent).toMatch(/1 error/); + expect(container.querySelector('.dn-warning')?.textContent).toMatch(/1 warning/); + // getByText throws if the node is absent, so calling it is the assertion. + expect(screen.getByText('in view')).toBeTruthy(); + }); + + it('cycles through markers on Next, wrapping at the end', () => { + const onReveal = jest.fn(); + render( + + + + ); + const next = screen.getByLabelText('Next error'); + fireEvent.click(next); + expect(onReveal).toHaveBeenLastCalledWith(2); + fireEvent.click(next); + expect(onReveal).toHaveBeenLastCalledWith(9); + fireEvent.click(next); // wraps back to the first + expect(onReveal).toHaveBeenLastCalledWith(2); + }); + + it('goes to the last marker on Previous from the start', () => { + const onReveal = jest.fn(); + render( + + + + ); + fireEvent.click(screen.getByLabelText('Previous error')); + expect(onReveal).toHaveBeenLastCalledWith(9); + }); +}); diff --git a/webview/src/components/DiagnosticNav.tsx b/webview/src/components/DiagnosticNav.tsx new file mode 100644 index 0000000..a58d02d --- /dev/null +++ b/webview/src/components/DiagnosticNav.tsx @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDiagnostics } from '../diagnostics/DiagnosticsContext'; + +/** + * Header widget summarising diagnostics in the validated viewport and cycling + * through them. Counts are labelled "in view" because validation is + * viewport-bounded — we never imply the whole file was validated. Also responds + * to `navError` messages forwarded from the F8 / Shift+F8 keybindings. + */ +export function DiagnosticNav() { + const { markerLines, counts, revealLine, scrollToLine, hasDiagnostics } = useDiagnostics(); + const indexRef = useRef(-1); + const [position, setPosition] = useState(0); // 1-based display; 0 = none selected + + const go = useCallback( + (dir: 'next' | 'prev') => { + const n = markerLines.length; + if (n === 0) return; + const prev = indexRef.current; + const next = + dir === 'next' + ? prev < 0 + ? 0 + : (prev + 1) % n + : prev <= 0 + ? n - 1 + : prev - 1; + indexRef.current = next; + setPosition(next + 1); + const line = markerLines[next]; + scrollToLine(line); + revealLine(line); + }, + [markerLines, scrollToLine, revealLine] + ); + + // Keep the selected index valid when the marker set changes (e.g. scroll). + useEffect(() => { + if (indexRef.current >= markerLines.length) { + indexRef.current = markerLines.length - 1; + setPosition(markerLines.length); + } + }, [markerLines]); + + // F8 / Shift+F8 are forwarded from the extension as `navError` messages. + useEffect(() => { + const handler = (event: MessageEvent) => { + const msg = event.data; + if (msg && msg.command === 'navError') { + go(msg.dir === 'prev' ? 'prev' : 'next'); + } + }; + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, [go]); + + if (!hasDiagnostics) return null; + + const total = markerLines.length; + const plural = (n: number) => (n === 1 ? '' : 's'); + + return ( +
+ + {counts.error > 0 && ( + {counts.error} error{plural(counts.error)} + )} + {counts.warning > 0 && ( + {counts.warning} warning{plural(counts.warning)} + )} + {counts.info > 0 && {counts.info} info} + in view + + + + {position}/{total} + + +
+ ); +} diff --git a/webview/src/components/RowStatus.test.tsx b/webview/src/components/RowStatus.test.tsx new file mode 100644 index 0000000..a1179f9 --- /dev/null +++ b/webview/src/components/RowStatus.test.tsx @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RowStatus } from './RowStatus'; +import { DiagnosticsProvider } from '../diagnostics/DiagnosticsContext'; +import type { PreviewDiagnostic } from '../../../src/shared/diagnostics'; + +const diags: PreviewDiagnostic[] = [ + { line: 5, endLine: 5, severity: 'error', message: 'bad thing', code: 'VCF-S005' }, +]; + +function wrap(ui: React.ReactElement, onReveal: (line: number) => void = () => {}) { + return render( + + {ui} + + ); +} + +describe('RowStatus', () => { + it('renders a clickable marker with a code+message tooltip for a diagnostic line', () => { + wrap(); + const btn = screen.getByRole('button'); + expect(btn.getAttribute('title')).toContain('[VCF-S005] bad thing'); + expect(btn.className).toContain('row-status-error'); + }); + + it('calls revealLine with the row line on click', () => { + const onReveal = jest.fn(); + wrap(, onReveal); + fireEvent.click(screen.getByRole('button')); + expect(onReveal).toHaveBeenCalledWith(5); + }); + + it('renders an empty placeholder for a clean line', () => { + const { container } = wrap(); + expect(container.querySelector('[role="button"]')).toBeNull(); + expect(container.querySelector('.row-status')).not.toBeNull(); + }); + + it('renders an empty placeholder when the line is undefined', () => { + const { container } = wrap(); + expect(container.querySelector('[role="button"]')).toBeNull(); + expect(container.querySelector('.row-status')).not.toBeNull(); + }); +}); diff --git a/webview/src/components/RowStatus.tsx b/webview/src/components/RowStatus.tsx new file mode 100644 index 0000000..20ad2e2 --- /dev/null +++ b/webview/src/components/RowStatus.tsx @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import React from 'react'; +import { useDiagnostics } from '../diagnostics/DiagnosticsContext'; +import type { PreviewDiagnosticSeverity } from '../../../src/shared/diagnostics'; + +/** Fixed width of the leading status column, shared by header spacer and rows. */ +export const STATUS_COL_WIDTH = 24; + +// Shape carries severity alongside colour, so the marker is not colour-only. +const GLYPH: Record = { + error: '●', // ● + warning: '▲', // ▲ + info: '■', // ■ + hint: '●', // ● +}; + +/** Row-tint className for a severity (empty string when none). */ +export function severityRowClass(sev?: PreviewDiagnosticSeverity): string { + return sev ? `row-has-${sev}` : ''; +} + +/** + * A single leading status cell for a preview row. Renders a severity glyph with + * a tooltip of the diagnostic message(s) when the row's source `line` has a + * diagnostic; clicking reveals that line in the text editor. Renders an empty + * placeholder (to keep columns aligned) when there is nothing to show. + */ +export function RowStatus({ line, width = STATUS_COL_WIDTH }: { line?: number; width?: number }) { + const { worstFor, forLine, revealLine } = useDiagnostics(); + const sev = line != null ? worstFor(line) : undefined; + + if (line == null || !sev) { + return