Registry view with element dialog; Elements and Connectors panels under diagrams

- .archify-registry opens in a new read-only custom editor (archify.registry,
  default): the file's elements as cards; click one for a dialog with its
  description, the defining line (opens the YAML) and the diagrams that use
  it (open on click); Show Source and Open Model work from the view
- the same dialog opens from the registry cards on the model page
- Archify's node index is titled Elements; a Connectors panel built from the
  SVG edges lists from → to with the label, highlights on hover and keeps the
  highlight on click; side by side in the bottom rail
- integration test for the registry view; docs and changelog
This commit is contained in:
root-at-skic committed 2026-10-19 13:53:14 +03:00
1 parent 36bccb69a5
commit b9676ece83
11 files changed
+535 -42

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
import path from 'node:path';
import * as vscode from 'vscode';
import { isMainFile, isRegistryFile, parseRegistry } from '../model/format';
import { RegistryPanel, registryPanelFromIndex, registryPanelStandalone } from '../model/panel';
import { ModelService } from '../model/service';
import { readText } from '../util/files';
import { log } from '../util/log';
import { handleWebviewMessage, WebviewMessage } from '../webview/messages';
import { makeNonce, registryPage } from '../webview/page';
import { DIAGRAM_EDITOR_VIEW_TYPE } from './diagramView';
/** Default editor for .archify-registry files: the elements as cards, each with where it is used. */
export const REGISTRY_VIEW_TYPE = 'archify.registry';
class RegistryDocument implements vscode.CustomDocument {
constructor(readonly uri: vscode.Uri) {}
dispose(): void {}
}
class RegistryView implements vscode.Disposable {
private seq = 0;
private timer: NodeJS.Timeout | undefined;
private last: string | undefined;
root: vscode.Uri | undefined;
private readonly disposables: vscode.Disposable[] = [];
constructor(
private readonly models: ModelService,
readonly panel: vscode.WebviewPanel,
readonly uri: vscode.Uri,
) {
this.disposables.push(
panel.webview.onDidReceiveMessage((message: WebviewMessage) => {
if (message.type === 'openDiagram') return void this.openInModel(message.path, DIAGRAM_EDITOR_VIEW_TYPE);
if (message.type === 'openRegistry') return void this.openRegistryLine(message.file, message.line);
void handleWebviewMessage(message, this.uri, this.panel);
}),
);
void this.update();
}
private async openInModel(relativePath: string, viewType: string): Promise<void> {
if (!this.root) return;
const target = vscode.Uri.joinPath(this.root, ...relativePath.split('/'));
try {
await vscode.workspace.fs.stat(target);
} catch {
vscode.window.showWarningMessage(`Archify: ${relativePath} does not exist in this model.`);
return;
}
await vscode.commands.executeCommand('vscode.openWith', target, viewType, vscode.ViewColumn.Active);
}
private async openRegistryLine(file: string, line?: number): Promise<void> {
const target = this.root ? vscode.Uri.joinPath(this.root, ...file.split('/')) : this.uri;
const column = this.panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
const at = Math.max(line ?? 0, 0);
await vscode.window.showTextDocument(target, { viewColumn: column, selection: new vscode.Range(at, 0, at, 0) });
}
scheduleUpdate(): void {
clearTimeout(this.timer);
this.timer = setTimeout(() => void this.update(), 300);
}
async update(): Promise<void> {
clearTimeout(this.timer);
const seq = ++this.seq;
let registry: RegistryPanel;
try {
this.root = await this.models.rootOf(this.uri);
if (this.root) {
const rel = path.relative(this.root.fsPath, this.uri.fsPath).split(path.sep).join('/');
registry = registryPanelFromIndex(await this.models.index(this.root), rel);
} else {
const name = path.basename(this.uri.path);
registry = registryPanelStandalone(name, parseRegistry(await readText(this.uri), name));
}
} catch (error) {
log().warn(`Registry view failed for ${this.uri.fsPath}: ${error instanceof Error ? error.message : String(error)}`);
return;
}
if (seq !== this.seq) return;
const fingerprint = JSON.stringify(registry);
if (fingerprint === this.last) return;
this.last = fingerprint;
this.panel.webview.html = registryPage({ cspSource: this.panel.webview.cspSource, nonce: makeNonce() }, registry);
}
dispose(): void {
clearTimeout(this.timer);
for (const d of this.disposables.splice(0)) d.dispose();
}
}
export class RegistryViewProvider implements vscode.CustomReadonlyEditorProvider<RegistryDocument>, vscode.Disposable {
private readonly views = new Set<RegistryView>();
private active: RegistryView | undefined;
private readonly disposables: vscode.Disposable[];
constructor(private readonly models: ModelService) {
this.disposables = [
vscode.window.registerCustomEditorProvider(REGISTRY_VIEW_TYPE, this, {
webviewOptions: { retainContextWhenHidden: true, enableFindWidget: true },
supportsMultipleEditorsPerDocument: true,
}),
// Any change in the model (a diagram starts or stops using an element, a registry edit) refreshes the page.
models.onDidChangeModel(({ root }) => {
for (const view of this.views) if (view.root?.toString() === root.toString()) view.scheduleUpdate();
}),
vscode.workspace.onDidChangeTextDocument((event) => {
if (!isRegistryFile(event.document.fileName) && !isMainFile(event.document.fileName)) return;
// Outside a model the model service stays quiet; refresh the page of the edited file itself.
for (const view of this.views) if (!view.root && view.uri.toString() === event.document.uri.toString()) view.scheduleUpdate();
}),
];
}
/** File shown in the focused registry view, if any. */
get activeUri(): vscode.Uri | undefined {
return this.active?.uri;
}
openCustomDocument(uri: vscode.Uri): RegistryDocument {
return new RegistryDocument(uri);
}
async resolveCustomEditor(document: RegistryDocument, panel: vscode.WebviewPanel): Promise<void> {
panel.webview.options = { enableScripts: true };
const view = new RegistryView(this.models, panel, document.uri);
this.views.add(view);
const onViewState = () => {
if (panel.active) this.active = view;
else if (this.active === view) this.active = undefined;
if (panel.active) void vscode.commands.executeCommand('setContext', 'archify.inModel', Boolean(view.root));
};
panel.onDidChangeViewState(onViewState);
onViewState();
panel.onDidDispose(() => {
this.views.delete(view);
if (this.active === view) this.active = undefined;
view.dispose();
});
}
dispose(): void {
for (const view of this.views) view.dispose();
this.views.clear();
for (const d of this.disposables.splice(0)) d.dispose();
}
}