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:
@@ -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<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(
|
||||
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 () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user