On a diagram page it goes last in Archify's toolbar, after Export, instead of floating over it. Every rule is !important and flex: none, so the page's button styles, narrow windows and high contrast themes no longer squeeze it or strip its colour.
1274 lines
76 KiB
TypeScript
1274 lines
76 KiB
TypeScript
export type ViewerTheme = 'dark' | 'light' | undefined;
|
|
|
|
export interface PageOptions {
|
|
/** webview.cspSource */
|
|
cspSource: string;
|
|
nonce: string;
|
|
/** Forced diagram theme; undefined keeps the diagram's own choice. */
|
|
theme: ViewerTheme;
|
|
/** Location hash to restore (Archify keeps focus/route state there). */
|
|
hash?: string;
|
|
/** Opaque state handed to vscode.setState for panel restore. */
|
|
state?: unknown;
|
|
/** Banner shown on top of the diagram. */
|
|
notice?: { text: string; kind: 'info' | 'warning' | 'error' };
|
|
/** The 3D viewer script (a webview URI), added to Archify pages. */
|
|
viewer3d?: { scriptUri: string; defaultView: '2d' | '3d' };
|
|
/** Node id → diagram path (relative to the model root); double-click opens it. */
|
|
links?: Record<string, string>;
|
|
/** The model page: metadata above the diagram, the diagram list and registry below. */
|
|
model?: ModelPanel;
|
|
/** Node id → registry description, shown as tooltips in the Elements panel. */
|
|
descriptions?: Record<string, string>;
|
|
/** Registry page: the elements of one .archify-registry file. */
|
|
registry?: RegistryPanel;
|
|
/** Decision page: one .archify-decision file. */
|
|
decision?: DecisionPanel;
|
|
/** Diagram page: decisions of this diagram, for the Decisions rail panel. */
|
|
decisions?: DiagramDecision[];
|
|
/** Pages inside a model get a switch to the model's Graph page; `left` names the page they are on. */
|
|
graphSwitch?: { left: 'Model' | 'Diagram'; query?: string };
|
|
/** Pages of a file that can be edited in place: the Source view's script, and the name of the page's own view. */
|
|
source?: { scriptUri: string; left: 'Model' | 'Diagram' | 'View' };
|
|
/** The Graph page: the model as one graph, the bundled script that draws it, and the query to start with. */
|
|
graph?: { graph: ModelGraph; query?: string; scriptUri: string };
|
|
}
|
|
|
|
import type { ModelGraph } from '../model/graph';
|
|
import type { DecisionPanel, ModelPanel, RegistryPanel } from '../model/panel';
|
|
import type { DiagramDecision } from '../model/prepare';
|
|
|
|
export function makeNonce(): string {
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
let nonce = '';
|
|
for (let i = 0; i < 32; i += 1) nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
return nonce;
|
|
}
|
|
|
|
export function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/** JSON that is safe to embed inside an inline <script>. */
|
|
function scriptJson(value: unknown): string {
|
|
return JSON.stringify(value ?? null)
|
|
.replace(/</g, '\\u003c')
|
|
.replace(/>/g, '\\u003e')
|
|
.replace(/\u2028/g, '\\u2028')
|
|
.replace(/\u2029/g, '\\u2029');
|
|
}
|
|
|
|
export function contentSecurityPolicy(cspSource: string, nonce: string): string {
|
|
return [
|
|
"default-src 'none'",
|
|
`img-src ${cspSource} https: data: blob:`,
|
|
`media-src ${cspSource} data: blob:`,
|
|
`font-src ${cspSource} data:`,
|
|
`style-src ${cspSource} 'unsafe-inline'`,
|
|
`script-src ${cspSource} 'nonce-${nonce}'`,
|
|
'connect-src data: blob:',
|
|
'worker-src blob:',
|
|
].join('; ');
|
|
}
|
|
|
|
/**
|
|
* Runs before any page script: pins the theme, restores the hash, and
|
|
* routes links, window.open and export downloads to the extension, since a
|
|
* webview cannot navigate or download on its own.
|
|
*/
|
|
function bridgeScript(options: PageOptions): string {
|
|
const config = {
|
|
theme: options.theme,
|
|
hash: options.hash,
|
|
state: options.state,
|
|
notice: options.notice,
|
|
defaultView: options.viewer3d?.defaultView,
|
|
links: options.links ?? {},
|
|
model: options.model,
|
|
registry: options.registry,
|
|
decision: options.decision,
|
|
decisions: options.decisions || [],
|
|
descriptions: options.descriptions || {},
|
|
graphSwitch: options.graphSwitch,
|
|
source: options.source,
|
|
graph: options.graph ? { graph: options.graph.graph, query: options.graph.query } : undefined,
|
|
};
|
|
return `(function () {
|
|
var config = ${scriptJson(config)};
|
|
var vscode = acquireVsCodeApi();
|
|
if (config.state) vscode.setState(Object.assign({}, vscode.getState() || {}, config.state));
|
|
if (config.theme) {
|
|
try { localStorage.setItem('archify-theme', config.theme); } catch (_) {}
|
|
}
|
|
if (config.hash && !location.hash) {
|
|
try { history.replaceState(null, '', config.hash); } catch (_) {}
|
|
}
|
|
window.addEventListener('hashchange', function () {
|
|
if (config.state) vscode.setState(Object.assign({}, vscode.getState() || {}, config.state, { hash: location.hash }));
|
|
vscode.postMessage({ type: 'hash', hash: location.hash });
|
|
});
|
|
|
|
function post(message) { vscode.postMessage(message); }
|
|
|
|
// For the 3D viewer, which cannot call acquireVsCodeApi a second time.
|
|
window.__archifyHost = {
|
|
defaultView: config.defaultView || '2d',
|
|
post: post,
|
|
getState: function () { return vscode.getState() || {}; },
|
|
setState: function (patch) { vscode.setState(Object.assign({}, vscode.getState() || {}, patch)); },
|
|
graph: config.graph
|
|
};
|
|
|
|
function saveBlobUrl(url, filename) {
|
|
fetch(url).then(function (response) { return response.blob(); }).then(function (blob) {
|
|
var reader = new FileReader();
|
|
reader.onload = function () {
|
|
var data = String(reader.result);
|
|
post({ type: 'save', filename: filename || 'archify-export', base64: data.slice(data.indexOf(',') + 1) });
|
|
};
|
|
reader.readAsDataURL(blob);
|
|
}).catch(function (error) {
|
|
post({ type: 'error', message: 'Export failed: ' + (error && error.message || error) });
|
|
});
|
|
}
|
|
|
|
document.addEventListener('click', function (event) {
|
|
var target = event.target;
|
|
var anchor = target && target.closest ? target.closest('a[href]') : null;
|
|
if (!anchor) return;
|
|
var href = anchor.getAttribute('href') || '';
|
|
if (anchor.hasAttribute('download') && /^(blob|data):/i.test(anchor.href)) {
|
|
event.preventDefault();
|
|
saveBlobUrl(anchor.href, anchor.getAttribute('download'));
|
|
return;
|
|
}
|
|
if (href.charAt(0) === '#') return;
|
|
event.preventDefault();
|
|
if (anchor.classList.contains('semantic-passport-source')) {
|
|
var pathNode = anchor.querySelector('small');
|
|
var match = /#L(\\d+)(?:-L(\\d+))?$/.exec(anchor.href);
|
|
post({
|
|
type: 'openSource',
|
|
path: pathNode ? pathNode.textContent : '',
|
|
line: match ? Number(match[1]) : undefined,
|
|
endLine: match && match[2] ? Number(match[2]) : undefined,
|
|
href: anchor.href
|
|
});
|
|
return;
|
|
}
|
|
if (/^(https?|mailto):/i.test(href)) post({ type: 'openExternal', href: href });
|
|
else post({ type: 'openRelative', href: href });
|
|
}, true);
|
|
|
|
var nativeOpen = window.open;
|
|
window.open = function (url) {
|
|
if (typeof url === 'string' && /^(https?|mailto):/i.test(url)) {
|
|
post({ type: 'openExternal', href: url });
|
|
return null;
|
|
}
|
|
return nativeOpen ? nativeOpen.apply(window, arguments) : null;
|
|
};
|
|
|
|
function showNotice(notice) {
|
|
var existing = document.getElementById('archify-vscode-notice');
|
|
if (existing) existing.remove();
|
|
if (!notice || !notice.text) return;
|
|
var bar = document.createElement('div');
|
|
bar.id = 'archify-vscode-notice';
|
|
bar.setAttribute('role', notice.kind === 'error' ? 'alert' : 'status');
|
|
bar.className = 'archify-vscode-notice archify-vscode-notice-' + notice.kind;
|
|
var text = document.createElement('span');
|
|
text.textContent = notice.text;
|
|
var close = document.createElement('button');
|
|
close.type = 'button';
|
|
close.textContent = '\\u00d7';
|
|
close.title = 'Dismiss';
|
|
close.addEventListener('click', function () { bar.remove(); });
|
|
bar.appendChild(text);
|
|
bar.appendChild(close);
|
|
(document.body || document.documentElement).appendChild(bar);
|
|
}
|
|
|
|
// A node that links to another diagram opens it on double-click.
|
|
document.addEventListener('dblclick', function (event) {
|
|
var target = event.target;
|
|
var node = target && target.closest ? target.closest('[data-node-id]') : null;
|
|
if (!node) return;
|
|
var id = node.getAttribute('data-node-id');
|
|
if (id && config.links[id]) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
post({ type: 'openDiagram', path: config.links[id] });
|
|
}
|
|
}, true);
|
|
|
|
// Collapsible panels of the model page; open/closed is kept in the webview state.
|
|
function panelStates() { return (vscode.getState() || {}).modelPanels || {}; }
|
|
function panel(key, className, title, count, defaultOpen) {
|
|
// count: a number or a short text such as "3 files".
|
|
var details = el('details', 'archify-model-panel ' + className);
|
|
var saved = panelStates()[key];
|
|
details.open = saved === undefined ? defaultOpen : saved;
|
|
var summary = el('summary', 'archify-model-summary');
|
|
summary.appendChild(el('span', 'archify-model-chevron'));
|
|
summary.appendChild(el('span', 'archify-model-summary-title', title));
|
|
if (count !== undefined) summary.appendChild(el('span', 'archify-model-count', String(count)));
|
|
details.appendChild(summary);
|
|
details.addEventListener('toggle', function () {
|
|
var states = panelStates();
|
|
states[key] = details.open;
|
|
vscode.setState(Object.assign({}, vscode.getState() || {}, { modelPanels: states }));
|
|
});
|
|
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);
|
|
if (element.decisions && element.decisions.length) {
|
|
var decided = el('li', 'archify-model-card-used');
|
|
element.decisions.forEach(function (row) { decided.appendChild(decisionChip(row)); });
|
|
list.appendChild(decided);
|
|
}
|
|
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;
|
|
}
|
|
|
|
// A decision as a chip (id, title, status) that opens its page.
|
|
function decisionChip(row) {
|
|
var chip = el('button', 'archify-model-used-link archify-decision-chip is-' + row.status, '');
|
|
chip.type = 'button';
|
|
chip.appendChild(el('span', 'archify-decision-dot'));
|
|
chip.appendChild(document.createTextNode(row.id + (row.title ? ' · ' + row.title : '')));
|
|
chip.title = 'Decision ' + row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')';
|
|
chip.addEventListener('click', function (event) { event.stopPropagation(); if (row.file) post({ type: 'openDecision', file: row.file }); });
|
|
return chip;
|
|
}
|
|
function statusBadge(row) {
|
|
var badge = el('span', 'archify-decision-status is-' + row.status, row.status + (row.supersededBy ? ' by ' + row.supersededBy : ''));
|
|
return badge;
|
|
}
|
|
// A decision as a row in a list: id, title, status, date; click opens it.
|
|
function decisionRowItem(row) {
|
|
var item = el('li', 'archify-model-item archify-decision-item is-' + row.status);
|
|
var button = el('button', 'archify-model-open archify-decision-open');
|
|
button.type = 'button';
|
|
button.title = row.file || row.id;
|
|
var line = el('span', 'archify-decision-line');
|
|
line.appendChild(el('span', 'archify-decision-id', row.id));
|
|
line.appendChild(el('span', 'archify-model-item-title', row.title || ''));
|
|
line.appendChild(statusBadge(row));
|
|
if (row.date) line.appendChild(el('span', 'archify-model-item-meta', row.date));
|
|
button.appendChild(line);
|
|
button.addEventListener('click', function () { if (row.file) post({ type: 'openDecision', file: row.file }); });
|
|
item.appendChild(button);
|
|
return item;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
if (element.decisions && element.decisions.length) {
|
|
modal.appendChild(el('h3', 'archify-model-title', 'Decisions · ' + element.decisions.length));
|
|
var decisions = el('ul', 'archify-model-list archify-decision-list');
|
|
element.decisions.forEach(function (row) { decisions.appendChild(decisionRowItem(row)); });
|
|
modal.appendChild(decisions);
|
|
}
|
|
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);
|
|
}
|
|
|
|
// The decision page: one .archify-decision file.
|
|
function renderDecision(page) {
|
|
if (!page) return;
|
|
var d = page.decision;
|
|
var head = el('section', 'archify-model archify-model-head');
|
|
head.appendChild(el('div', 'archify-model-kicker', 'Decision' + (page.model && page.model.id ? ' · ' + page.model.id : '') + (d ? ' · ' + d.id : '')));
|
|
head.appendChild(el('h1', 'archify-model-name', d && d.title ? d.title : page.file));
|
|
if (d) {
|
|
var meta = el('p', 'archify-decision-meta');
|
|
meta.appendChild(statusBadge(d));
|
|
if (d.date) meta.appendChild(el('span', 'archify-model-item-meta', d.date));
|
|
meta.appendChild(el('span', 'archify-model-item-meta', page.file));
|
|
head.appendChild(meta);
|
|
}
|
|
if (!page.inModel) head.appendChild(el('p', 'archify-model-problem', 'This decision has lost its model: there is no main.archify in a folder above it, so its elements and diagrams cannot be resolved.'));
|
|
page.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');
|
|
if (d) {
|
|
[['Context', d.context], ['Decision', d.decision], ['Consequences', d.consequences]].forEach(function (pair) {
|
|
if (!pair[1]) return;
|
|
var section = el('section', 'archify-decision-section');
|
|
section.appendChild(el('h2', 'archify-model-title', pair[0]));
|
|
section.appendChild(el('p', 'archify-decision-text', pair[1].trim()));
|
|
body.appendChild(section);
|
|
});
|
|
if (d.supersedes.length || page.supersededBy.length) {
|
|
var rel = el('section', 'archify-decision-section');
|
|
rel.appendChild(el('h2', 'archify-model-title', 'Related decisions'));
|
|
var relList = el('ul', 'archify-model-list archify-decision-list');
|
|
d.supersedes.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'supersedes'), item.firstChild.firstChild); relList.appendChild(item); });
|
|
page.supersededBy.forEach(function (row) { var item = decisionRowItem(row); item.firstChild.insertBefore(el('span', 'archify-model-item-meta', 'superseded by'), item.firstChild.firstChild); relList.appendChild(item); });
|
|
rel.appendChild(relList);
|
|
body.appendChild(rel);
|
|
}
|
|
}
|
|
if (page.elements.length) {
|
|
var elementsPanel = panel('decision:elements', 'archify-model-registry', 'Elements', page.elements.length, true);
|
|
var cards = el('div', 'cards archify-model-cards');
|
|
page.elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
|
elementsPanel.appendChild(cards);
|
|
body.appendChild(elementsPanel);
|
|
}
|
|
var diagramRows = [];
|
|
page.diagrams.forEach(function (use) { diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: 'named by the decision' }); });
|
|
page.referencedBy.forEach(function (use) {
|
|
var how = use.attachedTo.kind === 'node' ? 'on ' + use.attachedTo.id : use.attachedTo.kind === 'edge' ? 'on ' + use.attachedTo.from + ' \u2192 ' + use.attachedTo.to : 'on the diagram';
|
|
var existing = diagramRows.filter(function (r) { return r.path === use.path; })[0];
|
|
if (existing) existing.how += ', ' + how; else diagramRows.push({ path: use.path, title: use.title, kind: use.kind, how: how });
|
|
});
|
|
if (diagramRows.length) {
|
|
var diagramsPanel = panel('decision:diagrams', 'archify-model-diagrams', 'Diagrams', diagramRows.length, true);
|
|
var list = el('ul', 'archify-model-list');
|
|
diagramRows.forEach(function (row) {
|
|
var item = el('li', 'archify-model-item');
|
|
var button = el('button', 'archify-model-open');
|
|
button.type = 'button';
|
|
button.title = row.path;
|
|
button.appendChild(el('span', 'archify-model-item-title', row.title || row.path.split('/').pop()));
|
|
button.appendChild(el('span', 'archify-model-item-meta', (row.kind || 'diagram') + ' \u00b7 ' + row.path + ' \u00b7 ' + row.how));
|
|
button.addEventListener('click', function () { if (page.inModel) post({ type: 'openDiagram', path: row.path }); });
|
|
item.appendChild(button);
|
|
list.appendChild(item);
|
|
});
|
|
diagramsPanel.appendChild(list);
|
|
body.appendChild(diagramsPanel);
|
|
}
|
|
document.body.appendChild(body);
|
|
}
|
|
|
|
// Decisions rail panel on a diagram page: the decisions this diagram points
|
|
// at or that name it. Hover lights up what the decision is attached to.
|
|
function renderDecisionsRail() {
|
|
var rows = config.decisions || [];
|
|
var outline = document.getElementById('node-outline');
|
|
var svg = document.querySelector('.diagram-container > svg');
|
|
if (!rows.length || !outline || !svg || document.getElementById('archify-decisions')) return;
|
|
var nav = el('nav', 'node-outline no-print archify-connectors archify-decisions');
|
|
nav.id = 'archify-decisions';
|
|
var head = el('div', 'node-outline-head');
|
|
head.appendChild(el('strong', '', 'Decisions'));
|
|
head.appendChild(el('span', 'node-outline-count', String(rows.length)));
|
|
nav.appendChild(head);
|
|
var list = el('ol', 'node-outline-list');
|
|
var group = el('li', 'node-outline-group');
|
|
var items = el('ul');
|
|
function targets(row) {
|
|
var found = [];
|
|
row.attachedTo.forEach(function (a) {
|
|
if (a.kind === 'node') Array.prototype.push.apply(found, svg.querySelectorAll('[data-node-id="' + a.id + '"]'));
|
|
if (a.kind === 'edge') Array.prototype.push.apply(found, svg.querySelectorAll('[data-edge-from="' + a.from + '"][data-edge-to="' + a.to + '"]'));
|
|
});
|
|
return found;
|
|
}
|
|
function mark(row, on) { targets(row).forEach(function (t) { if (on) t.setAttribute('data-archify-hit', ''); else t.removeAttribute('data-archify-hit'); }); }
|
|
rows.forEach(function (row) {
|
|
var item = el('li');
|
|
var button = el('button', 'node-outline-item archify-decision-rail-item is-' + row.status);
|
|
button.type = 'button';
|
|
button.style.setProperty('--outline-tone', 'var(--text-muted)');
|
|
var where = row.attachedTo.map(function (a) { return a.kind === 'node' ? a.id : a.kind === 'edge' ? a.from + ' \u2192 ' + a.to : 'diagram'; }).join(', ');
|
|
button.title = row.id + (row.title ? ': ' + row.title : '') + ' (' + row.status + ')' + (where ? ' \u00b7 ' + where : '');
|
|
var dot = el('span', 'node-outline-dot');
|
|
dot.setAttribute('aria-hidden', 'true');
|
|
button.appendChild(dot);
|
|
button.appendChild(el('strong', '', row.id + (row.title ? ' \u00b7 ' + row.title : '')));
|
|
button.appendChild(el('small', '', row.status + (where ? ' \u00b7 ' + where : '')));
|
|
button.addEventListener('pointerenter', function () { mark(row, true); });
|
|
button.addEventListener('pointerleave', function () { mark(row, false); });
|
|
button.addEventListener('focus', function () { mark(row, true); });
|
|
button.addEventListener('blur', function () { mark(row, false); });
|
|
button.addEventListener('click', function () { post({ type: 'openDecision', file: row.file }); });
|
|
item.appendChild(button);
|
|
items.appendChild(item);
|
|
});
|
|
group.appendChild(items);
|
|
list.appendChild(group);
|
|
nav.appendChild(list);
|
|
var connectors = document.getElementById('archify-connectors');
|
|
(connectors || outline).insertAdjacentElement('afterend', nav);
|
|
var archify = window.Archify;
|
|
if (archify && archify.readerLayout && typeof archify.readerLayout.schedule === 'function') archify.readerLayout.schedule();
|
|
}
|
|
|
|
// Elements: registry descriptions as tooltips on Archify's node index rows.
|
|
function describeElements() {
|
|
var items = document.querySelectorAll('#node-outline .node-outline-item[data-outline-node]');
|
|
Array.prototype.forEach.call(items, function (item) {
|
|
var text = config.descriptions[item.getAttribute('data-outline-node')];
|
|
if (text) item.title = text;
|
|
});
|
|
}
|
|
|
|
// 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'; }
|
|
|
|
function el(tag, className, text) {
|
|
var node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
// The model page: metadata first, the diagram (if any) where Archify put
|
|
// it, then the diagrams of the model and the registry.
|
|
function renderModel(model) {
|
|
if (!model) return;
|
|
var anchor = document.querySelector('.container');
|
|
var head = el('section', 'archify-model archify-model-head');
|
|
head.appendChild(el('div', 'archify-model-kicker', 'Model' + (model.meta.id ? ' · ' + model.meta.id : '')));
|
|
head.appendChild(el('h1', 'archify-model-name', model.meta.name || model.meta.id || 'Untitled model'));
|
|
if (model.meta.description) head.appendChild(el('p', 'archify-model-description', model.meta.description.trim()));
|
|
if (model.nested.length) head.appendChild(el('p', 'archify-model-problem', 'Nested main.archify files are not supported and were ignored: ' + model.nested.join(', ')));
|
|
model.problems.forEach(function (problem) { head.appendChild(el('p', 'archify-model-problem', problem)); });
|
|
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(head, anchor); else document.body.appendChild(head);
|
|
|
|
var body = el('section', 'archify-model archify-model-body');
|
|
// Live: filled by the host when the model is shared (Archify Sync); hidden otherwise.
|
|
var live = el('div', 'archify-live');
|
|
live.id = 'archify-live';
|
|
live.hidden = true;
|
|
body.appendChild(live);
|
|
var count = model.groups.reduce(function (n, g) { return n + g.diagrams.length; }, 0);
|
|
var diagramsPanel = panel('diagrams', 'archify-model-diagrams', 'Diagrams', count, true);
|
|
body.appendChild(diagramsPanel);
|
|
if (!count) diagramsPanel.appendChild(el('p', 'archify-model-empty', 'No diagrams yet. Add .archify files next to main.archify or in subfolders.'));
|
|
model.groups.forEach(function (group) {
|
|
// Each folder is its own collapsible header; diagrams next to main.archify are listed directly.
|
|
var holder = diagramsPanel;
|
|
if (group.group) {
|
|
holder = panel('folder:' + group.group, 'archify-model-folder', group.group, group.diagrams.length, true);
|
|
diagramsPanel.appendChild(holder);
|
|
}
|
|
var list = el('ul', 'archify-model-list');
|
|
group.diagrams.forEach(function (diagram) {
|
|
var item = el('li', 'archify-model-item' + (diagram.parseError ? ' is-broken' : ''));
|
|
var button = el('button', 'archify-model-open');
|
|
button.type = 'button';
|
|
button.title = diagram.path;
|
|
button.setAttribute('data-path', diagram.path);
|
|
button.appendChild(el('span', 'archify-model-item-title', diagram.title || diagram.path.split('/').pop()));
|
|
button.appendChild(el('span', 'archify-model-item-meta', diagram.parseError ? 'does not parse: ' + diagram.parseError : (diagram.kind || 'diagram') + ' · ' + diagram.path));
|
|
button.addEventListener('click', function () { post({ type: 'openDiagram', path: diagram.path }); });
|
|
item.appendChild(button);
|
|
list.appendChild(item);
|
|
});
|
|
holder.appendChild(list);
|
|
});
|
|
// Registry: one collapsible header per registry file, its elements as cards inside.
|
|
if (model.registry.length) {
|
|
var files = [];
|
|
var byFile = {};
|
|
model.registry.forEach(function (element) {
|
|
if (!byFile[element.file]) { byFile[element.file] = []; files.push(element.file); }
|
|
byFile[element.file].push(element);
|
|
});
|
|
files.sort();
|
|
var registryPanel = panel('registry', 'archify-model-registry', 'Registries', files.length + (files.length === 1 ? ' file' : ' files'), true);
|
|
body.appendChild(registryPanel);
|
|
files.forEach(function (file) {
|
|
var elements = byFile[file].slice().sort(function (a, b) { return a.line - b.line; });
|
|
var filePanel = panel('registry:' + file, 'archify-model-folder archify-model-registry-file', file.replace(/\.archify-registry$/, ''), elements.length + (elements.length === 1 ? ' element' : ' elements'), false);
|
|
var open = el('button', 'archify-model-used-link archify-model-open-file', 'Open');
|
|
open.type = 'button';
|
|
open.title = 'Open ' + file;
|
|
open.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'openRegistry', file: file, line: 0 }); });
|
|
filePanel.firstChild.appendChild(open);
|
|
var cards = el('div', 'cards archify-model-cards');
|
|
elements.forEach(function (element) { cards.appendChild(elementCard(element)); });
|
|
filePanel.appendChild(cards);
|
|
registryPanel.appendChild(filePanel);
|
|
});
|
|
}
|
|
// Decisions: one list, live ones first, superseded and rejected dimmed and last.
|
|
if (model.decisions && model.decisions.length) {
|
|
var decisionsPanel = panel('decisions', 'archify-model-decisions', 'Decisions', model.decisions.length, true);
|
|
body.appendChild(decisionsPanel);
|
|
var decisionList = el('ul', 'archify-model-list archify-decision-list');
|
|
model.decisions.forEach(function (row) { decisionList.appendChild(decisionRowItem(row)); });
|
|
decisionsPanel.appendChild(decisionList);
|
|
}
|
|
if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(body, anchor.nextSibling); else document.body.appendChild(body);
|
|
post({ type: 'liveReady' });
|
|
}
|
|
|
|
var LIVE_STATE = {
|
|
online: 'Live',
|
|
connecting: 'Connecting\u2026',
|
|
offline: 'Offline \u00b7 edits are kept here and sent when the server is back',
|
|
'signed-out': 'Signed out \u00b7 edits are kept here until you sign in',
|
|
forbidden: 'No access \u00b7 you are no longer a member of this model',
|
|
outdated: 'The server needs a newer extension',
|
|
};
|
|
var KIND_LABEL = { snapshot: 'snapshot', publish: 'published', restore: 'restored' };
|
|
|
|
function liveButton(label, title, action) {
|
|
var button = el('button', 'archify-model-used-link archify-live-action', label);
|
|
button.type = 'button';
|
|
button.title = title;
|
|
button.addEventListener('click', function (event) { event.preventDefault(); event.stopPropagation(); post({ type: 'liveAction', action: action }); });
|
|
return button;
|
|
}
|
|
|
|
function when(iso) {
|
|
var date = new Date(iso);
|
|
if (isNaN(date.getTime())) return '';
|
|
var seconds = (Date.now() - date.getTime()) / 1000;
|
|
if (seconds < 60) return 'just now';
|
|
if (seconds < 3600) return Math.floor(seconds / 60) + ' min ago';
|
|
if (seconds < 86400) return Math.floor(seconds / 3600) + ' h ago';
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
// The Live section of a shared model: who is here, the state, and versions.
|
|
function renderLive(info) {
|
|
var holder = document.getElementById('archify-live');
|
|
if (!holder) return;
|
|
holder.textContent = '';
|
|
document.querySelectorAll('.archify-live-here').forEach(function (n) { n.remove(); });
|
|
if (!info) { holder.hidden = true; return; }
|
|
holder.hidden = false;
|
|
var others = info.people.filter(function (p) { return !p.me; });
|
|
var livePanel = panel('live', 'archify-model-live is-' + info.state, 'Shared \u00b7 ' + info.name, others.length ? others.length + 1 + ' here' : info.host, true);
|
|
var status = el('div', 'archify-live-status');
|
|
status.appendChild(el('span', 'archify-live-dot'));
|
|
status.appendChild(el('span', 'archify-live-state', (LIVE_STATE[info.state] || info.state) + (info.state === 'online' ? ' on ' + info.host : '')));
|
|
if (info.state === 'signed-out') status.appendChild(liveButton('Sign in', 'Sign in to ' + info.host, { kind: 'signIn' }));
|
|
var actions = el('span', 'archify-live-actions');
|
|
actions.appendChild(liveButton('Publish version', 'Name the current state as a version', { kind: 'publish' }));
|
|
actions.appendChild(liveButton('Copy invite link', 'Copy a link that lets someone join this model', { kind: 'invite' }));
|
|
status.appendChild(actions);
|
|
livePanel.appendChild(status);
|
|
|
|
var people = el('ul', 'archify-live-people');
|
|
info.people.forEach(function (person) {
|
|
var item = el('li', 'archify-live-person' + (person.me ? ' is-me' : ''));
|
|
item.appendChild(el('strong', '', person.me ? person.name + ' (you)' : person.name));
|
|
if (person.file) item.appendChild(el('small', '', person.file));
|
|
people.appendChild(item);
|
|
});
|
|
livePanel.appendChild(people);
|
|
|
|
// Who is in which diagram, on the diagram rows.
|
|
others.forEach(function (person) {
|
|
if (!person.file) return;
|
|
document.querySelectorAll('.archify-model-open[data-path]').forEach(function (button) {
|
|
if (button.getAttribute('data-path') !== person.file) return;
|
|
var meta = button.querySelector('.archify-live-here');
|
|
if (!meta) { meta = el('span', 'archify-live-here', ''); button.appendChild(meta); }
|
|
meta.textContent = (meta.textContent ? meta.textContent.replace(/ here$/, '') + ', ' : '') + person.name + ' here';
|
|
});
|
|
});
|
|
|
|
var versions = info.versions || [];
|
|
var head = el('div', 'archify-live-versions-head');
|
|
head.appendChild(el('span', 'archify-model-summary-title', 'Versions'));
|
|
if (versions.length > 8) head.appendChild(liveButton('All ' + versions.length + '\u2026', 'Pick from every version', { kind: 'versions' }));
|
|
livePanel.appendChild(head);
|
|
if (info.versionsProblem) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', info.versionsProblem));
|
|
else if (!versions.length) livePanel.appendChild(el('p', 'archify-model-empty archify-live-note', 'No versions yet. One is taken on its own after a pause in editing.'));
|
|
var list = el('ul', 'archify-live-versions');
|
|
versions.slice(0, 8).forEach(function (v) {
|
|
var item = el('li', 'archify-live-version is-' + v.kind);
|
|
item.appendChild(el('strong', 'archify-live-version-label', v.label));
|
|
item.appendChild(el('span', 'archify-decision-status archify-live-kind', KIND_LABEL[v.kind] + (v.restoredFrom ? ' ' + v.restoredFrom : '')));
|
|
if (v.name) item.appendChild(el('span', 'archify-live-version-name', v.name));
|
|
item.appendChild(el('small', 'archify-live-version-when', when(v.at)));
|
|
var buttons = el('span', 'archify-live-actions');
|
|
buttons.appendChild(liveButton('Compare', 'Compare ' + v.label + ' with the files here', { kind: 'compare', version: v.id }));
|
|
buttons.appendChild(liveButton('Restore', 'Bring back ' + v.label + ' for everyone, as a new version', { kind: 'restore', version: v.id }));
|
|
item.appendChild(buttons);
|
|
list.appendChild(item);
|
|
});
|
|
livePanel.appendChild(list);
|
|
holder.appendChild(livePanel);
|
|
}
|
|
|
|
// What the page shows: Diagram | Graph | Source (Model | Graph | Source on
|
|
// main.archify, View | Source on registry and decision pages). It sits
|
|
// left of Export in Archify's toolbar, after 2D | 3D, which only says how
|
|
// the picture is drawn; on our own pages it sits top right. Graph opens the
|
|
// model's Graph page; Source puts the file's YAML in place of the picture.
|
|
var sourceOn = false;
|
|
var syncModeSwitch = function () {};
|
|
function renderModeSwitch() {
|
|
var sw = config.graphSwitch;
|
|
var src = config.source;
|
|
if ((!sw && !src) || document.getElementById('archify-mode-switch')) return;
|
|
var group = el('div', 'archify-mode-switch');
|
|
group.id = 'archify-mode-switch';
|
|
group.setAttribute('role', 'group');
|
|
group.setAttribute('aria-label', 'View');
|
|
function segment(label, title, action) {
|
|
var button = el('button', 'archify-mode-seg', label);
|
|
button.type = 'button';
|
|
button.title = title;
|
|
button.addEventListener('click', function (event) { event.stopPropagation(); action(); });
|
|
group.appendChild(button);
|
|
return button;
|
|
}
|
|
var name = (src && src.left) || sw.left;
|
|
var left = segment(name, name === 'View' ? 'Show the page' : 'Show the ' + name.toLowerCase(), function () { setSource(false); });
|
|
if (sw) segment('Graph', 'Open the model as one graph, with a query box', function () {
|
|
// On main.archify the Graph page replaces this one; coming back must
|
|
// show the model, not Source. Other pages open the graph beside them.
|
|
if (sourceOn && config.model) vscode.setState(Object.assign({}, vscode.getState() || {}, { sourceView: false }));
|
|
post({ type: 'showGraph', query: sw.query });
|
|
});
|
|
var source = src ? segment('Source', 'Edit the YAML of this file, checked as you type', function () { setSource(true); }) : null;
|
|
if (source) {
|
|
var badge = el('span', 'archify-source-badge');
|
|
badge.id = 'archify-source-badge';
|
|
badge.hidden = true;
|
|
source.appendChild(badge);
|
|
}
|
|
syncModeSwitch = function () {
|
|
left.classList.toggle('on', !sourceOn);
|
|
left.setAttribute('aria-pressed', String(!sourceOn));
|
|
if (source) {
|
|
source.classList.toggle('on', sourceOn);
|
|
source.setAttribute('aria-pressed', String(sourceOn));
|
|
}
|
|
};
|
|
syncModeSwitch();
|
|
var exportWrap = document.querySelector('.toolbar .export-wrap');
|
|
var head = document.querySelector('.archify-model-head');
|
|
if (exportWrap && exportWrap.parentNode) exportWrap.parentNode.insertBefore(group, exportWrap);
|
|
else {
|
|
var row = el('div', 'archify-model-switch-row');
|
|
row.appendChild(group);
|
|
if (head) head.insertBefore(row, head.firstChild);
|
|
else document.body.insertBefore(row, document.body.firstChild);
|
|
}
|
|
}
|
|
|
|
// The Source view lives in dist/source.js, loaded the first time it is chosen.
|
|
function setSource(on) {
|
|
if (!config.source || on === sourceOn) return;
|
|
sourceOn = on;
|
|
vscode.setState(Object.assign({}, vscode.getState() || {}, { sourceView: on }));
|
|
syncModeSwitch();
|
|
if (!on) {
|
|
if (window.__archifySource) window.__archifySource.close();
|
|
return;
|
|
}
|
|
if (window.__archifySource) {
|
|
window.__archifySource.open();
|
|
return;
|
|
}
|
|
var script = document.createElement('script');
|
|
script.src = config.source.scriptUri;
|
|
script.onload = function () { if (sourceOn && window.__archifySource) window.__archifySource.open(); };
|
|
script.onerror = function () {
|
|
sourceOn = false;
|
|
syncModeSwitch();
|
|
post({ type: 'error', message: 'The Source view could not be loaded.' });
|
|
};
|
|
document.head.appendChild(script);
|
|
}
|
|
|
|
function showProblemCount(count) {
|
|
var badge = document.getElementById('archify-source-badge');
|
|
if (!badge) return;
|
|
badge.hidden = !count;
|
|
badge.textContent = count ? String(count) : '';
|
|
badge.title = count ? count + (count === 1 ? ' problem' : ' problems') + ' in the source' : '';
|
|
}
|
|
|
|
window.addEventListener('message', function (event) {
|
|
var message = event.data || {};
|
|
if (message.type === 'notice') showNotice(message.notice);
|
|
if (message.type === 'sourceDiagnostics') showProblemCount(message.count);
|
|
if (message.type === 'setView') setSource(message.view === 'source');
|
|
if (message.type === 'live') renderLive(message.live);
|
|
});
|
|
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); });
|
|
if (config.decision) onReady(function () { renderDecision(config.decision); });
|
|
onReady(renderConnectors);
|
|
onReady(renderDecisionsRail);
|
|
onReady(describeElements);
|
|
onReady(renderModeSwitch);
|
|
|
|
// Archify Sync: one icon button, top right, on every page of an Archify
|
|
// file (diagram, model, graph, registry, decision). It opens the Sync menu.
|
|
function renderSyncButton() {
|
|
if (!(config.source || config.model || config.registry || config.decision || config.graph)) return;
|
|
if (document.getElementById('archify-sync-button')) return;
|
|
var style = document.createElement('style');
|
|
style.textContent = SYNC_BUTTON_STYLE;
|
|
document.head.appendChild(style);
|
|
var button = document.createElement('button');
|
|
button.id = 'archify-sync-button';
|
|
button.type = 'button';
|
|
button.title = 'Archify Sync: sign in, share this model with others, or join one';
|
|
button.addEventListener('click', function (event) { event.stopPropagation(); post({ type: 'syncMenu' }); });
|
|
// A diagram page has Archify's toolbar at the top right: the button goes
|
|
// last in it, after Export. Other pages have nothing there; it floats.
|
|
var toolbar = document.querySelector('body > .toolbar');
|
|
if (toolbar) {
|
|
button.classList.add('in-toolbar');
|
|
toolbar.appendChild(button);
|
|
} else {
|
|
document.body.appendChild(button);
|
|
}
|
|
showSyncState(false);
|
|
post({ type: 'syncReady' });
|
|
}
|
|
// Not shared: a cloud. Shared (the model has an .archify-sync file): broadcast waves on green.
|
|
var SYNC_ICONS = {
|
|
off: '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M12.5 13H4.25a3.25 3.25 0 0 1-.6-6.44 4.5 4.5 0 0 1 8.7-.81A3.6 3.6 0 0 1 12.5 13Zm-8.25-1h8.25a2.6 2.6 0 0 0 0-5.2h-.5l-.12-.39a3.5 3.5 0 0 0-6.82.66l-.04.4-.4.05A2.25 2.25 0 0 0 4.25 12Z"/></svg>',
|
|
on: '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><circle cx="8" cy="8" r="1.75" fill="currentColor"/><path fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" d="M5.2 5.2a4 4 0 0 0 0 5.6M10.8 5.2a4 4 0 0 1 0 5.6M3.1 3.1a7 7 0 0 0 0 9.8M12.9 3.1a7 7 0 0 1 0 9.8"/></svg>',
|
|
};
|
|
function showSyncState(shared) {
|
|
var button = document.getElementById('archify-sync-button');
|
|
if (!button) return;
|
|
button.classList.toggle('is-shared', !!shared);
|
|
button.innerHTML = shared ? SYNC_ICONS.on : SYNC_ICONS.off;
|
|
button.title = shared
|
|
? 'Archify Sync: this model is shared. Invite people, publish a version, see versions'
|
|
: 'Archify Sync: sign in, share this model with others, or join one';
|
|
button.setAttribute('aria-label', shared ? 'Archify Sync (shared)' : 'Archify Sync');
|
|
}
|
|
window.addEventListener('message', function (event) {
|
|
var message = event.data || {};
|
|
if (message.type === 'syncState') showSyncState(message.shared);
|
|
});
|
|
// Every rule is !important: the page's own button styles (Archify's toolbar,
|
|
// VS Code's high contrast themes) must not resize or recolour it.
|
|
var SYNC_BUTTON_STYLE = '#archify-sync-button { all: unset; position: fixed !important; top: 12px !important; right: 28px !important; z-index: 1001 !important;'
|
|
+ ' box-sizing: border-box !important; width: 30px !important; height: 30px !important; min-width: 0 !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important;'
|
|
+ ' display: flex !important; align-items: center !important; justify-content: center !important; border-radius: 8px !important; cursor: pointer !important;'
|
|
+ ' color: #fff !important; background: var(--vscode-button-background, #0e639c) !important; border: 1px solid var(--vscode-contrastBorder, transparent) !important;'
|
|
+ ' box-shadow: 0 1px 4px rgba(0,0,0,.35) !important; }'
|
|
+ ' #archify-sync-button.in-toolbar { position: relative !important; top: auto !important; right: auto !important; align-self: center !important; flex: none !important; width: 36px !important; height: 36px !important; }'
|
|
+ ' body.vscode-high-contrast #archify-sync-button { background: #0e639c !important; }'
|
|
+ ' #archify-sync-button svg { display: block !important; width: 16px !important; height: 16px !important; flex: none !important; }'
|
|
+ ' #archify-sync-button:hover { background: var(--vscode-button-hoverBackground, #1177bb) !important; }'
|
|
+ ' #archify-sync-button.is-shared { background: #1f8f5f !important; }'
|
|
+ ' #archify-sync-button.is-shared:hover { background: #23a36c !important; }'
|
|
+ ' #archify-sync-button:focus-visible { outline: 2px solid var(--vscode-focusBorder, #007fd4) !important; outline-offset: 2px !important; }';
|
|
onReady(renderSyncButton);
|
|
if (config.source) {
|
|
// The host answers with the problem count for the Source badge.
|
|
post({ type: 'sourceReady' });
|
|
if ((vscode.getState() || {}).sourceView) onReady(function () { setSource(true); });
|
|
}
|
|
if (config.notice) {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
|
} else {
|
|
showNotice(config.notice);
|
|
}
|
|
}
|
|
})();`;
|
|
}
|
|
|
|
const MODEL_STYLE = `
|
|
.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; }
|
|
/* Under Archify's fixed toolbar (diagram pages of main.archify), start below it. */
|
|
.toolbar ~ .archify-model-head { padding-top: 4.25rem; }
|
|
.archify-model-kicker { font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--m-accent); }
|
|
.archify-model-name { margin: 0.25rem 0 0.5rem; font-size: 1.5rem; font-weight: 700; letter-spacing: -0.02em; }
|
|
.archify-model-description { margin: 0; max-width: 72ch; font-size: 0.9375rem; line-height: 1.55; color: var(--m-muted); white-space: pre-line; }
|
|
.archify-model-problem { margin: 0.75rem 0 0; padding: 0.5rem 0.75rem; border-radius: 0.5rem; border: 1px solid var(--vscode-inputValidation-errorBorder, #be1100); color: var(--m-text); font-size: 0.8125rem; }
|
|
/* Collapsible panels: Diagrams and Registry, and one per folder inside Diagrams. */
|
|
.archify-model-panel { margin: 1rem 0 0; border: 1px solid var(--m-border); border-radius: 0.75rem; background: var(--m-panel); }
|
|
.archify-model-panel[open] { padding-bottom: 0.85rem; }
|
|
.archify-model-summary { display: flex; align-items: center; gap: 0.6rem; padding: 0.75rem 0.9rem; cursor: pointer; list-style: none; user-select: none; font-size: 0.875rem; font-weight: 600; letter-spacing: 0.02em; color: var(--m-text); }
|
|
.archify-model-summary::-webkit-details-marker { display: none; }
|
|
.archify-model-summary:hover { color: var(--m-accent); }
|
|
.archify-model-summary:focus-visible { outline: 2px solid var(--m-accent); outline-offset: -2px; border-radius: 0.75rem; }
|
|
.archify-model-chevron { flex: none; width: 0.5rem; height: 0.5rem; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform 0.15s; opacity: 0.7; margin-left: 0.1rem; }
|
|
.archify-model-panel[open] > .archify-model-summary > .archify-model-chevron { transform: rotate(45deg); }
|
|
.archify-model-count { margin-left: auto; font-size: 0.75rem; font-weight: 500; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; padding: 0.05rem 0.5rem; border: 1px solid var(--m-border); border-radius: 999px; }
|
|
.archify-model-panel > .archify-model-list, .archify-model-panel > .archify-model-cards, .archify-model-panel > .archify-model-empty { margin: 0 0.9rem; }
|
|
.archify-model-folder { margin: 0.35rem 0.9rem 0; background: transparent; border-radius: 0.6rem; }
|
|
.archify-model-folder[open] { padding-bottom: 0.7rem; }
|
|
.archify-model-folder > .archify-model-summary { padding: 0.55rem 0.75rem; font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; }
|
|
.archify-model-folder > .archify-model-summary:hover { color: var(--m-accent); }
|
|
.archify-model-folder > .archify-model-list { margin: 0 0.75rem; }
|
|
.archify-model-registry-file > .archify-model-summary { text-transform: none; letter-spacing: 0; font-size: 0.8125rem; }
|
|
.archify-model-registry-file > .archify-model-cards { margin: 0.25rem 0.75rem 0; }
|
|
.archify-model-open-file { margin-left: 0.5rem; text-transform: none; letter-spacing: 0; font-family: inherit; }
|
|
.archify-model-open-file:hover { color: var(--m-accent); }
|
|
.archify-model-diagrams > .archify-model-list { margin-top: 0.25rem; }
|
|
.archify-model-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 0.5rem; }
|
|
.archify-model-open { display: flex; flex-direction: column; gap: 0.2rem; width: 100%; padding: 0.7rem 0.85rem; text-align: left; cursor: pointer; font: inherit; color: var(--m-text); background: var(--m-panel); border: 1px solid var(--m-border); border-radius: 0.6rem; transition: border-color 0.15s, transform 0.15s; }
|
|
.archify-model-open:hover { border-color: var(--m-accent); transform: translateY(-1px); }
|
|
.archify-model-open:focus-visible { outline: 2px solid var(--m-accent); outline-offset: 2px; }
|
|
.archify-model-item.is-broken .archify-model-open { border-style: dashed; }
|
|
.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)); }
|
|
/* Live: a shared model's state, people and versions. */
|
|
.archify-live-status { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0 0.9rem; font-size: 0.8125rem; }
|
|
.archify-live-dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--m-muted); flex: none; }
|
|
.archify-model-live.is-online .archify-live-dot { background: var(--vscode-testing-iconPassed, #3fb950); }
|
|
.archify-model-live.is-signed-out .archify-live-dot, .archify-model-live.is-forbidden .archify-live-dot, .archify-model-live.is-outdated .archify-live-dot { background: var(--vscode-editorWarning-foreground, #d29922); }
|
|
.archify-live-actions { display: inline-flex; flex-wrap: wrap; gap: 0.35rem; margin-left: auto; }
|
|
.archify-live .archify-live-action { font-family: var(--vscode-font-family, system-ui, sans-serif); }
|
|
.archify-live-people { list-style: none; display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.6rem 0.9rem 0; padding: 0; }
|
|
.archify-live-person { display: inline-flex; align-items: baseline; gap: 0.4rem; padding: 0.2rem 0.6rem; border: 1px solid var(--m-border); border-radius: 999px; font-size: 0.8125rem; }
|
|
.archify-live-person small { color: var(--m-muted); font-family: ui-monospace, Menlo, monospace; font-size: 0.6875rem; }
|
|
.archify-live-person.is-me { opacity: 0.75; }
|
|
.archify-live-here { font-size: 0.6875rem; color: var(--m-accent); font-weight: 600; }
|
|
.archify-live-versions-head { display: flex; align-items: center; gap: 0.5rem; margin: 0.9rem 0.9rem 0.35rem; font-size: 0.8125rem; font-weight: 600; }
|
|
.archify-live-note { margin: 0 0.9rem; font-size: 0.8125rem; }
|
|
.archify-live-versions { list-style: none; margin: 0 0.9rem; padding: 0; display: grid; gap: 0.3rem; }
|
|
.archify-live-version { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; padding: 0.4rem 0.7rem; border: 1px solid var(--m-border); border-radius: 0.5rem; font-size: 0.8125rem; }
|
|
.archify-live-version-label { font-family: ui-monospace, Menlo, monospace; }
|
|
.archify-live-version.is-publish .archify-live-version-label { color: var(--m-accent); }
|
|
.archify-live-version-name { font-weight: 600; }
|
|
.archify-live-version-when { color: var(--m-muted); }
|
|
/* Decisions: rows, chips and status badges. */
|
|
.archify-decision-list { grid-template-columns: 1fr; gap: 0.35rem; }
|
|
.archify-decision-open { padding: 0.55rem 0.85rem; }
|
|
.archify-decision-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem; }
|
|
.archify-decision-id { font-family: ui-monospace, Menlo, monospace; font-size: 0.8125rem; color: var(--m-muted); }
|
|
.archify-decision-status { font-size: 0.6875rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; padding: 0.05rem 0.45rem; border-radius: 999px; border: 1px solid var(--m-border); color: var(--m-muted); }
|
|
.archify-decision-status.is-accepted { color: var(--m-backend); border-color: var(--m-backend); }
|
|
.archify-decision-status.is-proposed { color: var(--m-cloud); border-color: var(--m-cloud); }
|
|
.archify-decision-status.is-rejected { color: var(--m-security); border-color: var(--m-security); }
|
|
.archify-decision-item.is-superseded .archify-model-open, .archify-decision-item.is-rejected .archify-model-open { opacity: 0.6; }
|
|
.archify-decision-chip { display: inline-flex; align-items: center; gap: 0.35rem; font-family: inherit; }
|
|
.archify-decision-dot { width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--m-muted); }
|
|
.archify-decision-chip.is-accepted .archify-decision-dot { background: var(--m-backend); }
|
|
.archify-decision-chip.is-proposed .archify-decision-dot { background: var(--m-cloud); }
|
|
.archify-decision-chip.is-rejected .archify-decision-dot { background: var(--m-security); }
|
|
.archify-decision-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin: 0.4rem 0 0; }
|
|
.archify-decision-section { margin-top: 0.5rem; }
|
|
.archify-decision-text { margin: 0; max-width: 72ch; font-size: 0.9375rem; line-height: 1.6; color: var(--m-text); white-space: pre-line; }
|
|
/* 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; }
|
|
.archify-model-card { background: var(--m-panel); border: 1px solid var(--m-border); border-radius: 0.75rem; padding: 1.125rem 1.25rem; }
|
|
.archify-model-card .card-header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; }
|
|
.archify-model-card .card-dot { flex: none; width: 10px; height: 10px; border-radius: 50%; }
|
|
.archify-model-card .card-dot.cyan { background: var(--m-frontend); } .archify-model-card .card-dot.emerald { background: var(--m-backend); } .archify-model-card .card-dot.violet { background: var(--m-database); } .archify-model-card .card-dot.amber { background: var(--m-cloud); } .archify-model-card .card-dot.rose { background: var(--m-security); } .archify-model-card .card-dot.orange { background: var(--m-messagebus); } .archify-model-card .card-dot.slate { background: var(--m-external); }
|
|
.archify-model-card h3 { margin: 0; font-size: 0.9375rem; font-weight: 600; line-height: 1.4; color: var(--m-text); }
|
|
.archify-model-card ul { list-style: none; margin: 0; padding: 0; color: var(--m-muted); font-size: 0.8125rem; line-height: 1.6; }
|
|
.archify-model-card li { margin-bottom: 0.3rem; }
|
|
.archify-model-card li.archify-model-card-id, .archify-model-card li.archify-model-card-used { padding-left: 0; }
|
|
.archify-model-card li.archify-model-card-id::before, .archify-model-card li.archify-model-card-used::before { display: none; }
|
|
.archify-model-card-id { font-family: ui-monospace, Menlo, monospace; font-size: 0.75rem; }
|
|
.archify-model-card-used { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.5rem; }
|
|
.archify-model-used-link { font: inherit; font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 999px; border: 1px solid var(--m-border); background: transparent; color: var(--m-text); cursor: pointer; font-family: ui-monospace, Menlo, monospace; }
|
|
.archify-model-used-link:hover { border-color: var(--m-accent); }
|
|
`;
|
|
|
|
/* The Model | Graph switch: in Archify's toolbar it is the 2D | 3D switch's twin; on the model page it sits top right. */
|
|
const SWITCH_STYLE = `
|
|
.archify-mode-switch { display: inline-flex; align-items: center; padding: 0 0.25rem; gap: 0; color: var(--text-muted, var(--vscode-descriptionForeground)); position: relative; }
|
|
.toolbar .archify-mode-switch::before { content: ""; position: absolute; inset: 0.25rem 0; border-radius: 0.6875rem; background: color-mix(in srgb, var(--panel) 78%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--panel-border) 85%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); backdrop-filter: blur(12px); }
|
|
.toolbar .archify-mode-switch:hover::before { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 22%, transparent), 0 1px 2px rgba(15, 23, 42, 0.04); }
|
|
/* Anchored on the id: Archify's .toolbar button rules (one of them with :not(#btn-theme)) would win otherwise. */
|
|
#archify-mode-switch .archify-mode-seg { position: relative; display: inline-flex; align-items: center; height: 1.75rem; padding: 0 0.7rem; border: 0; border-radius: 0.5rem; background: transparent; color: inherit; font: inherit; font-weight: 550; font-size: 0.8125rem; cursor: pointer; transition: background 0.15s, color 0.15s; }
|
|
#archify-mode-switch .archify-mode-seg.on[aria-pressed="true"] { color: var(--bg, var(--vscode-editor-background)); background: var(--text, var(--vscode-foreground)); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 6px 16px -8px rgba(15, 23, 42, 0.4); }
|
|
#archify-mode-switch .archify-mode-seg:hover:not(.on) { color: var(--text, var(--vscode-foreground)); }
|
|
#archify-mode-switch .archify-mode-seg:focus-visible { outline: 2px solid var(--frontend-stroke, var(--vscode-focusBorder)); outline-offset: 1px; }
|
|
.archify-source-badge { display: inline-block; min-width: 1rem; margin-left: 0.35rem; padding: 0 0.3rem; border-radius: 999px; background: var(--security-stroke, var(--vscode-editorError-foreground, #f14c4c)); color: var(--bg, #fff); font-size: 0.6875rem; font-weight: 700; line-height: 1rem; text-align: center; }
|
|
.archify-source-badge[hidden] { display: none; }
|
|
.archify-model-switch-row { float: right; margin: 1.5rem 0 0 1rem; }
|
|
body > .archify-model-switch-row { position: relative; z-index: 2; margin: 1rem 1.25rem 0 1rem; }
|
|
.archify-model-switch-row .archify-mode-switch { border: 1px solid var(--m-border, var(--vscode-editorWidget-border, rgba(128,128,128,.35))); border-radius: 0.75rem; background: var(--m-panel, var(--vscode-editorWidget-background)); padding: 0.1rem 0.25rem; }
|
|
`;
|
|
|
|
/* 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; }
|
|
.diagram-container svg [data-node-id][data-archify-hit] { filter: drop-shadow(0 0 6px var(--frontend-stroke)); }
|
|
/* Bottom rail with a Decisions panel: three columns. */
|
|
html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions) { grid-template-areas: "cards cards cards" "index connectors decisions"; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
html[data-reader-rail="bottom"] .reader-rail:has(> #archify-decisions):has(> #node-outline[hidden]) { grid-template-areas: "cards cards" "connectors decisions"; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions { display: block; grid-area: decisions; 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-decisions .node-outline-head { padding: 1rem 0.25rem 0.375rem; }
|
|
html[data-reader-rail="bottom"] .reader-rail > #archify-decisions .node-outline-list { columns: 15rem; column-gap: 1.75rem; padding: 0; overflow: visible; -webkit-mask-image: none; mask-image: none; }
|
|
.archify-decision-rail-item.is-superseded, .archify-decision-rail-item.is-rejected { opacity: 0.6; }
|
|
/* 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;
|
|
display: flex; gap: 12px; align-items: flex-start; justify-content: space-between;
|
|
padding: 8px 12px; border-radius: 6px; font: 12px/1.45 var(--vscode-font-family, system-ui, sans-serif);
|
|
color: var(--vscode-editorWidget-foreground, #ddd); background: var(--vscode-editorWidget-background, #252526);
|
|
border: 1px solid var(--vscode-editorWidget-border, #454545); box-shadow: 0 4px 16px rgba(0,0,0,.35);
|
|
white-space: pre-wrap;
|
|
}
|
|
.archify-vscode-notice-warning { border-left: 4px solid var(--vscode-editorWarning-foreground, #cca700); }
|
|
.archify-vscode-notice-error { border-left: 4px solid var(--vscode-editorError-foreground, #f14c4c); }
|
|
.archify-vscode-notice-info { border-left: 4px solid var(--vscode-editorInfo-foreground, #3794ff); }
|
|
.archify-vscode-notice button {
|
|
background: none; border: 0; color: inherit; cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px;
|
|
}`;
|
|
|
|
/** Prepares Archify HTML for a VS Code webview. */
|
|
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}${CONNECTOR_STYLE}${SWITCH_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.
|
|
.replace(/<script\b(?![^>]*\bnonce=)/gi, `<script nonce="${options.nonce}"`);
|
|
|
|
if (options.viewer3d) {
|
|
const tag = `<script nonce="${options.nonce}" defer src="${escapeHtml(options.viewer3d.scriptUri)}"></script>\n`;
|
|
page = /<\/head>/i.test(page) ? page.replace(/<\/head>/i, `${tag}</head>`) : `${tag}${page}`;
|
|
}
|
|
|
|
const headMatch = /<head\b[^>]*>/i.exec(page);
|
|
if (headMatch) {
|
|
const at = headMatch.index + headMatch[0].length;
|
|
page = `${page.slice(0, at)}\n${head}${page.slice(at)}`;
|
|
} else {
|
|
page = `<!DOCTYPE html><html><head>${head}</head><body>${page}</body></html>`;
|
|
}
|
|
return page;
|
|
}
|
|
|
|
/** The model page for a main.archify without a diagram of its own. */
|
|
export function modelPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state' | 'source'>, model: ModelPanel): string {
|
|
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, model, links: {}, graphSwitch: { left: 'Model' }, source: options.source });
|
|
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}
|
|
${SWITCH_STYLE}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>`;
|
|
}
|
|
|
|
/** The Graph page of a model: the bundled script builds everything from the host's graph data. */
|
|
export function graphPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>, graph: NonNullable<PageOptions['graph']>): string {
|
|
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, links: {}, graph });
|
|
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>
|
|
<script nonce="${options.nonce}" defer src="${escapeHtml(graph.scriptUri)}"></script>
|
|
<style>
|
|
html, body { margin: 0; height: 100%; color: var(--vscode-foreground); background: var(--vscode-editor-background); }
|
|
${NOTICE_STYLE}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>`;
|
|
}
|
|
|
|
/** The registry page for one .archify-registry file. */
|
|
export function registryPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state' | 'source'>, registry: RegistryPanel): string {
|
|
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, registry, links: {}, source: options.source });
|
|
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}
|
|
${SWITCH_STYLE}
|
|
${NOTICE_STYLE}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>`;
|
|
}
|
|
|
|
/** The decision page for one .archify-decision file. */
|
|
export function decisionPage(options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state' | 'source'>, decision: DecisionPanel): string {
|
|
const bridge = bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, decision, links: {}, source: options.source });
|
|
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}
|
|
${SWITCH_STYLE}
|
|
${NOTICE_STYLE}
|
|
</style>
|
|
</head>
|
|
<body></body>
|
|
</html>`;
|
|
}
|
|
|
|
/** A small VS Code-styled page for loading and error states; with `source`, the YAML can be fixed in place. */
|
|
export function messagePage(
|
|
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state' | 'source'>,
|
|
title: string,
|
|
body: string,
|
|
detail?: string,
|
|
): string {
|
|
const script = options.source
|
|
? bridgeScript({ cspSource: options.cspSource, nonce: options.nonce, theme: undefined, state: options.state, links: {}, source: options.source })
|
|
: `(function () { var vscode = acquireVsCodeApi(); var state = ${scriptJson(options.state)}; if (state) vscode.setState(state); })();`;
|
|
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}">${script}</script>
|
|
<style>
|
|
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 24px 28px; }
|
|
${options.source ? SWITCH_STYLE + NOTICE_STYLE : ''}
|
|
h2 { font-weight: 600; font-size: 15px; margin: 0 0 8px; }
|
|
p { margin: 0 0 12px; color: var(--vscode-descriptionForeground); }
|
|
pre { font-family: var(--vscode-editor-font-family); font-size: 12px; white-space: pre-wrap; word-break: break-word;
|
|
background: var(--vscode-textCodeBlock-background); padding: 12px 14px; border-radius: 4px;
|
|
border-left: 3px solid var(--vscode-editorError-foreground); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h2>${escapeHtml(title)}</h2>
|
|
<p>${escapeHtml(body)}</p>
|
|
${detail ? `<pre>${escapeHtml(detail)}</pre>` : ''}
|
|
</body>
|
|
</html>`;
|
|
}
|