From b8dcea9f9cd3ee9f95747d5fd4d252429e8d871d Mon Sep 17 00:00:00 2001 From: MuRong Date: Mon, 13 Jul 2026 09:16:13 +0800 Subject: [PATCH 01/23] feat(web): add offline workspace stores --- apps/web/src/runtime/web/database.test.ts | 29 ++++++ apps/web/src/runtime/web/database.ts | 18 +++- apps/web/src/test/web-runtime-fakes.ts | 112 +++++++++++++++++----- 3 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/runtime/web/database.test.ts 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..44dd033e --- /dev/null +++ b/apps/web/src/runtime/web/database.test.ts @@ -0,0 +1,29 @@ +import { FakeIdbTransaction, FakeIndexedDbFactory } from "../../test/web-runtime-fakes"; +import { + openWebRuntimeDatabase, + 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" }); + }); +}); 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/test/web-runtime-fakes.ts b/apps/web/src/test/web-runtime-fakes.ts index a75b5b8e..e51c4769 100644 --- a/apps/web/src/test/web-runtime-fakes.ts +++ b/apps/web/src/test/web-runtime-fakes.ts @@ -128,7 +128,7 @@ class FakeIdbOpenRequest extends FakeIdbRequest { class FakeIdbObjectStore { constructor( - private readonly keyPath: string, + private readonly keyPath: string | string[], private readonly records: Map ) {} @@ -136,7 +136,7 @@ class FakeIdbObjectStore { const request = new FakeIdbRequest(); queueMicrotask(() => { - this.records.delete(String(key)); + this.records.delete(serializeKey(key)); request.succeed(undefined); }); @@ -147,18 +147,30 @@ class FakeIdbObjectStore { const request = new FakeIdbRequest(); queueMicrotask(() => { - request.succeed(cloneValue(this.records.get(String(key)))); + request.succeed(cloneValue(this.records.get(serializeKey(key)))); }); return request as unknown as IDBRequest; } + getAll() { + const request = new FakeIdbRequest(); + + queueMicrotask(() => { + request.succeed(Array.from(this.records.values(), 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)); + 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 +178,58 @@ 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 state: "pending" | "complete" | "failed" | "aborted" = "pending"; + + constructor(private readonly stores = new Map()) {} + + abort(error = new DOMException("Transaction aborted", "AbortError")) { + if (this.state !== "pending") return; + this.error = error; + this.state = "aborted"; + queueMicrotask(() => this.onabort?.(new Event("abort"))); + } + + complete() { + if (this.state !== "pending") return; + queueMicrotask(() => { + if (this.state !== "pending") return; + this.state = "complete"; + this.oncomplete?.(new Event("complete")); + }); + } + + fail(error: DOMException) { + if (this.state !== "pending") return; + this.error = error; + this.state = "failed"; + queueMicrotask(() => this.onerror?.(new Event("error"))); + } + + 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); + } +} + class FakeIdbDatabase { - private readonly stores = new Map; - }>(); + private readonly stores = new Map(); + version = 0; objectStoreNames = { contains: (name: string) => this.stores.has(name) @@ -179,7 +238,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,19 +250,24 @@ 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[]) { + 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 transaction = new FakeIdbTransaction(stores); + transaction.complete(); - return { - objectStore: () => new FakeIdbObjectStore(store.keyPath, store.records) - }; + return transaction; } } @@ -209,16 +275,18 @@ export class FakeIndexedDbFactory { private readonly databases = new Map(); readonly openedNames: string[] = []; - open(name: string) { + open(name: string, version?: number) { const request = new FakeIdbOpenRequest(); const existingDatabase = this.databases.get(name); const database = existingDatabase ?? new FakeIdbDatabase(); + 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); From 321883b1fd2ab85df202e32e8373506d194dcfe4 Mon Sep 17 00:00:00 2001 From: MuRong Date: Mon, 13 Jul 2026 09:22:34 +0800 Subject: [PATCH 02/23] fix(web): defer fake IndexedDB completion --- apps/web/src/runtime/web/database.test.ts | 17 +++++++++ apps/web/src/test/web-runtime-fakes.ts | 44 ++++++++++++++++++----- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/apps/web/src/runtime/web/database.test.ts b/apps/web/src/runtime/web/database.test.ts index 44dd033e..59ae510c 100644 --- a/apps/web/src/runtime/web/database.test.ts +++ b/apps/web/src/runtime/web/database.test.ts @@ -1,6 +1,7 @@ import { FakeIdbTransaction, FakeIndexedDbFactory } from "../../test/web-runtime-fakes"; import { openWebRuntimeDatabase, + requestToPromise, transactionToPromise, webRuntimeAiChatAttachmentStoreName, webRuntimeSettingsStoreName, @@ -26,4 +27,20 @@ describe("Web runtime database", () => { 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/test/web-runtime-fakes.ts b/apps/web/src/test/web-runtime-fakes.ts index e51c4769..5b4948fe 100644 --- a/apps/web/src/test/web-runtime-fakes.ts +++ b/apps/web/src/test/web-runtime-fakes.ts @@ -129,13 +129,26 @@ class FakeIdbOpenRequest extends FakeIdbRequest { class FakeIdbObjectStore { constructor( private readonly keyPath: string | string[], - private readonly records: Map + 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.queueOperation(() => { this.records.delete(serializeKey(key)); request.succeed(undefined); }); @@ -146,7 +159,7 @@ class FakeIdbObjectStore { get(key: IDBValidKey) { const request = new FakeIdbRequest(); - queueMicrotask(() => { + this.queueOperation(() => { request.succeed(cloneValue(this.records.get(serializeKey(key)))); }); @@ -156,7 +169,7 @@ class FakeIdbObjectStore { getAll() { const request = new FakeIdbRequest(); - queueMicrotask(() => { + this.queueOperation(() => { request.succeed(Array.from(this.records.values(), cloneValue)); }); @@ -166,7 +179,7 @@ class FakeIdbObjectStore { put(record: StoredIndexedDbRecord) { const request = new FakeIdbRequest(); - queueMicrotask(() => { + this.queueOperation(() => { const key = typeof this.keyPath === "string" ? record[this.keyPath] as IDBValidKey : this.keyPath.map((part) => record[part]) as IDBValidKey; @@ -192,6 +205,8 @@ export class FakeIdbTransaction { onabort: RequestHandler = null; oncomplete: RequestHandler = null; onerror: RequestHandler = null; + private completionQueued = false; + private pendingRequests = 0; private state: "pending" | "complete" | "failed" | "aborted" = "pending"; constructor(private readonly stores = new Map()) {} @@ -204,9 +219,11 @@ export class FakeIdbTransaction { } complete() { - if (this.state !== "pending") return; + if (this.state !== "pending" || this.completionQueued) return; + this.completionQueued = true; queueMicrotask(() => { - if (this.state !== "pending") return; + this.completionQueued = false; + if (this.state !== "pending" || this.pendingRequests > 0) return; this.state = "complete"; this.oncomplete?.(new Event("complete")); }); @@ -219,11 +236,22 @@ export class FakeIdbTransaction { 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); + return new FakeIdbObjectStore(store.keyPath, store.records, this); } } From 316448ccbd8384eb270922165006f74bdf817bc3 Mon Sep 17 00:00:00 2001 From: MuRong Date: Mon, 13 Jul 2026 09:39:41 +0800 Subject: [PATCH 03/23] feat(web): persist browser workspaces --- apps/web/src/runtime/web/index.ts | 1 + apps/web/src/runtime/web/workspace.test.ts | 148 +++++++ apps/web/src/runtime/web/workspace.ts | 474 +++++++++++++++++++++ apps/web/src/test/web-runtime-fakes.ts | 67 ++- 4 files changed, 684 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/runtime/web/workspace.test.ts create mode 100644 apps/web/src/runtime/web/workspace.ts diff --git a/apps/web/src/runtime/web/index.ts b/apps/web/src/runtime/web/index.ts index 82621658..f35a79ed 100644 --- a/apps/web/src/runtime/web/index.ts +++ b/apps/web/src/runtime/web/index.ts @@ -9,3 +9,4 @@ export * from "./settings"; export * from "./types"; export * from "./web-resource"; export * from "./window"; +export * from "./workspace"; 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..d51ab547 --- /dev/null +++ b/apps/web/src/runtime/web/workspace.test.ts @@ -0,0 +1,148 @@ +import { FakeIndexedDbFactory } from "../../test/web-runtime-fakes"; +import { + openWebRuntimeDatabase, + requestToPromise, + transactionToPromise, + webRuntimeWorkspaceStoreName +} from "./database"; +import { createWorkspaceRepository, defaultWorkspaceId } 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: "Workspace" + }); + await expect(repository.ensureDefaultWorkspace()).resolves.toEqual({ + id: defaultWorkspaceId, + name: "Workspace" + }); + }); + + 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.toThrow("not found"); + 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.toThrow("docs"); + await expect(repository.move("default", "note.md", "docs")).rejects.toThrow("docs"); + 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("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: "Workspace" }]); + }); + + 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.writeFile("default", "notes/existing.md", new Blob(["existing"])); + + await expect( + repository.importDirectory("default", "notes", [upload("notes/existing.md", "replacement")]) + ).rejects.toThrow("notes/existing.md"); + + await expect((await repository.read("default", "notes/existing.md")).body?.text()).resolves.toBe("existing"); + await expect(repository.exportEntries("default")).resolves.toEqual([ + expect.objectContaining({ path: "notes/existing.md" }) + ]); + }); + + 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([]); + }); +}); diff --git a/apps/web/src/runtime/web/workspace.ts b/apps/web/src/runtime/web/workspace.ts new file mode 100644 index 00000000..a24fd2bb --- /dev/null +++ b/apps/web/src/runtime/web/workspace.ts @@ -0,0 +1,474 @@ +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) => Promise; + writeFile: (workspaceId: string, path: string, body: Blob) => 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; +}; + +type StoredWorkspace = { + id: string; + lifecycle: "active" | "staging"; + name: string; +}; + +const defaultWorkspaceName = "Workspace"; + +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 findFileAncestor(entries: readonly WorkspaceEntry[], path: string) { + const parts = path.split("/"); + for (let index = 1; index < parts.length; index += 1) { + const ancestor = findEntry(entries, parts.slice(0, index).join("/")); + if (ancestor?.kind === "file") return ancestor; + } + + return undefined; +} + +function conflictError(path: string) { + return new Error(`Workspace entry conflicts with ${path}.`); +} + +function notFoundError(path: string) { + return new Error(`Workspace entry was not found: ${path}.`); +} + +function requireActiveWorkspace(workspace: StoredWorkspace | undefined, workspaceId: string) { + if (!workspace || workspace.lifecycle !== "active") { + throw new Error(`Workspace was not found: ${workspaceId}.`); + } +} + +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 || "application/octet-stream", + modifiedAt: timestamp, + path, + workspaceId + }); + } + + return sortEntries(Array.from(entriesByPath.values())); +} + +export function createWorkspaceRepository( + options: IndexedDbSettingsRuntimeOptions = {} +): WorkspaceRepository { + let databasePromise: Promise | null = null; + const getDatabase = () => { + databasePromise ??= openWebRuntimeDatabase(options); + + return databasePromise; + }; + + const readWorkspaceEntries = async (workspaceId: 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 = transaction.objectStore(webRuntimeWorkspaceEntryStoreName).getAll(); + 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, storedEntries] = await Promise.all([ + requestToPromise(workspaceStore.get(workspaceId)), + requestToPromise(workspaceStore.get(stagedId)), + requestToPromise(entryStore.getAll()) + ]); + requireActiveWorkspace(targetWorkspace, workspaceId); + if (!stagedWorkspace || stagedWorkspace.lifecycle !== "staging") { + throw new Error(`Staging workspace was not found: ${stagedId}.`); + } + + const targetEntries = storedEntries.filter((entry) => entry.workspaceId === workspaceId); + const stagedEntries = storedEntries.filter((entry) => entry.workspaceId === stagedId); + const targetPaths = new Set(targetEntries.map((entry) => entry.path)); + for (const entry of stagedEntries) { + if (targetPaths.has(entry.path) || findFileAncestor(targetEntries, entry.path)) { + 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(entryStore.getAll()); + 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); + + return sortEntries(entries.filter((entry) => !normalizedRoot || isAtOrBelow(entry.path, normalizedRoot))); + }, + async read(workspaceId, path) { + const normalizedPath = normalizeWorkspacePath(path); + const entry = findEntry(await readWorkspaceEntries(workspaceId), normalizedPath); + if (!entry) throw notFoundError(normalizedPath); + + return entry; + }, + async createDirectory(workspaceId, path) { + 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, storedEntries] = await Promise.all([ + requestToPromise(workspaceStore.get(workspaceId)), + requestToPromise(entryStore.getAll()) + ]); + requireActiveWorkspace(workspace, workspaceId); + const entries = storedEntries.filter((entry) => entry.workspaceId === workspaceId); + const existing = findEntry(entries, normalizedPath); + if (existing?.kind === "directory") { + await completion; + return existing; + } + if (existing || findFileAncestor(entries, 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) { + 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, storedEntries] = await Promise.all([ + requestToPromise(workspaceStore.get(workspaceId)), + requestToPromise(entryStore.getAll()) + ]); + requireActiveWorkspace(workspace, workspaceId); + const entries = storedEntries.filter((entry) => entry.workspaceId === workspaceId); + const existing = findEntry(entries, normalizedPath); + if (existing?.kind === "directory" || findFileAncestor(entries, normalizedPath)) { + await completion; + throw conflictError(normalizedPath); + } + + const timestamp = Date.now(); + const entry: WorkspaceEntry = { + body, + createdAt: existing?.createdAt ?? timestamp, + kind: "file", + mediaType: body.type || "application/octet-stream", + 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, storedEntries] = await Promise.all([ + requestToPromise(workspaceStore.get(workspaceId)), + requestToPromise(entryStore.getAll()) + ]); + requireActiveWorkspace(workspace, workspaceId); + const entries = storedEntries.filter((entry) => entry.workspaceId === workspaceId); + const source = findEntry(entries, normalizedSource); + if (!source) { + await completion; + throw notFoundError(normalizedSource); + } + const affected = entries.filter((entry) => isAtOrBelow(entry.path, normalizedSource)); + if (normalizedTarget.startsWith(`${normalizedSource}/`)) { + await completion; + throw conflictError(normalizedTarget); + } + if (normalizedSource === normalizedTarget) { + await completion; + return sortEntries(affected); + } + + const affectedPaths = new Set(affected.map((entry) => entry.path)); + const unaffected = 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 (findEntry(unaffected, entry.path) || findFileAncestor(unaffected, entry.path)) { + 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, storedEntries] = await Promise.all([ + requestToPromise(workspaceStore.get(workspaceId)), + requestToPromise(entryStore.getAll()) + ]); + requireActiveWorkspace(workspace, workspaceId); + const entries = storedEntries.filter((entry) => entry.workspaceId === workspaceId); + const entry = findEntry(entries, normalizedPath); + if (!entry) { + await completion; + throw notFoundError(normalizedPath); + } + const affected = entries.filter((candidate) => isAtOrBelow(candidate.path, normalizedPath)); + 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({ 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); + + 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 5b4948fe..99935012 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; @@ -207,32 +214,65 @@ export class FakeIdbTransaction { 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()) {} + 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; - queueMicrotask(() => { + // 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"))); } @@ -259,6 +299,8 @@ class FakeIdbDatabase { private readonly stores = new Map(); version = 0; + constructor(private readonly takeTransactionFailure: () => DOMException | undefined) {} + objectStoreNames = { contains: (name: string) => this.stores.has(name) }; @@ -278,7 +320,7 @@ class FakeIdbDatabase { return new FakeIdbObjectStore(store.keyPath, store.records); } - transaction(names: string | string[]) { + transaction(names: string | string[], mode: IDBTransactionMode = "readonly") { const requestedNames = typeof names === "string" ? [names] : names; const stores = new Map(); @@ -292,7 +334,8 @@ class FakeIdbDatabase { stores.set(name, this.stores.get(name)!); } - const transaction = new FakeIdbTransaction(stores); + const completionError = mode === "readwrite" ? this.takeTransactionFailure() : undefined; + const transaction = new FakeIdbTransaction(stores, completionError, mode === "readwrite"); transaction.complete(); return transaction; @@ -301,12 +344,24 @@ class FakeIdbDatabase { export class FakeIndexedDbFactory { private readonly databases = new Map(); + private nextTransactionError: DOMException | undefined; readonly openedNames: string[] = []; + 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); From 9a0470ce54b14f227531ba227cfaf43f91f67bb6 Mon Sep 17 00:00:00 2001 From: MuRong Date: Mon, 13 Jul 2026 09:49:14 +0800 Subject: [PATCH 04/23] fix(web): enforce workspace namespace integrity --- apps/web/src/runtime/web/workspace.test.ts | 124 +++++++++++++++++++++ apps/web/src/runtime/web/workspace.ts | 47 +++++--- 2 files changed, 155 insertions(+), 16 deletions(-) diff --git a/apps/web/src/runtime/web/workspace.test.ts b/apps/web/src/runtime/web/workspace.test.ts index d51ab547..5b4a668d 100644 --- a/apps/web/src/runtime/web/workspace.test.ts +++ b/apps/web/src/runtime/web/workspace.test.ts @@ -3,6 +3,7 @@ import { openWebRuntimeDatabase, requestToPromise, transactionToPromise, + webRuntimeWorkspaceEntryStoreName, webRuntimeWorkspaceStoreName } from "./database"; import { createWorkspaceRepository, defaultWorkspaceId } from "./workspace"; @@ -98,6 +99,47 @@ describe("Browser workspace repository", () => { 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.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")).rejects.toThrow("not found"); + }); + + 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.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 }); @@ -134,6 +176,33 @@ describe("Browser workspace repository", () => { ]); }); + 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.writeFile("default", "archive/folder/existing.md", new Blob(["existing"])); + + await expect( + repository.importDirectory("default", "archive", [upload("archive/folder", "replacement")]) + ).rejects.toThrow("archive/folder"); + + await expect((await repository.read("default", "archive/folder/existing.md")).body?.text()).resolves.toBe( + "existing" + ); + await expect(repository.read("default", "archive/folder")).rejects.toThrow("not found"); + }); + + 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 }); @@ -145,4 +214,59 @@ describe("Browser workspace repository", () => { }); 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 index a24fd2bb..bc3b4ed4 100644 --- a/apps/web/src/runtime/web/workspace.ts +++ b/apps/web/src/runtime/web/workspace.ts @@ -67,14 +67,21 @@ function findEntry(entries: readonly WorkspaceEntry[], path: string) { return entries.find((entry) => entry.path === path); } -function findFileAncestor(entries: readonly WorkspaceEntry[], path: string) { - const parts = path.split("/"); - for (let index = 1; index < parts.length; index += 1) { - const ancestor = findEntry(entries, parts.slice(0, index).join("/")); - if (ancestor?.kind === "file") return ancestor; - } - - return undefined; +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) { @@ -130,7 +137,7 @@ function buildImportedEntries(workspaceId: string, rootPath: string, files: read body: file, createdAt: timestamp, kind: "file", - mediaType: file.type || "application/octet-stream", + mediaType: file.type, modifiedAt: timestamp, path, workspaceId @@ -218,9 +225,8 @@ export function createWorkspaceRepository( const targetEntries = storedEntries.filter((entry) => entry.workspaceId === workspaceId); const stagedEntries = storedEntries.filter((entry) => entry.workspaceId === stagedId); - const targetPaths = new Set(targetEntries.map((entry) => entry.path)); for (const entry of stagedEntries) { - if (targetPaths.has(entry.path) || findFileAncestor(targetEntries, entry.path)) { + if (findNamespaceConflict(targetEntries, entry)) { await completion; throw conflictError(entry.path); } @@ -305,7 +311,7 @@ export function createWorkspaceRepository( await completion; return existing; } - if (existing || findFileAncestor(entries, normalizedPath)) { + if (existing || findNamespaceConflict(entries, { kind: "directory", path: normalizedPath })) { await completion; throw conflictError(normalizedPath); } @@ -340,7 +346,12 @@ export function createWorkspaceRepository( requireActiveWorkspace(workspace, workspaceId); const entries = storedEntries.filter((entry) => entry.workspaceId === workspaceId); const existing = findEntry(entries, normalizedPath); - if (existing?.kind === "directory" || findFileAncestor(entries, normalizedPath)) { + if ( + existing?.kind === "directory" + || findNamespaceConflict(entries, { kind: "file", path: normalizedPath }, { + allowExactPath: existing?.kind === "file" + }) + ) { await completion; throw conflictError(normalizedPath); } @@ -350,7 +361,7 @@ export function createWorkspaceRepository( body, createdAt: existing?.createdAt ?? timestamp, kind: "file", - mediaType: body.type || "application/octet-stream", + mediaType: body.type, modifiedAt: timestamp, path: normalizedPath, workspaceId @@ -401,7 +412,9 @@ export function createWorkspaceRepository( path: `${normalizedTarget}${entry.path.slice(normalizedSource.length)}` })); for (const entry of moved) { - if (findEntry(unaffected, entry.path) || findFileAncestor(unaffected, entry.path)) { + if (findNamespaceConflict(unaffected, entry, { + claimDescendants: entry.path === normalizedTarget || entry.kind === "file" + })) { await completion; throw conflictError(entry.path); } @@ -437,7 +450,9 @@ export function createWorkspaceRepository( await completion; throw notFoundError(normalizedPath); } - const affected = entries.filter((candidate) => isAtOrBelow(candidate.path, normalizedPath)); + const affected = entry.kind === "file" + ? [entry] + : entries.filter((candidate) => isAtOrBelow(candidate.path, normalizedPath)); if (entry.kind === "directory" && affected.length > 1 && !recursive) { await completion; throw new Error(`Workspace directory is not empty: ${normalizedPath}.`); From 190f1b253287c4eccfc0e5cdc78a692450bddb3f Mon Sep 17 00:00:00 2001 From: MuRong Date: Mon, 13 Jul 2026 10:02:22 +0800 Subject: [PATCH 05/23] feat(web): add virtual workspace handles --- apps/web/src/runtime/web/index.ts | 1 + apps/web/src/runtime/web/types.ts | 13 + .../src/runtime/web/workspace-handles.test.ts | 110 ++++++ apps/web/src/runtime/web/workspace-handles.ts | 331 ++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 apps/web/src/runtime/web/workspace-handles.test.ts create mode 100644 apps/web/src/runtime/web/workspace-handles.ts diff --git a/apps/web/src/runtime/web/index.ts b/apps/web/src/runtime/web/index.ts index f35a79ed..3ffe46db 100644 --- a/apps/web/src/runtime/web/index.ts +++ b/apps/web/src/runtime/web/index.ts @@ -10,3 +10,4 @@ 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..a6d04366 100644 --- a/apps/web/src/runtime/web/types.ts +++ b/apps/web/src/runtime/web/types.ts @@ -9,10 +9,17 @@ 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; }; @@ -21,11 +28,17 @@ export type 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..e029eeec --- /dev/null +++ b/apps/web/src/runtime/web/workspace-handles.test.ts @@ -0,0 +1,110 @@ +import { FakeIndexedDbFactory } from "../../test/web-runtime-fakes"; +import type { WebDirectoryHandle } from "./types"; +import { + createWorkspaceDirectoryHandle, + createWorkspaceUrl, + parseWorkspaceUrl +} from "./workspace-handles"; +import { createWorkspaceRepository } 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("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("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 encoded durable workspace URLs", () => { + const url = createWorkspaceUrl("default", "notes/你好 world.md"); + + expect(url).toBe("web-workspace://default/notes%2F%E4%BD%A0%E5%A5%BD%20world.md"); + expect(parseWorkspaceUrl(url)).toEqual({ + path: "notes/你好 world.md", + workspaceId: "default" + }); + }); + + 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(); + }); +}); 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..8c59079d --- /dev/null +++ b/apps/web/src/runtime/web/workspace-handles.ts @@ -0,0 +1,331 @@ +import type { + WebDirectoryHandle, + WebFileHandle, + WebHandleMove, + WebWorkspaceLocation +} from "./types"; +import type { WorkspaceEntry, 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 { + throw notFound(path); + } +} + +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 { + 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([])); + } catch { + throw typeMismatch(path, kind); + } + } + + if (entry.kind !== kind) throw typeMismatch(path, kind); + + return entry; +} + +function targetMovePath( + state: WorkspaceHandleState, + directoryOrName: WebDirectoryHandle | string, + newName?: string +) { + if (typeof directoryOrName === "string") { + validateWorkspaceName(directoryOrName); + return workspaceChildPath(workspaceParentPath(state.path), directoryOrName); + } + + const target = directoryStates.get(directoryOrName); + if ( + !target + || target.repository !== state.repository + || target.workspaceId !== state.workspaceId + ) { + throw invalidModification(state.path); + } + + 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 = 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 { + 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. + await state.repository.writeFile(state.workspaceId, state.path, new Blob(chunks)); + }, + 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 *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); + 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); + 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); + + return `${workspaceUrlPrefix}${encodeURIComponent(workspaceId)}/${encodeURIComponent(path)}`; +} + +export function parseWorkspaceUrl(value: string): WebWorkspaceLocation | null { + if (!value.startsWith(workspaceUrlPrefix)) return null; + + const encodedLocation = value.slice(workspaceUrlPrefix.length); + const separatorIndex = encodedLocation.indexOf("/"); + if ( + separatorIndex <= 0 + || encodedLocation.includes("?") + || encodedLocation.includes("#") + ) { + return null; + } + + try { + const workspaceId = decodeURIComponent(encodedLocation.slice(0, separatorIndex)); + const path = decodeURIComponent(encodedLocation.slice(separatorIndex + 1)); + validateWorkspaceId(workspaceId); + validateWorkspacePath(path); + + return { path, workspaceId }; + } catch { + return null; + } +} From 74b9c347493417d16b04ec10b5fe3dce8796b28e Mon Sep 17 00:00:00 2001 From: MuRong Date: Mon, 13 Jul 2026 10:17:57 +0800 Subject: [PATCH 06/23] fix(web): preserve virtual workspace handle integrity --- .../src/runtime/web/workspace-handles.test.ts | 73 ++++++++++++++++++- apps/web/src/runtime/web/workspace-handles.ts | 66 ++++++++++++++--- 2 files changed, 126 insertions(+), 13 deletions(-) diff --git a/apps/web/src/runtime/web/workspace-handles.test.ts b/apps/web/src/runtime/web/workspace-handles.test.ts index e029eeec..2d1c81c2 100644 --- a/apps/web/src/runtime/web/workspace-handles.test.ts +++ b/apps/web/src/runtime/web/workspace-handles.test.ts @@ -2,10 +2,11 @@ import { FakeIndexedDbFactory } from "../../test/web-runtime-fakes"; import type { WebDirectoryHandle } from "./types"; import { createWorkspaceDirectoryHandle, + createWorkspaceFileHandle, createWorkspaceUrl, parseWorkspaceUrl } from "./workspace-handles"; -import { createWorkspaceRepository } from "./workspace"; +import { createWorkspaceRepository, type WorkspaceRepository } from "./workspace"; async function collectEntryNames(directory: WebDirectoryHandle) { const names: string[] = []; @@ -60,6 +61,31 @@ describe("IndexedDB-backed workspace handles", () => { await expect((await file.getFile()).text()).resolves.toBe("draft"); }); + 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 Error("Synthetic transaction failure"); + 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"); @@ -78,6 +104,35 @@ describe("IndexedDB-backed workspace handles", () => { 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"); @@ -92,19 +147,31 @@ describe("IndexedDB-backed workspace handles", () => { await expect(root.getDirectoryHandle!("docs")).rejects.toMatchObject({ name: "NotFoundError" }); }); - it("round-trips encoded durable workspace URLs", () => { + it("round-trips canonical hierarchical workspace URLs", () => { const url = createWorkspaceUrl("default", "notes/你好 world.md"); - expect(url).toBe("web-workspace://default/notes%2F%E4%BD%A0%E5%A5%BD%20world.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 index 8c59079d..bf26d026 100644 --- a/apps/web/src/runtime/web/workspace-handles.ts +++ b/apps/web/src/runtime/web/workspace-handles.ts @@ -89,14 +89,31 @@ function invalidModification(path: string) { return workspaceDomException("InvalidModificationError", `Workspace entry cannot be modified: ${path}.`); } +function isRepositoryNotFound(error: unknown, path: string) { + return error instanceof DOMException && error.name === "NotFoundError" + || error instanceof Error && error.message === `Workspace entry was not found: ${path}.`; +} + +function isRepositoryConflict(error: unknown, path: string) { + return error instanceof Error && error.message === `Workspace entry conflicts with ${path}.`; +} + async function readEntry(state: WorkspaceHandleState, path: string) { try { return await state.repository.read(state.workspaceId, path); - } catch { + } catch (error) { + if (!isRepositoryNotFound(error, path)) 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, @@ -106,14 +123,16 @@ async function getEntry( let entry: WorkspaceEntry; try { entry = await state.repository.read(state.workspaceId, path); - } catch { + } catch (error) { + if (!isRepositoryNotFound(error, path)) 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([])); - } catch { + } catch (creationError) { + if (!isRepositoryConflict(creationError, path)) throw creationError; throw typeMismatch(path, kind); } } @@ -123,14 +142,20 @@ async function getEntry( return entry; } -function targetMovePath( +async function targetMovePath( state: WorkspaceHandleState, directoryOrName: WebDirectoryHandle | string, newName?: string ) { if (typeof directoryOrName === "string") { validateWorkspaceName(directoryOrName); - return workspaceChildPath(workspaceParentPath(state.path), 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); @@ -141,6 +166,7 @@ function targetMovePath( ) { throw invalidModification(state.path); } + await requireReceivingDirectory(target); const targetName = newName ?? workspacePathName(state.path); validateWorkspaceName(targetName); @@ -152,7 +178,7 @@ function createMove(state: WorkspaceHandleState): WebHandleMove { return async (directoryOrName: WebDirectoryHandle | string, newName?: string) => { if (!state.path) throw invalidModification(state.path); - const targetPath = targetMovePath(state, directoryOrName, newName); + const targetPath = await targetMovePath(state, directoryOrName, newName); try { await state.repository.move(state.workspaceId, state.path, targetPath); } catch { @@ -170,6 +196,8 @@ export function createWorkspaceFileHandle( workspaceId: string, path: string ): WebFileHandle { + validateWorkspaceId(workspaceId); + validateWorkspacePath(path); const state: WorkspaceHandleState = { name: workspacePathName(path), path, @@ -252,6 +280,7 @@ export function createWorkspaceDirectoryHandle( }, 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); @@ -264,6 +293,7 @@ export function createWorkspaceDirectoryHandle( }, 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); @@ -301,8 +331,10 @@ export function createWorkspaceDirectoryHandle( export function createWorkspaceUrl(workspaceId: string, path: string) { validateWorkspaceId(workspaceId); validateWorkspacePath(path); + const workspaceUrl = `${workspaceUrlPrefix}${encodeURIComponent(workspaceId)}`; + if (!path) return workspaceUrl; - return `${workspaceUrlPrefix}${encodeURIComponent(workspaceId)}/${encodeURIComponent(path)}`; + return `${workspaceUrl}/${path.split("/").map(encodeURIComponent).join("/")}`; } export function parseWorkspaceUrl(value: string): WebWorkspaceLocation | null { @@ -311,7 +343,8 @@ export function parseWorkspaceUrl(value: string): WebWorkspaceLocation | null { const encodedLocation = value.slice(workspaceUrlPrefix.length); const separatorIndex = encodedLocation.indexOf("/"); if ( - separatorIndex <= 0 + !encodedLocation + || separatorIndex === 0 || encodedLocation.includes("?") || encodedLocation.includes("#") ) { @@ -319,8 +352,21 @@ export function parseWorkspaceUrl(value: string): WebWorkspaceLocation | null { } try { - const workspaceId = decodeURIComponent(encodedLocation.slice(0, separatorIndex)); - const path = decodeURIComponent(encodedLocation.slice(separatorIndex + 1)); + 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); From 7026cc1738b6fd18630a81f629fe1538dbbf32ce Mon Sep 17 00:00:00 2001 From: MuRong Date: Mon, 13 Jul 2026 10:24:58 +0800 Subject: [PATCH 07/23] fix(web): distinguish workspace repository failures --- .../src/runtime/web/workspace-handles.test.ts | 2 +- apps/web/src/runtime/web/workspace-handles.ts | 22 +++++++---------- apps/web/src/runtime/web/workspace.test.ts | 19 +++++++++++---- apps/web/src/runtime/web/workspace.ts | 24 +++++++++++++++++-- 4 files changed, 47 insertions(+), 20 deletions(-) diff --git a/apps/web/src/runtime/web/workspace-handles.test.ts b/apps/web/src/runtime/web/workspace-handles.test.ts index 2d1c81c2..42a7f6d0 100644 --- a/apps/web/src/runtime/web/workspace-handles.test.ts +++ b/apps/web/src/runtime/web/workspace-handles.test.ts @@ -64,7 +64,7 @@ describe("IndexedDB-backed workspace handles", () => { 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 Error("Synthetic transaction failure"); + const readFailure = new DOMException("Synthetic transaction failure", "NotFoundError"); const repository: WorkspaceRepository = { ...storedRepository, async read() { diff --git a/apps/web/src/runtime/web/workspace-handles.ts b/apps/web/src/runtime/web/workspace-handles.ts index bf26d026..bfab2f58 100644 --- a/apps/web/src/runtime/web/workspace-handles.ts +++ b/apps/web/src/runtime/web/workspace-handles.ts @@ -4,7 +4,12 @@ import type { WebHandleMove, WebWorkspaceLocation } from "./types"; -import type { WorkspaceEntry, WorkspaceRepository } from "./workspace"; +import { + WorkspaceEntryNotFoundError, + WorkspaceNamespaceConflictError, + type WorkspaceEntry, + type WorkspaceRepository +} from "./workspace"; const workspaceUrlPrefix = "web-workspace://"; @@ -89,20 +94,11 @@ function invalidModification(path: string) { return workspaceDomException("InvalidModificationError", `Workspace entry cannot be modified: ${path}.`); } -function isRepositoryNotFound(error: unknown, path: string) { - return error instanceof DOMException && error.name === "NotFoundError" - || error instanceof Error && error.message === `Workspace entry was not found: ${path}.`; -} - -function isRepositoryConflict(error: unknown, path: string) { - return error instanceof Error && error.message === `Workspace entry conflicts with ${path}.`; -} - async function readEntry(state: WorkspaceHandleState, path: string) { try { return await state.repository.read(state.workspaceId, path); } catch (error) { - if (!isRepositoryNotFound(error, path)) throw error; + if (!(error instanceof WorkspaceEntryNotFoundError)) throw error; throw notFound(path); } } @@ -124,7 +120,7 @@ async function getEntry( try { entry = await state.repository.read(state.workspaceId, path); } catch (error) { - if (!isRepositoryNotFound(error, path)) throw error; + if (!(error instanceof WorkspaceEntryNotFoundError)) throw error; if (!create) throw notFound(path); try { @@ -132,7 +128,7 @@ async function getEntry( ? await state.repository.createDirectory(state.workspaceId, path) : await state.repository.writeFile(state.workspaceId, path, new Blob([])); } catch (creationError) { - if (!isRepositoryConflict(creationError, path)) throw creationError; + if (!(creationError instanceof WorkspaceNamespaceConflictError)) throw creationError; throw typeMismatch(path, kind); } } diff --git a/apps/web/src/runtime/web/workspace.test.ts b/apps/web/src/runtime/web/workspace.test.ts index 5b4a668d..47154be7 100644 --- a/apps/web/src/runtime/web/workspace.test.ts +++ b/apps/web/src/runtime/web/workspace.test.ts @@ -6,7 +6,12 @@ import { webRuntimeWorkspaceEntryStoreName, webRuntimeWorkspaceStoreName } from "./database"; -import { createWorkspaceRepository, defaultWorkspaceId } from "./workspace"; +import { + createWorkspaceRepository, + defaultWorkspaceId, + WorkspaceEntryNotFoundError, + WorkspaceNamespaceConflictError +} from "./workspace"; function upload(path: string, contents: BlobPart, type = "text/plain") { const name = path.split("/").at(-1) ?? path; @@ -69,7 +74,9 @@ describe("Browser workspace repository", () => { expect.objectContaining({ path: "published/assets/chart.bin" }), expect.objectContaining({ path: "published/note.md" }) ]); - await expect(repository.read("default", "drafts/note.md")).rejects.toThrow("not found"); + 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); @@ -93,8 +100,12 @@ describe("Browser workspace repository", () => { await repository.createDirectory("default", "docs"); await repository.writeFile("default", "note.md", new Blob(["stable"])); - await expect(repository.writeFile("default", "docs", new Blob(["replacement"]))).rejects.toThrow("docs"); - await expect(repository.move("default", "note.md", "docs")).rejects.toThrow("docs"); + 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" })); }); diff --git a/apps/web/src/runtime/web/workspace.ts b/apps/web/src/runtime/web/workspace.ts index bc3b4ed4..c760a677 100644 --- a/apps/web/src/runtime/web/workspace.ts +++ b/apps/web/src/runtime/web/workspace.ts @@ -31,6 +31,26 @@ export type WorkspaceRepository = { 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 = { id: string; lifecycle: "active" | "staging"; @@ -85,11 +105,11 @@ function findNamespaceConflict( } function conflictError(path: string) { - return new Error(`Workspace entry conflicts with ${path}.`); + return new WorkspaceNamespaceConflictError(path); } function notFoundError(path: string) { - return new Error(`Workspace entry was not found: ${path}.`); + return new WorkspaceEntryNotFoundError(path); } function requireActiveWorkspace(workspace: StoredWorkspace | undefined, workspaceId: string) { From 1bcecb0315b8e543c336f24c0fd34906cb728071 Mon Sep 17 00:00:00 2001 From: MuRong Date: Mon, 13 Jul 2026 10:53:21 +0800 Subject: [PATCH 08/23] feat(web): add persistent offline workspaces --- apps/desktop/src/runtime/index.test.ts | 8 + apps/desktop/src/runtime/index.ts | 3 + apps/web/package.json | 1 + apps/web/src/runtime/index.test.ts | 6 + apps/web/src/runtime/index.ts | 4 +- apps/web/src/runtime/web/file.test.ts | 170 +++++++- apps/web/src/runtime/web/file.ts | 411 ++++++++++++++---- apps/web/src/runtime/web/workspace-handles.ts | 4 +- packages/app/src/lib/tauri/file.ts | 17 + packages/app/src/runtime/index.test.ts | 52 +++ packages/app/src/runtime/index.ts | 8 + pnpm-lock.yaml | 8 + 12 files changed, 601 insertions(+), 91 deletions(-) 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 6103389c..3e36efe1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@markra/app": "workspace:*", "@markra/editor": "workspace:*", "@markra/shared": "workspace:*", + "fflate": "0.8.3", "react": "19.2.5", "react-dom": "19.2.5" }, diff --git a/apps/web/src/runtime/index.test.ts b/apps/web/src/runtime/index.test.ts index 456b40ed..8fab08d0 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: "Workspace", + 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/file.test.ts b/apps/web/src/runtime/web/file.test.ts index 804cfd3d..7427ac99 100644 --- a/apps/web/src/runtime/web/file.test.ts +++ b/apps/web/src/runtime/web/file.test.ts @@ -3,11 +3,12 @@ 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"; -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 +19,13 @@ 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 createDropEvent(dataTransfer: Partial) { const event = new Event("drop", { bubbles: true, cancelable: true }); @@ -361,6 +369,166 @@ 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("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("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("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("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.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("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 df31691c..f3d6561f 100644 --- a/apps/web/src/runtime/web/file.ts +++ b/apps/web/src/runtime/web/file.ts @@ -22,6 +22,7 @@ import type { SaveNativePdfFileInput, SaveNativeSettingsFileInput } from "@markra/app/runtime"; +import { zipSync } from "fflate"; import { confirmWithBrowser, createBrowserDownload, @@ -34,6 +35,13 @@ import type { WebFileHandle, WebRuntimeOptions } from "./types"; +import { + createWorkspaceDirectoryHandle, + createWorkspaceFileHandle, + createWorkspaceUrl, + parseWorkspaceUrl +} from "./workspace-handles"; +import type { WorkspaceRepository } from "./workspace"; type WebHandlePath = | { @@ -47,6 +55,12 @@ type WebHandlePath = relativePath: string; }; +type RuntimeFolderPath = { + id: string; + relativePath: string; + workspace: boolean; +}; + type DirectoryUploadFile = File & { webkitRelativePath?: string; }; @@ -229,6 +243,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 +418,7 @@ function createUploadedDirectoryHandle(files: File[]) { export function createWebFileRuntime( settings: AppSettingsRuntime, + workspaceRepository: WorkspaceRepository, options: WebRuntimeOptions ): AppFileRuntime { const fileHandles = new Map(); @@ -518,11 +575,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 +594,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 +645,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 }; } @@ -678,19 +757,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 +811,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 +866,11 @@ export function createWebFileRuntime( } async function collectMarkdownEntries( - id: string, directory: WebDirectoryHandle, parentRelativePath: string, entries: NativeMarkdownFolderFile[], - managedAttachmentFolder: string | null + managedAttachmentFolder: string | null, + createPath: (relativePath: string) => string ) { const iterator = directory.entries?.() ?? fallbackDirectoryEntries(directory); if (!iterator) throw new Error("Browser directory handle cannot list files."); @@ -770,10 +882,10 @@ export function createWebFileRuntime( entries.push({ kind: "folder", name, - path: createFolderPath(id, relativePath), + path: createPath(relativePath), relativePath }); - await collectMarkdownEntries(id, handle, relativePath, entries, managedAttachmentFolder); + await collectMarkdownEntries(handle, relativePath, entries, managedAttachmentFolder, createPath); } continue; } @@ -781,7 +893,7 @@ export function createWebFileRuntime( const file = { ...folderFileKindFromName(name), name, - path: createFolderPath(id, relativePath), + path: createPath(relativePath), relativePath }; if (shouldIncludeFolderFile(file, managedAttachmentFolder)) entries.push(file); @@ -889,6 +1001,7 @@ export function createWebFileRuntime( 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."); }, @@ -898,34 +1011,46 @@ 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 }); 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; + 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 }; }, @@ -935,17 +1060,60 @@ 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 = await Promise.all(entries + .filter((entry) => entry.kind === "file") + .map(async (entry) => { + const relativePath = pathRelativeToRoot(entry.path, workspacePath.path); + + return [ + relativePath, + new Uint8Array(await (entry.body ?? new Blob([])).arrayBuffer()) + ] as const; + })); + 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(); @@ -992,14 +1160,22 @@ 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 parsedPath = parseRuntimeFolderPath(path); + if (!parsedPath) return []; + const resolved = await directoryForPath(path); const entries: NativeMarkdownFolderFile[] = []; const managedAttachmentFolder = normalizeManagedAttachmentFolder(options.managedAttachmentFolder); - await collectMarkdownEntries(parsedPath.id, root, "", entries, managedAttachmentFolder); + await collectMarkdownEntries( + resolved.directory, + "", + entries, + managedAttachmentFolder, + (relativePath) => createRuntimeFolderPath( + parsedPath, + joinRelativePath(parsedPath.relativePath, relativePath) + ) + ); return entries.sort((left, right) => left.relativePath.toLowerCase().localeCompare(right.relativePath.toLowerCase())); }, @@ -1024,7 +1200,13 @@ export function createWebFileRuntime( 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; @@ -1049,8 +1231,8 @@ 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."); } @@ -1058,7 +1240,7 @@ export function createWebFileRuntime( documentSegments.pop(); const localSrc = decodeMarkdownLocalPath(input.src.split(/[?#]/u)[0] ?? ""); 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"); @@ -1075,14 +1257,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), @@ -1126,18 +1309,19 @@ 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 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 imagePath = joinRelativePath(documentSegments.join("/"), input.src); - const handle = await resolveFileFromFolderPath(documentPath.id, imagePath); + const localSrc = decodeMarkdownLocalPath(input.src.split(/[?#]/u)[0] ?? ""); + const imagePath = normalizeWebRelativePath(joinRelativePath(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: createFolderPath(documentPath.id, imagePath), + path: createRuntimeFolderPath(documentPath, imagePath), src: input.src } satisfies NativeMarkdownImageFile; }, @@ -1151,7 +1335,13 @@ 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); @@ -1164,9 +1354,30 @@ export function createWebFileRuntime( 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); @@ -1203,8 +1414,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 { @@ -1213,12 +1424,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); @@ -1238,8 +1449,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 { @@ -1248,12 +1459,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"); @@ -1271,11 +1482,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(); @@ -1286,6 +1501,26 @@ export function createWebFileRuntime( } } + const defaultWorkspace = parseWorkspaceUrl(input.defaultDirectory ?? ""); + if (defaultWorkspace) { + await workspaceRepository.ensureDefaultWorkspace(); + const directory = createWorkspaceDirectoryHandle( + workspaceRepository, + defaultWorkspace.workspaceId, + defaultWorkspace.path, + baseNameFromPath(defaultWorkspace.path) || "Workspace" + ); + const handle = await directory.getFileHandle?.(input.suggestedName, { create: true }); + if (!handle) throw new Error("Workspace directory cannot create files."); + await writeFileHandle(handle, input.contents); + const path = joinRelativePath(defaultWorkspace.path, input.suggestedName); + + return { + name: input.suggestedName, + path: createWorkspaceUrl(defaultWorkspace.workspaceId, path) + }; + } + if (showSaveFilePicker) { const handle = await showSaveFilePicker({ suggestedName: input.suggestedName, diff --git a/apps/web/src/runtime/web/workspace-handles.ts b/apps/web/src/runtime/web/workspace-handles.ts index bfab2f58..372652c9 100644 --- a/apps/web/src/runtime/web/workspace-handles.ts +++ b/apps/web/src/runtime/web/workspace-handles.ts @@ -212,7 +212,9 @@ export function createWorkspaceFileHandle( closed = true; // Buffer until close so an incomplete save never replaces the durable entry. - await state.repository.writeFile(state.workspaceId, state.path, new Blob(chunks)); + // 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 : ""; + await state.repository.writeFile(state.workspaceId, state.path, new Blob(chunks, { type })); }, async write(chunk) { if (closed) throw new TypeError("Cannot write to a closed workspace file stream."); diff --git a/packages/app/src/lib/tauri/file.ts b/packages/app/src/lib/tauri/file.ts index 896f6921..de28d4ee 100644 --- a/packages/app/src/lib/tauri/file.ts +++ b/packages/app/src/lib/tauri/file.ts @@ -133,6 +133,11 @@ export type SavedNativeMarkdownFile = { name: string; }; +export type SavedNativeMarkdownFolderArchive = { + path: string; + name: string; +}; + export type SavedNativeHtmlFile = { path: string; name: string; @@ -421,6 +426,18 @@ export function openNativeMarkdownFolder(labels?: NativeMarkdownPickerLabels) { return getAppRuntime().files.openMarkdownFolder(labels); } +export function getNativeDefaultMarkdownFolder() { + return getAppRuntime().files.getDefaultMarkdownFolder(); +} + +export function canExportNativeMarkdownFolder(path: string) { + return getAppRuntime().files.canExportMarkdownFolder(path); +} + +export function exportNativeMarkdownFolder(path: string) { + return getAppRuntime().files.exportMarkdownFolder(path); +} + export function saveNativeMarkdownFile(input: SaveNativeMarkdownFileInput) { return getAppRuntime().files.saveMarkdownFile(input); } diff --git a/packages/app/src/runtime/index.test.ts b/packages/app/src/runtime/index.test.ts index d1bf2736..5815aa4f 100644 --- a/packages/app/src/runtime/index.test.ts +++ b/packages/app/src/runtime/index.test.ts @@ -1,3 +1,4 @@ +import * as nativeFiles from "../lib/tauri/file"; import { appLogger, configureAppRuntime, createDefaultAppRuntime, resetAppRuntimeForTests } from "./index"; describe("app runtime logging", () => { @@ -49,3 +50,54 @@ describe("app runtime logging", () => { await expect(attachments.deleteSession("session-1")).rejects.toThrow("deleteAiChatAttachmentSession is unavailable"); }); }); + +describe("app file runtime workspace export contract", () => { + afterEach(() => { + resetAppRuntimeForTests(); + }); + + it("uses unavailable defaults outside a configured file runtime", async () => { + const files = createDefaultAppRuntime().files; + + expect(files.canExportMarkdownFolder("web-workspace://default")).toBe(false); + await expect(files.exportMarkdownFolder("web-workspace://default")).resolves.toBeNull(); + await expect(files.getDefaultMarkdownFolder()).resolves.toBeNull(); + }); + + it("forwards the workspace export bridge to the configured file runtime", async () => { + const runtime = createDefaultAppRuntime(); + const archive = { name: "notes.zip", path: "web-download://notes.zip" }; + const canExportMarkdownFolder = vi.fn(() => true); + const exportMarkdownFolder = vi.fn(async () => archive); + const getDefaultMarkdownFolder = vi.fn(async () => ({ + name: "Workspace", + path: "web-workspace://default" + })); + configureAppRuntime({ + ...runtime, + files: { + ...runtime.files, + canExportMarkdownFolder, + exportMarkdownFolder, + getDefaultMarkdownFolder + } + }); + + const getDefault = Reflect.get(nativeFiles, "getNativeDefaultMarkdownFolder") as + (() => Promise) | undefined; + const canExport = Reflect.get(nativeFiles, "canExportNativeMarkdownFolder") as + ((path: string) => boolean) | undefined; + const exportFolder = Reflect.get(nativeFiles, "exportNativeMarkdownFolder") as + ((path: string) => Promise) | undefined; + + expect(getDefault).toEqual(expect.any(Function)); + expect(canExport).toEqual(expect.any(Function)); + expect(exportFolder).toEqual(expect.any(Function)); + await expect(getDefault!()).resolves.toEqual({ + name: "Workspace", + path: "web-workspace://default" + }); + expect(canExport!("web-workspace://default/notes")).toBe(true); + await expect(exportFolder!("web-workspace://default/notes")).resolves.toEqual(archive); + }); +}); diff --git a/packages/app/src/runtime/index.ts b/packages/app/src/runtime/index.ts index 358d6bd6..aa70affe 100644 --- a/packages/app/src/runtime/index.ts +++ b/packages/app/src/runtime/index.ts @@ -39,6 +39,7 @@ import type { SavedNativeClipboardImage, SavedNativeHtmlFile, SavedNativeMarkdownFile, + SavedNativeMarkdownFolderArchive, SavedNativePandocFile, SavedNativePdfFile, SavedNativeSettingsFile, @@ -138,6 +139,7 @@ export type AppDialogRuntime = { }; export type AppFileRuntime = { + canExportMarkdownFolder: (path: string) => boolean; confirmMarkdownFileDelete: ( fileName: string, labels: { cancelLabel: string; message: string; okLabel: string } @@ -161,6 +163,8 @@ export type AppFileRuntime = { deleteMarkdownTreeFile: (rootPath: string, path: string) => Promise; detectPandocPath: () => Promise; downloadWebImage: (input: DownloadNativeWebImageInput) => Promise; + exportMarkdownFolder: (path: string) => Promise; + getDefaultMarkdownFolder: () => Promise; installMarkdownFileDrop: (onDrop: NativeMarkdownFileDropHandler) => Promise; importLocalFile: (input: ImportNativeLocalFileInput) => Promise; listenOpenedMarkdownPaths: ( @@ -447,6 +451,7 @@ async function readBrowserClipboardText() { function createDefaultFileRuntime(): AppFileRuntime { return { + canExportMarkdownFolder: () => false, confirmMarkdownFileDelete: async () => false, confirmUnsavedMarkdownDocumentDiscard: async () => false, backupMarkdownFolder: () => unsupportedFeature("backupMarkdownFolder"), @@ -456,6 +461,8 @@ function createDefaultFileRuntime(): AppFileRuntime { deleteMarkdownTreeFile: () => unsupportedFeature("deleteMarkdownTreeFile"), detectPandocPath: async () => null, downloadWebImage: () => unsupportedFeature("downloadWebImage"), + exportMarkdownFolder: async () => null, + getDefaultMarkdownFolder: async () => null, installMarkdownFileDrop: async () => () => undefined, importLocalFile: () => unsupportedFeature("importLocalFile"), listenOpenedMarkdownPaths: async () => () => undefined, @@ -661,6 +668,7 @@ export type { SavedNativeClipboardImage, SavedNativeHtmlFile, SavedNativeMarkdownFile, + SavedNativeMarkdownFolderArchive, SavedNativePandocFile, SavedNativePdfFile, SavedNativeSettingsFile, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c89449b1..0272f276 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@markra/shared': specifier: workspace:* version: link:../../packages/shared + fflate: + specifier: 0.8.3 + version: 0.8.3 react: specifier: 19.2.5 version: 19.2.5 @@ -2274,6 +2277,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -5583,6 +5589,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} + format@0.2.2: {} formdata-polyfill@4.0.10: From 9e17c29fa6fd8426425388bba2803013f770f2f7 Mon Sep 17 00:00:00 2001 From: MuRong Date: Mon, 13 Jul 2026 11:06:11 +0800 Subject: [PATCH 09/23] fix(web): restore URL-encoded workspace files --- apps/web/src/runtime/web/file.test.ts | 79 +++++++++++++++++++++++++++ apps/web/src/runtime/web/file.ts | 10 ++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/apps/web/src/runtime/web/file.test.ts b/apps/web/src/runtime/web/file.test.ts index 7427ac99..daaeb302 100644 --- a/apps/web/src/runtime/web/file.test.ts +++ b/apps/web/src/runtime/web/file.test.ts @@ -529,6 +529,85 @@ describe("web file runtime", () => { ])); }); + 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 f3d6561f..faa610c9 100644 --- a/apps/web/src/runtime/web/file.ts +++ b/apps/web/src/runtime/web/file.ts @@ -147,9 +147,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; } @@ -1238,7 +1240,7 @@ export function createWebFileRuntime( 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(createRuntimeFolderPath(parsedDocumentPath, attachmentPath)); const file = await handle.getFile(); @@ -1313,7 +1315,7 @@ export function createWebFileRuntime( if (!documentPath) throw new Error("Current document is not a web folder file."); const documentSegments = documentPath.relativePath.split("/").filter(Boolean); documentSegments.pop(); - const localSrc = decodeMarkdownLocalPath(input.src.split(/[?#]/u)[0] ?? ""); + const localSrc = decodeMarkdownRelativePath(input.src); const imagePath = normalizeWebRelativePath(joinRelativePath(documentSegments.join("/"), localSrc)); const handle = await resolveFileFromFolderPath(createRuntimeFolderPath(documentPath, imagePath)); const file = await handle.getFile(); From 9ab8099ff3ded423d1cf08f95cd48aa0115f7559 Mon Sep 17 00:00:00 2001 From: MuRong Date: Mon, 13 Jul 2026 11:26:32 +0800 Subject: [PATCH 10/23] feat(app): open and export offline workspaces --- packages/app/src/App.test.tsx | 23 ++++ packages/app/src/App.tsx | 24 ++++ .../MarkdownFileTreeDrawer.test.tsx | 38 ++++++ .../src/components/MarkdownFileTreeDrawer.tsx | 11 ++ .../src/hooks/useMarkdownDocument.test.tsx | 121 ++++++++++++++++++ packages/app/src/hooks/useMarkdownDocument.ts | 23 +++- packages/app/src/test/app-harness.tsx | 15 +++ packages/shared/src/i18n/locales/de.ts | 3 + packages/shared/src/i18n/locales/en.ts | 3 + packages/shared/src/i18n/locales/es.ts | 3 + packages/shared/src/i18n/locales/fr.ts | 3 + packages/shared/src/i18n/locales/it.ts | 3 + packages/shared/src/i18n/locales/ja.ts | 3 + packages/shared/src/i18n/locales/ko.ts | 3 + packages/shared/src/i18n/locales/pt-BR.ts | 3 + packages/shared/src/i18n/locales/ru.ts | 3 + packages/shared/src/i18n/locales/types.ts | 3 + packages/shared/src/i18n/locales/zh-CN.ts | 3 + packages/shared/src/i18n/locales/zh-TW.ts | 3 + 19 files changed, 290 insertions(+), 1 deletion(-) diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 3567ca69..a247c2d0 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, @@ -512,6 +515,26 @@ function findEditorTextPosition(view: ProseMirrorEditorView, text: string, offse } describe("Markra workspace", () => { + 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("marks macOS 27 windows for the WebKit scrolling workaround", async () => { mockedResolveDesktopPlatform.mockReturnValue("macos"); mockedResolveDesktopOsVersion.mockReturnValue("27.0"); diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 3ba8b4ff..96d38a3a 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -133,7 +133,9 @@ import type { SelectionFormattingToolbarAction } from "./lib/selection-formatting"; import { + canExportNativeMarkdownFolder, closeNativeWindow, + exportNativeMarkdownFolder, hideSettingsWindow, openNativeExternalUrl, openSettingsWindow, @@ -696,6 +698,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"), @@ -726,6 +748,7 @@ function WorkspaceApp() { getCurrentMarkdown: readCurrentMarkdownForDocument, isCurrentMarkdownEquivalent: isCurrentMarkdownEquivalentForDocument, onActiveDiskFileContentChange: handleActiveDiskFileContentChange, + onAutoSaveError: handleAutoSaveError, onMarkdownTreeChange: refreshMarkdownFileTree, onTreeRootFromFolderPath: openFolderPath, onTreeRootFromFilePath: setRootFromMarkdownFilePath, @@ -4335,6 +4358,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({