Files
archify-vscode-ext/src/model/panel.ts
T
root-at-skic ebf320a023 Architecture decisions: files, diagram references, views and panels
- *.archify-decision files: id (unique, words), title, status, date,
  elements, diagrams, supersedes, and prose; parsed, merged and link-checked
  in the model index; diagnostics on the file
- diagrams point at decisions with decisions: [...] on the diagram, nodes
  and edges; stripped before Archify, unknown ids reported, completion,
  hover and go-to-definition for the ids and for elements/diagrams/
  supersedes in decision files
- Decisions panel on the main.archify page; Decisions rail panel under a
  diagram (hover lights up the node or connection); decisions on element
  cards and in the element dialog; decision view for the file, with a
  lost-model note outside a model
- Archify | New Decision… command and template; editor schemas; the three
  New Model examples carry decisions; docs, changelog, 0.8.0
2026-10-19 23:13:05 +03:00

195 lines
8.2 KiB
TypeScript

import { Decision, effectiveStatus, sortDecisions } from './decisions';
import { DecisionAttachment, DiagramSummary, RegistryElement } from './format';
import { ModelIndex } from './service';
/** A decision as the lists show it. */
export interface DecisionRow {
id: string;
title?: string;
/** Status shown: superseded when another decision supersedes it. */
status: string;
supersededBy?: string;
date?: string;
file: string;
}
/** 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[];
/** Decisions about this element, from the decision files and from diagram attachments. */
decisions: DecisionRow[];
}
/** 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: ElementCard[];
decisions: DecisionRow[];
problems: string[];
nested: string[];
}
/** What the decision page (one .archify-decision file) shows. */
export interface DecisionPanel {
file: string;
inModel: boolean;
model?: { id?: string; name?: string };
decision?: DecisionRow & { context?: string; decision?: string; consequences?: string; supersedes: DecisionRow[] };
/** Decisions that supersede this one. */
supersededBy: DecisionRow[];
elements: ElementCard[];
/** Diagrams the decision names. */
diagrams: UsedIn[];
/** Diagrams that point at the decision, with where. */
referencedBy: { path: string; title?: string; kind?: string; attachedTo: DecisionAttachment }[];
problems: 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 decisionRow(d: Decision, all: Map<string, Decision>): DecisionRow {
const { status, supersededBy } = effectiveStatus(d, all);
return { id: d.id, title: d.title, status, supersededBy, date: d.date, file: d.file };
}
/** Decisions about an element: named by the decision, or attached to its node in some diagram. */
function decisionsOfElement(id: string, index: Pick<ModelIndex, 'decisions' | 'decisionUsage'> | undefined): DecisionRow[] {
if (!index) return [];
const all = index.decisions.decisions;
const ids = new Set<string>();
for (const d of all.values()) if (d.elements.includes(id)) ids.add(d.id);
for (const [decisionId, uses] of index.decisionUsage) if (uses.some((u) => u.attachedTo.kind === 'node' && u.attachedTo.id === id) && all.has(decisionId)) ids.add(decisionId);
return sortDecisions([...ids].map((x) => all.get(x)!), all).map((d) => decisionRow(d, all));
}
export function elementCard(e: RegistryElement, usage: Map<string, string[]>, diagrams: DiagramSummary[], index?: Pick<ModelIndex, 'decisions' | 'decisionUsage'>): 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),
decisions: decisionsOfElement(e.id, index),
};
}
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(byId).map((e) => elementCard(e, index.usage, index.diagrams, index));
const all = index.decisions.decisions;
return {
meta: index.meta,
decisions: sortDecisions([...all.values()], all).map((d) => decisionRow(d, all)),
hasDiagram: index.mainHasDiagram,
groups: index.groups.map((g) => ({
group: g.group,
diagrams: g.diagrams.map((d) => ({ path: d.relativePath, title: d.title, kind: d.kind, parseError: d.parseError })),
})),
registry,
problems: [...index.registry.problems, ...index.decisions.problems].map((p) => (p.file ? `${p.file}: ${p.message}` : p.message)),
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, index));
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),
};
}
/** The decision page of one file inside a model. */
export function decisionPanelFromIndex(index: ModelIndex, file: string): DecisionPanel {
const all = index.decisions.decisions;
const d = [...all.values()].find((x) => x.file === file);
const problems = index.decisions.problems.filter((p) => p.file === file).map((p) => p.message);
const base = { file, inModel: true, model: { id: index.meta.id, name: index.meta.name }, problems };
if (!d) return { ...base, supersededBy: [], elements: [], diagrams: [], referencedBy: [] };
const rows = (ids: string[]) => ids.filter((id) => all.has(id)).map((id) => decisionRow(all.get(id)!, all));
const elements = d.elements.filter((id) => index.registry.elements.has(id)).map((id) => elementCard(index.registry.elements.get(id)!, index.usage, index.diagrams, index));
const summary = (path: string) => index.diagrams.find((x) => x.relativePath === path);
return {
...base,
decision: { ...decisionRow(d, all), context: d.context, decision: d.decision, consequences: d.consequences, supersedes: rows(d.supersedes) },
supersededBy: [...all.values()].filter((x) => x.supersedes.includes(d.id)).map((x) => decisionRow(x, all)),
elements,
diagrams: d.diagrams.map((path) => ({ path, title: summary(path)?.title, kind: summary(path)?.kind })),
referencedBy: (index.decisionUsage.get(d.id) ?? []).map((u) => ({ path: u.path, title: summary(u.path)?.title, kind: summary(u.path)?.kind, attachedTo: u.attachedTo })),
};
}
/** The decision page of a file outside a model: the text only, and a note that it has lost its model. */
export function decisionPanelStandalone(file: string, parsed: { decision?: Decision; problems: { message: string }[] }): DecisionPanel {
const d = parsed.decision;
return {
file,
inModel: false,
decision: d ? { id: d.id, title: d.title, status: d.status ?? 'proposed', date: d.date, file, context: d.context, decision: d.decision, consequences: d.consequences, supersedes: d.supersedes.map((id) => ({ id, status: 'unknown', file: '' })) } : undefined,
supersededBy: [],
elements: [],
diagrams: d ? d.diagrams.map((path) => ({ path })) : [],
referencedBy: [],
problems: parsed.problems.map((p) => p.message),
};
}