From 379638823b56d5441ea86e0ddfaca2d05da4844d Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Wed, 30 Sep 2026 18:19:50 +0200 Subject: [PATCH 1/3] fix(studio): make markdown-folder persistence safe and follow outside edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data-loss fixes: - An element whose description was never opened lost it on the second save after a rename or move: its lazily-loaded body path went stale. Body paths now follow each write (serializeToMdFolderWithPaths). - Pruning deleted any top-level .json and anything under nodes/. Only the manifest, the known sidecars and node files with id/type frontmatter are pruned now (staleMdFolderFiles); "Save as folder…" asks before writing into a folder that holds other files but no model. - Overlapping saves could prune each other's files. Disk saves run one at a time per document and collapse into the newest payload; localStorage saves stay synchronous for the page-hide flush. - A slow load finishing after a document switch loaded into the new document, which autosave then overwrote. Loads are sequenced; an edit still in the debounce window is saved to the document being left. Outside edits: - MdFolderSession (@radical/common) keeps a baseline of the model files: it writes only changed files, refuses to write over files changed on disk since the last read, and polls for such changes. Electron keeps one per folder in the main process (1 s polling, like the --file watcher; native events are unreliable on external volumes); the web build keeps one per directory handle (2 s). - Autosave watches the active md document and reloads it on an outside edit, keeping the open view and selection. The disk wins. - Only model files are read (no walking node_modules); folder writes are confined to the model folder. Co-Authored-By: Claude Opus 5.5 --- apps/studio/src/main/diskFolderStorage.ts | 56 ++++ apps/studio/src/main/index.ts | 93 +++--- apps/studio/src/preload/index.ts | 6 +- .../src/components/DocumentManager.tsx | 14 +- .../src/renderer/src/persist/webFolder.ts | 109 ++++--- .../src/renderer/src/persistence/autosave.ts | 134 ++++++--- .../src/renderer/src/store/documentStore.ts | 273 +++++++++++++++--- apps/studio/tests/diskFolderStorage.test.ts | 57 ++++ apps/studio/tests/mdFolderSafety.test.ts | 174 +++++++++++ apps/studio/tests/mdFolderWatch.test.ts | 120 ++++++++ apps/studio/tests/webFolder.test.ts | 82 ++++-- packages/common/package.json | 1 + packages/common/src/formats/mdFolder.ts | 61 +++- packages/common/src/formats/mdFolderSync.ts | 170 +++++++++++ .../common/tests/mdFolderPersistence.test.ts | 26 ++ packages/common/tests/mdFolderSync.test.ts | 146 ++++++++++ packages/host-bridge/src/index.ts | 23 +- 17 files changed, 1316 insertions(+), 229 deletions(-) create mode 100644 apps/studio/src/main/diskFolderStorage.ts create mode 100644 apps/studio/tests/diskFolderStorage.test.ts create mode 100644 apps/studio/tests/mdFolderSafety.test.ts create mode 100644 apps/studio/tests/mdFolderWatch.test.ts create mode 100644 packages/common/src/formats/mdFolderSync.ts create mode 100644 packages/common/tests/mdFolderSync.test.ts diff --git a/apps/studio/src/main/diskFolderStorage.ts b/apps/studio/src/main/diskFolderStorage.ts new file mode 100644 index 00000000..99048315 --- /dev/null +++ b/apps/studio/src/main/diskFolderStorage.ts @@ -0,0 +1,56 @@ +// ─── Md-folder storage on the local disk ───────────────────────────────────── +// +// The `MdFolderStorage` behind Electron's md-folder sessions (see +// @radical/common/formats/mdFolderSync). Kept free of Electron imports so it +// can be tested against a real temporary directory. + +import type { Stats } from 'fs' +import { readFile, writeFile, mkdir, readdir, rm, stat } from 'fs/promises' +import { join, resolve, dirname, sep } from 'path' +import { isMdFolderModelPath } from '@radical/common/formats/mdFolder' +import type { MdFolderStorage } from '@radical/common/formats/mdFolderSync' + +/** Absolute path of `rel` inside `root`; throws if `rel` would escape it. */ +function inside(root: string, rel: string): string { + const abs = resolve(root, rel) + if (!abs.startsWith(resolve(root) + sep)) throw new Error(`path outside the model folder: ${rel}`) + return abs +} + +/** A model folder on the local disk. Only the manifest, the top-level + * sidecars and `nodes/**` are listed — the rest of the folder (a repo's + * `node_modules`, say) is never walked. Hidden files are ignored. */ +export function diskFolderStorage(root: string): MdFolderStorage { + const stampOf = (s: Stats): string => `${s.mtimeMs}:${s.size}` + return { + async list() { + const stamps: Record = {} + const add = async (rel: string): Promise => { + try { stamps[rel] = stampOf(await stat(join(root, rel))) } catch { /* gone meanwhile */ } + } + const walk = async (rel: string): Promise => { + const entries = await readdir(join(root, rel), { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (entry.name.startsWith('.')) continue + const child = `${rel}/${entry.name}` + if (entry.isDirectory()) await walk(child) + else if (entry.isFile() && isMdFolderModelPath(child)) await add(child) + } + } + const top = await readdir(root, { withFileTypes: true }).catch(() => []) + for (const entry of top) { + if (entry.isFile() && isMdFolderModelPath(entry.name)) await add(entry.name) + else if (entry.isDirectory() && entry.name === 'nodes') await walk('nodes') + } + return stamps + }, + read: (rel) => readFile(inside(root, rel), 'utf-8'), + async write(rel, content) { + const abs = inside(root, rel) + await mkdir(dirname(abs), { recursive: true }) + await writeFile(abs, content, 'utf-8') + return stampOf(await stat(abs)) + }, + remove: (rel) => rm(inside(root, rel), { force: true }), + } +} diff --git a/apps/studio/src/main/index.ts b/apps/studio/src/main/index.ts index 7a9af4f9..f40da611 100644 --- a/apps/studio/src/main/index.ts +++ b/apps/studio/src/main/index.ts @@ -1,54 +1,33 @@ import { app, shell, BrowserWindow, ipcMain, dialog } from 'electron' -import { join, resolve, dirname as pathDirname, relative, sep } from 'path' +import { join, resolve } from 'path' import { watchFile, unwatchFile } from 'fs' -import { - readFile as readFileAsync, - writeFile as writeFileAsync, - mkdir, - readdir, - rm, -} from 'fs/promises' +import { readFile as readFileAsync, writeFile as writeFileAsync, mkdir } from 'fs/promises' import { electronApp, optimizer, is } from '@electron-toolkit/utils' - -/** Recursively collect the `.md` / `.json` files under `root` as a map of - * POSIX-relative path → UTF-8 content. Ignores hidden files/dirs. */ -async function readFolderFiles(root: string): Promise> { - const files: Record = {} - async function walk(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }) - for (const entry of entries) { - if (entry.name.startsWith('.')) continue - const abs = join(dir, entry.name) - if (entry.isDirectory()) { - await walk(abs) - } else if (entry.isFile() && /\.(md|json)$/i.test(entry.name)) { - const rel = relative(root, abs).split(sep).join('/') - files[rel] = await readFileAsync(abs, 'utf-8') +import { MdFolderSession } from '@radical/common/formats/mdFolderSync' +import { diskFolderStorage } from './diskFolderStorage' + +/** One session per model folder, kept for the app's lifetime: it remembers + * what the renderer last read, so writes never clobber outside edits. */ +const folderSessions = new Map() + +/** The folder of the active md document, polled for outside edits. */ +let watchedFolder: string | null = null +const FOLDER_POLL_MS = 1000 + +function folderSession(folderPath: string): MdFolderSession { + const key = resolve(folderPath) + let session = folderSessions.get(key) + if (!session) { + session = new MdFolderSession(diskFolderStorage(key)) + session.onExternalChange = (paths) => { + if (!watchedFolder || resolve(watchedFolder) !== key) return + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send('folder:external-change', { folderPath: watchedFolder, paths }) } } + folderSessions.set(key, session) } - await walk(root) - return files -} - -/** Write a file map into `root`, then prune our own stale files (`.md` under - * `nodes/` and known sidecars) that are no longer present, so removals / - * renames in the model are reflected on disk. Never touches unrelated files. */ -async function writeFolderFiles(root: string, files: Record): Promise { - await mkdir(root, { recursive: true }) - for (const [rel, content] of Object.entries(files)) { - const abs = join(root, rel) - await mkdir(pathDirname(abs), { recursive: true }) - await writeFileAsync(abs, content, 'utf-8') - } - // Prune stale managed files. - const existing = await readFolderFiles(root) - for (const rel of Object.keys(existing)) { - if (rel in files) continue - const managed = rel.startsWith('nodes/') || /^[^/]+\.json$/.test(rel) || rel === 'radical.md' - if (!managed) continue - await rm(join(root, rel), { force: true }) - } + return session } // ── CLI-specified model file (--file /path/to/model.c4.json or RADICAL_FILE env) ── @@ -187,7 +166,7 @@ app.whenReady().then(() => { if (result.canceled || result.filePaths.length === 0) return { success: false } const folderPath = result.filePaths[0] try { - const files = await readFolderFiles(folderPath) + const files = await folderSession(folderPath).readAll() return { success: true, folderPath, files } } catch (e) { return { success: false, error: (e as Error).message } @@ -206,7 +185,7 @@ app.whenReady().then(() => { ipcMain.handle('folder:read', async (_event, folderPath: string) => { try { - const files = await readFolderFiles(folderPath) + const files = await folderSession(folderPath).readAll() return { success: true, files } } catch (e) { return { success: false, error: (e as Error).message } @@ -217,7 +196,11 @@ app.whenReady().then(() => { 'folder:write', async (_event, folderPath: string, files: Record) => { try { - await writeFolderFiles(folderPath, files) + await mkdir(folderPath, { recursive: true }) + const res = await folderSession(folderPath).write(files) + if (!res.ok) { + return { success: false, conflict: res.conflict, error: 'The folder changed on disk since it was read.' } + } return { success: true } } catch (e) { return { success: false, error: (e as Error).message } @@ -225,6 +208,20 @@ app.whenReady().then(() => { }, ) + // ── Folder watcher: poll the active md document's folder ───────────────── + // Polling, like the --file watcher below: native change events are + // unreliable on external volumes. A poll only stats the model files and + // reads the ones whose stamp moved. + ipcMain.handle('folder:watch', (_event, folderPath: string | null) => { + watchedFolder = folderPath + }) + const folderPoll = setInterval(() => { + if (watchedFolder) { + folderSession(watchedFolder).poll().catch((e) => console.warn('[main] folder poll failed:', e)) + } + }, FOLDER_POLL_MS) + app.on('will-quit', () => clearInterval(folderPoll)) + // ── File watcher: push external changes to renderer ─────────────────────── // When launched with --file or RADICAL_FILE, watch the file with polling // (reliable on all mounts including /Volumes) and push its new content to diff --git a/apps/studio/src/preload/index.ts b/apps/studio/src/preload/index.ts index f295daa2..82e3690f 100644 --- a/apps/studio/src/preload/index.ts +++ b/apps/studio/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from 'electron' -import type { FileChange, HostCapabilities } from '@radical/host-bridge' +import type { FileChange, FolderChange, HostCapabilities } from '@radical/host-bridge' // The Electron implementation of Studio's host bridge: each capability is one // IPC channel handled in src/main. The renderer reaches it through host() @@ -13,6 +13,10 @@ const api = { pickFolder: () => ipcRenderer.invoke('folder:pick'), readFolder: (folderPath) => ipcRenderer.invoke('folder:read', folderPath), writeFolder: (folderPath, files) => ipcRenderer.invoke('folder:write', folderPath, files), + watchFolder: (folderPath) => ipcRenderer.invoke('folder:watch', folderPath), + onFolderChanged: (listener) => { + ipcRenderer.on('folder:external-change', (_event, data) => listener(data as FolderChange)) + }, getWatchedPath: () => ipcRenderer.invoke('file:getWatchedPath'), onFileChanged: (listener) => { ipcRenderer.on('file:external-change', (_event, data) => listener(data as FileChange)) diff --git a/apps/studio/src/renderer/src/components/DocumentManager.tsx b/apps/studio/src/renderer/src/components/DocumentManager.tsx index 52eccc7e..74a9a0d0 100644 --- a/apps/studio/src/renderer/src/components/DocumentManager.tsx +++ b/apps/studio/src/renderer/src/components/DocumentManager.tsx @@ -6,6 +6,7 @@ import { availableMetamodels } from '@radical/common/metamodel' import { parseStructurizrDsl } from '@radical/common/formats/structurizrDsl' import { webFolderSupported } from '../persist/webFolder' import { host } from '../platform/host' +import { reloadActiveDocument } from '../persistence/autosave' interface Props { open: boolean @@ -45,7 +46,6 @@ export function DocumentManagerModal({ open, onClose }: Props): React.ReactEleme const presets = useMemo(() => availableMetamodels(), []) const [newPresetId, setNewPresetId] = useState('c4-ddd-governance-builtin') const saveDiagram = useDiagramStore((s) => s.saveDiagram) - const loadDiagram = useDiagramStore((s) => s.loadDiagram) // Default the visible tab to the source of the active document so users // land on the section they're most likely editing. @@ -116,7 +116,11 @@ export function DocumentManagerModal({ open, onClose }: Props): React.ReactEleme const handleSaveAsFolder = async (d: DocumentMeta): Promise => { const data = saveDiagram() - const meta = await documents.saveAsFolder(d.id, data) + const meta = await documents.saveAsFolder(d.id, data, (folderName) => window.confirm( + `"${folderName}" already contains files and is not a Radical model folder.\n\n` + + 'Save the model into it anyway? Existing files are kept, except ones with the ' + + "same names as the model's own files (nodes/…, views.json, metamodel.json, …).", + )) if (meta) setTab('md') } @@ -128,9 +132,9 @@ export function DocumentManagerModal({ open, onClose }: Props): React.ReactEleme window.alert('Could not get permission to access the folder.') return } - documents.setActiveId(d.id) - const data = await documents.loadDocument(d.id) - if (data) loadDiagram(data) + // Switching loads the document; if it already is the active one, reload it. + if (d.id === documents.getActiveId()) reloadActiveDocument() + else documents.setActiveId(d.id) onClose() } diff --git a/apps/studio/src/renderer/src/persist/webFolder.ts b/apps/studio/src/renderer/src/persist/webFolder.ts index a5fbddbf..79626d31 100644 --- a/apps/studio/src/renderer/src/persist/webFolder.ts +++ b/apps/studio/src/renderer/src/persist/webFolder.ts @@ -7,16 +7,18 @@ // survives reloads — though the browser re-prompts for permission on the next // session (a `verifyPermission` call within a user gesture re-grants it). // -// The read/write helpers are handle-driven and side-effect-isolated so they can -// be unit-tested against an in-memory fake directory handle. +// Reads and writes go through an `MdFolderSession` over `handleFolderStorage`, +// which is handle-driven and side-effect-isolated so it can be unit-tested +// against an in-memory fake directory handle. -import type { FolderFiles } from '@radical/common/formats/mdFolder' +import { isMdFolderModelPath } from '@radical/common/formats/mdFolder' +import type { MdFolderStorage } from '@radical/common/formats/mdFolderSync' // Minimal structural typings for the File System Access API (avoids depending // on lib.dom variants that may not ship these definitions). export interface FsFileHandle { kind: 'file' - getFile(): Promise<{ text(): Promise }> + getFile(): Promise<{ text(): Promise; lastModified: number; size: number }> createWritable(): Promise<{ write(data: string): Promise; close(): Promise }> } export interface FsDirHandle { @@ -67,30 +69,6 @@ export async function verifyPermission( return false } -function isManagedPath(rel: string): boolean { - return rel.startsWith('nodes/') || /^[^/]+\.json$/.test(rel) || rel === 'radical.md' -} - -async function readInto(dir: FsDirHandle, prefix: string, out: FolderFiles): Promise { - for await (const [name, entry] of dir.entries()) { - if (name.startsWith('.')) continue - const rel = prefix ? `${prefix}/${name}` : name - if (entry.kind === 'directory') { - await readInto(entry as FsDirHandle, rel, out) - } else if (/\.(md|json)$/i.test(name)) { - const file = await (entry as FsFileHandle).getFile() - out[rel] = await file.text() - } - } -} - -/** Recursively read the `.md` / `.json` files under a directory handle. */ -export async function readFolderFromHandle(handle: FsDirHandle): Promise { - const out: FolderFiles = {} - await readInto(handle, '', out) - return out -} - /** Read a single file by its relative POSIX path (e.g. a lazily-loaded node * body), without walking the rest of the tree. */ export async function readOneFileFromHandle(handle: FsDirHandle, relPath: string): Promise { @@ -104,44 +82,55 @@ export async function readOneFileFromHandle(handle: FsDirHandle, relPath: string return file.text() } -/** Write a file map into a directory handle, then prune our own stale managed - * files (`.md` under `nodes/` and known sidecars) that are no longer present. */ -export async function writeFolderToHandle(handle: FsDirHandle, files: FolderFiles): Promise { - for (const [rel, content] of Object.entries(files)) { - const parts = rel.split('/') - let dir = handle - for (let i = 0; i < parts.length - 1; i++) { - dir = await dir.getDirectoryHandle(parts[i], { create: true }) - } - const fileHandle = await dir.getFileHandle(parts[parts.length - 1], { create: true }) - const writable = await fileHandle.createWritable() - await writable.write(content) - await writable.close() +/** A model folder behind a directory handle, for an `MdFolderSession`. Only + * the manifest, the top-level sidecars and `nodes/**` are listed; hidden + * entries are ignored. Stamps are the files' lastModified + size. */ +export function handleFolderStorage(root: FsDirHandle): MdFolderStorage { + const stampOf = async (file: FsFileHandle): Promise => { + const f = await file.getFile() + return `${f.lastModified}:${f.size}` } - - const existing: FolderFiles = {} - await readInto(handle, '', existing) - for (const rel of Object.keys(existing)) { - if (rel in files) continue - if (!isManagedPath(rel)) continue - const parts = rel.split('/') - let dir = handle - let reachable = true - for (let i = 0; i < parts.length - 1; i++) { - try { - dir = await dir.getDirectoryHandle(parts[i], { create: false }) - } catch { - reachable = false - break + const dirFor = async (parts: string[], create: boolean): Promise => { + let dir = root + for (const part of parts) dir = await dir.getDirectoryHandle(part, { create }) + return dir + } + return { + async list() { + const stamps: Record = {} + const walk = async (dir: FsDirHandle, rel: string): Promise => { + for await (const [name, entry] of dir.entries()) { + if (name.startsWith('.')) continue + const child = rel ? `${rel}/${name}` : name + if (entry.kind === 'directory') { + if (rel || name === 'nodes') await walk(entry as FsDirHandle, child) + } else if (isMdFolderModelPath(child)) { + try { stamps[child] = await stampOf(entry as FsFileHandle) } catch { /* gone meanwhile */ } + } + } } - } - if (reachable) { + await walk(root, '') + return stamps + }, + read: (rel) => readOneFileFromHandle(root, rel), + async write(rel, content) { + const parts = rel.split('/') + const dir = await dirFor(parts.slice(0, -1), true) + const file = await dir.getFileHandle(parts[parts.length - 1], { create: true }) + const writable = await file.createWritable() + await writable.write(content) + await writable.close() + return stampOf(file) + }, + async remove(rel) { + const parts = rel.split('/') try { + const dir = await dirFor(parts.slice(0, -1), false) await dir.removeEntry(parts[parts.length - 1]) } catch { - /* best-effort prune */ + /* already gone */ } - } + }, } } diff --git a/apps/studio/src/renderer/src/persistence/autosave.ts b/apps/studio/src/renderer/src/persistence/autosave.ts index 489cd7a6..2defe739 100644 --- a/apps/studio/src/renderer/src/persistence/autosave.ts +++ b/apps/studio/src/renderer/src/persistence/autosave.ts @@ -14,9 +14,17 @@ import './configureDocuments' // must run before the store module is evaluated import type { C4Node, C4Relation, DiagramData, DiagramView, NodePosition } from '@radical/common/c4' import { builtInGovernanceMetamodel } from '@radical/common/metamodel' import { useDiagramStore } from '@radical/ui/store/diagramStore' -import { documents, useDocumentsStore } from '../store/documentStore' +import { documents, useDocumentsStore, type DocumentSource } from '../store/documentStore' import { host } from '../platform/host' +let reloadActive: () => void = () => {} + +/** Re-read the active document from its storage into the diagram store, e.g. + * after a web folder regained permission. */ +export function reloadActiveDocument(): void { + reloadActive() +} + // ─── Auto-persist to the active document ──────────────────────────────────── // Subscribe to model slices and debounce-save into whatever the current // active document is (LS or FS, resolved fresh on every flush). @@ -100,54 +108,108 @@ if (typeof window !== 'undefined') { } }) + // ── Load a document into the store ─────────────────────────────────────── + // Auto-persist is suspended while loading so the "old model → loaded" + // replacement doesn't overwrite the document with stale data. Each load + // gets a sequence number: a load that finishes after a newer one started + // (the user switched again meanwhile) is dropped, so a slow folder read can + // never land in — and then be auto-saved into — another document. + let _loadSeq = 0 + const loadActive = ( + id: string, + source: DocumentSource | undefined, + opts?: { keepUi?: boolean }, + ): void => { + const seq = ++_loadSeq + _suspended = true + if (_watchingId !== id) stopWatching() + documents.loadDocument(id).then((data) => { + if (seq !== _loadSeq || documents.getActiveId() !== id) return + if (data) { + const store = useDiagramStore.getState() + const { activeViewId, selectedNodeId } = store + store.loadDiagram(data) + // A reload of the same document keeps the user where they were. + if (opts?.keepUi) { + const next = useDiagramStore.getState() + if (activeViewId && next.views[activeViewId]) next.setActiveView(activeViewId) + if (selectedNodeId && next.c4Nodes[selectedNodeId]) next.selectNode(selectedNodeId) + } + if (source === 'md') watchActive(id) + } else if (source === 'fs' || source === 'md') { + // New/empty file: initialize with the maximum built-in metamodel + useDiagramStore.getState().loadDiagram({ + nodes: [], + relations: [], + metamodel: builtInGovernanceMetamodel(), + }) + } + }).catch((e) => console.warn('[diagramStore] document load failed:', e)) + .finally(() => { if (seq === _loadSeq) _suspended = false }) + } + + // ── Follow outside edits to the active md-folder document ──────────────── + // Once a folder is loaded, its storage is polled for edits made in another + // editor or by git; on one, the document is reloaded from disk. The disk + // wins: a save refused because of such an edit is dropped with the reload. + let _watchingId: string | null = null + let _stopWatch: () => void = () => {} + function stopWatching(): void { + _stopWatch() + _stopWatch = () => {} + _watchingId = null + } + function watchActive(id: string): void { + if (_watchingId === id) return + stopWatching() + _watchingId = id + _stopWatch = documents.watchDocument(id, () => { + if (documents.getActiveId() !== id) return + console.info('[diagramStore] document changed on disk — reloading') + loadActive(id, 'md', { keepUi: true }) + }) + } + + reloadActive = () => { + const id = documents.getActiveId() + if (!id) return + loadActive(id, documents.listDocuments().find(d => d.id === id)?.source) + } + // ── Hydrate FS-backed active doc on boot ───────────────────────────────── // We rendered the sample synchronously above; if the active doc is FS, - // load the file now and replace the in-memory model. We suspend the - // auto-persist while doing this so the "sample → loaded" replacement - // doesn't overwrite the file with stale data. + // load the file now and replace the in-memory model. const activeId = documents.getActiveId() if (activeId) { const meta = documents.listDocuments().find(d => d.id === activeId) - if (meta?.source === 'fs' || meta?.source === 'md') { - _suspended = true - documents.loadDocument(activeId).then((data) => { - if (data) { - useDiagramStore.getState().loadDiagram(data) - } else { - // New/empty file: initialize with the maximum built-in metamodel - useDiagramStore.getState().loadDiagram({ - nodes: [], - relations: [], - metamodel: builtInGovernanceMetamodel(), - }) - } - }).catch((e) => console.warn('[diagramStore] FS hydrate failed:', e)) - .finally(() => { _suspended = false }) - } + if (meta?.source === 'fs' || meta?.source === 'md') loadActive(activeId, meta.source) } // ── React to active-document switches ──────────────────────────────────── // When the user picks a different document in the manager modal, load it. let prevActive = documents.getActiveId() useDocumentsStore.subscribe((s) => { - if (s.activeId === prevActive) return + if (s.activeId === prevActive) { + // The active document became folder-backed without a reload ("Save as + // folder…"): start following its folder. + const active = s.docs.find(d => d.id === s.activeId) + if (active?.source === 'md' && !_suspended) watchActive(active.id) + return + } + const leaving = prevActive prevActive = s.activeId + // An edit still inside the debounce window belongs to the document being + // left, which the store still holds: write it there before replacing it. + if (_persistTimer !== null && leaving && !_suspended) { + clearTimeout(_persistTimer) + _persistTimer = null + const data = buildPersistData() + _layoutSafePending = false + documents.saveDocument(leaving, data) + .catch((e) => console.warn('[diagramStore] persist on switch failed:', e)) + } if (!s.activeId) return - _suspended = true - const switchMeta = documents.listDocuments().find(d => d.id === s.activeId) - documents.loadDocument(s.activeId).then((data) => { - if (data) { - useDiagramStore.getState().loadDiagram(data) - } else if (switchMeta?.source === 'fs' || switchMeta?.source === 'md') { - // New/empty file: initialize with the maximum built-in metamodel - useDiagramStore.getState().loadDiagram({ - nodes: [], - relations: [], - metamodel: builtInGovernanceMetamodel(), - }) - } - }).catch((e) => console.warn('[diagramStore] switch-doc load failed:', e)) - .finally(() => { _suspended = false }) + loadActive(s.activeId, documents.listDocuments().find(d => d.id === s.activeId)?.source) }) // Expose hooks for other modules / future use. diff --git a/apps/studio/src/renderer/src/store/documentStore.ts b/apps/studio/src/renderer/src/store/documentStore.ts index 562ae5be..caa5fa20 100644 --- a/apps/studio/src/renderer/src/store/documentStore.ts +++ b/apps/studio/src/renderer/src/store/documentStore.ts @@ -13,15 +13,19 @@ import type { DiagramData, C4Node } from '@radical/common/c4' import { host } from '../platform/host' import { serializeToMdFolder, + serializeToMdFolderWithPaths, deserializeFromMdFolder, extractNodeBody, + isMdFolder, + type FolderFiles, } from '@radical/common/formats/mdFolder' +import { MdFolderSession } from '@radical/common/formats/mdFolderSync' import { webFolderSupported, pickWebDirectory, - readFolderFromHandle, readOneFileFromHandle, - writeFolderToHandle, + handleFolderStorage, + type FsDirHandle, verifyPermission, saveHandle, loadHandle, @@ -50,6 +54,42 @@ let bootSeededId: string | null = null * not here; re-fetching a path here is idempotent, just redundant I/O. */ const mdBodyPaths = new Map>() +/** Per-document chain of disk writes (fs / md docs). A folder write is many + * files followed by a prune, so two overlapping writes can each prune what the + * other just wrote — a renamed node would lose both its old and new file. + * Writes therefore run one at a time. */ +const saveChains = new Map>() +/** Newest payload per document not yet handed to a write: saves that queue up + * behind a slow write collapse into one write of the latest state. */ +const queuedSaves = new Map() + +/** Web md-folder docs: the session over each doc's directory handle, which + * remembers what was last read so writes never clobber outside edits. (In + * Electron the main process keeps the equivalent session per folder.) */ +const webSessions = new Map() +/** How often a web md-folder doc's directory is checked for outside edits. */ +const WEB_FOLDER_POLL_MS = 2000 + +/** Per md doc, who to tell when its folder changes outside Studio — set by + * `watchDocument`. */ +const externalChangeListeners = new Map void>() +/** Hosts whose folder-change events are already routed to the listeners. */ +const routedHosts = new WeakSet() + +function notifyExternalChange(id: string): void { + externalChangeListeners.get(id)?.() +} + +function webSession(id: string, handle: FsDirHandle): MdFolderSession { + let session = webSessions.get(id) + if (!session) { + session = new MdFolderSession(handleFolderStorage(handle)) + session.onExternalChange = () => notifyExternalChange(id) + webSessions.set(id, session) + } + return session +} + export type DocumentSource = 'ls' | 'fs' | 'md' export interface DocumentMeta { @@ -184,6 +224,111 @@ async function nodesForMdSave(id: string, data: DiagramData): Promise return patched ?? data.nodes } +/** After an md-folder write, point each still-unhydrated node at the file it + * now lives in — a rename or re-parent moves the file and the prune deletes + * the old one — and forget nodes that no longer exist. The bodies moved with + * their nodes: `nodesForMdSave` put them into this write. */ +function retargetBodyPaths(id: string, nodePaths: Record): void { + const pending = mdBodyPaths.get(id) + if (!pending) return + const next: Record = {} + for (const nodeId of Object.keys(pending)) { + if (nodePaths[nodeId]) next[nodeId] = nodePaths[nodeId] + } + mdBodyPaths.set(id, next) +} + +/** Resolves once every write queued for `id` so far has finished. */ +async function saveSettled(id: string): Promise { + await saveChains.get(id)?.catch(() => {}) +} + +function enqueueSave(id: string, data: DiagramData): Promise { + queuedSaves.set(id, data) + const run = async (): Promise => { + const next = queuedSaves.get(id) + if (!next) return // an earlier run already wrote the newest payload + queuedSaves.delete(id) + await writeDocument(id, next) + } + const chain = (saveChains.get(id) ?? Promise.resolve()).then(run, run) + saveChains.set(id, chain) + const cleanup = (): void => { if (saveChains.get(id) === chain) saveChains.delete(id) } + chain.then(cleanup, cleanup) + return chain +} + +/** Whether "Save as folder" may write into a folder that already holds these + * files: always for an empty folder or an existing Radical model, otherwise + * only when the user confirms. */ +async function mayWriteInto( + files: FolderFiles, + folderName: string, + confirmForeign?: (folderName: string) => boolean | Promise, +): Promise { + if (Object.keys(files).length === 0 || isMdFolder(files)) return true + return confirmForeign ? await confirmForeign(folderName) : false +} + +/** Write a disk-backed (fs / md) document. Called only through `enqueueSave`. */ +async function writeDocument(id: string, data: DiagramData): Promise { + const h = host() + const idx = readIndex() + const meta = idx.docs.find(d => d.id === id) + if (!meta) return + if (meta.source === 'fs' && meta.filePath && h.writeFile) { + const json = JSON.stringify(data, null, 2) + const res = await h.writeFile(meta.filePath, json) + if (!res.success) { + console.warn('[documentStore] FS write failed for', meta.filePath, res.error) + return + } + } else if (meta.source === 'md' && meta.folderPath && h.writeFolder) { + const nodes = await nodesForMdSave(id, data) + const { files, nodePaths } = serializeToMdFolderWithPaths({ ...data, nodes }, meta.name) + const res = await h.writeFolder(meta.folderPath, files) + if (!res.success) { + if (res.conflict) { + // The folder changed outside Studio; the host reports it and the + // document is reloaded from disk — the outside edit wins. + console.warn('[documentStore] folder changed on disk, not overwriting:', res.conflict) + } else { + console.warn('[documentStore] folder write failed for', meta.folderPath, res.error) + } + return + } + retargetBodyPaths(id, nodePaths) + } else if (meta.source === 'md' && !h.writeFolder && webFolderSupported()) { + // Never write until the handle's permission is verified this session, + // so a reload with a permission-pending handle can't overwrite files. + if (!connectedWebFolders.has(id)) { + const handle = await loadHandle(id) + if (!handle || !(await verifyPermission(handle, 'readwrite', false))) return + connectedWebFolders.add(id) + } + const handle = await loadHandle(id) + if (!handle) return + try { + const nodes = await nodesForMdSave(id, data) + const { files, nodePaths } = serializeToMdFolderWithPaths({ ...data, nodes }, meta.name) + const res = await webSession(id, handle).write(files) + if (!res.ok) { + console.warn('[documentStore] folder changed on disk, not overwriting:', res.conflict) + return + } + retargetBodyPaths(id, nodePaths) + } catch (e) { + console.warn('[documentStore] web folder write failed:', e) + return + } + } else { + return + } + meta.lastModified = Date.now() + writeIndex(idx) + notify() +} + /** Browser-only file picker used when running outside Electron. Resolves with * `{ name, content }` for the chosen file, or `null` if the user cancels. * Implemented via a transient that we click(). */ @@ -317,7 +462,9 @@ export interface DocumentsAPI { /** Persist new payload under an existing document. For md-folder docs, * any node whose body was never hydrated is re-read from disk just for * serialization (never cached into the live model) so an unopened - * node's saved content is never clobbered with an empty description. */ + * node's saved content is never clobbered with an empty description. + * localStorage docs are written synchronously (before this returns), so a + * page-hide flush lands; disk writes run one at a time per document. */ saveDocument(id: string, data: DiagramData): Promise /** Update the display name. (Does NOT rename files on disk.) */ @@ -356,9 +503,15 @@ export interface DocumentsAPI { importFromFolder(): Promise /** Pick a destination folder (Electron or web), write the current payload as - * a Markdown folder, and convert the doc to md-backed. Returns the mutated - * meta, or null on cancel / failure. */ - saveAsFolder(id: string, data: DiagramData): Promise + * a Markdown folder, and convert the doc to md-backed. A folder that already + * holds `.md` / `.json` files but is not a Radical model is written into + * only if `confirmForeignFolder` returns true. Returns the mutated meta, or + * null on cancel / failure. */ + saveAsFolder( + id: string, + data: DiagramData, + confirmForeignFolder?: (folderName: string) => boolean | Promise, + ): Promise /** Re-request read/write permission for a web md-folder document's handle * (must be called from a user gesture). Returns true on success. */ @@ -367,6 +520,13 @@ export interface DocumentsAPI { /** True when a web md-folder document has verified permission this session. */ isFolderConnected(id: string): boolean + /** Call `onExternalChange` when a document's storage is edited outside + * Studio (another editor, git) — md-folder docs only: Electron's main + * process polls the folder, the web build polls the directory handle. + * Also called when a save was refused because of such an edit. Returns a + * function that stops watching. */ + watchDocument(id: string, onExternalChange: () => void): () => void + /** Convenience: ensure there's at least one document; create an empty LS * doc if the index is empty. Returns the active doc. */ ensureActive(seedIfEmpty: () => DiagramData): { meta: DocumentMeta; seeded: boolean } @@ -449,6 +609,8 @@ export const documents: DocumentsAPI = { const meta = readIndex().docs.find(d => d.id === id) if (!meta) return null if (meta.source === 'ls') return readLSPayload(id) + // Don't read a folder (or file) while our own write to it is half done. + await saveSettled(id) if (meta.source === 'fs' && meta.filePath && h.readFile) { const res = await h.readFile(meta.filePath) if (!res.success || !res.content) return null @@ -470,7 +632,7 @@ export const documents: DocumentsAPI = { if (!(await verifyPermission(handle, 'readwrite', false))) return null // needs reconnect connectedWebFolders.add(id) try { - const files = await readFolderFromHandle(handle) + const files = await webSession(id, handle).readAll() if (Object.keys(files).length === 0) return null // empty/new folder const { data, bodyPaths } = deserializeFromMdFolder(files, { lazy: true }) mdBodyPaths.set(id, bodyPaths ?? {}) @@ -485,51 +647,17 @@ export const documents: DocumentsAPI = { }, async hydrateNodeBody(id, nodeId) { + // A write in flight may be moving this node's file; read once it landed. + await saveSettled(id) return fetchNodeBody(id, nodeId) }, async saveDocument(id, data) { - const h = host() const idx = readIndex() const meta = idx.docs.find(d => d.id === id) if (!meta) return - if (meta.source === 'ls') { - writeLSPayload(id, data) - } else if (meta.source === 'fs' && meta.filePath && h.writeFile) { - const json = JSON.stringify(data, null, 2) - const res = await h.writeFile(meta.filePath, json) - if (!res.success) { - console.warn('[documentStore] FS write failed for', meta.filePath, res.error) - return - } - } else if (meta.source === 'md' && meta.folderPath && h.writeFolder) { - const nodes = await nodesForMdSave(id, data) - const files = serializeToMdFolder({ ...data, nodes }, meta.name) - const res = await h.writeFolder(meta.folderPath, files) - if (!res.success) { - console.warn('[documentStore] folder write failed for', meta.folderPath, res.error) - return - } - } else if (meta.source === 'md' && !h.writeFolder && webFolderSupported()) { - // Never write until the handle's permission is verified this session, - // so a reload with a permission-pending handle can't overwrite files. - if (!connectedWebFolders.has(id)) { - const handle = await loadHandle(id) - if (!handle || !(await verifyPermission(handle, 'readwrite', false))) return - connectedWebFolders.add(id) - } - const handle = await loadHandle(id) - if (!handle) return - try { - const nodes = await nodesForMdSave(id, data) - await writeFolderToHandle(handle, serializeToMdFolder({ ...data, nodes }, meta.name)) - } catch (e) { - console.warn('[documentStore] web folder write failed:', e) - return - } - } else { - return - } + if (meta.source !== 'ls') return enqueueSave(id, data) + writeLSPayload(id, data) meta.lastModified = Date.now() writeIndex(idx) notify() @@ -553,6 +681,8 @@ export const documents: DocumentsAPI = { if (opts?.wipePayload !== false) deleteLSPayload(id) if (removed?.source === 'md') { connectedWebFolders.delete(id) + mdBodyPaths.delete(id) + webSessions.delete(id) void removeHandle(id) } if (idx.activeId === id) idx.activeId = idx.docs[0]?.id ?? null @@ -698,6 +828,7 @@ export const documents: DocumentsAPI = { } await saveHandle(meta.id, handle) connectedWebFolders.add(meta.id) + webSessions.delete(meta.id) idx.docs.push(meta) idx.activeId = meta.id writeIndex(idx) @@ -707,7 +838,7 @@ export const documents: DocumentsAPI = { return null }, - async saveAsFolder(id, data) { + async saveAsFolder(id, data, confirmForeignFolder) { const h = host() // ── Electron: pick a destination folder, write via IPC. ── if (h.pickFolder && h.writeFolder) { @@ -717,6 +848,10 @@ export const documents: DocumentsAPI = { const picked = await h.pickFolder() if (!picked.success || !picked.folderPath) return null const name = defaultNameFromFolder(picked.folderPath) + const present = h.readFolder ? await h.readFolder(picked.folderPath) : null + if (present?.success && present.files && !(await mayWriteInto(present.files, name, confirmForeignFolder))) { + return null + } const files = serializeToMdFolder(data, name) const res = await h.writeFolder(picked.folderPath, files) if (!res.success) { @@ -742,14 +877,19 @@ export const documents: DocumentsAPI = { const handle = await pickWebDirectory() if (!handle) return null const name = handle.name || meta.name + const session = new MdFolderSession(handleFolderStorage(handle)) try { - await writeFolderToHandle(handle, serializeToMdFolder(data, name)) + if (!(await mayWriteInto(await session.readAll(), name, confirmForeignFolder))) return null + const res = await session.write(serializeToMdFolder(data, name)) + if (!res.ok) return null // changed while we were writing; nothing written } catch (e) { console.warn('[documentStore] web saveAsFolder write failed:', e) return null } await saveHandle(meta.id, handle) connectedWebFolders.add(meta.id) + session.onExternalChange = () => notifyExternalChange(meta.id) + webSessions.set(meta.id, session) if (meta.source === 'ls') deleteLSPayload(id) meta.source = 'md' meta.folderPath = undefined @@ -777,6 +917,45 @@ export const documents: DocumentsAPI = { return connectedWebFolders.has(id) }, + watchDocument(id, onExternalChange) { + const meta = readIndex().docs.find(d => d.id === id) + if (meta?.source !== 'md') return () => {} + const h = host() + externalChangeListeners.set(id, onExternalChange) + const stopListening = (): void => { + if (externalChangeListeners.get(id) === onExternalChange) externalChangeListeners.delete(id) + } + + if (meta.folderPath && h.watchFolder && h.onFolderChanged) { + if (!routedHosts.has(h.onFolderChanged)) { + routedHosts.add(h.onFolderChanged) + h.onFolderChanged(({ folderPath }) => { + for (const d of readIndex().docs) { + if (d.source === 'md' && d.folderPath === folderPath) notifyExternalChange(d.id) + } + }) + } + const folderPath = meta.folderPath + void h.watchFolder(folderPath) + return () => { + stopListening() + void h.watchFolder?.(null) + } + } + + if (!h.writeFolder && webFolderSupported()) { + const timer = setInterval(() => { + if (!connectedWebFolders.has(id)) return + webSessions.get(id)?.poll().catch((e) => console.warn('[documentStore] folder poll failed:', e)) + }, WEB_FOLDER_POLL_MS) + return () => { + stopListening() + clearInterval(timer) + } + } + return stopListening + }, + ensureActive(seedIfEmpty) { let idx = migrateLegacyIfNeeded(readIndex()) if (idx.docs.length === 0) { diff --git a/apps/studio/tests/diskFolderStorage.test.ts b/apps/studio/tests/diskFolderStorage.test.ts new file mode 100644 index 00000000..d4c756e1 --- /dev/null +++ b/apps/studio/tests/diskFolderStorage.test.ts @@ -0,0 +1,57 @@ +// The Electron main process's md-folder storage, against a real directory. +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { MdFolderSession } from '@radical/common/formats/mdFolderSync' +import { diskFolderStorage } from '../src/main/diskFolderStorage' + +const node = (id: string, body = ''): string => `---\nid: "${id}"\ntype: "system"\n---\n${body}` + +describe('diskFolderStorage', () => { + let root: string + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'radical-md-')) }) + afterEach(async () => { await rm(root, { recursive: true, force: true }) }) + + it('round-trips a model and ignores everything that is not one', async () => { + const files = { 'radical.md': 'm', 'nodes/a/_index.md': node('a'), 'nodes/a/b.md': node('b'), 'views.json': '[]' } + await new MdFolderSession(diskFolderStorage(root)).write(files) + await writeFile(join(root, 'package.json'), '{}') + await mkdir(join(root, 'node_modules/x'), { recursive: true }) + await writeFile(join(root, 'node_modules/x/README.md'), 'x') + await mkdir(join(root, 'nodes/.git'), { recursive: true }) + await writeFile(join(root, 'nodes/.git/HEAD.md'), 'x') + + expect(await new MdFolderSession(diskFolderStorage(root)).readAll()).toEqual(files) + }) + + it('detects an outside edit and will not overwrite it', async () => { + const session = new MdFolderSession(diskFolderStorage(root)) + await session.write({ 'radical.md': 'm', 'nodes/a.md': node('a') }) + expect(await session.poll()).toEqual([]) + + await writeFile(join(root, 'nodes/a.md'), node('a', 'edited in another editor')) + expect(await session.poll()).toEqual(['nodes/a.md']) + expect(await session.write({ 'radical.md': 'm', 'nodes/a.md': node('a', 'app') })) + .toEqual({ ok: false, conflict: ['nodes/a.md'] }) + expect(await readFile(join(root, 'nodes/a.md'), 'utf-8')).toBe(node('a', 'edited in another editor')) + }) + + it('prunes a renamed node\'s old file but keeps hand-written notes', async () => { + const session = new MdFolderSession(diskFolderStorage(root)) + await session.write({ 'radical.md': 'm', 'nodes/old.md': node('a') }) + await writeFile(join(root, 'nodes/README.md'), '# notes') + await session.readAll() + await session.write({ 'radical.md': 'm', 'nodes/new.md': node('a') }) + expect(existsSync(join(root, 'nodes/old.md'))).toBe(false) + expect(existsSync(join(root, 'nodes/new.md'))).toBe(true) + expect(existsSync(join(root, 'nodes/README.md'))).toBe(true) + }) + + it('refuses paths that escape the folder', async () => { + const storage = diskFolderStorage(join(root, 'model')) + await expect(storage.write('../escape.md', 'x')).rejects.toThrow(/outside the model folder/) + expect(existsSync(join(root, 'escape.md'))).toBe(false) + }) +}) diff --git a/apps/studio/tests/mdFolderSafety.test.ts b/apps/studio/tests/mdFolderSafety.test.ts new file mode 100644 index 00000000..f7f0cffb --- /dev/null +++ b/apps/studio/tests/mdFolderSafety.test.ts @@ -0,0 +1,174 @@ +// Regression tests for md-folder data-loss bugs: stale lazy body paths after a +// rename, pruning files the format doesn't own, overlapping folder writes and +// a slow document load landing in another document. +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { documents, readLocalDocument } from '../src/renderer/src/store/documentStore' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { serializeToMdFolder, staleMdFolderFiles } from '@radical/common/formats/mdFolder' +import type { DiagramData } from '@radical/common/c4' + +class MemLS { + private map = new Map() + getItem(k: string): string | null { return this.map.has(k) ? this.map.get(k)! : null } + setItem(k: string, v: string): void { this.map.set(k, String(v)) } + removeItem(k: string): void { this.map.delete(k) } + clear(): void { this.map.clear() } + key(i: number): string | null { return [...this.map.keys()][i] ?? null } + get length(): number { return this.map.size } +} + +const tick = (): Promise => new Promise((r) => setTimeout(r, 0)) + +/** Folder-backed electronAPI over one in-memory folder. Like the main process, + * a write lands file by file (yielding in between, as each IPC-backed fs call + * does) and then prunes stale files the format owns. `gate`, when set, holds + * folder reads until it resolves. */ +function installFolderApi(initial: Record = {}) { + const disk: Record = { ...initial } + const io: { gate: Promise | null } = { gate: null } + ;(globalThis as any).window.electronAPI = { + pickFolder: vi.fn(async () => ({ success: true, folderPath: '/tmp/model' })), + readFolder: vi.fn(async () => { + if (io.gate) await io.gate + return { success: true, files: { ...disk } } + }), + readFile: vi.fn(async (filePath: string) => { + const rel = filePath.replace(/^\/tmp\/model\//, '') + return rel in disk ? { success: true, content: disk[rel] } : { success: false } + }), + writeFolder: vi.fn(async (_path: string, files: Record) => { + for (const [rel, content] of Object.entries(files)) { + await tick() + disk[rel] = content + } + await tick() + for (const rel of staleMdFolderFiles({ ...disk }, files)) delete disk[rel] + return { success: true } + }), + } + return { disk, io } +} + +function sys(id: string, label: string, description?: string, parentId?: string) { + return { + id, type: 'system' as const, label, collapsed: false, x: 0, y: 0, width: 400, height: 300, + ...(description ? { description } : {}), ...(parentId ? { parentId } : {}), + } +} + +const SAMPLE: DiagramData = { + nodes: [sys('sys1', 'Payments', 'Money mover'), sys('sys2', 'Ledger', 'Keeps the books')], + relations: [], +} + +/** An md doc on the fake folder, loaded lazily: no description in memory. */ +async function lazyMdDoc(): Promise<{ id: string; data: DiagramData; disk: Record }> { + const { disk } = installFolderApi() + const ls = documents.createLSDocument('Draft', SAMPLE) + const meta = await documents.saveAsFolder(ls.id, SAMPLE) + const data = await documents.loadDocument(meta!.id) + expect(data!.nodes.every((n) => n.description === undefined)).toBe(true) + return { id: meta!.id, data: data!, disk } +} + +async function bodyOnDisk(id: string, nodeId: string): Promise { + await documents.loadDocument(id) + return documents.hydrateNodeBody(id, nodeId) +} + +describe('md-folder persistence safety', () => { + beforeEach(() => { + ;(globalThis as any).localStorage = new MemLS() + if (!(globalThis as any).crypto?.randomUUID) { + ;(globalThis as any).crypto = { randomUUID: () => 'id-' + Math.random().toString(36).slice(2) } + } + delete (globalThis as any).window.electronAPI + }) + + it('keeps an unopened node\'s description across repeated saves after a rename', async () => { + const { id, data } = await lazyMdDoc() + const renamed = { ...data, nodes: data.nodes.map((n) => n.id === 'sys1' ? { ...n, label: 'Payments Hub' } : n) } + await documents.saveDocument(id, renamed) + await documents.saveDocument(id, renamed) // the old path no longer exists now + expect(await documents.hydrateNodeBody(id, 'sys1')).toBe('Money mover') + expect(await bodyOnDisk(id, 'sys1')).toBe('Money mover') + }) + + it('keeps an unopened node\'s description after it moves into a directory', async () => { + const { id, data } = await lazyMdDoc() + // sys2 becomes a child of sys1, and sys1 (a system) is already a directory. + const moved = { ...data, nodes: data.nodes.map((n) => n.id === 'sys2' ? { ...n, parentId: 'sys1' } : n) } + await documents.saveDocument(id, moved) + await documents.saveDocument(id, moved) + expect(await bodyOnDisk(id, 'sys2')).toBe('Keeps the books') + }) + + it('never deletes files the format does not own', async () => { + const { id, data, disk } = await lazyMdDoc() + disk['package.json'] = '{}' + disk['nodes/README.md'] = '# Notes\n' + await documents.saveDocument(id, { ...data, nodes: data.nodes.filter((n) => n.id !== 'sys2') }) + expect(disk['package.json']).toBe('{}') + expect(disk['nodes/README.md']).toBe('# Notes\n') + expect(Object.keys(disk).some((p) => p.startsWith('nodes/ledger'))).toBe(false) + }) + + it('does not save as folder into a non-model folder unless the user confirms', async () => { + const { disk } = installFolderApi({ 'package.json': '{"name":"app"}' }) + const ls = documents.createLSDocument('Draft', SAMPLE) + const confirm = vi.fn(() => false) + expect(await documents.saveAsFolder(ls.id, SAMPLE, confirm)).toBeNull() + expect(confirm).toHaveBeenCalledWith('model') + expect(Object.keys(disk)).toEqual(['package.json']) + expect(await documents.saveAsFolder(ls.id, SAMPLE)).toBeNull() // no confirm → refuse + + const meta = await documents.saveAsFolder(ls.id, SAMPLE, () => true) + expect(meta!.source).toBe('md') + expect(disk['package.json']).toBe('{"name":"app"}') + expect(disk['radical.md']).toContain('radicalFormat') + }) + + it('saves into an existing model folder without asking', async () => { + installFolderApi(serializeToMdFolder(SAMPLE, 'model')) + const ls = documents.createLSDocument('Draft', SAMPLE) + const confirm = vi.fn(() => false) + expect(await documents.saveAsFolder(ls.id, SAMPLE, confirm)).not.toBeNull() + expect(confirm).not.toHaveBeenCalled() + }) + + it('overlapping saves do not prune each other\'s files', async () => { + const { id, disk } = await lazyMdDoc() + const full = SAMPLE + const renamed = { ...SAMPLE, nodes: SAMPLE.nodes.map((n) => n.id === 'sys1' ? { ...n, label: 'Payments Hub' } : n) } + // The second save starts while the first is still writing. + await Promise.all([documents.saveDocument(id, full), documents.saveDocument(id, renamed)]) + const sys1Files = Object.entries(disk).filter(([p, c]) => p.startsWith('nodes/') && c.includes('"sys1"')) + expect(sys1Files.map(([p]) => p)).toEqual(['nodes/payments-hub/_index.md']) + }) + + it('drops a slow load that finishes after the user switched to another document', async () => { + const { io } = installFolderApi(serializeToMdFolder(SAMPLE, 'model')) + let release!: () => void + io.gate = new Promise((r) => { release = r }) + + documents.createMdDocument('/tmp/model') // becomes active → folder read starts, held by the gate + const other: DiagramData = { nodes: [sys('only', 'Other model')], relations: [] } + const b = documents.createLSDocument('Other', other) // switch away before it lands + await tick() + release() + await tick() + await tick() + + expect(documents.getActiveId()).toBe(b.id) + expect(Object.keys(useDiagramStore.getState().c4Nodes)).toEqual(['only']) + }) + + it('writes an edit made just before a switch to the document being left', async () => { + const a = documents.createLSDocument('A', { nodes: [sys('n1', 'Before')], relations: [] }) + await tick() + expect(Object.keys(useDiagramStore.getState().c4Nodes)).toEqual(['n1']) + useDiagramStore.getState().updateNode('n1', { label: 'After' }) + documents.createLSDocument('B', { nodes: [], relations: [] }) // within the 400 ms debounce + expect(readLocalDocument(a.id)!.nodes.find((n) => n.id === 'n1')!.label).toBe('After') + }) +}) diff --git a/apps/studio/tests/mdFolderWatch.test.ts b/apps/studio/tests/mdFolderWatch.test.ts new file mode 100644 index 00000000..4e90b602 --- /dev/null +++ b/apps/studio/tests/mdFolderWatch.test.ts @@ -0,0 +1,120 @@ +// The active md-folder document follows edits made outside Studio: the host +// reports a folder change, autosave reloads the document from disk. +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { documents, useDocumentsStore } from '../src/renderer/src/store/documentStore' +import { useDiagramStore } from '@radical/ui/store/diagramStore' +import { serializeToMdFolder } from '@radical/common/formats/mdFolder' +import type { FolderChange } from '@radical/host-bridge' +import type { DiagramData } from '@radical/common/c4' + +class MemLS { + private map = new Map() + getItem(k: string): string | null { return this.map.has(k) ? this.map.get(k)! : null } + setItem(k: string, v: string): void { this.map.set(k, String(v)) } + removeItem(k: string): void { this.map.delete(k) } + clear(): void { this.map.clear() } + key(i: number): string | null { return [...this.map.keys()][i] ?? null } + get length(): number { return this.map.size } +} + +const useDocumentsStoreSubscribe = (fn: () => void): (() => void) => useDocumentsStore.subscribe(fn) + +const settle = async (): Promise => { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)) +} + +function sys(id: string, label: string) { + return { id, type: 'system' as const, label, collapsed: false, x: 0, y: 0, width: 400, height: 300 } +} + +const SAMPLE: DiagramData = { nodes: [sys('sys1', 'Payments'), sys('sys2', 'Ledger')], relations: [] } + +function installFolderApi(initial: Record) { + const disk: Record = { ...initial } + let listener: ((change: FolderChange) => void) | null = null + const api = { + readFolder: vi.fn(async () => ({ success: true, files: { ...disk } })), + readFile: vi.fn(async () => ({ success: false })), + writeFolder: vi.fn(async () => ({ success: true })), + watchFolder: vi.fn(async () => {}), + onFolderChanged: vi.fn((l: (change: FolderChange) => void) => { listener = l }), + } + ;(globalThis as any).window.electronAPI = api + return { + api, + /** Replace the folder's content, as an outside editor or git would. */ + setDisk(files: Record) { + for (const k of Object.keys(disk)) delete disk[k] + Object.assign(disk, files) + }, + emit(change: FolderChange) { listener?.(change) }, + } +} + +describe('md-folder documents follow outside edits', () => { + beforeEach(() => { + ;(globalThis as any).localStorage = new MemLS() + if (!(globalThis as any).crypto?.randomUUID) { + ;(globalThis as any).crypto = { randomUUID: () => 'id-' + Math.random().toString(36).slice(2) } + } + }) + + it('reloads the active document when its folder changes, keeping the selection', async () => { + const folder = installFolderApi(serializeToMdFolder(SAMPLE, 'model')) + documents.createMdDocument('/tmp/model') + await settle() + expect(folder.api.watchFolder).toHaveBeenLastCalledWith('/tmp/model') + useDiagramStore.getState().selectNode('sys2') + + folder.setDisk(serializeToMdFolder({ + ...SAMPLE, + nodes: [sys('sys1', 'Renamed outside'), sys('sys2', 'Ledger'), sys('sys3', 'Added outside')], + }, 'model')) + folder.emit({ folderPath: '/tmp/model', paths: ['nodes/renamed-outside/_index.md'] }) + await settle() + + const s = useDiagramStore.getState() + expect(s.c4Nodes.sys1.label).toBe('Renamed outside') + expect(s.c4Nodes.sys3).toBeDefined() + expect(s.selectedNodeId).toBe('sys2') + }) + + it('starts watching when the active document is saved as a folder', async () => { + const folder = installFolderApi({}) + ;(folder.api as any).pickFolder = vi.fn(async () => ({ success: true, folderPath: '/tmp/saved' })) + const ls = documents.createLSDocument('Draft', SAMPLE) + await settle() + folder.api.watchFolder.mockClear() + await documents.saveAsFolder(ls.id, SAMPLE) + await settle() + expect(folder.api.watchFolder).toHaveBeenLastCalledWith('/tmp/saved') + }) + + it('stops watching when another document becomes active', async () => { + const folder = installFolderApi(serializeToMdFolder(SAMPLE, 'model')) + documents.createMdDocument('/tmp/model') + await settle() + documents.createLSDocument('Other', { nodes: [sys('only', 'Other')], relations: [] }) + await settle() + expect(folder.api.watchFolder).toHaveBeenLastCalledWith(null) + + folder.emit({ folderPath: '/tmp/model', paths: ['nodes/payments/_index.md'] }) + await settle() + expect(Object.keys(useDiagramStore.getState().c4Nodes)).toEqual(['only']) + }) + + it('keeps the document unchanged on disk when a save is refused as a conflict', async () => { + const folder = installFolderApi(serializeToMdFolder(SAMPLE, 'model')) + const meta = documents.createMdDocument('/tmp/model') + await settle() + folder.api.writeFolder.mockResolvedValueOnce({ success: false, conflict: ['nodes/payments/_index.md'] } as any) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const notify = vi.fn() + const unsubscribe = useDocumentsStoreSubscribe(notify) + await documents.saveDocument(meta.id, SAMPLE) + unsubscribe() + expect(warn).toHaveBeenCalledWith('[documentStore] folder changed on disk, not overwriting:', ['nodes/payments/_index.md']) + expect(notify).not.toHaveBeenCalled() // not recorded as saved + warn.mockRestore() + }) +}) diff --git a/apps/studio/tests/webFolder.test.ts b/apps/studio/tests/webFolder.test.ts index 8aa516c6..898980f3 100644 --- a/apps/studio/tests/webFolder.test.ts +++ b/apps/studio/tests/webFolder.test.ts @@ -1,22 +1,29 @@ import { describe, it, expect } from 'vitest' +import { MdFolderSession } from '@radical/common/formats/mdFolderSync' import { - readFolderFromHandle, - writeFolderToHandle, + handleFolderStorage, type FsDirHandle, type FsFileHandle, } from '../src/renderer/src/persist/webFolder' // ─── In-memory fake implementing the File System Access API surface used ───── +let clock = 0 + class FakeFile implements FsFileHandle { kind = 'file' as const + lastModified = ++clock constructor(public data: string) {} - getFile(): Promise<{ text(): Promise }> { - return Promise.resolve({ text: () => Promise.resolve(this.data) }) + getFile(): Promise<{ text(): Promise; lastModified: number; size: number }> { + return Promise.resolve({ + text: () => Promise.resolve(this.data), + lastModified: this.lastModified, + size: this.data.length, + }) } createWritable(): Promise<{ write(d: string): Promise; close(): Promise }> { return Promise.resolve({ - write: (d: string) => { this.data = d; return Promise.resolve() }, + write: (d: string) => { this.data = d; this.lastModified = ++clock; return Promise.resolve() }, close: () => Promise.resolve(), }) } @@ -55,6 +62,8 @@ class FakeDir implements FsDirHandle { } } +const session = (root: FakeDir): MdFolderSession => new MdFolderSession(handleFolderStorage(root)) + describe('webFolder read/write', () => { it('writes a nested file map and reads it back', async () => { const root = new FakeDir('model') @@ -64,32 +73,51 @@ describe('webFolder read/write', () => { 'nodes/system-a/db.md': '---\nid: "b"\n---\n', 'relations.json': '[]', } - await writeFolderToHandle(root, files) - const back = await readFolderFromHandle(root) - expect(back).toEqual(files) + await session(root).write(files) + expect(await session(root).readAll()).toEqual(files) }) - it('prunes managed files that disappear, keeps unrelated files', async () => { + it('prunes node files and sidecars that disappear, keeps files it does not own', async () => { const root = new FakeDir('model') - await writeFolderToHandle(root, { - 'radical.md': 'x', - 'nodes/a.md': 'A', - 'nodes/b.md': 'B', - 'notes.txt': 'keep me', // unmanaged extension — never touched - }) - // notes.txt is unmanaged; simulate it existing by writing directly. - ;(root.children.get('notes.txt') as FakeFile) // ensured by write above + const a = '---\nid: "a"\ntype: "system"\n---\n' + const b = '---\nid: "b"\ntype: "system"\n---\n' + const s = session(root) + await s.write({ 'radical.md': 'x', 'nodes/a.md': a, 'nodes/b.md': b, 'views.json': '[]' }) + // Files that aren't the model's own, placed by the user. + root.children.set('notes.txt', new FakeFile('keep me')) + root.children.set('package.json', new FakeFile('{}')) + ;(root.children.get('nodes') as FakeDir).children.set('README.md', new FakeFile('# Notes\n')) + await s.readAll() - // Second write drops nodes/b.md. - await writeFolderToHandle(root, { - 'radical.md': 'x', - 'nodes/a.md': 'A', - }) - const back = await readFolderFromHandle(root) - expect(back['nodes/a.md']).toBe('A') - expect('nodes/b.md' in back).toBe(false) - // The unmanaged file survives (readFolder only returns .md/.json, but the - // node still exists in the tree). + // Second write drops nodes/b.md and views.json. + await s.write({ 'radical.md': 'x', 'nodes/a.md': a }) + const back = await session(root).readAll() + expect(back).toEqual({ 'radical.md': 'x', 'nodes/a.md': a, 'nodes/README.md': '# Notes\n' }) + expect(root.children.has('views.json')).toBe(false) + expect(root.children.has('package.json')).toBe(true) expect(root.children.has('notes.txt')).toBe(true) }) + + it('reads only model files, never walking other directories', async () => { + const root = new FakeDir('repo') + await session(root).write({ 'radical.md': 'x', 'nodes/a.md': 'A' }) + const deps = new FakeDir('node_modules') + deps.children.set('lib.md', new FakeFile('not ours')) + root.children.set('node_modules', deps) + root.children.set('README.md', new FakeFile('# repo')) + expect(await session(root).readAll()).toEqual({ 'radical.md': 'x', 'nodes/a.md': 'A' }) + }) + + it('notices a file edited outside the app and refuses to overwrite it', async () => { + const root = new FakeDir('model') + const s = session(root) + await s.write({ 'radical.md': 'x', 'nodes/a.md': 'A' }) + const file = (root.children.get('nodes') as FakeDir).children.get('a.md') as FakeFile + file.data = 'edited elsewhere' + file.lastModified = ++clock + + expect(await s.poll()).toEqual(['nodes/a.md']) + expect(await s.write({ 'radical.md': 'x', 'nodes/a.md': 'app edit' })).toEqual({ ok: false, conflict: ['nodes/a.md'] }) + expect(file.data).toBe('edited elsewhere') + }) }) diff --git a/packages/common/package.json b/packages/common/package.json index 53ed1254..e0f58108 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -12,6 +12,7 @@ "./wireframe": "./src/wireframe.ts", "./formats/structurizrDsl": "./src/formats/structurizrDsl.ts", "./formats/mdFolder": "./src/formats/mdFolder.ts", + "./formats/mdFolderSync": "./src/formats/mdFolderSync.ts", "./formats/exportGherkin": "./src/formats/exportGherkin.ts", "./ai/queryLanguage": "./src/ai/queryLanguage.ts", "./ai/diagramFacade": "./src/ai/diagramFacade.ts", diff --git a/packages/common/src/formats/mdFolder.ts b/packages/common/src/formats/mdFolder.ts index feb77a03..c8b5bc51 100644 --- a/packages/common/src/formats/mdFolder.ts +++ b/packages/common/src/formats/mdFolder.ts @@ -44,6 +44,19 @@ const HUB_TEMPLATES_FILE = 'hubTemplates.json' const NODES_DIR = 'nodes' const INDEX_BASENAME = '_index.md' +/** Top-level JSON sidecars this format writes. Only these (plus the manifest + * and node `.md` files) are ever pruned from a folder — see `staleMdFolderFiles`. */ +const SIDECAR_FILES = new Set([ + LAYOUT_FILE, + RELATIONS_FILE, + VIEWS_FILE, + SEQUENCES_FILE, + SNAPSHOTS_FILE, + PRESENTATIONS_FILE, + METAMODEL_FILE, + HUB_TEMPLATES_FILE, +]) + const FORMAT_VERSION = 1 /** Node keys that are layout/state, not semantic — kept out of the `.md`. */ @@ -189,7 +202,20 @@ function uniqueSlug(base: string, used: Set): string { // ─── Serialize: DiagramData → folder files ─────────────────────────────────── export function serializeToMdFolder(data: DiagramData, modelName?: string): FolderFiles { + return serializeToMdFolderWithPaths(data, modelName).files +} + +export interface SerializeMdFolderResult { + files: FolderFiles + /** nodeId → relative path of the `.md` file holding that node. */ + nodePaths: Record +} + +/** `serializeToMdFolder`, also reporting where each node's file landed — + * paths move when a node is renamed, re-parented or gains its first child. */ +export function serializeToMdFolderWithPaths(data: DiagramData, modelName?: string): SerializeMdFolderResult { const files: FolderFiles = {} + const nodePaths: Record = {} const layout: LayoutSidecar = { nodes: {} } // Manifest. @@ -242,10 +268,12 @@ export function serializeToMdFolder(data: DiagramData, modelName?: string): Fold const isDir = isContainerType(node.type) || kids.length > 0 if (isDir) { const nodeDir = `${dir}/${slug}` - files[`${nodeDir}/${INDEX_BASENAME}`] = md + nodePaths[node.id] = `${nodeDir}/${INDEX_BASENAME}` + files[nodePaths[node.id]] = md writeChildren(kids, nodeDir) } else { - files[`${dir}/${slug}.md`] = md + nodePaths[node.id] = `${dir}/${slug}.md` + files[nodePaths[node.id]] = md } } @@ -282,7 +310,7 @@ export function serializeToMdFolder(data: DiagramData, modelName?: string): Fold files[HUB_TEMPLATES_FILE] = stableStringify(data.hubTemplates) } - return files + return { files, nodePaths } } function writeJsonIfPresent( @@ -412,6 +440,33 @@ export function isMdFolder(files: FolderFiles): boolean { return typeof files[MD_MANIFEST_FILE] === 'string' } +/** True for a path where this format keeps model content: the manifest, a + * known JSON sidecar, or a `.md` under `nodes/`. The files a folder session + * reads, stamps and watches — everything else in the folder is ignored. */ +export function isMdFolderModelPath(path: string): boolean { + if (path === MD_MANIFEST_FILE || SIDECAR_FILES.has(path)) return true + return path.startsWith(NODES_DIR + '/') && path.endsWith('.md') +} + +/** True for a file this format writes and may therefore delete: the manifest, + * a known JSON sidecar, or a node `.md` under `nodes/` carrying an `id` and + * `type` in its frontmatter. Anything else in the folder — a `package.json`, + * a hand-written `nodes/README.md` — belongs to the user. */ +export function isOwnedMdFolderFile(path: string, content: string): boolean { + if (path === MD_MANIFEST_FILE || SIDECAR_FILES.has(path)) return true + if (!isMdFolderModelPath(path)) return false + const { front } = parseMarkdown(content) + return !!front.id && !!front.type +} + +/** Paths in `existing` (what is on disk now) that a write of `next` leaves + * stale: files we own that `next` no longer contains. */ +export function staleMdFolderFiles(existing: FolderFiles, next: FolderFiles): string[] { + return Object.keys(existing).filter( + (path) => !(path in next) && isOwnedMdFolderFile(path, existing[path]), + ) +} + function readJson(content: string | undefined): T | undefined { if (!content) return undefined try { diff --git a/packages/common/src/formats/mdFolderSync.ts b/packages/common/src/formats/mdFolderSync.ts new file mode 100644 index 00000000..5d347633 --- /dev/null +++ b/packages/common/src/formats/mdFolderSync.ts @@ -0,0 +1,170 @@ +// ─── Md-folder sync session ────────────────────────────────────────────────── +// +// One md-folder model on some storage — Node's fs in Electron's main process, +// a File System Access directory handle in the browser. The session keeps a +// baseline: each model file's content and stamp as the app last read or wrote +// it. From that: +// +// • `write` touches only files whose content changed, prunes the stale files +// the format owns, and refuses to run at all when the folder changed on +// disk since the app last read it — the caller reloads instead of +// overwriting an edit made in another editor or by git; +// • `poll` reports such outside edits so the app can reload promptly. +// +// Stamps are cheap change markers (mtime + size, whatever the storage offers): +// only files whose stamp moved are read, and a file counts as changed only when +// its content differs. Operations run one at a time; a poll that would have to +// wait is skipped rather than queued. + +import { isMdFolderModelPath, staleMdFolderFiles, type FolderFiles } from './mdFolder' + +/** Where a session's files live. Paths are POSIX, relative to the folder. */ +export interface MdFolderStorage { + /** Stamp of every model file present (see `isMdFolderModelPath`). */ + list(): Promise> + read(path: string): Promise + /** Write a file, creating directories as needed; resolves with its new stamp. */ + write(path: string, content: string): Promise + remove(path: string): Promise +} + +export type MdFolderWriteResult = + | { ok: true } + /** Nothing was written: these files changed on disk since the last read. */ + | { ok: false; conflict: string[] } + +interface BaselineEntry { + content: string + stamp: string +} + +export class MdFolderSession { + /** Called with the changed paths whenever `poll` or a refused `write` + * finds edits made outside the app. */ + onExternalChange: ((paths: string[]) => void) | null = null + + private baseline = new Map() + private loaded = false + /** Stamps as of the last look at the folder, so one outside edit is + * reported once rather than on every poll until the app reloads. */ + private observed: Record = {} + private tail: Promise = Promise.resolve() + private running = 0 + + constructor(private readonly storage: MdFolderStorage) {} + + /** Read every model file and make that the baseline. */ + readAll(): Promise { + return this.exclusive(async () => { + await this.loadBaseline() + const files: FolderFiles = {} + for (const [path, entry] of this.baseline) files[path] = entry.content + return files + }) + } + + /** Make the folder hold exactly the model `files` (plus whatever the + * format doesn't own), unless it changed on disk since the last read. */ + write(files: FolderFiles): Promise { + return this.exclusive(async () => { + // A folder never read in this session (a fresh "save as folder", a + // restarted main process): what is on disk now is the baseline. + if (!this.loaded) await this.loadBaseline() + const stamps = await this.storage.list() + const conflict = await this.changedSince(stamps) + if (conflict.length > 0) { + this.observed = stamps + this.onExternalChange?.(conflict) + return { ok: false, conflict } + } + + for (const [path, content] of Object.entries(files)) { + if (this.baseline.get(path)?.content === content) continue + const stamp = await this.storage.write(path, content) + this.baseline.set(path, { content, stamp }) + } + const current: FolderFiles = {} + for (const [path, entry] of this.baseline) current[path] = entry.content + for (const path of staleMdFolderFiles(current, files)) { + await this.storage.remove(path) + this.baseline.delete(path) + } + this.observed = this.baselineStamps() + return { ok: true } + }) + } + + /** Look for edits made outside the app since the folder was last read, + * written or polled. Resolves with the changed paths (empty when none, or + * when another operation is running — the next poll will see it). */ + poll(): Promise { + if (this.running > 0 || !this.loaded) return Promise.resolve([]) + return this.exclusive(async () => { + const stamps = await this.storage.list() + if (sameStamps(stamps, this.observed)) return [] + this.observed = stamps + const changed = await this.changedSince(stamps) + if (changed.length > 0) this.onExternalChange?.(changed) + return changed + }) + } + + private async loadBaseline(): Promise { + const stamps = await this.storage.list() + const baseline = new Map() + for (const [path, stamp] of Object.entries(stamps)) { + baseline.set(path, { content: await this.storage.read(path), stamp }) + } + this.baseline = baseline + this.observed = stamps + this.loaded = true + } + + /** Paths whose content on disk differs from the baseline: added, removed, + * or rewritten with different content. A file whose stamp moved but whose + * content didn't (touched, saved unchanged) just gets its stamp updated. */ + private async changedSince(stamps: Record): Promise { + const changed: string[] = [] + for (const [path, stamp] of Object.entries(stamps)) { + const known = this.baseline.get(path) + if (!known) { + changed.push(path) + } else if (known.stamp !== stamp) { + let content: string + try { + content = await this.storage.read(path) + } catch { + changed.push(path) // vanished between list and read + continue + } + if (content === known.content) known.stamp = stamp + else changed.push(path) + } + } + for (const path of this.baseline.keys()) { + if (!(path in stamps)) changed.push(path) + } + return changed.sort() + } + + private baselineStamps(): Record { + const stamps: Record = {} + for (const [path, entry] of this.baseline) stamps[path] = entry.stamp + return stamps + } + + private exclusive(fn: () => Promise): Promise { + this.running++ + const run = this.tail.then(fn, fn) + this.tail = run.catch(() => {}) + const done = (): void => { this.running-- } + run.then(done, done) + return run + } +} + +function sameStamps(a: Record, b: Record): boolean { + const keys = Object.keys(a) + if (keys.length !== Object.keys(b).length) return false + return keys.every((key) => b[key] === a[key]) +} diff --git a/packages/common/tests/mdFolderPersistence.test.ts b/packages/common/tests/mdFolderPersistence.test.ts index d1de823c..18fb4d84 100644 --- a/packages/common/tests/mdFolderPersistence.test.ts +++ b/packages/common/tests/mdFolderPersistence.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from 'vitest' import { serializeToMdFolder, + serializeToMdFolderWithPaths, deserializeFromMdFolder, isMdFolder, + staleMdFolderFiles, MD_MANIFEST_FILE, } from '../src/formats/mdFolder' import type { DiagramData, C4Node } from '../src/c4' @@ -141,3 +143,27 @@ describe('md-folder persistence', () => { expect(files['relations.json'].endsWith('\n')).toBe(true) }) }) + +describe('md-folder ownership', () => { + it('reports where each node was written', () => { + const { files, nodePaths } = serializeToMdFolderWithPaths(sample) + expect(nodePaths.sys1).toBe('nodes/payment-system/_index.md') + expect(nodePaths.db1).toBe('nodes/payment-system/api/ledger-db.md') + for (const path of Object.values(nodePaths)) expect(files[path]).toBeDefined() + }) + + it('prunes only files the format owns', () => { + const next = serializeToMdFolder(sample) + const existing = { + ...next, + 'nodes/old-name.md': '---\nid: "gone"\ntype: "system"\n---\n', // a node we wrote earlier + 'hubTemplates.json': '{}', // a sidecar we wrote earlier + 'package.json': '{}', // not ours + 'tsconfig.json': '{}', // not ours + 'nodes/README.md': '# Notes\n', // hand-written, no frontmatter + 'nodes/draft.md': '---\ntitle: "x"\n---\n', // frontmatter without id/type + 'docs/intro.md': '---\nid: "a"\ntype: "system"\n---\n', // outside nodes/ + } + expect(staleMdFolderFiles(existing, next).sort()).toEqual(['hubTemplates.json', 'nodes/old-name.md']) + }) +}) diff --git a/packages/common/tests/mdFolderSync.test.ts b/packages/common/tests/mdFolderSync.test.ts new file mode 100644 index 00000000..cfea86c6 --- /dev/null +++ b/packages/common/tests/mdFolderSync.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi } from 'vitest' +import { MdFolderSession, type MdFolderStorage } from '../src/formats/mdFolderSync' +import { isMdFolderModelPath } from '../src/formats/mdFolder' + +/** In-memory folder. Every write bumps a file's stamp, like an mtime would. */ +function memStorage(initial: Record = {}) { + const files = new Map() + let clock = 0 + const put = (path: string, content: string): string => { + const stamp = `t${++clock}` + files.set(path, { content, stamp }) + return stamp + } + for (const [path, content] of Object.entries(initial)) put(path, content) + const storage: MdFolderStorage = { + list: async () => { + const out: Record = {} + for (const [path, f] of files) if (isMdFolderModelPath(path)) out[path] = f.stamp + return out + }, + read: async (path) => { + const f = files.get(path) + if (!f) throw new Error('NotFound') + return f.content + }, + write: vi.fn(async (path: string, content: string) => put(path, content)), + remove: vi.fn(async (path: string) => { files.delete(path) }), + } + return { + storage, + /** An edit made outside the app. */ + edit: (path: string, content: string) => put(path, content), + /** Rewrite a file with the same content (new stamp). */ + touch: (path: string) => put(path, files.get(path)!.content), + drop: (path: string) => files.delete(path), + content: (path: string) => files.get(path)?.content, + paths: () => [...files.keys()].sort(), + } +} + +const node = (id: string, body = ''): string => `---\nid: "${id}"\ntype: "system"\n---\n${body}` + +const MODEL = { + 'radical.md': '---\nradicalFormat: "md-folder"\n---\n', + 'nodes/a.md': node('a'), + 'nodes/b.md': node('b'), + 'relations.json': '[]\n', +} + +describe('MdFolderSession', () => { + it('reads only model files', async () => { + const fs = memStorage({ ...MODEL, 'package.json': '{}', 'docs/x.md': 'x' }) + const session = new MdFolderSession(fs.storage) + expect(await session.readAll()).toEqual(MODEL) + }) + + it('writes only files whose content changed, and prunes stale owned files', async () => { + const fs = memStorage({ ...MODEL, 'nodes/README.md': '# notes\n', 'package.json': '{}' }) + const session = new MdFolderSession(fs.storage) + await session.readAll() + const next = { ...MODEL, 'nodes/a.md': node('a', 'changed') } + delete (next as Record)['nodes/b.md'] + + expect(await session.write(next)).toEqual({ ok: true }) + expect(fs.storage.write).toHaveBeenCalledTimes(1) + expect(fs.storage.write).toHaveBeenCalledWith('nodes/a.md', node('a', 'changed')) + expect(fs.storage.remove).toHaveBeenCalledTimes(1) + expect(fs.paths()).toEqual(['nodes/README.md', 'nodes/a.md', 'package.json', 'radical.md', 'relations.json']) + }) + + it('refuses to write over an edit made outside the app, and reports it', async () => { + const fs = memStorage(MODEL) + const session = new MdFolderSession(fs.storage) + const onChange = vi.fn() + session.onExternalChange = onChange + await session.readAll() + fs.edit('nodes/b.md', node('b', 'edited in vim')) + + const res = await session.write({ ...MODEL, 'nodes/a.md': node('a', 'app edit') }) + expect(res).toEqual({ ok: false, conflict: ['nodes/b.md'] }) + expect(fs.storage.write).not.toHaveBeenCalled() + expect(fs.content('nodes/b.md')).toBe(node('b', 'edited in vim')) + expect(onChange).toHaveBeenCalledWith(['nodes/b.md']) + + // Once the app has re-read the folder, writing works again. + await session.readAll() + expect(await session.write({ ...MODEL, 'nodes/b.md': node('b', 'edited in vim') })).toEqual({ ok: true }) + }) + + it('treats files added or deleted outside the app as conflicts too', async () => { + const fs = memStorage(MODEL) + const session = new MdFolderSession(fs.storage) + await session.readAll() + fs.edit('nodes/c.md', node('c')) + fs.drop('nodes/a.md') + expect(await session.write(MODEL)).toEqual({ ok: false, conflict: ['nodes/a.md', 'nodes/c.md'] }) + }) + + it('ignores a file rewritten with the same content', async () => { + const fs = memStorage(MODEL) + const session = new MdFolderSession(fs.storage) + await session.readAll() + fs.touch('nodes/a.md') + expect(await session.poll()).toEqual([]) + expect(await session.write(MODEL)).toEqual({ ok: true }) + }) + + it('polls: reports an outside edit once, and never its own writes', async () => { + const fs = memStorage(MODEL) + const session = new MdFolderSession(fs.storage) + const onChange = vi.fn() + session.onExternalChange = onChange + await session.readAll() + + await session.write({ ...MODEL, 'nodes/a.md': node('a', 'app edit') }) + expect(await session.poll()).toEqual([]) + + fs.edit('nodes/b.md', node('b', 'git pull')) + expect(await session.poll()).toEqual(['nodes/b.md']) + expect(await session.poll()).toEqual([]) // already reported + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('does not poll a folder it never read', async () => { + const fs = memStorage(MODEL) + const session = new MdFolderSession(fs.storage) + fs.edit('nodes/a.md', node('a', 'x')) + expect(await session.poll()).toEqual([]) + }) + + it('takes the folder as it is on a first write without a read', async () => { + const fs = memStorage(MODEL) + const session = new MdFolderSession(fs.storage) + expect(await session.write({ ...MODEL, 'nodes/a.md': node('a', 'new') })).toEqual({ ok: true }) + expect(fs.storage.write).toHaveBeenCalledTimes(1) + }) + + it('skips a poll while a write is running instead of queueing it', async () => { + const fs = memStorage(MODEL) + const session = new MdFolderSession(fs.storage) + await session.readAll() + const writing = session.write({ ...MODEL, 'nodes/a.md': node('a', 'new') }) + expect(await session.poll()).toEqual([]) + await writing + }) +}) diff --git a/packages/host-bridge/src/index.ts b/packages/host-bridge/src/index.ts index 1d944fc9..9a787e0b 100644 --- a/packages/host-bridge/src/index.ts +++ b/packages/host-bridge/src/index.ts @@ -27,11 +27,23 @@ export interface ReadFolderResult extends HostResult { files?: Record } +export interface WriteFolderResult extends HostResult { + /** Set when nothing was written because these files (relative paths) + * changed on disk since the folder was last read. */ + conflict?: string[] +} + export interface FileChange { filePath: string content: string } +export interface FolderChange { + folderPath: string + /** Relative paths of the model files that changed. */ + paths: string[] +} + /** What a host can do. Everything but `kind` is optional. */ export interface HostCapabilities { /** Save dialog, then write `json` to the chosen file. */ @@ -40,12 +52,19 @@ export interface HostCapabilities { openDiagram?(): Promise readFile?(filePath: string): Promise writeFile?(filePath: string, content: string): Promise - /** Folder dialog, then read every file in the chosen folder. */ + /** Folder dialog, then read the model files in the chosen folder. */ openFolder?(): Promise /** Folder dialog only. */ pickFolder?(): Promise + /** Read the md-folder model files in a folder. */ readFolder?(folderPath: string): Promise - writeFolder?(folderPath: string, files: Record): Promise + /** Write an md-folder model, unless the folder changed on disk since it was + * last read (then `conflict` lists what changed and nothing is written). */ + writeFolder?(folderPath: string, files: Record): Promise + /** Watch one md-folder for edits made outside Studio (null stops). */ + watchFolder?(folderPath: string | null): Promise + /** Called when the watched folder's model files change outside Studio. */ + onFolderChanged?(listener: (change: FolderChange) => void): void /** The file the host opened Studio with (CLI --file, VS Code editor), if any. */ getWatchedPath?(): Promise /** Called when the watched file changes outside Studio. */ From 9d2fb02ed4c43eccdde46b7f5b9b1667cf3dca78 Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Wed, 30 Sep 2026 18:19:50 +0200 Subject: [PATCH 2/3] test(e2e): cover markdown-folder models in the web build Save as folder, reload, renames that move unopened elements' files, edits made outside Studio, and files Studio must leave alone. The directory picker returns an OPFS directory the test can also edit. Runs in a persistent profile: Chromium crashes when an off-the-record page reads a stored directory handle back from IndexedDB. Co-Authored-By: Claude Opus 5.5 --- apps/e2e/README.md | 8 ++ apps/e2e/support/folder.ts | 81 ++++++++++++++ apps/e2e/tests/studio/folders.spec.ts | 152 ++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 apps/e2e/support/folder.ts create mode 100644 apps/e2e/tests/studio/folders.spec.ts diff --git a/apps/e2e/README.md b/apps/e2e/README.md index a94294da..b997952f 100644 --- a/apps/e2e/README.md +++ b/apps/e2e/README.md @@ -28,6 +28,7 @@ The first time, install Chromium: `npx playwright install chromium`. | `studio/layout` | Smart Layout: no overlapping siblings, children inside their parent, same result on every run | | `studio/presentation` | Playing slides, keyboard navigation, slide deep links | | `studio/export` | PNG and SVG export produce real files | +| `studio/folders` | Models stored as a folder of Markdown files: save as folder, reload, renames that move files, edits made outside Studio, files Studio must not touch | | `studio/known-issues` | Open bugs, marked `test.fail` (see below) | | `hub/hub` | Landing page, categories, search, concept canvas / wiki / table, `.radical` download | @@ -39,6 +40,13 @@ tests the catalogue that is about to be deployed. Tests start from a known document instead of clicking one together: see `fixtures/bookstore.radical` and `Studio.seed()` in `support/fixtures.ts`. +Folder tests (`support/folder.ts`) run the web build's File System Access +code against a directory in the browser's origin-private file system: the +directory picker is replaced with one that returns it, and the test edits it +directly to play another editor. They run in a persistent browser profile, +because Chromium crashes when an off-the-record page (Playwright's default) +reads a stored directory handle back from IndexedDB. + ## Screenshots Visual checks use `toHaveScreenshot`. Baselines live in diff --git a/apps/e2e/support/folder.ts b/apps/e2e/support/folder.ts new file mode 100644 index 00000000..81e533fd --- /dev/null +++ b/apps/e2e/support/folder.ts @@ -0,0 +1,81 @@ +import type { Page } from '@playwright/test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test as base } from './fixtures' + +/** + * The Studio `test`, with its page in a persistent (on-disk) browser profile + * instead of Playwright's usual off-the-record one. Chromium takes the whole + * browser down when an off-the-record page reads a stored OPFS directory handle + * back from IndexedDB — which the app does on every folder save and load. A + * regular profile, like a user's, is not affected. + */ +export const test = base.extend<{ page: Page }>({ + page: async ({ playwright }, use, testInfo) => { + const { viewport, deviceScaleFactor, locale, timezoneId, colorScheme, baseURL, userAgent, headless } = testInfo.project.use + const profile = await mkdtemp(join(tmpdir(), 'radical-e2e-')) + const context = await playwright.chromium.launchPersistentContext(profile, { + headless: headless ?? true, viewport, deviceScaleFactor, locale, timezoneId, colorScheme, baseURL, userAgent, + }) + await use(context.pages()[0] ?? await context.newPage()) + await context.close() + await rm(profile, { recursive: true, force: true }) + }, +}) + +/** + * A model folder for Studio's web build, which works on real directories + * through the File System Access API. Playwright cannot drive the native + * directory picker, so `showDirectoryPicker()` is replaced with one that hands + * out a directory in the page's origin-private file system (OPFS): a real + * directory handle the app reads, writes, stores in IndexedDB and polls, and + * one the test can edit behind the app's back. Each test's profile has its + * own, empty OPFS. + */ +export class ModelFolder { + constructor(readonly page: Page, readonly name = 'model') {} + + /** Make the directory picker return this folder. Call before navigating. */ + async install(): Promise { + await this.page.addInitScript((name) => { + ;(window as unknown as { showDirectoryPicker: () => Promise }) + .showDirectoryPicker = async () => + (await navigator.storage.getDirectory()).getDirectoryHandle(name, { create: true }) + }, this.name) + } + + /** Every file in the folder: relative path → content. */ + async files(): Promise> { + return this.page.evaluate(async (name) => { + const out: Record = {} + const walk = async (dir: FileSystemDirectoryHandle, prefix: string): Promise => { + for await (const [entryName, entry] of (dir as unknown as AsyncIterable<[string, FileSystemHandle]>)) { + const rel = prefix ? `${prefix}/${entryName}` : entryName + if (entry.kind === 'directory') await walk(entry as FileSystemDirectoryHandle, rel) + else out[rel] = await (await (entry as FileSystemFileHandle).getFile()).text() + } + } + const root = await (await navigator.storage.getDirectory()).getDirectoryHandle(name, { create: true }) + await walk(root, '') + return out + }, this.name) + } + + /** Paths of the files in the folder, sorted. */ + async paths(): Promise { + return Object.keys(await this.files()).sort() + } + + /** Write a file as another program would (an editor, git), outside Studio. */ + async write(path: string, content: string): Promise { + await this.page.evaluate(async ([name, rel, text]) => { + let dir = await (await navigator.storage.getDirectory()).getDirectoryHandle(name, { create: true }) + const parts = rel.split('/') + for (const part of parts.slice(0, -1)) dir = await dir.getDirectoryHandle(part, { create: true }) + const writable = await (await dir.getFileHandle(parts[parts.length - 1], { create: true })).createWritable() + await writable.write(text) + await writable.close() + }, [this.name, path, content] as const) + } +} diff --git a/apps/e2e/tests/studio/folders.spec.ts b/apps/e2e/tests/studio/folders.spec.ts new file mode 100644 index 00000000..8573bff3 --- /dev/null +++ b/apps/e2e/tests/studio/folders.spec.ts @@ -0,0 +1,152 @@ +import { expect } from '../../support/fixtures' +import { ModelFolder, test } from '../../support/folder' +import type { Page } from '@playwright/test' + +// Models persisted as a folder of Markdown files (web build: File System +// Access API over an OPFS directory, see support/folder.ts). Systems and +// containers get a directory with an _index.md, other elements a file: the +// bookstore fixture becomes nodes/customer.md, nodes/payment-provider/_index.md +// and nodes/bookstore/_index.md with api/_index.md, web-app.md, orders-db.md. + +let folder: ModelFolder + +test.beforeEach(async ({ page, studio }) => { + folder = new ModelFolder(page) + await folder.install() + await studio.seed() + await studio.open('v-context') + await expect(studio.nodes).toHaveCount(3) +}) + +async function openModels(page: Page): Promise { + await page.getByTitle('Menu', { exact: true }).click() + await page.getByRole('menuitem', { name: /Manage models/ }).click() + await expect(page.getByRole('dialog', { name: 'Models' })).toBeVisible() +} + +/** Convert the seeded document to a folder through the Models dialog. */ +async function saveAsFolder(page: Page): Promise { + await openModels(page) + await page.getByRole('button', { name: 'Save as folder…' }).click() + const models = page.getByRole('dialog', { name: 'Models' }) + await expect(models.getByRole('tab', { name: /Folders/ })).toHaveAttribute('aria-selected', 'true') + await expect(models.locator('.docmgr-badge.md')).toBeVisible() + await models.getByRole('button', { name: 'Close' }).click() +} + +/** The properties-panel Label field of the selected element. */ +function labelField(page: Page) { + return page.locator('.props-field').filter({ has: page.getByText('Label', { exact: true }) }).locator('input') +} + +async function rename(page: Page, from: string, to: string): Promise { + await expect(labelField(page)).toHaveValue(from) + await labelField(page).fill(to) + await labelField(page).press('Tab') +} + +test('save as folder writes one Markdown file per element', async ({ page }) => { + await saveAsFolder(page) + await expect.poll(() => folder.paths()).toEqual(expect.arrayContaining([ + 'radical.md', + 'nodes/customer.md', + 'nodes/bookstore/_index.md', + 'nodes/bookstore/api/_index.md', + 'nodes/bookstore/web-app.md', + 'nodes/bookstore/orders-db.md', + 'nodes/payment-provider/_index.md', + 'relations.json', + '_layout.json', + ])) + const files = await folder.files() + expect(files['radical.md']).toContain('radicalFormat: "md-folder"') + expect(files['nodes/customer.md']).toContain('label: "Customer"') + expect(files['nodes/customer.md']).toContain('Buys books online') +}) + +test('a folder-backed model survives a reload', async ({ page, studio }) => { + await saveAsFolder(page) + await studio.node('payments').click() + await rename(page, 'Payment Provider', 'Card Gateway') + await expect.poll(() => folder.paths()).toContain('nodes/card-gateway/_index.md') + + await page.reload() + await studio.ready() + await expect(studio.node('payments')).toContainText('Card Gateway') + await openModels(page) + await expect(page.getByRole('dialog', { name: 'Models' }).locator('.docmgr-badge.md')).toBeVisible() +}) + +test('moving files keeps the descriptions of elements never opened', async ({ page, studio }) => { + await saveAsFolder(page) + // After a reload, descriptions are read from the files only on demand. + await page.reload() + await studio.ready() + + // Renaming the system moves its children's files, whose bodies were + // never loaded; a second save must not lose them. (The reload lands on the + // full canvas, where Bookstore is expanded: click its header.) + await studio.node('bookstore').click({ position: { x: 24, y: 12 } }) + await rename(page, 'Bookstore', 'Online Shop') + await expect.poll(() => folder.paths()).toContain('nodes/online-shop/api/_index.md') + await studio.node('customer').click() + await rename(page, 'Customer', 'Reader') + await expect.poll(() => folder.paths()).toContain('nodes/reader.md') + + const files = await folder.files() + expect(files['nodes/online-shop/api/_index.md']).toContain('Orders and catalogue API') + expect(files['nodes/online-shop/web-app.md']).toContain('Catalogue and checkout UI') + expect(files['nodes/online-shop/orders-db.md']).toContain('Orders and stock') + expect(Object.keys(files).some((p) => p.startsWith('nodes/bookstore/'))).toBe(false) +}) + +test('an edit made outside the app shows up on the canvas', async ({ page, studio }) => { + await saveAsFolder(page) + await expect.poll(() => folder.paths()).toContain('nodes/customer.md') + + const file = (await folder.files())['nodes/customer.md'] + await folder.write('nodes/customer.md', file.replace('label: "Customer"', 'label: "Book Lover"')) + + // The folder is polled every couple of seconds. + await expect(studio.node('customer')).toContainText('Book Lover', { timeout: 10_000 }) + // The app never wrote its own version back over the edit; its next save + // just files the element under its new name. + await expect.poll(() => folder.paths()).toContain('nodes/book-lover.md') + await page.waitForTimeout(3000) + const files = await folder.files() + expect(files['nodes/book-lover.md']).toContain('label: "Book Lover"') + expect(Object.values(files).some((c) => c.includes('label: "Customer"'))).toBe(false) +}) + +test('files the model does not own are left alone', async ({ page, studio }) => { + await folder.write('package.json', '{"name":"shop"}') + await folder.write('nodes/README.md', '# Notes\n') + + // The folder holds files but no model, so the app asks first. + const asked = page.waitForEvent('dialog').then(async (d) => { + expect(d.message()).toContain('is not a Radical model folder') + await d.accept() + }) + await saveAsFolder(page) + await asked + await expect.poll(() => folder.paths()).toContain('nodes/payment-provider/_index.md') + + await studio.node('payments').click() + await page.getByRole('button', { name: 'Delete', exact: true }).click() + await expect.poll(() => folder.paths()).not.toContain('nodes/payment-provider/_index.md') + + const files = await folder.files() + expect(files['package.json']).toBe('{"name":"shop"}') + expect(files['nodes/README.md']).toBe('# Notes\n') +}) + +test('declining the prompt leaves a non-model folder untouched', async ({ page }) => { + await folder.write('nodes/README.md', '# Notes\n') + page.once('dialog', (d) => d.dismiss()) + await openModels(page) + await page.getByRole('button', { name: 'Save as folder…' }).click() + const models = page.getByRole('dialog', { name: 'Models' }) + await expect(models.locator('.docmgr-badge.ls')).toBeVisible() + await expect(models.locator('.docmgr-badge.md')).toHaveCount(0) + expect(await folder.paths()).toEqual(['nodes/README.md']) +}) From 7c5701bd208c65c244a2d1952e376ff161b1836b Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Wed, 30 Sep 2026 18:19:50 +0200 Subject: [PATCH 3/3] docs: log the markdown-folder fixes and the review's open items Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ docs/IMPROVEMENTS.md | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8436c65c..c1ae5216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Welcome screen: on a first visit the sample model gets its own card (preview, what it contains, "Explore the sample") in place of the placeholder diagram, and opens on the System Context view instead of the all-elements canvas - Desktop installers for macOS (dmg, Apple Silicon + Intel), Windows (NSIS) and Linux (AppImage), built by electron-builder and drafted as a GitHub Release when a `v*` tag is pushed - App icon for the desktop builds +- Markdown-folder models follow edits made outside Studio (another editor, `git pull`): the active document's folder is polled (desktop every second, browser every two) and reloaded from disk, keeping the open view and selection; a save never overwrites a file changed on disk since Studio read it ### Changed - The Studio app is named radical.studio (installers, window and page title); radical.tools names the family of apps @@ -39,6 +40,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Production desktop build showed a blank window: the CSP header handler dropped every other response header (including `Content-Type`), so the renderer's module scripts were refused - Hub concept files' `views` were ignored by the viewer and on import - Importing from the Hub no longer stacks one "Imported …" notification per concept over the template-parameter form +- Markdown-folder models: renaming or moving an element whose description was never opened lost that description on the next save +- Markdown-folder models: saving deleted files Studio did not write — any top-level `.json` (e.g. `package.json`) and any file under `nodes/`, such as a hand-written `README.md`; "Save as folder…" into a folder that holds other files but no model now asks first +- Markdown-folder models: two saves overlapping (a slow disk, a large model) could delete an element's file altogether +- Switching documents while a slow one was still loading could load it into — and autosave it over — the document switched to; an edit made just before a switch now lands in the document being left +- Markdown-folder saves write only the files that changed, and only the model's own files are read (a folder inside a repository no longer walks `node_modules`) - Presentation slides without a captured canvas (the sample model's walkthrough, imported slides) lay nodes out with their view's saved positions instead of whatever layout was on screen, so they no longer need a Smart Layout to look right; a slide after a milestone slide shows the live model again instead of the milestone's subset [Unreleased]: https://github.com/radicaltools/radical.tools/compare/HEAD diff --git a/docs/IMPROVEMENTS.md b/docs/IMPROVEMENTS.md index c57546aa..5f106c4c 100644 --- a/docs/IMPROVEMENTS.md +++ b/docs/IMPROVEMENTS.md @@ -142,3 +142,41 @@ that turns red once the bug is fixed. so a concept deep link lands on the catalogue instead. 6. **Property labels are not tied to their inputs** in `RightPanel.tsx` (`