Add a 3D view of every diagram; organise source by job; 0.6.0
3D view: a "3D" menu beside Export switches the diagram to a three.js scene built from the live Archify SVG. Nodes become blocks topped with the node as Archify draws it, relationships become lit pipes along Archify's routes, and the canvas, frames, lanes and legend form the floor. Clicks and hover go to the SVG nodes, so focus, the Semantic Passport and the intent trace work as in 2D; Archify's own animations (trace, node pulse, passport/path/lens flows) are mirrored live. The menu also offers flat or stacked levels, reset view and a top view; Export gains "3D view" (PNG). Trackpad scrolling pans, pinch and the mouse wheel zoom, and the zoom buttons work beyond the 2D limits. The scene draws only when something moves. New setting archify.defaultView. Source: src/ is split by job (commands, views, webview, archify, sources, language, util, viewer3d); behaviour unchanged.
This commit is contained in:
1 parent
20786f4b5a
commit
97b3b5f685
40 files changed
+2553
-353
No files matched your search
@@ -0,0 +1,101 @@
|
||||
import path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { repoRootFor } from '../archify/runtime';
|
||||
import { log } from '../util/log';
|
||||
|
||||
export type WebviewMessage =
|
||||
| { type: 'hash'; hash: string }
|
||||
| { type: 'save'; filename: string; base64: string }
|
||||
| { type: 'openExternal'; href: string }
|
||||
| { type: 'openRelative'; href: string }
|
||||
| { type: 'openSource'; path: string; line?: number; endLine?: number; href?: string }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Column for opening files next to a webview without covering it. */
|
||||
function besideColumn(panel: vscode.WebviewPanel): vscode.ViewColumn {
|
||||
return panel.viewColumn === vscode.ViewColumn.One ? vscode.ViewColumn.Two : vscode.ViewColumn.One;
|
||||
}
|
||||
|
||||
async function exists(uri: vscode.Uri): Promise<boolean> {
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a diagram's source evidence in the editor. The path is relative to
|
||||
* the repository root; the diagram's checkout and the workspace folders are
|
||||
* tried in turn before falling back to the web link.
|
||||
*/
|
||||
async function openSource(
|
||||
message: Extract<WebviewMessage, { type: 'openSource' }>,
|
||||
context: vscode.Uri,
|
||||
panel: vscode.WebviewPanel,
|
||||
): Promise<void> {
|
||||
const relative = message.path.replace(/^\/+/, '');
|
||||
const roots = new Set<string>();
|
||||
const repoRoot = await repoRootFor(context);
|
||||
if (repoRoot) roots.add(repoRoot);
|
||||
for (const folder of vscode.workspace.workspaceFolders ?? []) roots.add(folder.uri.fsPath);
|
||||
|
||||
for (const root of roots) {
|
||||
const candidate = vscode.Uri.file(path.join(root, relative));
|
||||
if (relative && (await exists(candidate))) {
|
||||
const start = Math.max((message.line ?? 1) - 1, 0);
|
||||
const end = Math.max((message.endLine ?? message.line ?? 1) - 1, start);
|
||||
const selection = new vscode.Range(start, 0, end, Number.MAX_SAFE_INTEGER);
|
||||
await vscode.window.showTextDocument(candidate, { viewColumn: besideColumn(panel), selection });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (message.href && /^https?:/i.test(message.href)) {
|
||||
await vscode.env.openExternal(vscode.Uri.parse(message.href));
|
||||
return;
|
||||
}
|
||||
vscode.window.showWarningMessage(`Archify: source file "${message.path}" was not found in the workspace.`);
|
||||
}
|
||||
|
||||
export async function handleWebviewMessage(
|
||||
message: WebviewMessage,
|
||||
context: vscode.Uri,
|
||||
panel: vscode.WebviewPanel,
|
||||
): Promise<void> {
|
||||
try {
|
||||
switch (message.type) {
|
||||
case 'openExternal':
|
||||
await vscode.env.openExternal(vscode.Uri.parse(message.href));
|
||||
break;
|
||||
case 'openRelative': {
|
||||
const [file] = message.href.split('#');
|
||||
if (!file) break;
|
||||
const target = vscode.Uri.joinPath(context, '..', decodeURIComponent(file));
|
||||
await vscode.commands.executeCommand('vscode.open', target, besideColumn(panel));
|
||||
break;
|
||||
}
|
||||
case 'openSource':
|
||||
await openSource(message, context, panel);
|
||||
break;
|
||||
case 'save': {
|
||||
const name = path.basename(message.filename || 'archify-export');
|
||||
const defaultUri = vscode.Uri.joinPath(context, '..', name);
|
||||
const target = await vscode.window.showSaveDialog({ defaultUri, saveLabel: 'Export' });
|
||||
if (!target) break;
|
||||
await vscode.workspace.fs.writeFile(target, Buffer.from(message.base64, 'base64'));
|
||||
const choice = await vscode.window.showInformationMessage(`Exported ${path.basename(target.fsPath)}`, 'Reveal');
|
||||
if (choice === 'Reveal') await vscode.commands.executeCommand('revealFileInOS', target);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
vscode.window.showErrorMessage(`Archify: ${message.message}`);
|
||||
break;
|
||||
case 'hash':
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
log().error(error instanceof Error ? error : String(error));
|
||||
vscode.window.showErrorMessage(`Archify: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
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' };
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
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)); }
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
window.addEventListener('message', function (event) {
|
||||
var message = event.data || {};
|
||||
if (message.type === 'notice') showNotice(message.notice);
|
||||
});
|
||||
if (config.notice) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () { showNotice(config.notice); });
|
||||
} else {
|
||||
showNotice(config.notice);
|
||||
}
|
||||
}
|
||||
})();`;
|
||||
}
|
||||
|
||||
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}</style>\n`;
|
||||
|
||||
let page = html
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** A small VS Code-styled page for loading and error states. */
|
||||
export function messagePage(
|
||||
options: Pick<PageOptions, 'cspSource' | 'nonce' | 'state'>,
|
||||
title: string,
|
||||
body: string,
|
||||
detail?: string,
|
||||
): string {
|
||||
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}">(function () { var vscode = acquireVsCodeApi(); var state = ${scriptJson(options.state)}; if (state) vscode.setState(state); })();</script>
|
||||
<style>
|
||||
body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 24px 28px; }
|
||||
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>`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/** Folder of the bundled webview scripts; webviews may load files from it. */
|
||||
export function viewerRoots(extensionUri: vscode.Uri): vscode.Uri[] {
|
||||
return [vscode.Uri.joinPath(extensionUri, 'dist')];
|
||||
}
|
||||
|
||||
/** Page option that adds the 3D viewer to an Archify page. */
|
||||
export function viewer3dOption(webview: vscode.Webview, extensionUri: vscode.Uri): { scriptUri: string; defaultView: '2d' | '3d' } {
|
||||
const defaultView = vscode.workspace.getConfiguration('archify').get<string>('defaultView', '2d') === '3d' ? '3d' : '2d';
|
||||
return {
|
||||
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'viewer3d.js')).toString(),
|
||||
defaultView,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user