diff --git a/README.md b/README.md index 6db96ed2..17444f5a 100644 --- a/README.md +++ b/README.md @@ -86,14 +86,18 @@ Download the latest desktop builds from [GitHub Releases](https://github.com/mar | Capability | Desktop app | Web editor | | --- | --- | --- | | WYSIWYG and source editing | Full editor experience | Full editor experience | +| Installation and offline use | Installed desktop app; local editing works offline | Installable PWA; after the first successful online load, the app shell and browser-local workspace can open offline | | Open local files and folders | Native dialogs, file paths, and watchers | Browser file picker, folder picker, and file handles | +| Browser-local workspace | Not applicable; files stay on disk | Markdown files and attachments are stored in IndexedDB | +| Folder fallback | Opens folders in place | When direct directory picking is unavailable, opening a folder imports a persistent writable copy into the browser-local workspace | | File tree operations | Create, rename, move, delete, sort, reveal, and multi-select | Create, rename, move, and delete where browser permissions allow | | Auto-save and restore | Existing files, tabs, drafts, workspace windows | Browser file handles and IndexedDB state where available | | AI providers | Native runtime requests with app proxy settings | Browser requests subject to provider CORS support | +| Offline limits | Local editing works offline; network-backed features require a connection | AI, WebDAV, Web search, remote images, and other network-backed features require a connection and are not Service Worker cached | | Spellcheck | Markra-managed local spellcheck with on-demand language packs and a personal dictionary | Not available yet | | Image storage | Local folders, WebDAV, PicGo/PicList, and S3-compatible storage | Local/browser handles and WebDAV where CORS permits | | Backup and sync | Local backups and WebDAV sync | Not available in the web runtime | -| Export | HTML, PDF, and Pandoc formats when configured | HTML download and browser print/PDF | +| Export | HTML, PDF, and Pandoc formats when configured | HTML download, browser print/PDF, and ZIP for browser-local workspaces | ## Comparison diff --git a/README.zh-CN.md b/README.zh-CN.md index 266de480..25656d34 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,14 +86,18 @@ brew install --cask markrahq/tap/markra | 能力 | 桌面版 | Web 版 | | --- | --- | --- | | 所见即所得和源码编辑 | 完整编辑体验 | 完整编辑体验 | +| 安装与离线使用 | 安装后的桌面应用可离线编辑本地文件 | 可安装为 PWA;首次成功联网加载后,可离线打开应用外壳和浏览器本地工作区 | | 打开本地文件和文件夹 | 原生文件对话框、真实路径和文件监听 | 浏览器文件选择、文件夹选择和文件句柄 | +| 浏览器本地工作区 | 不适用;文件保留在磁盘上 | Markdown 文件和附件存储在 IndexedDB 中 | +| 文件夹兼容模式 | 直接打开原文件夹 | 浏览器不支持直接选择目录时,打开文件夹会将其导入为浏览器本地工作区中的持久可写副本 | | 文件树操作 | 新建、重命名、移动、删除、排序、定位和多选 | 在浏览器权限允许时新建、重命名、移动和删除 | | 自动保存和状态恢复 | 已有文件、标签页、草稿和工作区窗口 | 支持浏览器文件句柄和 IndexedDB 状态时可用 | | AI 服务商 | 通过原生运行时请求,支持应用代理设置 | 浏览器直接请求,受服务商 CORS 支持限制 | +| 离线限制 | 本地编辑可离线使用;联网功能仍需网络 | AI、WebDAV、联网搜索、远程图片等联网功能仍需网络,且不会被 Service Worker 缓存 | | 拼写检查 | Markra 自维护本地拼写检查,支持按需下载语言包和个人白名单 | 暂不可用 | | 图片存储 | 本地文件夹、WebDAV、PicGo/PicList 和 S3 兼容存储 | 本地/浏览器文件句柄,以及 CORS 允许时的 WebDAV | | 备份与同步 | 本地备份和 WebDAV 同步 | Web 运行时不可用 | -| 导出 | HTML、PDF,以及配置 Pandoc 后的更多格式 | HTML 下载和浏览器打印/PDF | +| 导出 | HTML、PDF,以及配置 Pandoc 后的更多格式 | HTML 下载、浏览器打印/PDF,以及浏览器本地工作区 ZIP | ## 对照 diff --git a/apps/desktop/src/runtime/index.test.ts b/apps/desktop/src/runtime/index.test.ts index 23f01c88..7b20398d 100644 --- a/apps/desktop/src/runtime/index.test.ts +++ b/apps/desktop/src/runtime/index.test.ts @@ -53,3 +53,11 @@ describe("desktop runtime logs", () => { expect(desktopRuntime.logs.writeLog).toBe(logs.writeNativeLog); }); }); + +describe("desktop file runtime workspace export contract", () => { + it("keeps virtual workspace export unavailable", async () => { + expect(desktopRuntime.files.canExportMarkdownFolder("web-workspace://default")).toBe(false); + await expect(desktopRuntime.files.exportMarkdownFolder("web-workspace://default")).resolves.toBeNull(); + await expect(desktopRuntime.files.getDefaultMarkdownFolder()).resolves.toBeNull(); + }); +}); diff --git a/apps/desktop/src/runtime/index.ts b/apps/desktop/src/runtime/index.ts index 41fd8569..6eb442d6 100644 --- a/apps/desktop/src/runtime/index.ts +++ b/apps/desktop/src/runtime/index.ts @@ -83,6 +83,7 @@ export const desktopRuntime = { }, files: { backupMarkdownFolder: files.backupNativeMarkdownFolder, + canExportMarkdownFolder: (_path: string) => false, confirmMarkdownFileDelete: files.confirmNativeMarkdownFileDelete, confirmUnsavedMarkdownDocumentDiscard: files.confirmNativeUnsavedMarkdownDocumentDiscard, createMarkdownTreeFile: files.createNativeMarkdownTreeFile, @@ -91,6 +92,8 @@ export const desktopRuntime = { deleteMarkdownTreeFile: files.deleteNativeMarkdownTreeFile, detectPandocPath: files.detectNativePandocPath, downloadWebImage: files.downloadNativeWebImage, + exportMarkdownFolder: async (_path: string) => null, + getDefaultMarkdownFolder: async () => null, installMarkdownFileDrop: files.installNativeMarkdownFileDrop, importLocalFile: files.importNativeLocalFile, listenOpenedMarkdownPaths: files.listenNativeOpenedMarkdownPaths, diff --git a/apps/web/package.json b/apps/web/package.json index 91f9fddb..cccce746 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,15 +6,17 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -p tsconfig.app.json && vite build", + "build": "tsc -p tsconfig.app.json && vite build && pnpm verify:pwa", "test": "vitest run", "test:watch": "vitest", - "typecheck:test": "tsc -p tsconfig.test.json --noEmit" + "typecheck:test": "tsc -p tsconfig.test.json --noEmit", + "verify:pwa": "node scripts/verify-pwa.mjs" }, "dependencies": { "@markra/app": "workspace:*", "@markra/editor": "workspace:*", "@markra/shared": "workspace:*", + "fflate": "0.8.3", "ignore": "7.0.5", "react": "19.2.5", "react-dom": "19.2.5" @@ -30,6 +32,7 @@ "tailwindcss": "^4.2.4", "typescript": "6.0.3", "vite": "8.0.10", + "vite-plugin-pwa": "1.3.0", "vitest": "4.1.5" } } diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png new file mode 100644 index 00000000..4da0ba7c Binary files /dev/null and b/apps/web/public/icon-192.png differ diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png new file mode 100644 index 00000000..3490f488 Binary files /dev/null and b/apps/web/public/icon-512.png differ diff --git a/apps/web/scripts/verify-pwa.mjs b/apps/web/scripts/verify-pwa.mjs new file mode 100644 index 00000000..f689c131 --- /dev/null +++ b/apps/web/scripts/verify-pwa.mjs @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import { readdir, readFile, stat } from "node:fs/promises"; + +function matchCount(value, pattern) { + return [...value.matchAll(pattern)].length; +} + +function extractArrayArgument(source, callName) { + const callMarker = `.${callName}(`; + const callIndex = source.indexOf(callMarker); + assert.notEqual(callIndex, -1, `${callName} call is missing`); + + const arrayStart = source.indexOf("[", callIndex + callMarker.length); + assert.notEqual(arrayStart, -1, `${callName} array argument is missing`); + + let depth = 0; + let escaped = false; + let inString = false; + + for (let index = arrayStart; index < source.length; index += 1) { + const character = source[index]; + + if (inString) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + + if (character === '"') inString = true; + else if (character === "[") depth += 1; + else if (character === "]") { + depth -= 1; + if (depth === 0) return source.slice(arrayStart, index + 1); + } + } + + assert.fail(`${callName} array argument is not closed`); +} + +async function listBuildFiles(directoryUrl, prefix = "") { + const entries = await readdir(directoryUrl, { withFileTypes: true }); + const files = await Promise.all(entries.map(async (entry) => { + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + if (!entry.isDirectory()) return [path]; + + return listBuildFiles(new URL(`${entry.name}/`, directoryUrl), path); + })); + + return files.flat(); +} + +const distUrl = new URL("../dist/", import.meta.url); +const manifest = JSON.parse(await readFile(new URL("manifest.webmanifest", distUrl), "utf8")); + +assert.equal(manifest.name, "Markra"); +assert.equal(manifest.short_name, "Markra"); +assert.equal(manifest.description, "Local-first Markdown editor"); +assert.equal(manifest.display, "standalone"); +assert.equal(manifest.start_url, "/"); +assert.equal(manifest.scope, "/"); +assert.deepEqual(manifest.icons, [ + { src: "/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icon-512.png", sizes: "512x512", type: "image/png" } +]); + +const expectedIcons = [ + ["icon-192.png", 192], + ["icon-512.png", 512] +]; + +for (const [fileName, expectedSize] of expectedIcons) { + const icon = await readFile(new URL(fileName, distUrl)); + assert.deepEqual(icon.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + assert.equal(icon.readUInt32BE(16), expectedSize, `${fileName} width`); + assert.equal(icon.readUInt32BE(20), expectedSize, `${fileName} height`); +} + +await stat(new URL("sw.js", distUrl)); +const serviceWorker = await readFile(new URL("sw.js", distUrl), "utf8"); +assert.match(serviceWorker, /index\.html/); +assert.doesNotMatch(serviceWorker, /api[_/-]|webdav|authorization/iu); +assert.doesNotMatch(serviceWorker, /CacheFirst|CacheOnly|NetworkFirst|NetworkOnly|StaleWhileRevalidate/); +assert.doesNotMatch(serviceWorker, /\.clientsClaim\(/); +assert.match(serviceWorker, /"SKIP_WAITING"===.*&&self\.skipWaiting\(\)/); + +assert.equal( + matchCount(serviceWorker, /(? JSON.parse(`"${encodedUrl}"`)); +assert.ok(precacheUrls.length > 0, "Precache URL list is empty"); +const precacheUrlSet = new Set(precacheUrls); +assert.equal(precacheUrlSet.size, precacheUrls.length, "Precache URLs must be unique"); + +const requiredShellUrls = new Set([ + "icon-192.png", + "icon-512.png", + "index.html", + "manifest.webmanifest", + "registerSW.js" +]); +for (const requiredUrl of requiredShellUrls) { + assert.ok(precacheUrlSet.has(requiredUrl), `Required shell URL is missing from precache: ${requiredUrl}`); +} + +const staticAssetPath = /^assets\/[A-Za-z0-9._~!$&'()*+,;=:@/-]+$/; +const forbiddenPath = + /(?:^|[/_.-])(?:api|auth|authorization|documents?|uploads?|user[-_]?data|user[-_]?(?:docs?|documents?|files?)|webdav)(?:$|[/_.-])/iu; + +for (const url of precacheUrls) { + assert.doesNotMatch(url, /^[a-z][a-z0-9+.-]*:/iu, `Precache URL must not use a scheme: ${url}`); + assert.doesNotMatch(url, /\/\//u, `Precache URL must not contain //: ${url}`); + assert.doesNotMatch(url, /[?#\\%]/u, `Precache URL must not contain query, hash, backslash, or encoding: ${url}`); + assert.ok( + !url.split("/").some((segment) => segment === "." || segment === ".."), + `Precache URL must not traverse: ${url}` + ); + assert.doesNotMatch(url, forbiddenPath, `Precache URL must not target sensitive or user-document paths: ${url}`); + assert.ok(requiredShellUrls.has(url) || staticAssetPath.test(url), `Unexpected precache URL: ${url}`); +} + +assert.equal( + matchCount(serviceWorker, /(? /^workbox-[\w-]+\.js$/.test(fileName)), "Workbox runtime is missing"); + +const eligibleStaticAsset = /\.(?:css|html|ico|jpg|js|png|svg|ttf|webp|woff|woff2)$/u; +const expectedPrecacheUrls = buildFiles.filter((fileName) => + fileName === "manifest.webmanifest" + || ( + eligibleStaticAsset.test(fileName) + && fileName !== "sw.js" + && !/^workbox-[\w-]+\.js$/u.test(fileName) + ) +); +for (const fileName of expectedPrecacheUrls) { + assert.ok(precacheUrlSet.has(fileName), `Eligible build asset is missing from precache: ${fileName}`); +} +for (const url of precacheUrls) { + assert.ok(buildFiles.includes(url), `Precache URL has no emitted build file: ${url}`); +} + +console.log(`PWA artifacts verified: ${precacheUrls.length} unique shell/static precache URLs and one navigation fallback`); diff --git a/apps/web/src/runtime/index.test.ts b/apps/web/src/runtime/index.test.ts index 456b40ed..31f73419 100644 --- a/apps/web/src/runtime/index.test.ts +++ b/apps/web/src/runtime/index.test.ts @@ -31,6 +31,12 @@ describe("web runtime", () => { expect(runtime.features.spellcheck).toBe(false); expect(runtime.features.updater).toBe(false); expect(runtime.platform.resolveDesktopPlatform()).toBe("windows"); + await expect(runtime.files.getDefaultMarkdownFolder()).resolves.toEqual({ + name: "Markra", + path: "web-workspace://default" + }); + expect(runtime.files.canExportMarkdownFolder("web-workspace://default")).toBe(true); + expect(runtime.files.canExportMarkdownFolder("web-folder://external")).toBe(false); await expect(runtime.updater.checkAppUpdate()).resolves.toBeNull(); }); }); diff --git a/apps/web/src/runtime/index.ts b/apps/web/src/runtime/index.ts index c6072658..86518ba1 100644 --- a/apps/web/src/runtime/index.ts +++ b/apps/web/src/runtime/index.ts @@ -12,6 +12,7 @@ import { createWebMenuRuntime, createWebResourceRuntime, createWebWindowRuntime, + createWorkspaceRepository, type WebRuntimeOptions } from "./web"; @@ -20,6 +21,7 @@ export * from "./web"; export function createWebRuntime(options: WebRuntimeOptions = {}): AppRuntime { const defaultRuntime = createDefaultAppRuntime(); const settings = createIndexedDbSettingsRuntime(options); + const workspaceRepository = createWorkspaceRepository(options); return { ...defaultRuntime, @@ -37,7 +39,7 @@ export function createWebRuntime(options: WebRuntimeOptions = {}): AppRuntime { spellcheck: false, updater: false }, - files: createWebFileRuntime(settings, options), + files: createWebFileRuntime(settings, workspaceRepository, options), menu: createWebMenuRuntime(defaultRuntime.menu, options), platform: { resolveDesktopOsVersion: () => null, diff --git a/apps/web/src/runtime/web/database.test.ts b/apps/web/src/runtime/web/database.test.ts new file mode 100644 index 00000000..59ae510c --- /dev/null +++ b/apps/web/src/runtime/web/database.test.ts @@ -0,0 +1,46 @@ +import { FakeIdbTransaction, FakeIndexedDbFactory } from "../../test/web-runtime-fakes"; +import { + openWebRuntimeDatabase, + requestToPromise, + transactionToPromise, + webRuntimeAiChatAttachmentStoreName, + webRuntimeSettingsStoreName, + webRuntimeWorkspaceEntryStoreName, + webRuntimeWorkspaceStoreName +} from "./database"; + +describe("Web runtime database", () => { + it("adds workspace stores without losing existing Web runtime stores", async () => { + const factory = new FakeIndexedDbFactory(); + const database = await openWebRuntimeDatabase({ indexedDB: factory.indexedDB }); + + expect(database.objectStoreNames.contains(webRuntimeSettingsStoreName)).toBe(true); + expect(database.objectStoreNames.contains(webRuntimeAiChatAttachmentStoreName)).toBe(true); + expect(database.objectStoreNames.contains(webRuntimeWorkspaceStoreName)).toBe(true); + expect(database.objectStoreNames.contains(webRuntimeWorkspaceEntryStoreName)).toBe(true); + }); + + it("waits for transaction completion and rejects transaction failures", async () => { + const transaction = new FakeIdbTransaction(); + const completion = transactionToPromise(transaction as unknown as IDBTransaction); + transaction.fail(new DOMException("quota", "QuotaExceededError")); + + await expect(completion).rejects.toMatchObject({ name: "QuotaExceededError" }); + }); + + it("completes successful transactions after queued requests settle", async () => { + const factory = new FakeIndexedDbFactory(); + const database = await openWebRuntimeDatabase({ indexedDB: factory.indexedDB }); + const transaction = database.transaction(webRuntimeWorkspaceStoreName, "readonly"); + const store = transaction.objectStore(webRuntimeWorkspaceStoreName); + const events: string[] = []; + + const record = await requestToPromise(store.get("workspace-1")); + events.push("request"); + await transactionToPromise(transaction); + events.push("transaction"); + + expect(record).toBeUndefined(); + expect(events).toEqual(["request", "transaction"]); + }, 100); +}); diff --git a/apps/web/src/runtime/web/database.ts b/apps/web/src/runtime/web/database.ts index 300949d3..ae8a2acc 100644 --- a/apps/web/src/runtime/web/database.ts +++ b/apps/web/src/runtime/web/database.ts @@ -1,8 +1,10 @@ import type { IndexedDbSettingsRuntimeOptions } from "./types"; -export const webRuntimeDatabaseVersion = 2; +export const webRuntimeDatabaseVersion = 3; export const webRuntimeSettingsStoreName = "stores"; export const webRuntimeAiChatAttachmentStoreName = "ai-chat-attachments"; +export const webRuntimeWorkspaceStoreName = "workspaces"; +export const webRuntimeWorkspaceEntryStoreName = "workspace-entries"; export function resolveIndexedDbFactory(indexedDb?: IDBFactory | null) { if (indexedDb) return indexedDb; @@ -22,6 +24,14 @@ export function requestToPromise(request: IDBRequest) { }); } +export function transactionToPromise(transaction: IDBTransaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(undefined); + transaction.onerror = () => reject(transaction.error ?? new Error("IndexedDB transaction failed.")); + transaction.onabort = () => reject(transaction.error ?? new Error("IndexedDB transaction aborted.")); + }); +} + export function openWebRuntimeDatabase( options: IndexedDbSettingsRuntimeOptions, settingsStoreName = webRuntimeSettingsStoreName @@ -38,6 +48,12 @@ export function openWebRuntimeDatabase( if (!database.objectStoreNames.contains(webRuntimeAiChatAttachmentStoreName)) { database.createObjectStore(webRuntimeAiChatAttachmentStoreName, { keyPath: "key" }); } + if (!database.objectStoreNames.contains(webRuntimeWorkspaceStoreName)) { + database.createObjectStore(webRuntimeWorkspaceStoreName, { keyPath: "id" }); + } + if (!database.objectStoreNames.contains(webRuntimeWorkspaceEntryStoreName)) { + database.createObjectStore(webRuntimeWorkspaceEntryStoreName, { keyPath: ["workspaceId", "path"] }); + } }; request.onsuccess = () => { resolve(request.result); diff --git a/apps/web/src/runtime/web/file.test.ts b/apps/web/src/runtime/web/file.test.ts index 65f91f70..9a13980a 100644 --- a/apps/web/src/runtime/web/file.test.ts +++ b/apps/web/src/runtime/web/file.test.ts @@ -3,11 +3,19 @@ import { FakeFileHandle, FakeIndexedDbFactory } from "../../test/web-runtime-fakes"; +import { strFromU8, unzipSync } from "fflate"; import { createWebRuntime } from ".."; import type { NativeMarkdownDroppedTarget } from "@markra/app/runtime"; import type { WebDownloadFile } from "./types"; +import { createWebFileRuntime } from "./file"; +import { createIndexedDbSettingsRuntime } from "./settings"; +import { + createWorkspaceRepository, + WorkspaceNamespaceConflictError, + type WorkspaceRepository +} from "./workspace"; -function createDirectoryUploadFile(relativePath: string, contents: string, type = "text/markdown") { +function createDirectoryUploadFile(relativePath: string, contents: BlobPart, type = "text/markdown") { const file = new File([contents], relativePath.split("/").pop() ?? relativePath, { type }); Object.defineProperty(file, "webkitRelativePath", { @@ -18,6 +26,56 @@ function createDirectoryUploadFile(relativePath: string, contents: string, type return file; } +async function seedWorkspace(runtime: ReturnType) { + const folder = await runtime.files.openMarkdownFolder(); + expect(folder).not.toBeNull(); + + return folder!; +} + +function withCompetingFileCreate( + repository: WorkspaceRepository, + targetPath: string, + competingContents: string +): WorkspaceRepository { + let competed = false; + let targetReads = 0; + + return { + ...repository, + async read(workspaceId, path) { + if (!competed && path === targetPath) { + targetReads += 1; + if (targetReads === 3) { + competed = true; + await repository.writeFile( + workspaceId, + path, + new Blob([competingContents]), + { mode: "create" } + ); + } + } + + return repository.read(workspaceId, path); + }, + async writeFile(workspaceId, path, body, options) { + if (!competed && path === targetPath && options?.mode === "create") { + competed = true; + await repository.writeFile( + workspaceId, + path, + new Blob([competingContents]), + { mode: "create" } + ); + throw new WorkspaceNamespaceConflictError(path); + } + + return repository.writeFile(workspaceId, path, body, options); + } + }; +} + function createDropEvent(dataTransfer: Partial) { const event = new Event("drop", { bubbles: true, cancelable: true }); @@ -468,6 +526,467 @@ describe("web file runtime", () => { }); }); + it("imports an uploaded directory into a persistent writable workspace when directory handles are unavailable", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const runtime = createWebRuntime({ + indexedDB, + pickDirectoryFiles: async () => [ + createDirectoryUploadFile("notes/guide.md", "# Guide"), + createDirectoryUploadFile("notes/assets/pixel.png", "png", "image/png") + ] + }); + + const folder = await runtime.files.openMarkdownFolder(); + + expect(folder).toEqual({ name: "notes", path: "web-workspace://default/notes" }); + const files = await runtime.files.listMarkdownFilesForPath(folder!.path); + expect(files.filter((file) => file.kind !== "folder").map((file) => file.relativePath)) + .toEqual(["assets/pixel.png", "guide.md"]); + expect(files).toContainEqual(expect.objectContaining({ + kind: "folder", + relativePath: "assets" + })); + + const reloaded = createWebRuntime({ indexedDB }); + await reloaded.files.saveMarkdownFile({ + contents: "# Updated", + path: "web-workspace://default/notes/guide.md", + suggestedName: "guide.md" + }); + await expect(reloaded.files.readMarkdownFile("web-workspace://default/notes/guide.md")) + .resolves.toMatchObject({ content: "# Updated" }); + }); + + it("applies workspace ignore rules to persistent uploaded workspaces", async () => { + const runtime = createWebRuntime({ + indexedDB: new FakeIndexedDbFactory().indexedDB, + pickDirectoryFiles: async () => [ + createDirectoryUploadFile("vault/.markraignore", "generated/\n*.tmp\n", "text/plain"), + createDirectoryUploadFile("vault/draft.tmp", "temporary", "text/plain"), + createDirectoryUploadFile("vault/generated/hidden.md", "# Hidden"), + createDirectoryUploadFile("vault/visible.md", "# Visible") + ] + }); + + const folder = await runtime.files.openMarkdownFolder(); + const entries = await runtime.files.listMarkdownFilesForPath(folder!.path); + + expect(entries.map((entry) => entry.relativePath)).toEqual(["visible.md"]); + }); + + it("resolves parent-relative images from persistent uploaded workspaces", async () => { + const runtime = createWebRuntime({ + indexedDB: new FakeIndexedDbFactory().indexedDB, + pickDirectoryFiles: async () => [ + createDirectoryUploadFile("vault/assets/pixel.png", "png", "image/png"), + createDirectoryUploadFile("vault/notes/guide.md", "# Guide") + ] + }); + await runtime.files.openMarkdownFolder(); + + await expect(runtime.files.resolveMarkdownImageSrc?.({ + documentPath: "web-workspace://default/vault/notes/guide.md", + src: "../assets/pixel.png" + })).resolves.toBe("data:image/png;base64,cG5n"); + }); + + it("resolves parent-relative images from retained browser directory handles", async () => { + const external = new FakeDirectoryHandle("external", { + assets: new FakeDirectoryHandle("assets", { + "pixel.png": new FakeFileHandle("pixel.png", "png", "image/png") + }), + notes: new FakeDirectoryHandle("notes", { + "guide.md": new FakeFileHandle("guide.md", "# Guide") + }) + }); + const runtime = createWebRuntime({ + indexedDB: new FakeIndexedDbFactory().indexedDB, + showDirectoryPicker: async () => external + }); + const folder = await runtime.files.openMarkdownFolder(); + + await expect(runtime.files.resolveMarkdownImageSrc?.({ + documentPath: `${folder!.path}/notes/guide.md`, + src: "../assets/pixel.png" + })).resolves.toBe("data:image/png;base64,cG5n"); + await expect(runtime.files.resolveMarkdownImageSrc?.({ + documentPath: `${folder!.path}/notes/guide.md`, + src: "../../outside.png" + })).rejects.toThrow("outside the web folder root"); + }); + + it("keeps direct directory access when showDirectoryPicker is available", async () => { + const external = new FakeDirectoryHandle("external", {}); + const runtime = createWebRuntime({ + indexedDB: new FakeIndexedDbFactory().indexedDB, + showDirectoryPicker: async () => external + }); + + await expect(runtime.files.openMarkdownFolder()).resolves.toMatchObject({ + name: "external", + path: expect.stringMatching(/^web-folder:\/\//u) + }); + }); + + it("exports the current virtual root as a ZIP and preserves binary bytes", async () => { + const downloads: WebDownloadFile[] = []; + const runtime = createWebRuntime({ + downloadFile: async (download) => downloads.push(download), + indexedDB: new FakeIndexedDbFactory().indexedDB, + pickDirectoryFiles: async () => [ + createDirectoryUploadFile("notes/guide.md", "# Guide"), + createDirectoryUploadFile( + "notes/assets/pixel.png", + new Uint8Array([1, 2, 3]), + "image/png" + ) + ] + }); + const folder = await seedWorkspace(runtime); + + await runtime.files.exportMarkdownFolder(folder.path); + + expect(downloads).toHaveLength(1); + expect(downloads[0]).toMatchObject({ name: "notes.zip", type: "application/zip" }); + const archive = unzipSync(new Uint8Array( + await (downloads[0].contents as Blob).arrayBuffer() + )); + expect(strFromU8(archive["guide.md"])).toBe("# Guide"); + expect(archive["assets/pixel.png"]).toEqual(new Uint8Array([1, 2, 3])); + }); + + it("preserves empty workspace directories in ZIP exports", async () => { + const downloads: WebDownloadFile[] = []; + const runtime = createWebRuntime({ + downloadFile: async (download) => downloads.push(download), + indexedDB: new FakeIndexedDbFactory().indexedDB + }); + const folder = await runtime.files.getDefaultMarkdownFolder(); + await runtime.files.createMarkdownTreeFolder(folder!.path, "empty"); + + await runtime.files.exportMarkdownFolder(folder!.path); + + const archive = unzipSync(new Uint8Array( + await (downloads[0].contents as Blob).arrayBuffer() + )); + expect(archive).toHaveProperty("empty/"); + expect(archive["empty/"]).toEqual(new Uint8Array()); + }); + + it("saves an untitled document into the current virtual workspace instead of downloading it", async () => { + const downloadFile = vi.fn(); + const runtime = createWebRuntime({ + downloadFile, + indexedDB: new FakeIndexedDbFactory().indexedDB + }); + + await expect(runtime.files.saveMarkdownFile({ + contents: "# New", + defaultDirectory: "web-workspace://default", + path: null, + suggestedName: "new.md" + })).resolves.toEqual({ name: "new.md", path: "web-workspace://default/new.md" }); + await expect(runtime.files.readMarkdownFile("web-workspace://default/new.md")) + .resolves.toMatchObject({ content: "# New" }); + expect(downloadFile).not.toHaveBeenCalled(); + }); + + it("chooses a unique workspace name instead of overwriting an existing pathless save", async () => { + const runtime = createWebRuntime({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + await runtime.files.saveMarkdownFile({ + contents: "# Original", + defaultDirectory: "web-workspace://default", + path: null, + suggestedName: "Untitled.md" + }); + + await expect(runtime.files.saveMarkdownFile({ + contents: "# New", + defaultDirectory: "web-workspace://default", + path: null, + suggestedName: "Untitled.md" + })).resolves.toEqual({ + name: "Untitled-2.md", + path: "web-workspace://default/Untitled-2.md" + }); + await expect(runtime.files.readMarkdownFile("web-workspace://default/Untitled.md")) + .resolves.toMatchObject({ content: "# Original" }); + await expect(runtime.files.readMarkdownFile("web-workspace://default/Untitled-2.md")) + .resolves.toMatchObject({ content: "# New" }); + }); + + it("retries a pathless save when another tab wins the exclusive create race", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const storedRepository = createWorkspaceRepository({ indexedDB }); + const repository = withCompetingFileCreate( + storedRepository, + "Untitled.md", + "# Other tab" + ); + const files = createWebFileRuntime( + createIndexedDbSettingsRuntime({ indexedDB }), + repository, + { indexedDB } + ); + + await expect(files.saveMarkdownFile({ + contents: "# This tab", + defaultDirectory: "web-workspace://default", + path: null, + suggestedName: "Untitled.md" + })).resolves.toEqual({ + name: "Untitled-2.md", + path: "web-workspace://default/Untitled-2.md" + }); + await expect(files.readMarkdownFile("web-workspace://default/Untitled.md")) + .resolves.toMatchObject({ content: "# Other tab" }); + await expect(files.readMarkdownFile("web-workspace://default/Untitled-2.md")) + .resolves.toMatchObject({ content: "# This tab" }); + }); + + it("rejects duplicate tree-file creation without changing the existing file", async () => { + const runtime = createWebRuntime({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + const folder = await runtime.files.getDefaultMarkdownFolder(); + await runtime.files.createMarkdownTreeFile(folder!.path, "note.md", { contents: "# Original" }); + + await expect( + runtime.files.createMarkdownTreeFile(folder!.path, "note.md", { contents: "# Replacement" }) + ).rejects.toThrow("already exists"); + await expect(runtime.files.readMarkdownFile("web-workspace://default/note.md")) + .resolves.toMatchObject({ content: "# Original" }); + }); + + it("does not overwrite a tree file created concurrently by another tab", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const storedRepository = createWorkspaceRepository({ indexedDB }); + const repository = withCompetingFileCreate(storedRepository, "note.md", "# Other tab"); + const files = createWebFileRuntime( + createIndexedDbSettingsRuntime({ indexedDB }), + repository, + { indexedDB } + ); + const folder = await files.getDefaultMarkdownFolder(); + + await expect(files.createMarkdownTreeFile(folder!.path, "note.md", { + contents: "# This tab" + })).rejects.toThrow("already exists"); + await expect(files.readMarkdownFile("web-workspace://default/note.md")) + .resolves.toMatchObject({ content: "# Other tab" }); + }); + + it("rejects duplicate tree-folder creation instead of reopening the existing folder", async () => { + const runtime = createWebRuntime({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + const folder = await runtime.files.getDefaultMarkdownFolder(); + await runtime.files.createMarkdownTreeFolder(folder!.path, "notes"); + + await expect(runtime.files.createMarkdownTreeFolder(folder!.path, "notes")) + .rejects.toThrow("already exists"); + await expect(runtime.files.listMarkdownFilesForPath(folder!.path)).resolves.toEqual([ + expect.objectContaining({ kind: "folder", relativePath: "notes" }) + ]); + }); + + it("rejects saving through a stale workspace file path", async () => { + const runtime = createWebRuntime({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + const folder = await runtime.files.getDefaultMarkdownFolder(); + const created = await runtime.files.createMarkdownTreeFile(folder!.path, "note.md", { + contents: "# Saved" + }); + await runtime.files.deleteMarkdownTreeFile(folder!.path, created.path); + + await expect(runtime.files.saveMarkdownFile({ + contents: "# Stale", + path: created.path, + suggestedName: created.name + })).rejects.toThrow("not found"); + await expect(runtime.files.listMarkdownFilesForPath(folder!.path)).resolves.toEqual([]); + }); + + it("supports tree CRUD across restored virtual workspace paths", async () => { + const runtime = createWebRuntime({ + indexedDB: new FakeIndexedDbFactory().indexedDB, + pickDirectoryFiles: async () => [ + createDirectoryUploadFile("notes/guide.md", "# Guide") + ] + }); + const folder = await seedWorkspace(runtime); + const drafts = await runtime.files.createMarkdownTreeFolder(folder.path, "drafts"); + const draft = await runtime.files.createMarkdownTreeFile(folder.path, "draft.md", { + contents: "# Draft", + parentPath: drafts.path + }); + + const renamed = await runtime.files.renameMarkdownTreeFile(folder.path, draft.path, "renamed.md"); + const moved = await runtime.files.moveMarkdownTreeFile(folder.path, renamed.path); + + await expect(runtime.files.readMarkdownFile(moved.path)).resolves.toMatchObject({ + content: "# Draft", + name: "renamed.md" + }); + await runtime.files.deleteMarkdownTreeFile(folder.path, drafts.path); + await expect(runtime.files.listMarkdownFilesForPath(folder.path)).resolves.toEqual([ + expect.objectContaining({ relativePath: "guide.md" }), + expect.objectContaining({ relativePath: "renamed.md" }) + ]); + }); + + it("routes workspace renames and moves through the repository transaction", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const repository = createWorkspaceRepository({ indexedDB }); + const move = vi.spyOn(repository, "move"); + const files = createWebFileRuntime( + createIndexedDbSettingsRuntime({ indexedDB }), + repository, + { indexedDB } + ); + const folder = await files.getDefaultMarkdownFolder(); + const drafts = await files.createMarkdownTreeFolder(folder!.path, "drafts"); + const note = await files.createMarkdownTreeFile(folder!.path, "note.md", { + contents: "# Draft", + parentPath: drafts.path + }); + + const renamed = await files.renameMarkdownTreeFile(folder!.path, note.path, "ready.md"); + await files.moveMarkdownTreeFile(folder!.path, renamed.path); + + expect(move).toHaveBeenNthCalledWith(1, "default", "drafts/note.md", "drafts/ready.md"); + expect(move).toHaveBeenNthCalledWith(2, "default", "drafts/ready.md", "ready.md"); + }); + + it("stores images and attachments beside a restored virtual workspace document", async () => { + const runtime = createWebRuntime({ + indexedDB: new FakeIndexedDbFactory().indexedDB, + pickDirectoryFiles: async () => [ + createDirectoryUploadFile("notes/guide.md", "# Guide") + ] + }); + await seedWorkspace(runtime); + const documentPath = "web-workspace://default/notes/guide.md"; + const image = new File([new Uint8Array([1, 2, 3])], "Screenshot.png", { type: "image/png" }); + const attachment = new File(["reference"], "reference.txt", { type: "text/plain" }); + + const savedImage = await runtime.files.saveClipboardImage({ + documentPath, + fileName: "粘贴 图.png", + folder: "assets", + image + }); + const savedAttachment = await runtime.files.saveClipboardAttachment({ + attachment, + documentPath, + folder: "downloads" + }); + + expect(savedImage).toEqual({ + alt: "Screenshot", + src: "assets/%E7%B2%98%E8%B4%B4%20%E5%9B%BE.png" + }); + expect(savedAttachment).toEqual({ label: "reference.txt", src: "downloads/reference.txt" }); + await expect(runtime.files.readMarkdownImageFile({ + documentPath, + src: savedImage.src + })).resolves.toMatchObject({ + mimeType: "image/png", + path: "web-workspace://default/notes/assets/%E7%B2%98%E8%B4%B4%20%E5%9B%BE.png" + }); + await expect(runtime.files.resolveMarkdownImageSrc?.({ + documentPath, + src: savedImage.src + })).resolves.toBe("data:image/png;base64,AQID"); + const localImage = await runtime.files.readLocalImageFile( + "web-workspace://default/notes/assets/%E7%B2%98%E8%B4%B4%20%E5%9B%BE.png" + ); + expect(localImage).toMatchObject({ + name: "粘贴 图.png", + type: "image/png" + }); + await expect(localImage.arrayBuffer()).resolves.toEqual(new Uint8Array([1, 2, 3]).buffer); + await expect(runtime.files.listMarkdownFilesForPath("web-workspace://default/notes")) + .resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "asset", relativePath: "assets/粘贴 图.png" }), + expect.objectContaining({ kind: "attachment", relativePath: "downloads/reference.txt" }) + ])); + }); + + it("reads a saved virtual workspace image whose name contains a reserved URL character", async () => { + const runtime = createWebRuntime({ + indexedDB: new FakeIndexedDbFactory().indexedDB, + pickDirectoryFiles: async () => [ + createDirectoryUploadFile("notes/guide.md", "# Guide") + ] + }); + await seedWorkspace(runtime); + const documentPath = "web-workspace://default/notes/guide.md"; + + const saved = await runtime.files.saveClipboardImage({ + documentPath, + fileName: "chart#1.png", + folder: "assets", + image: new File([new Uint8Array([1, 2, 3])], "chart#1.png", { type: "image/png" }) + }); + + expect(saved.src).toBe("assets/chart%231.png"); + await expect(runtime.files.readMarkdownImageFile({ + documentPath, + src: saved.src + })).resolves.toMatchObject({ + mimeType: "image/png", + path: "web-workspace://default/notes/assets/chart%231.png" + }); + }); + + it("opens a saved virtual workspace attachment whose name contains a reserved URL character", async () => { + const runtime = createWebRuntime({ + indexedDB: new FakeIndexedDbFactory().indexedDB, + pickDirectoryFiles: async () => [ + createDirectoryUploadFile("notes/guide.md", "# Guide") + ] + }); + await seedWorkspace(runtime); + const documentPath = "web-workspace://default/notes/guide.md"; + const saved = await runtime.files.saveClipboardAttachment({ + attachment: new File(["synthetic reference"], "chart#1.png", { type: "image/png" }), + documentPath, + folder: "downloads" + }); + const originalCreateObjectUrl = Object.getOwnPropertyDescriptor(URL, "createObjectURL"); + let openedFile: File | null = null; + const createObjectUrl = vi.fn((file: Blob) => { + openedFile = file as File; + return "blob:synthetic-attachment"; + }); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectUrl + }); + const openWindow = vi.spyOn(window, "open").mockReturnValue(null); + + try { + expect(saved.src).toBe("downloads/chart%231.png"); + await runtime.files.openMarkdownAttachment({ + documentPath, + rootPath: "web-workspace://default/notes", + src: saved.src + }); + + expect(createObjectUrl).toHaveBeenCalledOnce(); + expect(openedFile).toMatchObject({ name: "chart#1.png", type: "image/png" }); + await expect(openedFile!.text()).resolves.toBe("synthetic reference"); + expect(openWindow).toHaveBeenCalledWith( + "blob:synthetic-attachment", + "_blank", + "noopener,noreferrer" + ); + } finally { + openWindow.mockRestore(); + if (originalCreateObjectUrl) { + Object.defineProperty(URL, "createObjectURL", originalCreateObjectUrl); + } else { + Reflect.deleteProperty(URL, "createObjectURL"); + } + } + }); + it("rejects stale web folder paths instead of returning an empty tree", async () => { const runtime = createWebRuntime({ indexedDB: new FakeIndexedDbFactory().indexedDB diff --git a/apps/web/src/runtime/web/file.ts b/apps/web/src/runtime/web/file.ts index cb7fc2ce..48fc3103 100644 --- a/apps/web/src/runtime/web/file.ts +++ b/apps/web/src/runtime/web/file.ts @@ -9,6 +9,7 @@ import type { NativeMarkdownOpenTarget, NativeSettingsFile, ListNativeMarkdownFilesOptions, + ReadNativeMarkdownImageInput, SavedNativeClipboardAttachment, SavedNativeClipboardImage, SavedNativeHtmlFile, @@ -22,6 +23,7 @@ import type { SaveNativePdfFileInput, SaveNativeSettingsFileInput } from "@markra/app/runtime"; +import { zipSync } from "fflate"; import { confirmWithBrowser, createBrowserDownload, @@ -35,6 +37,13 @@ import type { WebFileHandle, WebRuntimeOptions } from "./types"; +import { + createWorkspaceDirectoryHandle, + createWorkspaceFileHandle, + createWorkspaceUrl, + parseWorkspaceUrl +} from "./workspace-handles"; +import type { WorkspaceRepository } from "./workspace"; type WebHandlePath = | { @@ -48,6 +57,12 @@ type WebHandlePath = relativePath: string; }; +type RuntimeFolderPath = { + id: string; + relativePath: string; + workspace: boolean; +}; + type DirectoryUploadFile = File & { webkitRelativePath?: string; }; @@ -133,9 +148,11 @@ function decodePathSegments(path: string) { return path.split("/").filter(Boolean).map(decodeURIComponent).join("/"); } -function decodeMarkdownLocalPath(path: string) { +function decodeMarkdownRelativePath(src: string) { + const path = src.split(/[?#]/u)[0] ?? ""; + try { - return decodeURI(path); + return path.split("/").map((segment) => decodeURIComponent(segment)).join("/"); } catch { return path; } @@ -149,6 +166,25 @@ function normalizeWebRelativePath(path: string) { return path.replace(/\/+/gu, "/").replace(/^\/|\/$/gu, ""); } +function resolveWebRelativePath(parentPath: string, localSrc: string) { + // Markdown may climb parent folders, but browser handles cannot escape their selected root. + const segments = localSrc.startsWith("/") + ? [] + : parentPath.split("/").filter(Boolean); + + for (const segment of localSrc.replace(/\\/gu, "/").split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") { + if (!segments.length) throw new Error("Image path is outside the web folder root."); + segments.pop(); + continue; + } + segments.push(segment); + } + + return normalizeWebRelativePath(segments.join("/")); +} + function baseNameFromPath(path: string) { return path.split("/").filter(Boolean).pop() ?? path; } @@ -229,6 +265,48 @@ function parseWebHandlePath(path: string): WebHandlePath | null { return null; } +function parseRuntimeFolderPath(path: string): RuntimeFolderPath | null { + const workspace = parseWorkspaceUrl(path); + if (workspace) { + return { + id: workspace.workspaceId, + relativePath: workspace.path, + workspace: true + }; + } + + const external = parseWebHandlePath(path); + if (external?.kind !== "folder") return null; + + return { + id: external.id, + relativePath: external.relativePath, + workspace: false + }; +} + +function sameFolderRuntime(left: RuntimeFolderPath, right: RuntimeFolderPath) { + return left.id === right.id && left.workspace === right.workspace; +} + +function pathAtOrBelow(path: string, rootPath: string) { + return !rootPath || path === rootPath || path.startsWith(`${rootPath}/`); +} + +function pathRelativeToRoot(path: string, rootPath: string) { + if (!rootPath) return path; + if (path === rootPath) return ""; + if (!path.startsWith(`${rootPath}/`)) throw new Error("Path is outside the selected web folder."); + + return path.slice(rootPath.length + 1); +} + +function createRuntimeFolderPath(location: RuntimeFolderPath, relativePath: string) { + return location.workspace + ? createWorkspaceUrl(location.id, relativePath) + : createFolderPath(location.id, relativePath); +} + async function fileToDataUrl(file: File) { const buffer = await file.arrayBuffer(); const bytes = new Uint8Array(buffer); @@ -362,6 +440,7 @@ function createUploadedDirectoryHandle(files: File[]) { export function createWebFileRuntime( settings: AppSettingsRuntime, + workspaceRepository: WorkspaceRepository, options: WebRuntimeOptions ): AppFileRuntime { const fileHandles = new Map(); @@ -518,11 +597,18 @@ export function createWebFileRuntime( } async function directoryForPath(path: string) { - const parsedPath = parseWebHandlePath(path); - if (parsedPath?.kind !== "folder") { + const parsedPath = parseRuntimeFolderPath(path); + if (!parsedPath) { throw new Error("Path is not a web folder handle."); } - const root = await directoryHandleForId(parsedPath.id); + const root = parsedPath.workspace + ? createWorkspaceDirectoryHandle( + workspaceRepository, + parsedPath.id, + "", + (await workspaceRepository.ensureDefaultWorkspace()).name + ) + : await directoryHandleForId(parsedPath.id); if (!root) { throw new Error("Web folder handle is no longer available."); } @@ -530,39 +616,46 @@ export function createWebFileRuntime( return { directory: await resolveDirectory(root, parsedPath.relativePath), id: parsedPath.id, + location: parsedPath, relativePath: parsedPath.relativePath, root }; } async function treeEntryForPath(rootPath: string, path: string) { - const parsedPath = parseWebHandlePath(path); - if (parsedPath?.kind !== "folder" || !parsedPath.relativePath) { + const parsedPath = parseRuntimeFolderPath(path); + if (!parsedPath?.relativePath) { throw new Error("Path is not a web folder entry."); } - const { id, root } = await directoryForPath(rootPath); - if (parsedPath.id !== id) throw new Error("Path belongs to a different web folder."); + const rootDirectory = await directoryForPath(rootPath); + if (!sameFolderRuntime(parsedPath, rootDirectory.location)) { + throw new Error("Path belongs to a different web folder."); + } + if (!pathAtOrBelow(parsedPath.relativePath, rootDirectory.relativePath)) { + throw new Error("Path is outside the selected web folder."); + } const segments = parsedPath.relativePath.split("/").filter(Boolean); const name = segments.pop(); if (!name) throw new Error("Path is not a movable web folder entry."); const parentRelativePath = segments.join("/"); - const parent = await resolveDirectory(root, parentRelativePath); + const parent = await resolveDirectory(rootDirectory.root, parentRelativePath); try { const directory = await parent.getDirectoryHandle?.(name); if (directory) { return { handle: directory, - id, + location: parsedPath, kind: "folder" as const, name, parent, parentRelativePath, relativePath: parsedPath.relativePath, - root + root: rootDirectory.root, + rootRelativePath: rootDirectory.relativePath }; } } catch { @@ -574,34 +667,42 @@ export function createWebFileRuntime( return { handle: file, - id, + location: parsedPath, kind: "file" as const, name, parent, parentRelativePath, relativePath: parsedPath.relativePath, - root + root: rootDirectory.root, + rootRelativePath: rootDirectory.relativePath }; } async function targetDirectoryForPath(rootPath: string, targetParentPath: string | null | undefined) { - const { id, root } = await directoryForPath(rootPath); + const rootDirectory = await directoryForPath(rootPath); if (!targetParentPath) { return { - directory: root, - id, - relativePath: "" + directory: rootDirectory.directory, + location: rootDirectory.location, + relativePath: rootDirectory.relativePath, + rootRelativePath: rootDirectory.relativePath }; } - const parsedTargetPath = parseWebHandlePath(targetParentPath); - if (parsedTargetPath?.kind !== "folder") throw new Error("Target path is not a web folder handle."); - if (parsedTargetPath.id !== id) throw new Error("Target path belongs to a different web folder."); + const parsedTargetPath = parseRuntimeFolderPath(targetParentPath); + if (!parsedTargetPath) throw new Error("Target path is not a web folder handle."); + if (!sameFolderRuntime(parsedTargetPath, rootDirectory.location)) { + throw new Error("Target path belongs to a different web folder."); + } + if (!pathAtOrBelow(parsedTargetPath.relativePath, rootDirectory.relativePath)) { + throw new Error("Target path is outside the selected web folder."); + } return { - directory: await resolveDirectory(root, parsedTargetPath.relativePath), - id, - relativePath: parsedTargetPath.relativePath + directory: await resolveDirectory(rootDirectory.root, parsedTargetPath.relativePath), + location: parsedTargetPath, + relativePath: parsedTargetPath.relativePath, + rootRelativePath: rootDirectory.relativePath }; } @@ -627,6 +728,47 @@ export function createWebFileRuntime( } } + function isExclusiveCreateConflict(error: unknown) { + return typeof error === "object" + && error !== null + && "name" in error + && error.name === "InvalidModificationError"; + } + + async function createFileExclusive(directory: WebDirectoryHandle, name: string) { + if (directory.createFileExclusive) return directory.createFileExclusive(name); + + await assertTargetEntryAvailable(directory, name); + if (!directory.getFileHandle) throw new Error("Browser directory handle cannot create files."); + + return directory.getFileHandle(name, { create: true }); + } + + async function createDirectoryExclusive(directory: WebDirectoryHandle, name: string) { + if (directory.createDirectoryExclusive) return directory.createDirectoryExclusive(name); + + await assertTargetEntryAvailable(directory, name); + if (!directory.getDirectoryHandle) throw new Error("Browser directory handle cannot create folders."); + + return directory.getDirectoryHandle(name, { create: true }); + } + + async function createUniqueWorkspaceFile(directory: WebDirectoryHandle, fileName: string) { + for (let attempt = 0; attempt < 1000; attempt += 1) { + const candidate = uniqueFileNameCandidate(fileName, attempt); + try { + return { + handle: await createFileExclusive(directory, candidate), + name: candidate + }; + } catch (error) { + if (!isExclusiveCreateConflict(error)) throw error; + } + } + + throw new Error("Could not create a unique workspace file."); + } + async function ensureDirectory(directory: WebDirectoryHandle, relativePath: string) { let current = directory; for (const segment of relativePath.split("/").filter(Boolean)) { @@ -678,19 +820,36 @@ export function createWebFileRuntime( await parent.removeEntry(name, { recursive: true }); } - function movedTreeFile(id: string, relativePath: string, name: string, kind: "file" | "folder") { + function movedTreeFile( + location: RuntimeFolderPath, + rootRelativePath: string, + path: string, + name: string, + kind: "file" | "folder" + ) { + const relativePath = pathRelativeToRoot(path, rootRelativePath); + return { ...(kind === "folder" ? { kind: "folder" as const } : folderFileKindFromName(name)), name, - path: createFolderPath(id, relativePath), + path: createRuntimeFolderPath(location, path), relativePath }; } - async function resolveFileFromFolderPath(id: string, relativePath: string) { - const root = await directoryHandleForId(id); + async function resolveFileFromFolderPath(path: string) { + const parsedPath = parseRuntimeFolderPath(path); + if (!parsedPath?.relativePath) throw new Error("Path is not a file."); + const root = parsedPath.workspace + ? createWorkspaceDirectoryHandle( + workspaceRepository, + parsedPath.id, + "", + (await workspaceRepository.ensureDefaultWorkspace()).name + ) + : await directoryHandleForId(parsedPath.id); if (!root) throw new Error("Web folder handle is no longer available."); - const segments = relativePath.split("/").filter(Boolean); + const segments = parsedPath.relativePath.split("/").filter(Boolean); const fileName = segments.pop(); if (!fileName) throw new Error("Path is not a file."); @@ -715,7 +874,23 @@ export function createWebFileRuntime( }; } if (parsedPath?.kind === "folder") { - const handle = await resolveFileFromFolderPath(parsedPath.id, parsedPath.relativePath); + const handle = await resolveFileFromFolderPath(path); + + return { + file: await handle.getFile(), + handle, + path + }; + } + + const workspacePath = parseWorkspaceUrl(path); + if (workspacePath?.path) { + await workspaceRepository.ensureDefaultWorkspace(); + const handle = createWorkspaceFileHandle( + workspaceRepository, + workspacePath.workspaceId, + workspacePath.path + ); return { file: await handle.getFile(), @@ -754,11 +929,11 @@ export function createWebFileRuntime( } async function collectMarkdownEntries( - id: string, directory: WebDirectoryHandle, parentRelativePath: string, entries: NativeMarkdownFolderFile[], managedAttachmentFolder: string | null, + createPath: (relativePath: string) => string, ignoreRules: MarkdownIgnoreRules ) { const iterator = directory.entries?.() ?? fallbackDirectoryEntries(directory); @@ -771,10 +946,17 @@ export function createWebFileRuntime( entries.push({ kind: "folder", name, - path: createFolderPath(id, relativePath), + path: createPath(relativePath), relativePath }); - await collectMarkdownEntries(id, handle, relativePath, entries, managedAttachmentFolder, ignoreRules); + await collectMarkdownEntries( + handle, + relativePath, + entries, + managedAttachmentFolder, + createPath, + ignoreRules + ); } continue; } @@ -784,13 +966,52 @@ export function createWebFileRuntime( const file = { ...folderFileKindFromName(name), name, - path: createFolderPath(id, relativePath), + path: createPath(relativePath), relativePath }; if (shouldIncludeFolderFile(file, managedAttachmentFolder)) entries.push(file); } } + async function listWorkspaceMarkdownEntries( + location: RuntimeFolderPath, + managedAttachmentFolder: string | null, + ignoreRules: MarkdownIgnoreRules + ) { + const storedEntries = await workspaceRepository.list( + location.id, + location.relativePath || undefined + ); + const entries: NativeMarkdownFolderFile[] = []; + for (const entry of storedEntries) { + const relativePath = pathRelativeToRoot(entry.path, location.relativePath); + if (!relativePath) continue; + + if (ignoreRules.ignores(relativePath, entry.kind === "directory")) continue; + + const segments = relativePath.split("/"); + const name = segments.at(-1) ?? relativePath; + const file = entry.kind === "directory" + ? { + kind: "folder" as const, + name, + path: createRuntimeFolderPath(location, entry.path), + relativePath + } + : { + ...folderFileKindFromName(name), + name, + path: createRuntimeFolderPath(location, entry.path), + relativePath + }; + if (entry.kind === "directory" || shouldIncludeFolderFile(file, managedAttachmentFolder)) { + entries.push(file); + } + } + + return entries; + } + async function templateStore() { return settings.loadStore(markdownTemplateStorePath, { autoSave: false, defaults: {} }); } @@ -888,10 +1109,29 @@ export function createWebFileRuntime( return droppedTargetFromUploadFiles(dropFiles(dataTransfer)); } + async function readMarkdownImageFile(input: ReadNativeMarkdownImageInput) { + const documentPath = parseRuntimeFolderPath(input.documentPath); + if (!documentPath) throw new Error("Current document is not a web folder file."); + const documentSegments = documentPath.relativePath.split("/").filter(Boolean); + documentSegments.pop(); + const localSrc = decodeMarkdownRelativePath(input.src); + const imagePath = resolveWebRelativePath(documentSegments.join("/"), localSrc); + const handle = await resolveFileFromFolderPath(createRuntimeFolderPath(documentPath, imagePath)); + const file = await handle.getFile(); + + return { + dataUrl: await fileToDataUrl(file), + mimeType: file.type || "application/octet-stream", + path: createRuntimeFolderPath(documentPath, imagePath), + src: input.src + } satisfies NativeMarkdownImageFile; + } + return { backupMarkdownFolder: async () => { throw new Error("Local folder backups require the desktop runtime."); }, + canExportMarkdownFolder: (path) => parseWorkspaceUrl(path) !== null, syncMarkdownFolder: async () => { throw new Error("Remote sync requires the desktop runtime."); }, @@ -901,34 +1141,57 @@ export function createWebFileRuntime( const options = typeof optionsOrParentPath === "object" && optionsOrParentPath !== null ? optionsOrParentPath : { parentPath: optionsOrParentPath }; - const { directory, id } = await directoryForPath(rootPath); - const parent = options.parentPath ? (await directoryForPath(options.parentPath)).directory : directory; + const root = await directoryForPath(rootPath); + const parentPath = options.parentPath ? await directoryForPath(options.parentPath) : root; + if (!sameFolderRuntime(root.location, parentPath.location)) { + throw new Error("Parent path belongs to a different web folder."); + } + if (!pathAtOrBelow(parentPath.relativePath, root.relativePath)) { + throw new Error("Parent path is outside the selected web folder."); + } + const parent = parentPath.directory; if (!parent.getFileHandle) throw new Error("Browser directory handle cannot create files."); - const handle = await parent.getFileHandle(fileName, { create: true }); + let handle: WebFileHandle; + try { + handle = await createFileExclusive(parent, fileName); + } catch (error) { + if (!isExclusiveCreateConflict(error)) throw error; + throw new Error("Target entry already exists."); + } await writeFileHandle(handle, options.contents ?? ""); - const relativePath = options.parentPath - ? joinRelativePath(parseWebHandlePath(options.parentPath)?.relativePath ?? "", fileName) - : fileName; + const path = joinRelativePath(parentPath.relativePath, fileName); + const relativePath = pathRelativeToRoot(path, root.relativePath); return { name: fileName, - path: createFolderPath(id, relativePath), + path: createRuntimeFolderPath(root.location, path), relativePath }; }, async createMarkdownTreeFolder(rootPath, folderName, parentPath = null) { - const { directory, id } = await directoryForPath(rootPath); - const parent = parentPath ? (await directoryForPath(parentPath)).directory : directory; + const root = await directoryForPath(rootPath); + const resolvedParent = parentPath ? await directoryForPath(parentPath) : root; + if (!sameFolderRuntime(root.location, resolvedParent.location)) { + throw new Error("Parent path belongs to a different web folder."); + } + if (!pathAtOrBelow(resolvedParent.relativePath, root.relativePath)) { + throw new Error("Parent path is outside the selected web folder."); + } + const parent = resolvedParent.directory; if (!parent.getDirectoryHandle) throw new Error("Browser directory handle cannot create folders."); - await parent.getDirectoryHandle(folderName, { create: true }); - const relativePath = parentPath - ? joinRelativePath(parseWebHandlePath(parentPath)?.relativePath ?? "", folderName) - : folderName; + try { + await createDirectoryExclusive(parent, folderName); + } catch (error) { + if (!isExclusiveCreateConflict(error)) throw error; + throw new Error("Target entry already exists."); + } + const path = joinRelativePath(resolvedParent.relativePath, folderName); + const relativePath = pathRelativeToRoot(path, root.relativePath); return { kind: "folder", name: folderName, - path: createFolderPath(id, relativePath), + path: createRuntimeFolderPath(root.location, path), relativePath }; }, @@ -938,17 +1201,64 @@ export function createWebFileRuntime( await store.save(); }, async deleteMarkdownTreeFile(rootPath, path) { - const parsedPath = parseWebHandlePath(path); - if (parsedPath?.kind !== "folder") throw new Error("Path is not a web folder entry."); + const parsedPath = parseRuntimeFolderPath(path); + if (!parsedPath) throw new Error("Path is not a web folder entry."); const segments = parsedPath.relativePath.split("/").filter(Boolean); const name = segments.pop(); if (!name) return; - const { root } = await directoryForPath(rootPath); - const parent = await resolveDirectory(root, segments.join("/")); + const rootDirectory = await directoryForPath(rootPath); + if (!sameFolderRuntime(parsedPath, rootDirectory.location)) { + throw new Error("Path belongs to a different web folder."); + } + if (!pathAtOrBelow(parsedPath.relativePath, rootDirectory.relativePath)) { + throw new Error("Path is outside the selected web folder."); + } + const parent = await resolveDirectory(rootDirectory.root, segments.join("/")); if (!parent.removeEntry) throw new Error("Browser directory handle cannot delete entries."); await parent.removeEntry(name, { recursive: true }); }, detectPandocPath: async () => null, + async exportMarkdownFolder(path) { + const workspacePath = parseWorkspaceUrl(path); + if (!workspacePath) throw new Error("Path is not a web workspace folder."); + await workspaceRepository.ensureDefaultWorkspace(); + const entries = await workspaceRepository.exportEntries( + workspacePath.workspaceId, + workspacePath.path || undefined + ); + const archiveEntries: Array = []; + for (const entry of entries) { + const relativePath = pathRelativeToRoot(entry.path, workspacePath.path); + if (!relativePath) continue; + if (entry.kind === "directory") { + archiveEntries.push([`${relativePath}/`, new Uint8Array()]); + continue; + } + + archiveEntries.push([ + relativePath, + new Uint8Array(await (entry.body ?? new Blob([])).arrayBuffer()) + ]); + } + const name = `${baseNameFromPath(workspacePath.path) || "Markra"}.zip`; + const contents = new Blob([zipSync(Object.fromEntries(archiveEntries))], { + type: "application/zip" + }); + await downloadFile({ contents, name, type: "application/zip" }); + + return { + name, + path: `web-download://${encodeURIComponent(name)}` + }; + }, + async getDefaultMarkdownFolder() { + const workspace = await workspaceRepository.ensureDefaultWorkspace(); + + return { + name: workspace.name, + path: createWorkspaceUrl(workspace.id, "") + }; + }, async downloadWebImage(input) { const response = await (options.fetch ?? globalThis.fetch)(input.src); const blob = await response.blob(); @@ -995,15 +1305,36 @@ export function createWebFileRuntime( listenOpenedMarkdownPaths: async () => () => undefined, listMarkdownFileHistory: async () => [], async listMarkdownFilesForPath(path, options: ListNativeMarkdownFilesOptions = {}) { - const parsedPath = parseWebHandlePath(path); - if (parsedPath?.kind !== "folder") return []; - const root = await directoryHandleForId(parsedPath.id); - if (!root) throw new Error("Web folder handle is no longer available."); - const entries: NativeMarkdownFolderFile[] = []; + const parsedPath = parseRuntimeFolderPath(path); + if (!parsedPath) return []; const managedAttachmentFolder = normalizeManagedAttachmentFolder(options.managedAttachmentFolder); - const ignoreRules = await loadMarkdownIgnoreRules(root, options.globalIgnoreRules ?? ""); + const resolved = await directoryForPath(path); + const ignoreRules = await loadMarkdownIgnoreRules( + resolved.directory, + options.globalIgnoreRules ?? "" + ); + if (parsedPath.workspace) { + // A flat repository scan prevents recursive handles from re-reading the same subtree at every depth. + const entries = await listWorkspaceMarkdownEntries(parsedPath, managedAttachmentFolder, ignoreRules); + + return entries.sort((left, right) => + left.relativePath.toLowerCase().localeCompare(right.relativePath.toLowerCase()) + ); + } - await collectMarkdownEntries(parsedPath.id, root, "", entries, managedAttachmentFolder, ignoreRules); + const entries: NativeMarkdownFolderFile[] = []; + + await collectMarkdownEntries( + resolved.directory, + "", + entries, + managedAttachmentFolder, + (relativePath) => createRuntimeFolderPath( + parsedPath, + joinRelativePath(parsedPath.relativePath, relativePath) + ), + ignoreRules + ); return entries.sort((left, right) => left.relativePath.toLowerCase().localeCompare(right.relativePath.toLowerCase())); }, @@ -1019,16 +1350,25 @@ export function createWebFileRuntime( } await assertTargetEntryAvailable(target.directory, source.name); - if (source.kind === "folder") { + if (source.location.workspace) { + if (!source.handle.move) throw new Error("Workspace entry cannot be moved atomically."); + await source.handle.move(target.directory); + } else if (source.kind === "folder") { await copyDirectoryHandle(source.handle, target.directory, source.name); } else { await copyFileHandle(source.handle, target.directory, source.name); } - await removeTreeEntry(source.parent, source.name); + if (!source.location.workspace) await removeTreeEntry(source.parent, source.name); const relativePath = joinRelativePath(target.relativePath, source.name); - return movedTreeFile(source.id, relativePath, source.name, source.kind); + return movedTreeFile( + target.location, + target.rootRelativePath, + relativePath, + source.name, + source.kind + ); }, async openMarkdownFile() { if (!showOpenFilePicker) return null; @@ -1053,16 +1393,16 @@ export function createWebFileRuntime( openLocalImages: async () => [], openLocalFiles: async () => [], async openMarkdownAttachment(input) { - const parsedDocumentPath = input.documentPath ? parseWebHandlePath(input.documentPath) : null; - if (parsedDocumentPath?.kind !== "folder" || !parsedDocumentPath.relativePath) { + const parsedDocumentPath = input.documentPath ? parseRuntimeFolderPath(input.documentPath) : null; + if (!parsedDocumentPath?.relativePath) { throw new Error("Current document is not a web folder file."); } const documentSegments = parsedDocumentPath.relativePath.split("/").filter(Boolean); documentSegments.pop(); - const localSrc = decodeMarkdownLocalPath(input.src.split(/[?#]/u)[0] ?? ""); + const localSrc = decodeMarkdownRelativePath(input.src); const attachmentPath = normalizeWebRelativePath(joinRelativePath(documentSegments.join("/"), localSrc)); - const handle = await resolveFileFromFolderPath(parsedDocumentPath.id, attachmentPath); + const handle = await resolveFileFromFolderPath(createRuntimeFolderPath(parsedDocumentPath, attachmentPath)); const file = await handle.getFile(); const url = URL.createObjectURL(file); window.open(url, "_blank", "noopener,noreferrer"); @@ -1079,14 +1419,15 @@ export function createWebFileRuntime( } satisfies NativeMarkdownFolder; } - const handle = createUploadedDirectoryHandle(await pickDirectoryFiles()); - if (!handle) return null; - const registered = registerDirectoryHandle(handle); - await persistDirectoryHandle(registered.id, handle); + const files = await pickDirectoryFiles(); + if (files.length === 0) return null; + const workspace = await workspaceRepository.ensureDefaultWorkspace(); + const rootName = uploadedDirectoryRootName(files); + const relativePath = await workspaceRepository.importDirectory(workspace.id, rootName, files); return { - name: handle.name, - path: registered.path + name: rootName, + path: createWorkspaceUrl(workspace.id, relativePath) } satisfies NativeMarkdownFolder; }, openMarkdownFolderInNewWindow: async (path) => openMarkdownRouteInNewWindow("folder", path), @@ -1114,8 +1455,11 @@ export function createWebFileRuntime( path: await registerFileHandle(handle) }; }, - async readLocalImageFile() { - throw new Error("Local image file reading requires the desktop runtime."); + async readLocalImageFile(path) { + const { file } = await readFileFromPath(path); + if (!isAssetFileName(file.name)) throw new Error("Selected file is not a supported image file."); + + return file; }, async readMarkdownFile(path) { const { file } = await readFileFromPath(path); @@ -1129,22 +1473,8 @@ export function createWebFileRuntime( }; }, readMarkdownFileHistory: () => Promise.reject(new Error("Markdown history is unavailable in the web runtime.")), - async readMarkdownImageFile(input) { - const documentPath = parseWebHandlePath(input.documentPath); - if (documentPath?.kind !== "folder") throw new Error("Current document is not a web folder file."); - const documentSegments = documentPath.relativePath.split("/").filter(Boolean); - documentSegments.pop(); - const imagePath = joinRelativePath(documentSegments.join("/"), input.src); - const handle = await resolveFileFromFolderPath(documentPath.id, imagePath); - const file = await handle.getFile(); - - return { - dataUrl: await fileToDataUrl(file), - mimeType: file.type || "application/octet-stream", - path: createFolderPath(documentPath.id, imagePath), - src: input.src - } satisfies NativeMarkdownImageFile; - }, + readMarkdownImageFile, + resolveMarkdownImageSrc: async (input) => (await readMarkdownImageFile(input)).dataUrl, async readMarkdownTemplateFile(fileName) { const store = await templateStore(); @@ -1155,22 +1485,52 @@ export function createWebFileRuntime( const normalizedFileName = fileName.trim(); if (!normalizedFileName) throw new Error("File name is required."); if (normalizedFileName === source.name) { - return movedTreeFile(source.id, source.relativePath, source.name, source.kind); + return movedTreeFile( + source.location, + source.rootRelativePath, + source.relativePath, + source.name, + source.kind + ); } await assertTargetEntryAvailable(source.parent, normalizedFileName); - if (source.kind === "folder") { + if (source.location.workspace) { + if (!source.handle.move) throw new Error("Workspace entry cannot be renamed atomically."); + await source.handle.move(normalizedFileName); + } else if (source.kind === "folder") { await copyDirectoryHandle(source.handle, source.parent, normalizedFileName); } else { await copyFileHandle(source.handle, source.parent, normalizedFileName); } - await removeTreeEntry(source.parent, source.name); + if (!source.location.workspace) await removeTreeEntry(source.parent, source.name); const relativePath = joinRelativePath(source.parentRelativePath, normalizedFileName); - return movedTreeFile(source.id, relativePath, normalizedFileName, source.kind); + return movedTreeFile( + source.location, + source.rootRelativePath, + relativePath, + normalizedFileName, + source.kind + ); }, async resolveMarkdownPath(path) { + const workspacePath = parseWorkspaceUrl(path); + if (workspacePath?.path) { + return { + kind: "file", + name: baseNameFromPath(workspacePath.path), + path + }; + } + if (workspacePath) { + return { + kind: "folder", + name: (await workspaceRepository.ensureDefaultWorkspace()).name, + path + }; + } const parsedPath = parseWebHandlePath(path); if (parsedPath?.kind === "folder" && parsedPath.relativePath) { const fileName = baseNameFromPath(parsedPath.relativePath); @@ -1207,8 +1567,8 @@ export function createWebFileRuntime( }; } - const parsedDocumentPath = parseWebHandlePath(input.documentPath ?? ""); - if (parsedDocumentPath?.kind !== "folder" || !parsedDocumentPath.relativePath) { + const parsedDocumentPath = parseRuntimeFolderPath(input.documentPath ?? ""); + if (!parsedDocumentPath?.relativePath) { const url = URL.createObjectURL(input.image); return { @@ -1217,12 +1577,12 @@ export function createWebFileRuntime( }; } - const root = await directoryHandleForId(parsedDocumentPath.id); - if (!root) throw new Error("Web folder handle is no longer available."); - const documentSegments = parsedDocumentPath.relativePath.split("/").filter(Boolean); documentSegments.pop(); - const documentDirectory = await resolveDirectory(root, documentSegments.join("/")); + const documentDirectory = (await directoryForPath(createRuntimeFolderPath( + parsedDocumentPath, + documentSegments.join("/") + ))).directory; const folder = normalizeClipboardImageFolder(input.folder); const targetDirectory = await ensureDirectory(documentDirectory, folder); const fileName = await uniqueFileName(targetDirectory, input.fileName); @@ -1242,8 +1602,8 @@ export function createWebFileRuntime( }; } - const parsedDocumentPath = parseWebHandlePath(input.documentPath ?? ""); - if (parsedDocumentPath?.kind !== "folder" || !parsedDocumentPath.relativePath) { + const parsedDocumentPath = parseRuntimeFolderPath(input.documentPath ?? ""); + if (!parsedDocumentPath?.relativePath) { const url = URL.createObjectURL(input.attachment); return { @@ -1252,12 +1612,12 @@ export function createWebFileRuntime( }; } - const root = await directoryHandleForId(parsedDocumentPath.id); - if (!root) throw new Error("Web folder handle is no longer available."); - const documentSegments = parsedDocumentPath.relativePath.split("/").filter(Boolean); documentSegments.pop(); - const documentDirectory = await resolveDirectory(root, documentSegments.join("/")); + const documentDirectory = (await directoryForPath(createRuntimeFolderPath( + parsedDocumentPath, + documentSegments.join("/") + ))).directory; const folder = normalizeClipboardImageFolder(input.folder); const targetDirectory = await ensureDirectory(documentDirectory, folder); const fileName = await uniqueFileName(targetDirectory, input.attachment.name.trim() || "attachment"); @@ -1275,11 +1635,15 @@ export function createWebFileRuntime( async saveMarkdownFile(input: SaveNativeMarkdownFileInput): Promise { if (input.path) { const parsedPath = parseWebHandlePath(input.path); - const handle = parsedPath?.kind === "file" - ? await fileHandleForId(parsedPath.id) - : parsedPath?.kind === "folder" - ? await resolveFileFromFolderPath(parsedPath.id, parsedPath.relativePath) - : null; + const workspacePath = parseWorkspaceUrl(input.path); + const handle = workspacePath?.path + ? createWorkspaceFileHandle(workspaceRepository, workspacePath.workspaceId, workspacePath.path) + : parsedPath?.kind === "file" + ? await fileHandleForId(parsedPath.id) + : parsedPath?.kind === "folder" + ? await resolveFileFromFolderPath(input.path) + : null; + if (workspacePath) await workspaceRepository.ensureDefaultWorkspace(); if (handle && await writeFileHandle(handle, input.contents)) { const file = await handle.getFile(); @@ -1290,6 +1654,26 @@ export function createWebFileRuntime( } } + const defaultWorkspace = parseWorkspaceUrl(input.defaultDirectory ?? ""); + if (defaultWorkspace) { + const workspace = await workspaceRepository.ensureDefaultWorkspace(); + const directory = createWorkspaceDirectoryHandle( + workspaceRepository, + defaultWorkspace.workspaceId, + defaultWorkspace.path, + baseNameFromPath(defaultWorkspace.path) || workspace.name + ); + const created = await createUniqueWorkspaceFile(directory, input.suggestedName); + const { fileName, handle } = { fileName: created.name, handle: created.handle }; + await writeFileHandle(handle, input.contents); + const path = joinRelativePath(defaultWorkspace.path, fileName); + + return { + name: fileName, + path: createWorkspaceUrl(defaultWorkspace.workspaceId, path) + }; + } + if (showSaveFilePicker) { const handle = await showSaveFilePicker({ suggestedName: input.suggestedName, diff --git a/apps/web/src/runtime/web/index.ts b/apps/web/src/runtime/web/index.ts index 82621658..3ffe46db 100644 --- a/apps/web/src/runtime/web/index.ts +++ b/apps/web/src/runtime/web/index.ts @@ -9,3 +9,5 @@ export * from "./settings"; export * from "./types"; export * from "./web-resource"; export * from "./window"; +export * from "./workspace"; +export * from "./workspace-handles"; diff --git a/apps/web/src/runtime/web/types.ts b/apps/web/src/runtime/web/types.ts index 23ce6deb..82b40b04 100644 --- a/apps/web/src/runtime/web/types.ts +++ b/apps/web/src/runtime/web/types.ts @@ -9,23 +9,38 @@ export type WebWritableFileStream = { write: (data: BlobPart) => Promise; }; +export type WebHandleMove = { + (name: string): Promise; + (directory: WebDirectoryHandle): Promise; + (directory: WebDirectoryHandle, name: string): Promise; +}; + export type WebFileHandle = { createWritable?: () => Promise; getFile: () => Promise; kind?: "file"; + move?: WebHandleMove; name: string; }; export type WebDirectoryHandle = { + createDirectoryExclusive?: (name: string) => Promise; + createFileExclusive?: (name: string) => Promise; entries?: () => AsyncIterable<[string, WebFileHandle | WebDirectoryHandle]>; getDirectoryHandle?: (name: string, options?: { create?: boolean }) => Promise; getFileHandle?: (name: string, options?: { create?: boolean }) => Promise; kind?: "directory"; + move?: WebHandleMove; name: string; removeEntry?: (name: string, options?: { recursive?: boolean }) => Promise; values?: () => AsyncIterable; }; +export type WebWorkspaceLocation = { + path: string; + workspaceId: string; +}; + export type WebDownloadFile = { contents: BlobPart; name: string; diff --git a/apps/web/src/runtime/web/workspace-handles.test.ts b/apps/web/src/runtime/web/workspace-handles.test.ts new file mode 100644 index 00000000..a075157c --- /dev/null +++ b/apps/web/src/runtime/web/workspace-handles.test.ts @@ -0,0 +1,190 @@ +import { FakeIndexedDbFactory } from "../../test/web-runtime-fakes"; +import type { WebDirectoryHandle } from "./types"; +import { + createWorkspaceDirectoryHandle, + createWorkspaceFileHandle, + createWorkspaceUrl, + parseWorkspaceUrl +} from "./workspace-handles"; +import { createWorkspaceRepository, type WorkspaceRepository } from "./workspace"; + +async function collectEntryNames(directory: WebDirectoryHandle) { + const names: string[] = []; + for await (const [name] of directory.entries!()) names.push(name); + + return names; +} + +async function createWorkspace() { + const repository = createWorkspaceRepository({ + indexedDB: new FakeIndexedDbFactory().indexedDB + }); + await repository.ensureDefaultWorkspace(); + + return repository; +} + +describe("IndexedDB-backed workspace handles", () => { + it("creates, writes, lists, reopens, and removes repository-backed files", async () => { + const repository = await createWorkspace(); + const root = createWorkspaceDirectoryHandle(repository, "default", "", "Markra"); + const docs = await root.getDirectoryHandle!("docs", { create: true }); + const note = await docs.getFileHandle!("note.md", { create: true }); + const writable = await note.createWritable!(); + await writable.write("# Local"); + await writable.close(); + + await expect((await note.getFile()).text()).resolves.toBe("# Local"); + await expect(collectEntryNames(root)).resolves.toEqual(["docs"]); + + const reopenedRoot = createWorkspaceDirectoryHandle(repository, "default", "", "Markra"); + const reopenedDocs = await reopenedRoot.getDirectoryHandle!("docs"); + const reopenedNote = await reopenedDocs.getFileHandle!("note.md"); + await expect((await reopenedNote.getFile()).text()).resolves.toBe("# Local"); + + await reopenedDocs.removeEntry!("note.md"); + await expect(reopenedDocs.getFileHandle!("note.md")).rejects.toMatchObject({ + name: "NotFoundError" + }); + }); + + it("does not commit a write until the writable stream closes", async () => { + const repository = await createWorkspace(); + await repository.writeFile("default", "note.md", new Blob(["saved"])); + const root = createWorkspaceDirectoryHandle(repository, "default", "", "Markra"); + const file = await root.getFileHandle!("note.md"); + + const writable = await file.createWritable!(); + await writable.write("draft"); + await expect((await file.getFile()).text()).resolves.toBe("saved"); + await writable.close(); + await expect((await file.getFile()).text()).resolves.toBe("draft"); + }); + + it("rejects closing a stale file handle after its entry was removed", async () => { + const repository = await createWorkspace(); + await repository.writeFile("default", "note.md", new Blob(["saved"])); + const root = createWorkspaceDirectoryHandle(repository, "default", "", "Markra"); + const file = await root.getFileHandle!("note.md"); + const writable = await file.createWritable!(); + await writable.write("stale replacement"); + await root.removeEntry!("note.md"); + + await expect(writable.close()).rejects.toMatchObject({ name: "NotFoundError" }); + await expect(repository.read("default", "note.md")).rejects.toThrow("not found"); + }); + + it("propagates storage read failures without truncating an existing file", async () => { + const storedRepository = await createWorkspace(); + await storedRepository.writeFile("default", "note.md", new Blob(["preserved"])); + const readFailure = new DOMException("Synthetic transaction failure", "NotFoundError"); + const repository: WorkspaceRepository = { + ...storedRepository, + async read() { + throw readFailure; + } + }; + const root = createWorkspaceDirectoryHandle(repository, "default", "", "Markra"); + + await expect(root.getFileHandle!("note.md", { create: true })).rejects.toBe(readFailure); + await expect((await storedRepository.read("default", "note.md")).body?.text()).resolves.toBe( + "preserved" + ); + }); + + it("validates file handle workspace ids and paths immediately", async () => { + const repository = await createWorkspace(); + + expect(() => createWorkspaceFileHandle(repository, "invalid/id", "note.md")).toThrow(TypeError); + expect(() => createWorkspaceFileHandle(repository, "default", "../secret.md")).toThrow(TypeError); + }); + + it("moves a handle within its repository-backed workspace", async () => { + const repository = await createWorkspace(); + const root = createWorkspaceDirectoryHandle(repository, "default", "", "Markra"); + const drafts = await root.getDirectoryHandle!("drafts", { create: true }); + const published = await root.getDirectoryHandle!("published", { create: true }); + const note = await drafts.getFileHandle!("note.md", { create: true }); + const writable = await note.createWritable!(); + await writable.write("ready"); + await writable.close(); + + await note.move!(published, "release.md"); + + expect(note.name).toBe("release.md"); + await expect(drafts.getFileHandle!("note.md")).rejects.toMatchObject({ name: "NotFoundError" }); + await expect((await note.getFile()).text()).resolves.toBe("ready"); + await expect(collectEntryNames(published)).resolves.toEqual(["release.md"]); + }); + + it("rejects creating children through a removed directory handle", async () => { + const repository = await createWorkspace(); + const root = createWorkspaceDirectoryHandle(repository, "default", "", "Markra"); + const staleDocs = await root.getDirectoryHandle!("docs", { create: true }); + await root.removeEntry!("docs", { recursive: true }); + + await expect(staleDocs.getFileHandle!("orphan.md", { create: true })).rejects.toMatchObject({ + name: "NotFoundError" + }); + await expect(repository.exportEntries("default")).resolves.toEqual([]); + }); + + it("rejects moving into a removed directory handle", async () => { + const repository = await createWorkspace(); + const root = createWorkspaceDirectoryHandle(repository, "default", "", "Markra"); + const staleTarget = await root.getDirectoryHandle!("archive", { create: true }); + const note = await root.getFileHandle!("note.md", { create: true }); + const writable = await note.createWritable!(); + await writable.write("preserved"); + await writable.close(); + await root.removeEntry!("archive", { recursive: true }); + + await expect(note.move!(staleTarget, "note.md")).rejects.toMatchObject({ + name: "NotFoundError" + }); + await expect((await note.getFile()).text()).resolves.toBe("preserved"); + await expect(repository.read("default", "archive/note.md")).rejects.toThrow("not found"); + }); + + it("uses browser-compatible exceptions for missing, mismatched, and non-empty entries", async () => { + const repository = await createWorkspace(); + const root = createWorkspaceDirectoryHandle(repository, "default", "", "Markra"); + const docs = await root.getDirectoryHandle!("docs", { create: true }); + await docs.getFileHandle!("note.md", { create: true }); + + await expect(root.getFileHandle!("missing.md")).rejects.toMatchObject({ name: "NotFoundError" }); + await expect(root.getFileHandle!("docs")).rejects.toMatchObject({ name: "TypeMismatchError" }); + await expect(root.removeEntry!("docs")).rejects.toMatchObject({ name: "InvalidModificationError" }); + + await root.removeEntry!("docs", { recursive: true }); + await expect(root.getDirectoryHandle!("docs")).rejects.toMatchObject({ name: "NotFoundError" }); + }); + + it("round-trips canonical hierarchical workspace URLs", () => { + const url = createWorkspaceUrl("default", "notes/你好 world.md"); + + expect(createWorkspaceUrl("default", "")).toBe("web-workspace://default"); + expect(parseWorkspaceUrl("web-workspace://default")).toEqual({ + path: "", + workspaceId: "default" + }); + expect(url).toBe("web-workspace://default/notes/%E4%BD%A0%E5%A5%BD%20world.md"); + expect(parseWorkspaceUrl(url)).toEqual({ + path: "notes/你好 world.md", + workspaceId: "default" + }); + }); + + it("resolves relative URLs within the current workspace directory", () => { + const baseUrl = createWorkspaceUrl("default", "notes/guide.md"); + + expect(new URL("image.png", baseUrl).href).toBe("web-workspace://default/notes/image.png"); + }); + + it("rejects foreign and malformed workspace URLs", () => { + expect(parseWorkspaceUrl("https://example.test/note.md")).toBeNull(); + expect(parseWorkspaceUrl("web-workspace://default?view=1/note.md")).toBeNull(); + expect(parseWorkspaceUrl("web-workspace://default/%2E%2E%2Fsecret.md")).toBeNull(); + expect(parseWorkspaceUrl("web-workspace://default/notes%2Fguide.md")).toBeNull(); + }); +}); diff --git a/apps/web/src/runtime/web/workspace-handles.ts b/apps/web/src/runtime/web/workspace-handles.ts new file mode 100644 index 00000000..9db26554 --- /dev/null +++ b/apps/web/src/runtime/web/workspace-handles.ts @@ -0,0 +1,421 @@ +import type { + WebDirectoryHandle, + WebFileHandle, + WebHandleMove, + WebWorkspaceLocation +} from "./types"; +import { + WorkspaceEntryNotFoundError, + WorkspaceNamespaceConflictError, + type WorkspaceEntry, + type WorkspaceRepository +} from "./workspace"; + +const workspaceUrlPrefix = "web-workspace://"; + +type WorkspaceHandleState = { + name: string; + path: string; + repository: WorkspaceRepository; + workspaceId: string; +}; + +const directoryStates = new WeakMap(); + +function workspacePathName(path: string) { + return path.split("/").at(-1) ?? path; +} + +function workspaceParentPath(path: string) { + const separatorIndex = path.lastIndexOf("/"); + + return separatorIndex < 0 ? "" : path.slice(0, separatorIndex); +} + +function workspaceChildPath(rootPath: string, name: string) { + return rootPath ? `${rootPath}/${name}` : name; +} + +function validateWorkspaceName(name: string) { + if ( + !name + || name === "." + || name === ".." + || name.includes("/") + || name.includes("\\") + || name !== name.normalize("NFC") + ) { + throw new TypeError(`Invalid workspace entry name: ${name}`); + } +} + +function validateWorkspacePath(path: string) { + if (path === "") return; + + const parts = path.split("/"); + if ( + path.startsWith("/") + || path.endsWith("/") + || path.includes("\\") + || path !== path.normalize("NFC") + || parts.some((part) => !part || part === "." || part === "..") + ) { + throw new TypeError(`Invalid workspace path: ${path}`); + } +} + +function validateWorkspaceId(workspaceId: string) { + if ( + !workspaceId + || workspaceId.includes("/") + || workspaceId.includes("\\") + || workspaceId !== workspaceId.normalize("NFC") + ) { + throw new TypeError(`Invalid workspace id: ${workspaceId}`); + } +} + +function workspaceDomException(name: string, message: string) { + return new DOMException(message, name); +} + +function notFound(path: string) { + return workspaceDomException("NotFoundError", `Workspace entry was not found: ${path}.`); +} + +function typeMismatch(path: string, expectedKind: WorkspaceEntry["kind"]) { + return workspaceDomException( + "TypeMismatchError", + `Workspace entry is not a ${expectedKind}: ${path}.` + ); +} + +function invalidModification(path: string) { + return workspaceDomException("InvalidModificationError", `Workspace entry cannot be modified: ${path}.`); +} + +async function readEntry(state: WorkspaceHandleState, path: string) { + try { + return await state.repository.read(state.workspaceId, path); + } catch (error) { + if (!(error instanceof WorkspaceEntryNotFoundError)) throw error; + throw notFound(path); + } +} + +async function requireReceivingDirectory(state: WorkspaceHandleState) { + if (!state.path) return; + + const entry = await readEntry(state, state.path); + if (entry.kind !== "directory") throw typeMismatch(state.path, "directory"); +} + +async function getEntry( + state: WorkspaceHandleState, + path: string, + kind: WorkspaceEntry["kind"], + create: boolean +) { + let entry: WorkspaceEntry; + try { + entry = await state.repository.read(state.workspaceId, path); + } catch (error) { + if (!(error instanceof WorkspaceEntryNotFoundError)) throw error; + if (!create) throw notFound(path); + + try { + entry = kind === "directory" + ? await state.repository.createDirectory(state.workspaceId, path) + : await state.repository.writeFile(state.workspaceId, path, new Blob([]), { mode: "create" }); + } catch (creationError) { + if (!(creationError instanceof WorkspaceNamespaceConflictError)) throw creationError; + throw typeMismatch(path, kind); + } + } + + if (entry.kind !== kind) throw typeMismatch(path, kind); + + return entry; +} + +async function targetMovePath( + state: WorkspaceHandleState, + directoryOrName: WebDirectoryHandle | string, + newName?: string +) { + if (typeof directoryOrName === "string") { + validateWorkspaceName(directoryOrName); + const parentPath = workspaceParentPath(state.path); + if (parentPath) { + const parent = await readEntry(state, parentPath); + if (parent.kind !== "directory") throw typeMismatch(parentPath, "directory"); + } + + return workspaceChildPath(parentPath, directoryOrName); + } + + const target = directoryStates.get(directoryOrName); + if ( + !target + || target.repository !== state.repository + || target.workspaceId !== state.workspaceId + ) { + throw invalidModification(state.path); + } + await requireReceivingDirectory(target); + + const targetName = newName ?? workspacePathName(state.path); + validateWorkspaceName(targetName); + + return workspaceChildPath(target.path, targetName); +} + +function createMove(state: WorkspaceHandleState): WebHandleMove { + return async (directoryOrName: WebDirectoryHandle | string, newName?: string) => { + if (!state.path) throw invalidModification(state.path); + + const targetPath = await targetMovePath(state, directoryOrName, newName); + try { + await state.repository.move(state.workspaceId, state.path, targetPath); + } catch { + throw invalidModification(targetPath); + } + + // A successful native-style move keeps the handle usable at its new location. + state.path = targetPath; + state.name = workspacePathName(targetPath); + }; +} + +export function createWorkspaceFileHandle( + repository: WorkspaceRepository, + workspaceId: string, + path: string +): WebFileHandle { + validateWorkspaceId(workspaceId); + validateWorkspacePath(path); + const state: WorkspaceHandleState = { + name: workspacePathName(path), + path, + repository, + workspaceId + }; + + return { + async createWritable() { + const chunks: BlobPart[] = []; + let closed = false; + + return { + async close() { + if (closed) throw new TypeError("Cannot close a closed workspace file stream."); + closed = true; + + // Buffer until close so an incomplete save never replaces the durable entry. + // A copied File is a single chunk, whose media type must survive for image rendering. + const type = chunks.length === 1 && chunks[0] instanceof Blob ? chunks[0].type : ""; + try { + await state.repository.writeFile( + state.workspaceId, + state.path, + new Blob(chunks, { type }), + { mode: "update" } + ); + } catch (error) { + if (!(error instanceof WorkspaceEntryNotFoundError)) throw error; + throw notFound(state.path); + } + }, + async write(chunk) { + if (closed) throw new TypeError("Cannot write to a closed workspace file stream."); + chunks.push(chunk); + } + }; + }, + async getFile() { + const entry = await readEntry(state, state.path); + if (entry.kind !== "file") throw typeMismatch(state.path, "file"); + + return new File([entry.body ?? new Blob([])], state.name, { + lastModified: entry.modifiedAt, + type: entry.mediaType ?? "application/octet-stream" + }); + }, + kind: "file", + move: createMove(state), + get name() { + return state.name; + } + }; +} + +export function createWorkspaceDirectoryHandle( + repository: WorkspaceRepository, + workspaceId: string, + rootPath: string, + name: string +): WebDirectoryHandle { + validateWorkspaceId(workspaceId); + validateWorkspacePath(rootPath); + const state: WorkspaceHandleState = { + name, + path: rootPath, + repository, + workspaceId + }; + const handle: WebDirectoryHandle = { + async createDirectoryExclusive(childName) { + validateWorkspaceName(childName); + await requireReceivingDirectory(state); + const path = workspaceChildPath(state.path, childName); + try { + await state.repository.createDirectory(state.workspaceId, path, { exclusive: true }); + } catch (error) { + if (!(error instanceof WorkspaceNamespaceConflictError)) throw error; + throw invalidModification(path); + } + + return createWorkspaceDirectoryHandle( + state.repository, + state.workspaceId, + path, + childName + ); + }, + async createFileExclusive(childName) { + validateWorkspaceName(childName); + await requireReceivingDirectory(state); + const path = workspaceChildPath(state.path, childName); + try { + await state.repository.writeFile( + state.workspaceId, + path, + new Blob([]), + { mode: "create" } + ); + } catch (error) { + if (!(error instanceof WorkspaceNamespaceConflictError)) throw error; + throw invalidModification(path); + } + + return createWorkspaceFileHandle(state.repository, state.workspaceId, path); + }, + async *entries() { + const prefix = state.path ? `${state.path}/` : ""; + const entries = await state.repository.list( + state.workspaceId, + state.path || undefined + ); + for (const entry of entries) { + if (!entry.path.startsWith(prefix)) continue; + const relativePath = entry.path.slice(prefix.length); + if (!relativePath || relativePath.includes("/")) continue; + + const child = entry.kind === "directory" + ? createWorkspaceDirectoryHandle( + state.repository, + state.workspaceId, + entry.path, + relativePath + ) + : createWorkspaceFileHandle(state.repository, state.workspaceId, entry.path); + yield [relativePath, child] satisfies [string, WebDirectoryHandle | WebFileHandle]; + } + }, + async getDirectoryHandle(childName, options) { + validateWorkspaceName(childName); + if (options?.create) await requireReceivingDirectory(state); + const path = workspaceChildPath(state.path, childName); + await getEntry(state, path, "directory", options?.create ?? false); + + return createWorkspaceDirectoryHandle( + state.repository, + state.workspaceId, + path, + childName + ); + }, + async getFileHandle(childName, options) { + validateWorkspaceName(childName); + if (options?.create) await requireReceivingDirectory(state); + const path = workspaceChildPath(state.path, childName); + await getEntry(state, path, "file", options?.create ?? false); + + return createWorkspaceFileHandle(state.repository, state.workspaceId, path); + }, + kind: "directory", + move: createMove(state), + get name() { + return state.name; + }, + async removeEntry(childName, options) { + validateWorkspaceName(childName); + const path = workspaceChildPath(state.path, childName); + const entry = await readEntry(state, path); + if (entry.kind === "directory" && !options?.recursive) { + const entries = await state.repository.list(state.workspaceId, path); + if (entries.length > 1) throw invalidModification(path); + } + + try { + await state.repository.remove(state.workspaceId, path, options?.recursive); + } catch { + throw invalidModification(path); + } + }, + async *values() { + for await (const [, child] of handle.entries!()) yield child; + } + }; + directoryStates.set(handle, state); + + return handle; +} + +export function createWorkspaceUrl(workspaceId: string, path: string) { + validateWorkspaceId(workspaceId); + validateWorkspacePath(path); + const workspaceUrl = `${workspaceUrlPrefix}${encodeURIComponent(workspaceId)}`; + if (!path) return workspaceUrl; + + return `${workspaceUrl}/${path.split("/").map(encodeURIComponent).join("/")}`; +} + +export function parseWorkspaceUrl(value: string): WebWorkspaceLocation | null { + if (!value.startsWith(workspaceUrlPrefix)) return null; + + const encodedLocation = value.slice(workspaceUrlPrefix.length); + const separatorIndex = encodedLocation.indexOf("/"); + if ( + !encodedLocation + || separatorIndex === 0 + || encodedLocation.includes("?") + || encodedLocation.includes("#") + ) { + return null; + } + + try { + const encodedWorkspaceId = separatorIndex < 0 + ? encodedLocation + : encodedLocation.slice(0, separatorIndex); + const encodedPath = separatorIndex < 0 ? "" : encodedLocation.slice(separatorIndex + 1); + if (separatorIndex >= 0 && !encodedPath) return null; + + const decodedSegments = encodedPath + ? encodedPath.split("/").map((segment) => decodeURIComponent(segment)) + : []; + if (decodedSegments.some((segment) => segment.includes("/") || segment.includes("\\"))) { + return null; + } + + const workspaceId = decodeURIComponent(encodedWorkspaceId); + const path = decodedSegments.join("/"); + validateWorkspaceId(workspaceId); + validateWorkspacePath(path); + + return { path, workspaceId }; + } catch { + return null; + } +} diff --git a/apps/web/src/runtime/web/workspace.test.ts b/apps/web/src/runtime/web/workspace.test.ts new file mode 100644 index 00000000..6756514b --- /dev/null +++ b/apps/web/src/runtime/web/workspace.test.ts @@ -0,0 +1,393 @@ +import { FakeIndexedDbFactory } from "../../test/web-runtime-fakes"; +import { + openWebRuntimeDatabase, + requestToPromise, + transactionToPromise, + webRuntimeWorkspaceEntryStoreName, + webRuntimeWorkspaceStoreName +} from "./database"; +import { + createWorkspaceRepository, + defaultWorkspaceId, + WorkspaceEntryNotFoundError, + WorkspaceNamespaceConflictError +} from "./workspace"; + +function upload(path: string, contents: BlobPart, type = "text/plain") { + const name = path.split("/").at(-1) ?? path; + const file = new File([contents], name, { type }); + Object.defineProperty(file, "webkitRelativePath", { value: path }); + + return file; +} + +describe("Browser workspace repository", () => { + it("creates the default workspace idempotently", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const repository = createWorkspaceRepository({ indexedDB }); + + await expect(repository.ensureDefaultWorkspace()).resolves.toEqual({ + id: defaultWorkspaceId, + name: "Markra" + }); + await expect(repository.ensureDefaultWorkspace()).resolves.toEqual({ + id: defaultWorkspaceId, + name: "Markra" + }); + }); + + it("enforces file create and update modes without recreating stale paths", async () => { + const repository = createWorkspaceRepository({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + await repository.ensureDefaultWorkspace(); + await repository.writeFile("default", "note.md", new Blob(["preserved"]), { mode: "create" }); + + await expect( + repository.writeFile("default", "note.md", new Blob(["replacement"]), { mode: "create" }) + ).rejects.toBeInstanceOf(WorkspaceNamespaceConflictError); + await expect((await repository.read("default", "note.md")).body?.text()).resolves.toBe("preserved"); + + await repository.remove("default", "note.md"); + await expect( + repository.writeFile("default", "note.md", new Blob(["stale"]), { mode: "update" }) + ).rejects.toBeInstanceOf(WorkspaceEntryNotFoundError); + await expect(repository.read("default", "note.md")).rejects.toBeInstanceOf( + WorkspaceEntryNotFoundError + ); + }); + + it("rejects files and directories whose parent directory does not exist", async () => { + const repository = createWorkspaceRepository({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + await repository.ensureDefaultWorkspace(); + + await expect( + repository.writeFile("default", "missing/note.md", new Blob(["orphan"]), { mode: "create" }) + ).rejects.toBeInstanceOf(WorkspaceEntryNotFoundError); + await expect(repository.createDirectory("default", "missing/nested")) + .rejects.toBeInstanceOf(WorkspaceEntryNotFoundError); + await expect(repository.exportEntries("default")).resolves.toEqual([]); + }); + + it("persists nested text and binary files across repository instances", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const first = createWorkspaceRepository({ indexedDB }); + await first.ensureDefaultWorkspace(); + await first.createDirectory("default", "docs"); + await first.writeFile("default", "docs/note.md", new Blob(["# Offline"], { type: "text/markdown" })); + await first.writeFile( + "default", + "docs/image.png", + new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }) + ); + + const second = createWorkspaceRepository({ indexedDB }); + await expect((await second.read("default", "docs/note.md")).body?.text()).resolves.toBe("# Offline"); + expect(new Uint8Array(await (await second.read("default", "docs/image.png")).body!.arrayBuffer())).toEqual( + new Uint8Array([1, 2, 3]) + ); + await expect(second.list("default", "docs")).resolves.toEqual([ + expect.objectContaining({ kind: "directory", path: "docs" }), + expect.objectContaining({ kind: "file", mediaType: "image/png", path: "docs/image.png" }), + expect.objectContaining({ kind: "file", mediaType: "text/markdown", path: "docs/note.md" }) + ]); + }); + + it("moves directory descendants and removes them recursively", async () => { + const repository = createWorkspaceRepository({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + await repository.ensureDefaultWorkspace(); + await repository.createDirectory("default", "drafts"); + await repository.createDirectory("default", "drafts/assets"); + await repository.writeFile("default", "drafts/note.md", new Blob(["draft"])); + await repository.writeFile("default", "drafts/assets/chart.bin", new Blob([new Uint8Array([4, 5])])); + + await expect(repository.move("default", "drafts", "published")).resolves.toEqual([ + expect.objectContaining({ path: "published" }), + expect.objectContaining({ path: "published/assets" }), + expect.objectContaining({ path: "published/assets/chart.bin" }), + expect.objectContaining({ path: "published/note.md" }) + ]); + await expect(repository.read("default", "drafts/note.md")).rejects.toBeInstanceOf( + WorkspaceEntryNotFoundError + ); + await expect(repository.remove("default", "published")).rejects.toThrow("not empty"); + + await repository.remove("default", "published", true); + + await expect(repository.exportEntries("default")).resolves.toEqual([]); + }); + + it.each(["../secret.md", "/absolute.md", "docs/./note.md", "docs//note.md"])( + "rejects invalid workspace path %s", + async (path) => { + const repository = createWorkspaceRepository({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + await repository.ensureDefaultWorkspace(); + + await expect(repository.writeFile("default", path, new Blob([]))).rejects.toThrow("Invalid workspace path"); + } + ); + + it("rejects entry conflicts before changing stored entries", async () => { + const repository = createWorkspaceRepository({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + await repository.ensureDefaultWorkspace(); + await repository.createDirectory("default", "docs"); + await repository.writeFile("default", "note.md", new Blob(["stable"])); + + await expect( + repository.writeFile("default", "docs", new Blob(["replacement"])) + ).rejects.toBeInstanceOf(WorkspaceNamespaceConflictError); + await expect(repository.move("default", "note.md", "docs")).rejects.toBeInstanceOf( + WorkspaceNamespaceConflictError + ); + await expect((await repository.read("default", "note.md")).body?.text()).resolves.toBe("stable"); + await expect(repository.read("default", "docs")).resolves.toEqual(expect.objectContaining({ kind: "directory" })); + }); + + it("rejects writing a file above an existing descendant", async () => { + const repository = createWorkspaceRepository({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + await repository.ensureDefaultWorkspace(); + await repository.createDirectory("default", "notes"); + await repository.writeFile("default", "notes/existing.md", new Blob(["stable"])); + + await expect(repository.writeFile("default", "notes", new Blob(["replacement"]))).rejects.toThrow("notes"); + await expect((await repository.read("default", "notes/existing.md")).body?.text()).resolves.toBe("stable"); + await expect(repository.read("default", "notes")).resolves.toEqual( + expect.objectContaining({ kind: "directory" }) + ); + }); + + it.each(["file", "directory"] as const)( + "rejects moving a %s onto an occupied descendant namespace", + async (kind) => { + const repository = createWorkspaceRepository({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + await repository.ensureDefaultWorkspace(); + await repository.createDirectory("default", "target"); + await repository.writeFile("default", "target/existing.md", new Blob(["stable"])); + if (kind === "file") { + await repository.writeFile("default", "source", new Blob(["source"])); + } else { + await repository.createDirectory("default", "source"); + await repository.writeFile("default", "source/note.md", new Blob(["source"])); + } + + await expect(repository.move("default", "source", "target")).rejects.toThrow("target"); + await expect((await repository.read("default", "target/existing.md")).body?.text()).resolves.toBe("stable"); + await expect(repository.read("default", "source")).resolves.toEqual(expect.objectContaining({ kind })); + } + ); + + it("preserves an empty Blob media type", async () => { + const repository = createWorkspaceRepository({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + await repository.ensureDefaultWorkspace(); + + await expect(repository.writeFile("default", "unknown.bin", new Blob(["data"]))).resolves.toEqual( + expect.objectContaining({ mediaType: "" }) + ); + await expect(repository.read("default", "unknown.bin")).resolves.toEqual( + expect.objectContaining({ mediaType: "" }) + ); + }); + + it("imports a directory and removes its staging workspace", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const repository = createWorkspaceRepository({ indexedDB }); + await repository.ensureDefaultWorkspace(); + + await expect(repository.importDirectory("default", "archive", [ + upload("archive/readme.md", "hello", "text/markdown"), + upload("archive/data.bin", new Uint8Array([8, 9]), "application/octet-stream") + ])).resolves.toBe("archive"); + await expect((await repository.read("default", "archive/readme.md")).body?.text()).resolves.toBe("hello"); + + const database = await openWebRuntimeDatabase({ indexedDB }); + const transaction = database.transaction(webRuntimeWorkspaceStoreName, "readonly"); + const workspaces = await requestToPromise[]>( + transaction.objectStore(webRuntimeWorkspaceStoreName).getAll() + ); + await transactionToPromise(transaction); + expect(workspaces).toEqual([{ id: "default", lifecycle: "active", name: "Markra" }]); + }); + + it("removes staging workspaces left by an interrupted earlier import", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const database = await openWebRuntimeDatabase({ indexedDB }); + const seed = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readwrite" + ); + const timestamp = Date.now(); + await Promise.all([ + requestToPromise(seed.objectStore(webRuntimeWorkspaceStoreName).put({ + id: "staging-interrupted", + lifecycle: "staging", + name: "interrupted" + })), + requestToPromise(seed.objectStore(webRuntimeWorkspaceEntryStoreName).put({ + body: new Blob(["orphaned upload"]), + createdAt: timestamp, + kind: "file", + mediaType: "text/markdown", + modifiedAt: timestamp, + path: "interrupted/note.md", + workspaceId: "staging-interrupted" + })) + ]); + await transactionToPromise(seed); + + const repository = createWorkspaceRepository({ indexedDB }); + await repository.ensureDefaultWorkspace(); + + const inspect = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readonly" + ); + const [workspaces, entries] = await Promise.all([ + requestToPromise[]>( + inspect.objectStore(webRuntimeWorkspaceStoreName).getAll() + ), + requestToPromise[]>( + inspect.objectStore(webRuntimeWorkspaceEntryStoreName).getAll() + ) + ]); + await transactionToPromise(inspect); + expect(workspaces).toEqual([{ id: "default", lifecycle: "active", name: "Markra" }]); + expect(entries).toEqual([]); + }); + + it("does not collect a recent staging workspace that another tab may still be importing", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const database = await openWebRuntimeDatabase({ indexedDB }); + const seed = database.transaction(webRuntimeWorkspaceStoreName, "readwrite"); + await requestToPromise(seed.objectStore(webRuntimeWorkspaceStoreName).put({ + createdAt: Date.now(), + id: "staging-active-tab", + lifecycle: "staging", + name: "active-tab" + })); + await transactionToPromise(seed); + + const repository = createWorkspaceRepository({ indexedDB }); + await repository.ensureDefaultWorkspace(); + + const inspect = database.transaction(webRuntimeWorkspaceStoreName, "readonly"); + const workspaces = await requestToPromise[]>( + inspect.objectStore(webRuntimeWorkspaceStoreName).getAll() + ); + await transactionToPromise(inspect); + expect(workspaces).toContainEqual(expect.objectContaining({ id: "staging-active-tab" })); + }); + + it("keeps the active workspace unchanged when a staged import conflicts", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const repository = createWorkspaceRepository({ indexedDB }); + await repository.ensureDefaultWorkspace(); + await repository.createDirectory("default", "notes"); + await repository.writeFile("default", "notes/existing.md", new Blob(["existing"])); + + await expect( + repository.importDirectory("default", "notes", [upload("notes/existing.md", "replacement")]) + ).rejects.toThrow("notes"); + + await expect((await repository.read("default", "notes/existing.md")).body?.text()).resolves.toBe("existing"); + await expect(repository.exportEntries("default")).resolves.toEqual([ + expect.objectContaining({ kind: "directory", path: "notes" }), + expect.objectContaining({ path: "notes/existing.md" }) + ]); + }); + + it("rejects a staged file that would become an ancestor of an active descendant", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const repository = createWorkspaceRepository({ indexedDB }); + await repository.ensureDefaultWorkspace(); + await repository.createDirectory("default", "archive"); + await repository.createDirectory("default", "archive/folder"); + await repository.writeFile("default", "archive/folder/existing.md", new Blob(["existing"])); + + await expect( + repository.importDirectory("default", "archive", [upload("archive/folder", "replacement")]) + ).rejects.toThrow("archive"); + + await expect((await repository.read("default", "archive/folder/existing.md")).body?.text()).resolves.toBe( + "existing" + ); + await expect(repository.read("default", "archive/folder")).resolves.toEqual( + expect.objectContaining({ kind: "directory" }) + ); + }); + + it("rejects duplicate canonical import paths before publishing", async () => { + const repository = createWorkspaceRepository({ indexedDB: new FakeIndexedDbFactory().indexedDB }); + await repository.ensureDefaultWorkspace(); + + await expect(repository.importDirectory("default", "archive", [ + upload("archive/note.md", "first"), + upload("archive/note.md", "second") + ])).rejects.toThrow("archive/note.md"); + await expect(repository.exportEntries("default")).resolves.toEqual([]); + }); + + it("rejects a failed mutation without committing partial records", async () => { + const factory = new FakeIndexedDbFactory(); + const repository = createWorkspaceRepository({ indexedDB: factory.indexedDB }); + await repository.ensureDefaultWorkspace(); + factory.failNextTransaction(new DOMException("quota", "QuotaExceededError")); + + await expect(repository.writeFile("default", "note.md", new Blob(["not saved"]))).rejects.toMatchObject({ + name: "QuotaExceededError" + }); + await expect(repository.exportEntries("default")).resolves.toEqual([]); + }); + + it("rolls back every record in a failed directory move", async () => { + const factory = new FakeIndexedDbFactory(); + const repository = createWorkspaceRepository({ indexedDB: factory.indexedDB }); + await repository.ensureDefaultWorkspace(); + await repository.createDirectory("default", "drafts"); + await repository.writeFile("default", "drafts/one.md", new Blob(["one"])); + await repository.writeFile("default", "drafts/two.md", new Blob(["two"])); + factory.failNextTransaction(new DOMException("quota", "QuotaExceededError")); + + await expect(repository.move("default", "drafts", "published")).rejects.toMatchObject({ + name: "QuotaExceededError" + }); + await expect(repository.exportEntries("default")).resolves.toEqual([ + expect.objectContaining({ path: "drafts" }), + expect.objectContaining({ path: "drafts/one.md" }), + expect.objectContaining({ path: "drafts/two.md" }) + ]); + }); + + it("removing a file never removes legacy descendant records", async () => { + const indexedDB = new FakeIndexedDbFactory().indexedDB; + const repository = createWorkspaceRepository({ indexedDB }); + await repository.ensureDefaultWorkspace(); + const database = await openWebRuntimeDatabase({ indexedDB }); + const transaction = database.transaction(webRuntimeWorkspaceEntryStoreName, "readwrite"); + const store = transaction.objectStore(webRuntimeWorkspaceEntryStoreName); + const timestamp = Date.now(); + await Promise.all([ + requestToPromise(store.put({ + body: new Blob(["parent"]), + createdAt: timestamp, + kind: "file", + mediaType: "", + modifiedAt: timestamp, + path: "legacy", + workspaceId: "default" + })), + requestToPromise(store.put({ + body: new Blob(["child"]), + createdAt: timestamp, + kind: "file", + mediaType: "", + modifiedAt: timestamp, + path: "legacy/child.md", + workspaceId: "default" + })) + ]); + await transactionToPromise(transaction); + + await repository.remove("default", "legacy", true); + + await expect(repository.read("default", "legacy")).rejects.toThrow("not found"); + await expect((await repository.read("default", "legacy/child.md")).body?.text()).resolves.toBe("child"); + }); +}); diff --git a/apps/web/src/runtime/web/workspace.ts b/apps/web/src/runtime/web/workspace.ts new file mode 100644 index 00000000..9d6f5f91 --- /dev/null +++ b/apps/web/src/runtime/web/workspace.ts @@ -0,0 +1,686 @@ +import { + openWebRuntimeDatabase, + requestToPromise, + transactionToPromise, + webRuntimeWorkspaceEntryStoreName, + webRuntimeWorkspaceStoreName +} from "./database"; +import type { IndexedDbSettingsRuntimeOptions } from "./types"; + +export const defaultWorkspaceId = "default"; + +export type WorkspaceEntry = { + body?: Blob; + createdAt: number; + kind: "directory" | "file"; + mediaType?: string; + modifiedAt: number; + path: string; + workspaceId: string; +}; + +export type WorkspaceRepository = { + ensureDefaultWorkspace: () => Promise<{ id: string; name: string }>; + list: (workspaceId: string, rootPath?: string) => Promise; + read: (workspaceId: string, path: string) => Promise; + createDirectory: ( + workspaceId: string, + path: string, + options?: { exclusive?: boolean } + ) => Promise; + writeFile: ( + workspaceId: string, + path: string, + body: Blob, + options?: { mode?: "create" | "update" | "upsert" } + ) => Promise; + move: (workspaceId: string, sourcePath: string, targetPath: string) => Promise; + remove: (workspaceId: string, path: string, recursive?: boolean) => Promise; + importDirectory: (workspaceId: string, rootName: string, files: readonly File[]) => Promise; + exportEntries: (workspaceId: string, rootPath?: string) => Promise; +}; + +export class WorkspaceEntryNotFoundError extends Error { + readonly path: string; + + constructor(path: string) { + super(`Workspace entry was not found: ${path}.`); + this.name = "WorkspaceEntryNotFoundError"; + this.path = path; + } +} + +export class WorkspaceNamespaceConflictError extends Error { + readonly path: string; + + constructor(path: string) { + super(`Workspace entry conflicts with ${path}.`); + this.name = "WorkspaceNamespaceConflictError"; + this.path = path; + } +} + +type StoredWorkspace = { + createdAt?: number; + id: string; + lifecycle: "active" | "staging"; + name: string; +}; + +const defaultWorkspaceName = "Markra"; +const staleStagingWorkspaceAgeMs = 24 * 60 * 60 * 1000; + +function normalizeWorkspacePath(path: string) { + const parts = path.split("/"); + if ( + path.length === 0 + || path.startsWith("/") + || path.endsWith("/") + || path.includes("\\") + || path !== path.normalize("NFC") + || parts.some((part) => part.length === 0 || part === "." || part === "..") + ) { + throw new Error(`Invalid workspace path: ${path}`); + } + + return path; +} + +function isAtOrBelow(path: string, rootPath: string) { + return path === rootPath || path.startsWith(`${rootPath}/`); +} + +function sortEntries(entries: WorkspaceEntry[]) { + return entries.sort((left, right) => left.path.localeCompare(right.path)); +} + +function findEntry(entries: readonly WorkspaceEntry[], path: string) { + return entries.find((entry) => entry.path === path); +} + +function parentPath(path: string) { + const separatorIndex = path.lastIndexOf("/"); + + return separatorIndex < 0 ? "" : path.slice(0, separatorIndex); +} + +function requireParentDirectory(entries: readonly WorkspaceEntry[], path: string) { + const parent = parentPath(path); + if (!parent) return; + + const entry = findEntry(entries, parent); + if (!entry) throw notFoundError(parent); + if (entry.kind !== "directory") throw conflictError(parent); +} + +function findNamespaceConflict( + entries: readonly WorkspaceEntry[], + candidate: Pick, + options: { allowExactPath?: boolean; claimDescendants?: boolean } = {} +) { + const claimDescendants = options.claimDescendants ?? candidate.kind === "file"; + + return entries.find((entry) => { + if (entry.path === candidate.path) return !options.allowExactPath; + if (candidate.path.startsWith(`${entry.path}/`)) return entry.kind === "file"; + + // A file can never own a path that already has descendants. Moves additionally claim + // their whole target subtree so directory moves cannot silently merge namespaces. + return claimDescendants && entry.path.startsWith(`${candidate.path}/`); + }); +} + +function conflictError(path: string) { + return new WorkspaceNamespaceConflictError(path); +} + +function notFoundError(path: string) { + return new WorkspaceEntryNotFoundError(path); +} + +function requireActiveWorkspace(workspace: StoredWorkspace | undefined, workspaceId: string) { + if (!workspace || workspace.lifecycle !== "active") { + throw new Error(`Workspace was not found: ${workspaceId}.`); + } +} + +function workspaceEntryRange(workspaceId: string, rootPath?: string) { + if (typeof globalThis.IDBKeyRange === "undefined") return undefined; + + const lowerPath = rootPath ?? ""; + const upperPath: IDBValidKey = rootPath ? `${rootPath}\uffff` : []; + + return globalThis.IDBKeyRange.bound( + [workspaceId, lowerPath], + [workspaceId, upperPath] + ); +} + +function getWorkspaceEntries( + store: IDBObjectStore, + workspaceId: string, + rootPath?: string +) { + return store.getAll(workspaceEntryRange(workspaceId, rootPath)); +} + +function ancestorPaths(path: string) { + const segments = path.split("/"); + + return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join("/")); +} + +async function requestWorkspacePathContext( + store: IDBObjectStore, + workspaceId: string, + path: string, + includeSubtree = true +) { + const ancestors = ancestorPaths(path); + const [exact, storedAncestors, storedSubtree] = await Promise.all([ + requestToPromise(store.get([workspaceId, path])), + Promise.all(ancestors.map((ancestor) => + requestToPromise(store.get([workspaceId, ancestor])) + )), + includeSubtree + ? requestToPromise(getWorkspaceEntries(store, workspaceId, path)) + : Promise.resolve([] as WorkspaceEntry[]) + ]); + const ancestorEntries = storedAncestors.filter((entry): entry is WorkspaceEntry => + entry?.workspaceId === workspaceId + ); + const subtree = storedSubtree.filter((entry) => + entry.workspaceId === workspaceId && isAtOrBelow(entry.path, path) + ); + if (exact?.workspaceId === workspaceId && !subtree.some((entry) => entry.path === exact.path)) { + subtree.push(exact); + } + const entriesByPath = new Map(); + for (const entry of [...ancestorEntries, ...subtree]) entriesByPath.set(entry.path, entry); + if (exact?.workspaceId === workspaceId) entriesByPath.set(exact.path, exact); + + return { + entries: Array.from(entriesByPath.values()), + exact: exact?.workspaceId === workspaceId ? exact : undefined, + subtree + }; +} + +function importedPath(rootPath: string, file: File) { + const relativePath = normalizeWorkspacePath(file.webkitRelativePath || file.name); + if (relativePath === rootPath || relativePath.startsWith(`${rootPath}/`)) return relativePath; + + return normalizeWorkspacePath(`${rootPath}/${relativePath}`); +} + +function buildImportedEntries(workspaceId: string, rootPath: string, files: readonly File[]) { + const timestamp = Date.now(); + const entriesByPath = new Map(); + entriesByPath.set(rootPath, { + createdAt: timestamp, + kind: "directory", + modifiedAt: timestamp, + path: rootPath, + workspaceId + }); + + for (const file of files) { + const path = importedPath(rootPath, file); + const parts = path.split("/"); + for (let index = 1; index < parts.length; index += 1) { + const directoryPath = parts.slice(0, index).join("/"); + const existing = entriesByPath.get(directoryPath); + if (existing?.kind === "file") throw conflictError(directoryPath); + entriesByPath.set(directoryPath, existing ?? { + createdAt: timestamp, + kind: "directory", + modifiedAt: timestamp, + path: directoryPath, + workspaceId + }); + } + + if (entriesByPath.has(path)) throw conflictError(path); + entriesByPath.set(path, { + body: file, + createdAt: timestamp, + kind: "file", + mediaType: file.type, + modifiedAt: timestamp, + path, + workspaceId + }); + } + + return sortEntries(Array.from(entriesByPath.values())); +} + +export function createWorkspaceRepository( + options: IndexedDbSettingsRuntimeOptions = {} +): WorkspaceRepository { + let databasePromise: Promise | null = null; + + const removeStaleStagingWorkspaces = async (database: IDBDatabase) => { + const transaction = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readwrite" + ); + const completion = transactionToPromise(transaction); + const workspaceStore = transaction.objectStore(webRuntimeWorkspaceStoreName); + const entryStore = transaction.objectStore(webRuntimeWorkspaceEntryStoreName); + const workspaces = await requestToPromise(workspaceStore.getAll()); + const cutoff = Date.now() - staleStagingWorkspaceAgeMs; + const staleWorkspaces = workspaces.filter((workspace) => + workspace.lifecycle === "staging" + && (workspace.createdAt === undefined || workspace.createdAt < cutoff) + ); + const storedEntries = await Promise.all(staleWorkspaces.map(async (workspace) => + requestToPromise(getWorkspaceEntries(entryStore, workspace.id)) + )); + await Promise.all(staleWorkspaces.flatMap((workspace, index) => [ + ...storedEntries[index] + .filter((entry) => entry.workspaceId === workspace.id) + .map((entry) => requestToPromise(entryStore.delete([workspace.id, entry.path]))), + requestToPromise(workspaceStore.delete(workspace.id)) + ])); + await completion; + }; + + const getDatabase = () => { + databasePromise ??= openWebRuntimeDatabase(options).then(async (database) => { + // Interrupted imports must not retain duplicate blobs forever, but recent staging records + // may belong to an import still running in another browser tab. + await removeStaleStagingWorkspaces(database); + + return database; + }); + + return databasePromise; + }; + + const readWorkspaceEntries = async (workspaceId: string, rootPath?: string) => { + const database = await getDatabase(); + const transaction = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readonly" + ); + const completion = transactionToPromise(transaction); + const workspaceRequest = transaction.objectStore(webRuntimeWorkspaceStoreName).get(workspaceId); + const entriesRequest = getWorkspaceEntries( + transaction.objectStore(webRuntimeWorkspaceEntryStoreName), + workspaceId, + rootPath + ); + const [workspace, storedEntries] = await Promise.all([ + requestToPromise(workspaceRequest), + requestToPromise(entriesRequest) + ]); + await completion; + requireActiveWorkspace(workspace, workspaceId); + + return storedEntries.filter((entry) => entry.workspaceId === workspaceId); + }; + + const createWorkspace = async (workspace: StoredWorkspace) => { + const database = await getDatabase(); + const transaction = database.transaction(webRuntimeWorkspaceStoreName, "readwrite"); + const completion = transactionToPromise(transaction); + await requestToPromise(transaction.objectStore(webRuntimeWorkspaceStoreName).put(workspace)); + await completion; + }; + + const writeImportedFiles = async (workspaceId: string, entries: readonly WorkspaceEntry[]) => { + const database = await getDatabase(); + const transaction = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readwrite" + ); + const completion = transactionToPromise(transaction); + const workspace = await requestToPromise( + transaction.objectStore(webRuntimeWorkspaceStoreName).get(workspaceId) + ); + if (!workspace || workspace.lifecycle !== "staging") { + await completion; + throw new Error(`Staging workspace was not found: ${workspaceId}.`); + } + + const store = transaction.objectStore(webRuntimeWorkspaceEntryStoreName); + await Promise.all(entries.map((entry) => requestToPromise(store.put(entry)))); + await completion; + }; + + const publishStagedEntries = async (stagedId: string, workspaceId: string) => { + const database = await getDatabase(); + const transaction = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readwrite" + ); + const completion = transactionToPromise(transaction); + const workspaceStore = transaction.objectStore(webRuntimeWorkspaceStoreName); + const entryStore = transaction.objectStore(webRuntimeWorkspaceEntryStoreName); + const [targetWorkspace, stagedWorkspace, storedTargetEntries, storedStagedEntries] = await Promise.all([ + requestToPromise(workspaceStore.get(workspaceId)), + requestToPromise(workspaceStore.get(stagedId)), + requestToPromise(getWorkspaceEntries(entryStore, workspaceId)), + requestToPromise(getWorkspaceEntries(entryStore, stagedId)) + ]); + requireActiveWorkspace(targetWorkspace, workspaceId); + if (!stagedWorkspace || stagedWorkspace.lifecycle !== "staging") { + throw new Error(`Staging workspace was not found: ${stagedId}.`); + } + + const targetEntries = storedTargetEntries.filter((entry) => entry.workspaceId === workspaceId); + const stagedEntries = storedStagedEntries.filter((entry) => entry.workspaceId === stagedId); + for (const entry of stagedEntries) { + if (findNamespaceConflict(targetEntries, entry)) { + await completion; + throw conflictError(entry.path); + } + } + + const published = stagedEntries.map((entry) => ({ ...entry, workspaceId })); + await Promise.all(published.map((entry) => requestToPromise(entryStore.put(entry)))); + await completion; + }; + + const removeWorkspace = async (workspaceId: string) => { + const database = await getDatabase(); + const transaction = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readwrite" + ); + const completion = transactionToPromise(transaction); + const workspaceStore = transaction.objectStore(webRuntimeWorkspaceStoreName); + const entryStore = transaction.objectStore(webRuntimeWorkspaceEntryStoreName); + const storedEntries = await requestToPromise( + getWorkspaceEntries(entryStore, workspaceId) + ); + const deleteRequests = storedEntries + .filter((entry) => entry.workspaceId === workspaceId) + .map((entry) => requestToPromise(entryStore.delete([workspaceId, entry.path]))); + deleteRequests.push(requestToPromise(workspaceStore.delete(workspaceId))); + await Promise.all(deleteRequests); + await completion; + }; + + return { + async ensureDefaultWorkspace() { + const database = await getDatabase(); + const transaction = database.transaction(webRuntimeWorkspaceStoreName, "readwrite"); + const completion = transactionToPromise(transaction); + const store = transaction.objectStore(webRuntimeWorkspaceStoreName); + const existing = await requestToPromise(store.get(defaultWorkspaceId)); + + if (!existing) { + await requestToPromise(store.put({ + id: defaultWorkspaceId, + lifecycle: "active", + name: defaultWorkspaceName + } satisfies StoredWorkspace)); + } + await completion; + + return { + id: defaultWorkspaceId, + name: existing?.name ?? defaultWorkspaceName + }; + }, + async list(workspaceId, rootPath) { + const normalizedRoot = rootPath === undefined ? undefined : normalizeWorkspacePath(rootPath); + const entries = await readWorkspaceEntries(workspaceId, normalizedRoot); + + return sortEntries(entries.filter((entry) => !normalizedRoot || isAtOrBelow(entry.path, normalizedRoot))); + }, + async read(workspaceId, path) { + const normalizedPath = normalizeWorkspacePath(path); + const database = await getDatabase(); + const transaction = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readonly" + ); + const completion = transactionToPromise(transaction); + const [workspace, entry] = await Promise.all([ + requestToPromise( + transaction.objectStore(webRuntimeWorkspaceStoreName).get(workspaceId) + ), + requestToPromise( + transaction.objectStore(webRuntimeWorkspaceEntryStoreName).get([workspaceId, normalizedPath]) + ) + ]); + await completion; + requireActiveWorkspace(workspace, workspaceId); + if (!entry) throw notFoundError(normalizedPath); + + return entry; + }, + async createDirectory(workspaceId, path, options = {}) { + const normalizedPath = normalizeWorkspacePath(path); + const database = await getDatabase(); + const transaction = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readwrite" + ); + const completion = transactionToPromise(transaction); + const workspaceStore = transaction.objectStore(webRuntimeWorkspaceStoreName); + const entryStore = transaction.objectStore(webRuntimeWorkspaceEntryStoreName); + const [workspace, context] = await Promise.all([ + requestToPromise(workspaceStore.get(workspaceId)), + requestWorkspacePathContext(entryStore, workspaceId, normalizedPath) + ]); + requireActiveWorkspace(workspace, workspaceId); + try { + requireParentDirectory(context.entries, normalizedPath); + } catch (error) { + await completion; + throw error; + } + const existing = context.exact; + if (existing?.kind === "directory") { + await completion; + if (options.exclusive) throw conflictError(normalizedPath); + return existing; + } + if ( + existing + || findNamespaceConflict(context.entries, { kind: "directory", path: normalizedPath }) + ) { + await completion; + throw conflictError(normalizedPath); + } + + const timestamp = Date.now(); + const entry: WorkspaceEntry = { + createdAt: timestamp, + kind: "directory", + modifiedAt: timestamp, + path: normalizedPath, + workspaceId + }; + await requestToPromise(entryStore.put(entry)); + await completion; + + return entry; + }, + async writeFile(workspaceId, path, body, options = {}) { + const normalizedPath = normalizeWorkspacePath(path); + const mode = options.mode ?? "upsert"; + const database = await getDatabase(); + const transaction = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readwrite" + ); + const completion = transactionToPromise(transaction); + const workspaceStore = transaction.objectStore(webRuntimeWorkspaceStoreName); + const entryStore = transaction.objectStore(webRuntimeWorkspaceEntryStoreName); + const [workspace, context] = await Promise.all([ + requestToPromise(workspaceStore.get(workspaceId)), + requestWorkspacePathContext(entryStore, workspaceId, normalizedPath, mode !== "update") + ]); + requireActiveWorkspace(workspace, workspaceId); + const existing = context.exact; + try { + requireParentDirectory(context.entries, normalizedPath); + } catch (error) { + await completion; + throw error; + } + if (mode === "create" && existing) { + await completion; + throw conflictError(normalizedPath); + } + if (mode === "update" && !existing) { + await completion; + throw notFoundError(normalizedPath); + } + if ( + existing?.kind === "directory" + || findNamespaceConflict(context.entries, { kind: "file", path: normalizedPath }, { + allowExactPath: existing?.kind === "file" + }) + ) { + await completion; + throw conflictError(normalizedPath); + } + + const timestamp = Date.now(); + const entry: WorkspaceEntry = { + body, + createdAt: existing?.createdAt ?? timestamp, + kind: "file", + mediaType: body.type, + modifiedAt: timestamp, + path: normalizedPath, + workspaceId + }; + await requestToPromise(entryStore.put(entry)); + await completion; + + return entry; + }, + async move(workspaceId, sourcePath, targetPath) { + const normalizedSource = normalizeWorkspacePath(sourcePath); + const normalizedTarget = normalizeWorkspacePath(targetPath); + const database = await getDatabase(); + const transaction = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readwrite" + ); + const completion = transactionToPromise(transaction); + const workspaceStore = transaction.objectStore(webRuntimeWorkspaceStoreName); + const entryStore = transaction.objectStore(webRuntimeWorkspaceEntryStoreName); + const [workspace, sourceContext, targetContext] = await Promise.all([ + requestToPromise(workspaceStore.get(workspaceId)), + requestWorkspacePathContext(entryStore, workspaceId, normalizedSource), + requestWorkspacePathContext(entryStore, workspaceId, normalizedTarget) + ]); + requireActiveWorkspace(workspace, workspaceId); + const source = sourceContext.exact; + if (!source) { + await completion; + throw notFoundError(normalizedSource); + } + const affected = sourceContext.subtree; + if (normalizedTarget.startsWith(`${normalizedSource}/`)) { + await completion; + throw conflictError(normalizedTarget); + } + if (normalizedSource === normalizedTarget) { + await completion; + return sortEntries(affected); + } + + try { + requireParentDirectory(targetContext.entries, normalizedTarget); + } catch (error) { + await completion; + throw error; + } + + const affectedPaths = new Set(affected.map((entry) => entry.path)); + const unaffected = targetContext.entries.filter((entry) => !affectedPaths.has(entry.path)); + const timestamp = Date.now(); + const moved = affected.map((entry) => ({ + ...entry, + modifiedAt: timestamp, + path: `${normalizedTarget}${entry.path.slice(normalizedSource.length)}` + })); + for (const entry of moved) { + if (findNamespaceConflict(unaffected, entry, { + claimDescendants: entry.path === normalizedTarget || entry.kind === "file" + })) { + await completion; + throw conflictError(entry.path); + } + } + + const deleteRequests = affected.map((entry) => requestToPromise( + entryStore.delete([workspaceId, entry.path]) + )); + const putRequests = moved.map((entry) => requestToPromise(entryStore.put(entry))); + await Promise.all([...deleteRequests, ...putRequests]); + await completion; + + return sortEntries(moved); + }, + async remove(workspaceId, path, recursive = false) { + const normalizedPath = normalizeWorkspacePath(path); + const database = await getDatabase(); + const transaction = database.transaction( + [webRuntimeWorkspaceStoreName, webRuntimeWorkspaceEntryStoreName], + "readwrite" + ); + const completion = transactionToPromise(transaction); + const workspaceStore = transaction.objectStore(webRuntimeWorkspaceStoreName); + const entryStore = transaction.objectStore(webRuntimeWorkspaceEntryStoreName); + const [workspace, context] = await Promise.all([ + requestToPromise(workspaceStore.get(workspaceId)), + requestWorkspacePathContext(entryStore, workspaceId, normalizedPath) + ]); + requireActiveWorkspace(workspace, workspaceId); + const entry = context.exact; + if (!entry) { + await completion; + throw notFoundError(normalizedPath); + } + const affected = entry.kind === "file" + ? [entry] + : context.subtree; + if (entry.kind === "directory" && affected.length > 1 && !recursive) { + await completion; + throw new Error(`Workspace directory is not empty: ${normalizedPath}.`); + } + + await Promise.all(affected.map((candidate) => requestToPromise( + entryStore.delete([workspaceId, candidate.path]) + ))); + await completion; + + return undefined; + }, + async importDirectory(workspaceId, rootName, files) { + const rootPath = normalizeWorkspacePath(rootName); + const stagedId = `staging-${globalThis.crypto.randomUUID()}`; + const entries = buildImportedEntries(stagedId, rootPath, files); + await createWorkspace({ + createdAt: Date.now(), + id: stagedId, + lifecycle: "staging", + name: rootName + }); + + try { + await writeImportedFiles(stagedId, entries); + await publishStagedEntries(stagedId, workspaceId); + return rootPath; + } finally { + await removeWorkspace(stagedId); + } + }, + async exportEntries(workspaceId, rootPath) { + const normalizedRoot = rootPath === undefined ? undefined : normalizeWorkspacePath(rootPath); + const entries = await readWorkspaceEntries(workspaceId, normalizedRoot); + + return sortEntries(entries.filter((entry) => !normalizedRoot || isAtOrBelow(entry.path, normalizedRoot))); + } + }; +} diff --git a/apps/web/src/test/web-runtime-fakes.ts b/apps/web/src/test/web-runtime-fakes.ts index a75b5b8e..cc1ff246 100644 --- a/apps/web/src/test/web-runtime-fakes.ts +++ b/apps/web/src/test/web-runtime-fakes.ts @@ -93,7 +93,14 @@ export class FakeDirectoryHandle { function cloneValue(value: T): T { if (value === undefined || value === null) return value; + if (value instanceof Blob) return value.slice(0, value.size, value.type) as T; if (containsFakeFileSystemHandle(value)) return value; + if (Array.isArray(value)) return value.map(cloneValue) as T; + if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [key, cloneValue(nestedValue)]) + ) as T; + } if (typeof globalThis.structuredClone === "function") return globalThis.structuredClone(value); return JSON.parse(JSON.stringify(value)) as T; @@ -126,17 +133,97 @@ class FakeIdbOpenRequest extends FakeIdbRequest { onupgradeneeded: RequestHandler = null; } +function fakeKeyTypeRank(key: IDBValidKey) { + if (typeof key === "number") return 0; + if (key instanceof Date) return 1; + if (typeof key === "string") return 2; + if (ArrayBuffer.isView(key) || key instanceof ArrayBuffer) return 3; + + return 4; +} + +function compareFakeKeys(left: IDBValidKey, right: IDBValidKey): number { + const leftRank = fakeKeyTypeRank(left); + const rightRank = fakeKeyTypeRank(right); + if (leftRank !== rightRank) return leftRank - rightRank; + if (Array.isArray(left) && Array.isArray(right)) { + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const comparison = compareFakeKeys(left[index], right[index]); + if (comparison !== 0) return comparison; + } + + return left.length - right.length; + } + if (typeof left === "string" && typeof right === "string") { + return left < right ? -1 : left > right ? 1 : 0; + } + if (typeof left === "number" && typeof right === "number") return left - right; + if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime(); + + return serializeKey(left).localeCompare(serializeKey(right)); +} + +class FakeIdbKeyRange { + readonly lower: IDBValidKey; + readonly lowerOpen: boolean; + readonly upper: IDBValidKey; + readonly upperOpen: boolean; + + private constructor( + lower: IDBValidKey, + upper: IDBValidKey, + lowerOpen: boolean, + upperOpen: boolean + ) { + this.lower = lower; + this.upper = upper; + this.lowerOpen = lowerOpen; + this.upperOpen = upperOpen; + } + + static bound( + lower: IDBValidKey, + upper: IDBValidKey, + lowerOpen = false, + upperOpen = false + ) { + return new FakeIdbKeyRange(lower, upper, lowerOpen, upperOpen); + } + + includes(key: IDBValidKey) { + const lowerComparison = compareFakeKeys(key, this.lower); + const upperComparison = compareFakeKeys(key, this.upper); + + return (this.lowerOpen ? lowerComparison > 0 : lowerComparison >= 0) + && (this.upperOpen ? upperComparison < 0 : upperComparison <= 0); + } +} + class FakeIdbObjectStore { constructor( - private readonly keyPath: string, - private readonly records: Map + private readonly keyPath: string | string[], + private readonly records: Map, + private readonly transaction?: FakeIdbTransaction ) {} + private queueOperation(operation: () => unknown) { + this.transaction?.requestStarted(); + queueMicrotask(() => { + try { + operation(); + } finally { + // Let request awaiters attach transaction handlers before completion is queued. + this.transaction?.requestFinished(); + } + }); + } + delete(key: IDBValidKey) { const request = new FakeIdbRequest(); - queueMicrotask(() => { - this.records.delete(String(key)); + this.queueOperation(() => { + this.records.delete(serializeKey(key)); request.succeed(undefined); }); @@ -146,19 +233,40 @@ class FakeIdbObjectStore { get(key: IDBValidKey) { const request = new FakeIdbRequest(); - queueMicrotask(() => { - request.succeed(cloneValue(this.records.get(String(key)))); + this.queueOperation(() => { + request.succeed(cloneValue(this.records.get(serializeKey(key)))); }); return request as unknown as IDBRequest; } + getAll(query?: IDBValidKey | IDBKeyRange | null) { + const request = new FakeIdbRequest(); + + this.queueOperation(() => { + const records = Array.from(this.records.values()).filter((record) => { + if (query === undefined || query === null) return true; + const key = typeof this.keyPath === "string" + ? record[this.keyPath] as IDBValidKey + : this.keyPath.map((part) => record[part]) as IDBValidKey; + if (query instanceof FakeIdbKeyRange) return query.includes(key); + + return serializeKey(query as IDBValidKey) === serializeKey(key); + }); + request.succeed(records.map(cloneValue)); + }); + + return request as unknown as IDBRequest; + } + put(record: StoredIndexedDbRecord) { const request = new FakeIdbRequest(); - queueMicrotask(() => { - const key = String(record[this.keyPath]); - this.records.set(key, cloneValue(record)); + this.queueOperation(() => { + const key = typeof this.keyPath === "string" + ? record[this.keyPath] as IDBValidKey + : this.keyPath.map((part) => record[part]) as IDBValidKey; + this.records.set(serializeKey(key), cloneValue(record)); request.succeed(key); }); @@ -166,11 +274,108 @@ class FakeIdbObjectStore { } } +function serializeKey(key: IDBValidKey) { + return Array.isArray(key) ? JSON.stringify(key) : String(key); +} + +type FakeIdbStore = { + keyPath: string | string[]; + records: Map; +}; + +export class FakeIdbTransaction { + error: DOMException | null = null; + onabort: RequestHandler = null; + oncomplete: RequestHandler = null; + onerror: RequestHandler = null; + private completionQueued = false; + private pendingRequests = 0; + private readonly snapshots = new Map>(); + private state: "pending" | "complete" | "failed" | "aborted" = "pending"; + + constructor( + private readonly stores = new Map(), + private readonly completionError?: DOMException, + rollbackOnFailure = false + ) { + if (rollbackOnFailure) { + for (const [name, store] of stores) { + this.snapshots.set(name, new Map( + Array.from(store.records, ([key, record]) => [key, cloneValue(record)]) + )); + } + } + } + + private rollback() { + for (const [name, snapshot] of this.snapshots) { + const records = this.stores.get(name)?.records; + if (!records) continue; + records.clear(); + for (const [key, record] of snapshot) records.set(key, cloneValue(record)); + } + } + + abort(error = new DOMException("Transaction aborted", "AbortError")) { + if (this.state !== "pending") return; + this.error = error; + this.state = "aborted"; + this.rollback(); + queueMicrotask(() => this.onabort?.(new Event("abort"))); + } + + complete() { + if (this.state !== "pending" || this.completionQueued) return; + this.completionQueued = true; + // IndexedDB stays active through request-created promise microtasks, so dependent writes + // must be allowed to enqueue before the transaction auto-commits at the end of the task. + setTimeout(() => { + this.completionQueued = false; + if (this.state !== "pending" || this.pendingRequests > 0) return; + if (this.completionError) { + this.error = this.completionError; + this.state = "failed"; + this.rollback(); + this.onerror?.(new Event("error")); + return; + } + this.state = "complete"; + this.oncomplete?.(new Event("complete")); + }, 0); + } + + fail(error: DOMException) { + if (this.state !== "pending") return; + this.error = error; + this.state = "failed"; + this.rollback(); + queueMicrotask(() => this.onerror?.(new Event("error"))); + } + + requestFinished() { + if (this.state !== "pending" || this.pendingRequests === 0) return; + this.pendingRequests -= 1; + if (this.pendingRequests === 0) this.complete(); + } + + requestStarted() { + if (this.state !== "pending") return; + this.pendingRequests += 1; + } + + objectStore(name: string) { + const store = this.stores.get(name); + if (!store) throw new DOMException(`Object store ${name} was not found.`, "NotFoundError"); + + return new FakeIdbObjectStore(store.keyPath, store.records, this); + } +} + class FakeIdbDatabase { - private readonly stores = new Map; - }>(); + private readonly stores = new Map(); + version = 0; + + constructor(private readonly takeTransactionFailure: () => DOMException | undefined) {} objectStoreNames = { contains: (name: string) => this.stores.has(name) @@ -179,7 +384,9 @@ class FakeIdbDatabase { createObjectStore(name: string, options: IDBObjectStoreParameters = {}) { if (!this.stores.has(name)) { this.stores.set(name, { - keyPath: typeof options.keyPath === "string" ? options.keyPath : "id", + keyPath: typeof options.keyPath === "string" || Array.isArray(options.keyPath) + ? options.keyPath + : "id", records: new Map() }); } @@ -189,36 +396,65 @@ class FakeIdbDatabase { return new FakeIdbObjectStore(store.keyPath, store.records); } - transaction(name: string) { - if (!this.stores.has(name)) { - this.stores.set(name, { - keyPath: "id", - records: new Map() - }); + transaction(names: string | string[], mode: IDBTransactionMode = "readonly") { + const requestedNames = typeof names === "string" ? [names] : names; + const stores = new Map(); + + for (const name of requestedNames) { + if (!this.stores.has(name)) { + this.stores.set(name, { + keyPath: "id", + records: new Map() + }); + } + stores.set(name, this.stores.get(name)!); } - const store = this.stores.get(name)!; + const completionError = mode === "readwrite" ? this.takeTransactionFailure() : undefined; + const transaction = new FakeIdbTransaction(stores, completionError, mode === "readwrite"); + transaction.complete(); - return { - objectStore: () => new FakeIdbObjectStore(store.keyPath, store.records) - }; + return transaction; } } export class FakeIndexedDbFactory { private readonly databases = new Map(); + private nextTransactionError: DOMException | undefined; readonly openedNames: string[] = []; - open(name: string) { + constructor() { + if (typeof globalThis.IDBKeyRange === "undefined") { + Object.defineProperty(globalThis, "IDBKeyRange", { + configurable: true, + value: FakeIdbKeyRange + }); + } + } + + failNextTransaction(error: DOMException) { + this.nextTransactionError = error; + } + + private takeTransactionFailure() { + const error = this.nextTransactionError; + this.nextTransactionError = undefined; + + return error; + } + + open(name: string, version?: number) { const request = new FakeIdbOpenRequest(); const existingDatabase = this.databases.get(name); - const database = existingDatabase ?? new FakeIdbDatabase(); + const database = existingDatabase ?? new FakeIdbDatabase(() => this.takeTransactionFailure()); + const requestedVersion = version ?? (existingDatabase?.version ?? 1); this.openedNames.push(name); queueMicrotask(() => { request.result = database; - if (!existingDatabase) { + if (requestedVersion > database.version) { this.databases.set(name, database); + database.version = requestedVersion; request.onupgradeneeded?.(new Event("upgradeneeded")); } request.succeed(database); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index c51b55cb..c8fc65d1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,6 +1,37 @@ import { createMarkraAppViteConfig } from "@markra/scripts/vite"; +import { VitePWA } from "vite-plugin-pwa"; export default createMarkraAppViteConfig({ browserNodeStubUrl: new URL("../../packages/app/src/lib/browser-node-stub.ts", import.meta.url), - packageJsonUrl: new URL("./package.json", import.meta.url) + packageJsonUrl: new URL("./package.json", import.meta.url), + plugins: [ + VitePWA({ + includeManifestIcons: false, + injectRegister: "auto", + manifest: { + name: "Markra", + short_name: "Markra", + description: "Local-first Markdown editor", + display: "standalone", + start_url: "/", + scope: "/", + background_color: "#ffffff", + theme_color: "#ffffff", + icons: [ + { src: "/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icon-512.png", sizes: "512x512", type: "image/png" } + ] + }, + registerType: "prompt", + workbox: { + cleanupOutdatedCaches: true, + clientsClaim: false, + globPatterns: ["**/*.{css,html,ico,jpg,js,png,svg,ttf,webp,woff,woff2}"], + // The lazy Mermaid/diagram chunk is ~2.71 MiB (~2.84 MB); keep it offline with a hard 3 MiB guard. + maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, + navigateFallback: "index.html", + skipWaiting: false + } + }) + ] }); diff --git a/docs/privacy.md b/docs/privacy.md index ed49b8fc..ae309ede 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -6,7 +6,7 @@ This document explains what stays local and what can leave the device when optio ## By Default -- Markdown files are ordinary files on disk or browser-selected file handles. +- Markdown files are ordinary files on disk, browser-selected file handles, or browser-local workspace entries in IndexedDB. - Desktop settings are stored locally by the Tauri app. - Web settings are stored in the browser through IndexedDB. - AI providers, web search, remote image upload, backup, and sync only run when configured or triggered. @@ -19,6 +19,7 @@ Markra may store these items locally: - editor preferences, theme choices, keyboard shortcuts, and export settings - recent files and folders - workspace state, open tabs, draft state, and file tree sort settings +- browser-local workspace Markdown files and attachments in IndexedDB - AI provider configuration and selected models - AI agent session summaries and session history - backup and sync settings @@ -81,6 +82,12 @@ Sync can upload, download, delete, and preserve conflict copies. Sync metadata i The desktop app can access native file paths, watch files, open folders, run local backups, and sync through WebDAV. The web editor runs inside browser permission and CORS limits, so it uses browser file handles, downloads, print-to-PDF, IndexedDB settings, and direct browser network requests where supported. +The web editor can be installed as a PWA. After its first successful online load, its application shell can open offline and read browser-local workspace files and attachments from IndexedDB. + +When direct directory picking is unavailable, opening a folder imports it as a persistent writable copy in the browser-local workspace. Browser-local workspaces can be exported as ZIP archives. + +The Service Worker caches the application shell and generated same-origin assets, not user documents or network-backed operations. AI providers, WebDAV, web search, remote images, credentialed requests, and other remote features still require a connection and are not Service Worker cached. + ## Network Settings Desktop network settings can apply to AI requests, web search, web image downloads, spellcheck dictionary downloads, remote sync, remote image uploads, and update checks. Localhost, LAN, and private IP bypass settings exist for tools such as Ollama, PicGo, and NAS WebDAV. diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index bba6abe4..64e0a504 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -17,6 +17,7 @@ import { mockSystemColorScheme, mockUntitledPath, mockedCloseNativeWindow, + mockedCanExportNativeMarkdownFolder, mockedConfirmNativeMarkdownFileDelete, mockedConfirmNativeUnsavedMarkdownDocumentDiscard, mockedConsumeWelcomeDocumentState, @@ -24,6 +25,7 @@ import { mockedCreateNativeMarkdownTreeFile, mockedCreateNativeMarkdownTreeFolder, mockedDetectNativePandocPath, + mockedExportNativeMarkdownFolder, mockedCheckNativeAppUpdate, mockedHideSettingsWindow, mockedImportNativeLocalFile, @@ -32,6 +34,7 @@ import { mockedDeleteNativeMarkdownTreeFile, mockedFetchAiProviderModels, mockedGetStoredCustomThemeCss, + mockedGetNativeDefaultMarkdownFolder, mockedGetStoredExportSettings, mockedGetStoredEditorPreferences, mockedGetStoredLanguage, @@ -112,7 +115,7 @@ import { } from "./App"; import type { NativeMenuHandlers } from "./test/app-harness"; import { configureAppRuntime, createDefaultAppRuntime, resetAppRuntimeForTests } from "./runtime"; -import { showAppToast } from "./lib/app-toast"; +import * as appToast from "./lib/app-toast"; import { createShardedTest } from "./test/shard"; installAppTestHarness(); @@ -512,6 +515,103 @@ function findEditorTextPosition(view: ProseMirrorEditorView, text: string, offse } describe("Markra workspace", () => { + it("hides workspace export for an external root that lacks export capability", async () => { + const externalRootPath = "/mock-files/external"; + mockedOpenNativeMarkdownFolder.mockResolvedValue({ + name: "external", + path: externalRootPath + }); + mockedCanExportNativeMarkdownFolder.mockReturnValue(false); + mockedListNativeMarkdownFilesForPath.mockResolvedValue([]); + + renderApp(); + + fireEvent.keyDown(window, { key: "o", metaKey: true, shiftKey: true }); + expect(await screen.findByRole("heading", { name: "external" })).toBeInTheDocument(); + + expect(screen.queryByRole("button", { name: "Export workspace" })).not.toBeInTheDocument(); + expect(mockedCanExportNativeMarkdownFolder).toHaveBeenCalledWith(externalRootPath); + expect(mockedExportNativeMarkdownFolder).not.toHaveBeenCalled(); + }); + + it("shows an error toast when workspace export fails", async () => { + mockedConsumeWelcomeDocumentState.mockResolvedValue(false); + mockedGetNativeDefaultMarkdownFolder.mockResolvedValue({ + name: "Markra", + path: "web-workspace://default" + }); + mockedCanExportNativeMarkdownFolder.mockReturnValue(true); + mockedExportNativeMarkdownFolder.mockRejectedValue( + new DOMException("Synthetic export failure", "InvalidStateError") + ); + + renderApp(); + + fireEvent.click(await screen.findByRole("button", { name: "Export workspace" })); + + await waitFor(() => + expect(document.querySelector(".app-toast")).toHaveTextContent("Could not export the workspace.") + ); + }); + + it("reuses one error toast when automatic saves repeatedly fail", async () => { + vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); + const showAppToast = vi.spyOn(appToast, "showAppToast"); + + try { + const file = { + content: "# Synthetic note\n\nOriginal content.", + name: "synthetic.md", + path: "/mock-files/synthetic.md" + }; + mockedConsumeWelcomeDocumentState.mockResolvedValue(false); + mockedGetStoredEditorPreferences.mockResolvedValue( + createStoredEditorPreferences({ autoSaveIntervalMinutes: 1 }) + ); + mockOpenMarkdownFile(file); + mockedSaveNativeMarkdownFile.mockRejectedValue( + new DOMException("Synthetic storage full", "QuotaExceededError") + ); + + renderApp(); + + fireEvent.keyDown(window, { key: "o", metaKey: true }); + expect(await screen.findByRole("heading", { name: "Synthetic note" })).toBeInTheDocument(); + await selectEditorViewMode("Source code"); + replaceMarkdownSource( + await screen.findByRole("textbox", { name: "Markdown source" }), + "# Synthetic note\n\nUnsaved content." + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + + const autoSaveToastCalls = showAppToast.mock.calls.filter( + ([toast]) => toast.id === "markdown-auto-save-error" + ); + expect(autoSaveToastCalls).toHaveLength(2); + expect(autoSaveToastCalls.map(([toast]) => toast)).toEqual([ + { + id: "markdown-auto-save-error", + message: "Could not auto-save changes. Your document is still unsaved.", + status: "error" + }, + { + id: "markdown-auto-save-error", + message: "Could not auto-save changes. Your document is still unsaved.", + status: "error" + } + ]); + } finally { + showAppToast.mockRestore(); + vi.useRealTimers(); + } + }); + it("marks macOS 27 windows for the WebKit scrolling workaround", async () => { mockedResolveDesktopPlatform.mockReturnValue("macos"); mockedResolveDesktopOsVersion.mockReturnValue("27.0"); @@ -1890,7 +1990,7 @@ describe("Markra workspace", () => { expect(mockedReadNativeMarkdownFile).not.toHaveBeenCalledWith(betaPath); }); - it("falls back to an empty document when the restored markdown folder is gone", async () => { + it("falls back to the welcome document when the restored markdown folder is gone", async () => { mockedGetStoredWorkspaceState.mockResolvedValue({ aiAgentSessionId: "session-app", filePath: null, @@ -1911,8 +2011,8 @@ describe("Markra workspace", () => { expect(screen.getByRole("button", { name: "Toggle file list" })).toHaveAttribute("aria-pressed", "false"); expect(screen.queryByRole("complementary", { name: "Markdown file tree" })).not.toBeInTheDocument(); expect(screen.queryByText("No folder")).not.toBeInTheDocument(); - expect(screen.queryByText("Welcome to Markra")).not.toBeInTheDocument(); - expect(mockedConsumeWelcomeDocumentState).not.toHaveBeenCalled(); + expect(screen.getAllByText("Welcome to Markra").length).toBeGreaterThan(0); + expect(mockedConsumeWelcomeDocumentState).toHaveBeenCalledTimes(1); }); it("keeps the saved folder root when restoring a nested file from that workspace", async () => { @@ -2827,7 +2927,7 @@ describe("Markra workspace", () => { renderApp(); act(() => { - showAppToast({ + appToast.showAppToast({ description: "S3 image upload failed: HTTP 403", message: "Could not save the pasted image.", status: "error" @@ -3327,7 +3427,9 @@ describe("Markra workspace", () => { expect(mockedListNativeMarkdownFilesForPath).toHaveBeenCalledWith(mockFolderPath, defaultFileTreeListOptions); expect(mockedOpenNativeMarkdownPath).not.toHaveBeenCalled(); expect(mockedSaveStoredWorkspaceState.mock.calls.at(-1)?.[0]).toEqual({ + activeDraftId: null, aiAgentSessionId: "session-app", + draftTabs: [], filePath: null, fileTreeOpen: true, folderName: "vault", @@ -3358,6 +3460,92 @@ describe("Markra workspace", () => { }); }); + it("reconfirms until edits made while a folder is opening are stable", async () => { + let resolveFolder: ((folder: { name: string; path: string }) => unknown) | null = null; + let resolveFiles: (() => unknown) | null = null; + let resolveReconfirmation: ((confirmed: boolean) => unknown) | null = null; + mockOpenMarkdownFile({ + content: "# Initial synthetic draft", + name: "native.md", + path: mockNativePath + }); + mockedOpenNativeMarkdownFolder.mockReturnValue(new Promise((resolve) => { + resolveFolder = resolve; + })); + mockedListNativeMarkdownFilesForPath.mockReturnValue(new Promise((resolve) => { + resolveFiles = () => resolve([ + { name: "index.md", path: `${mockFolderPath}/index.md`, relativePath: "index.md" } + ]); + })); + mockedConfirmNativeUnsavedMarkdownDocumentDiscard + .mockResolvedValueOnce(true) + .mockReturnValueOnce(new Promise((resolve) => { + resolveReconfirmation = resolve; + })) + .mockResolvedValueOnce(false); + + renderApp(); + + fireEvent.keyDown(window, { key: "o", metaKey: true }); + expect(await screen.findByText("Initial synthetic draft")).toBeInTheDocument(); + await selectEditorViewMode("Source code"); + + const sourceEditor = await screen.findByRole("textbox", { name: "Markdown source" }); + replaceMarkdownSource(sourceEditor, "# Approved synthetic draft"); + fireEvent.keyDown(window, { key: "o", metaKey: true, shiftKey: true }); + + await waitFor(() => expect(mockedConfirmNativeUnsavedMarkdownDocumentDiscard).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockedOpenNativeMarkdownFolder).toHaveBeenCalledTimes(1)); + + await act(async () => { + resolveFolder?.({ + name: "vault", + path: mockFolderPath + }); + }); + await waitFor(() => expect(mockedListNativeMarkdownFilesForPath).toHaveBeenCalledTimes(1)); + + replaceMarkdownSource(sourceEditor, "# Newer synthetic draft"); + await act(async () => { + resolveFiles?.(); + }); + + await waitFor(() => expect(mockedConfirmNativeUnsavedMarkdownDocumentDiscard).toHaveBeenCalledTimes(2)); + replaceMarkdownSource(sourceEditor, "# Latest synthetic draft"); + await act(async () => { + resolveReconfirmation?.(true); + }); + + await waitFor(() => expect(mockedConfirmNativeUnsavedMarkdownDocumentDiscard).toHaveBeenCalledTimes(3)); + expect(readMarkdownSource(screen.getByRole("textbox", { name: "Markdown source" }))).toBe( + "# Latest synthetic draft" + ); + expect(screen.getByRole("tab", { name: /native\.md/ })).toBeInTheDocument(); + expect(mockedSaveStoredRecentMarkdownFolder).not.toHaveBeenCalledWith({ + name: "vault", + path: mockFolderPath + }); + expect(mockedSaveStoredWorkspaceState).not.toHaveBeenCalledWith(expect.objectContaining({ + folderPath: mockFolderPath + })); + }); + + it("reports folder import conflicts without hiding the conflicting path", async () => { + mockedResolveDesktopPlatform.mockReturnValue("windows"); + mockedOpenNativeMarkdownFolder.mockRejectedValue( + new Error("Workspace entry conflicts with notes/existing.md.") + ); + + renderApp(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle workspace sidebar" })); + fireEvent.click(await screen.findByRole("button", { name: "Open Folder" })); + + expect(await screen.findByText( + "Could not open the folder. Workspace entry conflicts with notes/existing.md." + )).toBeInTheDocument(); + }); + it("opens a remembered markdown folder from the sidebar recent folders area", async () => { mockedGetStoredRecentMarkdownFolders.mockResolvedValue([ { name: "notes", path: "/mock-files/notes" } @@ -3383,7 +3571,9 @@ describe("Markra workspace", () => { path: "/mock-files/notes" }); expect(mockedSaveStoredWorkspaceState.mock.calls.at(-1)?.[0]).toEqual({ + activeDraftId: null, aiAgentSessionId: "session-app", + draftTabs: [], filePath: null, fileTreeOpen: true, folderName: "notes", @@ -3392,6 +3582,142 @@ describe("Markra workspace", () => { }); }); + it("reconfirms before discarding edits made while opening a remembered folder", async () => { + const recentFolderPath = "/mock-files/notes"; + let resolveFiles: (() => unknown) | null = null; + mockOpenMarkdownFile({ + content: "# Initial synthetic draft", + name: "native.md", + path: mockNativePath + }); + mockedGetStoredRecentMarkdownFolders.mockResolvedValue([ + { name: "notes", path: recentFolderPath } + ]); + mockedListNativeMarkdownFilesForPath.mockImplementation((path) => { + if (path === recentFolderPath) { + return new Promise((resolve) => { + resolveFiles = () => resolve([ + { name: "index.md", path: `${recentFolderPath}/index.md`, relativePath: "index.md" } + ]); + }); + } + + return Promise.resolve([ + { name: "native.md", path: mockNativePath, relativePath: "native.md" } + ]); + }); + mockedConfirmNativeUnsavedMarkdownDocumentDiscard + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + + renderApp(); + + fireEvent.keyDown(window, { key: "o", metaKey: true }); + expect(await screen.findByText("Initial synthetic draft")).toBeInTheDocument(); + await selectEditorViewMode("Source code"); + const sourceEditor = await screen.findByRole("textbox", { name: "Markdown source" }); + replaceMarkdownSource(sourceEditor, "# Approved synthetic draft"); + + fireEvent.click(screen.getByRole("button", { name: "Toggle file list" })); + expect(await screen.findByRole("button", { name: "native.md" })).toBeInTheDocument(); + const recentSection = await screen.findByRole("region", { name: "Recently used directories" }); + fireEvent.click(within(recentSection).getByRole("button", { name: "notes" })); + + await waitFor(() => expect(mockedConfirmNativeUnsavedMarkdownDocumentDiscard).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockedListNativeMarkdownFilesForPath).toHaveBeenCalledWith( + recentFolderPath, + defaultFileTreeListOptions + )); + replaceMarkdownSource(sourceEditor, "# Newer synthetic draft"); + await act(async () => { + resolveFiles?.(); + }); + + await waitFor(() => expect(mockedConfirmNativeUnsavedMarkdownDocumentDiscard).toHaveBeenCalledTimes(2)); + expect(readMarkdownSource(screen.getByRole("textbox", { name: "Markdown source" }))).toBe( + "# Newer synthetic draft" + ); + expect(screen.getByRole("button", { name: "native.md" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "index.md" })).not.toBeInTheDocument(); + expect(mockedSaveStoredRecentMarkdownFolder).not.toHaveBeenCalledWith({ + name: "notes", + path: recentFolderPath + }); + expect(mockedSaveStoredWorkspaceState).not.toHaveBeenCalledWith(expect.objectContaining({ + folderPath: recentFolderPath + })); + }); + + it("ignores an older recent-folder confirmation after a newer folder commits", async () => { + const olderFolderPath = "/mock-files/older"; + const newerFolderPath = "/mock-files/newer"; + let resolveOlderConfirmation: ((confirmed: boolean) => unknown) | null = null; + mockOpenMarkdownFile({ + content: "# Initial synthetic draft", + name: "native.md", + path: mockNativePath + }); + mockedGetStoredRecentMarkdownFolders.mockResolvedValue([ + { name: "older", path: olderFolderPath }, + { name: "newer", path: newerFolderPath } + ]); + mockedListNativeMarkdownFilesForPath.mockImplementation((path) => { + if (path === olderFolderPath) { + return Promise.resolve([ + { name: "older.md", path: `${olderFolderPath}/older.md`, relativePath: "older.md" } + ]); + } + if (path === newerFolderPath) { + return Promise.resolve([ + { name: "newer.md", path: `${newerFolderPath}/newer.md`, relativePath: "newer.md" } + ]); + } + + return Promise.resolve([ + { name: "native.md", path: mockNativePath, relativePath: "native.md" } + ]); + }); + mockedConfirmNativeUnsavedMarkdownDocumentDiscard + .mockReturnValueOnce(new Promise((resolve) => { + resolveOlderConfirmation = resolve; + })) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + + renderApp(); + + fireEvent.keyDown(window, { key: "o", metaKey: true }); + expect(await screen.findByText("Initial synthetic draft")).toBeInTheDocument(); + await selectEditorViewMode("Source code"); + replaceMarkdownSource( + await screen.findByRole("textbox", { name: "Markdown source" }), + "# Edited synthetic draft" + ); + + fireEvent.click(screen.getByRole("button", { name: "Toggle file list" })); + const recentSection = await screen.findByRole("region", { name: "Recently used directories" }); + fireEvent.click(within(recentSection).getByRole("button", { name: "older" })); + await waitFor(() => expect(mockedConfirmNativeUnsavedMarkdownDocumentDiscard).toHaveBeenCalledTimes(1)); + + fireEvent.click(within(recentSection).getByRole("button", { name: "newer" })); + expect(await screen.findByRole("button", { name: "newer.md" })).toBeInTheDocument(); + + await act(async () => { + resolveOlderConfirmation?.(true); + await new Promise((resolve) => window.setTimeout(resolve, 200)); + }); + + expect(mockedListNativeMarkdownFilesForPath).not.toHaveBeenCalledWith( + olderFolderPath, + defaultFileTreeListOptions + ); + expect(screen.getByRole("button", { name: "newer.md" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "older.md" })).not.toBeInTheDocument(); + expect(mockedSaveStoredWorkspaceState.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({ + folderPath: newerFolderPath + })); + }); + it("removes a remembered markdown folder from the sidebar recent folders area", async () => { mockedGetStoredRecentMarkdownFolders.mockResolvedValue([ { name: "notes", path: "/mock-files/notes" }, @@ -3772,6 +4098,39 @@ describe("Markra workspace", () => { expect(mockedReadNativeMarkdownFile).toHaveBeenCalledWith(guidePath); }); + it("renders local image assets from a web workspace runtime", async () => { + const documentPath = "web-workspace://default/notes/guide.md"; + const dataUrl = "data:image/png;base64,AQID"; + const runtime = createDefaultAppRuntime(); + const resolveMarkdownImageSrc = vi.fn(async () => dataUrl); + configureAppRuntime({ + ...runtime, + files: { + ...runtime.files, + resolveMarkdownImageSrc + } + }); + mockOpenMarkdownFile({ + content: "![Screenshot](assets/pasted-image.png)", + name: "guide.md", + path: documentPath + }); + + const { container } = renderApp(); + fireEvent.keyDown(window, { key: "o", metaKey: true }); + + const image = await waitFor(() => { + const target = container.querySelector(".ProseMirror .markra-image-node img"); + expect(target).toBeInTheDocument(); + return target!; + }); + await waitFor(() => expect(image).toHaveAttribute("src", dataUrl)); + expect(resolveMarkdownImageSrc).toHaveBeenCalledWith({ + documentPath, + src: "assets/pasted-image.png" + }); + }); + it("closes the current markdown file from Cmd+W without closing the window", async () => { mockOpenMarkdownFile({ content: "# Native file\n\nOpened from disk.", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 1e3ab137..156cc634 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -134,7 +134,9 @@ import type { SelectionFormattingToolbarAction } from "./lib/selection-formatting"; import { + canExportNativeMarkdownFolder, closeNativeWindow, + exportNativeMarkdownFolder, hideSettingsWindow, openNativeExternalUrl, openSettingsWindow, @@ -239,6 +241,12 @@ const sideDocumentMainPanePercentMax = 70; const defaultSideDocumentMainPanePercent = 50; const quietStatusOverlayInset = 56; +function createAppMarkdownImageSrcResolver(documentPath: string | null | undefined) { + return createMarkdownImageSrcResolver(documentPath, { + resolveLocalSrc: (input) => getAppRuntime().files.resolveMarkdownImageSrc?.(input) ?? null + }); +} + function persistSideDocumentGroup(group: StoredWorkspaceSideBySideGroup | null) { saveStoredWorkspaceState({ sideBySideGroup: group }).catch(() => {}); } @@ -699,6 +707,26 @@ function WorkspaceApp() { () => defaultSaveDirectoryFromFileTree(fileTreeSourcePath), [fileTreeSourcePath] ); + const exportWorkspace = useCallback(async (rootPath: string) => { + try { + await exportNativeMarkdownFolder(rootPath); + } catch { + showAppToast({ + message: translate("app.exportWorkspaceFailed"), + status: "error" + }); + } + }, [translate]); + const exportFolder = fileTreeSourcePath && canExportNativeMarkdownFolder(fileTreeSourcePath) + ? exportWorkspace + : undefined; + const handleAutoSaveError = useCallback(() => { + showAppToast({ + id: "markdown-auto-save-error", + message: translate("app.autoSaveFailed"), + status: "error" + }); + }, [translate]); const confirmDiscardUnsavedChanges = useCallback((currentDocument: { name: string }) => { return confirmNativeUnsavedMarkdownDocumentDiscard(currentDocument.name, { cancelLabel: translate("app.cancelDiscardUnsavedMarkdownDocument"), @@ -730,6 +758,7 @@ function WorkspaceApp() { globalIgnoreRules: fileIgnoreSettings.settings.rules, isCurrentMarkdownEquivalent: isCurrentMarkdownEquivalentForDocument, onActiveDiskFileContentChange: handleActiveDiskFileContentChange, + onAutoSaveError: handleAutoSaveError, onMarkdownTreeChange: refreshMarkdownFileTree, onTreeRootFromFolderPath: openFolderPath, onTreeRootFromFilePath: setRootFromMarkdownFilePath, @@ -739,6 +768,7 @@ function WorkspaceApp() { workspaceSourcePath: fileTreeSourcePath }); const { + captureDocumentDiscardSnapshot, clearRecentMarkdownFiles, clearOpenDocument, createBlankDocument, @@ -2779,7 +2809,7 @@ function WorkspaceApp() { const handleSaveDocument = useCallback(() => saveDocument(false), [saveDocument]); const saveDocumentAs = useCallback(() => saveDocument(true), [saveDocument]); const resolveSideDocumentImageSrc = useMemo( - () => createMarkdownImageSrcResolver(sideDocumentTab?.path ?? null), + () => createAppMarkdownImageSrcResolver(sideDocumentTab?.path ?? null), [sideDocumentTab?.path] ); const sideDocumentWordCount = useMemo( @@ -3449,42 +3479,74 @@ function WorkspaceApp() { const handleEditorSplitToggle = useCallback(() => { handleEditorModeSelect(splitMode ? "visual" : "split"); }, [handleEditorModeSelect, splitMode]); + const confirmFolderOpenCommit = useCallback(async (confirmedSnapshot: string) => { + let latestConfirmedSnapshot = confirmedSnapshot; + + // Folder selection and loading can outlive the discard confirmation. Reconfirm a + // stable content snapshot because document.revision does not advance on ordinary typing. + while (true) { + const currentSnapshot = captureDocumentDiscardSnapshot(); + if (currentSnapshot === latestConfirmedSnapshot) break; + + latestConfirmedSnapshot = currentSnapshot; + const canDiscard = await confirmCanDiscardCurrentDocument(); + if (!canDiscard) return false; + } + + return true; + }, [captureDocumentDiscardSnapshot, confirmCanDiscardCurrentDocument]); const handleOpenMarkdownFolder = useCallback(async () => { + const confirmedSnapshot = captureDocumentDiscardSnapshot(); captureActiveDocumentViewState(); - await openMarkdownFolder({ - beforeOpenFolder: () => { - const canDiscard = confirmCanDiscardCurrentDocument(); - if (typeof canDiscard !== "boolean") { - return canDiscard.then((confirmed) => { - if (!confirmed) return false; - - setActiveImageFile(null); - clearOpenDocument({ persistWorkspace: false }); - - return true; - }); - } - if (!canDiscard) return false; + try { + const opened = await openMarkdownFolder({ + beforeCommit: () => confirmFolderOpenCommit(confirmedSnapshot), + beforeOpenFolder: confirmCanDiscardCurrentDocument, + pickerTitle: translate("app.openFolder") + }); + if (!opened) return; - setActiveImageFile(null); - clearOpenDocument({ persistWorkspace: false }); + // The tree commits only after beforeCommit accepts a stable document snapshot. + setActiveImageFile(null); + clearOpenDocument({ persistWorkspace: false }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") return; - return true; - }, - pickerTitle: translate("app.openFolder") - }); - }, [captureActiveDocumentViewState, clearOpenDocument, confirmCanDiscardCurrentDocument, openMarkdownFolder, translate]); + const detail = error instanceof Error ? error.message : String(error); + showAppToast({ + id: "markdown-folder-open-error", + message: `${translate("app.openFolderFailed")} ${detail}`, + status: "error" + }); + } + }, [ + captureActiveDocumentViewState, + captureDocumentDiscardSnapshot, + clearOpenDocument, + confirmCanDiscardCurrentDocument, + confirmFolderOpenCommit, + openMarkdownFolder, + translate + ]); const handleOpenRecentMarkdownFolder = useCallback(async (folder: RecentMarkdownFolder) => { + const confirmedSnapshot = captureDocumentDiscardSnapshot(); captureActiveDocumentViewState(); - const canDiscard = await confirmCanDiscardCurrentDocument(); - if (!canDiscard) return; - - const openedFolder = await openRecentFolder(folder); + const openedFolder = await openRecentFolder(folder, { + beforeCommit: () => confirmFolderOpenCommit(confirmedSnapshot), + beforeOpenFolder: confirmCanDiscardCurrentDocument + }); if (!openedFolder) return; setActiveImageFile(null); clearOpenDocument({ persistWorkspace: false }); - }, [captureActiveDocumentViewState, clearOpenDocument, confirmCanDiscardCurrentDocument, openRecentFolder]); + }, [ + captureActiveDocumentViewState, + captureDocumentDiscardSnapshot, + clearOpenDocument, + confirmCanDiscardCurrentDocument, + confirmFolderOpenCommit, + openRecentFolder + ]); const handleOpenContainingFolder = useCallback((path: string) => { openNativeContainingFolder(path).catch(() => {}); }, []); @@ -4127,7 +4189,7 @@ function WorkspaceApp() { openExternalUrl={handleOpenEditorLink} readOnly={readOnlyMode} onTextSelectionChange={tabActive ? handleTextSelectionChange : undefined} - resolveImageSrc={createMarkdownImageSrcResolver(tab.path)} + resolveImageSrc={createAppMarkdownImageSrcResolver(tab.path)} revision={tab.revision} onScroll={tabActive ? handleVisualPaneScroll : undefined} scrollRef={tabActive ? visualScrollRef : undefined} @@ -4344,6 +4406,7 @@ function WorkspaceApp() { onCreateFile: handleCreateMarkdownTreeFile, onCreateFolder: handleCreateMarkdownTreeFolder, onDeleteFile: handleDeleteMarkdownTreeFile, + onExportFolder: exportFolder, onDocumentLinksOpenChange: handleDocumentLinksOpenChange, onFileTreeAssetsVisibleChange: setFileTreeAssetsVisible, onFileTreeSortChange: setFileTreeSort, diff --git a/packages/app/src/components/MarkdownFileTreeDrawer.test.tsx b/packages/app/src/components/MarkdownFileTreeDrawer.test.tsx index 666d75e2..65b8bd15 100644 --- a/packages/app/src/components/MarkdownFileTreeDrawer.test.tsx +++ b/packages/app/src/components/MarkdownFileTreeDrawer.test.tsx @@ -158,6 +158,44 @@ describe("MarkdownFileTreeDrawer", () => { expect(onInstallAvailableUpdate).toHaveBeenCalledTimes(1); }); + it("shows workspace export for an exportable root and invokes it with the root path", () => { + const exportWorkspace = vi.fn(); + render( + {}} + onSelectOutlineItem={() => {}} + /> + ); + + fireEvent.click(screen.getByRole("button", { name: "Export workspace" })); + + expect(exportWorkspace).toHaveBeenCalledWith("web-workspace://default"); + }); + + it("hides workspace export when the current root is external", () => { + render( + {}} + onSelectOutlineItem={() => {}} + /> + ); + + expect(screen.queryByRole("button", { name: "Export workspace" })).not.toBeInTheDocument(); + }); + it("collapses its own width so the drawer contents clip with the workspace animation", () => { const { container, rerender } = render( unknown | Promise; + onExportFolder?: (rootPath: string) => unknown | Promise; onFileTreeSortChange?: (sort: FileTreeSort) => unknown; onFileTreeAssetsVisibleChange?: (visible: boolean) => unknown; onDocumentLinksOpenChange?: (open: boolean) => unknown; @@ -483,6 +484,7 @@ export function MarkdownFileTreeDrawer({ onCreateFile, onCreateFolder, onDeleteFile, + onExportFolder, onDocumentLinksOpenChange, onFileTreeAssetsVisibleChange, onFileTreeSortChange, @@ -2782,6 +2784,15 @@ export function MarkdownFileTreeDrawer({