Archify Diagram Viewer 0.1.0

VS Code extension that previews Archify diagrams from their JSON sources
(live, as you type) and opens rendered Archify HTML in a viewer tab.
Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime.
Adds validation diagnostics, JSON schema help, source-link navigation,
export saving, render-to-file and open-in-browser commands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 12:44:15 +03:00
co-authored by Claude Opus 5.5
commit 22815a9940
119 changed files with 66708 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
import { parse, ParseError } from 'jsonc-parser';
export const DIAGRAM_TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] as const;
export type DiagramType = (typeof DIAGRAM_TYPES)[number];
/** Node collection that may carry `sources` evidence, per diagram type. */
export const EVIDENCE_COLLECTIONS: Record<DiagramType, string> = {
architecture: 'components',
workflow: 'nodes',
sequence: 'participants',
dataflow: 'nodes',
lifecycle: 'states',
};
const FILENAME_RE = /\.(architecture|workflow|sequence|dataflow|lifecycle)\.json$/i;
const MAX_SNIFF_BYTES = 8 * 1024 * 1024;
export function isDiagramType(value: unknown): value is DiagramType {
return typeof value === 'string' && (DIAGRAM_TYPES as readonly string[]).includes(value);
}
export function typeFromFilename(fileName: string): DiagramType | undefined {
const match = FILENAME_RE.exec(fileName);
return match ? (match[1].toLowerCase() as DiagramType) : undefined;
}
export interface DiagramSource {
type: DiagramType;
diagram: Record<string, unknown>;
}
/**
* Parses JSON text and returns the diagram when it declares an Archify
* `diagram_type`. Falls back to the file name for documents that do not
* parse yet, so a half-typed source still counts as a diagram.
*/
export function readDiagramSource(text: string, fileName = ''): DiagramSource | undefined {
if (text.length > MAX_SNIFF_BYTES) return undefined;
const errors: ParseError[] = [];
const value = parse(text, errors, { allowTrailingComma: true });
if (value && typeof value === 'object' && !Array.isArray(value) && isDiagramType(value.diagram_type)) {
return { type: value.diagram_type, diagram: value };
}
const byName = typeFromFilename(fileName);
if (byName) {
const diagram = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
return { type: byName, diagram };
}
return undefined;
}
/** True when the HTML was produced by the Archify renderer. */
export function isArchifyHtml(text: string): boolean {
const head = text.slice(0, 4096);
return /<meta\s+name=["']generator["']\s+content=["']archify\b/i.test(head);
}
export function hasSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): boolean {
const meta = diagram.meta as Record<string, unknown> | undefined;
if (meta && meta.repository) return true;
const nodes = diagram[EVIDENCE_COLLECTIONS[type]];
return Array.isArray(nodes) && nodes.some((node) => Array.isArray(node?.sources) && node.sources.length > 0);
}
/** Returns a copy of the diagram with repository evidence removed. */
export function stripSourceEvidence(type: DiagramType, diagram: Record<string, unknown>): Record<string, unknown> {
const copy = structuredClone(diagram);
const meta = copy.meta as Record<string, unknown> | undefined;
if (meta) delete meta.repository;
const nodes = copy[EVIDENCE_COLLECTIONS[type]];
if (Array.isArray(nodes)) {
for (const node of nodes) {
if (node && typeof node === 'object') delete node.sources;
}
}
return copy;
}
+132
View File
@@ -0,0 +1,132 @@
import { parse, ParseError } from 'jsonc-parser';
import * as vscode from 'vscode';
import { readDiagramSource } from './detect';
import { rangeForPointer } from './jsonPointer';
import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
import { evidenceMode, log, runtimeFor } from './runtime';
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
function severity(diagnostic: ArchifyDiagnostic, downgradeEvidence: boolean): vscode.DiagnosticSeverity {
if (downgradeEvidence && isEvidenceError(`${diagnostic.code ?? ''} ${diagnostic.message}`)) {
return vscode.DiagnosticSeverity.Warning;
}
switch (diagnostic.severity) {
case 'warning':
return vscode.DiagnosticSeverity.Warning;
case 'info':
return vscode.DiagnosticSeverity.Information;
default:
return vscode.DiagnosticSeverity.Error;
}
}
/** Runs `archify validate` on diagram sources and reports into Problems. */
export class DiagnosticsController implements vscode.Disposable {
private readonly collection = vscode.languages.createDiagnosticCollection('archify');
private readonly timers = new Map<string, NodeJS.Timeout>();
private readonly sequence = new Map<string, number>();
private readonly disposables: vscode.Disposable[];
constructor(private readonly context: vscode.ExtensionContext) {
this.disposables = [
this.collection,
vscode.workspace.onDidOpenTextDocument((doc) => this.schedule(doc, 0)),
vscode.workspace.onDidChangeTextDocument((event) => {
if (event.contentChanges.length) this.schedule(event.document, 700);
}),
vscode.workspace.onDidSaveTextDocument((doc) => this.schedule(doc, 0)),
vscode.workspace.onDidCloseTextDocument((doc) => this.clear(doc.uri)),
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('archify')) this.revalidateAll();
}),
];
this.revalidateAll();
}
private enabled(uri: vscode.Uri): boolean {
return vscode.workspace.getConfiguration('archify', uri).get<boolean>('validation.enabled', true);
}
revalidateAll(): void {
for (const doc of vscode.workspace.textDocuments) this.schedule(doc, 0);
}
private clear(uri: vscode.Uri): void {
const key = uri.toString();
clearTimeout(this.timers.get(key));
this.timers.delete(key);
this.sequence.set(key, (this.sequence.get(key) ?? 0) + 1);
this.collection.delete(uri);
}
private schedule(doc: vscode.TextDocument, delay: number): void {
if (!JSON_LANGUAGES.has(doc.languageId)) return;
const key = doc.uri.toString();
clearTimeout(this.timers.get(key));
this.timers.set(key, setTimeout(() => void this.validate(doc), delay));
}
private async validate(doc: vscode.TextDocument): Promise<void> {
const key = doc.uri.toString();
this.timers.delete(key);
const seq = (this.sequence.get(key) ?? 0) + 1;
this.sequence.set(key, seq);
const text = doc.getText();
const source = readDiagramSource(text, doc.fileName);
if (!source || !this.enabled(doc.uri) || doc.isClosed) {
this.collection.delete(doc.uri);
return;
}
// Leave syntax errors to the JSON language service.
const syntax: ParseError[] = [];
parse(text, syntax, { allowTrailingComma: false });
if (syntax.length) {
this.collection.delete(doc.uri);
return;
}
try {
const runtime = await runtimeFor(this.context, doc.uri);
const result = await validate(runtime, source.type, text);
if (seq !== this.sequence.get(key) || doc.isClosed) return;
if (result.crash) {
const diagnostic = new vscode.Diagnostic(
new vscode.Range(0, 0, 0, 0),
`Archify validation did not complete: ${result.crash.split('\n')[0]}`,
vscode.DiagnosticSeverity.Error,
);
diagnostic.source = 'archify';
this.collection.set(doc.uri, [diagnostic]);
return;
}
const downgradeEvidence = evidenceMode(doc.uri) === 'fallback';
this.collection.set(
doc.uri,
result.diagnostics.map((item) => {
const offsets = rangeForPointer(text, item.subject?.path);
const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
const fix = item.supportedFixes?.[0];
const diagnostic = new vscode.Diagnostic(
range,
fix ? `${item.message}\nFix: ${fix}` : item.message,
severity(item, downgradeEvidence),
);
diagnostic.source = 'archify';
if (item.code) diagnostic.code = item.code;
return diagnostic;
}),
);
} catch (error) {
log().error(`Validation failed for ${doc.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
dispose(): void {
for (const timer of this.timers.values()) clearTimeout(timer);
for (const disposable of this.disposables.splice(0)) disposable.dispose();
}
}
+172
View File
@@ -0,0 +1,172 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import * as vscode from 'vscode';
import { isArchifyHtml, readDiagramSource } from './detect';
import { DiagnosticsController } from './diagnostics';
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
import { PreviewManager } from './preview';
import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
const JSON_LANGUAGES = new Set(['json', 'jsonc']);
function isHtmlUri(uri: vscode.Uri): boolean {
return /\.html?$/i.test(uri.path);
}
export function activate(context: vscode.ExtensionContext): void {
const previews = new PreviewManager(context);
const htmlViewer = new HtmlViewerProvider();
context.subscriptions.push(previews, htmlViewer, new DiagnosticsController(context), log());
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
function targetUri(arg?: unknown): vscode.Uri | undefined {
if (arg instanceof vscode.Uri) return arg;
return previews.activePreview?.uri ?? htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
}
function sourceUri(arg?: unknown): vscode.Uri | undefined {
const uri = targetUri(arg);
if (!uri) vscode.window.showWarningMessage('Archify: open a diagram JSON source first.');
return uri;
}
// Context keys for editor title buttons and keybindings.
let contextTimer: NodeJS.Timeout | undefined;
const updateContext = () => {
const doc = vscode.window.activeTextEditor?.document;
const diagram = Boolean(doc && JSON_LANGUAGES.has(doc.languageId) && readDiagramSource(doc.getText(), doc.fileName));
const html = Boolean(doc && doc.languageId === 'html' && isArchifyHtml(doc.getText()));
void vscode.commands.executeCommand('setContext', 'archify.isDiagramSource', diagram);
void vscode.commands.executeCommand('setContext', 'archify.isArchifyHtml', html);
};
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(updateContext),
vscode.workspace.onDidChangeTextDocument((event) => {
if (event.document !== vscode.window.activeTextEditor?.document) return;
clearTimeout(contextTimer);
contextTimer = setTimeout(updateContext, 300);
}),
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('archify.repoRoot')) clearGitRootCache();
}),
{ dispose: () => clearTimeout(contextTimer) },
);
updateContext();
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
context.subscriptions.push(
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
const uri = sourceUri(arg);
if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri);
previews.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
}),
vscode.commands.registerCommand('archify.showPreviewToSide', (arg?: unknown) => {
const uri = sourceUri(arg);
if (!uri) return;
if (isHtmlUri(uri)) return openHtmlViewer(uri, vscode.ViewColumn.Beside);
previews.show(uri, vscode.ViewColumn.Beside, true);
}),
vscode.commands.registerCommand('archify.openHtmlViewer', (arg?: unknown) => {
const uri = sourceUri(arg);
if (uri) return openHtmlViewer(uri);
}),
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
vscode.commands.registerCommand('archify.showSource', async () => {
const preview = previews.activePreview;
if (preview) {
const column = preview.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
return;
}
const html = htmlViewer.activeUri;
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
}),
vscode.commands.registerCommand('archify.renderToHtml', async (arg?: unknown) => {
const uri = sourceUri(arg);
if (!uri) return;
const text = await readText(uri);
const source = readDiagramSource(text, uri.path);
if (!source) {
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} is not an Archify diagram source.`);
return;
}
const meta = (source.diagram.meta ?? {}) as { output?: unknown };
const authored = typeof meta.output === 'string' && !/[\\/]/.test(meta.output) ? meta.output : undefined;
const fileName = authored ?? `${path.basename(uri.path).replace(/\.json$/i, '')}.html`;
const defaultUri = uri.scheme === 'file' ? vscode.Uri.joinPath(uri, '..', fileName) : undefined;
const target = await vscode.window.showSaveDialog({
defaultUri,
filters: { HTML: ['html'] },
saveLabel: 'Render',
});
if (!target) return;
const runtime = await runtimeFor(context, uri);
let result = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
() => renderToFile(runtime, { ...source, text }, target.fsPath),
);
if (!result.ok && isEvidenceError(result.error)) {
const choice = await vscode.window.showWarningMessage(
'Archify could not verify the diagram\'s source evidence against a local checkout.',
{ modal: true, detail: result.error },
'Render Without Source Links',
);
if (choice) result = await renderToFile(runtime, { ...source, text }, target.fsPath, true);
else return;
}
if (!result.ok) {
log().error(result.error ?? 'render failed');
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
return;
}
const choice = await vscode.window.showInformationMessage(
`Rendered ${path.basename(target.fsPath)}`,
'Open in Viewer',
'Open in Browser',
);
if (choice === 'Open in Viewer') await openHtmlViewer(target);
if (choice === 'Open in Browser') await vscode.env.openExternal(target);
}),
vscode.commands.registerCommand('archify.openInBrowser', async (arg?: unknown) => {
const uri = sourceUri(arg);
if (!uri) return;
if (isHtmlUri(uri)) {
await vscode.env.openExternal(uri);
return;
}
const text = await readText(uri);
const source = readDiagramSource(text, uri.path);
if (!source) {
vscode.window.showWarningMessage(`Archify: ${path.basename(uri.path)} is not an Archify diagram source.`);
return;
}
const runtime = await runtimeFor(context, uri);
const result = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Archify: rendering…' },
() => renderDiagram(runtime, { ...source, text }, evidenceMode(uri)),
);
if (!result.ok || !result.html) {
vscode.window.showErrorMessage('Archify: render failed.', { modal: true, detail: result.error });
return;
}
const dir = path.join(context.globalStorageUri.fsPath, 'browser');
await fs.mkdir(dir, { recursive: true });
const file = path.join(dir, `${path.basename(uri.path).replace(/\.json$/i, '')}.html`);
await fs.writeFile(file, result.html, 'utf8');
await vscode.env.openExternal(vscode.Uri.file(file));
}),
);
}
export function deactivate(): void {}
+116
View File
@@ -0,0 +1,116 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { isArchifyHtml } from './detect';
import { viewerTheme } from './runtime';
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
export const HTML_VIEW_TYPE = 'archify.htmlViewer';
class HtmlDocument implements vscode.CustomDocument {
constructor(readonly uri: vscode.Uri) {}
dispose(): void {}
}
/** Read-only custom editor that shows rendered Archify HTML files. */
export class HtmlViewerProvider implements vscode.CustomReadonlyEditorProvider<HtmlDocument>, vscode.Disposable {
private readonly viewers = new Map<vscode.WebviewPanel, () => Promise<void>>();
private active: { uri: vscode.Uri; panel: vscode.WebviewPanel } | undefined;
private readonly disposables: vscode.Disposable[];
constructor() {
this.disposables = [
vscode.window.registerCustomEditorProvider(HTML_VIEW_TYPE, this, {
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
supportsMultipleEditorsPerDocument: true,
}),
vscode.window.onDidChangeActiveColorTheme(() => this.reloadAll()),
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('archify.theme')) this.reloadAll();
}),
];
}
/** File shown in the focused Archify HTML viewer, if any. */
get activeUri(): vscode.Uri | undefined {
return this.active?.uri;
}
dispose(): void {
for (const disposable of this.disposables.splice(0)) disposable.dispose();
}
openCustomDocument(uri: vscode.Uri): HtmlDocument {
return new HtmlDocument(uri);
}
async resolveCustomEditor(document: HtmlDocument, panel: vscode.WebviewPanel): Promise<void> {
const uri = document.uri;
let hash: string | undefined;
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
const reload = async () => {
let text: string;
try {
text = new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
} catch (error) {
panel.webview.html = messagePage(
{ cspSource: panel.webview.cspSource, nonce: makeNonce() },
'Cannot open file',
uri.fsPath,
error instanceof Error ? error.message : String(error),
);
return;
}
panel.webview.html = prepareDiagramHtml(text, {
cspSource: panel.webview.cspSource,
nonce: makeNonce(),
theme: viewerTheme(),
hash,
notice: isArchifyHtml(text)
? undefined
: { kind: 'info', text: 'This file was not generated by Archify. It is shown in a sandbox without network access.' },
});
};
this.viewers.set(panel, reload);
const disposables: vscode.Disposable[] = [
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
if (message.type === 'hash') {
hash = message.hash;
return;
}
void handleWebviewMessage(message, uri, panel);
}),
panel.onDidChangeViewState(() => {
if (panel.active) this.active = { uri, panel };
else if (this.active?.panel === panel) this.active = undefined;
}),
];
if (uri.scheme === 'file') {
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
);
let timer: NodeJS.Timeout | undefined;
const onChange = () => {
clearTimeout(timer);
timer = setTimeout(() => void reload(), 300);
};
disposables.push(watcher, watcher.onDidChange(onChange), watcher.onDidCreate(onChange), {
dispose: () => clearTimeout(timer),
});
}
panel.onDidDispose(() => {
this.viewers.delete(panel);
if (this.active?.panel === panel) this.active = undefined;
for (const disposable of disposables) disposable.dispose();
});
if (panel.active) this.active = { uri, panel };
await reload();
}
reloadAll(): void {
for (const reload of this.viewers.values()) void reload();
}
}
+39
View File
@@ -0,0 +1,39 @@
import { findNodeAtLocation, Node, parseTree } from 'jsonc-parser';
export interface OffsetRange {
start: number;
end: number;
}
/** Splits an RFC 6901 JSON pointer into path segments. */
export function pointerSegments(pointer: string): (string | number)[] {
if (!pointer || pointer === '/') return [];
return pointer
.replace(/^\//, '')
.split('/')
.map((raw) => raw.replace(/~1/g, '/').replace(/~0/g, '~'))
.map((segment) => (/^(0|[1-9]\d*)$/.test(segment) ? Number(segment) : segment));
}
/**
* Maps a JSON pointer to a text range. Objects and arrays resolve to their
* property key (or opening bracket) so a "missing property" error does not
* underline a whole block. Unresolvable paths fall back to the deepest
* ancestor that exists.
*/
export function rangeForPointer(text: string, pointer: string | undefined): OffsetRange {
const root = parseTree(text, [], { allowTrailingComma: true });
if (!root) return { start: 0, end: 0 };
const segments = pointerSegments(pointer ?? '');
let node: Node | undefined;
for (let length = segments.length; length >= 0 && !node; length -= 1) {
node = findNodeAtLocation(root, segments.slice(0, length));
}
if (!node) return { start: 0, end: 0 };
if (node.type === 'object' || node.type === 'array') {
const key = node.parent?.type === 'property' ? node.parent.children?.[0] : undefined;
if (key) return { start: key.offset, end: key.offset + key.length };
return { start: node.offset, end: node.offset + 1 };
}
return { start: node.offset, end: node.offset + node.length };
}
+232
View File
@@ -0,0 +1,232 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { readDiagramSource } from './detect';
import { renderDiagram } from './renderer';
import { evidenceMode, log, readText, runtimeFor, viewerTheme } from './runtime';
import { handleWebviewMessage, WebviewMessage } from './webviewMessages';
import { makeNonce, messagePage, prepareDiagramHtml } from './webviewHtml';
export const PREVIEW_VIEW_TYPE = 'archify.preview';
interface PreviewState {
uri: string;
hash?: string;
}
class DiagramPreview implements vscode.Disposable {
private seq = 0;
private hash: string | undefined;
private lastHtml: string | undefined;
private rendered = false;
private timer: NodeJS.Timeout | undefined;
private readonly disposables: vscode.Disposable[] = [];
private readonly onDisposeEmitter = new vscode.EventEmitter<void>();
readonly onDidDispose = this.onDisposeEmitter.event;
constructor(
private readonly context: vscode.ExtensionContext,
readonly panel: vscode.WebviewPanel,
readonly uri: vscode.Uri,
state?: PreviewState,
) {
this.hash = state?.hash;
panel.webview.options = { enableScripts: true, localResourceRoots: [] };
panel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'media', 'preview.svg');
panel.title = `Preview ${path.basename(uri.path)}`;
this.disposables.push(
panel.onDidDispose(() => this.dispose()),
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
if (message.type === 'hash') {
this.hash = message.hash;
return;
}
void handleWebviewMessage(message, this.uri, this.panel);
}),
);
if (uri.scheme === 'file') {
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(vscode.Uri.file(path.dirname(uri.fsPath)), path.basename(uri.fsPath)),
);
const onDisk = () => {
// Open documents are tracked through text change events instead.
if (!vscode.workspace.textDocuments.some((doc) => doc.uri.toString() === uri.toString())) this.scheduleUpdate();
};
this.disposables.push(watcher, watcher.onDidChange(onDisk), watcher.onDidCreate(onDisk));
}
this.showMessage('Rendering…', `Rendering ${path.basename(uri.path)} with Archify.`);
void this.update();
}
private state(): PreviewState {
return { uri: this.uri.toString(), hash: this.hash };
}
private showMessage(title: string, body: string, detail?: string): void {
this.lastHtml = undefined;
this.panel.webview.html = messagePage(
{ cspSource: this.panel.webview.cspSource, nonce: makeNonce(), state: this.state() },
title,
body,
detail,
);
}
scheduleUpdate(): void {
const delay = vscode.workspace.getConfiguration('archify', this.uri).get<number>('preview.debounceMs', 400);
clearTimeout(this.timer);
this.timer = setTimeout(() => void this.update(), delay);
}
async update(force = false): Promise<void> {
clearTimeout(this.timer);
const seq = ++this.seq;
let text: string;
try {
text = await readText(this.uri);
} catch (error) {
this.showMessage('Cannot read diagram', this.uri.fsPath, error instanceof Error ? error.message : String(error));
return;
}
const source = readDiagramSource(text, this.uri.path);
if (!source) {
this.showMessage('Not an Archify diagram', 'The file has no "diagram_type" of architecture, workflow, sequence, dataflow or lifecycle.');
return;
}
let result;
try {
const runtime = await runtimeFor(this.context, this.uri);
result = await renderDiagram(runtime, { ...source, text }, evidenceMode(this.uri));
} catch (error) {
result = { ok: false, error: error instanceof Error ? error.message : String(error) };
}
if (seq !== this.seq) return; // a newer render superseded this one
if (!result.ok || !result.html) {
log().warn(`Render failed for ${this.uri.fsPath}: ${result.error}`);
if (this.rendered && this.lastHtml) {
void this.panel.webview.postMessage({
type: 'notice',
notice: { kind: 'error', text: `Render failed; showing the last successful render.\n${firstLines(result.error)}` },
});
} else {
this.showMessage('Archify could not render this diagram', 'Fix the problems below (also listed in the Problems panel) and save.', result.error);
}
return;
}
const html = prepareDiagramHtml(result.html, {
cspSource: this.panel.webview.cspSource,
nonce: makeNonce(),
theme: viewerTheme(),
hash: this.hash,
state: this.state(),
notice: result.evidenceNotice ? { kind: 'warning', text: result.evidenceNotice } : undefined,
});
// Nonces differ per render, so compare the renderer output instead.
const fingerprint = `${viewerTheme()}\u0000${result.evidenceNotice ?? ''}\u0000${result.html}`;
if (!force && this.rendered && fingerprint === this.lastHtml) {
void this.panel.webview.postMessage({ type: 'notice', notice: undefined });
return;
}
this.lastHtml = fingerprint;
this.rendered = true;
this.panel.webview.html = html;
}
dispose(): void {
clearTimeout(this.timer);
this.onDisposeEmitter.fire();
this.onDisposeEmitter.dispose();
for (const disposable of this.disposables.splice(0)) disposable.dispose();
}
}
function firstLines(text: string | undefined, count = 6): string {
return (text ?? '').split('\n').slice(0, count).join('\n');
}
export class PreviewManager implements vscode.Disposable, vscode.WebviewPanelSerializer<PreviewState> {
private readonly previews = new Map<string, DiagramPreview>();
private active: DiagramPreview | undefined;
private readonly disposables: vscode.Disposable[] = [];
constructor(private readonly context: vscode.ExtensionContext) {
this.disposables.push(
vscode.window.registerWebviewPanelSerializer(PREVIEW_VIEW_TYPE, this),
vscode.workspace.onDidChangeTextDocument((event) => {
const preview = this.previews.get(event.document.uri.toString());
if (preview && event.contentChanges.length && this.liveUpdate(preview.uri)) preview.scheduleUpdate();
}),
vscode.workspace.onDidSaveTextDocument((doc) => void this.previews.get(doc.uri.toString())?.update()),
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('archify')) this.refreshAll();
}),
vscode.window.onDidChangeActiveColorTheme(() => this.refreshAll()),
);
}
private liveUpdate(uri: vscode.Uri): boolean {
return vscode.workspace.getConfiguration('archify', uri).get<boolean>('preview.liveUpdate', true);
}
get activePreview(): DiagramPreview | undefined {
return this.active;
}
show(uri: vscode.Uri, viewColumn: vscode.ViewColumn, preserveFocus = false): void {
const existing = this.previews.get(uri.toString());
if (existing) {
existing.panel.reveal(viewColumn, preserveFocus);
return;
}
const panel = vscode.window.createWebviewPanel(
PREVIEW_VIEW_TYPE,
`Preview ${path.basename(uri.path)}`,
{ viewColumn, preserveFocus },
{ enableScripts: true, retainContextWhenHidden: true, enableFindWidget: true, localResourceRoots: [] },
);
this.track(new DiagramPreview(this.context, panel, uri));
}
async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: PreviewState | undefined): Promise<void> {
if (!state?.uri) {
panel.dispose();
return;
}
this.track(new DiagramPreview(this.context, panel, vscode.Uri.parse(state.uri), state));
}
private track(preview: DiagramPreview): void {
const key = preview.uri.toString();
this.previews.get(key)?.panel.dispose();
this.previews.set(key, preview);
const onViewState = () => {
if (preview.panel.active) this.active = preview;
else if (this.active === preview) this.active = undefined;
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', Boolean(this.active));
};
preview.panel.onDidChangeViewState(onViewState);
onViewState();
preview.onDidDispose(() => {
if (this.previews.get(key) === preview) this.previews.delete(key);
if (this.active === preview) {
this.active = undefined;
void vscode.commands.executeCommand('setContext', 'archifyPreviewFocus', false);
}
});
}
refreshAll(force = false): void {
for (const preview of this.previews.values()) void preview.update(force);
}
dispose(): void {
for (const preview of this.previews.values()) preview.panel.dispose();
this.previews.clear();
for (const disposable of this.disposables.splice(0)) disposable.dispose();
}
}
+195
View File
@@ -0,0 +1,195 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { DiagramSource, DiagramType, hasSourceEvidence, stripSourceEvidence } from './detect';
export interface RuntimeOptions {
/** Absolute path to vendor/archify/bin/archify.mjs. */
cliPath: string;
/** Node (or Electron) executable. */
nodePath: string;
/** Set ELECTRON_RUN_AS_NODE so VS Code's Electron binary behaves as Node. */
runAsNode: boolean;
/** Scratch directory for input/output files. */
workDir: string;
quality?: string;
repoRoot?: string;
timeoutMs?: number;
log?: (line: string) => void;
}
export interface ArchifyDiagnostic {
code?: string;
severity?: 'error' | 'warning' | 'info';
message: string;
subject?: { path?: string; identity?: string; [key: string]: unknown };
supportedFixes?: string[];
}
export interface ValidateResult {
ok: boolean;
diagnostics: ArchifyDiagnostic[];
/** Set when the CLI did not produce parseable JSON. */
crash?: string;
}
export interface RenderResult {
ok: boolean;
html?: string;
error?: string;
/** Set when the diagram rendered without its source evidence. */
evidenceNotice?: string;
}
interface RunResult {
code: number | null;
stdout: string;
stderr: string;
}
const EVIDENCE_ERROR_RE = /repository-evidence\/|source evidence|--repo-root|Evidence (root|repository|revision)/i;
function run(options: RuntimeOptions, args: string[], env: Record<string, string> = {}): Promise<RunResult> {
return new Promise((resolve, reject) => {
options.log?.(`archify ${args.join(' ')}`);
const child = spawn(options.nodePath, [options.cliPath, ...args], {
cwd: options.workDir,
env: {
...process.env,
...(options.runAsNode ? { ELECTRON_RUN_AS_NODE: '1' } : {}),
...env,
},
windowsHide: true,
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8').on('data', (chunk: string) => (stdout += chunk));
child.stderr.setEncoding('utf8').on('data', (chunk: string) => (stderr += chunk));
const timer = setTimeout(() => child.kill(), options.timeoutMs ?? 60_000);
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.on('close', (code) => {
clearTimeout(timer);
if (stderr.trim()) options.log?.(stderr.trim());
resolve({ code, stdout, stderr });
});
});
}
function commonArgs(options: RuntimeOptions, withRepoRoot: boolean): string[] {
const args: string[] = [];
if (options.quality) args.push('--quality', options.quality);
if (withRepoRoot && options.repoRoot) args.push('--repo-root', options.repoRoot);
return args;
}
async function withScratch<T>(options: RuntimeOptions, body: (dir: string) => Promise<T>): Promise<T> {
await fs.mkdir(options.workDir, { recursive: true });
const dir = await fs.mkdtemp(path.join(options.workDir, 'run-'));
try {
return await body(dir);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}
export async function validate(options: RuntimeOptions, type: DiagramType, text: string): Promise<ValidateResult> {
return withScratch(options, async (dir) => {
const input = path.join(dir, `diagram.${type}.json`);
await fs.writeFile(input, text, 'utf8');
const result = await run(options, ['validate', type, input, '--json', ...commonArgs(options, true)]);
try {
const report = JSON.parse(result.stdout);
const diagnostics: ArchifyDiagnostic[] = Array.isArray(report.diagnostics) ? report.diagnostics : [];
if (!report.ok && diagnostics.length === 0 && report.error) {
diagnostics.push({ severity: 'error', message: String(report.error) });
}
return { ok: Boolean(report.ok), diagnostics };
} catch {
const crash = (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim();
return { ok: false, diagnostics: [], crash };
}
});
}
async function renderOnce(
options: RuntimeOptions,
type: DiagramType,
text: string,
withRepoRoot: boolean,
): Promise<RenderResult> {
return withScratch(options, async (dir) => {
const input = path.join(dir, `diagram.${type}.json`);
const output = path.join(dir, 'diagram.html');
await fs.writeFile(input, text, 'utf8');
const result = await run(options, ['render', type, input, output, ...commonArgs(options, withRepoRoot)]);
if (result.code === 0) {
try {
return { ok: true, html: await fs.readFile(output, 'utf8') };
} catch {
// fall through to the error below
}
}
const error = (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim();
return { ok: false, error };
});
}
/**
* Renders a diagram to HTML. With `evidence: 'fallback'`, a diagram whose
* source evidence cannot be verified is rendered again without it, and the
* reason is returned as `evidenceNotice`.
*/
export async function renderDiagram(
options: RuntimeOptions,
source: DiagramSource & { text: string },
evidence: 'fallback' | 'strict',
): Promise<RenderResult> {
const withEvidence = hasSourceEvidence(source.type, source.diagram);
if (withEvidence && evidence === 'fallback' && !options.repoRoot) {
const stripped = JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2);
const result = await renderOnce(options, source.type, stripped, false);
return {
...result,
evidenceNotice: 'Source links are hidden: the diagram is not inside a Git checkout, so its source evidence cannot be verified. Set "archify.repoRoot" to the matching checkout.',
};
}
const first = await renderOnce(options, source.type, source.text, withEvidence);
if (first.ok || !withEvidence || evidence !== 'fallback' || !isEvidenceError(first.error)) {
return first;
}
const stripped = JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2);
const second = await renderOnce(options, source.type, stripped, false);
if (!second.ok) return first;
const reason = (first.error ?? '').split('\n').find((line) => line.trim()) ?? 'verification failed';
return { ...second, evidenceNotice: `Source links are hidden: ${reason.replace(/^\[[^\]]+\]\s*/, '')}` };
}
/**
* Renders to a file the user chose. Source evidence is verified strictly
* unless `stripEvidence` is set, in which case it is removed first.
*/
export async function renderToFile(
options: RuntimeOptions,
source: DiagramSource & { text: string },
outputPath: string,
stripEvidence = false,
): Promise<RenderResult> {
const text = stripEvidence
? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2)
: source.text;
const withEvidence = !stripEvidence && hasSourceEvidence(source.type, source.diagram);
return withScratch(options, async (dir) => {
const input = path.join(dir, `diagram.${source.type}.json`);
await fs.writeFile(input, text, 'utf8');
const result = await run(options, ['render', source.type, input, outputPath, ...commonArgs(options, withEvidence)]);
if (result.code === 0) return { ok: true };
return { ok: false, error: (result.stderr || result.stdout || `archify exited with code ${result.code}`).trim() };
});
}
export function isEvidenceError(error: string | undefined): boolean {
return EVIDENCE_ERROR_RE.test(error ?? '');
}
+77
View File
@@ -0,0 +1,77 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import * as vscode from 'vscode';
import { RuntimeOptions } from './renderer';
import { ViewerTheme } from './webviewHtml';
let output: vscode.LogOutputChannel | undefined;
export function log(): vscode.LogOutputChannel {
output ??= vscode.window.createOutputChannel('Archify', { log: true });
return output;
}
const gitRoots = new Map<string, Promise<string | undefined>>();
function gitTopLevel(dir: string): Promise<string | undefined> {
let pending = gitRoots.get(dir);
if (!pending) {
pending = new Promise((resolve) => {
execFile('git', ['rev-parse', '--show-toplevel'], { cwd: dir, timeout: 10_000 }, (error, stdout) => {
resolve(error ? undefined : path.resolve(stdout.trim()));
});
});
gitRoots.set(dir, pending);
}
return pending;
}
export function clearGitRootCache(): void {
gitRoots.clear();
}
/** The checkout used to verify source evidence for a file, if any. */
export async function repoRootFor(uri: vscode.Uri | undefined): Promise<string | undefined> {
const configured = vscode.workspace.getConfiguration('archify', uri).get<string>('repoRoot', '').trim();
if (configured) {
if (path.isAbsolute(configured)) return configured;
const folder = (uri && vscode.workspace.getWorkspaceFolder(uri)) ?? vscode.workspace.workspaceFolders?.[0];
return folder ? path.resolve(folder.uri.fsPath, configured) : path.resolve(configured);
}
if (!uri || uri.scheme !== 'file') return undefined;
return gitTopLevel(path.dirname(uri.fsPath));
}
export async function runtimeFor(context: vscode.ExtensionContext, uri: vscode.Uri | undefined): Promise<RuntimeOptions> {
const config = vscode.workspace.getConfiguration('archify', uri);
const nodePath = config.get<string>('nodePath', '').trim();
const quality = config.get<string>('quality', '');
return {
cliPath: context.asAbsolutePath(path.join('vendor', 'archify', 'bin', 'archify.mjs')),
nodePath: nodePath || process.execPath,
runAsNode: !nodePath,
workDir: path.join(context.globalStorageUri.fsPath, 'renders'),
quality: quality || undefined,
repoRoot: await repoRootFor(uri),
log: (line) => log().debug(line),
};
}
export function evidenceMode(uri: vscode.Uri | undefined): 'fallback' | 'strict' {
return vscode.workspace.getConfiguration('archify', uri).get<'fallback' | 'strict'>('sourceEvidence', 'fallback');
}
export function viewerTheme(): ViewerTheme {
const setting = vscode.workspace.getConfiguration('archify').get<string>('theme', 'vscode');
if (setting === 'dark' || setting === 'light') return setting;
if (setting === 'diagram') return undefined;
const kind = vscode.window.activeColorTheme.kind;
return kind === vscode.ColorThemeKind.Light || kind === vscode.ColorThemeKind.HighContrastLight ? 'light' : 'dark';
}
/** Current text of a document: the open editor buffer, else the file on disk. */
export async function readText(uri: vscode.Uri): Promise<string> {
const open = vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === uri.toString());
if (open) return open.getText();
return new TextDecoder('utf-8').decode(await vscode.workspace.fs.readFile(uri));
}
+231
View File
@@ -0,0 +1,231 @@
export type ViewerTheme = 'dark' | 'light' | undefined;
export interface PageOptions {
/** webview.cspSource */
cspSource: string;
nonce: string;
/** Forced diagram theme; undefined keeps the diagram's own choice. */
theme: ViewerTheme;
/** Location hash to restore (Archify keeps focus/route state there). */
hash?: string;
/** Opaque state handed to vscode.setState for panel restore. */
state?: unknown;
/** Banner shown on top of the diagram. */
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
}
export function makeNonce(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let nonce = '';
for (let i = 0; i < 32; i += 1) nonce += chars.charAt(Math.floor(Math.random() * chars.length));
return nonce;
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** JSON that is safe to embed inside an inline <script>. */
function scriptJson(value: unknown): string {
return JSON.stringify(value ?? null)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
export function contentSecurityPolicy(cspSource: string, nonce: string): string {
return [
"default-src 'none'",
`img-src ${cspSource} https: data: blob:`,
`media-src ${cspSource} data: blob:`,
`font-src ${cspSource} data:`,
`style-src ${cspSource} 'unsafe-inline'`,
`script-src 'nonce-${nonce}'`,
'connect-src data: blob:',
'worker-src blob:',
].join('; ');
}
/**
* Runs before any page script: pins the theme, restores the hash, and
* routes links, window.open and export downloads to the extension, since a
* webview cannot navigate or download on its own.
*/
function bridgeScript(options: PageOptions): string {
const config = { theme: options.theme, hash: options.hash, state: options.state, notice: options.notice };
return `(function () {
var config = ${scriptJson(config)};
var vscode = acquireVsCodeApi();
if (config.state) vscode.setState(config.state);
if (config.theme) {
try { localStorage.setItem('archify-theme', config.theme); } catch (_) {}
}
if (config.hash && !location.hash) {
try { history.replaceState(null, '', config.hash); } catch (_) {}
}
window.addEventListener('hashchange', function () {
if (config.state) vscode.setState(Object.assign({}, config.state, { hash: location.hash }));
vscode.postMessage({ type: 'hash', hash: location.hash });
});
function post(message) { vscode.postMessage(message); }
function saveBlobUrl(url, filename) {
fetch(url).then(function (response) { return response.blob(); }).then(function (blob) {
var reader = new FileReader();
reader.onload = function () {
var data = String(reader.result);
post({ type: 'save', filename: filename || 'archify-export', base64: data.slice(data.indexOf(',') + 1) });
};
reader.readAsDataURL(blob);
}).catch(function (error) {
post({ type: 'error', message: 'Export failed: ' + (error && error.message || error) });
});
}
document.addEventListener('click', function (event) {
var target = event.target;
var anchor = target && target.closest ? target.closest('a[href]') : null;
if (!anchor) return;
var href = anchor.getAttribute('href') || '';
if (anchor.hasAttribute('download') && /^(blob|data):/i.test(anchor.href)) {
event.preventDefault();
saveBlobUrl(anchor.href, anchor.getAttribute('download'));
return;
}
if (href.charAt(0) === '#') return;
event.preventDefault();
if (anchor.classList.contains('semantic-passport-source')) {
var pathNode = anchor.querySelector('small');
var match = /#L(\\d+)(?:-L(\\d+))?$/.exec(anchor.href);
post({
type: 'openSource',
path: pathNode ? pathNode.textContent : '',
line: match ? Number(match[1]) : undefined,
endLine: match && match[2] ? Number(match[2]) : undefined,
href: anchor.href
});
return;
}
if (/^(https?|mailto):/i.test(href)) post({ type: 'openExternal', href: href });
else post({ type: 'openRelative', href: href });
}, true);
var nativeOpen = window.open;
window.open = function (url) {
if (typeof url === 'string' && /^(https?|mailto):/i.test(url)) {
post({ type: 'openExternal', href: url });
return null;
}
return nativeOpen ? nativeOpen.apply(window, arguments) : null;
};
function showNotice(notice) {
var existing = document.getElementById('archify-vscode-notice');
if (existing) existing.remove();
if (!notice || !notice.text) return;
var bar = document.createElement('div');
bar.id = 'archify-vscode-notice';
bar.setAttribute('role', notice.kind === 'error' ? 'alert' : 'status');
bar.className = 'archify-vscode-notice archify-vscode-notice-' + notice.kind;
var text = document.createElement('span');
text.textContent = notice.text;
var close = document.createElement('button');
close.type = 'button';
close.textContent = '\\u00d7';
close.title = 'Dismiss';
close.addEventListener('click', function () { bar.remove(); });
bar.appendChild(text);
bar.appendChild(close);
(document.body || document.documentElement).appendChild(bar);
}
window.addEventListener('message', function (event) {
var message = event.data || {};
if (message.type === 'notice') showNotice(message.notice);
});
if (config.notice) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
} else {
showNotice(config.notice);
}
}
})();`;
}
const NOTICE_STYLE = `
.archify-vscode-notice {
position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 2147483647;
display: flex; gap: 12px; align-items: flex-start; justify-content: space-between;
padding: 8px 12px; border-radius: 6px; font: 12px/1.45 var(--vscode-font-family, system-ui, sans-serif);
color: var(--vscode-editorWidget-foreground, #ddd); background: var(--vscode-editorWidget-background, #252526);
border: 1px solid var(--vscode-editorWidget-border, #454545); box-shadow: 0 4px 16px rgba(0,0,0,.35);
white-space: pre-wrap;
}
.archify-vscode-notice-warning { border-left: 4px solid var(--vscode-editorWarning-foreground, #cca700); }
.archify-vscode-notice-error { border-left: 4px solid var(--vscode-editorError-foreground, #f14c4c); }
.archify-vscode-notice-info { border-left: 4px solid var(--vscode-editorInfo-foreground, #3794ff); }
.archify-vscode-notice button {
background: none; border: 0; color: inherit; cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px;
}`;
/** Prepares Archify HTML for a VS Code webview. */
export function prepareDiagramHtml(html: string, options: PageOptions): string {
const head =
`<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">\n` +
`<script nonce="${options.nonce}">${bridgeScript(options)}</script>\n` +
`<style>${NOTICE_STYLE}</style>\n`;
let page = html
// Drop any CSP the file brings along; ours is the one that applies.
.replace(/<meta\s+http-equiv=["']Content-Security-Policy["'][^>]*>/gi, '')
// Allow the page's own inline scripts to run under the nonce policy.
.replace(/<script\b(?![^>]*\bnonce=)/gi, `<script nonce="${options.nonce}"`);
const headMatch = /<head\b[^>]*>/i.exec(page);
if (headMatch) {
const at = headMatch.index + headMatch[0].length;
page = `${page.slice(0, at)}\n${head}${page.slice(at)}`;
} else {
page = `<!DOCTYPE html><html><head>${head}</head><body>${page}</body></html>`;
}
return page;
}
/** A small VS Code-styled page for loading and error states. */
export function messagePage(
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
title: string,
body: string,
detail?: string,
): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="${escapeHtml(contentSecurityPolicy(options.cspSource, options.nonce))}">
<script nonce="${options.nonce}">(function () { var vscode = acquireVsCodeApi(); var state = ${scriptJson(options.state)}; if (state) vscode.setState(state); })();</script>
<style>
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 24px 28px; }
h2 { font-weight: 600; font-size: 15px; margin: 0 0 8px; }
p { margin: 0 0 12px; color: var(--vscode-descriptionForeground); }
pre { font-family: var(--vscode-editor-font-family); font-size: 12px; white-space: pre-wrap; word-break: break-word;
background: var(--vscode-textCodeBlock-background); padding: 12px 14px; border-radius: 4px;
border-left: 3px solid var(--vscode-editorError-foreground); }
</style>
</head>
<body>
<h2>${escapeHtml(title)}</h2>
<p>${escapeHtml(body)}</p>
${detail ? `<pre>${escapeHtml(detail)}</pre>` : ''}
</body>
</html>`;
}
+100
View File
@@ -0,0 +1,100 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { log, repoRootFor } from './runtime';
export type WebviewMessage =
| { type: 'hash'; hash: string }
| { type: 'save'; filename: string; base64: string }
| { type: 'openExternal'; href: string }
| { type: 'openRelative'; href: string }
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
| { type: 'error'; message: string };
/** Column for opening files next to a webview without covering it. */
function besideColumn(panel: vscode.WebviewPanel): vscode.ViewColumn {
return panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
}
async function exists(uri: vscode.Uri): Promise<boolean> {
try {
await vscode.workspace.fs.stat(uri);
return true;
} catch {
return false;
}
}
/**
* Opens a diagram's source evidence in the editor. The path is relative to
* the repository root; the diagram's checkout and the workspace folders are
* tried in turn before falling back to the web link.
*/
async function openSource(
message: Extract<WebviewMessage, { type: 'openSource' }>,
context: vscode.Uri,
panel: vscode.WebviewPanel,
): Promise<void> {
const relative = message.path.replace(/^\/+/, '');
const roots = new Set<string>();
const repoRoot = await repoRootFor(context);
if (repoRoot) roots.add(repoRoot);
for (const folder of vscode.workspace.workspaceFolders ?? []) roots.add(folder.uri.fsPath);
for (const root of roots) {
const candidate = vscode.Uri.file(path.join(root, relative));
if (relative && (await exists(candidate))) {
const start = Math.max((message.line ?? 1) - 1, 0);
const end = Math.max((message.endLine ?? message.line ?? 1) - 1, start);
const selection = new vscode.Range(start, 0, end, Number.MAX_SAFE_INTEGER);
await vscode.window.showTextDocument(candidate, { viewColumn: besideColumn(panel), selection });
return;
}
}
if (message.href && /^https?:/i.test(message.href)) {
await vscode.env.openExternal(vscode.Uri.parse(message.href));
return;
}
vscode.window.showWarningMessage(`Archify: source file "${message.path}" was not found in the workspace.`);
}
export async function handleWebviewMessage(
message: WebviewMessage,
context: vscode.Uri,
panel: vscode.WebviewPanel,
): Promise<void> {
try {
switch (message.type) {
case 'openExternal':
await vscode.env.openExternal(vscode.Uri.parse(message.href));
break;
case 'openRelative': {
const [file] = message.href.split('#');
if (!file) break;
const target = vscode.Uri.joinPath(context, '..', decodeURIComponent(file));
await vscode.commands.executeCommand('vscode.open', target, besideColumn(panel));
break;
}
case 'openSource':
await openSource(message, context, panel);
break;
case 'save': {
const name = path.basename(message.filename || 'archify-export');
const defaultUri = vscode.Uri.joinPath(context, '..', name);
const target = await vscode.window.showSaveDialog({ defaultUri, saveLabel: 'Export' });
if (!target) break;
await vscode.workspace.fs.writeFile(target, Buffer.from(message.base64, 'base64'));
const choice = await vscode.window.showInformationMessage(`Exported ${path.basename(target.fsPath)}`, 'Reveal');
if (choice === 'Reveal') await vscode.commands.executeCommand('revealFileInOS', target);
break;
}
case 'error':
vscode.window.showErrorMessage(`Archify: ${message.message}`);
break;
case 'hash':
break;
}
} catch (error) {
log().error(error instanceof Error ? error : String(error));
vscode.window.showErrorMessage(`Archify: ${error instanceof Error ? error.message : String(error)}`);
}
}