Add a 3D view of every diagram; organise source by job; 0.6.0
3D view: a "3D" menu beside Export switches the diagram to a three.js scene built from the live Archify SVG. Nodes become blocks topped with the node as Archify draws it, relationships become lit pipes along Archify's routes, and the canvas, frames, lanes and legend form the floor. Clicks and hover go to the SVG nodes, so focus, the Semantic Passport and the intent trace work as in 2D; Archify's own animations (trace, node pulse, passport/path/lens flows) are mirrored live. The menu also offers flat or stacked levels, reset view and a top view; Export gains "3D view" (PNG). Trackpad scrolling pans, pinch and the mouse wheel zoom, and the zoom buttons work beyond the 2D limits. The scene draws only when something moves. New setting archify.defaultView. Source: src/ is split by job (commands, views, webview, archify, sources, language, util, viewer3d); behaviour unchanged.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { DiagramSource, DiagramType } from '../sources/detect';
|
||||
import { hasSourceEvidence, stripSourceEvidence } from '../sources/evidence';
|
||||
|
||||
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,
|
||||
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.json, 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,
|
||||
outputPath: string,
|
||||
stripEvidence = false,
|
||||
): Promise<RenderResult> {
|
||||
const text = stripEvidence
|
||||
? JSON.stringify(stripSourceEvidence(source.type, source.diagram), null, 2)
|
||||
: source.json;
|
||||
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,55 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { log } from '../util/log';
|
||||
import { RuntimeOptions } from './cli';
|
||||
|
||||
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');
|
||||
}
|
||||
Reference in New Issue
Block a user