Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ npm-debug.log*
CLAUDE.md
SPEC.md
TODO.md
docs/superpowers/

# Temp files
*.tmp
Expand Down
4 changes: 4 additions & 0 deletions media/gutter-error.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions media/gutter-warning.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
14 changes: 9 additions & 5 deletions server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,17 @@ 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);

// Document manager
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);

// Track visible editor ranges per document for viewport-aware validation
const visibleRanges = new Map<string, { startLine: number; endLine: number }[]>();
// 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;
Expand Down Expand Up @@ -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);
});
Expand Down
46 changes: 46 additions & 0 deletions server/src/visibleRanges.ts
Original file line number Diff line number Diff line change
@@ -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<string, Map<string, LineRange[]>>();

/** 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);
}
}
80 changes: 78 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<vscode.FileSystemWatcher>();

// Map of supported language IDs for BioFmt
Expand Down Expand Up @@ -88,6 +93,11 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
// 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);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) => {
Expand All @@ -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': {
Expand Down Expand Up @@ -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);
}
);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -422,6 +496,8 @@ function registerCommands(context: vscode.ExtensionContext): void {
openDiagnosticRuleCommand,
openDiagnosticSpecCommand,
copyDiagnosticRuleCommand,
nextErrorCommand,
prevErrorCommand,
largFileListener
);
}
Expand Down
46 changes: 46 additions & 0 deletions src/services/DiagnosticBridge.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading