Add New Diagram command with starter templates

- Archify: New Diagram… (Explorer folder menu, File > New File…, Command
  Palette) creates a .archify file from a template and opens the diagram
- Six YAML templates: architecture, workflow, sequence, dataflow,
  lifecycle and an everyday plan; connections carry ids, workflows use
  schema 2
- Title and meta.output follow the file name; template comments are kept
- Tests: every template passes archify validate; integration check for
  the command

Closes #2
This commit is contained in:
2026-10-04 21:33:11 +03:00
parent d2f52d43ac
commit 9dc1c5acc5
13 changed files with 582 additions and 1 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog # Changelog
## Unreleased
- **New Diagram…** (Explorer folder menu, *File → New File…*, Command Palette): pick Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan, enter a name, and a small valid `.archify` (YAML) file is created from a template and opened as a diagram. Title and output file follow the name.
## 0.3.0 — 2026-09-30 ## 0.3.0 — 2026-09-30
- **`.archify` files are YAML.** They are parsed as YAML and handed to the renderer as JSON. Older JSON `.archify` files still open, since JSON is valid YAML. - **`.archify` files are YAML.** They are parsed as YAML and handed to the renderer as JSON. Older JSON `.archify` files still open, since JSON is valid YAML.
+6 -1
View File
@@ -8,6 +8,10 @@ This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i
## Quick start ## Quick start
The fastest start: right-click a folder in the Explorer and choose **New Archify Diagram…** (also in *File → New File…* and the Command Palette). Pick a kind of diagram (Architecture, Workflow, Sequence, Data Flow, Lifecycle or Everyday Plan), give it a name, and a small working `.archify` file is created and opened. Change its YAML to fit your case.
To write one by hand:
1. Create a file ending in `.archify`, for example `hello.archify`: 1. Create a file ending in `.archify`, for example `hello.archify`:
```yaml ```yaml
@@ -41,6 +45,7 @@ Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so
## Features ## Features
- **New diagram from a template**: *Archify: New Diagram…* creates a starter `.archify` file for any of the five diagram kinds, or an everyday plan, in the chosen folder.
- **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live. - **Diagram editor**: `.archify` files open as the rendered diagram. **Show Source** opens the YAML beside it, and edits re-render live.
- **Live preview**: for Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, <kbd>Cmd/Ctrl+K V</kbd> (to the side) or <kbd>Cmd/Ctrl+Shift+V</kbd>. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders. - **Live preview**: for Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, <kbd>Cmd/Ctrl+K V</kbd> (to the side) or <kbd>Cmd/Ctrl+Shift+V</kbd>. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders.
- **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk. - **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk.
@@ -48,7 +53,7 @@ Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so
- **Schema help**: completion and hover come from Archify's own schemas: built in for the JSON files, and through the YAML extension for `.archify` files. - **Schema help**: completion and hover come from Archify's own schemas: built in for the JSON files, and through the YAML extension for `.archify` files.
- **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead. - **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead.
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog. - **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
- **Commands**: *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*. - **Commands**: *Archify: New Diagram…*, *Archify: Render to HTML File…*, *Archify: Open in Browser*, *Archify: Refresh Preview*, *Archify: Show Source*.
## Source evidence ## Source evidence
+17
View File
@@ -38,6 +38,12 @@
], ],
"contributes": { "contributes": {
"commands": [ "commands": [
{
"command": "archify.newDiagram",
"title": "New Diagram\u2026",
"category": "Archify",
"icon": "$(new-file)"
},
{ {
"command": "archify.showPreview", "command": "archify.showPreview",
"title": "Open Preview", "title": "Open Preview",
@@ -145,6 +151,11 @@
} }
], ],
"explorer/context": [ "explorer/context": [
{
"command": "archify.newDiagram",
"when": "explorerResourceIsFolder",
"group": "navigation@10"
},
{ {
"command": "archify.showPreview", "command": "archify.showPreview",
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
@@ -167,6 +178,12 @@
"when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/",
"group": "1_open" "group": "1_open"
} }
],
"file/newFile": [
{
"command": "archify.newDiagram",
"group": "file"
}
] ]
}, },
"keybindings": [ "keybindings": [
+59
View File
@@ -7,6 +7,7 @@ import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview'; import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview';
import { isEvidenceError, renderDiagram, renderToFile } from './renderer'; import { isEvidenceError, renderDiagram, renderToFile } from './renderer';
import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime'; import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime';
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from './templates';
const JSON_LANGUAGES = new Set(['json', 'jsonc']); const JSON_LANGUAGES = new Set(['json', 'jsonc']);
@@ -61,6 +62,26 @@ export function activate(context: vscode.ExtensionContext): void {
const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) => const openHtmlViewer = (uri: vscode.Uri, viewColumn?: vscode.ViewColumn) =>
vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn); vscode.commands.executeCommand('vscode.openWith', uri, HTML_VIEW_TYPE, viewColumn);
const exists = async (uri: vscode.Uri) => {
try {
await vscode.workspace.fs.stat(uri);
return true;
} catch {
return false;
}
};
/** Folder for a new diagram: the clicked folder (or a file's folder), else a workspace folder. */
async function newDiagramFolder(arg?: unknown): Promise<vscode.Uri | undefined> {
if (arg instanceof vscode.Uri) {
const stat = await vscode.workspace.fs.stat(arg);
return stat.type & vscode.FileType.Directory ? arg : vscode.Uri.joinPath(arg, '..');
}
const folders = vscode.workspace.workspaceFolders ?? [];
if (folders.length <= 1) return folders[0]?.uri;
return (await vscode.window.showWorkspaceFolderPick({ placeHolder: 'Folder for the new diagram' }))?.uri;
}
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => { vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => {
const uri = sourceUri(arg); const uri = sourceUri(arg);
@@ -88,6 +109,44 @@ export function activate(context: vscode.ExtensionContext): void {
if (uri) return openHtmlViewer(uri); if (uri) return openHtmlViewer(uri);
}), }),
vscode.commands.registerCommand('archify.newDiagram', async (arg?: unknown) => {
const template = await vscode.window.showQuickPick(
TEMPLATES.map((item) => ({ label: item.label, description: item.description, template: item })),
{ title: 'New Archify Diagram', placeHolder: 'Kind of diagram' },
);
if (!template) return;
const { file, defaultName } = template.template;
const folder = await newDiagramFolder(arg);
let target: vscode.Uri | undefined;
if (folder) {
const name = await vscode.window.showInputBox({
title: 'New Archify Diagram',
prompt: `File name in ${vscode.workspace.asRelativePath(folder, true)}`,
value: defaultName,
validateInput: async (value) =>
invalidNameReason(value) ??
((await exists(vscode.Uri.joinPath(folder, `${diagramName(value)}.archify`))) ? `${diagramName(value)}.archify already exists.` : undefined),
});
if (name === undefined) return;
target = vscode.Uri.joinPath(folder, `${diagramName(name)}.archify`);
} else {
// No workspace: let the user choose where to save.
target = await vscode.window.showSaveDialog({
title: 'New Archify Diagram',
filters: { 'Archify Diagram': ['archify'] },
saveLabel: 'Create',
});
if (!target) return;
if (!isArchifyFile(target.path)) target = target.with({ path: `${target.path}.archify` });
}
const templateText = await readText(vscode.Uri.joinPath(context.extensionUri, 'templates', file));
const text = diagramFromTemplate(templateText, diagramBaseName(target.path));
await vscode.workspace.fs.writeFile(target, Buffer.from(text, 'utf8'));
await vscode.commands.executeCommand('vscode.openWith', target, DIAGRAM_EDITOR_VIEW_TYPE);
}),
vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)), vscode.commands.registerCommand('archify.refreshPreview', () => previews.activePreview?.update(true)),
vscode.commands.registerCommand('archify.showSource', async () => { vscode.commands.registerCommand('archify.showSource', async () => {
+53
View File
@@ -0,0 +1,53 @@
import { isMap, parseDocument } from 'yaml';
import { DiagramType } from './detect';
export interface DiagramTemplate {
/** File name under templates/. */
file: string;
type: DiagramType;
label: string;
description: string;
/** Suggested name for the new file, without .archify. */
defaultName: string;
}
export const TEMPLATES: readonly DiagramTemplate[] = [
{ file: 'architecture.archify', type: 'architecture', label: 'Architecture', description: 'Parts of a system and how they connect', defaultName: 'architecture' },
{ file: 'workflow.archify', type: 'workflow', label: 'Workflow', description: 'Steps of a process, in lanes', defaultName: 'workflow' },
{ file: 'sequence.archify', type: 'sequence', label: 'Sequence', description: 'Who calls whom, in order', defaultName: 'sequence' },
{ file: 'dataflow.archify', type: 'dataflow', label: 'Data Flow', description: 'Where data comes from and where it goes', defaultName: 'dataflow' },
{ file: 'lifecycle.archify', type: 'lifecycle', label: 'Lifecycle', description: 'States something moves through', defaultName: 'lifecycle' },
{ file: 'everyday-plan.archify', type: 'workflow', label: 'Everyday Plan', description: 'A plan or process from daily life', defaultName: 'plan' },
];
/** Strips a typed .archify suffix and surrounding spaces. */
export function diagramName(input: string): string {
return input.trim().replace(/\.archify$/i, '').trim();
}
/** Why a name cannot be used as a file name, or undefined when it can. */
export function invalidNameReason(input: string): string | undefined {
const name = diagramName(input);
if (!name) return 'Enter a name.';
if (/[\\/:*?"<>|\u0000-\u001f]/.test(name)) return 'A name cannot contain \\ / : * ? " < > | or control characters.';
if (/^\.+$/.test(name)) return 'Enter a name.';
return undefined;
}
/** "sales-report_v2" → "Sales report v2". */
export function titleFromName(name: string): string {
const words = name.replace(/[-_.]+/g, ' ').replace(/\s+/g, ' ').trim();
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
}
/**
* Template YAML with meta.title and meta.output set for the new file.
* Works on the YAML document so the template's comments and layout stay.
*/
export function diagramFromTemplate(templateText: string, name: string): string {
const doc = parseDocument(templateText);
if (!isMap(doc.get('meta'))) doc.set('meta', doc.createNode({}));
doc.setIn(['meta', 'title'], titleFromName(name));
doc.setIn(['meta', 'output'], `${name}.html`);
return doc.toString({ lineWidth: 0, flowCollectionPadding: false });
}
+50
View File
@@ -0,0 +1,50 @@
# Architecture: the parts of a system and how they connect.
# Components need a unique id; connections link component ids.
schema_version: 1
diagram_type: architecture
meta:
title: Web Application
output: architecture.html
components:
- id: users
type: external
label: Users
sublabel: Browser
pos: [40, 160]
size: [130, 60]
- id: api
type: backend
label: API
sublabel: REST service
pos: [250, 160]
size: [130, 60]
- id: cache
type: database
label: Cache
sublabel: Redis
pos: [460, 60]
size: [130, 60]
- id: db
type: database
label: Database
sublabel: PostgreSQL
pos: [460, 260]
size: [130, 60]
boundaries:
- kind: region
label: Production
wraps: [api, cache, db]
connections:
- id: users-api
from: users
to: api
label: HTTPS
variant: emphasis
- id: api-cache
from: api
to: cache
label: read
- id: api-db
from: api
to: db
label: SQL
+55
View File
@@ -0,0 +1,55 @@
# Data flow: where data comes from, how it is transformed, and where it ends up.
# Nodes sit in stages (columns) and rows; flows link node ids.
schema_version: 1
diagram_type: dataflow
meta:
title: Sales Reporting
output: dataflow.html
viewBox: [960, 420]
stages:
- label: Sources
- label: Processing
- label: Storage
- label: Consumers
nodes:
- id: shop
type: frontend
label: Web Shop
stage: 0
row: 0
- id: crm
type: external
label: CRM
stage: 0
row: 1
- id: etl
type: backend
label: Nightly ETL
stage: 1
row: 0
- id: warehouse
type: database
label: Warehouse
stage: 2
row: 0
- id: dashboard
type: frontend
label: Sales Dashboard
stage: 3
row: 0
flows:
- from: shop
to: etl
label: orders
- from: crm
to: etl
label: customers
classification: PII
variant: security
- from: etl
to: warehouse
label: daily facts
variant: emphasis
- from: warehouse
to: dashboard
label: aggregates
+72
View File
@@ -0,0 +1,72 @@
# Everyday plan: a workflow about daily life rather than software.
# Node types set the colour; meta.legend renames them for the reader,
# and icon picks the corner symbol (calendar, clock, person, briefcase, flag, moon).
schema_version: 2
diagram_type: workflow
meta:
title: Summer Holiday Plan
output: everyday-plan.html
legend:
entries:
backend:
label: Time off
security:
label: Work
external:
label: Milestone
lanes:
- id: plan
label: Plan
- id: work
label: Work
mainPath: [today, book, holiday, back]
nodes:
- id: today
lane: plan
col: 0
type: external
label: Today
icon: clock
- id: book
lane: plan
col: 1
type: external
label: Book flights
sublabel: by end of May
icon: flag
- id: holiday
lane: plan
col: 2
type: backend
label: Holiday
sublabel: 2 weeks
icon: calendar
- id: back
lane: plan
col: 3
type: external
label: Back home
icon: flag
- id: handover
lane: work
col: 1
type: security
label: Handover
icon: briefcase
edges:
- id: plan-booking
from: today
to: book
- id: go
from: book
to: holiday
label: leave approved
- id: return
from: holiday
to: back
- id: prepare
from: today
to: handover
label: before leaving
role: branch
variant: security
+53
View File
@@ -0,0 +1,53 @@
# Lifecycle: the states something moves through and what moves it.
# States sit in lanes and columns; transitions link state ids.
schema_version: 1
diagram_type: lifecycle
meta:
title: Order Status
output: lifecycle.html
viewBox: [880, 566]
lanes:
- id: main
label: Normal path
- id: exits
label: Exits
states:
- id: placed
type: start
label: Placed
lane: main
col: 0
- id: paid
type: active
label: Paid
lane: main
col: 1
- id: shipped
type: active
label: Shipped
lane: main
col: 2
- id: delivered
type: success
label: Delivered
lane: main
col: 3
- id: cancelled
type: failure
label: Cancelled
lane: exits
col: 0
transitions:
- id: pay
from: placed
to: paid
- id: ship
from: paid
to: shipped
- id: deliver
from: shipped
to: delivered
- id: cancel
from: placed
to: cancelled
variant: security
+50
View File
@@ -0,0 +1,50 @@
# Sequence: who calls whom, in order. Participants are columns;
# messages run top to bottom by y.
schema_version: 1
diagram_type: sequence
meta:
title: Login Request
output: sequence.html
viewBox: [960, 520]
participants:
- id: client
type: external
label: Client
- id: api
type: backend
label: API
- id: auth
type: security
label: Auth Service
- id: db
type: database
label: User Store
messages:
- from: client
to: api
y: 160
label: POST /login
variant: emphasis
- from: api
to: auth
y: 210
label: verify credentials
- from: auth
to: db
y: 260
label: find user
- from: db
to: auth
y: 310
label: user record
variant: return
- from: auth
to: api
y: 360
label: token
variant: return
- from: api
to: client
y: 410
label: 200 OK + token
variant: return
+70
View File
@@ -0,0 +1,70 @@
# Workflow: steps of a process, grouped in lanes, left to right by col.
# mainPath lists the happy path; edges link node ids.
schema_version: 2
diagram_type: workflow
meta:
title: Change Approval
output: workflow.html
# Rename the legend entries of the node types you use.
legend:
entries:
frontend:
label: Author step
security:
label: Review gate
backend:
label: Decision
external:
label: Outcome
lanes:
- id: author
label: Author
- id: review
label: Reviewers
mainPath: [draft, review, approved, released]
nodes:
- id: draft
lane: author
col: 0
type: frontend
label: Draft change
- id: review
lane: review
col: 1
type: security
label: Review
sublabel: two approvals
- id: approved
lane: review
col: 2
type: backend
label: Approved
- id: released
lane: author
col: 3
type: external
label: Released
- id: rework
lane: author
col: 1
type: frontend
label: Rework
edges:
- id: submit
from: draft
to: review
label: submit
- id: approve
from: review
to: approved
label: approve
- id: release
from: approved
to: released
label: release
- id: request-changes
from: review
to: rework
label: changes requested
role: branch
variant: dashed
+29
View File
@@ -97,6 +97,35 @@ export async function run(): Promise<void> {
assert.ok(diagnostics[0].range.start.line >= 2, `reported on line ${diagnostics[0].range.start.line + 1}`); assert.ok(diagnostics[0].range.start.line >= 2, `reported on line ${diagnostics[0].range.start.line + 1}`);
}); });
await check('New Diagram creates a .archify file from a template and opens the diagram', async () => {
const folder = workspaceFile('.');
// Stub the template pick and the name prompt; check the name validation on the way.
const window = vscode.window as unknown as Record<string, unknown>;
const originalPick = window.showQuickPick;
const originalInput = window.showInputBox;
let existingNameMessage: unknown;
window.showQuickPick = async (items: readonly { label: string }[]) => (await items).find((item) => item.label === 'Everyday Plan');
window.showInputBox = async (options: vscode.InputBoxOptions) => {
existingNameMessage = await options.validateInput?.('web-platform');
return 'summer-plan';
};
try {
await vscode.commands.executeCommand('archify.newDiagram', folder);
} finally {
window.showQuickPick = originalPick;
window.showInputBox = originalInput;
}
assert.match(String(existingNameMessage), /already exists/);
const uri = vscode.Uri.joinPath(folder, 'summer-plan.archify');
const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(uri));
assert.match(text, /^diagram_type: workflow$/m);
assert.match(text, /^ {2}title: Summer plan$/m);
assert.match(text, /^ {2}output: summer-plan\.html$/m);
await waitFor('diagram editor for the new file', () =>
webviewTabs('archify.diagram').some((tab) => tab.input instanceof vscode.TabInputCustom && tab.input.uri.toString() === uri.toString()),
);
});
await check('opens a live preview beside the source', async () => { await check('opens a live preview beside the source', async () => {
await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json')); await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json'));
const tabs = await waitFor('preview tab', () => { const tabs = await waitFor('preview tab', () => {
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { parse } from 'yaml';
import { readDiagramSource } from '../src/detect';
import { validate } from '../src/renderer';
import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES, titleFromName } from '../src/templates';
const root = path.resolve(__dirname, '..', '..');
const templates = path.join(root, 'templates');
const runtime = {
cliPath: path.join(root, 'vendor', 'archify', 'bin', 'archify.mjs'),
nodePath: process.execPath,
runAsNode: false,
workDir: fs.mkdtempSync(path.join(os.tmpdir(), 'archify-ext-test-')),
};
test('every template file is listed and every listed template exists', () => {
const files = fs.readdirSync(templates).filter((file) => file.endsWith('.archify')).sort();
assert.deepEqual(TEMPLATES.map((item) => item.file).sort(), files);
});
for (const item of TEMPLATES) {
test(`template ${item.file} is a valid ${item.type} diagram`, async () => {
const text = diagramFromTemplate(fs.readFileSync(path.join(templates, item.file), 'utf8'), item.defaultName);
const source = readDiagramSource(text, `${item.defaultName}.archify`);
assert.ok(source);
assert.equal(source.type, item.type);
assert.equal(source.format, 'yaml');
const result = await validate(runtime, source.type, source.json);
assert.deepEqual(
result.diagnostics.filter((d) => d.severity === 'error').map((d) => `${d.code}: ${d.message}`),
[],
);
assert.ok(result.ok);
});
}
test('diagramFromTemplate sets title and output and keeps comments and flow lists', () => {
const text = diagramFromTemplate('# keep me\nschema_version: 1\nmeta:\n title: Old\n output: old.html\npos: [1, 2]\n', 'sales-report');
assert.match(text, /^# keep me/);
assert.match(text, /pos: \[1, 2\]/);
assert.deepEqual(parse(text).meta, { title: 'Sales report', output: 'sales-report.html' });
});
test('diagramFromTemplate adds meta when the template has none', () => {
assert.deepEqual(parse(diagramFromTemplate('diagram_type: workflow\n', 'plan')).meta, { title: 'Plan', output: 'plan.html' });
});
test('diagram names', () => {
assert.equal(diagramName(' web.archify '), 'web');
assert.equal(diagramName('web.ARCHIFY'), 'web');
assert.equal(invalidNameReason('web'), undefined);
assert.equal(invalidNameReason('my plan 2026'), undefined);
assert.ok(invalidNameReason(''));
assert.ok(invalidNameReason('.archify'));
assert.ok(invalidNameReason('..'));
assert.ok(invalidNameReason('a/b'));
assert.ok(invalidNameReason('a:b'));
assert.equal(titleFromName('sales-report_v2'), 'Sales report v2');
assert.equal(titleFromName('plan'), 'Plan');
});