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:
@@ -2,12 +2,14 @@ import * as vscode from 'vscode';
|
||||
import { HTML_VIEW_TYPE, HtmlViewerProvider } from '../views/htmlViewer';
|
||||
import { ModelService } from '../model/service';
|
||||
import { DiagramViewManager } from '../views/diagramView';
|
||||
import { RegistryViewProvider } from '../views/registryView';
|
||||
|
||||
/** What command handlers share: the extension context and the open views. */
|
||||
export interface CommandContext {
|
||||
extension: vscode.ExtensionContext;
|
||||
views: DiagramViewManager;
|
||||
htmlViewer: HtmlViewerProvider;
|
||||
registry: RegistryViewProvider;
|
||||
models: ModelService;
|
||||
}
|
||||
|
||||
@@ -18,7 +20,7 @@ export function isHtmlUri(uri: vscode.Uri): boolean {
|
||||
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
||||
export function targetUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
|
||||
if (arg instanceof vscode.Uri) return arg;
|
||||
return ctx.views.activePreview?.uri ?? ctx.htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
return ctx.views.activePreview?.uri ?? ctx.registry.activeUri ?? ctx.htmlViewer.activeUri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
}
|
||||
|
||||
export function sourceUri(ctx: CommandContext, arg?: unknown): vscode.Uri | undefined {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { isRegistryFile } from '../model/format';
|
||||
import { isArchifyFile } from '../sources/detect';
|
||||
import { DIAGRAM_EDITOR_VIEW_TYPE } from '../views/diagramView';
|
||||
import { REGISTRY_VIEW_TYPE } from '../views/registryView';
|
||||
import { CommandContext, isHtmlUri, openHtmlViewer, sourceUri } from './context';
|
||||
|
||||
export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
@@ -9,6 +11,7 @@ export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
const uri = sourceUri(ctx, arg);
|
||||
if (!uri) return;
|
||||
if (isHtmlUri(uri)) return openHtmlViewer(uri);
|
||||
if (isRegistryFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, REGISTRY_VIEW_TYPE);
|
||||
if (isArchifyFile(uri.path)) return vscode.commands.executeCommand('vscode.openWith', uri, DIAGRAM_EDITOR_VIEW_TYPE);
|
||||
ctx.views.show(uri, vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.Active);
|
||||
}),
|
||||
@@ -45,6 +48,11 @@ export function viewCommands(ctx: CommandContext): vscode.Disposable[] {
|
||||
await vscode.window.showTextDocument(preview.uri, { viewColumn: column });
|
||||
return;
|
||||
}
|
||||
const registry = ctx.registry.activeUri;
|
||||
if (registry) {
|
||||
await vscode.commands.executeCommand('vscode.openWith', registry, 'default', vscode.ViewColumn.Beside);
|
||||
return;
|
||||
}
|
||||
const html = ctx.htmlViewer.activeUri;
|
||||
if (html) await vscode.commands.executeCommand('vscode.openWith', html, 'default');
|
||||
}),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DiagnosticsController } from './language/diagnostics';
|
||||
import { log } from './util/log';
|
||||
import { HtmlViewerProvider } from './views/htmlViewer';
|
||||
import { DiagramViewManager } from './views/diagramView';
|
||||
import { RegistryViewProvider } from './views/registryView';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const models = new ModelService();
|
||||
@@ -19,6 +20,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
extension: context,
|
||||
views: new DiagramViewManager(context, models),
|
||||
htmlViewer: new HtmlViewerProvider(context.extensionUri),
|
||||
registry: new RegistryViewProvider(models),
|
||||
models,
|
||||
};
|
||||
const refs = new RefLanguage(models);
|
||||
@@ -26,6 +28,7 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
models,
|
||||
ctx.views,
|
||||
ctx.htmlViewer,
|
||||
ctx.registry,
|
||||
new DiagnosticsController(context, models),
|
||||
log(),
|
||||
vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'),
|
||||
|
||||
+85
-4
@@ -1,20 +1,76 @@
|
||||
import { DiagramSummary, RegistryElement } from './format';
|
||||
import { ModelIndex } from './service';
|
||||
|
||||
/** A diagram an element is used in, as the pages show it. */
|
||||
export interface UsedIn {
|
||||
path: string;
|
||||
title?: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/** A registry element as the model page and the registry page show it. */
|
||||
export interface ElementCard {
|
||||
id: string;
|
||||
label?: string;
|
||||
sublabel?: string;
|
||||
type?: string;
|
||||
brand?: string;
|
||||
description?: string;
|
||||
/** Diagram that explains the element, relative to the model root. */
|
||||
diagram?: string;
|
||||
/** Registry file (relative to the model root) and 0-based line of the element. */
|
||||
file: string;
|
||||
line: number;
|
||||
usedIn: UsedIn[];
|
||||
}
|
||||
|
||||
/** What the model page shows; serialised into the webview. */
|
||||
export interface ModelPanel {
|
||||
meta: { id?: string; name?: string; description?: string };
|
||||
/** True when main.archify also carries a diagram, shown between metadata and the list. */
|
||||
hasDiagram: boolean;
|
||||
groups: { group: string; diagrams: { path: string; title?: string; kind?: string; parseError?: string }[] }[];
|
||||
registry: { id: string; label?: string; type?: string; description?: string; file: string; usedIn: string[] }[];
|
||||
registry: ElementCard[];
|
||||
problems: string[];
|
||||
nested: string[];
|
||||
}
|
||||
|
||||
/** What the registry page (one .archify-registry file) shows. */
|
||||
export interface RegistryPanel {
|
||||
/** The registry file, relative to the model root (or its base name outside a model). */
|
||||
file: string;
|
||||
inModel: boolean;
|
||||
model?: { id?: string; name?: string };
|
||||
elements: ElementCard[];
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
function usedIn(id: string, usage: Map<string, string[]>, diagrams: DiagramSummary[]): UsedIn[] {
|
||||
return (usage.get(id) ?? []).map((path) => {
|
||||
const summary = diagrams.find((d) => d.relativePath === path);
|
||||
return { path, title: summary?.title, kind: summary?.kind };
|
||||
});
|
||||
}
|
||||
|
||||
export function elementCard(e: RegistryElement, usage: Map<string, string[]>, diagrams: DiagramSummary[]): ElementCard {
|
||||
return {
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
sublabel: e.sublabel,
|
||||
type: e.type,
|
||||
brand: e.brand,
|
||||
description: e.description,
|
||||
diagram: e.diagram,
|
||||
file: e.file,
|
||||
line: e.line,
|
||||
usedIn: usedIn(e.id, usage, diagrams),
|
||||
};
|
||||
}
|
||||
|
||||
const byId = (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id);
|
||||
|
||||
export function panelFromIndex(index: ModelIndex): ModelPanel {
|
||||
const registry = [...index.registry.elements.values()]
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((e) => ({ id: e.id, label: e.label, type: e.type, description: e.description, file: e.file, usedIn: index.usage.get(e.id) ?? [] }));
|
||||
const registry = [...index.registry.elements.values()].sort(byId).map((e) => elementCard(e, index.usage, index.diagrams));
|
||||
return {
|
||||
meta: index.meta,
|
||||
hasDiagram: index.mainHasDiagram,
|
||||
@@ -27,3 +83,28 @@ export function panelFromIndex(index: ModelIndex): ModelPanel {
|
||||
nested: index.nested,
|
||||
};
|
||||
}
|
||||
|
||||
/** The registry page of one file inside a model: its elements, in file order, with usage. */
|
||||
export function registryPanelFromIndex(index: ModelIndex, file: string): RegistryPanel {
|
||||
const elements = [...index.registry.elements.values()]
|
||||
.filter((e) => e.file === file)
|
||||
.sort((a, b) => a.line - b.line)
|
||||
.map((e) => elementCard(e, index.usage, index.diagrams));
|
||||
return {
|
||||
file,
|
||||
inModel: true,
|
||||
model: { id: index.meta.id, name: index.meta.name },
|
||||
elements,
|
||||
problems: index.registry.problems.filter((p) => p.file === file).map((p) => p.message),
|
||||
};
|
||||
}
|
||||
|
||||
/** The registry page of a file outside a model: elements only, nothing is used anywhere. */
|
||||
export function registryPanelStandalone(file: string, parsed: { elements: RegistryElement[]; problems: { message: string }[] }): RegistryPanel {
|
||||
return {
|
||||
file,
|
||||
inModel: false,
|
||||
elements: parsed.elements.map((e) => elementCard(e, new Map(), [])),
|
||||
problems: parsed.problems.map((p) => p.message),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type WebviewMessage =
|
||||
| { type: 'openRelative'; href: string }
|
||||
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
|
||||
| { type: 'openDiagram'; path: string }
|
||||
| { type: 'openRegistry'; file: string; line?: number }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Column for opening files next to a webview without covering it. */
|
||||
|
||||
+253
-32
@@ -18,9 +18,11 @@ export interface PageOptions {
|
||||
links?: Record<string, string>;
|
||||
/** The model page: metadata above the diagram, the diagram list and registry below. */
|
||||
model?: ModelPanel;
|
||||
/** Registry page: the elements of one .archify-registry file. */
|
||||
registry?: RegistryPanel;
|
||||
}
|
||||
|
||||
import type { ModelPanel } from '../model/panel';
|
||||
import type { ModelPanel, RegistryPanel } from '../model/panel';
|
||||
|
||||
export function makeNonce(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
@@ -74,6 +76,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
defaultView: options.viewer3d?.defaultView,
|
||||
links: options.links ?? {},
|
||||
model: options.model,
|
||||
registry: options.registry,
|
||||
};
|
||||
return `(function () {
|
||||
var config = ${scriptJson(config)};
|
||||
@@ -202,6 +205,198 @@ function bridgeScript(options: PageOptions): string {
|
||||
return details;
|
||||
}
|
||||
|
||||
// One registry element as an Archify card. Clicking the card opens the
|
||||
// usage modal; the chips open the diagram directly.
|
||||
function elementCard(element) {
|
||||
var card = el('div', 'card archify-model-card');
|
||||
card.setAttribute('role', 'button');
|
||||
card.tabIndex = 0;
|
||||
card.title = 'Show where ' + (element.label || element.id) + ' is used';
|
||||
var header = el('div', 'card-header');
|
||||
header.appendChild(el('div', 'card-dot ' + dotForType(element.type)));
|
||||
header.appendChild(el('h3', '', element.label || element.id));
|
||||
card.appendChild(header);
|
||||
var list = el('ul');
|
||||
list.appendChild(el('li', 'archify-model-card-id', element.id + (element.type ? ' · ' + element.type : '') + (element.sublabel ? ' · ' + element.sublabel : '')));
|
||||
if (element.description) list.appendChild(el('li', '', element.description.trim()));
|
||||
var used = el('li', 'archify-model-card-used');
|
||||
if (element.usedIn.length) {
|
||||
element.usedIn.forEach(function (use) {
|
||||
var link = el('button', 'archify-model-used-link', use.path.replace(/\.archify$/, ''));
|
||||
link.type = 'button';
|
||||
link.title = 'Open ' + use.path;
|
||||
link.addEventListener('click', function (event) { event.stopPropagation(); post({ type: 'openDiagram', path: use.path }); });
|
||||
used.appendChild(link);
|
||||
});
|
||||
} else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet'));
|
||||
list.appendChild(used);
|
||||
card.appendChild(list);
|
||||
card.addEventListener('click', function () { openElementModal(element); });
|
||||
card.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openElementModal(element); } });
|
||||
return card;
|
||||
}
|
||||
|
||||
// Modal: one element, where it is defined and the diagrams that use it.
|
||||
function openElementModal(element) {
|
||||
closeElementModal();
|
||||
var backdrop = el('div', 'archify-modal-backdrop');
|
||||
backdrop.id = 'archify-element-modal';
|
||||
var modal = el('div', 'archify-modal archify-model');
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
var head = el('div', 'archify-modal-head');
|
||||
head.appendChild(el('span', 'card-dot ' + dotForType(element.type)));
|
||||
var titles = el('div', 'archify-modal-titles');
|
||||
titles.appendChild(el('h2', 'archify-modal-title', element.label || element.id));
|
||||
titles.appendChild(el('div', 'archify-model-card-id', element.id + (element.type ? ' · ' + element.type : '') + (element.sublabel ? ' · ' + element.sublabel : '')));
|
||||
head.appendChild(titles);
|
||||
var close = el('button', 'archify-modal-close', '\u00d7');
|
||||
close.type = 'button';
|
||||
close.title = 'Close (Esc)';
|
||||
close.addEventListener('click', closeElementModal);
|
||||
head.appendChild(close);
|
||||
modal.appendChild(head);
|
||||
if (element.description) modal.appendChild(el('p', 'archify-modal-description', element.description.trim()));
|
||||
var defined = el('p', 'archify-modal-defined');
|
||||
defined.appendChild(document.createTextNode('Defined in '));
|
||||
var source = el('button', 'archify-model-used-link', element.file + ':' + (element.line + 1));
|
||||
source.type = 'button';
|
||||
source.title = 'Open the registry at this line';
|
||||
source.addEventListener('click', function () { post({ type: 'openRegistry', file: element.file, line: element.line }); });
|
||||
defined.appendChild(source);
|
||||
if (element.diagram) {
|
||||
defined.appendChild(document.createTextNode(' · explained by '));
|
||||
var explained = el('button', 'archify-model-used-link', element.diagram.replace(/\.archify$/, ''));
|
||||
explained.type = 'button';
|
||||
explained.addEventListener('click', function () { post({ type: 'openDiagram', path: element.diagram }); });
|
||||
defined.appendChild(explained);
|
||||
}
|
||||
modal.appendChild(defined);
|
||||
modal.appendChild(el('h3', 'archify-model-title', element.usedIn.length ? 'Used in ' + element.usedIn.length + (element.usedIn.length === 1 ? ' diagram' : ' diagrams') : 'Not used in any diagram yet'));
|
||||
if (element.usedIn.length) {
|
||||
var list = el('ul', 'archify-model-list');
|
||||
element.usedIn.forEach(function (use) {
|
||||
var item = el('li', 'archify-model-item');
|
||||
var button = el('button', 'archify-model-open');
|
||||
button.type = 'button';
|
||||
button.title = use.path;
|
||||
button.appendChild(el('span', 'archify-model-item-title', use.title || use.path.split('/').pop()));
|
||||
button.appendChild(el('span', 'archify-model-item-meta', (use.kind || 'diagram') + ' · ' + use.path));
|
||||
button.addEventListener('click', function () { post({ type: 'openDiagram', path: use.path }); });
|
||||
item.appendChild(button);
|
||||
list.appendChild(item);
|
||||
});
|
||||
modal.appendChild(list);
|
||||
}
|
||||
backdrop.appendChild(modal);
|
||||
backdrop.addEventListener('click', function (event) { if (event.target === backdrop) closeElementModal(); });
|
||||
document.body.appendChild(backdrop);
|
||||
close.focus();
|
||||
}
|
||||
function closeElementModal() {
|
||||
var existing = document.getElementById('archify-element-modal');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && document.getElementById('archify-element-modal')) { event.stopPropagation(); closeElementModal(); }
|
||||
}, true);
|
||||
|
||||
// The registry page: one .archify-registry file, its elements as cards.
|
||||
function renderRegistry(registry) {
|
||||
if (!registry) return;
|
||||
var head = el('section', 'archify-model archify-model-head');
|
||||
head.appendChild(el('div', 'archify-model-kicker', 'Registry' + (registry.model && registry.model.id ? ' · ' + registry.model.id : '')));
|
||||
head.appendChild(el('h1', 'archify-model-name', registry.file));
|
||||
if (registry.model && registry.model.name) head.appendChild(el('p', 'archify-model-description', 'Shared elements of ' + registry.model.name + '. Click an element to see where it is used.'));
|
||||
if (!registry.inModel) head.appendChild(el('p', 'archify-model-problem', 'This registry is not inside a model (no main.archify in a folder above it), so nothing can refer to its elements yet.'));
|
||||
registry.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); });
|
||||
document.body.appendChild(head);
|
||||
var body = el('section', 'archify-model archify-model-body');
|
||||
var elementsPanel = panel('elements', 'archify-model-registry', 'Elements', registry.elements.length, true);
|
||||
body.appendChild(elementsPanel);
|
||||
if (!registry.elements.length) elementsPanel.appendChild(el('p', 'archify-model-empty', 'No elements yet. Add them under "elements:" with a type, a label and a description.'));
|
||||
var cards = el('div', 'cards archify-model-cards');
|
||||
registry.elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
||||
elementsPanel.appendChild(cards);
|
||||
document.body.appendChild(body);
|
||||
}
|
||||
|
||||
// Connectors: a rail panel next to Archify's Elements index, read from the
|
||||
// SVG's edges. Hover highlights the connector, click keeps it highlighted.
|
||||
function renderConnectors() {
|
||||
var svg = document.querySelector('.diagram-container > svg');
|
||||
var outline = document.getElementById('node-outline');
|
||||
if (!svg || !outline || document.getElementById('archify-connectors')) return;
|
||||
var labels = {};
|
||||
Array.prototype.forEach.call(svg.querySelectorAll('[data-node-id][data-node-label]'), function (node) {
|
||||
labels[node.getAttribute('data-node-id')] = node.getAttribute('data-node-label');
|
||||
});
|
||||
var edges = [];
|
||||
var byKey = {};
|
||||
Array.prototype.forEach.call(svg.querySelectorAll('[data-edge-key]'), function (fragment) {
|
||||
var key = fragment.getAttribute('data-edge-key');
|
||||
if (!byKey[key]) {
|
||||
byKey[key] = { key: key, from: fragment.getAttribute('data-edge-from') || '', to: fragment.getAttribute('data-edge-to') || '', label: fragment.getAttribute('data-edge-label') || '', fragments: [] };
|
||||
edges.push(byKey[key]);
|
||||
}
|
||||
byKey[key].fragments.push(fragment);
|
||||
});
|
||||
if (!edges.length) return;
|
||||
var nav = el('nav', 'node-outline no-print archify-connectors');
|
||||
nav.id = 'archify-connectors';
|
||||
nav.setAttribute('aria-labelledby', 'archify-connectors-title');
|
||||
var head = el('div', 'node-outline-head');
|
||||
var title = el('strong', '', 'Connectors');
|
||||
title.id = 'archify-connectors-title';
|
||||
head.appendChild(title);
|
||||
head.appendChild(el('span', 'node-outline-count', String(edges.length)));
|
||||
nav.appendChild(head);
|
||||
var list = el('ol', 'node-outline-list');
|
||||
var group = el('li', 'node-outline-group');
|
||||
var items = el('ul');
|
||||
var selected = null;
|
||||
function mark(edge, attr, on) {
|
||||
edge.fragments.forEach(function (fragment) { if (on) fragment.setAttribute(attr, ''); else fragment.removeAttribute(attr); });
|
||||
}
|
||||
function select(edge, button) {
|
||||
if (selected) { mark(selected.edge, 'data-archify-selected', false); selected.button.removeAttribute('aria-current'); }
|
||||
if (selected && selected.edge === edge) { selected = null; return; }
|
||||
selected = { edge: edge, button: button };
|
||||
mark(edge, 'data-archify-selected', true);
|
||||
button.setAttribute('aria-current', 'true');
|
||||
var first = edge.fragments[0];
|
||||
if (first && typeof first.scrollIntoView === 'function') first.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
|
||||
}
|
||||
edges.forEach(function (edge) {
|
||||
var item = el('li');
|
||||
var button = el('button', 'node-outline-item');
|
||||
button.type = 'button';
|
||||
button.style.setProperty('--outline-tone', 'var(--text-muted)');
|
||||
button.title = (labels[edge.from] || edge.from) + ' \u2192 ' + (labels[edge.to] || edge.to) + (edge.label ? ' \u00b7 ' + edge.label : '');
|
||||
var dot = el('span', 'node-outline-dot');
|
||||
dot.setAttribute('aria-hidden', 'true');
|
||||
button.appendChild(dot);
|
||||
button.appendChild(el('strong', '', (labels[edge.from] || edge.from) + ' \u2192 ' + (labels[edge.to] || edge.to)));
|
||||
if (edge.label) button.appendChild(el('small', '', edge.label));
|
||||
button.addEventListener('pointerenter', function () { mark(edge, 'data-archify-hit', true); });
|
||||
button.addEventListener('pointerleave', function () { mark(edge, 'data-archify-hit', false); });
|
||||
button.addEventListener('focus', function () { mark(edge, 'data-archify-hit', true); });
|
||||
button.addEventListener('blur', function () { mark(edge, 'data-archify-hit', false); });
|
||||
button.addEventListener('click', function () { select(edge, button); });
|
||||
item.appendChild(button);
|
||||
items.appendChild(item);
|
||||
});
|
||||
group.appendChild(items);
|
||||
list.appendChild(group);
|
||||
nav.appendChild(list);
|
||||
outline.insertAdjacentElement('afterend', nav);
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && selected) select(selected.edge, selected.button);
|
||||
});
|
||||
var archify = window.Archify;
|
||||
if (archify && archify.readerLayout && typeof archify.readerLayout.schedule === 'function') archify.readerLayout.schedule();
|
||||
}
|
||||
|
||||
var DOT_BY_TYPE = { frontend: 'cyan', backend: 'emerald', database: 'violet', cloud: 'amber', security: 'rose', messagebus: 'orange', external: 'slate' };
|
||||
function dotForType(type) { return DOT_BY_TYPE[type || ''] || 'slate'; }
|
||||
|
||||
@@ -256,29 +451,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
var registryPanel = panel('registry', 'archify-model-registry', 'Registry', model.registry.length, false);
|
||||
body.appendChild(registryPanel);
|
||||
var cards = el('div', 'cards archify-model-cards');
|
||||
model.registry.forEach(function (element) {
|
||||
var card = el('div', 'card archify-model-card');
|
||||
var header = el('div', 'card-header');
|
||||
header.appendChild(el('div', 'card-dot ' + dotForType(element.type)));
|
||||
header.appendChild(el('h3', '', element.label || element.id));
|
||||
card.appendChild(header);
|
||||
var list = el('ul');
|
||||
list.appendChild(el('li', 'archify-model-card-id', element.id + (element.type ? ' · ' + element.type : '')));
|
||||
if (element.description) list.appendChild(el('li', '', element.description.trim()));
|
||||
var used = el('li', 'archify-model-card-used');
|
||||
if (element.usedIn.length) {
|
||||
element.usedIn.forEach(function (path) {
|
||||
var link = el('button', 'archify-model-used-link', path.replace(/\.archify$/, ''));
|
||||
link.type = 'button';
|
||||
link.title = 'Open ' + path;
|
||||
link.addEventListener('click', function () { post({ type: 'openDiagram', path: path }); });
|
||||
used.appendChild(link);
|
||||
});
|
||||
} else used.appendChild(el('span', 'archify-model-item-meta', 'not used yet'));
|
||||
list.appendChild(used);
|
||||
card.appendChild(list);
|
||||
cards.appendChild(card);
|
||||
});
|
||||
model.registry.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
||||
registryPanel.appendChild(cards);
|
||||
}
|
||||
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
||||
@@ -288,13 +461,13 @@ function bridgeScript(options: PageOptions): string {
|
||||
var message = event.data || {};
|
||||
if (message.type === 'notice') showNotice(message.notice);
|
||||
});
|
||||
if (config.model) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { renderModel(config.model); });
|
||||
} else {
|
||||
renderModel(config.model);
|
||||
}
|
||||
function onReady(fn) {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
||||
else fn();
|
||||
}
|
||||
if (config.model) onReady(function () { renderModel(config.model); });
|
||||
if (config.registry) onReady(function () { renderRegistry(config.registry); });
|
||||
onReady(renderConnectors);
|
||||
if (config.notice) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
||||
@@ -306,7 +479,7 @@ function bridgeScript(options: PageOptions): string {
|
||||
}
|
||||
|
||||
const MODEL_STYLE = `
|
||||
.archify-model { --m-text: var(--text, var(--vscode-foreground)); --m-muted: var(--text-muted, var(--vscode-descriptionForeground)); --m-panel: var(--panel, var(--vscode-editorWidget-background)); --m-border: var(--panel-border, var(--vscode-editorWidget-border, rgba(128,128,128,.35))); --m-accent: var(--frontend-stroke, var(--vscode-textLink-foreground));
|
||||
.archify-model { --m-text: var(--text, var(--vscode-foreground, CanvasText)); --m-muted: var(--text-muted, var(--vscode-descriptionForeground, GrayText)); --m-panel: var(--panel, var(--vscode-editorWidget-background, Canvas)); --m-border: var(--panel-border, var(--vscode-editorWidget-border, rgba(128,128,128,.35))); --m-accent: var(--frontend-stroke, var(--vscode-textLink-foreground));
|
||||
max-width: 1400px; margin: 0 auto; padding: 1.25rem 2rem; color: var(--m-text); font-family: var(--vscode-font-family, system-ui, sans-serif); }
|
||||
.archify-model-head { padding-top: 1.5rem; }
|
||||
.archify-model-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--m-accent); }
|
||||
@@ -338,6 +511,19 @@ const MODEL_STYLE = `
|
||||
.archify-model-item-title { font-weight: 600; font-size: 0.9375rem; }
|
||||
.archify-model-item-meta { font-size: 0.75rem; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; }
|
||||
.archify-model-empty { color: var(--m-muted); }
|
||||
/* Element modal: where one element is defined and used. */
|
||||
.archify-modal-backdrop { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: flex-start; justify-content: center; padding: 6vh 1rem; background: rgba(0, 0, 0, 0.45); overflow: auto; }
|
||||
.archify-modal { width: min(44rem, 100%); max-height: 88vh; overflow: auto; padding: 1.25rem 1.5rem 1.5rem; border-radius: 0.9rem; border: 1px solid var(--m-border); background: var(--m-panel); color: var(--m-text); box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35); }
|
||||
.archify-modal-head { display: flex; align-items: flex-start; gap: 0.75rem; }
|
||||
.archify-modal-head .card-dot { flex: none; width: 12px; height: 12px; border-radius: 50%; margin-top: 0.45rem; }
|
||||
.archify-modal-titles { flex: 1 1 auto; min-width: 0; }
|
||||
.archify-modal-title { margin: 0; font-size: 1.25rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.archify-modal-close { flex: none; font: inherit; font-size: 1.25rem; line-height: 1; width: 2rem; height: 2rem; border-radius: 0.5rem; border: 1px solid var(--m-border); background: transparent; color: var(--m-muted); cursor: pointer; }
|
||||
.archify-modal-close:hover { color: var(--m-text); border-color: var(--m-accent); }
|
||||
.archify-modal-description { margin: 0.9rem 0 0; font-size: 0.9375rem; line-height: 1.55; color: var(--m-text); white-space: pre-line; }
|
||||
.archify-modal-defined { margin: 0.75rem 0 0; font-size: 0.8125rem; color: var(--m-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
|
||||
.archify-modal .archify-model-title { margin: 1.25rem 0 0.6rem; }
|
||||
.archify-modal .archify-model-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
/* Registry cards: Archify's own .card look, with fallbacks for the page without a diagram. */
|
||||
.archify-model { --m-frontend: var(--frontend-stroke, #22d3ee); --m-backend: var(--backend-stroke, #34d399); --m-database: var(--database-stroke, #a78bfa); --m-cloud: var(--cloud-stroke, #fbbf24); --m-security: var(--security-stroke, #fb7185); --m-messagebus: var(--messagebus-stroke, #fb923c); --m-external: var(--external-stroke, #94a3b8); }
|
||||
.archify-model-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); gap: 1rem; margin-top: 0.25rem; }
|
||||
@@ -356,6 +542,19 @@ const MODEL_STYLE = `
|
||||
.archify-model-used-link:hover { border-color: var(--m-accent); }
|
||||
`;
|
||||
|
||||
/* Connectors rail panel: highlight of the hovered or selected edge in the SVG. */
|
||||
const CONNECTOR_STYLE = `
|
||||
.diagram-container svg [data-edge-key][data-archify-hit] path, .diagram-container svg [data-edge-key][data-archify-selected] path { stroke-width: 3px; filter: drop-shadow(0 0 3px currentColor); }
|
||||
.diagram-container svg [data-edge-key][data-archify-selected] text { font-weight: 700; }
|
||||
.archify-connectors .node-outline-item strong { font-weight: 600; }
|
||||
/* Bottom rail: Elements and Connectors side by side; Connectors alone when Archify hides its index. */
|
||||
html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors) { grid-template-areas: "cards cards" "index connectors"; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 1rem; }
|
||||
html[data-reader-rail="bottom"] .reader-rail:has(> .archify-connectors):has(> #node-outline[hidden]) { grid-template-areas: "cards" "connectors"; grid-template-columns: minmax(0, 1fr); }
|
||||
html[data-reader-rail="bottom"] .reader-rail > #archify-connectors { display: block; grid-area: connectors; padding: 0 1.25rem 0.75rem; border: 1px solid var(--panel-border); border-radius: 0.75rem; background: var(--panel); }
|
||||
html[data-reader-rail="bottom"] .reader-rail > #archify-connectors .node-outline-head { padding: 1rem 0.25rem 0.375rem; }
|
||||
html[data-reader-rail="bottom"] .reader-rail > #archify-connectors .node-outline-list { columns: 15rem; column-gap: 1.75rem; padding: 0; overflow: visible; -webkit-mask-image: none; mask-image: none; }
|
||||
`;
|
||||
|
||||
const NOTICE_STYLE = `
|
||||
.archify-vscode-notice {
|
||||
position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 2147483647;
|
||||
@@ -377,9 +576,12 @@ 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}${options.model ? MODEL_STYLE : ''}</style>\n`;
|
||||
`<style>${NOTICE_STYLE}${CONNECTOR_STYLE}${options.model ? MODEL_STYLE : ''}</style>\n`;
|
||||
|
||||
let page = html
|
||||
// Archify calls its node rail "Node index"; next to the registry it reads better as Elements.
|
||||
.replace(/(id="node-outline-title">)Node index(<)/, '$1Elements$2')
|
||||
.replace(/("viewer\.outline\.title":")Node index(")/, '$1Elements$2')
|
||||
// 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.
|
||||
@@ -419,6 +621,25 @@ export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'st
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** The registry page for one .archify-registry file. */
|
||||
export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, registry: RegistryPanel): string {
|
||||
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, registry, links: {} });
|
||||
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}">${bridge}</script>
|
||||
<style>
|
||||
body { margin: 0; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
||||
${MODEL_STYLE}
|
||||
</style>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** A small VS Code-styled page for loading and error states. */
|
||||
export function messagePage(
|
||||
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
|
||||
|
||||
Reference in New Issue
Block a user