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:
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 ?? '');
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 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>`;
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user