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.
+
@@ -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 (
-
-
- Undo
-
- Esc
-
-
-
- Keep
-
- {isMacPlatform() ? "⌘↩" : "Ctrl↩"}
-
-
-
- );
-}
-
-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 (
-
- );
-}
-
-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 ``;
-}
-
-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 && (
-
-
- Remove
-
- )}
-
-
- Cancel
-
-
-
- {linkEditing ? "Update" : "Add link"}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
- />
-
-
-
- {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 .
-
-
-
----
-
-# 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
-
-
-
----
-
-## 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 .
-
-
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");
-```
-
----
-
-
-
-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.
-
-
-Remove this HTML block.
-
-
-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.
-
-
-Keep a short migration note and link to support.
-
-
-~~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.
-
-
-Keep a short migration note.
-
-
-~~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: [
- '',
- "Keep a short migration note.",
- " ",
- ].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: [
- '',
- "Keep a short migration note and link to support.",
- " ",
- ].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:
- '',
- },
- {
- 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:
- '',
- },
- {
- 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: ["", "Remove this HTML 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:
- '',
- },
- {
- id: "changed_image",
- orderKey: "360",
- block:
- '',
- },
- ],
- 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:
- '',
- },
- {
- id: "changed_image",
- orderKey: "360",
- block:
- '',
- },
- ],
- },
-];
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.
-
-
-
-
-
-[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.
-
-
-
-
-
-[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} `,
- ).join("")}
-
-
- ${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("");
-
- expect(ast.children[0]).toEqual({
- type: "html",
- value: "",
- });
- });
-
- 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('');
- 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.
-
-
-
-
- Open an issue on GitHub
-
- {" "}
- 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 (
-
- );
-}
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: "\n",
- afterMarkdown: "\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: "\n",
- afterMarkdown: "\n",
- beforeBlocks: [
- {
- id: "image_block",
- orderKey: "40",
- block: "",
- },
- ],
- afterBlocks: [
- {
- id: "image_block",
- orderKey: "40",
- block: "",
- },
- ],
- },
- { 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(" block.block.includes(text))?.id;
-}
-
-function fixtureById(id: string) {
- const fixture = MARKDOWN_DIFF_FIXTURES.find((entry) => entry.id === id);
- if (!fixture) throw new Error(`Missing markdown diff fixture '${id}'`);
- return fixture;
-}
-
-function statusesForText(html: string, text: string): string[] {
- const root = htmlRoot(html);
- const statuses = new Set();
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
- let node = walker.nextNode();
- while (node) {
- if (node.textContent?.includes(text)) {
- const status = nearestDiffStatus(parentElementForNode(node));
- if (status) statuses.add(status);
- }
- node = walker.nextNode();
- }
- return [...statuses].sort();
-}
-
-function statusesForSelector(html: string, selector: string): string[] {
- const root = htmlRoot(html);
- return [...root.querySelectorAll(selector)]
- .map((element) => nearestDiffStatus(element))
- .filter((status): status is string => status !== null)
- .sort();
-}
-
-function hrefsForText(html: string, text: string): string[] {
- const root = htmlRoot(html);
- return [...root.querySelectorAll("a")]
- .filter((link) => 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 (
- <>
-
- forceRender((n) => n + 1)}
- data-testid="rerender-trigger"
- >
- Rerender
-
- >
- );
- }
-
- 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
+
+
+ Try again
+
+
+
+ );
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.
+
+
+ )}
-
-
- {busyAction === "disable"
- ? "Disabling..."
- : "Disable Track Changes"}
-
+ {canDelete && (
+
+
+ {busyAction === "disable"
+ ? "Restarting..."
+ : "Delete .lix and restart"}
+
+ )}
window.location.reload()
diff --git a/src/main.tsx b/src/main.tsx
index 417b454c..8e26dac6 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,6 +1,10 @@
+import {
+ createMemorySessionStateStore,
+ type AtelierSessionUiState,
+} from "@opral/atelier/state-adapters";
+import { ShareButton } from "./shell/share-button";
import {
Suspense,
- lazy,
useCallback,
useEffect,
useMemo,
@@ -10,11 +14,9 @@ import {
import { createRoot } from "react-dom/client";
import {
Atelier,
- createAtelier,
- createMemorySessionStateStore,
- type AtelierInstance,
+ type AtelierLocation,
+ type AtelierProps,
type AtelierExtensionRegistration,
- type AtelierSessionUiState,
} from "@opral/atelier";
import "@opral/atelier/style.css";
import "./index.css";
@@ -29,6 +31,7 @@ import {
activatePostHogRecording,
syncPostHogWorkspaceContext,
} from "./lib/posthog-client";
+import { agentDiffBridge } from "./extensions/terminal/host-extensions";
import { createFlashTypeAtelierExtensions } from "./extensions/atelier-host-extensions";
import { createEphemeralFilesViewOptions } from "./lib/atelier-files-view";
import {
@@ -36,6 +39,7 @@ import {
createAtelierTelemetryHandler,
} from "./lib/atelier-telemetry";
import { createAgentTurnReviewHandler } from "./lib/agent-turn-review-bridge";
+import { createDesktopDocumentCommands } from "./lib/atelier-desktop-commands";
import { connectAtelierWorkspace } from "./lib/atelier-workspace-bridge";
type Workspace = Awaited<
@@ -75,12 +79,14 @@ export const AppRoot = () => {
string | null | undefined
>(undefined);
const [isUpdateReady, setIsUpdateReady] = useState(false);
- const [connectedAtelier, setConnectedAtelier] =
- useState(null);
+ const [location, setLocation] = useState();
+ const [connectedLix, setConnectedLix] = useState(null);
const atelierExtensions = useMemo(
() =>
workspace
- ? createFlashTypeAtelierExtensions()
+ ? createFlashTypeAtelierExtensions({
+ temporaryHistory: workspace.ephemeral === true,
+ })
: ([] as readonly AtelierExtensionRegistration[]),
[workspace],
);
@@ -102,7 +108,14 @@ export const AppRoot = () => {
? DOCUMENT_OPEN_ATELIER_PANELS
: DEFAULT_OPEN_ATELIER_PANELS;
const atelierSession = useMemo(
- () => ({ lix, store: createMemorySessionStateStore() }),
+ () => ({ lix, store: createMemorySessionStateStore({
+ focusedArea: "main",
+ areas: {
+ left: { views: [{ instance: "files-default", kind: "atelier_files" }], activeInstance: "files-default" },
+ main: { views: [], activeInstance: null },
+ right: { views: [{ instance: "agents-default", kind: "flashtype_agents" }], activeInstance: "agents-default" },
+ },
+ }) }),
[lix],
);
const atelierSessionStateStore = atelierSession.store;
@@ -118,28 +131,16 @@ export const AppRoot = () => {
}
};
}, [atelierSessionStateStore]);
- const atelier = useMemo(
+ const documents = useMemo(
() =>
lix
- ? createAtelier({
- // FlashType's renderer-side Lix proxy implements Atelier's runtime
- // contract but intentionally hides native SDK internals.
- lix: lix as unknown as AtelierInstance["lix"],
- extensions: atelierExtensions,
- ...(atelierFilesView ? { filesView: atelierFilesView } : {}),
- defaultOpenPanels: defaultOpenAtelierPanels,
- sessionStateStore: atelierSessionStateStore,
- onEvent: handleAtelierEvent,
+ ? createDesktopDocumentCommands({
+ lix,
+ store: atelierSessionStateStore,
+ navigate: setLocation,
})
: null,
- [
- lix,
- atelierExtensions,
- atelierFilesView,
- defaultOpenAtelierPanels,
- atelierSessionStateStore,
- handleAtelierEvent,
- ],
+ [lix, atelierSessionStateStore],
);
useEffect(() => {
@@ -302,30 +303,34 @@ export const AppRoot = () => {
useEffect(() => {
const desktopWorkspace = window.flashtypeDesktop?.workspace;
- if (!atelier || !lix || !desktopWorkspace) return;
+ if (!documents || !lix || !desktopWorkspace) return;
let cancelled = false;
const connection = connectAtelierWorkspace({
- documents: atelier.documents,
+ documents,
lix,
sessionStateStore: atelierSessionStateStore,
workspace: desktopWorkspace,
onError: setError,
});
void connection.ready.then(() => {
- if (!cancelled) setConnectedAtelier(atelier);
+ if (!cancelled) setConnectedLix(lix);
});
return () => {
cancelled = true;
connection.dispose();
};
- }, [atelier, atelierSessionStateStore, lix]);
+ }, [documents, atelierSessionStateStore, lix]);
useEffect(() => {
- if (!lix || !atelier) return;
+ if (!lix) return;
const handlePromptTelemetry = createAgentPromptTelemetryHandler(lix);
- const handleAgentTurnReview = createAgentTurnReviewHandler(atelier, {
- fileCapture: window.flashtypeDesktop?.workspace,
- });
+ const handleAgentTurnReview = createAgentTurnReviewHandler(
+ { lix, diff: agentDiffBridge },
+ {
+ fileCapture: window.flashtypeDesktop?.workspace,
+ getUiState: () => atelierSessionStateStore.getSnapshot(),
+ },
+ );
const unsubscribe = window.flashtypeDesktop?.agentHooks?.onTurnEvent(
(event) => {
handlePromptTelemetry(event);
@@ -333,7 +338,7 @@ export const AppRoot = () => {
},
);
return () => unsubscribe?.();
- }, [atelier, lix]);
+ }, [lix, atelierSessionStateStore]);
if (workspaceRecovery) return ;
if (error) return ;
@@ -351,10 +356,11 @@ export const AppRoot = () => {
/>
);
}
- if (!lix || !atelier) {
+ if (!lix) {
return (
);
}
@@ -363,7 +369,14 @@ export const AppRoot = () => {
}>
{
className="flashtype-traffic-light-spacer"
/>
) : null,
- navbarEnd: isUpdateReady ? (
- void handleInstallUpdate()}
- >
- Update
-
- ) : null,
+ navbarEnd: (
+
+
+ {isUpdateReady ? (
+ void handleInstallUpdate()}
+ >
+ Update
+
+ ) : 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({
+
);
}
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 (
+ <>
+ {
+ setError("");
+ dialog.current?.showModal();
+ }}
+ >
+ Share
+
+ {
+ if (event.target === event.currentTarget) dialog.current?.close();
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Escape") dialog.current?.close();
+ }}
+ >
+
+
+
+
+
+
+ dialog.current?.close()}
+ >
+
+
+
+
Flashtype, in the cloud.
+
+ Use Lixray, the cloud version of Flashtype, to publish and share
+ your files.
+
+
+ Open lixray.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}
+
void initialize()}
+ className="mt-2.5 rounded-md bg-[var(--color-bg-action-primary)] px-3 py-1.5 text-xs font-semibold text-[var(--color-text-on-action-primary)] hover:bg-[var(--color-bg-action-primary-hover)] disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring-focus-visible)]"
+ >
+ {pending ? "Initializing…" : "Initialize repository"}
+
+
void check()}
+ disabled={checking || pending}
+ className="ml-2 text-xs text-[var(--color-text-secondary)] underline disabled:opacity-60"
+ >
+ Check again
+
+ {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.
+
+ void deleteAndRestart()}
+ className="mt-4 rounded-lg bg-red-700 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
+ >
+ {busy ? "Restarting…" : "Delete .lix and 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