diff --git a/docs/engineering/ARCHITECTURE.md b/docs/engineering/ARCHITECTURE.md index 76aea72..c4488d6 100644 --- a/docs/engineering/ARCHITECTURE.md +++ b/docs/engineering/ARCHITECTURE.md @@ -324,3 +324,23 @@ intentionally out of v0 rather than inferred from point order. - The Python `graphforge` package's API can move independently of `@curatelabs/graphforge`'s; the host script and `EngineBackend` mapping may need updates when it does (defensive coding, not a compatibility guarantee). + +# Visualization instance lifecycle + +Graph, chart, temporal, geospatial, and figure webviews are owned by a shared +`VisualizationInstanceRegistry`. Saved visualizations derive a privacy-safe, +stable instance ID from the project root and project-relative visualization +path; unsaved visualizations receive a UUID. Opening one visualization never +replaces a different instance. + +Each controller owns a monotonically increasing render generation. Host +messages establish `{ instanceId, renderGeneration }`; every subsequent +webview event echoes that context. Controllers reject replies from another +instance or an older generation. Starting a render aborts superseded work, and +disposing a panel cancels its active work and releases registered resources. + +Cross-view selection, time, and spatial updates are opt-in. The registry routes +coordination events only when source and target declare the same coordination +group, rejects stale source generations, and never echoes an event to its +source. Registry identities and lifecycle diagnostics contain no project path, +result row, property, coordinate, timestamp, vector, or artifact content. diff --git a/docs/engineering/TESTING.md b/docs/engineering/TESTING.md index 5fc71d8..4ba92da 100644 --- a/docs/engineering/TESTING.md +++ b/docs/engineering/TESTING.md @@ -227,3 +227,11 @@ npm run check && npm run compile && npm run test:unit xvfb-run -a npm test # Linux only; on macOS/Windows just `npm test` npx vsce package --no-dependencies ``` + +# Visualization lifecycle + +`visualizationInstanceRegistry.test.ts` is the fast contract gate for +multi-instance ownership, stable private saved identities, explicit +coordination groups, stale-generation rejection, superseded-work cancellation, +and deterministic disposal. Webview typechecking additionally verifies that +graph, artifact, and figure messages carry the instance/revision context. diff --git a/package.json b/package.json index 9827a11..3556e74 100644 --- a/package.json +++ b/package.json @@ -640,7 +640,7 @@ "check": "tsc --noEmit && tsc --noEmit -p webview-ui && tsc --noEmit -p tsconfig.bench.json", "lint": "npm run check", "test": "npm run compile:host && vscode-test", - "test:unit": "npm run compile && mocha --ui tdd dist/test/projectDetector.test.js dist/test/environmentReport.test.js dist/test/uuid.test.js dist/test/arrowCodec.test.js dist/test/runtimeSelection.test.js dist/test/errorPresentation.test.js dist/test/projectKind.test.js dist/test/pythonInstallCommand.test.js dist/test/engineVersion.test.js dist/test/versionDiscovery.test.js dist/test/pythonProbe.test.js dist/test/pythonBridge.test.js dist/test/settingsSchema.test.js dist/test/getStartedContent.test.js dist/test/viewContributions.test.js dist/test/quickstartSample.test.js dist/test/projectArtifacts.test.js dist/test/visualizationRegistry.test.js dist/test/visualizationDocumentState.test.js dist/test/visualizationLoadingModel.test.js dist/test/geospatialModel.test.js dist/test/agentContext.test.js dist/test/knowledgeStatus.test.js dist/test/pickerCopy.test.js dist/test/paletteTitles.test.js dist/test/figureFromResult.test.js dist/test/figureSchema.test.js dist/test/resultGraphModel.test.js dist/test/resultTableModel.test.js dist/test/entityMutation.test.js dist/test/resultDocument.test.js dist/test/moduleManifest.test.js dist/test/workspaceScript.test.js dist/test/importData.test.js", + "test:unit": "npm run compile && mocha --ui tdd dist/test/visualizationInstanceRegistry.test.js dist/test/projectDetector.test.js dist/test/environmentReport.test.js dist/test/uuid.test.js dist/test/arrowCodec.test.js dist/test/runtimeSelection.test.js dist/test/errorPresentation.test.js dist/test/projectKind.test.js dist/test/pythonInstallCommand.test.js dist/test/engineVersion.test.js dist/test/versionDiscovery.test.js dist/test/pythonProbe.test.js dist/test/pythonBridge.test.js dist/test/settingsSchema.test.js dist/test/getStartedContent.test.js dist/test/viewContributions.test.js dist/test/quickstartSample.test.js dist/test/projectArtifacts.test.js dist/test/visualizationRegistry.test.js dist/test/visualizationDocumentState.test.js dist/test/visualizationLoadingModel.test.js dist/test/geospatialModel.test.js dist/test/agentContext.test.js dist/test/knowledgeStatus.test.js dist/test/pickerCopy.test.js dist/test/paletteTitles.test.js dist/test/figureFromResult.test.js dist/test/figureSchema.test.js dist/test/resultGraphModel.test.js dist/test/resultTableModel.test.js dist/test/entityMutation.test.js dist/test/resultDocument.test.js dist/test/moduleManifest.test.js dist/test/workspaceScript.test.js dist/test/importData.test.js", "compile:bench": "vite build --mode bench", "bench": "npm run compile:bench && npm run bench:run", "bench:run": "node dist/bench/index.mjs", diff --git a/src/commands/projectArtifacts.ts b/src/commands/projectArtifacts.ts index 1b474eb..bad16d9 100644 --- a/src/commands/projectArtifacts.ts +++ b/src/commands/projectArtifacts.ts @@ -35,6 +35,7 @@ import { type ResultGraphLifecycleMessage, } from "../webview/resultGraphPanel"; import type { ResultGraphViewOptions } from "../webview/resultGraphModel"; +import { visualizationInstanceId } from "../webview/visualizationInstanceRegistry"; function requireResultFields( result: { columns: string[] }, @@ -160,6 +161,7 @@ type ArtifactPathInput = string | vscode.Uri | ArtifactPathArgs; function waitForResultGraphLifecycle( renderer: "g6" | "cytoscape" | "sigma", timeoutMs: number, + instanceId: string, ): { promise: Promise; dispose: () => void } { let disposable: vscode.Disposable | undefined; let timer: NodeJS.Timeout | undefined; @@ -183,6 +185,7 @@ function waitForResultGraphLifecycle( disposable = ResultGraphPanel.onDidLifecycle((message) => { if ( message.renderer === renderer && + message.instanceId === instanceId && (message.type === "graphforge/renderReady" || message.type === "graphforge/renderFailed") ) { @@ -668,8 +671,9 @@ export function registerProjectArtifacts( throw new Error("Visualization timeoutMs must be an integer from 1000 through 60000."); } const timeoutMs = requestedTimeout ?? 30_000; + const graphInstanceId = visualizationInstanceId("graph", projectRoot, relativePath); const lifecycle = waitForReady - ? waitForResultGraphLifecycle(renderer, timeoutMs) + ? waitForResultGraphLifecycle(renderer, timeoutMs, graphInstanceId) : undefined; try { const outcome = await vscode.commands.executeCommand>( @@ -677,6 +681,7 @@ export function registerProjectArtifacts( { title: spec.name, payload: graphPayload, + instanceId: graphInstanceId, ...options, }, ); @@ -684,7 +689,11 @@ export function registerProjectArtifacts( return { path: relativePath, absolutePath, kind: spec.kind, spec, ...outcome }; } if (spec.format === "graphforge.visualization/v2") { - ResultGraphPanel.current?.attachArtifact( + const instanceId = typeof outcome?.instanceId === "string" ? outcome.instanceId : undefined; + const graphPanel = instanceId + ? ResultGraphPanel.instances().find((candidate) => candidate.instanceId === instanceId) + : undefined; + graphPanel?.attachArtifact( projectRoot, relativePath, spec, diff --git a/src/commands/visualize.ts b/src/commands/visualize.ts index 089507a..9e828e9 100644 --- a/src/commands/visualize.ts +++ b/src/commands/visualize.ts @@ -7,6 +7,8 @@ import type { ResultGraphViewOptions } from "../webview/resultGraphModel"; export interface ShowResultGraphArgs extends ResultGraphViewOptions { title?: string; payload?: GraphPayload; + instanceId?: string; + coordinationGroup?: string; } export function registerVisualizationCommands( @@ -30,9 +32,13 @@ export function registerVisualizationCommands( visualDensity: args?.visualDensity, labels: args?.labels, timebar: args?.timebar, + }, { + instanceId: args?.instanceId, + coordinationGroup: args?.coordinationGroup, }); return { panel: shown.status, + instanceId: shown.panel.instanceId, nodes: payload.nodes.length, edges: payload.edges.length, styleMode: payload.styleMode, diff --git a/src/extension.ts b/src/extension.ts index 62d8c48..b832454 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -24,6 +24,7 @@ import { OntologyTreeProvider } from "./views/ontologyTree"; import { ProjectExplorerProvider } from "./views/projectExplorer"; import { EntityInspectPanel } from "./webview/entityInspectPanel"; import { ModuleManagerPanel } from "./webview/moduleManagerPanel"; +import { visualizationInstances } from "./webview/visualizationInstanceRegistry"; import { RESULTS_VIEW_ID, ResultTableViewProvider, @@ -33,7 +34,7 @@ export async function activate( context: vscode.ExtensionContext, ): Promise { const session = new GraphForgeSession(); - context.subscriptions.push(session); + context.subscriptions.push(session, { dispose: () => visualizationInstances.dispose() }); const projects = new ProjectExplorerProvider(session); const ontology = new OntologyTreeProvider(session); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 0caa48d..24caefa 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -368,6 +368,26 @@ suite("GraphForge agent interop — safe commands (no binding, no project)", () assert.ok(result.panel === "opened" || result.panel === "updated"); }); + test("keeps independently opened unsaved figures alive (#81)", async () => { + const countFigures = () => vscode.window.tabGroups.all.flatMap((group) => group.tabs) + .filter((tab) => tab.input instanceof vscode.TabInputWebview) + .filter((tab) => (tab.input as vscode.TabInputWebview).viewType.replace(/^mainThreadWebview-/, "") === "graphforge.figure") + .length; + const before = countFigures(); + const first = await vscode.commands.executeCommand<{ panel?: string }>("graphforge.showFigure", { + figure: { data: [{ type: "bar", x: ["a"], y: [1] }], layout: { title: "Independent A" } }, + }); + const second = await vscode.commands.executeCommand<{ panel?: string }>("graphforge.showFigure", { + figure: { data: [{ type: "bar", x: ["b"], y: [2] }], layout: { title: "Independent B" } }, + }); + assert.equal(first?.panel, "opened"); + assert.equal(second?.panel, "opened"); + for (let attempt = 0; attempt < 50 && countFigures() < before + 2; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.ok(countFigures() >= before + 2); + }); + test("showFigure without figure returns FIGURE_REQUIRED (#62)", async () => { const result = await vscode.commands.executeCommand<{ error?: string; diff --git a/src/test/visualizationInstanceRegistry.test.ts b/src/test/visualizationInstanceRegistry.test.ts new file mode 100644 index 0000000..374b8c9 --- /dev/null +++ b/src/test/visualizationInstanceRegistry.test.ts @@ -0,0 +1,72 @@ +import * as assert from "node:assert/strict"; +import { VisualizationInstanceLifecycle, VisualizationInstanceRegistry, visualizationInstanceId, type VisualizationController, type VisualizationKind } from "../webview/visualizationInstanceRegistry"; + +function controller(instanceId: string, kind: VisualizationKind, coordinationGroup?: string): VisualizationController & { disposed: boolean } { + return { instanceId, kind, coordinationGroup, renderGeneration: 1, disposed: false, reveal() {}, dispose() { this.disposed = true; } }; +} + +suite("visualization instance registry (#81)", () => { + test("keeps multiple visualization kinds independent", () => { + const registry = new VisualizationInstanceRegistry(); + const graph = registry.register(controller("graph:a", "graph")); + const map = registry.register(controller("geospatial:b", "geospatial")); + assert.equal(registry.get("graph:a"), graph); + assert.equal(registry.get("geospatial:b"), map); + assert.equal(registry.values().length, 2); + }); + test("activation and removal never replace another instance", () => { + const registry = new VisualizationInstanceRegistry(); + const first = registry.register(controller("graph:a", "graph")); + const second = registry.register(controller("graph:b", "graph")); + assert.equal(registry.active("graph"), second); + registry.activate(first.instanceId); + assert.equal(registry.active("graph"), first); + registry.remove(first.instanceId); + assert.equal(registry.active("graph"), second); + }); + test("coordination is explicit and disposal is deterministic", () => { + const registry = new VisualizationInstanceRegistry(); + const graph = registry.register(controller("graph:a", "graph", "analysis:1")); + const timeline = registry.register(controller("temporal:b", "temporal", "analysis:1")); + registry.register(controller("chart:c", "chart")); + assert.deepEqual(registry.coordinated("analysis:1"), [graph, timeline]); + registry.dispose(); + assert.equal(graph.disposed, true); + assert.equal(timeline.disposed, true); + }); + test("saved identities are stable and private; unsaved identities are unique", () => { + const saved = visualizationInstanceId("graph", "/secret/project", "visualizations/a.json"); + assert.equal(saved, visualizationInstanceId("graph", "/secret/project", "visualizations/a.json")); + assert.notEqual(visualizationInstanceId("graph"), visualizationInstanceId("graph")); + assert.doesNotMatch(saved, /secret|visualizations/); + }); + test("cancels superseded work and rejects late generations", () => { + const lifecycle = new VisualizationInstanceLifecycle("graph:a"); + const first = lifecycle.beginRender(); + const second = lifecycle.beginRender(); + assert.equal(first.signal.aborted, true); + assert.equal(second.signal.aborted, false); + assert.equal(lifecycle.accepts(first.context), false); + assert.equal(lifecycle.accepts(second.context), true); + lifecycle.dispose(); + assert.equal(second.signal.aborted, true); + assert.equal(lifecycle.accepts(second.context), false); + }); + test("routes coordination only to an explicit group and rejects stale sources", () => { + const registry = new VisualizationInstanceRegistry(); + registry.register(controller("graph:a", "graph", "analysis:1")); + registry.register(controller("temporal:b", "temporal", "analysis:1")); + registry.register(controller("chart:c", "chart")); + const received: unknown[] = []; + registry.subscribeCoordination("temporal:b", (event) => received.push(event.payload)); + registry.publishCoordination({ + group: "analysis:1", sourceInstanceId: "graph:a", instanceId: "graph:a", + renderGeneration: 1, type: "selection", payload: { ids: ["n1"] }, + }); + registry.publishCoordination({ + group: "analysis:1", sourceInstanceId: "graph:a", instanceId: "graph:a", + renderGeneration: 0, type: "selection", payload: { ids: ["stale"] }, + }); + assert.deepEqual(received, [{ ids: ["n1"] }]); + }); +}); diff --git a/src/views/resultTableView.ts b/src/views/resultTableView.ts index 8294b04..4568df9 100644 --- a/src/views/resultTableView.ts +++ b/src/views/resultTableView.ts @@ -113,7 +113,8 @@ export class ResultTableViewProvider private selectResult(rowIndex: number, column?: string): void { if (!this.state) return; - const highlight = ResultGraphPanel.current?.highlightFromResult( + const graphPanel = ResultGraphPanel.active(); + const highlight = graphPanel?.highlightFromResult( this.state.result, rowIndex, column, @@ -123,7 +124,7 @@ export class ResultTableViewProvider this.post({ type: "graphforge/resultSelection", linked: count > 0, - message: !ResultGraphPanel.current + message: !graphPanel ? "Open Result Graph to link this selection." : count > 0 ? `Highlighted ${highlight?.nodeIds.length ?? 0} node(s) and ${highlight?.edgeIds.length ?? 0} edge(s).` diff --git a/src/webview/artifactVisualizationPanel.ts b/src/webview/artifactVisualizationPanel.ts index 0863e02..3d81fb3 100644 --- a/src/webview/artifactVisualizationPanel.ts +++ b/src/webview/artifactVisualizationPanel.ts @@ -17,6 +17,13 @@ import type { ArtifactVisualizationSpec, ArtifactVisualizationWebviewToHost, } from "./artifactVisualizationProtocol"; +import { + visualizationInstanceId, + visualizationInstances, + VisualizationInstanceLifecycle, + type VisualizationController, + type VisualizationKind, +} from "./visualizationInstanceRegistry"; function isRenderableSpec(value: unknown): value is ArtifactVisualizationSpec { return ( @@ -42,8 +49,10 @@ function immutableIdentityMatches( ); } -export class ArtifactVisualizationPanel { - public static current: ArtifactVisualizationPanel | undefined; +export class ArtifactVisualizationPanel implements VisualizationController { + private readonly lifecycle: VisualizationInstanceLifecycle; + public get renderGeneration(): number { return this.lifecycle.renderGeneration; } + public readonly coordinationGroup: string | undefined; private readonly panel: vscode.WebviewPanel; private readonly disposables: vscode.Disposable[] = []; private projectRoot: string; @@ -62,7 +71,12 @@ export class ArtifactVisualizationPanel { result: QueryResult, onSaved?: () => void, onSelectRow?: (rowIndex: number) => void, + public readonly instanceId: string = visualizationInstanceId("chart"), + coordinationGroup?: string, ) { + this.lifecycle = new VisualizationInstanceLifecycle(instanceId); + this.lifecycle.beginRender(); + this.coordinationGroup = coordinationGroup; this.panel = panel; this.projectRoot = projectRoot; this.artifactPath = artifactPath; @@ -72,7 +86,8 @@ export class ArtifactVisualizationPanel { this.onSelectRow = onSelectRow; trackVizPanel(panel); this.panel.onDidDispose(() => { - ArtifactVisualizationPanel.current = undefined; + visualizationInstances.remove(this.instanceId); + this.lifecycle.dispose(); for (const disposable of this.disposables.splice(0)) disposable.dispose(); }); this.panel.webview.onDidReceiveMessage((message: ArtifactVisualizationWebviewToHost) => { @@ -90,20 +105,23 @@ export class ArtifactVisualizationPanel { result: QueryResult, onSaved?: () => void, onSelectRow?: (rowIndex: number) => void, + coordinationGroup?: string, ): Promise<{ panel: ArtifactVisualizationPanel; status: "opened" | "updated" | "cancelled" }> { - if (ArtifactVisualizationPanel.current) { - revealVizPanel(ArtifactVisualizationPanel.current.panel); - if (ArtifactVisualizationPanel.current.state.dirty) { + const instanceId = visualizationInstanceId(spec.kind, projectRoot, artifactPath); + const existing = visualizationInstances.get(instanceId); + if (existing) { + existing.reveal(); + if (existing.state.dirty) { const choice = await vscode.window.showWarningMessage( "This visualization has unsaved artifact changes. Discard them and open the requested visualization?", { modal: true }, "Discard changes", ); if (choice !== "Discard changes") { - return { panel: ArtifactVisualizationPanel.current, status: "cancelled" }; + return { panel: existing, status: "cancelled" }; } } - ArtifactVisualizationPanel.current.replace( + existing.replace( projectRoot, artifactPath, spec, @@ -111,7 +129,7 @@ export class ArtifactVisualizationPanel { onSaved, onSelectRow, ); - return { panel: ArtifactVisualizationPanel.current, status: "updated" }; + return { panel: existing, status: "updated" }; } const panel = vscode.window.createWebviewPanel( "graphforge.artifactVisualization", @@ -132,11 +150,26 @@ export class ArtifactVisualizationPanel { result, onSaved, onSelectRow, + instanceId, + coordinationGroup, ); - ArtifactVisualizationPanel.current = created; + visualizationInstances.register(created); return { panel: created, status: "opened" }; } + get kind(): VisualizationKind { + return this.state.draft.kind; + } + + reveal(): void { + visualizationInstances.activate(this.instanceId); + revealVizPanel(this.panel); + } + + dispose(): void { + this.panel.dispose(); + } + private replace( projectRoot: string, artifactPath: string, @@ -145,6 +178,7 @@ export class ArtifactVisualizationPanel { onSaved?: () => void, onSelectRow?: (rowIndex: number) => void, ): void { + this.lifecycle.beginRender(); this.projectRoot = projectRoot; this.artifactPath = artifactPath; this.result = result; @@ -163,6 +197,7 @@ export class ArtifactVisualizationPanel { private postCurrent(): void { const message: ArtifactVisualizationHostToWebview = { + ...this.messageContext, type: "graphforge/artifactVisualization", path: this.artifactPath, spec: this.state.draft, @@ -177,6 +212,9 @@ export class ArtifactVisualizationPanel { this.postCurrent(); return; } + if (!this.lifecycle.accepts(message)) { + return; + } if (message.type === "graphforge/renderStarted") { console.info(`[GraphForge] Visualization ${message.kind}/${message.renderer} render started.`); return; @@ -201,6 +239,7 @@ export class ArtifactVisualizationPanel { const candidate: ProjectVisualizationSpecV2 = message.spec; if (!isRenderableSpec(candidate) || !immutableIdentityMatches(this.state.committed, candidate)) { const response: ArtifactVisualizationHostToWebview = { + ...this.messageContext, type: "graphforge/artifactError", message: "The visualization proposed an invalid or identity-changing artifact state.", }; @@ -209,6 +248,7 @@ export class ArtifactVisualizationPanel { } this.state.update(candidate); const response: ArtifactVisualizationHostToWebview = { + ...this.messageContext, type: "graphforge/artifactDirty", dirty: this.state.dirty, }; @@ -218,6 +258,7 @@ export class ArtifactVisualizationPanel { if (message.type === "graphforge/revertArtifactState") { const spec = this.state.revert(); const response: ArtifactVisualizationHostToWebview = { + ...this.messageContext, type: "graphforge/artifactReverted", spec, }; @@ -232,12 +273,14 @@ export class ArtifactVisualizationPanel { this.state.commit(); this.onSaved?.(); const response: ArtifactVisualizationHostToWebview = { + ...this.messageContext, type: "graphforge/artifactCommitted", spec, }; void this.panel.webview.postMessage(response); } catch (error) { const response: ArtifactVisualizationHostToWebview = { + ...this.messageContext, type: "graphforge/artifactError", message: error instanceof Error ? error.message : String(error), }; @@ -246,6 +289,10 @@ export class ArtifactVisualizationPanel { } } + private get messageContext(): { instanceId: string; renderGeneration: number } { + return { instanceId: this.instanceId, renderGeneration: this.renderGeneration }; + } + private getHtml(webview: vscode.Webview, extensionUri: vscode.Uri): string { const assetsRoot = vscode.Uri.joinPath(extensionUri, "dist", "webview-ui"); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(assetsRoot, "artifactVisualization.js")); diff --git a/src/webview/artifactVisualizationProtocol.ts b/src/webview/artifactVisualizationProtocol.ts index 02d0872..80556d8 100644 --- a/src/webview/artifactVisualizationProtocol.ts +++ b/src/webview/artifactVisualizationProtocol.ts @@ -1,5 +1,6 @@ import type { ProjectVisualizationSpecV2 } from "../session/visualizationRegistry"; import type { QueryResult } from "../session/types"; +import type { VisualizationMessageContext } from "./visualizationInstanceRegistry"; export type ArtifactVisualizationSpec = Extract< ProjectVisualizationSpecV2, @@ -8,7 +9,7 @@ export type ArtifactVisualizationSpec = Extract< export type ArtifactRenderPhase = "initialize" | "render" | "interaction"; -export type ArtifactVisualizationHostToWebview = +export type ArtifactVisualizationHostToWebview = ( | { type: "graphforge/artifactVisualization"; path: string; @@ -19,9 +20,10 @@ export type ArtifactVisualizationHostToWebview = | { type: "graphforge/artifactCommitted"; spec: ArtifactVisualizationSpec } | { type: "graphforge/artifactReverted"; spec: ArtifactVisualizationSpec } | { type: "graphforge/artifactDirty"; dirty: boolean } - | { type: "graphforge/artifactError"; message: string }; + | { type: "graphforge/artifactError"; message: string } +) & VisualizationMessageContext; -export type ArtifactVisualizationWebviewToHost = +export type ArtifactVisualizationWebviewToHost = ( | { type: "graphforge/ready" } | { type: "graphforge/renderStarted"; kind: ArtifactVisualizationSpec["kind"]; renderer: string } | { @@ -42,4 +44,5 @@ export type ArtifactVisualizationWebviewToHost = | { type: "graphforge/artifactStateChanged"; spec: ArtifactVisualizationSpec } | { type: "graphforge/saveArtifactState" } | { type: "graphforge/revertArtifactState" } - | { type: "graphforge/selectResult"; rowIndex: number }; + | { type: "graphforge/selectResult"; rowIndex: number } +) & Partial; diff --git a/src/webview/figurePanel.ts b/src/webview/figurePanel.ts index 59122b0..63c0b41 100644 --- a/src/webview/figurePanel.ts +++ b/src/webview/figurePanel.ts @@ -10,26 +10,43 @@ import { revealVizPanel, trackVizPanel, } from "./panelColumn"; +import { + visualizationInstanceId, + visualizationInstances, + VisualizationInstanceLifecycle, + type VisualizationController, +} from "./visualizationInstanceRegistry"; /** * GraphForge Figure panel (#62): Vite-built plotly.js surface. * Host only serves shell HTML and posts figure JSON — never eval. */ -export class FigurePanel { - public static current: FigurePanel | undefined; +export class FigurePanel implements VisualizationController { + public readonly kind = "figure" as const; + public readonly coordinationGroup = undefined; + private readonly lifecycle: VisualizationInstanceLifecycle; + public get renderGeneration(): number { return this.lifecycle.renderGeneration; } private readonly panel: vscode.WebviewPanel; private figure: PlotlyFigure | undefined; private errorMessage: string | undefined; - private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + public readonly instanceId: string, + ) { + this.lifecycle = new VisualizationInstanceLifecycle(instanceId); this.panel = panel; trackVizPanel(panel); this.panel.onDidDispose(() => { - FigurePanel.current = undefined; + visualizationInstances.remove(this.instanceId); + this.lifecycle.dispose(); }); this.panel.webview.onDidReceiveMessage((msg: FigureWebviewToHost) => { if (msg.type === "graphforge/ready") { this.repost(); + } else if (!this.lifecycle.accepts(msg)) { + return; } else if (msg.type === "graphforge/renderFailed") { this.errorMessage = msg.message; } @@ -40,13 +57,15 @@ export class FigurePanel { static show( extensionUri: vscode.Uri, figure?: PlotlyFigure, + instanceId = visualizationInstanceId("figure"), ): { panel: FigurePanel; status: "opened" | "updated" } { - if (FigurePanel.current) { - revealVizPanel(FigurePanel.current.panel); + const existing = visualizationInstances.get(instanceId); + if (existing) { + existing.reveal(); if (figure) { - FigurePanel.current.update(figure); + existing.update(figure); } - return { panel: FigurePanel.current, status: "updated" }; + return { panel: existing, status: "updated" }; } const showOptions = graphForgeVizShowOptions(); const panel = vscode.window.createWebviewPanel( @@ -59,14 +78,24 @@ export class FigurePanel { localResourceRoots: [vscode.Uri.joinPath(extensionUri, "dist", "webview-ui")], }, ); - FigurePanel.current = new FigurePanel(panel, extensionUri); + const created = visualizationInstances.register(new FigurePanel(panel, extensionUri, instanceId)); if (figure) { - FigurePanel.current.update(figure); + created.update(figure); } - return { panel: FigurePanel.current, status: "opened" }; + return { panel: created, status: "opened" }; + } + + reveal(): void { + visualizationInstances.activate(this.instanceId); + revealVizPanel(this.panel); + } + + dispose(): void { + this.panel.dispose(); } update(figure: PlotlyFigure): void { + this.lifecycle.beginRender(); this.figure = figure; this.errorMessage = undefined; this.panel.title = figureTitle(figure); @@ -74,6 +103,7 @@ export class FigurePanel { } showError(message: string): void { + this.lifecycle.beginRender(); this.errorMessage = message; this.figure = undefined; this.repost(); @@ -82,6 +112,7 @@ export class FigurePanel { private repost(): void { if (this.errorMessage) { const msg: FigureHostToWebview = { + ...this.messageContext, type: "graphforge/figureError", message: this.errorMessage, }; @@ -89,11 +120,15 @@ export class FigurePanel { return; } if (this.figure) { - const msg: FigureHostToWebview = { type: "graphforge/figure", figure: this.figure }; + const msg: FigureHostToWebview = { ...this.messageContext, type: "graphforge/figure", figure: this.figure }; void this.panel.webview.postMessage(msg); } } + private get messageContext(): { instanceId: string; renderGeneration: number } { + return { instanceId: this.instanceId, renderGeneration: this.renderGeneration }; + } + private getHtml(webview: vscode.Webview, extensionUri: vscode.Uri): string { const assetsRoot = vscode.Uri.joinPath(extensionUri, "dist", "webview-ui"); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(assetsRoot, "figure.js")); diff --git a/src/webview/figureSchema.ts b/src/webview/figureSchema.ts index e1fe900..ec55b94 100644 --- a/src/webview/figureSchema.ts +++ b/src/webview/figureSchema.ts @@ -19,13 +19,15 @@ export interface PlotlyFigure { frames?: unknown[]; } -export type FigureHostToWebview = +export type FigureHostToWebview = ( | { type: "graphforge/figure"; figure: PlotlyFigure } - | { type: "graphforge/figureError"; message: string }; + | { type: "graphforge/figureError"; message: string } +) & VisualizationMessageContext; -export type FigureWebviewToHost = +export type FigureWebviewToHost = ( | { type: "graphforge/ready" } - | { type: "graphforge/renderFailed"; message: string }; + | { type: "graphforge/renderFailed"; message: string } +) & Partial; export const FIGURE_LIMIT_DEFAULTS = { enabled: false, @@ -148,3 +150,4 @@ export function validateAndLimitFigure( } return enforceFigureLimits(validated.figure, limits); } +import type { VisualizationMessageContext } from "./visualizationInstanceRegistry"; diff --git a/src/webview/protocol.ts b/src/webview/protocol.ts index 83bd33e..cd17821 100644 --- a/src/webview/protocol.ts +++ b/src/webview/protocol.ts @@ -17,6 +17,7 @@ import type { ResultGraphTimebarOptions, ResultGraphVisualDensityOptions, } from "./resultGraphModel"; +import type { VisualizationMessageContext } from "./visualizationInstanceRegistry"; export type ResultGraphRenderPhase = | "initialize" @@ -35,6 +36,7 @@ export interface ResultEntityLink { } export type HostToWebview = + | ({ type: "graphforge/visualizationContext" } & VisualizationMessageContext) | { type: "graphforge/graph"; payload: GraphPayload } | { type: "graphforge/graphRenderer"; renderer: ResultGraphRenderer; render?: boolean } | { @@ -88,7 +90,7 @@ export type HostToWebview = } | { type: "graphforge/status"; message: string }; -export type WebviewToHost = +export type WebviewToHost = ( | { type: "graphforge/ready" } | { type: "graphforge/selectNode"; id: string; shiftKey?: boolean } | { type: "graphforge/selectEdge"; id: string; shiftKey?: boolean } @@ -151,7 +153,8 @@ export type WebviewToHost = | { type: "graphforge/requestReload" } | { type: "graphforge/explainMode" } | { type: "graphforge/openOntologyFile" } - | { type: "graphforge/runCommand"; command: string; args?: unknown[] }; + | { type: "graphforge/runCommand"; command: string; args?: unknown[] } +) & Partial; /** Extension-owned palette (product has no official colors). */ export const EPISTEMIC_COLORS: Record = { diff --git a/src/webview/resultGraphPanel.ts b/src/webview/resultGraphPanel.ts index 8b708a4..23b8ea1 100644 --- a/src/webview/resultGraphPanel.ts +++ b/src/webview/resultGraphPanel.ts @@ -21,6 +21,17 @@ import { resolveResultGraphHighlight, type GraphElementHighlight, } from "./resultTableModel"; +import { + visualizationInstanceId, + visualizationInstances, + VisualizationInstanceLifecycle, + type VisualizationController, +} from "./visualizationInstanceRegistry"; + +export interface ResultGraphInstanceOptions { + instanceId?: string; + coordinationGroup?: string; +} export type ResultGraphLifecycleMessage = Extract< WebviewToHost, @@ -34,8 +45,10 @@ export type ResultGraphLifecycleMessage = Extract< } >; -export class ResultGraphPanel { - public static current: ResultGraphPanel | undefined; +export class ResultGraphPanel implements VisualizationController { + public readonly kind = "graph" as const; + private readonly lifecycle: VisualizationInstanceLifecycle; + public get renderGeneration(): number { return this.lifecycle.renderGeneration; } private static readonly selectionEmitter = new vscode.EventEmitter(); public static readonly onDidSelect = ResultGraphPanel.selectionEmitter.event; @@ -64,11 +77,15 @@ export class ResultGraphPanel { private constructor( panel: vscode.WebviewPanel, private readonly extensionUri: vscode.Uri, + public readonly instanceId: string, + public readonly coordinationGroup?: string, ) { + this.lifecycle = new VisualizationInstanceLifecycle(instanceId); this.panel = panel; trackVizPanel(panel); this.panel.onDidDispose(() => { - ResultGraphPanel.current = undefined; + visualizationInstances.remove(this.instanceId); + this.lifecycle.dispose(); for (const disposable of this.disposables.splice(0)) { disposable.dispose(); } @@ -76,6 +93,7 @@ export class ResultGraphPanel { this.panel.webview.onDidReceiveMessage((msg: WebviewToHost) => { if (msg.type === "graphforge/ready") { this.webviewReady = true; + this.postContext(); this.postRenderer(false); this.postOptions(false); if (this.payload) { @@ -84,6 +102,9 @@ export class ResultGraphPanel { this.postArtifactState(); return; } + if (!this.lifecycle.accepts(msg)) { + return; + } if (msg.type === "graphforge/renderFailed") { ResultGraphPanel.lifecycleEmitter.fire(msg); console.error( @@ -213,25 +234,28 @@ export class ResultGraphPanel { extensionUri: vscode.Uri, payload?: GraphPayload, options: ResultGraphViewOptions = {}, + instance: ResultGraphInstanceOptions = {}, ): Promise<{ panel: ResultGraphPanel; status: "opened" | "updated" | "cancelled" }> { - if (ResultGraphPanel.current) { - revealVizPanel(ResultGraphPanel.current.panel); - if (ResultGraphPanel.current.artifact?.state.dirty) { + const instanceId = instance.instanceId ?? visualizationInstanceId("graph"); + const existing = visualizationInstances.get(instanceId); + if (existing) { + existing.reveal(); + if (existing.artifact?.state.dirty) { const choice = await vscode.window.showWarningMessage( "This result graph has unsaved artifact changes. Discard them and open the requested graph?", { modal: true }, "Discard changes", ); if (choice !== "Discard changes") { - return { panel: ResultGraphPanel.current, status: "cancelled" }; + return { panel: existing, status: "cancelled" }; } } - ResultGraphPanel.current.detachArtifact(); - ResultGraphPanel.current.setViewOptions(options); + existing.detachArtifact(); + existing.setViewOptions(options); if (payload) { - ResultGraphPanel.current.update(payload); + existing.update(payload); } - return { panel: ResultGraphPanel.current, status: "updated" }; + return { panel: existing, status: "updated" }; } const showOptions = graphForgeVizShowOptions(); @@ -245,15 +269,35 @@ export class ResultGraphPanel { localResourceRoots: [vscode.Uri.joinPath(extensionUri, "dist", "webview-ui")], }, ); - ResultGraphPanel.current = new ResultGraphPanel(panel, extensionUri); - ResultGraphPanel.current.setViewOptions(options); + const created = visualizationInstances.register( + new ResultGraphPanel(panel, extensionUri, instanceId, instance.coordinationGroup), + ); + created.setViewOptions(options); if (payload) { - ResultGraphPanel.current.update(payload); + created.update(payload); } - return { panel: ResultGraphPanel.current, status: "opened" }; + return { panel: created, status: "opened" }; + } + + static active(): ResultGraphPanel | undefined { + return visualizationInstances.active("graph"); + } + + static instances(): ResultGraphPanel[] { + return visualizationInstances.values("graph"); + } + + reveal(): void { + visualizationInstances.activate(this.instanceId); + revealVizPanel(this.panel); + } + + dispose(): void { + this.panel.dispose(); } update(payload: GraphPayload): void { + this.lifecycle.beginRender(); this.payload = payload; this.panel.title = payload.title ? `GraphForge: ${payload.title}` @@ -327,6 +371,15 @@ export class ResultGraphPanel { void this.panel.webview.postMessage(msg); } + private postContext(): void { + const msg: HostToWebview = { + type: "graphforge/visualizationContext", + instanceId: this.instanceId, + renderGeneration: this.renderGeneration, + }; + void this.panel.webview.postMessage(msg); + } + private postOptions(render = true): void { if (!this.webviewReady) return; const msg: HostToWebview = { @@ -354,6 +407,7 @@ export class ResultGraphPanel { private postGraph(payload: GraphPayload): void { if (!this.webviewReady) return; + this.postContext(); const msg: HostToWebview = { type: "graphforge/graph", payload }; void this.panel.webview.postMessage(msg); } diff --git a/src/webview/visualizationInstanceRegistry.ts b/src/webview/visualizationInstanceRegistry.ts new file mode 100644 index 0000000..5496c13 --- /dev/null +++ b/src/webview/visualizationInstanceRegistry.ts @@ -0,0 +1,157 @@ +import * as crypto from "node:crypto"; + +export type VisualizationKind = "graph" | "chart" | "temporal" | "geospatial" | "figure"; +export type VisualizationLifecyclePhase = "prepare" | "layout" | "paint" | "ready" | "failed" | "disposed"; + +export interface VisualizationController { + readonly instanceId: string; + readonly kind: VisualizationKind; + readonly coordinationGroup?: string; + readonly renderGeneration: number; + reveal(): void; + dispose(): void; +} + +export interface OwnedVisualizationResource { + dispose(): void; +} + +/** Revision, cancellation, and resource ownership shared by every panel kind. */ +export class VisualizationInstanceLifecycle { + private generation = 0; + private currentWork: AbortController | undefined; + private readonly resources = new Set(); + private disposed = false; + + constructor(readonly instanceId: string) {} + + get renderGeneration(): number { return this.generation; } + + beginRender(): { context: VisualizationMessageContext; signal: AbortSignal } { + if (this.disposed) throw new Error("Visualization instance is disposed."); + this.currentWork?.abort(); + this.currentWork = new AbortController(); + this.generation++; + return { + context: { instanceId: this.instanceId, renderGeneration: this.generation }, + signal: this.currentWork.signal, + }; + } + + accepts(context: Partial): boolean { + return !this.disposed && context.instanceId === this.instanceId && context.renderGeneration === this.generation; + } + + own(resource: T): T { + if (this.disposed) { + resource.dispose(); + return resource; + } + this.resources.add(resource); + return resource; + } + + release(resource: OwnedVisualizationResource): void { this.resources.delete(resource); } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.currentWork?.abort(); + this.currentWork = undefined; + for (const resource of this.resources) resource.dispose(); + this.resources.clear(); + } +} + +export interface VisualizationMessageContext { + instanceId: string; + renderGeneration: number; +} + +export interface VisualizationLifecycleDiagnostic extends VisualizationMessageContext { + kind: VisualizationKind; + phase: VisualizationLifecyclePhase; + durationMs?: number; + counts?: Readonly>; + code?: string; +} + +export interface VisualizationCoordinationEvent extends VisualizationMessageContext { + group: string; + sourceInstanceId: string; + type: "selection" | "time" | "space"; + payload: unknown; +} + +export function visualizationInstanceId(kind: VisualizationKind, projectRoot?: string, visualizationPath?: string): string { + if (!projectRoot || !visualizationPath) return `${kind}:${crypto.randomUUID()}`; + const digest = crypto.createHash("sha256").update(`${projectRoot}\0${visualizationPath}`).digest("hex").slice(0, 24); + return `${kind}:${digest}`; +} + +/** Process-local owner for live visualizations. Source values are never stored here. */ +export class VisualizationInstanceRegistry { + private readonly controllers = new Map(); + private readonly activationOrder: string[] = []; + private readonly coordinationListeners = new Map void>>(); + + register(controller: T): T { + if (this.controllers.has(controller.instanceId)) throw new Error(`Visualization instance is already registered: ${controller.instanceId}`); + this.controllers.set(controller.instanceId, controller); + this.touch(controller.instanceId); + return controller; + } + get(instanceId: string): T | undefined { return this.controllers.get(instanceId) as T | undefined; } + activate(instanceId: string): void { if (this.controllers.has(instanceId)) this.touch(instanceId); } + active(kind?: VisualizationKind): T | undefined { + for (let index = this.activationOrder.length - 1; index >= 0; index--) { + const controller = this.controllers.get(this.activationOrder[index]); + if (controller && (!kind || controller.kind === kind)) return controller as T; + } + return undefined; + } + coordinated(group: string): T[] { + return [...this.controllers.values()].filter((controller) => controller.coordinationGroup === group) as T[]; + } + subscribeCoordination(instanceId: string, listener: (event: VisualizationCoordinationEvent) => void): OwnedVisualizationResource { + if (!this.controllers.has(instanceId)) throw new Error(`Unknown visualization instance: ${instanceId}`); + const listeners = this.coordinationListeners.get(instanceId) ?? new Set(); + listeners.add(listener); + this.coordinationListeners.set(instanceId, listeners); + return { dispose: () => { + listeners.delete(listener); + if (listeners.size === 0) this.coordinationListeners.delete(instanceId); + } }; + } + publishCoordination(event: VisualizationCoordinationEvent): void { + const source = this.controllers.get(event.sourceInstanceId); + if (!source || source.coordinationGroup !== event.group || source.renderGeneration !== event.renderGeneration) return; + for (const target of this.coordinated(event.group)) { + if (target.instanceId === event.sourceInstanceId) continue; + for (const listener of this.coordinationListeners.get(target.instanceId) ?? []) listener(event); + } + } + remove(instanceId: string): boolean { + const removed = this.controllers.delete(instanceId); + this.coordinationListeners.delete(instanceId); + const index = this.activationOrder.indexOf(instanceId); + if (index >= 0) this.activationOrder.splice(index, 1); + return removed; + } + values(kind?: VisualizationKind): T[] { + return [...this.controllers.values()].filter((controller) => !kind || controller.kind === kind) as T[]; + } + dispose(): void { + for (const controller of [...this.controllers.values()]) controller.dispose(); + this.controllers.clear(); + this.activationOrder.length = 0; + this.coordinationListeners.clear(); + } + private touch(instanceId: string): void { + const previous = this.activationOrder.indexOf(instanceId); + if (previous >= 0) this.activationOrder.splice(previous, 1); + this.activationOrder.push(instanceId); + } +} + +export const visualizationInstances = new VisualizationInstanceRegistry(); diff --git a/webview-ui/src/artifactVisualization/main.ts b/webview-ui/src/artifactVisualization/main.ts index 1b9e3a3..a4d7b79 100644 --- a/webview-ui/src/artifactVisualization/main.ts +++ b/webview-ui/src/artifactVisualization/main.ts @@ -48,6 +48,7 @@ let playbackUpdateInProgress = false; let viewportTimer: number | undefined; let renderToken = 0; let loadingPhase: "prepare" | "layout" | "paint" = "prepare"; +let hostContext: { instanceId: string; renderGeneration: number } | undefined; const loading = createVisualizationLoadingController( renderStatusElement, container, @@ -77,7 +78,7 @@ const MAP_SCENE_LOAD_TIMEOUT_MS = 15_000; const VIEWPORT_SAVE_DEBOUNCE_MS = 200; function post(message: ArtifactVisualizationWebviewToHost): void { - vscode.postMessage(message); + vscode.postMessage(hostContext ? { ...message, ...hostContext } : message); } function showBanner(message?: string): void { @@ -608,6 +609,7 @@ pauseButton?.addEventListener("click", stopPlayback); window.addEventListener("message", (event: MessageEvent) => { const message = event.data; if (!message || typeof message !== "object") return; + hostContext = { instanceId: message.instanceId, renderGeneration: message.renderGeneration }; if (message.type === "graphforge/artifactError") { showBanner(message.message); return; diff --git a/webview-ui/src/figure/main.ts b/webview-ui/src/figure/main.ts index 600e6ac..cbbcc79 100644 --- a/webview-ui/src/figure/main.ts +++ b/webview-ui/src/figure/main.ts @@ -14,9 +14,10 @@ import "../shared/visualizationLoading.css"; import "./figure.css"; const vscode = acquireVsCodeApi(); +let hostContext: { instanceId: string; renderGeneration: number } | undefined; function post(message: FigureWebviewToHost): void { - vscode.postMessage(message); + vscode.postMessage(hostContext ? { ...message, ...hostContext } : message); } const root = document.getElementById("app"); @@ -82,6 +83,7 @@ window.addEventListener("message", (event: MessageEvent) => if (!msg || typeof msg !== "object") { return; } + hostContext = { instanceId: msg.instanceId, renderGeneration: msg.renderGeneration }; if (msg.type === "graphforge/figureError") { showBanner(msg.message); loading.update({ renderer: "plotly", phase: "failed", failedAt: "prepare", message: msg.message }); diff --git a/webview-ui/src/resultGraph/main.ts b/webview-ui/src/resultGraph/main.ts index 0cccc82..a54731c 100644 --- a/webview-ui/src/resultGraph/main.ts +++ b/webview-ui/src/resultGraph/main.ts @@ -69,6 +69,7 @@ let viewOptions: Omit = {}; let renderer: RendererHandle | undefined; let runtimeBanner: string | undefined; let renderGeneration = 0; +let hostContext: { instanceId: string; renderGeneration: number } | undefined; const loading = createVisualizationLoadingController( renderStatusElement, @@ -77,7 +78,7 @@ const loading = createVisualizationLoadingController( ); function post(message: WebviewToHost): void { - vscode.postMessage(message); + vscode.postMessage(hostContext ? { ...message, ...hostContext } : message); } function shiftKeyFromPointerPayload(value: unknown): boolean { @@ -1233,7 +1234,9 @@ window.addEventListener("message", (event: MessageEvent) => { if (!message || typeof message !== "object") { return; } - if (message.type === "graphforge/graph") { + if (message.type === "graphforge/visualizationContext") { + hostContext = { instanceId: message.instanceId, renderGeneration: message.renderGeneration }; + } else if (message.type === "graphforge/graph") { payload = message.payload; scheduleRender(); } else if (message.type === "graphforge/graphRenderer") {