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..a3e656eb 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,10 @@ Download Flashtype for macOS at [flashtype.ai](https://flashtype.ai). ## License Flashtype is released under the [MIT License](./LICENSE). + +## Sharing + +The **Share** button opens a teaser with a link to [Lixray](https://lixray.com). +To request syncing between Lixray and Flashtype, use the email link to +[samuel@opral.com](mailto:samuel@opral.com). +Flashtype opens repositories locally and does not upload, publish, or sync them. diff --git a/e2e/agent-review.spec.ts b/e2e/agent-review.spec.ts index ad5f9284..37e7cf86 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 expect(page.getByRole("button", { name: "Start Claude Code", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Use Codex instead", exact: true }).click(); await expect( page.locator('[data-active="true"][data-view-key="flashtype_codex"]'), ).toBeVisible(); @@ -92,15 +93,25 @@ 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 expect(page.locator('[data-review-status="added"]').first()).toBeVisible(); + await expect.poll(() => page.locator('.xterm-viewport').first().evaluate( + (element) => getComputedStyle(element).backgroundColor, + )).toBe('rgb(255, 255, 255)'); + await expect.poll(() => page.locator('.xterm-screen').first().evaluate((screen) => { + const container = screen.closest('.xterm')!.parentElement!; + const screenBounds = screen.getBoundingClientRect(); + const containerBounds = container.getBoundingClientRect(); + return screenBounds.bottom <= containerBounds.bottom && screenBounds.right <= containerBounds.right; + })).toBe(true); await ensureFilesViewOpenInLeftPanel(page); await expect(fileTreeFile(page, "/changelog.md")).toHaveAttribute( "data-item-git-status", @@ -108,8 +119,19 @@ 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", ); + const reviewActions = page.getByRole("group", { name: "Diff review actions" }); + await reviewActions.getByRole("button", { name: "More keep options" }).click(); + await reviewActions.getByRole("menuitem", { name: /Keep all/ }).click(); + await expect(reviewActions).toHaveCount(0); + await expect(page.getByText("This file changed while it was being reviewed.", { exact: false })).toHaveCount(0); + await expect.poll(() => readFile(welcomeFilePath, "utf8")).toContain("Codex e2e edit"); + await expect.poll(() => readFile(changelogFilePath, "utf8")).toContain("Codex unopened edit"); + await expect.poll(() => readFile(createdFilePath, "utf8")).toContain("Codex created file"); + await page.getByRole("button", { name: "Add view", exact: true }).click(); + await page.getByRole("menuitem", { name: "Codex", exact: true }).click(); + await expect(page.getByRole("button", { name: "Close Codex", exact: true })).toBeVisible(); } finally { process.env.PATH = originalPath; if (originalShell === undefined) { @@ -162,7 +184,7 @@ run_hook() { run_hook UserPromptSubmit turn-start printf '\\nCodex e2e edit.\\n' >> welcome.md -printf '\\nCodex unopened edit.\\n' >> changelog.md +printf '# Changelog\\n\\nCodex unopened edit.\\n' > changelog.md printf '# Codex created file\\n' > codex-created.md run_hook Stop turn-stop printf '%s\\n' "$PWD" > "$FLASHTYPE_E2E_CODEX_COMPLETION_PATH" diff --git a/e2e/claude-agent-review.spec.ts b/e2e/claude-agent-review.spec.ts new file mode 100644 index 00000000..0848ec16 --- /dev/null +++ b/e2e/claude-agent-review.spec.ts @@ -0,0 +1,243 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { ElectronApplication } from "playwright"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + closeElectronApp, + ensureFilesViewOpenInLeftPanel, + fileTreeFile, + launchDevElectronAppWithArgs, + registerRendererConsoleLogging, + writeStarterFiles, +} from "./electron-test-utils"; + +test.skip(process.platform === "win32", "fake Claude helper is POSIX-only"); + +test("Claude terminal launches and exposes file-scoped Keep and Undo", async ({ + browserName: _browserName, +}, testInfo) => { + const userDataDir = testInfo.outputPath("user-data"); + const workspaceDir = testInfo.outputPath("workspace"); + const welcomeFilePath = path.join(workspaceDir, "welcome.md"); + const changelogFilePath = path.join(workspaceDir, "changelog.md"); + const fakeBinDir = testInfo.outputPath("fake-bin"); + const completionPath = testInfo.outputPath("fake-claude-complete"); + const baselinePath = testInfo.outputPath("fake-claude-baseline"); + const continuePath = testInfo.outputPath("fake-claude-continue"); + let electronApp: ElectronApplication | undefined; + + try { + await writeStarterFiles(workspaceDir); + const initialChangelogOnDisk = await readFile(changelogFilePath, "utf8"); + await writeFakeClaude(fakeBinDir); + electronApp = await launchDevElectronAppWithArgs([workspaceDir], { + userDataDir, + env: { + PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, + SHELL: "/bin/sh", + FLASHTYPE_E2E_CLAUDE_COMPLETION_PATH: completionPath, + FLASHTYPE_E2E_CLAUDE_BASELINE_PATH: baselinePath, + FLASHTYPE_E2E_CLAUDE_CONTINUE_PATH: continuePath, + }, + }); + const page = await electronApp.firstWindow(); + registerRendererConsoleLogging(page); + await openWelcomeMarkdown(page); + + await page + .getByRole("button", { name: "Start Claude Code", exact: true }) + .click(); + await expect + .poll(() => readCompletionMarker(baselinePath), { + message: "the baseline import did not complete", + timeout: 30_000, + }) + .toBe(workspaceDir); + const initialChangelogInLix = await readLixMarkdown(page, "/changelog.md"); + await writeFile(continuePath, "continue\n"); + await expect( + page.locator('[data-active="true"][data-view-key="flashtype_claude"]'), + ).toBeVisible(); + await expect(page.locator(".xterm-screen").first()).toBeVisible(); + await expect + .poll(() => + page + .locator(".xterm-viewport") + .first() + .evaluate((element) => getComputedStyle(element).backgroundColor), + ) + .toBe("rgb(255, 255, 255)"); + await expect + .poll(() => + page + .locator(".xterm-rows") + .first() + .evaluate((element) => getComputedStyle(element).color), + ) + .toBe("rgb(28, 25, 23)"); + await expect + .poll(() => readCompletionMarker(completionPath), { + message: "the fake Claude command did not complete", + timeout: 30_000, + }) + .toBe(workspaceDir); + await expect + .poll(async () => await readFile(welcomeFilePath, "utf8"), { + timeout: 30_000, + }) + .toContain("Claude e2e edit"); + await expect + .poll(async () => await readFile(changelogFilePath, "utf8")) + .toContain("Claude second-file edit"); + + const review = page.getByRole("group", { name: "Diff review actions" }); + await expect(review).toBeVisible(); + await expect(review).toHaveAttribute( + "data-diff-float-mode", + "review-applied", + ); + await expect( + review.getByRole("button", { name: "Keep", exact: true }), + ).toBeVisible(); + await expect( + review.getByRole("button", { name: "Undo", exact: true }), + ).toBeVisible(); + + const scopeChip = review.locator('[data-attr="diff-scope-chip"]'); + await expect(scopeChip).toHaveAttribute("data-file-count", "2"); + await scopeChip.click(); + const fileChoices = review.getByRole("group", { + name: "Files in the working set", + }); + const welcomeChoice = fileChoices + .locator('[data-attr="diff-scope-file"]') + .filter({ hasText: "welcome.md" }); + const changelogChoice = fileChoices + .locator('[data-attr="diff-scope-file"]') + .filter({ hasText: "changelog.md" }); + await expect(welcomeChoice).toHaveCount(1); + await expect(changelogChoice).toHaveCount(1); + if ((await welcomeChoice.getAttribute("aria-checked")) === "true") { + await welcomeChoice.click(); + } + if ((await changelogChoice.getAttribute("aria-checked")) !== "true") { + await changelogChoice.click(); + } + await expect(welcomeChoice).toHaveAttribute("aria-checked", "false"); + await expect(changelogChoice).toHaveAttribute("aria-checked", "true"); + await expect(scopeChip).toHaveAttribute( + "aria-label", + "Working set: 1 of 2 files", + ); + await review.getByRole("button", { name: "Undo", exact: true }).click(); + + await expect + .poll(() => readLixMarkdown(page, "/changelog.md")) + .toBe(initialChangelogInLix); + await expect + .poll(async () => await readFile(changelogFilePath, "utf8")) + .toBe(initialChangelogOnDisk); + await expect + .poll(async () => await readFile(welcomeFilePath, "utf8")) + .toContain("Claude e2e edit"); + await expect(review).toBeVisible(); + await expect( + review.getByText("welcome.md", { exact: true }).last(), + ).toBeVisible(); + // A single file has no scope picker; undoing one file shrinks the + // two-file review to the remaining file and removes the picker. + await expect(scopeChip).toHaveCount(0); + await review.getByRole("button", { name: "Keep", exact: true }).click(); + await expect(review).toHaveCount(0); + await expect + .poll(async () => await readFile(welcomeFilePath, "utf8")) + .toContain("Claude e2e edit"); + await expect + .poll(async () => await readFile(changelogFilePath, "utf8")) + .toBe(initialChangelogOnDisk); + } finally { + await closeElectronApp(electronApp); + } +}); + +async function openWelcomeMarkdown(page: Page): Promise { + await ensureFilesViewOpenInLeftPanel(page); + const file = fileTreeFile(page, "/welcome.md"); + await expect(file).toBeVisible(); + await file.click(); + await expect(file).toHaveAttribute("data-item-selected", "true"); + await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible(); + await expect( + page.locator('[data-active="true"][data-view-key="atelier_file"]'), + ).toBeVisible(); +} + +async function writeFakeClaude(binDir: string): Promise { + await mkdir(binDir, { recursive: true }); + const scriptPath = path.join(binDir, "claude"); + await writeFile( + scriptPath, + `#!/bin/sh +set -eu + +case " $* " in + *" --version "*) + printf '%s\\n' '2.1.78 (Claude Code)' + exit 0 + ;; +esac + +run_hook() { + event_name="$1" + phase="$2" + printf '{"hook_event_name":"%s","session_id":"e2e-claude-session","turn_id":"e2e-claude-turn","cwd":"%s"}' "$event_name" "$PWD" | + ELECTRON_RUN_AS_NODE=1 "$FLASHTYPE_AGENT_HOOK_NODE" "$FLASHTYPE_AGENT_HOOK_SCRIPT" claude "$phase" +} + +run_hook UserPromptSubmit turn-start +printf '%s\\n' "$PWD" > "$FLASHTYPE_E2E_CLAUDE_BASELINE_PATH" +while [ ! -f "$FLASHTYPE_E2E_CLAUDE_CONTINUE_PATH" ]; do sleep 0.05; done +printf '\\nClaude e2e edit.\\n' >> welcome.md +printf '\\nClaude second-file edit.\\n' >> changelog.md +run_hook Stop turn-stop +printf '%s\\n' "$PWD" > "$FLASHTYPE_E2E_CLAUDE_COMPLETION_PATH" +printf '%s\\n' 'fake claude complete' +`, + "utf8", + ); + await chmod(scriptPath, 0o755); +} + +async function readCompletionMarker(filePath: string): Promise { + try { + return (await readFile(filePath, "utf8")).trim(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "pending"; + throw error; + } +} + +async function readLixMarkdown(page: Page, filePath: string): Promise { + return await page.evaluate(async (path) => { + const desktop = window.flashtypeDesktop; + if (!desktop) throw new Error("Flashtype desktop bridge is unavailable."); + const result = await desktop.lix.execute({ + sql: "SELECT content FROM lix_file WHERE path = $1", + params: [path], + }); + const row = result.rows[0]; + if (row === undefined) throw new Error(`No file row returned for ${path}.`); + const content = Array.isArray(row) + ? row[0] + : (row as Record).content; + if (content instanceof Uint8Array) return new TextDecoder().decode(content); + if (Array.isArray(content)) + return new TextDecoder().decode(new Uint8Array(content as number[])); + if (content && typeof content === "object") + return new TextDecoder().decode( + new Uint8Array(Object.values(content) as number[]), + ); + if (typeof content === "string") return content; + throw new Error(`Unexpected Lix content value: ${String(content)}`); + }, filePath); +} diff --git a/e2e/csv-file-opening.spec.ts b/e2e/csv-file-opening.spec.ts new file mode 100644 index 00000000..65ae32bc --- /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[data-document]").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..e42b9a7c 100644 --- a/e2e/electron-test-utils.ts +++ b/e2e/electron-test-utils.ts @@ -12,6 +12,29 @@ const rendererUrl = `http://127.0.0.1:${rendererPort}`; const electronCloseTimeoutMs = 5_000; export const devElectronHeadless = process.env.FLASHTYPE_HEADLESS ?? "1"; +export async function clickAndWaitForAppClose( + app: ElectronApplication, + button: Locator, +): Promise { + // On Linux, intentional app exit can close the page before Playwright + // acknowledges the click. Still require app closure and all recovery checks. + const closed = app.waitForEvent("close"); + await Promise.all([ + closed, + button.click().catch((error: unknown) => { + if ( + !button.page().isClosed() || + !(error instanceof Error) || + !error.message.includes( + "Target page, context or browser has been closed", + ) + ) { + throw error; + } + }), + ]); +} + export async function launchDevElectronApp( workspaceDir: string, options: LaunchDevElectronAppOptions = {}, @@ -91,7 +114,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 +123,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( @@ -166,20 +196,13 @@ export async function writeStarterFiles(workspaceDir: string): Promise { ); } -export async function expectInstalledPluginArchives( +export async function expectInstalledMarkdownPlugin( workspaceDir: string, ): Promise { await expect .poll(() => readBinaryFile( - path.join(workspaceDir, ".lix", "plugins", "plugin_md_v2.lixplugin"), - ), - ) - .toBeGreaterThan(0); - await expect - .poll(() => - readBinaryFile( - path.join(workspaceDir, ".lix", "plugins", "plugin_csv.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..b03f0715 100644 --- a/e2e/files-click-tour.spec.ts +++ b/e2e/files-click-tour.spec.ts @@ -32,7 +32,7 @@ test("left files panel survives a seeded random file click tour", async ({ await ensureFilesViewOpenInLeftPanel(page); await expect( page.locator( - '[data-panel-side="central"][data-active="true"][data-view-key="atelier_file"]', + '[data-area-side="main"][data-active="true"][data-view-key="atelier_file"]', ), ).toBeVisible(); @@ -86,7 +86,7 @@ async function expectActiveFileView( const viewKey = treePath.endsWith(".csv") ? "atelier_csv" : "atelier_file"; await expect( page.locator( - `[data-panel-side="central"][data-active="true"][data-view-key="${viewKey}"]`, + `[data-area-side="main"][data-active="true"][data-view-key="${viewKey}"]`, ), ).toBeVisible(); await expect( @@ -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("main-panel-empty-state")).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/markdown-table-reorder.spec.ts b/e2e/markdown-table-reorder.spec.ts new file mode 100644 index 00000000..c167f182 --- /dev/null +++ b/e2e/markdown-table-reorder.spec.ts @@ -0,0 +1,110 @@ +import { expect, test } from "@playwright/test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + closeElectronApp, + ensureFilesViewOpenInLeftPanel, + fileTreeFile, + launchDevElectronApp, +} from "./electron-test-utils"; + +const initialMarkdown = + "| Name | Qty | Note |\n| --- | ---: | --- |\n| apple | 3 | ripe |\n| pear | 5 | green |\n"; + +test("column grip reorder is undone by one Mod-z", async () => { + const fixture = await openTableFixture(); + try { + const { page, editor, filePath } = fixture; + const headerCells = editor.locator("table tbody tr:first-child th"); + await expect(headerCells).toHaveCount(3); + await headerCells.nth(0).click(); + await expect(editor).toBeFocused(); + await dragGripToCell(page, headerCells.nth(0), headerCells.nth(2), "column"); + await expect(headerCells).toHaveText(["Qty", "Note", "Name"]); + await expect(editor).toBeFocused(); + await expect.poll(() => readFile(filePath, "utf8")).toContain("| Qty | Note | Name |"); + + await page.keyboard.press(`${undoModifier()}+z`); + await expect(headerCells).toHaveText(["Name", "Qty", "Note"]); + await expect.poll(() => readFile(filePath, "utf8")).toContain("| Name | Qty | Note |"); + } finally { + await fixture.dispose(); + } +}); + +test("row grip reorder is undone by one Mod-z", async () => { + const fixture = await openTableFixture(); + try { + const { page, editor, filePath } = fixture; + const rows = editor.locator("table tbody tr:not(:first-child)"); + await expect(rows).toHaveCount(2); + await rows.nth(0).locator("td").first().click(); + await expect(editor).toBeFocused(); + await dragGripToCell( + page, + rows.nth(0).locator("td").first(), + rows.nth(1).locator("td").first(), + "row", + ); + await expect(rows.nth(0).locator("td")).toHaveText(["pear", "5", "green"]); + await expect(editor).toBeFocused(); + await expect.poll(() => readFile(filePath, "utf8")).toContain("| pear | 5 | green |"); + + await page.keyboard.press(`${undoModifier()}+z`); + await expect(rows.nth(0).locator("td")).toHaveText(["apple", "3", "ripe"]); + await expect.poll(() => readFile(filePath, "utf8")).toContain("| apple | 3 | ripe |"); + } finally { + await fixture.dispose(); + } +}); + +async function openTableFixture() { + const workspaceDir = await mkdtemp(path.join(tmpdir(), "flashtype-table-reorder-")); + const userDataDir = await mkdtemp(path.join(tmpdir(), "flashtype-table-profile-")); + const filePath = path.join(workspaceDir, "table.md"); + await writeFile(filePath, initialMarkdown, "utf8"); + const app = await launchDevElectronApp(workspaceDir, { userDataDir }); + const page = await app.firstWindow(); + await ensureFilesViewOpenInLeftPanel(page); + const file = fileTreeFile(page, "/table.md"); + await expect(file).toBeVisible(); + await file.click(); + await expect(file).toHaveAttribute("data-item-selected", "true"); + const editor = page.locator('[data-testid="tiptap-editor"] .ProseMirror'); + await expect(editor).toBeVisible(); + return { + page, + editor, + filePath, + async dispose() { + await closeElectronApp(app); + await rm(workspaceDir, { recursive: true, force: true }); + await rm(userDataDir, { recursive: true, force: true }); + }, + }; +} + +async function dragGripToCell( + page: import("@playwright/test").Page, + sourceCell: import("@playwright/test").Locator, + targetCell: import("@playwright/test").Locator, + axis: "row" | "column", +): Promise { + await sourceCell.hover(); + const grip = page.locator(`.markdown-table-grip[data-axis="${axis}"]`); + await expect(grip).toBeVisible(); + const source = await grip.boundingBox(); + const target = await targetCell.boundingBox(); + if (!source || !target) throw new Error(`Could not measure the ${axis} drag targets.`); + const targetX = target.x + target.width * (axis === "column" ? 0.8 : 0.5); + const targetY = target.y + target.height * (axis === "row" ? 0.8 : 0.5); + await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); + await page.mouse.down(); + await page.mouse.move(targetX, targetY, { steps: 8 }); + await page.mouse.up(); +} + +function undoModifier(): "Meta" | "Control" { + return process.platform === "darwin" ? "Meta" : "Control"; +} diff --git a/e2e/packaged-macos.spec.ts b/e2e/packaged-macos.spec.ts index 22f0b4a9..d8e3235a 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 ({ @@ -120,7 +122,7 @@ test("packaged app launches, seeds, and opens files without Vite", async ({ await ensureFilesViewOpenInLeftPanel(page); await expect( page.locator( - '[data-panel-side="central"][data-active="true"][data-view-key="atelier_file"]', + '[data-area-side="main"][data-active="true"][data-view-key="atelier_file"]', ), ).toBeVisible(); diff --git a/e2e/repository-limits.spec.ts b/e2e/repository-limits.spec.ts new file mode 100644 index 00000000..bf35031c --- /dev/null +++ b/e2e/repository-limits.spec.ts @@ -0,0 +1,89 @@ +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 }); + await page + .locator('[data-area-side="right"] [data-attr="panel-section-picker"]') + .click(); + await page.getByRole("menuitem", { name: "History", exact: true }).click(); + + 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..309cda77 --- /dev/null +++ b/e2e/share.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + closeElectronApp, + ensureFilesViewOpenInLeftPanel, + fileTreeFile, + launchDevElectronApp, +} from "./electron-test-utils"; + +test("Share shows Lixray teaser and sync request email", 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 launchDevElectronApp(workspace, { + 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 ensureFilesViewOpenInLeftPanel(page); + await fileTreeFile(page, "/share-me.md").click(); + await page.emulateMedia({ colorScheme: "dark" }); + const treeTheme = await page + .locator("[data-testid='files-view-tree-scroll'] file-tree-container") + .first() + .evaluate((element) => ({ + scheme: getComputedStyle(element).colorScheme, + })); + expect(treeTheme.scheme).toBe("light"); + await share.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole("link", { name: "Open lixray.com" }), + ).toHaveAttribute("href", "https://lixray.com"); + await expect( + dialog.getByRole("link", { name: "Write an email to samuel@opral.com" }), + ).toHaveAttribute("href", /mailto:samuel@opral.com/); + await expect(dialog.getByRole("textbox")).toHaveCount(0); + await dialog.screenshot({ path: testInfo.outputPath("share-teaser.png") }); + 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..58780628 --- /dev/null +++ b/e2e/status-review.spec.ts @@ -0,0 +1,98 @@ +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 + .locator('[data-area-side="right"] [data-attr="panel-section-picker"]') + .click(); + await page.getByRole("menuitem", { name: "History", exact: true }).click(); + await expect( + page + .getByRole("button", { + name: /Showing (this file|the repository)/, + }) + .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..0d1b7fc9 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\ncase \" $* \" in\n\t*\" --version \"*) printf '%s\\n' 'codex-cli 0.134.0'; exit 0 ;;\nesac\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", }, @@ -73,8 +82,34 @@ test("stress tests workspace changes through manual edits and fake agent turns", registerRendererConsoleLogging(page); await timeProfile(profile, "setup:open-file", null, async () => { + // This test measures durable history size after repeated changes. + // New folders start with in-memory history until initialized. + await expect( + page.getByRole("button", { + name: /Open checkpoint history|Review working changes/, + }), + ).toBeVisible({ timeout: 30_000 }); + await Promise.all([ + page.waitForEvent("load"), + page.evaluate(() => + window.flashtypeDesktop!.workspace.initializeRepository(), + ), + ]); + expect( + await page.evaluate(() => window.flashtypeDesktop!.lix.storageDir()), + ).toBeTruthy(); 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 + .locator('[data-area-side="right"] [data-attr="panel-section-picker"]') + .click(); + await page.getByRole("menuitem", { name: "Codex", exact: true }).click(); + await expect( + page.locator('[data-active="true"][data-view-key="flashtype_codex"]'), + ).toBeVisible(); + await expect(page.getByRole("alert")).toHaveCount(0); }); await timeProfile(profile, "setup:initial-settle", null, async () => { await expectMarkdownSettled({ @@ -152,6 +187,9 @@ test("stress tests workspace changes through manual edits and fake agent turns", await expect(reviewUndoButton(page)).toBeHidden({ timeout: 30_000, }); + await expect( + page.locator('[data-review-mode="true"]'), + ).toHaveCount(0); }, ); @@ -587,56 +625,25 @@ 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); - } - } + 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: { @@ -650,12 +657,30 @@ async function buildAgentReviewTimeoutMessage(args: { const state = await readMarkdownState(args.page, args.diskPath).catch( (error: unknown) => ({ stateReadError: String(error) }), ); + const uiSnapshot = await args.page.locator("body").ariaSnapshot().catch( + (error: unknown) => `Could not capture UI snapshot: ${String(error)}`, + ); + const syncDiagnostic = await args.page + .evaluate(() => + Promise.race([ + window.flashtypeDesktop!.lix.syncDiskToLix().then( + () => "completed", + (error: unknown) => String(error), + ), + new Promise((resolve) => + setTimeout(() => resolve("still pending after 5 seconds"), 5_000), + ), + ]), + ) + .catch((error: unknown) => String(error)); return [ "Timed out waiting for fake agent review controls.", `operationIndex=${args.index}`, `beforeAgentMarkdown=${JSON.stringify(args.beforeAgentMarkdown)}`, `proposedMarkdown=${JSON.stringify(args.proposedMarkdown)}`, `state=${JSON.stringify(state)}`, + `uiSnapshot=\n${uiSnapshot}`, + `syncDiagnostic=${syncDiagnostic}`, `cause=${ args.error instanceof Error ? args.error.message : String(args.error) }`, @@ -668,15 +693,43 @@ async function expectMarkdownSettled(args: { page: Page; timeout?: number; }): Promise { - await expect - .poll(async () => await readMarkdownState(args.page, args.diskPath), { - timeout: args.timeout ?? 30_000, - }) - .toEqual({ - diskMarkdown: args.expectedMarkdown, - editorMarkdown: args.expectedMarkdown, - lixMarkdown: args.expectedMarkdown, - }); + try { + await expect + .poll(async () => await readMarkdownState(args.page, args.diskPath), { + timeout: args.timeout ?? 30_000, + }) + .toEqual({ + diskMarkdown: args.expectedMarkdown, + editorMarkdown: args.expectedMarkdown, + lixMarkdown: args.expectedMarkdown, + }); + } catch (error) { + const editorState = await args.page + .locator('[data-testid="tiptap-editor"] .ProseMirror') + .evaluate((editor) => ({ + activeElement: document.activeElement?.outerHTML.slice(0, 500), + contentEditable: (editor as HTMLElement).contentEditable, + dataReviewMode: editor.closest('[data-review-mode]')?.getAttribute('data-review-mode'), + isContentEditable: (editor as HTMLElement).isContentEditable, + parentReviewMode: + editor.parentElement?.closest('[data-review-mode]')?.getAttribute('data-review-mode'), + readOnly: (editor as HTMLElement).getAttribute('data-readonly'), + } )) + .catch((stateError: unknown) => ({ error: String(stateError) })); + const uiSnapshot = await args.page + .locator('body') + .ariaSnapshot() + .catch((stateError: unknown) => `Could not capture UI snapshot: ${String(stateError)}`); + throw new Error( + [ + `Markdown did not settle to ${JSON.stringify(args.expectedMarkdown)}.`, + `editorState=${JSON.stringify(editorState)}`, + `uiSnapshot=\n${uiSnapshot}`, + `cause=${error instanceof Error ? error.message : String(error)}`, + ].join('\n'), + { cause: error }, + ); + } } async function readMarkdownState( @@ -746,7 +799,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-folder-create.spec.ts b/e2e/workspace-folder-create.spec.ts new file mode 100644 index 00000000..3660ad0f --- /dev/null +++ b/e2e/workspace-folder-create.spec.ts @@ -0,0 +1,118 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { ElectronApplication } from "playwright"; +import { mkdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { + closeElectronApp, + ensureFilesViewOpenInLeftPanel, + fileTreeDirectory, + launchDevElectronApp, + registerRendererConsoleLogging, +} from "./electron-test-utils"; + +test("New folder is persisted in Lix and materialized in the workspace", async ({ + browserName: _browserName, +}, testInfo) => { + const workspaceDir = testInfo.outputPath("workspace"); + await mkdir(workspaceDir, { recursive: true }); + + let electronApp: ElectronApplication | undefined; + try { + electronApp = await launchDevElectronApp(workspaceDir); + const page = await electronApp.firstWindow(); + registerRendererConsoleLogging(page); + await ensureFilesViewOpenInLeftPanel(page); + await page.getByRole("button", { name: "New", exact: true }).click(); + await page.getByRole("menuitem", { name: "New folder" }).click(); + const input = page.locator("[data-item-rename-input]"); + await expect(input).toBeVisible(); + await expect(input).toHaveValue("new-folder"); + await input.press("Enter"); + + const folder = fileTreeDirectory(page, "/new-folder"); + await expect(folder).toBeVisible(); + await expect + .poll(async () => + page.evaluate(async () => { + const result = await window.flashtypeDesktop!.lix.execute({ + sql: "SELECT path FROM lix_directory WHERE path = $1", + params: ["/new-folder"], + }); + return result.rows; + }), + ) + .toEqual([["/new-folder"]]); + await expect + .poll(async () => + stat(path.join(workspaceDir, "new-folder")) + .then((metadata) => metadata.isDirectory()) + .catch(() => false), + ) + .toBe(true); + + // The new folder becomes the current selection, so another create goes + // inside it. Accept that name unchanged to check the selected-folder path. + await page.getByRole("button", { name: "New", exact: true }).click(); + await page.getByRole("menuitem", { name: "New folder" }).click(); + const nestedInput = page.locator("[data-item-rename-input]"); + await expect(nestedInput).toBeVisible(); + await expect(nestedInput).toHaveValue("new-folder"); + await nestedInput.press("Enter"); + await expect( + fileTreeDirectory(page, "/new-folder/new-folder"), + ).toBeVisible(); + await expect + .poll(async () => + page.evaluate(async () => { + const result = await window.flashtypeDesktop!.lix.execute({ + sql: "SELECT path FROM lix_directory WHERE path = $1", + params: ["/new-folder/new-folder"], + }); + return result.rows; + }), + ) + .toEqual([["/new-folder/new-folder"]]); + await expect + .poll(async () => + stat(path.join(workspaceDir, "new-folder", "new-folder")) + .then((metadata) => metadata.isDirectory()) + .catch(() => false), + ) + .toBe(true); + + // Clear the selected destination to return to root. Because `/new-folder` + // already exists there, the root-level default must be suffixed. + await page + .locator("aside") + .first() + .locator('file-tree-container[aria-label="Files"]') + .click(); + await page.getByRole("button", { name: "New", exact: true }).click(); + await page.getByRole("menuitem", { name: "New folder" }).click(); + const rootInput = page.locator("[data-item-rename-input]"); + await expect(rootInput).toBeVisible(); + await expect(rootInput).toHaveValue("new-folder-2"); + await rootInput.press("Enter"); + await expect(fileTreeDirectory(page, "/new-folder-2")).toBeVisible(); + await expect + .poll(async () => + page.evaluate(async () => { + const result = await window.flashtypeDesktop!.lix.execute({ + sql: "SELECT path FROM lix_directory WHERE path = $1", + params: ["/new-folder-2"], + }); + return result.rows; + }), + ) + .toEqual([["/new-folder-2"]]); + await expect + .poll(async () => + stat(path.join(workspaceDir, "new-folder-2")) + .then((metadata) => metadata.isDirectory()) + .catch(() => false), + ) + .toBe(true); + } finally { + await closeElectronApp(electronApp); + } +}); diff --git a/e2e/workspace-open-recovery.spec.ts b/e2e/workspace-open-recovery.spec.ts new file mode 100644 index 00000000..d226bc70 --- /dev/null +++ b/e2e/workspace-open-recovery.spec.ts @@ -0,0 +1,160 @@ +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 { + clickAndWaitForAppClose, + 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); + await clickAndWaitForAppClose(app, button); + 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); + await clickAndWaitForAppClose( + app, + page.getByRole("button", { name: "Delete .lix and restart" }), + ); + 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..6e4dd574 100644 --- a/e2e/workspace-switch.spec.ts +++ b/e2e/workspace-switch.spec.ts @@ -40,7 +40,10 @@ 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("main-panel-empty-state")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Start writing", 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..bcd1acab 100644 --- a/e2e/workspace-windows.spec.ts +++ b/e2e/workspace-windows.spec.ts @@ -1,11 +1,13 @@ 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 { + clickAndWaitForAppClose, closeElectronApp, - expectInstalledPluginArchives, + expectInstalledMarkdownPlugin, fileTreeDirectory, fileTreeFile, launchDevElectronAppWithArgs, @@ -365,7 +367,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", @@ -382,7 +384,7 @@ test("Track Changes menu toggles workspace .lix storage", async ({ await expectTrackChangesSettled(electronApp, workspaceDir, true); await expectLixFilePath(page, "/marker.md"); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); - await expectInstalledPluginArchives(workspaceDir); + await expectInstalledMarkdownPlugin(workspaceDir); await clickTrackChangesMenuItemAndWaitForReload(electronApp, page); await expect(page).toHaveTitle(path.basename(workspaceDir)); @@ -394,7 +396,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 +433,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 +447,17 @@ test("Track Changes recovery screen can disable tracking", async ({ }), ).toBeVisible(); await expect( - page.getByText("Your project files will not be deleted."), + page.getByText("Your files and saved history have not been deleted."), ).toBeVisible(); - await Promise.all([ - page.waitForNavigation({ waitUntil: "domcontentloaded" }), - page.getByRole("button", { name: "Disable Track Changes" }).click(), - ]); + await clickAndWaitForAppClose( + electronApp, + page.getByRole("button", { name: "Delete .lix and restart" }), + ); + 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 +573,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 +584,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 +598,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"), @@ -755,7 +762,12 @@ test("mixed folder and standalone markdown args create folder and grouped file w await expect( fileTreeFile(groupedFilePage, "/folder-marker.md"), ).toHaveCount(0); - await fileTreeDirectory(groupedFilePage, "/standalone-mixed-two/").click(); + const secondDirectory = fileTreeDirectory( + groupedFilePage, + "/standalone-mixed-two/", + ); + await secondDirectory.click(); + await secondDirectory.press("ArrowRight"); const secondItem = fileTreeFile( groupedFilePage, "/standalone-mixed-two/two.md", @@ -790,8 +802,21 @@ test("relaunch restores grouped transient file workspace", async ({ electronApp = await launchDevElectronAppWithArgs([firstPath, secondPath], { userDataDir, }); - await pageWithTitle(electronApp, path.basename(groupedWorkspaceDir)); + const initialPage = await pageWithTitle( + electronApp, + path.basename(groupedWorkspaceDir), + ); await expectWindowCount(electronApp, 1); + // The native title appears before document startup and session persistence. + // Relaunch should restore the selected document after that startup completes. + await expect( + initialPage.getByRole("heading", { name: "First" }), + ).toBeVisible(); + await expectWorkspaceSessionOpenFilePaths( + userDataDir, + groupedWorkspaceDir, + ["restore-markdown-first/first.md"], + ); await closeElectronApp(electronApp); electronApp = undefined; @@ -833,7 +858,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/assets/plugin_markdown.lixplugin b/electron/assets/plugin_markdown.lixplugin new file mode 100644 index 00000000..60cc4b9b Binary files /dev/null and b/electron/assets/plugin_markdown.lixplugin differ diff --git a/electron/document-default-handlers.mjs b/electron/document-default-handlers.mjs new file mode 100644 index 00000000..e3667e39 --- /dev/null +++ b/electron/document-default-handlers.mjs @@ -0,0 +1,237 @@ +import path from "node:path"; + +export const APP_BUNDLE_ID = "com.flashtype.app"; +export const APPLE_NUMBERS_BUNDLE_ID = "com.apple.iWork.Numbers"; +export const APP_NAME = "Flashtype"; +export const DOCUMENT_CONTENT_TYPES = [ + "public.markdown", + "net.daringfireball.markdown", + "public.comma-separated-values-text", +]; + +export const LSREGISTER_PATH = + "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; + +export function getAppBundlePathFromExecutablePath(executablePath) { + const marker = ".app/Contents/MacOS/"; + const markerIndex = executablePath.indexOf(marker); + if (markerIndex === -1) { + return null; + } + return executablePath.slice(0, markerIndex + ".app".length); +} + +export function isCanonicalInstalledAppBundle(appBundlePath) { + if (!appBundlePath) { + return false; + } + return ( + path.resolve(appBundlePath) === path.resolve("/Applications/Flashtype.app") + ); +} + +// Preserve an explicitly selected app. CSV's uncustomized Numbers fallback is +// handled separately after checking the user's LaunchServices preferences. +export function shouldReplaceDocumentDefaultHandler(handlerBundleId) { + return ( + handlerBundleId == null || handlerBundleId.toLowerCase() === APP_BUNDLE_ID + ); +} + +export function getDocumentContentTypesToRegister( + currentHandlers, + explicitUserDefaults = [], +) { + return DOCUMENT_CONTENT_TYPES.filter((contentType) => { + if (!Object.hasOwn(currentHandlers, contentType)) return false; + const handler = currentHandlers[contentType]; + if (shouldReplaceDocumentDefaultHandler(handler)) return true; + return ( + contentType === "public.comma-separated-values-text" && + handler?.toLowerCase() === APPLE_NUMBERS_BUNDLE_ID.toLowerCase() && + !explicitUserDefaults.includes(contentType) + ); + }); +} + +export function getNonCanonicalFlashtypeBundlePathsFromLsregisterDump(dump) { + const blocks = dump.split(/\n-{20,}\n/u); + return blocks.flatMap((block) => { + const identifierMatch = block.match(/^identifier:\s+(.+)$/mu); + if (!identifierMatch || identifierMatch[1] !== APP_BUNDLE_ID) { + return []; + } + + const pathMatch = block.match( + /^path:\s+(.+?)(?:\s+\(0x[0-9a-fA-F]+\))?$/mu, + ); + if (!pathMatch) { + return []; + } + + const appBundlePath = pathMatch[1]; + return isCanonicalInstalledAppBundle(appBundlePath) ? [] : [appBundlePath]; + }); +} + +function buildDocumentHandlerQueryScript() { + return ` +ObjC.import("CoreServices"); +ObjC.import("Foundation"); +const contentTypes = ${JSON.stringify(DOCUMENT_CONTENT_TYPES)}; +const csvContentType = "public.comma-separated-values-text"; +const csvExtension = "csv"; +const preferenceDomain = "com.apple.LaunchServices/com.apple.launchservices.secure"; +const preferences = ObjC.deepUnwrap( + $.NSUserDefaults.standardUserDefaults.persistentDomainForName( + $.NSString.stringWithString(preferenceDomain) + ) +); +const savedHandlers = preferences && preferences.LSHandlers || []; +function hasExplicitUserDefault(contentType) { + return savedHandlers.some(function(saved) { + const matchesType = saved.LSHandlerContentType === contentType; + const matchesCsvExtension = contentType === csvContentType && + saved.LSHandlerContentTagClass === "public.filename-extension" && + String(saved.LSHandlerContentTag || "").toLowerCase() === csvExtension; + return (matchesType || matchesCsvExtension) && + (saved.LSHandlerRoleEditor || saved.LSHandlerRoleAll); + }); +} +const handlers = {}; +for (const contentType of contentTypes) { + const handler = $.LSCopyDefaultRoleHandlerForContentType( + $(contentType), + $.kLSRolesEditor + ); + handlers[contentType] = handler ? ObjC.unwrap(ObjC.castRefToObject(handler)) : null; +} +const explicitUserDefaults = contentTypes.filter(hasExplicitUserDefault); +console.log(JSON.stringify({ handlers: handlers, explicitUserDefaults: explicitUserDefaults })); +`; +} + +function buildDocumentHandlerRegistrationScript(contentTypes) { + return ` +ObjC.import("CoreServices"); +ObjC.import("Foundation"); +const bundleId = ${JSON.stringify(APP_BUNDLE_ID)}; +const numbersBundleId = ${JSON.stringify(APPLE_NUMBERS_BUNDLE_ID)}; +const csvContentType = "public.comma-separated-values-text"; +const csvExtension = "csv"; +const preferenceDomain = "com.apple.LaunchServices/com.apple.launchservices.secure"; +const preferences = ObjC.deepUnwrap( + $.NSUserDefaults.standardUserDefaults.persistentDomainForName( + $.NSString.stringWithString(preferenceDomain) + ) +); +const savedHandlers = preferences && preferences.LSHandlers || []; +function hasExplicitCsvDefault() { + return savedHandlers.some(function(saved) { + const matchesType = saved.LSHandlerContentType === csvContentType; + const matchesExtension = saved.LSHandlerContentTagClass === "public.filename-extension" && + String(saved.LSHandlerContentTag || "").toLowerCase() === csvExtension; + return (matchesType || matchesExtension) && + (saved.LSHandlerRoleEditor || saved.LSHandlerRoleAll); +}); +} +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) { + const currentBundleId = ObjC.unwrap(ObjC.castRefToObject(current)).toLowerCase(); + const isNumbersSystemFallback = contentType === csvContentType && + currentBundleId === numbersBundleId.toLowerCase() && !hasExplicitCsvDefault(); + if (currentBundleId !== bundleId && !isNumbersSystemFallback) continue; + } + const status = $.LSSetDefaultRoleHandlerForContentType( + $(contentType), + $.kLSRolesEditor, + $(bundleId) + ); + if (status !== 0) { + throw new Error("LSSetDefaultRoleHandlerForContentType failed for " + contentType + ": " + status); + } +} +`; +} + +async function getCurrentDocumentDefaults(execFileAsync) { + const { stdout } = await execFileAsync( + "/usr/bin/osascript", + ["-l", "JavaScript", "-e", buildDocumentHandlerQueryScript()], + { timeout: 5000 }, + ); + return JSON.parse(stdout); +} + +async function unregisterNonCanonicalFlashtypeBundles(execFileAsync) { + const { stdout } = await execFileAsync(LSREGISTER_PATH, ["-dump"], { + timeout: 5000, + maxBuffer: 20 * 1024 * 1024, + }); + const appBundlePaths = + getNonCanonicalFlashtypeBundlePathsFromLsregisterDump(stdout); + + await Promise.all( + appBundlePaths.map(async (appBundlePath) => { + try { + await execFileAsync(LSREGISTER_PATH, ["-u", appBundlePath], { + timeout: 5000, + }); + } catch { + // Stale LaunchServices entries can point at unmounted DMGs or deleted + // builds. Keep going so the canonical app can still be refreshed. + } + }), + ); + + return appBundlePaths; +} + +export async function registerDocumentDefaultHandlers({ + execFileAsync, + executablePath, + isPackaged, + platform, +}) { + if (platform !== "darwin" || !isPackaged) { + return { status: "skipped", reason: "unsupported-runtime" }; + } + + const appBundlePath = getAppBundlePathFromExecutablePath(executablePath); + if (!isCanonicalInstalledAppBundle(appBundlePath)) { + return { status: "skipped", reason: "non-canonical-app-bundle" }; + } + + const unregisteredAppBundlePaths = + await unregisterNonCanonicalFlashtypeBundles(execFileAsync); + + await execFileAsync(LSREGISTER_PATH, ["-f", appBundlePath], { + timeout: 5000, + }); + + const { handlers, explicitUserDefaults } = + await getCurrentDocumentDefaults(execFileAsync); + const contentTypes = getDocumentContentTypesToRegister( + handlers, + explicitUserDefaults, + ); + if (contentTypes.length === 0) { + return { status: "skipped", reason: "user-handler-preserved" }; + } + + await execFileAsync( + "/usr/bin/osascript", + [ + "-l", + "JavaScript", + "-e", + buildDocumentHandlerRegistrationScript(contentTypes), + ], + { timeout: 5000 }, + ); + + return { status: "registered", contentTypes, unregisteredAppBundlePaths }; +} diff --git a/electron/document-default-handlers.test.ts b/electron/document-default-handlers.test.ts new file mode 100644 index 00000000..36b6a297 --- /dev/null +++ b/electron/document-default-handlers.test.ts @@ -0,0 +1,462 @@ +import { describe, expect, test, vi } from "vitest"; +import { runInNewContext } from "node:vm"; +import { + APP_BUNDLE_ID, + getAppBundlePathFromExecutablePath, + getDocumentContentTypesToRegister, + getNonCanonicalFlashtypeBundlePathsFromLsregisterDump, + isCanonicalInstalledAppBundle, + LSREGISTER_PATH, + registerDocumentDefaultHandlers, + shouldReplaceDocumentDefaultHandler, +} from "./document-default-handlers.mjs"; + +describe("Document default handler registration policy", () => { + test("derives the .app bundle path from a packaged executable path", () => { + expect( + getAppBundlePathFromExecutablePath( + "/Applications/Flashtype.app/Contents/MacOS/Flashtype", + ), + ).toBe("/Applications/Flashtype.app"); + }); + + test("only treats the /Applications app bundle as canonical", () => { + expect(isCanonicalInstalledAppBundle("/Applications/Flashtype.app")).toBe( + true, + ); + expect( + isCanonicalInstalledAppBundle( + "/Volumes/Flashtype 0.1.1-arm64/Flashtype.app", + ), + ).toBe(false); + expect( + isCanonicalInstalledAppBundle( + "/tmp/flashtype/release/mac-arm64/Flashtype.app", + ), + ).toBe(false); + }); + + test("claims only unassigned types or Flashtype itself", () => { + expect(shouldReplaceDocumentDefaultHandler(null)).toBe(true); + expect(shouldReplaceDocumentDefaultHandler("com.apple.dt.Xcode")).toBe( + false, + ); + expect(shouldReplaceDocumentDefaultHandler("com.apple.TextEdit")).toBe( + false, + ); + expect(shouldReplaceDocumentDefaultHandler(APP_BUNDLE_ID)).toBe(true); + }); + + test("preserves third-party Markdown defaults", () => { + expect(shouldReplaceDocumentDefaultHandler("com.microsoft.VSCode")).toBe( + false, + ); + expect(shouldReplaceDocumentDefaultHandler("md.obsidian")).toBe(false); + }); + + test("chooses only replaceable Markdown content types", () => { + expect( + getDocumentContentTypesToRegister({ + "public.markdown": null, + "net.daringfireball.markdown": "com.microsoft.VSCode", + }), + ).toEqual(["public.markdown"]); + }); + + test("finds registered noncanonical Flashtype app bundles", () => { + const dump = ` +-------------------------------------------------------------------------------- +bundle id: Flashtype (0x40c8) +path: /Applications/Flashtype.app (0x5bcc) +identifier: com.flashtype.app +versionString: 0.2.0 + +-------------------------------------------------------------------------------- +bundle id: Flashtype (0x3f44) +path: /Volumes/Flashtype 0.1.1-arm64/Flashtype.app (0x59DC) +identifier: com.flashtype.app +versionString: 0.1.1 + +-------------------------------------------------------------------------------- +bundle id: Flashtype Helper (0x5bdc) +path: /Applications/Flashtype.app/Contents/Frameworks/Flashtype Helper.app (0x5bdc) +identifier: com.flashtype.app.helper +versionString: 0.2.0 + +-------------------------------------------------------------------------------- +bundle id: Other (0x1234) +path: /Applications/Other.app (0x1234) +identifier: com.example.other +versionString: 1.0.0 +`; + + expect(getNonCanonicalFlashtypeBundlePathsFromLsregisterDump(dump)).toEqual( + ["/Volumes/Flashtype 0.1.1-arm64/Flashtype.app"], + ); + }); + + test("skips packaged apps outside /Applications", async () => { + const execFileAsync = vi.fn(); + + const result = await registerDocumentDefaultHandlers({ + execFileAsync, + executablePath: + "/Volumes/Flashtype 0.1.1-arm64/Flashtype.app/Contents/MacOS/Flashtype", + isPackaged: true, + platform: "darwin", + }); + + expect(result).toEqual({ + status: "skipped", + reason: "non-canonical-app-bundle", + }); + expect(execFileAsync).not.toHaveBeenCalled(); + }); + + test("registers only from the canonical installed app", async () => { + const execFileAsync = vi + .fn() + .mockResolvedValueOnce({ + stdout: ` +-------------------------------------------------------------------------------- +path: /Applications/Flashtype.app (0x5bcc) +identifier: com.flashtype.app + +-------------------------------------------------------------------------------- +path: /Volumes/Flashtype 0.1.1-arm64/Flashtype.app (0x59dc) +identifier: com.flashtype.app +`, + }) + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + handlers: { + "public.markdown": null, + "net.daringfireball.markdown": null, + }, + explicitUserDefaults: [], + }), + }) + .mockResolvedValueOnce({ stdout: "" }); + + const result = await registerDocumentDefaultHandlers({ + execFileAsync, + executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", + isPackaged: true, + platform: "darwin", + }); + + expect(result).toEqual({ + status: "registered", + contentTypes: ["public.markdown", "net.daringfireball.markdown"], + unregisteredAppBundlePaths: [ + "/Volumes/Flashtype 0.1.1-arm64/Flashtype.app", + ], + }); + expect(execFileAsync).toHaveBeenNthCalledWith( + 1, + LSREGISTER_PATH, + ["-dump"], + { timeout: 5000, maxBuffer: 20 * 1024 * 1024 }, + ); + expect(execFileAsync).toHaveBeenNthCalledWith( + 2, + LSREGISTER_PATH, + ["-u", "/Volumes/Flashtype 0.1.1-arm64/Flashtype.app"], + { timeout: 5000 }, + ); + expect(execFileAsync).toHaveBeenNthCalledWith( + 3, + LSREGISTER_PATH, + ["-f", "/Applications/Flashtype.app"], + { timeout: 5000 }, + ); + expect(execFileAsync).toHaveBeenNthCalledWith( + 4, + "/usr/bin/osascript", + expect.arrayContaining(["JavaScript"]), + { timeout: 5000 }, + ); + expect(execFileAsync).toHaveBeenNthCalledWith( + 5, + "/usr/bin/osascript", + expect.arrayContaining(["JavaScript"]), + { timeout: 5000 }, + ); + + const queryScript = execFileAsync.mock.calls[3][1][3]; + expect(queryScript).toContain("ObjC.castRefToObject(handler)"); + + const registrationScript = execFileAsync.mock.calls[4][1][3]; + expect(registrationScript).toContain('"public.markdown"'); + expect(registrationScript).toContain('"net.daringfireball.markdown"'); + expect(registrationScript).toContain(APP_BUNDLE_ID); + }); + + test("registers only filtered content types through the full workflow", async () => { + const execFileAsync = vi + .fn() + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + handlers: { + "public.markdown": null, + "net.daringfireball.markdown": "com.microsoft.VSCode", + }, + explicitUserDefaults: [], + }), + }) + .mockResolvedValueOnce({ stdout: "" }); + + const result = await registerDocumentDefaultHandlers({ + execFileAsync, + executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", + isPackaged: true, + platform: "darwin", + }); + + expect(result).toEqual({ + status: "registered", + contentTypes: ["public.markdown"], + unregisteredAppBundlePaths: [], + }); + + const registrationScript = execFileAsync.mock.calls[3][1][3]; + expect(registrationScript).toContain('"public.markdown"'); + expect(registrationScript).not.toContain('"net.daringfireball.markdown"'); + }); + + test("preserves user-selected third-party handlers", async () => { + const execFileAsync = vi + .fn() + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + handlers: { + "public.markdown": "com.microsoft.VSCode", + "net.daringfireball.markdown": "md.obsidian", + }, + explicitUserDefaults: [], + }), + }); + + const result = await registerDocumentDefaultHandlers({ + execFileAsync, + executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", + isPackaged: true, + platform: "darwin", + }); + + expect(result).toEqual({ + status: "skipped", + reason: "user-handler-preserved", + }); + expect(execFileAsync).toHaveBeenCalledTimes(3); + }); +}); + +describe("CSV default handler registration", () => { + const csv = "public.comma-separated-values-text"; + test("claims CSV when unassigned or when Numbers is only the system fallback", () => { + expect(getDocumentContentTypesToRegister({ [csv]: null })).toEqual([csv]); + expect( + getDocumentContentTypesToRegister({ + [csv]: "com.apple.iWork.Numbers", + }), + ).toEqual([csv]); + expect( + getDocumentContentTypesToRegister({ [csv]: "com.apple.iWork.Numbers" }, [ + csv, + ]), + ).toEqual([]); + for (const handler of [ + "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({ + handlers: { + "public.markdown": "com.microsoft.VSCode", + "net.daringfireball.markdown": "com.apple.TextEdit", + [csv]: null, + }, + explicitUserDefaults: [], + }), + }) + .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"'); + + const query = execFileAsync.mock.calls[2][1][3]; + const csvPreference = { + LSHandlerContentTag: "CSV", + LSHandlerContentTagClass: "public.filename-extension", + LSHandlerRoleEditor: "com.apple.iWork.Numbers", + }; + const queryOutput = vi.fn(); + const query$ = Object.assign((value: unknown) => value, { + kLSRolesEditor: 4, + LSCopyDefaultRoleHandlerForContentType: (contentType: string) => + contentType === csv ? "com.apple.iWork.Numbers" : null, + NSString: { stringWithString: (value: string) => value }, + NSUserDefaults: { + standardUserDefaults: { + persistentDomainForName: () => ({ LSHandlers: [csvPreference] }), + }, + }, + }); + runInNewContext(query, { + $: query$, + ObjC: { + import: () => {}, + deepUnwrap: (value: unknown) => value, + unwrap: (value: unknown) => value, + castRefToObject: (value: unknown) => value, + }, + console: { log: queryOutput }, + }); + expect(JSON.parse(queryOutput.mock.calls[0][0])).toMatchObject({ + handlers: { [csv]: "com.apple.iWork.Numbers" }, + explicitUserDefaults: [csv], + }); + + // Execute the generated JXA logic with CoreServices mocked: a late user + // override must survive even after the first query said CSV was unassigned. + const set = vi.fn().mockReturnValue(0); + const $ = Object.assign((value: unknown) => value, { + kLSRolesEditor: 4, + LSCopyDefaultRoleHandlerForContentType: () => "com.microsoft.Excel", + LSSetDefaultRoleHandlerForContentType: set, + NSString: { stringWithString: (value: string) => value }, + NSUserDefaults: { + standardUserDefaults: { + persistentDomainForName: () => ({ LSHandlers: [] }), + }, + }, + }); + runInNewContext(registration, { + $, + ObjC: { + import: () => {}, + deepUnwrap: (value: unknown) => value, + unwrap: (value: unknown) => value, + castRefToObject: (value: unknown) => value, + }, + }); + expect(set).not.toHaveBeenCalled(); + + const setNumbersFallback = vi.fn().mockReturnValue(0); + const numbersFallback = Object.assign((value: unknown) => value, { + kLSRolesEditor: 4, + LSCopyDefaultRoleHandlerForContentType: () => "com.apple.iWork.Numbers", + LSSetDefaultRoleHandlerForContentType: setNumbersFallback, + NSString: { stringWithString: (value: string) => value }, + NSUserDefaults: { + standardUserDefaults: { + persistentDomainForName: () => ({ LSHandlers: [] }), + }, + }, + }); + runInNewContext(registration, { + $: numbersFallback, + ObjC: { + import: () => {}, + deepUnwrap: (value: unknown) => value, + unwrap: (value: unknown) => value, + castRefToObject: (value: unknown) => value, + }, + }); + expect(setNumbersFallback).toHaveBeenCalledWith( + expect.anything(), + 4, + APP_BUNDLE_ID, + ); + }); + test("keeps a user-selected Numbers default during the late registration check", async () => { + const execFileAsync = vi + .fn() + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ stdout: "" }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + handlers: { [csv]: "com.apple.iWork.Numbers" }, + explicitUserDefaults: [], + }), + }) + .mockResolvedValueOnce({ stdout: "" }); + await registerDocumentDefaultHandlers({ + execFileAsync, + executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", + isPackaged: true, + platform: "darwin", + }); + const registration = execFileAsync.mock.calls[3][1][3]; + const set = vi.fn().mockReturnValue(0); + const $ = Object.assign((value: unknown) => value, { + kLSRolesEditor: 4, + LSCopyDefaultRoleHandlerForContentType: () => "com.apple.iWork.Numbers", + LSSetDefaultRoleHandlerForContentType: set, + NSString: { stringWithString: (value: string) => value }, + NSUserDefaults: { + standardUserDefaults: { + persistentDomainForName: () => ({ + LSHandlers: [ + { + LSHandlerContentType: csv, + LSHandlerRoleEditor: "com.apple.iWork.Numbers", + }, + ], + }), + }, + }, + }); + runInNewContext(registration, { + $, + ObjC: { + import: () => {}, + deepUnwrap: (value: unknown) => value, + 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.mjs b/electron/initialize-checkpoint.mjs new file mode 100644 index 00000000..1ae56b01 --- /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(NULL, NULL, 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..79653be1 --- /dev/null +++ b/electron/initialize-checkpoint.test.mjs @@ -0,0 +1,48 @@ +// @vitest-environment node +import { test, expect } from "vitest"; +import { createRequire } from "node:module"; +import { FilesystemStorage } from "@lix-js/storage-filesystem"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { checkpointRepositoryMetadata } from "./initialize-checkpoint.mjs"; +const { openLix } = createRequire(import.meta.url)("@lix-js/sdk"); +const markdownPluginArchive = await readFile( + new URL("./assets/plugin_markdown.lixplugin", import.meta.url), +); + +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); + await lix.execute( + "INSERT INTO lix_file (path, content) VALUES ($1, $2)", + ["/.lix/plugins/plugin_markdown.lixplugin", markdownPluginArchive], + ); + 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 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); + 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..293e3e60 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, @@ -73,6 +74,22 @@ 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, + results: results.results.map((result) => ({ + ...serializeQueryResult(result), + statementIndex: result.statementIndex, + })), + }; + }); ipcMain.handle("lix:executeTransaction", async (event, payload) => { const lix = await ensureLixOpenForEvent(event); const statements = Array.isArray(payload?.statements) @@ -213,8 +230,11 @@ export function registerLixIpc(resolveWindowForEvent, options = {}) { } const started = performance.now(); try { - const event = await observeEvents.next(); - if (!event) { + const iteration = await observeEvents.next(); + if (iteration.done) { + observeHandles.delete(observeId, ownerId); + observeTraceMeta.delete(observeId, ownerId); + await observeEvents.return?.(); logSlowOperation("observe:next", started, { observeId, ...observeTraceMeta.getOptional(observeId, ownerId), @@ -222,6 +242,7 @@ export function registerLixIpc(resolveWindowForEvent, options = {}) { }); return undefined; } + const event = iteration.value; const serializedResult = serializeExecuteResult( event.result, "lix.observe", @@ -258,7 +279,7 @@ export function registerLixIpc(resolveWindowForEvent, options = {}) { return; } observeTraceMeta.delete(observeId, ownerId); - observeEvents.close(); + await observeEvents.return?.(); }); ipcMain.handle("lix:activeBranchId", async (event) => { @@ -337,7 +358,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 = {}) { @@ -356,7 +380,7 @@ async function closeAllHandles(ownerId) { ? observeHandles.values().map((value) => ({ value })) : observeHandles.valuesForOwner(ownerId); for (const { value: observeEvents } of observeEntries) { - observeEvents.close(); + await observeEvents.return?.(); } if (ownerId === undefined) { observeHandles.clear(); @@ -691,6 +715,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..562f7199 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,7 +23,9 @@ vi.mock("@lix-js/sdk", () => ({ async importPaths() {} async syncDiskToLix() {} }, - bundledPluginArchives: async () => [], +})); + +vi.mock("@lix-js/sdk", () => ({ openLix: mocks.openLix, })); @@ -35,11 +37,15 @@ 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("./telemetry.mjs", () => ({ captureTelemetryException: vi.fn(), })); @@ -61,6 +67,54 @@ describe("Lix workspace lifecycle", () => { mocks.openLix.mockReset(); }); + test("adapts SDK iterator events and aborts a pending subscription on close", async () => { + mocks.getWorkspace.mockReturnValue({ + ephemeral: false, + name: "workspace", + path: "/workspace", + }); + const event = { + sequence: 1, + mutationSequence: 1, + result: { rows: [{ value: 1 }] }, + }; + let signal; + let calls = 0; + mocks.openLix.mockResolvedValue({ + close: vi.fn(async () => {}), + execute: vi.fn(async () => ({ + rows: [], + columns: [], + rowsAffected: 0, + notices: [], + })), + observe: vi.fn((_sql, _params, options) => { + signal = options.signal; + return { + next: () => + ++calls === 1 + ? Promise.resolve({ done: false, value: event }) + : new Promise((resolve) => + signal.addEventListener( + "abort", + () => resolve({ done: true, value: undefined }), + { once: true }, + ), + ), + }; + }), + }); + const lix = await ensureLixOpen(createTestWindow()); + const events = lix.observe("SELECT 1"); + expect(await events.next()).toEqual({ done: false, value: event }); + const pending = events.next(); + await Promise.resolve(); + await events.return(); + expect(signal.aborted).toBe(true); + expect(await pending).toEqual({ done: true, value: undefined }); + expect(await events.next()).toEqual({ done: true, value: undefined }); + }); + test("does not reopen Lix until an atomic workspace transition completes", async () => { const persistentOptions = { path: "/workspace", @@ -87,6 +141,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 +180,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..6a3883a4 100644 --- a/electron/lix.mjs +++ b/electron/lix.mjs @@ -1,13 +1,17 @@ +import { createWriteStream } from "node:fs"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { assertWorkspaceCanOpen } from "./workspace-open-preflight.mjs"; +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 { openLix } from "@lix-js/sdk"; import path from "node:path"; import { readFile, rm } from "node:fs/promises"; +import { getWorkspace, getWorkspaceLixDatabasePath } from "./workspace.mjs"; import { - acquireWorkspaceLocalFilesystemOptions, - getWorkspace, - getWorkspaceLixDatabasePath, -} from "./workspace.mjs"; -import { + readWorkspaceRecovery, clearWorkspaceLixOpenPendingSync, markWorkspaceLixOpenPendingSync, writeWorkspaceRecoverySync, @@ -17,6 +21,11 @@ import { captureWorkspaceRecoveryLifecycle } from "./workspace-recovery-telemetr const LIX_DATABASE_DIR = ".lix"; const sessions = new Map(); +// Lix stopped bundling plugin archives in the SDK. This is the released +// Markdown plugin artifact: https://github.com/opral/lix/releases/tag/plugin_markdown/v0.1.0 +const markdownPluginArchive = readFile( + new URL("./assets/plugin_markdown.lixplugin", import.meta.url), +); export async function ensureLixOpen(window) { const session = getOrCreateSession(window); @@ -30,6 +39,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; @@ -43,15 +64,27 @@ 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, - }); - await ensureDefaultPluginsInstalledOnCurrentBranch(nativeLix); + let storage; + if (tracksPersistentWorkspace) { + await assertWorkspaceCanOpen(userDataPath, workspace.path); + storage = new LocalFilesystem({ path: workspace.path }); + nativeLix = await openLix({ + storage, + onProgress(progress) { + if (!window.isDestroyed() && !window.webContents.isDestroyed()) + window.webContents.send("lix:openProgress", progress); + }, + }); + } else { + nativeLix = await openLix(); + storage = createMemoryWorkspace(nativeLix, workspace.path); + } + const pluginsUpdated = + await ensureDefaultPluginsInstalledOnCurrentBranch(nativeLix); + if (pluginsUpdated) await storage.syncDiskToLix(); + if (nativeLix.openReport?.initialized) { + await checkpointRepositoryMetadata(nativeLix); + } if (tracksPersistentWorkspace) { clearWorkspaceLixOpenPendingSync(userDataPath, workspace.path); } @@ -59,8 +92,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, ); @@ -104,30 +138,25 @@ export async function ensureLixOpen(window) { } async function ensureDefaultPluginsInstalledOnCurrentBranch(lix) { - for (const plugin of await bundledPluginArchives()) { - const archivePath = pluginArchivePath(plugin); - const existing = await readLixFileBytes(lix, archivePath); - if (!bytesEqual(existing, plugin.archiveBytes)) { - await writeLixFileBytes(lix, archivePath, plugin.archiveBytes); - } - } -} - -function pluginArchivePath(plugin) { - return `/.lix/plugins/${plugin.key}.lixplugin`; + const archivePath = "/.lix/plugins/plugin_markdown.lixplugin"; + const archiveBytes = await markdownPluginArchive; + const existing = await readLixFileBytes(lix, archivePath); + if (bytesEqual(existing, archiveBytes)) return false; + await writeLixFileBytes(lix, archivePath, archiveBytes); + return true; } 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], ); } @@ -163,6 +192,7 @@ export async function closeLix(window, options = {}) { return; } await enqueue(session, async () => { + await options.beforeClose?.(); await closeCurrentLix(session, options); }); } @@ -330,7 +360,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,7 +376,24 @@ 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 { + async exportShareSnapshot(destination) { + await runQueued(() => + pipeline( + Readable.fromWeb(nativeLix.exportSnapshot()), + createWriteStream(destination, { flags: "wx", mode: 0o600 }), + ), + ); + }, sessionId() { return sessionId; }, @@ -379,6 +428,7 @@ function createDesktopLixHandle( } try { await transaction.commit(); + await storage.flush?.(); } finally { transactionClosed = true; releaseSlot(); @@ -397,6 +447,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 +461,7 @@ function createDesktopLixHandle( ]); } await transaction.commit(); + await storage.flush?.(); return result; } catch (error) { await transaction.rollback(); @@ -444,7 +498,9 @@ function createDesktopLixHandle( async switchBranch(options) { return await runQueued(async () => { const receipt = await nativeLix.switchBranch(options); - await ensureDefaultPluginsInstalledOnCurrentBranch(nativeLix); + const pluginsUpdated = + await ensureDefaultPluginsInstalledOnCurrentBranch(nativeLix); + if (pluginsUpdated) await storage.syncDiskToLix(); return receipt; }); }, @@ -455,8 +511,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(); } @@ -476,12 +536,16 @@ function createQueuedObserve( ) { let closed = false; let events; + let observationController; async function ensureEvents() { while (!closed) { try { if (!events) { - events = nativeLix.observe(sql, params); + observationController = new AbortController(); + events = nativeLix.observe(sql, params, { + signal: observationController.signal, + }); } return events; } catch (error) { @@ -499,26 +563,35 @@ function createQueuedObserve( while (!closed) { const currentEvents = await ensureEvents(); if (!currentEvents) { - return undefined; + return { done: true, value: undefined }; } try { - return await currentEvents.next(); + const result = await currentEvents.next(); + if (result.done) { + closed = true; + return { done: true, value: undefined }; + } + return result; } catch (error) { if (!isActiveTransactionError(error)) { throw error; } - currentEvents.close(); + observationController?.abort(); if (events === currentEvents) { events = undefined; } await waitForOperationQueueToDrain(); } } - return undefined; + return { done: true, value: undefined }; }, - close() { + async return() { closed = true; - events?.close(); + observationController?.abort(); + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; }, }; } diff --git a/electron/main.mjs b/electron/main.mjs index af613e42..675f2d5b 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, @@ -510,11 +519,16 @@ 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; } + if (trackChanges && getWorkspace(window)?.ephemeral === true) { + await assertRepositorySize(getWorkspace(window).path); + } const workspace = await runWithLixSessionClosed( window, () => setWorkspaceTrackChanges(window, trackChanges), @@ -744,6 +758,8 @@ async function createMainWindow(workspaceRequest) { window.focus(); }); + const windowId = window.id; + const webContents = window.webContents; let windowCleanupDone = false; const cleanupWorkspaceWindow = () => { if (windowCleanupDone) { @@ -753,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(); @@ -871,6 +891,7 @@ async function startWorkspaceLifecycle() { return; } const userDataPath = app.getPath("userData"); + await applyScheduledWorkspaceLixDeletion(userDataPath); const recoveredLixOpenRecoveries = recoverPendingWorkspaceLixOpenRecoveriesSync(userDataPath); for (const recovery of recoveredLixOpenRecoveries) { @@ -878,6 +899,44 @@ 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, + BrowserWindow.fromWebContents(event.sender), + ), + ); registerLixIpc((event) => BrowserWindow.fromWebContents(event.sender), { disableTrackChanges: async (window) => { const workspace = await disableWorkspaceTrackChanges(window); @@ -909,13 +968,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)) @@ -1830,14 +1889,25 @@ function buildFileMenu() { }, { id: "track-changes", - label: "Track Changes", + label: + trackingWorkspace?.ephemeral === true + ? "Initialize repository" + : "Track Changes", type: "checkbox", enabled: Boolean(trackingWorkspace), checked: trackingWorkspace ? 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" }, @@ -1881,14 +1951,25 @@ function updateDockMenu() { }, { id: "dock-track-changes", - label: "Track Changes", + label: + trackingWorkspace?.ephemeral === true + ? "Initialize repository" + : "Track Changes", type: "checkbox", enabled: Boolean(trackingWorkspace), checked: trackingWorkspace ? 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/markdown-default-handler.mjs b/electron/markdown-default-handler.mjs deleted file mode 100644 index 652a71d7..00000000 --- a/electron/markdown-default-handler.mjs +++ /dev/null @@ -1,178 +0,0 @@ -import path from "node:path"; - -export const APP_BUNDLE_ID = "com.flashtype.app"; -export const APP_NAME = "Flashtype"; -export const MARKDOWN_CONTENT_TYPES = [ - "public.markdown", - "net.daringfireball.markdown", -]; - -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"; - -export function getAppBundlePathFromExecutablePath(executablePath) { - const marker = ".app/Contents/MacOS/"; - const markerIndex = executablePath.indexOf(marker); - if (markerIndex === -1) { - return null; - } - return executablePath.slice(0, markerIndex + ".app".length); -} - -export function isCanonicalInstalledAppBundle(appBundlePath) { - if (!appBundlePath) { - return false; - } - return ( - path.resolve(appBundlePath) === path.resolve("/Applications/Flashtype.app") - ); -} - -export function shouldReplaceMarkdownDefaultHandler(handlerBundleId) { - return ( - handlerBundleId == null || - REPLACEABLE_MARKDOWN_HANDLER_BUNDLE_IDS.has(handlerBundleId) - ); -} - -export function getMarkdownContentTypesToRegister(currentHandlers) { - return MARKDOWN_CONTENT_TYPES.filter((contentType) => - shouldReplaceMarkdownDefaultHandler(currentHandlers[contentType] ?? null), - ); -} - -export function getNonCanonicalFlashtypeBundlePathsFromLsregisterDump(dump) { - const blocks = dump.split(/\n-{20,}\n/u); - return blocks.flatMap((block) => { - const identifierMatch = block.match(/^identifier:\s+(.+)$/mu); - if (!identifierMatch || identifierMatch[1] !== APP_BUNDLE_ID) { - return []; - } - - const pathMatch = block.match( - /^path:\s+(.+?)(?:\s+\(0x[0-9a-fA-F]+\))?$/mu, - ); - if (!pathMatch) { - return []; - } - - const appBundlePath = pathMatch[1]; - return isCanonicalInstalledAppBundle(appBundlePath) ? [] : [appBundlePath]; - }); -} - -function buildMarkdownHandlerQueryScript() { - return ` -ObjC.import("CoreServices"); -const contentTypes = ${JSON.stringify(MARKDOWN_CONTENT_TYPES)}; -const handlers = {}; -for (const contentType of contentTypes) { - const handler = $.LSCopyDefaultRoleHandlerForContentType( - $(contentType), - $.kLSRolesEditor - ); - handlers[contentType] = handler ? ObjC.unwrap(ObjC.castRefToObject(handler)) : null; -} -console.log(JSON.stringify(handlers)); -`; -} - -function buildMarkdownHandlerRegistrationScript(contentTypes) { - return ` -ObjC.import("CoreServices"); -const bundleId = ${JSON.stringify(APP_BUNDLE_ID)}; -const contentTypes = ${JSON.stringify(contentTypes)}; -for (const contentType of contentTypes) { - const status = $.LSSetDefaultRoleHandlerForContentType( - $(contentType), - $.kLSRolesEditor, - $(bundleId) - ); - if (status !== 0) { - throw new Error("LSSetDefaultRoleHandlerForContentType failed for " + contentType + ": " + status); - } -} -`; -} - -async function getCurrentMarkdownDefaultHandlers(execFileAsync) { - const { stdout } = await execFileAsync( - "/usr/bin/osascript", - ["-l", "JavaScript", "-e", buildMarkdownHandlerQueryScript()], - { timeout: 5000 }, - ); - return JSON.parse(stdout); -} - -async function unregisterNonCanonicalFlashtypeBundles(execFileAsync) { - const { stdout } = await execFileAsync(LSREGISTER_PATH, ["-dump"], { - timeout: 5000, - maxBuffer: 20 * 1024 * 1024, - }); - const appBundlePaths = - getNonCanonicalFlashtypeBundlePathsFromLsregisterDump(stdout); - - await Promise.all( - appBundlePaths.map(async (appBundlePath) => { - try { - await execFileAsync(LSREGISTER_PATH, ["-u", appBundlePath], { - timeout: 5000, - }); - } catch { - // Stale LaunchServices entries can point at unmounted DMGs or deleted - // builds. Keep going so the canonical app can still be refreshed. - } - }), - ); - - return appBundlePaths; -} - -export async function registerMarkdownDefaultHandler({ - execFileAsync, - executablePath, - isPackaged, - platform, -}) { - if (platform !== "darwin" || !isPackaged) { - return { status: "skipped", reason: "unsupported-runtime" }; - } - - const appBundlePath = getAppBundlePathFromExecutablePath(executablePath); - if (!isCanonicalInstalledAppBundle(appBundlePath)) { - return { status: "skipped", reason: "non-canonical-app-bundle" }; - } - - const unregisteredAppBundlePaths = - await unregisterNonCanonicalFlashtypeBundles(execFileAsync); - - await execFileAsync(LSREGISTER_PATH, ["-f", appBundlePath], { - timeout: 5000, - }); - - const currentHandlers = - await getCurrentMarkdownDefaultHandlers(execFileAsync); - const contentTypes = getMarkdownContentTypesToRegister(currentHandlers); - if (contentTypes.length === 0) { - return { status: "skipped", reason: "user-handler-preserved" }; - } - - await execFileAsync( - "/usr/bin/osascript", - [ - "-l", - "JavaScript", - "-e", - buildMarkdownHandlerRegistrationScript(contentTypes), - ], - { timeout: 5000 }, - ); - - return { status: "registered", contentTypes, unregisteredAppBundlePaths }; -} diff --git a/electron/markdown-default-handler.test.ts b/electron/markdown-default-handler.test.ts deleted file mode 100644 index 4afb2e15..00000000 --- a/electron/markdown-default-handler.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { - APP_BUNDLE_ID, - getAppBundlePathFromExecutablePath, - getMarkdownContentTypesToRegister, - getNonCanonicalFlashtypeBundlePathsFromLsregisterDump, - isCanonicalInstalledAppBundle, - LSREGISTER_PATH, - registerMarkdownDefaultHandler, - shouldReplaceMarkdownDefaultHandler, -} from "./markdown-default-handler.mjs"; - -describe("Markdown default handler registration policy", () => { - test("derives the .app bundle path from a packaged executable path", () => { - expect( - getAppBundlePathFromExecutablePath( - "/Applications/Flashtype.app/Contents/MacOS/Flashtype", - ), - ).toBe("/Applications/Flashtype.app"); - }); - - test("only treats the /Applications app bundle as canonical", () => { - expect(isCanonicalInstalledAppBundle("/Applications/Flashtype.app")).toBe( - true, - ); - expect( - isCanonicalInstalledAppBundle( - "/Volumes/Flashtype 0.1.1-arm64/Flashtype.app", - ), - ).toBe(false); - expect( - isCanonicalInstalledAppBundle( - "/tmp/flashtype/release/mac-arm64/Flashtype.app", - ), - ).toBe(false); - }); - - test("replaces no handler, Xcode, TextEdit, and Flashtype itself", () => { - expect(shouldReplaceMarkdownDefaultHandler(null)).toBe(true); - expect(shouldReplaceMarkdownDefaultHandler("com.apple.dt.Xcode")).toBe( - true, - ); - expect(shouldReplaceMarkdownDefaultHandler("com.apple.TextEdit")).toBe( - true, - ); - expect(shouldReplaceMarkdownDefaultHandler(APP_BUNDLE_ID)).toBe(true); - }); - - test("preserves third-party Markdown defaults", () => { - expect(shouldReplaceMarkdownDefaultHandler("com.microsoft.VSCode")).toBe( - false, - ); - expect(shouldReplaceMarkdownDefaultHandler("md.obsidian")).toBe(false); - }); - - test("chooses only replaceable Markdown content types", () => { - expect( - getMarkdownContentTypesToRegister({ - "public.markdown": "com.apple.TextEdit", - "net.daringfireball.markdown": "com.microsoft.VSCode", - }), - ).toEqual(["public.markdown"]); - }); - - test("finds registered noncanonical Flashtype app bundles", () => { - const dump = ` --------------------------------------------------------------------------------- -bundle id: Flashtype (0x40c8) -path: /Applications/Flashtype.app (0x5bcc) -identifier: com.flashtype.app -versionString: 0.2.0 - --------------------------------------------------------------------------------- -bundle id: Flashtype (0x3f44) -path: /Volumes/Flashtype 0.1.1-arm64/Flashtype.app (0x59DC) -identifier: com.flashtype.app -versionString: 0.1.1 - --------------------------------------------------------------------------------- -bundle id: Flashtype Helper (0x5bdc) -path: /Applications/Flashtype.app/Contents/Frameworks/Flashtype Helper.app (0x5bdc) -identifier: com.flashtype.app.helper -versionString: 0.2.0 - --------------------------------------------------------------------------------- -bundle id: Other (0x1234) -path: /Applications/Other.app (0x1234) -identifier: com.example.other -versionString: 1.0.0 -`; - - expect(getNonCanonicalFlashtypeBundlePathsFromLsregisterDump(dump)).toEqual( - ["/Volumes/Flashtype 0.1.1-arm64/Flashtype.app"], - ); - }); - - test("skips packaged apps outside /Applications", async () => { - const execFileAsync = vi.fn(); - - const result = await registerMarkdownDefaultHandler({ - execFileAsync, - executablePath: - "/Volumes/Flashtype 0.1.1-arm64/Flashtype.app/Contents/MacOS/Flashtype", - isPackaged: true, - platform: "darwin", - }); - - expect(result).toEqual({ - status: "skipped", - reason: "non-canonical-app-bundle", - }); - expect(execFileAsync).not.toHaveBeenCalled(); - }); - - test("registers only from the canonical installed app", async () => { - const execFileAsync = vi - .fn() - .mockResolvedValueOnce({ - stdout: ` --------------------------------------------------------------------------------- -path: /Applications/Flashtype.app (0x5bcc) -identifier: com.flashtype.app - --------------------------------------------------------------------------------- -path: /Volumes/Flashtype 0.1.1-arm64/Flashtype.app (0x59dc) -identifier: com.flashtype.app -`, - }) - .mockResolvedValueOnce({ stdout: "" }) - .mockResolvedValueOnce({ stdout: "" }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - "public.markdown": "com.apple.dt.Xcode", - "net.daringfireball.markdown": null, - }), - }) - .mockResolvedValueOnce({ stdout: "" }); - - const result = await registerMarkdownDefaultHandler({ - execFileAsync, - executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", - isPackaged: true, - platform: "darwin", - }); - - expect(result).toEqual({ - status: "registered", - contentTypes: ["public.markdown", "net.daringfireball.markdown"], - unregisteredAppBundlePaths: [ - "/Volumes/Flashtype 0.1.1-arm64/Flashtype.app", - ], - }); - expect(execFileAsync).toHaveBeenNthCalledWith( - 1, - LSREGISTER_PATH, - ["-dump"], - { timeout: 5000, maxBuffer: 20 * 1024 * 1024 }, - ); - expect(execFileAsync).toHaveBeenNthCalledWith( - 2, - LSREGISTER_PATH, - ["-u", "/Volumes/Flashtype 0.1.1-arm64/Flashtype.app"], - { timeout: 5000 }, - ); - expect(execFileAsync).toHaveBeenNthCalledWith( - 3, - LSREGISTER_PATH, - ["-f", "/Applications/Flashtype.app"], - { timeout: 5000 }, - ); - expect(execFileAsync).toHaveBeenNthCalledWith( - 4, - "/usr/bin/osascript", - expect.arrayContaining(["JavaScript"]), - { timeout: 5000 }, - ); - expect(execFileAsync).toHaveBeenNthCalledWith( - 5, - "/usr/bin/osascript", - expect.arrayContaining(["JavaScript"]), - { timeout: 5000 }, - ); - - const queryScript = execFileAsync.mock.calls[3][1][3]; - expect(queryScript).toContain("ObjC.castRefToObject(handler)"); - - const registrationScript = execFileAsync.mock.calls[4][1][3]; - expect(registrationScript).toContain('"public.markdown"'); - expect(registrationScript).toContain('"net.daringfireball.markdown"'); - expect(registrationScript).toContain(APP_BUNDLE_ID); - }); - - test("registers only filtered content types through the full workflow", async () => { - const execFileAsync = vi - .fn() - .mockResolvedValueOnce({ stdout: "" }) - .mockResolvedValueOnce({ stdout: "" }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - "public.markdown": "com.apple.TextEdit", - "net.daringfireball.markdown": "com.microsoft.VSCode", - }), - }) - .mockResolvedValueOnce({ stdout: "" }); - - const result = await registerMarkdownDefaultHandler({ - execFileAsync, - executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", - isPackaged: true, - platform: "darwin", - }); - - expect(result).toEqual({ - status: "registered", - contentTypes: ["public.markdown"], - unregisteredAppBundlePaths: [], - }); - - const registrationScript = execFileAsync.mock.calls[3][1][3]; - expect(registrationScript).toContain('"public.markdown"'); - expect(registrationScript).not.toContain('"net.daringfireball.markdown"'); - }); - - test("preserves user-selected third-party handlers", async () => { - const execFileAsync = vi - .fn() - .mockResolvedValueOnce({ stdout: "" }) - .mockResolvedValueOnce({ stdout: "" }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - "public.markdown": "com.microsoft.VSCode", - "net.daringfireball.markdown": "md.obsidian", - }), - }); - - const result = await registerMarkdownDefaultHandler({ - execFileAsync, - executablePath: "/Applications/Flashtype.app/Contents/MacOS/Flashtype", - isPackaged: true, - platform: "darwin", - }); - - expect(result).toEqual({ - status: "skipped", - reason: "user-handler-preserved", - }); - expect(execFileAsync).toHaveBeenCalledTimes(3); - }); -}); diff --git a/electron/memory-workspace.mjs b/electron/memory-workspace.mjs new file mode 100644 index 00000000..31fe1539 --- /dev/null +++ b/electron/memory-workspace.mjs @@ -0,0 +1,186 @@ +import path from "node:path"; +import { + readFile, + writeFile, + mkdir, + rmdir, + 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(); + const createdDirectories = new Set(); + 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 directoryResult = await lix.execute( + "SELECT path FROM lix_directory WHERE path != '/' AND path NOT LIKE '/.lix%'", + ); + const directories = new Set( + directoryResult.rows + .map((row) => row.path) + .filter((directoryPath) => typeof directoryPath === "string") + .map(normalizeDirectoryPath), + ); + for (const directoryPath of [...directories].sort(compareWorkspacePaths)) { + await ensureDirectory(directoryPath); + } + + 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); + } + + for (const directoryPath of [...createdDirectories].sort( + compareWorkspacePathsDescending, + )) { + if (directories.has(directoryPath)) continue; + const target = await diskPath(directoryPath); + try { + await rmdir(target); + createdDirectories.delete(directoryPath); + } catch (error) { + if (error.code === "ENOENT") { + createdDirectories.delete(directoryPath); + continue; + } + if (error.code !== "ENOTEMPTY" && error.code !== "EEXIST") { + throw error; + } + } + } + } + async function ensureDirectory(relative) { + const target = await diskPath(relative); + const base = await realpath(root); + const parts = relative.replace(/^\/+/, "").split("/"); + let current = base; + for (let index = 0; index < parts.length; index += 1) { + current = path.join(current, parts[index]); + try { + const metadata = await lstat(current); + if (metadata.isSymbolicLink()) { + throw new Error( + "Symbolic links are not supported for in-memory workspace files", + ); + } + if (!metadata.isDirectory()) { + throw new Error( + `Workspace directory path is not a directory: ${relative}`, + ); + } + } catch (error) { + if (error.code !== "ENOENT") throw error; + try { + await mkdir(current); + createdDirectories.add(`/${parts.slice(0, index + 1).join("/")}`); + } catch (mkdirError) { + if (mkdirError.code !== "EEXIST") throw mkdirError; + const metadata = await lstat(current); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error( + `Workspace directory path is not a directory: ${relative}`, + ); + } + } + } + } + return target; + } + return { + importPaths, + flush, + syncDiskToLix: () => importPaths([...baseline.keys()]), + }; +} + +function normalizeDirectoryPath(directoryPath) { + return directoryPath.endsWith("/") && directoryPath !== "/" + ? directoryPath.slice(0, -1) + : directoryPath; +} + +function compareWorkspacePaths(left, right) { + return ( + left.split("/").length - right.split("/").length || + left.localeCompare(right) + ); +} + +function compareWorkspacePathsDescending(left, right) { + return compareWorkspacePaths(right, left); +} diff --git a/electron/memory-workspace.test.mjs b/electron/memory-workspace.test.mjs new file mode 100644 index 00000000..c8dca8ce --- /dev/null +++ b/electron/memory-workspace.test.mjs @@ -0,0 +1,108 @@ +// @vitest-environment node +import { test, expect } from "vitest"; +import { createRequire } from "node:module"; +import { + mkdtemp, + mkdir, + writeFile, + readFile, + readdir, + rm, + stat, +} 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 }); + } +}); + +test("memory workspace persists new directories and only removes directories it created", async () => { + const root = await mkdtemp( + path.join(tmpdir(), "flashtype-memory-directories-test-"), + ); + const lix = await openLix(); + try { + await mkdir(path.join(root, "existing"), { recursive: true }); + await writeFile(path.join(root, "existing", "keep.md"), "Keep me"); + const bridge = createMemoryWorkspace(lix, root); + + await lix.execute("INSERT INTO lix_directory (path) VALUES ($1)", [ + "/existing", + ]); + await lix.execute("INSERT INTO lix_directory (path) VALUES ($1)", [ + "/created", + ]); + await lix.execute("INSERT INTO lix_directory (path) VALUES ($1)", [ + "/created/nested", + ]); + await bridge.flush(); + + expect((await stat(path.join(root, "created"))).isDirectory()).toBe(true); + expect( + (await stat(path.join(root, "created", "nested"))).isDirectory(), + ).toBe(true); + + await lix.execute("DELETE FROM lix_directory WHERE path = $1", [ + "/created/nested", + ]); + await lix.execute("DELETE FROM lix_directory WHERE path = $1", [ + "/created", + ]); + await lix.execute("DELETE FROM lix_directory WHERE path = $1", [ + "/existing", + ]); + await bridge.flush(); + + await expect(stat(path.join(root, "created"))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await readFile(path.join(root, "existing", "keep.md"), "utf8")).toBe( + "Keep me", + ); + expect(await readdir(path.join(root, "existing"))).toEqual(["keep.md"]); + } finally { + await lix.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/electron/preload.mjs b/electron/preload.mjs index 18a82a0a..4706de76 100644 --- a/electron/preload.mjs +++ b/electron/preload.mjs @@ -25,7 +25,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: () => @@ -75,6 +81,12 @@ const workspace = { }; const lix = { + onOpenProgress: (listener) => { + const wrapped = (_event, progress) => listener(progress); + ipcRenderer.on("lix:openProgress", wrapped); + return () => ipcRenderer.off("lix:openProgress", wrapped); + }, + 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/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/types.d.ts b/electron/types.d.ts index 6eb26764..58a1f985 100644 --- a/electron/types.d.ts +++ b/electron/types.d.ts @@ -46,9 +46,18 @@ export type DesktopObserveEvent = { }; export type DesktopLixApi = { + onOpenProgress( + listener: (progress: import("@lix-js/sdk").LixOpenProgress) => void, + ): () => void; + executeBatch(payload: { + statements: ReadonlyArray<{ sql: string; params?: ReadonlyArray }>; + }): Promise<{ + results: Array; + commit: import("@lix-js/sdk").CommitSpan | null; + }>; open(): Promise<{ sessionId: string }>; workspaceDir(): Promise; - storageDir(): Promise; + storageDir(): Promise; execute(payload: { sql: string; params?: ReadonlyArray; @@ -117,14 +126,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"; @@ -279,6 +288,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; @@ -317,6 +327,14 @@ export type DesktopWorkspaceApi = { exportLixFile(): Promise; resetLixRepository(): Promise; disableTrackChanges(): Promise; + initializeRepository(): Promise; + inspectRepositorySize(): Promise<{ + fileCount: number; + totalBytes: number; + complete: boolean; + allowed: boolean; + maxBytes: number; + }>; resolveMarkdownImageSrc(payload: { src: string; sourceFilePath: string; 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 36bd5cef..4d594ebf 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.18.1", + "@lix-js/storage-filesystem": "0.18.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..b4c888c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,8 +32,11 @@ importers: specifier: ^0.1.0 version: 0.1.0 '@lix-js/sdk': - specifier: 0.8.3 - version: 0.8.3 + specifier: 0.18.1 + version: 0.18.1 + '@lix-js/storage-filesystem': + specifier: 0.18.1 + version: 0.18.1(@lix-js/sdk@0.18.1) '@opral/atelier': specifier: workspace:* version: link:submodule/atelier @@ -227,6 +230,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,9 +248,15 @@ 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 + '@codemirror/merge': + specifier: ^6.12.2 + version: 6.12.2 '@codemirror/search': specifier: ^6.7.1 version: 6.7.1 @@ -275,9 +287,21 @@ importers: '@lix-js/html-diff': specifier: ^0.1.0 version: 0.1.0 + '@opral/zettel-ast': + specifier: 1.2.0 + version: 1.2.0 + '@opral/zettel-html': + specifier: 1.2.0 + version: 1.2.0 + '@opral/zettel-lexical': + specifier: 2.0.0 + version: 2.0.0(lexical@0.51.0(typescript@7.0.2))(typescript@7.0.2) '@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) @@ -308,6 +332,9 @@ importers: kysely: specifier: ^0.29.3 version: 0.29.3 + lexical: + specifier: ^0.51.0 + version: 0.51.0(typescript@7.0.2) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -341,9 +368,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 @@ -358,8 +391,8 @@ importers: version: 2.8.2 devDependencies: '@lix-js/sdk': - specifier: ^0.8.2 - version: 0.8.2 + specifier: link:vendor/lix/packages/js-sdk + version: link:vendor/lix/packages/js-sdk '@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 +423,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 @@ -402,6 +438,9 @@ importers: oxlint: specifier: ^1.73.0 version: 1.73.0 + postcss: + specifier: ^8.5.16 + version: 8.5.16 react: specifier: ^19.2.7 version: 19.2.7 @@ -681,14 +720,69 @@ 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/jco-node-fs-darwin-arm64@0.3.2': + resolution: {integrity: sha512-WXQq1tRVUWhbK+ImH0G8Kni2AR0sdokLf/KyXRw5P6n4A9ZbgMHOuPfN/PEsYgw4eosuTAonutK0xILk/hA81A==} + engines: {node: '>=20'} + cpu: [arm64] + os: [darwin] + + '@bytecodealliance/jco-node-fs-darwin-x64@0.3.2': + resolution: {integrity: sha512-Fy/OjHnto7hNE5ksewVkmAMXG/uXyj+RAKFZWQFTzFkp73UyT5vuPjKq3nvx9KolUGWBG37BudEMb4ZAoD9TbQ==} + engines: {node: '>=20'} + cpu: [x64] + os: [darwin] + + '@bytecodealliance/jco-node-fs-linux-arm64-gnu@0.3.2': + resolution: {integrity: sha512-OafRtRu3tfOFQARfkia/LDEqSo2R3yAL1xPwzf4RcRW6JqkOY7qKWsUS5+LPVo/aFaSkMX7F7bdFGpFPmcoZJg==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + + '@bytecodealliance/jco-node-fs-linux-arm64-musl@0.3.2': + resolution: {integrity: sha512-JblDiwD7+Y+w0d/tjaNDX4QdGvh5lyuDc+S/jDeT7sYA1caA9yiwIjLa8PC/lm2xudqSxD+Cuy4AosXvl+qI0g==} + engines: {node: '>=20'} + cpu: [arm64] + os: [linux] + + '@bytecodealliance/jco-node-fs-linux-x64-gnu@0.3.2': + resolution: {integrity: sha512-6FzmHoLOsa6b4X9AuFLsvlpNF66Dt4sRRftZHaT1I1pYzCWrJkkpgNJhRXxf67ZPmOwBZJT4dan/g752Rx4xRw==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + + '@bytecodealliance/jco-node-fs-linux-x64-musl@0.3.2': + resolution: {integrity: sha512-SbAJG2PDV8YAHBESHU9sVSckOitJyvlezdF4CGP5/mOp01JnijVkLVXzGK62yVSfa5TpAybbSd1rk+v36NAW/Q==} + engines: {node: '>=20'} + cpu: [x64] + os: [linux] + + '@bytecodealliance/jco-node-fs-win32-arm64-msvc@0.3.2': + resolution: {integrity: sha512-nUWYdFysRQJGsIOCk0KAYN2smkNYiHG7rZ7m+0ZXqP+5wqWxKEjkDsORwXiBjdaWFLVww0anZ3SBxrju6P7hXQ==} + engines: {node: '>=20'} + cpu: [arm64] + os: [win32] + + '@bytecodealliance/jco-node-fs-win32-x64-msvc@0.3.2': + resolution: {integrity: sha512-RsPj2t8TvltsMWK7/Z7JcVC/IWPMi07/AJkNLiNTCuGTdT/sseqTThJJwhuz3sjFw7dJdxA+bSGT+23y/KT0fw==} + engines: {node: '>=20'} + cpu: [x64] + os: [win32] + + '@bytecodealliance/jco-node-fs@0.3.2': + resolution: {integrity: sha512-yxvqQreNcOQdMsEKZomkSqThtkxrAz3f1Cmy8BsV0UHi9saa2/D1KDB14IAXPYwDq/maS6lCzS+MuMQx6QkUgA==} + engines: {node: '>=20'} + + '@bytecodealliance/jco-transpile@0.13.0': + resolution: {integrity: sha512-9hZ2GMqOlYP1Bf7qQ8v3sCsmkFoXGbgi0HtjHipzrgyk+9GGDzvvbehTGe0oM54rcbKJacvyjpPDn7wtpgzwYA==} + + '@bytecodealliance/preview2-shim@0.24.1': + resolution: {integrity: sha512-QGZsKD5M76xBhwu3BzLWqYDEpaMX90QWrrfhkQPn2pN9t378koumtdAVH4lr25mqGCZt10YNXkWvyWkw/vJbLQ==} - '@bytecodealliance/preview2-shim@0.19.0': - resolution: {integrity: sha512-OSUlcM62fXLuzYP0DjBn/SwjKMZ/y/2PMXZ8v5cRiiFcroauntlPRl/oCnx0xYjYiqdYvE0PY50VCNutll0xOw==} + '@bytecodealliance/preview2-shim@0.25.0': + resolution: {integrity: sha512-6ZSDRJ8hJc/febZepzd68z3KzLcOXyCOKtRm8LB2rgAU88xV7ykq6dMb5I4a+sBh1cuV2Yb1159gDzI1q8UsoQ==} - '@bytecodealliance/preview3-shim@0.2.0': - resolution: {integrity: sha512-44zSPzlOyF/hWTNK32odet+Jt/nVXgtboSvgvZqMEJKP9GuM+Ln3L6js31WAakDEe+TYgG4iNEBk39rByINZkA==} + '@bytecodealliance/preview3-shim@0.6.1': + resolution: {integrity: sha512-61mEfjfgGmZhEMA0H3waawPKvYd3za9y0vJ7pmQz/9yvm4FR6Ef+cmqhnxDE7ezsM07MY1btP92ymmgsgFwxmg==} '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -769,12 +863,18 @@ 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==} '@codemirror/lint@6.9.7': resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + '@codemirror/merge@6.12.2': + resolution: {integrity: sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==} + '@codemirror/search@6.7.1': resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} @@ -1485,6 +1585,46 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lexical/extension@0.51.0': + resolution: {integrity: sha512-PR1cKenPgRh8QKeyAlPz5KY60kxnV624UGOVZ2W4GrZjUtvCx4eKgVu/Pw1TuuK/aSLBCY1f6VxMF3hhs+CD+w==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/history@0.51.0': + resolution: {integrity: sha512-dymiLsX003GoXTn06Nt9mZXbJhMRdB9qSvg+PEBtqUDMKCGEJ+qT0w+NsbF+MW1VOV8Qzs9nayk2lwklx0526g==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/internal@0.51.0': + resolution: {integrity: sha512-4f0k20V92koqXFpYPiu9MobSQuhTXtSL6RvQSjGKq4QOUMaNl+P5nNtvf2PN732ch/hEkWM7ZBw0WVlAmg9pxg==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/selection@0.51.0': + resolution: {integrity: sha512-nkZH9MkNX2m2DBSBVeKVpau/OV7bkHKeUsSaim22yPYiKRncXlRZFLH/avI6joOh6mDt3gGVVBP2BUnKw+duNA==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + + '@lexical/utils@0.51.0': + resolution: {integrity: sha512-x2tGha3n3Slf6tdHXAcbg+vk8zoKK94Y3auVzJ7pE77UEUdxnMd6B4fk953oQgTkJHFwgVFX/bUgThsJEGW4Ew==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + '@lezer/common@1.5.2': resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} @@ -1531,51 +1671,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.18.1': + resolution: {integrity: sha512-d5AjKHKBcF+tfK5Eeucn3hTN4gKC+PBZZzwWEzkOcmWG3UglL17OGPvT2VOU+QllwuAG/K1BGrBBztl1UvpEFQ==} 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.18.1': + resolution: {integrity: sha512-B5akmWsl+V+bWRsDC6Lzt3MbUTAfW2rokzliK2qtAhGJdBk9P4nZZjCIac9/5kY3OJPWFxmxddnRODA42ec32g==} 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.18.1': + resolution: {integrity: sha512-fUjpS50ik1Z8LueWwnEA6YCffbhna4ittuIUiK1oFHxvYB/oYQp6gDM1cvEW7RZ9D0CQRg1cXcNTQyHPRUg2Ig==} 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==} + '@lix-js/sdk-win32-x64@0.18.1': + resolution: {integrity: sha512-pJYBIayv7O/RWEIPtksx828DZnW0ypCLR4cnCW849YafHjUVOWmYezOxbf3aj1+h1oq0Kgq9YnufY74YiY7B6Q==} cpu: [x64] os: [win32] - '@lix-js/sdk-win32-x64@0.8.3': - resolution: {integrity: sha512-w78WRWZ0JCyO7oIz1qtORnT+er0ySgr9FoF9/RMZE/Hn263HS8DL9tJxU69UDcMg+8qFv3rgOqHxEbfhPRpB9w==} - cpu: [x64] - os: [win32] + '@lix-js/sdk@0.18.1': + resolution: {integrity: sha512-PIU7V//11++YBNmA7O8vKwyHFksvFBBGDfhSFNqx1uivKBx7eqncQHbw8SND2Oh/s0WlGaMM7p6j/+2NMsmBaQ==} - '@lix-js/sdk@0.8.2': - resolution: {integrity: sha512-R4ESto7SejuNIttxrxuSxSslJkPg8TXHbwZ6FI6z3Y8/gXUOBYdd9QpDDVbw8IifJpXgfZJITUTgwmmrxi5UZg==} - - '@lix-js/sdk@0.8.3': - resolution: {integrity: sha512-eocYJKwdsOIYeT8CXDXKjSErIMC0j7IQHtp1p0uzkLzMAFw2pmDhMU9aBrscpcSUzQFWjIG/UnmiQ6wPy1s0zg==} + '@lix-js/storage-filesystem@0.18.1': + resolution: {integrity: sha512-QsJbzI0wx+tTJFvuvqGCcJ0Vd+RxRGIsch01j1hbW8b1hzMJ9RQ0ULUKiOJYgCLH071XHLBBELuVw7fcgMtEPg==} + peerDependencies: + '@lix-js/sdk': 0.18.1 '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} @@ -1690,6 +1812,19 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opral/zettel-ast@1.2.0': + resolution: {integrity: sha512-hDSLhog3Uclt3cFTRISY+QcasBqnpf5vf2ECwKgn+G0afcCTS3HXh444E32rYvRCKkk4FgnLQfd36z9/M1xfPQ==} + engines: {node: '>=20'} + + '@opral/zettel-html@1.2.0': + resolution: {integrity: sha512-/in3ZlgQtu19edZUNmHbmmN8BNuEi5pxyGSWvRQAD55dAwjXoQEigBnw5GyCJUPBqheApz2o0L/z8k3c5HOYAA==} + engines: {node: '>=20'} + + '@opral/zettel-lexical@2.0.0': + resolution: {integrity: sha512-4MNiZIrKyJHe7oJ8gDuLG+JJB8EBxVPXwRp9X4ByllUNnm+02OXotmcOcOBL3J4TbO2yx6d3uNLm1WrY08dbZw==} + peerDependencies: + lexical: ^0.51.0 + '@oxc-minify/binding-android-arm-eabi@0.136.0': resolution: {integrity: sha512-gk1jzky1vLeOcK7w9Ib2RT/UooZ7O21s8YSeXCsy4tzsmguKb3Cbsv/Ra2fKhMp4mKeKCzvC1Xa41OfSBWfWdQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2243,6 +2378,9 @@ packages: '@posthog/types@1.391.0': resolution: {integrity: sha512-oJ6jkqVMq+T4ax9F0rUllJc0KHpSgpaMwTNYWkE70iBiyXDVyhcNBmYnNKzSODgpzsaQNI6VfK8JrRYbkSJZZw==} + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@radix-ui/primitive@1.0.0': resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==} @@ -2255,6 +2393,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 +2409,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 +2467,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 +2525,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 +2579,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 +2611,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 +2646,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 +2725,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 +2747,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 +2818,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 +2853,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 +2918,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 +2944,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 +2989,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 +3060,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 +3131,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 +3176,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 +3249,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: @@ -2943,6 +3290,15 @@ packages: '@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 + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.2': resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: @@ -2952,8 +3308,17 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-effect-event@0.0.3': - resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + 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-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 @@ -2988,6 +3353,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 +3394,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 +3430,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 +3466,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 +3510,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} @@ -5245,6 +5649,14 @@ packages: lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + lexical@0.51.0: + resolution: {integrity: sha512-uwThveE7nnR3ewETt9q7KBgbXVwtx7ZntJXWbonI/IrrdF7VtIbAGgqJcYxPchkQVjIppIzNoWaFolDd9Yph0g==} + peerDependencies: + typescript: '>=5.2' + peerDependenciesMeta: + typescript: + optional: true + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -7477,18 +7889,60 @@ snapshots: '@bufbuild/protobuf@2.10.2': optional: true - '@bytecodealliance/jco-transpile@0.4.2': + '@bytecodealliance/jco-node-fs-darwin-arm64@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-darwin-x64@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-linux-arm64-gnu@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-linux-arm64-musl@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-linux-x64-gnu@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-linux-x64-musl@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-win32-arm64-msvc@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs-win32-x64-msvc@0.3.2': + optional: true + + '@bytecodealliance/jco-node-fs@0.3.2': + optionalDependencies: + '@bytecodealliance/jco-node-fs-darwin-arm64': 0.3.2 + '@bytecodealliance/jco-node-fs-darwin-x64': 0.3.2 + '@bytecodealliance/jco-node-fs-linux-arm64-gnu': 0.3.2 + '@bytecodealliance/jco-node-fs-linux-arm64-musl': 0.3.2 + '@bytecodealliance/jco-node-fs-linux-x64-gnu': 0.3.2 + '@bytecodealliance/jco-node-fs-linux-x64-musl': 0.3.2 + '@bytecodealliance/jco-node-fs-win32-arm64-msvc': 0.3.2 + '@bytecodealliance/jco-node-fs-win32-x64-msvc': 0.3.2 + + '@bytecodealliance/jco-transpile@0.13.0': dependencies: - '@bytecodealliance/preview2-shim': 0.19.0 - '@bytecodealliance/preview3-shim': 0.2.0 + '@bytecodealliance/preview2-shim': 0.24.1 + '@bytecodealliance/preview3-shim': 0.6.1 binaryen: 130.0.0 oxc-minify: 0.136.0 - '@bytecodealliance/preview2-shim@0.19.0': {} + '@bytecodealliance/preview2-shim@0.24.1': + dependencies: + '@bytecodealliance/jco-node-fs': 0.3.2 + + '@bytecodealliance/preview2-shim@0.25.0': + dependencies: + '@bytecodealliance/jco-node-fs': 0.3.2 - '@bytecodealliance/preview3-shim@0.2.0': + '@bytecodealliance/preview3-shim@0.6.1': dependencies: - '@bytecodealliance/preview2-shim': 0.19.0 + '@bytecodealliance/jco-node-fs': 0.3.2 + '@bytecodealliance/preview2-shim': 0.25.0 '@chevrotain/cst-dts-gen@11.0.3': dependencies: @@ -7577,6 +8031,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 @@ -7592,6 +8055,14 @@ snapshots: '@codemirror/view': 6.43.6 crelt: 1.0.6 + '@codemirror/merge@6.12.2': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + '@lezer/highlight': 1.2.3 + style-mod: 4.1.3 + '@codemirror/search@6.7.1': dependencies: '@codemirror/state': 6.7.1 @@ -8274,6 +8745,42 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lexical/extension@0.51.0(typescript@7.0.2)': + dependencies: + '@lexical/internal': 0.51.0(typescript@7.0.2) + '@lexical/utils': 0.51.0(typescript@7.0.2) + '@preact/signals-core': 1.14.4 + lexical: 0.51.0(typescript@7.0.2) + optionalDependencies: + typescript: 7.0.2 + + '@lexical/history@0.51.0(typescript@7.0.2)': + dependencies: + '@lexical/extension': 0.51.0(typescript@7.0.2) + '@lexical/utils': 0.51.0(typescript@7.0.2) + lexical: 0.51.0(typescript@7.0.2) + optionalDependencies: + typescript: 7.0.2 + + '@lexical/internal@0.51.0(typescript@7.0.2)': + optionalDependencies: + typescript: 7.0.2 + + '@lexical/selection@0.51.0(typescript@7.0.2)': + dependencies: + '@lexical/internal': 0.51.0(typescript@7.0.2) + lexical: 0.51.0(typescript@7.0.2) + optionalDependencies: + typescript: 7.0.2 + + '@lexical/utils@0.51.0(typescript@7.0.2)': + dependencies: + '@lexical/internal': 0.51.0(typescript@7.0.2) + '@lexical/selection': 0.51.0(typescript@7.0.2) + lexical: 0.51.0(typescript@7.0.2) + optionalDependencies: + typescript: 7.0.2 + '@lezer/common@1.5.2': {} '@lezer/css@1.3.4': @@ -8379,51 +8886,32 @@ 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.18.1': optional: true - '@lix-js/sdk-linux-x64@0.8.3': + '@lix-js/sdk-linux-arm64@0.18.1': optional: true - '@lix-js/sdk-win32-x64@0.8.2': + '@lix-js/sdk-linux-x64@0.18.1': optional: true - '@lix-js/sdk-win32-x64@0.8.3': + '@lix-js/sdk-win32-x64@0.18.1': optional: true - '@lix-js/sdk@0.8.2': + '@lix-js/sdk@0.18.1': dependencies: - '@bytecodealliance/jco-transpile': 0.4.2 - '@bytecodealliance/preview2-shim': 0.19.0 + '@bytecodealliance/jco-transpile': 0.13.0 + binaryen: 130.0.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.18.1 + '@lix-js/sdk-linux-arm64': 0.18.1 + '@lix-js/sdk-linux-x64': 0.18.1 + '@lix-js/sdk-win32-x64': 0.18.1 - '@lix-js/sdk@0.8.3': + '@lix-js/storage-filesystem@0.18.1(@lix-js/sdk@0.18.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.18.1 '@malept/cross-spawn-promise@2.0.0': dependencies: @@ -8524,6 +9012,26 @@ snapshots: '@opentelemetry/api@1.9.0': optional: true + '@opral/zettel-ast@1.2.0': + dependencies: + ajv: 8.20.0 + + '@opral/zettel-html@1.2.0': + dependencies: + '@opral/zettel-ast': 1.2.0 + parse5: 7.3.0 + + '@opral/zettel-lexical@2.0.0(lexical@0.51.0(typescript@7.0.2))(typescript@7.0.2)': + dependencies: + '@lexical/extension': 0.51.0(typescript@7.0.2) + '@lexical/history': 0.51.0(typescript@7.0.2) + '@lexical/utils': 0.51.0(typescript@7.0.2) + '@opral/zettel-ast': 1.2.0 + '@opral/zettel-html': 1.2.0 + lexical: 0.51.0(typescript@7.0.2) + transitivePeerDependencies: + - typescript + '@oxc-minify/binding-android-arm-eabi@0.136.0': optional: true @@ -8870,6 +9378,8 @@ snapshots: '@posthog/types@1.391.0': {} + '@preact/signals-core@1.14.4': {} + '@radix-ui/primitive@1.0.0': dependencies: '@babel/runtime': 7.29.7 @@ -8880,6 +9390,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 +9401,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 +9450,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 +9497,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 +9539,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 +9562,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 +9594,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 +9668,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 +9685,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 +9745,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 +9804,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 +9907,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 +9935,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 +9973,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 +10027,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 +10105,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 +10151,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 +10235,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 +10270,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 +10293,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 +10320,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 +10349,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 +10376,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 +10404,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 +10435,8 @@ snapshots: '@radix-ui/rect@1.1.2': {} + '@radix-ui/rect@1.1.3': {} + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -11886,6 +12635,12 @@ snapshots: lazy-val@1.0.5: {} + lexical@0.51.0(typescript@7.0.2): + dependencies: + '@lexical/internal': 0.51.0(typescript@7.0.2) + optionalDependencies: + typescript: 7.0.2 + lightningcss-android-arm64@1.30.2: optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09d2a638..528d4da9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,11 +14,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.18.1" + - "@lix-js/sdk-linux-arm64@0.18.1" + - "@lix-js/sdk-linux-x64@0.18.1" + - "@lix-js/sdk-win32-x64@0.18.1" + - "@lix-js/sdk@0.18.1" allowBuilds: "@parcel/watcher": set this to true or false 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 43299bb9..b5e6b497 100644 --- a/src/extensions/atelier-host-extensions.test.ts +++ b/src/extensions/atelier-host-extensions.test.ts @@ -1,13 +1,20 @@ -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, welcome panel, and History view", () => { const extensions = createFlashTypeAtelierExtensions(); - expect(extensions.map((extension) => extension.manifest.id)).toEqual([ + expect(extensions.map((extension) => extension.id)).toEqual([ "flashtype_claude", "flashtype_codex", + "flashtype_agents", + "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..a90f3933 100644 --- a/src/extensions/atelier-host-extensions.ts +++ b/src/extensions/atelier-host-extensions.ts @@ -1,11 +1,18 @@ +import { createHistoryExtension } from "./history/host-extension"; import type { AtelierExtensionRegistration } from "@opral/atelier"; -import { FLASHTYPE_ATELIER_EXTENSIONS as TERMINAL_EXTENSIONS } from "./terminal/host-extensions"; +import { AGENT_WELCOME_EXTENSION, FLASHTYPE_ATELIER_EXTENSIONS as TERMINAL_EXTENSIONS } from "./terminal/host-extensions"; /** * FlashType-owned atelier extensions. The Files view is atelier's bundled * 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, + AGENT_WELCOME_EXTENSION, + createHistoryExtension(options.temporaryHistory === true), + ]; } 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 04862397..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]?.get("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 new file mode 100644 index 00000000..0ae0c2d9 --- /dev/null +++ b/src/extensions/history/host-extension.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import type { + AtelierExtensionRuntime, + AtelierExtensionView, +} 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`, () => { + const runtime = { diff: { autoAccept: false } } as AtelierExtensionRuntime; + 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 new file mode 100644 index 00000000..63250075 --- /dev/null +++ b/src/extensions/history/host-extension.tsx @@ -0,0 +1,45 @@ +import { History } from "lucide-react"; +import { + Atelier, + ATELIER_BUILTIN_EXTENSION_IDS, + type AtelierExtensionRegistration, +} 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 { + id: ATELIER_BUILTIN_EXTENSION_IDS.history, + name: "History", + placement: ["left", "right", "main"], + icon: History, + HeaderAccessory: ({ atelier, view }) => { + if (!atelier.diff) return null; + return ( + + ); + }, + 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 06ace7b6..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.get("data")), - ); - const observedOriginKey = firstRow.get("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
    - return ["ul", diffAttrs(node, "element"), 0]; - }, - }), - Node.create({ - name: "orderedList", - group: "block", - content: "listItem+", - addAttributes() { - return { start: { default: 1 }, data: { default: null } }; - }, - renderHTML({ node }) { - const attrs: any = {}; - const start = (node as any).attrs?.start; - if (start && start !== 1) attrs.start = start; - return ["ol", { ...attrs, ...diffAttrs(node, "element") }, 0]; - }, - }), - // table - Node.create({ - name: "table", - group: "block", - content: "tableRow+", - addAttributes() { - return { align: { default: [] }, data: { default: null } }; - }, - renderHTML({ node }) { - return ["table", diffAttrs(node, "element"), ["tbody", 0]]; - }, - }), - Node.create({ - name: "tableRow", - content: "tableCell+", - addAttributes() { - return { data: { default: null } }; - }, - renderHTML({ node }) { - return ["tr", diffAttrs(node, "element"), 0]; - }, - }), - Node.create({ - name: "tableCell", - content: "inline*", - addAttributes() { - return { data: { default: null } }; - }, - renderHTML({ node }) { - return ["td", diffAttrs(node), 0]; - }, - }), - Node.create({ - name: "listItem", - group: "block", - content: "paragraph block*", - defining: true, - addAttributes() { - return { checked: { default: null }, data: { default: null } }; - }, - renderHTML({ node }) { - const isTask = - node.attrs.checked === true || node.attrs.checked === false; - const attrs = diffAttrs(node, "element"); - if (!isTask) return ["li", attrs, ["div", 0]]; - return [ - "li", - { - ...attrs, - "data-task": node.attrs.checked ? "x" : " ", - }, - [ - "input", - { - type: "checkbox", - checked: node.attrs.checked ? "checked" : undefined, - disabled: "true", - }, - ], - ["div", 0], - ]; - }, - addNodeView() { - return ({ node, editor, getPos }) => { - const dom = document.createElement("li"); - const isTask = - node.attrs.checked === true || node.attrs.checked === false; - let input: HTMLInputElement | null = null; - const content = document.createElement("div"); - if (isTask) { - dom.setAttribute("data-task", node.attrs.checked ? "x" : " "); - input = document.createElement("input"); - input.type = "checkbox"; - input.checked = node.attrs.checked === true; - input.addEventListener("mousedown", (e) => { - // Prevent focusing the checkbox from moving the caret unexpectedly - e.preventDefault(); - }); - input.addEventListener("change", () => { - const pos = typeof getPos === "function" ? getPos() : null; - if (pos == null) return; - const tr = editor.view.state.tr.setNodeMarkup(pos, undefined, { - ...node.attrs, - checked: !node.attrs.checked, - }); - editor.view.dispatch(tr); - }); - dom.appendChild(input); - } - for (const [key, value] of Object.entries( - diffAttrs(node, "element"), - )) { - dom.setAttribute(key, String(value)); - } - dom.appendChild(content); - return { - dom, - contentDOM: content, - update: (newNode) => { - if (newNode.type.name !== "listItem") return false; - const wasTask = isTask; - const isNowTask = - newNode.attrs.checked === true || - newNode.attrs.checked === false; - // If task-state toggled between task/non-task, recreate - if (wasTask !== isNowTask) return false; - if (isNowTask) { - if (input) input.checked = newNode.attrs.checked === true; - dom.setAttribute( - "data-task", - newNode.attrs.checked ? "x" : " ", - ); - } - // Update attrs reference - // @ts-ignore - node is captured; we can't reassign but it's fine for event handlers - node = newNode; - return true; - }, - }; - }; - }, - }), - // blockquote - Node.create({ - name: "blockquote", - group: "block", - content: "block+", - defining: true, - addAttributes() { - return { data: { default: null } }; - }, - renderHTML({ node }) { - return ["blockquote", diffAttrs(node, "element"), 0]; - }, - }), - // code block - Node.create({ - name: "codeBlock", - group: "block", - content: "text*", - marks: "", - defining: true, - code: true, - addAttributes() { - return { language: { default: null }, data: { default: null } }; - }, - renderHTML({ node }) { - const lang = (node as any).attrs?.language ?? null; - const codeAttrs: any = diffAttrs(node); - if (lang) codeAttrs.class = `language-${lang}`; - return ["pre", ["code", codeAttrs, 0]]; - }, - addNodeView() { - return ({ node, editor, getPos }) => - createCodeBlockNodeView({ - node, - editor, - view: editor.view, - getPos, - diffAttrs: diffAttrs(node), - }); - }, - }), - // horizontal rule - Node.create({ - name: "horizontalRule", - group: "block", - addAttributes() { - return { data: { default: null } }; - }, - renderHTML({ node }) { - return ["hr", diffAttrs(node, "element")]; - }, - addCommands() { - const nodeName = this.name; - return { - setHorizontalRule: - () => - ({ commands }: CommandProps) => { - return commands.insertContent({ type: nodeName }); - }, - }; - }, - }), - // Unsupported blocks (html, yaml, etc.) - Node.create({ - name: "markdownUnsupported", - group: "block", - atom: true, - selectable: true, - defining: true, - addAttributes() { - return { - kind: { default: "html" }, - value: { default: "" }, - data: { default: null }, - }; - }, - renderHTML({ node }) { - const kind = (node as any).attrs?.kind ?? "unsupported"; - const label = - kind === "yaml" - ? "YAML frontmatter (read only)" - : "HTML block (read only)"; - const value = (node as any).attrs?.value ?? ""; - return [ - "div", - { - "data-markdown-wc-unsupported": kind, - class: "markdown-wc-unsupported-block", - ...diffAttrs(node, "element"), - }, - ["strong", label], - ["pre", ["code", value]], - ]; - }, - }), - // Inline HTML placeholder - Node.create({ - name: "markdownInlineHtml", - group: "inline", - inline: true, - atom: true, - selectable: true, - addAttributes() { - return { - value: { default: "" }, - data: { default: null }, - }; - }, - renderHTML({ node }) { - return [ - "span", - { - "data-markdown-inline-html": "true", - class: "markdown-wc-inline-html", - ...diffAttrs(node, "element"), - }, - ["code", (node as any).attrs?.value ?? ""], - ]; - }, - }), - // hard break - Node.create({ - name: "hardBreak", - group: "inline", - inline: true, - selectable: false, - addAttributes() { - return { data: { default: null } }; - }, - renderHTML({ node }) { - return ["br", diffAttrs(node, "element")]; - }, - }), - // marks - Mark.create({ - name: "bold", - renderHTML() { - return ["strong", 0]; - }, - }), - Mark.create({ - name: "italic", - renderHTML() { - return ["em", 0]; - }, - }), - Mark.create({ - name: "strike", - renderHTML() { - return ["s", 0]; - }, - }), - Mark.create({ - name: "code", - renderHTML() { - return ["code", 0]; - }, - }), - Mark.create({ - name: "link", - // Don't extend the link when typing at its edges — matches how links - // behave in other editors (you type *out* of a link, not into it). - inclusive: false, - addAttributes() { - return { - href: { - default: null, - parseHTML: (el: any) => el.getAttribute("href"), - }, - title: { - default: null, - parseHTML: (el: any) => el.getAttribute("title"), - }, - data: { default: null }, - }; - }, - parseHTML() { - return [{ tag: "a[href]" }]; - }, - renderHTML({ mark }) { - const attrs: any = {}; - const href = (mark as any).attrs?.href; - if (href) attrs.href = href; - const title = (mark as any).attrs?.title; - if (title) attrs.title = title; - return ["a", attrs, 0]; - }, - }), - // image (inline) - Node.create({ - name: "image", - group: "inline", - inline: true, - atom: true, - addAttributes() { - return { - src: { default: null }, - alt: { default: null }, - title: { default: null }, - data: { default: null }, - }; - }, - renderHTML({ node }) { - const attrs: any = {}; - const src = (node as any).attrs?.src; - if (typeof src === "string" && src.length > 0) { - attrs.src = resolveRenderedImageSrc(src, resolveImageSrc); - } - const alt = (node as any).attrs?.alt; - if (alt) attrs.alt = alt; - const title = (node as any).attrs?.title; - if (title) attrs.title = title; - return ["img", { ...attrs, ...diffAttrs(node, "element") }]; - }, - }), - ]; -} - -function resolveRenderedImageSrc( - src: string, - resolveImageSrc: MarkdownImageSrcResolver | undefined, -): string { - if (!resolveImageSrc) { - return src; - } - try { - return resolveImageSrc(src); - } catch { - return src; - } -} diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/roundtrip.test.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/roundtrip.test.ts deleted file mode 100644 index af9628c1..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/roundtrip.test.ts +++ /dev/null @@ -1,741 +0,0 @@ -// @vitest-environment jsdom -import { describe, expect, test } from "vitest"; -import { astToTiptapDoc } from "./mdwc-to-tiptap"; -import { tiptapDocToAst } from "./tiptap-to-mdwc"; -import { Editor } from "@tiptap/core"; -import { MarkdownWc } from "./markdown-wc"; -import { normalizeAst, parseMarkdown, serializeAst } from "../markdown"; - -type Ast = any; - -function roundtrip(ast: Ast): Ast { - const pmDoc = astToTiptapDoc(ast); - const out = tiptapDocToAst(pmDoc); - return out; -} - -function roundtripThroughEditor(ast: Ast): Ast { - const pmDoc = astToTiptapDoc(ast); - const editor = new Editor({ - extensions: MarkdownWc(), - content: pmDoc, - }); - const outJSON = editor.getJSON(); - const result = tiptapDocToAst(outJSON); - editor.destroy(); - return result; -} - -function roundtripMarkdownThroughEditor(markdown: string): string { - const ast = parseMarkdown(markdown); - const editorAst = roundtripThroughEditor(ast); - return serializeAst(editorAst); -} - -function stripNullData(value: any): any { - if (Array.isArray(value)) { - return value.map(stripNullData); - } - if (!value || typeof value !== "object") { - return value; - } - const out: Record = {}; - for (const [key, inner] of Object.entries(value)) { - if (key === "data" && inner == null) continue; - out[key] = stripNullData(inner); - } - return out; -} - -function canonicalAst(ast: Ast): Ast { - return stripNullData(normalizeAst(ast)); -} - -function renderPlainText(ast: Ast): string { - return (ast?.children ?? []).map(renderBlockPlainText).join("\x1E"); -} - -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 "\n"; - if (isHtmlHardBreak(node)) return "\n"; - 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 === "" - ); -} - -describe("root & paragraph", () => { - test("simple paragraph", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "Hello world." }], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("empty top-level paragraph serializes as a span placeholder", () => { - const markdown = serializeAst( - tiptapDocToAst({ - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "a" }], - }, - { type: "paragraph" }, - { - type: "paragraph", - content: [{ type: "text", text: "b" }], - }, - ], - }), - ); - - expect(markdown).toBe("a\n\n\n\nb\n"); - expect(renderPlainText(parseMarkdown(markdown))).toBe("a\x1E\x1Eb"); - }); - - test("leading and trailing empty top-level paragraphs are preserved", () => { - const markdown = serializeAst( - tiptapDocToAst({ - type: "doc", - content: [ - { type: "paragraph" }, - { - type: "paragraph", - content: [{ type: "text", text: "a" }], - }, - { type: "paragraph" }, - ], - }), - ); - - expect(markdown).toBe("\n\na\n\n\n"); - expect(renderPlainText(parseMarkdown(markdown))).toBe("\x1Ea\x1E"); - }); - - test("span placeholder parses back into an empty editable paragraph", () => { - const pmDoc = astToTiptapDoc(parseMarkdown("\n")); - - expect(pmDoc.content?.[0]?.type).toBe("paragraph"); - expect(pmDoc.content?.[0]?.content ?? []).toEqual([]); - expect(serializeAst(tiptapDocToAst(pmDoc))).toBe("\n"); - }); - - test("trailing hard break serializes as inline br and parses back", () => { - const markdown = serializeAst( - tiptapDocToAst({ - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { type: "text", text: "x" }, - { type: "hardBreak" }, - ], - }, - ], - }), - ); - const pmDoc = astToTiptapDoc(parseMarkdown(markdown)); - - expect(markdown).toBe("x
    \n"); - expect(renderPlainText(parseMarkdown(markdown))).toBe("x\n"); - expect(pmDoc.content?.[0]?.content?.[1]?.type).toBe("hardBreak"); - }); - - test("hard-break-only paragraph keeps a paragraph anchor", () => { - const markdown = serializeAst( - tiptapDocToAst({ - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "hardBreak" }], - }, - ], - }), - ); - const pmDoc = astToTiptapDoc(parseMarkdown(markdown)); - - expect(markdown).toBe("
    \n"); - expect(renderPlainText(parseMarkdown(markdown))).toBe("\n"); - expect(pmDoc.content?.[0]?.type).toBe("paragraph"); - expect(pmDoc.content?.[0]?.content?.[0]?.type).toBe("hardBreak"); - }); - - test("untouched empty document scaffold serializes as empty markdown", () => { - const pmDoc = astToTiptapDoc({ type: "root", children: [] }); - - expect(serializeAst(tiptapDocToAst(pmDoc))).toBe(""); - }); -}); - -describe("vendor data roundtrip", () => { - test("data.id and arbitrary keys survive ast → pm → ast", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "heading", - depth: 2, - data: { id: "H1", foo: "bar", nested: { a: 1 } }, - children: [{ type: "text", value: "Title" }], - }, - { - type: "paragraph", - data: { id: "P1", custom: { x: 42 } }, - children: [{ type: "text", value: "Content" }], - }, - ], - }; - - const pm = astToTiptapDoc(input); - const output = tiptapDocToAst(pm); - - // Expect full structural equality including data.* preservation - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - }); -}); - -describe("heading", () => { - for (let level = 1 as 1 | 2 | 3 | 4 | 5 | 6; level <= 6; level++) { - test(`h${level}`, () => { - const input: Ast = { - type: "root", - children: [ - { - type: "heading", - depth: level, - children: [{ type: "text", value: "Heading" }], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - } -}); - -describe("paragraph marks", () => { - test("bold + italic + text", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [ - { type: "text", value: "Hello " }, - { type: "strong", children: [{ type: "text", value: "world" }] }, - { type: "text", value: " and " }, - { - type: "emphasis", - children: [{ type: "text", value: "friends" }], - }, - { type: "text", value: "." }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("strong only", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [ - { type: "strong", children: [{ type: "text", value: "bold" }] }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("italic only", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [ - { type: "emphasis", children: [{ type: "text", value: "italic" }] }, - ], - }, - ], - } as any; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("inline code", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [{ type: "inlineCode", value: "code" }], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("strikethrough", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [ - { type: "delete", children: [{ type: "text", value: "strike" }] }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("link with text and title", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [ - { - type: "link", - url: "https://example.com", - title: "title", - children: [{ type: "text", value: "text" }], - }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - // is a valid markdown form - test.skip("link retains literal bracket syntax when label matches url", () => { - const markdown = "[https://agents.md/](https://agents.md/)"; - const roundtripped = roundtripMarkdownThroughEditor(markdown); - expect(roundtripped).toBe(markdown); - }); -}); - -describe("unsupported blocks", () => { - test("html block preserved through editor", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "html", - value: '
    Welcome
    ', - data: { id: "html_1" }, - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("yaml frontmatter preserved through editor", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "yaml", - value: "title: Demo\nlayout: doc", - data: { id: "yaml_1" }, - }, - { - type: "paragraph", - children: [{ type: "text", value: "Hello" }], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); -}); - -describe("inline HTML", () => { - test("inline html inside paragraph survives editor", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [ - { type: "text", value: "Hello" }, - { type: "html", value: "beta" }, - { type: "text", value: "world" }, - ], - }, - ], - }; - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); -}); - -describe("lists", () => { - test("unordered", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "list", - ordered: false, - children: [ - { - type: "listItem", - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "one" }], - }, - ], - }, - { - type: "listItem", - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "two" }], - }, - ], - }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("ordered (start omitted = 1)", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "list", - ordered: true, - children: [ - { - type: "listItem", - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "one" }], - }, - ], - }, - { - type: "listItem", - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "two" }], - }, - ], - }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("ordered with start=3", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "list", - ordered: true, - start: 3, - children: [ - { - type: "listItem", - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "three" }], - }, - ], - }, - { - type: "listItem", - children: [ - { - type: "paragraph", - children: [{ type: "text", value: "four" }], - }, - ], - }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("task list (checked/unchecked)", () => { - const input: Ast = { - 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" }], - }, - ], - }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - // Editor roundtrip of task list is exercised in the example app; core mapping equality is asserted here. - }); - - test("task list markdown preserves checked markers through editor serialization", () => { - const markdown = "- [x] done\n- [ ] todo\n"; - - expect(roundtripMarkdownThroughEditor(markdown)).toBe(markdown); - }); - - test("mixed plain and formatted task list markdown preserves markers", () => { - const markdown = - "- plain bullet\n- [ ] unchecked **bold**\n- [x] checked _italic_\n"; - - expect(roundtripMarkdownThroughEditor(markdown)).toBe(markdown); - }); -}); - -describe("blocks", () => { - test("blockquote", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "blockquote", - children: [ - { type: "paragraph", children: [{ type: "text", value: "quote" }] }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - }); - - test("table", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "table", - align: [null, null], - children: [ - { - type: "tableRow", - children: [ - { type: "tableCell", children: [{ type: "text", value: "a" }] }, - { type: "tableCell", children: [{ type: "text", value: "b" }] }, - ], - }, - { - type: "tableRow", - children: [ - { type: "tableCell", children: [{ type: "text", value: "1" }] }, - { type: "tableCell", children: [{ type: "text", value: "2" }] }, - ], - }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - const editorOutput = roundtripThroughEditor(input); - expect(canonicalAst(editorOutput)).toEqual(canonicalAst(input)); - }); - - test("thematic break", () => { - const input: Ast = { type: "root", children: [{ type: "thematicBreak" }] }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - }); - - test("code block", () => { - const input: Ast = { - type: "root", - children: [{ type: "code", lang: "js", value: "const a = 1" }], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - }); - - test("code block without lang", () => { - const input: Ast = { - type: "root", - children: [{ type: "code", value: "plain" }], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - }); - - test("mermaid code block", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "code", - lang: "mermaid", - value: "graph TD\n A --> B", - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - }); -}); - -describe("inline", () => { - test("resolved image render src does not change serialized markdown src", () => { - const ast = parseMarkdown('![Alt](images/logo.png "Logo")'); - const editor = new Editor({ - extensions: MarkdownWc({ - resolveImageSrc: (src) => `file:///workspace/docs/${src}`, - }), - content: astToTiptapDoc(ast), - }); - - const image = editor.view.dom.querySelector("img"); - expect(image?.getAttribute("src")).toBe( - "file:///workspace/docs/images/logo.png", - ); - - const markdown = serializeAst(tiptapDocToAst(editor.getJSON() as any)); - expect(markdown).toContain("images/logo.png"); - expect(markdown).not.toContain("file:///workspace"); - editor.destroy(); - }); - - test("hard break", () => { - const input: Ast = { - type: "root", - children: [ - { - type: "paragraph", - children: [ - { type: "text", value: "line" }, - { type: "break" }, - { type: "text", value: "break" }, - ], - }, - ], - }; - const output = roundtrip(input); - expect(canonicalAst(output)).toEqual(canonicalAst(input)); - }); -}); diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/shortcuts.test.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/shortcuts.test.ts deleted file mode 100644 index 492c14be..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/shortcuts.test.ts +++ /dev/null @@ -1,689 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, describe, expect, test } from "vitest"; -import { Editor } from "@tiptap/core"; -import { MarkdownWc } from "./markdown-wc"; -import { buildMarkdownFromEditor } from "../build-markdown-from-editor"; - -const __editors: Editor[] = []; -function createEditor(content?: any) { - const ed = new Editor({ - extensions: MarkdownWc(), - content, - }); - __editors.push(ed); - return ed; -} - -afterEach(() => { - // Ensure all editors are destroyed to stop ProseMirror DOM observers - for (const ed of __editors.splice(0)) { - try { - ed.destroy(); - } catch {} - } -}); - -// Simulate real text input so input rules trigger -function typeText(editor: Editor, text: string) { - for (const ch of text) { - const { from, to } = editor.state.selection; - let handled = false; - editor.view.someProp("handleTextInput", (f: any) => { - handled = f(editor.view, from, to, ch) || handled; - }); - if (!handled) { - // Fallback: insert as plain content if no handler consumed it - editor.commands.insertContent(ch); - } - } -} - -function sendModKey(editor: Editor, key: string, opts?: { shift?: boolean }) { - const tryPress = (flags: { - metaKey: boolean; - ctrlKey: boolean; - shiftKey?: boolean; - }) => { - const event = new KeyboardEvent("keydown", { - key, - metaKey: flags.metaKey, - ctrlKey: flags.ctrlKey, - shiftKey: !!flags.shiftKey, - bubbles: true, - cancelable: true, - }); - let handled = false; - editor.view.someProp("handleKeyDown", (f: any) => { - handled = f(editor.view, event) || handled; - }); - return handled; - }; - // Try meta-only first (mac style), then ctrl-only (windows/linux) - if (tryPress({ metaKey: true, ctrlKey: false, shiftKey: opts?.shift })) - return true; - return tryPress({ metaKey: false, ctrlKey: true, shiftKey: opts?.shift }); -} - -function sendKey(editor: Editor, key: string, opts?: { shift?: boolean }) { - const event = new KeyboardEvent("keydown", { - key, - shiftKey: !!opts?.shift, - bubbles: true, - cancelable: true, - }); - editor.view.someProp("handleKeyDown", (f: any) => f(editor.view, event)); -} - -function setCursorAfterText(editor: Editor, text: string) { - let position: number | null = null; - editor.state.doc.descendants((node, pos) => { - if (position != null) return false; - if (!node.isText) return true; - const value = node.text ?? ""; - const index = value.indexOf(text); - if (index >= 0) { - position = pos + index + text.length; - return false; - } - return true; - }); - if (position == null) { - throw new Error(`Could not find text: ${text}`); - } - editor.commands.setTextSelection(position); -} - -describe("Markdown typing shortcuts (input rules)", () => { - test.each([ - ["#", 1], - ["##", 2], - ["###", 3], - ["####", 4], - ["#####", 5], - ["######", 6], - ])("%s ␣ → heading level %s", (hashes, level) => { - const editor = createEditor(); - typeText(editor, `${hashes} `); - const node = editor.state.doc.child(0); - expect(node.type.name).toBe("heading"); - expect((node as any).attrs.level).toBe(level); - }); - - test("- ␣ → bullet list", () => { - const editor = createEditor(); - typeText(editor, "- "); - const list = editor.state.doc.child(0); - expect(list.type.name).toBe("bulletList"); - expect(list.childCount).toBeGreaterThan(0); - expect(list.child(0).type.name).toBe("listItem"); - }); - - test("3. ␣ → ordered list start=3", () => { - const editor = createEditor(); - typeText(editor, "3. "); - const list = editor.state.doc.child(0); - expect(list.type.name).toBe("orderedList"); - expect((list as any).attrs.start).toBe(3); - }); - - test("> ␣ → blockquote", () => { - const editor = createEditor(); - typeText(editor, "> "); - const node = editor.state.doc.child(0); - expect(node.type.name).toBe("blockquote"); - }); - - test("[label](url) → link mark with normalized href", () => { - const editor = createEditor(); - typeText(editor, "[Flashtype](flashtype.dev)"); - const md = buildMarkdownFromEditor(editor); - expect(md).toContain("[Flashtype](https://flashtype.dev)"); - }); - - test("[label](https://...) → link keeps explicit scheme", () => { - const editor = createEditor(); - typeText(editor, "[docs](https://example.com/a)"); - const md = buildMarkdownFromEditor(editor); - expect(md).toContain("[docs](https://example.com/a)"); - }); - - test("[label](./path) → link keeps dot-relative href", () => { - const editor = createEditor(); - typeText(editor, "[intro](./intro.md)"); - const md = buildMarkdownFromEditor(editor); - expect(md).toContain("[intro](./intro.md)"); - }); - - test("[label](../path) → link keeps parent-relative href", () => { - const editor = createEditor(); - typeText(editor, "[page](../page)"); - const md = buildMarkdownFromEditor(editor); - expect(md).toContain("[page](../page)"); - }); - - test("link mark does not extend to text typed after the closing paren", () => { - const editor = createEditor(); - typeText(editor, "[site](example.com) tail"); - // Walk the paragraph's inline content; the trailing text must be unlinked. - const paragraph = editor.state.doc.child(0); - let linkedText = ""; - let plainText = ""; - paragraph.descendants((node: any) => { - if (!node.isText) return; - const hasLink = node.marks.some((m: any) => m.type.name === "link"); - if (hasLink) linkedText += node.text; - else plainText += node.text; - }); - expect(linkedText).toBe("site"); - expect(plainText).toContain("tail"); - }); - - test.each([ - ["[] ", false], - ["[ ] ", false], - ["[x] ", true], - ])("%s → task list item (checked=%s)", (trigger, checked) => { - const editor = createEditor(); - // Support creating task from a plain paragraph - typeText(editor, trigger as string); - const list = editor.state.doc.child(0) as any; - expect(list.type.name).toBe("bulletList"); - const li = list.child(0) as any; - expect(li.type.name).toBe("listItem"); - expect(!!li.attrs?.checked).toBe(checked); - // Should not retain trigger text - const para = li.child(0) as any; - expect((para.textContent || "").trim()).toBe(""); - }); - - test.each([ - ["- [] todo", "- [ ] todo\n", false], - ["- [ ] todo", "- [ ] todo\n", false], - ["- [x] done", "- [x] done\n", true], - ])("%s serializes as task-list markdown", (typed, markdown, checked) => { - const editor = createEditor(); - typeText(editor, typed as string); - const list = editor.state.doc.child(0) as any; - const item = list.child(0) as any; - - expect(list.type.name).toBe("bulletList"); - expect(item.attrs?.checked).toBe(checked); - expect(buildMarkdownFromEditor(editor)).toBe(markdown); - }); - - test("- [] serializes as a blank unchecked task item", () => { - const editor = createEditor(); - typeText(editor, "- [] "); - const list = editor.state.doc.child(0) as any; - const item = list.child(0) as any; - - expect(item.attrs?.checked).toBe(false); - expect(buildMarkdownFromEditor(editor)).toBe("- [ ] \n"); - }); - - test("[ ] in a continuation paragraph stays literal text", () => { - const editor = createEditor({ - type: "doc", - content: [ - { - type: "bulletList", - content: [ - { - type: "listItem", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "first paragraph" }], - }, - { - type: "paragraph", - content: [{ type: "text", text: "continuation" }], - }, - ], - }, - ], - }, - ], - }); - - setCursorAfterText(editor, "continuation"); - typeText(editor, "[ ] "); - const item = editor.state.doc.child(0).child(0) as any; - - expect(item.attrs?.checked ?? null).toBeNull(); - expect(buildMarkdownFromEditor(editor)).toBe( - "- first paragraph\n\n continuation\\[ ]\n", - ); - }); -}); - -describe("Keyboard shortcuts (keymap)", () => { - test("Mod-b toggles bold on selection", () => { - const editor = createEditor(); - editor.commands.insertContent("abc"); - editor.commands.setTextSelection({ from: 1, to: 4 }); - sendModKey(editor, "b"); - expect(editor.isActive("bold")).toBe(true); - }); - - test("Mod-i toggles italic on selection", () => { - const editor = createEditor(); - editor.commands.insertContent("abc"); - editor.commands.setTextSelection({ from: 1, to: 4 }); - sendModKey(editor, "i"); - expect(editor.isActive("italic")).toBe(true); - }); - - test("Shift-Mod-s toggles strike on selection", () => { - const editor = createEditor(); - editor.commands.insertContent("abc"); - editor.commands.setTextSelection({ from: 1, to: 4 }); - sendModKey(editor, "s", { shift: true }); - expect(editor.isActive("strike")).toBe(true); - }); - - test("Mod-Backspace deletes the previous word instead of the whole line", () => { - const editor = createEditor(); - editor.commands.insertContent("alpha beta gamma"); - editor.commands.setTextSelection(editor.state.doc.content.size); - sendModKey(editor, "Backspace"); - expect(buildMarkdownFromEditor(editor)).toBe("alpha beta\n"); - }); - - test("Mod-Backspace at the start of a text block does not merge blocks", () => { - const editor = createEditor({ - type: "doc", - content: [ - { type: "paragraph", content: [{ type: "text", text: "alpha" }] }, - { type: "paragraph", content: [{ type: "text", text: "beta" }] }, - ], - }); - setCursorAfterText(editor, "beta"); - editor.commands.setTextSelection( - editor.state.selection.from - "beta".length, - ); - - expect(sendModKey(editor, "Backspace")).toBe(true); - expect(buildMarkdownFromEditor(editor)).toBe("alpha\n\nbeta\n"); - }); - - test("Mod-Backspace deletes the selection", () => { - const editor = createEditor(); - editor.commands.insertContent("alpha beta gamma"); - setCursorAfterText(editor, "alpha "); - const from = editor.state.selection.from; - editor.commands.setTextSelection({ from, to: from + "beta".length }); - sendModKey(editor, "Backspace"); - expect(buildMarkdownFromEditor(editor)).toBe("alpha gamma\n"); - }); - - test("Shift-Enter inserts a hard break inside a paragraph", () => { - const editor = createEditor(); - typeText(editor, "line"); - sendKey(editor, "Enter", { shift: true }); - typeText(editor, "break"); - - const paragraph: any = editor.state.doc.child(0); - expect(editor.state.doc.childCount).toBe(1); - expect(paragraph.type.name).toBe("paragraph"); - expect(paragraph.childCount).toBe(3); - expect(paragraph.child(0).type.name).toBe("text"); - expect(paragraph.child(0).text).toBe("line"); - expect(paragraph.child(1).type.name).toBe("hardBreak"); - expect(paragraph.child(2).type.name).toBe("text"); - expect(paragraph.child(2).text).toBe("break"); - expect(buildMarkdownFromEditor(editor)).toBe("line\\\nbreak\n"); - }); - - test("Shift-Enter in a bullet list inserts a hard break without creating another item", () => { - const editor = createEditor(); - typeText(editor, "- "); - typeText(editor, "line"); - sendKey(editor, "Enter", { shift: true }); - typeText(editor, "break"); - - const list: any = editor.state.doc.child(0); - const item: any = list.child(0); - const paragraph: any = item.child(0); - expect(list.type.name).toBe("bulletList"); - expect(list.childCount).toBe(1); - expect(item.type.name).toBe("listItem"); - expect(paragraph.childCount).toBe(3); - expect(paragraph.child(1).type.name).toBe("hardBreak"); - }); - - test("Enter in bullet list creates another bullet item", () => { - const editor = createEditor(); - typeText(editor, "- "); - typeText(editor, "abc"); - sendKey(editor, "Enter"); - const list: any = editor.state.doc.child(0); - expect(list.type.name).toBe("bulletList"); - expect(list.childCount).toBe(2); - const li2: any = list.child(1); - expect(li2.type.name).toBe("listItem"); - const para2: any = li2.child(0); - expect((para2.textContent || "").trim()).toBe(""); - }); - - test("Enter in ordered list creates another numbered item", () => { - const editor = createEditor(); - typeText(editor, "1. "); - typeText(editor, "abc"); - sendKey(editor, "Enter"); - const list: any = editor.state.doc.child(0); - expect(list.type.name).toBe("orderedList"); - expect(list.childCount).toBe(2); - }); - - test("Enter in todo list creates another unchecked todo", () => { - const editor = createEditor(); - typeText(editor, "[] "); - typeText(editor, "abc"); - sendKey(editor, "Enter"); - const list: any = editor.state.doc.child(0); - expect(list.type.name).toBe("bulletList"); - expect(list.childCount).toBe(2); - const li2: any = list.child(1); - expect(li2.type.name).toBe("listItem"); - expect(li2.attrs?.checked).toBe(false); - expect(buildMarkdownFromEditor(editor)).toBe("- [ ] abc\n- [ ] \n"); - }); - - test("Tab in bullet list indents the current item", () => { - const editor = createEditor({ - type: "doc", - content: [ - { - type: "bulletList", - content: [ - { - type: "listItem", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "parent" }], - }, - ], - }, - { - type: "listItem", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "child" }], - }, - ], - }, - ], - }, - ], - }); - - setCursorAfterText(editor, "child"); - sendKey(editor, "Tab"); - - expect(buildMarkdownFromEditor(editor)).toBe("- parent\n - child\n"); - }); - - test("Shift-Tab in nested bullet list outdents the current item", () => { - const editor = createEditor({ - type: "doc", - content: [ - { - type: "bulletList", - content: [ - { - type: "listItem", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "parent" }], - }, - { - type: "bulletList", - content: [ - { - type: "listItem", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "child" }], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }); - - setCursorAfterText(editor, "child"); - sendKey(editor, "Tab", { shift: true }); - - expect(buildMarkdownFromEditor(editor)).toBe("- parent\n- child\n"); - typeText(editor, " updated"); - expect(buildMarkdownFromEditor(editor)).toBe("- parent\n- child updated\n"); - }); - - test("Enter on empty bullet list item exits the list", () => { - const editor = createEditor(); - typeText(editor, "- "); - typeText(editor, "abc"); - sendKey(editor, "Enter"); // create empty next item - let list: any = editor.state.doc.child(0); - expect(list.type.name).toBe("bulletList"); - expect(list.childCount).toBe(2); - // Now press Enter on empty item to exit - sendKey(editor, "Enter"); - // Expect bullet list + following paragraph - const root: any = editor.state.doc; - expect(root.childCount).toBe(2); - expect(root.child(0).type.name).toBe("bulletList"); - expect(root.child(1).type.name).toBe("paragraph"); - }); - - test("Backspace on empty bullet list item removes the empty item", () => { - const editor = createEditor(); - typeText(editor, "- "); - typeText(editor, "abc"); - sendKey(editor, "Enter"); // create empty next item - sendKey(editor, "Backspace"); - const root: any = editor.state.doc; - expect(root.childCount).toBe(1); - expect(root.child(0).type.name).toBe("bulletList"); - expect(root.child(0).childCount).toBe(1); - expect(buildMarkdownFromEditor(editor)).toBe("- abc\n"); - }); - - test("double Backspace after empty bullet list item keeps the list intact", () => { - const editor = createEditor(); - typeText(editor, "- "); - typeText(editor, "abc"); - sendKey(editor, "Enter"); - sendKey(editor, "Backspace"); - sendKey(editor, "Backspace"); - const root: any = editor.state.doc; - expect(root.childCount).toBe(1); - expect(root.child(0).type.name).toBe("bulletList"); - expect(root.child(0).childCount).toBe(1); - expect(buildMarkdownFromEditor(editor)).toBe("- abc\n"); - }); - - test("Backspace on empty nested bullet removes it without flattening parent", () => { - const editor = createEditor({ - type: "doc", - content: [ - { - type: "bulletList", - content: [ - { - type: "listItem", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Hello world" }], - }, - { - type: "bulletList", - content: [ - { - type: "listItem", - content: [{ type: "paragraph" }], - }, - ], - }, - ], - }, - ], - }, - ], - }); - - const docSize = editor.state.doc.content.size; - editor.commands.setTextSelection(docSize - 3); - sendKey(editor, "Backspace"); - sendKey(editor, "Backspace"); - - expect(buildMarkdownFromEditor(editor)).toBe("- Hello world\n"); - }); - - test("Backspace on empty list item with nested content keeps nested content", () => { - const editor = createEditor({ - type: "doc", - content: [ - { - type: "bulletList", - content: [ - { - type: "listItem", - content: [ - { type: "paragraph" }, - { - type: "bulletList", - content: [ - { - type: "listItem", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "child" }], - }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }); - - editor.commands.setTextSelection(3); - sendKey(editor, "Backspace"); - - const root: any = editor.state.doc; - const item = root.child(0).child(0); - expect(item.childCount).toBe(2); - expect(item.child(1).type.name).toBe("bulletList"); - expect(buildMarkdownFromEditor(editor)).toContain("child"); - }); - - test("Enter on empty ordered list item exits the list", () => { - const editor = createEditor(); - typeText(editor, "1. "); - typeText(editor, "abc"); - sendKey(editor, "Enter"); // create empty next item - let list: any = editor.state.doc.child(0); - expect(list.type.name).toBe("orderedList"); - // Now press Enter on empty item to exit - sendKey(editor, "Enter"); - const root: any = editor.state.doc; - expect(root.child(0).type.name).toBe("orderedList"); - expect(root.child(1).type.name).toBe("paragraph"); - }); - - test("Backspace on empty ordered list item removes the empty item", () => { - const editor = createEditor(); - typeText(editor, "1. "); - typeText(editor, "abc"); - sendKey(editor, "Enter"); // create empty next item - sendKey(editor, "Backspace"); - const root: any = editor.state.doc; - expect(root.child(0).type.name).toBe("orderedList"); - expect(root.child(0).childCount).toBe(1); - expect(buildMarkdownFromEditor(editor)).toBe("1. abc\n"); - }); - - test("Enter on empty todo item exits the list", () => { - const editor = createEditor(); - typeText(editor, "[] "); - typeText(editor, "abc"); - sendKey(editor, "Enter"); // create empty next todo - let list: any = editor.state.doc.child(0); - expect(list.type.name).toBe("bulletList"); - // Now press Enter on empty todo to exit - sendKey(editor, "Enter"); - const root: any = editor.state.doc; - expect(root.child(0).type.name).toBe("bulletList"); - expect(root.child(1).type.name).toBe("paragraph"); - }); - - test("Backspace on empty todo item removes the empty item", () => { - const editor = createEditor(); - typeText(editor, "[] "); - typeText(editor, "abc"); - sendKey(editor, "Enter"); // create empty next todo - sendKey(editor, "Backspace"); - const root: any = editor.state.doc; - expect(root.child(0).type.name).toBe("bulletList"); - expect(root.child(0).childCount).toBe(1); - expect(buildMarkdownFromEditor(editor)).toBe("- [ ] abc\n"); - }); - - // Why this matters: Top-level ids are used for persistence/threading. Pressing Enter - // inside list items should not create/modify top-level ids beyond the list container. - // This test ensures that editing within a list keeps the list's top-level id stable. - test("Enter inside list items does not affect top-level root ids", () => { - const editor = createEditor(); - // Create a bullet list with content - typeText(editor, "- "); - typeText(editor, "abc"); - - const topLevelIds = () => { - const doc: any = editor.getJSON(); - const content: any[] = (doc?.content ?? []) as any[]; - return content - .filter((n) => n?.type === "bulletList") - .map((n) => n?.attrs?.data?.id) - .filter(Boolean); - }; - - // Trigger id assignment by creating the first list - let before = topLevelIds(); - // If the id is not yet assigned, press Enter to force a transaction - if (before.length === 0) { - sendKey(editor, "Enter"); - before = topLevelIds(); - } - expect(before.length).toBe(1); - const listId = before[0]; - - // Create another list item - typeText(editor, "xyz"); - sendKey(editor, "Enter"); - const afterItem = topLevelIds(); - expect(afterItem.length).toBe(1); - expect(afterItem[0]).toBe(listId); - - // Exit the list (Enter on empty item) - sendKey(editor, "Enter"); - const afterExit = topLevelIds(); - expect(afterExit.length).toBe(1); - expect(afterExit[0]).toBe(listId); - }); -}); diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/shortcuts.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/shortcuts.ts deleted file mode 100644 index 50f00836..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/shortcuts.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { - Extension, - InputRule, - textblockTypeInputRule, - wrappingInputRule, -} from "@tiptap/core"; -import { TextSelection } from "@tiptap/pm/state"; -import { normalizeUrl } from "../normalize-url"; - -// Markdown-like typing shortcuts and editor keybindings -// - "# ": Convert to heading (level by number of #) -// - "- ", "* ": Start bullet list -// - "1. ": Start ordered list (captures start) -// - "> ": Start blockquote -// - Mod-b / Mod-i / Shift-Mod-s: Toggle bold/italic/strike -export const MarkdownWcShortcuts = Extension.create({ - name: "markdownWcShortcuts", - priority: 1000, - - addInputRules() { - const rules: any[] = []; - const { schema } = this.editor; - - // Heading: #, ##, ..., ###### + space - if ((schema.nodes as any).heading) { - rules.push( - textblockTypeInputRule({ - find: /^(#{1,6})\s$/, - type: (schema.nodes as any).heading, - getAttributes: (match) => ({ level: match[1]?.length || 1 }), - }), - ); - } - - // Bullet list: - or * + space - if ((schema.nodes as any).bulletList && (schema.nodes as any).listItem) { - const bullet = (char: string) => - wrappingInputRule({ - find: new RegExp(`^\\${char}\\s$`), - type: (schema.nodes as any).bulletList, - }); - rules.push(bullet("-")); - rules.push(bullet("*")); - } - - // Ordered list: 1. + space (captures custom start) - if ((schema.nodes as any).orderedList && (schema.nodes as any).listItem) { - rules.push( - wrappingInputRule({ - find: /^(\d+)\.\s$/, - type: (schema.nodes as any).orderedList, - getAttributes: (match) => ({ start: Number(match[1] || 1) }), - }), - ); - } - - // Blockquote: > + space - if ((schema.nodes as any).blockquote) { - rules.push( - wrappingInputRule({ - find: /^>\s$/, - type: (schema.nodes as any).blockquote, - }), - ); - } - - // Task list / TODOs: Notion-style only → "[] ", "[ ] ", "[x] " - if ((schema.nodes as any).bulletList && (schema.nodes as any).listItem) { - const patterns = [/^\[\]\s$/, /^\[ \]\s$/, /^\[(x|X)\]\s$/]; - for (const re of patterns) { - rules.push( - new InputRule({ - find: re as any, - // @ts-expect-error - typing are outdated - handler: ({ state, range, match, commands }) => { - const checked = /x/i.test(String((match && match[1]) || "")); - const $from: any = (state as any).selection.$from; - // Check if we're inside an existing bullet list - for (let d = $from.depth; d > 0; d--) { - const n = $from.node(d); - if (n?.type?.name === "bulletList") { - return commands.command(({ state, tr, dispatch }: any) => { - const selectionFrom = state.selection.$from; - let listItemDepth = -1; - for (let depth = selectionFrom.depth; depth > 0; depth--) { - const node = selectionFrom.node(depth); - if (node?.type?.name === "listItem") { - listItemDepth = depth; - break; - } - } - if (listItemDepth < 0) return false; - const listItemPos = selectionFrom.before(listItemDepth); - const listItem = selectionFrom.node(listItemDepth); - if (listItem.firstChild !== selectionFrom.parent) { - return false; - } - tr.delete(range.from, range.to); - tr.setNodeMarkup(listItemPos, undefined, { - ...listItem.attrs, - checked, - }); - if (dispatch) dispatch(tr); - return true; - }); - } - } - - // Not in a list: delete trigger and replace the nearest paragraph - // with bulletList > listItem(checked) > paragraph (preserving content after deletion) - return commands.command(({ state, tr, dispatch }: any) => { - // 1) Delete the trigger text using the provided range - tr.delete(range.from, range.to); - // 2) Find the nearest paragraph around the current selection - const $from = tr.selection.$from; - let paraDepth = -1; - for (let d = $from.depth; d > 0; d--) { - const n = $from.node(d); - if (n?.type?.name === "paragraph") { - paraDepth = d; - break; - } - } - if (paraDepth < 0) return false; - const paraNode = $from.node(paraDepth); - const fromPos = $from.before(paraDepth); - const toPos = fromPos + paraNode.nodeSize; - // 3) Build new wrapper structure reusing paragraph content - const nodes: any = (state.schema as any).nodes; - const newParagraph = nodes.paragraph.create( - paraNode.attrs, - paraNode.content, - ); - const listItem = nodes.listItem.create( - { checked }, - newParagraph, - ); - const bulletList = nodes.bulletList.create(null, listItem); - tr.replaceWith(fromPos, toPos, bulletList); - if (dispatch) dispatch(tr); - return true; - }); - }, - }), - ); - } - } - - // Inline link: typing "[label](url)" converts to linked text. - if ((schema.marks as any).link) { - rules.push( - new InputRule({ - find: /\[([^\]]+)\]\(([^()\s]+)\)$/, - // @ts-expect-error - typings are outdated - handler: ({ state, range, match, commands }) => { - const linkType = (state.schema.marks as any).link; - if (!linkType) return null; - const label = String((match && match[1]) || ""); - const href = normalizeUrl(String((match && match[2]) || "")); - if (!label || !href) return null; - return commands.command(({ tr, dispatch }: any) => { - tr.insertText(label, range.from, range.to); - tr.addMark( - range.from, - range.from + label.length, - linkType.create({ href }), - ); - // Don't carry the link mark into whatever is typed next. - tr.removeStoredMark(linkType); - if (dispatch) dispatch(tr); - return true; - }); - }, - }), - ); - } - - return rules; - }, - - addKeyboardShortcuts() { - const flushDomSelection = () => { - (this.editor.view as any).domObserver?.flush?.(); - }; - - const deletePreviousWord = () => { - flushDomSelection(); - const { state, view } = this.editor; - const { selection } = state; - if (!selection.empty) { - return this.editor.commands.deleteSelection(); - } - - const $from: any = selection.$from; - const parent: any = $from.parent; - if (!parent?.isTextblock || $from.parentOffset === 0) return true; - - const textBefore = parent.textBetween( - 0, - $from.parentOffset, - "\uFFFC", - "\uFFFC", - ); - const textToDelete = previousWordText(textBefore); - if (!textToDelete) return false; - - const from = Math.max($from.start(), $from.pos - textToDelete.length); - if (from >= $from.pos) return false; - - view.dispatch(state.tr.delete(from, $from.pos).scrollIntoView()); - return true; - }; - - const insertHardBreak = () => { - flushDomSelection(); - const { state, view } = this.editor; - const hardBreak = (state.schema.nodes as any).hardBreak; - if (!hardBreak) return false; - - const { selection } = state; - let tr = state.tr; - if (!selection.empty) { - tr = tr.deleteSelection(); - } - const { $from, $to } = tr.selection as any; - if (!$from.sameParent($to) || !$from.parent?.isTextblock) { - return false; - } - if (!$from.parent.canReplaceWith($from.index(), $to.index(), hardBreak)) { - return false; - } - - const marks = - tr.storedMarks ?? - state.storedMarks ?? - ($from.parentOffset ? $from.marks() : null); - tr.replaceSelectionWith(hardBreak.create()); - if (marks) tr.ensureMarks(marks); - view.dispatch(tr.scrollIntoView()); - return true; - }; - - return { - // Bold / Italic / Strike - "Mod-b": () => this.editor.chain().focus().toggleMark("bold").run(), - "Mod-i": () => this.editor.chain().focus().toggleMark("italic").run(), - "Shift-Mod-s": () => - this.editor.chain().focus().toggleMark("strike").run(), - - "Mod-Backspace": deletePreviousWord, - "Cmd-Backspace": deletePreviousWord, - "Ctrl-Backspace": deletePreviousWord, - - Tab: () => { - const { state } = this.editor; - const $from: any = state.selection.$from; - for (let d = $from.depth; d > 0; d--) { - if ($from.node(d)?.type?.name === "listItem") { - return this.editor.chain().focus().sinkListItem("listItem").run(); - } - } - return false; - }, - - "Shift-Tab": () => { - const { state } = this.editor; - const $from: any = state.selection.$from; - let listItemDepth = -1; - for (let d = $from.depth; d > 0; d--) { - if ($from.node(d)?.type?.name === "listItem") { - listItemDepth = d; - break; - } - } - if (listItemDepth < 3) return false; - - const listDepth = listItemDepth - 1; - const parentListItemDepth = listItemDepth - 2; - const listNode = $from.node(listDepth); - const listItem = $from.node(listItemDepth); - const parentListItem = $from.node(parentListItemDepth); - if ( - (listNode?.type?.name !== "bulletList" && - listNode?.type?.name !== "orderedList") || - parentListItem?.type?.name !== "listItem" - ) { - return false; - } - - return this.editor.commands.command(({ tr, dispatch }) => { - const listFrom = $from.before(listDepth); - const listTo = $from.after(listDepth); - const parentListItemTo = $from.after(parentListItemDepth); - const listItemIndex = $from.index(listDepth); - const offsetInListItem = $from.pos - $from.before(listItemDepth); - const beforeItems = []; - const afterItems = []; - for (let index = 0; index < listNode.childCount; index++) { - const child = listNode.child(index); - if (index < listItemIndex) beforeItems.push(child); - if (index > listItemIndex) afterItems.push(child); - } - - if (listNode.childCount === 1) { - tr.delete(listFrom, listTo); - } else { - const replacementLists = []; - if (beforeItems.length > 0) { - replacementLists.push( - listNode.type.create(listNode.attrs, beforeItems), - ); - } - if (afterItems.length > 0) { - replacementLists.push( - listNode.type.create(listNode.attrs, afterItems), - ); - } - tr.replaceWith(listFrom, listTo, replacementLists); - } - - const mappedInsertPos = tr.mapping.map(parentListItemTo); - tr.insert(mappedInsertPos, listItem); - const mappedSelectionPos = mappedInsertPos + offsetInListItem; - tr.setSelection( - TextSelection.near(tr.doc.resolve(mappedSelectionPos)), - ); - if (dispatch) dispatch(tr.scrollIntoView()); - return true; - }); - }, - - "Shift-Enter": insertHardBreak, - - Backspace: () => { - const { state } = this.editor; - const { selection } = state; - if (!selection.empty) return false; - const $from: any = selection.$from; - const para: any = $from.parent; - const isEmptyPara = - para?.type?.name === "paragraph" && - (para.textContent || "").length === 0; - if (!isEmptyPara || $from.parentOffset !== 0) return false; - - let listItemDepth = -1; - for (let d = $from.depth; d > 0; d--) { - const n = $from.node(d); - if (n?.type?.name === "listItem") { - listItemDepth = d; - break; - } - } - if (listItemDepth < 0) return false; - if ($from.node(listItemDepth).firstChild !== para) return false; - - const listDepth = listItemDepth - 1; - const listNode = listDepth > 0 ? $from.node(listDepth) : null; - const listItem = $from.node(listItemDepth); - const listItemIndex = $from.index(listDepth); - if (listNode?.childCount > 1 && listItem.childCount === 1) { - return this.editor - .chain() - .focus() - .deleteRange({ - from: $from.before(listItemDepth), - to: $from.after(listItemDepth), - }) - .run(); - } - if ( - listDepth > 1 && - listNode?.childCount === 1 && - listItem.childCount === 1 - ) { - return this.editor - .chain() - .focus() - .deleteRange({ - from: $from.before(listDepth), - to: $from.after(listDepth), - }) - .run(); - } - if ( - listDepth === 1 && - listNode?.childCount === 1 && - listItemIndex === 0 && - listItem.childCount === 1 - ) { - return this.editor - .chain() - .focus() - .deleteRange({ - from: $from.before(listDepth), - to: $from.after(listDepth), - }) - .insertContent({ type: "paragraph" }) - .run(); - } - if (listItem.childCount > 1) return true; - - return false; - }, - - // Enter in list: create a new list item; for tasks, make it unchecked - Enter: () => { - flushDomSelection(); - const { state } = this.editor; - const $from: any = state.selection.$from; - // Find enclosing listItem - let inListItem = false; - let isTask = false; - for (let d = $from.depth; d > 0; d--) { - const n = $from.node(d); - if (n?.type?.name === "listItem") { - inListItem = true; - isTask = n.attrs?.checked === true || n.attrs?.checked === false; - break; - } - } - if (!inListItem) { - // Enter replaces a range selection before splitting the remaining block. - // Running both commands in one chain keeps the split position mapped to - // the document produced by the deletion. - if (state.selection.empty) { - return this.editor.commands.splitBlock(); - } - return this.editor.chain().deleteSelection().splitBlock().run(); - } - // If current paragraph is empty, exit the list (lift) - const para: any = $from.parent; - const isEmptyPara = - para?.type?.name === "paragraph" && - (para.textContent || "").length === 0; - if (isEmptyPara) { - return this.editor.commands.liftListItem("listItem"); - } - if (isTask) { - return this.editor.commands.splitListItem("listItem", { - checked: false, - }); - } - return this.editor.commands.splitListItem("listItem"); - }, - }; - }, -}); - -function previousWordText(textBeforeCursor: string): string { - return textBeforeCursor.match(/\s*\S+$/u)?.[0] ?? ""; -} diff --git a/src/extensions/markdown/editor/tiptap-markdown-bridge/tiptap-to-mdwc.ts b/src/extensions/markdown/editor/tiptap-markdown-bridge/tiptap-to-mdwc.ts deleted file mode 100644 index a2f56a5b..00000000 --- a/src/extensions/markdown/editor/tiptap-markdown-bridge/tiptap-to-mdwc.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { normalizeAst } from "../markdown"; -import { - EMPTY_MARKDOWN_PARAGRAPH_DATA_KEY, - EMPTY_MARKDOWN_SCAFFOLD_DATA_KEY, -} from "./mdwc-to-tiptap"; - -const SPREAD_META_KEY = "__mdwc_spread"; - -function extractNodeData(attrs: PMNode["attrs"]): { - data?: Record; - spread?: boolean; -} { - const raw = attrs?.data; - if (!raw || typeof raw !== "object") { - return { data: undefined }; - } - const clone: Record = { ...raw }; - let spread: boolean | undefined; - if (SPREAD_META_KEY in clone) { - const value = clone[SPREAD_META_KEY]; - if (typeof value === "boolean") { - spread = value; - } - delete clone[SPREAD_META_KEY]; - } - delete clone[EMPTY_MARKDOWN_SCAFFOLD_DATA_KEY]; - delete clone[EMPTY_MARKDOWN_PARAGRAPH_DATA_KEY]; - return { - data: Object.keys(clone).length > 0 ? clone : undefined, - spread, - }; -} - -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 function tiptapDocToAst(doc: PMNode): any { - const outChildren: any[] = []; - const children = doc.content || []; - for (const n of children) { - if (n.type === "paragraph") { - const inline = pmInlineToMd(n.content || []); - if ( - !inline.length && - children.length === 1 && - !isExplicitEmptyParagraph(n) - ) { - continue; - } - } - if ( - n.type === "heading" && - n.attrs?.data?.[EMPTY_MARKDOWN_SCAFFOLD_DATA_KEY] - ) { - const inline = pmInlineToMd(n.content || []); - if (!inline.length) continue; - } - const preserveEmptyParagraph = - n.type === "paragraph" && - !pmInlineToMd(n.content || []).length && - (children.length > 1 || isExplicitEmptyParagraph(n)); - outChildren.push(pmBlockToAst(n, { preserveEmptyParagraph })); - } - return normalizeAst({ type: "root", children: outChildren } as any); -} - -function isExplicitEmptyParagraph(node: PMNode): boolean { - return Boolean(node.attrs?.data?.[EMPTY_MARKDOWN_PARAGRAPH_DATA_KEY]); -} - -function emptyParagraphPlaceholderChildren(): any[] { - return [ - { type: "html", value: "" }, - { type: "html", value: "" }, - ]; -} - -function anchorHardBreakOnlyParagraph(children: any[]): any[] { - return children.length > 0 && children.every(isHtmlHardBreak) - ? [...emptyParagraphPlaceholderChildren(), ...children] - : children; -} - -function pmBlockToAst( - node: PMNode, - options: { preserveEmptyParagraph?: boolean } = {}, -): any { - switch (node.type) { - case "paragraph": - const paraData = extractNodeData(node.attrs); - const inline = anchorHardBreakOnlyParagraph( - pmInlineToMd(node.content || []), - ); - return { - type: "paragraph", - data: paraData.data, - children: - inline.length || !options.preserveEmptyParagraph - ? inline - : emptyParagraphPlaceholderChildren(), - }; - case "heading": - const headingData = extractNodeData(node.attrs); - return { - type: "heading", - depth: node.attrs?.level || 1, - data: headingData.data, - children: pmInlineToMd(node.content || []), - }; - case "bulletList": - case "orderedList": { - const listData = extractNodeData(node.attrs); - const spread = - listData.spread === undefined ? undefined : listData.spread; - const ordered = node.type === "orderedList"; - const base: any = { - type: "list", - ordered, - data: listData.data, - children: (node.content || []).map((child) => pmBlockToAst(child)), - }; - if (spread !== undefined) base.spread = spread; - if (ordered && node.attrs?.start != null && node.attrs.start !== 1) - base.start = node.attrs.start; - return base as any; - } - case "listItem": { - const listItemData = extractNodeData(node.attrs); - const out: any = { - type: "listItem", - children: (node.content || []).map((child) => pmBlockToAst(child)), - }; - if (listItemData.data) out.data = listItemData.data; - if (listItemData.spread !== undefined) out.spread = listItemData.spread; - if ( - node.attrs && - (node.attrs.checked === true || node.attrs.checked === false) - ) - out.checked = node.attrs.checked; - return out; - } - - case "blockquote": - const blockquoteData = extractNodeData(node.attrs); - return { - type: "blockquote", - data: blockquoteData.data, - children: (node.content || []).map((child) => pmBlockToAst(child)), - }; - case "codeBlock": { - const text = collectText(node.content || []); - const lang = node.attrs?.language; - const out: any = { type: "code", value: text }; - const codeData = extractNodeData(node.attrs); - if (codeData.data) out.data = codeData.data; - if (lang != null) out.lang = lang; - return out; - } - case "horizontalRule": { - const hrData = extractNodeData(node.attrs); - return { type: "thematicBreak", data: hrData.data }; - } - case "table": { - const align = node.attrs?.align ?? []; - const tableData = extractNodeData(node.attrs); - return { - type: "table", - align, - data: tableData.data, - children: (node.content || []).map((child) => pmBlockToAst(child)), - } as any; - } - case "tableRow": { - const rowData = extractNodeData(node.attrs); - return { - type: "tableRow", - data: rowData.data, - children: (node.content || []).map((child) => pmBlockToAst(child)), - }; - } - case "tableCell": { - const cellData = extractNodeData(node.attrs); - return { - type: "tableCell", - data: cellData.data, - children: pmInlineToMd(node.content || []), - }; - } - case "markdownUnsupported": { - const unsupportedData = extractNodeData(node.attrs); - const kind = node.attrs?.kind ?? "html"; - const value = node.attrs?.value ?? ""; - if (kind === "yaml") { - return { - type: "yaml", - value: value as string, - data: unsupportedData.data, - }; - } - return { - type: "html", - value: value as string, - data: unsupportedData.data, - }; - } - default: - if ( - node.content && - node.content.length && - isInline(node.content[0] as any) - ) { - const inlineData = extractNodeData(node.attrs); - return { - type: "paragraph", - data: inlineData.data, - children: pmInlineToMd(node.content), - }; - } - const fallbackData = extractNodeData(node.attrs); - return { type: "paragraph", data: fallbackData.data, children: [] }; - } -} - -function pmInlineToMd(nodes: PMNode[]): any[] { - const out: any[] = []; - for (let index = 0; index < nodes.length; index += 1) { - const n = nodes[index]; - if (n.type === "text") { - out.push(applyMarksToText(n.text || "", n.marks || [])); - } else if (n.type === "hardBreak") { - const br: any = isTrailingHardBreak(nodes, index) - ? { type: "html", value: "
    " } - : { type: "break" }; - if (n.attrs?.data != null) br.data = n.attrs.data; - out.push(br as any); - } else if (n.type === "markdownInlineHtml") { - const htmlValue = (n.attrs?.value ?? "") as string; - const htmlData = n.attrs?.data ?? null; - const htmlNode: any = { type: "html", value: htmlValue }; - if (htmlData != null) htmlNode.data = htmlData; - out.push(htmlNode); - } else if (n.type === "image") { - const src = n.attrs?.src ?? null; - const title = n.attrs?.title ?? null; - const alt = n.attrs?.alt ?? null; - const im: any = { type: "image", url: src, title, alt }; - if (n.attrs?.data != null) im.data = n.attrs.data; - out.push(im as any); - } - } - return out; -} - -function isTrailingHardBreak(nodes: PMNode[], index: number): boolean { - for (let nextIndex = index + 1; nextIndex < nodes.length; nextIndex += 1) { - if (nodes[nextIndex]?.type !== "hardBreak") return false; - } - return true; -} - -function isHtmlHardBreak(node: any): boolean { - return ( - node?.type === "html" && - typeof node.value === "string" && - /^$/i.test(node.value) - ); -} - -function applyMarksToText(value: string, marks: PMMark[]): any { - let node: any = { type: "text", value } as any; - const order: PMMark["type"][] = ["bold", "italic", "strike", "code", "link"]; - for (const t of order) { - if (marks.find((m) => m.type === t)) { - if (t === "bold") node = { type: "strong", children: [node] } as any; - else if (t === "italic") - node = { type: "emphasis", children: [node] } as any; - else if (t === "strike") - node = { type: "delete", children: [node] } as any; - else if (t === "code") node = { type: "inlineCode", value } as any; - else if (t === "link") { - const mark = marks.find((m) => m.type === "link")!; - const href = mark.attrs?.href ?? null; - const title = mark.attrs?.title ?? null; - const ln: any = { type: "link", url: href, title, children: [node] }; - if (mark.attrs?.data != null) ln.data = mark.attrs.data; - node = ln as any; - } - } - } - return node; -} - -function isInline(n: PMNode) { - return ( - !n.content && - (n.text != null || - n.type === "hardBreak" || - n.type === "markdownInlineHtml") - ); -} - -function collectText(nodes: PMNode[]): string { - return (nodes || []) - .map((n) => (n.type === "text" ? n.text || "" : "")) - .join(""); -} diff --git a/src/extensions/markdown/editor/upsert-markdown-file.ts b/src/extensions/markdown/editor/upsert-markdown-file.ts deleted file mode 100644 index ac13b424..00000000 --- a/src/extensions/markdown/editor/upsert-markdown-file.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { Lix } from "@/lib/lix-types"; -import { qb } from "@/lib/lix-kysely"; -import { - captureTelemetryThrottled, - fileExtensionProperty, - workspaceTelemetryProperties, -} from "@/lib/telemetry"; -import { readWorkspaceId } from "@/lib/workspace-profile-telemetry"; - -export async function upsertMarkdownFile(args: { - lix: Lix; - fileId: string; - markdown: string; - path?: string; - metadata?: any; - createIfMissing?: boolean; - originKey?: string; -}): Promise { - const { - lix, - fileId, - markdown, - path, - metadata, - createIfMissing = true, - originKey, - } = args; - const data = new TextEncoder().encode(markdown); - const db = qb(lix); - - const existing = await db - .selectFrom("lix_file") - .select(["id", "path", "lixcol_metadata"]) - .where("id", "=", fileId) - .executeTakeFirst(); - - if (existing) { - const resolvedPath = path ?? existing.path ?? `/${fileId}.md`; - const resolvedMetadata = metadata ?? existing.lixcol_metadata ?? null; - const updateValues: { - data: Uint8Array; - path?: string; - lixcol_metadata?: any; - } = { data }; - if (path !== undefined && resolvedPath !== existing.path) { - updateValues.path = resolvedPath; - } - if (metadata !== undefined && metadata !== existing.lixcol_metadata) { - updateValues.lixcol_metadata = resolvedMetadata; - } - await executeMarkdownFileWrite( - lix, - { - sql: `UPDATE lix_file SET ${Object.keys(updateValues) - .map((column) => `${column} = ?`) - .join(", ")} WHERE id = ?`, - params: [...Object.values(updateValues), fileId], - }, - originKey, - ); - captureDocumentModifiedTelemetry({ lix, fileId, filePath: resolvedPath }); - } else { - if (!createIfMissing) return; - // Insert requires a path; use provided or fallback to /.md - await executeMarkdownFileWrite( - lix, - { - sql: "INSERT INTO lix_file (id, path, data, lixcol_metadata) VALUES (?, ?, ?, ?)", - params: [fileId, path ?? `/${fileId}.md`, data, metadata ?? null], - }, - originKey, - ); - captureDocumentModifiedTelemetry({ - lix, - fileId, - filePath: path ?? `/${fileId}.md`, - }); - } -} - -async function executeMarkdownFileWrite( - lix: Lix, - statement: { sql: string; params: ReadonlyArray }, - originKey: string | undefined, -): Promise { - if (originKey) { - await lix.execute(statement.sql, statement.params, { originKey }); - return; - } - await lix.execute(statement.sql, statement.params); -} - -function captureDocumentModifiedTelemetry({ - lix, - fileId, - filePath, -}: { - readonly lix: Lix; - readonly fileId: string; - readonly filePath: string; -}) { - void (async () => { - const workspaceId = await readWorkspaceId(lix); - captureTelemetryThrottled( - `document_modified:${fileId}`, - "document_modified", - { - file_extension: fileExtensionProperty(filePath), - modified_by: "user", - source: "renderer", - ...workspaceTelemetryProperties(workspaceId), - }, - ); - })().catch((error: unknown) => { - console.warn("Failed to capture document_modified telemetry", error); - }); -} diff --git a/src/extensions/markdown/index.test.tsx b/src/extensions/markdown/index.test.tsx deleted file mode 100644 index 461cc6cd..00000000 --- a/src/extensions/markdown/index.test.tsx +++ /dev/null @@ -1,626 +0,0 @@ -import { Suspense } from "react"; -import { act, render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, test } from "vitest"; -import { LixProvider } from "@/lib/lix-react"; -import { openLix } from "@/test-utils/node-lix-sdk"; -import { MarkdownView } from "./index"; -import { KeyValueProvider } from "@/hooks/key-value/use-key-value"; -import { KEY_VALUE_DEFINITIONS } from "@/hooks/key-value/schema"; -import { qb } from "@/lib/lix-kysely"; -import { appendAgentTurnCommitRange } from "@/shell/agent-turn-review-range"; - -describe("MarkdownView", () => { - test("throws when no file id is provided", () => { - expect(() => render()).toThrow( - "MarkdownView requires a non-empty fileId.", - ); - }); - - test("renders the TipTap editor when file is found", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_1", - path: "/docs/readme.md", - data: new TextEncoder().encode("# Hello world"), - }) - .execute(); - - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: "flashtype_active_file_id", - value: "file_1", - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }) - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - expect(await screen.findByTestId("tiptap-editor")).toBeInTheDocument(); - expect( - screen - .getByTestId("tiptap-editor") - .closest("[data-attr='markdown-editor']"), - ).toBeInTheDocument(); - - await waitFor(async () => { - const rows = await qb(lix) - .selectFrom("lix_key_value_by_branch") - .where("key", "=", "flashtype_active_file_id") - .select(["value"]) - .execute(); - expect(rows[0]?.value).toBe("file_1"); - }); - - await act(async () => { - utils?.unmount(); - }); - }); - - test("renders a read-only historical snapshot from afterCommitId", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_snapshot", - path: "/snapshot.md", - data: new TextEncoder().encode("# Snapshot version"), - }) - .execute(); - const snapshotCommitId = await activeCommitId(lix); - await qb(lix) - .updateTable("lix_file") - .set({ data: new TextEncoder().encode("# Head version") }) - .where("id", "=", "file_snapshot") - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - await waitFor(() => { - expect(utils!.container).toHaveTextContent("Snapshot version"); - }); - expect(utils!.container).not.toHaveTextContent("Head version"); - expect(screen.queryByTestId("tiptap-editor")).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /keep/i })).toBeNull(); - expect(screen.queryByRole("button", { name: /undo/i })).toBeNull(); - - await act(async () => { - utils?.unmount(); - }); - await lix.close(); - }); - - test("renders a read-only diff from beforeCommitId to HEAD", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_head_diff", - path: "/head-diff.md", - data: new TextEncoder().encode("# Before version"), - }) - .execute(); - const beforeCommitId = await activeCommitId(lix); - await qb(lix) - .updateTable("lix_file") - .set({ data: new TextEncoder().encode("# Head version") }) - .where("id", "=", "file_head_diff") - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - await waitFor(() => { - expect( - utils!.container.querySelector(".markdown-review-overlay"), - ).toBeInTheDocument(); - expect( - utils!.container.querySelector("[data-diff-status]"), - ).toBeInTheDocument(); - }); - await waitFor(() => { - expect(screen.getByText("Before")).toBeInTheDocument(); - expect(screen.getByText("Head")).toBeInTheDocument(); - expect(utils!.container).toHaveTextContent("version"); - }); - expect(screen.queryByRole("button", { name: /keep/i })).toBeNull(); - expect(screen.queryByRole("button", { name: /undo/i })).toBeNull(); - - await act(async () => { - utils?.unmount(); - }); - await lix.close(); - }); - - test("does not mark unchanged before-to-HEAD files as fully added", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_unchanged_head_diff", - path: "/unchanged-head-diff.md", - data: new TextEncoder().encode("# Stable version"), - }) - .execute(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_other_head_diff", - path: "/other-head-diff.md", - data: new TextEncoder().encode("# Other before"), - }) - .execute(); - const beforeCommitId = await activeCommitId(lix); - await qb(lix) - .updateTable("lix_file") - .set({ data: new TextEncoder().encode("# Other after") }) - .where("id", "=", "file_other_head_diff") - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - await waitFor(() => { - expect(utils!.container).toHaveTextContent("Stable version"); - }); - expect( - utils!.container.querySelector("[data-diff-status='added']"), - ).toBeNull(); - expect( - utils!.container.querySelector("[data-diff-status='removed']"), - ).toBeNull(); - - await act(async () => { - utils?.unmount(); - }); - await lix.close(); - }); - - test("shows an autosave hint when pressing Cmd+S", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_autosave_hint", - path: "/docs/autosave.md", - data: new TextEncoder().encode("# Autosave"), - }) - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - expect(await screen.findByTestId("tiptap-editor")).toBeInTheDocument(); - - const event = new KeyboardEvent("keydown", { - key: "s", - metaKey: true, - bubbles: true, - cancelable: true, - }); - await act(async () => { - window.dispatchEvent(event); - }); - - expect(event.defaultPrevented).toBe(true); - expect(await screen.findByRole("status")).toHaveTextContent( - /auto-saved.*no cmd\+s needed/i, - ); - - await act(async () => { - utils?.unmount(); - }); - }); - - test("renders the TipTap editor for .markdown files", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_markdown", - path: "/docs/guide.markdown", - data: new TextEncoder().encode("# Guide"), - }) - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - expect(await screen.findByTestId("tiptap-editor")).toBeInTheDocument(); - - await act(async () => { - utils?.unmount(); - }); - }); - - test("renders the TipTap editor for uppercase markdown extensions", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_uppercase", - path: "/docs/README.MD", - data: new TextEncoder().encode("# Readme"), - }) - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - expect(await screen.findByTestId("tiptap-editor")).toBeInTheDocument(); - - await act(async () => { - utils?.unmount(); - }); - }); - - test("shows an unsupported file prompt for non-markdown files", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_csv", - path: "/data.csv", - data: new TextEncoder().encode("name,value\nalpha,1"), - }) - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - expect( - await screen.findByText(/this file type is not supported yet/i), - ).toBeInTheDocument(); - expect( - screen.getByRole("link", { name: /open an issue/i }), - ).toHaveAttribute("href", "https://github.com/opral/flashtype/issues"); - expect(screen.queryByText(/alpha,1/)).not.toBeInTheDocument(); - expect(screen.queryByTestId("tiptap-editor")).not.toBeInTheDocument(); - - await act(async () => { - utils?.unmount(); - }); - }); - - test("does not sync unsupported files as the active markdown file", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_csv", - path: "/data.csv", - data: new TextEncoder().encode("name,value\nalpha,1"), - }) - .execute(); - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: "flashtype_active_file_id", - value: "existing_markdown", - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }) - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - expect( - await screen.findByText(/this file type is not supported yet/i), - ).toBeInTheDocument(); - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - const record = await qb(lix) - .selectFrom("lix_key_value_by_branch") - .select(["value"]) - .where("key", "=", "flashtype_active_file_id") - .executeTakeFirst(); - expect(record?.value).toBe("existing_markdown"); - - await act(async () => { - utils?.unmount(); - }); - }); - - test("renders the requested file even if a different active file is stored", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_alpha", - path: "/alpha.md", - data: new TextEncoder().encode("# Alpha"), - }) - .execute(); - - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_beta", - path: "/beta.md", - data: new TextEncoder().encode("# Beta"), - }) - .execute(); - - // Persist a stale active file id pointing to alpha - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: "flashtype_active_file_id", - value: "file_alpha", - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }) - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - const editor = await screen.findByTestId("tiptap-editor"); - expect(editor).toHaveTextContent("Beta"); - - await waitFor(async () => { - const record = await qb(lix) - .selectFrom("lix_key_value_by_branch") - .select(["value"]) - .where("key", "=", "flashtype_active_file_id") - .executeTakeFirst(); - expect(record?.value).toBe("file_beta"); - }); - - await act(async () => { - utils?.unmount(); - }); - }); - - test("shows review controls for a file already mounted before the range is persisted", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_review_startup", - path: "/review-startup.md", - data: new TextEncoder().encode("# Before"), - }) - .execute(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - expect(await screen.findByTestId("tiptap-editor")).toBeInTheDocument(); - const beforeCommitId = await activeCommitId(lix); - - await act(async () => { - await qb(lix) - .updateTable("lix_file") - .set({ data: new TextEncoder().encode("# After") }) - .where("id", "=", "file_review_startup") - .execute(); - }); - const afterCommitId = await activeCommitId(lix); - - await act(async () => { - await appendAgentTurnCommitRange(lix, { - id: "range-review-startup", - sourceId: "codex", - beforeCommitId, - afterCommitId, - startedAt: 1, - completedAt: 2, - }); - }); - - expect( - await screen.findByRole("button", { name: /keep/i }), - ).toHaveAttribute("data-attr", "diff-accept"); - expect(screen.getByRole("button", { name: /undo/i })).toHaveAttribute( - "data-attr", - "diff-reject", - ); - - await act(async () => { - utils?.unmount(); - }); - }); - - test("shows a not found message when the file is missing", async () => { - const lix = await openLix(); - - let utils: ReturnType | undefined; - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - expect(await screen.findByText(/file not found/i)).toBeInTheDocument(); - expect(screen.queryByTestId("tiptap-editor")).not.toBeInTheDocument(); - - await act(async () => { - utils?.unmount(); - }); - }); -}); - -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; -} diff --git a/src/extensions/markdown/index.tsx b/src/extensions/markdown/index.tsx deleted file mode 100644 index 59f24920..00000000 --- a/src/extensions/markdown/index.tsx +++ /dev/null @@ -1,977 +0,0 @@ -import { Suspense, useEffect } from "react"; -import { useMemo, useState } from "react"; -import type { ReactNode } from "react"; -import { Check, ExternalLink, Github, Loader2 } from "lucide-react"; -import { useLix, useQuery, useQueryTakeFirst } from "@/lib/lix-react"; -import { qb } from "@/lib/lix-kysely"; -import { isMarkdownFilePath } from "@/extension-runtime/file-handlers"; -import { EditorProvider } from "@/extensions/markdown/editor/editor-context"; -import { TipTapEditor } from "@/extensions/markdown/editor/tip-tap-editor"; -import { - desktopWorkspaceApi, - useDesktopWorkspaceDir, -} from "@/extensions/markdown/editor/desktop-workspace"; -import type { EmptyMarkdownDefaultBlock } from "@/extensions/markdown/editor/tiptap-markdown-bridge"; -import { renderMarkdownAstEditorHtml } from "@/extensions/markdown/editor/render-markdown-html"; -import { parseMarkdown } from "@/extensions/markdown/editor/markdown"; -import { - historicalMarkdownNodeBlocks, - type HistoricalMarkdownNodeRow, -} from "./markdown-node-history"; -import { renderMarkdownReviewDiffHtml } from "./render-review-diff-html"; -import "./style.css"; -import { FormattingToolbar } from "./components/formatting-toolbar"; -import { SlashCommandMenu } from "./components/slash-command-menu"; -import type { MarkdownBlockSnapshot, MarkdownReviewDiff } from "./review-diff"; -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 { AnimatedZap } from "@/components/animated-zap"; - -type MarkdownViewProps = { - readonly fileId: string; - readonly filePath?: string; - readonly isActiveView?: boolean; - readonly isPanelFocused?: boolean; - readonly focusOnLoad?: boolean; - readonly defaultBlock?: EmptyMarkdownDefaultBlock; - readonly syncActiveFile?: boolean; - readonly beforeCommitId?: string | null; - readonly afterCommitId?: string | null; - readonly registerExternalWriteReview?: ( - review: ExternalWriteReview, - ) => () => void; - readonly onAcceptReviewDiff?: (args: { - readonly fileId: string; - readonly reviewId: string; - readonly review?: ExternalWriteReview; - }) => Promise; - readonly onRejectReviewDiff?: (args: { - readonly fileId: string; - readonly reviewId: string; - readonly review?: ExternalWriteReview; - }) => Promise; -}; - -type MarkdownFileRow = { - readonly id: string; - readonly path: string; - readonly data: unknown; -}; - -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 HistoricalMarkdownFile = { - readonly fileRow: MarkdownFileRow; - readonly review: ExternalWriteReview | null; - readonly reviewData: ExternalWriteReviewData | null; -}; - -const EMPTY_FILE_DATA = new Uint8Array(); - -/** - * Embeds the shared TipTap editor to render Markdown documents. - * - * @example - * - */ -export function MarkdownView({ - fileId, - filePath, - isActiveView = true, - isPanelFocused = true, - focusOnLoad = false, - defaultBlock, - syncActiveFile = true, - beforeCommitId, - afterCommitId, - registerExternalWriteReview, - onAcceptReviewDiff, - onRejectReviewDiff, -}: MarkdownViewProps) { - return ( - }> - - - ); -} - -function MarkdownViewContent({ fileId, ...props }: MarkdownViewProps) { - assertFileId(fileId); - - const fileRow = useQueryTakeFirst( - (lix) => - qb(lix) - .selectFrom("lix_file") - .select(["id", "path", "data"]) - .where("id", "=", fileId) - .limit(1), - { subscribe: false }, - ); - - return ; -} - -function MarkdownViewLoaded({ - fileId, - filePath, - fileRow, - isActiveView = true, - isPanelFocused = true, - focusOnLoad = false, - defaultBlock, - syncActiveFile = true, - beforeCommitId, - afterCommitId, - registerExternalWriteReview, - onAcceptReviewDiff, - onRejectReviewDiff, -}: MarkdownViewProps & { - readonly fileRow: MarkdownFileRow | 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, - }); - const externalWriteReviewData = - useExternalWriteReviewData(externalWriteReview); - useEffect(() => { - if (!externalWriteReview) return; - return registerExternalWriteReview?.(externalWriteReview); - }, [externalWriteReview, registerExternalWriteReview]); - const review = externalWriteReview; - const reviewData: ExternalWriteReviewData | null = externalWriteReviewData; - const reviewDiff: MarkdownReviewDiff | null = reviewData - ? { - beforeMarkdown: decodeFileDataToText(reviewData.beforeData), - afterMarkdown: decodeFileDataToText(reviewData.afterData), - } - : null; - - let content: ReactNode; - - if (!effectiveFileRow) { - content = ( -
    - File not found in the workspace. -
    - ); - } else if (!isMarkdownFilePath(effectiveFileRow.path)) { - content = ; - } else { - content = ( - -
    -
    - -
    -
    - - - {reviewDiff && review ? ( - }> - - - ) : review ? ( - - ) : null} -
    - {reviewDiff ? null : } -
    -
    - ); - } - - return ( -
    - {syncActiveFile && fileRow && isMarkdownFilePath(fileRow.path) ? ( - - ) : null} - {content} -
    - ); -} - -function MarkdownHistoricalViewLoaded({ - fileId, - filePath, - fileRow, - isActiveView, - isPanelFocused, - editorRevision, -}: { - readonly fileId: string; - readonly filePath: string | undefined; - readonly fileRow: MarkdownFileRow | undefined; - readonly isActiveView: boolean; - readonly isPanelFocused: boolean; - readonly syncActiveFile: boolean; - readonly editorRevision: EditorRevisionState; -}) { - const revisionMode = editorRevisionMode(editorRevision); - 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 - ? buildHistoricalMarkdownFile({ - fileId, - filePath, - fileRow, - revision: editorRevision, - beforeSnapshot: beforeSnapshot.snapshot, - afterSnapshot: afterSnapshot.snapshot, - }) - : null, - [ - beforeSnapshot.snapshot, - editorRevision, - fileId, - filePath, - fileRow, - historicalSnapshotsLoaded, - afterSnapshot.snapshot, - ], - ); - const effectiveFileRow = historicalFile?.fileRow; - const review = historicalFile?.review ?? null; - const reviewData = historicalFile?.reviewData ?? null; - const reviewDiff: MarkdownReviewDiff | null = reviewData - ? { - beforeMarkdown: decodeFileDataToText(reviewData.beforeData), - afterMarkdown: decodeFileDataToText(reviewData.afterData), - } - : null; - - let content: ReactNode; - if (!historicalSnapshotsLoaded) { - content = ( -
    - -
    - ); - } else if (!effectiveFileRow) { - content = ( -
    - File not found in the workspace. -
    - ); - } else if (!isMarkdownFilePath(effectiveFileRow.path)) { - content = ; - } else if (revisionMode === "snapshot") { - content = ( - - ); - } else { - content = ( -
    -
    - {reviewDiff && review ? ( - }> - - - ) : ( - - )} -
    -
    - ); - } - - return
    {content}
    ; -} - -function MarkdownAutosaveHint({ enabled }: { readonly enabled: boolean }) { - const [hintKey, setHintKey] = useState(0); - - useEffect(() => { - if (enabled) return; - setHintKey(0); - }, [enabled]); - - useEffect(() => { - if (!enabled) return; - const handleKeyDown = (event: KeyboardEvent) => { - const usesPrimaryModifier = event.metaKey || event.ctrlKey; - if (!usesPrimaryModifier || event.altKey || event.shiftKey) return; - if (event.key.toLowerCase() !== "s") return; - event.preventDefault(); - event.stopPropagation(); - setHintKey((current) => current + 1); - }; - window.addEventListener("keydown", handleKeyDown, { capture: true }); - return () => { - window.removeEventListener("keydown", handleKeyDown, { capture: true }); - }; - }, [enabled]); - - useEffect(() => { - if (hintKey === 0) return; - const timeoutId = window.setTimeout(() => setHintKey(0), 2400); - return () => window.clearTimeout(timeoutId); - }, [hintKey]); - - if (hintKey === 0) return null; - - return ( -
    - - - Auto-saved. No Cmd+S needed. - -
    - ); -} - -function MarkdownSnapshotView({ - filePath, - markdown, -}: { - readonly filePath: string; - readonly markdown: string; -}) { - const workspaceDirState = useDesktopWorkspaceDir(); - const html = useMemo(() => { - if (!workspaceDirState.loaded) return null; - const workspaceApi = desktopWorkspaceApi(); - const workspacePath = workspaceDirState.workspaceDir; - const resolveImageSrc = - filePath && workspacePath && workspaceApi?.resolveMarkdownImageSrc - ? (src: string) => - workspaceApi.resolveMarkdownImageSrc({ - src, - sourceFilePath: filePath, - workspacePath, - }) - : undefined; - return renderMarkdownAstEditorHtml(parseMarkdown(markdown) as any, { - resolveImageSrc, - }); - }, [ - filePath, - markdown, - workspaceDirState.loaded, - workspaceDirState.workspaceDir, - ]); - - return ( -
    -
    -
    - {html ? ( -
    - ) : ( -
    - - Loading file… -
    - )} -
    -
    -
    - ); -} - -function MarkdownReviewOverlay({ - fileId, - sourceFilePath, - review, - reviewDiff, - reviewId, - beforeCommitId, - afterCommitId, - isActive, - controls = "review", - onAccept, - onReject, -}: { - readonly fileId: string; - readonly sourceFilePath: string; - readonly review: ExternalWriteReview; - readonly reviewDiff: MarkdownReviewDiff; - readonly reviewId: string; - readonly beforeCommitId: string; - readonly afterCommitId: string; - 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 workspaceDirState = useDesktopWorkspaceDir(); - const { beforeBlocks, afterBlocks } = useMarkdownBlocksAtCommits( - fileId, - beforeCommitId, - afterCommitId, - reviewDiff.beforeMarkdown, - reviewDiff.afterMarkdown, - ); - - if (!workspaceDirState.loaded) { - return ; - } - - const workspaceApi = desktopWorkspaceApi(); - const workspacePath = workspaceDirState.workspaceDir; - const resolveImageSrc = - sourceFilePath && workspacePath && workspaceApi?.resolveMarkdownImageSrc - ? (src: string) => - workspaceApi.resolveMarkdownImageSrc({ - src, - sourceFilePath, - workspacePath, - }) - : undefined; - const enrichedReviewDiff = enrichMarkdownReviewDiff( - reviewDiff, - beforeBlocks, - afterBlocks, - ); - const diffHtml = renderMarkdownReviewDiffHtml(enrichedReviewDiff, { - resolveImageSrc, - }); - const rejectReview = () => void onReject?.({ fileId, reviewId, review }); - - return ( -
    -
    -
    -
    -
    -
    - {controls === "review" ? ( - void onAccept?.({ fileId, reviewId, review })} - onReject={rejectReview} - /> - ) : null} -
    - ); -} - -function enrichMarkdownReviewDiff( - reviewDiff: MarkdownReviewDiff, - beforeBlocks: MarkdownBlockSnapshot[] | undefined, - afterBlocks: MarkdownBlockSnapshot[] | undefined, -): MarkdownReviewDiff { - const beforeSnapshotsAvailable = - beforeBlocks !== undefined && - (beforeBlocks.length > 0 || reviewDiff.beforeMarkdown.trim().length === 0); - const afterSnapshotsAvailable = - afterBlocks !== undefined && - (afterBlocks.length > 0 || reviewDiff.afterMarkdown.trim().length === 0); - if (!beforeSnapshotsAvailable || !afterSnapshotsAvailable) { - return reviewDiff; - } - return { - ...reviewDiff, - beforeBlocks: beforeBlocks ?? [], - afterBlocks: afterBlocks ?? [], - }; -} - -function MarkdownReviewOverlayFallback() { - return ( -
    -
    - - Loading review… -
    -
    - ); -} - -function useMarkdownBlocksAtCommits( - fileId: string, - beforeCommitId: string | undefined, - afterCommitId: string | undefined, - beforeMarkdown: string, - afterMarkdown: string, -): { - readonly beforeBlocks: MarkdownBlockSnapshot[] | undefined; - readonly afterBlocks: MarkdownBlockSnapshot[] | undefined; -} { - const rows = useQuery( - (lix) => - beforeCommitId && afterCommitId - ? historicalMarkdownBlocksQuery(lix, { - beforeCommitId, - afterCommitId, - fileId, - }) - : emptyMarkdownBlocksQuery(), - { subscribe: false }, - ); - if (!beforeCommitId || !afterCommitId) { - return { beforeBlocks: undefined, afterBlocks: undefined }; - } - return { - beforeBlocks: historicalMarkdownNodeBlocks( - rows, - beforeCommitId, - beforeMarkdown, - ), - afterBlocks: historicalMarkdownNodeBlocks( - rows, - afterCommitId, - afterMarkdown, - ), - }; -} - -function historicalMarkdownBlocksQuery( - lix: ReturnType, - args: { - readonly beforeCommitId: string; - readonly afterCommitId: string; - readonly fileId: string; - }, -) { - const sql = ` - WITH ranked AS ( - SELECT - start_commit_id, - entity_pk, - snapshot_content, - depth, - ROW_NUMBER() OVER ( - PARTITION BY start_commit_id, entity_pk - ORDER BY depth ASC - ) AS rn - FROM lix_state_history - WHERE start_commit_id IN (?, ?) - AND file_id = ? - AND schema_key = 'markdown_node' - ) - SELECT start_commit_id, snapshot_content - FROM ranked - WHERE rn = 1 - AND snapshot_content IS NOT NULL - `; - const parameters = [ - args.beforeCommitId, - args.afterCommitId, - args.fileId, - ] as const; - return { - compile: () => ({ sql, parameters }), - execute: async () => { - const result = await lix.execute(sql, parameters); - return result.rows.map( - (row) => row.toObject() as HistoricalMarkdownNodeRow, - ); - }, - }; -} - -function emptyMarkdownBlocksQuery() { - return { - compile: () => ({ sql: "SELECT 1 WHERE 0", parameters: [] }), - execute: async () => [] as HistoricalMarkdownNodeRow[], - }; -} - -function safeJsonParse(value: string): unknown { - try { - return JSON.parse(value); - } catch { - return null; - } -} - -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 markdown 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 buildHistoricalMarkdownFile(args: { - readonly fileId: string; - readonly filePath: string | undefined; - readonly fileRow: MarkdownFileRow | undefined; - readonly revision: EditorRevisionState; - readonly beforeSnapshot: HistoricalFileSnapshotRow | undefined; - readonly afterSnapshot: HistoricalFileSnapshotRow | undefined; -}): HistoricalMarkdownFile | 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: null, - }; - } - - 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, - }, - }; -} - -function UnsupportedFilePlaceholder({ - filePath, -}: { - readonly filePath: string; -}): ReactNode { - return ( -
    -
    -

    - This file type is not supported yet. -

    -

    - Flashtype only opens markdown files in this editor, so{" "} - - {filePath} - {" "} - was left blank to avoid damaging its formatting. -

    -

    - - {" "} - for support for more file types. -

    -
    -
    - ); -} - -function assertFileId(fileId: unknown): asserts fileId is string { - if (typeof fileId !== "string" || fileId.length === 0) { - throw new Error("MarkdownView requires a non-empty fileId."); - } -} - -function ActiveFileSync({ - fileId, - isActiveView, -}: { - readonly fileId?: string; - readonly isActiveView: boolean; -}) { - const activeFile = useQueryTakeFirst<{ value: string }>((lix) => - qb(lix) - .selectFrom("lix_key_value_by_branch") - .where("lixcol_branch_id", "=", "global") - .where("key", "=", "flashtype_active_file_id") - .select(["value"]), - ); - - return ( - - ); -} - -function ActiveFileSyncEffect({ - fileId, - isActiveView, - activeFileId, -}: { - readonly fileId?: string; - readonly isActiveView: boolean; - readonly activeFileId: string | null; -}) { - const lix = useLix(); - - useEffect(() => { - if (!fileId) return; - if (!isActiveView) return; - if (activeFileId === fileId) return; - void 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, - }) - .onConflict((oc) => - oc.columns(["key", "lixcol_branch_id"]).doUpdateSet({ value: fileId }), - ) - .execute(); - }, [lix, fileId, activeFileId, isActiveView]); - - return null; -} - -function MarkdownLoadingSpinner(): ReactNode { - return ( -
    -
    - - Loading editor… -
    -
    - ); -} diff --git a/src/extensions/markdown/markdown-node-history.ts b/src/extensions/markdown/markdown-node-history.ts deleted file mode 100644 index 0343001a..00000000 --- a/src/extensions/markdown/markdown-node-history.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { parseMarkdownSource } from "./editor/markdown"; -import type { MarkdownBlockSnapshot } from "./review-diff"; - -export type HistoricalMarkdownNodeRow = { - readonly start_commit_id: string; - readonly snapshot_content: unknown; -}; - -export function historicalMarkdownNodeBlocks( - rows: readonly HistoricalMarkdownNodeRow[], - commitId: string, - markdown: string, -): MarkdownBlockSnapshot[] | undefined { - const nodes = rows - .filter((row) => row.start_commit_id === commitId) - .map((row) => parseNode(row.snapshot_content)) - .filter( - (node): node is { id: string; orderKey: string } => node !== null, - ) - .sort( - (left, right) => - left.orderKey.localeCompare(right.orderKey) || - left.id.localeCompare(right.id), - ); - const segments = sourceSegments(markdown); - if (!segments || segments.length !== nodes.length) return undefined; - return nodes.map((node, index) => ({ - ...node, - block: segments[index]!, - })); -} - -function parseNode(value: unknown): { id: string; orderKey: string } | null { - const snapshot = typeof value === "string" ? safeJsonParse(value) : value; - if (!snapshot || typeof snapshot !== "object") return null; - const record = snapshot as Record; - if ( - typeof record.id !== "string" || - record.parent_id !== "root" || - typeof record.order_key !== "string" - ) { - return null; - } - return { id: record.id, orderKey: record.order_key }; -} - -function sourceSegments(markdown: string): string[] | null { - const children = parseMarkdownSource(markdown).children ?? []; - if (children.length === 0) return markdown.length === 0 ? [] : null; - const starts: number[] = []; - for (let index = 0; index < children.length; index += 1) { - const offset = children[index]?.position?.start?.offset; - if ( - typeof offset !== "number" || - offset < 0 || - offset > markdown.length || - (index > 0 && offset <= starts[index - 1]!) - ) { - return null; - } - starts.push(offset); - } - return children.map((_child, index) => - markdown.slice(index === 0 ? 0 : starts[index]!, starts[index + 1]), - ); -} - -function safeJsonParse(value: string): unknown { - try { - return JSON.parse(value); - } catch { - return null; - } -} diff --git a/src/extensions/markdown/render-review-diff-html.test.ts b/src/extensions/markdown/render-review-diff-html.test.ts deleted file mode 100644 index 0ba20446..00000000 --- a/src/extensions/markdown/render-review-diff-html.test.ts +++ /dev/null @@ -1,905 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { - bundledPluginArchives, - openLix, - type Lix, -} from "@/test-utils/node-lix-sdk"; -import { - MARKDOWN_DIFF_FIXTURES, - type MarkdownDiffFixture, -} from "./diff-fixtures"; -import { renderMarkdownReviewDiffHtml } from "./render-review-diff-html"; -import type { MarkdownBlockSnapshot } from "./review-diff"; -import { historicalMarkdownNodeBlocks } from "./markdown-node-history"; - -describe("renderMarkdownReviewDiffHtml", () => { - test("marks added headings and blocks", () => { - const html = renderMarkdownReviewDiffHtml({ - beforeMarkdown: "# Title\n\nBody\n", - afterMarkdown: "# Title\n\nBody\n\n## Poem\n\nFresh line\n", - }); - - expect(html).toContain("Poem"); - expect(html).toContain(">Fresh line

    "); - expect(html.trim().startsWith("\n"); - }); - - test("marks removed blocks", () => { - const html = renderMarkdownReviewDiffHtml({ - beforeMarkdown: "# Title\n\nDelete me\n\nKeep me\n", - afterMarkdown: "# Title\n\nKeep me\n", - }); - - expect(html).toContain('data-diff-status="removed"'); - expect(html).toContain(">Delete me

    "); - }); - - test("marks inline word replacements as removed and added", () => { - const html = renderMarkdownReviewDiffHtml({ - beforeMarkdown: "# Strategy\n\nTarget a general audience.\n", - afterMarkdown: "# Strategy\n\nTarget writers already using Claude.\n", - }); - - expect(html).toContain('data-diff-status="removed"'); - expect(html).toContain('data-diff-status="added"'); - expect(html).toContain("general audience"); - expect(html).toContain("writers already using Claude"); - }); - - test("uses markdown block ids when snapshots are available", () => { - const html = renderMarkdownReviewDiffHtml({ - beforeMarkdown: "# Strategy\n\nTarget a general audience.\n", - afterMarkdown: "# Strategy\n\nTarget writers already using Claude.\n", - beforeBlocks: [ - { id: "heading_block", orderKey: "40", block: "# Strategy" }, - { - id: "paragraph_block", - orderKey: "80", - block: "Target a general audience.", - }, - ], - afterBlocks: [ - { id: "heading_block", orderKey: "40", block: "# Strategy" }, - { - id: "paragraph_block", - orderKey: "80", - block: "Target writers already using Claude.", - }, - ], - }); - - expect(html).toContain('data-diff-key="paragraph_block"'); - expect(html).toContain("general audience"); - expect(html).toContain("writers already using Claude"); - }); - - test("uses markdown block snapshots when one side is empty", () => { - const html = renderMarkdownReviewDiffHtml({ - beforeMarkdown: "", - afterMarkdown: "# New document\n", - beforeBlocks: [], - afterBlocks: [ - { id: "new_document_heading", orderKey: "40", block: "# New document" }, - ], - }); - - expect(html).toContain('data-diff-key="new_document_heading"'); - expect(statusesForText(html, "New document")).toContain("added"); - }); - - test("resolves image sources in review diffs", () => { - const resolveImageSrc = (src: string) => `file:///workspace/docs/${src}`; - const fullDocumentHtml = renderMarkdownReviewDiffHtml( - { - beforeMarkdown: "![Lix mark](./lix-mark-2.svg)\n", - afterMarkdown: "![Lix mark](./lix-mark-3.svg)\n", - }, - { resolveImageSrc }, - ); - - expect(imageSrcsForAlt(fullDocumentHtml, "Lix mark")).toEqual([ - "file:///workspace/docs/./lix-mark-2.svg", - "file:///workspace/docs/./lix-mark-3.svg", - ]); - expect(fullDocumentHtml).not.toContain('src="./lix-mark'); - - const snapshotHtml = renderMarkdownReviewDiffHtml( - { - beforeMarkdown: "![Lix mark](./lix-mark-2.svg)\n", - afterMarkdown: "![Lix mark](./lix-mark-3.svg)\n", - beforeBlocks: [ - { - id: "image_block", - orderKey: "40", - block: "![Lix mark](./lix-mark-2.svg)", - }, - ], - afterBlocks: [ - { - id: "image_block", - orderKey: "40", - block: "![Lix mark](./lix-mark-3.svg)", - }, - ], - }, - { resolveImageSrc }, - ); - - expect(imageSrcsForAlt(snapshotHtml, "Lix mark")).toEqual([ - "file:///workspace/docs/./lix-mark-2.svg", - "file:///workspace/docs/./lix-mark-3.svg", - ]); - expect(snapshotHtml).not.toContain('src="./lix-mark'); - }); - - test("keeps unchanged list items stable inside a changed list block", () => { - const fixture = fixtureById("quick-facts-list"); - const html = renderMarkdownReviewDiffHtml(fixture); - - expect(statusesForText(html, "Best for")).toEqual([]); - expect(statusesForText(html, "Trip length")).toEqual([]); - expect(statusesForText(html, "Pace")).toEqual([]); - expect(statusesForText(html, "regional trains")).toContain("added"); - expect(statusesForText(html, "Budget")).toContain("added"); - }); - - test("keeps unchanged table cells stable inside a changed table block", () => { - const fixture = fixtureById("plan-table"); - const html = renderMarkdownReviewDiffHtml(fixture); - - expect(statusesForText(html, "Friday")).toEqual([]); - expect(statusesForText(html, "Mitte")).toEqual([]); - expect(statusesForText(html, "Coffee near Hackescher Markt")).toEqual([]); - expect(statusesForText(html, "and the Berliner Dom")).toContain("added"); - expect(statusesForText(html, "neighborhood wandering")).toContain("added"); - }); - - test("marks inline formatting and link edits inside aligned GFM table cells", () => { - const fixture = fixtureById("gfm-table-inline"); - const html = renderMarkdownReviewDiffHtml(fixture); - - expect(html).toContain(" { - const fixture = fixtureById("gfm-table-rows"); - const html = renderMarkdownReviewDiffHtml(fixture); - - expect(duplicateDiffKeys(html)).toEqual([]); - expect(html).toContain(" { - const fixture = fixtureById("gfm-table-structure"); - const html = renderMarkdownReviewDiffHtml(fixture); - const root = htmlRoot(html); - - expect(root.querySelectorAll("tr")).toHaveLength(7); - expect(root.querySelectorAll("tr")[2]?.querySelectorAll("td")).toHaveLength( - 5, - ); - expect(statusesForText(html, "Item")).toEqual([]); - expect(statusesForText(html, "Owner")).toEqual([]); - expect(statusesForText(html, "Notes")).toEqual([]); - expect(statusesForText(html, "Priority")).toContain("added"); - expect(statusesForText(html, "Legacy")).toEqual([]); - expect(statusesForText(html, "Status")).toContain("added"); - expect(html).toContain( - 'data-diff-key="gfm_table_structure:tr:alpha:td:owner"', - ); - expect(html).toContain( - 'data-diff-key="gfm_table_structure:tr:alpha:td:notes"', - ); - - expect(statusesForText(html, "Alpha")).toEqual([]); - expect(statusesForText(html, "Mia")).toEqual([]); - expect(statusesForText(html, "high")).toContain("added"); - expect(statusesForText(html, "Filled empty cell")).toContain("added"); - expect(statusesForText(html, "Pipe demo")).toEqual([]); - expect(statusesForText(html, "Escaped")).toEqual([]); - expect(statusesForText(html, "pipe and")).toEqual([]); - expect(statusesForText(html, "b")).toContain("removed"); - expect(statusesForText(html, "c")).toContain("added"); - - expect(statusesForText(html, "First duplicate keeps same note")).toEqual( - [], - ); - expect(statusesForText(html, "Second duplicate")).toEqual([]); - expect(statusesForText(html, "will be edited")).toContain("removed"); - expect(statusesForText(html, "gets a modified note")).toContain("added"); - expect(statusesForText(html, "Column removal keeps row identity")).toEqual( - [], - ); - expect(statusesForText(html, "old")).toEqual([]); - expect(statusesForText(html, "New row")).toContain("added"); - expect( - statusesForText(html, "Inserted row with changed-ready content"), - ).toContain("added"); - expect(statusesForText(html, "blue")).toContain("added"); - }); - - test("tracks task list checked-state and nested list edits", () => { - const fixture = fixtureById("release-checklist"); - const html = renderMarkdownReviewDiffHtml(fixture); - const root = htmlRoot(html); - - expect(root.querySelector("li ul")).not.toBeNull(); - expect(root.querySelector('li[data-task] input[type="checkbox"]')).not.toBe( - null, - ); - expect(taskCheckedStatesForText(html, "Confirm launch owner")).toEqual([ - true, - ]); - expect(taskCheckedStatesForText(html, "Draft release notes")).toContain( - true, - ); - expect(taskCheckedStatesForText(html, "Confirm event names")).toContain( - true, - ); - expect(statusesForText(html, "Confirm launch owner")).toEqual([]); - expect(statusesForText(html, "Draft release notes")).toEqual([]); - expect(statusesForText(html, "for beta customers")).toContain("added"); - expect(statusesForText(html, "Schedule support handoff")).toEqual([]); - expect(statusesForText(html, "Check conversion dashboard")).toEqual([]); - expect(statusesForText(html, "Confirm event names")).toContain("added"); - expect(statusesForText(html, "Archive notes")).toEqual([]); - expect(statusesForText(html, "Leave changelog draft untouched")).toEqual( - [], - ); - expect(statusesForText(html, "Announce internally")).toEqual([]); - }); - - test("keeps reordered duplicate task labels nested with checkbox DOM", () => { - const fixture = fixtureById("duplicate-task-list"); - const html = renderMarkdownReviewDiffHtml(fixture); - const root = htmlRoot(html); - - expect(duplicateDiffKeys(html)).toEqual([]); - expect(root.querySelectorAll("li ul").length).toBeGreaterThanOrEqual(3); - expect( - root.querySelectorAll('li[data-task] input[type="checkbox"]').length, - ).toBeGreaterThanOrEqual(10); - expect(taskCheckedStatesForText(html, "Prep")).toEqual([false, false]); - expect(taskCheckedStatesForText(html, "Confirm venue")).toContain(false); - expect(taskCheckedStatesForText(html, "Confirm venue")).toContain(true); - expect(taskCheckedStatesForText(html, "Invite reviewers")).toContain(true); - expect(taskCheckedStatesForText(html, "Send announcement")).toContain(true); - expect(taskCheckedStatesForText(html, "Send announcement")).toContain( - false, - ); - - expect(statusesForText(html, "Prep")).toEqual(["added", "removed"]); - expect(statusesForText(html, "Update docs")).toEqual([]); - expect(statusesForText(html, "Invite reviewers")).toEqual([]); - expect(statusesForText(html, "Book rehearsal room")).toContain("added"); - expect(statusesForText(html, "Pack demo kit")).toEqual([ - "added", - "removed", - ]); - }); - - test("handles GFM fenced code, blockquotes, raw HTML, strikethrough, and autolinks", () => { - const fixture = fixtureById("gfm-edge-blocks"); - const html = renderMarkdownReviewDiffHtml(fixture); - - expect(html).toContain('data-diff-key="gfm_code_fence"'); - expect(html).toContain('data-diff-key="gfm_blockquote"'); - expect(html).toContain('data-diff-key="gfm_raw_html"'); - expect(html).toContain('data-diff-key="gfm_strikethrough"'); - expect(html).toContain('data-diff-key="gfm_autolink"'); - - expect(statusesForText(html, "const route")).toEqual([]); - expect(statusesForText(html, "draft")).toContain("removed"); - expect(statusesForText(html, "launch")).toContain("added"); - expect(statusesForText(html, 'cache: "no-store"')).toContain("added"); - - expect(statusesForText(html, "Keep the customer quote")).toEqual([]); - expect(statusesForText(html, "current")).toContain("removed"); - expect(statusesForText(html, "revised")).toContain("added"); - expect(statusesForText(html, "Add a rollout owner")).toContain("added"); - - expect(statusesForText(html, "HTML block (read only)")).toEqual([ - "added", - "removed", - ]); - expect(statusesForText(html, "Keep a short migration note")).toEqual([ - "added", - "removed", - ]); - expect(statusesForText(html, "and link to support")).toContain("added"); - expect(statusesForText(html, "invite-only")).toContain("removed"); - expect(statusesForText(html, "private")).toContain("added"); - expect(statusesForText(html, "docs")).toContain("removed"); - expect(statusesForText(html, "guides")).toContain("added"); - }); - - test("handles moved headings, media, breaks, HTML, code language, quotes, and autolinks", () => { - const fixture = fixtureById("block-media-link"); - const html = renderMarkdownReviewDiffHtml(fixture); - const root = htmlRoot(html); - - expect(root.querySelectorAll("img")).toHaveLength(2); - expect(root.querySelector('img[alt="Old dashboard"]')).not.toBeNull(); - expect(root.querySelector('img[alt="New dashboard"]')).not.toBeNull(); - expect(root.querySelectorAll("hr")).toHaveLength(1); - expect( - root.querySelector('hr[data-diff-key="theme_break"]'), - ).not.toBeNull(); - expect(root.querySelector("code.language-ts")).not.toBeNull(); - expect(html).toContain("<kbd>"); - expect(html).toContain('<span data-kind="badge">'); - - expect(statusesForText(html, "Launch notes")).toEqual([]); - expect(statusesForText(html, "Metrics")).toEqual([]); - expect(statusesForText(html, "renamed")).toContain("added"); - - expect(statusesForSelector(html, 'img[alt="Old dashboard"]')).toEqual([ - "removed", - ]); - expect(statusesForSelector(html, 'img[alt="New dashboard"]')).toEqual([ - "added", - ]); - - expect(statusesForText(html, "export const enabled")).toEqual([]); - expect(statusesForText(html, "false")).toContain("removed"); - expect(statusesForText(html, "true")).toContain("added"); - - expect(statusesForText(html, "Keep")).toEqual([]); - expect(statusesForText(html, "bold")).toContain("removed"); - expect(statusesForText(html, "strong")).toContain("added"); - expect(statusesForText(html, "calm")).toContain("removed"); - expect(statusesForText(html, "focused")).toContain("added"); - expect(statusesForText(html, "owner")).toContain("added"); - - expect(statusesForText(html, "docs")).toContain("removed"); - expect(statusesForText(html, "guides")).toContain("added"); - expect(statusesForText(html, "team")).toContain("removed"); - expect(statusesForText(html, "help")).toContain("added"); - - expect(statusesForText(html, "Cmd")).toContain("removed"); - expect(statusesForText(html, "Ctrl")).toContain("added"); - expect(statusesForText(html, "beta")).toContain("removed"); - expect(statusesForText(html, "stable")).toContain("added"); - expect(statusesForText(html, "Raw block content")).toEqual([ - "added", - "removed", - ]); - expect(statusesForText(html, "plus owner")).toContain("added"); - }); - - test("covers inline media, reference links, autolinks, escaped markdown, and breaks", () => { - const fixture = fixtureById("inline-media-link"); - const html = renderMarkdownReviewDiffHtml(fixture); - const root = htmlRoot(html); - - expect(statusesForText(html, "Inline media and links")).toEqual([]); - expect(statusesForText(html, "emoji :) around")).toEqual([]); - expect(statusesForText(html, "bold")).toContain("removed"); - expect(statusesForText(html, "strong")).toContain("added"); - expect(statusesForText(html, "code")).toContain("removed"); - expect(statusesForText(html, "snippet")).toContain("added"); - expect(statusesForText(html, "strike")).toContain("removed"); - expect(statusesForText(html, "retired")).toContain("added"); - expect(statusesForText(html, "punctuation")).toEqual([]); - expect(statusesForText(html, "!")).toContain("removed"); - expect(statusesForText(html, "?")).toContain("added"); - - expect(statusesForText(html, "Docs")).toContain("removed"); - expect(statusesForText(html, "Guides")).toContain("added"); - expect(hrefsForText(html, "Guides")).toEqual([]); - expect(statusesForText(html, "API")).toEqual([]); - expect(hrefsForText(html, "API")).toEqual([]); - - expect(statusesForText(html, "Reference")).toEqual([]); - expect(hrefsForText(html, "Guidebook")).toEqual([]); - expect(statusesForSelector(html, 'img[alt="Reference logo"]')).toEqual([]); - expect(statusesForSelector(html, 'img[alt="Reference logo new"]')).toEqual( - [], - ); - - expect(hrefsForText(html, "https://example.com/bare-v2")).toEqual([]); - expect(hrefsForText(html, "ops-v2@example.com")).toEqual([]); - expect(statusesForText(html, "v1")).toContain("removed"); - expect(statusesForText(html, "v2")).toContain("added"); - - expect(statusesForText(html, "stars")).toContain("removed"); - expect(statusesForText(html, "asterisks")).toContain("added"); - expect(statusesForText(html, "link")).toContain("removed"); - expect(statusesForText(html, "reference")).toContain("added"); - - expect(root.querySelectorAll("br")).toHaveLength(0); - expect(statusesForText(html, "Soft break stays here")).toEqual([]); - expect(statusesForText(html, "with the next line")).toEqual([]); - expect(statusesForText(html, "updated")).toContain("added"); - expect(statusesForText(html, "hard break stays here")).toEqual([]); - expect(statusesForText(html, "with the forced line")).toEqual([]); - - expect(statusesForSelector(html, 'img[alt="Removed chart"]')).toContain( - "removed", - ); - expect(statusesForSelector(html, 'img[alt="Added chart"]')).toEqual([ - "added", - ]); - expect(imageTitlesForAlt(html, "Added chart")).toEqual(["Added title"]); - expect(statusesForSelector(html, 'img[alt="Screenshot old alt"]')).toEqual( - [], - ); - expect(statusesForSelector(html, 'img[alt="Screenshot new alt"]')).toEqual( - [], - ); - expect(imageTitlesForAlt(html, "Screenshot new alt")).toEqual([ - "New screenshot title", - ]); - }); - - test("handles block moves, heading levels, lifecycle blocks, and repeated text", () => { - const fixture = fixtureById("block-structure-moves"); - const html = renderMarkdownReviewDiffHtml(fixture); - const root = htmlRoot(html); - - expect(html).toContain('data-diff-key="moved_paragraph"'); - expect(statusesForText(html, "Shared paragraph")).toEqual([]); - expect( - statusesForText(html, "Move this paragraph below the checklist"), - ).toEqual([]); - expect(statusesForText(html, "Repeated heading")).toEqual([]); - expect(statusesForText(html, "Keep the same repeated paragraph")).toEqual( - [], - ); - - expect(root.querySelector("h2")?.textContent).toContain("Overview"); - expect(root.querySelector("h3")?.textContent).toContain("Details"); - expect(statusesForText(html, "Overview")).toEqual([]); - expect(statusesForText(html, "Details")).toEqual([]); - - expect(statusesForText(html, "Parent quote stays")).toEqual([]); - expect(statusesForText(html, "calm").length).toBeGreaterThan(0); - expect(statusesForText(html, "focused")).toContain("added"); - expect(statusesForText(html, "Nested quote owner added")).toContain( - "added", - ); - - expect(root.querySelector("code.language-ts")).not.toBeNull(); - expect(root.querySelector("code.language-sh")).not.toBeNull(); - expect(statusesForText(html, 'console.log("keep");')).toEqual([]); - expect(statusesForText(html, 'console.log("remove me");')).toContain( - "removed", - ); - expect(statusesForText(html, 'echo "new fence"')).toContain("added"); - - expect(root.querySelectorAll("hr")).toHaveLength(1); - expect( - root.querySelector('hr[data-diff-key="changed_theme_break"]'), - ).not.toBeNull(); - - expect(statusesForText(html, "Paragraph before list")).toEqual([]); - expect(statusesForText(html, "Keep list item")).toEqual([]); - expect(statusesForText(html, "Move list with paragraph boundary")).toEqual( - [], - ); - expect(statusesForText(html, "Add list item between paragraphs")).toContain( - "added", - ); - expect(statusesForText(html, "Paragraph after list")).toEqual([]); - - expect(statusesForText(html, "Remove this HTML block")).toContain( - "removed", - ); - expect(statusesForText(html, "Add this HTML block")).toContain("added"); - }); - - test.each([ - { - id: "quick-facts-list", - assertions: assertQuickFactsListDiff, - }, - { - id: "plan-table", - assertions: assertPlanTableDiff, - }, - { - id: "release-checklist", - assertions: assertReleaseChecklistDiff, - }, - { - id: "mixed-gfm-document", - assertions: assertMixedGfmDocumentDiff, - }, - ])( - "renders $id fixture diff from real Lix markdown_node history", - async ({ id, assertions }) => { - const fixture = fixtureById(id); - const { html, beforeBlocks, afterBlocks } = - await renderFixtureDiffFromRealLix(fixture); - - expect(beforeBlocks.length).toBeGreaterThan(0); - expect(afterBlocks.length).toBeGreaterThan(0); - expect(beforeBlocks.map((block) => block.block).join("\n\n")).toContain( - fixture.beforeMarkdown.trim().split("\n")[0], - ); - expect(afterBlocks.map((block) => block.block).join("\n\n")).toContain( - fixture.afterMarkdown.trim().split("\n")[0], - ); - - if (id === "mixed-gfm-document") { - expectStableRealLixBlockKeys(html, beforeBlocks, afterBlocks); - } - - assertions(html); - }, - ); -}); - -async function renderFixtureDiffFromRealLix( - fixture: MarkdownDiffFixture, -): Promise<{ - html: string; - beforeBlocks: MarkdownBlockSnapshot[]; - afterBlocks: MarkdownBlockSnapshot[]; -}> { - const lix = await openLix(); - try { - await installBundledPlugins(lix); - const filePath = `/fixtures/${fixture.id}.md`; - await writeMarkdownFile(lix, filePath, fixture.beforeMarkdown); - const fileId = await fileIdByPath(lix, filePath); - const beforeCommitId = await activeCommitId(lix); - - await writeMarkdownFile(lix, filePath, fixture.afterMarkdown); - const afterCommitId = await activeCommitId(lix); - const beforeBlocks = await historicalMarkdownBlocks( - lix, - beforeCommitId, - fileId, - fixture.beforeMarkdown, - ); - const afterBlocks = await historicalMarkdownBlocks( - lix, - afterCommitId, - fileId, - fixture.afterMarkdown, - ); - - const html = renderMarkdownReviewDiffHtml({ - beforeMarkdown: fixture.beforeMarkdown, - afterMarkdown: fixture.afterMarkdown, - beforeBlocks, - afterBlocks, - }); - - return { html, beforeBlocks, afterBlocks }; - } finally { - await lix.close(); - } -} - -function assertQuickFactsListDiff(html: string): void { - expect(statusesForText(html, "Best for")).toEqual([]); - expect(statusesForText(html, "Trip length")).toEqual([]); - expect(statusesForText(html, "Pace")).toEqual([]); - expect(statusesForText(html, "regional trains")).toContain("added"); - expect(statusesForText(html, "Budget")).toContain("added"); -} - -function assertPlanTableDiff(html: string): void { - expect(html).toContain(" link.textContent?.includes(text)) - .map((link) => link.getAttribute("href")) - .filter((href): href is string => href !== null) - .sort(); -} - -function imageTitlesForAlt(html: string, alt: string): string[] { - const root = htmlRoot(html); - return [...root.querySelectorAll("img")] - .filter((image) => image.getAttribute("alt") === alt) - .map((image) => image.getAttribute("title")) - .filter((title): title is string => title !== null) - .sort(); -} - -function imageSrcsForAlt(html: string, alt: string): string[] { - const root = htmlRoot(html); - return [...root.querySelectorAll("img")] - .filter((image) => image.getAttribute("alt") === alt) - .map((image) => image.getAttribute("src")) - .filter((src): src is string => src !== null) - .sort(); -} - -function htmlRoot(html: string): HTMLDivElement { - const root = document.createElement("div"); - root.innerHTML = html; - return root; -} - -function duplicateDiffKeys(html: string): string[] { - const root = htmlRoot(html); - const seen = new Set(); - const duplicates = new Set(); - for (const element of root.querySelectorAll("[data-diff-key]")) { - const key = element.getAttribute("data-diff-key"); - if (!key) continue; - if (seen.has(key)) duplicates.add(key); - seen.add(key); - } - return [...duplicates].sort(); -} - -function taskCheckedStatesForText(html: string, text: string): boolean[] { - const root = htmlRoot(html); - return [...root.querySelectorAll("li[data-task]")] - .filter((item) => listItemOwnText(item).includes(text)) - .map((item) => { - const checkbox = item.querySelector( - 'input[type="checkbox"]', - ); - if (!checkbox) throw new Error(`Missing checkbox for task '${text}'`); - return checkbox.checked; - }); -} - -function listItemOwnText(item: Element): string { - let text = ""; - for (const child of item.childNodes) { - if ( - child.nodeType === Node.ELEMENT_NODE && - (child as Element).tagName === "UL" - ) { - continue; - } - text += child.textContent ?? ""; - } - return text.replace(/\s+/g, " ").trim(); -} - -function parentElementForNode(node: Node): Element | null { - const parent = node.parentNode; - return parent?.nodeType === Node.ELEMENT_NODE ? (parent as Element) : null; -} - -function nearestDiffStatus(element: Element | null): string | null { - let current: Element | null = element; - while (current) { - const status = current.getAttribute("data-diff-status"); - if (status) return status; - current = current.parentElement; - } - return null; -} - -async function writeMarkdownFile( - lix: Lix, - path: string, - markdown: string, -): Promise { - await lix.execute( - "INSERT INTO lix_file (path, data) VALUES (?, ?) \ - ON CONFLICT (path) DO UPDATE SET data = excluded.data", - [path, new TextEncoder().encode(markdown)], - ); -} - -async function installBundledPlugins(lix: Lix): Promise { - for (const plugin of await bundledPluginArchives()) { - await lix.execute( - "INSERT INTO lix_file (path, data) VALUES (?, ?) \ - ON CONFLICT (path) DO UPDATE SET data = excluded.data", - [`/.lix/plugins/${plugin.key}.lixplugin`, plugin.archiveBytes], - ); - } -} - -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"); - 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()"); - if (typeof commitId !== "string") { - throw new Error("Missing active branch commit id"); - } - return commitId; -} - -async function historicalMarkdownBlocks( - lix: Lix, - commitId: string, - fileId: string, - markdown: string, -): Promise { - const result = await lix.execute( - ` - WITH ranked AS ( - SELECT - snapshot_content, - ROW_NUMBER() OVER ( - PARTITION BY entity_pk - ORDER BY depth ASC - ) AS rn - FROM lix_state_history - WHERE start_commit_id = ? - AND file_id = ? - AND schema_key = 'markdown_node' - ) - SELECT snapshot_content - FROM ranked - WHERE rn = 1 - AND snapshot_content IS NOT NULL - `, - [commitId, fileId], - ); - return ( - historicalMarkdownNodeBlocks( - result.rows.map((row) => ({ - start_commit_id: commitId, - snapshot_content: row.get("snapshot_content"), - })), - commitId, - markdown, - ) ?? [] - ); -} diff --git a/src/extensions/markdown/render-review-diff-html.ts b/src/extensions/markdown/render-review-diff-html.ts deleted file mode 100644 index 7c8b6199..00000000 --- a/src/extensions/markdown/render-review-diff-html.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { renderHtmlDiff } from "@lix-js/html-diff"; -import { - ensureDiffIds, - renderMarkdownAstEditorHtml, - supportsDiffId, -} from "@/extensions/markdown/editor/render-markdown-html"; -import { parseMarkdown } from "@/extensions/markdown/editor/markdown"; -import type { MarkdownReviewDiff } from "./review-diff"; - -type RenderMarkdownReviewDiffHtmlOptions = { - readonly resolveImageSrc?: (src: string) => string; -}; - -export function renderMarkdownReviewDiffHtml( - reviewDiff: MarkdownReviewDiff, - options: RenderMarkdownReviewDiffHtmlOptions = {}, -): string { - if ( - reviewDiff.beforeBlocks !== undefined && - reviewDiff.afterBlocks !== undefined - ) { - return normalizeHtmlDiffFragment( - renderHtmlDiff({ - beforeHtml: renderMarkdownBlocksHtml(reviewDiff.beforeBlocks, options), - afterHtml: renderMarkdownBlocksHtml(reviewDiff.afterBlocks, options), - diffAttribute: "data-diff-key", - }), - ); - } - - const beforeAst = parseMarkdown(reviewDiff.beforeMarkdown) as any; - const afterAst = parseMarkdown(reviewDiff.afterMarkdown) as any; - ensurePairedDiffIds(beforeAst, afterAst); - const beforeHtml = renderMarkdownAstEditorHtml(beforeAst, options); - const afterHtml = renderMarkdownAstEditorHtml(afterAst, options); - return normalizeHtmlDiffFragment( - renderHtmlDiff({ - beforeHtml, - afterHtml, - diffAttribute: "data-diff-key", - }), - ); -} - -function normalizeHtmlDiffFragment(html: string): string { - // html-diff wraps multi-root fragments in a plain div. The markdown view - // needs TipTap blocks as direct ProseMirror children, and ProseMirror's - // break-spaces styling makes top-level whitespace text nodes visible. - const template = document.createElement("template"); - template.innerHTML = html.trim(); - const childElements = Array.from(template.content.children); - if (childElements.length !== 1) { - return serializeHtmlFragmentWithoutWhitespaceText(template.content); - } - const wrapper = childElements[0]; - if (wrapper?.tagName !== "DIV" || wrapper.attributes.length > 0) { - return html; - } - if (wrapper.children.length <= 1) { - return html; - } - return serializeHtmlFragmentWithoutWhitespaceText(wrapper); -} - -function serializeHtmlFragmentWithoutWhitespaceText( - root: DocumentFragment | Element, -): string { - return Array.from(root.childNodes) - .filter( - (node) => node.nodeType !== Node.TEXT_NODE || node.textContent?.trim(), - ) - .map((node) => { - if (node instanceof Element) return node.outerHTML; - return node.textContent ?? ""; - }) - .join(""); -} - -function renderMarkdownBlocksHtml( - blocks: NonNullable, - options: RenderMarkdownReviewDiffHtmlOptions, -): string { - return blocks - .map((block) => renderMarkdownBlockHtml(block, options)) - .join("\n"); -} - -function renderMarkdownBlockHtml( - block: NonNullable[number], - options: RenderMarkdownReviewDiffHtmlOptions, -): string { - const ast = parseMarkdown(block.block) as any; - ensureDiffIds(ast); - ensureNestedDiffIds(ast, block.id); - const children = Array.isArray(ast.children) ? ast.children : []; - for (const child of children) { - if (supportsDiffId(child)) { - if (child?.type === "list" || child?.type === "table") { - clearDataId(child); - } else { - writeDataId(child, block.id); - if (child?.type === "blockquote") { - writeDataDiffMode(child, "words"); - } - } - break; - } - } - return renderMarkdownAstEditorHtml(ast, options); -} - -function ensureNestedDiffIds(ast: any, blockId: string): void { - const children = Array.isArray(ast?.children) ? ast.children : []; - for (const child of children) { - if (child?.type === "list") { - ensureListItemDiffIds(child, blockId); - } else if (child?.type === "table") { - ensureTableDiffIds(child, blockId); - } - } -} - -function ensureListItemDiffIds( - listNode: any, - blockId: string, - parentKey = "", -): void { - clearDataId(listNode); - const items = Array.isArray(listNode.children) ? listNode.children : []; - const itemKeys = items.map((item: any, index: number) => - item?.type === "listItem" ? stableListItemKey(item, index) : "", - ); - const itemKeyCounts = new Map(); - for (const itemKey of itemKeys) { - if (!itemKey) continue; - itemKeyCounts.set(itemKey, (itemKeyCounts.get(itemKey) ?? 0) + 1); - } - const resolvedKeys = items.map((item: any, index: number) => { - const stableKey = itemKeys[index] || `index-${index}`; - if (itemKeyCounts.get(stableKey) === 1) return stableKey; - return `${stableKey}:${listItemChildSignature(item, index)}`; - }); - const resolvedKeyCounts = new Map(); - for (const key of resolvedKeys) { - if (!key) continue; - resolvedKeyCounts.set(key, (resolvedKeyCounts.get(key) ?? 0) + 1); - } - items.forEach((item: any, index: number) => { - if (item?.type !== "listItem") return; - const resolvedKey = resolvedKeys[index] || `index-${index}`; - const itemKey = - resolvedKeyCounts.get(resolvedKey) === 1 - ? `${parentKey}${resolvedKey}` - : `${parentKey}${resolvedKey}:${index}`; - clearDataId(item); - writeListItemOwnDiffId(item, `${blockId}:li:${itemKey}`); - for (const child of Array.isArray(item.children) ? item.children : []) { - if (child?.type === "list") { - ensureListItemDiffIds(child, blockId, `${itemKey}:`); - } - } - }); -} - -function ensureTableDiffIds(tableNode: any, blockId: string): void { - clearDataId(tableNode); - const rows = Array.isArray(tableNode.children) ? tableNode.children : []; - const columnKeys = tableColumnKeys(rows); - const firstCellCounts = new Map(); - for (const row of rows) { - if (row?.type !== "tableRow") continue; - const firstCellKey = tableRowFirstCellKey(row); - firstCellCounts.set( - firstCellKey, - (firstCellCounts.get(firstCellKey) ?? 0) + 1, - ); - } - rows.forEach((row: any, rowIndex: number) => { - if (row?.type !== "tableRow") return; - const rowKey = `${blockId}:tr:${tableRowKey( - row, - rowIndex, - firstCellCounts, - )}`; - clearDataId(row); - const cells = Array.isArray(row.children) ? row.children : []; - cells.forEach((cell: any, cellIndex: number) => { - if (cell?.type === "tableCell") { - writeDataId( - cell, - `${rowKey}:td:${columnKeys[cellIndex] ?? `index-${cellIndex}`}`, - ); - } - }); - }); -} - -function tableColumnKeys(rows: any[]): string[] { - const headerCells: any[] = Array.isArray(rows[0]?.children) - ? rows[0].children - : []; - const baseKeys = headerCells.map((cell: any, index: number) => - stableTextKey(cell, index), - ); - const counts = new Map(); - for (const key of baseKeys) { - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return baseKeys.map((key, index) => - counts.get(key) === 1 ? key : `${key}:${index}`, - ); -} - -function ensurePairedDiffIds(beforeAst: any, afterAst: any): void { - ensureDiffIds(beforeAst); - ensureDiffIds(afterAst); - const beforeBlocks = collectBlocks(beforeAst); - const afterBlocks = collectBlocks(afterAst); - const beforeBySignature = groupUnclaimedBySignature(beforeBlocks); - - for (const afterBlock of afterBlocks) { - const existingId = readDataId(afterBlock.node); - if (existingId && !existingId.startsWith("diff_")) continue; - const candidates = beforeBySignature.get(afterBlock.signature) ?? []; - const beforeBlock = candidates.shift(); - if (!beforeBlock) continue; - const beforeId = readDataId(beforeBlock.node); - if (!beforeId) continue; - writeDataId(afterBlock.node, beforeId); - } -} - -type DiffBlock = { - readonly node: any; - readonly signature: string; -}; - -function collectBlocks(node: any): DiffBlock[] { - const blocks: DiffBlock[] = []; - const visit = (current: any) => { - if (!current || typeof current !== "object") return; - if (supportsDiffId(current)) { - blocks.push({ - node: current, - signature: blockSignature(current), - }); - } - for (const child of Array.isArray(current.children) - ? current.children - : []) { - visit(child); - } - }; - visit(node); - return blocks; -} - -function groupUnclaimedBySignature( - blocks: readonly DiffBlock[], -): Map { - const grouped = new Map(); - for (const block of blocks) { - const id = readDataId(block.node); - if (id && !id.startsWith("diff_")) continue; - const entries = grouped.get(block.signature) ?? []; - entries.push(block); - grouped.set(block.signature, entries); - } - return grouped; -} - -function readDataId(node: any): string | null { - const id = node?.data?.id; - return typeof id === "string" && id.length > 0 ? id : null; -} - -function writeDataId(node: any, id: string): void { - node.data = node.data && typeof node.data === "object" ? node.data : {}; - node.data.id = id; -} - -function clearDataId(node: any): void { - if (!node || typeof node !== "object") return; - if (!node.data || typeof node.data !== "object") return; - delete node.data.id; -} - -function writeDataDiffMode(node: any, mode: "words" | "element"): void { - node.data = node.data && typeof node.data === "object" ? node.data : {}; - node.data.diffMode = mode; -} - -function blockSignature(node: any): string { - return `${node.type}:${textContent(node).replace(/\s+/g, " ").trim()}`; -} - -function tableRowKey( - row: any, - rowIndex: number, - firstCellCounts: ReadonlyMap, -): string { - const cells = Array.isArray(row?.children) ? row.children : []; - const firstCellText = cells.length > 0 ? textContent(cells[0]) : ""; - let identityText = firstCellText; - if (firstCellCounts.get(tableRowFirstCellKey(row)) !== 1) { - const disambiguator = duplicateRowDisambiguator(cells.slice(1)); - identityText = `${firstCellText} ${disambiguator ?? ""}`; - } - return stableTextKey({ value: identityText }, rowIndex); -} - -function duplicateRowDisambiguator(cells: any[]): string | undefined { - const candidates = cells - .map(textContent) - .map((text: string) => text.replace(/\s+/g, " ").trim()) - .filter(Boolean); - const labelLike = candidates - .filter((text: string) => /^[A-Z][\p{L}\p{N}\s.'-]{0,39}$/u.test(text)) - .sort((a: string, b: string) => a.length - b.length)[0]; - return ( - labelLike ?? - candidates.sort((a: string, b: string) => b.length - a.length)[0] - ); -} - -function tableRowFirstCellKey(row: any): string { - const cells = Array.isArray(row?.children) ? row.children : []; - return textContent(cells[0]).replace(/\s+/g, " ").trim().toLowerCase(); -} - -function stableTextKey(node: any, index: number): string { - const text = textContent(node).replace(/\s+/g, " ").trim(); - const label = text.match(/^([^:]{1,80}):/)?.[1] ?? text; - const normalized = label - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return normalized || `index-${index}`; -} - -function stableListItemKey(item: any, index: number): string { - const text = listItemOwnText(item).replace(/\s+/g, " ").trim(); - const label = text.match(/^([^:]{1,80}):/)?.[1]; - const identityText = label ?? text.split(/\s+/).slice(0, 3).join(" "); - const normalized = identityText - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return normalized || `index-${index}`; -} - -function listItemOwnText(item: any): string { - const children = Array.isArray(item?.children) ? item.children : []; - return children - .filter((child: any) => child?.type !== "list") - .map(textContent) - .join(""); -} - -function listItemChildSignature(item: any, index: number): string { - const childKeys = nestedListItems(item) - .map((child, childIndex) => stableListItemKey(child, childIndex)) - .filter(Boolean) - .sort(); - const uniqueChildKeys = [...new Set(childKeys)]; - return uniqueChildKeys.length > 0 - ? uniqueChildKeys.join("-") - : `index-${index}`; -} - -function nestedListItems(item: any): any[] { - const out: any[] = []; - const visit = (node: any) => { - const children = Array.isArray(node?.children) ? node.children : []; - for (const child of children) { - if (child?.type === "list") { - for (const listItem of Array.isArray(child.children) - ? child.children - : []) { - if (listItem?.type === "listItem") { - out.push(listItem); - visit(listItem); - } - } - } - } - }; - visit(item); - return out; -} - -function writeListItemOwnDiffId(item: any, id: string): void { - const children = Array.isArray(item?.children) ? item.children : []; - const ownBlock = children.find( - (child: any) => child?.type !== "list" && supportsDiffId(child), - ); - if (ownBlock) { - writeDataId(ownBlock, id); - writeDataDiffMode(ownBlock, "words"); - } -} - -function textContent(node: any): string { - if (!node || typeof node !== "object") return ""; - if (typeof node.value === "string") return node.value; - const children = Array.isArray(node.children) ? node.children : []; - return children.map(textContent).join(""); -} diff --git a/src/extensions/markdown/review-diff.ts b/src/extensions/markdown/review-diff.ts deleted file mode 100644 index ce1b45c6..00000000 --- a/src/extensions/markdown/review-diff.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type MarkdownReviewDiff = { - readonly beforeMarkdown: string; - readonly afterMarkdown: string; - readonly beforeBlocks?: readonly MarkdownBlockSnapshot[]; - readonly afterBlocks?: readonly MarkdownBlockSnapshot[]; -}; - -export type MarkdownBlockSnapshot = { - readonly id: string; - readonly orderKey: string; - readonly block: string; -}; diff --git a/src/extensions/markdown/style.css b/src/extensions/markdown/style.css deleted file mode 100644 index 98d5dce2..00000000 --- a/src/extensions/markdown/style.css +++ /dev/null @@ -1,447 +0,0 @@ -/* Markdown view scoped styles */ - -/* Remove focus outline */ -.markdown-view .ProseMirror:focus, -.markdown-view .ProseMirror:focus-visible { - outline: none; -} - -/* Base markdown view container. - No horizontal padding: the toolbar is a full-bleed header and the document - gutter lives on the editor body wrapper instead. */ -.markdown-view { - flex: 1 1 auto; - min-height: 0; -} - -/* Hide the native scrollbar; a lightweight overlay thumb mirrors scroll - position so fade behavior is reliable across Electron/Chromium repaints. */ -.markdown-view .tiptap-container { - scrollbar-width: none; -} -.markdown-view .tiptap-container::-webkit-scrollbar { - width: 0; - height: 0; -} - -.markdown-view .tiptap-scrollbar-thumb { - position: absolute; - top: 0; - right: 4px; - z-index: 10; - width: 6px; - min-height: 36px; - background-color: rgba(87, 83, 78, 0.58); - border-radius: 9999px; - opacity: 0; - pointer-events: none; - will-change: transform, height, opacity; - transition: opacity 320ms ease-in; -} - -.markdown-view .tiptap-scrollbar-thumb[data-scrollable="false"] { - display: none; -} - -.markdown-view .tiptap-scrollbar-thumb[data-visible="true"] { - opacity: 1; - transition-duration: 120ms; - transition-timing-function: ease-out; -} - -.markdown-autosave-hint { - position: absolute; - right: 1rem; - top: 1rem; - z-index: 20; - display: inline-flex; - max-width: min(24rem, calc(100% - 2rem)); - align-items: center; - gap: 0.375rem; - border: 0; - border-radius: 7px; - background: var(--color-bg-notice-neutral); - box-shadow: inset 0 0 0 1px var(--color-border-notice-neutral); - color: var(--color-text-notice-neutral); - font-size: 0.75rem; - font-weight: 600; - height: 1.75rem; - line-height: 1rem; - padding: 0 0.5625rem; - pointer-events: none; - transform: translateY(0); - animation: markdown-autosave-hint-in 160ms ease-out; -} - -.markdown-autosave-hint strong { - font-weight: 600; -} - -.markdown-autosave-hint-icon { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: center; - color: var(--color-icon-notice-neutral); -} - -.markdown-autosave-hint-icon svg { - width: 0.8125rem; - height: 0.8125rem; - stroke-width: 2; -} - -@keyframes markdown-autosave-hint-in { - from { - opacity: 0; - transform: translateY(0.35rem); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.markdown-view .tiptap { - /* Exactly fill the viewport (border-box includes padding-bottom) so a short - document does not force a scrollbar; longer content overflows normally. */ - min-height: 100%; - max-width: none; -} - -.markdown-view .ProseMirror { - box-sizing: border-box; - color: var(--color-text-primary); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; - font-size: 14.5px; - line-height: 1.7; - min-height: 100%; - padding: 2rem max(2rem, calc((100% - 760px) / 2)) 4rem; - caret-color: var(--color-ring-focus-visible); - cursor: auto; - width: 100%; -} - -/* Headings */ -.markdown-view .ProseMirror h1 { - color: var(--color-text-primary); - font-size: 27px; - font-weight: 700; - letter-spacing: -0.02em; - line-height: 1.2; - margin-top: 0; - margin-bottom: 1rem; -} - -.markdown-view .ProseMirror h2 { - color: var(--color-text-primary); - font-size: 22px; - font-weight: 700; - letter-spacing: -0.015em; - line-height: 1.25; - margin-top: 2rem; - margin-bottom: 0.9rem; -} - -.markdown-view .ProseMirror h3 { - color: var(--color-text-primary); - font-size: 18px; - font-weight: 700; - line-height: 1.35; - margin-top: 1.4rem; - margin-bottom: 0.65rem; -} - -.markdown-view .ProseMirror h4 { - color: var(--color-text-primary); - font-size: 16px; - font-weight: 700; - margin-top: 1.25rem; - margin-bottom: 0.75rem; -} - -/* Paragraphs */ -.markdown-view .ProseMirror p { - margin-top: 0.85rem; - margin-bottom: 0.85rem; -} - -/* Links */ -.markdown-view .ProseMirror a { - color: var(--color-text-link); - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 2px; - cursor: pointer; -} - -.markdown-view .ProseMirror a:hover { - color: var(--color-text-link-hover); -} - -/* Lists */ -.markdown-view .ProseMirror ul, -.markdown-view .ProseMirror ol { - padding-left: 1.5rem; - margin-top: 0.85rem; - margin-bottom: 0.85rem; - list-style-position: outside; -} - -.markdown-view .ProseMirror ul { - list-style-type: disc; -} - -.markdown-view .ProseMirror ol { - list-style-type: decimal; -} - -.markdown-view .ProseMirror li { - margin-top: 0.1rem; - margin-bottom: 0.1rem; -} - -.markdown-view .ProseMirror li p { - margin-top: 0; - margin-bottom: 0; -} - -.markdown-view .ProseMirror li p + p { - margin-top: 0.35rem; -} - -/* Tables */ -.markdown-view .ProseMirror table { - border-collapse: collapse; - width: 100%; - margin-top: 1.25rem; - margin-bottom: 1.25rem; -} - -.markdown-view .ProseMirror table thead tr { - background-color: var(--muted); -} - -.markdown-view .ProseMirror table th, -.markdown-view .ProseMirror table td { - border: 1px solid var(--border); - padding: 0.5rem 1rem; - text-align: left; -} - -.markdown-view .ProseMirror table th { - font-weight: 600; -} - -/* Blockquote */ -.markdown-view .ProseMirror blockquote { - border-left: 2px solid var(--border); - padding-left: 0.75rem; - font-style: italic; - color: var(--muted-foreground); - margin-top: 0.75rem; - margin-bottom: 0.75rem; -} - -.markdown-view .ProseMirror blockquote p { - margin-top: 0; - margin-bottom: 0; -} - -/* Inline code */ -.markdown-view .ProseMirror :not(pre) > code { - background-color: var(--muted); - color: var(--foreground); - font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - font-size: 0.9em; - border-radius: 0.25rem; - padding: 0.125rem 0.375rem; -} - -/* Code blocks */ -.markdown-view .ProseMirror pre { - background-color: var(--muted); - border: 1px solid var(--border); - border-radius: 0.375rem; - padding: 0.75rem; - overflow-x: auto; - margin-top: 0.75rem; - margin-bottom: 0.75rem; -} - -.markdown-view .ProseMirror pre code { - background-color: transparent; - padding: 0; - font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - font-size: 14px; - line-height: 1.625; -} - -/* Mermaid diagrams */ -.markdown-view .ProseMirror .markdown-mermaid-block { - margin-top: 0.75rem; - margin-bottom: 0.75rem; -} - -.markdown-view .ProseMirror .markdown-mermaid-preview { - display: flex; - justify-content: center; - padding: 0.75rem; - background-color: var(--muted); - border: 1px solid var(--border); - border-radius: 0.375rem; - overflow-x: auto; -} - -.markdown-view .ProseMirror .markdown-mermaid-preview svg { - max-width: 100%; - height: auto; -} - -.markdown-view .ProseMirror .markdown-mermaid-error { - background-color: color-mix(in srgb, var(--destructive) 8%, var(--muted)); - border: 1px solid color-mix(in srgb, var(--destructive) 30%, var(--border)); - border-radius: 0.375rem; - padding: 0.75rem; - color: var(--destructive); - font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; - font-size: 13px; - white-space: pre-wrap; -} - -.markdown-view .ProseMirror .markdown-mermaid-source { - background-color: var(--muted); - border: 1px solid var(--border); - border-radius: 0.375rem; - padding: 0.75rem; - overflow-x: auto; - margin: 0; -} - -.markdown-view .ProseMirror .markdown-mermaid-block[data-editing="false"] .markdown-mermaid-source { - display: none; -} - -.markdown-view .ProseMirror .markdown-mermaid-block[data-editing="true"] .markdown-mermaid-preview, -.markdown-view .ProseMirror .markdown-mermaid-block[data-editing="true"] .markdown-mermaid-error { - display: none; -} - -.markdown-view .ProseMirror .markdown-mermaid-sr-description { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.markdown-view .ProseMirror .markdown-mermaid-block[data-editing="true"] .markdown-mermaid-sr-description { - display: none; -} - -/* Task lists */ -.markdown-view .ProseMirror li[data-task] { - display: grid; - grid-template-columns: 1.5rem minmax(0, 1fr); - align-items: flex-start; - margin-left: -1.5rem; - list-style: none; -} - -.markdown-view .ProseMirror li[data-task] > input[type="checkbox"] { - appearance: none; - grid-column: 1; - width: 0.875rem; - height: 0.875rem; - margin: calc((1.7em - 0.875rem) / 2) 0 0; - justify-self: center; - border: 1.5px solid var(--color-icon-tertiary); - border-radius: 4px; - background-color: var(--color-bg-panel); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); - transition: - background-color 120ms ease, - border-color 120ms ease, - box-shadow 120ms ease; -} - -.markdown-view .ProseMirror li[data-task] > input[type="checkbox"]:checked { - border-color: var(--color-bg-action-primary); - background-color: var(--color-bg-action-primary); - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3 6.2 5 8.1 9 3.8' fill='none' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-position: center; - background-repeat: no-repeat; - background-size: 0.75rem 0.75rem; - box-shadow: 0 1px 2px rgba(194, 65, 12, 0.18); -} - -.markdown-view .ProseMirror li[data-task] > div { - grid-column: 2; - flex: 1 1 auto; - min-width: 0; -} - -.markdown-view .ProseMirror li[data-task] p { - margin-top: 0; - margin-bottom: 0; -} - -.markdown-review { - position: relative; -} - -.markdown-review-overlay { - position: absolute; - inset: 0; - z-index: 15; - display: flex; - min-height: 0; - flex-direction: column; - background: var(--background); -} - -.markdown-review .markdown-review-surface { - flex: 1 1 auto; - min-height: 0; -} - -.markdown-review .ProseMirror { - cursor: default; - user-select: text; -} - -.markdown-review .ProseMirror [data-diff-status="added"] { - background-color: var(--color-bg-diff-added); - box-decoration-break: clone; - -webkit-box-decoration-break: clone; - border-radius: 0.25rem; - color: var(--color-text-diff-added); - text-decoration: none; -} - -.markdown-review .ProseMirror [data-diff-status="removed"] { - background-color: var(--color-bg-diff-removed); - box-decoration-break: clone; - -webkit-box-decoration-break: clone; - border-radius: 0.25rem; - color: var(--color-text-diff-removed); - text-decoration: line-through; - text-decoration-color: var(--color-border-diff-removed); -} - -.markdown-review .ProseMirror [data-diff-status="modified"] { - background-color: var(--color-bg-diff-modified); - border-radius: 0.25rem; - color: var(--color-text-diff-modified); -} diff --git a/src/extensions/terminal/agent-review-intent.test.tsx b/src/extensions/terminal/agent-review-intent.test.tsx new file mode 100644 index 00000000..0ecb0bf3 --- /dev/null +++ b/src/extensions/terminal/agent-review-intent.test.tsx @@ -0,0 +1,62 @@ +import { render, cleanup } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import type { + AtelierExtensionRuntime, + AtelierExtensionView, +} from "@opral/atelier"; +import { + agentDiffBridge, + FLASHTYPE_ATELIER_EXTENSIONS, +} from "./host-extensions"; +vi.mock("./index", () => ({ TerminalView: () => null })); +afterEach(cleanup); +test("agent turns open an applied review scoped to that turn", async () => { + const open = vi.fn().mockResolvedValue(undefined); + const Component = FLASHTYPE_ATELIER_EXTENSIONS[0]!.Component; + render( + , + ); + await agentDiffBridge.open({ + beforeCommitId: "before-agent-turn", + afterCommitId: "after-agent-turn", + }); + expect(open).toHaveBeenCalledWith({ + base: { commitId: "before-agent-turn" }, + target: { commitId: "after-agent-turn" }, + intent: "review-applied", + reveal: true, + }); +}); + +test("keeps the shared review runtime while another agent view remains mounted", async () => { + const open = vi.fn().mockResolvedValue(undefined); + const atelier = { diff: { open } } as unknown as AtelierExtensionRuntime; + const view = {} as AtelierExtensionView; + const ClaudeComponent = FLASHTYPE_ATELIER_EXTENSIONS[0]!.Component; + const CodexComponent = FLASHTYPE_ATELIER_EXTENSIONS[1]!.Component; + const claude = render( + , + ); + render(); + + claude.unmount(); + await agentDiffBridge.open({ + beforeCommitId: "before-agent-turn", + afterCommitId: "after-agent-turn", + }); + + expect(open).toHaveBeenCalledWith({ + base: { commitId: "before-agent-turn" }, + target: { commitId: "after-agent-turn" }, + intent: "review-applied", + reveal: true, + }); +}); + +test("agent terminals support every area", () => { + for (const extension of FLASHTYPE_ATELIER_EXTENSIONS) expect(extension.placement).toEqual(["left", "main", "right"]); +}); diff --git a/src/extensions/terminal/host-extensions.tsx b/src/extensions/terminal/host-extensions.tsx index aa5f28a9..7e88fbe0 100644 --- a/src/extensions/terminal/host-extensions.tsx +++ b/src/extensions/terminal/host-extensions.tsx @@ -1,8 +1,7 @@ -import { createRoot } from "react-dom/client"; +import { useEffect } from "react"; import type { AtelierExtensionRegistration, - ExtensionManifest, - ExtensionRuntimeEntry, + AtelierExtensionRuntime, } from "@opral/atelier"; import { ClaudeIcon, @@ -12,13 +11,46 @@ import { } from "@/shell/agent-icons"; import { buildTerminalLaunchConfig } from "@/extension-runtime/agent-terminal-command"; import { buildAgentLaunchArgsWithActiveFile } from "@/shell/agent-launch"; +import { AgentInvite } from "@/shell/agent-invite"; import { TerminalView } from "./index"; import claudeManifestJson from "./claude.manifest.json"; import codexManifestJson from "./codex.manifest.json"; +type AtelierDiffApi = NonNullable; +type ExtensionManifest = Omit; + +const activeDiffs = new Map(); +export const agentDiffBridge = { + async open(options: { + beforeCommitId: string; + afterCommitId: string; + }) { + const diff = [...activeDiffs.keys()].at(-1); + if (!diff) throw new Error("Agent review runtime is unavailable"); + await diff.open({ + base: { commitId: options.beforeCommitId }, + target: { commitId: options.afterCommitId }, + intent: "review-applied", + reveal: true, + }); + }, +}; + const claudeManifest = claudeManifestJson as ExtensionManifest; const codexManifest = codexManifestJson as ExtensionManifest; +export const AGENT_WELCOME_EXTENSION: AtelierExtensionRegistration = { + id: "flashtype_agents", + name: "Agents", + placement: ["right"], + Component: ({ atelier }) => ( + void atelier.views.open("flashtype_claude", { area: "right" })} + onStartCodex={() => void atelier.views.open("flashtype_codex", { area: "right" })} + /> + ), +}; + export const FLASHTYPE_ATELIER_EXTENSIONS = [ createAgentExtension({ manifest: claudeManifest, @@ -37,20 +69,27 @@ function createAgentExtension(args: { readonly agent: "claude" | "codex"; readonly icon: typeof ClaudeIcon; }): AtelierExtensionRegistration { - const mount: ExtensionRuntimeEntry["mount"] = ({ element }) => { - const root = createRoot(element); - root.render( - , - ); - return { - dispose: () => root.unmount(), - }; - }; return { - manifest: args.manifest, - entry: { - icon: args.icon, - mount, + ...args.manifest, + placement: ["left", "main", "right"], + icon: args.icon, + Component: ({ atelier }) => { + useEffect(() => { + if (!atelier.diff) return; + const diff = atelier.diff; + activeDiffs.set(diff, (activeDiffs.get(diff) ?? 0) + 1); + return () => { + const count = activeDiffs.get(diff); + if (count === undefined || count <= 1) { + activeDiffs.delete(diff); + } else { + activeDiffs.set(diff, count - 1); + } + }; + }, [atelier.diff]); + return ( + + ); }, }; } diff --git a/src/extensions/terminal/index.tsx b/src/extensions/terminal/index.tsx index 421c3119..4ebccc41 100644 --- a/src/extensions/terminal/index.tsx +++ b/src/extensions/terminal/index.tsx @@ -10,10 +10,14 @@ import { createTerminalOutputNormalizer } from "./ansi-style-normalizer"; function cssColor(name: string, fallback: string): string { if (typeof window === "undefined") return fallback; - const value = window - .getComputedStyle(document.documentElement) - .getPropertyValue(name) - .trim(); + // Resolve light-dark() and CSS variables before handing colors to xterm, + // whose color parser cannot evaluate CSS theme expressions. + const probe = document.createElement("span"); + probe.style.color = `var(${name}, ${fallback})`; + probe.style.display = "none"; + document.documentElement.appendChild(probe); + const value = window.getComputedStyle(probe).color; + probe.remove(); return value || fallback; } @@ -223,10 +227,10 @@ export function TerminalView({ return (
    -
    +
    ); } diff --git a/src/hooks/key-value/schema.ts b/src/hooks/key-value/schema.ts deleted file mode 100644 index 9d00ea27..00000000 --- a/src/hooks/key-value/schema.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type KeyValueBranchId = "active" | "global" | string; - -export type KeyDef = { - defaultBranchId: KeyValueBranchId; - untracked: boolean; - defaultValue?: V | null; -}; - -// Flashtype keys + per-key defaults -export const KEY_VALUE_DEFINITIONS = { - // Cross-branch UI state, not change-controlled - flashtype_active_file_id: { - defaultBranchId: "global", - untracked: true, - } as KeyDef, - - // Test-only keys used in unit tests to exercise tracked behavior - flashtype_test_tracked: { - defaultBranchId: "active", - untracked: false, - } as KeyDef, - - flashtype_test_tracked_external: { - defaultBranchId: "active", - untracked: false, - } as KeyDef, - - flashtype_test_untracked: { - defaultBranchId: "global", - untracked: true, - defaultValue: null, - } as KeyDef, -} as const; - -export type KnownKey = keyof typeof KEY_VALUE_DEFINITIONS; - -export type ValueOf = K extends KnownKey - ? (typeof KEY_VALUE_DEFINITIONS)[K] extends KeyDef - ? V - : never - : unknown; diff --git a/src/hooks/key-value/use-key-value.loop.test.tsx b/src/hooks/key-value/use-key-value.loop.test.tsx deleted file mode 100644 index 34c396dd..00000000 --- a/src/hooks/key-value/use-key-value.loop.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, { StrictMode, useEffect } from "react"; -import { afterEach, expect, test, vi } from "vitest"; -import { render, waitFor } from "@testing-library/react"; - -const executeMock = vi.fn(async () => undefined); - -vi.mock("@/lib/lix-react", () => ({ - useLix: () => ({}) as any, - useQuery: () => [{ value: "file-123" }], -})); - -vi.mock("@/lib/lix-kysely", () => ({ - qb: () => ({ - insertInto: () => ({ - values: () => ({ - onConflict: (_fn: any) => ({ - execute: executeMock, - }), - execute: executeMock, - }), - }), - selectFrom: () => ({ - select: () => ({ - executeTakeFirstOrThrow: async () => ({ branch_id: "global" }), - }), - }), - }), -})); - -import { KeyValueProvider, useKeyValue } from "./use-key-value"; -import { KEY_VALUE_DEFINITIONS } from "./schema"; - -function Writer() { - const [, setValue] = useKeyValue("flashtype_active_file_id"); - useEffect(() => { - void setValue("file-123" as any); - }, [setValue]); - return null; -} - -function Reader() { - useKeyValue("flashtype_active_file_id"); - return null; -} - -afterEach(() => { - executeMock.mockClear(); -}); - -test("does not hit maximum update depth when optimistic value clears across many subscribers", async () => { - const consoleError = vi - .spyOn(console, "error") - .mockImplementation(() => undefined); - - render( - - - - {Array.from({ length: 40 }, (_, i) => ( - - ))} - - , - ); - - await waitFor(() => { - expect(executeMock).toHaveBeenCalled(); - }); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - const sawMaxDepth = consoleError.mock.calls.some((call) => - String(call[0]).includes("Maximum update depth exceeded"), - ); - expect(sawMaxDepth).toBe(false); - consoleError.mockRestore(); -}); diff --git a/src/hooks/key-value/use-key-value.test.tsx b/src/hooks/key-value/use-key-value.test.tsx deleted file mode 100644 index 63ac9ac5..00000000 --- a/src/hooks/key-value/use-key-value.test.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import React from "react"; -import { test, expect, vi } from "vitest"; -import { qb } from "@/lib/lix-kysely"; -import { - render, - screen, - waitFor, - act, -} from "@testing-library/react"; -import { LixProvider } from "@/lib/lix-react"; -import { openLix } from "@/test-utils/node-lix-sdk"; -import { useKeyValue, KeyValueProvider } from "./use-key-value"; -import { KEY_VALUE_DEFINITIONS } from "./schema"; - -function nextTestKey(base: string): string { - return `${base}_${Math.random().toString(36).slice(2, 10)}`; -} - -function withKeyDef( - key: string, - def: { defaultBranchId: "active" | "global" | string; untracked: boolean }, -) { - return { - ...KEY_VALUE_DEFINITIONS, - [key]: def, - } as any; -} - -async function actAndFlush(callback: () => void | Promise) { - await act(async () => { - await callback(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); -} - -function renderUseKeyValue( - key: string, - wrapper: React.ComponentType<{ children: React.ReactNode }>, - opts?: Parameters[1], -) { - const resultRef: { current: unknown } = { current: null }; - function TestComponent() { - resultRef.current = useKeyValue(key, opts); - return null; - } - render(, { wrapper }); - return resultRef; -} - -test("reads a global, untracked key (test fixture)", async () => { - const testKey = nextTestKey("flashtype_test_untracked"); - const defs = withKeyDef(testKey, { - defaultBranchId: "global", - untracked: true, - }); - const lix = await openLix({}); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - - // Pre-insert expected value - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: testKey, - value: "alpha", - lixcol_branch_id: "global", - lixcol_global: true, - }) - .execute(); - - let hookResult: { current: unknown } = { current: null }; - await act(async () => { - hookResult = renderUseKeyValue(testKey, wrapper); - }); - - await waitFor(() => { - const [value] = hookResult.current as any; - expect(value).toBe("alpha"); - }); - - const [value] = hookResult.current as any; - expect(value).toBe("alpha"); -}); - -test("writes and reads a global, untracked key (test fixture)", async () => { - const testKey = nextTestKey("flashtype_test_untracked"); - const defs = withKeyDef(testKey, { - defaultBranchId: "global", - untracked: true, - }); - const lix = await openLix({}); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - - let resultRef: { current: unknown } = { current: null }; - await act(async () => { - resultRef = renderUseKeyValue(testKey, wrapper); - }); - - // Wait for hook to initialize - await waitFor(() => - expect(Array.isArray(resultRef.current as any)).toBe(true), - ); - - await actAndFlush(async () => { - await (resultRef.current as any)?.[1]("beta"); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await waitFor(() => expect((resultRef.current as any)?.[0]).toBe("beta")); - - // Verify DB row persisted to key_value_by_branch with lixcol_branch_id = 'global' - const rows = (await qb(lix) - .selectFrom("lix_key_value_by_branch") - .where("key", "=", testKey) - .where("lixcol_branch_id", "=", "global") - .select(["value"]) - .execute()) as any; - expect(rows[0]?.value).toBe("beta"); -}); - -test("writes and reads a tracked key on active branch", async () => { - const TEST_KEY = nextTestKey("flashtype_test_tracked"); - const lix = await openLix({}); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - - let hookResult: { current: unknown } = { current: null }; - await act(async () => { - hookResult = renderUseKeyValue(TEST_KEY, wrapper); - }); - - // Wait for hook to initialize - await waitFor(() => - expect(Array.isArray(hookResult.current as any)).toBe(true), - ); - - await waitFor(() => typeof (hookResult.current as any)[1] === "function"); - - await actAndFlush(async () => { - await (hookResult.current as any)[1]("hello"); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - - await waitFor(() => { - expect((hookResult.current as any)[0]).toBe("hello"); - }); - - // Verify DB row persisted to tracked table - const rows = (await qb(lix) - .selectFrom("lix_key_value") - .where("key", "=", TEST_KEY) - .select(["value"]) - .execute()) as any; - expect(rows[0]?.value).toBe("hello"); -}); - -test("writes and reads an untracked key on active branch", async () => { - const testKey = nextTestKey("flashtype_test_active_untracked"); - const defs = withKeyDef(testKey, { - defaultBranchId: "active", - untracked: true, - }); - const lix = await openLix({}); - const activeBranchId = await lix.activeBranchId(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - - let hookResult: { current: unknown } = { current: null }; - await act(async () => { - hookResult = renderUseKeyValue(testKey, wrapper); - }); - - await waitFor(() => - expect(Array.isArray(hookResult.current as any)).toBe(true), - ); - await actAndFlush(async () => { - await (hookResult.current as any)[1]("local"); - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - await waitFor(() => expect((hookResult.current as any)[0]).toBe("local")); - - const rows = (await qb(lix) - .selectFrom("lix_key_value_by_branch") - .where("key", "=", testKey) - .where("lixcol_branch_id", "=", activeBranchId) - .select(["value", "lixcol_global", "lixcol_untracked"]) - .execute()) as any; - expect(rows[0]).toMatchObject({ - value: "local", - lixcol_global: false, - lixcol_untracked: true, - }); -}); - -test("reads explicit global key when active branch has same key", async () => { - const testKey = nextTestKey("flashtype_test_global_shadowed"); - const defs = withKeyDef(testKey, { - defaultBranchId: "global", - untracked: true, - }); - const lix = await openLix({}); - const activeBranchId = await lix.activeBranchId(); - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: testKey, - value: "global-value", - lixcol_branch_id: "global", - lixcol_global: true, - lixcol_untracked: true, - }) - .execute(); - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: testKey, - value: "active-value", - lixcol_branch_id: activeBranchId, - lixcol_global: false, - lixcol_untracked: true, - }) - .execute(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - - let hookResult: { current: unknown } = { current: null }; - await act(async () => { - hookResult = renderUseKeyValue(testKey, wrapper); - }); - - await waitFor(() => - expect((hookResult.current as any)?.[0]).toBe("global-value"), - ); -}); - -test("shows Suspense fallback first, then renders value on initial read", async () => { - const testKey = nextTestKey("flashtype_test_untracked"); - const defs = withKeyDef(testKey, { - defaultBranchId: "global", - untracked: true, - }); - const lix = await openLix({}); - // Ensure the key exists so the initial load resolves deterministically - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: testKey, - value: "ready", - lixcol_branch_id: "global", - lixcol_global: true, - }) - .execute(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - loading
    }> - {children} - - - - ); - - function ReadKV() { - const [val] = useKeyValue(testKey); - return
    {String(val)}
    ; - } - - await act(async () => { - render(, { wrapper }); - }); - // Eventually value appears once Suspense resolves - const el = await screen.findByTestId("val"); - expect(el.textContent).toBe("ready"); -}); - -test("re-renders when key value changes externally", async () => { - const TEST_KEY = nextTestKey("flashtype_test_tracked_external"); - const lix = await openLix({}); - await qb(lix) - .insertInto("lix_key_value") - .values({ key: TEST_KEY, value: "initial" }) - .execute(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - - let resultRef: { current: unknown } = { current: null }; - await act(async () => { - resultRef = renderUseKeyValue(TEST_KEY, wrapper); - }); - // wait for initial suspense resolution - await waitFor(() => - expect(Array.isArray(resultRef.current as any)).toBe(true), - ); - await waitFor(() => expect((resultRef.current as any)[0]).toBe("initial")); - - // mutate externally (simulate another part of app) - await actAndFlush(async () => { - await qb(lix) - .updateTable("lix_key_value") - .set({ value: "external" }) - .where("key", "=", TEST_KEY) - .execute(); - }); - - // observe re-render with new value - await waitFor(() => expect((resultRef.current as any)[0]).toBe("external")); -}); - -function createDeferred() { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -test("shares optimistic updates across hook instances", async () => { - const SHARED_KEY = nextTestKey("flashtype_test_tracked_shared_optimistic"); - const lix = await openLix({}); - await qb(lix) - .insertInto("lix_key_value") - .values({ key: SHARED_KEY, value: "initial" }) - .execute(); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - - type Snapshot = { primary: unknown; secondary: unknown }; - const snapshots: Snapshot[] = []; - let setValueRef: - | ((value: string) => Promise) - | ((value: string | null) => Promise) - | null = null; - - function TwinReaders({ - onSnapshot, - assignSetter, - }: { - onSnapshot: (snapshot: Snapshot) => void; - assignSetter: ( - setter: - | ((value: string) => Promise) - | ((value: string | null) => Promise), - ) => void; - }) { - const [primary, setPrimary] = useKeyValue(SHARED_KEY as any); - const [secondary] = useKeyValue(SHARED_KEY as any); - - React.useEffect(() => { - assignSetter(setPrimary); - }, [assignSetter, setPrimary]); - - React.useEffect(() => { - onSnapshot({ primary, secondary }); - }, [onSnapshot, primary, secondary]); - - return null; - } - - await act(async () => { - render( - snapshots.push(snapshot)} - assignSetter={(setter) => { - setValueRef = setter; - }} - />, - { wrapper }, - ); - }); - - await waitFor(() => expect(setValueRef).not.toBeNull()); - await waitFor(() => - expect( - snapshots.some( - (snapshot) => - snapshot.primary === "initial" && snapshot.secondary === "initial", - ), - ).toBe(true), - ); - - const gate = createDeferred(); - const originalExecute = lix.execute.bind(lix); - const executeSpy = vi - .spyOn(lix, "execute") - .mockImplementation(async (...args) => { - const [sql] = args; - if ( - typeof sql === "string" && - sql.includes("INSERT INTO") && - sql.includes("lix_key_value") - ) { - await gate.promise; - } - return originalExecute(...args); - }); - - let pendingWrite: Promise | null = null; - act(() => { - pendingWrite = setValueRef - ? (setValueRef("next") as Promise) - : Promise.resolve(); - }); - - await waitFor(() => - expect(snapshots.some((snapshot) => snapshot.primary === "next")).toBe( - true, - ), - ); - const latest = snapshots[snapshots.length - 1]; - expect(latest).toMatchObject({ - primary: "next", - secondary: "next", - }); - - await actAndFlush(async () => { - gate.resolve(); - await pendingWrite; - }); - - executeSpy.mockRestore(); -}); - -test("returns optimistic value immediately when setter is called", async () => { - const lix = await openLix({}); - const TEST_KEY = nextTestKey("flashtype_test_optimistic") as any; - await qb(lix) - .insertInto("lix_key_value") - .values({ key: TEST_KEY, value: "initial" }) - .execute(); - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - - let hookResult: { current: unknown } = { current: null }; - await act(async () => { - hookResult = renderUseKeyValue(TEST_KEY, wrapper); - }); - - await waitFor(() => - expect(Array.isArray(hookResult.current as any)).toBe(true), - ); - - let pending: Promise | undefined; - await actAndFlush(async () => { - pending = (hookResult.current as any)[1]("value-1"); - }); - - await waitFor(() => expect((hookResult.current as any)[0]).toBe("value-1")); - - await actAndFlush(async () => { - await pending; - }); - - await waitFor(() => expect((hookResult.current as any)[0]).toBe("value-1")); -}); - -test("memoized children should not re-render when parent state changes", async () => { - const testKey = nextTestKey("flashtype_test_untracked"); - const defs = withKeyDef(testKey, { - defaultBranchId: "global", - untracked: true, - }); - const lix = await openLix({}); - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: testKey, - value: "initial", - lixcol_branch_id: "global", - lixcol_global: true, - }) - .execute(); - - const wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - - ); - - let childRenders = 0; - - const MemoChild = React.memo(function MemoChild({ - pair, - }: { - pair: ReturnType; - }) { - childRenders++; - return
    {String(pair[0] ?? "unknown")}
    ; - }); - - function Parent() { - const pair = useKeyValue(testKey, { - defaultBranchId: "global", - untracked: true, - }); - const [, forceRender] = React.useState(0); - return ( - <> - - - - ); - } - - await act(async () => { - render(, { wrapper }); - }); - - await screen.findByTestId("current-tab"); - await waitFor(() => expect(childRenders).toBeGreaterThan(0)); - const baseline = childRenders; - - const button = screen.getByTestId("rerender-trigger"); - await act(async () => { - button.click(); - }); - - await waitFor(() => expect(childRenders).toBe(baseline)); -}); diff --git a/src/hooks/key-value/use-key-value.ts b/src/hooks/key-value/use-key-value.ts deleted file mode 100644 index f20c4e16..00000000 --- a/src/hooks/key-value/use-key-value.ts +++ /dev/null @@ -1,367 +0,0 @@ -import type { Lix } from "@/lib/lix-types"; -import { useLix, useQuery } from "@/lib/lix-react"; -import { qb } from "@/lib/lix-kysely"; -import { - type KeyDef, - type ValueOf, - type KnownKey, - KEY_VALUE_DEFINITIONS, -} from "./schema"; -import { - createContext, - useContext, - createElement, - useCallback, - useRef, - useState, - useEffect, -} from "react"; -import type React from "react"; - -type KVDefs = Record>; -const KVDefsContext = createContext(null); - -/** - * Provides key-value definitions to `useKeyValue` within a React subtree. - * - * Pass in a map of key definitions (branch scope, tracking, defaults) so the - * hook can infer behavior for known keys. - * - * @example - * const lix = await openLix({}) - * render( - * - * - * - * - * - * ) - */ -export function KeyValueProvider({ - defs, - children, -}: { - defs: KVDefs; - children: React.ReactNode; -}) { - // oxlint-disable-next-line no-children-prop - return createElement(KVDefsContext.Provider, { value: defs, children }); -} - -/** - * Options passed to `useKeyValue` to override defaults for a specific key. - */ -export type UseKeyValueOptions = { - defaultBranchId?: "active" | "global" | string; - untracked?: boolean; -}; - -type OptimisticSlot = { - hasValue: boolean; - value: unknown; - listeners: Set<() => void>; - notifyScheduled?: boolean; -}; - -const OPTIMISTIC_SLOTS = new WeakMap>(); - -function notifyOptimisticListeners(slot: OptimisticSlot): void { - if (slot.notifyScheduled) { - return; - } - slot.notifyScheduled = true; - queueMicrotask(() => { - slot.notifyScheduled = false; - const listeners = Array.from(slot.listeners); - for (const listener of listeners) { - listener(); - } - }); -} - -function getOptimisticSlots(lix: Lix): Map { - let slots = OPTIMISTIC_SLOTS.get(lix as object); - if (!slots) { - slots = new Map(); - OPTIMISTIC_SLOTS.set(lix as object, slots); - } - return slots; -} - -function getOptimisticSlot(lix: Lix, key: string): OptimisticSlot { - const slots = getOptimisticSlots(lix); - let slot = slots.get(key); - if (!slot) { - slot = { hasValue: false, value: undefined, listeners: new Set() }; - slots.set(key, slot); - } - return slot; -} - -function readOptimisticSnapshot( - lix: Lix, - key: string, -): { - hasValue: boolean; - value: unknown; -} { - const slot = OPTIMISTIC_SLOTS.get(lix as object)?.get(key); - if (!slot) { - return { hasValue: false, value: undefined }; - } - return { hasValue: slot.hasValue, value: slot.value }; -} - -function setOptimisticValue(lix: Lix, key: string, value: unknown): void { - const slot = getOptimisticSlot(lix, key); - if (slot.hasValue && valuesEqual(slot.value, value)) { - return; - } - slot.hasValue = true; - slot.value = value; - notifyOptimisticListeners(slot); -} - -function clearOptimisticValue(lix: Lix, key: string): void { - const slots = OPTIMISTIC_SLOTS.get(lix as object); - const slot = slots?.get(key); - if (!slot) return; - if (!slot.hasValue) return; - slot.hasValue = false; - slot.value = undefined; - notifyOptimisticListeners(slot); - if (slot.listeners.size === 0) { - slots?.delete(key); - } -} - -function subscribeOptimistic( - lix: Lix, - key: string, - listener: () => void, -): () => void { - const slot = getOptimisticSlot(lix, key); - slot.listeners.add(listener); - return () => { - slot.listeners.delete(listener); - if (!slot.hasValue && slot.listeners.size === 0) { - OPTIMISTIC_SLOTS.get(lix as object)?.delete(key); - } - }; -} - -function getDefaults( - key: string, - defs: Record>, -): { - defaultBranchId: "active" | "global" | string; - untracked: boolean; -} { - const def = defs[key]; - if (def) return def; - // Lix defaults: active branch, tracked (untracked=false) - return { defaultBranchId: "active", untracked: false }; -} - -// Overloads for strong typing on known keys -// Suspense behavior is handled by useQuery; no extra one-time loader needed. - -/** - * React hook for reading and writing a key-value setting. - * - * - Suspends on first load to ensure deterministic rendering. - * - Re-renders on live DB updates via `useQuery` subscription. - * - Honors per-key defaults from `KeyValueProvider` or built-in schema. - * - * @example - * function ActiveFileBadge() { - * const [activeFileId] = useKeyValue('flashtype_active_file_id') - * return ( - * {activeFileId ?? 'No active file'} - * ) - * } - */ -export function useKeyValue( - key: K, - opts?: UseKeyValueOptions, -): readonly [ValueOf | null, (newValue: ValueOf) => Promise]; -export function useKeyValue( - key: string, - opts?: UseKeyValueOptions, -): readonly [unknown | null, (newValue: unknown) => Promise]; -export function useKeyValue( - key: K, - opts?: UseKeyValueOptions, -): readonly [ValueOf | null, (newValue: ValueOf) => Promise] { - const lix = useLix(); - const providedDefs = - useContext(KVDefsContext) ?? (KEY_VALUE_DEFINITIONS as KVDefs); - const d = getDefaults(key as string, providedDefs); - const defaultBranchId = opts?.defaultBranchId ?? d.defaultBranchId; - const untracked = opts?.untracked ?? d.untracked; - - const [optimistic, setOptimisticState] = useState<{ - hasValue: boolean; - value: ValueOf | null; - }>(() => { - const snapshot = readOptimisticSnapshot(lix, key as string); - return { - hasValue: snapshot.hasValue, - value: (snapshot.value ?? null) as ValueOf | null, - }; - }); - - useEffect(() => { - const snapshot = readOptimisticSnapshot(lix, key as string); - const next = { - hasValue: snapshot.hasValue, - value: (snapshot.value ?? null) as ValueOf | null, - }; - setOptimisticState((prev) => - prev.hasValue === next.hasValue && valuesEqual(prev.value, next.value) - ? prev - : next, - ); - }, [lix, key]); - - useEffect(() => { - const handle = () => { - const snapshot = readOptimisticSnapshot(lix, key as string); - const next = { - hasValue: snapshot.hasValue, - value: (snapshot.value ?? null) as ValueOf | null, - }; - setOptimisticState((prev) => - prev.hasValue === next.hasValue && valuesEqual(prev.value, next.value) - ? prev - : next, - ); - }; - return subscribeOptimistic(lix, key as string, handle); - }, [lix, key]); - - const latestLixRef = useRef(lix); - const latestKeyRef = useRef(key as string); - const latestQueryValueRef = useRef | null>(null); - const latestOptimisticRef = useRef(optimistic); - const lastOptimisticClearRef = useRef(null); - const resultRef = useRef< - readonly [ValueOf | null, (newValue: ValueOf) => Promise] - >([null, async () => {}]); - - useEffect(() => { - const latestOptimistic = latestOptimisticRef.current; - if (!latestOptimistic.hasValue) return; - if (valuesEqual(latestQueryValueRef.current, latestOptimistic.value)) { - const clearKey = `${latestKeyRef.current}:${JSON.stringify(latestOptimistic.value)}`; - if (lastOptimisticClearRef.current === clearKey) return; - lastOptimisticClearRef.current = clearKey; - clearOptimisticValue(latestLixRef.current, latestKeyRef.current); - } else { - lastOptimisticClearRef.current = null; - } - }); - - const setValue = useCallback( - async (newValue: ValueOf) => { - setOptimisticValue(lix, key as string, newValue as ValueOf | null); - await upsertValue(lix, key as string, newValue as unknown, { - defaultBranchId: String(defaultBranchId), - untracked, - }); - }, - [lix, key, defaultBranchId, untracked], - ); - - // Subscribe to live updates and suspend on first load via useQuery. Keep this - // after the hook setup above so suspense retries preserve hook order. - const rows = useQuery<{ value: unknown }>((lix) => - selectValue(lix, key as string, { - defaultBranchId: String(defaultBranchId), - untracked, - }), - ); - const defVal = (providedDefs as any)[key]?.defaultValue ?? null; - const value = ( - rows && rows[0]?.value !== undefined ? rows[0]?.value : defVal - ) as ValueOf | null; - const resolvedValue = optimistic.hasValue ? optimistic.value : value; - - latestKeyRef.current = key as string; - latestLixRef.current = lix; - latestQueryValueRef.current = value; - latestOptimisticRef.current = optimistic; - - if ( - resultRef.current[1] !== setValue || - !valuesEqual(resultRef.current[0], resolvedValue) - ) { - resultRef.current = [resolvedValue, setValue] as const; - } - - return resultRef.current; -} - -function selectValue( - lix: Lix, - key: string, - opts: { defaultBranchId: string; untracked: boolean }, -) { - if (opts.defaultBranchId !== "active") { - return qb(lix) - .selectFrom("lix_key_value_by_branch") - .where("lixcol_branch_id", "=", opts.defaultBranchId) - .where("key", "=", key) - .select(["value"]); - } - - return qb(lix) - .selectFrom("lix_key_value") - .where("key", "=", key) - .select(["value"]); -} - -async function upsertValue( - lix: Lix, - key: string, - value: T, - opts: { defaultBranchId: string; untracked: boolean }, -) { - if (opts.defaultBranchId === "active") { - await qb(lix) - .insertInto("lix_key_value") - .values({ - key, - value, - lixcol_untracked: opts.untracked, - }) - .onConflict((oc) => oc.column("key").doUpdateSet({ value })) - .execute(); - return; - } - - const branchId = opts.defaultBranchId; - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key, - value, - lixcol_branch_id: branchId, - lixcol_global: branchId === "global", - lixcol_untracked: opts.untracked, - }) - .onConflict((oc) => - oc.columns(["key", "lixcol_branch_id"]).doUpdateSet({ value }), - ) - .execute(); -} - -function valuesEqual(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) return true; - if (a === undefined || b === undefined) return a === b; - try { - return JSON.stringify(a) === JSON.stringify(b); - } catch { - return false; - } -} diff --git a/src/index.css b/src/index.css index 07b0d4ae..54cf72fd 100644 --- a/src/index.css +++ b/src/index.css @@ -17,6 +17,12 @@ -webkit-app-region: no-drag; } +/* Atelier's `bg-accent` is brand-colored; Flashtype uses --accent for neutral UI. */ +.atelier-root { + --accent: var(--atelier-accent); + --accent-foreground: var(--atelier-accent-on); +} + .flashtype-update-button { -webkit-app-region: no-drag; border: 0; @@ -558,3 +564,132 @@ body, float: left; } } + +.flashtype-share-button { + -webkit-app-region: no-drag; + display: inline-flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 10px; + border-radius: 7px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.flashtype-share-button:hover { + background: var(--color-bg-surface-hover); +} +.flashtype-share-button:disabled { + opacity: 0.4; + cursor: default; +} +.flashtype-share-dialog { + margin: auto; + width: min(480px, calc(100vw - 32px)); + max-height: calc(100dvh - 48px); + overflow: auto; + border: 1px solid var(--color-border-subtle); + border-radius: 16px; + background: var(--color-bg-app); + color: var(--color-text-primary); + box-shadow: 0 24px 80px #0003; +} +.flashtype-share-dialog::backdrop { + background: #0005; +} + +.flashtype-share-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--atelier-accent); + font-size: 14px; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 4px; +} +.flashtype-share-link:focus-visible { + outline: 2px solid currentColor; + outline-offset: 4px; +} + +.share-teaser { + padding: 28px; +} +.share-teaser-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 22px; +} +.share-teaser-icon { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 14px; + color: var(--atelier-accent); + background: var(--atelier-accent-subtle); +} +.share-teaser-close { + padding: 6px; + border-radius: 8px; + color: var(--color-text-secondary); + cursor: pointer; +} +.share-teaser-close:hover { + background: var(--color-bg-surface-hover); +} +.share-teaser h2 { + font-size: 24px; + line-height: 1.25; + font-weight: 650; + letter-spacing: -0.6px; +} +.share-teaser-description { + margin: 12px 0 22px; + font-size: 15px; + line-height: 1.6; + color: var(--color-text-secondary); +} +.share-teaser-primary { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 12px 18px; + border-radius: 10px; + background: var(--atelier-accent); + color: white; + font-size: 14px; + font-weight: 600; + box-shadow: 0 3px 8px transparent; +} +.share-teaser-primary:hover { + background: var(--atelier-accent-hover); +} +.share-teaser-primary:focus-visible, +.share-teaser-close:focus-visible { + outline: 2px solid var(--atelier-accent); + outline-offset: 4px; +} +.share-teaser-sync { + margin-top: 26px; + padding-top: 22px; + border-top: 1px solid var(--color-border-subtle); +} +.share-teaser-sync h3 { + font-size: 14px; + font-weight: 600; +} +.share-teaser-sync p { + margin: 6px 0 12px; + font-size: 13px; + line-height: 1.6; + color: var(--color-text-secondary); +} + +.share-teaser-sync .flashtype-share-link { + margin-top: 10px; +} diff --git a/src/lib/agent-turn-review-bridge.test.ts b/src/lib/agent-turn-review-bridge.test.ts index adebaa06..5e78af1a 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"; @@ -10,6 +10,9 @@ function createTestLix(args: { }) { const commitIds = [...args.commitIds]; return { + state: args.activeFilePath + ? uiState("active-file", args.activeFilePath) + : null, syncDiskToLix: vi.fn(async () => {}), importFilesystemPaths: vi.fn(async () => {}), execute: vi.fn(async (query: string, params?: ReadonlyArray) => { @@ -31,7 +34,7 @@ function createTestLix(args: { } throw new Error(`Unexpected query: ${query}`); }), - } as unknown as Lix; + } as unknown as Lix & { state: unknown }; } describe("createAgentTurnReviewHandler", () => { @@ -47,7 +50,9 @@ describe("createAgentTurnReviewHandler", () => { lix, diff: { open }, } as unknown as AtelierInstance; - const handle = createAgentTurnReviewHandler(atelier); + const handle = createAgentTurnReviewHandler(atelier, { + getUiState: () => lix.state, + }); const startResult = await handle({ id: "start", @@ -92,7 +97,9 @@ describe("createAgentTurnReviewHandler", () => { lix, diff: { open }, } as unknown as AtelierInstance; - const handle = createAgentTurnReviewHandler(atelier); + const handle = createAgentTurnReviewHandler(atelier, { + getUiState: () => lix.state, + }); const base = { instanceId: "terminal-1", agent: "claude" as const, @@ -122,7 +129,9 @@ describe("createAgentTurnReviewHandler", () => { lix, diff: { open: vi.fn(async () => {}) }, } as unknown as AtelierInstance; - const handle = createAgentTurnReviewHandler(atelier); + const handle = createAgentTurnReviewHandler(atelier, { + getUiState: () => lix.state, + }); const result = await handle({ id: "start", @@ -218,7 +227,7 @@ async function runCapturedFileTurn(args: { } throw new Error(`Unexpected query: ${query}`); }), - } as unknown as Lix; + } as unknown as Lix & { state: unknown }; let status: "added" | "deleted" | "modified" | "unchanged" = "unchanged"; const atelier = { lix, @@ -271,8 +280,8 @@ async function runCapturedFileTurn(args: { function uiState(fileId: string, filePath: string) { return { - panels: { - central: { + areas: { + main: { activeInstance: `atelier_file:${fileId}`, views: [ { diff --git a/src/lib/agent-turn-review-bridge.ts b/src/lib/agent-turn-review-bridge.ts index 4f44e1de..15055428 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"; @@ -25,6 +34,7 @@ type AgentTurnFileCaptureApi = Pick< type AgentTurnReviewOptions = { readonly fileCapture?: AgentTurnFileCaptureApi; + readonly getUiState?: () => unknown; }; export type AgentTurnEventResult = void | { @@ -40,17 +50,17 @@ 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( - createActiveDocumentContextHandler(atelier), + createActiveDocumentContextHandler(atelier, options.getUiState), createAgentTurnDiffHandler(atelier, options.fileCapture), ); } function createAgentTurnDiffHandler( - atelier: AtelierInstance, + atelier: AgentReviewHost, fileCapture?: AgentTurnFileCaptureApi, ): AgentTurnEventHandler { const activeTurns = new Map(); @@ -105,7 +115,7 @@ function createAgentTurnDiffHandler( } async function captureAgentTurnStart( - atelier: AtelierInstance, + atelier: AgentReviewHost, fileCapture: AgentTurnFileCaptureApi | undefined, captureId: string, ): Promise { @@ -123,7 +133,7 @@ async function captureAgentTurnStart( } async function importAgentTurnTouchedPaths( - atelier: AtelierInstance, + atelier: AgentReviewHost, fileCapture: AgentTurnFileCaptureApi | undefined, captureId: string, ): Promise { @@ -139,7 +149,7 @@ async function importAgentTurnTouchedPaths( } async function importAgentTurnPaths( - atelier: AtelierInstance, + atelier: AgentReviewHost, paths: readonly string[], kind: "baseline" | "touched", ): Promise { @@ -170,13 +180,15 @@ async function importAgentTurnPaths( } function createActiveDocumentContextHandler( - atelier: AtelierInstance, + atelier: AgentReviewHost, + getUiState?: () => unknown, ): AgentTurnEventHandler { return async (event) => { if (event.phase !== "turn-start") return; try { const filePath = await readCurrentAtelierDocumentPath( atelier.lix as unknown as Lix, + getUiState?.(), ); const additionalContext = buildFlashtypeActiveFilePrompt(filePath); return additionalContext ? { additionalContext } : undefined; @@ -208,7 +220,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-desktop-commands.test.ts b/src/lib/atelier-desktop-commands.test.ts new file mode 100644 index 00000000..1b24a77f --- /dev/null +++ b/src/lib/atelier-desktop-commands.test.ts @@ -0,0 +1,84 @@ +import { expect, test, vi } from "vitest"; +import { createMemorySessionStateStore } from "@opral/atelier/state-adapters"; +import { createDesktopDocumentCommands } from "./atelier-desktop-commands"; +import type { Lix } from "./lix-types"; + +test("new document skips an existing filename rather than overwriting it", async () => { + const execute = vi + .fn() + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ id: "new" }] }); + const navigate = vi.fn(); + const commands = createDesktopDocumentCommands({ + lix: { execute } as unknown as Lix, + store: createMemorySessionStateStore(), + navigate, + }); + await commands.startNew(); + expect(execute.mock.calls[0]?.[1]?.[0]).toBe("/Untitled.md"); + expect(navigate).toHaveBeenCalledWith({ path: "/Untitled 1.md" }); +}); + +test("close preserves other areas and selects the remaining tab", async () => { + const store = createMemorySessionStateStore({ + focusedArea: "main", + areas: { + left: { + views: [{ instance: "files", kind: "atelier_files" }], + activeInstance: "files", + }, + right: { views: [], activeInstance: null }, + main: { + views: [ + { instance: "a", kind: "markdown" }, + { instance: "b", kind: "markdown" }, + ], + activeInstance: "b", + }, + }, + }); + const commands = createDesktopDocumentCommands({ + lix: {} as Lix, + store, + navigate: vi.fn(), + }); + await commands.closeActive(); + expect(store.getSnapshot()?.areas.main.activeInstance).toBe("a"); + expect(store.getSnapshot()?.areas.main.views).toHaveLength(1); + expect(store.getSnapshot()?.areas.left.activeInstance).toBe("files"); +}); + +test("repeated external open activates a document after an internal tab switch", async () => { + const store = createMemorySessionStateStore({ + focusedArea: "main", + areas: { + left: { views: [], activeInstance: null }, + right: { views: [], activeInstance: null }, + main: { + views: [ + { instance: "a", kind: "atelier_file", state: { filePath: "/a.md" } }, + { instance: "b", kind: "atelier_file", state: { filePath: "/b.md" } }, + ], + activeInstance: "a", + }, + }, + }); + const navigate = vi.fn(); + const commands = createDesktopDocumentCommands({ + lix: {} as Lix, + store, + navigate, + }); + await commands.open("/a.md"); + const state = store.getSnapshot()!; + store.setSnapshot({ + ...state, + areas: { + ...state.areas, + main: { ...state.areas.main, activeInstance: "b" }, + }, + }); + await commands.open("/a.md"); + expect(store.getSnapshot()?.areas.main.activeInstance).toBe("a"); + expect(navigate).toHaveBeenLastCalledWith({ path: "/a.md" }); +}); diff --git a/src/lib/atelier-desktop-commands.ts b/src/lib/atelier-desktop-commands.ts new file mode 100644 index 00000000..d4f0b685 --- /dev/null +++ b/src/lib/atelier-desktop-commands.ts @@ -0,0 +1,75 @@ +import type { AtelierLocation, AtelierSessionStateStore } from "@opral/atelier"; +import type { Lix } from "./lix-types"; + +/** Host-owned menu commands drive Atelier through location and session state. */ +export function createDesktopDocumentCommands({ + lix, + store, + navigate, +}: { + lix: Lix; + store: AtelierSessionStateStore; + navigate: (location: AtelierLocation | undefined) => void; +}) { + return { + async open(path: string) { + const state = store.getSnapshot(); + const panel = state?.areas.main; + const existing = panel?.views.find((view) => { + const viewState = view.state as { filePath?: string } | undefined; + return viewState?.filePath === path; + }); + // An external open can repeat the last requested location after the + // user switched tabs internally. Activate the tab even when Atelier + // deduplicates that unchanged location. + if (state && panel && existing) { + store.setSnapshot({ + ...state, + focusedArea: "main", + areas: { + ...state.areas, + main: { ...panel, activeInstance: existing.instance }, + }, + }); + } + navigate({ path }); + }, + async startNew() { + for (let index = 0; ; index++) { + const path = index === 0 ? "/Untitled.md" : `/Untitled ${index}.md`; + const result = await lix.execute( + "INSERT INTO lix_file (path, content) VALUES ($1, $2) ON CONFLICT (path) DO NOTHING RETURNING id", + [path, new Uint8Array()], + ); + if (result.rows.length) { + navigate({ path }); + return; + } + } + }, + async closeActive() { + const state = store.getSnapshot(); + if (!state) return; + const panel = state.areas.main; + const active = panel.views.find( + (view) => view.instance === panel.activeInstance, + ); + if (!active || active.isPinned) return; + const views = panel.views.filter( + (view) => view.instance !== panel.activeInstance, + ); + navigate(undefined); + store.setSnapshot({ + ...state, + areas: { + ...state.areas, + main: { + ...panel, + views, + activeInstance: views.at(-1)?.instance ?? null, + }, + }, + }); + }, + }; +} diff --git a/src/lib/atelier-document-state.test.ts b/src/lib/atelier-document-state.test.ts index 3bd1c827..d07fb6c8 100644 --- a/src/lib/atelier-document-state.test.ts +++ b/src/lib/atelier-document-state.test.ts @@ -6,7 +6,7 @@ import { } from "./atelier-document-state"; describe("readCurrentAtelierDocumentPath", () => { - test("returns the current Lix path for Atelier's active central document", async () => { + test("returns the current Lix path for Atelier's active main document", async () => { const lix = createTestLix({ state: uiState( [ @@ -18,21 +18,23 @@ describe("readCurrentAtelierDocumentPath", () => { files: { file_one: "/renamed.md", file_two: "/second.md" }, }); - await expect(readCurrentAtelierDocumentPath(lix)).resolves.toBe( + await expect(readCurrentAtelierDocumentPath(lix, lix.state)).resolves.toBe( "/renamed.md", ); }); - test("uses the first central view when no active instance is persisted", async () => { + test("uses the first main view when no active instance is persisted", async () => { const lix = createTestLix({ state: uiState([documentView("file_one", "/one.md")], null), files: { file_one: "/one.md" }, }); - await expect(readCurrentAtelierDocumentPath(lix)).resolves.toBe("/one.md"); + await expect(readCurrentAtelierDocumentPath(lix, lix.state)).resolves.toBe( + "/one.md", + ); }); - test("returns validated active and open central document paths for sessions", async () => { + test("returns validated active and open main document paths for sessions", async () => { const lix = createTestLix({ state: uiState( [ @@ -45,7 +47,9 @@ describe("readCurrentAtelierDocumentPath", () => { files: { file_one: "/one.md", file_two: "/renamed-two.md" }, }); - await expect(readAtelierDocumentSessionState(lix)).resolves.toEqual({ + await expect( + readAtelierDocumentSessionState(lix, lix.state), + ).resolves.toEqual({ activePath: "/renamed-two.md", openPaths: ["/renamed-two.md", "/one.md"], }); @@ -87,7 +91,9 @@ describe("readCurrentAtelierDocumentPath", () => { files: {}, }); - await expect(readCurrentAtelierDocumentPath(lix)).resolves.toBeNull(); + await expect( + readCurrentAtelierDocumentPath(lix, lix.state), + ).resolves.toBeNull(); }); test("returns null for a malformed document instance", async () => { @@ -104,7 +110,9 @@ describe("readCurrentAtelierDocumentPath", () => { files: { file_one: "/one.md" }, }); - await expect(readCurrentAtelierDocumentPath(lix)).resolves.toBeNull(); + await expect( + readCurrentAtelierDocumentPath(lix, lix.state), + ).resolves.toBeNull(); }); test("returns null when the persisted document was deleted", async () => { @@ -116,7 +124,9 @@ describe("readCurrentAtelierDocumentPath", () => { files: {}, }); - await expect(readCurrentAtelierDocumentPath(lix)).resolves.toBeNull(); + await expect( + readCurrentAtelierDocumentPath(lix, lix.state), + ).resolves.toBeNull(); }); }); @@ -130,8 +140,8 @@ function documentView(fileId: string, filePath: string) { function uiState(views: readonly unknown[], activeInstance: string | null) { return { - panels: { - central: { views, activeInstance }, + areas: { + main: { views, activeInstance }, }, }; } @@ -139,19 +149,17 @@ function uiState(views: readonly unknown[], activeInstance: string | null) { function createTestLix(args: { readonly state: unknown; readonly files: Readonly>; -}): Lix { +}): Lix & { state: unknown } { return { + state: args.state, execute: vi.fn(async (sql: string, params?: ReadonlyArray) => { - if (sql.includes("lix_key_value_by_branch")) { - return queryResult([args.state], ["value"]); - } if (sql.includes("FROM lix_file")) { const path = args.files[String(params?.[0])]; return queryResult(path ? [path] : [], ["path"]); } throw new Error(`Unexpected query: ${sql}`); }), - } as unknown as Lix; + } as unknown as Lix & { state: unknown }; } function queryResult( @@ -160,7 +168,7 @@ function queryResult( ): LixRuntimeQueryResult { return { rows: row.length > 0 ? [row] : [], - columns: [...columns], + columns: columns.map((name) => ({ name, type: "text" })), rowsAffected: 0, notices: [], } as unknown as LixRuntimeQueryResult; diff --git a/src/lib/atelier-document-state.ts b/src/lib/atelier-document-state.ts index ec816a7d..706331ec 100644 --- a/src/lib/atelier-document-state.ts +++ b/src/lib/atelier-document-state.ts @@ -1,8 +1,5 @@ import type { Lix, LixRuntimeQueryResult } from "@/lib/lix-types"; -const ATELIER_UI_STATE_KEY = "atelier_ui_state"; -const GLOBAL_BRANCH_ID = "global"; - type PersistedDocumentView = { readonly instance?: unknown; readonly kind?: unknown; @@ -17,16 +14,12 @@ export type AtelierDocumentSessionState = { readonly openPaths: readonly string[]; }; -/** Reads the central documents Atelier has persisted in Lix. */ +/** Reads the main documents Atelier's host session state describes. */ export async function readAtelierDocumentSessionState( lix: Lix, - uiState?: unknown, + uiState: unknown, ): Promise { - const rawState = - uiState === undefined - ? readResultValue(await readAtelierUiState(lix), "value") - : uiState; - const candidates = documentCandidates(rawState); + const candidates = documentCandidates(uiState); if (candidates.views.length === 0) { return { activePath: null, openPaths: [] }; } @@ -58,25 +51,14 @@ export async function readAtelierDocumentSessionState( return { activePath, openPaths }; } -/** Reads Atelier's current central document from its persisted Lix UI state. */ +/** Reads Atelier's current main document from host session state. */ export async function readCurrentAtelierDocumentPath( lix: Lix, - uiState?: unknown, + uiState: unknown, ): Promise { return (await readAtelierDocumentSessionState(lix, uiState)).activePath; } -function readAtelierUiState(lix: Lix): Promise { - return lix.execute( - `SELECT value - FROM lix_key_value_by_branch - WHERE key = $1 - AND lixcol_branch_id = $2 - LIMIT 1`, - [ATELIER_UI_STATE_KEY, GLOBAL_BRANCH_ID], - ); -} - function documentCandidates(rawState: unknown): { readonly active: { readonly fileId: string; @@ -88,13 +70,13 @@ function documentCandidates(rawState: unknown): { }[]; } { const state = parseObject(rawState); - const panels = parseObject(state?.panels); - const central = parseObject(panels?.central); - const views = Array.isArray(central?.views) - ? (central.views as readonly PersistedDocumentView[]) + const areas = parseObject(state?.areas ?? state?.panels); + const main = parseObject(areas?.main ?? areas?.central); + const views = Array.isArray(main?.views) + ? (main.views as readonly PersistedDocumentView[]) : []; const activeInstance = - typeof central?.activeInstance === "string" ? central.activeInstance : null; + typeof main?.activeInstance === "string" ? main.activeInstance : null; const candidates = views.flatMap((view) => { const fileId = view.state?.fileId; const filePath = view.state?.filePath; @@ -143,7 +125,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/atelier-files-view.ts b/src/lib/atelier-files-view.ts index 3f6b9603..6b431de4 100644 --- a/src/lib/atelier-files-view.ts +++ b/src/lib/atelier-files-view.ts @@ -1,7 +1,9 @@ -import type { AtelierFilesViewOptions } from "@opral/atelier"; +import type { AtelierProps } from "@opral/atelier"; import { qb } from "@/lib/lix-kysely"; import type { Lix } from "@/lib/lix-types"; +type AtelierFilesViewOptions = NonNullable; + /** * Feeds FlashType's transient-workspace filesystem watchers into atelier's * bundled Files view: un-imported disk files render as `source: "watched"` diff --git a/src/lib/atelier-telemetry.test.ts b/src/lib/atelier-telemetry.test.ts index 532f9c67..32b012e9 100644 --- a/src/lib/atelier-telemetry.test.ts +++ b/src/lib/atelier-telemetry.test.ts @@ -39,7 +39,7 @@ describe("createAtelierTelemetryHandler", () => { handle({ type: "extension_opened", extensionId: "flashtype_codex", - panel: "right", + area: "right", }); await vi.waitFor(() => { diff --git a/src/lib/atelier-telemetry.ts b/src/lib/atelier-telemetry.ts index 81d697a5..44e8cec5 100644 --- a/src/lib/atelier-telemetry.ts +++ b/src/lib/atelier-telemetry.ts @@ -71,7 +71,7 @@ export function createAtelierTelemetryHandler(lix: Lix) { if (!agent) break; captureTelemetry("agent_opened", { agent, - panel: event.panel, + panel: event.area, source: "renderer", surface: "terminal", ...workspace, diff --git a/src/lib/atelier-workspace-bridge.test.ts b/src/lib/atelier-workspace-bridge.test.ts index 1f8f5426..272a325b 100644 --- a/src/lib/atelier-workspace-bridge.test.ts +++ b/src/lib/atelier-workspace-bridge.test.ts @@ -1,6 +1,6 @@ import { waitFor } from "@testing-library/react"; import { describe, expect, test, vi } from "vitest"; -import type { AtelierDocumentsApi } from "@opral/atelier"; +import type { AtelierExtensionRuntime } from "@opral/atelier"; import type { Lix, LixRuntimeQueryResult } from "@/lib/lix-types"; import { connectAtelierWorkspace, @@ -89,7 +89,7 @@ describe("connectAtelierWorkspace", () => { expect(harness.unsubscribeCloseFile).toHaveBeenCalledOnce(); }); - test("persists Atelier's validated central documents as session paths", async () => { + test("persists Atelier's validated main documents as session paths", async () => { const harness = createHarness({ activeDocumentPath: "/docs/active.md" }); const connection = connectAtelierWorkspace(harness.options); await connection.ready; @@ -100,7 +100,7 @@ describe("connectAtelierWorkspace", () => { }); }); connection.dispose(); - expect(harness.observeClose).toHaveBeenCalledTimes(2); + expect(harness.observeClose).toHaveBeenCalledTimes(1); }); test("persists observation-driven document changes after startup", async () => { @@ -222,63 +222,72 @@ function createHarness( mutationSequence: number; result: LixRuntimeQueryResult; }; - const createObservedEvents = (columns: readonly string[]) => { - const queued: ObservedEvent[] = [ - { - sequence: 1, - mutationSequence: 0, - result: queryResult([], columns), - }, - ]; - let resolveNext: ((event: ObservedEvent | undefined) => void) | undefined; + const createObservedEvents = ( + columns: readonly string[], + readRows: () => readonly (readonly unknown[])[], + ) => { + const queued: ObservedEvent[] = [{ + sequence: 1, + mutationSequence: 0, + result: queryRows(readRows(), columns), + }]; + let resolveNext: + | ((event: IteratorResult) => void) + | undefined; + let closed = false; + const returnIterator = vi.fn(async () => { + if (closed) return { done: true, value: undefined } as const; + closed = true; + observeClose(); + resolveNext?.({ done: true, value: undefined }); + resolveNext = undefined; + return { done: true, value: undefined } as const; + }); return { + attachSignal(signal?: AbortSignal) { + signal?.addEventListener("abort", () => void returnIterator(), { + once: true, + }); + }, emit() { const event = { sequence: 2, mutationSequence: 1, - result: queryResult([], columns), + result: queryRows(readRows(), columns), }; if (resolveNext) { const resolve = resolveNext; resolveNext = undefined; - resolve(event); + resolve({ done: false, value: event }); } else { queued.push(event); } }, - next: vi.fn(async () => { + next: vi.fn(async (): Promise> => { const event = queued.shift(); - if (event) return event; - return await new Promise((resolve) => { + if (event) return { done: false, value: event }; + return await new Promise>((resolve) => { resolveNext = resolve; }); }), - close() { - observeClose(); - resolveNext?.(undefined); - resolveNext = undefined; + return: returnIterator, + [Symbol.asyncIterator]() { + return this; }, }; }; - const uiStateEvents = createObservedEvents(["value"]); - const filePathEvents = createObservedEvents(["id", "path"]); + const listeners = new Set<() => void>(); + const filePathEvents = createObservedEvents( + ["id", "path"], + () => [...filesById.entries()].map(([id, path]) => [id, path]), + ); const lix = { importFilesystemPaths, - observe: vi.fn((sql: string) => { - const events = sql.includes("lix_key_value_by_branch") - ? uiStateEvents - : filePathEvents; - return { - next: events.next, - close: events.close, - }; + observe: vi.fn((_sql, _params, options) => { + filePathEvents.attachSignal(options?.signal); + return filePathEvents; }), execute: vi.fn(async (sql: string, params?: ReadonlyArray) => { - if (sql.includes("lix_key_value_by_branch")) { - return activeDocumentPath - ? queryResult([uiState("active-file", activeDocumentPath)], ["value"]) - : queryResult([], ["value"]); - } if (sql.includes("WHERE id =")) { const path = filesById.get(String(params?.[0])); return queryResult(path ? [path] : [], ["path"]); @@ -303,7 +312,9 @@ function createHarness( }; setActiveDocument(activeDocumentPath); - const documents: AtelierDocumentsApi = { + const documents: AtelierExtensionRuntime["documents"] = { + activeFileId: null, + activeFilePath: null, open: vi.fn(async () => {}), startNew: vi.fn(async () => {}), close: vi.fn(async () => {}), @@ -343,7 +354,9 @@ function createHarness( unsubscribeNewFile, unsubscribeCloseFile, observeClose, - emitUiStateChange: uiStateEvents.emit, + emitUiStateChange: () => { + for (const listener of listeners) listener(); + }, emitFilePathChange: filePathEvents.emit, setActiveDocument, setActiveFilePath, @@ -354,6 +367,16 @@ function createHarness( await closeFileListener?.(); }, options: { + sessionStateStore: { + getSnapshot: () => + sessionUiState("active-file", activeDocumentPath ?? ""), + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, documents, lix, workspace: workspace as unknown as NonNullable< @@ -366,8 +389,8 @@ function createHarness( function uiState(fileId: string, filePath: string) { return { - panels: { - central: { + areas: { + main: { activeInstance: `atelier_file:${fileId}`, views: [ { @@ -383,10 +406,10 @@ function uiState(fileId: string, filePath: string) { function sessionUiState(fileId: string, filePath: string) { return { - focusedPanel: "central" as const, - panels: { + focusedArea: "main" as const, + areas: { left: { views: [], activeInstance: null }, - central: uiState(fileId, filePath).panels.central, + main: uiState(fileId, filePath).areas.main, right: { views: [], activeInstance: null }, }, }; @@ -396,20 +419,25 @@ function queryResult( row: readonly unknown[], columns: readonly string[], ): LixRuntimeQueryResult { + return queryRows(row.length > 0 ? [row] : [], columns); +} + +function queryRows( + rows: readonly (readonly unknown[])[], + columns: readonly string[], +): LixRuntimeQueryResult { + const resultRows = rows.map((row) => { const values = Object.fromEntries( columns.map((column, index) => [column, row[index]]), ); return { - rows: - row.length > 0 - ? [ - { - get: (column: string) => values[column], - toObject: () => values, - }, - ] - : [], - columns: [...columns], + get: (column: string) => values[column], + toObject: () => values, + }; + }); + return { + rows: resultRows, + columns: columns.map((name) => ({ name, type: "text" })), rowsAffected: 0, notices: [], } as unknown as LixRuntimeQueryResult; diff --git a/src/lib/atelier-workspace-bridge.ts b/src/lib/atelier-workspace-bridge.ts index 09dd2338..932d7797 100644 --- a/src/lib/atelier-workspace-bridge.ts +++ b/src/lib/atelier-workspace-bridge.ts @@ -1,5 +1,5 @@ import type { - AtelierDocumentsApi, + AtelierExtensionRuntime, AtelierSessionStateStore, } from "@opral/atelier"; import type { Lix } from "@/lib/lix-types"; @@ -19,9 +19,12 @@ type DesktopWorkspaceBridge = Pick< >; type ConnectAtelierWorkspaceOptions = { - readonly documents: AtelierDocumentsApi; + readonly documents: Pick< + AtelierExtensionRuntime["documents"], + "open" | "startNew" | "closeActive" + >; readonly lix: Lix; - readonly sessionStateStore?: Pick< + readonly sessionStateStore: Pick< AtelierSessionStateStore, "getSnapshot" | "subscribe" >; @@ -65,20 +68,13 @@ export function connectAtelierWorkspace( const unsubscribeCloseFile = workspace.onCloseFile(() => { void documents.closeActive().catch(reportError); }); - const uiStateEvents = options.sessionStateStore - ? null - : options.lix.observe( - `SELECT value - FROM lix_key_value_by_branch - WHERE key = $1 - AND lixcol_branch_id = $2`, - ["atelier_ui_state", "global"], - ); const filePathEvents = options.lix.observe( `SELECT id, path FROM lix_file WHERE path NOT LIKE '/.lix/%' ORDER BY id`, + [], + { signal: abortController.signal }, ); let startupReady = false; let sessionPersistenceQueue = Promise.resolve(); @@ -90,7 +86,7 @@ export function connectAtelierWorkspace( if (abortController.signal.aborted) return; const state = await readAtelierDocumentSessionState( options.lix, - options.sessionStateStore?.getSnapshot(), + options.sessionStateStore.getSnapshot(), ); if (abortController.signal.aborted) return; await workspace.setSessionOpenFilePaths({ @@ -102,15 +98,13 @@ export function connectAtelierWorkspace( const watchSessionEvents = async ( events: ReturnType, ): Promise => { - while (!abortController.signal.aborted) { - const event = await events.next(); - if (!event || abortController.signal.aborted) return; + for await (const _event of events) { + if (abortController.signal.aborted) return; persistSessionDocuments(); } }; - if (uiStateEvents) void watchSessionEvents(uiStateEvents).catch(reportError); void watchSessionEvents(filePathEvents).catch(reportError); - const unsubscribeSessionState = options.sessionStateStore?.subscribe( + const unsubscribeSessionState = options.sessionStateStore.subscribe( persistSessionDocuments, ); @@ -132,7 +126,7 @@ export function connectAtelierWorkspace( if ( await readCurrentAtelierDocumentPath( options.lix, - options.sessionStateStore?.getSnapshot(), + options.sessionStateStore.getSnapshot(), ) ) return; @@ -144,7 +138,7 @@ export function connectAtelierWorkspace( if ( await readCurrentAtelierDocumentPath( options.lix, - options.sessionStateStore?.getSnapshot(), + options.sessionStateStore.getSnapshot(), ) ) return; @@ -162,9 +156,8 @@ export function connectAtelierWorkspace( dispose: () => { if (abortController.signal.aborted) return; abortController.abort(); - uiStateEvents?.close(); - filePathEvents.close(); - unsubscribeSessionState?.(); + void filePathEvents.return?.(); + unsubscribeSessionState(); unsubscribeNewFile(); unsubscribeCloseFile(); }, @@ -173,7 +166,7 @@ export function connectAtelierWorkspace( /** Imports a lazy Electron filesystem entry, then opens it through Atelier. */ export async function openAtelierWorkspacePath(args: { - readonly documents: Pick; + readonly documents: Pick; readonly lix: Lix; readonly path: string; }): Promise { diff --git a/src/lib/lix-client.test.ts b/src/lib/lix-client.test.ts index 05179593..5fb201f7 100644 --- a/src/lib/lix-client.test.ts +++ b/src/lib/lix-client.test.ts @@ -54,6 +54,41 @@ describe("openDesktopLix syncDiskToLix", () => { expect(desktop.syncDiskToLix).toHaveBeenCalledOnce(); expect(desktop.order).toEqual(["sync"]); }); + + test("exposes desktop observations as standard async iterators", async () => { + const desktop = createDesktop({ + workspace: { + ephemeral: false, + path: "/workspace", + name: "workspace", + }, + trackedPaths: [], + }); + desktop.observeNext + .mockResolvedValueOnce({ + sequence: 1, + mutationSequence: 2, + result: { + columns: ["value"], + rows: [["snapshot"]], + rowsAffected: 0, + notices: [], + commit: null, + }, + }) + .mockResolvedValueOnce(undefined); + window.flashtypeDesktop = desktop.api; + + const lix = await openDesktopLix(); + const events = lix.observe("SELECT 'snapshot' AS value"); + const observed = []; + for await (const event of events) observed.push(event); + + expect(observed).toHaveLength(1); + expect(observed[0]?.result.rows).toEqual([{ value: "snapshot" }]); + expect(desktop.observeStart).toHaveBeenCalledOnce(); + expect(desktop.observeClose).toHaveBeenCalledOnce(); + }); }); function createDesktop(args: { @@ -81,6 +116,19 @@ function createDesktop(args: { const syncDiskToLix = vi.fn(async () => { order.push("sync"); }); + const observeStart = vi.fn(async () => "observe-1"); + const observeNext = vi.fn(async (): Promise<{ + sequence: number; + mutationSequence: number; + result: { + columns: string[]; + rows: unknown[][]; + rowsAffected: number; + notices: never[]; + commit: null; + }; + } | undefined> => undefined); + const observeClose = vi.fn(async () => {}); const api = { lix: { open: vi.fn(async () => ({ sessionId: "desktop-session" })), @@ -88,6 +136,9 @@ function createDesktop(args: { importFilesystemPaths, syncDiskToLix, close: vi.fn(async () => {}), + observeStart, + observeNext, + observeClose, }, workspace: { get: vi.fn(async () => args.workspace), @@ -99,5 +150,8 @@ function createDesktop(args: { importFilesystemPaths, order, syncDiskToLix, + observeStart, + observeNext, + observeClose, }; } diff --git a/src/lib/lix-client.ts b/src/lib/lix-client.ts index 78118c50..237e7d14 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, @@ -26,6 +25,7 @@ export async function openDesktopLix(): Promise { const openSqlTransactions = new Set<{ forceRollback: () => Promise; }>(); + const observations = new Set(); const ensureOpen = (methodName: string): void => { if (closed) { @@ -176,11 +176,14 @@ export async function openDesktopLix(): Promise { const observe = ( sql: string, params: ReadonlyArray = [], + options?: Parameters[2], ): ObserveEvents => { ensureOpen("observe"); let localClosed = false; let observeIdPromise: Promise | null = null; + let closePromise: Promise | undefined; + const signal = options?.signal; const ensureObserveId = async (): Promise => { if (!observeIdPromise) { @@ -189,36 +192,62 @@ export async function openDesktopLix(): Promise { return await observeIdPromise; }; - return { - async next(): Promise { - if (closed || localClosed) { - return undefined; + const close = async (): Promise => { + if (localClosed) return closePromise; + localClosed = true; + observations.delete(events); + signal?.removeEventListener("abort", abortObservation); + if (!observeIdPromise) return; + closePromise = (async () => { + const observeId = await ensureObserveId(); + await desktop.lix.observeClose({ observeId }); + })(); + await closePromise; + }; + const events: ObserveEvents = { + async next() { + if (closed || localClosed || signal?.aborted) { + return { done: true, value: undefined }; } const observeId = await ensureObserveId(); - const event = await desktop.lix.observeNext({ observeId }); - if (!event) { - return undefined; + if (closed || localClosed || signal?.aborted) { + return { done: true, value: undefined }; } - return { - sequence: event.sequence, - mutationSequence: event.mutationSequence, - result: toRuntimeQueryResult(event.result), - } as ObserveEvent; - }, - close(): void { - if (localClosed) { - return; + let event: Awaited>; + try { + event = await desktop.lix.observeNext({ observeId }); + } catch (error) { + if (closed || localClosed || signal?.aborted) { + return { done: true, value: undefined }; + } + throw error; } - localClosed = true; - if (!observeIdPromise) { - return; + if (closed || localClosed || signal?.aborted || !event) { + if (!localClosed) await close(); + return { done: true, value: undefined }; } - void (async () => { - const observeId = await ensureObserveId(); - await desktop.lix.observeClose({ observeId }); - })(); + return { + done: false, + value: { + sequence: event.sequence, + mutationSequence: event.mutationSequence, + result: toRuntimeQueryResult(event.result), + } as ObserveEvent, + }; + }, + async return() { + await close(); + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; }, }; + const abortObservation = () => void close(); + observations.add(events); + if (signal?.aborted) abortObservation(); + else signal?.addEventListener("abort", abortObservation, { once: true }); + return events; }; const activeBranchId = async (): Promise => { @@ -233,11 +262,14 @@ export async function openDesktopLix(): Promise { return await runQueued(() => desktop.lix.createBranch({ options })); }; + const branchListeners = new Set<() => void>(); const switchBranch = async ( options: SwitchBranchOptions, ): Promise => { ensureOpen("switchBranch"); - return await runQueued(() => desktop.lix.switchBranch(options)); + const receipt = await runQueued(() => desktop.lix.switchBranch(options)); + for (const listener of branchListeners) listener(); + return receipt; }; const importFilesystemPaths = async ( @@ -260,6 +292,15 @@ export async function openDesktopLix(): Promise { return; } closed = true; + branchListeners.clear(); + for (const observation of [...observations]) { + try { + await observation.return?.(); + } catch { + // ignore observation cleanup failures while shutting down + } + } + observations.clear(); for (const tx of [...openSqlTransactions]) { try { await tx.forceRollback(); @@ -272,12 +313,31 @@ export async function openDesktopLix(): Promise { }; const lix = { + async executeBatch(statements: ReadonlyArray) { + ensureOpen("executeBatch"); + const batch = await runQueued(() => + desktop.lix.executeBatch({ statements }), + ); + return { + ...batch, + results: batch.results.map((result) => ({ + ...toRuntimeQueryResult(result), + statementIndex: result.statementIndex, + })), + }; + }, execute, beginTransaction, transaction, executeTransaction, observe, activeBranchId, + subscribeActiveBranch(listener: () => void) { + branchListeners.add(listener); + return () => { + branchListeners.delete(listener); + }; + }, createBranch, switchBranch, importFilesystemPaths, @@ -338,84 +398,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..5d4d8e01 100644 --- a/src/lib/lix-kysely.ts +++ b/src/lib/lix-kysely.ts @@ -9,7 +9,7 @@ import { type QueryCompiler, type QueryResult, } from "kysely"; -import type { ExecuteResult } from "@lix-js/sdk"; +import type { StatementResult as ExecuteResult } from "@lix-js/sdk"; export { sql } from "kysely"; export type LixDatabaseSchema = Record>; @@ -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..5133ca1d 100644 --- a/src/lib/lix-react.test.tsx +++ b/src/lib/lix-react.test.tsx @@ -10,20 +10,28 @@ afterEach(() => { test("useQuery applies the first observe snapshot over the initial read", async () => { let resolveFirstObserve: - | ((event: ObserveEvent | undefined) => void) + | ((event: IteratorResult) => void) | undefined; const next = vi .fn() .mockImplementationOnce( () => - new Promise((resolve) => { + new Promise>((resolve) => { resolveFirstObserve = resolve; }), ) - .mockImplementation(() => new Promise(() => {})); - const close = vi.fn(); + .mockImplementation( + () => new Promise>(() => {}), + ); + const returnIterator = vi.fn(async () => ({ done: true, value: undefined })); const lix = { - observe: vi.fn(() => ({ next, close })), + observe: vi.fn(() => ({ + next, + return: returnIterator, + [Symbol.asyncIterator]() { + return this; + }, + })), } as unknown as Lix; const execute = vi.fn(async () => [{ value: "stale" }]); @@ -53,17 +61,21 @@ test("useQuery applies the first observe snapshot over the initial read", async ); resolveFirstObserve?.({ - sequence: 1, - mutationSequence: 1, - result: { - columns: ["value"], - rows: [ - { - toObject: () => ({ value: "fresh" }), - }, - ] as unknown as ObserveEvent["result"]["rows"], - rowsAffected: 0, - notices: [], + done: false, + value: { + sequence: 1, + mutationSequence: 1, + result: { + columns: [{ name: "value", type: "jsonb" }], + rows: [ + { + value: "fresh", + }, + ] as unknown as ObserveEvent["result"]["rows"], + rowsAffected: 0, + notices: [], + commit: null, + }, }, }); diff --git a/src/lib/lix-react.tsx b/src/lib/lix-react.tsx index 92e54f3c..b8cef393 100644 --- a/src/lib/lix-react.tsx +++ b/src/lib/lix-react.tsx @@ -82,13 +82,17 @@ export function useQuery( useEffect(() => { if (!subscribe) return; let closed = false; - const events = lix.observe(observeQuery.sql, observeQuery.params); + const eventsController = new AbortController(); + const events = lix.observe( + observeQuery.sql, + observeQuery.params, + { signal: eventsController.signal }, + ); void (async () => { try { - while (!closed) { - const event = await events.next(); - if (closed || event === undefined) break; + for await (const event of events) { + if (closed) break; const nextRows = queryResultToRows(event.result); cacheQueryRows(cacheKey, nextRows); if (rowsEqual(rowsRef.current, nextRows)) { @@ -108,7 +112,7 @@ export function useQuery( return () => { closed = true; - events.close(); + eventsController.abort(); }; }, [cacheKey, subscribe, lix, observeQuery]); @@ -144,11 +148,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/lib/lix-types.ts b/src/lib/lix-types.ts index f473977b..fd658178 100644 --- a/src/lib/lix-types.ts +++ b/src/lib/lix-types.ts @@ -1,11 +1,11 @@ import type { - ExecuteResult, + StatementResult as ExecuteResult, Lix as SdkLix, - LixTransaction as SdkLixTransaction, + ObserveEvent as SdkObserveEvent, OpenLixOptions as SdkOpenLixOptions, } from "@lix-js/sdk"; -export type { ExecuteResult as LixRuntimeQueryResult } from "@lix-js/sdk"; +export type { StatementResult as LixRuntimeQueryResult } from "@lix-js/sdk"; export type ExecuteOptions = { originKey?: string }; export type LixExecuteOptions = ExecuteOptions; @@ -16,7 +16,9 @@ export type TransactionStatement = { params?: ReadonlyArray; }; -export type SqlTransaction = Pick & { +export type SqlTransaction = { + commit(): Promise; + rollback(): Promise; execute( sql: string, params?: ReadonlyArray, @@ -24,32 +26,14 @@ export type SqlTransaction = Pick & { ): Promise; }; -export type ObserveEvent = { - sequence: number; - mutationSequence: number; - result: ExecuteResult; -}; - -export type ObserveEvents = { - /** First event is the current result snapshot; later events are changes. */ - next(): Promise; - close(): void; -}; +export type ObserveEvent = SdkObserveEvent; +export type ObserveEvents = ReturnType; export type OpenLixKeyValueEntry = { key: string; value: unknown; lixcol_untracked?: boolean; -} & ( - | { - lixcol_branch_id: string; - lixcol_global: boolean; - } - | { - lixcol_branch_id?: undefined; - lixcol_global?: boolean; - } -); +}; export type OpenLixOptions = SdkOpenLixOptions & { keyValues?: ReadonlyArray; @@ -61,6 +45,7 @@ type SdkLixBase = Pick< >; export interface FlashtypeLix extends SdkLixBase { + subscribeActiveBranch?(listener: () => void): () => void; execute( sql: string, params?: ReadonlyArray, @@ -71,7 +56,11 @@ export interface FlashtypeLix extends SdkLixBase { executeTransaction( statements: ReadonlyArray, ): Promise; - observe(sql: string, params?: ReadonlyArray): ObserveEvents; + observe( + sql: string, + params?: ReadonlyArray, + options?: Parameters[2], + ): ObserveEvents; importFilesystemPaths(paths: readonly string[]): Promise; mergeBranchPreview?: SdkLix["mergeBranchPreview"]; mergeBranch?: SdkLix["mergeBranch"]; diff --git a/src/main.error.test.tsx b/src/main.error.test.tsx new file mode 100644 index 00000000..ba4ec208 --- /dev/null +++ b/src/main.error.test.tsx @@ -0,0 +1,45 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { ErrorFallback } from "./main.error"; +vi.mock("./lib/telemetry", () => ({ captureTelemetryException: vi.fn() })); +vi.mock("./lib/workspace-recovery-telemetry", () => ({ + captureWorkspaceRecoveryLifecycle: vi.fn(), +})); +afterEach(() => { + cleanup(); + delete window.flashtypeDesktop; +}); +test("older repository format offers upgrade guidance instead of history deletion", async () => { + window.flashtypeDesktop = { + workspace: { get: async () => ({ path: "/old", ephemeral: false }) }, + } as unknown as NonNullable; + render( + , + ); + await waitFor(() => + expect( + screen.getByRole("heading", { name: "Repository upgrade required" }), + ).toBeVisible(), + ); + expect( + screen.queryByRole("button", { name: "Delete .lix and restart" }), + ).toBeNull(); +}); +test("temporary repository query errors cannot offer deletion of nonexistent history", async () => { + window.flashtypeDesktop = { + workspace: { get: async () => ({ path: "/temporary", ephemeral: true }) }, + } as unknown as NonNullable; + render(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Try again" })).toBeVisible(), + ); + expect( + screen.queryByRole("button", { name: "Delete .lix and restart" }), + ).toBeNull(); +}); diff --git a/src/main.error.tsx b/src/main.error.tsx index fe856a26..3cfa8b85 100644 --- a/src/main.error.tsx +++ b/src/main.error.tsx @@ -77,6 +77,26 @@ export function ErrorFallback(props: { const [actionError, setActionError] = useState(null); const busy = busyAction !== null; const recovery = props.recovery ?? null; + const [canDelete, setCanDelete] = useState(false); + useEffect(() => { + let active = true; + void window.flashtypeDesktop?.workspace + ?.get() + .then((workspace) => { + if (active) + setCanDelete( + Boolean( + workspace && + !workspace.ephemeral && + (!recovery || recovery.workspacePath === workspace.path), + ), + ); + }) + .catch(() => {}); + return () => { + active = false; + }; + }, [recovery]); useEffect(() => { if (!recovery) { @@ -101,15 +121,14 @@ export function ErrorFallback(props: { setBusyAction("disable"); setActionError(null); try { - const disableTrackChanges = - window.flashtypeDesktop?.workspace?.disableTrackChanges; - if (typeof disableTrackChanges !== "function") { + const api = window.flashtypeDesktop?.workspace; + const workspacePath = recovery?.workspacePath ?? (await api?.get())?.path; + if (!api?.deleteLixAndRestart || !workspacePath) { throw new Error( - "Disabling Track Changes is only available in the desktop app.", + "Restart Flashtype to load the repository recovery action.", ); } - await disableTrackChanges(); - window.location.reload(); + await api.deleteLixAndRestart(workspacePath); } catch (error) { setActionError(error); setBusyAction(null); @@ -164,6 +183,42 @@ export function ErrorFallback(props: { } const details = recovery ?? props.error; + const upgradeRequired = + /LIX_ERROR_REPOSITORY_MIGRATION_REQUIRED|detached migration tool/.test( + formatError(details), + ); + if (upgradeRequired) + return ( +
    +
    +

    Repository upgrade required

    +

    + This repository was created with an older Lix version. The installed + SDK requires a separate migration tool that is not yet available for + Flashtype. +

    +

    + Your files and saved history have not been deleted. Keep the .lix + folder to preserve them. +

    + + Follow the migration fix + + +
    +
    + ); return (
    @@ -177,26 +232,30 @@ export function ErrorFallback(props: { {recovery?.workspaceName ? ` ${recovery.workspaceName}` : " this workspace"} - . You can disable Track Changes to remove the workspace .lix data - and open the folder normally. Your project files will not be + . Try again to reopen it. Your files and saved history have not been deleted.

    -
    -

    - Disabling Track Changes removes change history stored in .lix. -

    -
    + {canDelete && ( +
    +

    + Deleting .lix permanently removes this folder’s saved change + history. +

    +
    + )}
    - + {canDelete && ( + + )} - ) : null, + navbarEnd: ( +
    + + {isUpdateReady ? ( + + ) : null} +
    + ), rightPanelEmpty: ({ openExtension }) => ( openExtension("flashtype_claude")} @@ -388,9 +406,12 @@ export const AppRoot = () => { ), }} /> - {connectedAtelier !== atelier ? ( + {connectedLix !== lix ? (
    - +
    ) : null}
    @@ -412,33 +433,6 @@ function workspaceNameFromPath(path: string): string | null { } const root = createRoot(document.getElementById("root")!); -const MarkdownEditorFuzzHarness = import.meta.env.DEV - ? lazy(async () => { - const module = - await import("./extensions/markdown/editor/markdown-editor-fuzz-harness"); - return { default: module.MarkdownEditorFuzzHarness }; - }) - : null; - -if (shouldRenderMarkdownEditorFuzzHarness() && MarkdownEditorFuzzHarness) { - root.render( - }> - - , - ); -} else { - // Atelier hosts extensions through imperative nested React roots. Rendering - // the desktop shell in StrictMode would synchronously simulate teardown of - // those roots (and the shared Lix connection) during development startup. - // Use the same lifecycle as the packaged Electron app; editor components keep - // dedicated StrictMode coverage in their tests. - root.render(); -} - -function shouldRenderMarkdownEditorFuzzHarness(): boolean { - return ( - import.meta.env.DEV && - new URLSearchParams(window.location.search).get("e2e") === - "markdown-editor-fuzz" - ); -} +// Atelier owns the editor implementation and its isolated fuzz tests. The +// desktop end-to-end fuzz suite exercises the same AppRoot users run. +root.render(); diff --git a/src/shell/agent-turn-review-range.ts b/src/shell/agent-turn-review-range.ts deleted file mode 100644 index ca973ba2..00000000 --- a/src/shell/agent-turn-review-range.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { Lix } from "@/lib/lix-types"; -import { qb } from "@/lib/lix-kysely"; - -export const AGENT_TURN_COMMIT_RANGE_KEY = - "atelier_agent_turn_commit_range" as const; -export const AGENT_TURN_COMMIT_RANGE_KEY_PREFIX = - `${AGENT_TURN_COMMIT_RANGE_KEY}:` as const; - -const agentTurnCommitRangeMutationQueues = new WeakMap>(); - -export type AgentTurnCommitRange = { - readonly id: string; - readonly sourceId: string; - readonly beforeCommitId: string; - readonly afterCommitId: string; - readonly sessionId?: string; - readonly turnId?: string; - readonly clearedFileIds?: readonly string[]; - readonly startedAt: number; - readonly completedAt: number; -}; - -export type AgentTurnCommitRangeStore = { - readonly ranges: readonly AgentTurnCommitRange[]; -}; - -export function agentTurnReviewId( - fileId: string, - rangeIds: readonly string[], -): string { - return `${fileId}:${rangeIds.join(",")}`; -} - -export async function readAgentTurnCommitRanges( - lix: Lix, - branchId?: string, -): Promise { - const resolvedBranchId = branchId ?? (await lix.activeBranchId()); - const rows = await qb(lix) - .selectFrom("lix_key_value_by_branch") - .select("value") - .where("key", "like", `${AGENT_TURN_COMMIT_RANGE_KEY}%`) - .where("lixcol_branch_id", "=", resolvedBranchId) - .execute(); - return agentTurnCommitRangesFromValues(rows.map((row) => row.value)); -} - -export async function appendAgentTurnCommitRange( - lix: Lix, - range: AgentTurnCommitRange, -): Promise { - await runAgentTurnCommitRangeMutation(lix, async () => { - const ranges = await readAgentTurnCommitRanges(lix); - await writeAgentTurnCommitRanges(lix, [ - ...ranges.filter((existing) => existing.id !== range.id), - range, - ]); - }); -} - -async function writeAgentTurnCommitRanges( - lix: Lix, - ranges: readonly AgentTurnCommitRange[], -): Promise { - const value = serializeAgentTurnCommitRangeStore({ ranges }); - const branchId = await lix.activeBranchId(); - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: AGENT_TURN_COMMIT_RANGE_KEY, - value, - lixcol_branch_id: branchId, - lixcol_global: branchId === "global", - lixcol_untracked: true, - }) - .onConflict((oc) => - oc.columns(["key", "lixcol_branch_id"]).doUpdateSet({ value }), - ) - .execute(); -} - -export async function clearAgentTurnCommitRangeFile( - lix: Lix, - args: { - readonly fileId: string; - readonly reviewId?: string; - readonly agentTurnRangeIds?: readonly string[]; - }, -): Promise { - return await runAgentTurnCommitRangeMutation(lix, async () => { - const ranges = await readAgentTurnCommitRanges(lix); - const rangeIds = args.agentTurnRangeIds ?? []; - if (rangeIds.length === 0) { - return false; - } - if ( - args.reviewId && - args.reviewId !== agentTurnReviewId(args.fileId, rangeIds) - ) { - return false; - } - const rangeIdSet = new Set(rangeIds); - let changed = false; - const nextRanges = ranges.map((range) => { - if (!rangeIdSet.has(range.id)) return range; - if (range.clearedFileIds?.includes(args.fileId)) return range; - changed = true; - return { - ...range, - clearedFileIds: [...(range.clearedFileIds ?? []), args.fileId], - }; - }); - if (!changed) return false; - await writeAgentTurnCommitRanges(lix, nextRanges); - return true; - }); -} - -async function runAgentTurnCommitRangeMutation( - lix: Lix, - operation: () => Promise, -): Promise { - const previous = - agentTurnCommitRangeMutationQueues.get(lix) ?? Promise.resolve(); - let releaseCurrent: (() => void) | undefined; - const current = new Promise((resolve) => { - releaseCurrent = resolve; - }); - const next = previous.catch(() => undefined).then(() => current); - agentTurnCommitRangeMutationQueues.set(lix, next); - await previous.catch(() => undefined); - try { - return await operation(); - } finally { - releaseCurrent?.(); - if (agentTurnCommitRangeMutationQueues.get(lix) === next) { - agentTurnCommitRangeMutationQueues.delete(lix); - } - } -} - -export function isAgentTurnCommitRangeStore( - value: unknown, -): value is AgentTurnCommitRangeStore { - if (!value || typeof value !== "object") { - return false; - } - const store = value as Partial; - return ( - Array.isArray(store.ranges) && store.ranges.every(isAgentTurnCommitRange) - ); -} - -export function agentTurnCommitRangesFromValues( - values: readonly unknown[], -): readonly AgentTurnCommitRange[] { - const rangesById = new Map(); - for (const value of values) { - const ranges = isAgentTurnCommitRangeStore(value) - ? value.ranges - : isAgentTurnCommitRange(value) - ? [value] - : []; - for (const range of ranges) { - const existing = rangesById.get(range.id); - if (!existing) { - rangesById.set(range.id, serializeAgentTurnCommitRange(range)); - continue; - } - const clearedFileIds = [ - ...new Set([ - ...(existing.clearedFileIds ?? []), - ...(range.clearedFileIds ?? []), - ]), - ]; - rangesById.set(range.id, { - ...existing, - ...(clearedFileIds.length > 0 ? { clearedFileIds } : {}), - }); - } - } - return [...rangesById.values()].sort( - (left, right) => - left.completedAt - right.completedAt || left.id.localeCompare(right.id), - ); -} - -export function isAgentTurnCommitRange( - value: unknown, -): value is AgentTurnCommitRange { - if (!value || typeof value !== "object") { - return false; - } - const range = value as Partial; - const clearedFileIds = range.clearedFileIds; - return ( - typeof range.sourceId === "string" && - range.sourceId.length > 0 && - typeof range.id === "string" && - range.id.length > 0 && - typeof range.beforeCommitId === "string" && - range.beforeCommitId.length > 0 && - typeof range.afterCommitId === "string" && - range.afterCommitId.length > 0 && - typeof range.startedAt === "number" && - Number.isFinite(range.startedAt) && - typeof range.completedAt === "number" && - Number.isFinite(range.completedAt) && - (range.sessionId === undefined || typeof range.sessionId === "string") && - (range.turnId === undefined || typeof range.turnId === "string") && - (clearedFileIds === undefined || - (Array.isArray(clearedFileIds) && - clearedFileIds.every( - (fileId) => typeof fileId === "string" && fileId.length > 0, - ))) - ); -} - -function serializeAgentTurnCommitRangeStore( - store: AgentTurnCommitRangeStore, -): AgentTurnCommitRangeStore { - return { - ranges: store.ranges.map(serializeAgentTurnCommitRange), - }; -} - -function serializeAgentTurnCommitRange( - range: AgentTurnCommitRange, -): AgentTurnCommitRange { - return { - id: range.id, - sourceId: range.sourceId, - beforeCommitId: range.beforeCommitId, - afterCommitId: range.afterCommitId, - ...(range.sessionId !== undefined ? { sessionId: range.sessionId } : {}), - ...(range.turnId !== undefined ? { turnId: range.turnId } : {}), - ...(range.clearedFileIds?.length - ? { clearedFileIds: [...new Set(range.clearedFileIds)] } - : {}), - startedAt: range.startedAt, - completedAt: range.completedAt, - }; -} diff --git a/src/shell/external-write-review-history.test.ts b/src/shell/external-write-review-history.test.ts deleted file mode 100644 index 4ff69da2..00000000 --- a/src/shell/external-write-review-history.test.ts +++ /dev/null @@ -1,700 +0,0 @@ -import { createElement, Suspense, useEffect, type ComponentType } from "react"; -import { act, render, waitFor } from "@testing-library/react"; -import { describe, expect, test } from "vitest"; -import { LixProvider } from "@/lib/lix-react"; -import { openLix, type Lix } from "@/test-utils/node-lix-sdk"; -import { qb } from "@/lib/lix-kysely"; -import type { ExternalWriteReview } from "@/extension-runtime/external-write-review"; -import { - getExternalWriteReview, - getExternalWriteReviewData, - getPendingExternalWriteReviewPaths, - useExternalWriteReview, -} from "./external-write-review-history"; -import { - AGENT_TURN_COMMIT_RANGE_KEY, - appendAgentTurnCommitRange, - clearAgentTurnCommitRangeFile, - isAgentTurnCommitRangeStore, - readAgentTurnCommitRanges, - type AgentTurnCommitRange, -} from "./agent-turn-review-range"; - -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -describe("getExternalWriteReview", () => { - test("returns no review when no agent turn range exists", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "history-file", "/docs/history.md", "before"); - await writeFile(lix, "history-file", "/docs/history.md", "after"); - - const review = await getExternalWriteReview( - lix, - "history-file", - "/docs/history.md", - ); - - expect(review).toBeNull(); - } finally { - await lix.close(); - } - }); - - test("uses an agent turn range for the review diff", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "agent-file", "/docs/agent.md", "turn before"); - const beforeCommitId = await activeCommitId(lix); - await writeFile(lix, "agent-file", "/docs/agent.md", "intermediate"); - await writeFile(lix, "agent-file", "/docs/agent.md", "turn after"); - const afterCommitId = await activeCommitId(lix); - - await appendAgentTurnCommitRange( - lix, - agentRange({ id: "range-1", beforeCommitId, afterCommitId }), - ); - - const review = await getExternalWriteReview( - lix, - "agent-file", - "/docs/agent.md", - ); - - expect(review?.agentTurnRangeIds).toEqual(["range-1"]); - expect(review?.beforeCommitId).toBe(beforeCommitId); - expect(review?.afterCommitId).toBe(afterCommitId); - await expectReviewData(lix, review, "turn before", "turn after"); - } finally { - await lix.close(); - } - }); - - test("uses the nearest inherited file history snapshot at the before commit", async () => { - const lix = await openLix(); - try { - await writeFile( - lix, - "inherited-file", - "/docs/inherited.md", - "inherited before", - ); - await writeFile( - lix, - "other-file", - "/docs/other.md", - "unrelated turn start", - ); - const beforeCommitId = await activeCommitId(lix); - await writeFile( - lix, - "inherited-file", - "/docs/inherited.md", - "inherited after", - ); - const afterCommitId = await activeCommitId(lix); - - await appendAgentTurnCommitRange( - lix, - agentRange({ id: "range-inherited", beforeCommitId, afterCommitId }), - ); - - const review = await getExternalWriteReview( - lix, - "inherited-file", - "/docs/inherited.md", - ); - - expect(review?.agentTurnRangeIds).toEqual(["range-inherited"]); - await expectReviewData( - lix, - review, - "inherited before", - "inherited after", - ); - } finally { - await lix.close(); - } - }); - - test("returns no review for a no-op agent turn range", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "noop-file", "/docs/noop.md", "before"); - const commitId = await activeCommitId(lix); - await writeFile(lix, "noop-file", "/docs/noop.md", "after"); - - await appendAgentTurnCommitRange( - lix, - agentRange({ - id: "range-noop", - beforeCommitId: commitId, - afterCommitId: commitId, - }), - ); - - await expect( - getExternalWriteReview(lix, "noop-file", "/docs/noop.md"), - ).resolves.toBeNull(); - } finally { - await lix.close(); - } - }); - - test("omits undefined optional ids when persisting agent turn ranges", async () => { - const lix = await openLix(); - try { - await appendAgentTurnCommitRange(lix, { - id: "range-without-optional-ids", - sourceId: "codex", - beforeCommitId: "commit-before", - afterCommitId: "commit-after", - sessionId: undefined, - turnId: undefined, - startedAt: 1, - completedAt: 2, - }); - - const [range] = await readAgentTurnCommitRanges(lix); - - expect(range?.id).toBe("range-without-optional-ids"); - expect(Object.hasOwn(range ?? {}, "sessionId")).toBe(false); - expect(Object.hasOwn(range ?? {}, "turnId")).toBe(false); - } finally { - await lix.close(); - } - }); - - test("accepts Atelier-shaped source metadata without an agent field", () => { - expect( - isAgentTurnCommitRangeStore({ - ranges: [ - { - id: "atelier-range", - sourceId: "custom-agent-host", - beforeCommitId: "commit-before", - afterCommitId: "commit-after", - startedAt: 1, - completedAt: 2, - }, - ], - }), - ).toBe(true); - expect( - isAgentTurnCommitRangeStore({ - ranges: [ - { - id: "legacy-range", - agent: "codex", - beforeCommitId: "commit-before", - afterCommitId: "commit-after", - startedAt: 1, - completedAt: 2, - }, - ], - }), - ).toBe(false); - }); - - test("marks a file added during an agent turn as pending", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "existing-file", "/docs/existing.md", "before"); - const beforeCommitId = await activeCommitId(lix); - await writeFile(lix, "added-file", "/docs/added.md", "created"); - const afterCommitId = await activeCommitId(lix); - const range = agentRange({ - id: "range-added-file", - beforeCommitId, - afterCommitId, - }); - - await expect( - getPendingExternalWriteReviewPaths( - lix, - [{ fileId: "added-file", path: "/docs/added.md" }], - [range], - ), - ).resolves.toEqual(new Set(["/docs/added.md"])); - } finally { - await lix.close(); - } - }); - - test("serializes concurrent agent turn range appends", async () => { - const lix = await openLix(); - try { - const ranges = Array.from({ length: 8 }, (_, index) => - agentRange({ - id: `range-concurrent-${index}`, - beforeCommitId: `commit-before-${index}`, - afterCommitId: `commit-after-${index}`, - }), - ); - - await Promise.all( - ranges.map((range) => appendAgentTurnCommitRange(lix, range)), - ); - - const persistedRanges = await readAgentTurnCommitRanges(lix); - expect(persistedRanges).toHaveLength(ranges.length); - expect(persistedRanges.map((range) => range.id).sort()).toEqual( - ranges.map((range) => range.id).sort(), - ); - } finally { - await lix.close(); - } - }); - - test("stores agent turn ranges on the active branch", async () => { - const lix = await openLix(); - try { - const mainBranchId = await lix.activeBranchId(); - const draftBranch = await lix.createBranch({ name: "Draft" }); - const mainRange = agentRange({ - id: "range-main", - beforeCommitId: "commit-main-before", - afterCommitId: "commit-main-after", - }); - const draftRange = agentRange({ - id: "range-draft", - beforeCommitId: "commit-draft-before", - afterCommitId: "commit-draft-after", - }); - - await appendAgentTurnCommitRange(lix, mainRange); - expect( - (await readAgentTurnCommitRanges(lix)).map((range) => range.id), - ).toEqual(["range-main"]); - - await lix.switchBranch({ branchId: draftBranch.id }); - expect(await readAgentTurnCommitRanges(lix)).toEqual([]); - - await appendAgentTurnCommitRange(lix, draftRange); - expect( - (await readAgentTurnCommitRanges(lix)).map((range) => range.id), - ).toEqual(["range-draft"]); - - await lix.switchBranch({ branchId: mainBranchId }); - expect( - (await readAgentTurnCommitRanges(lix)).map((range) => range.id), - ).toEqual(["range-main"]); - } finally { - await lix.close(); - } - }); - - test("persists cleared files in the agent turn range", async () => { - const lix = await openLix(); - try { - await writeFile( - lix, - "cleared-file", - "/docs/cleared.md", - "cleared before", - ); - await writeFile(lix, "open-file", "/docs/open.md", "open before"); - const beforeCommitId = await activeCommitId(lix); - await writeFile(lix, "cleared-file", "/docs/cleared.md", "cleared after"); - await writeFile(lix, "open-file", "/docs/open.md", "open after"); - const afterCommitId = await activeCommitId(lix); - const range = agentRange({ - id: "range-with-cleared-file", - beforeCommitId, - afterCommitId, - }); - - await appendAgentTurnCommitRange(lix, range); - await clearAgentTurnCommitRangeFile(lix, { - fileId: "cleared-file", - reviewId: "cleared-file:range-with-cleared-file", - agentTurnRangeIds: [range.id], - }); - - const [persistedRange] = await readAgentTurnCommitRanges(lix); - expect(persistedRange?.clearedFileIds).toEqual(["cleared-file"]); - await expect( - getExternalWriteReview(lix, "cleared-file", "/docs/cleared.md"), - ).resolves.toBeNull(); - const openReview = await getExternalWriteReview( - lix, - "open-file", - "/docs/open.md", - ); - expect(openReview?.agentTurnRangeIds).toEqual([ - "range-with-cleared-file", - ]); - await expectReviewData(lix, openReview, "open before", "open after"); - } finally { - await lix.close(); - } - }); - - test("combines unresolved ranges for the same file into one review", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "multi-file", "/docs/multi.md", "turn 1 before"); - const beforeCommitId = await activeCommitId(lix); - await writeFile(lix, "multi-file", "/docs/multi.md", "turn 1 after"); - const middleCommitId = await activeCommitId(lix); - await appendAgentTurnCommitRange( - lix, - agentRange({ - id: "range-multi-1", - beforeCommitId, - afterCommitId: middleCommitId, - }), - ); - await writeFile(lix, "multi-file", "/docs/multi.md", "turn 2 after"); - const afterCommitId = await activeCommitId(lix); - await appendAgentTurnCommitRange( - lix, - agentRange({ - id: "range-multi-2", - beforeCommitId: middleCommitId, - afterCommitId, - }), - ); - - const review = await getExternalWriteReview( - lix, - "multi-file", - "/docs/multi.md", - ); - - expect(review?.reviewId).toBe("multi-file:range-multi-1,range-multi-2"); - expect(review?.agentTurnRangeIds).toEqual([ - "range-multi-1", - "range-multi-2", - ]); - expect(review?.beforeCommitId).toBe(beforeCommitId); - expect(review?.afterCommitId).toBe(afterCommitId); - await expectReviewData(lix, review, "turn 1 before", "turn 2 after"); - } finally { - await lix.close(); - } - }); - - test("clears a combined file review across all contributing ranges", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "multi-clear-file", "/docs/multi-clear.md", "a0"); - await writeFile(lix, "other-clear-file", "/docs/other-clear.md", "b0"); - const beforeCommitId = await activeCommitId(lix); - await writeFile(lix, "multi-clear-file", "/docs/multi-clear.md", "a1"); - await writeFile(lix, "other-clear-file", "/docs/other-clear.md", "b1"); - const middleCommitId = await activeCommitId(lix); - await appendAgentTurnCommitRange( - lix, - agentRange({ - id: "range-clear-1", - beforeCommitId, - afterCommitId: middleCommitId, - }), - ); - await writeFile(lix, "multi-clear-file", "/docs/multi-clear.md", "a2"); - const afterCommitId = await activeCommitId(lix); - await appendAgentTurnCommitRange( - lix, - agentRange({ - id: "range-clear-2", - beforeCommitId: middleCommitId, - afterCommitId, - }), - ); - const review = await getExternalWriteReview( - lix, - "multi-clear-file", - "/docs/multi-clear.md", - ); - expect(review?.agentTurnRangeIds).toEqual([ - "range-clear-1", - "range-clear-2", - ]); - - await clearAgentTurnCommitRangeFile(lix, { - fileId: "multi-clear-file", - reviewId: review?.reviewId, - agentTurnRangeIds: review?.agentTurnRangeIds, - }); - - const ranges = await readAgentTurnCommitRanges(lix); - expect(ranges.map((range) => range.clearedFileIds)).toEqual([ - ["multi-clear-file"], - ["multi-clear-file"], - ]); - await expect( - getExternalWriteReview(lix, "multi-clear-file", "/docs/multi-clear.md"), - ).resolves.toBeNull(); - const otherReview = await getExternalWriteReview( - lix, - "other-clear-file", - "/docs/other-clear.md", - ); - expect(otherReview?.agentTurnRangeIds).toEqual(["range-clear-1"]); - await expectReviewData(lix, otherReview, "b0", "b1"); - } finally { - await lix.close(); - } - }); - - test("does not miss a range persisted before the first observer snapshot", async () => { - const lix = await openLix(); - const observerGate = gateInitialReviewRangeSnapshot(lix); - let utils: ReturnType | undefined; - try { - await writeFile(lix, "live-file", "/docs/live.md", "live before"); - const beforeCommitId = await activeCommitId(lix); - await writeFile(lix, "live-file", "/docs/live.md", "live after"); - const afterCommitId = await activeCommitId(lix); - const reviews: Array = []; - - await act(async () => { - utils = render( - createElement( - LixProvider as ComponentType<{ lix: Lix }>, - { lix: observerGate.lix }, - createElement( - Suspense, - { fallback: null }, - createElement(ExternalWriteReviewProbe, { - fileId: "live-file", - path: "/docs/live.md", - onReview: (review) => reviews.push(review), - }), - ), - ), - ); - }); - - await waitFor(() => { - expect(reviews.length).toBeGreaterThan(0); - expect(reviews.at(-1)).toBeNull(); - }); - await observerGate.started; - - await act(async () => { - await appendAgentTurnCommitRange( - lix, - agentRange({ - id: "range-live-hook", - beforeCommitId, - afterCommitId, - }), - ); - observerGate.release(); - }); - - await waitFor(() => { - const review = reviews.at(-1); - expect(review?.agentTurnRangeIds).toEqual(["range-live-hook"]); - expect(review?.beforeCommitId).toBe(beforeCommitId); - expect(review?.afterCommitId).toBe(afterCommitId); - }); - } finally { - observerGate.release(); - await act(async () => { - utils?.unmount(); - }); - await lix.close(); - } - }); - - test("updates an already mounted review hook when the file is cleared", async () => { - const lix = await openLix(); - let utils: ReturnType | undefined; - try { - await writeFile( - lix, - "live-clear-file", - "/docs/live-clear.md", - "clear before", - ); - const beforeCommitId = await activeCommitId(lix); - await writeFile( - lix, - "live-clear-file", - "/docs/live-clear.md", - "clear after", - ); - const afterCommitId = await activeCommitId(lix); - const reviews: Array = []; - - await appendAgentTurnCommitRange( - lix, - agentRange({ - id: "range-live-clear-hook", - beforeCommitId, - afterCommitId, - }), - ); - - await act(async () => { - utils = render( - createElement( - LixProvider as ComponentType<{ lix: Lix }>, - { lix }, - createElement( - Suspense, - { fallback: null }, - createElement(ExternalWriteReviewProbe, { - fileId: "live-clear-file", - path: "/docs/live-clear.md", - onReview: (review) => reviews.push(review), - }), - ), - ), - ); - }); - - await waitFor(() => { - const review = reviews.at(-1); - expect(review?.agentTurnRangeIds).toEqual(["range-live-clear-hook"]); - }); - - await act(async () => { - await clearAgentTurnCommitRangeFile(lix, { - fileId: "live-clear-file", - reviewId: "live-clear-file:range-live-clear-hook", - agentTurnRangeIds: ["range-live-clear-hook"], - }); - }); - - await waitFor(() => { - expect(reviews.at(-1)).toBeNull(); - }); - } finally { - await act(async () => { - utils?.unmount(); - }); - await lix.close(); - } - }); -}); - -function ExternalWriteReviewProbe({ - fileId, - path, - onReview, -}: { - readonly fileId: string; - readonly path: string; - readonly onReview: (review: ExternalWriteReview | null) => void; -}) { - const review = useExternalWriteReview({ fileId, path }); - useEffect(() => { - onReview(review); - }, [onReview, review]); - return null; -} - -function gateInitialReviewRangeSnapshot(lix: Lix): { - readonly lix: Lix; - readonly started: Promise; - readonly release: () => void; -} { - const originalObserve = lix.observe.bind(lix); - let markStarted: (() => void) | undefined; - let releaseSnapshot: (() => void) | undefined; - const started = new Promise((resolve) => { - markStarted = resolve; - }); - const snapshotGate = new Promise((resolve) => { - releaseSnapshot = resolve; - }); - - return { - lix: { - ...lix, - observe(sql, params = []) { - const events = originalObserve(sql, params); - if ( - !params.some( - (param) => - typeof param === "string" && - param.startsWith(AGENT_TURN_COMMIT_RANGE_KEY), - ) - ) { - return events; - } - let isFirstSnapshot = true; - return { - async next() { - if (isFirstSnapshot) { - isFirstSnapshot = false; - markStarted?.(); - await snapshotGate; - } - return await events.next(); - }, - close: () => events.close(), - }; - }, - }, - started, - release: () => releaseSnapshot?.(), - }; -} - -async function writeFile( - lix: Lix, - id: string, - path: string, - text: string, -): Promise { - await qb(lix) - .insertInto("lix_file") - .values({ id, path, data: encoder.encode(text) }) - .onConflict((oc) => - oc.column("id").doUpdateSet({ path, data: encoder.encode(text) }), - ) - .execute(); -} - -async function activeCommitId(lix: Lix): Promise { - const result = await lix.execute( - "SELECT lix_active_branch_commit_id() AS commit_id", - ); - const commitId = result.rows[0]?.get("commit_id"); - if (typeof commitId !== "string") { - throw new Error("Missing active commit id"); - } - return commitId; -} - -function agentRange( - overrides: Pick< - AgentTurnCommitRange, - "id" | "beforeCommitId" | "afterCommitId" - >, -): AgentTurnCommitRange { - return { - sourceId: "codex", - sessionId: "session-1", - turnId: "turn-1", - startedAt: 1, - completedAt: 2, - ...overrides, - }; -} - -async function expectReviewData( - lix: Lix, - review: ExternalWriteReview | null | undefined, - beforeText: string, - afterText: string, -): Promise { - expect(review).not.toBeNull(); - expect(review).not.toBeUndefined(); - const data = await getExternalWriteReviewData( - lix, - review as ExternalWriteReview, - ); - expect(decode(data?.beforeData)).toBe(beforeText); - expect(decode(data?.afterData)).toBe(afterText); -} - -function decode(value: Uint8Array | undefined): string { - return decoder.decode(value ?? new Uint8Array()); -} diff --git a/src/shell/external-write-review-history.ts b/src/shell/external-write-review-history.ts deleted file mode 100644 index f2b5d039..00000000 --- a/src/shell/external-write-review-history.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { useEffect, useState } from "react"; -import type { Lix } from "@/lib/lix-types"; -import { useLix } from "@/lib/lix-react"; -import { qb } from "@/lib/lix-kysely"; -import { decodeFileDataToBytes } from "@/lib/decode-file-data"; -import type { - ExternalWriteReview, - ExternalWriteReviewData, -} from "@/extension-runtime/external-write-review"; -import { - AGENT_TURN_COMMIT_RANGE_KEY, - agentTurnCommitRangesFromValues, - agentTurnReviewId, - readAgentTurnCommitRanges, - type AgentTurnCommitRange, -} from "./agent-turn-review-range"; - -type FileHistoryRow = { - readonly data: unknown; -}; - -export type ExternalWriteReviewFile = { - readonly fileId: string; - readonly path: string; -}; - -export async function getExternalWriteReview( - lix: Lix, - fileId: string, - path: string, -): Promise { - const ranges = await readAgentTurnCommitRanges(lix); - return getAgentTurnExternalWriteReview(lix, fileId, path, ranges); -} - -export async function getPendingExternalWriteReviewPaths( - lix: Lix, - files: readonly ExternalWriteReviewFile[], - ranges?: readonly AgentTurnCommitRange[], -): Promise> { - const pendingPaths = new Set(); - const resolvedRanges = ranges ?? (await readAgentTurnCommitRanges(lix)); - if (files.length === 0 || resolvedRanges.length === 0) { - return pendingPaths; - } - await Promise.all( - files.map(async (file) => { - const hasPendingReview = await hasPendingAgentTurnReview( - lix, - file.fileId, - resolvedRanges, - ); - if (hasPendingReview) { - pendingPaths.add(file.path); - } - }), - ); - return pendingPaths; -} - -async function hasPendingAgentTurnReview( - lix: Lix, - fileId: string, - ranges: readonly AgentTurnCommitRange[], -): Promise { - for (const range of ranges) { - if (range.beforeCommitId === range.afterCommitId) continue; - if (range.clearedFileIds?.includes(fileId)) continue; - const [beforeData, afterData] = await Promise.all([ - getFileDataAtCommit(lix, fileId, range.beforeCommitId), - getFileDataAtCommit(lix, fileId, range.afterCommitId), - ]); - if (!beforeData && !afterData) continue; - if (!beforeData || !afterData || !fileBytesEqual(beforeData, afterData)) { - return true; - } - } - return false; -} - -export function useExternalWriteReview(args: { - readonly fileId?: string | null; - readonly path?: string | null; -}): ExternalWriteReview | null { - const lix = useLix(); - const [review, setReview] = useState(null); - const [reviewRevision, setReviewRevision] = useState(0); - - useEffect(() => { - let cancelled = false; - const activeBranchEvents = lix.observe( - `SELECT value - FROM lix_key_value - WHERE key = ?`, - ["lix_workspace_branch_id"], - ); - const reviewRangeEvents = lix.observe( - `SELECT value, lixcol_branch_id - FROM lix_key_value_by_branch - WHERE key LIKE ?`, - [`${AGENT_TURN_COMMIT_RANGE_KEY}%`], - ); - const watchEvents = async ( - events: ReturnType, - ): Promise => { - while (!cancelled) { - const event = await events.next(); - if (!event || cancelled) break; - // The first snapshot may already contain a mutation that landed after - // the initial review read but before the observer started. Treat every - // snapshot as an invalidation so that ordering cannot lose a review. - setReviewRevision((current) => current + 1); - } - }; - void watchEvents(activeBranchEvents); - void watchEvents(reviewRangeEvents); - return () => { - cancelled = true; - activeBranchEvents.close(); - reviewRangeEvents.close(); - }; - }, [lix]); - - useEffect(() => { - let cancelled = false; - setReview(null); - if (!args.fileId || !args.path) { - return; - } - void (async () => { - const ranges = await readActiveBranchReviewRanges(lix); - if (ranges.length === 0) return null; - return await getAgentTurnExternalWriteReview( - lix, - args.fileId as string, - args.path as string, - ranges, - ); - })() - .then((nextReview) => { - if (!cancelled) { - setReview(nextReview); - } - }) - .catch((error: unknown) => { - if (!cancelled) { - console.warn("[agent-turn-review] failed to load review", error); - setReview(null); - } - }); - return () => { - cancelled = true; - }; - }, [lix, args.fileId, args.path, reviewRevision]); - - if (!review) { - return null; - } - return review; -} - -async function readActiveBranchReviewRanges( - lix: Lix, -): Promise { - const activeBranch = await qb(lix) - .selectFrom("lix_key_value") - .where("key", "=", "lix_workspace_branch_id") - .select(["value"]) - .executeTakeFirst(); - const activeBranchId = - typeof activeBranch?.value === "string" ? activeBranch.value : ""; - const rangeRows = await qb(lix) - .selectFrom("lix_key_value_by_branch") - .select("value") - .where("key", "like", `${AGENT_TURN_COMMIT_RANGE_KEY}%`) - .where("lixcol_branch_id", "=", activeBranchId) - .execute(); - return agentTurnCommitRangesFromValues(rangeRows.map((row) => row.value)); -} - -export function useExternalWriteReviewData( - review: ExternalWriteReview | null | undefined, -): ExternalWriteReviewData | null { - const lix = useLix(); - const [data, setData] = useState(null); - - useEffect(() => { - let cancelled = false; - setData(null); - if (!review) { - return; - } - void getExternalWriteReviewData(lix, review) - .then((nextData) => { - if (!cancelled) { - setData(nextData); - } - }) - .catch((error: unknown) => { - if (!cancelled) { - console.warn("[agent-turn-review] failed to load review data", error); - setData(null); - } - }); - return () => { - cancelled = true; - }; - }, [lix, review]); - - return data; -} - -export async function getExternalWriteReviewData( - lix: Lix, - review: ExternalWriteReview, -): Promise { - const [beforeData, afterData] = await Promise.all([ - getFileDataAtCommit(lix, review.fileId, review.beforeCommitId), - getFileDataAtCommit(lix, review.fileId, review.afterCommitId), - ]); - if (!beforeData || !afterData) return null; - return { beforeData, afterData }; -} - -export async function getFileDataAtCommit( - lix: Lix, - fileId: string, - commitId: string, -): Promise { - const snapshot = await getFileHistorySnapshotAtCommit(lix, fileId, commitId); - return snapshot ? decodeFileDataToBytes(snapshot.data) : null; -} - -async function getAgentTurnExternalWriteReview( - lix: Lix, - fileId: string, - path: string, - ranges: readonly AgentTurnCommitRange[], -): Promise { - const relevantRanges: AgentTurnCommitRange[] = []; - for (const range of ranges) { - if (range.beforeCommitId === range.afterCommitId) continue; - if (range.clearedFileIds?.includes(fileId)) continue; - const data = await getRangeFileData(lix, fileId, range); - if (!data) continue; - if (fileBytesEqual(data.beforeData, data.afterData)) continue; - relevantRanges.push(range); - } - if (relevantRanges.length === 0) return null; - const firstRange = relevantRanges[0]; - const lastRange = relevantRanges[relevantRanges.length - 1]; - if (!firstRange || !lastRange) return null; - const [beforeData, afterData] = await Promise.all([ - getFileDataAtCommit(lix, fileId, firstRange.beforeCommitId), - getFileDataAtCommit(lix, fileId, lastRange.afterCommitId), - ]); - if (!beforeData || !afterData) return null; - if (fileBytesEqual(beforeData, afterData)) return null; - const rangeIds = relevantRanges.map((range) => range.id); - return { - fileId, - path, - reviewId: agentTurnReviewId(fileId, rangeIds), - beforeCommitId: firstRange.beforeCommitId, - afterCommitId: lastRange.afterCommitId, - agentTurnRangeIds: rangeIds, - }; -} - -async function getRangeFileData( - lix: Lix, - fileId: string, - range: AgentTurnCommitRange, -): Promise { - const [beforeData, afterData] = await Promise.all([ - getFileDataAtCommit(lix, fileId, range.beforeCommitId), - getFileDataAtCommit(lix, fileId, range.afterCommitId), - ]); - if (!beforeData || !afterData) return null; - return { beforeData, afterData }; -} - -async function getFileHistorySnapshotAtCommit( - lix: Lix, - fileId: string, - commitId: string, -): Promise { - const row = (await qb(lix) - .selectFrom("lix_file_history") - .select("data") - .where("lixcol_start_commit_id", "=", commitId) - .where("id", "=", fileId) - .orderBy("lixcol_depth", "asc") - .limit(1) - .executeTakeFirst()) as FileHistoryRow | undefined; - return row ?? null; -} - -function fileBytesEqual(left: Uint8Array, right: Uint8Array): boolean { - if (left.byteLength !== right.byteLength) return false; - for (let index = 0; index < left.byteLength; index += 1) { - if (left[index] !== right[index]) return false; - } - return true; -} 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/share-button.test.tsx b/src/shell/share-button.test.tsx new file mode 100644 index 00000000..8d0e68b3 --- /dev/null +++ b/src/shell/share-button.test.tsx @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { ShareButton } from "./share-button"; + +beforeEach(() => { + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation( + function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }, + ); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(function ( + this: HTMLDialogElement, + ) { + this.removeAttribute("open"); + }); +}); + +afterEach(() => { + cleanup(); + delete window.flashtypeDesktop; + vi.restoreAllMocks(); +}); +test("Share offers Lixray and an email draft without repository setup", () => { + const openExternal = vi.fn().mockResolvedValue(undefined); + window.flashtypeDesktop = { app: { openExternal } } as unknown as NonNullable< + Window["flashtypeDesktop"] + >; + render(); + fireEvent.click(screen.getByRole("button", { name: "Share" })); + expect(screen.getByRole("dialog")).toBeVisible(); + fireEvent.click(screen.getByRole("link", { name: /Open lixray.com/ })); + expect(openExternal).toHaveBeenCalledWith({ url: "https://lixray.com/" }); + fireEvent.click(screen.getByRole("link", { name: /samuel@opral.com/ })); + expect(openExternal).toHaveBeenCalledWith({ + url: "mailto:samuel@opral.com?subject=Flashtype%20sync%20request&body=Hi%20Samuel%2C%0A%0AI%E2%80%99m%20interested%20in%20syncing%20my%20local%20Flashtype%20files%20with%20Lixray.%20Please%20let%20me%20know%20when%20local%20sync%20is%20available.%0A%0AThanks%21", + }); + expect(screen.queryByText("Initialize repository")).toBeNull(); +}); + +test("Share dialog closes from the keyboard", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Share" })); + const dialog = screen.getByRole("dialog"); + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(dialog).not.toHaveAttribute("open"); +}); diff --git a/src/shell/share-button.tsx b/src/shell/share-button.tsx new file mode 100644 index 00000000..37c048a6 --- /dev/null +++ b/src/shell/share-button.tsx @@ -0,0 +1,89 @@ +import { useRef, useState } from "react"; +import { Share2, X, ExternalLink, Mail, Cloud } from "lucide-react"; + +export function ShareButton() { + const dialog = useRef(null); + const [error, setError] = useState(""); + async function openExternal(event: React.MouseEvent) { + const api = window.flashtypeDesktop?.app; + if (!api) return; + event.preventDefault(); + setError(""); + try { + await api.openExternal({ url: event.currentTarget.href }); + } catch { + setError("Could not open the link. Please try again."); + } + } + return ( + <> + + { + if (event.target === event.currentTarget) dialog.current?.close(); + }} + onKeyDown={(event) => { + if (event.key === "Escape") dialog.current?.close(); + }} + > +
    +
    + + + + +
    +

    Flashtype, in the cloud.

    +

    + Use Lixray, the cloud version of Flashtype, to publish and share + your files. +

    + + Open lixray.com + +
    +

    Interested in local sync?

    + + Write an email to samuel@opral.com + +
    + {error && ( +

    + {error} +

    + )} +
    +
    + + ); +} diff --git a/src/shell/temporary-history-notice.test.tsx b/src/shell/temporary-history-notice.test.tsx new file mode 100644 index 00000000..5410c402 --- /dev/null +++ b/src/shell/temporary-history-notice.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { TemporaryHistoryNotice } from "./temporary-history-notice"; +const original = window.flashtypeDesktop; +afterEach(() => { + cleanup(); + window.flashtypeDesktop = original; +}); +function desktop(inspectRepositorySize: ReturnType) { + const initializeRepository = vi.fn().mockResolvedValue({}); + window.flashtypeDesktop = { + workspace: { inspectRepositorySize, initializeRepository }, + } as unknown as NonNullable; + return initializeRepository; +} +test("shows limits and blocks initialization until inspection succeeds", async () => { + let finish!: (value: unknown) => void; + const init = desktop( + vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ), + ); + render(); + const button = screen.getByRole("button", { name: "Initialize repository" }); + expect(button).toBeDisabled(); + expect( + screen.getByText(/1 GB total, with no file-count limit/), + ).toBeInTheDocument(); + finish({ + fileCount: 200, + totalBytes: 1_000_000_000, + complete: true, + allowed: true, + }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); + await waitFor(() => expect(init).toHaveBeenCalledOnce()); +}); +test("explains oversized folders and allows checking again after files are removed", async () => { + const inspect = vi + .fn() + .mockResolvedValueOnce({ + fileCount: 201, + totalBytes: 1_000_000_001, + complete: false, + allowed: false, + }) + .mockResolvedValueOnce({ + fileCount: 5, + totalBytes: 12, + complete: true, + allowed: true, + }); + const init = desktop(inspect); + render(); + expect( + await screen.findByText(/exceeds the repository limit/), + ).toBeInTheDocument(); + const button = screen.getByRole("button", { name: "Initialize repository" }); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(init).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Check again" })); + await waitFor(() => expect(button).toBeEnabled()); +}); +test("fails closed and explains inspection errors", async () => { + desktop(vi.fn().mockRejectedValue(new Error("EACCES"))); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not check this folder", + ); + expect( + screen.getByRole("button", { name: "Initialize repository" }), + ).toBeDisabled(); +}); + +test("explains when the desktop process needs a restart", async () => { + desktop( + vi + .fn() + .mockRejectedValue( + new Error( + "No handler registered for 'workspace:inspectRepositorySize'", + ), + ), + ); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Restart Flashtype", + ); + expect( + screen.getByRole("button", { name: "Initialize repository" }), + ).toBeDisabled(); +}); diff --git a/src/shell/temporary-history-notice.tsx b/src/shell/temporary-history-notice.tsx new file mode 100644 index 00000000..09e4525f --- /dev/null +++ b/src/shell/temporary-history-notice.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from "react"; + +type RepositorySize = { + fileCount: number; + totalBytes: number; + complete: boolean; + allowed: boolean; +}; + +function formatBytes(bytes: number) { + if (bytes >= 1_000_000_000) + return `${Math.ceil(bytes / 10_000_000) / 100} GB`; + if (bytes >= 1_000_000) return `${Math.ceil(bytes / 10_000) / 100} MB`; + if (bytes >= 1_000) return `${Math.ceil(bytes / 10) / 100} kB`; + return `${bytes} B`; +} + +export function TemporaryHistoryNotice() { + const [pending, setPending] = useState(false); + const [checking, setChecking] = useState(true); + const [size, setSize] = useState(null); + const [error, setError] = useState(null); + const check = useCallback(async () => { + setChecking(true); + setError(null); + try { + const workspace = window.flashtypeDesktop?.workspace; + if (!workspace?.inspectRepositorySize) + throw new Error( + "Open or restart Flashtype on desktop to check this folder.", + ); + setSize(await workspace.inspectRepositorySize()); + } catch (error) { + setSize(null); + setError( + error instanceof Error && + (error.message.includes("restart Flashtype") || + error.message.includes("No handler registered")) + ? "Restart Flashtype to enable folder size checks." + : "Could not check this folder. Make sure it is accessible, then check again.", + ); + } finally { + setChecking(false); + } + }, []); + useEffect(() => { + void check(); + }, [check]); + + async function initialize() { + if (pending || checking || !size?.allowed) 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) { + await check(); + 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. +

    +

    + Supports up to 1 GB total, with no file-count limit. Includes + subfolders. Git and Lix metadata are excluded. +

    +

    + {checking + ? "Checking folder…" + : size + ? `${size.complete ? "" : "At least "}${size.fileCount} files · ${formatBytes(size.totalBytes)}${size.complete ? "" : " scanned"}` + : "Folder size unavailable"} +

    + {!checking && size && !size.allowed ? ( +

    + This folder exceeds the repository limit. Open a smaller folder to + keep persistent history. You can continue editing here. +

    + ) : null} + + + {error ? ( +

    + {error} +

    + ) : null} +
    + ); +} diff --git a/src/shell/theme.css b/src/shell/theme.css index 4395639a..10b712cb 100644 --- a/src/shell/theme.css +++ b/src/shell/theme.css @@ -1,3 +1,5 @@ +@import "@opral/atelier/theme.css"; + /** * Based on the semantic color guidance from Subframe: * https://www.subframe.com/blog/how-to-setup-semantic-tailwind-colors @@ -30,32 +32,32 @@ --color-neutral-950: rgb(12, 10, 9); /* Flashtype semantic aliases. Named by role, prominence, and state. */ - --color-bg-app: rgb(244, 242, 239); - --color-bg-panel: var(--color-neutral-0); - --color-bg-panel-muted: var(--color-neutral-50); - --color-bg-hover: rgb(245, 242, 237); - --color-border-panel: rgb(236, 232, 226); - --color-border-subtle: rgb(244, 241, 236); - --color-text-primary: var(--color-neutral-900); - --color-text-secondary: var(--color-neutral-700); - --color-text-tertiary: var(--color-neutral-500); - --color-icon-secondary: var(--color-neutral-600); - --color-icon-tertiary: var(--color-neutral-500); - --color-icon-brand: var(--color-brand-600); - --color-bg-brand-soft: var(--color-brand-50); - --color-border-brand-soft: var(--color-brand-100); - --color-text-link: var(--color-brand-600); - --color-text-link-hover: var(--color-brand-700); + --color-bg-app: var(--atelier-bg); + --color-bg-panel: var(--atelier-panel); + --color-bg-panel-muted: var(--atelier-bg-subtle); + --color-bg-hover: var(--atelier-bg-hover); + --color-border-panel: var(--atelier-border); + --color-border-subtle: var(--atelier-border-subtle); + --color-text-primary: var(--atelier-fg); + --color-text-secondary: var(--atelier-fg-muted); + --color-text-tertiary: var(--atelier-fg-subtle); + --color-icon-secondary: var(--atelier-fg-muted); + --color-icon-tertiary: var(--atelier-fg-subtle); + --color-icon-brand: var(--atelier-accent); + --color-bg-brand-soft: var(--atelier-accent-subtle); + --color-border-brand-soft: var(--atelier-accent-border); + --color-text-link: var(--atelier-link); + --color-text-link-hover: var(--atelier-link-hover); --color-bg-tooltip: var(--color-neutral-900); --color-text-tooltip: var(--color-neutral-0); - --color-bg-selection-current: rgb(251, 239, 228); - --color-border-selection-current: rgb(243, 216, 190); - --color-icon-selection-current: var(--color-brand-700); + --color-bg-selection-current: var(--atelier-bg-selection); + --color-border-selection-current: var(--atelier-accent-border); + --color-icon-selection-current: var(--atelier-accent); --color-action-selection-current: var(--color-icon-selection-current); - --color-ring-focus-visible: var(--color-brand-700); - --color-bg-action-primary: var(--color-brand-700); - --color-bg-action-primary-hover: var(--color-brand-800); - --color-text-on-action-primary: var(--color-neutral-0); + --color-ring-focus-visible: var(--atelier-ring); + --color-bg-action-primary: var(--atelier-accent); + --color-bg-action-primary-hover: var(--atelier-accent-hover); + --color-text-on-action-primary: var(--atelier-accent-on); --color-bg-action-secondary: var(--color-neutral-900); --color-bg-action-secondary-hover: var(--color-neutral-800); --color-text-on-action-secondary: var(--color-neutral-0); @@ -66,8 +68,8 @@ --color-bg-notice-warning: var(--color-warning-50); --color-border-notice-warning: var(--color-warning-200); --color-text-notice-warning: var(--color-warning-900); - --color-text-status-danger: var(--color-error-600); - --color-text-status-success: var(--color-success-600); + --color-text-status-danger: var(--atelier-danger); + --color-text-status-success: var(--atelier-success); --color-bg-control-selected: var(--color-bg-hover); --color-border-control-selected: var(--color-border-panel); --color-icon-control-selected: var(--color-icon-secondary); @@ -92,8 +94,8 @@ ); --color-text-diff-modified: var(--color-warning-800); --color-border-diff-modified: var(--color-warning-700); - --color-border-table-grid: var(--color-border-subtle); - --color-bg-table-row-hover: rgb(250, 246, 240); + --color-border-table-grid: var(--atelier-border-subtle); + --color-bg-table-row-hover: var(--atelier-bg-hover); --color-error-50: rgb(254, 242, 242); --color-error-100: rgb(254, 226, 226); diff --git a/src/shell/top-bar/flashtype-menu.tsx b/src/shell/top-bar/flashtype-menu.tsx deleted file mode 100644 index 3af766eb..00000000 --- a/src/shell/top-bar/flashtype-menu.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Circle, CircleOff, FileDown, Hammer, Zap } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -} from "@/components/ui/dropdown-menu"; -import { useCallback } from "react"; -import { useKeyValue } from "@/hooks/key-value/use-key-value"; - -/** - * Dropdown launcher for flashtype developer utilities. - * - * @example - * - */ -export function FlashtypeMenu() { - const [deterministicMode, setDeterministicMode] = useKeyValue( - "lix_deterministic_mode" as any, - { - defaultBranchId: "global", - untracked: true, - }, - ) as [ - { enabled?: boolean } | null, - (value: { enabled: boolean }) => Promise, - ]; - - const deterministicEnabled = Boolean(deterministicMode?.enabled); - - const toggleDeterministicMode = useCallback(async () => { - await setDeterministicMode({ enabled: !deterministicEnabled }); - }, [deterministicEnabled, setDeterministicMode]); - - const handleExportLix = async () => { - const workspace = window.flashtypeDesktop?.workspace; - if (!workspace) return; - try { - const lixFileBytes = await workspace.exportLixFile(); - const blob = new Blob([new Uint8Array(lixFileBytes)], { - type: "application/octet-stream", - }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = `flashtype-export-${new Date() - .toISOString() - .slice(0, 10)}.lix`; - document.body.appendChild(anchor); - anchor.click(); - window.setTimeout(() => { - document.body.removeChild(anchor); - URL.revokeObjectURL(url); - }, 100); - } catch (error) { - console.error("Failed to export Lix file", error); - } - }; - - // Inspector is intentionally disabled for now to reduce merge conflicts. - // const handleToggleInspector = async () => { - // try { - // await toggleLixInspector(); - // } catch (error) { - // console.error("Failed to toggle Lix Inspector", error); - // } - // }; - - return ( - - - - - - { - void handleExportLix(); - }} - > - - Export lix image - - - - - Developer tools - - - {/* { - void handleToggleInspector(); - }} - > - - Toggle Lix Inspector - */} - { - void toggleDeterministicMode(); - }} - > - {deterministicEnabled ? ( - - ) : ( - - )} - - {deterministicEnabled - ? "Turn off deterministic mode" - : "Turn on deterministic mode"} - - - - - - - ); -} diff --git a/src/shell/workspace-loading-screen.test.tsx b/src/shell/workspace-loading-screen.test.tsx new file mode 100644 index 00000000..140dede6 --- /dev/null +++ b/src/shell/workspace-loading-screen.test.tsx @@ -0,0 +1,84 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { WorkspaceLoadingScreen } from "./workspace-loading-screen"; +vi.mock("./top-bar", () => ({ TopBar: () => null })); +vi.mock("@/components/animated-zap", () => ({ AnimatedZap: () => null })); +const original = window.flashtypeDesktop; +afterEach(() => { + cleanup(); + vi.useRealTimers(); + window.flashtypeDesktop = original; +}); +test("offers recovery after 30 seconds, deletes only on explicit click", async () => { + vi.useFakeTimers(); + const recover = vi.fn().mockResolvedValue(undefined); + window.flashtypeDesktop = { + workspace: { deleteLixAndRestart: recover }, + } as unknown as NonNullable; + render( + , + ); + act(() => vi.advanceTimersByTime(29_999)); + expect(screen.queryByRole("button")).toBeNull(); + act(() => vi.advanceTimersByTime(1)); + expect(recover).not.toHaveBeenCalled(); + expect( + screen.getByText(/permanently removes its change history/), + ).toBeInTheDocument(); + await act(async () => + fireEvent.click( + screen.getByRole("button", { name: "Delete .lix and restart" }), + ), + ); + expect(recover).toHaveBeenCalledWith("/Downloads"); + expect(screen.getByRole("button")).toBeDisabled(); +}); +test("resets timeout when folders change and clears timer on unmount", () => { + vi.useFakeTimers(); + const view = render( + , + ); + act(() => vi.advanceTimersByTime(29_000)); + view.rerender( + , + ); + act(() => vi.advanceTimersByTime(1_000)); + expect(screen.queryByRole("button")).toBeNull(); + view.unmount(); + expect(vi.getTimerCount()).toBe(0); +}); +test("does not offer deletion for temporary workspaces", () => { + vi.useFakeTimers(); + render(); + act(() => vi.advanceTimersByTime(30_000)); + expect(screen.queryByRole("button")).toBeNull(); +}); +test("reports recovery failures and allows retry", async () => { + vi.useFakeTimers(); + window.flashtypeDesktop = { + workspace: { + deleteLixAndRestart: vi + .fn() + .mockRejectedValue(new Error("Disk unavailable")), + }, + } as unknown as NonNullable; + render( + , + ); + act(() => vi.advanceTimersByTime(30_000)); + await act(async () => fireEvent.click(screen.getByRole("button"))); + expect(screen.getByRole("alert")).toHaveTextContent("Disk unavailable"); + expect(screen.getByRole("button")).toBeEnabled(); +}); diff --git a/src/shell/workspace-loading-screen.tsx b/src/shell/workspace-loading-screen.tsx index d7456b2c..86a1e787 100644 --- a/src/shell/workspace-loading-screen.tsx +++ b/src/shell/workspace-loading-screen.tsx @@ -1,14 +1,49 @@ -import type { JSX } from "react"; +import { useEffect, useState, type JSX } from "react"; import { Zap } from "lucide-react"; import { AnimatedZap } from "@/components/animated-zap"; import { TopBar } from "./top-bar"; export function WorkspaceLoadingScreen({ workspaceName, + workspacePath, }: { readonly workspaceName?: string | null; + readonly workspacePath?: string; }): JSX.Element { const name = workspaceName?.trim(); + const [phase, setPhase] = useState(); + useEffect( + () => + window.flashtypeDesktop?.lix?.onOpenProgress?.((progress) => + setPhase(progress.phase), + ), + [], + ); + const migrating = phase === "migrating" || phase === "validating"; + const [timedOut, setTimedOut] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + setTimedOut(false); + setBusy(false); + setError(null); + const timer = setTimeout(() => setTimedOut(true), 30_000); + return () => clearTimeout(timer); + }, [workspacePath, workspaceName]); + async function deleteAndRestart() { + if (!workspacePath || busy) return; + setBusy(true); + setError(null); + try { + const recover = window.flashtypeDesktop?.workspace?.deleteLixAndRestart; + if (!recover) + throw new Error("Restart Flashtype to load the recovery action."); + await recover(workspacePath); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + setBusy(false); + } + } return (
    @@ -23,11 +58,45 @@ export function WorkspaceLoadingScreen({

    - {name ? `Opening ${name}` : "Opening folder"} + {migrating + ? `Upgrading ${name || "repository"}` + : name + ? `Opening ${name}` + : "Opening folder"}

    - Teaching the zap where the files live. + {migrating + ? "Updating this repository for the latest Lix. Your files and saved history are preserved. Please keep Flashtype open." + : "Opening your local files and history…"}

    + {timedOut && workspacePath && !migrating ? ( +
    +

    + Opening is taking longer than expected +

    +

    + You can keep waiting, or delete this folder’s .lix and restart + Flashtype. This permanently removes its change history. Your files + will not be deleted. All Flashtype windows will restart. +

    + + {error ? ( +

    + {error} +

    + ) : null} +
    + ) : null}
    ); diff --git a/src/test-utils/node-lix-sdk.test.ts b/src/test-utils/node-lix-sdk.test.ts index 9809a335..377e7062 100644 --- a/src/test-utils/node-lix-sdk.test.ts +++ b/src/test-utils/node-lix-sdk.test.ts @@ -19,12 +19,16 @@ test("observe returns the current result snapshot until next is called", async ( await new Promise((resolve) => setTimeout(resolve, 100)); 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.done).toBe(false); + if (event.done) return; + expect(event.value.result.columns).toEqual([ + { name: "value", type: "jsonb" }, + ]); + expect(event.value.result.rows.map((row) => [row["value"]])).toEqual([ ["current"], ]); } finally { - events.close(); + await events.return?.(); await lix.close(); } }); diff --git a/src/test-utils/node-lix-sdk.ts b/src/test-utils/node-lix-sdk.ts index 73a27c9f..ad0ab682 100644 --- a/src/test-utils/node-lix-sdk.ts +++ b/src/test-utils/node-lix-sdk.ts @@ -1,7 +1,7 @@ +import { FilesystemStorage } from "@lix-js/storage-filesystem"; import { createRequire } from "node:module"; import type { - BundledPluginArchive, - ExecuteResult, + StatementResult as ExecuteResult, Lix as SdkLix, OpenLixOptions as SdkOpenLixOptions, SqlParam, @@ -18,7 +18,6 @@ import type { type ExecuteOptions = LixExecuteOptions; export type { Lix, SqlTransaction } from "@/lib/lix-types"; -export type { BundledPluginArchive }; type OpenTestLixOptions = SdkOpenLixOptions & { keyValues?: ReadonlyArray; @@ -34,7 +33,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); @@ -44,11 +43,6 @@ export async function openLix(options: OpenTestLixOptions = {}): Promise { return lix; } -export async function bundledPluginArchives(): Promise { - const sdk = await loadSdk(); - return await sdk.bundledPluginArchives(); -} - async function loadSdk(): Promise { if (!sdkModulePromise) { const sdkPath = require.resolve("@lix-js/sdk"); @@ -67,24 +61,6 @@ async function seedKeyValues( if (!entry || typeof entry.key !== "string") { continue; } - if (typeof entry.lixcol_branch_id === "string") { - if (typeof entry.lixcol_global !== "boolean") { - throw new TypeError( - "branch-scoped keyValues entries require lixcol_global", - ); - } - await lix.execute( - "INSERT INTO lix_key_value_by_branch (key, value, lixcol_branch_id, lixcol_global, lixcol_untracked) VALUES ($1, $2, $3, $4, $5)", - [ - entry.key, - entry.value, - entry.lixcol_branch_id, - entry.lixcol_global, - entry.lixcol_untracked ?? true, - ], - ); - continue; - } await lix.execute( "INSERT INTO lix_key_value (key, value, lixcol_global, lixcol_untracked) VALUES ($1, $2, true, true)", [entry.key, entry.value], @@ -94,7 +70,7 @@ async function seedKeyValues( function createTestLixAdapter( sdkLix: SdkLix, - localFilesystem?: InstanceType, + localFilesystem?: FilesystemStorage, ): Lix { const observations = new Set(); let closing = false; @@ -157,24 +133,40 @@ function createTestLixAdapter( throw error; } }, - observe(sql: string, params: ReadonlyArray = []): ObserveEvents { - const sdkEvents = sdkLix.observe(sql, toSqlParams(params)); + observe( + sql: string, + params: ReadonlyArray = [], + options?: Parameters[2], + ): ObserveEvents { + const controller = new AbortController(); + const signal = options?.signal; + const abort = () => controller.abort(); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + const sdkEvents = sdkLix.observe(sql, toSqlParams(params), { + signal: controller.signal, + }); let closed = false; const events: ObserveEvents = { async next() { - if (closed || closing) return undefined; + if (closed || closing) return { done: true, value: undefined }; try { return await sdkEvents.next(); } catch (error) { - if (closed || closing) return undefined; + if (closed || closing) return { done: true, value: undefined }; throw error; } }, - close() { - if (closed) return; + async return() { + if (closed) return { done: true, value: undefined }; closed = true; observations.delete(events); - sdkEvents.close(); + controller.abort(); + await sdkEvents.return?.(); + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; }, }; observations.add(events); @@ -212,7 +204,7 @@ function createTestLixAdapter( async close() { closing = true; for (const observation of [...observations]) { - observation.close(); + await observation.return?.(); } await sdkLix.close(); }, diff --git a/submodule/atelier b/submodule/atelier index 29e5e4a3..cd031e30 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit 29e5e4a3f915c1c1b02bd9602b976e174f234033 +Subproject commit cd031e3044ecd5a3cd0b6af6c02b24db858efdb7