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:
1 parent
20786f4b5a
commit
97b3b5f685
40 files changed
+2553
-353
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { isArchifyFile } from '../sources/detect';
|
||||
import { log } from '../util/log';
|
||||
import { Brand, brandValueAt, loadBrandCatalog } from './brands';
|
||||
|
||||
const JSON_SOURCES = '**/*.{architecture,workflow,sequence,dataflow,lifecycle}.json';
|
||||
|
||||
export const BRAND_COMPLETION_SELECTOR: vscode.DocumentSelector = [
|
||||
{ pattern: '**/*.archify' },
|
||||
{ language: 'json', pattern: JSON_SOURCES },
|
||||
{ language: 'jsonc', pattern: JSON_SOURCES },
|
||||
];
|
||||
|
||||
/** Suggests Archify's built-in brand IDs for `brand` values. */
|
||||
export class BrandCompletionProvider implements vscode.CompletionItemProvider {
|
||||
private brands: Brand[] | undefined;
|
||||
|
||||
constructor(private readonly extensionPath: string) {}
|
||||
|
||||
private catalog(): Brand[] {
|
||||
if (!this.brands) {
|
||||
try {
|
||||
this.brands = loadBrandCatalog(path.join(this.extensionPath, 'vendor', 'archify', 'brand-marks', 'catalog.json'));
|
||||
} catch (error) {
|
||||
log().error(`Could not read the Archify brand catalog: ${error instanceof Error ? error.message : String(error)}`);
|
||||
this.brands = [];
|
||||
}
|
||||
}
|
||||
return this.brands;
|
||||
}
|
||||
|
||||
provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): vscode.CompletionItem[] | undefined {
|
||||
const linePrefix = document.lineAt(position.line).text.slice(0, position.character);
|
||||
const value = brandValueAt(linePrefix, isArchifyFile(document.fileName) ? 'yaml' : 'json');
|
||||
if (!value) return undefined;
|
||||
const range = new vscode.Range(position.translate(0, -value.typed.length), position);
|
||||
return this.catalog().map((brand) => {
|
||||
const item = new vscode.CompletionItem({ label: brand.id, description: brand.title }, vscode.CompletionItemKind.Value);
|
||||
item.detail = `${brand.title} · ${brand.category}`;
|
||||
if (brand.aliases.length) item.documentation = `Also matches: ${brand.aliases.join(', ')}`;
|
||||
// Typing an alias or the product name finds the brand too.
|
||||
item.filterText = [brand.id, ...brand.aliases, brand.title].join(' ');
|
||||
item.range = range;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
export interface Brand {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
/** Archify's built-in brand marks, read from brand-marks/catalog.json. */
|
||||
export function loadBrandCatalog(file: string): Brand[] {
|
||||
const catalog = JSON.parse(fs.readFileSync(file, 'utf8')) as { marks?: Partial<Brand>[] };
|
||||
return (catalog.marks ?? [])
|
||||
.filter((mark): mark is Partial<Brand> & { id: string } => typeof mark.id === 'string')
|
||||
.map((mark) => ({ id: mark.id, title: mark.title ?? mark.id, category: mark.category ?? '', aliases: mark.aliases ?? [] }));
|
||||
}
|
||||
|
||||
export interface BrandValue {
|
||||
/** What has been typed of the value so far. */
|
||||
typed: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the text before the cursor ends inside a `brand` value:
|
||||
* `brand: post` or `{ id: db, brand: "post` in YAML, `"brand": "post` in JSON.
|
||||
*/
|
||||
export function brandValueAt(linePrefix: string, format: 'yaml' | 'json'): BrandValue | undefined {
|
||||
const match =
|
||||
format === 'yaml'
|
||||
? /(?:^|[\s{,])brand:\s*["']?([\w.-]*)$/.exec(linePrefix)
|
||||
: /"brand"\s*:\s*"([\w.-]*)$/.exec(linePrefix);
|
||||
return match ? { typed: match[1] } : undefined;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { parse, ParseError } from 'jsonc-parser';
|
||||
import * as vscode from 'vscode';
|
||||
import { ArchifyDiagnostic, isEvidenceError, validate } from '../archify/cli';
|
||||
import { evidenceMode, runtimeFor } from '../archify/runtime';
|
||||
import { isArchifyFile, readDiagramSource } from '../sources/detect';
|
||||
import { log } from '../util/log';
|
||||
import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer';
|
||||
|
||||
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) && !isArchifyFile(doc.fileName)) 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();
|
||||
if (!this.enabled(doc.uri) || doc.isClosed) {
|
||||
this.collection.delete(doc.uri);
|
||||
return;
|
||||
}
|
||||
if (isArchifyFile(doc.fileName)) {
|
||||
// Nothing else reports YAML syntax errors in .archify files, and a file
|
||||
// that does not parse has no diagram to validate yet.
|
||||
const problems = yamlSyntaxProblems(text);
|
||||
if (problems.length) {
|
||||
this.collection.set(
|
||||
doc.uri,
|
||||
problems.map((problem) => {
|
||||
const diagnostic = new vscode.Diagnostic(
|
||||
new vscode.Range(doc.positionAt(problem.start), doc.positionAt(problem.end)),
|
||||
`YAML: ${problem.message}`,
|
||||
vscode.DiagnosticSeverity.Error,
|
||||
);
|
||||
diagnostic.source = 'archify';
|
||||
return diagnostic;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const source = readDiagramSource(text, doc.fileName);
|
||||
if (!source) {
|
||||
this.collection.delete(doc.uri);
|
||||
return;
|
||||
}
|
||||
if (source.format === 'json') {
|
||||
// Leave JSON 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, source.json);
|
||||
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 pointer = diagnosticPointer(item);
|
||||
const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, pointer) : rangeForPointer(text, pointer);
|
||||
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,87 @@
|
||||
import { findNodeAtLocation, Node, parseTree } from 'jsonc-parser';
|
||||
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
||||
|
||||
export interface OffsetRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The JSON pointer an Archify diagnostic is about. Most diagnostics carry it
|
||||
* in subject.path; brand diagnostics only start their message with it.
|
||||
*/
|
||||
export function diagnosticPointer(diagnostic: { message: string; subject?: { path?: string } }): string | undefined {
|
||||
return diagnostic.subject?.path ?? /^(\/[^\s"]+)\s/.exec(diagnostic.message)?.[1];
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
|
||||
/** The same mapping for YAML text (`.archify` files). */
|
||||
export function rangeForPointerYaml(text: string, pointer: string | undefined): OffsetRange {
|
||||
const doc = parseDocument(text, { keepSourceTokens: false, uniqueKeys: false });
|
||||
let node: unknown = doc.contents;
|
||||
let keyRange: [number, number] | undefined;
|
||||
for (const segment of pointerSegments(pointer ?? '')) {
|
||||
if (isMap(node)) {
|
||||
const pair = node.items.find((item) => isScalar(item.key) && String(item.key.value) === String(segment));
|
||||
if (!pair) break;
|
||||
keyRange = isScalar(pair.key) && pair.key.range ? [pair.key.range[0], pair.key.range[1]] : undefined;
|
||||
node = pair.value;
|
||||
} else if (isSeq(node) && typeof segment === 'number' && segment < node.items.length) {
|
||||
keyRange = undefined;
|
||||
node = node.items[segment];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((isMap(node) || isSeq(node)) && keyRange) return { start: keyRange[0], end: keyRange[1] };
|
||||
const range = (node as { range?: [number, number, number] } | null)?.range;
|
||||
if (!range) return { start: 0, end: 0 };
|
||||
if (isMap(node) || isSeq(node)) return { start: range[0], end: Math.min(range[0] + 1, range[1]) };
|
||||
return { start: range[0], end: range[1] };
|
||||
}
|
||||
|
||||
export interface SyntaxProblem extends OffsetRange {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** YAML syntax errors with their offsets (none for valid YAML). */
|
||||
export function yamlSyntaxProblems(text: string): SyntaxProblem[] {
|
||||
const doc = parseDocument(text, { uniqueKeys: false });
|
||||
return doc.errors.map((error) => ({
|
||||
start: error.pos[0],
|
||||
end: Math.max(error.pos[1], error.pos[0] + 1),
|
||||
message: error.message.split('\n')[0],
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { isMap, parseDocument } from 'yaml';
|
||||
import { DiagramType } from '../sources/detect';
|
||||
|
||||
export interface DiagramTemplate {
|
||||
/** File name under templates/. */
|
||||
file: string;
|
||||
type: DiagramType;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Suggested name for the new file, without .archify. */
|
||||
defaultName: string;
|
||||
}
|
||||
|
||||
export const TEMPLATES: readonly DiagramTemplate[] = [
|
||||
{ file: 'architecture.archify', type: 'architecture', label: 'Architecture', description: 'Parts of a system and how they connect', defaultName: 'architecture' },
|
||||
{ file: 'workflow.archify', type: 'workflow', label: 'Workflow', description: 'Steps of a process, in lanes', defaultName: 'workflow' },
|
||||
{ file: 'sequence.archify', type: 'sequence', label: 'Sequence', description: 'Who calls whom, in order', defaultName: 'sequence' },
|
||||
{ file: 'dataflow.archify', type: 'dataflow', label: 'Data Flow', description: 'Where data comes from and where it goes', defaultName: 'dataflow' },
|
||||
{ file: 'lifecycle.archify', type: 'lifecycle', label: 'Lifecycle', description: 'States something moves through', defaultName: 'lifecycle' },
|
||||
{ file: 'everyday-plan.archify', type: 'workflow', label: 'Everyday Plan', description: 'A plan or process from daily life', defaultName: 'plan' },
|
||||
];
|
||||
|
||||
/** Strips a typed .archify suffix and surrounding spaces. */
|
||||
export function diagramName(input: string): string {
|
||||
return input.trim().replace(/\.archify$/i, '').trim();
|
||||
}
|
||||
|
||||
/** Why a name cannot be used as a file name, or undefined when it can. */
|
||||
export function invalidNameReason(input: string): string | undefined {
|
||||
const name = diagramName(input);
|
||||
if (!name) return 'Enter a name.';
|
||||
if (/[\\/:*?"<>|\u0000-\u001f]/.test(name)) return 'A name cannot contain \\ / : * ? " < > | or control characters.';
|
||||
if (/^\.+$/.test(name)) return 'Enter a name.';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** "sales-report_v2" → "Sales report v2". */
|
||||
export function titleFromName(name: string): string {
|
||||
const words = name.replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template YAML with meta.title and meta.output set for the new file.
|
||||
* Works on the YAML document so the template's comments and layout stay.
|
||||
*/
|
||||
export function diagramFromTemplate(templateText: string, name: string): string {
|
||||
const doc = parseDocument(templateText);
|
||||
if (!isMap(doc.get('meta'))) doc.set('meta', doc.createNode({}));
|
||||
doc.setIn(['meta', 'title'], titleFromName(name));
|
||||
doc.setIn(['meta', 'output'], `${name}.html`);
|
||||
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
|
||||
}
|
||||
Reference in new issue
Block a user