From 90cc03c511dfaa8a9028d3888af41ff4cc289a80 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:28:23 -0700 Subject: [PATCH 01/12] feat: upgrade Lix and add memory workspaces with repository initialization --- electron/initialize-checkpoint.mjs | 15 + electron/initialize-checkpoint.test.mjs | 49 + electron/ipc-lix.mjs | 16 + electron/lix-lifecycle.test.mjs | 16 +- electron/lix.mjs | 65 +- electron/main.mjs | 22 +- electron/memory-workspace.mjs | 98 ++ electron/memory-workspace.test.mjs | 50 + electron/preload.mjs | 3 + electron/types.d.ts | 6 +- package.json | 5 +- pnpm-lock.yaml | 872 +++++++++++------- pnpm-workspace.yaml | 1 + .../atelier-host-extensions.test.ts | 10 +- src/extensions/atelier-host-extensions.ts | 10 +- src/extensions/csv/index.reactive.test.tsx | 2 +- .../history/host-extension.test.tsx | 53 ++ src/extensions/history/host-extension.tsx | 48 + .../markdown/editor/tip-tap-editor.tsx | 4 +- src/extensions/markdown/index.test.tsx | 2 +- src/extensions/markdown/index.tsx | 4 +- .../markdown/render-review-diff-html.test.ts | 6 +- src/extensions/terminal/host-extensions.tsx | 25 +- src/lib/agent-turn-review-bridge.test.ts | 2 +- src/lib/agent-turn-review-bridge.ts | 25 +- src/lib/atelier-document-state.ts | 2 +- src/lib/lix-client.ts | 90 +- src/lib/lix-kysely.ts | 21 +- src/lib/lix-react.test.tsx | 4 +- src/lib/lix-react.tsx | 7 +- src/main.tsx | 57 +- .../external-write-review-history.test.ts | 2 +- src/shell/first-run-screen.tsx | 3 + src/shell/temporary-history-notice.tsx | 50 + src/test-utils/node-lix-sdk.test.ts | 2 +- src/test-utils/node-lix-sdk.ts | 5 +- submodule/atelier | 2 +- 37 files changed, 1166 insertions(+), 488 deletions(-) create mode 100644 electron/initialize-checkpoint.mjs create mode 100644 electron/initialize-checkpoint.test.mjs create mode 100644 electron/memory-workspace.mjs create mode 100644 electron/memory-workspace.test.mjs create mode 100644 src/extensions/history/host-extension.test.tsx create mode 100644 src/extensions/history/host-extension.tsx create mode 100644 src/shell/temporary-history-notice.tsx diff --git a/electron/initialize-checkpoint.mjs b/electron/initialize-checkpoint.mjs new file mode 100644 index 00000000..94136e2d --- /dev/null +++ b/electron/initialize-checkpoint.mjs @@ -0,0 +1,15 @@ +/** Checkpoint repository metadata only; leave document changes for user review. */ +export async function checkpointRepositoryMetadata(lix) { + const metadataDiff = `SELECT row_ref FROM lix_diff('lix_file') + WHERE coalesce(to_path, from_path) LIKE '/.lix/%'`; + const pending = await lix.execute(metadataDiff); + if (pending.rows.length === 0) return; + const result = await lix.execute( + `SELECT commit_id FROM lix_create_checkpoint(ARRAY(${metadataDiff}))`, + ); + if (typeof result.rows[0]?.commit_id !== "string") { + throw new Error( + "Repository metadata checkpoint did not return a commit ID.", + ); + } +} diff --git a/electron/initialize-checkpoint.test.mjs b/electron/initialize-checkpoint.test.mjs new file mode 100644 index 00000000..f680ff50 --- /dev/null +++ b/electron/initialize-checkpoint.test.mjs @@ -0,0 +1,49 @@ +// @vitest-environment node +import { test, expect } from "vitest"; +import { createRequire } from "node:module"; +import { FilesystemStorage } from "@lix-js/storage-filesystem"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { checkpointRepositoryMetadata } from "./initialize-checkpoint.mjs"; +const { openLix, bundledPluginArchives } = createRequire(import.meta.url)( + "@lix-js/sdk", +); + +test("initialization checkpoints .lix files, leaves documents pending, and survives reopening", async () => { + const root = await mkdtemp(path.join(tmpdir(), "flashtype-checkpoint-")); + let lix; + try { + await writeFile(path.join(root, "note.md"), "# Keep for review\n"); + lix = await openLix({ storage: new FilesystemStorage({ path: root }) }); + expect(lix.openReport.initialized).toBe(true); + for (const plugin of await bundledPluginArchives()) { + await lix.execute( + "INSERT INTO lix_file (path, content) VALUES ($1, $2)", + [`/.lix/plugins/${plugin.key}.lixplugin`, plugin.archiveBytes], + ); + } + await checkpointRepositoryMetadata(lix); + const pendingPaths = async () => + ( + await lix.execute( + "SELECT coalesce(to_path, from_path) AS path FROM lix_diff('lix_file') ORDER BY path", + ) + ).rows.map((row) => row.path); + expect(await pendingPaths()).toEqual(["/note.md"]); + const checkpoint = async () => + (await lix.execute("SELECT lix_latest_checkpoint_commit_id() AS id")) + .rows[0].id; + const initial = await checkpoint(); + await checkpointRepositoryMetadata(lix); + expect(await checkpoint()).toBe(initial); + await lix.close(); + lix = await openLix({ storage: new FilesystemStorage({ path: root }) }); + expect(lix.openReport.initialized).toBe(false); + expect(await checkpoint()).toBe(initial); + expect(await pendingPaths()).toEqual(["/note.md"]); + } finally { + await lix?.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/electron/ipc-lix.mjs b/electron/ipc-lix.mjs index 23e80280..88fc7fb6 100644 --- a/electron/ipc-lix.mjs +++ b/electron/ipc-lix.mjs @@ -73,6 +73,16 @@ export function registerLixIpc(resolveWindowForEvent, options = {}) { } }); + ipcMain.handle("lix:executeBatch", async (event, payload) => { + const lix = await ensureLixOpenForEvent(event); + const results = await lix.executeBatch( + payload.statements.map((statement) => ({ + sql: String(statement.sql), + params: normalizeParams(statement.params), + })), + ); + return results.map(serializeQueryResult); + }); ipcMain.handle("lix:executeTransaction", async (event, payload) => { const lix = await ensureLixOpenForEvent(event); const statements = Array.isArray(payload?.statements) @@ -691,6 +701,12 @@ function base64ToBytes(base64) { } function serializeQueryResult(result) { + result = { + ...result, + columns: result.columns?.map((column) => + typeof column === "string" ? column : column.name, + ), + }; const rows = Array.isArray(result?.rows) ? result.rows.map((row) => serializeSqlRow(row, result.columns)) : []; diff --git a/electron/lix-lifecycle.test.mjs b/electron/lix-lifecycle.test.mjs index 15b553da..2ec7f9e3 100644 --- a/electron/lix-lifecycle.test.mjs +++ b/electron/lix-lifecycle.test.mjs @@ -14,8 +14,8 @@ vi.mock("electron", () => ({ app: { getPath: () => "/tmp/flashtype-lix-lifecycle-user-data" }, })); -vi.mock("@lix-js/sdk", () => ({ - LocalFilesystem: class FakeLocalFilesystem { +vi.mock("@lix-js/storage-filesystem", () => ({ + FilesystemStorage: class FakeLocalFilesystem { constructor(options) { mocks.storageOptions.push(options); } @@ -23,6 +23,9 @@ vi.mock("@lix-js/sdk", () => ({ async importPaths() {} async syncDiskToLix() {} }, +})); + +vi.mock("@lix-js/sdk", () => ({ bundledPluginArchives: async () => [], openLix: mocks.openLix, })); @@ -87,6 +90,12 @@ describe("Lix workspace lifecycle", () => { mocks.openLix.mockImplementation(async () => { const nativeLix = { close: vi.fn(async () => {}), + execute: vi.fn(async () => ({ + rows: [], + columns: [], + rowsAffected: 0, + notices: [], + })), }; mocks.nativeLixHandles.push(nativeLix); return nativeLix; @@ -120,7 +129,8 @@ describe("Lix workspace lifecycle", () => { await reopen; expect(mocks.openLix).toHaveBeenCalledTimes(2); - expect(mocks.storageOptions).toEqual([persistentOptions, ephemeralOptions]); + expect(mocks.storageOptions).toEqual([{ path: "/workspace" }]); + expect(mocks.openLix).toHaveBeenNthCalledWith(2); }); }); diff --git a/electron/lix.mjs b/electron/lix.mjs index 27b8f242..792aebd0 100644 --- a/electron/lix.mjs +++ b/electron/lix.mjs @@ -1,12 +1,11 @@ +import { checkpointRepositoryMetadata } from "./initialize-checkpoint.mjs"; +import { createMemoryWorkspace } from "./memory-workspace.mjs"; import { app } from "electron"; -import { LocalFilesystem, bundledPluginArchives, openLix } from "@lix-js/sdk"; +import { FilesystemStorage as LocalFilesystem } from "@lix-js/storage-filesystem"; +import { bundledPluginArchives, openLix } from "@lix-js/sdk"; import path from "node:path"; import { readFile, rm } from "node:fs/promises"; -import { - acquireWorkspaceLocalFilesystemOptions, - getWorkspace, - getWorkspaceLixDatabasePath, -} from "./workspace.mjs"; +import { getWorkspace, getWorkspaceLixDatabasePath } from "./workspace.mjs"; import { clearWorkspaceLixOpenPendingSync, markWorkspaceLixOpenPendingSync, @@ -43,15 +42,18 @@ export async function ensureLixOpen(window) { }), ); } - const acquiredStorage = - await acquireWorkspaceLocalFilesystemOptions(window); - const storageOptions = acquiredStorage.options; - releaseStorageOptions = acquiredStorage.release; - const storage = new LocalFilesystem(storageOptions); - nativeLix = await openLix({ - storage, - }); + let storage; + if (tracksPersistentWorkspace) { + storage = new LocalFilesystem({ path: workspace.path }); + nativeLix = await openLix({ storage }); + } else { + nativeLix = await openLix(); + storage = createMemoryWorkspace(nativeLix, workspace.path); + } await ensureDefaultPluginsInstalledOnCurrentBranch(nativeLix); + if (nativeLix.openReport?.initialized) { + await checkpointRepositoryMetadata(nativeLix); + } if (tracksPersistentWorkspace) { clearWorkspaceLixOpenPendingSync(userDataPath, workspace.path); } @@ -59,8 +61,9 @@ export async function ensureLixOpen(window) { nativeLix, storage, workspace.path, - storageOptions.lixDir ?? - path.join(workspace.path, LIX_DATABASE_DIR), + tracksPersistentWorkspace + ? path.join(workspace.path, LIX_DATABASE_DIR) + : null, crypto.randomUUID(), releaseStorageOptions, ); @@ -119,15 +122,15 @@ function pluginArchivePath(plugin) { async function readLixFileBytes(lix, path) { const result = await lix.execute( - "SELECT data FROM lix_file WHERE path = $1", + "SELECT content FROM lix_file WHERE path = $1", [path], ); - return result.rows[0]?.value("data").asBytes(); + return result.rows[0]?.content; } async function writeLixFileBytes(lix, path, data) { await lix.execute( - "INSERT INTO lix_file (path, data) VALUES ($1, $2) ON CONFLICT (path) DO UPDATE SET data = excluded.data", + "INSERT INTO lix_file (path, content) VALUES ($1, $2) ON CONFLICT (path) DO UPDATE SET content = excluded.content", [path, data], ); } @@ -330,7 +333,9 @@ function createDesktopLixHandle( async function runQueued(operation) { const release = await acquireOperationSlot(); try { - return await operation(); + const result = await operation(); + await storage.flush?.(); + return result; } finally { release(); } @@ -344,6 +349,15 @@ function createDesktopLixHandle( } } + const syncTimer = storage.flush + ? setInterval(() => { + void runQueued(() => storage.syncDiskToLix()).catch((error) => + console.error("Workspace file sync failed", error), + ); + }, 750) + : null; + syncTimer?.unref(); + return { sessionId() { return sessionId; @@ -379,6 +393,7 @@ function createDesktopLixHandle( } try { await transaction.commit(); + await storage.flush?.(); } finally { transactionClosed = true; releaseSlot(); @@ -397,6 +412,9 @@ function createDesktopLixHandle( }, }; }, + async executeBatch(statements) { + return await runQueued(() => nativeLix.executeBatch(statements)); + }, async executeTransaction(statements) { return await runQueued(async () => { const transaction = await nativeLix.beginTransaction(); @@ -408,6 +426,7 @@ function createDesktopLixHandle( ]); } await transaction.commit(); + await storage.flush?.(); return result; } catch (error) { await transaction.rollback(); @@ -455,8 +474,12 @@ function createDesktopLixHandle( return await runQueued(() => storage.syncDiskToLix()); }, async close() { + if (syncTimer) clearInterval(syncTimer); try { - await runQueued(() => nativeLix.close()); + await runQueued(async () => { + await storage.flush?.(); + }); + await nativeLix.close(); } finally { await releaseStorageOptions(); } diff --git a/electron/main.mjs b/electron/main.mjs index af613e42..86c08137 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -510,8 +510,10 @@ function requestCloseFileFromApplicationMenu() { window.webContents.send("workspace:closeFile"); } -async function toggleTrackChangesFromApplicationMenu(trackChanges) { - const window = getWorkspaceWindowForFileAction(); +async function toggleTrackChangesFromApplicationMenu( + trackChanges, + window = getWorkspaceWindowForFileAction(), +) { if (!window || window.isDestroyed() || !getWorkspace(window)) { return null; } @@ -878,6 +880,12 @@ async function startWorkspaceLifecycle() { source: "electron-workspace-recovery", }); } + ipcMain.handle("workspace:initializeRepository", (event) => + toggleTrackChangesFromApplicationMenu( + true, + BrowserWindow.fromWebContents(event.sender), + ), + ); registerLixIpc((event) => BrowserWindow.fromWebContents(event.sender), { disableTrackChanges: async (window) => { const workspace = await disableWorkspaceTrackChanges(window); @@ -1830,7 +1838,10 @@ function buildFileMenu() { }, { id: "track-changes", - label: "Track Changes", + label: + trackingWorkspace?.ephemeral === true + ? "Initialize repository" + : "Track Changes", type: "checkbox", enabled: Boolean(trackingWorkspace), checked: trackingWorkspace @@ -1881,7 +1892,10 @@ function updateDockMenu() { }, { id: "dock-track-changes", - label: "Track Changes", + label: + trackingWorkspace?.ephemeral === true + ? "Initialize repository" + : "Track Changes", type: "checkbox", enabled: Boolean(trackingWorkspace), checked: trackingWorkspace diff --git a/electron/memory-workspace.mjs b/electron/memory-workspace.mjs new file mode 100644 index 00000000..da22f404 --- /dev/null +++ b/electron/memory-workspace.mjs @@ -0,0 +1,98 @@ +import path from "node:path"; +import { + readFile, + writeFile, + mkdir, + unlink, + lstat, + realpath, +} from "node:fs/promises"; + +const equal = (a, b) => + a === b || (a != null && b != null && Buffer.from(a).equals(Buffer.from(b))); + +/** Disk files remain durable; only their Lix history lives in memory. */ +export function createMemoryWorkspace(lix, root) { + const baseline = new Map(); + async function diskPath(relative) { + const parts = relative.replace(/^\/+/, "").split("/"); + if ( + parts.some( + (part) => !part || part === "." || part === ".." || part === ".lix", + ) || + relative.includes("\\") + ) + throw new Error("Invalid workspace path"); + const base = await realpath(root); + let current = base; + for (const part of parts) { + current = path.join(current, part); + try { + if ((await lstat(current)).isSymbolicLink()) + throw new Error( + "Symbolic links are not supported for in-memory workspace files", + ); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + return current; + } + async function read(relative) { + try { + return new Uint8Array(await readFile(await diskPath(relative))); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } + } + async function importPaths(paths) { + for (const relative of paths) { + const key = "/" + relative.replace(/^\/+/, ""); + const data = await read(key); + if (equal(data, baseline.get(key))) continue; + if (data === null) + await lix.execute("DELETE FROM lix_file WHERE path = $1", [key]); + else + await lix.execute( + "INSERT INTO lix_file (path, content) VALUES ($1, $2) ON CONFLICT (path) DO UPDATE SET content = excluded.content", + [key, data], + ); + baseline.set(key, data); + } + } + async function flush() { + const result = await lix.execute( + "SELECT path, content FROM lix_file WHERE path NOT LIKE '/.lix/%'", + ); + const files = new Map( + result.rows + .filter((row) => !row.path.endsWith("/")) + .map((row) => [row.path, row.content]), + ); + for (const key of new Set([...baseline.keys(), ...files.keys()])) { + const previous = baseline.get(key) ?? null; + const next = files.get(key) ?? null; + if (equal(previous, next)) continue; + const disk = await read(key); + if (!equal(disk, previous) && !equal(disk, next)) + throw new Error( + `File changed on disk while editing: ${key}. Reload before saving.`, + ); + if (!equal(disk, next)) { + const target = await diskPath(key); + if (next === null) await unlink(target); + else { + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, next); + } + } + baseline.set(key, next); + } + } + return { + importPaths, + flush, + syncDiskToLix: () => importPaths([...baseline.keys()]), + }; +} diff --git a/electron/memory-workspace.test.mjs b/electron/memory-workspace.test.mjs new file mode 100644 index 00000000..a2e2bcc2 --- /dev/null +++ b/electron/memory-workspace.test.mjs @@ -0,0 +1,50 @@ +// @vitest-environment node +import { test, expect } from "vitest"; +import { createRequire } from "node:module"; +import { mkdtemp, writeFile, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createMemoryWorkspace } from "./memory-workspace.mjs"; +const { openLix } = createRequire(import.meta.url)("@lix-js/sdk"); + +test("memory workspace saves files, imports external edits, rejects conflicts, and leaves no repository", async () => { + const root = await mkdtemp(path.join(tmpdir(), "flashtype-memory-test-")); + const lix = await openLix(); + try { + await writeFile(path.join(root, "note.md"), "original"); + const bridge = createMemoryWorkspace(lix, root); + await bridge.importPaths(["note.md"]); + const update = (text) => + lix.execute("UPDATE lix_file SET content = $1 WHERE path = $2", [ + new TextEncoder().encode(text), + "/note.md", + ]); + await update("edited"); + await bridge.flush(); + expect(await readFile(path.join(root, "note.md"), "utf8")).toBe("edited"); + await writeFile(path.join(root, "note.md"), "external"); + await bridge.syncDiskToLix(); + expect( + new TextDecoder().decode( + ( + await lix.execute("SELECT content FROM lix_file WHERE path = $1", [ + "/note.md", + ]) + ).rows[0].content, + ), + ).toBe("external"); + await update("local conflict"); + await writeFile(path.join(root, "note.md"), "new external"); + await expect(bridge.flush()).rejects.toThrow("changed on disk"); + expect(await readFile(path.join(root, "note.md"), "utf8")).toBe( + "new external", + ); + await expect(bridge.importPaths(["../outside.md"])).rejects.toThrow( + "Invalid workspace path", + ); + expect(await readdir(root)).toEqual(["note.md"]); + } finally { + await lix.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/electron/preload.mjs b/electron/preload.mjs index 18a82a0a..eedcea52 100644 --- a/electron/preload.mjs +++ b/electron/preload.mjs @@ -25,6 +25,8 @@ const telemetry = { }; const workspace = { + initializeRepository: () => + ipcRenderer.invoke("workspace:initializeRepository"), get: () => ipcRenderer.invoke("workspace:get"), getRecovery: () => ipcRenderer.invoke("workspace:getRecovery"), clearRecovery: () => ipcRenderer.invoke("workspace:clearRecovery"), @@ -75,6 +77,7 @@ const workspace = { }; const lix = { + executeBatch: (payload) => ipcRenderer.invoke("lix:executeBatch", payload), open: () => ipcRenderer.invoke("lix:open"), workspaceDir: () => ipcRenderer.invoke("lix:workspaceDir"), storageDir: () => ipcRenderer.invoke("lix:storageDir"), diff --git a/electron/types.d.ts b/electron/types.d.ts index 6eb26764..daa501ef 100644 --- a/electron/types.d.ts +++ b/electron/types.d.ts @@ -46,9 +46,12 @@ export type DesktopObserveEvent = { }; export type DesktopLixApi = { + executeBatch(payload: { + statements: ReadonlyArray<{ sql: string; params?: ReadonlyArray }>; + }): Promise; open(): Promise<{ sessionId: string }>; workspaceDir(): Promise; - storageDir(): Promise; + storageDir(): Promise; execute(payload: { sql: string; params?: ReadonlyArray; @@ -317,6 +320,7 @@ export type DesktopWorkspaceApi = { exportLixFile(): Promise; resetLixRepository(): Promise; disableTrackChanges(): Promise; + initializeRepository(): Promise; resolveMarkdownImageSrc(payload: { src: string; sourceFilePath: string; diff --git a/package.json b/package.json index 36bd5cef..8b6efb00 100644 --- a/package.json +++ b/package.json @@ -36,14 +36,15 @@ "ci": "pnpm run build && pnpm test" }, "dependencies": { - "@opral/atelier": "workspace:*", "@base-ui-components/react": "1.0.0-beta.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@glideapps/glide-data-grid": "^6.0.3", "@lix-js/html-diff": "^0.1.0", - "@lix-js/sdk": "0.8.3", + "@lix-js/sdk": "0.15.1", + "@lix-js/storage-filesystem": "0.15.1", + "@opral/atelier": "workspace:*", "@pierre/trees": "1.0.0-beta.4", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 816b8bf6..57ac015f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + '@lix-js/sdk': 0.15.1 prosemirror-model: 1.25.10 prosemirror-transform: 1.12.0 prosemirror-view: 1.42.0 @@ -32,8 +33,11 @@ importers: specifier: ^0.1.0 version: 0.1.0 '@lix-js/sdk': - specifier: 0.8.3 - version: 0.8.3 + specifier: 0.15.1 + version: 0.15.1 + '@lix-js/storage-filesystem': + specifier: 0.15.1 + version: 0.15.1(@lix-js/sdk@0.15.1) '@opral/atelier': specifier: workspace:* version: link:submodule/atelier @@ -227,6 +231,9 @@ importers: '@base-ui/react': specifier: ^1.6.0 version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@codemirror/autocomplete': + specifier: ^6.20.3 + version: 6.20.3 '@codemirror/commands': specifier: ^6.10.4 version: 6.10.4 @@ -242,6 +249,9 @@ importers: '@codemirror/lang-python': specifier: ^6.2.1 version: 6.2.1 + '@codemirror/lang-sql': + specifier: ^6.10.0 + version: 6.10.0 '@codemirror/language': specifier: ^6.12.4 version: 6.12.4 @@ -278,6 +288,9 @@ importers: '@pierre/trees': specifier: 1.0.0-beta.5 version: 1.0.0-beta.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context-menu': + specifier: ^2.3.7 + version: 2.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-dropdown-menu': specifier: ^2.1.20 version: 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -358,8 +371,8 @@ importers: version: 2.8.2 devDependencies: '@lix-js/sdk': - specifier: ^0.8.2 - version: 0.8.2 + specifier: 0.15.1 + version: 0.15.1 '@tailwindcss/vite': specifier: ^4.3.2 version: 4.3.2(vite@8.1.4(@types/node@24.10.15)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -390,6 +403,9 @@ importers: babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 + fflate: + specifier: ^0.8.3 + version: 0.8.3 happy-dom: specifier: ^20.10.6 version: 20.10.6 @@ -681,15 +697,6 @@ packages: '@bufbuild/protobuf@2.10.2': resolution: {integrity: sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==} - '@bytecodealliance/jco-transpile@0.4.2': - resolution: {integrity: sha512-45aeQsUSJsrru1FY1EU47T6kREUkQ7QC1EwIqb5Vi3yakrl6cyqOTRG005k/BWq/fNR0i0n3QtTZQRoF4fi88g==} - - '@bytecodealliance/preview2-shim@0.19.0': - resolution: {integrity: sha512-OSUlcM62fXLuzYP0DjBn/SwjKMZ/y/2PMXZ8v5cRiiFcroauntlPRl/oCnx0xYjYiqdYvE0PY50VCNutll0xOw==} - - '@bytecodealliance/preview3-shim@0.2.0': - resolution: {integrity: sha512-44zSPzlOyF/hWTNK32odet+Jt/nVXgtboSvgvZqMEJKP9GuM+Ln3L6js31WAakDEe+TYgG4iNEBk39rByINZkA==} - '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -769,6 +776,9 @@ packages: '@codemirror/lang-python@6.2.1': resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + '@codemirror/language@6.12.4': resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} @@ -1531,51 +1541,33 @@ packages: '@lix-js/html-diff@0.1.0': resolution: {integrity: sha512-4dr7+xo8UEncWGjne9PoL6KqFGcOHZXyeBTTghTW196kK5xidQ8h4hufjHXYxv6x5VAFzXcBCuFnW1GgPS5kqw==} - '@lix-js/sdk-darwin-arm64@0.8.2': - resolution: {integrity: sha512-biWtEtZT3vwXgMn/NV0gZie4cBK5ksygCQjGgIjoE0Mc2ONmmrfMoeIEjV6kTlzQo20UuAcMTD+L/ORRuUa+vA==} - cpu: [arm64] - os: [darwin] - - '@lix-js/sdk-darwin-arm64@0.8.3': - resolution: {integrity: sha512-tht5deqPDvbEgCQxw6/OPWz8Vp7EVYuuqbQESx2EY3qEjuo56dmARy49cpJBwGeXP/npXWWRvjTv7X7XsGGBlA==} + '@lix-js/sdk-darwin-arm64@0.15.1': + resolution: {integrity: sha512-RTe5hNe8lkF4uZ/ktKQFaXbFtowf617XTLs8Q/lPbAvCN/87A7KkmGyWV2mO8uZyposn/UO8q2NnD2yns8bPPA==} cpu: [arm64] os: [darwin] - '@lix-js/sdk-linux-arm64@0.8.2': - resolution: {integrity: sha512-0atYrjXhgX/Vm8PMaLgmuz/JbBJadqrCL752s8eR0fSLrSLHhNngvG+Hfdxqt4W9yu0y52Oyiwi/PcgFUWzcqQ==} + '@lix-js/sdk-linux-arm64@0.15.1': + resolution: {integrity: sha512-uxyc4NzNGDVyytz8CZblVW++X1JtP9qin28CVyqLKV3iM0CQ/VcUog0WwyEWL7OogvjXilZLJdu1pskDzsRm1A==} cpu: [arm64] os: [linux] - '@lix-js/sdk-linux-arm64@0.8.3': - resolution: {integrity: sha512-ytVELrB4X3oWP2Cxu0KBrwPnXlSISup7/946eZWEBbOF9N5I8dIEw4aE5+MzBL72Tvrupa7ydZyKdCQuRNzyAA==} - cpu: [arm64] - os: [linux] - - '@lix-js/sdk-linux-x64@0.8.2': - resolution: {integrity: sha512-oUhbN1Sm/HFPGq4vRAXnpbwZO9RAltU3WUkW2QZQ52CUugihL68S6ku+XY2QY9mjx6cigzP+mW+H0oy8i0sMPg==} + '@lix-js/sdk-linux-x64@0.15.1': + resolution: {integrity: sha512-gCEN5Ql2JvQn4MpwiILpmFxxO3AHAfmoa4mMvBWFwdKUICaJf5Yzr0zUfHEeLYtvcKpXbpu6dxyZ+AOc8I18ng==} cpu: [x64] os: [linux] - '@lix-js/sdk-linux-x64@0.8.3': - resolution: {integrity: sha512-K/jM/FG/TwxQftzWtTOKuxRf7N6OOJjINs4HLurxlNfhYWQANF4DClZHBHjriqmZYSlZH+Id4orNHRXlUtQwIA==} - cpu: [x64] - os: [linux] - - '@lix-js/sdk-win32-x64@0.8.2': - resolution: {integrity: sha512-QHJh6aQE4FqZixzOlLbXSoe8oYOg4sRHJJTrD9gWajWlyZYZE0Bs38b7GS/EwNjhz5OQzQoiHsXp7P6ViRDcMQ==} - cpu: [x64] - os: [win32] - - '@lix-js/sdk-win32-x64@0.8.3': - resolution: {integrity: sha512-w78WRWZ0JCyO7oIz1qtORnT+er0ySgr9FoF9/RMZE/Hn263HS8DL9tJxU69UDcMg+8qFv3rgOqHxEbfhPRpB9w==} + '@lix-js/sdk-win32-x64@0.15.1': + resolution: {integrity: sha512-hyo2Sj67mBwhMZ2DFO1UgN4barByYiHojUnVQI9WGue5Slva5vBusbecQG1XSXrYv6HkVxLpS2zwUrr7DTjWQg==} cpu: [x64] os: [win32] - '@lix-js/sdk@0.8.2': - resolution: {integrity: sha512-R4ESto7SejuNIttxrxuSxSslJkPg8TXHbwZ6FI6z3Y8/gXUOBYdd9QpDDVbw8IifJpXgfZJITUTgwmmrxi5UZg==} + '@lix-js/sdk@0.15.1': + resolution: {integrity: sha512-mgtPwKg1Etx4NLGADesa+0DNkwVYOrSsIFVhv+VaBGJTisFYMVMucgzYXZQ1XHRpA1jxdFaKhheK3x3tArvv8Q==} - '@lix-js/sdk@0.8.3': - resolution: {integrity: sha512-eocYJKwdsOIYeT8CXDXKjSErIMC0j7IQHtp1p0uzkLzMAFw2pmDhMU9aBrscpcSUzQFWjIG/UnmiQ6wPy1s0zg==} + '@lix-js/storage-filesystem@0.15.1': + resolution: {integrity: sha512-mDbfsq2YFC5vznuurPiWMCTtz+hnPzSx6ita38itOINSRkLl6eVb9PLWYQVL7vXoARXSx4VEQD1yvexPzltm/g==} + peerDependencies: + '@lix-js/sdk': 0.15.1 '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} @@ -1690,125 +1682,6 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@oxc-minify/binding-android-arm-eabi@0.136.0': - resolution: {integrity: sha512-gk1jzky1vLeOcK7w9Ib2RT/UooZ7O21s8YSeXCsy4tzsmguKb3Cbsv/Ra2fKhMp4mKeKCzvC1Xa41OfSBWfWdQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [android] - - '@oxc-minify/binding-android-arm64@0.136.0': - resolution: {integrity: sha512-5GIYanqud/ohXEcg2dsdn0+eSKXqclw5qlRUkvSM1sKEa4ZQ7hqOk8XPJBNPh3LXXw+Qw85B6jkmOFLKsCHp5w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [android] - - '@oxc-minify/binding-darwin-arm64@0.136.0': - resolution: {integrity: sha512-QAMpHKXWOoMCTenSZjQjVT5YmMooujlCKAf1T3PHFl2LuaA6ukQFXba0bgSLgAxRrAeX/itkwi4lzVQXtYmnRg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [darwin] - - '@oxc-minify/binding-darwin-x64@0.136.0': - resolution: {integrity: sha512-rDnoPj8hztiU8C1VNKuzujh7SgTJlgiUvUCiWQjTnVSLsABsLW9ZWd1l1Ji0VN3UW39wGpptgvahIWTrHnGH/A==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [darwin] - - '@oxc-minify/binding-freebsd-x64@0.136.0': - resolution: {integrity: sha512-9fGSgP7uyPLCTjMx9z3AUV4UjnfOVoGTH5JzamLegPwGLJ97Gr1jht1437CRk8ZNabGmxQR6qNn3e+Tox2lswg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [freebsd] - - '@oxc-minify/binding-linux-arm-gnueabihf@0.136.0': - resolution: {integrity: sha512-k0QvZS+x6ROI4O5Ikqn9dDU2dmkEJ46Yh6baZJI/eUk/a1k1GOs9/vyXItLZrKlwYrSgdEx2BFXbKgyd1NyRPw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-minify/binding-linux-arm-musleabihf@0.136.0': - resolution: {integrity: sha512-iRFxUc8ubotVfuyq3e4LPGbHrjvWd1/B3499a55GueMLc1EDJ0fHzNXJiCx2eMQGfzHtHodjmvrJ2BSvSXBNhQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm] - os: [linux] - - '@oxc-minify/binding-linux-arm64-gnu@0.136.0': - resolution: {integrity: sha512-hbKxNCXJsULrb6EyG5SumzJKuPfP8Y0UQ/TElRgXVUhyyPf6cBPlF8HMq+MAylF98ACBzV8jBp2Mm62lJyTo+w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@oxc-minify/binding-linux-arm64-musl@0.136.0': - resolution: {integrity: sha512-T+u/CGKW0UXemJeVux4ix6rK+ug3bQCUEbu5hFlX9K61KQNd44rOcrM15mDK99L7USaXwmiwHeIkEDvmmyDMSA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [linux] - - '@oxc-minify/binding-linux-ppc64-gnu@0.136.0': - resolution: {integrity: sha512-WNPTN2F448tb49BoqjV9IMRzZWa0WB8GHzrB+bnxKQPOyzz07T6rmDKrr4Hjl81ADGjDYSsr1fgXcjyLQFdMdw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ppc64] - os: [linux] - - '@oxc-minify/binding-linux-riscv64-gnu@0.136.0': - resolution: {integrity: sha512-MvcCfu8OeabrmNN9wqhh8Gqizg68RQ3iOvXBJmlLMy5p1PC6az9bAwqOk98lsCmKI4I5LpULekOlfxRYh3o5Kw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - - '@oxc-minify/binding-linux-riscv64-musl@0.136.0': - resolution: {integrity: sha512-rwJXFUFFA0egAULtOOgfW8YhhLhtWrr2qFxftvZ7/U6Xtxiev8i+eCwSNW1RpHiX74QMsJVpRE3ykoefqb1MtQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [riscv64] - os: [linux] - - '@oxc-minify/binding-linux-s390x-gnu@0.136.0': - resolution: {integrity: sha512-qizAxHmj7VHm5/D5qiIlvzxAS2tlmadDOJ5XFI7zx/wfnk/+fdk5iVaHOv+C4tDE/4wLQyMoBYVIs6R1LeN91w==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [s390x] - os: [linux] - - '@oxc-minify/binding-linux-x64-gnu@0.136.0': - resolution: {integrity: sha512-gcyLAad0UblkEhGjVfx5Mwq63OSx8r04IbcfwzpZYpHisO/vioMsrs4GkDsQZ2Ejc8cdUk6YMIrf9FwNBzh+Dw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@oxc-minify/binding-linux-x64-musl@0.136.0': - resolution: {integrity: sha512-QAK6kwBjljX6luYzeppVdRP3gTaFNFfp9iS/V0cCvU43wUZay5xFXBgmzBHYrzZV2MhzURNnXfEZRo9poAxyvA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [linux] - - '@oxc-minify/binding-openharmony-arm64@0.136.0': - resolution: {integrity: sha512-nIGxkTOnN9GwslmooxbZ3Ti2RgbX9QLHy0X9uO0pslOT+4dsNVUawBU/6r4M5cWM+ZdRD2tOF4V+n0l8tS59nQ==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [openharmony] - - '@oxc-minify/binding-wasm32-wasi@0.136.0': - resolution: {integrity: sha512-YB3G8AHT6PpDS8/DfjlJrQjywj+/6gCkNBcAJdYzcYbQRIZE6SaTNowiGuZPR1m1jOl1WjcjefniKSmVxzhvRA==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] - - '@oxc-minify/binding-win32-arm64-msvc@0.136.0': - resolution: {integrity: sha512-UVRH3BlNc2rmla+sbSd5xTF/u+esCqZS6VSWPolZybcBh3TlVe1Sf5MOuHQWUaFoyv5uFOvee4hOfS/14nPvZw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [arm64] - os: [win32] - - '@oxc-minify/binding-win32-ia32-msvc@0.136.0': - resolution: {integrity: sha512-zFGkUd82jci0E+isc2O36lVmmXVUDXyEMN2RYIyez+MBNqhdjPLIGx62FCsrJtLKeuiPf/8XkFv977aUtndUng==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [ia32] - os: [win32] - - '@oxc-minify/binding-win32-x64-msvc@0.136.0': - resolution: {integrity: sha512-VHgPwd5xibC9UYxBeq7nURTjY+wxhdumllij6JaCIUVfD9IRcrA66SVlJ5fPNoUXcRp8ZXhJfoIZwVz9wAEGJw==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [x64] - os: [win32] - '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} @@ -2255,6 +2128,9 @@ packages: '@radix-ui/primitive@1.1.5': resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + '@radix-ui/react-arrow@1.1.11': resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==} peerDependencies: @@ -2268,6 +2144,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.2': resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} peerDependencies: @@ -2313,6 +2202,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -2358,6 +2260,28 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.3.7': + resolution: {integrity: sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-context@1.0.0': resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} peerDependencies: @@ -2390,6 +2314,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-direction@1.0.0': resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==} peerDependencies: @@ -2413,6 +2346,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: @@ -2439,6 +2381,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dismissable-layer@1.1.5': resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==} peerDependencies: @@ -2505,6 +2460,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-focus-scope@1.1.12': resolution: {integrity: sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==} peerDependencies: @@ -2518,6 +2482,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-scope@1.1.2': resolution: {integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==} peerDependencies: @@ -2576,6 +2553,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-menu@2.1.16': resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} peerDependencies: @@ -2602,6 +2588,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.24': + resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.6': resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==} peerDependencies: @@ -2654,6 +2653,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.13': resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==} peerDependencies: @@ -2667,6 +2679,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.4': resolution: {integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==} peerDependencies: @@ -2699,6 +2724,19 @@ packages: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.2': resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==} peerDependencies: @@ -2757,6 +2795,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.3': resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: @@ -2815,6 +2866,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.0.1': resolution: {integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==} peerDependencies: @@ -2847,6 +2911,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-tabs@1.0.2': resolution: {integrity: sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==} peerDependencies: @@ -2911,6 +2984,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.0.0': resolution: {integrity: sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==} peerDependencies: @@ -2934,8 +3016,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-controllable-state@1.2.3': - resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -2961,6 +3052,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.0': resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} peerDependencies: @@ -2988,6 +3088,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.0.0': resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==} peerDependencies: @@ -3020,6 +3129,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.0': resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} peerDependencies: @@ -3047,6 +3165,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-size@1.1.0': resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} peerDependencies: @@ -3074,6 +3201,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-visually-hidden@1.2.3': resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} peerDependencies: @@ -3109,6 +3245,9 @@ packages: '@radix-ui/rect@1.1.2': resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4120,10 +4259,6 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - binaryen@130.0.0: - resolution: {integrity: sha512-XDrb+zql0RbFPKgj7MuH9zOc78R3Fa/P/VSGnnpdwYsvNZPWjcMYMdAkKCOQEL2A7yqgjSMTDRFp6gfSDW+/QQ==} - hasBin: true - bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -5815,10 +5950,6 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} - oxc-minify@0.136.0: - resolution: {integrity: sha512-u9UvPFTYVwQ/i1kpp/c20S/SbvLaLV0Ngzyhgi0YV4B1SlxM7RgnnePl4vY9N3Z9sTRcozL1MRStI3PlUN9RFA==} - engines: {node: ^20.19.0 || >=22.12.0} - oxfmt@0.58.0: resolution: {integrity: sha512-8feG/7NVEHDVwc1OUpP6Pks+TnaDFUw2jLLFIMi5bcmmwxAX2wBQvjSzj62RRTYBf2Op1Wt8xbkmagmPTR5ETg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7477,19 +7608,6 @@ snapshots: '@bufbuild/protobuf@2.10.2': optional: true - '@bytecodealliance/jco-transpile@0.4.2': - dependencies: - '@bytecodealliance/preview2-shim': 0.19.0 - '@bytecodealliance/preview3-shim': 0.2.0 - binaryen: 130.0.0 - oxc-minify: 0.136.0 - - '@bytecodealliance/preview2-shim@0.19.0': {} - - '@bytecodealliance/preview3-shim@0.2.0': - dependencies: - '@bytecodealliance/preview2-shim': 0.19.0 - '@chevrotain/cst-dts-gen@11.0.3': dependencies: '@chevrotain/gast': 11.0.3 @@ -7577,6 +7695,15 @@ snapshots: '@lezer/common': 1.5.2 '@lezer/python': 1.1.19 + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + '@codemirror/language@6.12.4': dependencies: '@codemirror/state': 6.7.1 @@ -8379,51 +8506,30 @@ snapshots: diff: 8.0.4 parse5: 7.3.0 - '@lix-js/sdk-darwin-arm64@0.8.2': - optional: true - - '@lix-js/sdk-darwin-arm64@0.8.3': - optional: true - - '@lix-js/sdk-linux-arm64@0.8.2': - optional: true - - '@lix-js/sdk-linux-arm64@0.8.3': - optional: true - - '@lix-js/sdk-linux-x64@0.8.2': + '@lix-js/sdk-darwin-arm64@0.15.1': optional: true - '@lix-js/sdk-linux-x64@0.8.3': + '@lix-js/sdk-linux-arm64@0.15.1': optional: true - '@lix-js/sdk-win32-x64@0.8.2': + '@lix-js/sdk-linux-x64@0.15.1': optional: true - '@lix-js/sdk-win32-x64@0.8.3': + '@lix-js/sdk-win32-x64@0.15.1': optional: true - '@lix-js/sdk@0.8.2': + '@lix-js/sdk@0.15.1': dependencies: - '@bytecodealliance/jco-transpile': 0.4.2 - '@bytecodealliance/preview2-shim': 0.19.0 fflate: 0.8.3 optionalDependencies: - '@lix-js/sdk-darwin-arm64': 0.8.2 - '@lix-js/sdk-linux-arm64': 0.8.2 - '@lix-js/sdk-linux-x64': 0.8.2 - '@lix-js/sdk-win32-x64': 0.8.2 + '@lix-js/sdk-darwin-arm64': 0.15.1 + '@lix-js/sdk-linux-arm64': 0.15.1 + '@lix-js/sdk-linux-x64': 0.15.1 + '@lix-js/sdk-win32-x64': 0.15.1 - '@lix-js/sdk@0.8.3': + '@lix-js/storage-filesystem@0.15.1(@lix-js/sdk@0.15.1)': dependencies: - '@bytecodealliance/jco-transpile': 0.4.2 - '@bytecodealliance/preview2-shim': 0.19.0 - fflate: 0.8.3 - optionalDependencies: - '@lix-js/sdk-darwin-arm64': 0.8.3 - '@lix-js/sdk-linux-arm64': 0.8.3 - '@lix-js/sdk-linux-x64': 0.8.3 - '@lix-js/sdk-win32-x64': 0.8.3 + '@lix-js/sdk': 0.15.1 '@malept/cross-spawn-promise@2.0.0': dependencies: @@ -8524,70 +8630,6 @@ snapshots: '@opentelemetry/api@1.9.0': optional: true - '@oxc-minify/binding-android-arm-eabi@0.136.0': - optional: true - - '@oxc-minify/binding-android-arm64@0.136.0': - optional: true - - '@oxc-minify/binding-darwin-arm64@0.136.0': - optional: true - - '@oxc-minify/binding-darwin-x64@0.136.0': - optional: true - - '@oxc-minify/binding-freebsd-x64@0.136.0': - optional: true - - '@oxc-minify/binding-linux-arm-gnueabihf@0.136.0': - optional: true - - '@oxc-minify/binding-linux-arm-musleabihf@0.136.0': - optional: true - - '@oxc-minify/binding-linux-arm64-gnu@0.136.0': - optional: true - - '@oxc-minify/binding-linux-arm64-musl@0.136.0': - optional: true - - '@oxc-minify/binding-linux-ppc64-gnu@0.136.0': - optional: true - - '@oxc-minify/binding-linux-riscv64-gnu@0.136.0': - optional: true - - '@oxc-minify/binding-linux-riscv64-musl@0.136.0': - optional: true - - '@oxc-minify/binding-linux-s390x-gnu@0.136.0': - optional: true - - '@oxc-minify/binding-linux-x64-gnu@0.136.0': - optional: true - - '@oxc-minify/binding-linux-x64-musl@0.136.0': - optional: true - - '@oxc-minify/binding-openharmony-arm64@0.136.0': - optional: true - - '@oxc-minify/binding-wasm32-wasi@0.136.0': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - optional: true - - '@oxc-minify/binding-win32-arm64-msvc@0.136.0': - optional: true - - '@oxc-minify/binding-win32-ia32-msvc@0.136.0': - optional: true - - '@oxc-minify/binding-win32-x64-msvc@0.136.0': - optional: true - '@oxc-project/types@0.139.0': {} '@oxfmt/binding-android-arm-eabi@0.58.0': @@ -8880,6 +8922,8 @@ snapshots: '@radix-ui/primitive@1.1.5': {} + '@radix-ui/primitive@1.1.7': {} + '@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -8889,6 +8933,15 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-arrow@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -8929,6 +8982,18 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.0) @@ -8964,6 +9029,25 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-context@1.0.0(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 @@ -8987,6 +9071,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-context@1.2.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-direction@1.0.0(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 @@ -9004,6 +9094,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-direction@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -9030,6 +9126,19 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-dismissable-layer@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -9091,6 +9200,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-focus-scope@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) @@ -9102,6 +9217,17 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-focus-scope@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) @@ -9151,6 +9277,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-id@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -9203,6 +9336,32 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-popover@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.1 @@ -9280,6 +9439,24 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -9290,6 +9467,16 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-portal@1.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -9318,6 +9505,15 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-presence@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) @@ -9363,6 +9559,15 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.0) @@ -9432,6 +9637,25 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-slot@1.0.1(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 @@ -9459,6 +9683,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-slot@1.3.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-tabs@1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 @@ -9536,6 +9767,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-controllable-state@1.0.0(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 @@ -9565,6 +9802,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.2.0)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.0) @@ -9579,6 +9825,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-escape-keydown@1.1.0(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) @@ -9599,6 +9852,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-layout-effect@1.0.0(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 @@ -9622,6 +9881,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-rect@1.1.0(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/rect': 1.1.0 @@ -9643,6 +9908,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-size@1.1.0(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) @@ -9664,6 +9936,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -9688,6 +9967,8 @@ snapshots: '@radix-ui/rect@1.1.2': {} + '@radix-ui/rect@1.1.3': {} + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -10626,8 +10907,6 @@ snapshots: binary-extensions@2.3.0: {} - binaryen@130.0.0: {} - bl@4.1.0: dependencies: buffer: 5.7.1 @@ -12609,29 +12888,6 @@ snapshots: orderedmap@2.1.1: {} - oxc-minify@0.136.0: - optionalDependencies: - '@oxc-minify/binding-android-arm-eabi': 0.136.0 - '@oxc-minify/binding-android-arm64': 0.136.0 - '@oxc-minify/binding-darwin-arm64': 0.136.0 - '@oxc-minify/binding-darwin-x64': 0.136.0 - '@oxc-minify/binding-freebsd-x64': 0.136.0 - '@oxc-minify/binding-linux-arm-gnueabihf': 0.136.0 - '@oxc-minify/binding-linux-arm-musleabihf': 0.136.0 - '@oxc-minify/binding-linux-arm64-gnu': 0.136.0 - '@oxc-minify/binding-linux-arm64-musl': 0.136.0 - '@oxc-minify/binding-linux-ppc64-gnu': 0.136.0 - '@oxc-minify/binding-linux-riscv64-gnu': 0.136.0 - '@oxc-minify/binding-linux-riscv64-musl': 0.136.0 - '@oxc-minify/binding-linux-s390x-gnu': 0.136.0 - '@oxc-minify/binding-linux-x64-gnu': 0.136.0 - '@oxc-minify/binding-linux-x64-musl': 0.136.0 - '@oxc-minify/binding-openharmony-arm64': 0.136.0 - '@oxc-minify/binding-wasm32-wasi': 0.136.0 - '@oxc-minify/binding-win32-arm64-msvc': 0.136.0 - '@oxc-minify/binding-win32-ia32-msvc': 0.136.0 - '@oxc-minify/binding-win32-x64-msvc': 0.136.0 - oxfmt@0.58.0: dependencies: tinypool: 2.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09d2a638..1df9a1c0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: # Keep ProseMirror nominal types single-instance across FlashType and Atelier. overrides: + "@lix-js/sdk": 0.15.1 prosemirror-model: 1.25.10 prosemirror-transform: 1.12.0 prosemirror-view: 1.42.0 diff --git a/src/extensions/atelier-host-extensions.test.ts b/src/extensions/atelier-host-extensions.test.ts index 43299bb9..d178cd4a 100644 --- a/src/extensions/atelier-host-extensions.test.ts +++ b/src/extensions/atelier-host-extensions.test.ts @@ -1,13 +1,19 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { createFlashTypeAtelierExtensions } from "./atelier-host-extensions"; describe("createFlashTypeAtelierExtensions", () => { - test("registers only the agent terminals (Files is atelier's bundled view)", () => { + test("registers agent terminals and the host-composed History view", () => { const extensions = createFlashTypeAtelierExtensions(); expect(extensions.map((extension) => extension.manifest.id)).toEqual([ "flashtype_claude", "flashtype_codex", + "atelier_history", ]); }); }); + +vi.mock("@opral/atelier", () => ({ + Atelier: { History: () => null }, + ATELIER_BUILTIN_EXTENSION_IDS: { history: "atelier_history" }, +})); diff --git a/src/extensions/atelier-host-extensions.ts b/src/extensions/atelier-host-extensions.ts index c977e73d..a630f74b 100644 --- a/src/extensions/atelier-host-extensions.ts +++ b/src/extensions/atelier-host-extensions.ts @@ -1,3 +1,4 @@ +import { createHistoryExtension } from "./history/host-extension"; import type { AtelierExtensionRegistration } from "@opral/atelier"; import { FLASHTYPE_ATELIER_EXTENSIONS as TERMINAL_EXTENSIONS } from "./terminal/host-extensions"; @@ -6,6 +7,11 @@ import { FLASHTYPE_ATELIER_EXTENSIONS as TERMINAL_EXTENSIONS } from "./terminal/ * extension (transient workspaces feed it watched disk entries through * `createAtelier({ filesView })`). */ -export function createFlashTypeAtelierExtensions(): readonly AtelierExtensionRegistration[] { - return [...TERMINAL_EXTENSIONS]; +export function createFlashTypeAtelierExtensions( + options: { temporaryHistory?: boolean } = {}, +): readonly AtelierExtensionRegistration[] { + return [ + ...TERMINAL_EXTENSIONS, + createHistoryExtension(options.temporaryHistory === true), + ]; } diff --git a/src/extensions/csv/index.reactive.test.tsx b/src/extensions/csv/index.reactive.test.tsx index 04862397..f3fdb2af 100644 --- a/src/extensions/csv/index.reactive.test.tsx +++ b/src/extensions/csv/index.reactive.test.tsx @@ -281,5 +281,5 @@ async function activeCommitId(lix: Awaited>) { const result = await lix.execute( "SELECT lix_active_branch_commit_id() AS commit_id", ); - return result.rows[0]?.get("commit_id") as string; + return result.rows[0]?.["commit_id"] as string; } diff --git a/src/extensions/history/host-extension.test.tsx b/src/extensions/history/host-extension.test.tsx new file mode 100644 index 00000000..82d33c83 --- /dev/null +++ b/src/extensions/history/host-extension.test.tsx @@ -0,0 +1,53 @@ +import { act, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import type { + AtelierExtensionRuntime, + AtelierMountedExtension, +} from "@opral/atelier"; +import { createHistoryExtension } from "./host-extension"; + +vi.mock("@opral/atelier", () => ({ + ATELIER_BUILTIN_EXTENSION_IDS: { history: "atelier_history" }, + Atelier: { + History: ({ atelier }: { atelier: { diff: { autoAccept: boolean } } }) => ( +
{String(atelier.diff.autoAccept)}
+ ), + }, +})); + +for (const temporary of [true, false]) { + test(`composes History with temporary=${temporary} and forwards runtime updates`, async () => { + const element = document.createElement("div"); + document.body.append(element); + const runtime = { diff: { autoAccept: false } } as AtelierExtensionRuntime; + const registration = createHistoryExtension(temporary); + let mounted: AtelierMountedExtension | void; + const args = { + element, + atelier: runtime, + signal: new AbortController().signal, + view: {} as Parameters[0]["view"], + }; + try { + await act(async () => { + mounted = registration.entry.mount(args); + }); + expect(screen.getByTestId("timeline")).toHaveTextContent("false"); + expect( + screen.queryByRole("button", { name: "Initialize repository" }) !== + null, + ).toBe(temporary); + await act(async () => { + mounted?.update?.({ + ...args, + atelier: { ...runtime, diff: { ...runtime.diff!, autoAccept: true } }, + }); + }); + expect(screen.getByTestId("timeline")).toHaveTextContent("true"); + } finally { + await act(async () => mounted?.dispose?.()); + expect(element.childElementCount).toBe(0); + element.remove(); + } + }); +} diff --git a/src/extensions/history/host-extension.tsx b/src/extensions/history/host-extension.tsx new file mode 100644 index 00000000..42ad0c98 --- /dev/null +++ b/src/extensions/history/host-extension.tsx @@ -0,0 +1,48 @@ +import { createRoot } from "react-dom/client"; +import { History } from "lucide-react"; +import { + Atelier, + ATELIER_BUILTIN_EXTENSION_IDS, + type AtelierExtensionRegistration, + type AtelierExtensionRuntime, +} from "@opral/atelier"; +import { TemporaryHistoryNotice } from "@/shell/temporary-history-notice"; + +/** Flashtype owns persistence messaging; Atelier owns the timeline and reviews. */ +export function createHistoryExtension( + temporary: boolean, +): AtelierExtensionRegistration { + return { + manifest: { + apiVersion: 1, + id: ATELIER_BUILTIN_EXTENSION_IDS.history, + name: "History", + placement: ["left", "right", "central"], + }, + entry: { + icon: History, + mount: ({ element, atelier }) => { + const root = createRoot(element); + const render = (runtime: AtelierExtensionRuntime) => { + if (!runtime.diff) + throw new Error("History requires the shell's diff runtime"); + root.render( +
+ {temporary ? ( +
+ +
+ ) : null} + +
, + ); + }; + render(atelier); + return { + update: ({ atelier }) => render(atelier), + dispose: () => root.unmount(), + }; + }, + }, + }; +} diff --git a/src/extensions/markdown/editor/tip-tap-editor.tsx b/src/extensions/markdown/editor/tip-tap-editor.tsx index 06ace7b6..8aa35504 100644 --- a/src/extensions/markdown/editor/tip-tap-editor.tsx +++ b/src/extensions/markdown/editor/tip-tap-editor.tsx @@ -445,9 +445,9 @@ function TipTapEditorLoadedContent({ continue; } const nextMarkdown = normalizePersistedMarkdown( - decodeMarkdownData(firstRow.get("data")), + decodeMarkdownData(firstRow["data"]), ); - const observedOriginKey = firstRow.get("origin_key"); + const observedOriginKey = firstRow["origin_key"]; const currentMarkdown = buildNormalizedMarkdownFromEditor(editor); if (!sawInitialSnapshot) { sawInitialSnapshot = true; diff --git a/src/extensions/markdown/index.test.tsx b/src/extensions/markdown/index.test.tsx index 461cc6cd..e7828a4f 100644 --- a/src/extensions/markdown/index.test.tsx +++ b/src/extensions/markdown/index.test.tsx @@ -622,5 +622,5 @@ async function activeCommitId(lix: Awaited>) { const result = await lix.execute( "SELECT lix_active_branch_commit_id() AS commit_id", ); - return result.rows[0]?.get("commit_id") as string; + return result.rows[0]?.["commit_id"] as string; } diff --git a/src/extensions/markdown/index.tsx b/src/extensions/markdown/index.tsx index 59f24920..4832f607 100644 --- a/src/extensions/markdown/index.tsx +++ b/src/extensions/markdown/index.tsx @@ -691,9 +691,7 @@ function historicalMarkdownBlocksQuery( compile: () => ({ sql, parameters }), execute: async () => { const result = await lix.execute(sql, parameters); - return result.rows.map( - (row) => row.toObject() as HistoricalMarkdownNodeRow, - ); + return result.rows.map((row) => row as HistoricalMarkdownNodeRow); }, }; } diff --git a/src/extensions/markdown/render-review-diff-html.test.ts b/src/extensions/markdown/render-review-diff-html.test.ts index 0ba20446..413c0cf5 100644 --- a/src/extensions/markdown/render-review-diff-html.test.ts +++ b/src/extensions/markdown/render-review-diff-html.test.ts @@ -851,14 +851,14 @@ async function fileIdByPath(lix: Lix, path: string): Promise { const result = await lix.execute("SELECT id FROM lix_file WHERE path = ?", [ path, ]); - const id = result.rows[0]?.get("id"); + const id = result.rows[0]?.["id"]; if (typeof id !== "string") throw new Error(`Missing file id for ${path}`); return id; } async function activeCommitId(lix: Lix): Promise { const result = await lix.execute("SELECT lix_active_branch_commit_id()"); - const commitId = result.rows[0]?.get("lix_active_branch_commit_id()"); + const commitId = result.rows[0]?.["lix_active_branch_commit_id()"]; if (typeof commitId !== "string") { throw new Error("Missing active branch commit id"); } @@ -896,7 +896,7 @@ async function historicalMarkdownBlocks( historicalMarkdownNodeBlocks( result.rows.map((row) => ({ start_commit_id: commitId, - snapshot_content: row.get("snapshot_content"), + snapshot_content: row["snapshot_content"], })), commitId, markdown, diff --git a/src/extensions/terminal/host-extensions.tsx b/src/extensions/terminal/host-extensions.tsx index aa5f28a9..786fec6e 100644 --- a/src/extensions/terminal/host-extensions.tsx +++ b/src/extensions/terminal/host-extensions.tsx @@ -1,6 +1,7 @@ import { createRoot } from "react-dom/client"; import type { AtelierExtensionRegistration, + AtelierDiffApi, ExtensionManifest, ExtensionRuntimeEntry, } from "@opral/atelier"; @@ -16,6 +17,19 @@ import { TerminalView } from "./index"; import claudeManifestJson from "./claude.manifest.json"; import codexManifestJson from "./codex.manifest.json"; +const activeDiffs = new Set(); +export const agentDiffBridge = { + async open(options: { beforeCommitId: string; afterCommitId: string }) { + const diff = [...activeDiffs].at(-1); + if (!diff) throw new Error("Agent review runtime is unavailable"); + await diff.open({ + base: { commitId: options.beforeCommitId }, + target: { commitId: options.afterCommitId }, + reveal: true, + }); + }, +}; + const claudeManifest = claudeManifestJson as ExtensionManifest; const codexManifest = codexManifestJson as ExtensionManifest; @@ -37,13 +51,20 @@ function createAgentExtension(args: { readonly agent: "claude" | "codex"; readonly icon: typeof ClaudeIcon; }): AtelierExtensionRegistration { - const mount: ExtensionRuntimeEntry["mount"] = ({ element }) => { + const mount: ExtensionRuntimeEntry["mount"] = ({ + element, + atelier: runtime, + }) => { + if (runtime.diff) activeDiffs.add(runtime.diff); const root = createRoot(element); root.render( , ); return { - dispose: () => root.unmount(), + dispose: () => { + if (runtime.diff) activeDiffs.delete(runtime.diff); + root.unmount(); + }, }; }; return { diff --git a/src/lib/agent-turn-review-bridge.test.ts b/src/lib/agent-turn-review-bridge.test.ts index adebaa06..c258f866 100644 --- a/src/lib/agent-turn-review-bridge.test.ts +++ b/src/lib/agent-turn-review-bridge.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import type { Lix, LixRuntimeQueryResult } from "@/lib/lix-types"; -import type { AtelierInstance } from "@opral/atelier"; +import type { AgentReviewHost as AtelierInstance } from "./agent-turn-review-bridge"; import { createAgentTurnReviewHandler } from "./agent-turn-review-bridge"; diff --git a/src/lib/agent-turn-review-bridge.ts b/src/lib/agent-turn-review-bridge.ts index 4f44e1de..581a563c 100644 --- a/src/lib/agent-turn-review-bridge.ts +++ b/src/lib/agent-turn-review-bridge.ts @@ -1,4 +1,13 @@ -import type { AtelierInstance } from "@opral/atelier"; +export type AgentReviewHost = { + lix: Lix; + diff: { + open(options: { + beforeCommitId: string; + afterCommitId: string; + source?: { id: string; sessionId?: string; turnId?: string }; + }): Promise; + }; +}; import type { Lix } from "@/lib/lix-types"; import { buildFlashtypeActiveFilePrompt } from "@/shell/agent-launch"; import { readCurrentAtelierDocumentPath } from "./atelier-document-state"; @@ -40,7 +49,7 @@ type AgentTurnEventHandler = ( * active-document context on turn start and a diff review on turn stop. */ export function createAgentTurnReviewHandler( - atelier: AtelierInstance, + atelier: AgentReviewHost, options: AgentTurnReviewOptions = {}, ) { return composeAgentTurnEventHandlers( @@ -50,7 +59,7 @@ export function createAgentTurnReviewHandler( } function createAgentTurnDiffHandler( - atelier: AtelierInstance, + atelier: AgentReviewHost, fileCapture?: AgentTurnFileCaptureApi, ): AgentTurnEventHandler { const activeTurns = new Map(); @@ -105,7 +114,7 @@ function createAgentTurnDiffHandler( } async function captureAgentTurnStart( - atelier: AtelierInstance, + atelier: AgentReviewHost, fileCapture: AgentTurnFileCaptureApi | undefined, captureId: string, ): Promise { @@ -123,7 +132,7 @@ async function captureAgentTurnStart( } async function importAgentTurnTouchedPaths( - atelier: AtelierInstance, + atelier: AgentReviewHost, fileCapture: AgentTurnFileCaptureApi | undefined, captureId: string, ): Promise { @@ -139,7 +148,7 @@ async function importAgentTurnTouchedPaths( } async function importAgentTurnPaths( - atelier: AtelierInstance, + atelier: AgentReviewHost, paths: readonly string[], kind: "baseline" | "touched", ): Promise { @@ -170,7 +179,7 @@ async function importAgentTurnPaths( } function createActiveDocumentContextHandler( - atelier: AtelierInstance, + atelier: AgentReviewHost, ): AgentTurnEventHandler { return async (event) => { if (event.phase !== "turn-start") return; @@ -208,7 +217,7 @@ export function composeAgentTurnEventHandlers( } async function readSyncedActiveCommitId( - atelier: AtelierInstance, + atelier: AgentReviewHost, ): Promise { // The desktop bridge exposes filesystem synchronization in addition to the // public Lix surface Atelier consumes. diff --git a/src/lib/atelier-document-state.ts b/src/lib/atelier-document-state.ts index ec816a7d..f31238fd 100644 --- a/src/lib/atelier-document-state.ts +++ b/src/lib/atelier-document-state.ts @@ -143,7 +143,7 @@ function readResultValue( const row = result.rows[0]; if (!row) return undefined; if (Array.isArray(row)) { - const index = result.columns.indexOf(column); + const index = result.columns.findIndex((entry) => entry.name === column); return index >= 0 ? row[index] : undefined; } if (typeof (row as { get?: unknown }).get === "function") { diff --git a/src/lib/lix-client.ts b/src/lib/lix-client.ts index 78118c50..a780a4ce 100644 --- a/src/lib/lix-client.ts +++ b/src/lib/lix-client.ts @@ -6,7 +6,6 @@ import type { } from "@lix-js/sdk"; import type { Lix, - LixRow, LixRuntimeQueryResult, LixExecuteOptions, ObserveEvent, @@ -272,6 +271,12 @@ export async function openDesktopLix(): Promise { }; const lix = { + async executeBatch(statements: ReadonlyArray) { + ensureOpen("executeBatch"); + return ( + await runQueued(() => desktop.lix.executeBatch({ statements })) + ).map(toRuntimeQueryResult); + }, execute, beginTransaction, transaction, @@ -338,84 +343,13 @@ function toRuntimeQueryResult(result: { }>; }): LixRuntimeQueryResult { return { - rows: result.rows.map((row) => new DesktopRow(result.columns, row)), - columns: result.columns, + rows: result.rows.map((row) => + Object.fromEntries( + result.columns.map((column, index) => [column, row[index]]), + ), + ), + columns: result.columns.map((name) => ({ name, type: "null" as const })), rowsAffected: result.rowsAffected ?? 0, notices: result.notices ?? [], }; } - -type LixValueLike = ReturnType; -type LixValueKind = LixValueLike["kind"]; - -class DesktopRow implements LixRow { - constructor( - private readonly columns: string[], - private readonly values: unknown[], - ) {} - - get(column: string): unknown { - return this.value(column).toJS(); - } - - value(column: string): LixValueLike { - const index = this.columns.indexOf(column); - if (index === -1) { - throw new Error( - `Unknown column "${column}". Available columns: ${this.columns.join(", ")}`, - ); - } - return new DesktopValue(this.values[index]); - } - - toObject(): Record { - return Object.fromEntries( - this.columns.map((column, index) => [ - column, - new DesktopValue(this.values[index]).toJS(), - ]), - ); - } - - toValueMap(): Record { - return Object.fromEntries( - this.columns.map((column, index) => [ - column, - new DesktopValue(this.values[index]), - ]), - ); - } -} - -class DesktopValue implements LixValueLike { - readonly kind: LixValueKind; - - constructor(private readonly raw: unknown) { - this.kind = valueKind(raw); - } - - toJS(): unknown { - if (this.raw instanceof Uint8Array) { - return new Uint8Array(this.raw); - } - return this.raw; - } - - asBytes(): Uint8Array | undefined { - if (!(this.raw instanceof Uint8Array)) { - return undefined; - } - return new Uint8Array(this.raw); - } -} - -function valueKind(value: unknown): LixValueKind { - if (value === null) return "null"; - if (typeof value === "boolean") return "boolean"; - if (typeof value === "string") return "text"; - if (typeof value === "number") { - return Number.isSafeInteger(value) ? "integer" : "real"; - } - if (value instanceof Uint8Array) return "blob"; - return "json"; -} diff --git a/src/lib/lix-kysely.ts b/src/lib/lix-kysely.ts index 62d8a14e..18445588 100644 --- a/src/lib/lix-kysely.ts +++ b/src/lib/lix-kysely.ts @@ -53,9 +53,17 @@ class LixConnection implements DatabaseConnection { ); const columnNames = raw.columns.length > 0 - ? raw.columns + ? raw.columns.map((column) => + typeof column === "string" ? column : column.name, + ) : columnNamesFromQueryNode(compiledQuery.query); - const decodedRows = decodeRows(raw.rows, columnNames ?? raw.columns); + const decodedRows = decodeRows( + raw.rows, + columnNames ?? + raw.columns.map((column) => + typeof column === "string" ? column : column.name, + ), + ); const rows = columnNames && decodedRows.every((row) => row.length === columnNames.length) @@ -187,6 +195,9 @@ class LixDriver implements Driver { } class LixQueryCompiler extends SqliteQueryCompiler { + protected override getCurrentParameterPlaceholder(): string { + return `$${this.numParameters}`; + } protected override getLeftIdentifierWrapper(): string { return ""; } @@ -228,7 +239,11 @@ function decodeRows( rows: ExecuteResult["rows"], columns: string[], ): unknown[][] { - return rows.map((row) => columns.map((column) => row.get(column))); + return rows.map((row) => + columns.map((column) => + typeof row.get === "function" ? row.get(column) : row[column], + ), + ); } function extractIntegerValue(value: unknown): bigint | undefined { diff --git a/src/lib/lix-react.test.tsx b/src/lib/lix-react.test.tsx index 07c2eeb8..290afefb 100644 --- a/src/lib/lix-react.test.tsx +++ b/src/lib/lix-react.test.tsx @@ -56,10 +56,10 @@ test("useQuery applies the first observe snapshot over the initial read", async sequence: 1, mutationSequence: 1, result: { - columns: ["value"], + columns: [{ name: "value", type: "jsonb" }], rows: [ { - toObject: () => ({ value: "fresh" }), + value: "fresh", }, ] as unknown as ObserveEvent["result"]["rows"], rowsAffected: 0, diff --git a/src/lib/lix-react.tsx b/src/lib/lix-react.tsx index 92e54f3c..017eec11 100644 --- a/src/lib/lix-react.tsx +++ b/src/lib/lix-react.tsx @@ -144,11 +144,14 @@ export const useQueryTakeFirstOrThrow = ( function queryResultToRows(result: { rows?: ReadonlyArray<{ - toObject(): Record; + [key: string]: unknown; }>; }): TRow[] { const rows = Array.isArray(result?.rows) ? result.rows : []; - return rows.map((row) => row.toObject() as TRow); + return rows.map( + (row) => + (typeof row.toObject === "function" ? row.toObject() : row) as TRow, + ); } function rowsEqual(a: unknown, b: unknown): boolean { diff --git a/src/main.tsx b/src/main.tsx index 417b454c..2d4d3b29 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,9 +10,9 @@ import { import { createRoot } from "react-dom/client"; import { Atelier, - createAtelier, createMemorySessionStateStore, - type AtelierInstance, + type AtelierShellHandle, + type AtelierShellProps, type AtelierExtensionRegistration, type AtelierSessionUiState, } from "@opral/atelier"; @@ -29,6 +29,7 @@ import { activatePostHogRecording, syncPostHogWorkspaceContext, } from "./lib/posthog-client"; +import { agentDiffBridge } from "./extensions/terminal/host-extensions"; import { createFlashTypeAtelierExtensions } from "./extensions/atelier-host-extensions"; import { createEphemeralFilesViewOptions } from "./lib/atelier-files-view"; import { @@ -76,11 +77,13 @@ export const AppRoot = () => { >(undefined); const [isUpdateReady, setIsUpdateReady] = useState(false); const [connectedAtelier, setConnectedAtelier] = - useState(null); + useState(null); const atelierExtensions = useMemo( () => workspace - ? createFlashTypeAtelierExtensions() + ? createFlashTypeAtelierExtensions({ + temporaryHistory: workspace.ephemeral === true, + }) : ([] as readonly AtelierExtensionRegistration[]), [workspace], ); @@ -118,29 +121,7 @@ export const AppRoot = () => { } }; }, [atelierSessionStateStore]); - const atelier = useMemo( - () => - lix - ? createAtelier({ - // FlashType's renderer-side Lix proxy implements Atelier's runtime - // contract but intentionally hides native SDK internals. - lix: lix as unknown as AtelierInstance["lix"], - extensions: atelierExtensions, - ...(atelierFilesView ? { filesView: atelierFilesView } : {}), - defaultOpenPanels: defaultOpenAtelierPanels, - sessionStateStore: atelierSessionStateStore, - onEvent: handleAtelierEvent, - }) - : null, - [ - lix, - atelierExtensions, - atelierFilesView, - defaultOpenAtelierPanels, - atelierSessionStateStore, - handleAtelierEvent, - ], - ); + const [atelier, setAtelier] = useState(null); useEffect(() => { void activatePostHogRecording(); @@ -323,9 +304,12 @@ export const AppRoot = () => { useEffect(() => { if (!lix || !atelier) return; const handlePromptTelemetry = createAgentPromptTelemetryHandler(lix); - const handleAgentTurnReview = createAgentTurnReviewHandler(atelier, { - fileCapture: window.flashtypeDesktop?.workspace, - }); + const handleAgentTurnReview = createAgentTurnReviewHandler( + { lix, diff: agentDiffBridge }, + { + fileCapture: window.flashtypeDesktop?.workspace, + }, + ); const unsubscribe = window.flashtypeDesktop?.agentHooks?.onTurnEvent( (event) => { handlePromptTelemetry(event); @@ -351,7 +335,7 @@ export const AppRoot = () => { /> ); } - if (!lix || !atelier) { + if (!lix) { return ( { return ( }>
- { const result = await lix.execute( "SELECT lix_active_branch_commit_id() AS commit_id", ); - const commitId = result.rows[0]?.get("commit_id"); + const commitId = result.rows[0]?.["commit_id"]; if (typeof commitId !== "string") { throw new Error("Missing active commit id"); } diff --git a/src/shell/first-run-screen.tsx b/src/shell/first-run-screen.tsx index fca2af05..83004e63 100644 --- a/src/shell/first-run-screen.tsx +++ b/src/shell/first-run-screen.tsx @@ -115,6 +115,9 @@ export function FirstRunScreen({
+
+ No folder open +
); } diff --git a/src/shell/temporary-history-notice.tsx b/src/shell/temporary-history-notice.tsx new file mode 100644 index 00000000..c4a118c3 --- /dev/null +++ b/src/shell/temporary-history-notice.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; + +export function TemporaryHistoryNotice() { + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + async function initialize() { + if (pending) return; + setPending(true); + setError(null); + try { + const workspace = window.flashtypeDesktop?.workspace; + if (!workspace) + throw new Error( + "Open Flashtype on desktop to initialize a repository.", + ); + await workspace.initializeRepository(); + } catch (error) { + setError( + error instanceof Error + ? error.message + : "Could not initialize the repository. Try again.", + ); + setPending(false); + } + } + return ( +
+

+ Keep history after you close +

+

+ History is temporary. Initialize a repository to save future history in + this folder. Your files are already saved. +

+ + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/src/test-utils/node-lix-sdk.test.ts b/src/test-utils/node-lix-sdk.test.ts index 9809a335..71b8b1fa 100644 --- a/src/test-utils/node-lix-sdk.test.ts +++ b/src/test-utils/node-lix-sdk.test.ts @@ -20,7 +20,7 @@ test("observe returns the current result snapshot until next is called", async ( const event = await withTimeout(events.next(), 2_000); expect(event?.result.columns).toEqual(["value"]); - expect(event?.result.rows.map((row) => [row.get("value")])).toEqual([ + expect(event?.result.rows.map((row) => [row["value"]])).toEqual([ ["current"], ]); } finally { diff --git a/src/test-utils/node-lix-sdk.ts b/src/test-utils/node-lix-sdk.ts index 73a27c9f..065f00e2 100644 --- a/src/test-utils/node-lix-sdk.ts +++ b/src/test-utils/node-lix-sdk.ts @@ -1,3 +1,4 @@ +import { FilesystemStorage } from "@lix-js/storage-filesystem"; import { createRequire } from "node:module"; import type { BundledPluginArchive, @@ -34,7 +35,7 @@ export async function openLix(options: OpenTestLixOptions = {}): Promise { const sdk = await loadSdk(); const sdkLix = await sdk.openLix(sdkOptions); const localFilesystem = - sdkOptions.storage instanceof sdk.LocalFilesystem + sdkOptions.storage instanceof FilesystemStorage ? sdkOptions.storage : undefined; const lix = createTestLixAdapter(sdkLix, localFilesystem); @@ -94,7 +95,7 @@ async function seedKeyValues( function createTestLixAdapter( sdkLix: SdkLix, - localFilesystem?: InstanceType, + localFilesystem?: FilesystemStorage, ): Lix { const observations = new Set(); let closing = false; diff --git a/submodule/atelier b/submodule/atelier index 29e5e4a3..82ae9843 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit 29e5e4a3f915c1c1b02bd9602b976e174f234033 +Subproject commit 82ae984307404fa4af87b67573adf29d8b370885 From 7b2acdc255f2d777a8bcd597ced6658179e8fdf0 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:40:27 -0700 Subject: [PATCH 02/12] Add account-token sharing and update the Lix workspace integration --- NEXT_RELEASE.md | 4 + README.md | 91 ++ e2e/agent-review.spec.ts | 11 +- e2e/csv-file-opening.spec.ts | 45 + e2e/electron-test-utils.ts | 21 +- e2e/files-click-tour.spec.ts | 2 +- e2e/markdown-editor-fuzz.spec.ts | 169 +-- e2e/packaged-macos.spec.ts | 4 +- e2e/repository-limits.spec.ts | 85 ++ e2e/share.spec.ts | 49 + e2e/status-review.spec.ts | 91 ++ e2e/workspace-change-stress.spec.ts | 72 +- e2e/workspace-open-recovery.spec.ts | 158 +++ e2e/workspace-switch.spec.ts | 2 +- e2e/workspace-windows.spec.ts | 31 +- electron-builder.yml | 7 + electron/dev-sync.mjs | 20 + electron/dev-sync.test.mjs | 29 + ...dler.mjs => document-default-handlers.mjs} | 45 +- ...t.ts => document-default-handlers.test.ts} | 119 ++- electron/initialize-checkpoint.test.mjs | 2 +- electron/ipc-lix.mjs | 6 +- electron/lix-lifecycle.test.mjs | 7 + electron/lix.mjs | 46 +- electron/main.mjs | 94 +- electron/preload.mjs | 14 + electron/repository-limits.mjs | 51 + electron/repository-limits.test.mjs | 120 +++ electron/share-auth.mjs | 48 + electron/share-auth.test.mjs | 46 + electron/share-http.mjs | 47 + electron/share-ipc.mjs | 56 + electron/share-runtime.mjs | 63 ++ electron/share-service.mjs | 182 ++++ electron/share-service.test.mjs | 118 +++ electron/share-store.mjs | 72 ++ electron/share-store.test.mjs | 45 + electron/types.d.ts | 29 +- electron/workspace-open-preflight.mjs | 19 + electron/workspace-open-preflight.test.mjs | 47 + electron/workspace-open-recovery.mjs | 56 + electron/workspace-open-recovery.test.mjs | 70 ++ electron/workspace.mjs | 2 + package.json | 8 +- pnpm-lock.yaml | 69 +- pnpm-workspace.yaml | 13 +- scripts/dev-sync.mjs | 76 ++ scripts/dev.mjs | 64 +- .../editor-revision-state.ts | 73 -- .../external-write-review-controls.css | 74 -- .../external-write-review-controls.tsx | 81 -- .../external-write-review.ts | 13 - src/extension-runtime/file-handlers.test.ts | 16 - src/extension-runtime/file-handlers.ts | 11 - .../atelier-host-extensions.test.ts | 2 +- src/extensions/csv/csv-data.ts | 60 -- src/extensions/csv/index.reactive.test.tsx | 285 ----- src/extensions/csv/index.test.ts | 85 -- src/extensions/csv/index.tsx | 732 ------------- src/extensions/csv/render-review-diff-html.ts | 92 -- src/extensions/csv/style.css | 77 -- .../history/host-extension.test.tsx | 55 +- src/extensions/history/host-extension.tsx | 59 +- .../components/formatting-toolbar.test.tsx | 448 -------- .../components/formatting-toolbar.tsx | 684 ------------ .../components/slash-command-menu.tsx | 266 ----- .../diff-fixtures/block-media-link.after.md | 23 - .../diff-fixtures/block-media-link.before.md | 22 - .../block-structure-moves.after.md | 43 - .../block-structure-moves.before.md | 40 - .../duplicate-task-list.after.md | 13 - .../duplicate-task-list.before.md | 12 - .../diff-fixtures/gfm-edge-blocks.after.md | 16 - .../diff-fixtures/gfm-edge-blocks.before.md | 15 - .../diff-fixtures/gfm-table-inline.after.md | 6 - .../diff-fixtures/gfm-table-inline.before.md | 6 - .../diff-fixtures/gfm-table-rows.after.md | 7 - .../diff-fixtures/gfm-table-rows.before.md | 7 - .../gfm-table-structure.after.md | 8 - .../gfm-table-structure.before.md | 7 - .../markdown/diff-fixtures/index.ts | 820 --------------- .../diff-fixtures/inline-media-link.after.md | 22 - .../diff-fixtures/inline-media-link.before.md | 22 - .../diff-fixtures/mixed-gfm-document.after.md | 21 - .../mixed-gfm-document.before.md | 18 - .../diff-fixtures/plan-table.after.md | 5 - .../diff-fixtures/plan-table.before.md | 5 - .../diff-fixtures/quick-facts-list.after.md | 5 - .../diff-fixtures/quick-facts-list.before.md | 4 - .../diff-fixtures/release-checklist.after.md | 11 - .../diff-fixtures/release-checklist.before.md | 10 - .../markdown/diff-fixtures/visual.html | 12 - .../markdown/diff-fixtures/visual.ts | 57 - .../markdown/editor/assemble-md-ast.test.ts | 37 - .../markdown/editor/assemble-md-ast.ts | 33 - .../markdown/editor/block-commands.ts | 241 ----- .../editor/build-markdown-from-editor.ts | 48 - .../markdown/editor/create-editor.test.ts | 866 ---------------- .../markdown/editor/create-editor.ts | 284 ----- .../editor/decode-markdown-data.test.ts | 33 - .../markdown/editor/decode-markdown-data.ts | 119 --- .../markdown/editor/desktop-workspace.ts | 61 -- .../markdown/editor/editor-context.tsx | 27 - .../editor/extensions/slash-commands.ts | 206 ---- .../editor/extensions/table-navigation.ts | 50 - .../markdown/editor/handle-paste.test.ts | 284 ----- .../markdown/editor/handle-paste.ts | 50 - .../editor/markdown-editor-fuzz-harness.tsx | 97 -- .../markdown/editor/markdown-editor-fuzz.ts | 555 ---------- .../markdown/editor/markdown.test.ts | 290 ------ src/extensions/markdown/editor/markdown.ts | 293 ------ .../markdown/editor/normalize-url.test.ts | 48 - .../markdown/editor/normalize-url.ts | 42 - .../markdown/editor/render-markdown-html.ts | 52 - .../editor/tip-tap-editor.fuzz.test.tsx | 224 ---- .../markdown/editor/tip-tap-editor.test.tsx | 767 -------------- .../markdown/editor/tip-tap-editor.tsx | 580 ----------- .../assign-data-id.test.ts | 184 ---- .../tiptap-markdown-bridge/assign-data-id.ts | 81 -- .../default-block.test.ts | 54 - .../editor/tiptap-markdown-bridge/index.ts | 9 - .../tiptap-markdown-bridge/markdown-wc.ts | 54 - .../tiptap-markdown-bridge/mdwc-to-tiptap.ts | 361 ------- .../mermaid-code-block-node-view.ts | 273 ----- .../tiptap-markdown-bridge/mermaid-render.ts | 88 -- .../tiptap-markdown-bridge/mermaid.test.ts | 255 ----- .../editor/tiptap-markdown-bridge/nodes.ts | 444 -------- .../tiptap-markdown-bridge/roundtrip.test.ts | 741 ------------- .../tiptap-markdown-bridge/shortcuts.test.ts | 689 ------------- .../tiptap-markdown-bridge/shortcuts.ts | 451 -------- .../tiptap-markdown-bridge/tiptap-to-mdwc.ts | 315 ------ .../markdown/editor/upsert-markdown-file.ts | 117 --- src/extensions/markdown/index.test.tsx | 626 ----------- src/extensions/markdown/index.tsx | 975 ------------------ .../markdown/markdown-node-history.ts | 74 -- .../markdown/render-review-diff-html.test.ts | 905 ---------------- .../markdown/render-review-diff-html.ts | 408 -------- src/extensions/markdown/review-diff.ts | 12 - src/extensions/markdown/style.css | 447 -------- .../terminal/agent-review-intent.test.tsx | 33 + src/extensions/terminal/host-extensions.tsx | 37 +- src/hooks/key-value/schema.ts | 41 - .../key-value/use-key-value.loop.test.tsx | 77 -- src/hooks/key-value/use-key-value.test.tsx | 571 ---------- src/hooks/key-value/use-key-value.ts | 367 ------- src/index.css | 34 + src/lib/agent-turn-review-bridge.test.ts | 19 +- src/lib/agent-turn-review-bridge.ts | 5 +- src/lib/atelier-desktop-commands.test.ts | 84 ++ src/lib/atelier-desktop-commands.ts | 75 ++ src/lib/atelier-document-state.test.ts | 32 +- src/lib/atelier-document-state.ts | 28 +- src/lib/atelier-workspace-bridge.test.ts | 38 +- src/lib/atelier-workspace-bridge.ts | 28 +- src/lib/lix-client.ts | 12 +- src/lib/lix-types.ts | 12 +- src/main.error.tsx | 24 +- src/main.tsx | 109 +- src/shell/agent-turn-review-range.ts | 243 ----- .../external-write-review-history.test.ts | 700 ------------- src/shell/external-write-review-history.ts | 305 ------ src/shell/share-button.test.tsx | 110 ++ src/shell/share-button.tsx | 326 ++++++ src/shell/temporary-history-notice.test.tsx | 103 ++ src/shell/temporary-history-notice.tsx | 82 +- src/shell/top-bar/flashtype-menu.tsx | 136 --- src/shell/workspace-loading-screen.test.tsx | 84 ++ src/shell/workspace-loading-screen.tsx | 56 +- src/test-utils/node-lix-sdk.test.ts | 2 +- src/test-utils/node-lix-sdk.ts | 18 - submodule/atelier | 2 +- 171 files changed, 3369 insertions(+), 20149 deletions(-) create mode 100644 e2e/csv-file-opening.spec.ts create mode 100644 e2e/repository-limits.spec.ts create mode 100644 e2e/share.spec.ts create mode 100644 e2e/status-review.spec.ts create mode 100644 e2e/workspace-open-recovery.spec.ts create mode 100644 electron/dev-sync.mjs create mode 100644 electron/dev-sync.test.mjs rename electron/{markdown-default-handler.mjs => document-default-handlers.mjs} (72%) rename electron/{markdown-default-handler.test.ts => document-default-handlers.test.ts} (64%) create mode 100644 electron/repository-limits.mjs create mode 100644 electron/repository-limits.test.mjs create mode 100644 electron/share-auth.mjs create mode 100644 electron/share-auth.test.mjs create mode 100644 electron/share-http.mjs create mode 100644 electron/share-ipc.mjs create mode 100644 electron/share-runtime.mjs create mode 100644 electron/share-service.mjs create mode 100644 electron/share-service.test.mjs create mode 100644 electron/share-store.mjs create mode 100644 electron/share-store.test.mjs create mode 100644 electron/workspace-open-preflight.mjs create mode 100644 electron/workspace-open-preflight.test.mjs create mode 100644 electron/workspace-open-recovery.mjs create mode 100644 electron/workspace-open-recovery.test.mjs create mode 100644 scripts/dev-sync.mjs delete mode 100644 src/extension-runtime/editor-revision-state.ts delete mode 100644 src/extension-runtime/external-write-review-controls.css delete mode 100644 src/extension-runtime/external-write-review-controls.tsx delete mode 100644 src/extension-runtime/external-write-review.ts delete mode 100644 src/extension-runtime/file-handlers.test.ts delete mode 100644 src/extension-runtime/file-handlers.ts delete mode 100644 src/extensions/csv/csv-data.ts delete mode 100644 src/extensions/csv/index.reactive.test.tsx delete mode 100644 src/extensions/csv/index.test.ts delete mode 100644 src/extensions/csv/index.tsx delete mode 100644 src/extensions/csv/render-review-diff-html.ts delete mode 100644 src/extensions/csv/style.css delete mode 100644 src/extensions/markdown/components/formatting-toolbar.test.tsx delete mode 100644 src/extensions/markdown/components/formatting-toolbar.tsx delete mode 100644 src/extensions/markdown/components/slash-command-menu.tsx delete mode 100644 src/extensions/markdown/diff-fixtures/block-media-link.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/block-media-link.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/block-structure-moves.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/block-structure-moves.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/duplicate-task-list.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/duplicate-task-list.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/gfm-edge-blocks.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/gfm-edge-blocks.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/gfm-table-inline.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/gfm-table-inline.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/gfm-table-rows.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/gfm-table-rows.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/gfm-table-structure.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/gfm-table-structure.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/index.ts delete mode 100644 src/extensions/markdown/diff-fixtures/inline-media-link.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/inline-media-link.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/mixed-gfm-document.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/mixed-gfm-document.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/plan-table.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/plan-table.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/quick-facts-list.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/quick-facts-list.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/release-checklist.after.md delete mode 100644 src/extensions/markdown/diff-fixtures/release-checklist.before.md delete mode 100644 src/extensions/markdown/diff-fixtures/visual.html delete mode 100644 src/extensions/markdown/diff-fixtures/visual.ts delete mode 100644 src/extensions/markdown/editor/assemble-md-ast.test.ts delete mode 100644 src/extensions/markdown/editor/assemble-md-ast.ts delete mode 100644 src/extensions/markdown/editor/block-commands.ts delete mode 100644 src/extensions/markdown/editor/build-markdown-from-editor.ts delete mode 100644 src/extensions/markdown/editor/create-editor.test.ts delete mode 100644 src/extensions/markdown/editor/create-editor.ts delete mode 100644 src/extensions/markdown/editor/decode-markdown-data.test.ts delete mode 100644 src/extensions/markdown/editor/decode-markdown-data.ts delete mode 100644 src/extensions/markdown/editor/desktop-workspace.ts delete mode 100644 src/extensions/markdown/editor/editor-context.tsx delete mode 100644 src/extensions/markdown/editor/extensions/slash-commands.ts delete mode 100644 src/extensions/markdown/editor/extensions/table-navigation.ts delete mode 100644 src/extensions/markdown/editor/handle-paste.test.ts delete mode 100644 src/extensions/markdown/editor/handle-paste.ts delete mode 100644 src/extensions/markdown/editor/markdown-editor-fuzz-harness.tsx delete mode 100644 src/extensions/markdown/editor/markdown-editor-fuzz.ts delete mode 100644 src/extensions/markdown/editor/markdown.test.ts delete mode 100644 src/extensions/markdown/editor/markdown.ts delete mode 100644 src/extensions/markdown/editor/normalize-url.test.ts delete mode 100644 src/extensions/markdown/editor/normalize-url.ts delete mode 100644 src/extensions/markdown/editor/render-markdown-html.ts delete mode 100644 src/extensions/markdown/editor/tip-tap-editor.fuzz.test.tsx delete mode 100644 src/extensions/markdown/editor/tip-tap-editor.test.tsx delete mode 100644 src/extensions/markdown/editor/tip-tap-editor.tsx delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/assign-data-id.test.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/assign-data-id.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/default-block.test.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/index.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/markdown-wc.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/mdwc-to-tiptap.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid-code-block-node-view.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid-render.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid.test.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/nodes.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/roundtrip.test.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/shortcuts.test.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/shortcuts.ts delete mode 100644 src/extensions/markdown/editor/tiptap-markdown-bridge/tiptap-to-mdwc.ts delete mode 100644 src/extensions/markdown/editor/upsert-markdown-file.ts delete mode 100644 src/extensions/markdown/index.test.tsx delete mode 100644 src/extensions/markdown/index.tsx delete mode 100644 src/extensions/markdown/markdown-node-history.ts delete mode 100644 src/extensions/markdown/render-review-diff-html.test.ts delete mode 100644 src/extensions/markdown/render-review-diff-html.ts delete mode 100644 src/extensions/markdown/review-diff.ts delete mode 100644 src/extensions/markdown/style.css create mode 100644 src/extensions/terminal/agent-review-intent.test.tsx delete mode 100644 src/hooks/key-value/schema.ts delete mode 100644 src/hooks/key-value/use-key-value.loop.test.tsx delete mode 100644 src/hooks/key-value/use-key-value.test.tsx delete mode 100644 src/hooks/key-value/use-key-value.ts create mode 100644 src/lib/atelier-desktop-commands.test.ts create mode 100644 src/lib/atelier-desktop-commands.ts delete mode 100644 src/shell/agent-turn-review-range.ts delete mode 100644 src/shell/external-write-review-history.test.ts delete mode 100644 src/shell/external-write-review-history.ts create mode 100644 src/shell/share-button.test.tsx create mode 100644 src/shell/share-button.tsx create mode 100644 src/shell/temporary-history-notice.test.tsx delete mode 100644 src/shell/top-bar/flashtype-menu.tsx create mode 100644 src/shell/workspace-loading-screen.test.tsx diff --git a/NEXT_RELEASE.md b/NEXT_RELEASE.md index d7b262ca..fe9dc87f 100644 --- a/NEXT_RELEASE.md +++ b/NEXT_RELEASE.md @@ -4,3 +4,7 @@ type: minor - support for excalidraw files - colorful file tree explorer (upgraded atelier) +- Share individual files on Lixray with private repository sync and account API tokens +- Recover from stalled or crashed repository opens and limit initialization to 1 GB +- Open CSV files through the standard desktop file-opening flow +- Review agent changes with Keep and Undo, and switch file/repository history diff --git a/README.md b/README.md index e30d8cae..d45394e1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,24 @@ Agents edit. You review the diff. Nothing lands without you. ## Powered by Lix +Initialize a repository from History to keep future history after closing Flashtype. +Existing repositories are also checked against the size limit before native Lix +opening. After a crash during opening, the next launch shows recovery without +reopening the repository. The dev supervisor restarts once into this recovery +screen after a native crash. + +If a persistent repository takes more than 30 seconds to open, Flashtype offers +**Delete .lix and restart**. Opening continues while the offer is visible. Choosing +it permanently removes that folder’s history, preserves its normal files, and +restarts all Flashtype windows. Deletion runs before repositories open in the new +process, so a stalled Lix open cannot block recovery. Nothing is deleted unless +the user chooses the action. + +Initialization supports folders with up to **1 GB total**, with no file-count limit (including +subfolders, excluding Git and Lix metadata). Larger folders remain editable with +temporary history. History shows the limit check and lets you check again after +reducing the folder size. +

Lix @@ -37,3 +55,76 @@ Download Flashtype for macOS at [flashtype.ai](https://flashtype.ai). ## License Flashtype is released under the [MIT License](./LICENSE). + +## Local sync development + +The development workspace uses published Lix SDK and filesystem storage v0.16.0 +and Atelier through `submodule/atelier`. No sibling Lix checkout is required. +Both client and server must use the same server protocol version. + +With the Lix reference server running at `http://127.0.0.1:8088`: + +```sh +pnpm install +pnpm dev:sync --remote-debugging-port=9237 +``` + +This creates a sample workspace at `.flashtype-dev/sync-workspace`, uploads its +repository with history using `createLix({ server, from: lix })`, and reopens +it with `openLix({ storage, server })`. The connection descriptor is saved in +`.flashtype-dev/sync-connections` and reused on subsequent launches. +The sync connection applies only to that exact workspace in development mode. + +The default local server token is `flashtype-local-dev`. Override the target +with `FLASHTYPE_DEV_SYNC_SERVER`, `FLASHTYPE_DEV_SYNC_TOKEN`, and +`FLASHTYPE_DEV_SYNC_WORKSPACE`. Use a dedicated development server and workspace. +For a local-only preview, run `pnpm dev -- /path/to/folder` instead. + +On machines whose default Python is older than 3.8, set `PYTHON` to a current +Python executable when running the development command; Electron's native +module rebuild invokes node-gyp. + +The local setup used for this preview starts MinIO from the sibling Lixray +checkout and the Lix reference server from the source build: + +```sh +docker compose -p flashtype-sync-dev -f ../lixray/compose.yml up -d minio minio-init +BIND_ADDR=127.0.0.1:8088 \ +LIX_SERVER_PUBLIC_URL=http://127.0.0.1:8088 \ +LIX_SERVER_INTERNAL_TOKEN=flashtype-local-dev \ +S3_ENDPOINT=http://127.0.0.1:9000 S3_BUCKET=lixray \ +S3_ACCESS_KEY_ID=lixray S3_SECRET_ACCESS_KEY=lixray-local-secret \ +S3_REGION=us-east-1 S3_ALLOW_HTTP=true S3_PREFIX=flashtype-sync-final \ +../lix-flashtype-dev/target/debug/lix-server +``` + +If using a shared `CARGO_TARGET_DIR`, use its `debug/lix-server` binary instead. +The currently verified preview uses `FLASHTYPE_DEV_SYNC_WORKSPACE=.flashtype-dev/sync-final`. +Keep the server's storage prefix stable when restarting so saved connection +descriptors continue to point to the same repositories. + + +## Sharing files with Lixray + +Open a file and choose **Share**. Initialize the repository if history is +temporary. In [Lixray account settings](https://lixray.com/settings), generate an +API token and paste it into Flashtype. The token has your account's permissions. +Confirm **Sync privately and publish file**: Flashtype uploads the repository and +its history privately, then publishes only the selected file. Later synced edits +use the same public link. Share also supports copying the link and unpublishing. + +The token is encrypted using the operating system's secure storage and kept in +Flashtype's user-data directory, outside `.lix`. There is no desktop OAuth flow. +Delete a token in Lixray account settings to revoke it; paste a replacement into +Share to reconnect. The saved connection is bound to the local `.lix` directory +identity and account, and resumes sync on reopen. An unavailable server leaves +the local workspace usable. + +Interrupted initial uploads retain their snapshot and idempotency key so retries +send identical bytes. Publication waits until the selected file reaches the +server. This requires Lixray's creation gateway and account-token migration. +`FLASHTYPE_DEV_LIXRAY_ORIGIN` selects a local Lixray origin in development only. + +Atelier owns Markdown, CSV, history, and review. Flashtype owns desktop integration +and Share. Its end-to-end Markdown fuzz test drives the normal Atelier UI; +isolated editor tests live in Atelier. diff --git a/e2e/agent-review.spec.ts b/e2e/agent-review.spec.ts index ad5f9284..2203626f 100644 --- a/e2e/agent-review.spec.ts +++ b/e2e/agent-review.spec.ts @@ -68,7 +68,8 @@ test("Atelier reveals a review after Codex edits restored markdown", async ({ await expect(page).toHaveTitle(path.basename(workspaceDir)); await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible(); - await page.locator('[data-attr="agent-start-codex"]').click(); + await page.getByRole("button", { name: "History panel view menu" }).click(); + await page.getByRole("menuitem", { name: "Codex", exact: true }).click(); await expect( page.locator('[data-active="true"][data-view-key="flashtype_codex"]'), ).toBeVisible(); @@ -92,14 +93,14 @@ test("Atelier reveals a review after Codex edits restored markdown", async ({ await expect( page.getByRole("group", { - name: /^Review change 1 of \d+(?:, \d+ remaining)?$/, + name: "Diff review actions", }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "Keep change" }), + page.getByRole("button", { name: "Keep", exact: true }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "Undo change" }), + page.getByRole("button", { name: "Undo", exact: true }), ).toBeVisible(); await ensureFilesViewOpenInLeftPanel(page); await expect(fileTreeFile(page, "/changelog.md")).toHaveAttribute( @@ -108,7 +109,7 @@ test("Atelier reveals a review after Codex edits restored markdown", async ({ ); await expect(fileTreeFile(page, "/codex-created.md")).toHaveAttribute( "data-item-git-status", - "modified", + "added", ); } finally { process.env.PATH = originalPath; diff --git a/e2e/csv-file-opening.spec.ts b/e2e/csv-file-opening.spec.ts new file mode 100644 index 00000000..16703afc --- /dev/null +++ b/e2e/csv-file-opening.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { launchDevElectronAppWithArgs } from "./electron-test-utils"; + +for (const source of ["launch argument", "macOS open-file event"] as const) { + test(`opens CSV through the standard ${source} flow`, async () => { + const root = await mkdtemp(path.join(tmpdir(), "flashtype-csv-open-")); + const userDataDir = await mkdtemp( + path.join(tmpdir(), "flashtype-csv-profile-"), + ); + const csv = path.join(root, "table.csv"); + await writeFile(csv, "name,value\nAlpha,42\n"); + const app = await launchDevElectronAppWithArgs( + source === "launch argument" ? [csv] : [], + { userDataDir }, + ); + try { + const first = await app.firstWindow(); + await first.waitForFunction(() => Boolean(window.flashtypeDesktop)); + if (source === "macOS open-file event") { + await app.evaluate(({ app }, file) => { + app.emit("open-file", { preventDefault() {} }, file); + }, csv); + } + await expect + .poll( + async () => { + for (const page of app.windows()) + if (await page.locator(".csv-view").isVisible()) return true; + return false; + }, + { timeout: 30_000 }, + ) + .toBe(true); + const page = app.windows().find((page) => !page.isClosed()); + expect(page).toBeDefined(); + } finally { + await app.close(); + await rm(root, { recursive: true, force: true }); + await rm(userDataDir, { recursive: true, force: true }); + } + }); +} diff --git a/e2e/electron-test-utils.ts b/e2e/electron-test-utils.ts index 921f4997..f93627fa 100644 --- a/e2e/electron-test-utils.ts +++ b/e2e/electron-test-utils.ts @@ -91,7 +91,7 @@ export async function ensureFilesViewOpenInLeftPanel( await expect(leftPanelToggle).toHaveAttribute("aria-pressed", "true"); await expect(filesTab).toBeVisible(); await filesTab.click(); - await expect(filesTab).toHaveAttribute("data-focused", "true"); + await expect(filesTab).toHaveAttribute("data-active", "true"); } async function waitForWorkspaceReady(page: Page): Promise { @@ -100,17 +100,24 @@ async function waitForWorkspaceReady(page: Page): Promise { } export function fileTreeFiles(page: Page): Locator { - return page.locator( - '[data-type="item"][data-item-type="file"][data-item-path]', - ); + return page + .locator("aside") + .first() + .locator('[data-type="item"][data-item-type="file"][data-item-path]'); } export function fileTreeFile(page: Page, appPath: string): Locator { - return page.locator(fileTreeItemSelector(appPath, "file")); + return page + .locator("aside") + .first() + .locator(fileTreeItemSelector(appPath, "file")); } export function fileTreeDirectory(page: Page, appPath: string): Locator { - return page.locator(fileTreeItemSelector(appPath, "folder")); + return page + .locator("aside") + .first() + .locator(fileTreeItemSelector(appPath, "folder")); } function fileTreeItemSelector( @@ -172,7 +179,7 @@ export async function expectInstalledPluginArchives( await expect .poll(() => readBinaryFile( - path.join(workspaceDir, ".lix", "plugins", "plugin_md_v2.lixplugin"), + path.join(workspaceDir, ".lix", "plugins", "plugin_markdown.lixplugin"), ), ) .toBeGreaterThan(0); diff --git a/e2e/files-click-tour.spec.ts b/e2e/files-click-tour.spec.ts index eebd1f0f..c4b6fe62 100644 --- a/e2e/files-click-tour.spec.ts +++ b/e2e/files-click-tour.spec.ts @@ -164,7 +164,7 @@ test("deleting the active file closes the central file view", async ({ await expect( page.locator('[data-active="true"][data-view-key="atelier_file"]'), ).toHaveCount(0); - await expect(page.getByTestId("central-panel-empty-state")).toBeVisible(); + await expect(page.getByTestId("files-view-wide")).toBeVisible(); } finally { await closeElectronApp(electronApp); } diff --git a/e2e/markdown-editor-fuzz.spec.ts b/e2e/markdown-editor-fuzz.spec.ts index 4561827f..45459c8c 100644 --- a/e2e/markdown-editor-fuzz.spec.ts +++ b/e2e/markdown-editor-fuzz.spec.ts @@ -5,23 +5,13 @@ import path from "node:path"; import seedrandom from "seedrandom"; import { applyOperationToSimplifiedState, - buildOperationFailureMessage, - buildPlainTextMismatchMessage, - buildSelectionInvariantFailureMessage, createSimplifiedState, expectedPlainText, - MARKDOWN_EDITOR_FUZZ_DEFAULT_SEED, - MARKDOWN_EDITOR_FUZZ_HARD_BREAK, - MARKDOWN_EDITOR_FUZZ_OPERATION_COUNT, - MARKDOWN_EDITOR_FUZZ_PARAGRAPH_BREAK, nextOperation, renderPlainTextFromMarkdown, - validateSimplifiedSelectionInvariant, type FuzzOperation, - type MarkdownFuzzSnapshot, - type SimplifiedSelection, type SimplifiedState, -} from "../src/extensions/markdown/editor/markdown-editor-fuzz"; +} from "../submodule/atelier/src/extensions/markdown/editor/markdown-editor-fuzz"; import { closeElectronApp, ensureFilesViewOpenInLeftPanel, @@ -30,70 +20,15 @@ import { registerRendererConsoleLogging, } from "./electron-test-utils"; -const rendererPort = process.env.FLASHTYPE_E2E_RENDERER_PORT ?? "4174"; -const rendererUrl = `http://127.0.0.1:${rendererPort}`; +const MARKDOWN_EDITOR_FUZZ_HARD_BREAK = "\n"; +const MARKDOWN_EDITOR_FUZZ_PARAGRAPH_BREAK = "\x1E"; +type SimplifiedSelection = NonNullable< + import("../submodule/atelier/src/extensions/markdown/editor/markdown-editor-fuzz").MarkdownFuzzSnapshot["selection"] +>; const uiFuzzFilePath = "/fuzz.md"; test.setTimeout(600_000); -test("fuzzes markdown editor plain text in a real browser", async ({ - page, -}) => { - const seed = - process.env.FLASHTYPE_MARKDOWN_FUZZ_SEED ?? - MARKDOWN_EDITOR_FUZZ_DEFAULT_SEED; - const rng = seedrandom(seed); - const state = createSimplifiedState(); - - await page.goto(`${rendererUrl}/?e2e=markdown-editor-fuzz`); - await expect(page.getByTestId("markdown-editor-fuzz-harness")).toBeVisible(); - await page.waitForFunction(() => - Boolean((window as any).__flashtypeMarkdownFuzz), - ); - - for ( - let index = 0; - index < MARKDOWN_EDITOR_FUZZ_OPERATION_COUNT; - index += 1 - ) { - const operation = nextOperation(rng, state); - try { - await applyOperationToHarnessPage(page, operation); - applyOperationToSimplifiedState(state, operation); - } catch (error) { - const snapshot = await safeHarnessSnapshot(page); - throw new Error( - buildOperationFailureMessage({ - seed, - index, - operation, - state, - editorJson: snapshot?.editorJson, - cause: error, - }), - ); - } - - const snapshot = await readSnapshot(page); - assertSnapshotSelectionMatches(snapshot, state, seed, index, operation); - const expected = expectedPlainText(state); - if (snapshot.plainText !== expected) { - throw new Error( - buildPlainTextMismatchMessage({ - seed, - index, - operation, - state, - expected, - actual: snapshot.plainText, - markdown: snapshot.markdown, - editorJson: snapshot.editorJson, - }), - ); - } - } -}); - test("fuzzes markdown editor plain text through the Flashtype UI", async ({ browserName: _browserName, }, testInfo) => { @@ -182,65 +117,6 @@ test("fuzzes markdown editor plain text through the Flashtype UI", async ({ } }); -function assertSnapshotSelectionMatches( - snapshot: MarkdownFuzzSnapshot, - state: SimplifiedState, - seed: number, - index: number, - operation: FuzzOperation, -): void { - const reason = validateSimplifiedSelectionInvariant({ - state, - positions: snapshot.positions, - docSize: snapshot.docSize, - domSelection: snapshot.domSelection, - selection: snapshot.selection, - }); - - if (!reason) return; - - throw new Error( - buildSelectionInvariantFailureMessage({ - seed, - index, - operation, - state, - reason, - positions: snapshot.positions, - docSize: snapshot.docSize, - domSelection: snapshot.domSelection, - selection: snapshot.selection, - editorJson: snapshot.editorJson, - }), - ); -} - -async function applyOperationToHarnessPage( - page: Page, - operation: FuzzOperation, -): Promise { - switch (operation.kind) { - case "move": - await setHarnessPageSelection(page, operation.anchor, operation.head); - return; - case "type": - await page.keyboard.type(operation.value); - return; - case "enter": - await page.keyboard.press("Enter"); - return; - case "shiftEnter": - await page.keyboard.press("Shift+Enter"); - return; - case "left": - await page.keyboard.press("ArrowLeft"); - return; - case "right": - await page.keyboard.press("ArrowRight"); - return; - } -} - async function applyOperationToUiPage( page: Page, operation: FuzzOperation, @@ -267,37 +143,6 @@ async function applyOperationToUiPage( } } -async function setHarnessPageSelection( - page: Page, - anchor: number, - head: number, -): Promise { - await page.evaluate( - ({ anchor, head }) => { - (window as any).__flashtypeMarkdownFuzz.setSelection(anchor, head); - }, - { anchor, head }, - ); -} - -async function readSnapshot(page: Page): Promise { - return await page.evaluate(() => { - const api = (window as any).__flashtypeMarkdownFuzz; - if (!api) throw new Error("Markdown fuzz harness API is not available."); - return api.snapshot(); - }); -} - -async function safeHarnessSnapshot( - page: Page, -): Promise { - try { - return await readSnapshot(page); - } catch { - return null; - } -} - type UiMarkdownFuzzSnapshot = { plainText: string; selection: SimplifiedSelection | null; @@ -344,7 +189,7 @@ async function readPersistedMarkdown( ): Promise { return await page.evaluate(async (pathToFind) => { const queryResult = await window.flashtypeDesktop?.lix.execute({ - sql: "SELECT data FROM lix_file WHERE path = $1", + sql: "SELECT content FROM lix_file WHERE path = $1", params: [pathToFind], }); const value = queryResult?.rows?.[0]?.[0]; diff --git a/e2e/packaged-macos.spec.ts b/e2e/packaged-macos.spec.ts index 22f0b4a9..66e0b665 100644 --- a/e2e/packaged-macos.spec.ts +++ b/e2e/packaged-macos.spec.ts @@ -85,7 +85,7 @@ test("packaged app includes arm64 native modules", async () => { } }); -test("packaged app declares folder document support", async () => { +test("packaged app declares folder, Markdown, and CSV document support", async () => { await expectFile(packagedInfoPlistPath); const { stdout } = await execFileAsync("/usr/libexec/PlistBuddy", [ @@ -96,6 +96,8 @@ test("packaged app declares folder document support", async () => { expect(stdout).toContain("public.folder"); expect(stdout).toContain("Markdown Document"); + expect(stdout).toContain("CSV Document"); + expect(stdout).toContain("public.comma-separated-values-text"); }); test("packaged app launches, seeds, and opens files without Vite", async ({ diff --git a/e2e/repository-limits.spec.ts b/e2e/repository-limits.spec.ts new file mode 100644 index 00000000..15d02025 --- /dev/null +++ b/e2e/repository-limits.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from "@playwright/test"; +import { + mkdtemp, + open, + readdir, + rm, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { launchDevElectronApp } from "./electron-test-utils"; + +test("oversized folders remain editable and initialization is blocked through IPC and History", async () => { + const root = await mkdtemp(path.join(tmpdir(), "flashtype-limit-e2e-")); + const userDataDir = await mkdtemp( + path.join(tmpdir(), "flashtype-limit-profile-"), + ); + await Promise.all( + Array.from({ length: 201 }, (_, i) => + writeFile(path.join(root, `note-${i}.md`), `# Note ${i}\n`), + ), + ); + const oversized = await open(path.join(root, "large.bin"), "w"); + await oversized.truncate(1_000_000_001); + await oversized.close(); + const app = await launchDevElectronApp(root, { userDataDir }); + try { + const page = await app.firstWindow(); + await expect( + page.getByRole("button", { + name: /Open checkpoint history|Review working changes/, + }), + ).toBeVisible({ timeout: 30_000 }); + + const history = page + .getByTestId(/atelier-view:.*history/) + .filter({ visible: true }) + .first(); + await expect( + history.getByText(/This folder exceeds the repository limit/), + ).toBeVisible(); + await expect( + history.getByRole("button", { + name: "Initialize repository", + exact: true, + }), + ).toBeDisabled(); + const message = await page.evaluate(async () => { + try { + await window.flashtypeDesktop!.workspace.initializeRepository(); + return "unexpected success"; + } catch (error) { + return String(error); + } + }); + expect(message).toContain("exceeds the 1 GB"); + expect(await readdir(root)).not.toContain(".lix"); + expect( + await page.evaluate(() => window.flashtypeDesktop!.workspace.get()), + ).toMatchObject({ ephemeral: true }); + await page.screenshot({ path: "/tmp/flashtype-repository-limit-ui.png" }); + await unlink(path.join(root, "large.bin")); + await history.getByRole("button", { name: "Check again" }).click(); + await expect( + history.getByRole("button", { + name: "Initialize repository", + exact: true, + }), + ).toBeEnabled(); + await history + .getByRole("button", { name: "Initialize repository", exact: true }) + .click(); + await expect + .poll(async () => + page.evaluate(() => window.flashtypeDesktop!.workspace.get()), + ) + .toMatchObject({ ephemeral: false }); + await expect.poll(() => readdir(root)).toContain(".lix"); + } finally { + await app.close(); + await rm(root, { recursive: true, force: true }); + await rm(userDataDir, { recursive: true, force: true }); + } +}); diff --git a/e2e/share.spec.ts b/e2e/share.spec.ts new file mode 100644 index 00000000..c072f410 --- /dev/null +++ b/e2e/share.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + closeElectronApp, + launchDevElectronAppWithArgs, +} from "./electron-test-utils"; + +test("Share explains private sync and requires repository initialization before publishing", async ({ + browserName: _browserName, +}, testInfo) => { + const workspace = testInfo.outputPath("workspace"); + await mkdir(workspace, { recursive: true }); + const file = path.join(workspace, "share-me.md"); + await writeFile(file, "# Share me\n"); + const app = await launchDevElectronAppWithArgs([file], { + userDataDir: testInfo.outputPath("profile"), + }); + try { + const page = await app.firstWindow(); + const share = page.getByRole("button", { name: "Share", exact: true }); + await expect(share).toBeEnabled({ timeout: 30_000 }); + await share.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByText(/including its history, will sync privately/), + ).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Initialize repository" }), + ).toBeVisible(); + await expect( + dialog.getByRole("button", { name: "Publish file", exact: true }), + ).toHaveCount(0); + await dialog.getByRole("button", { name: "Initialize repository" }).click(); + await expect(page.getByRole("dialog")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByLabel("API token", { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Save token", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Sign in to Lixray" }), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + } finally { + await closeElectronApp(app); + } +}); diff --git a/e2e/status-review.spec.ts b/e2e/status-review.spec.ts new file mode 100644 index 00000000..bc8006d5 --- /dev/null +++ b/e2e/status-review.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { launchDevElectronApp } from "./electron-test-utils"; + +test("changed-files status opens review and preserves the Files sidebar", async () => { + const root = await mkdtemp(path.join(tmpdir(), "flashtype-status-review-")); + const userDataDir = await mkdtemp( + path.join(tmpdir(), "flashtype-status-profile-"), + ); + await writeFile(path.join(root, "note.md"), "# Review me\n"); + const app = await launchDevElectronApp(root, { userDataDir }); + try { + const page = await app.firstWindow(); + const status = page.getByRole("button", { + name: /file.* changed since checkpoint\. Review working changes/, + }); + await expect(status).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByRole("button", { name: "Files panel view menu" }), + ).toBeVisible(); + await status.click(); + await expect(page.locator('[data-review-mode="true"]')).toBeVisible(); + await expect( + page.getByRole("button", { name: /^Checkpoint(ing…)?$/ }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Files panel view menu" }), + ).toBeVisible(); + await expect(page.getByRole("tree")).toBeVisible(); + } finally { + await app.close(); + await rm(root, { recursive: true, force: true }); + await rm(userDataDir, { recursive: true, force: true }); + } +}); + +test("History header switches between active-file and repository history", async () => { + const root = await mkdtemp(path.join(tmpdir(), "flashtype-history-scope-")); + const userDataDir = await mkdtemp( + path.join(tmpdir(), "flashtype-history-profile-"), + ); + await writeFile(path.join(root, "one.md"), "# One\n"); + await writeFile(path.join(root, "two.md"), "# Two\n"); + const app = await launchDevElectronApp(root, { + userDataDir, + }); + try { + const page = await app.firstWindow(); + await expect( + page.getByRole("button", { name: /since checkpoint/ }), + ).toBeVisible(); + await page.getByRole("button", { name: "Files", exact: true }).click(); + await expect( + page.getByLabel("Showing the repository", { exact: true }).first(), + ).toBeVisible(); + await page + .getByTestId("atelier-view:files-default") + .getByRole("treeitem", { name: "one.md" }) + .click(); + const fileSwitch = page + .getByRole("button", { + name: "Showing this file. Switch to the repository", + }) + .first(); + await expect(fileSwitch).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByText("No checkpoint includes this file yet.").first(), + ).toBeVisible(); + await fileSwitch.click(); + const repoSwitch = page + .getByRole("button", { + name: "Showing the repository. Switch to this file", + }) + .first(); + await expect(repoSwitch).toBeVisible(); + await expect( + page.getByText("No checkpoint includes this file yet."), + ).toHaveCount(0); + await repoSwitch.click(); + await expect(fileSwitch).toBeVisible(); + await expect( + page.getByText("No checkpoint includes this file yet.").first(), + ).toBeVisible(); + } finally { + await app.close(); + await rm(root, { recursive: true, force: true }); + await rm(userDataDir, { recursive: true, force: true }); + } +}); diff --git a/e2e/workspace-change-stress.spec.ts b/e2e/workspace-change-stress.spec.ts index 764cb489..520b58bb 100644 --- a/e2e/workspace-change-stress.spec.ts +++ b/e2e/workspace-change-stress.spec.ts @@ -34,6 +34,7 @@ test("stress tests workspace changes through manual edits and fake agent turns", const rng = seedrandom(stressSeed); const userDataDir = testInfo.outputPath("user-data"); const workspaceDir = testInfo.outputPath("workspace"); + const fakeBinDir = testInfo.outputPath("fake-bin"); const helperScriptPath = testInfo.outputPath("fake-agent-turn.mjs"); const payloadDir = testInfo.outputPath("agent-payloads"); const stressDiskPath = path.join(workspaceDir, stressFileName); @@ -46,6 +47,12 @@ test("stress tests workspace changes through manual edits and fake agent turns", await timeProfile(profile, "setup:files", null, async () => { await mkdir(workspaceDir, { recursive: true }); await mkdir(payloadDir, { recursive: true }); + await mkdir(fakeBinDir, { recursive: true }); + await writeFile( + path.join(fakeBinDir, "codex"), + "#!/bin/sh\nexec /bin/sh\n", + { mode: 0o755 }, + ); await writeFile(stressDiskPath, expectedMarkdown, "utf8"); await writeFakeAgentTurnHelper(helperScriptPath); }); @@ -57,6 +64,8 @@ test("stress tests workspace changes through manual edits and fake agent turns", async () => await launchDevElectronApp(workspaceDir, { env: { + PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, + SHELL: "/bin/sh", FLASHTYPE_TRACE_LIX_IPC: "1", FLASHTYPE_TRACE_LIX_SLOW_MS: "0", }, @@ -75,6 +84,15 @@ test("stress tests workspace changes through manual edits and fake agent turns", await timeProfile(profile, "setup:open-file", null, async () => { await openStressMarkdown(page); await installStressEditorHelpers(page); + // Mount the agent extension, as a real terminal turn does, so its + // Atelier review integration is available to the fake hook events. + await page + .getByRole("button", { name: "History panel view menu" }) + .click(); + await page.getByRole("menuitem", { name: "Codex", exact: true }).click(); + await expect( + page.locator('[data-active="true"][data-view-key="flashtype_codex"]'), + ).toBeVisible(); }); await timeProfile(profile, "setup:initial-settle", null, async () => { await expectMarkdownSettled({ @@ -587,56 +605,26 @@ async function waitForReviewControls(page: Page): Promise { } async function resolveReview(page: Page, keep: boolean): Promise { - const remainingCount = await reviewRemainingCount(page); - if (keep) { - if (remainingCount > 1) { - await expect(reviewKeepAllButton(page)).toBeVisible(); - await reviewKeepAllButton(page).click(); - return; - } - await reviewKeepButton(page).click(); - return; - } - - for ( - let resolvedCount = 0; - resolvedCount < remainingCount; - resolvedCount += 1 - ) { - await reviewUndoButton(page).click(); - if (resolvedCount + 1 < remainingCount) { - await expect - .poll(async () => await reviewRemainingCount(page), { - timeout: 30_000, - }) - .toBe(remainingCount - resolvedCount - 1); - } - } + // This fixture changes one file per turn; Atelier resolves the selected file. + await (keep ? reviewKeepButton(page) : reviewUndoButton(page)).click(); } function reviewControls(page: Page) { - return page.locator('[role="group"][aria-label^="Review change "]'); + return page.getByRole("group", { name: "Diff review actions" }); } function reviewKeepButton(page: Page) { - return page.locator('[data-attr="review-change-keep"]'); -} - -function reviewKeepAllButton(page: Page) { - return page.locator('[data-attr="review-change-keep-all"]'); + return reviewControls(page).getByRole("button", { + name: "Keep", + exact: true, + }); } function reviewUndoButton(page: Page) { - return page.locator('[data-attr="review-change-undo"]'); -} - -async function reviewRemainingCount(page: Page): Promise { - const label = await reviewControls(page).getAttribute("aria-label"); - const match = /^Review change \d+ of \d+, (\d+) remaining$/.exec(label ?? ""); - if (!match) { - throw new Error(`Could not read remaining review changes from ${label}.`); - } - return Number(match[1]); + return reviewControls(page).getByRole("button", { + name: "Undo", + exact: true, + }); } async function buildAgentReviewTimeoutMessage(args: { @@ -746,7 +734,7 @@ async function readPersistedMarkdown( ): Promise { return await page.evaluate(async (pathToFind) => { const queryResult = await window.flashtypeDesktop?.lix.execute({ - sql: "SELECT data FROM lix_file WHERE path = $1", + sql: "SELECT content FROM lix_file WHERE path = $1", params: [pathToFind], }); return decodeMarkdownValue(queryResult?.rows?.[0]?.[0]); diff --git a/e2e/workspace-open-recovery.spec.ts b/e2e/workspace-open-recovery.spec.ts new file mode 100644 index 00000000..a90e6d55 --- /dev/null +++ b/e2e/workspace-open-recovery.spec.ts @@ -0,0 +1,158 @@ +import { expect, test } from "@playwright/test"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { launchDevElectronApp } from "./electron-test-utils"; + +test("a stalled repository open offers deletion and recovers on restart", async () => { + const root = await mkdtemp(path.join(tmpdir(), "flashtype-timeout-e2e-")); + const userDataDir = await mkdtemp( + path.join(tmpdir(), "flashtype-timeout-profile-"), + ); + await writeFile(path.join(root, "notes.md"), "# Keep this file\n"); + let app = await launchDevElectronApp(root, { + userDataDir, + env: { FLASHTYPE_DEV_SUPERVISED: "1" }, + }); + try { + let page = await app.firstWindow(); + await expect( + page.getByRole("button", { + name: /Open checkpoint history|Review working changes/, + }), + ).toBeVisible({ timeout: 30_000 }); + await page.evaluate(() => + window.flashtypeDesktop!.workspace.initializeRepository(), + ); + await expect.poll(() => readdir(root)).toContain(".lix"); + // Simulate an open that never returns without touching the native engine. + await app.evaluate(({ ipcMain }) => { + ipcMain.removeHandler("lix:open"); + ipcMain.handle("lix:open", () => new Promise(() => {})); + }); + await page.clock.install(); + await page.reload(); + await expect(page.getByRole("heading", { name: /Opening/ })).toBeVisible(); + await page.clock.fastForward(30_000); + const button = page.getByRole("button", { + name: "Delete .lix and restart", + }); + await expect(button).toBeVisible(); + await page.screenshot({ path: "/tmp/flashtype-opening-timeout.png" }); + const shutdownErrors = path.join(userDataDir, "shutdown-errors.txt"); + await app.evaluate(({ dialog }, errorPath) => { + const fs = process.getBuiltinModule("fs"); + const record = (error: unknown) => + fs.appendFileSync(errorPath, String(error) + "\n"); + process.on("uncaughtException", record); + process.on("unhandledRejection", record); + dialog.showErrorBox = (title, content) => record(title + ": " + content); + }, shutdownErrors); + const closed = app.waitForEvent("close"); + await button.click(); + await closed; + expect( + await readFile(shutdownErrors, "utf8").catch((error) => { + if (error.code === "ENOENT") return ""; + throw error; + }), + ).toBe(""); + // The dev supervisor normally relaunches this; do it explicitly for the test. + expect(await readdir(root)).toContain(".lix"); + app = await launchDevElectronApp(root, { userDataDir }); + page = await app.firstWindow(); + await expect( + page.getByRole("button", { + name: /Open checkpoint history|Review working changes/, + }), + ).toBeVisible({ timeout: 30_000 }); + expect(await readdir(root)).not.toContain(".lix"); + expect(await readFile(path.join(root, "notes.md"), "utf8")).toBe( + "# Keep this file\n", + ); + expect( + await page.evaluate(() => window.flashtypeDesktop!.workspace.get()), + ).toMatchObject({ ephemeral: true }); + } finally { + await app.close().catch(() => {}); + await rm(root, { recursive: true, force: true }); + await rm(userDataDir, { recursive: true, force: true }); + } +}); + +test("a previous native crash opens recovery without touching the damaged repository", async () => { + const { mkdir } = await import("node:fs/promises"); + const root = await mkdtemp(path.join(tmpdir(), "flashtype-crash-e2e-")); + const userDataDir = await mkdtemp( + path.join(tmpdir(), "flashtype-crash-profile-"), + ); + await mkdir(path.join(root, ".lix", ".internal", "rocksdb"), { + recursive: true, + }); + await writeFile( + path.join(root, ".lix", "damaged-data"), + "must not be opened", + ); + await writeFile(path.join(root, "keep.md"), "keep"); + await writeFile( + path.join(userDataDir, "workspace-pending-lix-open.json"), + JSON.stringify({ + version: 1, + recoveries: [{ workspacePath: root, reason: "lix_open_pending" }], + }), + ); + let app = await launchDevElectronApp(root, { + userDataDir, + env: { FLASHTYPE_DEV_SUPERVISED: "1" }, + }); + try { + let page = await app.firstWindow(); + await expect( + page.getByRole("heading", { name: "Track Changes could not be opened" }), + ).toBeVisible(); + // Even bypassing the renderer recovery gate cannot trigger native opening. + const rejected = await page.evaluate(async () => { + try { + await window.flashtypeDesktop!.lix.open(); + return "opened"; + } catch (error) { + return String(error); + } + }); + expect(rejected).toContain("needs recovery"); + expect( + await readFile(path.join(root, ".lix", "damaged-data"), "utf8"), + ).toBe("must not be opened"); + const shutdownErrors = path.join(userDataDir, "shutdown-errors.txt"); + await app.evaluate(({ dialog }, errorPath) => { + const fs = process.getBuiltinModule("fs"); + const record = (error: unknown) => + fs.appendFileSync(errorPath, String(error) + "\n"); + process.on("uncaughtException", record); + process.on("unhandledRejection", record); + dialog.showErrorBox = (title, content) => record(title + ": " + content); + }, shutdownErrors); + const closed = app.waitForEvent("close"); + await page.getByRole("button", { name: "Delete .lix and restart" }).click(); + await closed; + expect( + await readFile(shutdownErrors, "utf8").catch((error) => { + if (error.code === "ENOENT") return ""; + throw error; + }), + ).toBe(""); + app = await launchDevElectronApp(root, { userDataDir }); + page = await app.firstWindow(); + await expect( + page.getByRole("button", { + name: /Open checkpoint history|Review working changes/, + }), + ).toBeVisible({ timeout: 30_000 }); + expect(await readdir(root)).not.toContain(".lix"); + expect(await readFile(path.join(root, "keep.md"), "utf8")).toBe("keep"); + } finally { + await app.close().catch(() => {}); + await rm(root, { recursive: true, force: true }); + await rm(userDataDir, { recursive: true, force: true }); + } +}); diff --git a/e2e/workspace-switch.spec.ts b/e2e/workspace-switch.spec.ts index 7da1cbbe..d34f43ba 100644 --- a/e2e/workspace-switch.spec.ts +++ b/e2e/workspace-switch.spec.ts @@ -40,7 +40,7 @@ test("switching to an empty workspace closes the previous lix session", async ({ await expect(page).toHaveTitle(path.basename(secondWorkspaceDir)); await expect(fileTreeFile(page, "/old-workspace-marker.md")).toHaveCount(0); - await expect(page.getByText("Start writing")).toBeVisible(); + await expect(page.getByTestId("files-view-wide").getByRole("button", { name: "New", exact: true })).toBeVisible(); await expectPathMissing(path.join(secondWorkspaceDir, ".lix")); } finally { await closeElectronApp(electronApp); diff --git a/e2e/workspace-windows.spec.ts b/e2e/workspace-windows.spec.ts index b641255b..0a41ce84 100644 --- a/e2e/workspace-windows.spec.ts +++ b/e2e/workspace-windows.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; import type { ElectronApplication } from "playwright"; -import { LocalFilesystem, openLix } from "@lix-js/sdk"; +import { openLix } from "@lix-js/sdk"; +import { FilesystemStorage } from "@lix-js/storage-filesystem"; import { mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises"; import path from "node:path"; import { @@ -365,7 +366,7 @@ test("Track Changes menu toggles workspace .lix storage", async ({ await page.evaluate(async () => { await window.flashtypeDesktop?.lix.execute({ - sql: "UPDATE lix_file SET data = $1 WHERE path = $2", + sql: "UPDATE lix_file SET content = $1 WHERE path = $2", params: [ new TextEncoder().encode("# Updated while off\n"), "/marker.md", @@ -394,7 +395,7 @@ test("Track Changes menu toggles workspace .lix storage", async ({ } }); -test("Track Changes recovery screen can disable tracking", async ({ +test("Track Changes recovery screen deletes damaged tracking and restarts", async ({ browserName: _browserName, }, testInfo) => { const workspaceDir = testInfo.outputPath("track-changes-recovery-workspace"); @@ -431,8 +432,9 @@ test("Track Changes recovery screen can disable tracking", async ({ electronApp = await launchDevElectronAppWithArgs([workspaceDir], { userDataDir, + env: { FLASHTYPE_DEV_SUPERVISED: "1" }, }); - const page = await pageWithTitle(electronApp, path.basename(workspaceDir)); + let page = await pageWithTitle(electronApp, path.basename(workspaceDir)); registerRendererConsoleLogging(page); await expectTrackChangesMenuChecked(electronApp, true); @@ -444,13 +446,16 @@ test("Track Changes recovery screen can disable tracking", async ({ }), ).toBeVisible(); await expect( - page.getByText("Your project files will not be deleted."), + page.getByText("Your normal files will not be deleted."), ).toBeVisible(); - await Promise.all([ - page.waitForNavigation({ waitUntil: "domcontentloaded" }), - page.getByRole("button", { name: "Disable Track Changes" }).click(), - ]); + const closed = electronApp.waitForEvent("close"); + await page.getByRole("button", { name: "Delete .lix and restart" }).click(); + await closed; + electronApp = await launchDevElectronAppWithArgs([workspaceDir], { + userDataDir, + }); + page = await pageWithTitle(electronApp, path.basename(workspaceDir)); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); await expectTrackChangesMenuChecked(electronApp, false); @@ -566,7 +571,7 @@ test("macOS open-file events open standalone files as transient workspaces", asy await filePage.evaluate(async () => { await window.flashtypeDesktop?.lix.execute({ - sql: "UPDATE lix_file SET data = $1 WHERE path = $2", + sql: "UPDATE lix_file SET content = $1 WHERE path = $2", params: [new TextEncoder().encode("# Updated\n"), "/solo.md"], }); }); @@ -577,7 +582,7 @@ test("macOS open-file events open standalone files as transient workspaces", asy expect(await readFile(siblingPath, "utf8")).toBe("# Sibling\n"); await filePage.evaluate(async () => { await window.flashtypeDesktop?.lix.execute({ - sql: "INSERT INTO lix_file (path, data) VALUES ($1, $2)", + sql: "INSERT INTO lix_file (path, content) VALUES ($1, $2)", params: ["/generated.md", new TextEncoder().encode("# Generated\n")], }); }); @@ -591,7 +596,7 @@ test("macOS open-file events open standalone files as transient workspaces", asy await expectPathMissing(path.join(directory, ".lix_system")); await filePage.evaluate(async () => { await window.flashtypeDesktop?.lix.execute({ - sql: "INSERT INTO lix_file (path, data) VALUES ($1, $2)", + sql: "INSERT INTO lix_file (path, content) VALUES ($1, $2)", params: [ "/.lix/app_data/transient-test.bin", new TextEncoder().encode("internal"), @@ -833,7 +838,7 @@ async function writeMarkerFile( async function initializeLixWorkspace(workspaceDir: string): Promise { const lix = await openLix({ - storage: new LocalFilesystem({ path: workspaceDir, syncAllFiles: true }), + storage: new FilesystemStorage({ path: workspaceDir }), }); await lix.close(); } diff --git a/electron-builder.yml b/electron-builder.yml index 286628b4..e2a5edc7 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -56,6 +56,13 @@ mac: - markdown - mdown - mkd + - CFBundleTypeName: CSV Document + CFBundleTypeRole: Editor + LSHandlerRank: Alternate + LSItemContentTypes: + - public.comma-separated-values-text + CFBundleTypeExtensions: + - csv - CFBundleTypeName: Folder CFBundleTypeRole: Viewer LSHandlerRank: Alternate diff --git a/electron/dev-sync.mjs b/electron/dev-sync.mjs new file mode 100644 index 00000000..6c950222 --- /dev/null +++ b/electron/dev-sync.mjs @@ -0,0 +1,20 @@ +import path from "node:path"; + +/** A development connection applies only to its explicitly selected workspace. */ +export function getDevSyncServer(workspace, env = process.env) { + if (env.FLASHTYPE_DEV_RUNTIME !== "1" || workspace.ephemeral === true) + return undefined; + if (!env.FLASHTYPE_DEV_SYNC_URL || !env.FLASHTYPE_DEV_SYNC_WORKSPACE) + return undefined; + if ( + path.resolve(workspace.path) !== + path.resolve(env.FLASHTYPE_DEV_SYNC_WORKSPACE) + ) + return undefined; + return { + url: env.FLASHTYPE_DEV_SYNC_URL, + ...(env.FLASHTYPE_DEV_SYNC_TOKEN + ? { headers: { Authorization: `Bearer ${env.FLASHTYPE_DEV_SYNC_TOKEN}` } } + : {}), + }; +} diff --git a/electron/dev-sync.test.mjs b/electron/dev-sync.test.mjs new file mode 100644 index 00000000..e9c1ab17 --- /dev/null +++ b/electron/dev-sync.test.mjs @@ -0,0 +1,29 @@ +// @vitest-environment node +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { getDevSyncServer } from "./dev-sync.mjs"; + +test("dev sync is limited to the selected persistent workspace", () => { + const env = { + FLASHTYPE_DEV_RUNTIME: "1", + FLASHTYPE_DEV_SYNC_URL: "http://127.0.0.1:8088/lix/id", + FLASHTYPE_DEV_SYNC_WORKSPACE: "/tmp/sync-demo", + FLASHTYPE_DEV_SYNC_TOKEN: "test", + }; + assert.equal(getDevSyncServer({ path: "/tmp/other" }, env), undefined); + assert.equal( + getDevSyncServer({ path: "/tmp/sync-demo", ephemeral: true }, env), + undefined, + ); + assert.equal( + getDevSyncServer( + { path: "/tmp/sync-demo" }, + { ...env, FLASHTYPE_DEV_RUNTIME: "0" }, + ), + undefined, + ); + assert.deepEqual(getDevSyncServer({ path: "/tmp/sync-demo" }, env), { + url: env.FLASHTYPE_DEV_SYNC_URL, + headers: { Authorization: "Bearer test" }, + }); +}); diff --git a/electron/markdown-default-handler.mjs b/electron/document-default-handlers.mjs similarity index 72% rename from electron/markdown-default-handler.mjs rename to electron/document-default-handlers.mjs index 652a71d7..15f75dc4 100644 --- a/electron/markdown-default-handler.mjs +++ b/electron/document-default-handlers.mjs @@ -2,17 +2,12 @@ import path from "node:path"; export const APP_BUNDLE_ID = "com.flashtype.app"; export const APP_NAME = "Flashtype"; -export const MARKDOWN_CONTENT_TYPES = [ +export const DOCUMENT_CONTENT_TYPES = [ "public.markdown", "net.daringfireball.markdown", + "public.comma-separated-values-text", ]; -export const REPLACEABLE_MARKDOWN_HANDLER_BUNDLE_IDS = new Set([ - APP_BUNDLE_ID, - "com.apple.dt.Xcode", - "com.apple.TextEdit", -]); - export const LSREGISTER_PATH = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; @@ -34,16 +29,19 @@ export function isCanonicalInstalledAppBundle(appBundlePath) { ); } -export function shouldReplaceMarkdownDefaultHandler(handlerBundleId) { +// LaunchServices does not distinguish a fallback from a deliberate user choice. +// Preserve every other app, including Apple apps; never reclaim a changed default. +export function shouldReplaceDocumentDefaultHandler(handlerBundleId) { return ( - handlerBundleId == null || - REPLACEABLE_MARKDOWN_HANDLER_BUNDLE_IDS.has(handlerBundleId) + handlerBundleId == null || handlerBundleId.toLowerCase() === APP_BUNDLE_ID ); } -export function getMarkdownContentTypesToRegister(currentHandlers) { - return MARKDOWN_CONTENT_TYPES.filter((contentType) => - shouldReplaceMarkdownDefaultHandler(currentHandlers[contentType] ?? null), +export function getDocumentContentTypesToRegister(currentHandlers) { + return DOCUMENT_CONTENT_TYPES.filter( + (contentType) => + Object.hasOwn(currentHandlers, contentType) && + shouldReplaceDocumentDefaultHandler(currentHandlers[contentType]), ); } @@ -67,10 +65,10 @@ export function getNonCanonicalFlashtypeBundlePathsFromLsregisterDump(dump) { }); } -function buildMarkdownHandlerQueryScript() { +function buildDocumentHandlerQueryScript() { return ` ObjC.import("CoreServices"); -const contentTypes = ${JSON.stringify(MARKDOWN_CONTENT_TYPES)}; +const contentTypes = ${JSON.stringify(DOCUMENT_CONTENT_TYPES)}; const handlers = {}; for (const contentType of contentTypes) { const handler = $.LSCopyDefaultRoleHandlerForContentType( @@ -83,12 +81,15 @@ console.log(JSON.stringify(handlers)); `; } -function buildMarkdownHandlerRegistrationScript(contentTypes) { +function buildDocumentHandlerRegistrationScript(contentTypes) { return ` ObjC.import("CoreServices"); const bundleId = ${JSON.stringify(APP_BUNDLE_ID)}; const contentTypes = ${JSON.stringify(contentTypes)}; for (const contentType of contentTypes) { + // A user may have changed the default since the initial query. + const current = $.LSCopyDefaultRoleHandlerForContentType($(contentType), $.kLSRolesEditor); + if (current && ObjC.unwrap(ObjC.castRefToObject(current)).toLowerCase() !== bundleId) continue; const status = $.LSSetDefaultRoleHandlerForContentType( $(contentType), $.kLSRolesEditor, @@ -101,10 +102,10 @@ for (const contentType of contentTypes) { `; } -async function getCurrentMarkdownDefaultHandlers(execFileAsync) { +async function getCurrentDocumentDefaultHandlers(execFileAsync) { const { stdout } = await execFileAsync( "/usr/bin/osascript", - ["-l", "JavaScript", "-e", buildMarkdownHandlerQueryScript()], + ["-l", "JavaScript", "-e", buildDocumentHandlerQueryScript()], { timeout: 5000 }, ); return JSON.parse(stdout); @@ -134,7 +135,7 @@ async function unregisterNonCanonicalFlashtypeBundles(execFileAsync) { return appBundlePaths; } -export async function registerMarkdownDefaultHandler({ +export async function registerDocumentDefaultHandlers({ execFileAsync, executablePath, isPackaged, @@ -157,8 +158,8 @@ export async function registerMarkdownDefaultHandler({ }); const currentHandlers = - await getCurrentMarkdownDefaultHandlers(execFileAsync); - const contentTypes = getMarkdownContentTypesToRegister(currentHandlers); + await getCurrentDocumentDefaultHandlers(execFileAsync); + const contentTypes = getDocumentContentTypesToRegister(currentHandlers); if (contentTypes.length === 0) { return { status: "skipped", reason: "user-handler-preserved" }; } @@ -169,7 +170,7 @@ export async function registerMarkdownDefaultHandler({ "-l", "JavaScript", "-e", - buildMarkdownHandlerRegistrationScript(contentTypes), + buildDocumentHandlerRegistrationScript(contentTypes), ], { timeout: 5000 }, ); diff --git a/electron/markdown-default-handler.test.ts b/electron/document-default-handlers.test.ts similarity index 64% rename from electron/markdown-default-handler.test.ts rename to electron/document-default-handlers.test.ts index 4afb2e15..8ffea980 100644 --- a/electron/markdown-default-handler.test.ts +++ b/electron/document-default-handlers.test.ts @@ -2,15 +2,15 @@ import { describe, expect, test, vi } from "vitest"; import { APP_BUNDLE_ID, getAppBundlePathFromExecutablePath, - getMarkdownContentTypesToRegister, + getDocumentContentTypesToRegister, getNonCanonicalFlashtypeBundlePathsFromLsregisterDump, isCanonicalInstalledAppBundle, LSREGISTER_PATH, - registerMarkdownDefaultHandler, - shouldReplaceMarkdownDefaultHandler, -} from "./markdown-default-handler.mjs"; + registerDocumentDefaultHandlers, + shouldReplaceDocumentDefaultHandler, +} from "./document-default-handlers.mjs"; -describe("Markdown default handler registration policy", () => { +describe("Document default handler registration policy", () => { test("derives the .app bundle path from a packaged executable path", () => { expect( getAppBundlePathFromExecutablePath( @@ -35,28 +35,28 @@ describe("Markdown default handler registration policy", () => { ).toBe(false); }); - test("replaces no handler, Xcode, TextEdit, and Flashtype itself", () => { - expect(shouldReplaceMarkdownDefaultHandler(null)).toBe(true); - expect(shouldReplaceMarkdownDefaultHandler("com.apple.dt.Xcode")).toBe( - true, + test("claims only unassigned types or Flashtype itself", () => { + expect(shouldReplaceDocumentDefaultHandler(null)).toBe(true); + expect(shouldReplaceDocumentDefaultHandler("com.apple.dt.Xcode")).toBe( + false, ); - expect(shouldReplaceMarkdownDefaultHandler("com.apple.TextEdit")).toBe( - true, + expect(shouldReplaceDocumentDefaultHandler("com.apple.TextEdit")).toBe( + false, ); - expect(shouldReplaceMarkdownDefaultHandler(APP_BUNDLE_ID)).toBe(true); + expect(shouldReplaceDocumentDefaultHandler(APP_BUNDLE_ID)).toBe(true); }); test("preserves third-party Markdown defaults", () => { - expect(shouldReplaceMarkdownDefaultHandler("com.microsoft.VSCode")).toBe( + expect(shouldReplaceDocumentDefaultHandler("com.microsoft.VSCode")).toBe( false, ); - expect(shouldReplaceMarkdownDefaultHandler("md.obsidian")).toBe(false); + expect(shouldReplaceDocumentDefaultHandler("md.obsidian")).toBe(false); }); test("chooses only replaceable Markdown content types", () => { expect( - getMarkdownContentTypesToRegister({ - "public.markdown": "com.apple.TextEdit", + getDocumentContentTypesToRegister({ + "public.markdown": null, "net.daringfireball.markdown": "com.microsoft.VSCode", }), ).toEqual(["public.markdown"]); @@ -97,7 +97,7 @@ versionString: 1.0.0 test("skips packaged apps outside /Applications", async () => { const execFileAsync = vi.fn(); - const result = await registerMarkdownDefaultHandler({ + const result = await registerDocumentDefaultHandlers({ execFileAsync, executablePath: "/Volumes/Flashtype 0.1.1-arm64/Flashtype.app/Contents/MacOS/Flashtype", @@ -130,13 +130,13 @@ identifier: com.flashtype.app .mockResolvedValueOnce({ stdout: "" }) .mockResolvedValueOnce({ stdout: JSON.stringify({ - "public.markdown": "com.apple.dt.Xcode", + "public.markdown": null, "net.daringfireball.markdown": null, }), }) .mockResolvedValueOnce({ stdout: "" }); - const result = await registerMarkdownDefaultHandler({ + const result = await registerDocumentDefaultHandlers({ execFileAsync, executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", isPackaged: true, @@ -197,13 +197,13 @@ identifier: com.flashtype.app .mockResolvedValueOnce({ stdout: "" }) .mockResolvedValueOnce({ stdout: JSON.stringify({ - "public.markdown": "com.apple.TextEdit", + "public.markdown": null, "net.daringfireball.markdown": "com.microsoft.VSCode", }), }) .mockResolvedValueOnce({ stdout: "" }); - const result = await registerMarkdownDefaultHandler({ + const result = await registerDocumentDefaultHandlers({ execFileAsync, executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", isPackaged: true, @@ -233,7 +233,7 @@ identifier: com.flashtype.app }), }); - const result = await registerMarkdownDefaultHandler({ + const result = await registerDocumentDefaultHandlers({ execFileAsync, executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", isPackaged: true, @@ -247,3 +247,78 @@ identifier: com.flashtype.app expect(execFileAsync).toHaveBeenCalledTimes(3); }); }); + +describe("CSV default handler registration", () => { + const csv = "public.comma-separated-values-text"; + test("claims CSV when unassigned and preserves every existing app", () => { + expect(getDocumentContentTypesToRegister({ [csv]: null })).toEqual([csv]); + for (const handler of [ + "com.apple.iWork.Numbers", + "com.microsoft.Excel", + "com.apple.TextEdit", + "com.apple.dt.Xcode", + "com.microsoft.VSCode", + ]) { + expect(getDocumentContentTypesToRegister({ [csv]: handler })).toEqual([]); + } + expect(getDocumentContentTypesToRegister({ [csv]: APP_BUNDLE_ID })).toEqual( + [csv], + ); + expect(getDocumentContentTypesToRegister({})).toEqual([]); + }); + test("registers CSV through the shared workflow without claiming Markdown", async () => { + const execFileAsync = vi + .fn() + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + "public.markdown": "com.microsoft.VSCode", + "net.daringfireball.markdown": "com.apple.TextEdit", + [csv]: null, + }), + }) + .mockResolvedValueOnce({ stdout: "" }); + const result = await registerDocumentDefaultHandlers({ + execFileAsync, + executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", + isPackaged: true, + platform: "darwin", + }); + expect(result).toMatchObject({ status: "registered", contentTypes: [csv] }); + expect(execFileAsync.mock.calls[2][1][3]).toContain(csv); + const registration = execFileAsync.mock.calls[3][1][3]; + expect(registration).toContain(csv); + expect(registration).not.toContain('"public.markdown"'); + // Execute the generated JXA logic with CoreServices mocked: a late user + // override must survive even after the first query said CSV was unassigned. + const { runInNewContext } = await import("node:vm"); + const set = vi.fn().mockReturnValue(0); + const $ = Object.assign((value: unknown) => value, { + kLSRolesEditor: 4, + LSCopyDefaultRoleHandlerForContentType: () => "com.microsoft.Excel", + LSSetDefaultRoleHandlerForContentType: set, + }); + runInNewContext(registration, { + $, + ObjC: { + import: () => {}, + unwrap: (value: unknown) => value, + castRefToObject: (value: unknown) => value, + }, + }); + expect(set).not.toHaveBeenCalled(); + }); + test("does not change system defaults from a development runtime", async () => { + const execFileAsync = vi.fn(); + expect( + await registerDocumentDefaultHandlers({ + execFileAsync, + executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", + isPackaged: false, + platform: "darwin", + }), + ).toMatchObject({ status: "skipped" }); + expect(execFileAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/initialize-checkpoint.test.mjs b/electron/initialize-checkpoint.test.mjs index f680ff50..82de61f6 100644 --- a/electron/initialize-checkpoint.test.mjs +++ b/electron/initialize-checkpoint.test.mjs @@ -32,7 +32,7 @@ test("initialization checkpoints .lix files, leaves documents pending, and survi ).rows.map((row) => row.path); expect(await pendingPaths()).toEqual(["/note.md"]); const checkpoint = async () => - (await lix.execute("SELECT lix_latest_checkpoint_commit_id() AS id")) + (await lix.execute("SELECT working_base_commit_id AS id FROM lix_branch WHERE id = lix_active_branch_id()")) .rows[0].id; const initial = await checkpoint(); await checkpointRepositoryMetadata(lix); diff --git a/electron/ipc-lix.mjs b/electron/ipc-lix.mjs index 88fc7fb6..00946759 100644 --- a/electron/ipc-lix.mjs +++ b/electron/ipc-lix.mjs @@ -2,6 +2,7 @@ import { ipcMain } from "electron"; import { Value } from "@lix-js/sdk"; import { closeAllLixSessions, + closeLix, ensureLixOpen, exportCurrentLixImage, resetLixRepository, @@ -347,7 +348,10 @@ export async function closeLixSession(window, options = {}) { if (!window) { return; } - await runWithLixSessionClosed(window, async () => {}, options); + await closeLix(window, { + ...options, + beforeClose: () => closeAllHandles(window.id), + }); } export async function runWithLixSessionClosed(window, operation, options = {}) { diff --git a/electron/lix-lifecycle.test.mjs b/electron/lix-lifecycle.test.mjs index 2ec7f9e3..0a438f82 100644 --- a/electron/lix-lifecycle.test.mjs +++ b/electron/lix-lifecycle.test.mjs @@ -38,11 +38,18 @@ vi.mock("./workspace.mjs", () => ({ })); vi.mock("./workspace-recovery.mjs", () => ({ + readWorkspaceRecovery: vi.fn(async () => null), clearWorkspaceLixOpenPendingSync: vi.fn(), markWorkspaceLixOpenPendingSync: vi.fn(), writeWorkspaceRecoverySync: vi.fn(), })); +vi.mock("./workspace-open-preflight.mjs", () => ({ + assertWorkspaceCanOpen: vi.fn(async () => {}), +})); +vi.mock("./share-runtime.mjs", () => ({ + getShareServer: vi.fn(async () => undefined), +})); vi.mock("./telemetry.mjs", () => ({ captureTelemetryException: vi.fn(), })); diff --git a/electron/lix.mjs b/electron/lix.mjs index 792aebd0..a0d694de 100644 --- a/electron/lix.mjs +++ b/electron/lix.mjs @@ -1,3 +1,9 @@ +import { getShareRuntime, getShareServer } from "./share-runtime.mjs"; +import { createWriteStream } from "node:fs"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { assertWorkspaceCanOpen } from "./workspace-open-preflight.mjs"; +import { getDevSyncServer } from "./dev-sync.mjs"; import { checkpointRepositoryMetadata } from "./initialize-checkpoint.mjs"; import { createMemoryWorkspace } from "./memory-workspace.mjs"; import { app } from "electron"; @@ -7,6 +13,7 @@ import path from "node:path"; import { readFile, rm } from "node:fs/promises"; import { getWorkspace, getWorkspaceLixDatabasePath } from "./workspace.mjs"; import { + readWorkspaceRecovery, clearWorkspaceLixOpenPendingSync, markWorkspaceLixOpenPendingSync, writeWorkspaceRecoverySync, @@ -29,6 +36,18 @@ export async function ensureLixOpen(window) { "No workspace is open. Open a folder before using lix.", ); } + // Keep an existing crash report intact and avoid entering native code + // at all until recovery has explicitly been cleared. + if (workspace.ephemeral !== true) { + const recovery = await readWorkspaceRecovery( + app.getPath("userData"), + workspace.path, + ); + if (recovery) + throw new Error( + "This repository needs recovery before it can be opened.", + ); + } let nativeLix; let releaseStorageOptions = async () => {}; const tracksPersistentWorkspace = workspace.ephemeral !== true; @@ -44,8 +63,24 @@ export async function ensureLixOpen(window) { } let storage; if (tracksPersistentWorkspace) { + await assertWorkspaceCanOpen(userDataPath, workspace.path); storage = new LocalFilesystem({ path: workspace.path }); - nativeLix = await openLix({ storage }); + const server = + getDevSyncServer(workspace) ?? (await getShareServer(workspace)); + try { + nativeLix = await openLix({ + storage, + ...(server ? { server } : {}), + }); + } catch (error) { + if (!server) throw error; + getShareRuntime().errors.set( + workspace.path, + "Sync is unavailable. You can keep working locally and reconnect from Share.", + ); + storage = new LocalFilesystem({ path: workspace.path }); + nativeLix = await openLix({ storage }); + } } else { nativeLix = await openLix(); storage = createMemoryWorkspace(nativeLix, workspace.path); @@ -166,6 +201,7 @@ export async function closeLix(window, options = {}) { return; } await enqueue(session, async () => { + await options.beforeClose?.(); await closeCurrentLix(session, options); }); } @@ -359,6 +395,14 @@ function createDesktopLixHandle( syncTimer?.unref(); return { + async exportShareSnapshot(destination) { + await runQueued(() => + pipeline( + Readable.fromWeb(nativeLix.exportSnapshot()), + createWriteStream(destination, { flags: "wx", mode: 0o600 }), + ), + ); + }, sessionId() { return sessionId; }, diff --git a/electron/main.mjs b/electron/main.mjs index 86c08137..3316c33d 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -1,4 +1,13 @@ -import { app, BrowserWindow, ipcMain, Menu, shell } from "electron"; +import { + applyScheduledWorkspaceLixDeletion, + scheduleWorkspaceLixDeletion, + RECOVERY_RESTART_EXIT_CODE, +} from "./workspace-open-recovery.mjs"; +import { + assertRepositorySize, + inspectRepositorySize, +} from "./repository-limits.mjs"; +import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; import { execFile } from "node:child_process"; import { readFileSync } from "node:fs"; import { stat } from "node:fs/promises"; @@ -28,8 +37,8 @@ import { } from "./telemetry.mjs"; import { APP_NAME, - registerMarkdownDefaultHandler, -} from "./markdown-default-handler.mjs"; + registerDocumentDefaultHandlers, +} from "./document-default-handlers.mjs"; import { getApplicationIconPath as resolveApplicationIconPath } from "./app-icon.mjs"; import { getWorkspacePathArguments, @@ -517,6 +526,9 @@ async function toggleTrackChangesFromApplicationMenu( if (!window || window.isDestroyed() || !getWorkspace(window)) { return null; } + if (trackChanges && getWorkspace(window)?.ephemeral === true) { + await assertRepositorySize(getWorkspace(window).path); + } const workspace = await runWithLixSessionClosed( window, () => setWorkspaceTrackChanges(window, trackChanges), @@ -746,6 +758,8 @@ async function createMainWindow(workspaceRequest) { window.focus(); }); + const windowId = window.id; + const webContents = window.webContents; let windowCleanupDone = false; const cleanupWorkspaceWindow = () => { if (windowCleanupDone) { @@ -755,16 +769,20 @@ async function createMainWindow(workspaceRequest) { if (showFallback !== undefined) { clearTimeout(showFallback); } - forgetTelemetrySessionContextForWebContents(window.webContents); + forgetTelemetrySessionContextForWebContents(webContents); workspaceWindows.delete(window); forgetClosedWorkspaceIfOtherWindowsRemain(window); - void closeLixSession(window, { ignoreOpenError: true }).finally(() => { - void disposeWorkspaceWindowState(window.id); - }); + if (!isQuitting) { + void closeLixSession({ id: windowId }, { ignoreOpenError: true }) + .finally(() => disposeWorkspaceWindowState(windowId)) + .catch((error) => + console.warn("Failed to clean up closed workspace", error), + ); + } }; window.on("closed", cleanupWorkspaceWindow); - window.webContents.once("destroyed", cleanupWorkspaceWindow); + webContents.once("destroyed", cleanupWorkspaceWindow); window.on("focus", () => { installApplicationMenu(); updateDockMenu(); @@ -873,6 +891,7 @@ async function startWorkspaceLifecycle() { return; } const userDataPath = app.getPath("userData"); + await applyScheduledWorkspaceLixDeletion(userDataPath); const recoveredLixOpenRecoveries = recoverPendingWorkspaceLixOpenRecoveriesSync(userDataPath); for (const recovery of recoveredLixOpenRecoveries) { @@ -880,6 +899,38 @@ async function startWorkspaceLifecycle() { source: "electron-workspace-recovery", }); } + ipcMain.handle( + "workspace:deleteLixAndRestart", + async (event, workspacePath) => { + const workspace = getWorkspace( + BrowserWindow.fromWebContents(event.sender), + ); + if ( + !workspace || + workspace.ephemeral || + workspace.path !== workspacePath + ) { + throw new Error("This repository is no longer open."); + } + await scheduleWorkspaceLixDeletion(userDataPath, workspace.path); + flushOpenWorkspacePaths(); + isQuitting = true; + clearWorkspaceBootRecoveryGuard(); + // Do not wait on the stalled Lix promise. The next process deletes .lix + // before opening any repositories, after this process has exited. + if (process.env.FLASHTYPE_DEV_SUPERVISED === "1") { + app.exit(RECOVERY_RESTART_EXIT_CODE); + } else { + app.relaunch(); + app.exit(0); + } + }, + ); + ipcMain.handle("workspace:inspectRepositorySize", (event) => { + const workspace = getWorkspace(BrowserWindow.fromWebContents(event.sender)); + if (!workspace) throw new Error("No workspace is open."); + return inspectRepositorySize(workspace.path); + }); ipcMain.handle("workspace:initializeRepository", (event) => toggleTrackChangesFromApplicationMenu( true, @@ -898,6 +949,9 @@ async function startWorkspaceLifecycle() { return workspace; }, }); + (await import("./share-ipc.mjs")).registerShareIpc((event) => + BrowserWindow.fromWebContents(event.sender), + ); registerTerminalIpc(); registerWorkspaceIpc((event) => BrowserWindow.fromWebContents(event.sender), { ...createWorkspaceChangeOptions(), @@ -917,13 +971,13 @@ async function startWorkspaceLifecycle() { ); }, }); - void registerMarkdownDefaultHandler({ + void registerDocumentDefaultHandlers({ execFileAsync, executablePath: process.execPath, isPackaged: app.isPackaged, platform: process.platform, }).catch((error) => { - console.warn("Failed to register Flashtype as the Markdown editor", error); + console.warn("Failed to register Flashtype file associations", error); }); const savedWorkspaceEntries = await readWorkspaceSessionEntries(userDataPath); const recoveryWorkspaceEntries = (await readWorkspaceRecoveries(userDataPath)) @@ -1848,7 +1902,15 @@ function buildFileMenu() { ? trackingWorkspace.ephemeral !== true : false, click: (menuItem) => { - void toggleTrackChangesFromApplicationMenu(menuItem.checked); + void toggleTrackChangesFromApplicationMenu(menuItem.checked).catch( + (error) => { + dialog.showErrorBox( + "Could not change repository tracking", + error.message, + ); + installApplicationMenu(); + }, + ); }, }, { type: "separator" }, @@ -1902,7 +1964,15 @@ function updateDockMenu() { ? trackingWorkspace.ephemeral !== true : false, click: (menuItem) => { - void toggleTrackChangesFromApplicationMenu(menuItem.checked); + void toggleTrackChangesFromApplicationMenu(menuItem.checked).catch( + (error) => { + dialog.showErrorBox( + "Could not change repository tracking", + error.message, + ); + installApplicationMenu(); + }, + ); }, }, ]), diff --git a/electron/preload.mjs b/electron/preload.mjs index eedcea52..0f7ebf10 100644 --- a/electron/preload.mjs +++ b/electron/preload.mjs @@ -15,6 +15,15 @@ const app = { }, }; +const share = { + setToken: (token) => ipcRenderer.invoke("share:setToken", token), + status: (filePath) => ipcRenderer.invoke("share:status", filePath), + connect: (confirmed) => ipcRenderer.invoke("share:connect", confirmed), + publish: (filePath, publish) => + ipcRenderer.invoke("share:publish", filePath, publish), + reconnect: () => ipcRenderer.invoke("share:reconnect"), +}; + const telemetry = { capture: (payload) => ipcRenderer.invoke("telemetry:capture", payload), captureException: (payload) => @@ -25,9 +34,13 @@ const telemetry = { }; const workspace = { + inspectRepositorySize: () => + ipcRenderer.invoke("workspace:inspectRepositorySize"), initializeRepository: () => ipcRenderer.invoke("workspace:initializeRepository"), get: () => ipcRenderer.invoke("workspace:get"), + deleteLixAndRestart: (workspacePath) => + ipcRenderer.invoke("workspace:deleteLixAndRestart", workspacePath), getRecovery: () => ipcRenderer.invoke("workspace:getRecovery"), clearRecovery: () => ipcRenderer.invoke("workspace:clearRecovery"), consumePendingOpenFiles: () => @@ -176,6 +189,7 @@ function normalizeAgentHookListenerResult(value) { contextBridge.exposeInMainWorld("flashtypeDesktop", { agentHooks, app, + share, platform: process.platform, telemetry, lix, diff --git a/electron/repository-limits.mjs b/electron/repository-limits.mjs new file mode 100644 index 00000000..dbc90441 --- /dev/null +++ b/electron/repository-limits.mjs @@ -0,0 +1,51 @@ +import { lstat, opendir } from "node:fs/promises"; +import path from "node:path"; + +export const MAX_REPOSITORY_BYTES = 1_000_000_000; + +/** Inspect metadata only, stopping as soon as the size limit is exceeded. */ +export async function inspectRepositorySize(root) { + root = path.resolve(root); + let fileCount = 0; + let totalBytes = 0; + const directories = [root]; + const result = (complete) => ({ + fileCount, + totalBytes, + complete, + allowed: totalBytes <= MAX_REPOSITORY_BYTES, + maxBytes: MAX_REPOSITORY_BYTES, + }); + while (directories.length) { + const directory = await opendir(directories.pop()); + for await (const entry of directory) { + if ( + entry.name === ".git" || + (directory.path === root && + [".lix", ".lix_system"].includes(entry.name)) + ) + continue; + const filename = path.join(directory.path, entry.name); + const info = await lstat(filename); + // Never follow links outside the selected folder or count special files. + if (info.isDirectory()) directories.push(filename); + else if (info.isFile()) { + fileCount += 1; + totalBytes += info.size; + if (totalBytes > MAX_REPOSITORY_BYTES) return result(false); + } + } + } + return result(true); +} + +export async function assertRepositorySize(root) { + const size = await inspectRepositorySize(root); + if (!size.allowed) { + throw new Error( + "Cannot initialize this folder: it exceeds the 1 GB total-size limit. " + + "Open a smaller folder to save persistent history. Your files remain editable.", + ); + } + return size; +} diff --git a/electron/repository-limits.test.mjs b/electron/repository-limits.test.mjs new file mode 100644 index 00000000..f08cbd8a --- /dev/null +++ b/electron/repository-limits.test.mjs @@ -0,0 +1,120 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { + mkdtemp, + mkdir, + open, + rm, + symlink, + writeFile, + readdir, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + inspectRepositorySize, + assertRepositorySize, + MAX_REPOSITORY_BYTES, +} from "./repository-limits.mjs"; +import { + setWorkspaceFromPath, + setWorkspaceTrackChanges, + getWorkspace, + disposeWorkspaceWindowState, +} from "./workspace.mjs"; +vi.mock("electron", () => ({ dialog: {}, ipcMain: { handle: vi.fn() } })); +const roots = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); +async function folder() { + const root = await mkdtemp(path.join(tmpdir(), "flashtype-limits-")); + roots.push(root); + return root; +} +async function sparse(file, size) { + const handle = await open(file, "w"); + try { + await handle.truncate(size); + } finally { + await handle.close(); + } +} + +test("allows more than 200 files and blocks only oversized initialization", async () => { + const root = await folder(); + await mkdir(path.join(root, "nested")); + await Promise.all( + Array.from({ length: 200 }, (_, i) => + writeFile(path.join(root, "nested", `${i}.md`), "x"), + ), + ); + expect(await inspectRepositorySize(root)).toMatchObject({ + allowed: true, + complete: true, + fileCount: 200, + totalBytes: 200, + }); + const window = { + id: 123456, + isDestroyed: () => false, + setTitle: vi.fn(), + setRepresentedFilename: vi.fn(), + webContents: { send: vi.fn() }, + }; + try { + await setWorkspaceFromPath(root, window); + await writeFile(path.join(root, "overflow.md"), "x"); + expect(await inspectRepositorySize(root)).toMatchObject({ + allowed: true, + fileCount: 201, + }); + await sparse(path.join(root, "large.bin"), MAX_REPOSITORY_BYTES + 1); + await expect(setWorkspaceTrackChanges(window, true)).rejects.toThrow( + "exceeds the 1 GB", + ); + expect(getWorkspace(window).ephemeral).toBe(true); + expect(await readdir(root)).not.toContain(".lix"); + } finally { + await disposeWorkspaceWindowState(window); + } +}); + +test("allows exactly 1 GB and rejects aggregate bytes above it without reading content", async () => { + const root = await folder(); + await sparse(path.join(root, "large.bin"), MAX_REPOSITORY_BYTES); + expect(await inspectRepositorySize(root)).toMatchObject({ + allowed: true, + totalBytes: MAX_REPOSITORY_BYTES, + }); + await writeFile(path.join(root, "extra.md"), "x"); + await expect(assertRepositorySize(root)).rejects.toThrow("exceeds the 1 GB"); +}); + +test("counts hidden and non-Markdown files but excludes metadata and symlink targets", async () => { + const root = await folder(); + const outside = await folder(); + for (const name of [".git", ".lix", ".lix_system"]) { + await mkdir(path.join(root, name)); + await sparse(path.join(root, name, "db"), MAX_REPOSITORY_BYTES + 1); + } + await writeFile(path.join(root, ".hidden"), "a"); + await writeFile(path.join(root, "image.bin"), "bc"); + await mkdir(path.join(root, "nested", ".lix"), { recursive: true }); + await writeFile(path.join(root, "nested", ".lix", "user.md"), "d"); + await sparse(path.join(outside, "large"), MAX_REPOSITORY_BYTES + 1); + await symlink(outside, path.join(root, "link")); + expect(await inspectRepositorySize(root)).toMatchObject({ + allowed: true, + fileCount: 3, + totalBytes: 4, + }); +}); + +test("fails closed when a folder cannot be inspected", async () => { + const root = await folder(); + await expect( + assertRepositorySize(path.join(root, "missing")), + ).rejects.toThrow(); +}); diff --git a/electron/share-auth.mjs b/electron/share-auth.mjs new file mode 100644 index 00000000..4d672243 --- /dev/null +++ b/electron/share-auth.mjs @@ -0,0 +1,48 @@ +import { readSharingJson, withRequestTimeout } from "./share-http.mjs"; + +/** One account token, pasted by the user and encrypted by the host store. */ +export function createShareAuth({ store, origin, fetcher = fetch }) { + async function saved() { + const value = await store.getAuth(); + if (!value?.token || !value?.accountId) + throw new Error("Paste an API token from your Lixray account settings."); + return value; + } + return { + async setToken(input) { + if ( + typeof input !== "string" || + !/^lixray_pat_[0-9a-f]{64}$/.test(input.trim()) + ) + throw new Error("Enter a valid Lixray API token."); + const token = input.trim(); + const response = await withRequestTimeout(fetcher)( + new URL("/api/account", origin), + { + headers: { Authorization: `Bearer ${token}` }, + }, + ); + if (!response.ok) { + await response.body?.cancel(); + throw new Error("This token is invalid, expired, or revoked."); + } + const account = await readSharingJson(response); + if ( + typeof account.id !== "string" || + !/^[0-9a-f-]{36}$/i.test(account.id) + ) + throw new Error("Lixray returned an invalid account."); + await store.setAuth({ token, accountId: account.id }); + }, + async token() { + return (await saved()).token; + }, + async subject() { + return (await saved()).accountId; + }, + async hasToken() { + const value = await store.getAuth(); + return Boolean(value?.token && value?.accountId); + }, + }; +} diff --git a/electron/share-auth.test.mjs b/electron/share-auth.test.mjs new file mode 100644 index 00000000..e6566eb8 --- /dev/null +++ b/electron/share-auth.test.mjs @@ -0,0 +1,46 @@ +// @vitest-environment node +import { test, expect, vi } from "vitest"; +import { createShareAuth } from "./share-auth.mjs"; +const token = `lixray_pat_${"a".repeat(64)}`; +const id = "86000000-0000-4000-8000-000000000001"; +test("validates the pasted account token before storing it and reuses it on reopen", async () => { + let value; + const store = { + getAuth: async () => value, + setAuth: vi.fn(async (next) => { + value = next; + }), + }; + const fetcher = vi.fn(async (_url, init) => { + expect(init.headers.Authorization).toBe(`Bearer ${token}`); + return Response.json({ id }); + }); + const auth = createShareAuth({ + store, + origin: "https://lixray.test", + fetcher, + }); + expect(await auth.hasToken()).toBe(false); + await auth.setToken(` ${token} `); + expect(store.setAuth).toHaveBeenCalledWith({ token, accountId: id }); + const reopened = createShareAuth({ + store, + origin: "https://lixray.test", + fetcher, + }); + expect(await reopened.token()).toBe(token); + expect(await reopened.subject()).toBe(id); + expect(await reopened.hasToken()).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(1); +}); +test("invalid or revoked tokens never replace the saved credential", async () => { + const store = { getAuth: async () => null, setAuth: vi.fn() }; + const auth = createShareAuth({ + store, + origin: "https://lixray.test", + fetcher: async () => new Response(null, { status: 401 }), + }); + await expect(auth.setToken("not-a-token")).rejects.toThrow("valid Lixray"); + await expect(auth.setToken(token)).rejects.toThrow("revoked"); + expect(store.setAuth).not.toHaveBeenCalled(); +}); diff --git a/electron/share-http.mjs b/electron/share-http.mjs new file mode 100644 index 00000000..41506623 --- /dev/null +++ b/electron/share-http.mjs @@ -0,0 +1,47 @@ +/** Bound token validation and ordinary API calls, including reading their response bodies. */ +export function withRequestTimeout(fetcher, timeoutMs = 10_000) { + return (input, init = {}) => { + const signals = [AbortSignal.timeout(timeoutMs)]; + if (input instanceof Request) signals.push(input.signal); + if (init.signal) signals.push(init.signal); + return fetcher(input, { ...init, signal: AbortSignal.any(signals) }); + }; +} +/** Sync streams may remain open; only bound time to the first response headers. */ +export async function fetchSync(input, init = {}) { + const controller = new AbortController(); + const signals = [controller.signal]; + if (input instanceof Request) signals.push(input.signal); + if (init.signal) signals.push(init.signal); + const timer = setTimeout(() => controller.abort(), 10_000); + try { + return await fetch(input, { ...init, signal: AbortSignal.any(signals) }); + } finally { + clearTimeout(timer); + } +} +export async function readSharingJson(response) { + const reader = response.body?.getReader(); + if (!reader) return {}; + const chunks = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.length; + if (size > 64 * 1024) + throw new Error("Lixray returned an oversized sharing response."); + chunks.push(value); + } + } finally { + await reader.cancel(); + reader.releaseLock(); + } + const bytes = Buffer.concat(chunks); + try { + return bytes.length ? JSON.parse(bytes.toString()) : {}; + } catch { + throw new Error("Lixray sharing is unavailable. Please try again."); + } +} diff --git a/electron/share-ipc.mjs b/electron/share-ipc.mjs new file mode 100644 index 00000000..b331a3f9 --- /dev/null +++ b/electron/share-ipc.mjs @@ -0,0 +1,56 @@ +import { ipcMain } from "electron"; +import { closeLixSession } from "./ipc-lix.mjs"; +import { ensureLixOpen } from "./lix.mjs"; +import { getWorkspace } from "./workspace.mjs"; +import { getShareRuntime } from "./share-runtime.mjs"; +import { createShareService } from "./share-service.mjs"; + +export function registerShareIpc(resolveWindow) { + const runtime = getShareRuntime(); + const service = createShareService({ runtime, getLix: ensureLixOpen }); + const context = (event) => { + const window = resolveWindow(event); + const workspace = window && getWorkspace(window); + if (!window || window.isDestroyed() || !workspace) + throw new Error("Open a workspace first."); + return { window, workspace }; + }; + const file = (value) => { + if ( + typeof value !== "string" || + !value.startsWith("/") || + value.includes("\\") || + [...value].some((character) => character.charCodeAt(0) < 32) || + value + .split("/") + .slice(1) + .some((p) => !p || p === "." || p === "..") + ) + throw new Error("A file path is required."); + return value; + }; + ipcMain.handle("share:reconnect", async (event) => { + const { window } = context(event); + await closeLixSession(window); + if (!window.isDestroyed()) window.webContents.reload(); + }); + ipcMain.handle("share:setToken", (event, token) => { + context(event); + return runtime.auth.setToken(token); + }); + ipcMain.handle("share:status", (event, filePath) => + service.status(context(event).workspace, file(filePath)), + ); + ipcMain.handle("share:connect", (event, confirmed) => { + if (confirmed !== true) + throw new Error("Confirm private repository sync before connecting."); + const { window, workspace } = context(event); + return service.connect(workspace, window); + }); + ipcMain.handle("share:publish", (event, filePath, publish) => { + if (typeof publish !== "boolean") + throw new Error("Invalid publication action."); + const { window, workspace } = context(event); + return service.publish(workspace, window, file(filePath), publish); + }); +} diff --git a/electron/share-runtime.mjs b/electron/share-runtime.mjs new file mode 100644 index 00000000..74e988da --- /dev/null +++ b/electron/share-runtime.mjs @@ -0,0 +1,63 @@ +import { fetchSync } from "./share-http.mjs"; +import { app, safeStorage } from "electron"; +import { createShareAuth } from "./share-auth.mjs"; +import { createShareStore } from "./share-store.mjs"; +let runtime; +export function getShareRuntime() { + if (!runtime) { + const origin = + process.env.FLASHTYPE_DEV_RUNTIME === "1" && + process.env.FLASHTYPE_DEV_LIXRAY_ORIGIN + ? new URL(process.env.FLASHTYPE_DEV_LIXRAY_ORIGIN).origin + : "https://lixray.com"; + const url = new URL(origin); + if ( + url.protocol !== "https:" && + !( + url.protocol === "http:" && + ["127.0.0.1", "localhost"].includes(url.hostname) + ) + ) + throw new Error("Lixray requires HTTPS."); + const store = createShareStore( + app.getPath("userData"), + origin, + safeStorage, + ); + runtime = { + origin, + store, + auth: createShareAuth({ + store, + origin, + }), + errors: new Map(), + }; + } + return runtime; +} +export async function getShareServer(workspace) { + const runtime = getShareRuntime(); + try { + const connection = await runtime.store.getConnection(workspace.path); + if (!connection?.repository) return undefined; + const url = new URL(connection.repository.url); + if (url.origin !== runtime.origin) + throw new Error("The saved sync server does not match Lixray."); + if (connection.accountId !== (await runtime.auth.subject())) + throw new Error( + "Paste a token for the Lixray account connected to this repository.", + ); + runtime.errors.delete(workspace.path); + return { + url: url.href, + fetch: fetchSync, + headers: async () => ({ + Authorization: `Bearer ${await runtime.auth.token()}`, + }), + }; + } catch (error) { + runtime.errors.set(workspace.path, error.message); + return undefined; + } +} diff --git a/electron/share-service.mjs b/electron/share-service.mjs new file mode 100644 index 00000000..c63f0e5a --- /dev/null +++ b/electron/share-service.mjs @@ -0,0 +1,182 @@ +import { readSharingJson, withRequestTimeout } from "./share-http.mjs"; +import { createHash } from "node:crypto"; +import { openLix } from "@lix-js/sdk"; +import { mkdir, rename, rm } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +/** A durable snapshot makes uncertain HTTP retries independent of ongoing edits. */ +export function createShareService({ + runtime, + getLix, + fetcher = fetch, + openRemote = openLix, + waitForSync = () => new Promise((resolve) => setTimeout(resolve, 1000)), +}) { + const pending = new Map(); + async function request(endpoint, init = {}) { + const response = await withRequestTimeout( + fetcher, + endpoint === "/lix/v1" ? 600_000 : 10_000, + )(new URL(endpoint, runtime.origin), { + ...init, + headers: { + ...init.headers, + Authorization: `Bearer ${await runtime.auth.token()}`, + }, + }); + const body = await readSharingJson(response); + if (!response.ok) + throw new Error( + typeof body.error === "string" + ? body.error + : (body.error?.message ?? + "Lixray sharing is unavailable. Please try again."), + ); + return body; + } + const connectionFor = async (workspace) => { + const value = await runtime.store.getConnection(workspace.path); + if (!value?.repository) + throw new Error("Connect this repository to Lixray first."); + if (new URL(value.repository.url).origin !== runtime.origin) + throw new Error("Unexpected saved sync server."); + if (value.accountId !== (await runtime.auth.subject())) + throw new Error( + "Paste a token for the Lixray account connected to this repository.", + ); + return value; + }; + return { + async status(workspace, filePath) { + const hasToken = await runtime.auth.hasToken(); + const connection = workspace.ephemeral + ? null + : await runtime.store.getConnection(workspace.path); + const mismatch = + hasToken && + connection && + connection.accountId !== (await runtime.auth.subject()); + const error = mismatch + ? "Paste a token for the account that started sharing this repository." + : runtime.errors.get(workspace.path); + if (!hasToken || !connection?.repository || mismatch) + return { hasToken, connected: Boolean(connection?.repository), error }; + const result = await request( + `/api/repositories/${connection.repository.id}/share?path=${encodeURIComponent(filePath)}`, + ); + return { ...result, hasToken, connected: true, error }; + }, + async connect(workspace, window) { + if (workspace.ephemeral) + throw new Error("Initialize this repository before sharing."); + if (pending.has(workspace.path)) return pending.get(workspace.path); + const operation = (async () => { + const accountId = await runtime.auth.subject(); + let connection = await runtime.store.getConnection(workspace.path); + if (connection && connection.accountId !== accountId) + throw new Error( + "Paste a token for the account that started sharing this repository.", + ); + if (connection?.repository) return { reload: true }; + const snapshot = runtime.store.snapshotPath(workspace.path); + if (!connection?.idempotencyKey) { + await mkdir(path.dirname(snapshot), { recursive: true, mode: 0o700 }); + const staging = `${snapshot}.${randomUUID()}.tmp`; + try { + await (await getLix(window)).exportShareSnapshot(staging); + await rename(staging, snapshot); + } finally { + await rm(staging, { force: true }); + } + connection = { idempotencyKey: randomUUID(), accountId }; + await runtime.store.saveConnection(workspace.path, connection); + } + const repository = await request("/lix/v1", { + method: "POST", + headers: { + "Content-Type": "application/vnd.lix.snapshot", + "Idempotency-Key": connection.idempotencyKey, + }, + body: createReadStream(snapshot), + duplex: "half", + }); + if ( + !/^[0-9a-f-]{36}$/.test(repository.id) || + repository.url !== `${runtime.origin}/lix/${repository.id}` + ) + throw new Error("Lixray returned an invalid repository URL."); + await runtime.store.saveConnection(workspace.path, { + ...connection, + repository, + }); + await rm(snapshot, { force: true }); + return { reload: true }; + })().finally(() => { + pending.delete(workspace.path); + }); + pending.set(workspace.path, operation); + return operation; + }, + async publish(workspace, window, filePath, publish) { + const connection = await connectionFor(workspace); + const lix = await getLix(window); + const result = await lix.execute( + "SELECT id, content FROM lix_file WHERE path = $1", + [filePath], + ); + if (!result.rows.length) + throw new Error( + "This file no longer exists. Open a file before sharing.", + ); + if (publish) { + if (runtime.errors.has(workspace.path)) + throw new Error("Reconnect sync from Share before publishing."); + const remote = await openRemote({ + server: { + url: connection.repository.url, + fetch: withRequestTimeout(fetcher), + headers: async () => ({ + Authorization: `Bearer ${await runtime.auth.token()}`, + }), + }, + }); + try { + let found = false; + for (let attempt = 0; attempt < 15; attempt++) { + const current = await remote.execute( + "SELECT id, content FROM lix_file WHERE path = $1", + [filePath], + ); + if ( + current.rows[0]?.id === result.rows[0]?.id && + fileContentHash(current.rows[0]?.content) === + fileContentHash(result.rows[0]?.content) + ) { + found = true; + break; + } + await waitForSync(); + } + if (!found) + throw new Error( + "This file is still syncing. Wait a moment, then publish again.", + ); + } finally { + await remote.close(); + } + } + return request( + `/api/repositories/${connection.repository.id}/share?path=${encodeURIComponent(filePath)}`, + { method: publish ? "POST" : "DELETE" }, + ); + }, + }; +} + +function fileContentHash(value) { + if (!(value instanceof Uint8Array)) + throw new Error("The file content could not be read for sharing."); + return createHash("sha256").update(value).digest("hex"); +} diff --git a/electron/share-service.test.mjs b/electron/share-service.test.mjs new file mode 100644 index 00000000..c6d6130a --- /dev/null +++ b/electron/share-service.test.mjs @@ -0,0 +1,118 @@ +// @vitest-environment node +import { test, expect, vi } from "vitest"; +import { mkdtemp, writeFile, rm, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createShareService } from "./share-service.mjs"; + +test("creation retries reuse identical persisted bytes even after restart and source edits", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "share-retry-")); + let connection, + source = "original snapshot"; + const snapshot = path.join(root, "upload.lixsnap"); + const runtime = { + origin: "https://lixray.test", + auth: { token: async () => "token", subject: async () => "owner" }, + errors: new Map(), + store: { + getConnection: async () => connection, + saveConnection: async (_path, value) => { + connection = structuredClone(value); + }, + snapshotPath: () => snapshot, + }, + }; + const exported = vi.fn(async (destination) => { + await writeFile(destination, source); + }); + const uploads = []; + const fetcher = async (_input, init) => { + const chunks = []; + for await (const chunk of init.body) chunks.push(chunk); + uploads.push({ + key: init.headers["Idempotency-Key"], + bytes: Buffer.concat(chunks).toString(), + }); + return uploads.length === 1 + ? Response.json( + { error: { message: "Retry registration" } }, + { status: 503 }, + ) + : Response.json( + { + id: "11111111-1111-4111-8111-111111111111", + url: "https://lixray.test/lix/11111111-1111-4111-8111-111111111111", + }, + { status: 201 }, + ); + }; + try { + const makeService = () => + createShareService({ + runtime, + getLix: async () => ({ exportShareSnapshot: exported }), + fetcher, + }); + await expect(makeService().connect({ path: root }, {})).rejects.toThrow( + "Retry registration", + ); + source = "edited source"; + expect(await makeService().connect({ path: root }, {})).toEqual({ + reload: true, + }); + expect(uploads[0]).toEqual(uploads[1]); + expect(exported).toHaveBeenCalledOnce(); + await expect(readFile(snapshot)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +test("publication waits for content equality, not just the same file id", async () => { + const content = new TextEncoder().encode("new content"); + let reads = 0, + published = false; + const connection = { + accountId: "owner", + repository: { + id: "11111111-1111-4111-8111-111111111111", + url: "https://lixray.test/lix/11111111-1111-4111-8111-111111111111", + }, + }; + const runtime = { + origin: "https://lixray.test", + auth: { token: async () => "token", subject: async () => "owner" }, + errors: new Map(), + store: { getConnection: async () => connection }, + }; + const close = vi.fn(); + const service = createShareService({ + runtime, + getLix: async () => ({ + execute: async () => ({ rows: [{ id: "file", content }] }), + }), + openRemote: async () => ({ + execute: async () => ({ + rows: [ + { + id: "file", + content: + ++reads === 1 ? new TextEncoder().encode("stale") : content, + }, + ], + }), + close, + }), + waitForSync: async () => { + expect(published).toBe(false); + }, + fetcher: async () => { + published = true; + return Response.json({ published: true }); + }, + }); + expect( + await service.publish({ path: "/workspace" }, {}, "/a.md", true), + ).toEqual({ published: true }); + expect(reads).toBe(2); + expect(close).toHaveBeenCalledOnce(); +}); diff --git a/electron/share-store.mjs b/electron/share-store.mjs new file mode 100644 index 00000000..394b306f --- /dev/null +++ b/electron/share-store.mjs @@ -0,0 +1,72 @@ +import { mkdir, readFile, rename, writeFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; + +export function createShareStore(directory, origin, encryption) { + const root = path.join( + directory, + "sharing", + createHash("sha256").update(origin).digest("hex"), + ); + const authPath = path.join(root, "auth.enc"); + async function atomic(file, data) { + await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + const temp = `${file}.${randomUUID()}.tmp`; + await writeFile(temp, data, { mode: 0o600 }); + await rename(temp, file); + } + const connectionPath = (workspace) => + path.join( + root, + createHash("sha256").update(path.resolve(workspace)).digest("hex"), + "connection.json", + ); + async function read(file) { + try { + return await readFile(file); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } + } + async function identity(workspace) { + const info = await stat(path.join(workspace, ".lix")); + return `${info.dev}:${info.ino}:${info.birthtimeMs}`; + } + return { + async getAuth() { + const data = await read(authPath); + if (!data) return null; + if (!encryption.isEncryptionAvailable()) + throw new Error("Secure credential storage is unavailable."); + return JSON.parse(encryption.decryptString(data)); + }, + async setAuth(value) { + if ( + !encryption.isEncryptionAvailable() || + encryption.getSelectedStorageBackend?.() === "basic_text" + ) + throw new Error("Secure credential storage is unavailable."); + await atomic(authPath, encryption.encryptString(JSON.stringify(value))); + }, + async getConnection(workspace) { + const bytes = await read(connectionPath(workspace)); + if (!bytes) return null; + const connection = JSON.parse(bytes); + if (connection.identity !== (await identity(workspace))) return null; + return connection; + }, + async saveConnection(workspace, connection) { + await atomic( + connectionPath(workspace), + JSON.stringify({ ...connection, identity: await identity(workspace) }), + ); + }, + snapshotPath(workspace) { + return path.join( + path.dirname(connectionPath(workspace)), + "upload.lixsnap", + ); + }, + }; +} diff --git a/electron/share-store.test.mjs b/electron/share-store.test.mjs new file mode 100644 index 00000000..30d259ec --- /dev/null +++ b/electron/share-store.test.mjs @@ -0,0 +1,45 @@ +// @vitest-environment node +import { test, expect } from "vitest"; +import { mkdtemp, mkdir, rm, readFile, readdir } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createShareStore } from "./share-store.mjs"; + +test("credentials are encrypted and replaced repositories do not inherit sync", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "share-store-")); + const workspace = path.join(dir, "workspace"); + await mkdir(path.join(workspace, ".lix"), { recursive: true }); + const encryption = { + isEncryptionAvailable: () => true, + encryptString: (text) => Buffer.from(Buffer.from(text).toString("base64")), + decryptString: (bytes) => + Buffer.from(bytes.toString(), "base64").toString(), + }; + try { + const store = createShareStore(dir, "https://lixray.test", encryption); + await store.setAuth({ tokens: { access_token: "secret-token" } }); + expect(await store.getAuth()).toEqual({ + tokens: { access_token: "secret-token" }, + }); + const [host] = await readdir(path.join(dir, "sharing")); + expect( + (await readFile(path.join(dir, "sharing", host, "auth.enc"))).toString(), + ).not.toContain("secret-token"); + await store.saveConnection(workspace, { repository: { id: "original" } }); + expect((await store.getConnection(workspace)).repository.id).toBe( + "original", + ); + await rm(path.join(workspace, ".lix"), { recursive: true }); + await mkdir(path.join(workspace, ".lix")); + expect(await store.getConnection(workspace)).toBeNull(); + const insecure = createShareStore(dir, "https://other.test", { + ...encryption, + isEncryptionAvailable: () => false, + }); + await expect(insecure.setAuth({ token: "secret" })).rejects.toThrow( + "Secure credential storage", + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/electron/types.d.ts b/electron/types.d.ts index daa501ef..05e50135 100644 --- a/electron/types.d.ts +++ b/electron/types.d.ts @@ -120,14 +120,14 @@ export type DesktopAgentName = "claude" | "codex"; export type DesktopAgentAuthStatus = | "unknown" | "notSignedIn" - | "signedIn" + | "hasToken" | "free" | "paid"; export type DesktopAgentPreferenceReason = | "paid" | "free" - | "signedIn" + | "hasToken" | "supportedVersion" | "installed" | "fallback"; @@ -282,6 +282,7 @@ export type DesktopWatchedFilesystemEntry = { export type DesktopWorkspaceApi = { get(): Promise; getRecovery(): Promise; + deleteLixAndRestart(workspacePath: string): Promise; clearRecovery(): Promise; /** Returns workspace-relative file paths queued for editor opening. */ consumePendingOpenFiles(): Promise; @@ -321,6 +322,13 @@ export type DesktopWorkspaceApi = { resetLixRepository(): Promise; disableTrackChanges(): Promise; initializeRepository(): Promise; + inspectRepositorySize(): Promise<{ + fileCount: number; + totalBytes: number; + complete: boolean; + allowed: boolean; + maxBytes: number; + }>; resolveMarkdownImageSrc(payload: { src: string; sourceFilePath: string; @@ -411,6 +419,23 @@ declare global { flashtypeDesktop?: { agentHooks: DesktopAgentHooksApi; app: DesktopAppApi; + share: { + setToken(token: string): Promise; + status(path: string): Promise<{ + hasToken: boolean; + connected: boolean; + published?: boolean; + inherited?: boolean; + url?: string; + error?: string; + }>; + connect(confirmed: boolean): Promise<{ reload: boolean }>; + publish( + path: string, + publish: boolean, + ): Promise<{ published: boolean; inherited: boolean; url: string }>; + reconnect(): Promise; + }; platform: string; telemetry: DesktopTelemetryApi; lix: DesktopLixApi; diff --git a/electron/workspace-open-preflight.mjs b/electron/workspace-open-preflight.mjs new file mode 100644 index 00000000..fcb428fc --- /dev/null +++ b/electron/workspace-open-preflight.mjs @@ -0,0 +1,19 @@ +import { readWorkspaceRecovery } from "./workspace-recovery.mjs"; +import { inspectRepositorySize } from "./repository-limits.mjs"; + +// Run before constructing filesystem storage or calling the native SDK. A +// renderer retry or a background IPC request must not bypass crash recovery. +export async function assertWorkspaceCanOpen(userDataPath, workspacePath) { + const recovery = await readWorkspaceRecovery(userDataPath, workspacePath); + if (recovery) { + throw new Error( + "This repository needs recovery. Delete .lix or explicitly choose Try again before opening it.", + ); + } + const size = await inspectRepositorySize(workspacePath); + if (!size.allowed) { + throw new Error( + "Cannot open persistent history: this folder exceeds the 1 GB total-size limit. Delete .lix to open the folder with temporary history. Your normal files will be preserved.", + ); + } +} diff --git a/electron/workspace-open-preflight.test.mjs b/electron/workspace-open-preflight.test.mjs new file mode 100644 index 00000000..e443199b --- /dev/null +++ b/electron/workspace-open-preflight.test.mjs @@ -0,0 +1,47 @@ +// @vitest-environment node +import { mkdtemp, mkdir, open, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { afterEach, expect, test } from "vitest"; +import { assertWorkspaceCanOpen } from "./workspace-open-preflight.mjs"; +import { writeWorkspaceRecoverySync } from "./workspace-recovery.mjs"; +const roots = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((p) => rm(p, { recursive: true, force: true })), + ); +}); +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), "flashtype-preflight-")); + roots.push(root); + const workspace = path.join(root, "Downloads"); + await mkdir(workspace); + return { root, workspace }; +} +test("blocks oversized existing repositories before native opening", async () => { + const { root, workspace } = await fixture(); + await mkdir(path.join(workspace, ".lix")); + const file = await open(path.join(workspace, "large.zip"), "w"); + await file.truncate(1_000_000_001); + await file.close(); + await expect(assertWorkspaceCanOpen(root, workspace)).rejects.toThrow( + "Cannot open persistent history", + ); +}); +test("blocks a recorded crash even when the folder is below the limit", async () => { + const { root, workspace } = await fixture(); + await writeFile(path.join(workspace, "note.md"), "safe"); + writeWorkspaceRecoverySync(root, { + workspacePath: workspace, + reason: "previous_lix_open_crash", + }); + await expect(assertWorkspaceCanOpen(root, workspace)).rejects.toThrow( + "needs recovery", + ); +}); +test("allows a small repository without a crash report", async () => { + const { root, workspace } = await fixture(); + await expect( + assertWorkspaceCanOpen(root, workspace), + ).resolves.toBeUndefined(); +}); diff --git a/electron/workspace-open-recovery.mjs b/electron/workspace-open-recovery.mjs new file mode 100644 index 00000000..8ecc1c88 --- /dev/null +++ b/electron/workspace-open-recovery.mjs @@ -0,0 +1,56 @@ +import { readFile, writeFile, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import { + clearWorkspaceLixOpenPendingSync, + clearWorkspaceRecoverySync, + writeWorkspaceRecoverySync, +} from "./workspace-recovery.mjs"; + +export const RECOVERY_RESTART_EXIT_CODE = 75; +const REQUEST_FILE = "workspace-delete-lix-on-restart.json"; + +// Only called after the user explicitly chooses deletion. Never delete while +// the old process may still be opening or writing the repository. +export async function scheduleWorkspaceLixDeletion( + userDataPath, + workspacePath, +) { + if (!path.isAbsolute(workspacePath)) + throw new Error("Expected an absolute workspace path."); + const file = path.join(userDataPath, REQUEST_FILE); + await writeFile(`${file}.tmp`, JSON.stringify({ workspacePath }), "utf8"); + await rename(`${file}.tmp`, file); +} + +export async function applyScheduledWorkspaceLixDeletion(userDataPath) { + const file = path.join(userDataPath, REQUEST_FILE); + let request; + try { + request = JSON.parse(await readFile(file, "utf8")); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + // Consume once, even if deletion fails. A later restart must not unexpectedly + // delete a repository the user subsequently repaired or initialized. + await rm(file); + const workspacePath = request?.workspacePath; + if (typeof workspacePath !== "string" || !path.isAbsolute(workspacePath)) { + throw new Error("Invalid repository recovery request."); + } + try { + await rm(path.join(workspacePath, ".lix"), { + recursive: true, + force: true, + }); + clearWorkspaceLixOpenPendingSync(userDataPath, workspacePath); + clearWorkspaceRecoverySync(userDataPath, workspacePath); + } catch (error) { + clearWorkspaceLixOpenPendingSync(userDataPath, workspacePath); + writeWorkspaceRecoverySync(userDataPath, { + workspacePath, + reason: "lix_deletion_failed", + message: String(error), + }); + } +} diff --git a/electron/workspace-open-recovery.test.mjs b/electron/workspace-open-recovery.test.mjs new file mode 100644 index 00000000..08f2c891 --- /dev/null +++ b/electron/workspace-open-recovery.test.mjs @@ -0,0 +1,70 @@ +// @vitest-environment node +import { + mkdtemp, + mkdir, + writeFile, + readFile, + rm, + access, + symlink, +} from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { afterEach, expect, test } from "vitest"; +import { + applyScheduledWorkspaceLixDeletion, + scheduleWorkspaceLixDeletion, +} from "./workspace-open-recovery.mjs"; +import { + writeWorkspaceRecoverySync, + readWorkspaceRecovery, + markWorkspaceLixOpenPendingSync, + recoverPendingWorkspaceLixOpenSync, +} from "./workspace-recovery.mjs"; +const roots = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), "flashtype-recovery-")); + roots.push(root); + const workspace = path.join(root, "workspace"); + const userData = path.join(root, "user-data"); + await mkdir(path.join(workspace, ".lix"), { recursive: true }); + await mkdir(userData); + await writeFile(path.join(workspace, "notes.md"), "keep me"); + await writeFile(path.join(workspace, ".lix", "history"), "history"); + return { root, workspace, userData }; +} +test("scheduling preserves history; startup deletes only .lix and clears stale recovery once", async () => { + const { workspace, userData } = await fixture(); + writeWorkspaceRecoverySync(userData, { workspacePath: workspace }); + markWorkspaceLixOpenPendingSync(userData, { workspacePath: workspace }); + await scheduleWorkspaceLixDeletion(userData, workspace); + expect(await readFile(path.join(workspace, ".lix", "history"), "utf8")).toBe( + "history", + ); + await applyScheduledWorkspaceLixDeletion(userData); + await expect(access(path.join(workspace, ".lix"))).rejects.toThrow(); + expect(await readFile(path.join(workspace, "notes.md"), "utf8")).toBe( + "keep me", + ); + expect(await readWorkspaceRecovery(userData, workspace)).toBeNull(); + expect(recoverPendingWorkspaceLixOpenSync(userData)).toBe(0); + await mkdir(path.join(workspace, ".lix")); + await applyScheduledWorkspaceLixDeletion(userData); + await access(path.join(workspace, ".lix")); +}); +test("does not follow a .lix symlink into another folder", async () => { + const { root, workspace, userData } = await fixture(); + const target = path.join(root, "other"); + await mkdir(target); + await writeFile(path.join(target, "keep"), "safe"); + await rm(path.join(workspace, ".lix"), { recursive: true }); + await symlink(target, path.join(workspace, ".lix")); + await scheduleWorkspaceLixDeletion(userData, workspace); + await applyScheduledWorkspaceLixDeletion(userData); + expect(await readFile(path.join(target, "keep"), "utf8")).toBe("safe"); +}); diff --git a/electron/workspace.mjs b/electron/workspace.mjs index 619dc3f5..a0ca15fe 100644 --- a/electron/workspace.mjs +++ b/electron/workspace.mjs @@ -1,3 +1,4 @@ +import { assertRepositorySize } from "./repository-limits.mjs"; import { dialog, ipcMain } from "electron"; import os from "node:os"; import path from "node:path"; @@ -470,6 +471,7 @@ export async function setWorkspaceTrackChanges(window, trackChanges) { return workspace; } if (trackChanges) { + await assertRepositorySize(workspace.path); disposeAgentTurnFileCaptures(state); disposeEphemeralFileTreeState(state); await moveExternalLixBackIntoWorkspace(state); diff --git a/package.json b/package.json index 8b6efb00..d57de100 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,9 @@ "pretest:e2e:headed": "pnpm run pretest:e2e", "test:e2e:headed": "FLASHTYPE_HEADLESS=0 playwright test", "test:e2e:packaged-macos": "playwright test --config playwright.packaged.config.ts", - "ci": "pnpm run build && pnpm test" + "ci": "pnpm run build && pnpm test", + "dev:sync": "node scripts/dev-sync.mjs", + "predev:sync": "pnpm run predev" }, "dependencies": { "@base-ui-components/react": "1.0.0-beta.4", @@ -42,8 +44,8 @@ "@dnd-kit/utilities": "^3.2.2", "@glideapps/glide-data-grid": "^6.0.3", "@lix-js/html-diff": "^0.1.0", - "@lix-js/sdk": "0.15.1", - "@lix-js/storage-filesystem": "0.15.1", + "@lix-js/sdk": "0.16.0", + "@lix-js/storage-filesystem": "0.16.0", "@opral/atelier": "workspace:*", "@pierre/trees": "1.0.0-beta.4", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57ac015f..24b0369e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - '@lix-js/sdk': 0.15.1 + '@lix-js/sdk': 0.16.0 + '@lix-js/storage-filesystem': 0.16.0 prosemirror-model: 1.25.10 prosemirror-transform: 1.12.0 prosemirror-view: 1.42.0 @@ -33,11 +34,11 @@ importers: specifier: ^0.1.0 version: 0.1.0 '@lix-js/sdk': - specifier: 0.15.1 - version: 0.15.1 + specifier: 0.16.0 + version: 0.16.0 '@lix-js/storage-filesystem': - specifier: 0.15.1 - version: 0.15.1(@lix-js/sdk@0.15.1) + specifier: 0.16.0 + version: 0.16.0(@lix-js/sdk@0.16.0) '@opral/atelier': specifier: workspace:* version: link:submodule/atelier @@ -354,9 +355,15 @@ importers: pdfjs-dist: specifier: 5.4.624 version: 5.4.624 + perfect-freehand: + specifier: 1.2.0 + version: 1.2.0 react-resizable-panels: specifier: ^4.12.1 version: 4.12.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + roughjs: + specifier: 4.6.6 + version: 4.6.6 tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -371,8 +378,8 @@ importers: version: 2.8.2 devDependencies: '@lix-js/sdk': - specifier: 0.15.1 - version: 0.15.1 + specifier: 0.16.0 + version: 0.16.0 '@tailwindcss/vite': specifier: ^4.3.2 version: 4.3.2(vite@8.1.4(@types/node@24.10.15)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.97.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -1541,33 +1548,33 @@ packages: '@lix-js/html-diff@0.1.0': resolution: {integrity: sha512-4dr7+xo8UEncWGjne9PoL6KqFGcOHZXyeBTTghTW196kK5xidQ8h4hufjHXYxv6x5VAFzXcBCuFnW1GgPS5kqw==} - '@lix-js/sdk-darwin-arm64@0.15.1': - resolution: {integrity: sha512-RTe5hNe8lkF4uZ/ktKQFaXbFtowf617XTLs8Q/lPbAvCN/87A7KkmGyWV2mO8uZyposn/UO8q2NnD2yns8bPPA==} + '@lix-js/sdk-darwin-arm64@0.16.0': + resolution: {integrity: sha512-nSDCjB4m96SnUpG9uSA2c6sGkUtNdmMp3tqFHSJfgNaib4yP4b0FSGJGhcWRqEw2iWqw/oPtbOdYxpCwHYFodw==} cpu: [arm64] os: [darwin] - '@lix-js/sdk-linux-arm64@0.15.1': - resolution: {integrity: sha512-uxyc4NzNGDVyytz8CZblVW++X1JtP9qin28CVyqLKV3iM0CQ/VcUog0WwyEWL7OogvjXilZLJdu1pskDzsRm1A==} + '@lix-js/sdk-linux-arm64@0.16.0': + resolution: {integrity: sha512-vAvfgxCx8uzP9pQuE84gYctRYtiJ+L5O5U0Hl2tCOnx/G+JqV/MT6FyLKX54HKjXTvgIz9YSDbyPBj0vuxMuHA==} cpu: [arm64] os: [linux] - '@lix-js/sdk-linux-x64@0.15.1': - resolution: {integrity: sha512-gCEN5Ql2JvQn4MpwiILpmFxxO3AHAfmoa4mMvBWFwdKUICaJf5Yzr0zUfHEeLYtvcKpXbpu6dxyZ+AOc8I18ng==} + '@lix-js/sdk-linux-x64@0.16.0': + resolution: {integrity: sha512-9XOIg62ZwDBMO9ERk6yfUmg7qZppim1rFEvkQm6vMhczX6emMZFHvk9zOEc8ktsRr5XwQ6KoKFgePkbU6USCpA==} cpu: [x64] os: [linux] - '@lix-js/sdk-win32-x64@0.15.1': - resolution: {integrity: sha512-hyo2Sj67mBwhMZ2DFO1UgN4barByYiHojUnVQI9WGue5Slva5vBusbecQG1XSXrYv6HkVxLpS2zwUrr7DTjWQg==} + '@lix-js/sdk-win32-x64@0.16.0': + resolution: {integrity: sha512-Hn+Qhg87S6XGNPmGxBtawT8KfjreRwPxlKq21NoeS+VREo9QammIgQZc4nBUs6V+uiHhf6PBQBuVGtu8dWJZ1g==} cpu: [x64] os: [win32] - '@lix-js/sdk@0.15.1': - resolution: {integrity: sha512-mgtPwKg1Etx4NLGADesa+0DNkwVYOrSsIFVhv+VaBGJTisFYMVMucgzYXZQ1XHRpA1jxdFaKhheK3x3tArvv8Q==} + '@lix-js/sdk@0.16.0': + resolution: {integrity: sha512-oMwU3nPpwIj1kDttjK7EVqjyHmC7xaU4dTFmz7jeeT2pZ4GxbmLuZSzcvojJgeSExatCzUUUZSzxW/6nNDyyCA==} - '@lix-js/storage-filesystem@0.15.1': - resolution: {integrity: sha512-mDbfsq2YFC5vznuurPiWMCTtz+hnPzSx6ita38itOINSRkLl6eVb9PLWYQVL7vXoARXSx4VEQD1yvexPzltm/g==} + '@lix-js/storage-filesystem@0.16.0': + resolution: {integrity: sha512-WizCuGFwA4LMpsoJUIeOyflAU/Jma8ooz4GBJX/Z+lASGkpCEN4Ocm80utviXNtVzB7TCTKT8F16KeuFIYy3cw==} peerDependencies: - '@lix-js/sdk': 0.15.1 + '@lix-js/sdk': 0.16.0 '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} @@ -8506,30 +8513,30 @@ snapshots: diff: 8.0.4 parse5: 7.3.0 - '@lix-js/sdk-darwin-arm64@0.15.1': + '@lix-js/sdk-darwin-arm64@0.16.0': optional: true - '@lix-js/sdk-linux-arm64@0.15.1': + '@lix-js/sdk-linux-arm64@0.16.0': optional: true - '@lix-js/sdk-linux-x64@0.15.1': + '@lix-js/sdk-linux-x64@0.16.0': optional: true - '@lix-js/sdk-win32-x64@0.15.1': + '@lix-js/sdk-win32-x64@0.16.0': optional: true - '@lix-js/sdk@0.15.1': + '@lix-js/sdk@0.16.0': dependencies: fflate: 0.8.3 optionalDependencies: - '@lix-js/sdk-darwin-arm64': 0.15.1 - '@lix-js/sdk-linux-arm64': 0.15.1 - '@lix-js/sdk-linux-x64': 0.15.1 - '@lix-js/sdk-win32-x64': 0.15.1 + '@lix-js/sdk-darwin-arm64': 0.16.0 + '@lix-js/sdk-linux-arm64': 0.16.0 + '@lix-js/sdk-linux-x64': 0.16.0 + '@lix-js/sdk-win32-x64': 0.16.0 - '@lix-js/storage-filesystem@0.15.1(@lix-js/sdk@0.15.1)': + '@lix-js/storage-filesystem@0.16.0(@lix-js/sdk@0.16.0)': dependencies: - '@lix-js/sdk': 0.15.1 + '@lix-js/sdk': 0.16.0 '@malept/cross-spawn-promise@2.0.0': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1df9a1c0..4c0bf630 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,7 +4,8 @@ packages: # Keep ProseMirror nominal types single-instance across FlashType and Atelier. overrides: - "@lix-js/sdk": 0.15.1 + "@lix-js/sdk": 0.16.0 + "@lix-js/storage-filesystem": 0.16.0 prosemirror-model: 1.25.10 prosemirror-transform: 1.12.0 prosemirror-view: 1.42.0 @@ -15,11 +16,11 @@ peerDependencyRules: "@glideapps/glide-data-grid>react-dom": "^19.0.0" minimumReleaseAgeExclude: - - "@lix-js/sdk-darwin-arm64@0.8.2" - - "@lix-js/sdk-linux-arm64@0.8.2" - - "@lix-js/sdk-linux-x64@0.8.2" - - "@lix-js/sdk-win32-x64@0.8.2" - - "@lix-js/sdk@0.8.2" + - "@lix-js/sdk-darwin-arm64@0.16.0" + - "@lix-js/sdk-linux-arm64@0.16.0" + - "@lix-js/sdk-linux-x64@0.16.0" + - "@lix-js/sdk-win32-x64@0.16.0" + - "@lix-js/sdk@0.16.0" allowBuilds: "@parcel/watcher": set this to true or false diff --git a/scripts/dev-sync.mjs b/scripts/dev-sync.mjs new file mode 100644 index 00000000..a132113c --- /dev/null +++ b/scripts/dev-sync.mjs @@ -0,0 +1,76 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { createLix, openLix, bundledPluginArchives } from "@lix-js/sdk"; +import { FilesystemStorage } from "@lix-js/storage-filesystem"; + +const workspace = path.resolve( + process.env.FLASHTYPE_DEV_SYNC_WORKSPACE ?? ".flashtype-dev/sync-workspace", +); +const server = process.env.FLASHTYPE_DEV_SYNC_SERVER ?? "http://127.0.0.1:8088"; +const token = process.env.FLASHTYPE_DEV_SYNC_TOKEN ?? "flashtype-local-dev"; +const stateDir = path.resolve(".flashtype-dev/sync-connections"); +const key = createHash("sha256") + .update(`${workspace}\n${server}`) + .digest("hex"); +const statePath = path.join(stateDir, `${key}.json`); +await mkdir(workspace, { recursive: true }); +await mkdir(stateDir, { recursive: true }); +let state; +try { + state = JSON.parse(await readFile(statePath, "utf8")); +} catch (error) { + if (error.code !== "ENOENT") throw error; +} +if (!state) { + state = { workspace, server, idempotencyKey: randomUUID() }; + await writeFile(statePath, JSON.stringify(state, null, 2)); +} +if (!state.repository) { + const lix = await openLix({ + storage: new FilesystemStorage({ path: workspace }), + }); + try { + for (const plugin of await bundledPluginArchives()) { + await lix.execute( + "INSERT INTO lix_file (path, content) VALUES ($1, $2) ON CONFLICT (path) DO UPDATE SET content = excluded.content", + [`/.lix/plugins/${plugin.key}.lixplugin`, plugin.archiveBytes], + ); + } + await lix.execute( + "INSERT INTO lix_file (path, content) VALUES ($1, $2) ON CONFLICT (path) DO NOTHING", + [ + "/Sync demo.md", + new TextEncoder().encode( + "# Flashtype local sync\n\nEdit this file in Flashtype or on disk. Changes sync to the local Lix server.\n", + ), + ], + ); + state.repository = await createLix({ + server: { url: server, headers: { Authorization: `Bearer ${token}` } }, + from: lix, + idempotencyKey: state.idempotencyKey, + }); + await writeFile(statePath, JSON.stringify(state, null, 2)); + } finally { + await lix.close(); + } +} +console.log(`Syncing ${workspace} to ${state.repository.url}`); +const child = spawn( + process.execPath, + ["scripts/dev.mjs", workspace, ...process.argv.slice(2)], + { + stdio: "inherit", + env: { + ...process.env, + FLASHTYPE_DEV_SYNC_WORKSPACE: workspace, + FLASHTYPE_DEV_SYNC_URL: state.repository.url, + FLASHTYPE_DEV_SYNC_TOKEN: token, + }, + }, +); +for (const signal of ["SIGINT", "SIGTERM"]) + process.on(signal, () => child.kill(signal)); +child.on("exit", (code) => process.exit(code ?? 1)); diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 2398f922..a937a5ea 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -1,3 +1,4 @@ +import { RECOVERY_RESTART_EXIT_CODE } from "../electron/workspace-open-recovery.mjs"; import { spawn, spawnSync } from "node:child_process"; import { existsSync, @@ -30,6 +31,7 @@ const electronArgs = [ ]; const children = new Set(); let shuttingDown = false; +let restartedAfterNativeCrash = false; process.on("SIGINT", () => shutdown(130)); process.on("SIGTERM", () => shutdown(143)); @@ -65,19 +67,42 @@ try { } console.log(`Starting Flashtype Electron with ${rendererUrl}`); - const electron = spawnCommand(electronCommand, electronArgs, { - env: { - ...process.env, - FLASHTYPE_DEV_RUNTIME: "1", - VITE_DEV_SERVER_URL: rendererUrl, - }, - }); + function startElectron() { + const electron = spawnCommand(electronCommand, electronArgs, { + env: { + ...process.env, + FLASHTYPE_DEV_RUNTIME: "1", + FLASHTYPE_DEV_SUPERVISED: "1", + VITE_DEV_SERVER_URL: rendererUrl, + }, + }); - electron.on("exit", (code, signal) => { - if (shuttingDown) return; - console.log(`Electron exited ${formatExitReason(code, signal)}.`); - shutdown(code ?? 0); - }); + electron.on("exit", (code, signal) => { + if (shuttingDown) return; + if (code === RECOVERY_RESTART_EXIT_CODE) { + startElectron(); + return; + } + if ( + !restartedAfterNativeCrash && + signal && + ["SIGTRAP", "SIGABRT", "SIGSEGV", "SIGBUS", "SIGKILL"].includes( + signal, + ) && + existsSync(path.join(userDataDir, "workspace-pending-lix-open.json")) + ) { + restartedAfterNativeCrash = true; + console.warn( + "Electron crashed while opening a repository. Restarting into recovery.", + ); + startElectron(); + return; + } + console.log(`Electron exited ${formatExitReason(code, signal)}.`); + shutdown(code ?? 0); + }); + } + startElectron(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); shutdown(1); @@ -109,18 +134,9 @@ function prepareMacOSDevelopmentElectronApp() { repoRoot, "node_modules/electron/dist/Electron.app", ); - const targetApp = path.resolve( - repoRoot, - ".flashtype-dev/Flashtype Dev.app", - ); - const sourceExecutable = path.join( - sourceApp, - "Contents/MacOS/Electron", - ); - const targetExecutable = path.join( - targetApp, - "Contents/MacOS/Electron", - ); + const targetApp = path.resolve(repoRoot, ".flashtype-dev/Flashtype Dev.app"); + const sourceExecutable = path.join(sourceApp, "Contents/MacOS/Electron"); + const targetExecutable = path.join(targetApp, "Contents/MacOS/Electron"); const sourceInfoPlist = path.join(sourceApp, "Contents/Info.plist"); const targetInfoPlist = path.join(targetApp, "Contents/Info.plist"); diff --git a/src/extension-runtime/editor-revision-state.ts b/src/extension-runtime/editor-revision-state.ts deleted file mode 100644 index 6f10f9fd..00000000 --- a/src/extension-runtime/editor-revision-state.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { ExtensionState } from "./types"; - -export type EditorRevisionState = { - readonly beforeCommitId: string | null; - readonly afterCommitId: string | null; -}; - -export type EditorRevisionMode = "editor" | "snapshot" | "diff"; - -const BEFORE_COMMIT_ID_STATE_KEY = "beforeCommitId"; -const AFTER_COMMIT_ID_STATE_KEY = "afterCommitId"; - -export function normalizeEditorRevisionState( - state: - | ExtensionState - | { - readonly beforeCommitId?: unknown; - readonly afterCommitId?: unknown; - } - | null - | undefined, -): EditorRevisionState { - return { - beforeCommitId: normalizeCommitId(state?.beforeCommitId), - afterCommitId: normalizeCommitId(state?.afterCommitId), - }; -} - -export function editorRevisionMode( - revision: EditorRevisionState, -): EditorRevisionMode { - if (revision.beforeCommitId) return "diff"; - if (revision.afterCommitId) return "snapshot"; - return "editor"; -} - -export function hasHistoricalEditorRevisionState( - state: ExtensionState | null | undefined, -): boolean { - const revision = normalizeEditorRevisionState(state); - return revision.beforeCommitId !== null || revision.afterCommitId !== null; -} - -export function stripEditorRevisionState( - state: ExtensionState | undefined, -): ExtensionState | undefined { - if (!state) return undefined; - const { - [BEFORE_COMMIT_ID_STATE_KEY]: _beforeCommitId, - [AFTER_COMMIT_ID_STATE_KEY]: _afterCommitId, - ...rest - } = state; - return Object.keys(rest).length > 0 ? rest : undefined; -} - -export function editorRevisionReviewId(args: { - readonly fileId: string; - readonly path: string; - readonly beforeCommitId: string | null; - readonly afterCommitId: string | null; -}): string { - return [ - "editor-revision", - args.beforeCommitId ?? "none", - args.afterCommitId ?? "head", - args.fileId, - args.path, - ].join(":"); -} - -function normalizeCommitId(value: unknown): string | null { - return typeof value === "string" && value.length > 0 ? value : null; -} diff --git a/src/extension-runtime/external-write-review-controls.css b/src/extension-runtime/external-write-review-controls.css deleted file mode 100644 index ca6572d4..00000000 --- a/src/extension-runtime/external-write-review-controls.css +++ /dev/null @@ -1,74 +0,0 @@ -.external-write-review-actions { - position: absolute; - left: 50%; - bottom: 1.5rem; - z-index: 20; - display: flex; - align-items: center; - gap: 0.35rem; - padding: 0.38rem; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 0.85rem; - background: rgb(38, 34, 31); - box-shadow: - 0 26px 70px rgba(38, 34, 31, 0.28), - 0 10px 30px rgba(38, 34, 31, 0.18), - 0 1px 0 rgba(255, 255, 255, 0.08) inset; - transform: translateX(-50%); - backdrop-filter: blur(16px); -} - -.external-write-review-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.42rem; - height: 2.05rem; - min-width: 4.9rem; - padding: 0 0.85rem; - border: 1px solid transparent; - border-radius: 0.68rem; - font-size: 0.86rem; - font-weight: 720; - line-height: 1; - white-space: nowrap; - transition: - background-color 120ms ease, - border-color 120ms ease, - color 120ms ease; -} - -.external-write-review-button-accept { - background: var(--color-bg-action-primary); - color: var(--color-text-on-action-primary); - box-shadow: - 0 1px 0 rgba(255, 255, 255, 0.18) inset, - 0 8px 18px rgba(0, 0, 0, 0.14); -} - -.external-write-review-button-accept:hover { - background: var(--color-bg-action-primary-hover); -} - -.external-write-review-button-reject { - background: transparent; - color: rgba(255, 255, 255, 0.88); -} - -.external-write-review-button-reject:hover { - background: rgba(255, 255, 255, 0.09); - color: rgb(255, 255, 255); -} - -.external-write-review-shortcut { - color: rgba(255, 255, 255, 0.72); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - font-size: 0.68rem; - font-weight: 700; - line-height: 1; -} - -.external-write-review-shortcut-dark { - color: rgba(255, 255, 255, 0.58); -} diff --git a/src/extension-runtime/external-write-review-controls.tsx b/src/extension-runtime/external-write-review-controls.tsx deleted file mode 100644 index 575d7b65..00000000 --- a/src/extension-runtime/external-write-review-controls.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useEffect } from "react"; -import "./external-write-review-controls.css"; - -type ExternalWriteReviewControlsProps = { - readonly isActive: boolean; - readonly onAccept?: () => void; - readonly onReject?: () => void; -}; - -export function ExternalWriteReviewControls({ - isActive, - onAccept, - onReject, -}: ExternalWriteReviewControlsProps) { - useEffect(() => { - if (!isActive) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - onReject?.(); - return; - } - const usesPrimaryModifier = - event.metaKey || (event.ctrlKey && !event.metaKey); - if (!usesPrimaryModifier) return; - if (event.altKey || event.shiftKey) return; - if (event.key === "Enter") { - event.preventDefault(); - event.stopPropagation(); - onAccept?.(); - return; - } - if (event.key === "Backspace" || event.key === "Delete") { - event.preventDefault(); - event.stopPropagation(); - onReject?.(); - } - }; - window.addEventListener("keydown", handleKeyDown, { capture: true }); - return () => { - window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }; - }, [isActive, onAccept, onReject]); - - return ( -

- ); -} - -function isMacPlatform(): boolean { - if (typeof navigator === "undefined") return true; - return /Mac|iPhone|iPad|iPod/.test(navigator.platform); -} diff --git a/src/extension-runtime/external-write-review.ts b/src/extension-runtime/external-write-review.ts deleted file mode 100644 index ba6a102b..00000000 --- a/src/extension-runtime/external-write-review.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type ExternalWriteReview = { - readonly fileId: string; - readonly path: string; - readonly reviewId: string; - readonly beforeCommitId: string; - readonly afterCommitId: string; - readonly agentTurnRangeIds: readonly string[]; -}; - -export type ExternalWriteReviewData = { - readonly beforeData: Uint8Array; - readonly afterData: Uint8Array; -}; diff --git a/src/extension-runtime/file-handlers.test.ts b/src/extension-runtime/file-handlers.test.ts deleted file mode 100644 index 745ab2a7..00000000 --- a/src/extension-runtime/file-handlers.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { fileExtensionFromPath, isMarkdownFilePath } from "./file-handlers"; - -describe("file handlers", () => { - test("reads literal file extensions without URL decoding", () => { - expect(fileExtensionFromPath("/docs/readme.MD")).toBe("md"); - expect(fileExtensionFromPath("/docs/readme")).toBeUndefined(); - expect(fileExtensionFromPath("/docs/readme.")).toBeUndefined(); - }); - - test("detects supported Markdown paths", () => { - expect(isMarkdownFilePath("/docs/readme.MD")).toBe(true); - expect(isMarkdownFilePath("/docs/%6d.md")).toBe(true); - expect(isMarkdownFilePath("/docs/readme.md%20")).toBe(false); - }); -}); diff --git a/src/extension-runtime/file-handlers.ts b/src/extension-runtime/file-handlers.ts deleted file mode 100644 index a768f120..00000000 --- a/src/extension-runtime/file-handlers.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function fileExtensionFromPath(filePath: string): string | undefined { - const name = filePath.split("/").pop() ?? filePath; - const dotIndex = name.lastIndexOf("."); - if (dotIndex < 0 || dotIndex === name.length - 1) return undefined; - return name.slice(dotIndex + 1).toLowerCase(); -} - -export function isMarkdownFilePath(filePath: string): boolean { - const extension = fileExtensionFromPath(filePath); - return extension === "md" || extension === "markdown"; -} diff --git a/src/extensions/atelier-host-extensions.test.ts b/src/extensions/atelier-host-extensions.test.ts index d178cd4a..f9fb6db2 100644 --- a/src/extensions/atelier-host-extensions.test.ts +++ b/src/extensions/atelier-host-extensions.test.ts @@ -5,7 +5,7 @@ describe("createFlashTypeAtelierExtensions", () => { test("registers agent terminals and the host-composed History view", () => { const extensions = createFlashTypeAtelierExtensions(); - expect(extensions.map((extension) => extension.manifest.id)).toEqual([ + expect(extensions.map((extension) => extension.id)).toEqual([ "flashtype_claude", "flashtype_codex", "atelier_history", diff --git a/src/extensions/csv/csv-data.ts b/src/extensions/csv/csv-data.ts deleted file mode 100644 index 7d0a62c2..00000000 --- a/src/extensions/csv/csv-data.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { parse } from "papaparse"; - -export type CsvRow = { - readonly rowNumber: number; - readonly cells: readonly string[]; -}; - -export type CsvParseResult = { - readonly columns: readonly string[]; - readonly rows: readonly CsvRow[]; - readonly warnings: readonly string[]; -}; - -export function parseCsv(rawCsv: string): CsvParseResult { - const result = parse(rawCsv.replace(/^\uFEFF/, ""), { - skipEmptyLines: false, - }); - const rawRows = trimTrailingEmptyRows( - result.data.map((row) => row.map((cell) => String(cell ?? ""))), - ); - const maxColumns = rawRows.reduce((max, row) => Math.max(max, row.length), 0); - if (rawRows.length === 0 || maxColumns === 0) { - return { columns: [], rows: [], warnings: csvWarnings(result.errors) }; - } - - const columns = normalizeHeaders(rawRows[0] ?? [], maxColumns); - const rows = rawRows.slice(1).map((row, index) => ({ - rowNumber: index + 1, - cells: Array.from({ length: maxColumns }, (_, cellIndex) => - String(row[cellIndex] ?? ""), - ), - })); - return { columns, rows, warnings: csvWarnings(result.errors) }; -} - -function normalizeHeaders( - headerRow: readonly string[], - columnCount: number, -): string[] { - const seen = new Map(); - return Array.from({ length: columnCount }, (_, index) => { - const raw = headerRow[index]?.trim(); - const base = raw && raw.length > 0 ? raw : `Column ${index + 1}`; - const count = seen.get(base) ?? 0; - seen.set(base, count + 1); - return count === 0 ? base : `${base} ${count + 1}`; - }); -} - -function trimTrailingEmptyRows(rows: string[][]): string[][] { - let end = rows.length; - while (end > 0 && rows[end - 1]?.every((cell) => cell.trim() === "")) { - end -= 1; - } - return rows.slice(0, end); -} - -function csvWarnings(errors: readonly { message: string }[]): string[] { - return errors.map((error) => error.message).filter(Boolean); -} diff --git a/src/extensions/csv/index.reactive.test.tsx b/src/extensions/csv/index.reactive.test.tsx deleted file mode 100644 index f3fdb2af..00000000 --- a/src/extensions/csv/index.reactive.test.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import { Suspense } from "react"; -import { act, render, screen, waitFor } from "@testing-library/react"; -import { expect, test, vi } from "vitest"; -import { qb } from "@/lib/lix-kysely"; -import { LixProvider } from "@/lib/lix-react"; -import { openLix } from "@/test-utils/node-lix-sdk"; -import { CsvView } from "./index"; - -vi.mock("@glideapps/glide-data-grid", () => ({ - DataEditor: ({ - columns, - getCellContent, - rows, - }: { - columns: readonly { title: string }[]; - getCellContent: (cell: readonly [number, number]) => { - displayData: string; - kind: string; - data?: string; - }; - rows: number; - }) => ( -
- {columns.map((column) => ( -
{column.title}
- ))} - {Array.from({ length: rows }, (_, rowIndex) => - columns.map((_column, columnIndex) => { - const cell = getCellContent([columnIndex, rowIndex]); - return ( -
- {cell.displayData} -
- ); - }), - )} -
- ), - GridCellKind: { - Text: "text", - Uri: "uri", - }, -})); - -test("updates when CSV file data changes in Lix", async () => { - const lix = await openLix(); - let utils: ReturnType | undefined; - try { - const fileId = "file_csv_reactive"; - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/data.csv", - data: new TextEncoder().encode( - "name,value,email,url\nalpha,1,alice@example.com,https://example.com", - ), - }) - .execute(); - - await act(async () => { - utils = render( - - - - - , - ); - }); - - expect(await screen.findByText("name")).toBeInTheDocument(); - expect(screen.getByTestId("csv-cell-0-2")).toHaveAttribute( - "data-cell-kind", - "uri", - ); - expect(screen.getByTestId("csv-cell-0-2")).toHaveAttribute( - "data-cell-data", - "mailto:alice@example.com", - ); - expect(screen.getByTestId("csv-cell-0-3")).toHaveAttribute( - "data-cell-kind", - "uri", - ); - expect(screen.getByTestId("csv-cell-0-3")).toHaveAttribute( - "data-cell-data", - "https://example.com", - ); - - await act(async () => { - await qb(lix) - .updateTable("lix_file") - .set({ - data: new TextEncoder().encode("person,score\nbeta,2\ngamma,3"), - }) - .where("id", "=", fileId) - .execute(); - }); - - await waitFor(() => { - expect(screen.getByText("person")).toBeInTheDocument(); - }); - } finally { - if (utils) { - const rendered = utils; - await act(async () => { - rendered.unmount(); - }); - } - await lix.close(); - } -}); - -test("renders a read-only historical CSV snapshot from afterCommitId", async () => { - const lix = await openLix(); - let utils: ReturnType | undefined; - try { - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_csv_snapshot", - path: "/snapshot.csv", - data: new TextEncoder().encode("name,value\nsnapshot,1"), - }) - .execute(); - const snapshotCommitId = await activeCommitId(lix); - await qb(lix) - .updateTable("lix_file") - .set({ data: new TextEncoder().encode("name,value\nhead,2") }) - .where("id", "=", "file_csv_snapshot") - .execute(); - - await act(async () => { - utils = render( - - - - - , - ); - }); - - expect(await screen.findByText("snapshot")).toBeInTheDocument(); - expect(screen.queryByText("head")).toBeNull(); - expect(screen.queryByRole("button", { name: /keep/i })).toBeNull(); - expect(screen.queryByRole("button", { name: /undo/i })).toBeNull(); - } finally { - if (utils) { - await act(async () => { - utils!.unmount(); - }); - } - await lix.close(); - } -}); - -test("renders a read-only CSV diff from beforeCommitId to HEAD", async () => { - const lix = await openLix(); - let utils: ReturnType | undefined; - try { - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_csv_head_diff", - path: "/head-diff.csv", - data: new TextEncoder().encode("name,value\nbefore,1"), - }) - .execute(); - const beforeCommitId = await activeCommitId(lix); - await qb(lix) - .updateTable("lix_file") - .set({ data: new TextEncoder().encode("name,value\nhead,2") }) - .where("id", "=", "file_csv_head_diff") - .execute(); - - await act(async () => { - utils = render( - - - - - , - ); - }); - - await waitFor(() => { - expect(utils!.container.querySelector(".csv-review-table")).toBeTruthy(); - }); - expect(screen.queryByRole("button", { name: /keep/i })).toBeNull(); - expect(screen.queryByRole("button", { name: /undo/i })).toBeNull(); - } finally { - if (utils) { - await act(async () => { - utils!.unmount(); - }); - } - await lix.close(); - } -}); - -test("does not mark unchanged before-to-HEAD CSV files as fully added", async () => { - const lix = await openLix(); - let utils: ReturnType | undefined; - try { - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_csv_unchanged_head_diff", - path: "/unchanged-head-diff.csv", - data: new TextEncoder().encode("name,value\nstable,1"), - }) - .execute(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_csv_other_head_diff", - path: "/other-head-diff.csv", - data: new TextEncoder().encode("name,value\nbefore,1"), - }) - .execute(); - const beforeCommitId = await activeCommitId(lix); - await qb(lix) - .updateTable("lix_file") - .set({ data: new TextEncoder().encode("name,value\nafter,2") }) - .where("id", "=", "file_csv_other_head_diff") - .execute(); - - await act(async () => { - utils = render( - - - - - , - ); - }); - - await waitFor(() => { - expect(screen.getAllByText("stable").length).toBeGreaterThan(0); - }); - expect( - utils!.container.querySelector("[data-diff-status='added']"), - ).toBeNull(); - expect( - utils!.container.querySelector("[data-diff-status='removed']"), - ).toBeNull(); - } finally { - if (utils) { - await act(async () => { - utils!.unmount(); - }); - } - await lix.close(); - } -}); - -async function activeCommitId(lix: Awaited>) { - const result = await lix.execute( - "SELECT lix_active_branch_commit_id() AS commit_id", - ); - return result.rows[0]?.["commit_id"] as string; -} diff --git a/src/extensions/csv/index.test.ts b/src/extensions/csv/index.test.ts deleted file mode 100644 index 7767f121..00000000 --- a/src/extensions/csv/index.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, test } from "vitest"; -import type { ExternalWriteReviewData } from "@/extension-runtime/external-write-review"; -import { parseCsv, renderCsvReviewDiffHtml } from "./index"; - -describe("parseCsv", () => { - test("parses simple CSV with headers", () => { - const parsed = parseCsv("name,value\nalpha,1\nbeta,2"); - - expect(parsed.columns).toEqual(["name", "value"]); - expect(parsed.rows).toEqual([ - { rowNumber: 1, cells: ["alpha", "1"] }, - { rowNumber: 2, cells: ["beta", "2"] }, - ]); - }); - - test("uses Papa Parse semantics for quoted commas and newlines", () => { - const parsed = parseCsv( - 'name,notes\nalpha,"hello, world"\nbeta,"line 1\nline 2"', - ); - - expect(parsed.rows[0]?.cells).toEqual(["alpha", "hello, world"]); - expect(parsed.rows[1]?.cells).toEqual(["beta", "line 1\nline 2"]); - }); - - test("normalizes empty and duplicate headers", () => { - const parsed = parseCsv("name,name,\nalpha,beta,gamma"); - - expect(parsed.columns).toEqual(["name", "name 2", "Column 3"]); - }); - - test("returns an empty result for empty CSV", () => { - const parsed = parseCsv(""); - - expect(parsed.columns).toEqual([]); - expect(parsed.rows).toEqual([]); - }); -}); - -describe("renderCsvReviewDiffHtml", () => { - const review = ( - beforeCsv: string, - afterCsv: string, - ): ExternalWriteReviewData => ({ - beforeData: new TextEncoder().encode(beforeCsv), - afterData: new TextEncoder().encode(afterCsv), - }); - - test("marks changed cells", () => { - const html = renderCsvReviewDiffHtml( - review("name,value\nalpha,1\nbeta,2", "name,value\nalpha,1\nbeta,3"), - ); - - expect(html).toContain('data-diff-status="removed"'); - expect(html).toContain('data-diff-status="added"'); - expect(html).toContain(">2"); - expect(html).toContain(">3"); - }); - - test("marks added rows", () => { - const html = renderCsvReviewDiffHtml( - review("name,value\nalpha,1", "name,value\nalpha,1\nbeta,2"), - ); - - expect(html).toContain('data-diff-status="added"'); - expect(html).toContain(">beta"); - }); - - test("marks removed rows", () => { - const html = renderCsvReviewDiffHtml( - review("name,value\nalpha,1\nbeta,2", "name,value\nalpha,1"), - ); - - expect(html).toContain('data-diff-status="removed"'); - expect(html).toContain(">beta"); - }); - - test("does not reuse row keys when rows are inserted before existing rows", () => { - const html = renderCsvReviewDiffHtml( - review("name,value\nalpha,1", "name,value\nbeta,2\nalpha,1"), - ); - - const afterRowZeroMatches = html.match(/data-diff-key="after_row_0"/g); - expect(afterRowZeroMatches).toHaveLength(1); - }); -}); diff --git a/src/extensions/csv/index.tsx b/src/extensions/csv/index.tsx deleted file mode 100644 index 66e02e40..00000000 --- a/src/extensions/csv/index.tsx +++ /dev/null @@ -1,732 +0,0 @@ -import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; -import { AlertTriangle, Loader2 } from "lucide-react"; -import { - DataEditor, - GridCellKind, - type GridCell, - type GridColumn, - type Item, -} from "@glideapps/glide-data-grid"; -import "@glideapps/glide-data-grid/dist/index.css"; -import { useLix, useQueryTakeFirst } from "@/lib/lix-react"; -import { qb } from "@/lib/lix-kysely"; -import { - decodeFileDataToBytes, - decodeFileDataToText, -} from "@/lib/decode-file-data"; -import { ExternalWriteReviewControls } from "@/extension-runtime/external-write-review-controls"; -import type { - ExternalWriteReview, - ExternalWriteReviewData, -} from "@/extension-runtime/external-write-review"; -import { - editorRevisionMode, - editorRevisionReviewId, - normalizeEditorRevisionState, - type EditorRevisionState, -} from "@/extension-runtime/editor-revision-state"; -import { - useExternalWriteReview, - useExternalWriteReviewData, -} from "@/shell/external-write-review-history"; -import { parseCsv, type CsvParseResult, type CsvRow } from "./csv-data"; -import { renderCsvReviewDiffHtml } from "./render-review-diff-html"; -import "./style.css"; - -type CsvViewProps = { - readonly fileId: string; - readonly filePath?: string; - readonly isActiveView?: boolean; - readonly isPanelFocused?: boolean; - readonly beforeCommitId?: string | null; - readonly afterCommitId?: string | null; - readonly registerExternalWriteReview?: ( - review: ExternalWriteReview, - ) => () => void; - readonly onAcceptReview?: (args: { - readonly fileId: string; - readonly reviewId: string; - readonly review?: ExternalWriteReview; - }) => Promise; - readonly onRejectReview?: (args: { - readonly fileId: string; - readonly reviewId: string; - readonly review?: ExternalWriteReview; - }) => Promise; -}; - -const COLUMN_MIN_WIDTH = 112; -const COLUMN_MAX_WIDTH = 520; -const COLUMN_SAMPLE_ROW_LIMIT = 100; -const ROW_HEIGHT = 48; -const HEADER_HEIGHT = 40; -const CSV_GRID_THEME = { - accentColor: "rgb(194, 65, 12)", - accentFg: "rgb(255, 255, 255)", - accentLight: "rgb(251, 239, 228)", - bgHeader: "rgb(255, 255, 255)", - bgHeaderHasFocus: "rgb(255, 255, 255)", - bgHeaderHovered: "rgb(255, 255, 255)", - borderColor: "rgb(244, 241, 236)", - headerBottomBorderColor: "rgb(244, 241, 236)", - horizontalBorderColor: "rgb(244, 241, 236)", - linkColor: "rgb(194, 65, 12)", - resizeIndicatorColor: "rgb(234, 88, 12)", - textHeaderSelected: "rgb(124, 45, 18)", -}; - -type CsvFileRow = { - readonly id: string; - readonly path: string; - readonly data: Uint8Array; -}; - -type HistoricalFileSnapshotRow = { - readonly id: string; - readonly path: string; - readonly data: unknown; -}; - -type RawHistoricalFileSnapshotRow = { - readonly id: string; - readonly path: string | null; - readonly data: unknown | null; -}; - -type HistoricalFileSnapshotState = { - readonly commitId: string | null; - readonly loaded: boolean; - readonly snapshot: HistoricalFileSnapshotRow | undefined; -}; - -type HistoricalCsvFile = { - readonly fileRow: CsvFileRow; - readonly review: ExternalWriteReview | null; - readonly reviewData: ExternalWriteReviewData | undefined; - readonly controls: "review" | "none"; -}; - -const EMPTY_FILE_DATA = new Uint8Array(); - -export function CsvView({ - fileId, - filePath, - isActiveView = true, - isPanelFocused = true, - beforeCommitId, - afterCommitId, - registerExternalWriteReview, - onAcceptReview, - onRejectReview, -}: CsvViewProps) { - return ( - }> - - - ); -} - -function CsvViewContent({ fileId, ...props }: CsvViewProps) { - assertFileId(fileId); - - const fileRow = useQueryTakeFirst((lix) => - qb(lix) - .selectFrom("lix_file") - .select(["id", "path", "data"]) - .where("id", "=", fileId) - .limit(1), - ); - return ; -} - -function CsvViewData({ - fileId, - filePath, - fileRow, - beforeCommitId, - afterCommitId, - registerExternalWriteReview, - ...props -}: CsvViewProps & { - readonly fileRow?: CsvFileRow | undefined; -}) { - const editorRevision = normalizeEditorRevisionState({ - beforeCommitId, - afterCommitId, - }); - const revisionMode = editorRevisionMode(editorRevision); - if (revisionMode !== "editor") { - return ( - - ); - } - - const effectiveFileRow = fileRow; - const externalWriteReview = useExternalWriteReview({ - fileId: effectiveFileRow?.id, - path: effectiveFileRow?.path, - }); - useEffect(() => { - if (!externalWriteReview) return; - return registerExternalWriteReview?.(externalWriteReview); - }, [externalWriteReview, registerExternalWriteReview]); - - if (!effectiveFileRow) { - return ( -
- File not found in the workspace. -
- ); - } - - return ( - - ); -} - -function CsvHistoricalViewData({ - fileId, - filePath, - fileRow, - editorRevision, - ...props -}: Omit & { - readonly fileId: string; - readonly fileRow?: CsvFileRow | undefined; - readonly editorRevision: EditorRevisionState; -}) { - const beforeSnapshot = useHistoricalFileSnapshot( - fileId, - editorRevision.beforeCommitId, - ); - const afterSnapshot = useHistoricalFileSnapshot( - fileId, - editorRevision.afterCommitId, - ); - const historicalSnapshotsLoaded = - (!editorRevision.beforeCommitId || beforeSnapshot.loaded) && - (!editorRevision.afterCommitId || afterSnapshot.loaded); - const historicalFile = useMemo( - () => - historicalSnapshotsLoaded - ? buildHistoricalCsvFile({ - fileId, - filePath, - fileRow, - revision: editorRevision, - beforeSnapshot: beforeSnapshot.snapshot, - afterSnapshot: afterSnapshot.snapshot, - }) - : null, - [ - beforeSnapshot.snapshot, - editorRevision, - fileId, - filePath, - fileRow, - historicalSnapshotsLoaded, - afterSnapshot.snapshot, - ], - ); - - if (!historicalSnapshotsLoaded) { - return ; - } - - if (!historicalFile?.fileRow) { - return ( -
- File not found in the workspace. -
- ); - } - - return ( - - ); -} - -function CsvViewLoaded({ - fileRow, - externalWriteReview, - reviewDataOverride, - reviewControls = "review", - isActiveView = true, - isPanelFocused = true, - onAcceptReview, - onRejectReview, -}: Omit & { - readonly fileRow: CsvFileRow; - readonly externalWriteReview: ExternalWriteReview | null; - readonly reviewDataOverride?: ExternalWriteReviewData; - readonly reviewControls?: "review" | "none"; -}) { - const parsed = useMemo(() => { - return parseCsv(decodeFileDataToText(fileRow.data)); - }, [fileRow]); - - return ( -
- {parsed.warnings.length > 0 ? ( -
- - {parsed.warnings[0]} -
- ) : null} -
- {parsed.columns.length === 0 ? ( - - ) : ( - - )} - {externalWriteReview ? ( - - ) : null} -
-
- ); -} - -function CsvReviewOverlay({ - fileId, - review, - reviewDataOverride, - isActive, - controls = "review", - onAccept, - onReject, -}: { - readonly fileId: string; - readonly review: ExternalWriteReview; - readonly reviewDataOverride?: ExternalWriteReviewData; - readonly isActive: boolean; - readonly controls?: "review" | "none"; - readonly onAccept?: (args: { - readonly fileId: string; - readonly reviewId: string; - readonly review?: ExternalWriteReview; - }) => Promise; - readonly onReject?: (args: { - readonly fileId: string; - readonly reviewId: string; - readonly review?: ExternalWriteReview; - }) => Promise; -}) { - const externalReviewData = useExternalWriteReviewData( - reviewDataOverride ? null : review, - ); - const reviewData = reviewDataOverride ?? externalReviewData; - const diffHtml = useMemo( - () => (reviewData ? renderCsvReviewDiffHtml(reviewData) : null), - [reviewData], - ); - const rejectReview = () => - void onReject?.({ fileId, reviewId: review.reviewId, review }); - - return ( -
- {diffHtml ? ( -
- ) : ( -
- - Loading review… -
- )} - {controls === "review" ? ( - - void onAccept?.({ fileId, reviewId: review.reviewId, review }) - } - onReject={rejectReview} - /> - ) : null} -
- ); -} - -function CsvTable({ - parsed, - isActiveView, -}: { - readonly parsed: CsvParseResult; - readonly isActiveView: boolean; -}) { - const initialColumnWidths = useMemo( - () => - parsed.columns.map((header, index) => - measureColumnWidth(header, parsed.rows, index), - ), - [parsed.columns, parsed.rows], - ); - const [columnWidthOverrides, setColumnWidthOverrides] = useState< - Record - >({}); - useEffect(() => { - setColumnWidthOverrides({}); - }, [parsed]); - useEffect(() => { - if (!isActiveView) return; - const frame = window.requestAnimationFrame(() => { - window.dispatchEvent(new Event("resize")); - }); - return () => window.cancelAnimationFrame(frame); - }, [isActiveView]); - const columns = useMemo(() => { - return parsed.columns.map((title, index) => ({ - id: String(index), - title, - width: columnWidthOverrides[index] ?? initialColumnWidths[index], - })); - }, [columnWidthOverrides, initialColumnWidths, parsed.columns]); - const getCellContent = useCallback( - ([columnIndex, rowIndex]: Item): GridCell => { - const value = parsed.rows[rowIndex]?.cells[columnIndex] ?? ""; - const linkUrl = toExternalLinkUrl(value); - if (linkUrl) { - return { - kind: GridCellKind.Uri, - data: linkUrl, - displayData: value, - hoverEffect: true, - allowOverlay: false, - readonly: true, - copyData: value, - onClickUri: (event) => { - event.preventDefault(); - void window.flashtypeDesktop?.app.openExternal({ url: linkUrl }); - }, - }; - } - return { - kind: GridCellKind.Text, - data: value, - displayData: value, - allowOverlay: false, - readonly: true, - copyData: value, - }; - }, - [parsed.rows], - ); - const onColumnResizeEnd = useCallback( - (_column: GridColumn, newSize: number, columnIndex: number) => { - setColumnWidthOverrides((current) => ({ - ...current, - [columnIndex]: clamp(newSize, COLUMN_MIN_WIDTH, COLUMN_MAX_WIDTH), - })); - }, - [], - ); - - return ( -
- -
- ); -} - -function CsvEmptyState({ filePath }: { readonly filePath: string }) { - return ( -
-
-

- No CSV rows to display. -

-

- - {filePath} - {" "} - is empty or does not contain a header row. -

-
-
- ); -} - -function CsvLoadingSpinner() { - return ( -
-
- - Loading CSV… -
-
- ); -} - -export { parseCsv, renderCsvReviewDiffHtml }; - -function measureColumnWidth( - header: string, - rows: readonly CsvRow[], - columnIndex: number, -): number { - let widest = textWidthEstimate(header, true); - for (const row of rows.slice(0, COLUMN_SAMPLE_ROW_LIMIT)) { - widest = Math.max( - widest, - textWidthEstimate(row.cells[columnIndex] ?? "", false), - ); - } - return clamp(Math.ceil(widest + 32), COLUMN_MIN_WIDTH, COLUMN_MAX_WIDTH); -} - -function textWidthEstimate(value: string, isHeader: boolean): number { - const text = value.trim(); - if (text.length === 0) return 0; - - let width = isHeader ? 10 : 0; - for (const char of text) { - if (char === " " || char === "," || char === "." || char === ":") { - width += 4; - } else if (/[ilIj|]/.test(char)) { - width += 4.5; - } else if (/[mwMW@%#]/.test(char)) { - width += 11; - } else if (/[A-Z0-9]/.test(char)) { - width += 8; - } else { - width += 7; - } - } - return width; -} - -function toExternalLinkUrl(value: string): string | null { - const text = value.trim(); - if (/^https?:\/\/\S+$/i.test(text)) { - return text; - } - if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)) { - return `mailto:${text}`; - } - return null; -} - -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -function useHistoricalFileSnapshot( - fileId: string, - commitId: string | null, -): HistoricalFileSnapshotState { - const lix = useLix(); - const [snapshotState, setSnapshotState] = - useState({ - commitId: null, - loaded: true, - snapshot: undefined, - }); - useEffect(() => { - if (!commitId) { - setSnapshotState({ - commitId: null, - loaded: true, - snapshot: undefined, - }); - return; - } - let cancelled = false; - setSnapshotState({ - commitId, - loaded: false, - snapshot: undefined, - }); - void qb(lix) - .selectFrom("lix_file_history") - .select(["id", "path", "data"]) - .where("id", "=", fileId) - .where("lixcol_start_commit_id", "=", commitId) - .orderBy("lixcol_depth", "asc") - .limit(1) - .executeTakeFirst() - .then((row) => { - if (!cancelled) { - setSnapshotState({ - commitId, - loaded: true, - snapshot: visibleHistoricalSnapshot(row), - }); - } - }) - .catch((error: unknown) => { - if (!cancelled) { - console.warn("Failed to load historical CSV snapshot", error); - setSnapshotState({ - commitId, - loaded: true, - snapshot: undefined, - }); - } - }); - return () => { - cancelled = true; - }; - }, [commitId, fileId, lix]); - if (snapshotState.commitId !== commitId) { - return { - commitId, - loaded: commitId === null, - snapshot: undefined, - }; - } - return snapshotState; -} - -function visibleHistoricalSnapshot( - row: RawHistoricalFileSnapshotRow | undefined, -): HistoricalFileSnapshotRow | undefined { - if (!row || typeof row.path !== "string" || row.data === null) { - return undefined; - } - return { - id: row.id, - path: row.path, - data: row.data, - }; -} - -function buildHistoricalCsvFile(args: { - readonly fileId: string; - readonly filePath: string | undefined; - readonly fileRow: CsvFileRow | undefined; - readonly revision: EditorRevisionState; - readonly beforeSnapshot: HistoricalFileSnapshotRow | undefined; - readonly afterSnapshot: HistoricalFileSnapshotRow | undefined; -}): HistoricalCsvFile | null { - const mode = editorRevisionMode(args.revision); - if (mode === "editor") return null; - const path = - args.afterSnapshot?.path ?? - args.beforeSnapshot?.path ?? - args.fileRow?.path ?? - args.filePath; - if (!path) return null; - - if (mode === "snapshot") { - const data = args.afterSnapshot - ? decodeFileDataToBytes(args.afterSnapshot.data) - : null; - if (!data) return null; - return { - fileRow: { - id: args.fileId, - path, - data, - }, - review: null, - reviewData: undefined, - controls: "none", - }; - } - - const beforeData = args.beforeSnapshot - ? decodeFileDataToBytes(args.beforeSnapshot.data) - : EMPTY_FILE_DATA; - const afterData = args.revision.afterCommitId - ? args.afterSnapshot - ? decodeFileDataToBytes(args.afterSnapshot.data) - : EMPTY_FILE_DATA - : args.fileRow - ? decodeFileDataToBytes(args.fileRow.data) - : EMPTY_FILE_DATA; - - return { - fileRow: { - id: args.fileId, - path, - data: afterData, - }, - review: { - fileId: args.fileId, - path, - reviewId: editorRevisionReviewId({ - fileId: args.fileId, - path, - beforeCommitId: args.revision.beforeCommitId, - afterCommitId: args.revision.afterCommitId, - }), - beforeCommitId: args.revision.beforeCommitId ?? "", - afterCommitId: args.revision.afterCommitId ?? "", - agentTurnRangeIds: [], - }, - reviewData: { - beforeData, - afterData, - }, - controls: "none", - }; -} - -function assertFileId(fileId: unknown): asserts fileId is string { - if (typeof fileId !== "string" || fileId.length === 0) { - throw new Error("CsvView requires a non-empty fileId."); - } -} diff --git a/src/extensions/csv/render-review-diff-html.ts b/src/extensions/csv/render-review-diff-html.ts deleted file mode 100644 index 71eca57b..00000000 --- a/src/extensions/csv/render-review-diff-html.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { renderHtmlDiff } from "@lix-js/html-diff"; -import { decodeFileDataToText } from "@/lib/decode-file-data"; -import type { ExternalWriteReviewData } from "@/extension-runtime/external-write-review"; -import { parseCsv, type CsvParseResult, type CsvRow } from "./csv-data"; - -export function renderCsvReviewDiffHtml(data: ExternalWriteReviewData): string { - const beforeParsed = parseCsv(decodeFileDataToText(data.beforeData)); - const afterParsed = parseCsv(decodeFileDataToText(data.afterData)); - const beforeRows = assignCsvRowKeys(beforeParsed.rows); - const afterRows = assignCsvRowKeys(afterParsed.rows, beforeRows, "after"); - return renderHtmlDiff({ - beforeHtml: renderStaticCsvTable(beforeParsed, beforeRows), - afterHtml: renderStaticCsvTable(afterParsed, afterRows), - diffAttribute: "data-diff-key", - }); -} - -type KeyedCsvRow = CsvRow & { - readonly diffKey: string; -}; - -function assignCsvRowKeys( - rows: readonly CsvRow[], - beforeRows: readonly KeyedCsvRow[] = [], - unmatchedPrefix = "before", -): KeyedCsvRow[] { - const availableBeforeRows = new Map(); - for (const row of beforeRows) { - const signature = csvRowIdentity(row); - const entries = availableBeforeRows.get(signature) ?? []; - entries.push(row); - availableBeforeRows.set(signature, entries); - } - const usedKeys = new Set(); - return rows.map((row, index) => { - const signature = csvRowIdentity(row); - const match = availableBeforeRows - .get(signature) - ?.find((entry) => !usedKeys.has(entry.diffKey)); - const diffKey = match?.diffKey ?? `${unmatchedPrefix}_row_${index}`; - usedKeys.add(diffKey); - return { ...row, diffKey }; - }); -} - -function renderStaticCsvTable( - parsed: CsvParseResult, - rows: readonly KeyedCsvRow[], -): string { - const columnCount = parsed.columns.length; - const header = parsed.columns - .map( - (column, index) => - `${escapeHtml( - column, - )}`, - ) - .join(""); - const body = rows - .map((row) => { - const cells = Array.from({ length: columnCount }, (_, index) => { - const value = row.cells[index] ?? ""; - return `${escapeHtml( - value, - )}`; - }).join(""); - return `${cells}`; - }) - .join(""); - return `${header}${body}
`; -} - -function csvRowIdentity(row: CsvRow): string { - const firstCell = row.cells[0]?.trim(); - if (firstCell) return `first:${firstCell}`; - return `row:${row.cells.join("\u001f")}`; -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">"); -} - -function escapeAttribute(value: string): string { - return escapeHtml(value).replace(/"/g, """); -} diff --git a/src/extensions/csv/style.css b/src/extensions/csv/style.css deleted file mode 100644 index b133e5e6..00000000 --- a/src/extensions/csv/style.css +++ /dev/null @@ -1,77 +0,0 @@ -.csv-view { - position: relative; -} - -.csv-review-overlay { - position: absolute; - inset: 0; - z-index: 15; - display: flex; - height: 100%; - min-height: 0; - flex-direction: column; - overflow: auto; - background: var(--background); -} - -.csv-review-table { - min-width: 100%; - padding-bottom: 5.5rem; -} - -.csv-review-table table { - width: 100%; - border-collapse: collapse; - table-layout: fixed; -} - -.csv-review-table thead { - position: sticky; - top: 0; - z-index: 1; -} - -.csv-review-table th { - height: 40px; - border-right: 1px solid var(--color-border-table-grid); - border-bottom: 1px solid var(--color-border-subtle); - background: var(--background); - padding: 0 1rem; - color: var(--color-text-primary); - font-size: 14px; - font-weight: 700; - letter-spacing: 0; - text-align: left; - text-transform: none; -} - -.csv-review-table td { - min-width: 72px; - height: 48px; - border-right: 1px solid var(--color-border-table-grid); - border-bottom: 1px solid var(--color-border-table-grid); - padding: 0 1rem; - color: var(--color-text-primary); - font-size: 14px; - font-weight: 400; - line-height: 1.2; - white-space: normal; - word-break: break-word; -} - -.csv-review-table [data-diff-status="added"] { - background-color: var(--color-bg-diff-added); - color: var(--color-text-diff-added); -} - -.csv-review-table [data-diff-status="removed"] { - background-color: var(--color-bg-diff-removed); - color: var(--color-text-diff-removed); - text-decoration: line-through; - text-decoration-color: var(--color-border-diff-removed); -} - -.csv-review-table [data-diff-status="modified"] { - background-color: var(--color-bg-diff-modified); - color: var(--color-text-diff-modified); -} diff --git a/src/extensions/history/host-extension.test.tsx b/src/extensions/history/host-extension.test.tsx index 82d33c83..0ae0c2d9 100644 --- a/src/extensions/history/host-extension.test.tsx +++ b/src/extensions/history/host-extension.test.tsx @@ -1,8 +1,8 @@ -import { act, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { expect, test, vi } from "vitest"; import type { AtelierExtensionRuntime, - AtelierMountedExtension, + AtelierExtensionView, } from "@opral/atelier"; import { createHistoryExtension } from "./host-extension"; @@ -16,38 +16,25 @@ vi.mock("@opral/atelier", () => ({ })); for (const temporary of [true, false]) { - test(`composes History with temporary=${temporary} and forwards runtime updates`, async () => { - const element = document.createElement("div"); - document.body.append(element); + test(`composes History with temporary=${temporary} and forwards runtime updates`, () => { const runtime = { diff: { autoAccept: false } } as AtelierExtensionRuntime; - const registration = createHistoryExtension(temporary); - let mounted: AtelierMountedExtension | void; - const args = { - element, - atelier: runtime, - signal: new AbortController().signal, - view: {} as Parameters[0]["view"], - }; - try { - await act(async () => { - mounted = registration.entry.mount(args); - }); - expect(screen.getByTestId("timeline")).toHaveTextContent("false"); - expect( - screen.queryByRole("button", { name: "Initialize repository" }) !== - null, - ).toBe(temporary); - await act(async () => { - mounted?.update?.({ - ...args, - atelier: { ...runtime, diff: { ...runtime.diff!, autoAccept: true } }, - }); - }); - expect(screen.getByTestId("timeline")).toHaveTextContent("true"); - } finally { - await act(async () => mounted?.dispose?.()); - expect(element.childElementCount).toBe(0); - element.remove(); - } + const { Component } = createHistoryExtension(temporary); + const view = {} as AtelierExtensionView; + const rendered = render( + , + ); + expect(screen.getByTestId("timeline")).toHaveTextContent("false"); + expect( + screen.queryByRole("button", { name: "Initialize repository" }) !== null, + ).toBe(temporary); + rendered.rerender( + , + ); + expect(screen.getByTestId("timeline")).toHaveTextContent("true"); + rendered.unmount(); }); } diff --git a/src/extensions/history/host-extension.tsx b/src/extensions/history/host-extension.tsx index 42ad0c98..b3eb214c 100644 --- a/src/extensions/history/host-extension.tsx +++ b/src/extensions/history/host-extension.tsx @@ -1,10 +1,8 @@ -import { createRoot } from "react-dom/client"; import { History } from "lucide-react"; import { Atelier, ATELIER_BUILTIN_EXTENSION_IDS, type AtelierExtensionRegistration, - type AtelierExtensionRuntime, } from "@opral/atelier"; import { TemporaryHistoryNotice } from "@/shell/temporary-history-notice"; @@ -13,36 +11,35 @@ export function createHistoryExtension( temporary: boolean, ): AtelierExtensionRegistration { return { - manifest: { - apiVersion: 1, - id: ATELIER_BUILTIN_EXTENSION_IDS.history, - name: "History", - placement: ["left", "right", "central"], + id: ATELIER_BUILTIN_EXTENSION_IDS.history, + name: "History", + placement: ["left", "right", "central"], + icon: History, + HeaderAccessory: ({ atelier, view }) => { + if (!atelier.diff) return null; + return ( + + ); }, - entry: { - icon: History, - mount: ({ element, atelier }) => { - const root = createRoot(element); - const render = (runtime: AtelierExtensionRuntime) => { - if (!runtime.diff) - throw new Error("History requires the shell's diff runtime"); - root.render( -
- {temporary ? ( -
- -
- ) : null} - -
, - ); - }; - render(atelier); - return { - update: ({ atelier }) => render(atelier), - dispose: () => root.unmount(), - }; - }, + Component: ({ atelier, view }) => { + if (!atelier.diff) + throw new Error("History requires the shell's diff runtime"); + return ( +
+ {temporary ? ( +
+ +
+ ) : null} + +
+ ); }, }; } diff --git a/src/extensions/markdown/components/formatting-toolbar.test.tsx b/src/extensions/markdown/components/formatting-toolbar.test.tsx deleted file mode 100644 index b0ece24f..00000000 --- a/src/extensions/markdown/components/formatting-toolbar.test.tsx +++ /dev/null @@ -1,448 +0,0 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; -import { useEffect } from "react"; -import { describe, expect, test, beforeEach, afterEach, vi } from "vitest"; -import { Editor, type JSONContent } from "@tiptap/core"; -import { - MarkdownWc, - astToTiptapDoc, -} from "@/extensions/markdown/editor/tiptap-markdown-bridge"; -import { FormattingToolbar } from "./formatting-toolbar"; -import { EditorProvider, useEditorCtx } from "../editor/editor-context"; -import { buildMarkdownFromEditor } from "../editor/build-markdown-from-editor"; -import { parseMarkdown } from "../editor/markdown"; - -type EditorSetup = { - editor: Editor; - element: HTMLElement; -}; - -function createEditor(content: JSONContent): EditorSetup { - const element = document.createElement("div"); - document.body.appendChild(element); - - const editor = new Editor({ - element, - extensions: MarkdownWc() as any, - content, - }); - - return { editor, element }; -} - -function destroyEditor({ editor, element }: EditorSetup) { - editor.destroy(); - element.remove(); -} - -function InjectEditor({ editor }: { editor: Editor }) { - const { setEditor } = useEditorCtx(); - - useEffect(() => { - setEditor(editor); - return () => { - setEditor(null); - }; - }, [editor, setEditor]); - - return null; -} - -function renderToolbar(editor: Editor) { - return render( - - - - , - ); -} - -function textSelection(editor: Editor, text: string) { - let from: number | null = null; - let to: number | null = null; - editor.state.doc.descendants((node, pos) => { - if (from != null) return false; - if (!node.isText) return true; - const value = node.text ?? ""; - const index = value.indexOf(text); - if (index >= 0) { - from = pos + index; - to = from + text.length; - return false; - } - return true; - }); - if (from == null || to == null) { - throw new Error(`Could not find text selection: ${text}`); - } - return { from, to }; -} - -const paragraphDoc: JSONContent = { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Hello world" }], - }, - ], -}; - -const bulletListDoc: JSONContent = { - type: "doc", - content: [ - { - type: "bulletList", - content: [ - { - type: "listItem", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "First" }], - }, - ], - }, - ], - }, - ], -}; - -const newFileMarkdown = - "# Launching\n\n- open source launch (reddit)\n- hello\n\n# Campaigns\n\n- github stars\n"; -const mixedTaskListMarkdown = "- plain bullet\n- [ ] todo\n"; - -describe("FormattingToolbar", () => { - const originalClipboard = navigator.clipboard; - let writeTextMock: ReturnType; - - beforeEach(() => { - writeTextMock = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { - value: { writeText: writeTextMock }, - configurable: true, - }); - }); - - afterEach(() => { - Object.defineProperty(navigator, "clipboard", { - value: originalClipboard, - configurable: true, - }); - }); - - test("renders stable analytics selectors for toolbar controls", async () => { - const setup = createEditor(paragraphDoc); - const utils = renderToolbar(setup.editor); - - const toolbar = await screen.findByRole("toolbar", { - name: "Formatting toolbar", - }); - - expect(toolbar).toHaveAttribute("data-attr", "markdown-format-toolbar"); - expect(toolbar.querySelector("[data-attr='markdown-block-selector']")).toBe( - screen.getByRole("combobox"), - ); - expect(screen.getByLabelText("Bold")).toHaveAttribute( - "data-attr", - "markdown-format-bold", - ); - expect(screen.getByLabelText("Italic")).toHaveAttribute( - "data-attr", - "markdown-format-italic", - ); - expect(screen.getByLabelText("Inline code")).toHaveAttribute( - "data-attr", - "markdown-format-code", - ); - expect(screen.getByLabelText("Numbered list")).toHaveAttribute( - "data-attr", - "markdown-format-ordered-list", - ); - expect(screen.getByLabelText("Bullet list")).toHaveAttribute( - "data-attr", - "markdown-format-bullet-list", - ); - expect(screen.getByLabelText("Checklist")).toHaveAttribute( - "data-attr", - "markdown-format-task-list", - ); - expect(screen.getByLabelText("Copy markdown")).toHaveAttribute( - "data-attr", - "markdown-copy-markdown", - ); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); - - test("applies bold formatting to the current selection", async () => { - const setup = createEditor(paragraphDoc); - const utils = renderToolbar(setup.editor); - - await screen.findByLabelText("Bold"); - - await act(async () => { - setup.editor.commands.setTextSelection({ from: 1, to: 6 }); - fireEvent.click(screen.getByLabelText("Bold")); - }); - - expect(setup.editor.isActive("bold")).toBe(true); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); - - test("wraps the current block in a bullet list without unwrapping a collapsed list selection", async () => { - const setup = createEditor(paragraphDoc); - const utils = renderToolbar(setup.editor); - - const bulletButton = await screen.findByLabelText("Bullet list"); - - await act(async () => { - setup.editor.commands.setTextSelection( - textSelection(setup.editor, "Hello world"), - ); - fireEvent.click(bulletButton); - }); - - expect(setup.editor.isActive("bulletList")).toBe(true); - let doc = setup.editor.getJSON() as any; - expect(doc.content?.[0]?.type).toBe("bulletList"); - - await act(async () => { - setup.editor.commands.setTextSelection({ from: 2, to: 2 }); - fireEvent.click(bulletButton); - }); - - expect(setup.editor.isActive("bulletList")).toBe(true); - doc = setup.editor.getJSON() as any; - expect(doc.content?.[0]?.type).toBe("bulletList"); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); - - test("unwraps an explicitly selected bullet-list item", async () => { - const setup = createEditor(bulletListDoc); - const utils = renderToolbar(setup.editor); - - const bulletButton = await screen.findByLabelText("Bullet list"); - - await act(async () => { - setup.editor.commands.setTextSelection( - textSelection(setup.editor, "First"), - ); - fireEvent.click(bulletButton); - }); - - expect(setup.editor.isActive("bulletList")).toBe(false); - const doc = setup.editor.getJSON() as any; - expect(doc.content?.[0]?.type).toBe("paragraph"); - expect(buildMarkdownFromEditor(setup.editor)).toBe("First\n"); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); - - test("keeps the final single-item bullet list serialized after choosing the bullet-list control", async () => { - const setup = createEditor( - astToTiptapDoc(parseMarkdown(newFileMarkdown)) as JSONContent, - ); - const utils = renderToolbar(setup.editor); - - const bulletButton = await screen.findByLabelText("Bullet list"); - - await act(async () => { - setup.editor.commands.setTextSelection( - setup.editor.state.doc.content.size, - ); - fireEvent.click(bulletButton); - }); - - expect(buildMarkdownFromEditor(setup.editor)).toBe(newFileMarkdown); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); - - test("toggles checklist state using the fallback implementation", async () => { - const setup = createEditor(paragraphDoc); - const utils = renderToolbar(setup.editor); - - const bulletButton = await screen.findByLabelText("Bullet list"); - const checklistButton = await screen.findByLabelText("Checklist"); - - await act(async () => { - setup.editor.commands.selectAll(); - fireEvent.click(bulletButton); - }); - - await act(async () => { - setup.editor.commands.setTextSelection({ from: 2, to: 2 }); - fireEvent.click(checklistButton); - }); - - let listItem = (setup.editor.getJSON() as any).content?.[0]?.content?.[0]; - expect(listItem?.attrs?.checked).toBe(false); - expect(bulletButton).toHaveAttribute("aria-pressed", "false"); - expect(checklistButton).toHaveAttribute("aria-pressed", "true"); - - await act(async () => { - fireEvent.click(checklistButton); - }); - - listItem = (setup.editor.getJSON() as any).content?.[0]?.content?.[0]; - expect(listItem?.attrs?.checked ?? null).toBeNull(); - expect(bulletButton).toHaveAttribute("aria-pressed", "true"); - expect(checklistButton).toHaveAttribute("aria-pressed", "false"); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); - - test("converts a selected checklist item back to a plain bullet list", async () => { - const setup = createEditor( - astToTiptapDoc(parseMarkdown("- [ ] todo\n")) as JSONContent, - ); - const utils = renderToolbar(setup.editor); - - const bulletButton = await screen.findByLabelText("Bullet list"); - const checklistButton = await screen.findByLabelText("Checklist"); - - await act(async () => { - setup.editor.commands.setTextSelection({ from: 3, to: 3 }); - fireEvent.click(bulletButton); - }); - - const list = (setup.editor.getJSON() as any).content?.[0]; - const listItem = list?.content?.[0]; - expect(list?.attrs?.isTaskList).toBe(false); - expect(listItem?.attrs?.checked ?? null).toBeNull(); - expect(buildMarkdownFromEditor(setup.editor)).toBe("- todo\n"); - expect(bulletButton).toHaveAttribute("aria-pressed", "true"); - expect(checklistButton).toHaveAttribute("aria-pressed", "false"); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); - - test("does not mutate parent list items when toggling an inner item", async () => { - const setup = createEditor( - astToTiptapDoc(parseMarkdown("- outer\n - inner\n")) as JSONContent, - ); - const utils = renderToolbar(setup.editor); - - const checklistButton = await screen.findByLabelText("Checklist"); - - await act(async () => { - const selection = textSelection(setup.editor, "inner"); - setup.editor.commands.setTextSelection({ - from: selection.from + 1, - to: selection.from + 1, - }); - fireEvent.click(checklistButton); - }); - - const outerList = (setup.editor.getJSON() as any).content?.[0]; - const outerItem = outerList?.content?.[0]; - const innerList = outerItem?.content?.[1]; - const innerItem = innerList?.content?.[0]; - expect(outerList?.attrs?.isTaskList).toBe(false); - expect(outerItem?.attrs?.checked ?? null).toBeNull(); - expect(innerList?.attrs?.isTaskList).toBe(true); - expect(innerItem?.attrs?.checked).toBe(false); - expect(buildMarkdownFromEditor(setup.editor)).toBe( - "- outer\n - [ ] inner\n", - ); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); - - test("shows plain bullets and checklist items separately in a mixed list", async () => { - const setup = createEditor( - astToTiptapDoc(parseMarkdown(mixedTaskListMarkdown)) as JSONContent, - ); - const utils = renderToolbar(setup.editor); - - const bulletButton = await screen.findByLabelText("Bullet list"); - const checklistButton = await screen.findByLabelText("Checklist"); - - await act(async () => { - setup.editor.commands.setTextSelection( - textSelection(setup.editor, "plain bullet"), - ); - }); - - expect(bulletButton).toHaveAttribute("aria-pressed", "true"); - expect(checklistButton).toHaveAttribute("aria-pressed", "false"); - - await act(async () => { - setup.editor.commands.setTextSelection( - textSelection(setup.editor, "todo"), - ); - }); - - expect(bulletButton).toHaveAttribute("aria-pressed", "false"); - expect(checklistButton).toHaveAttribute("aria-pressed", "true"); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); - - test("copies the current document as markdown", async () => { - const setup = createEditor(paragraphDoc); - const utils = renderToolbar(setup.editor); - - const copyButton = await screen.findByLabelText(/copy markdown/i); - const expected = buildMarkdownFromEditor(setup.editor); - - await act(async () => { - fireEvent.click(copyButton); - }); - - expect(writeTextMock).toHaveBeenCalledWith(expected); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); - - test("reflects external content changes in the toolbar state", async () => { - const setup = createEditor(bulletListDoc); - const utils = renderToolbar(setup.editor); - - const bulletButton = await screen.findByLabelText("Bullet list"); - expect(bulletButton).toHaveAttribute("aria-pressed", "true"); - - await act(async () => { - setup.editor.commands.setContent(paragraphDoc); - }); - - expect(bulletButton).toHaveAttribute("aria-pressed", "false"); - - await act(async () => { - utils.unmount(); - }); - destroyEditor(setup); - }); -}); diff --git a/src/extensions/markdown/components/formatting-toolbar.tsx b/src/extensions/markdown/components/formatting-toolbar.tsx deleted file mode 100644 index 40a40cb6..00000000 --- a/src/extensions/markdown/components/formatting-toolbar.tsx +++ /dev/null @@ -1,684 +0,0 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type KeyboardEvent, - type MouseEvent, -} from "react"; -import { Toolbar } from "@base-ui-components/react/toolbar"; -import { Select } from "@base-ui-components/react/select"; -import { Tooltip } from "@base-ui-components/react/tooltip"; -import { Popover } from "@base-ui-components/react/popover"; -import clsx from "clsx"; -import { - Bold, - Check, - ChevronDown, - Code2, - Copy, - Italic, - Link as LinkIcon, - List, - ListChecks, - ListOrdered, - Unlink, - X, -} from "lucide-react"; -import type { Editor } from "@tiptap/core"; -import { useEditorCtx } from "../editor/editor-context"; -import { buildMarkdownFromEditor } from "../editor/build-markdown-from-editor"; -import { normalizeUrl } from "../editor/normalize-url"; -import { - TOOLBAR_BLOCK_OPTIONS, - type ToolbarBlockType, -} from "../editor/block-commands"; - -type FormatState = { - block: ToolbarBlockType; - isBold: boolean; - isItalic: boolean; - isCode: boolean; - isLink: boolean; - isBulletList: boolean; - isOrderedList: boolean; - isTaskList: boolean; -}; - -/** 28px square icon button, matching the panel-header chips in the islands UI. */ -const iconButtonClass = - "inline-flex size-7 shrink-0 select-none items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-[background-color,color,box-shadow] duration-100 ease-out hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring-focus-visible)] disabled:cursor-not-allowed disabled:opacity-40 [&_svg]:stroke-[1.9]"; - -/** Pressed state for a formatting toggle. */ -const iconButtonActiveClass = - "bg-[var(--color-bg-control-selected)] text-[var(--color-text-primary)] [&_svg]:text-[var(--color-icon-control-selected)]"; - -const ToolbarSeparator = () => ( - -); - -const initialFormatState: FormatState = { - block: "paragraph", - isBold: false, - isItalic: false, - isCode: false, - isLink: false, - isBulletList: false, - isOrderedList: false, - isTaskList: false, -}; - -/** - * Floating toolbar rendering Markdown formatting controls for the TipTap editor. - * - * @example - * - */ -export function FormattingToolbar({ className }: { className?: string }) { - const { editor } = useEditorCtx(); - const [formatState, setFormatState] = - useState(initialFormatState); - const [copyStatus, setCopyStatus] = useState<"idle" | "success" | "error">( - "idle", - ); - const [blockMenuOpen, setBlockMenuOpen] = useState(false); - const [linkOpen, setLinkOpen] = useState(false); - const [linkValue, setLinkValue] = useState(""); - const [linkEditing, setLinkEditing] = useState(false); - const linkInputRef = useRef(null); - - const suppressMouseDown = useCallback((event: MouseEvent) => { - event.preventDefault(); - }, []); - - const hasTaskListCommand = useMemo( - () => - Boolean( - editor && - typeof (editor.commands as any)?.toggleTaskList === "function", - ), - [editor], - ); - - useEffect(() => { - if (!editor) return; - const update = () => { - const isTaskList = computeTaskListActive(editor, hasTaskListCommand); - setFormatState({ - block: getActiveBlock(editor), - isBold: editor.isActive("bold"), - isItalic: editor.isActive("italic"), - isCode: editor.isActive("code"), - isLink: editor.isActive("link"), - isBulletList: editor.isActive("bulletList") && !isTaskList, - isOrderedList: editor.isActive("orderedList"), - isTaskList, - }); - }; - - update(); - - editor.on("selectionUpdate", update); - editor.on("transaction", update); - editor.on("update", update); - - return () => { - editor.off("selectionUpdate", update); - editor.off("transaction", update); - editor.off("update", update); - }; - }, [editor, hasTaskListCommand]); - - const activeBlockLabel = useMemo(() => { - const active = TOOLBAR_BLOCK_OPTIONS.find( - (option) => option.value === formatState.block, - ); - return active?.label ?? "Text"; - }, [formatState.block]); - - const handleBlockChange = useCallback( - (value: ToolbarBlockType) => { - if (!editor) return; - const option = TOOLBAR_BLOCK_OPTIONS.find( - (entry) => entry.value === value, - ); - option?.apply(editor); - setBlockMenuOpen(false); - }, - [editor], - ); - - const handleToggleBold = useCallback(() => { - if (!editor) return; - editor.chain().focus().toggleMark("bold").run(); - }, [editor]); - - const handleToggleItalic = useCallback(() => { - if (!editor) return; - editor.chain().focus().toggleMark("italic").run(); - }, [editor]); - - const handleToggleCode = useCallback(() => { - if (!editor) return; - editor.chain().focus().toggleMark("code").run(); - }, [editor]); - - const handleLinkOpenChange = useCallback( - (next: boolean) => { - if (next && editor) { - const active = editor.isActive("link"); - setLinkEditing(active); - setLinkValue( - active ? String(editor.getAttributes("link")?.href ?? "") : "", - ); - } - setLinkOpen(next); - }, - [editor], - ); - - const handleApplyLink = useCallback(() => { - if (!editor) return; - const href = normalizeUrl(linkValue); - if (!href) { - setLinkOpen(false); - return; - } - const chain = editor.chain().focus(); - if (editor.isActive("link")) { - // Caret inside an existing link — update the whole mark range. - chain.extendMarkRange("link").setMark("link", { href }).run(); - } else if (!editor.state.selection.empty) { - // Apply to the current selection. - chain.setMark("link", { href }).run(); - } else { - // No selection — insert the URL itself as the linked text. - chain - .insertContent({ - type: "text", - text: linkValue.trim() || href, - marks: [{ type: "link", attrs: { href } }], - }) - .run(); - } - setLinkOpen(false); - }, [editor, linkValue]); - - const handleRemoveLink = useCallback(() => { - if (!editor) return; - editor.chain().focus().extendMarkRange("link").unsetMark("link").run(); - setLinkOpen(false); - }, [editor]); - - const handleLinkKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - handleApplyLink(); - } - }, - [handleApplyLink], - ); - - const handleToggleBulletList = useCallback(() => { - if (!editor) return; - if (computeTaskListActive(editor, hasTaskListCommand)) { - setTaskListState(editor, null); - return; - } - if (editor.isActive("bulletList") && editor.state.selection.empty) { - return; - } - const chain = editor.chain().focus() as any; - let success = editor.isActive("bulletList") - ? (chain.liftListItem?.("listItem")?.run?.() ?? false) - : (chain.wrapIn?.("bulletList")?.run?.() ?? false); - if (!success) { - const altChain = editor.chain().focus() as any; - success = - chain.toggleList?.("bulletList", "listItem")?.run?.() ?? - altChain.toggleBulletList?.()?.run?.() ?? - false; - } - }, [editor, hasTaskListCommand]); - - const handleToggleOrderedList = useCallback(() => { - if (!editor) return; - if (editor.isActive("orderedList") && editor.state.selection.empty) { - return; - } - const chain = editor.chain().focus() as any; - let success = editor.isActive("orderedList") - ? (chain.liftListItem?.("listItem")?.run?.() ?? false) - : (chain.wrapIn?.("orderedList")?.run?.() ?? false); - if (!success) { - const altChain = editor.chain().focus() as any; - success = - chain.toggleList?.("orderedList", "listItem")?.run?.() ?? - altChain.toggleOrderedList?.()?.run?.() ?? - false; - } - }, [editor]); - - const handleToggleTaskList = useCallback(() => { - if (!editor) return; - if (typeof editor.chain().focus().toggleTaskList === "function") { - editor.chain().focus().toggleTaskList().run(); - return; - } - toggleTaskListFallback(editor); - }, [editor]); - - const handleCopyMarkdown = useCallback(() => { - if (!editor) return; - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { - setCopyStatus("error"); - return; - } - const markdown = buildMarkdownFromEditor(editor); - navigator.clipboard - .writeText(markdown) - .then(() => setCopyStatus("success")) - .catch(() => { - setCopyStatus("error"); - }); - }, [editor]); - - useEffect(() => { - if (copyStatus === "idle") return; - const reset = window.setTimeout(() => setCopyStatus("idle"), 2000); - return () => window.clearTimeout(reset); - }, [copyStatus]); - - if (!editor) return null; - - return ( - - - - } - nativeButton={false} - data-attr="markdown-block-selector" - className={clsx( - "inline-flex h-7 shrink-0 select-none items-center gap-1 rounded-[7px] pr-1.5 pl-2.25 text-[12.5px] font-medium text-[var(--color-text-secondary)] transition-[background-color,color,box-shadow] duration-100 ease-out hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring-focus-visible)]", - blockMenuOpen && - "bg-[var(--color-bg-control-selected)] text-[var(--color-text-primary)]", - )} - onMouseDown={suppressMouseDown} - > - - {activeBlockLabel} - - - - - - - - -
- Turn into -
- {TOOLBAR_BLOCK_OPTIONS.map((option) => ( - - - - -
- - {option.label} - - - {option.description} - -
- - - -
- ))} -
-
-
-
- - - - - - - - - - - - - - - - - } - nativeButton={false} - className={clsx( - iconButtonClass, - (linkOpen || formatState.isLink) && iconButtonActiveClass, - )} - onMouseDown={suppressMouseDown} - aria-label="Link" - data-attr="markdown-format-link" - > - - - - - -
- - setLinkValue(event.target.value)} - onKeyDown={handleLinkKeyDown} - aria-label="Link URL" - placeholder="Paste a link..." - className="h-full min-w-0 flex-1 border-0 bg-transparent p-0 text-[12.5px] font-medium text-[var(--color-text-primary)] outline-none placeholder:font-normal placeholder:text-[var(--color-text-tertiary)]" - data-attr="markdown-link-input" - /> -
-
- {linkEditing && ( - - )} - - - Cancel - - -
-
-
-
-
- - - - - - - - - - - - - - -
- - - - - - - - - } - /> - - - - {copyStatus === "success" ? "Copied Markdown" : "Copy Markdown"} - - - - -
- ); -} - -function getActiveBlock(editor: Editor): ToolbarBlockType { - if (editor.isActive("heading", { level: 1 })) return "heading-1"; - if (editor.isActive("heading", { level: 2 })) return "heading-2"; - if (editor.isActive("heading", { level: 3 })) return "heading-3"; - if (editor.isActive("codeBlock")) return "code"; - if (editor.isActive("blockquote")) return "blockquote"; - return "paragraph"; -} - -function computeTaskListActive(editor: Editor, hasTaskListCommand: boolean) { - if (hasTaskListCommand && editor.isActive("taskList")) return true; - const itemAttrs = editor.getAttributes("listItem"); - if (itemAttrs && "checked" in itemAttrs) { - return typeof itemAttrs.checked === "boolean"; - } - const listAttrs = editor.getAttributes("bulletList"); - if (listAttrs?.isTaskList) return true; - return false; -} - -function toggleTaskListFallback(editor: Editor) { - if (!editor.isActive("bulletList")) { - const chain = editor.chain().focus() as any; - const wrapped = chain.wrapIn?.("bulletList")?.run?.(); - if (!wrapped) { - return; - } - } - - const listItemAttrs = editor.getAttributes("listItem"); - const isCurrentlyTask = - listItemAttrs && typeof listItemAttrs.checked === "boolean"; - setTaskListState(editor, isCurrentlyTask ? null : false); -} - -function setTaskListState(editor: Editor, checked: boolean | null) { - const { state, view } = editor; - const { selection } = state; - const tr = state.tr; - let applied = false; - const touchedBulletLists = new Set(); - - const applyListItem = (node: any, pos: number) => { - if (node.type.name !== "listItem") return; - if (checked === null && node.attrs.checked == null) return; - if (checked !== null && node.attrs.checked === checked) return; - tr.setNodeMarkup(pos, undefined, { ...node.attrs, checked }); - applied = true; - }; - - const applyBulletList = (node: any, pos: number) => { - if (node.type.name !== "bulletList" || touchedBulletLists.has(pos)) return; - touchedBulletLists.add(pos); - const isTaskList = checked !== null; - if (node.attrs.isTaskList === isTaskList) return; - tr.setNodeMarkup(pos, undefined, { ...node.attrs, isTaskList }); - applied = true; - }; - - const visitSelection = () => { - state.doc.nodesBetween(selection.from, selection.to, (node, pos) => { - applyListItem(node, pos); - applyBulletList(node, pos); - }); - }; - - const visitAncestors = () => { - const $from: any = selection.$from; - let appliedListItem = false; - let appliedBulletList = false; - for ( - let depth = $from.depth; - depth > 0 && (!appliedListItem || !appliedBulletList); - depth-- - ) { - const node = $from.node(depth); - if (!appliedListItem && node.type.name === "listItem") { - applyListItem(node, $from.before(depth)); - appliedListItem = true; - continue; - } - if (!appliedBulletList && node.type.name === "bulletList") { - applyBulletList(node, $from.before(depth)); - appliedBulletList = true; - } - } - }; - - if (selection.empty) { - visitAncestors(); - } else { - visitSelection(); - } - - if (applied) { - view.dispatch(tr); - } -} diff --git a/src/extensions/markdown/components/slash-command-menu.tsx b/src/extensions/markdown/components/slash-command-menu.tsx deleted file mode 100644 index 849ef09f..00000000 --- a/src/extensions/markdown/components/slash-command-menu.tsx +++ /dev/null @@ -1,266 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { useEditorCtx } from "../editor/editor-context"; -import { - slashCommandsPluginKey, - type SlashCommandState, -} from "../editor/extensions/slash-commands"; -import { - SLASH_BLOCK_COMMANDS, - type BlockCommand, -} from "../editor/block-commands"; - -function filterCommands( - commands: BlockCommand[], - query: string, -): BlockCommand[] { - if (!query) return commands; - const lowerQuery = query.toLowerCase(); - return commands.filter( - (cmd) => - cmd.label.toLowerCase().includes(lowerQuery) || - cmd.keywords.some((kw) => kw.toLowerCase().includes(lowerQuery)), - ); -} - -export function SlashCommandMenu() { - const { editor } = useEditorCtx(); - const [slashState, setSlashState] = useState({ - active: false, - query: "", - range: null, - }); - const [selectedIndex, setSelectedIndex] = useState(0); - const [position, setPosition] = useState<{ - top: number; - left: number; - placement: "above" | "below"; - } | null>(null); - const menuRef = useRef(null); - - const filteredCommands = useMemo( - () => filterCommands(SLASH_BLOCK_COMMANDS, slashState.query), - [slashState.query], - ); - - // Reset selection when filtered list changes - useEffect(() => { - setSelectedIndex(0); - }, [filteredCommands.length, slashState.query]); - - // Listen to slash command state changes - useEffect(() => { - if (!editor) return; - - const updateState = () => { - const state = slashCommandsPluginKey.getState(editor.state); - if (state) { - setSlashState(state); - } - }; - - // Initial state - updateState(); - - editor.on("transaction", updateState); - return () => { - editor.off("transaction", updateState); - }; - }, [editor]); - - // Calculate position when active and update on scroll - useEffect(() => { - if (!slashState.active || !slashState.range || !editor) { - setPosition(null); - return; - } - - // Capture range value for closure - const range = slashState.range; - - const updatePosition = () => { - const { view } = editor; - const coords = view.coordsAtPos(range.from); - const editorRect = view.dom.getBoundingClientRect(); - - // Menu dimensions (max-h-80 = 320px) - const menuHeight = 320; - const menuWidth = 180; - const gap = 8; - - const viewportHeight = window.innerHeight; - const viewportWidth = window.innerWidth; - - // Check if there's enough space below - const spaceBelow = viewportHeight - coords.bottom - gap; - const spaceAbove = coords.top - gap; - - let top: number; - let placement: "above" | "below"; - - if (spaceBelow >= menuHeight || spaceBelow >= spaceAbove) { - // Position below - top = coords.bottom + gap; - placement = "below"; - } else { - // Position above - top = coords.top - gap - Math.min(menuHeight, spaceAbove); - placement = "above"; - } - - // Ensure left doesn't go off-screen - let left = Math.max(coords.left, editorRect.left); - if (left + menuWidth > viewportWidth) { - left = viewportWidth - menuWidth - gap; - } - - setPosition({ top, left, placement }); - }; - - updatePosition(); - - // Update position on scroll and resize - window.addEventListener("scroll", updatePosition, true); - window.addEventListener("resize", updatePosition); - return () => { - window.removeEventListener("scroll", updatePosition, true); - window.removeEventListener("resize", updatePosition); - }; - }, [slashState.active, slashState.range, editor]); - - const executeCommand = useCallback( - (command: BlockCommand) => { - if (!editor) return; - - // Delete the slash and query text - (editor.commands as any).deleteSlashCommand?.(); - - // Execute the command insert action - command.insert(editor); - }, - [editor], - ); - - const handleItemClick = useCallback( - (command: BlockCommand) => { - executeCommand(command); - }, - [executeCommand], - ); - - // Handle keyboard navigation - useEffect(() => { - if (!slashState.active || !editor) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "ArrowDown") { - event.preventDefault(); - setSelectedIndex((prev) => - prev < filteredCommands.length - 1 ? prev + 1 : 0, - ); - return; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - setSelectedIndex((prev) => - prev > 0 ? prev - 1 : filteredCommands.length - 1, - ); - return; - } - - if (event.key === "Enter") { - event.preventDefault(); - const command = filteredCommands[selectedIndex]; - if (command) { - executeCommand(command); - } - return; - } - - if (event.key === "Escape") { - // Let the extension handle this - return; - } - }; - - window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [ - slashState.active, - editor, - filteredCommands, - selectedIndex, - executeCommand, - ]); - - // Scroll selected item into view - useEffect(() => { - if (!menuRef.current) return; - const selectedEl = menuRef.current.querySelector( - `[data-index="${selectedIndex}"]`, - ); - if (selectedEl) { - selectedEl.scrollIntoView({ block: "nearest" }); - } - }, [selectedIndex]); - - // Close menu when clicking outside - useEffect(() => { - if (!slashState.active || !editor) return; - - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - (editor.commands as any).closeSlashMenu?.(); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [slashState.active, editor]); - - if (!slashState.active || !position || filteredCommands.length === 0) { - return null; - } - - return createPortal( -
- {filteredCommands.map((command, index) => ( -
handleItemClick(command)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleItemClick(command); - } - }} - onMouseEnter={() => setSelectedIndex(index)} - tabIndex={0} - > - - {command.label} -
- ))} -
, - document.body, - ); -} diff --git a/src/extensions/markdown/diff-fixtures/block-media-link.after.md b/src/extensions/markdown/diff-fixtures/block-media-link.after.md deleted file mode 100644 index 69e8d6eb..00000000 --- a/src/extensions/markdown/diff-fixtures/block-media-link.after.md +++ /dev/null @@ -1,23 +0,0 @@ -## Metrics renamed - -The docs live at and . - -![New dashboard](https://example.com/dashboard-new.png "New title") - ---- - -# Launch notes - -```ts -export const enabled = true; -``` - -> Keep **strong signal** and [support link](https://example.com/help). -> Nested _quote detail_ stays focused. -> Add `owner` callout. - -Use Ctrl + `K` for search and stable. - -
-Raw block content plus owner -
diff --git a/src/extensions/markdown/diff-fixtures/block-media-link.before.md b/src/extensions/markdown/diff-fixtures/block-media-link.before.md deleted file mode 100644 index 99a15c5c..00000000 --- a/src/extensions/markdown/diff-fixtures/block-media-link.before.md +++ /dev/null @@ -1,22 +0,0 @@ -# Launch notes - -![Old dashboard](https://example.com/dashboard-old.png "Old title") - ---- - -## Metrics - -```js -export const enabled = false; -``` - -> Keep **bold signal** and [support link](https://example.com/support). -> Nested _quote detail_ stays calm. - -The docs live at and . - -Use Cmd + `K` for search and beta. - -
-Raw block content -
diff --git a/src/extensions/markdown/diff-fixtures/block-structure-moves.after.md b/src/extensions/markdown/diff-fixtures/block-structure-moves.after.md deleted file mode 100644 index dd3fdf04..00000000 --- a/src/extensions/markdown/diff-fixtures/block-structure-moves.after.md +++ /dev/null @@ -1,43 +0,0 @@ -## Overview - -Shared paragraph. - -Repeated heading - -Keep the same repeated paragraph. - -### Details - -Paragraph before list. - -- Keep list item -- Move list with paragraph boundary -- Add list item between paragraphs - -Move this paragraph below the checklist. - -Paragraph after list. - -> Parent quote stays. -> -> > Nested quote detail stays focused. -> > -> > Nested quote owner added. - -```ts -console.log("keep"); -``` - ---- - -
-Add this HTML block. -
- -Repeated heading - -Keep the same repeated paragraph. - -```sh -echo "new fence" -``` diff --git a/src/extensions/markdown/diff-fixtures/block-structure-moves.before.md b/src/extensions/markdown/diff-fixtures/block-structure-moves.before.md deleted file mode 100644 index 7f86c375..00000000 --- a/src/extensions/markdown/diff-fixtures/block-structure-moves.before.md +++ /dev/null @@ -1,40 +0,0 @@ -# Overview - -Shared paragraph. - -Move this paragraph below the checklist. - -## Details - -Repeated heading - -Keep the same repeated paragraph. - -> Parent quote stays. -> -> > Nested quote detail stays calm. - -```js -console.log("keep"); -``` - -```ts -console.log("remove me"); -``` - ---- - -Paragraph before list. - -- Keep list item -- Move list with paragraph boundary - -Paragraph after list. - - - -Repeated heading - -Keep the same repeated paragraph. diff --git a/src/extensions/markdown/diff-fixtures/duplicate-task-list.after.md b/src/extensions/markdown/diff-fixtures/duplicate-task-list.after.md deleted file mode 100644 index 87e2fac4..00000000 --- a/src/extensions/markdown/diff-fixtures/duplicate-task-list.after.md +++ /dev/null @@ -1,13 +0,0 @@ -# Duplicate task list - -- [ ] Prep - - [ ] Confirm venue - - [x] Invite reviewers - - [ ] Confirm venue -- [ ] Prep - - [x] Confirm venue - - [ ] Pack demo kit - - [ ] Book rehearsal room -- [x] Publish - - [ ] Send announcement - - [ ] Update docs diff --git a/src/extensions/markdown/diff-fixtures/duplicate-task-list.before.md b/src/extensions/markdown/diff-fixtures/duplicate-task-list.before.md deleted file mode 100644 index c03f93a1..00000000 --- a/src/extensions/markdown/diff-fixtures/duplicate-task-list.before.md +++ /dev/null @@ -1,12 +0,0 @@ -# Duplicate task list - -- [ ] Prep - - [ ] Confirm venue - - [x] Confirm venue - - [ ] Pack demo kit -- [x] Publish - - [x] Send announcement - - [ ] Update docs -- [ ] Prep - - [ ] Confirm venue - - [ ] Invite reviewers diff --git a/src/extensions/markdown/diff-fixtures/gfm-edge-blocks.after.md b/src/extensions/markdown/diff-fixtures/gfm-edge-blocks.after.md deleted file mode 100644 index ffe0a8dc..00000000 --- a/src/extensions/markdown/diff-fixtures/gfm-edge-blocks.after.md +++ /dev/null @@ -1,16 +0,0 @@ -```ts -const route = "/launch"; -return fetch(route, { cache: "no-store" }); -``` - -> Keep the customer quote. -> Ship the revised onboarding checklist. -> Add a rollout owner. - - - -~~Deprecated: private beta~~ - -See . diff --git a/src/extensions/markdown/diff-fixtures/gfm-edge-blocks.before.md b/src/extensions/markdown/diff-fixtures/gfm-edge-blocks.before.md deleted file mode 100644 index 40a519d9..00000000 --- a/src/extensions/markdown/diff-fixtures/gfm-edge-blocks.before.md +++ /dev/null @@ -1,15 +0,0 @@ -```ts -const route = "/draft"; -return fetch(route); -``` - -> Keep the customer quote. -> Ship the current onboarding checklist. - - - -~~Deprecated: invite-only beta~~ - -See . diff --git a/src/extensions/markdown/diff-fixtures/gfm-table-inline.after.md b/src/extensions/markdown/diff-fixtures/gfm-table-inline.after.md deleted file mode 100644 index 98400a4e..00000000 --- a/src/extensions/markdown/diff-fixtures/gfm-table-inline.after.md +++ /dev/null @@ -1,6 +0,0 @@ -| Segment | Status | Owner Link | Validation | -| :------ | :---------: | --------------------------------------: | ------------------------- | -| North | **Ready** | [Ada Lovelace](https://example.com/ada) | Run `full` pass | -| South | _Blocked_ | [Ben](https://example.com/ben) | Needs `api` and `db` key | -| West | **Shipped** | [Cy](https://example.com/cy) | Watch latency | -| East | ~~Paused~~ | [Dee](https://example.com/v2) | Keep `cache` and _parser_ | diff --git a/src/extensions/markdown/diff-fixtures/gfm-table-inline.before.md b/src/extensions/markdown/diff-fixtures/gfm-table-inline.before.md deleted file mode 100644 index 05168323..00000000 --- a/src/extensions/markdown/diff-fixtures/gfm-table-inline.before.md +++ /dev/null @@ -1,6 +0,0 @@ -| Segment | Status | Owner Link | Validation | -| :------ | :---------: | -----------------------------: | ------------------------- | -| North | **Draft** | [Ada](https://example.com/ada) | Run `smoke` pass | -| South | _Blocked_ | [Ben](https://example.com/ben) | Needs `api` key | -| West | **Shipped** | [Cy](https://example.com/cy) | Watch latency | -| East | ~~Paused~~ | [Dee](https://example.com/v1) | Keep `cache` and _parser_ | diff --git a/src/extensions/markdown/diff-fixtures/gfm-table-rows.after.md b/src/extensions/markdown/diff-fixtures/gfm-table-rows.after.md deleted file mode 100644 index 2d93d587..00000000 --- a/src/extensions/markdown/diff-fixtures/gfm-table-rows.after.md +++ /dev/null @@ -1,7 +0,0 @@ -| Area | Owner | State | Notes | -| ------- | ----- | ----------- | ----------------------- | -| Beta | Noe | In progress | Move this row unchanged | -| Alpha | Mia | Planned | Keep copy stable | -| Epsilon | Val | New | Insert this row | -| Alpha | Rae | At risk | Duplicate label remains | -| Delta | Uma | Queued | Keep second stable cell | diff --git a/src/extensions/markdown/diff-fixtures/gfm-table-rows.before.md b/src/extensions/markdown/diff-fixtures/gfm-table-rows.before.md deleted file mode 100644 index aded4b9e..00000000 --- a/src/extensions/markdown/diff-fixtures/gfm-table-rows.before.md +++ /dev/null @@ -1,7 +0,0 @@ -| Area | Owner | State | Notes | -| ----- | ----- | ----------- | ----------------------- | -| Alpha | Mia | Planned | Keep copy stable | -| Beta | Noe | In progress | Move this row unchanged | -| Alpha | Rae | At risk | Duplicate label remains | -| Gamma | Sol | Done | Remove this row | -| Delta | Uma | Queued | Keep second stable cell | diff --git a/src/extensions/markdown/diff-fixtures/gfm-table-structure.after.md b/src/extensions/markdown/diff-fixtures/gfm-table-structure.after.md deleted file mode 100644 index b30d0cf8..00000000 --- a/src/extensions/markdown/diff-fixtures/gfm-table-structure.after.md +++ /dev/null @@ -1,8 +0,0 @@ -| Item | Priority | Owner | Notes | Status | -| ---------- | -------- | ----- | --------------------------------------- | ------ | -| Duplicate | medium | Rae | First duplicate keeps same note | green | -| Pipe demo | medium | Noe | Escaped \| pipe and `a \| c` code | yellow | -| Alpha | high | Mia | Filled empty cell | green | -| Duplicate | low | Sol | Second duplicate gets a modified note | red | -| Remove col | medium | Uma | Column removal keeps row identity | green | -| New row | high | Val | Inserted row with changed-ready content | blue | diff --git a/src/extensions/markdown/diff-fixtures/gfm-table-structure.before.md b/src/extensions/markdown/diff-fixtures/gfm-table-structure.before.md deleted file mode 100644 index 7ba14154..00000000 --- a/src/extensions/markdown/diff-fixtures/gfm-table-structure.before.md +++ /dev/null @@ -1,7 +0,0 @@ -| Item | Owner | Notes | Legacy | -| ---------- | ----- | --------------------------------- | ------ | -| Alpha | Mia | | keep | -| Pipe demo | Noe | Escaped \| pipe and `a \| b` code | drop | -| Duplicate | Rae | First duplicate keeps same note | stay | -| Duplicate | Sol | Second duplicate will be edited | stay | -| Remove col | Uma | Column removal keeps row identity | old | diff --git a/src/extensions/markdown/diff-fixtures/index.ts b/src/extensions/markdown/diff-fixtures/index.ts deleted file mode 100644 index 545ffcfa..00000000 --- a/src/extensions/markdown/diff-fixtures/index.ts +++ /dev/null @@ -1,820 +0,0 @@ -import quickFactsBefore from "./quick-facts-list.before.md?raw"; -import quickFactsAfter from "./quick-facts-list.after.md?raw"; -import planTableBefore from "./plan-table.before.md?raw"; -import planTableAfter from "./plan-table.after.md?raw"; -import gfmTableInlineBefore from "./gfm-table-inline.before.md?raw"; -import gfmTableInlineAfter from "./gfm-table-inline.after.md?raw"; -import gfmTableRowsBefore from "./gfm-table-rows.before.md?raw"; -import gfmTableRowsAfter from "./gfm-table-rows.after.md?raw"; -import gfmTableStructureBefore from "./gfm-table-structure.before.md?raw"; -import gfmTableStructureAfter from "./gfm-table-structure.after.md?raw"; -import releaseChecklistBefore from "./release-checklist.before.md?raw"; -import releaseChecklistAfter from "./release-checklist.after.md?raw"; -import duplicateTaskListBefore from "./duplicate-task-list.before.md?raw"; -import duplicateTaskListAfter from "./duplicate-task-list.after.md?raw"; -import gfmEdgeBlocksBefore from "./gfm-edge-blocks.before.md?raw"; -import gfmEdgeBlocksAfter from "./gfm-edge-blocks.after.md?raw"; -import blockMediaLinkBefore from "./block-media-link.before.md?raw"; -import blockMediaLinkAfter from "./block-media-link.after.md?raw"; -import blockStructureMovesBefore from "./block-structure-moves.before.md?raw"; -import blockStructureMovesAfter from "./block-structure-moves.after.md?raw"; -import mixedGfmDocumentBefore from "./mixed-gfm-document.before.md?raw"; -import mixedGfmDocumentAfter from "./mixed-gfm-document.after.md?raw"; -import inlineMediaLinkBefore from "./inline-media-link.before.md?raw"; -import inlineMediaLinkAfter from "./inline-media-link.after.md?raw"; -import type { MarkdownBlockSnapshot } from "../review-diff"; - -export type MarkdownDiffFixture = { - readonly id: string; - readonly title: string; - readonly beforeMarkdown: string; - readonly afterMarkdown: string; - readonly beforeBlocks: readonly MarkdownBlockSnapshot[]; - readonly afterBlocks: readonly MarkdownBlockSnapshot[]; -}; - -export const MARKDOWN_DIFF_FIXTURES: readonly MarkdownDiffFixture[] = [ - { - id: "quick-facts-list", - title: "Quick Facts list item edit and add", - beforeMarkdown: quickFactsBefore, - afterMarkdown: quickFactsAfter, - beforeBlocks: [ - { - id: "quick_facts_list", - orderKey: "40", - block: quickFactsBefore.trimEnd(), - }, - ], - afterBlocks: [ - { - id: "quick_facts_list", - orderKey: "40", - block: quickFactsAfter.trimEnd(), - }, - ], - }, - { - id: "plan-table", - title: "Plan table cell edits", - beforeMarkdown: planTableBefore, - afterMarkdown: planTableAfter, - beforeBlocks: [ - { - id: "plan_table", - orderKey: "80", - block: planTableBefore.trimEnd(), - }, - ], - afterBlocks: [ - { - id: "plan_table", - orderKey: "80", - block: planTableAfter.trimEnd(), - }, - ], - }, - { - id: "gfm-table-inline", - title: "GFM table inline formatting edits", - beforeMarkdown: gfmTableInlineBefore, - afterMarkdown: gfmTableInlineAfter, - beforeBlocks: [ - { - id: "gfm_table_inline", - orderKey: "120", - block: gfmTableInlineBefore.trimEnd(), - }, - ], - afterBlocks: [ - { - id: "gfm_table_inline", - orderKey: "120", - block: gfmTableInlineAfter.trimEnd(), - }, - ], - }, - { - id: "gfm-table-rows", - title: "GFM table row reorder, add, remove, and duplicate labels", - beforeMarkdown: gfmTableRowsBefore, - afterMarkdown: gfmTableRowsAfter, - beforeBlocks: [ - { - id: "gfm_table_rows", - orderKey: "140", - block: gfmTableRowsBefore.trimEnd(), - }, - ], - afterBlocks: [ - { - id: "gfm_table_rows", - orderKey: "140", - block: gfmTableRowsAfter.trimEnd(), - }, - ], - }, - { - id: "gfm-table-structure", - title: "GFM table column changes, empty cells, and pipe literals", - beforeMarkdown: gfmTableStructureBefore, - afterMarkdown: gfmTableStructureAfter, - beforeBlocks: [ - { - id: "gfm_table_structure", - orderKey: "160", - block: gfmTableStructureBefore.trimEnd(), - }, - ], - afterBlocks: [ - { - id: "gfm_table_structure", - orderKey: "160", - block: gfmTableStructureAfter.trimEnd(), - }, - ], - }, - { - id: "release-checklist", - title: "Task list checked state and nested item edits", - beforeMarkdown: releaseChecklistBefore, - afterMarkdown: releaseChecklistAfter, - beforeBlocks: [ - { - id: "release_checklist_heading", - orderKey: "40", - block: "# Release checklist", - }, - { - id: "release_checklist_tasks", - orderKey: "80", - block: releaseChecklistBefore - .replace(/^# Release checklist\n\n/, "") - .trimEnd(), - }, - ], - afterBlocks: [ - { - id: "release_checklist_heading", - orderKey: "40", - block: "# Release checklist", - }, - { - id: "release_checklist_tasks", - orderKey: "80", - block: releaseChecklistAfter - .replace(/^# Release checklist\n\n/, "") - .trimEnd(), - }, - ], - }, - { - id: "duplicate-task-list", - title: "Duplicate task labels, reorder, and nested task edits", - beforeMarkdown: duplicateTaskListBefore, - afterMarkdown: duplicateTaskListAfter, - beforeBlocks: [ - { - id: "duplicate_task_list_heading", - orderKey: "40", - block: "# Duplicate task list", - }, - { - id: "duplicate_task_list_tasks", - orderKey: "80", - block: duplicateTaskListBefore - .replace(/^# Duplicate task list\n\n/, "") - .trimEnd(), - }, - ], - afterBlocks: [ - { - id: "duplicate_task_list_heading", - orderKey: "40", - block: "# Duplicate task list", - }, - { - id: "duplicate_task_list_tasks", - orderKey: "80", - block: duplicateTaskListAfter - .replace(/^# Duplicate task list\n\n/, "") - .trimEnd(), - }, - ], - }, - { - id: "gfm-edge-blocks", - title: "GFM edge block edits", - beforeMarkdown: gfmEdgeBlocksBefore, - afterMarkdown: gfmEdgeBlocksAfter, - beforeBlocks: [ - { - id: "gfm_code_fence", - orderKey: "40", - block: [ - "```ts", - 'const route = "/draft";', - "return fetch(route);", - "```", - ].join("\n"), - }, - { - id: "gfm_blockquote", - orderKey: "80", - block: [ - "> Keep the customer quote.", - "> Ship the current onboarding checklist.", - ].join("\n"), - }, - { - id: "gfm_raw_html", - orderKey: "120", - block: [ - '", - ].join("\n"), - }, - { - id: "gfm_strikethrough", - orderKey: "160", - block: "~~Deprecated: invite-only beta~~", - }, - { - id: "gfm_autolink", - orderKey: "200", - block: "See .", - }, - ], - afterBlocks: [ - { - id: "gfm_code_fence", - orderKey: "40", - block: [ - "```ts", - 'const route = "/launch";', - 'return fetch(route, { cache: "no-store" });', - "```", - ].join("\n"), - }, - { - id: "gfm_blockquote", - orderKey: "80", - block: [ - "> Keep the customer quote.", - "> Ship the revised onboarding checklist.", - "> Add a rollout owner.", - ].join("\n"), - }, - { - id: "gfm_raw_html", - orderKey: "120", - block: [ - '", - ].join("\n"), - }, - { - id: "gfm_strikethrough", - orderKey: "160", - block: "~~Deprecated: private beta~~", - }, - { - id: "gfm_autolink", - orderKey: "200", - block: "See .", - }, - ], - }, - { - id: "block-media-link", - title: "Block, media, link, and HTML edits", - beforeMarkdown: blockMediaLinkBefore, - afterMarkdown: blockMediaLinkAfter, - beforeBlocks: [ - { - id: "launch_heading", - orderKey: "40", - block: "# Launch notes", - }, - { - id: "dashboard_image", - orderKey: "80", - block: - '![Old dashboard](https://example.com/dashboard-old.png "Old title")', - }, - { - id: "theme_break", - orderKey: "120", - block: "---", - }, - { - id: "metrics_heading", - orderKey: "160", - block: "## Metrics", - }, - { - id: "language_fence", - orderKey: "200", - block: ["```js", "export const enabled = false;", "```"].join("\n"), - }, - { - id: "nested_quote", - orderKey: "240", - block: [ - "> Keep **bold signal** and [support link](https://example.com/support).", - "> Nested _quote detail_ stays calm.", - ].join("\n"), - }, - { - id: "autolinks", - orderKey: "280", - block: - "The docs live at and .", - }, - { - id: "inline_html", - orderKey: "320", - block: - 'Use Cmd + `K` for search and beta.', - }, - { - id: "raw_html_block", - orderKey: "360", - block: [ - '
', - "Raw block content", - "
", - ].join("\n"), - }, - ], - afterBlocks: [ - { - id: "metrics_heading", - orderKey: "40", - block: "## Metrics renamed", - }, - { - id: "autolinks", - orderKey: "80", - block: - "The docs live at and .", - }, - { - id: "dashboard_image", - orderKey: "120", - block: - '![New dashboard](https://example.com/dashboard-new.png "New title")', - }, - { - id: "theme_break", - orderKey: "160", - block: "***", - }, - { - id: "launch_heading", - orderKey: "200", - block: "# Launch notes", - }, - { - id: "language_fence", - orderKey: "240", - block: ["```ts", "export const enabled = true;", "```"].join("\n"), - }, - { - id: "nested_quote", - orderKey: "280", - block: [ - "> Keep **strong signal** and [support link](https://example.com/help).", - "> Nested _quote detail_ stays focused.", - "> Add `owner` callout.", - ].join("\n"), - }, - { - id: "inline_html", - orderKey: "320", - block: - 'Use Ctrl + `K` for search and stable.', - }, - { - id: "raw_html_block", - orderKey: "360", - block: [ - '
', - "Raw block content plus owner", - "
", - ].join("\n"), - }, - ], - }, - { - id: "block-structure-moves", - title: "Block moves, repeated text, and structural add/remove cases", - beforeMarkdown: blockStructureMovesBefore, - afterMarkdown: blockStructureMovesAfter, - beforeBlocks: [ - { - id: "overview_heading", - orderKey: "40", - block: "# Overview", - }, - { - id: "shared_paragraph", - orderKey: "80", - block: "Shared paragraph.", - }, - { - id: "moved_paragraph", - orderKey: "120", - block: "Move this paragraph below the checklist.", - }, - { - id: "details_heading", - orderKey: "160", - block: "## Details", - }, - { - id: "repeated_heading_first", - orderKey: "200", - block: "Repeated heading", - }, - { - id: "repeated_paragraph_first", - orderKey: "240", - block: "Keep the same repeated paragraph.", - }, - { - id: "nested_blockquote", - orderKey: "280", - block: [ - "> Parent quote stays.", - ">", - "> > Nested quote detail stays calm.", - ].join("\n"), - }, - { - id: "changed_language_fence", - orderKey: "320", - block: ["```js", 'console.log("keep");', "```"].join("\n"), - }, - { - id: "removed_code_fence", - orderKey: "360", - block: ["```ts", 'console.log("remove me");', "```"].join("\n"), - }, - { - id: "changed_theme_break", - orderKey: "400", - block: "---", - }, - { - id: "paragraph_before_list", - orderKey: "440", - block: "Paragraph before list.", - }, - { - id: "list_between_paragraphs", - orderKey: "480", - block: ["- Keep list item", "- Move list with paragraph boundary"].join( - "\n", - ), - }, - { - id: "paragraph_after_list", - orderKey: "520", - block: "Paragraph after list.", - }, - { - id: "removed_html_block", - orderKey: "560", - block: [""].join("\n"), - }, - { - id: "repeated_heading_second", - orderKey: "600", - block: "Repeated heading", - }, - { - id: "repeated_paragraph_second", - orderKey: "640", - block: "Keep the same repeated paragraph.", - }, - ], - afterBlocks: [ - { - id: "overview_heading", - orderKey: "40", - block: "## Overview", - }, - { - id: "shared_paragraph", - orderKey: "80", - block: "Shared paragraph.", - }, - { - id: "repeated_heading_first", - orderKey: "120", - block: "Repeated heading", - }, - { - id: "repeated_paragraph_first", - orderKey: "160", - block: "Keep the same repeated paragraph.", - }, - { - id: "details_heading", - orderKey: "200", - block: "### Details", - }, - { - id: "paragraph_before_list", - orderKey: "240", - block: "Paragraph before list.", - }, - { - id: "list_between_paragraphs", - orderKey: "280", - block: [ - "- Keep list item", - "- Move list with paragraph boundary", - "- Add list item between paragraphs", - ].join("\n"), - }, - { - id: "moved_paragraph", - orderKey: "320", - block: "Move this paragraph below the checklist.", - }, - { - id: "paragraph_after_list", - orderKey: "360", - block: "Paragraph after list.", - }, - { - id: "nested_blockquote", - orderKey: "400", - block: [ - "> Parent quote stays.", - ">", - "> > Nested quote detail stays focused.", - "> >", - "> > Nested quote owner added.", - ].join("\n"), - }, - { - id: "changed_language_fence", - orderKey: "440", - block: ["```ts", 'console.log("keep");', "```"].join("\n"), - }, - { - id: "changed_theme_break", - orderKey: "480", - block: "***", - }, - { - id: "added_html_block", - orderKey: "520", - block: ["
", "Add this HTML block.", "
"].join("\n"), - }, - { - id: "repeated_heading_second", - orderKey: "560", - block: "Repeated heading", - }, - { - id: "repeated_paragraph_second", - orderKey: "600", - block: "Keep the same repeated paragraph.", - }, - { - id: "added_code_fence", - orderKey: "640", - block: ["```sh", 'echo "new fence"', "```"].join("\n"), - }, - ], - }, - { - id: "mixed-gfm-document", - title: "Mixed GFM document table, list, code, and links", - beforeMarkdown: mixedGfmDocumentBefore, - afterMarkdown: mixedGfmDocumentAfter, - beforeBlocks: [ - { - id: "mixed_launch_heading", - orderKey: "40", - block: "# Launch review", - }, - { - id: "mixed_link_paragraph", - orderKey: "80", - block: - "Read the [runbook](https://example.com/runbook) and ping .", - }, - { - id: "mixed_table", - orderKey: "120", - block: [ - "| Area | Owner | Status |", - "| --- | --- | --- |", - "| API | Dee | Ready |", - "| Web | Mo | Draft |", - ].join("\n"), - }, - { - id: "mixed_task_list", - orderKey: "160", - block: [ - "- [x] Confirm launch owner", - "- [ ] Update docs", - " - Keep migration note", - " - Verify search index", - ].join("\n"), - }, - { - id: "mixed_code_fence", - orderKey: "200", - block: [ - "```ts", - 'export const rollout = "staged";', - 'notify("ops");', - "```", - ].join("\n"), - }, - ], - afterBlocks: [ - { - id: "mixed_launch_heading", - orderKey: "40", - block: "# Launch review", - }, - { - id: "mixed_link_paragraph", - orderKey: "80", - block: - "Read the [runbook](https://example.com/runbook-v2) and ping .", - }, - { - id: "mixed_table", - orderKey: "120", - block: [ - "| Area | Owner | Status |", - "| --- | --- | --- |", - "| API | Dee | Ready |", - "| Web | Mo | Approved |", - "| Billing | Ada | Watching |", - ].join("\n"), - }, - { - id: "mixed_task_list", - orderKey: "160", - block: [ - "- [x] Confirm launch owner", - "- [x] Update docs", - " - Keep migration note", - " - Verify search index", - " - Add rollback note", - ].join("\n"), - }, - { - id: "mixed_code_fence", - orderKey: "200", - block: [ - "```ts", - 'export const rollout = "global";', - 'notify("ops");', - 'notify("support");', - "```", - ].join("\n"), - }, - ], - }, - { - id: "inline-media-link", - title: "Inline media, reference links, autolinks, escapes, and breaks", - beforeMarkdown: inlineMediaLinkBefore, - afterMarkdown: inlineMediaLinkAfter, - beforeBlocks: [ - { - id: "inline_media_link_heading", - orderKey: "40", - block: "# Inline media and links", - }, - { - id: "inline_punctuation", - orderKey: "80", - block: - "Opening line keeps emoji :) around **bold**, `code`, ~~strike~~, and punctuation!", - }, - { - id: "inline_links", - orderKey: "120", - block: - "Change [Docs](https://example.com/docs) text, but keep [API](https://example.com/api-v1) text with a URL-only edit.", - }, - { - id: "reference_links", - orderKey: "160", - block: [ - "Reference [Guide][guide-v1] and logo ![Reference logo][logo-v1].", - "", - "[guide-v1]: https://example.com/guide-v1", - '[logo-v1]: https://example.com/logo-v1.png "Logo v1"', - ].join("\n"), - }, - { - id: "bare_autolinks", - orderKey: "200", - block: - "Bare links: https://example.com/bare-v1 and ops-v1@example.com.", - }, - { - id: "escaped_markdown", - orderKey: "240", - block: String.raw`Escaped markdown: \*literal stars\* and \[literal link\].`, - }, - { - id: "breaks", - orderKey: "280", - block: [ - "Soft break stays here", - "with the next line, and hard break stays here ", - "with the forced line.", - ].join("\n"), - }, - { - id: "removed_image", - orderKey: "320", - block: - '![Removed chart](https://example.com/chart-old.png "Removed title")', - }, - { - id: "changed_image", - orderKey: "360", - block: - '![Screenshot old alt](https://example.com/screenshot.png "Old screenshot title")', - }, - ], - afterBlocks: [ - { - id: "inline_media_link_heading", - orderKey: "40", - block: "# Inline media and links", - }, - { - id: "inline_punctuation", - orderKey: "80", - block: - "Opening line keeps emoji :) around **strong**, `snippet`, ~~retired~~, and punctuation?", - }, - { - id: "inline_links", - orderKey: "120", - block: - "Change [Guides](https://example.com/docs) text, but keep [API](https://example.com/api-v2) text with a URL-only edit.", - }, - { - id: "reference_links", - orderKey: "160", - block: [ - "Reference [Guidebook][guide-v2] and logo ![Reference logo new][logo-v2].", - "", - "[guide-v2]: https://example.com/guide-v2", - '[logo-v2]: https://example.com/logo-v2.png "Logo v2"', - ].join("\n"), - }, - { - id: "bare_autolinks", - orderKey: "200", - block: - "Bare links: https://example.com/bare-v2 and ops-v2@example.com.", - }, - { - id: "escaped_markdown", - orderKey: "240", - block: String.raw`Escaped markdown: \*literal asterisks\* and \[literal reference\].`, - }, - { - id: "breaks", - orderKey: "280", - block: [ - "Soft break stays here", - "with the next line updated, and hard break stays here ", - "with the forced line updated.", - ].join("\n"), - }, - { - id: "added_image", - orderKey: "320", - block: - '![Added chart](https://example.com/chart-new.png "Added title")', - }, - { - id: "changed_image", - orderKey: "360", - block: - '![Screenshot new alt](https://example.com/screenshot.png "New screenshot title")', - }, - ], - }, -]; diff --git a/src/extensions/markdown/diff-fixtures/inline-media-link.after.md b/src/extensions/markdown/diff-fixtures/inline-media-link.after.md deleted file mode 100644 index 9457330e..00000000 --- a/src/extensions/markdown/diff-fixtures/inline-media-link.after.md +++ /dev/null @@ -1,22 +0,0 @@ -# Inline media and links - -Opening line keeps emoji :) around **strong**, `snippet`, ~~retired~~, and punctuation? - -Change [Guides](https://example.com/docs) text, but keep [API](https://example.com/api-v2) text with a URL-only edit. - -Reference [Guidebook][guide-v2] and logo ![Reference logo new][logo-v2]. - -Bare links: https://example.com/bare-v2 and ops-v2@example.com. - -Escaped markdown: \*literal asterisks\* and \[literal reference\]. - -Soft break stays here -with the next line updated, and hard break stays here -with the forced line updated. - -![Added chart](https://example.com/chart-new.png "Added title") - -![Screenshot new alt](https://example.com/screenshot.png "New screenshot title") - -[guide-v2]: https://example.com/guide-v2 -[logo-v2]: https://example.com/logo-v2.png "Logo v2" diff --git a/src/extensions/markdown/diff-fixtures/inline-media-link.before.md b/src/extensions/markdown/diff-fixtures/inline-media-link.before.md deleted file mode 100644 index 4cd17357..00000000 --- a/src/extensions/markdown/diff-fixtures/inline-media-link.before.md +++ /dev/null @@ -1,22 +0,0 @@ -# Inline media and links - -Opening line keeps emoji :) around **bold**, `code`, ~~strike~~, and punctuation! - -Change [Docs](https://example.com/docs) text, but keep [API](https://example.com/api-v1) text with a URL-only edit. - -Reference [Guide][guide-v1] and logo ![Reference logo][logo-v1]. - -Bare links: https://example.com/bare-v1 and ops-v1@example.com. - -Escaped markdown: \*literal stars\* and \[literal link\]. - -Soft break stays here -with the next line, and hard break stays here -with the forced line. - -![Removed chart](https://example.com/chart-old.png "Removed title") - -![Screenshot old alt](https://example.com/screenshot.png "Old screenshot title") - -[guide-v1]: https://example.com/guide-v1 -[logo-v1]: https://example.com/logo-v1.png "Logo v1" diff --git a/src/extensions/markdown/diff-fixtures/mixed-gfm-document.after.md b/src/extensions/markdown/diff-fixtures/mixed-gfm-document.after.md deleted file mode 100644 index d973109d..00000000 --- a/src/extensions/markdown/diff-fixtures/mixed-gfm-document.after.md +++ /dev/null @@ -1,21 +0,0 @@ -# Launch review - -Read the [runbook](https://example.com/runbook-v2) and ping . - -| Area | Owner | Status | -| ------- | ----- | -------- | -| API | Dee | Ready | -| Web | Mo | Approved | -| Billing | Ada | Watching | - -- [x] Confirm launch owner -- [x] Update docs - - Keep migration note - - Verify search index - - Add rollback note - -```ts -export const rollout = "global"; -notify("ops"); -notify("support"); -``` diff --git a/src/extensions/markdown/diff-fixtures/mixed-gfm-document.before.md b/src/extensions/markdown/diff-fixtures/mixed-gfm-document.before.md deleted file mode 100644 index 33da4a6e..00000000 --- a/src/extensions/markdown/diff-fixtures/mixed-gfm-document.before.md +++ /dev/null @@ -1,18 +0,0 @@ -# Launch review - -Read the [runbook](https://example.com/runbook) and ping . - -| Area | Owner | Status | -| ---- | ----- | ------ | -| API | Dee | Ready | -| Web | Mo | Draft | - -- [x] Confirm launch owner -- [ ] Update docs - - Keep migration note - - Verify search index - -```ts -export const rollout = "staged"; -notify("ops"); -``` diff --git a/src/extensions/markdown/diff-fixtures/plan-table.after.md b/src/extensions/markdown/diff-fixtures/plan-table.after.md deleted file mode 100644 index 5c696013..00000000 --- a/src/extensions/markdown/diff-fixtures/plan-table.after.md +++ /dev/null @@ -1,5 +0,0 @@ -| Day | Base | Main Move | Food Stop | Evening Mood | -| -------- | -------------- | ---------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------ | -| Friday | Mitte | [Museum Island](https://www.museumsinsel-berlin.de/en/) and the Berliner Dom | Coffee near Hackescher Markt | Easy dinner in Prenzlauer Berg | -| Saturday | Kreuzberg | Landwehr Canal walk and neighborhood wandering | [Markthalle Neun](https://markthalleneun.de/) | Bars around Oranienstrasse | -| Sunday | Charlottenburg | Palace gardens and Savignyplatz | Savignyplatz cafes | Soft exit before departure | diff --git a/src/extensions/markdown/diff-fixtures/plan-table.before.md b/src/extensions/markdown/diff-fixtures/plan-table.before.md deleted file mode 100644 index 57ea3602..00000000 --- a/src/extensions/markdown/diff-fixtures/plan-table.before.md +++ /dev/null @@ -1,5 +0,0 @@ -| Day | Base | Main Move | Food Stop | Evening Mood | -| -------- | -------------- | ------------------------------------------------------- | --------------------------------------------- | ------------------------------ | -| Friday | Mitte | [Museum Island](https://www.museumsinsel-berlin.de/en/) | Coffee near Hackescher Markt | Easy dinner in Prenzlauer Berg | -| Saturday | Kreuzberg | Landwehr Canal walk | [Markthalle Neun](https://markthalleneun.de/) | Bars around Oranienstrasse | -| Sunday | Charlottenburg | Palace gardens | Savignyplatz cafes | Soft exit before departure | diff --git a/src/extensions/markdown/diff-fixtures/quick-facts-list.after.md b/src/extensions/markdown/diff-fixtures/quick-facts-list.after.md deleted file mode 100644 index 8c709f2a..00000000 --- a/src/extensions/markdown/diff-fixtures/quick-facts-list.after.md +++ /dev/null @@ -1,5 +0,0 @@ -- **Best for:** first-time visitors who want museums, food markets, canals, and relaxed neighborhood wandering. -- **Trip length:** three days and two nights. -- **Transit:** U-Bahn, S-Bahn, tram, regional trains, and plenty of walking. -- **Pace:** one main plan per day, with open space around it. -- **Budget:** moderate, with inexpensive lunches and a few paid attractions. diff --git a/src/extensions/markdown/diff-fixtures/quick-facts-list.before.md b/src/extensions/markdown/diff-fixtures/quick-facts-list.before.md deleted file mode 100644 index 6972d903..00000000 --- a/src/extensions/markdown/diff-fixtures/quick-facts-list.before.md +++ /dev/null @@ -1,4 +0,0 @@ -- **Best for:** first-time visitors who want museums, food markets, canals, and relaxed neighborhood wandering. -- **Trip length:** three days and two nights. -- **Transit:** U-Bahn, S-Bahn, tram, and plenty of walking. -- **Pace:** one main plan per day, with open space around it. diff --git a/src/extensions/markdown/diff-fixtures/release-checklist.after.md b/src/extensions/markdown/diff-fixtures/release-checklist.after.md deleted file mode 100644 index ce4c41da..00000000 --- a/src/extensions/markdown/diff-fixtures/release-checklist.after.md +++ /dev/null @@ -1,11 +0,0 @@ -# Release checklist - -- [x] Confirm launch owner - - [x] Draft release notes for beta customers - - [ ] Schedule support handoff -- [ ] Verify analytics - - [ ] Check conversion dashboard - - [x] Confirm event names -- [ ] Archive notes - - [ ] Leave changelog draft untouched -- [ ] Announce internally diff --git a/src/extensions/markdown/diff-fixtures/release-checklist.before.md b/src/extensions/markdown/diff-fixtures/release-checklist.before.md deleted file mode 100644 index ab078f79..00000000 --- a/src/extensions/markdown/diff-fixtures/release-checklist.before.md +++ /dev/null @@ -1,10 +0,0 @@ -# Release checklist - -- [ ] Confirm launch owner - - [ ] Draft release notes - - [ ] Schedule support handoff -- [ ] Verify analytics - - [ ] Check conversion dashboard -- [ ] Archive notes - - [ ] Leave changelog draft untouched -- [ ] Announce internally diff --git a/src/extensions/markdown/diff-fixtures/visual.html b/src/extensions/markdown/diff-fixtures/visual.html deleted file mode 100644 index bec945ae..00000000 --- a/src/extensions/markdown/diff-fixtures/visual.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Markdown Diff Fixtures - - -
- - - diff --git a/src/extensions/markdown/diff-fixtures/visual.ts b/src/extensions/markdown/diff-fixtures/visual.ts deleted file mode 100644 index abe4270b..00000000 --- a/src/extensions/markdown/diff-fixtures/visual.ts +++ /dev/null @@ -1,57 +0,0 @@ -import "@/index.css"; -import "../style.css"; -import { renderMarkdownReviewDiffHtml } from "../render-review-diff-html"; -import { MARKDOWN_DIFF_FIXTURES } from "."; - -const root = document.getElementById("root"); - -if (!root) { - throw new Error("Missing #root"); -} - -root.innerHTML = ` -
-
-
-

Markdown Diff Fixtures

-
-
- ${MARKDOWN_DIFF_FIXTURES.map( - (fixture) => ` -
-

${fixture.title}

-
-
-

Before

-
${escapeHtml(fixture.beforeMarkdown.trimEnd())}
-
-
-

After

-
${escapeHtml(fixture.afterMarkdown.trimEnd())}
-
-
-
-
-
- ${renderMarkdownReviewDiffHtml(fixture)} -
-
-
-
- `, - ).join("")} -
-
-`; - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">"); -} diff --git a/src/extensions/markdown/editor/assemble-md-ast.test.ts b/src/extensions/markdown/editor/assemble-md-ast.test.ts deleted file mode 100644 index b432ccd4..00000000 --- a/src/extensions/markdown/editor/assemble-md-ast.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { test, expect } from "vitest"; -import { openLix } from "@/test-utils/node-lix-sdk"; -import { assembleMdAst } from "./assemble-md-ast"; -import { qb } from "@/lib/lix-kysely"; - -test("assembleMdAst returns empty root when file is missing", async () => { - const lix = await openLix(); - const ast = await assembleMdAst({ lix, fileId: "missing_file" }); - expect(ast).toEqual({ type: "root", children: [] }); -}); - -test("assembleMdAst parses markdown from lix_file.data", async () => { - const lix = await openLix(); - - const fileId = "util_file_1"; - const markdown = "Hello"; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/util.md", - data: new TextEncoder().encode(markdown), - }) - .execute(); - - const ast = await assembleMdAst({ lix, fileId }); - expect(ast?.type).toBe("root"); - const children = (ast as any)?.children || []; - expect(Array.isArray(children)).toBe(true); - const hasHello = children.some( - (n: any) => - n?.type === "paragraph" && - Array.isArray(n.children) && - n.children.some((c: any) => c?.type === "text" && c?.value === "Hello"), - ); - expect(hasHello).toBe(true); -}); diff --git a/src/extensions/markdown/editor/assemble-md-ast.ts b/src/extensions/markdown/editor/assemble-md-ast.ts deleted file mode 100644 index cc76fb14..00000000 --- a/src/extensions/markdown/editor/assemble-md-ast.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Lix } from "@/lib/lix-types"; -import { qb } from "@/lib/lix-kysely"; -import { parseMarkdown } from "./markdown"; -import { decodeMarkdownData } from "./decode-markdown-data"; - -export async function assembleMdAst(args: { - lix: Lix; - fileId: string | null | undefined; -}): Promise { - const { lix, fileId } = args; - if (!fileId) return { type: "root", children: [] }; - - const file = await qb(lix) - .selectFrom("lix_file") - .where("id", "=", fileId) - .select(["data"]) - .executeTakeFirst(); - - if (!file?.data) { - return { type: "root", children: [] }; - } - - const markdown = decodeMarkdownData(file.data); - if (!markdown.trim()) { - return { type: "root", children: [] }; - } - - try { - return parseMarkdown(markdown) as any; - } catch { - return { type: "root", children: [] }; - } -} diff --git a/src/extensions/markdown/editor/block-commands.ts b/src/extensions/markdown/editor/block-commands.ts deleted file mode 100644 index f52fe916..00000000 --- a/src/extensions/markdown/editor/block-commands.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { - CheckSquare, - Code2, - Heading1, - Heading2, - Heading3, - List, - ListOrdered, - Minus, - Pilcrow, - Table, - TextQuote, -} from "lucide-react"; -import type { Editor } from "@tiptap/core"; -import type { ComponentType } from "react"; - -export type BlockCommand = { - id: string; - label: string; - description: string; - icon: ComponentType<{ className?: string }>; - keywords: string[]; - /** Insert action - used by slash commands */ - insert: (editor: Editor) => void; - /** Toggle action - used by toolbar (converts existing block) */ - toggle?: (editor: Editor) => void; -}; - -export const BLOCK_COMMANDS: BlockCommand[] = [ - { - id: "paragraph", - label: "Text", - description: "Paragraph", - icon: Pilcrow, - keywords: ["p", "text", "paragraph"], - insert: (editor) => editor.chain().focus().setNode("paragraph").run(), - toggle: (editor) => editor.chain().focus().setNode("paragraph").run(), - }, - { - id: "heading1", - label: "Heading 1", - description: "Large heading", - icon: Heading1, - keywords: ["h1", "#", "title"], - insert: (editor) => - editor.chain().focus().setNode("heading", { level: 1 }).run(), - toggle: (editor) => - editor.chain().focus().setNode("heading", { level: 1 }).run(), - }, - { - id: "heading2", - label: "Heading 2", - description: "Section heading", - icon: Heading2, - keywords: ["h2", "##", "subtitle"], - insert: (editor) => - editor.chain().focus().setNode("heading", { level: 2 }).run(), - toggle: (editor) => - editor.chain().focus().setNode("heading", { level: 2 }).run(), - }, - { - id: "heading3", - label: "Heading 3", - description: "Subheading", - icon: Heading3, - keywords: ["h3", "###"], - insert: (editor) => - editor.chain().focus().setNode("heading", { level: 3 }).run(), - toggle: (editor) => - editor.chain().focus().setNode("heading", { level: 3 }).run(), - }, - { - id: "bulletList", - label: "Bullet List", - description: "Unordered list", - icon: List, - keywords: ["ul", "-", "unordered", "bullets"], - insert: (editor) => { - const chain = editor.chain().focus() as any; - if (!chain.wrapIn?.("bulletList")?.run?.()) { - chain.toggleBulletList?.()?.run?.(); - } - }, - }, - { - id: "orderedList", - label: "Numbered List", - description: "Ordered list", - icon: ListOrdered, - keywords: ["ol", "1.", "numbered", "ordered"], - insert: (editor) => { - const chain = editor.chain().focus() as any; - if (!chain.wrapIn?.("orderedList")?.run?.()) { - chain.toggleOrderedList?.()?.run?.(); - } - }, - }, - { - id: "taskList", - label: "To-do List", - description: "Checklist", - icon: CheckSquare, - keywords: ["todo", "checkbox", "checklist", "task", "[]"], - insert: (editor) => { - editor - .chain() - .focus() - .insertContent({ - type: "bulletList", - attrs: { isTaskList: true }, - content: [ - { - type: "listItem", - attrs: { checked: false }, - content: [{ type: "paragraph" }], - }, - ], - }) - .run(); - }, - }, - { - id: "codeBlock", - label: "Code Block", - description: "Code snippet", - icon: Code2, - keywords: ["code", "```", "pre", "snippet"], - insert: (editor) => editor.chain().focus().setNode("codeBlock").run(), - toggle: (editor) => { - if (editor.isActive("codeBlock")) { - editor.chain().focus().lift("codeBlock").run(); - } else { - editor.chain().focus().setNode("codeBlock").run(); - } - }, - }, - { - id: "blockquote", - label: "Quote", - description: "Quoted text", - icon: TextQuote, - keywords: [">", "quote", "blockquote"], - insert: (editor) => editor.chain().focus().wrapIn("blockquote").run(), - toggle: (editor) => { - if (editor.isActive("blockquote")) { - editor.chain().focus().lift("blockquote").run(); - } else { - editor.chain().focus().wrapIn("blockquote").run(); - } - }, - }, - { - id: "horizontalRule", - label: "Divider", - description: "Horizontal line", - icon: Minus, - keywords: ["hr", "---", "divider", "line", "separator"], - insert: (editor) => { - editor.chain().focus().insertContent({ type: "horizontalRule" }).run(); - }, - }, - { - id: "table", - label: "Table", - description: "Table grid", - icon: Table, - keywords: ["table", "grid"], - insert: (editor) => { - const rows = []; - for (let r = 0; r < 3; r++) { - const cells = []; - for (let c = 0; c < 3; c++) { - cells.push({ type: "tableCell" }); - } - rows.push({ type: "tableRow", content: cells }); - } - editor - .chain() - .focus() - .insertContent({ type: "table", content: rows }) - .run(); - }, - }, -]; - -/** Block commands that can be used in the toolbar (have toggle action) */ -export const TOOLBAR_BLOCK_COMMANDS = BLOCK_COMMANDS.filter( - (cmd) => cmd.toggle, -); - -/** All block commands for slash menu */ -export const SLASH_BLOCK_COMMANDS = BLOCK_COMMANDS; - -/** Block type values used by the toolbar dropdown */ -export type ToolbarBlockType = - | "paragraph" - | "heading-1" - | "heading-2" - | "heading-3" - | "code" - | "blockquote"; - -/** Block option format for toolbar dropdown */ -export type ToolbarBlockOption = { - value: ToolbarBlockType; - label: string; - description: string; - icon: ComponentType<{ className?: string }>; - apply: (editor: Editor) => void; -}; - -/** Map internal IDs to toolbar dropdown values */ -const idToToolbarValue: Record = { - paragraph: "paragraph", - heading1: "heading-1", - heading2: "heading-2", - heading3: "heading-3", - codeBlock: "code", - blockquote: "blockquote", -}; - -/** Toolbar-specific label overrides (where different from slash menu) */ -const toolbarLabelOverrides: Record< - string, - { label: string; description: string } -> = { - codeBlock: { label: "Code", description: "Code block" }, -}; - -/** Block options formatted for toolbar dropdown */ -export const TOOLBAR_BLOCK_OPTIONS: ToolbarBlockOption[] = - TOOLBAR_BLOCK_COMMANDS.filter((cmd) => idToToolbarValue[cmd.id]).map( - (cmd) => ({ - value: idToToolbarValue[cmd.id]!, - label: toolbarLabelOverrides[cmd.id]?.label ?? cmd.label, - description: - toolbarLabelOverrides[cmd.id]?.description ?? cmd.description, - icon: cmd.icon, - apply: cmd.toggle!, - }), - ); diff --git a/src/extensions/markdown/editor/build-markdown-from-editor.ts b/src/extensions/markdown/editor/build-markdown-from-editor.ts deleted file mode 100644 index 5df6816c..00000000 --- a/src/extensions/markdown/editor/build-markdown-from-editor.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { serializeAst } from "./markdown"; -import { tiptapDocToAst } from "./tiptap-markdown-bridge"; - -const createNodeId = (): string => { - if ( - typeof crypto !== "undefined" && - typeof crypto.randomUUID === "function" - ) { - return crypto.randomUUID().replaceAll("-", "").slice(0, 10); - } - return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`.slice( - 0, - 10, - ); -}; - -function ensureTopLevelIds(children: any[]): void { - const seen = new Set(); - for (const node of children) { - node.data = node.data || {}; - let id = (node.data.id ?? "") as string; - if (!id || seen.has(id)) { - do { - id = createNodeId(); - } while (seen.has(id)); - node.data.id = id; - } - seen.add(id); - } -} - -export const normalizePersistedMarkdown = (markdown: string): string => - markdown.endsWith("\n") ? markdown : `${markdown}\n`; - -export function buildMarkdownFromEditor(editor: any): string { - const ast = tiptapDocToAst(editor.getJSON() as any) as any; - const children = (ast?.children ?? []) as any[]; - ensureTopLevelIds(children); - const root = { - type: "root", - children, - } as any; - return serializeAst(root); -} - -export function buildNormalizedMarkdownFromEditor(editor: any): string { - return normalizePersistedMarkdown(buildMarkdownFromEditor(editor)); -} diff --git a/src/extensions/markdown/editor/create-editor.test.ts b/src/extensions/markdown/editor/create-editor.test.ts deleted file mode 100644 index 60d22137..00000000 --- a/src/extensions/markdown/editor/create-editor.test.ts +++ /dev/null @@ -1,866 +0,0 @@ -import { test, expect } from "vitest"; -import { openLix } from "@/test-utils/node-lix-sdk"; -import { createEditor } from "./create-editor"; -import { astToTiptapDoc } from "./tiptap-markdown-bridge"; -import { parseMarkdown, serializeAst } from "./markdown"; -import { handlePaste } from "./handle-paste"; -import { Editor } from "@tiptap/core"; -import { qb } from "@/lib/lix-kysely"; - -const ensureTrailingNewline = (value: string) => - value.endsWith("\n") ? value : `${value}\n`; - -async function readMarkdown( - lix: Awaited>, - fileId: string, -): Promise { - const row = await qb(lix) - .selectFrom("lix_file") - .where("id", "=", fileId) - .select("data") - .executeTakeFirst(); - return new TextDecoder().decode(row?.data ?? new Uint8Array()); -} - -async function waitForMarkdown( - lix: Awaited>, - fileId: string, - matches: (markdown: string) => boolean, -): Promise { - let markdown = ""; - for (let i = 0; i < 40; i += 1) { - markdown = await readMarkdown(lix, fileId); - if (matches(markdown)) { - return markdown; - } - await new Promise((resolve) => setTimeout(resolve, 20)); - } - return markdown; -} - -function paragraphTexts(markdown: string): string[] { - return markdown - .trim() - .split(/\n{2,}/) - .map((paragraph) => paragraph.trim()) - .filter(Boolean); -} - -function buildLongMarkdownRepro(): string { - const parts = [ - "# Complex roundtrip document", - "", - "Opening paragraph with **bold**, _italic_, `inline code`, and [a link](https://example.com).", - "", - ]; - for (let i = 1; i <= 36; i += 1) { - parts.push(`## Section ${String(i).padStart(2, "0")}`); - parts.push(""); - parts.push( - `Paragraph ${i} alpha. This text should remain after edits, including punctuation: commas, semicolons; and parentheses (like this).`, - ); - parts.push(""); - if (i % 3 === 0) { - parts.push(`- Bullet ${i}.1 remains`); - parts.push(`- Bullet ${i}.2 remains`); - parts.push(""); - } - if (i % 4 === 0) { - parts.push(`- [ ] Todo ${i} remains unchecked`); - parts.push(`- [x] Done ${i} remains checked`); - parts.push(""); - } - if (i % 5 === 0) { - parts.push(`1. Ordered ${i}.1 remains`); - parts.push(`2. Ordered ${i}.2 remains`); - parts.push(""); - } - if (i % 6 === 0) { - parts.push(`> Quote ${i} should remain below edit points.`); - parts.push(""); - } - if (i === 18) { - parts.push( - "TARGET paragraph. Editing inside this line should not delete anything below it.", - ); - parts.push(""); - } - } - parts.push("## Tail table"); - parts.push(""); - parts.push("| Name | Value |"); - parts.push("| - | - |"); - parts.push("| tail-a | survives |"); - parts.push("| tail-b | survives |"); - parts.push(""); - parts.push("```ts"); - parts.push('export const tail = "survives";'); - parts.push("```"); - parts.push(""); - parts.push( - "Final paragraph at the very bottom must survive mid-document edits.", - ); - return `${parts.join("\n")}\n`; -} - -function positionAfterText(editor: Editor, needle: string): number { - let found: number | null = null; - editor.state.doc.descendants((node, pos) => { - if (found != null) return false; - if (!node.isText) return true; - const text = node.text ?? ""; - const index = text.indexOf(needle); - if (index >= 0) { - found = pos + index + needle.length; - return false; - } - return true; - }); - if (found == null) { - throw new Error(`Could not find text in editor: ${needle}`); - } - return found; -} - -async function createEditorFromFile(args: { - lix: Awaited>; - fileId: string; - persistDebounceMs?: number; -}) { - const row = await qb(args.lix) - .selectFrom("lix_file") - .where("id", "=", args.fileId) - .select(["data"]) - .executeTakeFirst(); - - const initialMarkdown = new TextDecoder().decode( - row?.data ?? new Uint8Array(), - ); - const editor = createEditor({ - lix: args.lix, - fileId: args.fileId, - initialMarkdown, - persistDebounceMs: args.persistDebounceMs, - }); - - return editor; -} - -test("clicking a rendered markdown link opens it externally", async () => { - const lix = await openLix(); - const originalDesktop = window.flashtypeDesktop; - const openedUrls: string[] = []; - window.flashtypeDesktop = { - app: { - openExternal: async ({ url }: { url: string }) => { - openedUrls.push(url); - return { status: "opened" }; - }, - }, - } as unknown as Window["flashtypeDesktop"]; - const editor = createEditor({ - lix, - initialMarkdown: "Read the [docs](https://example.com/docs).", - persistState: false, - }); - const editorDom = editor.view.dom; - document.body.appendChild(editorDom); - - try { - const link = editor.view.dom.querySelector("a"); - expect(link).toBeInstanceOf(HTMLAnchorElement); - - const clickEvent = new MouseEvent("click", { - bubbles: true, - cancelable: true, - button: 0, - }); - clickEvent.preventDefault(); - link?.dispatchEvent(clickEvent); - - expect(clickEvent.defaultPrevented).toBe(true); - expect(openedUrls).toEqual(["https://example.com/docs"]); - } finally { - editor.destroy(); - editorDom.remove(); - window.flashtypeDesktop = originalDesktop; - } -}); - -test("clicking a relative or fragment markdown link does not open externally", async () => { - const lix = await openLix(); - const originalDesktop = window.flashtypeDesktop; - const openedUrls: string[] = []; - window.flashtypeDesktop = { - app: { - openExternal: async ({ url }: { url: string }) => { - openedUrls.push(url); - return { status: "opened" }; - }, - }, - } as unknown as Window["flashtypeDesktop"]; - const editor = createEditor({ - lix, - initialMarkdown: - "Read the [guide](./guide.md) or jump to [intro](#intro).", - persistState: false, - }); - const editorDom = editor.view.dom; - document.body.appendChild(editorDom); - - try { - const links = Array.from(editor.view.dom.querySelectorAll("a")); - expect(links).toHaveLength(2); - - for (const link of links) { - const clickEvent = new MouseEvent("click", { - bubbles: true, - cancelable: true, - button: 0, - }); - link.dispatchEvent(clickEvent); - expect(clickEvent.defaultPrevented).toBe(false); - } - - expect(openedUrls).toEqual([]); - } finally { - editor.destroy(); - editorDom.remove(); - window.flashtypeDesktop = originalDesktop; - } -}); - -// TipTap + Lix persistence paste tests (no React) -test("paste at start inserts before existing content (TipTap + Lix)", async () => { - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - ], - }); - const fileId = "paste_start_before"; - - // Seed initial file content - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/paste-start.md", - data: new TextEncoder().encode("Start"), - }) - .execute(); - - // Create editor from fileId (auto-loads initial content) - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - - // Set cursor at start and simulate paste of plain text - editor.commands.setTextSelection(1); - await new Promise((resolve) => setTimeout(resolve, 0)); - await handlePaste({ - editor, - event: { - preventDefault: () => {}, - clipboardData: { - getData: (t: string) => (t === "text/plain" ? "New" : ""), - }, - }, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const mdAfter = await waitForMarkdown( - lix, - fileId, - (markdown) => markdown === ensureTrailingNewline("New\n\nStart"), - ); - expect(mdAfter).toBe(ensureTrailingNewline("New\n\nStart")); - - editor.destroy(); -}); - -test("paste at end inserts after existing content (TipTap + Lix)", async () => { - const lix = await openLix(); - const fileId = "paste_end_after"; - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/paste-end.md", - data: new TextEncoder().encode("Start"), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - const end = editor.state.doc.content.size; - editor.commands.setTextSelection(end); - await handlePaste({ - editor, - event: { - preventDefault: () => {}, - clipboardData: { - getData: (t: string) => (t === "text/plain" ? "New" : ""), - }, - }, - }); - - const mdAfter = await waitForMarkdown( - lix, - fileId, - (markdown) => markdown === ensureTrailingNewline("Start\n\nNew"), - ); - expect(mdAfter).toBe(ensureTrailingNewline("Start\n\nNew")); - editor.destroy(); -}); - -test("replace word selection with paste (TipTap + Lix)", async () => { - const lix = await openLix(); - const fileId = "paste_replace_word"; - const initial = "Replace THIS TEXT here."; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/paste-replace-word.md", - data: new TextEncoder().encode(initial), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - // Select the substring "THIS TEXT" (roughly positions 9..18 in PM coords) - editor.commands.setTextSelection({ from: 9, to: 18 }); - await handlePaste({ - editor, - event: { - preventDefault: () => {}, - clipboardData: { - getData: (t: string) => (t === "text/plain" ? "new content" : ""), - }, - }, - }); - - const mdAfter = await waitForMarkdown( - lix, - fileId, - (markdown) => - markdown === ensureTrailingNewline("Replace new content here."), - ); - expect(mdAfter).toBe(ensureTrailingNewline("Replace new content here.")); - editor.destroy(); -}); - -test("replace entire document with paste (TipTap + Lix)", async () => { - const lix = await openLix(); - const fileId = "paste_replace_all"; - const initial = "Old content\n\nTo be replaced"; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/paste-replace-all.md", - data: new TextEncoder().encode(initial), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - editor.commands.selectAll(); - await handlePaste({ - editor, - event: { - preventDefault: () => {}, - clipboardData: { - getData: (t: string) => - t === "text/plain" ? "# New Document\n\nCompletely new content" : "", - }, - }, - }); - - const expectedMarkdown = ensureTrailingNewline( - "# New Document\n\nCompletely new content", - ); - const mdAfter = await waitForMarkdown( - lix, - fileId, - (markdown) => markdown === expectedMarkdown, - ); - expect(mdAfter).toBe(expectedMarkdown); - editor.destroy(); -}); - -test("paste multi-paragraph plain text into empty doc (TipTap + Lix)", async () => { - const lix = await openLix(); - const fileId = "paste_plain_multi"; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/paste-plain-multi.md", - data: new TextEncoder().encode(""), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - - await handlePaste({ - editor, - event: { - preventDefault: () => {}, - clipboardData: { - getData: (t: string) => - t === "text/plain" ? "First line\n\nSecond line" : "", - }, - }, - }); - - const mdAfter = await waitForMarkdown( - lix, - fileId, - (markdown) => - markdown === ensureTrailingNewline("First line\n\nSecond line"), - ); - expect(mdAfter).toBe(ensureTrailingNewline("First line\n\nSecond line")); - editor.destroy(); -}); - -test("Enter splits paragraph into persisted markdown paragraphs", async () => { - const lix = await openLix(); - const fileId = "enter_split_ids_unique"; - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/enter-split.md", - data: new TextEncoder().encode("Hello world."), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - - // Place caret after "Hello" - const para = editor.state.doc.child(0); - const paraFrom = 1; - const idxHello = para.textContent.indexOf("Hello"); - const posSplit = paraFrom + 1 + idxHello + "Hello".length; - editor.commands.setTextSelection(posSplit); - - // Simulate an Enter key press - const event = new KeyboardEvent("keydown", { - key: "Enter", - bubbles: true, - cancelable: true, - }); - editor.view.someProp("handleKeyDown", (f) => f(editor.view, event)); - - // Give onUpdate/persist a tick (persistDebounceMs=0 still runs async) - await new Promise((r) => setTimeout(r, 0)); - - const markdown = await waitForMarkdown( - lix, - fileId, - (value) => paragraphTexts(value).length === 2, - ); - expect(paragraphTexts(markdown)).toEqual(["Hello", "world."]); - - editor.destroy(); -}); - -test("two Enters create three persisted paragraphs in order", async () => { - const lix = await openLix(); - const fileId = "enter_split_three"; - - // Seed with a single paragraph - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/enter-split-three.md", - data: new TextEncoder().encode("Hello world"), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - - // Move caret to end and split → new empty paragraph (#2) - const end = editor.state.doc.content.size; - editor.commands.setTextSelection(end); - editor.commands.splitBlock(); - // Type content for paragraph #2 - editor.commands.insertContent("How are you? "); - - // Split again → new paragraph (#3) - editor.commands.splitBlock(); - editor.commands.insertContent("Good and you? "); - - const markdown = await waitForMarkdown( - lix, - fileId, - (value) => paragraphTexts(value).length === 3, - ); - expect(paragraphTexts(markdown)).toEqual([ - "Hello world", - "How are you?", - "Good and you?", - ]); - - editor.destroy(); -}); - -test("normalize CRLF line endings on paste (TipTap + Lix)", async () => { - const lix = await openLix(); - const fileId = "paste_crlf"; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/paste-crlf.md", - data: new TextEncoder().encode(""), - }) - .execute(); - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - await handlePaste({ - editor, - event: { - preventDefault: () => {}, - clipboardData: { - getData: (t: string) => - t === "text/plain" ? "Line one\r\n\r\nLine two" : "", - }, - }, - }); - const mdAfter = await waitForMarkdown( - lix, - fileId, - (markdown) => markdown === ensureTrailingNewline("Line one\n\nLine two"), - ); - expect(mdAfter).toBe(ensureTrailingNewline("Line one\n\nLine two")); - editor.destroy(); -}); - -test("paste complex markdown with lists and code blocks (TipTap + Lix)", async () => { - const lix = await openLix(); - const fileId = "paste_complex"; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/paste-complex.md", - data: new TextEncoder().encode(""), - }) - .execute(); - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - const complex = `# Title\n\n- Item 1\n- Item 2\n\n\`\`\`javascript\nconst x = 1;\n\`\`\``; - await handlePaste({ - editor, - event: { - preventDefault: () => {}, - clipboardData: { - getData: (t: string) => (t === "text/plain" ? complex : ""), - }, - }, - }); - const mdAfter = await waitForMarkdown( - lix, - fileId, - (markdown) => - markdown.includes("# Title") && - markdown.includes("- Item 1") && - markdown.includes("- Item 2") && - markdown.includes("```javascript") && - markdown.includes("const x = 1;"), - ); - expect(mdAfter).toContain("# Title"); - expect(mdAfter).toContain("- Item 1"); - expect(mdAfter).toContain("- Item 2"); - expect(mdAfter).toContain("```javascript"); - expect(mdAfter).toContain("const x = 1;"); - editor.destroy(); -}); - -test("paste inline formatting markdown (TipTap + Lix)", async () => { - const lix = await openLix(); - const fileId = "paste_inline_format"; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/paste-inline-format.md", - data: new TextEncoder().encode(""), - }) - .execute(); - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - const input = "This has **bold**, _italic_, and `code`."; - await handlePaste({ - editor, - event: { - preventDefault: () => {}, - clipboardData: { - getData: (t: string) => (t === "text/plain" ? input : ""), - }, - }, - }); - const mdAfter = await waitForMarkdown( - lix, - fileId, - (markdown) => markdown === ensureTrailingNewline(input), - ); - expect(mdAfter).toBe(ensureTrailingNewline(input)); - editor.destroy(); -}); - -/** - * Why this matters - * - * - Rapid user input can trigger multiple editor updates in quick succession. - * - Without serialized persistence, overlapping transactions can drop rows, - * drop or reorder persisted markdown paragraphs. - * - This test simulates Enter + typing without awaits to assert our debounce/queue - * logic persists a consistent 3-paragraph document. - */ -test("rapid Enter/type coalescing persists 3 paragraphs", async () => { - const lix = await openLix(); - const fileId = "rapid_enter_coalesce"; - - // Seed with a single paragraph - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/rapid-enter.md", - data: new TextEncoder().encode("Start"), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - - // Simulate rapid user actions with no awaits between Enter and typing - const end = editor.state.doc.content.size; - editor.commands.setTextSelection(end); - // Enter (new paragraph), then type quickly - { - const ev = new KeyboardEvent("keydown", { - key: "Enter", - bubbles: true, - cancelable: true, - }); - editor.view.someProp("handleKeyDown", (f) => f(editor.view, ev)); - } - editor.commands.insertContent("Second "); - - // Enter again and type another paragraph - editor.commands.setTextSelection(editor.state.doc.content.size); - { - const ev = new KeyboardEvent("keydown", { - key: "Enter", - bubbles: true, - cancelable: true, - }); - editor.view.someProp("handleKeyDown", (f) => f(editor.view, ev)); - } - editor.commands.insertContent("Third "); - - const markdown = await waitForMarkdown( - lix, - fileId, - (value) => paragraphTexts(value).length === 3, - ); - expect(paragraphTexts(markdown)).toEqual(["Start", "Second", "Third"]); - - editor.destroy(); -}); - -test("delete removes the middle paragraph from persisted markdown", async () => { - const lix = await openLix(); - const fileId = "delete_middle_cleanup"; - - // Seed with three paragraphs - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/delete-cleanup.md", - data: new TextEncoder().encode("Start\n\nSecond\n\nThird"), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - - // Force a persistence round on initial content - editor.commands.setContent( - astToTiptapDoc(parseMarkdown("Start\n\nSecond\n\nThird")) as any, - ); - - // Replace document with only first and third paragraphs (simulate deletion) - editor.commands.setContent( - astToTiptapDoc(parseMarkdown("Start\n\nThird")) as any, - ); - - const markdown = await waitForMarkdown( - lix, - fileId, - (value) => paragraphTexts(value).length === 2, - ); - expect(paragraphTexts(markdown)).toEqual(["Start", "Third"]); - - editor.destroy(); -}); - -test("destroy flushes pending autosave for an existing file", async () => { - const lix = await openLix(); - const fileId = "destroy_flush_pending_autosave"; - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/destroy-flush-pending-autosave.md", - data: new TextEncoder().encode("Start"), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 50, - }); - - editor.commands.setTextSelection(editor.state.doc.content.size); - editor.commands.insertContent(" Changed"); - editor.destroy(); - - const markdown = await waitForMarkdown( - lix, - fileId, - (value) => value === ensureTrailingNewline("Start Changed"), - ); - expect(markdown).toBe(ensureTrailingNewline("Start Changed")); - - await lix.close(); -}); - -test("destroy flushes pending autosave without recreating a deleted file", async () => { - const lix = await openLix(); - const fileId = "destroy_cancel_pending_autosave"; - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/destroy-cancel-pending-autosave.md", - data: new TextEncoder().encode("Start"), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 50, - }); - - editor.commands.setTextSelection(editor.state.doc.content.size); - editor.commands.insertContent(" Changed"); - await qb(lix).deleteFrom("lix_file").where("id", "=", fileId).execute(); - editor.destroy(); - - await new Promise((resolve) => setTimeout(resolve, 80)); - const row = await qb(lix) - .selectFrom("lix_file") - .select(["id", "path"]) - .where("id", "=", fileId) - .executeTakeFirst(); - expect(row).toBeUndefined(); - - await lix.close(); -}); - -test("editing a long markdown document does not truncate content below the edit point", async () => { - const lix = await openLix(); - const fileId = "long_markdown_mid_edit"; - const initial = buildLongMarkdownRepro(); - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/long-markdown-mid-edit.md", - data: new TextEncoder().encode(initial), - }) - .execute(); - - const editor: Editor = await createEditorFromFile({ - lix, - fileId, - persistDebounceMs: 0, - }); - - editor.commands.setTextSelection(positionAfterText(editor, "TARGET")); - editor.commands.insertContent(" EXACT"); - const expected = serializeAst(parseMarkdown(initial)).replace( - "TARGET paragraph.", - "TARGET EXACT paragraph.", - ); - - const markdown = await waitForMarkdown( - lix, - fileId, - (value) => value === expected, - ); - expect(markdown).toBe(expected); - - await new Promise((resolve) => setTimeout(resolve, 50)); - const settledMarkdown = await readMarkdown(lix, fileId); - expect(settledMarkdown).toBe(expected); - - editor.destroy(); -}); diff --git a/src/extensions/markdown/editor/create-editor.ts b/src/extensions/markdown/editor/create-editor.ts deleted file mode 100644 index ac84e523..00000000 --- a/src/extensions/markdown/editor/create-editor.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { Editor } from "@tiptap/core"; -import History from "@tiptap/extension-history"; -import Placeholder from "@tiptap/extension-placeholder"; -import type { Lix } from "@/lib/lix-types"; -import { MarkdownWc, astToTiptapDoc } from "./tiptap-markdown-bridge"; -import type { EmptyMarkdownDefaultBlock } from "./tiptap-markdown-bridge"; -import { parseMarkdown, serializeAst } from "./markdown"; -import { handlePaste as defaultHandlePaste } from "./handle-paste"; -import { SlashCommandsExtension } from "./extensions/slash-commands"; -import { TableNavigationExtension } from "./extensions/table-navigation"; -import { upsertMarkdownFile } from "./upsert-markdown-file"; -import { - buildNormalizedMarkdownFromEditor, - normalizePersistedMarkdown, -} from "./build-markdown-from-editor"; - -type CreateEditorArgs = { - lix: Lix; - initialMarkdown?: string; - contentAst?: any; - onCreate?: (args: { editor: Editor }) => void; - onUpdate?: (args: { editor: Editor }) => void | false; - editorProps?: any; - editable?: boolean; - fileId?: string; - defaultBlock?: EmptyMarkdownDefaultBlock; - persistDebounceMs?: number; - persistState?: boolean; - resolveImageSrc?: (src: string) => string; - originKey?: string; -}; - -export const createMarkdownEditorOriginKey = (): string => { - if ( - typeof crypto !== "undefined" && - typeof crypto.randomUUID === "function" - ) { - return `flashtype.markdown-editor:${crypto.randomUUID()}`; - } - return `flashtype.markdown-editor:${Date.now().toString(36)}${Math.random() - .toString(36) - .slice(2)}`; -}; - -function flushEditorViewDomObserver(view: any): void { - view?.domObserver?.flush?.(); -} - -function isSelectionNavigationKey(event: KeyboardEvent): boolean { - return ( - event.key === "ArrowLeft" || - event.key === "ArrowRight" || - event.key === "ArrowUp" || - event.key === "ArrowDown" || - event.key === "Home" || - event.key === "End" || - event.key === "PageUp" || - event.key === "PageDown" - ); -} - -function externalLinkUrlFromClick(event: MouseEvent): string | null { - if (event.button !== 0) { - return null; - } - const target = - event.target instanceof Element ? event.target.closest("a[href]") : null; - if (!(target instanceof HTMLAnchorElement)) { - return null; - } - const href = target.getAttribute("href")?.trim(); - if (!href) { - return null; - } - const protocolMatch = href.match(/^([a-zA-Z][a-zA-Z\d+.-]*):/); - const protocol = protocolMatch?.[1]?.toLowerCase(); - if (protocol === "http" || protocol === "https" || protocol === "mailto") { - return target.href; - } - return null; -} - -function openExternalLink(url: string): void { - const openExternal = window.flashtypeDesktop?.app?.openExternal; - if (openExternal) { - void openExternal({ url }); - return; - } - window.open(url, "_blank", "noopener,noreferrer"); -} - -function handleExternalLinkClick(event: MouseEvent): void { - const url = externalLinkUrlFromClick(event); - if (!url) { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - openExternalLink(url); -} - -// Plain TipTap Editor factory (no React). Useful for unit/integration tests. -export function createEditor(args: CreateEditorArgs): Editor { - const { - lix, - initialMarkdown, - contentAst, - onCreate, - onUpdate, - editorProps, - editable = true, - fileId, - defaultBlock, - persistDebounceMs, - persistState = true, - resolveImageSrc, - originKey = createMarkdownEditorOriginKey(), - } = args; - - const ast = contentAst ?? (parseMarkdown(initialMarkdown ?? "") as any); - - let persistStateTimer: any = null; - let persistRunning = false; - let persistQueued = false; - let persistPromise: Promise | null = null; - let destroyed = false; - let editorInstance: Editor | null = null; - let currentEditor: Editor | null = null; - let cleanupExternalLinkClick: (() => void) | null = null; - let lastPersistedMarkdown = normalizePersistedMarkdown( - initialMarkdown ?? serializeAst(ast as any), - ); - const persistDebounceMsResolved = persistDebounceMs ?? 0; - const persistOnce = async (editor: Editor) => { - const markdown = buildNormalizedMarkdownFromEditor(editor); - if (markdown === lastPersistedMarkdown) return; - await upsertMarkdownFile({ - lix, - fileId: fileId!, - markdown, - createIfMissing: false, - originKey, - }); - lastPersistedMarkdown = markdown; - }; - const runPersist = (editor: Editor): Promise => { - if (!fileId || !persistState) return Promise.resolve(); - if (persistRunning) { - persistQueued = true; - return persistPromise ?? Promise.resolve(); - } - persistRunning = true; - persistPromise = (async () => { - try { - do { - persistQueued = false; - await persistOnce(editor); - } while (persistQueued && !destroyed); - } finally { - persistRunning = false; - persistPromise = null; - } - })(); - return persistPromise; - }; - - const placeholderConfig: any = { - placeholder: ({ node }: { node: any }) => { - if (node.childCount !== 0) return ""; - if (node.type.name === "heading" && node.attrs?.level === 1) { - return "Heading 1"; - } - return node.type.name === "paragraph" ? "Start typing..." : ""; - }, - showOnlyWhenEditable: true, - includeChildren: true, - shouldShow: ({ editor, node }: { editor: Editor; node: any }) => - editor.isFocused && - (node.type.name === "paragraph" || - (node.type.name === "heading" && node.attrs?.level === 1)) && - node.childCount === 0, - }; - - const markdownExtensions = MarkdownWc({ resolveImageSrc }) as any[]; - - editorInstance = new Editor({ - extensions: [ - ...markdownExtensions, - History.configure({ - depth: 200, - newGroupDelay: 500, - }), - Placeholder.configure(placeholderConfig), - SlashCommandsExtension.configure({ - onStateChange: () => {}, - }), - TableNavigationExtension, - ], - editable, - content: astToTiptapDoc(ast, { defaultBlock }) as any, - onCreate: ({ editor }) => { - currentEditor = editor as Editor; - lastPersistedMarkdown = buildNormalizedMarkdownFromEditor(editor); - onCreate?.({ editor }); - }, - onUpdate: ({ editor }) => { - if (destroyed) return; - if (onUpdate?.({ editor }) === false) return; - if (!fileId || !persistState) return; - const scheduleRun = () => { - if (destroyed) return; - if (persistDebounceMsResolved <= 0) { - void runPersist(editor); - return; - } - if (persistStateTimer) clearTimeout(persistStateTimer); - persistStateTimer = setTimeout(() => { - persistStateTimer = null; - if (destroyed) return; - void runPersist(editor); - }, persistDebounceMsResolved); - }; - scheduleRun(); - }, - onDestroy: () => { - cleanupExternalLinkClick?.(); - cleanupExternalLinkClick = null; - persistQueued = false; - if (persistStateTimer) { - clearTimeout(persistStateTimer); - persistStateTimer = null; - } - const editorToPersist = currentEditor ?? editorInstance; - if (editorToPersist && fileId && persistState) { - if (persistRunning) { - persistQueued = true; - } - void runPersist(editorToPersist).finally(() => { - destroyed = true; - currentEditor = null; - }); - } else { - destroyed = true; - currentEditor = null; - } - }, - editorProps: { - handlePaste: async (_view: any, event: ClipboardEvent) => { - if (!currentEditor) return false; - return await defaultHandlePaste({ - editor: currentEditor as any, - event, - }); - }, - ...editorProps, - handleDOMEvents: { - ...(editorProps?.handleDOMEvents ?? {}), - keyup: (view: any, event: KeyboardEvent) => { - if (isSelectionNavigationKey(event)) { - flushEditorViewDomObserver(view); - } - const handleKeyUp = editorProps?.handleDOMEvents?.keyup; - return typeof handleKeyUp === "function" - ? handleKeyUp(view, event) - : false; - }, - }, - }, - }); - const editorDom = editorInstance.view.dom; - editorDom.addEventListener("click", handleExternalLinkClick, { - capture: true, - }); - cleanupExternalLinkClick = () => { - editorDom.removeEventListener("click", handleExternalLinkClick, { - capture: true, - }); - }; - currentEditor = editorInstance; - return editorInstance; -} - -// React useEditor config builder. TipTapEditor should use this to keep a single source. diff --git a/src/extensions/markdown/editor/decode-markdown-data.test.ts b/src/extensions/markdown/editor/decode-markdown-data.test.ts deleted file mode 100644 index a964a41f..00000000 --- a/src/extensions/markdown/editor/decode-markdown-data.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { decodeMarkdownData } from "./decode-markdown-data"; - -describe("decodeMarkdownData", () => { - test("decodes Uint8Array bytes", () => { - const value = new TextEncoder().encode("hello"); - expect(decodeMarkdownData(value)).toBe("hello"); - }); - - test("decodes hex string values", () => { - expect(decodeMarkdownData("0x68656c6c6f")).toBe("hello"); - }); - - test("decodes canonical text wrapper", () => { - expect(decodeMarkdownData({ kind: "text", value: "hello" })).toBe("hello"); - }); - - test("decodes canonical blob base64 wrapper", () => { - expect(decodeMarkdownData({ kind: "blob", base64: "aGVsbG8=" })).toBe( - "hello", - ); - }); - - test("decodes canonical blob value wrapper", () => { - expect(decodeMarkdownData({ kind: "blob", value: "aGVsbG8=" })).toBe( - "hello", - ); - }); - - test("decodes empty canonical blob as empty text", () => { - expect(decodeMarkdownData({ kind: "blob", base64: "" })).toBe(""); - }); -}); diff --git a/src/extensions/markdown/editor/decode-markdown-data.ts b/src/extensions/markdown/editor/decode-markdown-data.ts deleted file mode 100644 index 108e4a88..00000000 --- a/src/extensions/markdown/editor/decode-markdown-data.ts +++ /dev/null @@ -1,119 +0,0 @@ -const textDecoder = new TextDecoder(); - -function hexToBytes(value: string): Uint8Array { - const hex = value.startsWith("0x") ? value.slice(2) : value; - if (hex.length === 0) return new Uint8Array(); - if (hex.length % 2 !== 0) return new Uint8Array(); - if (!/^[0-9a-fA-F]+$/.test(hex)) return new Uint8Array(); - - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); - } - return bytes; -} - -function base64ToBytes(value: string): Uint8Array { - if (!value) return new Uint8Array(); - const maybeBuffer = (globalThis as { Buffer?: { from: Function } }).Buffer; - if (maybeBuffer && typeof maybeBuffer.from === "function") { - return new Uint8Array(maybeBuffer.from(value, "base64")); - } - if (typeof atob !== "function") { - return new Uint8Array(); - } - const decoded = atob(value); - const bytes = new Uint8Array(decoded.length); - for (let i = 0; i < decoded.length; i += 1) { - bytes[i] = decoded.charCodeAt(i); - } - return bytes; -} - -function decodeBlobLike(value: unknown): Uint8Array | null { - if (value instanceof Uint8Array) return value; - if (value instanceof ArrayBuffer) return new Uint8Array(value); - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - } - if (Array.isArray(value)) { - return Uint8Array.from(value as number[]); - } - if (typeof value === "string") { - if (value.startsWith("0x")) { - return hexToBytes(value); - } - return base64ToBytes(value); - } - return null; -} - -function unwrapSerializedValue(value: unknown): unknown { - if (!value || typeof value !== "object") { - return value; - } - - const record = value as Record; - const kind = typeof record.kind === "string" ? record.kind : null; - if (!kind) { - return value; - } - - switch (kind) { - case "null": - case "Null": - return null; - case "bool": - case "Boolean": - return Boolean(record.value); - case "int": - case "Integer": - case "float": - case "Real": - return record.value; - case "text": - case "Text": - return typeof record.value === "string" - ? record.value - : String(record.value ?? ""); - case "blob": - case "Blob": { - if (typeof record.base64 === "string") { - return base64ToBytes(record.base64); - } - const decoded = decodeBlobLike(record.value); - return decoded ?? new Uint8Array(); - } - default: - if ("value" in record) { - return record.value; - } - return value; - } -} - -export function decodeMarkdownData(value: unknown): string { - const raw = unwrapSerializedValue(value); - if (raw === null || raw === undefined) return ""; - if (typeof raw === "string") { - if (raw.startsWith("0x")) { - return textDecoder.decode(hexToBytes(raw)); - } - return raw; - } - if (raw instanceof Uint8Array) return textDecoder.decode(raw); - if (raw instanceof ArrayBuffer) - return textDecoder.decode(new Uint8Array(raw)); - if (ArrayBuffer.isView(raw)) { - return textDecoder.decode( - new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength), - ); - } - if (Array.isArray(raw)) { - return textDecoder.decode(Uint8Array.from(raw as number[])); - } - if (typeof raw === "number" || typeof raw === "boolean") { - return String(raw); - } - return ""; -} diff --git a/src/extensions/markdown/editor/desktop-workspace.ts b/src/extensions/markdown/editor/desktop-workspace.ts deleted file mode 100644 index f072b435..00000000 --- a/src/extensions/markdown/editor/desktop-workspace.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect, useState } from "react"; - -type WorkspaceDirState = { - readonly loaded: boolean; - readonly workspaceDir: string | null; -}; - -export function useDesktopWorkspaceDir(): WorkspaceDirState { - const [state, setState] = useState(() => { - const desktopLix = desktopLixApi(); - return desktopLix - ? { loaded: false, workspaceDir: null } - : { loaded: true, workspaceDir: null }; - }); - - useEffect(() => { - const desktopLix = desktopLixApi(); - if (!desktopLix) { - setState({ loaded: true, workspaceDir: null }); - return; - } - - let cancelled = false; - void desktopLix.workspaceDir().then( - (workspaceDir) => { - if (!cancelled) { - setState({ loaded: true, workspaceDir }); - } - }, - () => { - if (!cancelled) { - setState({ loaded: true, workspaceDir: null }); - } - }, - ); - - return () => { - cancelled = true; - }; - }, []); - - return state; -} - -function desktopLixApi(): - | NonNullable["lix"] - | undefined { - if (typeof window === "undefined") { - return undefined; - } - return window.flashtypeDesktop?.lix; -} - -export function desktopWorkspaceApi(): - | NonNullable["workspace"] - | undefined { - if (typeof window === "undefined") { - return undefined; - } - return window.flashtypeDesktop?.workspace; -} diff --git a/src/extensions/markdown/editor/editor-context.tsx b/src/extensions/markdown/editor/editor-context.tsx deleted file mode 100644 index 86201db9..00000000 --- a/src/extensions/markdown/editor/editor-context.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from "react"; - -type EditorContextValue = { - editor: any | null; - setEditor: (editor: any | null) => void; -}; - -const Ctx = React.createContext(undefined); - -export function EditorProvider({ children }: { children: React.ReactNode }) { - const [editor, setEditor] = React.useState(null); - const setEditorStable = React.useCallback((next: any | null) => { - setEditor(next); - }, []); - const value = React.useMemo( - () => ({ editor, setEditor: setEditorStable }), - [editor, setEditorStable], - ); - return {children}; -} - -export function useEditorCtx() { - const ctx = React.useContext(Ctx); - if (!ctx) - throw new Error("useEditorCtx must be used within "); - return ctx; -} diff --git a/src/extensions/markdown/editor/extensions/slash-commands.ts b/src/extensions/markdown/editor/extensions/slash-commands.ts deleted file mode 100644 index f2cc1c60..00000000 --- a/src/extensions/markdown/editor/extensions/slash-commands.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Extension, type CommandProps } from "@tiptap/core"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; -import type { EditorView } from "@tiptap/pm/view"; - -// Extend Commands interface for slash command extension -declare module "@tiptap/core" { - interface Commands { - slashCommands: { - closeSlashMenu: () => ReturnType; - deleteSlashCommand: () => ReturnType; - }; - } -} - -export const slashCommandsPluginKey = new PluginKey( - "slashCommands", -); - -export type SlashCommandState = { - active: boolean; - query: string; - range: { from: number; to: number } | null; -}; - -export type SlashCommandsOptions = { - onStateChange: (state: SlashCommandState) => void; -}; - -/** - * TipTap extension that detects "/" typed anywhere in text and tracks - * subsequent characters as a filter query for the slash command menu. - * - * Does NOT trigger inside code blocks or inline code. - */ -export const SlashCommandsExtension = Extension.create({ - name: "slashCommands", - - addOptions() { - return { - onStateChange: () => {}, - }; - }, - - addProseMirrorPlugins() { - const { onStateChange } = this.options; - - return [ - new Plugin({ - key: slashCommandsPluginKey, - state: { - init(): SlashCommandState { - return { active: false, query: "", range: null }; - }, - apply(tr, prev, _oldState, newState): SlashCommandState { - // Check for explicit close via metadata - const meta = tr.getMeta(slashCommandsPluginKey); - if (meta?.close) { - return { active: false, query: "", range: null }; - } - - // If not active and no text input, keep inactive - if (!prev.active && !tr.docChanged) { - return prev; - } - - const { selection } = newState; - const { $from } = selection; - - // Don't trigger in code blocks or inline code - const parentNode = $from.parent; - if (parentNode.type.name === "codeBlock") { - if (prev.active) { - return { active: false, query: "", range: null }; - } - return prev; - } - - // Check for inline code mark - const marks = $from.marks(); - const hasCodeMark = marks.some((m) => m.type.name === "code"); - if (hasCodeMark) { - if (prev.active) { - return { active: false, query: "", range: null }; - } - return prev; - } - - // Get text before cursor in current text block - const textBefore = $from.parent.textBetween( - 0, - $from.parentOffset, - undefined, - "\ufffc", - ); - - // Find the last "/" in the text before cursor - const slashIndex = textBefore.lastIndexOf("/"); - - if (slashIndex === -1) { - // No slash found - if (prev.active) { - return { active: false, query: "", range: null }; - } - return prev; - } - - // Check if slash is at start of line or after whitespace - const charBeforeSlash = - slashIndex > 0 ? textBefore[slashIndex - 1] : null; - const isValidSlashPosition = - charBeforeSlash === null || - charBeforeSlash === " " || - charBeforeSlash === "\t" || - charBeforeSlash === "\n"; - - if (!isValidSlashPosition) { - if (prev.active) { - return { active: false, query: "", range: null }; - } - return prev; - } - - // Extract query (text after slash) - const query = textBefore.slice(slashIndex + 1); - - // Check for space in query (means user finished typing command) - if (query.includes(" ")) { - if (prev.active) { - return { active: false, query: "", range: null }; - } - return prev; - } - - // Calculate absolute positions - const blockStart = $from.start(); - const from = blockStart + slashIndex; - const to = blockStart + textBefore.length; - - return { - active: true, - query, - range: { from, to }, - }; - }, - }, - view() { - return { - update(view: EditorView) { - const state = slashCommandsPluginKey.getState(view.state); - if (state) { - onStateChange(state); - } - }, - }; - }, - }), - ]; - }, - - addKeyboardShortcuts() { - return { - Escape: () => { - const state = slashCommandsPluginKey.getState(this.editor.state); - if (state?.active) { - // Close the menu - this.editor.view.dispatch( - this.editor.state.tr.setMeta(slashCommandsPluginKey, { - close: true, - }), - ); - return true; - } - return false; - }, - }; - }, - - addCommands() { - return { - closeSlashMenu: - () => - ({ tr, dispatch }: CommandProps) => { - if (dispatch) { - dispatch(tr.setMeta(slashCommandsPluginKey, { close: true })); - } - return true; - }, - deleteSlashCommand: - () => - ({ tr, dispatch, state }: CommandProps) => { - const pluginState = slashCommandsPluginKey.getState(state); - if (!pluginState?.active || !pluginState.range) { - return false; - } - if (dispatch) { - dispatch( - tr - .delete(pluginState.range.from, pluginState.range.to) - .setMeta(slashCommandsPluginKey, { close: true }), - ); - } - return true; - }, - }; - }, -}); diff --git a/src/extensions/markdown/editor/extensions/table-navigation.ts b/src/extensions/markdown/editor/extensions/table-navigation.ts deleted file mode 100644 index cfbecce1..00000000 --- a/src/extensions/markdown/editor/extensions/table-navigation.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Extension } from "@tiptap/core"; - -/** - * Extension to handle table navigation. - * Adds Mod+Enter to exit a table and create a paragraph below it. - */ -export const TableNavigationExtension = Extension.create({ - name: "tableNavigation", - - addKeyboardShortcuts() { - return { - // Mod+Enter exits the table and creates a paragraph below - "Mod-Enter": ({ editor }) => { - const { state } = editor; - const { selection } = state; - const { $from } = selection; - - // Check if we're inside a table - let tableNode = null; - let tablePos = -1; - - for (let depth = $from.depth; depth > 0; depth--) { - const node = $from.node(depth); - if (node.type.name === "table") { - tableNode = node; - tablePos = $from.before(depth); - break; - } - } - - if (!tableNode || tablePos === -1) { - return false; - } - - // Calculate position after the table - const posAfterTable = tablePos + tableNode.nodeSize; - - // Insert a paragraph after the table and move cursor there - editor - .chain() - .focus() - .insertContentAt(posAfterTable, { type: "paragraph" }) - .setTextSelection(posAfterTable + 1) - .run(); - - return true; - }, - }; - }, -}); diff --git a/src/extensions/markdown/editor/handle-paste.test.ts b/src/extensions/markdown/editor/handle-paste.test.ts deleted file mode 100644 index 1ae48a9b..00000000 --- a/src/extensions/markdown/editor/handle-paste.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { test, expect, describe } from "vitest"; -import { handlePaste } from "./handle-paste"; -import { Editor } from "@tiptap/core"; -import { MarkdownWc } from "./tiptap-markdown-bridge"; - -function makeClipboardEvent(md: string): any { - return { - preventDefault: () => {}, - clipboardData: { - getData: (type: string) => (type === "text/plain" ? md : ""), - }, - }; -} - -function createEditor(initialContent?: any): Editor { - return new Editor({ - extensions: MarkdownWc() as any, - content: initialContent || { type: "doc", content: [] }, - }); -} - -describe("handlePaste - cursor position insertion", () => { - test("inserts at beginning of document", async () => { - const editor = createEditor({ - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Existing content" }], - }, - ], - }); - - // Set cursor to beginning - editor.commands.setTextSelection(1); - - const ok = await handlePaste({ - editor, - event: makeClipboardEvent("New text"), - }); - expect(ok).toBe(true); - expect(editor.getText()).toContain("New text"); - expect(editor.getText()).toContain("Existing content"); - - editor.destroy(); - }); - - test("inserts at middle of paragraph", async () => { - const editor = createEditor({ - type: "doc", - content: [ - { type: "paragraph", content: [{ type: "text", text: "Hello world" }] }, - ], - }); - - // Set cursor after "Hello " (position 7) - editor.commands.setTextSelection(7); - - const ok = await handlePaste({ - editor, - event: makeClipboardEvent("beautiful "), - }); - expect(ok).toBe(true); - expect(editor.getText()).toContain("beautiful"); - - editor.destroy(); - }); - - test("inserts at end of document", async () => { - const editor = createEditor({ - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Existing content" }], - }, - ], - }); - - // Set cursor to end - editor.commands.setTextSelection(editor.state.doc.content.size); - - const ok = await handlePaste({ - editor, - event: makeClipboardEvent("New paragraph"), - }); - expect(ok).toBe(true); - expect(editor.getText()).toContain("New paragraph"); - - editor.destroy(); - }); - - test("inserts between paragraphs", async () => { - const editor = createEditor({ - type: "doc", - content: [ - { type: "paragraph", content: [{ type: "text", text: "First para" }] }, - { type: "paragraph", content: [{ type: "text", text: "Second para" }] }, - ], - }); - - // Set cursor at the end of the first paragraph. - editor.commands.setTextSelection(11); - - const ok = await handlePaste({ - editor, - event: makeClipboardEvent("Middle para"), - }); - expect(ok).toBe(true); - expect(editor.getText()).toContain("Middle para"); - - editor.destroy(); - }); -}); - -describe("handlePaste - selection replacement", () => { - test("replaces single word selection", async () => { - const editor = createEditor({ - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Replace this word here" }], - }, - ], - }); - - // Select "this" (positions 9-13) - editor.commands.setTextSelection({ from: 9, to: 13 }); - - const ok = await handlePaste({ editor, event: makeClipboardEvent("that") }); - expect(ok).toBe(true); - expect(editor.getText()).toContain("that"); - expect(editor.getText()).not.toContain("this"); - - editor.destroy(); - }); - - test("replaces multi-line selection", async () => { - const editor = createEditor({ - type: "doc", - content: [ - { type: "paragraph", content: [{ type: "text", text: "Line one" }] }, - { type: "paragraph", content: [{ type: "text", text: "Line two" }] }, - { type: "paragraph", content: [{ type: "text", text: "Line three" }] }, - ], - }); - - // Select "Line two" paragraph text. - editor.commands.setTextSelection({ from: 11, to: 19 }); - - const ok = await handlePaste({ - editor, - event: makeClipboardEvent("Replacement"), - }); - expect(ok).toBe(true); - expect(editor.getText()).toContain("Replacement"); - expect(editor.getText()).not.toContain("Line two"); - - editor.destroy(); - }); - - test("replaces entire document selection", async () => { - const editor = createEditor({ - type: "doc", - content: [ - { type: "paragraph", content: [{ type: "text", text: "Old content" }] }, - ], - }); - - // Select all - editor.commands.selectAll(); - - const ok = await handlePaste({ - editor, - event: makeClipboardEvent("# New Document\n\nCompletely new"), - }); - expect(ok).toBe(true); - expect(editor.getText()).toContain("New Document"); - expect(editor.getText()).toContain("Completely new"); - expect(editor.getText()).not.toContain("Old content"); - - editor.destroy(); - }); -}); - -describe("handlePaste - edge cases", () => { - test("returns false for empty clipboard data", async () => { - const editor = createEditor(); - - const ok = await handlePaste({ editor, event: makeClipboardEvent("") }); - expect(ok).toBe(false); - - editor.destroy(); - }); - - test("handles complex markdown with lists", async () => { - const editor = createEditor(); - const complexMd = `# Title - -- Item 1 -- Item 2 - - Nested item - -1. Ordered item`; - - const ok = await handlePaste({ - editor, - event: makeClipboardEvent(complexMd), - }); - expect(ok).toBe(true); - expect(editor.getText()).toContain("Title"); - expect(editor.getText()).toContain("Item 1"); - expect(editor.getText()).toContain("Ordered item"); - - editor.destroy(); - }); - - test("handles markdown with code blocks", async () => { - const editor = createEditor(); - const mdWithCode = `Here's some code: - -\`\`\`javascript -function hello() { - console.log("world"); -} -\`\`\` - -And inline \`code\` too.`; - - const ok = await handlePaste({ - editor, - event: makeClipboardEvent(mdWithCode), - }); - expect(ok).toBe(true); - expect(editor.getText()).toContain("hello"); - expect(editor.getText()).toContain("world"); - - editor.destroy(); - }); - - test("handles multiple paragraph paste", async () => { - const editor = createEditor(); - const multiPara = `First paragraph. - -Second paragraph. - -Third paragraph with **bold** and *italic*.`; - - const ok = await handlePaste({ - editor, - event: makeClipboardEvent(multiPara), - }); - expect(ok).toBe(true); - expect(editor.getText()).toContain("First paragraph"); - expect(editor.getText()).toContain("Second paragraph"); - expect(editor.getText()).toContain("Third paragraph"); - - editor.destroy(); - }); - - test("handles paste with no clipboardData", async () => { - const editor = createEditor(); - const event = { preventDefault: () => {} }; - - const ok = await handlePaste({ editor, event }); - expect(ok).toBe(false); - - editor.destroy(); - }); - - test("handles paste with null getData", async () => { - const editor = createEditor(); - const event = { - preventDefault: () => {}, - clipboardData: { getData: null }, - }; - - const ok = await handlePaste({ editor, event }); - expect(ok).toBe(false); - - editor.destroy(); - }); -}); diff --git a/src/extensions/markdown/editor/handle-paste.ts b/src/extensions/markdown/editor/handle-paste.ts deleted file mode 100644 index 549adf6d..00000000 --- a/src/extensions/markdown/editor/handle-paste.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { astToTiptapDoc } from "./tiptap-markdown-bridge"; -import { parseMarkdown } from "./markdown"; - -export async function handlePaste(args: { - editor: any; - event: ClipboardEvent | any; -}): Promise { - const { editor, event } = args; - - // Get clipboard text - const text = event?.clipboardData?.getData?.("text/plain") ?? ""; - if (!text) return false; // let default handlers run - - // Prevent default paste behavior - event.preventDefault?.(); - - // Parse markdown to TipTap document structure - const ast = parseMarkdown(text); - const tiptapDoc = astToTiptapDoc(ast) as any; - const blockFragment = tiptapDoc?.content ?? []; - - // Insert at current cursor position/selection explicitly - if (editor?.state?.selection && editor.commands?.insertContentAt) { - const sel = editor.state.selection as any; - const { from, to, $from, $to } = sel; - const isRange = from !== to; - const inlineFrom = !!$from?.parent?.inlineContent; - const inlineTo = !!$to?.parent?.inlineContent; - - if (isRange) { - // Replace the selected range. If the range is inline, use inline content when possible. - if (inlineFrom && inlineTo) { - const first = Array.isArray(blockFragment) ? blockFragment[0] : null; - const isSimpleParagraph = - first && first.type === "paragraph" && Array.isArray(first.content); - const inlineContent = isSimpleParagraph ? first.content : blockFragment; - editor.commands.insertContentAt({ from, to } as any, inlineContent); - } else { - editor.commands.insertContentAt({ from, to } as any, blockFragment); - } - return true; - } - - // Collapsed cursor: insert as blocks (new paragraphs before/after) - editor.commands.insertContentAt(from as any, blockFragment); - return true; - } - - return false; -} diff --git a/src/extensions/markdown/editor/markdown-editor-fuzz-harness.tsx b/src/extensions/markdown/editor/markdown-editor-fuzz-harness.tsx deleted file mode 100644 index ec5654cc..00000000 --- a/src/extensions/markdown/editor/markdown-editor-fuzz-harness.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { EditorContent } from "@tiptap/react"; -import type { Editor } from "@tiptap/core"; -import { buildMarkdownFromEditor } from "./build-markdown-from-editor"; -import { createEditor } from "./create-editor"; -import { - renderPlainTextFromMarkdown, - setEditorSelectionBySimplifiedOffset, - simplifiedOffsetPositions, - simplifiedSelectionFromDom, - simplifiedSelectionFromEditor, - type MarkdownFuzzSnapshot, -} from "./markdown-editor-fuzz"; - -export type MarkdownEditorFuzzHarnessApi = { - setSelection(anchor: number, head: number): void; - snapshot(): MarkdownFuzzSnapshot; - destroy(): void; -}; - -declare global { - interface Window { - __flashtypeMarkdownFuzz?: MarkdownEditorFuzzHarnessApi; - } -} - -export function MarkdownEditorFuzzHarness() { - const editor = useMemo( - () => - createEditor({ - lix: {} as any, - initialMarkdown: "", - persistState: false, - }), - [], - ); - - useEffect(() => { - const api = createMarkdownFuzzApi(editor); - window.__flashtypeMarkdownFuzz = api; - editor.commands.focus("start"); - editor.view.focus(); - - return () => { - if (window.__flashtypeMarkdownFuzz === api) { - delete window.__flashtypeMarkdownFuzz; - } - api.destroy(); - }; - }, [editor]); - - return ( -
- -
- ); -} - -function createMarkdownFuzzApi(editor: Editor): MarkdownEditorFuzzHarnessApi { - return { - setSelection(anchor: number, head: number) { - editor.view.focus(); - setEditorSelectionBySimplifiedOffset(editor, anchor, head); - editor.view.focus(); - }, - snapshot() { - const domSelection = simplifiedSelectionFromDom(editor); - syncEditorSelectionFromDom(editor); - const positions = simplifiedOffsetPositions(editor); - const markdown = buildMarkdownFromEditor(editor); - return { - markdown, - plainText: renderPlainTextFromMarkdown(markdown), - editorJson: editor.getJSON(), - docSize: editor.state.doc.content.size, - positions, - domSelection, - selection: simplifiedSelectionFromEditor(editor), - }; - }, - destroy() { - if (!editor.isDestroyed) { - editor.destroy(); - } - }, - }; -} - -function syncEditorSelectionFromDom(editor: Editor): void { - (editor.view as any).domObserver?.flush?.(); -} diff --git a/src/extensions/markdown/editor/markdown-editor-fuzz.ts b/src/extensions/markdown/editor/markdown-editor-fuzz.ts deleted file mode 100644 index 2dbac164..00000000 --- a/src/extensions/markdown/editor/markdown-editor-fuzz.ts +++ /dev/null @@ -1,555 +0,0 @@ -import type { Editor } from "@tiptap/core"; -import { TextSelection } from "@tiptap/pm/state"; -import { parseMarkdown } from "./markdown"; - -export const MARKDOWN_EDITOR_FUZZ_PARAGRAPH_BREAK = "\x1E"; -export const MARKDOWN_EDITOR_FUZZ_HARD_BREAK = "\n"; -export const MARKDOWN_EDITOR_FUZZ_OPERATION_COUNT = 10_000; -export const MARKDOWN_EDITOR_FUZZ_DEFAULT_SEED = - "markdown-editor-plain-text-fuzz-v1"; - -export type SimplifiedState = { - chars: string[]; - anchor: number; - head: number; -}; - -export type SimplifiedSelection = { - anchor: number; - head: number; - rawAnchor: number; - rawHead: number; -}; - -export type FuzzOperation = - | { kind: "type"; value: string } - | { kind: "enter" } - | { kind: "shiftEnter" } - | { kind: "left" } - | { kind: "right" } - | { kind: "move"; anchor: number; head: number }; - -export type MarkdownFuzzSnapshot = { - markdown: string; - plainText: string; - editorJson: unknown; - docSize: number; - positions: number[]; - domSelection: SimplifiedSelection | null; - selection: SimplifiedSelection | null; -}; - -type RandomSource = () => number; -type ArrowDirection = "left" | "right"; - -export function createSimplifiedState(): SimplifiedState { - return { chars: [], anchor: 0, head: 0 }; -} - -export function nextOperation( - rng: RandomSource, - state: SimplifiedState, -): FuzzOperation { - const roll = rng(); - if (roll < 0.45) { - const charCode = "a".charCodeAt(0) + Math.floor(rng() * 26); - return { kind: "type", value: String.fromCharCode(charCode) }; - } - if (roll < 0.55) return { kind: "enter" }; - if (roll < 0.65) return { kind: "shiftEnter" }; - if (roll < 0.72) return { kind: "left" }; - if (roll < 0.79) return { kind: "right" }; - - const anchor = randomOffset(rng, state); - const shouldSelectRange = rng() < 0.45 && state.chars.length > 0; - const head = shouldSelectRange ? randomOffset(rng, state) : anchor; - return { kind: "move", anchor, head }; -} - -function randomOffset(rng: RandomSource, state: SimplifiedState): number { - return Math.floor(rng() * (state.chars.length + 1)); -} - -export function applyOperationToSimplifiedState( - state: SimplifiedState, - operation: FuzzOperation, -): void { - switch (operation.kind) { - case "type": - replaceSelection(state, operation.value); - return; - case "enter": - replaceSelection(state, MARKDOWN_EDITOR_FUZZ_PARAGRAPH_BREAK); - return; - case "shiftEnter": - replaceSelection(state, MARKDOWN_EDITOR_FUZZ_HARD_BREAK); - return; - case "left": { - const next = nextSimplifiedArrowSelection(state, "left"); - state.anchor = next.anchor; - state.head = next.head; - return; - } - case "right": { - const next = nextSimplifiedArrowSelection(state, "right"); - state.anchor = next.anchor; - state.head = next.head; - return; - } - case "move": - state.anchor = operation.anchor; - state.head = operation.head; - return; - } -} - -function replaceSelection(state: SimplifiedState, value: string): void { - const { from, to } = selectionBounds(state); - state.chars.splice(from, to - from, value); - const next = from + 1; - state.anchor = next; - state.head = next; -} - -export function selectionBounds(state: SimplifiedState): { - from: number; - to: number; -} { - return { - from: Math.min(state.anchor, state.head), - to: Math.max(state.anchor, state.head), - }; -} - -export function nextSimplifiedArrowSelection( - state: SimplifiedState, - direction: ArrowDirection, -): { anchor: number; head: number } { - const { from, to } = selectionBounds(state); - if (from !== to) { - const next = direction === "left" ? from : to; - return { anchor: next, head: next }; - } - - const next = - direction === "left" - ? Math.max(0, from - 1) - : Math.min(state.chars.length, to + 1); - return { anchor: next, head: next }; -} - -export function expectedPlainText(state: SimplifiedState): string { - return state.chars.join(""); -} - -export function renderPlainTextFromMarkdown(markdown: string): string { - return renderPlainText(parseMarkdown(markdown)); -} - -export function renderPlainText(ast: any): string { - return (ast?.children ?? []) - .map(renderBlockPlainText) - .join(MARKDOWN_EDITOR_FUZZ_PARAGRAPH_BREAK); -} - -function renderBlockPlainText(node: any): string { - if (!node || typeof node !== "object") return ""; - if ( - node.type === "paragraph" && - isEmptyParagraphPlaceholder(node.children ?? []) - ) { - return ""; - } - if (Array.isArray(node.children)) { - return node.children.map(renderInlinePlainText).join(""); - } - return typeof node.value === "string" ? node.value : ""; -} - -function renderInlinePlainText(node: any): string { - if (!node || typeof node !== "object") return ""; - if (node.type === "text" || node.type === "inlineCode") { - return typeof node.value === "string" ? node.value : ""; - } - if (node.type === "break") return MARKDOWN_EDITOR_FUZZ_HARD_BREAK; - if (isHtmlHardBreak(node)) return MARKDOWN_EDITOR_FUZZ_HARD_BREAK; - if (Array.isArray(node.children)) { - return node.children.map(renderInlinePlainText).join(""); - } - return ""; -} - -function isHtmlHardBreak(node: any): boolean { - return ( - node?.type === "html" && - typeof node.value === "string" && - /^$/i.test(node.value) - ); -} - -function isEmptyParagraphPlaceholder(children: any[]): boolean { - return ( - children.length === 2 && - children[0]?.type === "html" && - children[0]?.value === "" && - children[1]?.type === "html" && - children[1]?.value === "" - ); -} - -export function setEditorSelectionBySimplifiedOffset( - editor: Editor, - anchorOffset: number, - headOffset: number, -): void { - const positions = simplifiedOffsetPositions(editor); - const anchor = positions[anchorOffset]; - const head = positions[headOffset]; - if (anchor == null || head == null) { - throw new Error( - `Could not map simplified selection ${anchorOffset}:${headOffset} into editor positions.`, - ); - } - - editor.view.dispatch( - editor.state.tr.setSelection( - TextSelection.create(editor.state.doc, anchor, head), - ), - ); -} - -export function simplifiedOffsetPositions(editor: Editor): number[] { - const positions: number[] = []; - let offset = 0; - - editor.state.doc.forEach((block, blockOffset, blockIndex) => { - if (blockIndex > 0) { - offset += 1; - } - - let position = blockOffset + 1; - positions[offset] = position; - block.forEach((inline) => { - if (inline.isText) { - const text = inline.text ?? ""; - for (let index = 0; index < text.length; index += 1) { - position += 1; - offset += 1; - positions[offset] = position; - } - return; - } - if (inline.type.name === "hardBreak") { - position += inline.nodeSize; - offset += 1; - positions[offset] = position; - return; - } - position += inline.nodeSize; - }); - }); - - return positions; -} - -export function simplifiedSelectionFromEditor( - editor: Editor, -): MarkdownFuzzSnapshot["selection"] { - const { anchor: rawAnchor, head: rawHead } = editor.state.selection as any; - return simplifiedSelectionFromRawPositions(editor, rawAnchor, rawHead); -} - -export function simplifiedSelectionFromDom( - editor: Editor, -): MarkdownFuzzSnapshot["domSelection"] { - const view = editor.view as any; - const domSelection = view.domSelectionRange?.(); - if ( - !domSelection?.anchorNode || - !domSelection?.focusNode || - !isNodeInsideEditor(editor.view.dom, domSelection.anchorNode) || - !isNodeInsideEditor(editor.view.dom, domSelection.focusNode) - ) { - return null; - } - - const rawAnchor = view.docView?.posFromDOM?.( - domSelection.anchorNode, - domSelection.anchorOffset, - 1, - ); - const rawHead = view.docView?.posFromDOM?.( - domSelection.focusNode, - domSelection.focusOffset, - 1, - ); - if ( - typeof rawAnchor !== "number" || - typeof rawHead !== "number" || - rawAnchor < 0 || - rawHead < 0 - ) { - return null; - } - - return simplifiedSelectionFromRawPositions(editor, rawAnchor, rawHead); -} - -function isNodeInsideEditor(editorDom: HTMLElement, node: Node): boolean { - const element = node.nodeType === Node.TEXT_NODE ? node.parentNode : node; - return Boolean(element && editorDom.contains(element)); -} - -function simplifiedSelectionFromRawPositions( - editor: Editor, - rawAnchor: number, - rawHead: number, -): SimplifiedSelection | null { - const positions = simplifiedOffsetPositions(editor); - const anchor = positions.indexOf(rawAnchor); - const head = positions.indexOf(rawHead); - if (anchor < 0 || head < 0) { - return null; - } - return { anchor, head, rawAnchor, rawHead }; -} - -export function validateEditorPositionMap( - editor: Editor, - state: SimplifiedState, -): void { - const positions = simplifiedOffsetPositions(editor); - const docSize = editor.state.doc.content.size; - const reason = validateSimplifiedPositionMap({ state, positions, docSize }); - if (!reason) return; - - throw new Error( - [ - "Editor position map does not match simplified state.", - reason, - `simplified=${JSON.stringify(expectedPlainText(state))}`, - `doc=${JSON.stringify(editor.getJSON())}`, - ].join("\n"), - ); -} - -export function validateSimplifiedPositionMap(args: { - state: SimplifiedState; - positions: number[]; - docSize: number; -}): string | null { - const expectedOffsets = args.state.chars.length + 1; - if (args.positions.length !== expectedOffsets) { - return [ - "Position map length does not match simplified state.", - `expectedOffsets=${expectedOffsets}`, - `actualOffsets=${args.positions.length}`, - `docSize=${args.docSize}`, - ].join("\n"); - } - - let previous: number | null = null; - for (let index = 0; index < expectedOffsets; index += 1) { - if (!Object.prototype.hasOwnProperty.call(args.positions, index)) { - return [ - "Position map has a hole.", - `offset=${index}`, - `expectedOffsets=${expectedOffsets}`, - `docSize=${args.docSize}`, - ].join("\n"); - } - - const position = args.positions[index]; - if (!Number.isInteger(position)) { - return [ - "Position map contains a non-integer position.", - `offset=${index}`, - `position=${String(position)}`, - `docSize=${args.docSize}`, - ].join("\n"); - } - - if (position < 0 || position > args.docSize) { - return [ - "Position map contains a position outside the document.", - `offset=${index}`, - `position=${position}`, - `docSize=${args.docSize}`, - ].join("\n"); - } - - if (previous != null && position <= previous) { - return [ - "Position map is not strictly increasing.", - `offset=${index}`, - `previousPosition=${previous}`, - `position=${position}`, - `docSize=${args.docSize}`, - ].join("\n"); - } - - previous = position; - } - - return null; -} - -export function validateSimplifiedSelectionInvariant(args: { - state: SimplifiedState; - positions: number[]; - docSize: number; - domSelection?: MarkdownFuzzSnapshot["domSelection"]; - selection: MarkdownFuzzSnapshot["selection"]; -}): string | null { - const positionMapReason = validateSimplifiedPositionMap({ - state: args.state, - positions: args.positions, - docSize: args.docSize, - }); - if (positionMapReason) return positionMapReason; - - if (args.domSelection !== undefined) { - const domReason = validateSelectionMatchesState( - "DOM selection", - args.domSelection, - args.state, - ); - if (domReason) return domReason; - } - - const editorReason = validateSelectionMatchesState( - "Editor selection", - args.selection, - args.state, - ); - if (editorReason) return editorReason; - - if ( - args.domSelection && - args.selection && - (args.domSelection.anchor !== args.selection.anchor || - args.domSelection.head !== args.selection.head) - ) { - return [ - "DOM selection and editor selection do not agree.", - `domSelection=${formatSelection(args.domSelection)}`, - `editorSelection=${formatSelection(args.selection)}`, - ].join("\n"); - } - - return null; -} - -function validateSelectionMatchesState( - label: string, - selection: SimplifiedSelection | null, - state: SimplifiedState, -): string | null { - if (!selection) { - return `${label} does not map to a simplified offset.`; - } - - if (selection.anchor !== state.anchor || selection.head !== state.head) { - return [ - `${label} does not match simplified state.`, - `expectedSelection=${state.anchor}:${state.head}`, - `actualSelection=${selection.anchor}:${selection.head}`, - `rawSelection=${selection.rawAnchor}:${selection.rawHead}`, - ].join("\n"); - } - - return null; -} - -function formatSelection(selection: SimplifiedSelection | null): string { - return selection - ? `${selection.anchor}:${selection.head} (${selection.rawAnchor}:${selection.rawHead})` - : ""; -} - -export function buildSelectionInvariantFailureMessage(args: { - seed: string; - index: number; - operation: FuzzOperation; - state: SimplifiedState; - reason: string; - positions: number[]; - docSize: number; - domSelection?: MarkdownFuzzSnapshot["domSelection"]; - selection: MarkdownFuzzSnapshot["selection"]; - editorJson?: unknown; -}): string { - return [ - "Markdown editor selection fuzz invariant failed.", - `seed=${args.seed}`, - `operationIndex=${args.index}`, - `operation=${JSON.stringify(args.operation)}`, - `expectedSelection=${args.state.anchor}:${args.state.head}`, - args.domSelection === undefined - ? null - : `domSelection=${formatSelection(args.domSelection)}`, - `editorSelection=${formatSelection(args.selection)}`, - `positionCount=${args.positions.length}`, - `docSize=${args.docSize}`, - `simplified=${JSON.stringify(expectedPlainText(args.state))}`, - `reason=${args.reason}`, - args.editorJson === undefined - ? null - : `doc=${JSON.stringify(args.editorJson)}`, - ] - .filter((line): line is string => line != null) - .join("\n"); -} - -export function buildOperationFailureMessage(args: { - seed: string; - index: number; - operation: FuzzOperation; - state: SimplifiedState; - editorJson?: unknown; - cause: unknown; -}): string { - return [ - "Markdown editor fuzz operation failed.", - `seed=${args.seed}`, - `operationIndex=${args.index}`, - `operation=${JSON.stringify(args.operation)}`, - `selection=${args.state.anchor}:${args.state.head}`, - `simplified=${JSON.stringify(expectedPlainText(args.state))}`, - args.editorJson === undefined - ? null - : `doc=${JSON.stringify(args.editorJson)}`, - `cause=${ - args.cause instanceof Error ? args.cause.message : String(args.cause) - }`, - ] - .filter((line): line is string => line != null) - .join("\n"); -} - -export function buildPlainTextMismatchMessage(args: { - seed: string; - index: number; - operation: FuzzOperation; - state: SimplifiedState; - expected: string; - actual: string; - markdown: string; - editorJson?: unknown; -}): string { - return [ - "Markdown editor plain-text fuzz mismatch.", - `seed=${args.seed}`, - `operationIndex=${args.index}`, - `operation=${JSON.stringify(args.operation)}`, - `selection=${args.state.anchor}:${args.state.head}`, - `expected=${JSON.stringify(args.expected)}`, - `actual=${JSON.stringify(args.actual)}`, - `markdown=${JSON.stringify(args.markdown)}`, - args.editorJson === undefined - ? null - : `doc=${JSON.stringify(args.editorJson)}`, - ] - .filter((line): line is string => line != null) - .join("\n"); -} diff --git a/src/extensions/markdown/editor/markdown.test.ts b/src/extensions/markdown/editor/markdown.test.ts deleted file mode 100644 index a85592ea..00000000 --- a/src/extensions/markdown/editor/markdown.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { normalizeAst, parseMarkdown, serializeAst } from "./markdown"; - -describe("markdown parser", () => { - test("parses GFM tables with inline content and alignment", () => { - const ast = parseMarkdown("| A | B |\n| :- | -: |\n| **a** | `b` |\n"); - const table = ast.children[0]; - - expect(table?.type).toBe("table"); - expect(table?.align).toEqual(["left", "right"]); - expect(table?.children?.[1]?.children?.[0]?.children).toEqual([ - { type: "strong", children: [{ type: "text", value: "a" }] }, - ]); - expect(table?.children?.[1]?.children?.[1]?.children).toEqual([ - { type: "inlineCode", value: "b" }, - ]); - }); - - test("parses task lists", () => { - const ast = parseMarkdown("- [ ] todo\n- [x] done\n"); - const items = ast.children[0]?.children; - - expect(ast.children[0]?.type).toBe("list"); - expect(items?.[0]?.checked).toBe(false); - expect(items?.[1]?.checked).toBe(true); - expect(items?.[0]?.children?.[0]?.children).toEqual([ - { type: "text", value: "todo" }, - ]); - }); - - test("parses YAML frontmatter", () => { - const ast = parseMarkdown("---\ntitle: Demo\n---\n\nBody"); - - expect(ast.children[0]).toEqual({ - type: "yaml", - value: "title: Demo", - }); - expect(ast.children[1]?.type).toBe("paragraph"); - }); - - test("parses reference links, reference images, and autolinks", () => { - const ast = parseMarkdown( - [ - "Reference [Guide][guide] and ![Logo][logo].", - "", - "Bare https://example.com and .", - "", - "[guide]: https://example.com/guide", - '[logo]: https://example.com/logo.png "Logo"', - ].join("\n"), - ); - const firstParagraphInline = ast.children[0]?.children ?? []; - const secondParagraphLinks = collectNodes(ast.children[1], "link"); - - expect(firstParagraphInline).toContainEqual({ - type: "link", - title: null, - url: "https://example.com/guide", - children: [{ type: "text", value: "Guide" }], - }); - expect(firstParagraphInline).toContainEqual({ - type: "image", - title: "Logo", - url: "https://example.com/logo.png", - alt: "Logo", - }); - expect(secondParagraphLinks.map((node) => node.url)).toEqual([ - "https://example.com", - "mailto:ops@example.com", - ]); - }); - - test("parses inline HTML and hard breaks", () => { - const ast = parseMarkdown("Use Ctrl \nnext"); - const inline = ast.children[0]?.children ?? []; - - expect(inline).toContainEqual({ type: "html", value: "" }); - expect(inline).toContainEqual({ type: "html", value: "" }); - expect(inline).toContainEqual({ type: "break" }); - }); - - test("parses block HTML", () => { - const ast = parseMarkdown("
\nRaw\n
"); - - expect(ast.children[0]).toEqual({ - type: "html", - value: "
\nRaw\n
", - }); - }); - - test("parses code fence metadata", () => { - const ast = parseMarkdown("```ts meta\nconst x = 1;\n```"); - - expect(ast.children[0]).toEqual({ - type: "code", - lang: "ts", - meta: "meta", - value: "const x = 1;", - }); - }); - - test("normalizes CRLF and Unicode text", () => { - const ast = parseMarkdown("Cafe\u0301\r\nnext"); - - expect(ast.children[0]?.children?.[0]).toEqual({ - type: "text", - value: "Café\nnext", - }); - }); - - test("keeps dollar-delimited math as text", () => { - expect(parseMarkdown("$x$\n").children[0]).toEqual({ - type: "paragraph", - children: [{ type: "text", value: "$x$" }], - }); - expect(parseMarkdown("$$\nx\n$$\n").children[0]).toEqual({ - type: "paragraph", - children: [{ type: "text", value: "$$\nx\n$$" }], - }); - }); -}); - -describe("markdown serializer", () => { - test("serializes task markers and preserves trailing newline", () => { - expect( - serializeAst({ - type: "root", - children: [ - { - type: "list", - ordered: false, - children: [ - { - type: "listItem", - checked: true, - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "done" }], - }, - ], - }, - { - type: "listItem", - checked: false, - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "todo" }], - }, - ], - }, - ], - }, - ], - }), - ).toBe("- [x] done\n- [ ] todo\n"); - }); - - test("serializes empty task items with markers", () => { - expect( - serializeAst({ - type: "root", - children: [ - { - type: "list", - ordered: false, - children: [ - { - type: "listItem", - checked: false, - children: [{ type: "paragraph", children: [] }], - }, - { - type: "listItem", - checked: true, - children: [{ type: "paragraph", children: [] }], - }, - ], - }, - ], - }), - ).toBe("- [ ] \n- [x] \n"); - }); - - test("serializes code fence metadata", () => { - expect( - serializeAst({ - type: "root", - children: [ - { - type: "code", - lang: "ts", - meta: "meta", - value: "const x = 1;", - }, - ], - }), - ).toBe("```ts meta\nconst x = 1;\n```\n"); - }); - - test("escapes literal inline markdown syntax", () => { - const ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [ - { type: "text", value: "literal *stars* and [linkish](url)" }, - ], - }, - ], - }; - - const output = serializeAst(ast); - - expect(output).toBe("literal \\*stars\\* and \\[linkish]\\(url)\n"); - expect(parseMarkdown(output)).toEqual(ast); - }); - - test("preserves inline code backticks and spaces", () => { - const ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [ - { type: "inlineCode", value: "`tick`" }, - { type: "text", value: " " }, - { type: "inlineCode", value: " spaced " }, - ], - }, - ], - }; - - expect(parseMarkdown(serializeAst(ast))).toEqual(ast); - }); - - test("preserves table alignment and literal pipes", () => { - const ast = parseMarkdown( - "| A | B |\n| :- | -: |\n| a \\| b | `c \\| d` |\n", - ); - const output = serializeAst(ast); - - expect(output).toContain("| :"); - expect(output).toContain(": |"); - expect(output).toContain("a \\| b"); - expect(output).toContain("`c \\| d`"); - expect(parseMarkdown(output)).toEqual(ast); - }); - - test("preserves multiple blocks inside list items", () => { - const ast = parseMarkdown("- first\n\n second\n"); - - expect(parseMarkdown(serializeAst(ast))).toEqual(ast); - }); - - test("normalizes without mutating the input", () => { - const input = { - type: "root", - position: { start: 1 }, - children: [{ type: "paragraph", children: [{ value: "Cafe\u0301\r" }] }], - }; - - expect(normalizeAst(input)).toEqual({ - type: "root", - children: [{ type: "paragraph", children: [{ value: "Café\n" }] }], - }); - expect(input).toEqual({ - type: "root", - position: { start: 1 }, - children: [{ type: "paragraph", children: [{ value: "Cafe\u0301\r" }] }], - }); - }); -}); - -function collectNodes(node: any, type: string): any[] { - const out: any[] = []; - const visit = (current: any) => { - if (!current || typeof current !== "object") return; - if (current.type === type) out.push(current); - for (const child of Array.isArray(current.children) - ? current.children - : []) { - visit(child); - } - }; - visit(node); - return out; -} diff --git a/src/extensions/markdown/editor/markdown.ts b/src/extensions/markdown/editor/markdown.ts deleted file mode 100644 index 62759e54..00000000 --- a/src/extensions/markdown/editor/markdown.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { fromMarkdown } from "mdast-util-from-markdown"; -import { - frontmatterFromMarkdown, - frontmatterToMarkdown, -} from "mdast-util-frontmatter"; -import { gfmFromMarkdown, gfmToMarkdown } from "mdast-util-gfm"; -import { defaultHandlers, toMarkdown } from "mdast-util-to-markdown"; -import { frontmatter } from "micromark-extension-frontmatter"; -import { gfm } from "micromark-extension-gfm"; - -type AstRoot = { - type: "root"; - children: any[]; -}; - -export function parseMarkdown(markdown: string): AstRoot { - return normalizeAst(parseMarkdownSource(markdown)); -} - -/** Parses Markdown without discarding source positions used by review plans. */ -export function parseMarkdownSource(markdown: string): AstRoot { - return resolveReferences( - fromMarkdown(markdown, { - extensions: [gfm(), frontmatter(["yaml"])], - mdastExtensions: [gfmFromMarkdown(), frontmatterFromMarkdown(["yaml"])], - }), - ); -} - -function resolveReferences(ast: any): any { - const definitions = new Map(); - for (const child of Array.isArray(ast?.children) ? ast.children : []) { - if (child?.type === "definition" && typeof child.identifier === "string") { - definitions.set(normalizeIdentifier(child.identifier), child); - } - } - - const resolve = (node: any): any => { - if (!node || typeof node !== "object") { - return node; - } - if (Array.isArray(node)) { - return node.map(resolve); - } - if (node.type === "definition") { - return null; - } - - const children = Array.isArray(node.children) - ? node.children.map(resolve).filter(Boolean) - : undefined; - if (node.type === "linkReference" && typeof node.identifier === "string") { - const definition = definitions.get(normalizeIdentifier(node.identifier)); - if (definition) { - return { - type: "link", - url: definition.url, - title: definition.title ?? null, - children: children ?? [], - }; - } - } - if (node.type === "imageReference" && typeof node.identifier === "string") { - const definition = definitions.get(normalizeIdentifier(node.identifier)); - if (definition) { - return { - type: "image", - url: definition.url, - title: definition.title ?? null, - alt: node.alt ?? null, - }; - } - } - - return { - ...node, - ...(children ? { children } : {}), - }; - }; - - return resolve(ast); -} - -function normalizeIdentifier(identifier: string): string { - return identifier.toLowerCase(); -} - -export function serializeAst(ast: any): string { - return normalizeSerializedMarkdown( - toMarkdown(prepareAstForMarkdown(ast), { - extensions: [ - gfmToMarkdown(), - taskListItemToMarkdown(), - frontmatterToMarkdown(["yaml"]), - ], - bullet: "-", - listItemIndent: "one", - rule: "-", - ruleRepetition: 3, - ruleSpaces: false, - emphasis: "_", - strong: "*", - fence: "`", - fences: true, - }), - ); -} - -function taskListItemToMarkdown(): any { - return { - handlers: { - listItem: taskListItemWithEmptyMarker, - }, - }; -} - -function taskListItemWithEmptyMarker( - node: any, - parent: any, - state: any, - info: any, -): string { - const checkable = typeof node.checked === "boolean"; - if (!checkable) { - return defaultHandlers.listItem(node, parent, state, info); - } - - const checkbox = `[${node.checked ? "x" : " "}] `; - const tracker = state.createTracker(info); - tracker.move(checkbox); - const value = defaultHandlers.listItem(node, parent, state, { - ...info, - ...tracker.current(), - }); - const marked = value.replace( - /^((?:[*+-]|\d+\.)(?:[\r\n]| {1,3}))/, - `$1${checkbox}`, - ); - return marked === value - ? value.replace(/^([*+-]|\d+\.)$/, `$1 ${checkbox}`) - : marked; -} - -function prepareAstForMarkdown(value: any): any { - if (Array.isArray(value)) { - return value.map(prepareAstForMarkdown); - } - if (!value || typeof value !== "object") { - return value; - } - - const out: Record = {}; - for (const [key, child] of Object.entries(value)) { - out[key] = prepareAstForMarkdown(child); - } - if ( - (out.type === "list" || out.type === "listItem") && - typeof out.spread !== "boolean" - ) { - out.spread = false; - } - if (isInlineContainer(out) && Array.isArray(out.children)) { - out.children = trimInlineBoundaryWhitespace(out.children); - } - return out; -} - -function isInlineContainer(node: Record): boolean { - return ( - node.type === "paragraph" || - node.type === "heading" || - node.type === "tableCell" - ); -} - -function trimInlineBoundaryWhitespace(children: any[]): any[] { - const out = [...children]; - trimInlineStart(out); - trimInlineEnd(out); - return out.filter((child) => !isEmptyInline(child)); -} - -function trimInlineStart(children: any[]): void { - for (const child of children) { - if (trimInlineNodeStart(child)) return; - } -} - -function trimInlineEnd(children: any[]): void { - for (let index = children.length - 1; index >= 0; index -= 1) { - if (trimInlineNodeEnd(children[index])) return; - } -} - -function trimInlineNodeStart(node: any): boolean { - if (!node || typeof node !== "object") return true; - if (node.type === "text") { - node.value = - typeof node.value === "string" ? node.value.replace(/^[\t ]+/g, "") : ""; - return node.value.length > 0; - } - if (canTrimInlineChildren(node)) { - trimInlineStart(node.children); - return !isEmptyInline(node); - } - return true; -} - -function trimInlineNodeEnd(node: any): boolean { - if (!node || typeof node !== "object") return true; - if (node.type === "text") { - node.value = - typeof node.value === "string" ? node.value.replace(/[\t ]+$/g, "") : ""; - return node.value.length > 0; - } - if (canTrimInlineChildren(node)) { - trimInlineEnd(node.children); - return !isEmptyInline(node); - } - return true; -} - -function canTrimInlineChildren(node: any): boolean { - return ( - (node.type === "emphasis" || - node.type === "strong" || - node.type === "delete" || - node.type === "link") && - Array.isArray(node.children) - ); -} - -function isEmptyInline(node: any): boolean { - if (!node || typeof node !== "object") return false; - if (node.type === "text") return !node.value; - if (canTrimInlineChildren(node)) return node.children.every(isEmptyInline); - return false; -} - -function normalizeSerializedMarkdown(markdown: string): string { - const normalized = markdown.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - const withoutTrailingNewlines = normalized.replace(/\n+$/g, ""); - return withoutTrailingNewlines.length > 0 - ? `${withoutTrailingNewlines}\n` - : ""; -} - -export function normalizeAst(ast: any): AstRoot { - return asRoot(normalizeValue(ast)); -} - -function normalizeValue(value: any): any { - if (value === undefined) { - return null; - } - if (typeof value === "string") { - return normalizeText(value); - } - if (Array.isArray(value)) { - return value.map(normalizeValue); - } - if (!value || typeof value !== "object") { - return value; - } - - const out: Record = {}; - for (const [key, child] of Object.entries(value)) { - if (key === "position") continue; - out[key] = normalizeValue(child); - } - return out; -} - -function normalizeText(input: string): string { - const normalizedNewlines = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - return isAscii(normalizedNewlines) - ? normalizedNewlines - : normalizedNewlines.normalize("NFC"); -} - -function isAscii(input: string): boolean { - for (let index = 0; index < input.length; index++) { - if (input.charCodeAt(index) > 0x7f) return false; - } - return true; -} - -function asRoot(ast: any): AstRoot { - return { - type: "root", - children: Array.isArray(ast?.children) ? ast.children : [], - }; -} diff --git a/src/extensions/markdown/editor/normalize-url.test.ts b/src/extensions/markdown/editor/normalize-url.test.ts deleted file mode 100644 index 4d917ef2..00000000 --- a/src/extensions/markdown/editor/normalize-url.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { normalizeUrl } from "./normalize-url"; - -describe("normalizeUrl", () => { - test("prefixes https:// onto bare domains", () => { - expect(normalizeUrl("superset.sh")).toBe("https://superset.sh"); - expect(normalizeUrl("example.com/path?q=1")).toBe( - "https://example.com/path?q=1", - ); - }); - - test("leaves fully-qualified URLs untouched", () => { - expect(normalizeUrl("https://example.com")).toBe("https://example.com"); - expect(normalizeUrl("http://example.com")).toBe("http://example.com"); - expect(normalizeUrl("ftp://files.example.com")).toBe( - "ftp://files.example.com", - ); - }); - - test("passes through schemes without an authority", () => { - expect(normalizeUrl("mailto:hi@example.com")).toBe("mailto:hi@example.com"); - expect(normalizeUrl("tel:+15551234")).toBe("tel:+15551234"); - }); - - test("turns bare emails into mailto: links", () => { - expect(normalizeUrl("hi@example.com")).toBe("mailto:hi@example.com"); - }); - - test("keeps anchors and relative paths", () => { - expect(normalizeUrl("#section")).toBe("#section"); - expect(normalizeUrl("/docs/intro")).toBe("/docs/intro"); - expect(normalizeUrl("./intro.md")).toBe("./intro.md"); - expect(normalizeUrl("../page")).toBe("../page"); - }); - - test("trims surrounding whitespace", () => { - expect(normalizeUrl(" example.com ")).toBe("https://example.com"); - }); - - test("returns null for empty input", () => { - expect(normalizeUrl("")).toBeNull(); - expect(normalizeUrl(" ")).toBeNull(); - }); - - test("does not mistake a host:port for a scheme", () => { - expect(normalizeUrl("localhost:3000")).toBe("https://localhost:3000"); - }); -}); diff --git a/src/extensions/markdown/editor/normalize-url.ts b/src/extensions/markdown/editor/normalize-url.ts deleted file mode 100644 index 2492af87..00000000 --- a/src/extensions/markdown/editor/normalize-url.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Normalize a user-entered link target into a usable `href`. - * - * - Values that already carry a scheme (`https://…`, `mailto:`, `tel:`, …) are - * returned untouched. - * - Anchors (`#section`) and relative paths (`/docs`, `./intro.md`, - * `../page`) pass through as-is. - * - Bare email addresses become `mailto:` links. - * - Everything else is treated as an external link and gets an `https://` prefix. - * - * Returns `null` for empty input so callers can bail out early. - * - * @example - * normalizeUrl("superset.sh") // "https://superset.sh" - * normalizeUrl("hi@example.com") // "mailto:hi@example.com" - * normalizeUrl("/docs") // "/docs" - * normalizeUrl("./intro.md") // "./intro.md" - * normalizeUrl("") // null - */ -export function normalizeUrl(input: string): string | null { - const value = input.trim(); - if (!value) return null; - - // Already a fully-qualified URL: scheme://host - if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return value; - // Schemes without an authority component - if (/^(mailto|tel|sms|ftp):/i.test(value)) return value; - // Anchor or relative path — leave the author in control - if ( - value.startsWith("#") || - value.startsWith("/") || - value.startsWith("./") || - value.startsWith("../") - ) { - return value; - } - // Looks like a bare email address - if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `mailto:${value}`; - - // Default: an external link missing its protocol - return `https://${value}`; -} diff --git a/src/extensions/markdown/editor/render-markdown-html.ts b/src/extensions/markdown/editor/render-markdown-html.ts deleted file mode 100644 index 943edc05..00000000 --- a/src/extensions/markdown/editor/render-markdown-html.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { generateHTML } from "@tiptap/core"; -import History from "@tiptap/extension-history"; -import Placeholder from "@tiptap/extension-placeholder"; -import { MarkdownWc, astToTiptapDoc } from "./tiptap-markdown-bridge"; -import { SlashCommandsExtension } from "./extensions/slash-commands"; -import { TableNavigationExtension } from "./extensions/table-navigation"; - -export function renderMarkdownAstEditorHtml( - ast: any, - options: { readonly resolveImageSrc?: (src: string) => string } = {}, -): string { - return generateHTML(astToTiptapDoc(ast) as any, [ - ...(MarkdownWc({ resolveImageSrc: options.resolveImageSrc }) as any[]), - History, - Placeholder, - SlashCommandsExtension.configure({ onStateChange: () => {} }), - TableNavigationExtension, - ]); -} - -export function ensureDiffIds(node: any, path: string = "0"): void { - if (!node || typeof node !== "object") return; - if (supportsDiffId(node)) { - node.data = node.data && typeof node.data === "object" ? node.data : {}; - if (typeof node.data.id !== "string" || node.data.id.length === 0) { - node.data.id = `diff_${path}`; - } - } - const children = Array.isArray(node.children) ? node.children : []; - children.forEach((child: any, index: number) => { - ensureDiffIds(child, `${path}_${index}`); - }); -} - -export function supportsDiffId(node: any): boolean { - return [ - "paragraph", - "heading", - "list", - "listItem", - "blockquote", - "code", - "html", - "yaml", - "thematicBreak", - "table", - "tableRow", - "tableCell", - "break", - "image", - ].includes(node.type); -} diff --git a/src/extensions/markdown/editor/tip-tap-editor.fuzz.test.tsx b/src/extensions/markdown/editor/tip-tap-editor.fuzz.test.tsx deleted file mode 100644 index a068944c..00000000 --- a/src/extensions/markdown/editor/tip-tap-editor.fuzz.test.tsx +++ /dev/null @@ -1,224 +0,0 @@ -// @vitest-environment jsdom -import { EditorContent } from "@tiptap/react"; -import type { Editor } from "@tiptap/core"; -import { render } from "@testing-library/react"; -import seedrandom from "seedrandom"; -import { expect, test } from "vitest"; -import { buildMarkdownFromEditor } from "./build-markdown-from-editor"; -import { createEditor } from "./create-editor"; -import { - applyOperationToSimplifiedState, - buildOperationFailureMessage, - buildPlainTextMismatchMessage, - buildSelectionInvariantFailureMessage, - createSimplifiedState, - expectedPlainText, - MARKDOWN_EDITOR_FUZZ_DEFAULT_SEED, - MARKDOWN_EDITOR_FUZZ_OPERATION_COUNT, - nextSimplifiedArrowSelection, - nextOperation, - renderPlainTextFromMarkdown, - setEditorSelectionBySimplifiedOffset, - simplifiedOffsetPositions, - simplifiedSelectionFromEditor, - validateEditorPositionMap, - validateSimplifiedSelectionInvariant, - type FuzzOperation, - type SimplifiedState, -} from "./markdown-editor-fuzz"; - -test("fuzzes plain-text editor operations through markdown serialization", () => { - const seed = - process.env.FLASHTYPE_MARKDOWN_FUZZ_SEED ?? - MARKDOWN_EDITOR_FUZZ_DEFAULT_SEED; - const rng = seedrandom(seed); - const state = createSimplifiedState(); - const editor = createEditor({ - lix: {} as any, - initialMarkdown: "", - persistState: false, - }); - const rendered = render(); - - try { - for ( - let index = 0; - index < MARKDOWN_EDITOR_FUZZ_OPERATION_COUNT; - index += 1 - ) { - const operation = nextOperation(rng, state); - try { - applyOperationToEditor(editor, state, operation); - applyOperationToSimplifiedState(state, operation); - } catch (error) { - throw new Error( - buildOperationFailureMessage({ - seed, - index, - operation, - state, - editorJson: editor.getJSON(), - cause: error, - }), - ); - } - assertEditorSelectionMatches(editor, state, seed, index, operation); - assertMarkdownPlainTextMatches(editor, state, seed, index, operation); - } - } finally { - rendered.unmount(); - editor.destroy(); - } -}, 120_000); - -function applyOperationToEditor( - editor: Editor, - state: SimplifiedState, - operation: FuzzOperation, -): void { - validateEditorPositionMap(editor, state); - if (operation.kind === "move") { - setEditorSelectionBySimplifiedOffset( - editor, - operation.anchor, - operation.head, - ); - return; - } - - setEditorSelectionBySimplifiedOffset(editor, state.anchor, state.head); - - switch (operation.kind) { - case "type": - typeEditorText(editor, operation.value); - return; - case "enter": - sendEditorKey(editor, "Enter"); - return; - case "shiftEnter": - sendEditorKey(editor, "Enter", { shift: true }); - return; - case "left": - if (!trySendEditorKey(editor, "ArrowLeft")) { - applyNativeArrowFallbackToEditor(editor, state, "left"); - } - return; - case "right": - if (!trySendEditorKey(editor, "ArrowRight")) { - applyNativeArrowFallbackToEditor(editor, state, "right"); - } - return; - } -} - -function applyNativeArrowFallbackToEditor( - editor: Editor, - state: SimplifiedState, - direction: "left" | "right", -): void { - const next = nextSimplifiedArrowSelection(state, direction); - setEditorSelectionBySimplifiedOffset(editor, next.anchor, next.head); -} - -function typeEditorText(editor: Editor, value: string): void { - const { from, to } = editor.state.selection; - let handled = false; - editor.view.someProp("handleTextInput", (handler: any) => { - const result = handler(editor.view, from, to, value); - handled = result || handled; - return result; - }); - if (!handled) { - editor.commands.insertContent(value); - } -} - -function sendEditorKey( - editor: Editor, - key: string, - options: { shift?: boolean } = {}, -): void { - if (trySendEditorKey(editor, key, options)) return; - throw new Error(`Editor did not handle key: ${key}`); -} - -function trySendEditorKey( - editor: Editor, - key: string, - options: { shift?: boolean } = {}, -): boolean { - const event = new KeyboardEvent("keydown", { - key, - shiftKey: options.shift ?? false, - bubbles: true, - cancelable: true, - }); - let handled = false; - editor.view.someProp("handleKeyDown", (handler: any) => { - const result = handler(editor.view, event); - handled = result || handled; - return result; - }); - return handled; -} - -function assertEditorSelectionMatches( - editor: Editor, - state: SimplifiedState, - seed: string, - index: number, - operation: FuzzOperation, -): void { - const positions = simplifiedOffsetPositions(editor); - const selection = simplifiedSelectionFromEditor(editor); - const reason = validateSimplifiedSelectionInvariant({ - state, - positions, - docSize: editor.state.doc.content.size, - selection, - }); - - if (!reason) return; - - throw new Error( - buildSelectionInvariantFailureMessage({ - seed, - index, - operation, - state, - reason, - positions, - docSize: editor.state.doc.content.size, - selection, - editorJson: editor.getJSON(), - }), - ); -} - -function assertMarkdownPlainTextMatches( - editor: Editor, - state: SimplifiedState, - seed: string, - index: number, - operation: FuzzOperation, -): void { - const markdown = buildMarkdownFromEditor(editor); - const expected = expectedPlainText(state); - const actual = renderPlainTextFromMarkdown(markdown); - - if (actual !== expected) { - expect( - actual, - buildPlainTextMismatchMessage({ - seed, - index, - operation, - state, - expected, - actual, - markdown, - editorJson: editor.getJSON(), - }), - ).toBe(expected); - } -} diff --git a/src/extensions/markdown/editor/tip-tap-editor.test.tsx b/src/extensions/markdown/editor/tip-tap-editor.test.tsx deleted file mode 100644 index 21c59943..00000000 --- a/src/extensions/markdown/editor/tip-tap-editor.test.tsx +++ /dev/null @@ -1,767 +0,0 @@ -import React, { Suspense, StrictMode } from "react"; -import { expect, test } from "vitest"; -import { qb } from "@/lib/lix-kysely"; -import { - render, - waitFor, - screen, - act, - fireEvent, -} from "@testing-library/react"; -import { LixProvider } from "@/lib/lix-react"; -import { openLix, type Lix } from "@/test-utils/node-lix-sdk"; -import { TipTapEditor } from "./tip-tap-editor"; -import { KeyValueProvider } from "@/hooks/key-value/use-key-value"; -import { KEY_VALUE_DEFINITIONS } from "@/hooks/key-value/schema"; -import { EditorProvider } from "./editor-context"; -import type { Editor } from "@tiptap/core"; - -function Providers({ - lix, - defs, - children, -}: { - lix: Lix; - defs?: any; - children: React.ReactNode; -}) { - return ( - - - {children} - - - ); -} - -async function renderEditorForMarkdownFile({ - fileId, - markdown, - originKey = "flashtype.markdown-editor:test-origin", -}: { - fileId: string; - markdown: string; - originKey?: string; -}): Promise<{ lix: Lix; editor: Editor }> { - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - { - key: "flashtype_active_file_id", - value: fileId, - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }, - ], - }); - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: `/${fileId}.md`, - data: new TextEncoder().encode(markdown), - }) - .execute(); - - let editorRef: Editor | null = null; - await act(async () => { - render( - - - (editorRef = editor)} - originKey={originKey} - persistDebounceMs={60_000} - /> - - , - ); - }); - await screen.findByTestId("tiptap-editor"); - await waitFor(() => expect(editorRef).not.toBeNull()); - return { lix, editor: editorRef! }; -} - -async function setEditorText(editor: Editor, text: string): Promise { - await act(async () => { - editor.commands.setContent({ - type: "doc", - content: [ - { - type: "paragraph", - content: text ? [{ type: "text", text }] : undefined, - }, - ], - }); - }); - await waitFor(() => - expect(screen.getByTestId("tiptap-editor")).toHaveTextContent(text), - ); -} - -async function writeMarkdownFileWithOrigin( - lix: Lix, - fileId: string, - markdown: string, - originKey?: string, -): Promise { - await lix.execute( - "UPDATE lix_file SET data = $1 WHERE id = $2", - [new TextEncoder().encode(markdown), fileId], - originKey ? { originKey } : undefined, - ); -} - -async function settleMarkdownObserver(): Promise { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 75)); - }); -} - -// Removed CaptureEditor and editor ref helpers; interact via DOM instead - -test("renders initial document content", async () => { - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - ], - }); - const fileId = "file_render_doc"; - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/render.md", - data: new TextEncoder().encode("Hello"), - }) - .execute(); - - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: "flashtype_active_file_id", - value: fileId, - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }) - .execute(); - - await act(async () => { - render( - - - - - , - ); - }); - - const editor = await screen.findByTestId("tiptap-editor"); - expect(editor).toHaveTextContent("Hello"); -}); - -test("persists state changes on edit (paragraph append)", async () => { - const fileId = "file_1"; - const markdown = "# Title\n\nHello"; - - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - { - key: "flashtype_active_file_id", - value: fileId, - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }, - ], - }); - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/test.md", - data: new TextEncoder().encode(markdown), - }) - .execute(); - - let editorRef: Editor = undefined as any; - - await act(async () => { - render( - - - (editorRef = editor)} - persistDebounceMs={0} - /> - - , - ); - }); - - await waitFor(async () => { - const end = editorRef.state.doc.content.size; - editorRef.commands.insertContentAt(end, { - type: "paragraph", - content: [{ type: "text", text: "New Paragraph" }], - }); - }); - - await waitFor(async () => { - const row = await qb(lix) - .selectFrom("lix_file") - .where("id", "=", fileId) - .select("data") - .executeTakeFirstOrThrow(); - const markdown = new TextDecoder().decode(row.data ?? new Uint8Array()); - expect(markdown).toContain("New Paragraph"); - }); -}); - -test("renders content under React.StrictMode", async () => { - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - ], - }); - - const fileId = "file_strict"; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/strict.md", - data: new TextEncoder().encode("Hello Strict"), - }) - .execute(); - - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: "flashtype_active_file_id", - value: fileId, - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }) - .execute(); - - await act(async () => { - render( - - - - - - - , - ); - }); - - const editor = await screen.findByTestId("tiptap-editor"); - await waitFor(() => expect(editor).toHaveTextContent("Hello Strict")); -}); - -test("shows placeholder only while focused on an empty document", async () => { - const fileId = "file_placeholder_focus"; - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - { - key: "flashtype_active_file_id", - value: fileId, - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }, - ], - }); - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/placeholder.md", - data: new TextEncoder().encode(""), - }) - .execute(); - - let editorRef: Editor | null = null; - - await act(async () => { - render( - - - (editorRef = editor)} /> - - , - ); - }); - - const editorNode = await screen.findByTestId("tiptap-editor"); - - const container = editorNode.closest(".tiptap-container"); - await waitFor(() => { - expect(container?.getAttribute("data-editor-focused")).toBe("false"); - const paragraph = editorNode.querySelector("p"); - expect(paragraph).toBeTruthy(); - }); - - await act(async () => { - fireEvent.mouseDown(container as HTMLElement); - fireEvent.click(container as HTMLElement); - }); - - await waitFor(() => { - const paragraph = editorNode.querySelector("p"); - expect(paragraph?.getAttribute("data-placeholder")).toBe("Start typing..."); - expect(container?.getAttribute("data-editor-focused")).toBe("true"); - }); - - await act(async () => { - editorRef?.commands.blur(); - }); - - await waitFor(() => { - expect(container?.getAttribute("data-editor-focused")).toBe("false"); - }); -}); - -test("uses heading 1 as the requested empty document default", async () => { - const fileId = "file_default_heading"; - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - { - key: "flashtype_active_file_id", - value: fileId, - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }, - ], - }); - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/default-heading.md", - data: new TextEncoder().encode(""), - }) - .execute(); - - let editorRef: Editor | null = null; - - await act(async () => { - render( - - - (editorRef = editor)} - persistDebounceMs={0} - /> - - , - ); - }); - - const editorNode = await screen.findByTestId("tiptap-editor"); - await waitFor(() => { - expect(editorNode.querySelector("h1")).toBeTruthy(); - expect(editorNode.querySelector("p")).toBeNull(); - expect(editorRef?.isActive("heading", { level: 1 })).toBe(true); - }); - - await act(async () => { - editorRef?.commands.insertContent("Document title"); - }); - - await waitFor(async () => { - const row = await qb(lix) - .selectFrom("lix_file") - .where("id", "=", fileId) - .select("data") - .executeTakeFirstOrThrow(); - const markdown = new TextDecoder().decode(row.data ?? new Uint8Array()); - expect(markdown).toBe("# Document title\n"); - }); -}); - -test("clicking the surface focuses the editor even when content exists", async () => { - const fileId = "file_focus_surface"; - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - { - key: "flashtype_active_file_id", - value: fileId, - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }, - ], - }); - - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/has-content.md", - data: new TextEncoder().encode("Hello world"), - }) - .execute(); - - await act(async () => { - render( - - - - - , - ); - }); - - const editorNode = await screen.findByTestId("tiptap-editor"); - const container = editorNode.closest(".tiptap-container"); - await waitFor(() => { - expect(container?.getAttribute("data-editor-focused")).toBe("false"); - }); - - await act(async () => { - fireEvent.mouseDown(container as HTMLElement); - fireEvent.click(container as HTMLElement); - }); - - await waitFor(() => { - expect(container?.getAttribute("data-editor-focused")).toBe("true"); - }); -}); - -test("updates editor when switching to a branch with different external state", async () => { - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - ], - }); - - // Create a file and set it active - const fileId = "file_switch_branch"; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/switch.md", - data: new TextEncoder().encode("Hello A"), - }) - .execute(); - - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: "flashtype_active_file_id", - value: fileId, - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }) - .execute(); - - const branchB = await lix.createBranch({ name: "Draft" }); - - await qb(lix) - .updateTable("lix_file_by_branch") - .set({ data: new TextEncoder().encode("Hello B") }) - .where("id", "=", fileId) - .where("lixcol_branch_id", "=", branchB.id) - .execute(); - - // Initial render in base branch - await act(async () => { - render( - - - - - , - ); - }); - - const editorA = await screen.findByTestId("tiptap-editor"); - expect(editorA).toHaveTextContent("Hello A"); - - // Switch to branch B; the editor should reflect branch B's content "Hello B" - await act(async () => { - await lix.switchBranch({ branchId: branchB.id }); - }); - - await waitFor(() => { - expect(screen.getByTestId("tiptap-editor")).toHaveTextContent("Hello B"); - }); -}); - -test("updates editor when file.data is updated externally (simulate updateFile with markdown)", async () => { - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - ], - }); - - const fileId = "file_update_blob"; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/blob.md", - data: new TextEncoder().encode("Hello A"), - }) - .execute(); - - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: "flashtype_active_file_id", - value: fileId, - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }) - .execute(); - - await act(async () => { - render( - - - - - , - ); - }); - - const editorA = await screen.findByTestId("tiptap-editor"); - expect(editorA).toHaveTextContent("Hello A"); - - // External: write markdown into file.data directly (simulating lix.updateFile) - await qb(lix) - .updateTable("lix_file") - .set({ data: new TextEncoder().encode("Hello B from file.data") }) - .where("id", "=", fileId) - .execute(); - - // Expect editor to pick up the updated file content (currently fails) - await waitFor(async () => { - const editorB = await screen.findByTestId("tiptap-editor"); - expect(editorB).toHaveTextContent("Hello B from file.data"); - }); -}); - -test("ignores same-origin stale markdown autosave echoes", async () => { - const originKey = "flashtype.markdown-editor:same-origin-stale"; - const fileId = "file_same_origin_stale"; - const { lix, editor } = await renderEditorForMarkdownFile({ - fileId, - markdown: "Initial\n", - originKey, - }); - - await setEditorText(editor, "Local newer"); - await writeMarkdownFileWithOrigin( - lix, - fileId, - "Stale saved copy\n", - originKey, - ); - await settleMarkdownObserver(); - - const editorNode = screen.getByTestId("tiptap-editor"); - expect(editorNode).toHaveTextContent("Local newer"); - expect(editorNode).not.toHaveTextContent("Stale saved copy"); -}); - -test("same-origin echo matching current markdown marks editor clean", async () => { - const originKey = "flashtype.markdown-editor:same-origin-clean"; - const fileId = "file_same_origin_clean"; - const { lix, editor } = await renderEditorForMarkdownFile({ - fileId, - markdown: "Initial\n", - originKey, - }); - - await setEditorText(editor, "Local current"); - await writeMarkdownFileWithOrigin(lix, fileId, "Local current\n", originKey); - await settleMarkdownObserver(); - await writeMarkdownFileWithOrigin( - lix, - fileId, - "External after clean\n", - "external-origin", - ); - - await waitFor(() => { - expect(screen.getByTestId("tiptap-editor")).toHaveTextContent( - "External after clean", - ); - }); -}); - -test("applies different-origin markdown update when editor is clean", async () => { - const fileId = "file_external_clean"; - const { lix } = await renderEditorForMarkdownFile({ - fileId, - markdown: "Initial\n", - }); - - await writeMarkdownFileWithOrigin( - lix, - fileId, - "External clean update\n", - "external-origin", - ); - - await waitFor(() => { - expect(screen.getByTestId("tiptap-editor")).toHaveTextContent( - "External clean update", - ); - }); -}); - -test("does not clobber dirty editor content with different-origin markdown update", async () => { - const fileId = "file_external_dirty"; - const { lix, editor } = await renderEditorForMarkdownFile({ - fileId, - markdown: "Initial\n", - }); - - await setEditorText(editor, "Unsaved local edit"); - await writeMarkdownFileWithOrigin( - lix, - fileId, - "External dirty update\n", - "external-origin", - ); - await settleMarkdownObserver(); - - const editorNode = screen.getByTestId("tiptap-editor"); - expect(editorNode).toHaveTextContent("Unsaved local edit"); - expect(editorNode).not.toHaveTextContent("External dirty update"); -}); - -test("preserves main content when switching to a new branch and back", async () => { - const lix = await openLix({ - keyValues: [ - { - key: "lix_deterministic_mode", - value: { enabled: true }, - lixcol_branch_id: "global", - lixcol_global: true, - }, - ], - }); - - const fileId = "file_regression_main_preserve"; - await qb(lix) - .insertInto("lix_file") - .values({ - id: fileId, - path: "/regression.md", - data: new TextEncoder().encode("Hello world"), - }) - .execute(); - - // Activate file globally - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: "flashtype_active_file_id", - value: fileId, - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }) - .execute(); - - // Remember currently active branch id (main) - const mainId = await lix.activeBranchId(); - - // Render editor on main - await act(async () => { - render( - - - - - , - ); - }); - const editorA = await screen.findByTestId("tiptap-editor"); - expect(editorA).toHaveTextContent("Hello world"); - - // Create a new branch from main and switch to it - const vB = await lix.createBranch({ name: "Draft" }); - await act(async () => { - await lix.switchBranch({ branchId: vB.id }); - }); - - // Switch back to main; the content should still be "Hello world" - await act(async () => { - await lix.switchBranch({ branchId: mainId }); - }); - - await waitFor(() => { - expect(screen.getByTestId("tiptap-editor")).toHaveTextContent( - "Hello world", - ); - }); -}); diff --git a/src/extensions/markdown/editor/tip-tap-editor.tsx b/src/extensions/markdown/editor/tip-tap-editor.tsx deleted file mode 100644 index 8aa35504..00000000 --- a/src/extensions/markdown/editor/tip-tap-editor.tsx +++ /dev/null @@ -1,580 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { EditorContent } from "@tiptap/react"; -import type { Editor } from "@tiptap/core"; -import { qb, sql } from "@/lib/lix-kysely"; -import { useEditorCtx } from "./editor-context"; -import { useLix, useQueryTakeFirst } from "@/lib/lix-react"; -import { useKeyValue } from "@/hooks/key-value/use-key-value"; -import { createEditor, createMarkdownEditorOriginKey } from "./create-editor"; -import { assembleMdAst } from "./assemble-md-ast"; -import { astToTiptapDoc } from "./tiptap-markdown-bridge"; -import type { EmptyMarkdownDefaultBlock } from "./tiptap-markdown-bridge"; -import { parseMarkdown } from "./markdown"; -import { decodeMarkdownData } from "./decode-markdown-data"; -import { - buildNormalizedMarkdownFromEditor, - normalizePersistedMarkdown, -} from "./build-markdown-from-editor"; -import { - desktopWorkspaceApi, - useDesktopWorkspaceDir, -} from "./desktop-workspace"; - -type TipTapEditorProps = { - fileId?: string | null; - filePath?: string | null; - className?: string; - onReady?: (editor: Editor) => void; - persistDebounceMs?: number; - focusOnLoad?: boolean; - defaultBlock?: EmptyMarkdownDefaultBlock; - isActiveView?: boolean; - originKey?: string; -}; - -/** - * Rich text editor for Markdown files backed by the Lix store. - * - * Loads the active file lazily, keeps the ProseMirror instance in sync with - * remote changes, and persists edits via the collaborative Lix writer. - * - * @example - * editor.commands.focus()} - * focusOnLoad - * /> - */ -export function TipTapEditor({ - fileId, - filePath, - className, - onReady, - persistDebounceMs, - focusOnLoad, - defaultBlock, - isActiveView = true, - originKey, -}: TipTapEditorProps) { - if (fileId) { - return ( - - ); - } - - return ( - - ); -} - -function TipTapEditorWithActiveKey(props: Omit) { - const [activeFileId] = useKeyValue("flashtype_active_file_id"); - return ( - - ); -} - -type TipTapEditorContentProps = Omit & { - readonly activeFileId?: string | null; -}; - -function TipTapEditorContent(props: TipTapEditorContentProps) { - const activeBranch = useQueryTakeFirst<{ value: string }>((lix) => - qb(lix) - .selectFrom("lix_key_value") - .where("key", "=", "lix_workspace_branch_id") - .select(["value"]), - ); - const activeBranchId = String(activeBranch?.value ?? ""); - - if (!props.activeFileId) { - return ( - - ); - } - - return ( - - ); -} - -function TipTapEditorFileContent({ - activeBranchId, - activeFileId, - ...props -}: TipTapEditorContentProps & { - readonly activeBranchId: string; - readonly activeFileId: string; -}) { - const initialFile = useQueryTakeFirst( - (lix) => - qb(lix) - .selectFrom("lix_file") - .select(["data", "path"]) - .select(() => [sql`${activeBranchId}`.as("active_branch_id")]) - .where("id", "=", activeFileId), - { subscribe: false }, - ); - const initialMarkdown = decodeMarkdownData(initialFile?.data); - - return ( - - ); -} - -function TipTapEditorLoadedContent({ - activeFileId, - activeBranchId, - className, - onReady, - persistDebounceMs, - focusOnLoad, - defaultBlock, - isActiveView = true, - originKey, - hasInitialFile, - initialMarkdown, - sourceFilePath, -}: TipTapEditorContentProps & { - readonly activeBranchId: string; - readonly hasInitialFile: boolean; - readonly initialMarkdown: string; - readonly sourceFilePath?: string | null; -}) { - const lix = useLix(); - const { setEditor } = useEditorCtx(); - const workspaceDirState = useDesktopWorkspaceDir(); - const PERSIST_DEBOUNCE_MS = persistDebounceMs ?? 500; - const editorOriginKey = useMemo( - () => originKey ?? createMarkdownEditorOriginKey(), - [originKey], - ); - const resolveImageSrc = useMemo(() => { - const workspaceApi = desktopWorkspaceApi(); - const workspacePath = workspaceDirState.workspaceDir; - if ( - !sourceFilePath || - !workspacePath || - !workspaceApi?.resolveMarkdownImageSrc - ) { - return undefined; - } - return (src: string) => - workspaceApi.resolveMarkdownImageSrc({ - src, - sourceFilePath, - workspacePath, - }); - }, [sourceFilePath, workspaceDirState.workspaceDir]); - - const [initialAst, setInitialAst] = useState(null); - const [initialAstLoaded, setInitialAstLoaded] = useState(false); - const lastInitialAstRef = useRef(null); - const hasAutoFocusedRef = useRef(false); - const mountedEditorRef = useRef(null); - const pendingExternalMarkdownRef = useRef(null); - - const editor = useMemo(() => { - if ( - !activeFileId || - !hasInitialFile || - !initialAstLoaded || - !workspaceDirState.loaded - ) { - return null; - } - // Prefer assembled AST from the current file bytes so initialization stays deterministic. - const hasAstSnapshot = - Array.isArray(initialAst?.children) && initialAst.children.length > 0; - return createEditor({ - lix, - initialMarkdown, - contentAst: hasAstSnapshot ? initialAst : undefined, - fileId: activeFileId, - defaultBlock, - persistDebounceMs: PERSIST_DEBOUNCE_MS, - resolveImageSrc, - originKey: editorOriginKey, - }); - }, [ - lix, - activeFileId, - PERSIST_DEBOUNCE_MS, - hasInitialFile, - initialAst, - initialAstLoaded, - initialMarkdown, - defaultBlock, - resolveImageSrc, - editorOriginKey, - workspaceDirState.loaded, - ]); - - useEffect(() => { - mountedEditorRef.current = editor; - return () => { - const editorToDestroy = editor; - mountedEditorRef.current = null; - queueMicrotask(() => { - if (mountedEditorRef.current !== editorToDestroy) { - editorToDestroy?.destroy(); - } - }); - }; - }, [editor]); - - const [isEditorFocused, setIsEditorFocused] = useState(false); - - useEffect(() => { - if (!editor) return; - const syncFocus = () => setIsEditorFocused(editor.isFocused); - syncFocus(); - editor.on("focus", syncFocus); - editor.on("blur", syncFocus); - return () => { - editor.off("focus", syncFocus); - editor.off("blur", syncFocus); - }; - }, [editor]); - - const handleSurfacePointerDown = useCallback( - (event: React.MouseEvent) => { - if (!editor) return; - const target = event.target as HTMLElement | null; - const insideContent = target?.closest(".ProseMirror"); - if (insideContent) return; - event.preventDefault(); - if (editor.isEmpty) { - editor.commands.focus("start"); - } else { - editor.commands.focus("end"); - } - }, - [editor], - ); - - // Custom overlay scrollbar avoids flaky native scrollbar repaint behavior. - const scrollIdleTimerRef = useRef(null); - const scrollFrameRef = useRef(null); - const scrollContainerRef = useRef(null); - const scrollThumbRef = useRef(null); - const setScrollbarVisible = useCallback((visible: boolean) => { - const thumb = scrollThumbRef.current; - if (!thumb) return; - const next = visible ? "true" : "false"; - if (thumb.dataset.visible !== next) { - thumb.dataset.visible = next; - } - }, []); - const syncScrollbarThumb = useCallback(() => { - if (scrollFrameRef.current !== null) return; - scrollFrameRef.current = window.requestAnimationFrame(() => { - scrollFrameRef.current = null; - const el = scrollContainerRef.current; - const thumb = scrollThumbRef.current; - if (!el || !thumb) return; - - const { clientHeight, scrollHeight, scrollTop } = el; - if (scrollHeight <= clientHeight) { - thumb.dataset.scrollable = "false"; - setScrollbarVisible(false); - return; - } - - const minThumbHeight = 36; - const thumbHeight = Math.max( - minThumbHeight, - (clientHeight / scrollHeight) * clientHeight, - ); - const maxThumbTop = clientHeight - thumbHeight; - const maxScrollTop = scrollHeight - clientHeight; - const thumbTop = - maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0; - - thumb.dataset.scrollable = "true"; - thumb.style.height = `${thumbHeight}px`; - thumb.style.transform = `translate3d(0, ${thumbTop}px, 0)`; - }); - }, [setScrollbarVisible]); - - useEffect(() => { - const el = scrollContainerRef.current; - if (!el) return; - - const supportsScrollEnd = "onscrollend" in window; - const hideScrollbar = () => { - if (scrollIdleTimerRef.current !== null) { - window.clearTimeout(scrollIdleTimerRef.current); - scrollIdleTimerRef.current = null; - } - setScrollbarVisible(false); - }; - const scheduleFallbackHide = () => { - if (scrollIdleTimerRef.current !== null) { - window.clearTimeout(scrollIdleTimerRef.current); - } - scrollIdleTimerRef.current = window.setTimeout(hideScrollbar, 450); - }; - const showScrollbar = () => { - syncScrollbarThumb(); - setScrollbarVisible(true); - }; - const handleNativeScroll = () => { - showScrollbar(); - if (!supportsScrollEnd) { - scheduleFallbackHide(); - } - }; - const handlePointerEnter = () => { - showScrollbar(); - }; - const handlePointerLeave = () => { - if (el.scrollHeight > el.clientHeight) { - hideScrollbar(); - } - }; - const handleWheel = () => { - showScrollbar(); - if (!supportsScrollEnd) { - scheduleFallbackHide(); - } - }; - - const resizeObserver = new ResizeObserver(syncScrollbarThumb); - resizeObserver.observe(el); - if (el.firstElementChild) { - resizeObserver.observe(el.firstElementChild); - } - syncScrollbarThumb(); - - el.addEventListener("scroll", handleNativeScroll, { passive: true }); - el.addEventListener("pointerenter", handlePointerEnter, { passive: true }); - el.addEventListener("pointerleave", handlePointerLeave, { passive: true }); - el.addEventListener("wheel", handleWheel, { passive: true }); - if (supportsScrollEnd) { - el.addEventListener("scrollend", hideScrollbar, { passive: true }); - } - - return () => { - resizeObserver.disconnect(); - el.removeEventListener("scroll", handleNativeScroll); - el.removeEventListener("pointerenter", handlePointerEnter); - el.removeEventListener("pointerleave", handlePointerLeave); - el.removeEventListener("wheel", handleWheel); - if (supportsScrollEnd) { - el.removeEventListener("scrollend", hideScrollbar); - } - if (scrollIdleTimerRef.current !== null) { - window.clearTimeout(scrollIdleTimerRef.current); - scrollIdleTimerRef.current = null; - } - if (scrollFrameRef.current !== null) { - window.cancelAnimationFrame(scrollFrameRef.current); - scrollFrameRef.current = null; - } - }; - }, [editor, setScrollbarVisible, syncScrollbarThumb]); - - // Observe markdown file rows and refresh on external changes. - useEffect(() => { - if (!activeFileId || !editor || !isActiveView) return; - const events = lix.observe( - ` - SELECT - f.data, - f.lixcol_change_id, - c.origin_key - FROM lix_file AS f - LEFT JOIN lix_change AS c ON c.id = f.lixcol_change_id - WHERE f.id = ? - `, - [activeFileId], - ); - let closed = false; - let sawInitialSnapshot = false; - const initialObservedMarkdown = normalizePersistedMarkdown(initialMarkdown); - let lastCleanPersistedMarkdown = buildNormalizedMarkdownFromEditor(editor); - pendingExternalMarkdownRef.current = null; - - void (async () => { - while (!closed) { - const event = await events.next(); - if (!event || closed) { - continue; - } - const firstRow = event.result.rows[0]; - if (!firstRow) { - continue; - } - const nextMarkdown = normalizePersistedMarkdown( - decodeMarkdownData(firstRow["data"]), - ); - const observedOriginKey = firstRow["origin_key"]; - const currentMarkdown = buildNormalizedMarkdownFromEditor(editor); - if (!sawInitialSnapshot) { - sawInitialSnapshot = true; - if (nextMarkdown === initialObservedMarkdown) { - continue; - } - } - if (currentMarkdown === nextMarkdown) { - lastCleanPersistedMarkdown = nextMarkdown; - pendingExternalMarkdownRef.current = null; - continue; - } - if (observedOriginKey === editorOriginKey) { - continue; - } - if (currentMarkdown !== lastCleanPersistedMarkdown) { - pendingExternalMarkdownRef.current = nextMarkdown; - continue; - } - const ast = parseMarkdown(nextMarkdown) as any; - editor.commands.setContent(astToTiptapDoc(ast, { defaultBlock }), { - emitUpdate: false, - }); - lastCleanPersistedMarkdown = nextMarkdown; - pendingExternalMarkdownRef.current = null; - } - })(); - - return () => { - closed = true; - events.close(); - }; - }, [ - lix, - editor, - activeFileId, - activeBranchId, - isActiveView, - editorOriginKey, - initialMarkdown, - defaultBlock, - ]); - - useEffect(() => { - hasAutoFocusedRef.current = false; - }, [activeFileId]); - - useEffect(() => { - if (!editor) return; - if (!focusOnLoad) return; - if (!isActiveView) return; - if (hasAutoFocusedRef.current) return; - editor.commands.focus("end"); - hasAutoFocusedRef.current = true; - }, [editor, focusOnLoad, isActiveView, activeFileId]); - - useEffect(() => { - let cancelled = false; - if (!activeFileId) { - setInitialAst(null); - setInitialAstLoaded(true); - lastInitialAstRef.current = null; - return; - } - (async () => { - const ast = await assembleMdAst({ lix, fileId: activeFileId }); - if (cancelled) return; - const serialized = JSON.stringify(ast); - if (serialized === lastInitialAstRef.current && initialAstLoaded) return; - lastInitialAstRef.current = serialized; - setInitialAstLoaded(false); - setInitialAst(ast); - setInitialAstLoaded(true); - })(); - return () => { - cancelled = true; - }; - }, [lix, activeFileId, activeBranchId, initialAstLoaded]); - - useEffect(() => { - if (!editor) return; - setEditor(editor); - onReady?.(editor); - }, [editor, setEditor, onReady]); - - if (!activeFileId) { - return ( -
-
-

- Select a file to start writing. -

-
-
- ); - } - - if (!editor) { - return ( -
-
-
-
-
- ); - } - - return ( -
-
- -
- - ); -} diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/assign-data-id.test.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/assign-data-id.test.ts deleted file mode 100644 index 3f518ddd..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/assign-data-id.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -// @vitest-environment jsdom -import { expect, test } from "vitest"; -import { Editor, type JSONContent } from "@tiptap/core"; -import { MarkdownWc } from "./markdown-wc"; -import { tiptapDocToAst, type PMNode } from "./tiptap-to-mdwc"; - -type Ast = any; - -test("assign-data-id dedupes duplicate top-level ids on boot", () => { - // Build a TipTap JSON doc with two paragraphs that share the same data.id - const doc: JSONContent = { - type: "doc", - content: [ - { - type: "paragraph", - attrs: { data: { id: "DUP" } }, - content: [{ type: "text", text: "A" }], - }, - { - type: "paragraph", - attrs: { data: { id: "DUP" } }, - content: [{ type: "text", text: "B" }], - }, - ], - }; - - let i = 0; - const editor = new Editor({ - extensions: MarkdownWc({ idProvider: () => `FIX${i++}` }), - content: doc, - }); - - // Trigger a benign edit to ensure appendTransaction runs and dedupes ids - editor.commands.setTextSelection(editor.state.doc.content.size); - editor.commands.insertContent(" "); - - const ast = tiptapDocToAst(editor.getJSON() as unknown as PMNode) as Ast; - const children = (ast as any).children ?? []; - const ids = children.map((n: any) => n?.data?.id); - const texts = children.map((n: any) => - (n.children ?? []) - .map((c: any) => c?.value ?? "") - .join("") - .trim(), - ); - - // Expect two paragraphs with texts preserved - expect(texts).toEqual(["A", "B"]); - // The first keeps DUP, the second gets a new id from idProvider - expect(ids[0]).toBe("DUP"); - expect(typeof ids[1]).toBe("string"); - expect(ids[1]).toBe("FIX0"); - // Unique ids - expect(new Set(ids).size).toBe(2); - - editor.destroy(); -}); - -test("assign-data-id assigns ids to nested nodes", () => { - const doc: JSONContent = { - type: "doc", - content: [ - { - type: "bulletList", - content: [ - { - type: "listItem", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Item 1" }], - }, - ], - }, - { - type: "listItem", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Item 2" }], - }, - ], - }, - ], - }, - ], - }; - - let nestedCounter = 0; - const editor = new Editor({ - extensions: MarkdownWc({ idProvider: () => `Nested_${nestedCounter++}` }), - content: doc, - }); - - editor.commands.setTextSelection(editor.state.doc.content.size); - editor.commands.insertContent(" "); - - const json = editor.getJSON() as JSONContent; - const list = json.content?.[0]; - const firstItem = list?.content?.[0]; - const firstParagraph = firstItem?.content?.[0]; - const secondItem = list?.content?.[1]; - const secondParagraph = secondItem?.content?.[0]; - - const ids = [ - list?.attrs?.data?.id, - firstItem?.attrs?.data?.id, - firstParagraph?.attrs?.data?.id, - secondItem?.attrs?.data?.id, - secondParagraph?.attrs?.data?.id, - ].filter((id): id is string => typeof id === "string"); - - expect(ids.length).toBe(5); - expect(new Set(ids).size).toBe(ids.length); - - editor.destroy(); -}); - -test("assign-data-id dedupes nested duplicate ids", () => { - const doc: JSONContent = { - type: "doc", - content: [ - { - type: "bulletList", - attrs: { data: { id: "LIST_DUP" } }, - content: [ - { - type: "listItem", - attrs: { data: { id: "DUP" } }, - content: [ - { - type: "paragraph", - attrs: { data: { id: "P_DUP" } }, - content: [{ type: "text", text: "Item 1" }], - }, - ], - }, - { - type: "listItem", - attrs: { data: { id: "DUP" } }, - content: [ - { - type: "paragraph", - attrs: { data: { id: "P_DUP" } }, - content: [{ type: "text", text: "Item 2" }], - }, - ], - }, - ], - }, - ], - }; - - let counter = 0; - const editor = new Editor({ - extensions: MarkdownWc({ idProvider: () => `FIX_DUP_${counter++}` }), - content: doc, - }); - - editor.commands.setTextSelection(editor.state.doc.content.size); - editor.commands.insertContent(" "); - - const json = editor.getJSON() as JSONContent; - const list = json.content?.[0]; - const firstItem = list?.content?.[0]; - const secondItem = list?.content?.[1]; - const firstParagraph = firstItem?.content?.[0]; - const secondParagraph = secondItem?.content?.[0]; - - expect(list?.attrs?.data?.id).toBe("LIST_DUP"); - expect(firstItem?.attrs?.data?.id).toBe("DUP"); - expect(secondItem?.attrs?.data?.id).toBe("FIX_DUP_0"); - expect(firstParagraph?.attrs?.data?.id).toBe("P_DUP"); - expect(secondParagraph?.attrs?.data?.id).toBe("FIX_DUP_1"); - expect( - new Set([firstItem?.attrs?.data?.id, secondItem?.attrs?.data?.id]).size, - ).toBe(2); - expect( - new Set([firstParagraph?.attrs?.data?.id, secondParagraph?.attrs?.data?.id]) - .size, - ).toBe(2); - - editor.destroy(); -}); diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/assign-data-id.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/assign-data-id.ts deleted file mode 100644 index 5426315a..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/assign-data-id.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Extension } from "@tiptap/core"; -import { Plugin } from "@tiptap/pm/state"; - -function defaultGenId() { - // Simple, readable id good enough for tests/runtime without external deps - return ( - "id_" + - Math.random().toString(36).slice(2, 8) + - Date.now().toString(36).slice(-4) - ); -} - -export type AssignDataIdOptions = { idProvider?: () => string }; - -export function createAssignDataIdExtension(opts?: AssignDataIdOptions) { - const idProvider = opts?.idProvider ?? defaultGenId; - const supportsDataAttr = (node: any) => - Boolean(node?.type?.spec?.attrs && "data" in node.type.spec.attrs); - const nextUniqueId = (seen: Set) => { - let next: string | null = null; - while (!next || seen.has(next)) { - next = idProvider(); - } - return next; - }; - return Extension.create({ - name: "markdownWcAssignDataId", - addProseMirrorPlugins() { - return [ - new Plugin({ - appendTransaction: ( - _trs: readonly any[], - oldState: any, - newState: any, - ) => { - if (oldState.doc === newState.doc) return null; - - const tr = newState.tr; - let modified = false; - const seen = new Set(); - - newState.doc.descendants((node: any, pos: number) => { - if (!supportsDataAttr(node)) return; - const mappedPos = tr.mapping.map(pos); - const currentNode = tr.doc.nodeAt(mappedPos); - if (!currentNode || !supportsDataAttr(currentNode)) return; - if (!currentNode.type.validContent(currentNode.content)) return; - const attrs = currentNode.attrs || {}; - const data = attrs.data || null; - let id: string | null = - data && typeof data.id === "string" && data.id.length > 0 - ? (data.id as string) - : null; - if (id && seen.has(id)) id = null; - if (!id) { - id = nextUniqueId(seen); - const nextData = { ...(data || {}), id }; - const nextAttrs = { ...attrs, data: nextData }; - try { - tr.setNodeMarkup( - mappedPos, - undefined, - nextAttrs, - currentNode.marks, - ); - } catch { - // Skip malformed/transient nodes instead of crashing the editor. - return; - } - modified = true; - } - seen.add(id!); - }); - - return modified ? tr : null; - }, - }), - ]; - }, - }); -} diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/default-block.test.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/default-block.test.ts deleted file mode 100644 index ae094539..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/default-block.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from "vitest"; -import { - EMPTY_MARKDOWN_SCAFFOLD_DATA_KEY, - astToTiptapDoc, -} from "./mdwc-to-tiptap"; -import { tiptapDocToAst } from "./tiptap-to-mdwc"; - -test("empty document defaults to a paragraph scaffold", () => { - const pm = astToTiptapDoc({ type: "root", children: [] }); - - expect(pm.content).toEqual([{ type: "paragraph", attrs: { data: {} } }]); - expect(tiptapDocToAst(pm)).toEqual({ type: "root", children: [] }); -}); - -test("empty document can default to a heading 1 scaffold", () => { - const pm = astToTiptapDoc( - { type: "root", children: [] }, - { defaultBlock: "heading1" }, - ); - - expect(pm.content).toEqual([ - { - type: "heading", - attrs: { - level: 1, - data: { [EMPTY_MARKDOWN_SCAFFOLD_DATA_KEY]: true }, - }, - }, - ]); - expect(tiptapDocToAst(pm)).toEqual({ type: "root", children: [] }); -}); - -test("default heading scaffold marker is not persisted once content exists", () => { - const pm = astToTiptapDoc( - { type: "root", children: [] }, - { defaultBlock: "heading1" }, - ); - pm.content![0] = { - ...pm.content![0], - content: [{ type: "text", text: "Document title" }], - }; - - expect(tiptapDocToAst(pm)).toEqual({ - type: "root", - children: [ - { - type: "heading", - depth: 1, - data: null, - children: [{ type: "text", value: "Document title" }], - }, - ], - }); -}); diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/index.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/index.ts deleted file mode 100644 index 6b54f41a..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { MarkdownWc } from "./markdown-wc"; -export { astToTiptapDoc } from "./mdwc-to-tiptap"; -export { tiptapDocToAst } from "./tiptap-to-mdwc"; -export { MarkdownWcShortcuts } from "./shortcuts"; -export type { - EmptyMarkdownDefaultBlock, - PMNode, - PMMark, -} from "./mdwc-to-tiptap"; diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/markdown-wc.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/markdown-wc.ts deleted file mode 100644 index 232d0f9c..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/markdown-wc.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Extensions } from "@tiptap/core"; -import { markdownWcNodes } from "./nodes"; -import { MarkdownWcShortcuts } from "./shortcuts"; -import { createAssignDataIdExtension } from "./assign-data-id"; - -export type MarkdownWcOptions = { - readonly idProvider?: () => string; - readonly resolveImageSrc?: (src: string) => string; -}; - -// --- TipTap minimal extensions (no HTML parsing, schema only) --- - -/** - * Build the minimal TipTap extension set for Markdown‑WC. - * - * - Schema-only nodes/marks for Markdown editing (no UI/menus included). - * - Preserves arbitrary vendor metadata via `attrs.data` on nodes/marks. - * - Ensures each top‑level block has a stable `data.id` to support - * identity/persistence across edits (splits/inserts get a new id). - * - * Identity provider - * - By default, a small local generator is used (readable, dependency‑free). - * - You can inject your own `idProvider` to align with your app/runtime. - * - * Examples - * ```ts - * import { MarkdownWc } from "@/extensions/markdown/editor/tiptap-markdown-bridge" - * import { Editor } from "@tiptap/core" - * - * // 1) Default ids (sufficient for most apps/tests) - * const editor = new Editor({ - * extensions: MarkdownWc(), - * }) - * - * // 2) Integrate with Lix nanoId for persistence alignment - * import { nanoId } from "@lix-js/sdk" - * const editor2 = new Editor({ - * extensions: MarkdownWc({ idProvider: () => nanoId({ lix }) }), - * }) - * - * // 3) Deterministic ids in tests - * let i = 0 - * const editor3 = new Editor({ - * extensions: MarkdownWc({ idProvider: () => `test_${i++}` }), - * }) - * ``` - */ -export function MarkdownWc(opts?: MarkdownWcOptions): Extensions { - return [ - ...markdownWcNodes({ resolveImageSrc: opts?.resolveImageSrc }), - createAssignDataIdExtension(opts), - MarkdownWcShortcuts, - ]; -} diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/mdwc-to-tiptap.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/mdwc-to-tiptap.ts deleted file mode 100644 index 00c8968a..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/mdwc-to-tiptap.ts +++ /dev/null @@ -1,361 +0,0 @@ -// Avoid tight compile-time coupling to mdast types; operate on structural shape - -const SPREAD_META_KEY = "__mdwc_spread"; -export const EMPTY_MARKDOWN_SCAFFOLD_DATA_KEY = "__flashtype_empty_scaffold"; -export const EMPTY_MARKDOWN_PARAGRAPH_DATA_KEY = - "__flashtype_empty_paragraph"; - -export type PMMark = { - type: "bold" | "italic" | "strike" | "code" | "link"; - attrs?: Record; -}; -export type PMNode = { - type: string; - attrs?: Record; - content?: PMNode[]; - text?: string; - marks?: PMMark[]; -}; - -export type EmptyMarkdownDefaultBlock = "paragraph" | "heading1"; - -export function astToTiptapDoc( - ast: any, - options: { defaultBlock?: EmptyMarkdownDefaultBlock } = {}, -): PMNode { - const children = Array.isArray(ast?.children) - ? ast.children.map(astBlockToPM) - : []; - return { - type: "doc", - content: - children.length > 0 - ? children - : [emptyDefaultBlockToPM(options.defaultBlock ?? "paragraph")], - }; -} - -function emptyDefaultBlockToPM( - defaultBlock: EmptyMarkdownDefaultBlock, -): PMNode { - if (defaultBlock === "heading1") { - return { - type: "heading", - attrs: { - level: 1, - data: { [EMPTY_MARKDOWN_SCAFFOLD_DATA_KEY]: true }, - }, - }; - } - return { type: "paragraph", attrs: { data: {} } }; -} - -function astBlockToPM(node: any): PMNode { - switch (node.type) { - case "paragraph": - const paragraphData = buildNodeData((node as any).data); - const paragraphChildren = (node as any).children || []; - const isEmptyPlaceholder = - isEmptyParagraphPlaceholder(paragraphChildren); - const inlineChildren = isHardBreakOnlyParagraphPlaceholder( - paragraphChildren, - ) - ? paragraphChildren.slice(2) - : paragraphChildren; - return { - type: "paragraph", - attrs: { - data: buildNodeData(paragraphData, { - [EMPTY_MARKDOWN_PARAGRAPH_DATA_KEY]: isEmptyPlaceholder - ? true - : undefined, - }), - }, - content: isEmptyPlaceholder - ? [] - : flattenInline(inlineChildren, []), - }; - case "heading": - const headingData = buildNodeData((node as any).data); - return { - type: "heading", - attrs: { level: (node as any).depth, data: headingData }, - content: flattenInline((node as any).children || [], []), - }; - case "list": { - const n = node as any; - const type = n.ordered ? "orderedList" : "bulletList"; - let attrs: any = { - data: buildNodeData(n.data, { - [SPREAD_META_KEY]: - typeof n.spread === "boolean" ? n.spread : undefined, - }), - }; - if (n.ordered && n.start != null && n.start !== 1) - attrs = { ...attrs, start: n.start }; - - // Mark bullet lists as task lists if any item has checked set - if (!n.ordered) { - const hasTask = Array.isArray(n.children) - ? n.children.some( - (li: any) => li && (li.checked === true || li.checked === false), - ) - : false; - attrs = { ...(attrs || {}), isTaskList: hasTask }; - } - - return { type, attrs, content: (n.children || []).map(astBlockToPM) }; - } - case "listItem": { - const n = node as any; - const hasChecked = n.checked === true || n.checked === false; - const attrs = { - data: buildNodeData(n.data, { - [SPREAD_META_KEY]: - typeof n.spread === "boolean" ? n.spread : undefined, - }), - ...(hasChecked ? { checked: n.checked } : {}), - }; - return { - type: "listItem", - attrs, - content: (n.children || []).map(astBlockToPM), - }; - } - case "blockquote": { - const n = node as any; - return { - type: "blockquote", - attrs: { data: buildNodeData(n.data) }, - content: (n.children || []).map(astBlockToPM), - }; - } - case "code": { - const n = node as any; - return { - type: "codeBlock", - attrs: { language: n.lang ?? null, data: buildNodeData(n.data) }, - content: textContent(n.value || ""), - }; - } - case "html": { - const n = node as any; - return { - type: "markdownUnsupported", - attrs: { - kind: "html", - value: n.value ?? "", - data: buildNodeData(n.data), - }, - }; - } - case "yaml": { - const n = node as any; - return { - type: "markdownUnsupported", - attrs: { - kind: "yaml", - value: n.value ?? "", - data: buildNodeData(n.data), - }, - }; - } - case "thematicBreak": { - const hrData = buildNodeData((node as any).data); - return { type: "horizontalRule", attrs: { data: hrData } }; - } - case "table": { - const n = node as any; - return { - type: "table", - attrs: { - align: Array.isArray(n.align) ? n.align : [], - data: buildNodeData(n.data), - }, - content: (n.children || []).map(astBlockToPM), - }; - } - case "tableRow": { - const n = node as any; - return { - type: "tableRow", - attrs: { data: buildNodeData(n.data) }, - content: (n.children || []).map(astBlockToPM), - }; - } - case "tableCell": { - const n = node as any; - return { - type: "tableCell", - attrs: { data: buildNodeData(n.data) }, - content: flattenInline((n.children || []) as any, []), - }; - } - default: - // Fallback: paragraph of inline content if present - // @ts-ignore - if ((node as any).children) - return { - type: "paragraph", - attrs: { data: buildNodeData((node as any).data) }, - content: flattenInline((node as any).children, []), - }; - return { - type: "paragraph", - attrs: { data: buildNodeData((node as any).data) }, - content: textContent(""), - }; - } -} - -function isEmptyParagraphPlaceholder(children: any[]): boolean { - return ( - children.length === 2 && - children[0]?.type === "html" && - children[0]?.value === "" && - children[1]?.type === "html" && - children[1]?.value === "" - ); -} - -function isHardBreakOnlyParagraphPlaceholder(children: any[]): boolean { - return ( - children.length > 2 && - children[0]?.type === "html" && - children[0]?.value === "" && - children[1]?.type === "html" && - children[1]?.value === "" && - children.slice(2).every(isHtmlHardBreak) - ); -} - -function isHtmlHardBreak(node: any): boolean { - return ( - node?.type === "html" && - typeof node.value === "string" && - /^$/i.test(node.value) - ); -} - -function buildNodeData( - data: Record | null | undefined, - extras?: Record, -): Record | null { - const base = data && typeof data === "object" ? { ...data } : {}; - if (extras) { - for (const [key, value] of Object.entries(extras)) { - if (value === undefined) continue; - base[key] = value; - } - } - return Object.keys(base).length > 0 ? base : null; -} - -function textContent(str: string): PMNode[] { - return str ? [{ type: "text", text: str }] : []; -} - -function flattenInline(nodes: any[], active: PMMark[]): PMNode[] { - const out: PMNode[] = []; - for (const n of nodes) { - switch (n.type) { - case "text": { - const t = (n as any).value; - if (t) - out.push({ - type: "text", - text: t, - marks: active.length ? [...active] : undefined, - }); - break; - } - case "emphasis": - out.push( - ...flattenInline( - (n as any).children || [], - addMark(active, { type: "italic" }), - ), - ); - break; - case "strong": - out.push( - ...flattenInline( - (n as any).children || [], - addMark(active, { type: "bold" }), - ), - ); - break; - case "delete": - out.push( - ...flattenInline( - (n as any).children || [], - addMark(active, { type: "strike" }), - ), - ); - break; - case "inlineCode": - out.push({ - type: "text", - text: (n as any).value || "", - marks: addMark(active, { type: "code" }), - }); - break; - case "link": { - const ln = n as any; - const href = ln.url || null; - const title = ln.title ?? null; - out.push( - ...flattenInline( - (ln.children || []) as any, - addMark(active, { - type: "link", - attrs: { href, title, data: ln.data ?? null }, - }), - ), - ); - break; - } - case "image": { - const im = n as any; - const src = im.url || null; - const title = im.title ?? null; - const alt = im.alt ?? null; - out.push({ - type: "image", - attrs: { src, title, alt, data: im.data ?? null }, - } as any); - break; - } - case "break": - out.push({ - type: "hardBreak", - attrs: { data: (n as any).data ?? null } as any, - }); - break; - case "html": { - const html = n as any; - if (isHtmlHardBreak(html)) { - out.push({ - type: "hardBreak", - attrs: { data: html.data ?? null } as any, - }); - break; - } - out.push({ - type: "markdownInlineHtml", - attrs: { value: html.value ?? "", data: html.data ?? null }, - }); - break; - } - default: - // ignore unsupported inline nodes in this minimal pass - break; - } - } - return out; -} - -function addMark(active: PMMark[], mark: PMMark): PMMark[] { - if (active.find((m) => m.type === mark.type)) return active; - return [...active, mark]; -} diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid-code-block-node-view.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid-code-block-node-view.ts deleted file mode 100644 index dc6cfd11..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid-code-block-node-view.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { Editor } from "@tiptap/core"; -import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import type { EditorView, NodeView } from "@tiptap/pm/view"; -import { - getMermaidRenderTheme, - onMermaidThemeChange, - renderMermaidDiagram, -} from "./mermaid-render"; - -type DiffAttrs = Record; -type MermaidTheme = ReturnType; - -export function createCodeBlockNodeView(options: { - readonly node: ProseMirrorNode; - readonly editor: Editor; - readonly view: EditorView; - readonly getPos: () => number | undefined; - readonly diffAttrs: DiffAttrs; -}): NodeView { - const { node, editor, view, getPos, diffAttrs } = options; - const language = node.attrs?.language ?? null; - - if (language !== "mermaid") { - return createPlainCodeBlockNodeView(node, diffAttrs); - } - - return createMermaidCodeBlockNodeView({ - node, - editor, - view, - getPos, - diffAttrs, - }); -} - -function createPlainCodeBlockNodeView( - node: ProseMirrorNode, - diffAttrs: DiffAttrs, -): NodeView { - const pre = document.createElement("pre"); - const code = document.createElement("code"); - const language = node.attrs?.language ?? null; - if (language) { - code.className = `language-${language}`; - } - for (const [key, value] of Object.entries(diffAttrs)) { - code.setAttribute(key, value); - } - pre.appendChild(code); - return { - dom: pre, - contentDOM: code, - }; -} - -function createMermaidCodeBlockNodeView(options: { - readonly node: ProseMirrorNode; - readonly editor: Editor; - readonly view: EditorView; - readonly getPos: () => number | undefined; - readonly diffAttrs: DiffAttrs; -}): NodeView { - const { editor, view, getPos, diffAttrs } = options; - let currentNode = options.node; - - const dom = document.createElement("div"); - dom.className = "markdown-mermaid-block"; - for (const [key, value] of Object.entries(diffAttrs)) { - dom.setAttribute(key, value); - } - - const preview = document.createElement("div"); - preview.className = "markdown-mermaid-preview"; - - const srDescription = document.createElement("span"); - srDescription.className = "markdown-mermaid-sr-description"; - - const error = document.createElement("div"); - error.className = "markdown-mermaid-error"; - error.hidden = true; - - const pre = document.createElement("pre"); - pre.className = "markdown-mermaid-source"; - const code = document.createElement("code"); - code.className = "language-mermaid"; - pre.appendChild(code); - - dom.appendChild(preview); - dom.appendChild(srDescription); - dom.appendChild(error); - dom.appendChild(pre); - - let lastRenderedSource = ""; - let lastRenderedTheme: MermaidTheme | null = null; - let renderDebounceTimer: number | null = null; - let renderInFlight = false; - let renderScheduledAfterFlight = false; - let destroyed = false; - let showingSource = - editor.isFocused && isNodeSelected(view, getPos, currentNode); - - function getSourceText(): string { - return code.textContent || currentNode.textContent; - } - - function previewIsCurrent(source: string): boolean { - return ( - source === lastRenderedSource && - lastRenderedTheme === getMermaidRenderTheme() && - preview.querySelector("svg") !== null - ); - } - - function scheduleRenderPreview(): void { - if (showingSource) return; - if (renderInFlight) { - renderScheduledAfterFlight = true; - return; - } - if (renderDebounceTimer !== null) { - window.clearTimeout(renderDebounceTimer); - } - renderDebounceTimer = window.setTimeout(() => { - renderDebounceTimer = null; - void renderPreview(); - }, 50); - } - - function syncAccessibility(source: string): void { - preview.setAttribute("aria-hidden", showingSource ? "true" : "false"); - srDescription.textContent = source; - srDescription.hidden = showingSource || !source.trim(); - } - - function setViewMode(editing: boolean): void { - showingSource = editing; - dom.dataset.editing = editing ? "true" : "false"; - error.hidden = editing || error.textContent === ""; - syncAccessibility(getSourceText()); - if (!editing) { - scheduleRenderPreview(); - } - } - - async function renderPreview(): Promise { - if (destroyed || showingSource) return; - if (renderInFlight) { - renderScheduledAfterFlight = true; - return; - } - - const source = getSourceText(); - if (previewIsCurrent(source)) { - return; - } - if (!source.trim()) { - preview.replaceChildren(); - error.hidden = true; - error.textContent = ""; - lastRenderedSource = ""; - lastRenderedTheme = null; - syncAccessibility(source); - return; - } - - renderInFlight = true; - const renderTheme = getMermaidRenderTheme(); - try { - await renderMermaidDiagram(source, preview); - if (destroyed || showingSource) return; - error.hidden = true; - error.textContent = ""; - lastRenderedSource = source; - lastRenderedTheme = renderTheme; - syncAccessibility(source); - if (renderTheme !== getMermaidRenderTheme()) { - renderScheduledAfterFlight = true; - } - } catch (cause) { - if (destroyed || showingSource) return; - preview.replaceChildren(); - error.hidden = false; - error.textContent = formatMermaidError(cause); - } finally { - renderInFlight = false; - if (renderScheduledAfterFlight) { - renderScheduledAfterFlight = false; - scheduleRenderPreview(); - } - } - } - - setViewMode(showingSource); - - const syncViewMode = () => { - const selected = - editor.isFocused && isNodeSelected(view, getPos, currentNode); - if (selected !== showingSource) { - setViewMode(selected); - } - }; - editor.on("selectionUpdate", syncViewMode); - editor.on("blur", syncViewMode); - - const unsubscribeThemeChange = onMermaidThemeChange(() => { - if (destroyed || showingSource) return; - scheduleRenderPreview(); - }); - - return { - dom, - contentDOM: code, - ignoreMutation(mutation) { - const target = mutation.target; - if (!(target instanceof Node)) { - return true; - } - return !code.contains(target); - }, - update(updatedNode) { - if (updatedNode.type.name !== "codeBlock") return false; - if ((updatedNode.attrs?.language ?? null) !== "mermaid") { - return false; - } - - currentNode = updatedNode; - syncAccessibility(updatedNode.textContent); - const selected = - editor.isFocused && isNodeSelected(view, getPos, updatedNode); - if (selected !== showingSource) { - setViewMode(selected); - } else if ( - !selected && - !previewIsCurrent(updatedNode.textContent) - ) { - scheduleRenderPreview(); - } - return true; - }, - selectNode() { - setViewMode(true); - }, - deselectNode() { - setViewMode(false); - }, - destroy() { - destroyed = true; - unsubscribeThemeChange(); - if (renderDebounceTimer !== null) { - window.clearTimeout(renderDebounceTimer); - renderDebounceTimer = null; - } - editor.off("selectionUpdate", syncViewMode); - editor.off("blur", syncViewMode); - }, - }; -} - -function isNodeSelected( - view: EditorView, - getPos: () => number | undefined, - node: ProseMirrorNode, -): boolean { - const pos = getPos(); - if (pos == null) return false; - const { from, to } = view.state.selection; - return from >= pos && to <= pos + node.nodeSize; -} - -function formatMermaidError(cause: unknown): string { - if (cause instanceof Error) return cause.message; - return String(cause); -} diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid-render.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid-render.ts deleted file mode 100644 index 03dd4917..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid-render.ts +++ /dev/null @@ -1,88 +0,0 @@ -import mermaid from "mermaid"; - -let initialized = false; -let renderCounter = 0; -let activeTheme: "dark" | "default" | null = null; -let themeObserver: MutationObserver | null = null; -const themeChangeListeners = new Set<() => void>(); - -function isDarkMode(): boolean { - return document.documentElement.classList.contains("dark"); -} - -function currentTheme(): "dark" | "default" { - return isDarkMode() ? "dark" : "default"; -} - -function ensureThemeObserver(): void { - if (themeObserver || typeof document === "undefined") return; - themeObserver = new MutationObserver(() => { - const theme = currentTheme(); - if (theme === activeTheme) return; - syncMermaidTheme(); - for (const listener of themeChangeListeners) { - listener(); - } - }); - themeObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"], - }); -} - -export function getMermaidRenderTheme(): "dark" | "default" { - ensureThemeObserver(); - return currentTheme(); -} - -export function onMermaidThemeChange(listener: () => void): () => void { - ensureThemeObserver(); - themeChangeListeners.add(listener); - return () => { - themeChangeListeners.delete(listener); - }; -} - -function syncMermaidTheme(): void { - const theme = currentTheme(); - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - theme, - }); - initialized = true; - activeTheme = theme; -} - -function ensureMermaidInitialized(): void { - if (!initialized || currentTheme() !== activeTheme) { - syncMermaidTheme(); - } -} - -export function resetMermaidForTests(): void { - initialized = false; - renderCounter = 0; - activeTheme = null; - themeObserver?.disconnect(); - themeObserver = null; - themeChangeListeners.clear(); -} - -export async function renderMermaidDiagram( - source: string, - container: HTMLElement, -): Promise { - const trimmed = source.trim(); - container.replaceChildren(); - if (!trimmed) { - return; - } - - ensureMermaidInitialized(); - const renderId = `flashtype-mermaid-${++renderCounter}`; - const { svg } = await mermaid.render(renderId, trimmed); - const wrapper = document.createElement("div"); - wrapper.innerHTML = svg; - container.appendChild(wrapper); -} diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid.test.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid.test.ts deleted file mode 100644 index dc765d51..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/mermaid.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -// @vitest-environment jsdom -import { describe, expect, test, vi } from "vitest"; -import { Editor } from "@tiptap/core"; -import { MarkdownWc } from "./markdown-wc"; -import { astToTiptapDoc } from "./mdwc-to-tiptap"; -import { parseMarkdown, serializeAst } from "../markdown"; -import { tiptapDocToAst } from "./tiptap-to-mdwc"; - -const mermaidMock = vi.hoisted(() => { - let theme: "dark" | "default" = "default"; - const themeListeners = new Set<() => void>(); - let releaseInFlightRender: (() => void) | null = null; - let deferNextRender = false; - - return { - renderMermaidDiagram: vi.fn( - async (_source: string, container: HTMLElement) => { - if (deferNextRender) { - deferNextRender = false; - await new Promise((resolve) => { - releaseInFlightRender = resolve; - }); - releaseInFlightRender = null; - } - const svg = document.createElementNS( - "http://www.w3.org/2000/svg", - "svg", - ); - container.replaceChildren(svg); - }, - ), - resetMermaidForTests: vi.fn(), - getMermaidRenderTheme: vi.fn(() => theme), - onMermaidThemeChange: vi.fn((listener: () => void) => { - themeListeners.add(listener); - return () => { - themeListeners.delete(listener); - }; - }), - setTheme(next: "dark" | "default") { - theme = next; - for (const listener of themeListeners) { - listener(); - } - }, - deferNextRenderOnce() { - deferNextRender = true; - }, - finishInFlightRender() { - releaseInFlightRender?.(); - }, - reset() { - theme = "default"; - deferNextRender = false; - releaseInFlightRender = null; - themeListeners.clear(); - mermaidMock.renderMermaidDiagram.mockClear(); - }, - }; -}); - -vi.mock("./mermaid-render", () => mermaidMock); - -const FLOWCHART = [ - "graph TD", - " A[Start] --> B{Done?}", - " B -->|Yes| C[End]", -].join("\n"); - -describe("mermaid code blocks", () => { - test("parses mermaid fenced code blocks", () => { - const ast = parseMarkdown(`\`\`\`mermaid\n${FLOWCHART}\n\`\`\``); - - expect(ast.children[0]).toEqual({ - type: "code", - lang: "mermaid", - meta: null, - value: FLOWCHART, - }); - }); - - test("roundtrips mermaid fenced code blocks", () => { - const markdown = `\`\`\`mermaid\n${FLOWCHART}\n\`\`\`\n`; - const ast = parseMarkdown(markdown); - const editor = new Editor({ - extensions: MarkdownWc(), - content: astToTiptapDoc(ast), - }); - - const output = serializeAst(tiptapDocToAst(editor.getJSON() as any)); - expect(output).toBe(markdown); - editor.destroy(); - }); - - test("shows a preview container when the editor is blurred", async () => { - mermaidMock.reset(); - const ast = parseMarkdown(`\`\`\`mermaid\n${FLOWCHART}\n\`\`\``); - const editor = new Editor({ - extensions: MarkdownWc(), - content: astToTiptapDoc(ast), - }); - - const block = editor.view.dom.querySelector(".markdown-mermaid-block"); - expect(block).not.toBeNull(); - expect( - editor.view.dom.querySelector(".markdown-mermaid-preview"), - ).not.toBeNull(); - - editor.commands.blur(); - expect(block?.getAttribute("data-editing")).toBe("false"); - - await vi.waitFor(() => { - expect( - editor.view.dom.querySelector(".markdown-mermaid-preview svg"), - ).not.toBeNull(); - }); - - editor.destroy(); - }); - - test("hides preview from view and screen readers while editing", () => { - mermaidMock.reset(); - const ast = parseMarkdown(`\`\`\`mermaid\n${FLOWCHART}\n\`\`\``); - const editor = new Editor({ - extensions: MarkdownWc(), - content: astToTiptapDoc(ast), - }); - - (editor.view.dom as HTMLElement).focus(); - editor.commands.setTextSelection({ from: 1, to: 1 }); - const isFocusedSpy = vi.spyOn(editor, "isFocused", "get"); - isFocusedSpy.mockReturnValue(true); - editor.emit("selectionUpdate", { editor, transaction: editor.state.tr }); - - const block = editor.view.dom.querySelector(".markdown-mermaid-block"); - const preview = block?.querySelector(".markdown-mermaid-preview"); - - expect(block?.getAttribute("data-editing")).toBe("true"); - expect(preview?.getAttribute("aria-hidden")).toBe("true"); - - isFocusedSpy.mockReturnValue(false); - editor.commands.blur(); - editor.emit("blur", { - editor, - event: new FocusEvent("blur"), - transaction: editor.state.tr, - }); - expect(block?.getAttribute("data-editing")).toBe("false"); - expect(preview?.getAttribute("aria-hidden")).toBe("false"); - expect( - block?.querySelector(".markdown-mermaid-sr-description")?.textContent, - ).toContain("graph TD"); - - editor.destroy(); - }); - - test("re-renders when theme changes during an in-flight render", async () => { - mermaidMock.reset(); - mermaidMock.deferNextRenderOnce(); - - const ast = parseMarkdown(`\`\`\`mermaid\n${FLOWCHART}\n\`\`\``); - const editor = new Editor({ - extensions: MarkdownWc(), - content: astToTiptapDoc(ast), - }); - - editor.commands.blur(); - await vi.waitFor(() => { - expect(mermaidMock.renderMermaidDiagram).toHaveBeenCalled(); - }); - const callsBeforeThemeChange = - mermaidMock.renderMermaidDiagram.mock.calls.length; - - mermaidMock.setTheme("dark"); - mermaidMock.finishInFlightRender(); - await vi.waitFor(() => { - expect( - mermaidMock.renderMermaidDiagram.mock.calls.length, - ).toBeGreaterThan(callsBeforeThemeChange); - }); - - editor.destroy(); - }); - - test("re-renders when the app theme changes", async () => { - mermaidMock.reset(); - const ast = parseMarkdown(`\`\`\`mermaid\n${FLOWCHART}\n\`\`\``); - const editor = new Editor({ - extensions: MarkdownWc(), - content: astToTiptapDoc(ast), - }); - - editor.commands.blur(); - await vi.waitFor(() => { - expect(mermaidMock.renderMermaidDiagram).toHaveBeenCalledTimes(1); - }); - - mermaidMock.setTheme("dark"); - await vi.waitFor(() => { - expect(mermaidMock.renderMermaidDiagram).toHaveBeenCalledTimes(2); - }); - - editor.destroy(); - }); - - test("re-renders after a failed preview when the diagram source is unchanged", async () => { - mermaidMock.reset(); - mermaidMock.renderMermaidDiagram - .mockRejectedValueOnce(new Error("parse error")) - .mockImplementation(async (_source: string, container: HTMLElement) => { - const svg = document.createElementNS( - "http://www.w3.org/2000/svg", - "svg", - ); - container.replaceChildren(svg); - }); - - const ast = parseMarkdown(`\`\`\`mermaid\n${FLOWCHART}\n\`\`\``); - const editor = new Editor({ - extensions: MarkdownWc(), - content: astToTiptapDoc(ast), - }); - - editor.commands.blur(); - await vi.waitFor(() => { - expect( - editor.view.dom.querySelector(".markdown-mermaid-error")?.textContent, - ).toContain("parse error"); - }); - - mermaidMock.setTheme("dark"); - await vi.waitFor(() => { - expect( - editor.view.dom.querySelector(".markdown-mermaid-preview svg"), - ).not.toBeNull(); - }); - - editor.destroy(); - }); - - test("keeps non-mermaid code blocks as plain pre/code", () => { - const ast = parseMarkdown("```js\nconst x = 1;\n```"); - const editor = new Editor({ - extensions: MarkdownWc(), - content: astToTiptapDoc(ast), - }); - - expect(editor.view.dom.querySelector(".markdown-mermaid-block")).toBeNull(); - expect( - editor.view.dom.querySelector("pre code.language-js"), - ).not.toBeNull(); - - editor.destroy(); - }); -}); diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/nodes.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/nodes.ts deleted file mode 100644 index 850078b6..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/nodes.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { Node, Mark, type Extensions, type CommandProps } from "@tiptap/core"; -import { createCodeBlockNodeView } from "./mermaid-code-block-node-view"; - -export type MarkdownImageSrcResolver = (src: string) => string; - -// Extend TipTap's command types -declare module "@tiptap/core" { - interface Commands { - horizontalRule: { - setHorizontalRule: () => ReturnType; - }; - } -} - -// Minimal schema-only nodes and marks for MarkdownWc -function diffAttrs(node: any, mode: "words" | "element" = "words"): any { - const id = node?.attrs?.data?.id; - if (typeof id !== "string" || id.length === 0) return {}; - const diffMode = - node?.attrs?.data?.diffMode === "words" || - node?.attrs?.data?.diffMode === "element" - ? node.attrs.data.diffMode - : mode; - return { - "data-diff-key": id, - "data-diff-mode": diffMode, - "data-diff-show-when-removed": "true", - }; -} - -export function markdownWcNodes( - options: { readonly resolveImageSrc?: MarkdownImageSrcResolver } = {}, -): Extensions { - const resolveImageSrc = options.resolveImageSrc; - return [ - // doc - Node.create({ name: "doc", topNode: true, content: "block+" }), - // text - Node.create({ name: "text", group: "inline" }), - // paragraph - Node.create({ - name: "paragraph", - group: "block", - content: "inline*", - addAttributes() { - return { data: { default: null } }; - }, - renderHTML({ node }) { - return ["p", diffAttrs(node), 0]; - }, - }), - // heading - Node.create({ - name: "heading", - group: "block", - content: "inline*", - addAttributes() { - return { level: { default: 1 }, data: { default: null } }; - }, - renderHTML({ node }) { - const level = (node as any).attrs?.level || 1; - return ["h" + level, diffAttrs(node), 0]; - }, - }), - // lists - Node.create({ - name: "bulletList", - group: "block", - content: "listItem+", - addAttributes() { - return { isTaskList: { default: false }, data: { default: null } }; - }, - renderHTML({ node }) { - // Match serializeToHtml default: plain