diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3e499..5b0e086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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 - **`.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. diff --git a/README.md b/README.md index 1b65cde..9eae705 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ This is an unofficial extension. It bundles the Archify renderer (MIT, © tt-a1i ## 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`: ```yaml @@ -41,6 +45,7 @@ Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so ## 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. - **Live preview**: for Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, Cmd/Ctrl+K V (to the side) or Cmd/Ctrl+Shift+V. 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. @@ -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. - **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. -- **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 diff --git a/package.json b/package.json index 0043169..6a316c9 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,12 @@ ], "contributes": { "commands": [ + { + "command": "archify.newDiagram", + "title": "New Diagram\u2026", + "category": "Archify", + "icon": "$(new-file)" + }, { "command": "archify.showPreview", "title": "Open Preview", @@ -145,6 +151,11 @@ } ], "explorer/context": [ + { + "command": "archify.newDiagram", + "when": "explorerResourceIsFolder", + "group": "navigation@10" + }, { "command": "archify.showPreview", "when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", @@ -167,6 +178,12 @@ "when": "resourceFilename =~ /(\\.archify|\\.(architecture|workflow|sequence|dataflow|lifecycle)\\.json)$/", "group": "1_open" } + ], + "file/newFile": [ + { + "command": "archify.newDiagram", + "group": "file" + } ] }, "keybindings": [ diff --git a/src/extension.ts b/src/extension.ts index 4346be3..2f55f14 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,7 @@ import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer'; import { DIAGRAM_EDITOR_VIEW_TYPE, PreviewManager } from './preview'; import { isEvidenceError, renderDiagram, renderToFile } from './renderer'; import { clearGitRootCache, evidenceMode, log, readText, runtimeFor } from './runtime'; +import { diagramFromTemplate, diagramName, invalidNameReason, TEMPLATES } from './templates'; 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) => 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 { + 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( vscode.commands.registerCommand('archify.showPreview', (arg?: unknown) => { const uri = sourceUri(arg); @@ -88,6 +109,44 @@ export function activate(context: vscode.ExtensionContext): void { 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.showSource', async () => { diff --git a/src/templates.ts b/src/templates.ts new file mode 100644 index 0000000..79c2517 --- /dev/null +++ b/src/templates.ts @@ -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 }); +} diff --git a/templates/architecture.archify b/templates/architecture.archify new file mode 100644 index 0000000..592a1c4 --- /dev/null +++ b/templates/architecture.archify @@ -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 diff --git a/templates/dataflow.archify b/templates/dataflow.archify new file mode 100644 index 0000000..d284df1 --- /dev/null +++ b/templates/dataflow.archify @@ -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 diff --git a/templates/everyday-plan.archify b/templates/everyday-plan.archify new file mode 100644 index 0000000..6e36a3f --- /dev/null +++ b/templates/everyday-plan.archify @@ -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 diff --git a/templates/lifecycle.archify b/templates/lifecycle.archify new file mode 100644 index 0000000..2c8125b --- /dev/null +++ b/templates/lifecycle.archify @@ -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 diff --git a/templates/sequence.archify b/templates/sequence.archify new file mode 100644 index 0000000..9b31010 --- /dev/null +++ b/templates/sequence.archify @@ -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 diff --git a/templates/workflow.archify b/templates/workflow.archify new file mode 100644 index 0000000..bd8b1a7 --- /dev/null +++ b/templates/workflow.archify @@ -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 diff --git a/test/integration/suite.ts b/test/integration/suite.ts index 1a10ea1..2210dd2 100644 --- a/test/integration/suite.ts +++ b/test/integration/suite.ts @@ -97,6 +97,35 @@ export async function run(): Promise { 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; + 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 vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json')); const tabs = await waitFor('preview tab', () => { diff --git a/test/templates.test.ts b/test/templates.test.ts new file mode 100644 index 0000000..1139abf --- /dev/null +++ b/test/templates.test.ts @@ -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'); +});