diff --git a/e2e/branch-switcher.spec.ts b/e2e/branch-switcher.spec.ts deleted file mode 100644 index 5f9a3c6e..00000000 --- a/e2e/branch-switcher.spec.ts +++ /dev/null @@ -1,1137 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; -import type { ElectronApplication } from "playwright"; -import type { AtelierSessionUiState } from "@opral/atelier"; -import { LocalFilesystem, openLix } from "@lix-js/sdk"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { - closeElectronApp, - ensureFilesViewOpenInLeftPanel, - ensureHistoryViewOpenInLeftPanel, - expectPathMissing, - fileTreeFile, - launchDevElectronApp, - registerRendererConsoleLogging, -} from "./electron-test-utils"; - -test("persistent workspace branch switching keeps sidebar and disk on the active branch", async ({ - browserName: _browserName, -}, testInfo) => { - const workspaceDir = testInfo.outputPath("persistent-branch-workspace"); - const sharedPath = path.join(workspaceDir, "shared.md"); - const draftOnlyPath = path.join(workspaceDir, "draft-only.md"); - - let electronApp: ElectronApplication | undefined; - try { - await mkdir(workspaceDir, { recursive: true }); - await writeFile(sharedPath, "# Main shared\n", "utf8"); - await writeFile( - path.join(workspaceDir, "main-only.md"), - "# Main only\n", - "utf8", - ); - await initializeLixWorkspace(workspaceDir); - - electronApp = await launchDevElectronApp(workspaceDir); - const page = await electronApp.firstWindow(); - registerRendererConsoleLogging(page); - - await ensureFilesViewOpenInLeftPanel(page); - await expect(fileTreeFile(page, "/shared.md")).toBeVisible(); - await expect(fileTreeFile(page, "/main-only.md")).toBeVisible(); - await ensureHistoryViewOpenInLeftPanel(page); - await expectCurrentCheckpointActive(page); - - await createCheckpointFromUi(page); - await expectCurrentCheckpointActive(page); - - await switchBranchFromUi(page, "Naming checkpoint..."); - await expectCheckpointActive(page, "Naming checkpoint..."); - - await ensureFilesViewOpenInLeftPanel(page); - await writeDraftBranchState(page); - await expect(fileTreeFile(page, "/draft-only.md")).toBeVisible(); - await expect(fileTreeFile(page, "/shared.md")).toBeVisible(); - await expectDiskText(sharedPath, "# Draft shared\n"); - await expectDiskText(draftOnlyPath, "# Draft only\n"); - - await switchBranchFromUi(page, "Current Checkpoint"); - await expectCurrentCheckpointActive(page); - await ensureFilesViewOpenInLeftPanel(page); - await expect(fileTreeFile(page, "/shared.md")).toBeVisible(); - await expect(fileTreeFile(page, "/main-only.md")).toBeVisible(); - await expect(fileTreeFile(page, "/draft-only.md")).toHaveCount(0); - await expectDiskText(sharedPath, "# Main shared\n"); - await expectPathMissing(draftOnlyPath); - } finally { - await closeElectronApp(electronApp); - } -}); - -test("ephemeral workspace shows enabled branch UI", async ({ - browserName: _browserName, -}, testInfo) => { - const workspaceDir = testInfo.outputPath("ephemeral-branch-workspace"); - - let electronApp: ElectronApplication | undefined; - try { - await mkdir(workspaceDir, { recursive: true }); - await writeFile(path.join(workspaceDir, "note.md"), "# Note\n", "utf8"); - - electronApp = await launchDevElectronApp(workspaceDir); - const page = await electronApp.firstWindow(); - registerRendererConsoleLogging(page); - - await ensureFilesViewOpenInLeftPanel(page); - await expect(fileTreeFile(page, "/note.md")).toBeVisible(); - await expectPathMissing(path.join(workspaceDir, ".lix")); - - await ensureHistoryViewOpenInLeftPanel(page); - await expectCurrentCheckpointActive(page); - await expect( - page.getByRole("button", { name: "Create checkpoint" }), - ).toBeVisible(); - } finally { - await closeElectronApp(electronApp); - } -}); - -test("checkpoint row click marks files without auto-opening a diff", async ({ - browserName: _browserName, -}, testInfo) => { - const workspaceDir = testInfo.outputPath("checkpoint-diff-workspace"); - - let electronApp: ElectronApplication | undefined; - try { - await mkdir(workspaceDir, { recursive: true }); - await writeFile(path.join(workspaceDir, "seed.md"), "# Seed\n", "utf8"); - await initializeLixWorkspace(workspaceDir); - - electronApp = await launchDevElectronApp(workspaceDir); - const page = await electronApp.firstWindow(); - registerRendererConsoleLogging(page); - - const setup = await createCheckpointDiffBranches(page); - - await ensureHistoryViewOpenInLeftPanel(page); - await expect - .poll(async () => await checkpointRowLabels(page)) - .toEqual(["a-previous", "b-target", "Current Checkpoint"]); - - const targetCheckpoint = page.getByRole("button", { - name: "b-target", - exact: true, - }); - await targetCheckpoint.click(); - await expect(targetCheckpoint).toHaveAttribute("data-selected", "true"); - await expect(targetCheckpoint).not.toHaveAttribute("aria-current", "true"); - await expect - .poll(async () => await activeBranchIdFromUi(page)) - .toBe(setup.activeBranchId); - await expect(page.locator(".markdown-review-overlay")).toHaveCount(0); - - await ensureFilesViewOpenInLeftPanel(page); - for (const [filePath, status] of [ - ["/added.md", "added"], - ["/removed.md", "deleted"], - ["/shared.md", "modified"], - ] as const) { - const file = fileTreeFile(page, filePath); - await expect(file).toBeVisible(); - await expect(file).toHaveAttribute("data-item-git-status", status); - } - - await fileTreeFile(page, "/added.md").click(); - await expectMarkdownDiff(page, { added: ["Added only in target"] }); - - await fileTreeFile(page, "/removed.md").click(); - await expectMarkdownDiff(page, { removed: ["Removed before target"] }); - - await fileTreeFile(page, "/shared.md").click(); - await expectMarkdownDiff(page); - await expect( - page - .locator(".markdown-review-overlay [data-review-status='removed']") - .filter({ hasText: "Previous" }), - ).toBeVisible(); - await expect( - page - .locator(".markdown-review-overlay [data-review-status='added']") - .filter({ hasText: "Target" }), - ).toBeVisible(); - await expect( - page.locator(".markdown-review-overlay").getByText("snapshot"), - ).toBeVisible(); - await expect - .poll(async () => await activeBranchIdFromUi(page)) - .toBe(setup.activeBranchId); - } finally { - await closeElectronApp(electronApp); - } -}); - -test("checkpoint diff selection keeps the active editor and toggles revision state", async ({ - browserName: _browserName, -}, testInfo) => { - const workspaceDir = testInfo.outputPath( - "checkpoint-editor-revision-workspace", - ); - - let electronApp: ElectronApplication | undefined; - try { - await mkdir(workspaceDir, { recursive: true }); - await initializeLixWorkspace(workspaceDir); - - electronApp = await launchDevElectronApp(workspaceDir); - const page = await electronApp.firstWindow(); - registerRendererConsoleLogging(page); - - const setup = await createCheckpointEditorRevisionMatrix(page); - const firstInitialCommitId = await initialCommitIdForCommitFromUi( - page, - setup.firstCommitId, - ); - await expectHistoryBranchOrder(page, [ - setup.firstBranchId, - setup.secondBranchId, - setup.activeBranchId, - ]); - await expect - .poll(async () => await activeBranchIdFromUi(page)) - .toBe(setup.activeBranchId); - - await openMarkdownFileFromTree(page, "/modified.md"); - await expectActiveEditorRevisionState(page, { - beforeCommitId: null, - afterCommitId: null, - }); - await expectEditableMarkdown(page); - await expectSingleCentralDocumentSlot(page); - const modifiedDocument = await expectActiveCentralDocumentIdentityForPath( - page, - "/modified.md", - ); - - await clickCheckpointRow(page, 0); - await expectCheckpointRowSelected(page, 0); - await expectActiveCentralFile(page, "/modified.md"); - await expectActiveCentralDocumentIdentity( - page, - modifiedDocument, - "first checkpoint selection should reuse the active modified.md document view", - ); - await expectActiveBranchId(page, setup.activeBranchId); - await expect - .poll(async () => await activeEditorRevisionStateFromUi(page), { - message: - "first checkpoint selection should keep the editor as a diff from the initial commit", - timeout: 3000, - }) - .toEqual({ - beforeCommitId: firstInitialCommitId, - afterCommitId: setup.firstCommitId, - }); - await expectMarkdownDiff(page, { - added: ["Modified at first checkpoint"], - }); - await expectSingleCentralDocumentSlot(page); - await expectFileTreeStatuses( - page, - { - "/deleted.md": "added", - "/head-deleted.md": "added", - "/head-recreated.md": "added", - "/modified.md": "added", - "/recreated.md": "added", - }, - "first checkpoint should mark every file as added from the initial commit", - ); - - await clickCheckpointRow(page, 1); - await expectCheckpointRowSelected(page, 1); - await expectActiveCentralFile(page, "/modified.md"); - await expectActiveCentralDocumentIdentity( - page, - modifiedDocument, - "checkpoint-to-checkpoint selection should reuse the active modified.md document view", - ); - await expectActiveBranchId(page, setup.activeBranchId); - await expect - .poll(async () => await activeEditorRevisionStateFromUi(page), { - message: - "checkpoint-to-checkpoint selection should keep the editor as a diff between checkpoints", - timeout: 3000, - }) - .toEqual({ - beforeCommitId: setup.firstCommitId, - afterCommitId: setup.secondCommitId, - }); - await expectMarkdownDiff(page, { - added: ["second"], - removed: ["first"], - }); - await expectSingleCentralDocumentSlot(page); - await expectFileTreeStatuses( - page, - { - "/added.md": "added", - "/deleted.md": "deleted", - "/modified.md": "modified", - "/recreated.md": "recreated", - }, - "checkpoint-to-checkpoint diff should expose per-file statuses", - ); - await expectFileTreeStatuses( - page, - { - "/head-deleted.md": null, - "/head-recreated.md": null, - }, - "unchanged files in the checkpoint range should not be marked", - ); - - await clickCheckpointRow(page, 2); - await expectCheckpointRowSelected(page, 2); - await expectActiveCentralFile(page, "/modified.md"); - await expectActiveCentralDocumentIdentity( - page, - modifiedDocument, - "current checkpoint selection should reuse the active modified.md document view", - ); - await expectActiveBranchId(page, setup.activeBranchId); - await expect - .poll(async () => await activeEditorRevisionStateFromUi(page), { - message: - "current checkpoint selection should keep the editor as a diff from the previous checkpoint to HEAD", - timeout: 3000, - }) - .toEqual({ - beforeCommitId: setup.secondCommitId, - afterCommitId: null, - }); - await expectMarkdownDiff(page, { - added: ["HEAD"], - removed: ["second"], - }); - await expectSingleCentralDocumentSlot(page); - await expectFileTreeStatuses( - page, - { - "/head-added.md": "added", - "/head-deleted.md": "deleted", - "/head-recreated.md": "recreated", - "/modified.md": "modified", - }, - "checkpoint-to-HEAD diff should expose per-file statuses", - ); - await expectFileTreeStatuses( - page, - { - "/added.md": null, - "/recreated.md": null, - }, - "unchanged checkpoint files should not be marked in checkpoint-to-HEAD diff", - ); - - await openMarkdownFileFromTree(page, "/added.md"); - await expectActiveCentralFile(page, "/added.md"); - const addedDocument = await expectActiveCentralDocumentIdentityForPath( - page, - "/added.md", - ); - await expect - .poll(async () => await activeEditorRevisionStateFromUi(page), { - message: - "unchanged visible file opened during checkpoint-to-HEAD diff should enter review mode", - timeout: 3000, - }) - .toEqual({ - beforeCommitId: setup.secondCommitId, - afterCommitId: null, - }); - await expectReadonlyMarkdown(page); - await expectSingleCentralDocumentSlot(page); - - await clickCheckpointRow(page, 2); - await expectNoCheckpointRowSelected(page); - await expectActiveCentralFile(page, "/added.md"); - await expectActiveCentralDocumentIdentity( - page, - addedDocument, - "clearing checkpoint-to-HEAD should reuse the active added.md document view", - ); - await expectActiveBranchId(page, setup.activeBranchId); - await expectActiveEditorRevisionState(page, { - beforeCommitId: null, - afterCommitId: null, - }); - await expectEditableMarkdown(page); - await expectSingleCentralDocumentSlot(page); - - await clickCheckpointRow(page, 2); - await expectCheckpointRowSelected(page, 2); - await expectActiveCentralFile(page, "/added.md"); - await expectActiveCentralDocumentIdentity( - page, - addedDocument, - "reselecting checkpoint-to-HEAD should reuse the active added.md document view", - ); - await expectActiveBranchId(page, setup.activeBranchId); - await expect - .poll(async () => await activeEditorRevisionStateFromUi(page), { - message: - "reselecting the checkpoint-to-HEAD diff should restore review mode on the active unchanged file", - timeout: 3000, - }) - .toEqual({ - beforeCommitId: setup.secondCommitId, - afterCommitId: null, - }); - await expectReadonlyMarkdown(page); - await expectSingleCentralDocumentSlot(page); - - await openMarkdownFileFromTree(page, "/modified.md"); - await expectActiveCentralFile(page, "/modified.md"); - await expectActiveCentralDocumentIdentity( - page, - modifiedDocument, - "reopening modified.md should restore the same central document identity", - ); - await expect - .poll(async () => await activeEditorRevisionStateFromUi(page), { - message: - "changed file opened after reselecting the checkpoint-to-HEAD diff should stay in review mode", - timeout: 3000, - }) - .toEqual({ - beforeCommitId: setup.secondCommitId, - afterCommitId: null, - }); - await expectMarkdownDiff(page, { - added: ["HEAD"], - removed: ["second"], - }); - await expectSingleCentralDocumentSlot(page); - - await clickCheckpointRow(page, 2); - await expectNoCheckpointRowSelected(page); - await expectActiveCentralFile(page, "/modified.md"); - await expectActiveCentralDocumentIdentity( - page, - modifiedDocument, - "clearing checkpoint-to-HEAD should reuse the active modified.md document view", - ); - await expectActiveBranchId(page, setup.activeBranchId); - await expectActiveEditorRevisionState(page, { - beforeCommitId: null, - afterCommitId: null, - }); - await expectEditableMarkdown(page); - await expectSingleCentralDocumentSlot(page); - await expectFileTreeStatuses( - page, - { - "/added.md": null, - "/head-added.md": null, - "/head-recreated.md": null, - "/modified.md": null, - "/recreated.md": null, - }, - "clearing the checkpoint diff should clear file-view statuses", - ); - await expect(fileTreeFile(page, "/deleted.md")).toHaveCount(0); - await expect(fileTreeFile(page, "/head-deleted.md")).toHaveCount(0); - } finally { - await closeElectronApp(electronApp); - } -}); - -async function initializeLixWorkspace(workspaceDir: string): Promise { - const lix = await openLix({ - storage: new LocalFilesystem({ path: workspaceDir, syncAllFiles: true }), - }); - await lix.close(); -} - -async function openMarkdownFileFromTree( - page: Page, - appPath: string, -): Promise { - await ensureFilesViewOpenInLeftPanel(page); - const file = fileTreeFile(page, appPath); - await expect(file).toBeVisible(); - await file.click(); - await expectActiveCentralFile(page, appPath); -} - -async function createCheckpointFromUi(page: Page): Promise { - const beforeIds = await branchIdsFromUi(page); - await ensureHistoryViewOpenInLeftPanel(page); - await page.getByRole("button", { name: "Create checkpoint" }).click(); - await expect( - page.getByRole("button", { name: "Naming checkpoint...", exact: true }), - ).toBeVisible(); - await expect - .poll(async () => await newBranchIdFromUi(page, beforeIds)) - .not.toBeNull(); - const branchId = await newBranchIdFromUi(page, beforeIds); - if (!branchId) { - throw new Error("Created checkpoint branch was not found."); - } - return branchId; -} - -async function expectHistoryBranchOrder( - page: Page, - expectedBranchIds: readonly string[], -): Promise { - await ensureHistoryViewOpenInLeftPanel(page); - await expect - .poll(async () => await visibleBranchIdsInHistoryOrderFromUi(page)) - .toEqual(expectedBranchIds); - const rows = page.locator('[data-attr="branch-diff"]'); - await expect(rows).toHaveCount(expectedBranchIds.length); - await expect(rows.nth(expectedBranchIds.length - 1)).toContainText( - "Current Checkpoint", - ); -} - -async function clickCheckpointRow(page: Page, rowIndex: number): Promise { - await ensureHistoryViewOpenInLeftPanel(page); - const checkpoint = page.locator('[data-attr="branch-diff"]').nth(rowIndex); - await expect(checkpoint).toBeVisible(); - await checkpoint.click(); -} - -async function expectCheckpointRowSelected( - page: Page, - rowIndex: number, -): Promise { - await ensureHistoryViewOpenInLeftPanel(page); - await expect( - page.locator('[data-attr="branch-diff"]').nth(rowIndex), - ).toHaveAttribute("data-selected", "true"); -} - -async function expectNoCheckpointRowSelected(page: Page): Promise { - await ensureHistoryViewOpenInLeftPanel(page); - await expect( - page.locator('[data-attr="branch-diff"][data-selected="true"]'), - ).toHaveCount(0); -} - -async function switchBranchFromUi( - page: Page, - branchName: string, -): Promise { - await ensureHistoryViewOpenInLeftPanel(page); - await page - .getByRole("button", { - name: `Checkpoint actions for ${branchName}`, - exact: true, - }) - .click(); - await page.getByRole("menuitem", { name: "Restore", exact: true }).click(); -} - -async function expectCurrentCheckpointActive(page: Page): Promise { - await expectCheckpointActive(page, "Current Checkpoint"); -} - -async function expectCheckpointActive( - page: Page, - checkpointName: string, -): Promise { - const checkpoint = page.getByRole("button", { - name: checkpointName, - exact: true, - }); - await expect(checkpoint).toBeEnabled(); - await expect(checkpoint).toHaveAttribute("aria-current", "true"); -} - -async function writeDraftBranchState(page: Page): Promise { - await page.evaluate(async () => { - const encoder = new TextEncoder(); - await window.flashtypeDesktop?.lix.execute({ - sql: "UPDATE lix_file SET data = $1 WHERE path = $2", - params: [encoder.encode("# Draft shared\n"), "/shared.md"], - }); - await window.flashtypeDesktop?.lix.execute({ - sql: "INSERT INTO lix_file (path, data) VALUES ($1, $2)", - params: ["/draft-only.md", encoder.encode("# Draft only\n")], - }); - }); -} - -async function expectDiskText( - filePath: string, - expected: string, -): Promise { - await expect - .poll(async () => await readFile(filePath, "utf8")) - .toBe(expected); -} - -async function createCheckpointDiffBranches( - page: Page, -): Promise<{ activeBranchId: string }> { - return await page.evaluate(async () => { - const lix = window.flashtypeDesktop?.lix; - if (!lix) { - throw new Error("Desktop Lix bridge is unavailable"); - } - - const encoder = new TextEncoder(); - const data = (text: string) => encoder.encode(text); - const paths = ["/shared.md", "/added.md", "/removed.md"]; - - await lix.execute({ - sql: "DELETE FROM lix_file WHERE path IN ($1, $2, $3)", - params: paths, - }); - await lix.execute({ - sql: "INSERT INTO lix_file (id, path, data) VALUES ($1, $2, $3), ($4, $5, $6)", - params: [ - "e2e_shared", - "/shared.md", - data("# Shared\n\nTarget snapshot\n"), - "e2e_added", - "/added.md", - data("# Added\n\nAdded only in target\n"), - ], - }); - await lix.createBranch({ options: { name: "b-target" } }); - - await lix.execute({ - sql: "UPDATE lix_file SET data = $1 WHERE id = $2", - params: [data("# Shared\n\nPrevious snapshot\n"), "e2e_shared"], - }); - await lix.execute({ - sql: "DELETE FROM lix_file WHERE id = $1", - params: ["e2e_added"], - }); - await lix.execute({ - sql: "INSERT INTO lix_file (id, path, data) VALUES ($1, $2, $3)", - params: [ - "e2e_removed", - "/removed.md", - data("# Removed\n\nRemoved before target\n"), - ], - }); - await lix.createBranch({ options: { name: "a-previous" } }); - - return { activeBranchId: await lix.activeBranchId() }; - }); -} - -async function createCheckpointEditorRevisionMatrix(page: Page): Promise<{ - firstBranchId: string; - firstCommitId: string; - secondBranchId: string; - secondCommitId: string; - activeBranchId: string; -}> { - return await page.evaluate(async () => { - const lix = window.flashtypeDesktop?.lix; - if (!lix) { - throw new Error("Desktop Lix bridge is unavailable"); - } - - const encoder = new TextEncoder(); - const data = (text: string) => encoder.encode(text); - const execute = async (sql: string, params: unknown[] = []) => { - return await lix.execute({ sql, params }); - }; - const insertFile = async (id: string, path: string, text: string) => { - await execute( - "INSERT INTO lix_file (id, path, data) VALUES ($1, $2, $3)", - [id, path, data(text)], - ); - }; - const updateFile = async (id: string, text: string) => { - await execute("UPDATE lix_file SET data = $1 WHERE id = $2", [ - data(text), - id, - ]); - }; - const deleteFile = async (id: string) => { - await execute("DELETE FROM lix_file WHERE id = $1", [id]); - }; - const createBranch = async (name: string) => { - await lix.createBranch({ options: { name } }); - const result = await execute( - "SELECT id, commit_id FROM lix_branch WHERE name = $1", - [name], - ); - const row = result?.rows?.[0]; - const id = cell(row, 0, "id"); - const commitId = cell(row, 1, "commit_id"); - if ( - typeof id !== "string" || - id.length === 0 || - typeof commitId !== "string" || - commitId.length === 0 - ) { - throw new Error(`Created checkpoint ${name} was not found.`); - } - return { id, commitId }; - }; - - await insertFile( - "matrix_modified", - "/modified.md", - "# Modified\n\nModified at first checkpoint\n", - ); - await insertFile( - "matrix_deleted", - "/deleted.md", - "# Deleted\n\nDeleted after first checkpoint\n", - ); - await insertFile( - "matrix_recreated_before", - "/recreated.md", - "# Recreated\n\nOriginal identity before second checkpoint\n", - ); - await insertFile( - "matrix_head_deleted", - "/head-deleted.md", - "# Head deleted\n\nDeleted after second checkpoint\n", - ); - await insertFile( - "matrix_head_recreated_before", - "/head-recreated.md", - "# Head recreated\n\nOriginal identity before HEAD\n", - ); - - const first = await createBranch("a-first-statuses"); - - await updateFile( - "matrix_modified", - "# Modified\n\nModified at second checkpoint\n", - ); - await deleteFile("matrix_deleted"); - await insertFile( - "matrix_added", - "/added.md", - "# Added\n\nAdded at second checkpoint\n", - ); - await deleteFile("matrix_recreated_before"); - await insertFile( - "matrix_recreated_after", - "/recreated.md", - "# Recreated\n\nNew identity at second checkpoint\n", - ); - - const second = await createBranch("b-second-statuses"); - - await updateFile("matrix_modified", "# Modified\n\nModified at HEAD\n"); - await insertFile("matrix_head_added", "/head-added.md", "# Head added\n"); - await deleteFile("matrix_head_deleted"); - await deleteFile("matrix_head_recreated_before"); - await insertFile( - "matrix_head_recreated_after", - "/head-recreated.md", - "# Head recreated\n\nNew identity at HEAD\n", - ); - - const activeBranchId = await lix.activeBranchId(); - if (!activeBranchId) { - throw new Error("Active branch id is unavailable."); - } - - return { - firstBranchId: first.id, - firstCommitId: first.commitId, - secondBranchId: second.id, - secondCommitId: second.commitId, - activeBranchId, - }; - - function cell(row: unknown, index: number, key: string): unknown { - if (Array.isArray(row)) return row[index]; - if ( - row && - typeof row === "object" && - "get" in row && - typeof (row as { get?: unknown }).get === "function" - ) { - return (row as { get(key: string): unknown }).get(key); - } - if (row && typeof row === "object" && key in row) { - return (row as Record)[key]; - } - return undefined; - } - }); -} - -async function branchIdsFromUi(page: Page): Promise { - return await page.evaluate(async () => { - const result = await window.flashtypeDesktop?.lix.execute({ - sql: "SELECT id FROM lix_branch", - params: [], - }); - return (result?.rows ?? []).map((row) => String(row[0])); - }); -} - -async function expectActiveBranchId( - page: Page, - expectedBranchId: string, -): Promise { - await expect - .poll(async () => await activeBranchIdFromUi(page), { - message: "checkpoint diff selection should not restore/switch branches", - timeout: 3000, - }) - .toBe(expectedBranchId); -} - -async function newBranchIdFromUi( - page: Page, - beforeIds: readonly string[], -): Promise { - return await page.evaluate(async (previousIds) => { - const result = await window.flashtypeDesktop?.lix.execute({ - sql: "SELECT id FROM lix_branch", - params: [], - }); - const previous = new Set(previousIds); - for (const row of result?.rows ?? []) { - const id = String(row[0]); - if (!previous.has(id)) return id; - } - return null; - }, beforeIds); -} - -async function initialCommitIdForCommitFromUi( - page: Page, - commitId: string, -): Promise { - const initialCommitId = await page.evaluate(async (startCommitId) => { - const result = await window.flashtypeDesktop?.lix.execute({ - sql: ` - SELECT DISTINCT h.observed_commit_id AS commit_id - FROM lix_state_history h - LEFT JOIN lix_commit_edge e - ON e.child_id = h.observed_commit_id - WHERE h.start_commit_id = $1 - AND h.schema_key = 'lix_commit' - AND e.child_id IS NULL - `, - params: [startCommitId], - }); - const commitIds = (result?.rows ?? []) - .map((row) => row[0]) - .filter( - (value): value is string => - typeof value === "string" && value.length > 0, - ); - if (commitIds.length !== 1) { - throw new Error( - `Expected exactly one initial commit for ${startCommitId}, found ${commitIds.length}.`, - ); - } - return commitIds[0]; - }, commitId); - return initialCommitId; -} - -async function visibleBranchIdsInHistoryOrderFromUi( - page: Page, -): Promise { - return await page.evaluate(async () => { - const result = await window.flashtypeDesktop?.lix.execute({ - sql: ` - SELECT id - FROM lix_branch - WHERE COALESCE(CAST(hidden AS TEXT), 'false') NOT IN ('true', '1', 't') - ORDER BY name ASC - `, - params: [], - }); - return (result?.rows ?? []).map((row) => String(row[0])); - }); -} - -async function expectActiveCentralFile( - page: Page, - appPath: string, -): Promise { - await expect - .poll(async () => await activeCentralFilePathFromUi(page)) - .toBe(appPath); -} - -type ActiveCentralDocumentIdentity = { - readonly instance: string; - readonly kind: string; - readonly fileId: string; - readonly filePath: string; -}; - -async function atelierSessionStateFromUi( - page: Page, -): Promise { - return await page.evaluate(() => { - const e2e = ( - window as Window & { - __flashtypeE2E?: { - getAtelierSessionState: () => AtelierSessionUiState | null; - }; - } - ).__flashtypeE2E; - return e2e?.getAtelierSessionState() ?? null; - }); -} - -async function expectActiveCentralDocumentIdentityForPath( - page: Page, - appPath: string, -): Promise { - await expect - .poll(async () => await activeCentralDocumentIdentityFromUi(page), { - message: `expected ${appPath} to be the active central document`, - timeout: 3000, - }) - .toMatchObject({ filePath: appPath }); - const identity = await activeCentralDocumentIdentityFromUi(page); - if (!identity || identity.filePath !== appPath) { - throw new Error(`Active central document for ${appPath} was not found.`); - } - return identity; -} - -async function expectActiveCentralDocumentIdentity( - page: Page, - expected: ActiveCentralDocumentIdentity, - message: string, -): Promise { - await expect - .poll(async () => await activeCentralDocumentIdentityFromUi(page), { - message, - timeout: 3000, - }) - .toEqual(expected); -} - -async function activeCentralDocumentIdentityFromUi( - page: Page, -): Promise { - const state = await atelierSessionStateFromUi(page); - const central = state?.panels.central; - const views = central?.views ?? []; - const active = - views.find((view) => view.instance === central?.activeInstance) ?? views[0]; - const instance = active?.instance; - const kind = active?.kind; - const fileId = active?.state?.fileId; - const filePath = active?.state?.filePath; - if ( - typeof instance !== "string" || - typeof kind !== "string" || - typeof fileId !== "string" || - typeof filePath !== "string" - ) { - return null; - } - return { instance, kind, fileId, filePath }; -} - -async function activeCentralFilePathFromUi(page: Page): Promise { - const state = await atelierSessionStateFromUi(page); - const central = state?.panels.central; - const views = central?.views ?? []; - const active = - views.find((view) => view.instance === central?.activeInstance) ?? views[0]; - const filePath = active?.state?.filePath; - return typeof filePath === "string" ? filePath : null; -} - -async function expectActiveEditorRevisionState( - page: Page, - expected: { - readonly beforeCommitId: string | null; - readonly afterCommitId: string | null; - }, -): Promise { - await expect - .poll(async () => await activeEditorRevisionStateFromUi(page)) - .toEqual(expected); -} - -async function expectSingleCentralDocumentSlot(page: Page): Promise { - await expect - .poll(async () => await documentSlotViolationsFromUi(page), { - message: - "document views should only exist as the single active central view", - timeout: 3000, - }) - .toEqual([]); -} - -async function documentSlotViolationsFromUi(page: Page): Promise { - const panels = (await atelierSessionStateFromUi(page))?.panels; - const violations: string[] = []; - const isDocumentView = ( - view: AtelierSessionUiState["panels"]["central"]["views"][number], - ): boolean => { - const fileId = view.state?.fileId; - return ( - typeof view.kind === "string" && - typeof view.instance === "string" && - typeof fileId === "string" && - view.instance === `${view.kind}:${fileId}` - ); - }; - for (const side of ["left", "right"] as const) { - const documentViews = panels?.[side].views.filter(isDocumentView) ?? []; - if (documentViews.length > 0) { - violations.push(`${side} has ${documentViews.length} document view(s)`); - } - } - const central = panels?.central; - const centralViews = central?.views ?? []; - if (centralViews.length > 1) { - violations.push(`central has ${centralViews.length} views`); - } - const centralView = centralViews[0]; - if (centralView && !isDocumentView(centralView)) { - violations.push("central view is not a document view"); - } - if ( - centralView && - typeof centralView.instance === "string" && - central?.activeInstance !== centralView.instance - ) { - violations.push("central document view is not active"); - } - return violations; -} - -async function activeEditorRevisionStateFromUi(page: Page): Promise<{ - beforeCommitId: string | null; - afterCommitId: string | null; -} | null> { - const state = await atelierSessionStateFromUi(page); - const central = state?.panels.central; - const views = central?.views ?? []; - const active = - views.find((view) => view.instance === central?.activeInstance) ?? views[0]; - if (!active) return null; - const beforeCommitId = active.state?.beforeCommitId; - const afterCommitId = active.state?.afterCommitId; - return { - beforeCommitId: - typeof beforeCommitId === "string" && beforeCommitId.length > 0 - ? beforeCommitId - : null, - afterCommitId: - typeof afterCommitId === "string" && afterCommitId.length > 0 - ? afterCommitId - : null, - }; -} - -async function expectFileTreeStatuses( - page: Page, - expected: Record, - message: string, -): Promise { - await ensureFilesViewOpenInLeftPanel(page); - await expect - .poll(async () => await fileTreeStatuses(page, Object.keys(expected)), { - message, - timeout: 3000, - }) - .toEqual(expected); -} - -async function fileTreeStatuses( - page: Page, - appPaths: readonly string[], -): Promise> { - const statuses: Record = {}; - for (const appPath of appPaths) { - const file = fileTreeFile(page, appPath); - if ((await file.count()) === 0) { - statuses[appPath] = ""; - continue; - } - statuses[appPath] = await file.first().getAttribute("data-item-git-status"); - } - return statuses; -} - -async function expectEditableMarkdown(page: Page): Promise { - await expect(page.locator(".markdown-review-overlay")).toHaveCount(0); - await expect( - page.locator('[data-testid="tiptap-editor"] .ProseMirror'), - ).toBeVisible(); - await expect( - page.locator('[data-testid="tiptap-editor"] .ProseMirror'), - ).toHaveAttribute("contenteditable", "true"); -} - -async function expectReadonlyMarkdown(page: Page): Promise { - const editor = page.locator('[data-attr="markdown-editor"] .ProseMirror'); - await expect(editor.first()).toBeVisible(); - await expect - .poll(async () => { - return await editor.evaluateAll((nodes) => - nodes.every((node) => node.getAttribute("contenteditable") !== "true"), - ); - }) - .toBe(true); -} - -async function expectMarkdownDiff( - page: Page, - expected: { added?: readonly string[]; removed?: readonly string[] } = {}, -): Promise { - const overlay = page - .locator(".markdown-review-overlay") - .filter({ has: page.locator("[data-review-status]") }) - .first(); - await expect(overlay).toBeVisible(); - await expectReadonlyMarkdown(page); - await expect(overlay.locator("[data-review-status]").first()).toBeVisible(); - for (const text of expected.added ?? []) { - await expect( - overlay.locator("[data-review-status='added']").filter({ hasText: text }), - ).toBeVisible(); - } - for (const text of expected.removed ?? []) { - await expect( - overlay - .locator("[data-review-status='removed']") - .filter({ hasText: text }), - ).toBeVisible(); - } - await expect( - page.getByRole("button", { name: "Keep", exact: true }), - ).toHaveCount(0); - await expect( - page.getByRole("button", { name: "Undo", exact: true }), - ).toHaveCount(0); -} - -async function checkpointRowLabels(page: Page): Promise { - return await page - .locator('[data-attr="branch-diff"]') - .evaluateAll((rows) => - rows.map((row) => (row.textContent ?? "").replace(/\s+/g, " ").trim()), - ); -} - -async function activeBranchIdFromUi(page: Page): Promise { - const activeBranchId = await page.evaluate(async () => { - return await window.flashtypeDesktop?.lix.activeBranchId(); - }); - if (!activeBranchId) { - throw new Error("Active branch id is unavailable."); - } - return activeBranchId; -} diff --git a/e2e/electron-test-utils.ts b/e2e/electron-test-utils.ts index a57c27a9..921f4997 100644 --- a/e2e/electron-test-utils.ts +++ b/e2e/electron-test-utils.ts @@ -94,40 +94,6 @@ export async function ensureFilesViewOpenInLeftPanel( await expect(filesTab).toHaveAttribute("data-focused", "true"); } -export async function ensureHistoryViewOpenInLeftPanel( - page: Page, -): Promise { - await waitForWorkspaceReady(page); - const leftPanel = page.locator("aside").first(); - let historyTab = leftPanel - .locator('[data-view-key="atelier_history"]') - .first(); - const leftPanelToggle = page.getByLabel("Toggle left panel").first(); - - if ((await historyTab.count()) === 0) { - await expect(leftPanelToggle).toBeVisible(); - if ((await leftPanelToggle.getAttribute("aria-pressed")) !== "true") { - await leftPanelToggle.click(); - } - await expect(leftPanelToggle).toHaveAttribute("aria-pressed", "true"); - const addViewButton = leftPanel.getByLabel("Add view").first(); - await expect(addViewButton).toBeVisible(); - await addViewButton.click(); - await page.getByRole("menuitem", { name: "History", exact: true }).click(); - historyTab = leftPanel.locator('[data-view-key="atelier_history"]').first(); - } - - await expect(leftPanelToggle).toBeVisible(); - if ((await leftPanelToggle.getAttribute("aria-pressed")) !== "true") { - await leftPanelToggle.click(); - } - - await expect(leftPanelToggle).toHaveAttribute("aria-pressed", "true"); - await expect(historyTab).toBeVisible(); - await historyTab.click(); - await expect(historyTab).toHaveAttribute("data-focused", "true"); -} - async function waitForWorkspaceReady(page: Page): Promise { await expect(page.locator(".atelier-panel-group")).toBeVisible(); await expect(page.getByLabel("Flashtype loading")).toHaveCount(0); diff --git a/e2e/workspace-windows.spec.ts b/e2e/workspace-windows.spec.ts index b7e55bd3..b641255b 100644 --- a/e2e/workspace-windows.spec.ts +++ b/e2e/workspace-windows.spec.ts @@ -639,7 +639,8 @@ test("launching with multiple standalone markdown files creates one grouped tran "false", ); await groupedPage.getByLabel("Toggle left panel").click(); - await fileTreeDirectory(groupedPage, "/standalone-markdown-alpha/").click(); + // The active document's directory is auto-revealed (expanded), so its + // lix-backed and watched children are visible without clicking it. await expect( fileTreeFile(groupedPage, "/standalone-markdown-alpha/alpha.md"), ).toBeVisible(); diff --git a/electron/checkpoint-name.mjs b/electron/checkpoint-name.mjs deleted file mode 100644 index 78e4dee4..00000000 --- a/electron/checkpoint-name.mjs +++ /dev/null @@ -1,259 +0,0 @@ -import { spawn } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { refreshAgentExecutablePaths } from "./agent-executable-paths.mjs"; - -const CHECKPOINT_NAME_TIMEOUT_MS = 45_000; -const MAX_AGENT_OUTPUT_LENGTH = 64 * 1024; -const MAX_DIFF_CONTEXT_LENGTH = 12 * 1024; -const CHECKPOINT_NAME_INSTRUCTIONS = [ - "Name this checkpoint by summarizing the diff below.", - "Write a concise checkpoint title in 2 to 5 words.", - 'Prefer direct titles like "Update onboarding copy" or "Add new ICP".', - "Output only the title.", -].join("\n"); - -export async function generateCheckpointName(args) { - const cwd = normalizeCwd(args?.cwd); - const prompt = buildCheckpointNamePrompt(args?.diffContext); - let paths; - try { - paths = await refreshAgentExecutablePaths({ - cwd, - env: args?.env, - shell: args?.shell, - shellArgs: args?.shellArgs, - timeoutMs: args?.pathResolutionTimeoutMs, - }); - } catch (error) { - console.warn("[checkpoint-name] failed to resolve agent paths", error); - paths = { claude: null, codex: null }; - } - - for (const agent of ["codex", "claude"]) { - const executablePath = paths[agent]; - if (!executablePath) { - continue; - } - try { - const name = await generateCheckpointNameWithAgent({ - agent, - cwd, - env: args?.env, - executablePath, - prompt, - timeoutMs: args?.timeoutMs, - }); - if (name) { - return { name, source: agent }; - } - } catch (error) { - console.warn( - `[checkpoint-name] failed to generate checkpoint name with ${agent}`, - error, - ); - } - } - - return { name: formatLocalTimestamp(), source: "timestamp" }; -} - -export function buildCheckpointNamePrompt(diffContext) { - return [ - CHECKPOINT_NAME_INSTRUCTIONS, - "", - "Diff context:", - normalizeDiffContext(diffContext), - ].join("\n"); -} - -export function formatLocalTimestamp(date = new Date()) { - const pad = (value) => String(value).padStart(2, "0"); - return [ - `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, - `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`, - ].join(" "); -} - -export function normalizeCheckpointNameOutput(output) { - const lines = String(output ?? "") - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - for (const rawLine of lines) { - const name = rawLine - .replace(/^```(?:text)?/iu, "") - .replace(/```$/u, "") - .replace(/^(?:checkpoint\s+name|name)\s*:\s*/iu, "") - .replace(/[\u0000-\u001f\u007f]/gu, " ") - .replace(/[\\/]/gu, "-") - .replace(/\s+/gu, " ") - .trim() - .replace(/^["'`]+|["'`]+$/gu, "") - .trim(); - if (name.length > 0) { - return name.slice(0, 80); - } - } - return null; -} - -async function generateCheckpointNameWithAgent(args) { - if (args.agent === "codex") { - return await generateCheckpointNameWithCodex(args); - } - return await generateCheckpointNameWithClaude(args); -} - -async function generateCheckpointNameWithCodex(args) { - const tmpRoot = await mkdtemp( - path.join(tmpdir(), "flashtype-checkpoint-name-"), - ); - const outputPath = path.join(tmpRoot, "last-message.txt"); - try { - const result = await runAgentCommand({ - cwd: args.cwd, - env: args.env, - executablePath: args.executablePath, - args: [ - "exec", - "--skip-git-repo-check", - "--ephemeral", - "--color", - "never", - "-s", - "read-only", - "-C", - args.cwd, - "-o", - outputPath, - args.prompt, - ], - timeoutMs: args.timeoutMs, - }); - if (result.exitCode !== 0) { - throw new Error( - `Codex checkpoint naming exited with ${result.exitCode}: ${result.stderr || result.stdout}`, - ); - } - const output = await readFile(outputPath, "utf8").catch( - () => result.stdout, - ); - return normalizeCheckpointNameOutput(output); - } finally { - await rm(tmpRoot, { recursive: true, force: true }); - } -} - -async function generateCheckpointNameWithClaude(args) { - const result = await runAgentCommand({ - cwd: args.cwd, - env: args.env, - executablePath: args.executablePath, - args: [ - "--print", - "--output-format", - "text", - "--no-session-persistence", - "--permission-mode", - "dontAsk", - args.prompt, - ], - timeoutMs: args.timeoutMs, - }); - if (result.exitCode !== 0) { - throw new Error( - `Claude checkpoint naming exited with ${result.exitCode}: ${result.stderr || result.stdout}`, - ); - } - return normalizeCheckpointNameOutput(result.stdout); -} - -function runAgentCommand(args) { - return new Promise((resolve, reject) => { - let child; - try { - child = spawn(args.executablePath, args.args, { - cwd: args.cwd, - env: args.env, - stdio: ["ignore", "pipe", "pipe"], - }); - } catch (error) { - reject(error); - return; - } - - let stdout = ""; - let stderr = ""; - let settled = false; - let timeout; - const finish = (result) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - resolve({ - ...result, - stderr: stderr.trim(), - stdout: stdout.trim(), - }); - }; - timeout = setTimeout(() => { - child.kill(); - finish({ exitCode: null, signal: "timeout", timedOut: true }); - }, args.timeoutMs ?? CHECKPOINT_NAME_TIMEOUT_MS); - - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk) => { - stdout = appendBoundedOutput(stdout, chunk); - }); - child.stderr.on("data", (chunk) => { - stderr = appendBoundedOutput(stderr, chunk); - }); - child.on("error", (error) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - reject(error); - }); - child.on("exit", (exitCode, signal) => { - finish({ - exitCode: exitCode ?? null, - signal: signal ?? null, - timedOut: false, - }); - }); - }); -} - -function normalizeCwd(cwd) { - return typeof cwd === "string" && cwd.trim().length > 0 ? cwd : process.cwd(); -} - -function normalizeDiffContext(value) { - const text = - typeof value === "string" && value.trim().length > 0 - ? value - : "No diff details were available."; - const normalized = text - .replace(/\r\n?/gu, "\n") - .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, " ") - .trim(); - if (normalized.length <= MAX_DIFF_CONTEXT_LENGTH) { - return normalized; - } - return `${normalized.slice(0, MAX_DIFF_CONTEXT_LENGTH).trimEnd()}\n[diff context truncated]`; -} - -function appendBoundedOutput(current, next) { - const output = current + next; - if (output.length <= MAX_AGENT_OUTPUT_LENGTH) { - return output; - } - return output.slice(output.length - MAX_AGENT_OUTPUT_LENGTH); -} diff --git a/electron/checkpoint-name.test.mjs b/electron/checkpoint-name.test.mjs deleted file mode 100644 index 7210dca9..00000000 --- a/electron/checkpoint-name.test.mjs +++ /dev/null @@ -1,189 +0,0 @@ -import { - chmod, - mkdir, - mkdtemp, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { describe, expect, test } from "vitest"; -import { - buildCheckpointNamePrompt, - formatLocalTimestamp, - generateCheckpointName, - normalizeCheckpointNameOutput, -} from "./checkpoint-name.mjs"; -import { resetAgentExecutablePathsForTests } from "./agent-executable-paths.mjs"; - -const unixTest = process.platform === "win32" ? test.skip : test; - -describe("checkpoint name generation", () => { - test("formats the local timestamp fallback", () => { - expect(formatLocalTimestamp(new Date(2026, 0, 2, 3, 4, 5))).toBe( - "2026-01-02 03:04:05", - ); - }); - - test("normalizes agent output into a checkpoint name", () => { - expect( - normalizeCheckpointNameOutput("Name: `Silly Markdown Pancake`\n"), - ).toBe("Silly Markdown Pancake"); - expect(normalizeCheckpointNameOutput("\n")).toBeNull(); - }); - - test("builds a safe diff-summary prompt without diff details", () => { - const prompt = buildCheckpointNamePrompt(""); - - expect(prompt).toContain("Name this checkpoint by summarizing the diff"); - expect(prompt).toContain("No diff details were available."); - }); - - unixTest("asks Codex first with the checkpoint naming prompt", async () => { - const rootDir = await mkdtemp( - path.join(tmpdir(), "flashtype-checkpoint-name-test-"), - ); - try { - resetAgentExecutablePathsForTests(); - const binDir = path.join(rootDir, "bin"); - await mkdir(binDir); - const argsPath = path.join(rootDir, "codex-args.txt"); - const promptPath = path.join(rootDir, "prompt.txt"); - await writeExecutable( - path.join(binDir, "codex"), - [ - `printf "%s\\n" "$@" > ${shellQuote(argsPath)}`, - 'output_path=""', - 'prompt=""', - 'while [ "$#" -gt 0 ]; do', - ' if [ "$1" = "-o" ]; then', - " shift", - ' output_path="$1"', - " fi", - ' prompt="$1"', - " shift", - "done", - `printf "%s\\n" "$prompt" > ${shellQuote(promptPath)}`, - 'printf "%s\\n" "Silly Markdown Pancake" > "$output_path"', - ].join("\n"), - ); - - const diffContext = [ - "Files changed: 1 (1 modified)", - "File: modified /docs/onboarding.md", - "After excerpt:", - " Welcome to the updated flow.", - ].join("\n"); - const result = await generateCheckpointName( - generatorArgs({ cwd: rootDir, PATH: binDir, diffContext }), - ); - - expect(result).toEqual({ - name: "Silly Markdown Pancake", - source: "codex", - }); - const codexArgs = (await readFile(argsPath, "utf8")) - .trimEnd() - .split("\n"); - expect(codexArgs.slice(0, 11)).toEqual([ - "exec", - "--skip-git-repo-check", - "--ephemeral", - "--color", - "never", - "-s", - "read-only", - "-C", - rootDir, - "-o", - expect.stringContaining("flashtype-checkpoint-name-"), - ]); - const prompt = (await readFile(promptPath, "utf8")).trimEnd(); - expect(prompt).toContain("Name this checkpoint by summarizing the diff"); - expect(prompt).toContain("2 to 5 words"); - expect(prompt).toContain(diffContext); - } finally { - await rm(rootDir, { recursive: true, force: true }); - resetAgentExecutablePathsForTests(); - } - }); - - // Temporarily skipped: node-pty path probing intermittently times out under - // the full Vitest worker load, causing this path to fall back to a timestamp. - test.skip("falls back to Claude when Codex is missing", async () => { - const rootDir = await mkdtemp( - path.join(tmpdir(), "flashtype-checkpoint-name-test-"), - ); - try { - resetAgentExecutablePathsForTests(); - const binDir = path.join(rootDir, "bin"); - await mkdir(binDir); - await writeExecutable( - path.join(binDir, "claude"), - 'printf "%s\\n" "Giggle Draft Junction"', - ); - - const result = await generateCheckpointName( - generatorArgs({ cwd: rootDir, PATH: binDir }), - ); - - expect(result).toEqual({ - name: "Giggle Draft Junction", - source: "claude", - }); - } finally { - await rm(rootDir, { recursive: true, force: true }); - resetAgentExecutablePathsForTests(); - } - }); - - unixTest("falls back to a timestamp when no agent is resolved", async () => { - const rootDir = await mkdtemp( - path.join(tmpdir(), "flashtype-checkpoint-name-test-"), - ); - try { - resetAgentExecutablePathsForTests(); - const binDir = path.join(rootDir, "bin"); - await mkdir(binDir); - - const result = await generateCheckpointName( - generatorArgs({ cwd: rootDir, PATH: binDir }), - ); - - expect(result.source).toBe("timestamp"); - expect(result.name).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u); - } finally { - await rm(rootDir, { recursive: true, force: true }); - resetAgentExecutablePathsForTests(); - } - }); -}); - -function generatorArgs(options = {}) { - return { - cwd: options.cwd ?? process.cwd(), - diffContext: options.diffContext, - env: { - ...process.env, - PATH: options.PATH ?? process.env.PATH, - TERM: "xterm-256color", - }, - shell: "/bin/sh", - shellArgs: [], - // The node-pty executable-path probe can be delayed when the full Vitest - // suite is saturating the host. Keep the fake agent command short while - // giving that independent probe enough headroom to find the test binary. - pathResolutionTimeoutMs: 20_000, - timeoutMs: 5_000, - }; -} - -async function writeExecutable(filePath, body) { - await writeFile(filePath, `#!/bin/sh\n${body}\n`, { mode: 0o700 }); - await chmod(filePath, 0o700); -} - -function shellQuote(value) { - return `'${String(value).replace(/'/gu, `'\\''`)}'`; -} diff --git a/electron/ipc-terminal.mjs b/electron/ipc-terminal.mjs index a9df27f4..6273663b 100644 --- a/electron/ipc-terminal.mjs +++ b/electron/ipc-terminal.mjs @@ -17,7 +17,6 @@ import { import { checkAgentVersionPreflight } from "./agent-version-preflight.mjs"; import { getPreferredAgent } from "./agent-status.mjs"; import { refreshAgentExecutablePaths } from "./agent-executable-paths.mjs"; -import { generateCheckpointName } from "./checkpoint-name.mjs"; const terminals = new Map(); const ownerHooks = new Set(); @@ -185,23 +184,6 @@ export function registerTerminalIpc() { shellArgs, }); }); - - ipcMain.handle("terminal:generateCheckpointName", async (_event, payload) => { - const shell = resolveShell(payload?.shell); - const shellArgs = resolveShellArgs(shell); - const terminalEnv = buildTerminalEnv( - process.env, - process.platform, - normalizeExtraEnv(payload?.env), - ); - return await generateCheckpointName({ - cwd: payload?.cwd, - diffContext: payload?.diffContext, - env: terminalEnv, - shell, - shellArgs, - }); - }); } export function disposeTerminalIpc() { diff --git a/electron/preload.mjs b/electron/preload.mjs index 28381bfd..18a82a0a 100644 --- a/electron/preload.mjs +++ b/electron/preload.mjs @@ -103,8 +103,6 @@ const lix = { const terminal = { create: (payload) => ipcRenderer.invoke("terminal:create", payload), - generateCheckpointName: (payload) => - ipcRenderer.invoke("terminal:generateCheckpointName", payload), getPreferredAgent: (payload) => ipcRenderer.invoke("terminal:getPreferredAgent", payload), refreshAgentExecutablePaths: (payload) => diff --git a/electron/types.d.ts b/electron/types.d.ts index 9093fa8c..6eb26764 100644 --- a/electron/types.d.ts +++ b/electron/types.d.ts @@ -147,18 +147,6 @@ export type DesktopAgentPreferenceResult = { agents: Record; }; -export type DesktopGenerateCheckpointNamePayload = { - cwd?: string; - diffContext?: string; - shell?: string; - env?: Record; -}; - -export type DesktopGenerateCheckpointNameResult = { - name: string; - source: "codex" | "claude" | "timestamp"; -}; - export type DesktopTerminalCreateResult = | { status: "created"; @@ -189,9 +177,6 @@ export type DesktopTerminalApi = { create( payload: DesktopTerminalCreatePayload, ): Promise; - generateCheckpointName( - payload?: DesktopGenerateCheckpointNamePayload, - ): Promise; getPreferredAgent( payload?: DesktopAgentPreferencePayload, ): Promise; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daf0aa50..816b8bf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.7) + '@excalidraw/excalidraw': + specifier: 0.18.1 + version: 0.18.1(@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) '@glideapps/glide-data-grid': specifier: ^6.0.3 version: 6.0.3(lodash@4.18.1)(marked@16.4.2)(react-dom@19.2.7(react@19.2.7))(react-responsive-carousel@3.2.23)(react@19.2.7) @@ -665,6 +668,9 @@ packages: '@types/react': optional: true + '@braintree/sanitize-url@6.0.2': + resolution: {integrity: sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==} + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -684,9 +690,24 @@ packages: '@bytecodealliance/preview3-shim@0.2.0': resolution: {integrity: sha512-44zSPzlOyF/hWTNK32odet+Jt/nVXgtboSvgvZqMEJKP9GuM+Ln3L6js31WAakDEe+TYgG4iNEBk39rByINZkA==} + '@chevrotain/cst-dts-gen@11.0.3': + resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} + + '@chevrotain/gast@11.0.3': + resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} + + '@chevrotain/regexp-to-ast@11.0.3': + resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} + + '@chevrotain/types@11.0.3': + resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} + '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@chevrotain/utils@11.0.3': + resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -1204,6 +1225,25 @@ packages: cpu: [x64] os: [win32] + '@excalidraw/excalidraw@0.18.1': + resolution: {integrity: sha512-6i5Gt7IDTOH//qa0Z315Ly5iVRhjWpu2whrlQFqkuwrkKUWgRsMk0P5qdE7bpyDpai7jeLeWYkyj1eVAfni1lw==} + peerDependencies: + react: ^17.0.2 || ^18.2.0 || ^19.0.0 + react-dom: ^17.0.2 || ^18.2.0 || ^19.0.0 + + '@excalidraw/laser-pointer@1.3.1': + resolution: {integrity: sha512-psA1z1N2qeAfsORdXc9JmD2y4CmDwmuMRxnNdJHZexIcPwaNEyIpNcelw+QkL9rz9tosaN9krXuKaRqYpRAR6g==} + + '@excalidraw/markdown-to-text@0.1.2': + resolution: {integrity: sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==} + + '@excalidraw/mermaid-to-excalidraw@2.2.2': + resolution: {integrity: sha512-5VKQq5CdRocC82vOIUpQ5ufJOVV9FpBTdHGA+ULqazeIVV+cr299877omQCibsdS3Bpitz2fsnTwnIXEmLVDSg==} + + '@excalidraw/random-username@1.1.0': + resolution: {integrity: sha512-nULYsQxkWHnbmHvcs+efMkJ4/9TtvNyFeLyHdeGxW0zHs6P+jYVqcRff9A6Vq9w9JXeDRnRh2VKvTtS19GW2qA==} + engines: {node: '>=10'} + '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1548,6 +1588,9 @@ packages: '@marijn/find-cluster-break@1.0.3': resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + '@mermaid-js/parser@0.6.3': + resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@mermaid-js/parser@1.2.0': resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} @@ -2200,6 +2243,12 @@ packages: '@posthog/types@1.391.0': resolution: {integrity: sha512-oJ6jkqVMq+T4ax9F0rUllJc0KHpSgpaMwTNYWkE70iBiyXDVyhcNBmYnNKzSODgpzsaQNI6VfK8JrRYbkSJZZw==} + '@radix-ui/primitive@1.0.0': + resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==} + + '@radix-ui/primitive@1.1.1': + resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -2219,6 +2268,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-arrow@1.1.2': + resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} + 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.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -2232,6 +2294,12 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.0.1': + resolution: {integrity: sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-collection@1.1.12': resolution: {integrity: sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==} peerDependencies: @@ -2258,6 +2326,20 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-compose-refs@1.0.0': + resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-compose-refs@1.1.1': + resolution: {integrity: sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -2276,6 +2358,20 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.0.0': + resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-context@1.1.1': + resolution: {integrity: sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==} + 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@1.1.2': resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: @@ -2294,6 +2390,11 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.0.0': + resolution: {integrity: sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-direction@1.1.1': resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} peerDependencies: @@ -2338,6 +2439,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.5': + resolution: {integrity: sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==} + 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-dropdown-menu@2.1.16': resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} peerDependencies: @@ -2364,6 +2478,15 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-guards@1.1.1': + resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} + 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-guards@1.1.3': resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} peerDependencies: @@ -2395,6 +2518,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.2': + resolution: {integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==} + 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.7': resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} peerDependencies: @@ -2408,6 +2544,20 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-id@1.0.0': + resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-id@1.1.0': + resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-id@1.1.1': resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} peerDependencies: @@ -2452,6 +2602,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.6': + resolution: {integrity: sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==} + 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-popper@1.2.2': + resolution: {integrity: sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==} + 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-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -2491,6 +2667,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.4': + resolution: {integrity: sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==} + 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.9': resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} peerDependencies: @@ -2504,6 +2693,25 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.0.0': + resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-presence@1.1.2': + resolution: {integrity: sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==} + 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.5': resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} peerDependencies: @@ -2530,6 +2738,25 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@1.0.1': + resolution: {integrity: sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-primitive@2.0.2': + resolution: {integrity: sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==} + 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: @@ -2556,6 +2783,12 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.0.2': + resolution: {integrity: sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-roving-focus@1.1.11': resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: @@ -2582,6 +2815,20 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slot@1.0.1': + resolution: {integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-slot@1.1.2': + resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -2600,6 +2847,12 @@ packages: '@types/react': optional: true + '@radix-ui/react-tabs@1.0.2': + resolution: {integrity: sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-tooltip@1.2.12': resolution: {integrity: sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA==} peerDependencies: @@ -2626,6 +2879,20 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-use-callback-ref@1.0.0': + resolution: {integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-callback-ref@1.1.0': + resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} + 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-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -2644,6 +2911,20 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.0.0': + resolution: {integrity: sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-controllable-state@1.1.0': + resolution: {integrity: sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.2': resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: @@ -2680,6 +2961,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-escape-keydown@1.1.0': + resolution: {integrity: sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.1': resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: @@ -2698,6 +2988,20 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.0.0': + resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + + '@radix-ui/react-use-layout-effect@1.1.0': + resolution: {integrity: sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==} + 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.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -2716,6 +3020,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-rect@1.1.0': + resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} + 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.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -2734,6 +3047,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.0': + resolution: {integrity: sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==} + 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.1': resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} peerDependencies: @@ -2778,6 +3100,9 @@ packages: '@types/react-dom': optional: true + '@radix-ui/rect@1.1.0': + resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} @@ -3712,6 +4037,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + app-builder-lib@26.15.2: resolution: {integrity: sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ==} engines: {node: '>=14.0.0'} @@ -3787,6 +4116,10 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + binaryen@130.0.0: resolution: {integrity: sha512-XDrb+zql0RbFPKgj7MuH9zOc78R3Fa/P/VSGnnpdwYsvNZPWjcMYMdAkKCOQEL2A7yqgjSMTDRFp6gfSDW+/QQ==} hasBin: true @@ -3818,6 +4151,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browser-fs-access@0.29.1: + resolution: {integrity: sha512-LSvVX5e21LRrXqVMhqtAwj5xPgDb+fXAIH80NsnCQ9xuZPs2xWsOREi24RKgZa1XOiQRbcmVrv87+ulOKsgjxw==} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3873,6 +4209,9 @@ packages: canvas-hypertxt@1.0.3: resolution: {integrity: sha512-+VsMpRr64jYgKq2IeFUNel3vCZH/IzS+iXSHxmUV3IUH5dXlC9xHz4AwtPZisDxZ5MWcuK0V+TXgPKFPiZnxzg==} + canvas-roundrect-polyfill@0.0.1: + resolution: {integrity: sha512-yWq+R3U3jE+coOeEb3a3GgE2j/0MMiDKM/QpLb6h9ihf5fGY9UXtvK9o4vNqjWXoZz7/3EaSVU3IX53TvFFUOw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3887,6 +4226,18 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.0.3: + resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -3931,6 +4282,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + clsx@1.1.1: + resolution: {integrity: sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==} + engines: {node: '>=6'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -3999,12 +4354,21 @@ packages: cose-base@2.2.0: resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + crc-32@0.3.0: + resolution: {integrity: sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==} + engines: {node: '>=0.8'} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4387,6 +4751,10 @@ packages: es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + es6-promise-pool@2.5.0: + resolution: {integrity: sha512-VHErXfzR/6r/+yyzPKeBvO0lgjfC5cbDCQWjWwMZWSb6YU39TGIl51OUmCfWCq4ylMdJSB8zkz2vIuIeIxXApA==} + engines: {node: '>=0.10.0'} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -4487,6 +4855,10 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -4527,6 +4899,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + fuzzy@0.1.3: + resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==} + engines: {node: '>= 0.6.0'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -4554,6 +4930,10 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -4571,6 +4951,9 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + glur@1.1.2: + resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -4645,6 +5028,12 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + image-blob-reduce@3.0.1: + resolution: {integrity: sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==} + + immutable@4.3.9: + resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} + immutable@5.1.4: resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} @@ -4677,6 +5066,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-extendable@1.0.1: resolution: {integrity: sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==} engines: {node: '>=0.10.0'} @@ -4758,6 +5151,24 @@ packages: resolution: {integrity: sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==} engines: {node: '>= 20'} + jotai-scope@0.7.2: + resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==} + peerDependencies: + jotai: '>=2.9.2' + react: '>=17.0.0' + + jotai@2.11.0: + resolution: {integrity: sha512-zKfoBBD1uDw3rljwHkt0fWuja1B76R7CjznuBO+mSX6jpsO1EBeWNRKpeaQho9yPI/pvCv4recGfgOXGxwPZvQ==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4821,6 +5232,10 @@ packages: resolution: {integrity: sha512-VHtBdW6XB/pgoTSqraM3UAa2rYoYdNXqnNPpX+8XXP+cwYbVEFuAp3HyPt1vpNfU9l7Y2kpUrA9QDPsy8uUqOQ==} engines: {node: '>=22.0.0'} + langium@3.3.1: + resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} + engines: {node: '>=16.0.0'} + layout-base@1.0.2: resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} @@ -4974,9 +5389,15 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.escaperegexp@4.1.2: resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} @@ -4984,6 +5405,9 @@ packages: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -5282,6 +5706,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multimath@2.0.0: + resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5292,6 +5719,16 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.3: + resolution: {integrity: sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@4.0.2: + resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} + engines: {node: ^14 || ^16 || >=18} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -5338,6 +5775,10 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + normalize-url@6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} @@ -5364,6 +5805,9 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + open-color@1.9.1: + resolution: {integrity: sha512-vCseG/EQ6/RcvxhUcGJiHViOgrtz4x0XbZepXvKik66TMGkvbmjeJrKFyBEx6daG5rNyyd14zYXhz0hZVwQFOw==} + ora@5.3.0: resolution: {integrity: sha512-zAKMgGXUim0Jyd6CXK9lraBnD3H5yPGBPPOkC23a2BG6hsm4Zu6OQSjQuEtV0BHDf4aKHcUFvJiGRrFuW3MG8g==} engines: {node: '>=10'} @@ -5433,6 +5877,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pako@2.0.3: + resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==} + papaparse@5.5.3: resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} @@ -5481,6 +5928,12 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-freehand@1.2.0: + resolution: {integrity: sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw==} + + pica@7.1.1: + resolution: {integrity: sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5514,9 +5967,21 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + png-chunk-text@1.0.0: + resolution: {integrity: sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==} + + png-chunks-encode@1.0.0: + resolution: {integrity: sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==} + + png-chunks-extract@1.0.0: + resolution: {integrity: sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + points-on-curve@1.0.1: + resolution: {integrity: sha512-3nmX4/LIiyuwGLwuUrfhTlDeQFlAhi7lyK/zcRNGhalwapDWgAGR82bUpmn2mA03vII3fvNCG8jAONzKXwpxAg==} + points-on-path@0.2.1: resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} @@ -5646,6 +6111,9 @@ packages: resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} engines: {node: '>=16.0.0'} + pwacompat@2.0.17: + resolution: {integrity: sha512-6Du7IZdIy7cHiv7AhtDy4X2QRM8IAD5DII69mt5qWibC2d15ZU8DmBG1WdZKekG11cChSu4zkSUGPF9sweOl6w==} + query-selector-shadow-dom@1.0.1: resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==} @@ -5750,6 +6218,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -5818,6 +6290,9 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + roughjs@4.6.4: + resolution: {integrity: sha512-s6EZ0BntezkFYMf/9mGn7M8XGIoaav9QQBCnJROWB3brUWQ683Q2LbRD/hq0Z3bAJ/9NVpU/5LpiTWvQMyLDhw==} + roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -5948,6 +6423,11 @@ packages: engines: {node: '>=16.0.0'} hasBin: true + sass@1.51.0: + resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==} + engines: {node: '>=12.0.0'} + hasBin: true + sass@1.97.1: resolution: {integrity: sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==} engines: {node: '>=14.0.0'} @@ -6017,6 +6497,10 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} + sliced@1.0.1: + resolution: {integrity: sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==} + deprecated: Unsupported + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -6240,6 +6724,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-rat@0.1.2: + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -6510,6 +6997,26 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.0.8: + resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -6535,6 +7042,9 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} + webworkify@1.5.0: + resolution: {integrity: sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} @@ -6656,6 +7166,21 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6941,6 +7466,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@braintree/sanitize-url@6.0.2': {} + '@braintree/sanitize-url@7.1.2': {} '@bramus/specificity@2.4.2': @@ -6963,8 +7490,25 @@ snapshots: dependencies: '@bytecodealliance/preview2-shim': 0.19.0 + '@chevrotain/cst-dts-gen@11.0.3': + dependencies: + '@chevrotain/gast': 11.0.3 + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/gast@11.0.3': + dependencies: + '@chevrotain/types': 11.0.3 + lodash-es: 4.17.21 + + '@chevrotain/regexp-to-ast@11.0.3': {} + + '@chevrotain/types@11.0.3': {} + '@chevrotain/types@11.1.2': {} + '@chevrotain/utils@11.0.3': {} + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260708.1)': @@ -7444,6 +7988,59 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@excalidraw/excalidraw@0.18.1(@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: + '@braintree/sanitize-url': 6.0.2 + '@excalidraw/laser-pointer': 1.3.1 + '@excalidraw/mermaid-to-excalidraw': 2.2.2 + '@excalidraw/random-username': 1.1.0 + '@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) + '@radix-ui/react-tabs': 1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + browser-fs-access: 0.29.1 + canvas-roundrect-polyfill: 0.0.1 + clsx: 1.1.1 + cross-env: 7.0.3 + es6-promise-pool: 2.5.0 + fractional-indexing: 3.2.0 + fuzzy: 0.1.3 + image-blob-reduce: 3.0.1 + jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7) + jotai-scope: 0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + lodash.debounce: 4.0.8 + lodash.throttle: 4.1.1 + nanoid: 3.3.3 + open-color: 1.9.1 + pako: 2.0.3 + perfect-freehand: 1.2.0 + pica: 7.1.1 + png-chunk-text: 1.0.0 + png-chunks-encode: 1.0.0 + png-chunks-extract: 1.0.0 + points-on-curve: 1.0.1 + pwacompat: 2.0.17 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + roughjs: 4.6.4 + sass: 1.51.0 + tunnel-rat: 0.1.2(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - immer + + '@excalidraw/laser-pointer@1.3.1': {} + + '@excalidraw/markdown-to-text@0.1.2': {} + + '@excalidraw/mermaid-to-excalidraw@2.2.2': + dependencies: + '@excalidraw/markdown-to-text': 0.1.2 + '@mermaid-js/parser': 0.6.3 + mermaid: 11.16.0 + nanoid: 4.0.2 + + '@excalidraw/random-username@1.1.0': {} + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': optionalDependencies: '@noble/hashes': 2.2.0 @@ -7825,8 +8422,8 @@ snapshots: optionalDependencies: '@lix-js/sdk-darwin-arm64': 0.8.3 '@lix-js/sdk-linux-arm64': 0.8.3 - '@lix-js/sdk-win32-x64': 0.8.3 '@lix-js/sdk-linux-x64': 0.8.3 + '@lix-js/sdk-win32-x64': 0.8.3 '@malept/cross-spawn-promise@2.0.0': dependencies: @@ -7843,6 +8440,10 @@ snapshots: '@marijn/find-cluster-break@1.0.3': {} + '@mermaid-js/parser@0.6.3': + dependencies: + langium: 3.3.1 + '@mermaid-js/parser@1.2.0': dependencies: '@chevrotain/types': 11.1.2 @@ -8269,6 +8870,12 @@ snapshots: '@posthog/types@1.391.0': {} + '@radix-ui/primitive@1.0.0': + dependencies: + '@babel/runtime': 7.29.7 + + '@radix-ui/primitive@1.1.1': {} + '@radix-ui/primitive@1.1.3': {} '@radix-ui/primitive@1.1.5': {} @@ -8282,6 +8889,15 @@ snapshots: '@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) + 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.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-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) @@ -8291,6 +8907,16 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-collection@1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + '@radix-ui/react-context': 1.0.0(react@19.2.7) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.0.1(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-collection@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) @@ -8315,13 +8941,35 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.0)': + '@radix-ui/react-compose-refs@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + + '@radix-ui/react-compose-refs@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.7 + + '@radix-ui/react-compose-refs@1.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-context@1.0.0(react@19.2.7)': dependencies: - react: 19.2.0 - optionalDependencies: - '@types/react': 19.2.7 + '@babel/runtime': 7.29.7 + react: 19.2.7 - '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-context@1.1.1(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: @@ -8339,6 +8987,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-direction@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + '@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.2.0)': dependencies: react: 19.2.0 @@ -8377,6 +9030,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.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 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@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) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-escape-keydown': 1.1.0(@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-dropdown-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 @@ -8407,6 +9073,12 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-focus-guards@1.1.1(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.7)(react@19.2.0)': dependencies: react: 19.2.0 @@ -8430,6 +9102,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.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) + '@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) + '@radix-ui/react-use-callback-ref': 1.1.0(@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.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) @@ -8441,6 +9124,19 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-id@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.7) + react: 19.2.7 + + '@radix-ui/react-id@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) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-id@1.1.1(@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) @@ -8507,6 +9203,47 @@ snapshots: '@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 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@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) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@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) + '@radix-ui/react-id': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.2.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) + '@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) + '@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) + '@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) + '@radix-ui/react-slot': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.1.0(@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-popper@1.2.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: + '@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.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) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@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) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.0 + 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-popper@1.2.8(@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: '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -8553,6 +9290,16 @@ snapshots: '@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) + '@radix-ui/react-use-layout-effect': 1.1.0(@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.9(@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) @@ -8563,6 +9310,24 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-presence@1.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@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) + '@radix-ui/react-use-layout-effect': 1.1.0(@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.5(@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) @@ -8582,6 +9347,22 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-primitive@1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/react-slot': 1.0.1(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@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)': + dependencies: + '@radix-ui/react-slot': 1.1.2(@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) @@ -8600,6 +9381,21 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/react-roving-focus@1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@radix-ui/primitive': 1.0.0 + '@radix-ui/react-collection': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + '@radix-ui/react-context': 1.0.0(react@19.2.7) + '@radix-ui/react-direction': 1.0.0(react@19.2.7) + '@radix-ui/react-id': 1.0.0(react@19.2.7) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.0.0(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-roving-focus@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 @@ -8636,6 +9432,19 @@ snapshots: '@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 + '@radix-ui/react-compose-refs': 1.0.0(react@19.2.7) + react: 19.2.7 + + '@radix-ui/react-slot@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.0) @@ -8650,6 +9459,20 @@ snapshots: 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 + '@radix-ui/primitive': 1.0.0 + '@radix-ui/react-context': 1.0.0(react@19.2.7) + '@radix-ui/react-direction': 1.0.0(react@19.2.7) + '@radix-ui/react-id': 1.0.0(react@19.2.7) + '@radix-ui/react-presence': 1.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 1.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.0.0(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + '@radix-ui/react-tooltip@1.2.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/primitive': 1.1.5 @@ -8690,6 +9513,17 @@ snapshots: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) + '@radix-ui/react-use-callback-ref@1.0.0(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.7)(react@19.2.0)': dependencies: react: 19.2.0 @@ -8702,6 +9536,19 @@ snapshots: 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 + '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.7) + react: 19.2.7 + + '@radix-ui/react-use-controllable-state@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) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.2.0)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.2.0) @@ -8732,6 +9579,13 @@ snapshots: 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) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.7)(react@19.2.0)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.7)(react@19.2.0) @@ -8745,6 +9599,17 @@ snapshots: 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 + react: 19.2.7 + + '@radix-ui/react-use-layout-effect@1.1.0(@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.1.1(@types/react@19.2.7)(react@19.2.0)': dependencies: react: 19.2.0 @@ -8757,6 +9622,13 @@ snapshots: 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 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.7)(react@19.2.0)': dependencies: '@radix-ui/rect': 1.1.1 @@ -8771,6 +9643,13 @@ snapshots: 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) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-size@1.1.1(@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) @@ -8803,6 +9682,8 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@radix-ui/rect@1.1.0': {} + '@radix-ui/rect@1.1.1': {} '@radix-ui/rect@1.1.2': {} @@ -9630,6 +10511,11 @@ snapshots: ansi-styles@6.2.3: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + app-builder-lib@26.15.2(dmg-builder@26.15.2)(electron-builder-squirrel-windows@26.15.2): dependencies: '@electron/asar': 3.4.1 @@ -9738,6 +10624,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + binary-extensions@2.3.0: {} + binaryen@130.0.0: {} bl@4.1.0: @@ -9769,7 +10657,8 @@ snapshots: braces@3.0.3: dependencies: fill-range: 7.1.1 - optional: true + + browser-fs-access@0.29.1: {} browserslist@4.28.1: dependencies: @@ -9859,6 +10748,8 @@ snapshots: canvas-hypertxt@1.0.3: {} + canvas-roundrect-polyfill@0.0.1: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -9870,6 +10761,32 @@ snapshots: character-entities@2.0.2: {} + chevrotain-allstar@0.3.1(chevrotain@11.0.3): + dependencies: + chevrotain: 11.0.3 + lodash-es: 4.18.1 + + chevrotain@11.0.3: + dependencies: + '@chevrotain/cst-dts-gen': 11.0.3 + '@chevrotain/gast': 11.0.3 + '@chevrotain/regexp-to-ast': 11.0.3 + '@chevrotain/types': 11.0.3 + '@chevrotain/utils': 11.0.3 + lodash-es: 4.17.21 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -9907,6 +10824,8 @@ snapshots: clone@1.0.4: {} + clsx@1.1.1: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -9963,11 +10882,17 @@ snapshots: dependencies: layout-base: 2.0.1 + crc-32@0.3.0: {} + crelt@1.0.6: {} cross-dirname@0.1.0: optional: true + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -10414,6 +11339,8 @@ snapshots: es6-error@4.1.1: optional: true + es6-promise-pool@2.5.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -10530,7 +11457,6 @@ snapshots: fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - optional: true find-up@5.0.0: dependencies: @@ -10554,6 +11480,8 @@ snapshots: format@0.2.2: {} + fractional-indexing@3.2.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -10599,6 +11527,8 @@ snapshots: function-bind@1.1.2: {} + fuzzy@0.1.3: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -10632,6 +11562,10 @@ snapshots: resolve-pkg-maps: 1.0.0 optional: true + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -10666,6 +11600,8 @@ snapshots: gopd: 1.2.0 optional: true + glur@1.1.2: {} + gopd@1.2.0: {} got@11.8.6: @@ -10761,6 +11697,12 @@ snapshots: ieee754@1.2.1: {} + image-blob-reduce@3.0.1: + dependencies: + pica: 7.1.1 + + immutable@4.3.9: {} + immutable@5.1.4: optional: true @@ -10783,24 +11725,25 @@ snapshots: ip-address@10.1.0: {} + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-extendable@1.0.1: dependencies: is-plain-object: 2.0.4 - is-extglob@2.1.1: - optional: true + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 - optional: true is-interactive@1.0.0: {} - is-number@7.0.0: - optional: true + is-number@7.0.0: {} is-plain-object@2.0.4: dependencies: @@ -10850,6 +11793,16 @@ snapshots: '@hapi/topo': 6.0.2 '@standard-schema/spec': 1.1.0 + jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): + dependencies: + jotai: 2.11.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + + jotai@2.11.0(@types/react@19.2.17)(react@19.2.7): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + js-tokens@4.0.0: {} js-yaml@4.2.0: @@ -10919,6 +11872,14 @@ snapshots: kysely@0.29.3: {} + langium@3.3.1: + dependencies: + chevrotain: 11.0.3 + chevrotain-allstar: 0.3.1(chevrotain@11.0.3) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + layout-base@1.0.2: {} layout-base@2.0.1: {} @@ -11027,12 +11988,18 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.21: {} + lodash-es@4.18.1: {} + lodash.debounce@4.0.8: {} + lodash.escaperegexp@4.1.2: {} lodash.isequal@4.5.0: {} + lodash.throttle@4.1.1: {} + lodash@4.18.1: {} log-symbols@4.1.0: @@ -11532,10 +12499,19 @@ snapshots: ms@2.1.3: {} + multimath@2.0.0: + dependencies: + glur: 1.1.2 + object-assign: 4.1.1 + nanoid@3.3.11: {} nanoid@3.3.15: {} + nanoid@3.3.3: {} + + nanoid@4.0.2: {} + negotiator@1.0.0: {} node-abi@4.26.0: @@ -11595,6 +12571,8 @@ snapshots: dependencies: abbrev: 4.0.0 + normalize-path@3.0.0: {} + normalize-url@6.1.0: {} object-assign@4.1.1: {} @@ -11616,6 +12594,8 @@ snapshots: dependencies: mimic-fn: 2.1.0 + open-color@1.9.1: {} + ora@5.3.0: dependencies: bl: 4.1.0 @@ -11725,6 +12705,8 @@ snapshots: package-manager-detector@1.6.0: {} + pako@2.0.3: {} + papaparse@5.5.3: {} papaparse@5.5.4: {} @@ -11763,10 +12745,19 @@ snapshots: pend@1.2.0: {} + perfect-freehand@1.2.0: {} + + pica@7.1.1: + dependencies: + glur: 1.1.2 + inherits: 2.0.4 + multimath: 2.0.0 + object-assign: 4.1.1 + webworkify: 1.5.0 + picocolors@1.1.1: {} - picomatch@2.3.1: - optional: true + picomatch@2.3.1: {} picomatch@4.0.3: {} @@ -11795,8 +12786,21 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + png-chunk-text@1.0.0: {} + + png-chunks-encode@1.0.0: + dependencies: + crc-32: 0.3.0 + sliced: 1.0.1 + + png-chunks-extract@1.0.0: + dependencies: + crc-32: 0.3.0 + points-on-curve@0.2.0: {} + points-on-curve@1.0.1: {} + points-on-path@0.2.1: dependencies: path-data-parser: 0.1.0 @@ -11966,6 +12970,8 @@ snapshots: pvutils@1.1.5: {} + pwacompat@2.0.17: {} + query-selector-shadow-dom@1.0.1: {} quick-lru@5.1.1: {} @@ -12100,6 +13106,10 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + readdirp@4.1.2: optional: true @@ -12203,6 +13213,13 @@ snapshots: rope-sequence@1.3.4: {} + roughjs@4.6.4: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + roughjs@4.6.6: dependencies: hachure-fill: 0.5.2 @@ -12315,6 +13332,12 @@ snapshots: sass-embedded-win32-x64: 1.97.1 optional: true + sass@1.51.0: + dependencies: + chokidar: 3.6.0 + immutable: 4.3.9 + source-map-js: 1.2.1 + sass@1.97.1: dependencies: chokidar: 4.0.3 @@ -12397,6 +13420,8 @@ snapshots: dependencies: semver: 7.7.3 + sliced@1.0.1: {} + smart-buffer@4.2.0: {} socks-proxy-agent@8.0.5: @@ -12579,7 +13604,6 @@ snapshots: to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - optional: true tough-cookie@6.0.2: dependencies: @@ -12611,6 +13635,14 @@ snapshots: fsevents: 2.3.3 optional: true + tunnel-rat@0.1.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + zustand: 4.5.7(@types/react@19.2.17)(react@19.2.7) + transitivePeerDependencies: + - '@types/react' + - immer + - react + tw-animate-css@1.4.0: {} type-fest@0.13.1: @@ -12854,6 +13886,23 @@ snapshots: transitivePeerDependencies: - msw + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.0.8: {} + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: @@ -12886,6 +13935,8 @@ snapshots: webidl-conversions@8.0.1: {} + webworkify@1.5.0: {} + whatwg-mimetype@3.0.0: {} whatwg-mimetype@5.0.0: {} @@ -13003,4 +14054,11 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + zustand@4.5.7(@types/react@19.2.17)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + zwitch@2.0.4: {} diff --git a/src/extension-runtime/checkpoint-diff.ts b/src/extension-runtime/checkpoint-diff.ts deleted file mode 100644 index 59f93742..00000000 --- a/src/extension-runtime/checkpoint-diff.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type CheckpointDiffFileStatus = - | "added" - | "deleted" - | "modified" - | "recreated"; - -export type CheckpointDiffFile = { - readonly fileId: string; - readonly path: string; - readonly beforePath: string | null; - readonly afterPath: string | null; - readonly beforeData: Uint8Array; - readonly afterData: Uint8Array; - readonly beforeCommitId: string; - readonly afterCommitId: string; - readonly reviewId: string; - readonly status: CheckpointDiffFileStatus; -}; - -export type CheckpointDiffVisibleFile = { - readonly fileId: string; - readonly path: string; -}; - -export type CheckpointDiff = { - readonly branchId: string; - readonly branchName: string; - readonly beforeBranchId: string; - readonly beforeBranchName: string; - readonly beforeCommitId: string; - readonly afterCommitId: string; - readonly afterIsActiveHead?: boolean; - readonly visibleFiles?: readonly CheckpointDiffVisibleFile[]; - readonly files: readonly CheckpointDiffFile[]; -}; - -export type CheckpointDiffBranchRow = { - readonly id: string; - readonly name: string; - readonly commit_id: string | null; -}; diff --git a/src/extension-runtime/editor-revision-state.ts b/src/extension-runtime/editor-revision-state.ts index 4488d91f..6f10f9fd 100644 --- a/src/extension-runtime/editor-revision-state.ts +++ b/src/extension-runtime/editor-revision-state.ts @@ -48,8 +48,6 @@ export function stripEditorRevisionState( const { [BEFORE_COMMIT_ID_STATE_KEY]: _beforeCommitId, [AFTER_COMMIT_ID_STATE_KEY]: _afterCommitId, - checkpointDiffReviewId: _checkpointDiffReviewId, - checkpointDiffBranchId: _checkpointDiffBranchId, ...rest } = state; return Object.keys(rest).length > 0 ? rest : undefined; diff --git a/src/extension-runtime/types.ts b/src/extension-runtime/types.ts index 35cbf797..a762836c 100644 --- a/src/extension-runtime/types.ts +++ b/src/extension-runtime/types.ts @@ -1,6 +1,3 @@ -import type { Lix } from "@/lib/lix-types"; -import type { CheckpointDiff } from "./checkpoint-diff"; - /** * Persisted view state. Only include values that should survive reloads. * @@ -19,21 +16,6 @@ export type ExtensionState = { readonly [key: string]: unknown; }; -export type WorkspaceContext = - | { - readonly ephemeral: false; - readonly path: string; - readonly name: string; - readonly initialPanelMode?: "document"; - } - | { - readonly ephemeral: true; - readonly path: string; - readonly name: string; - readonly initialPanelMode?: "document"; - readonly openFilePaths: readonly string[]; - }; - /** * One-shot launch-time arguments that must not be persisted. * @@ -41,56 +23,3 @@ export type WorkspaceContext = * const launchArgs: ExtensionLaunchArgs = { initialMessage: "Summarize changes" }; */ export type ExtensionLaunchArgs = Record; - -/** - * FlashType host context consumed by its Files view. - * - * Atelier owns extension registration and panel state. This adapter contains - * only the desktop filesystem behavior that remains owned by FlashType. - */ -export interface ExtensionContext { - readonly openFile?: (args: { - readonly panel: PanelSide; - readonly fileId: string; - readonly filePath: string; - readonly state?: ExtensionState; - readonly launchArgs?: ExtensionLaunchArgs; - readonly focus?: boolean; - readonly pending?: boolean; - readonly documentOrigin?: "existing" | "new"; - readonly trackTelemetry?: boolean; - readonly trackDocumentOpenAttempt?: boolean; - readonly trackDocumentViewed?: boolean; - }) => void | Promise; - readonly checkpointDiff?: CheckpointDiff | null; - readonly checkpointBranchId?: string | null; - readonly closeFileViews?: (args: { - readonly panel?: PanelSide; - readonly fileId: string; - }) => void; - /** File id for the file currently active in the central editor panel. */ - readonly activeFileId?: string | null; - /** Path for the file currently active in the central editor panel. */ - readonly activeFilePath?: string | null; - readonly isPanelFocused?: boolean; - readonly setTabBadgeCount: (count: number | null | undefined) => void; - readonly panelSide?: PanelSide; - readonly viewInstance?: string; - readonly isActiveView?: boolean; - readonly registerNewFileDraftHandler?: (registration: { - readonly panelSide: PanelSide; - readonly viewInstance: string; - readonly isActiveView: boolean; - readonly handler: () => void; - }) => () => void; - readonly workspace?: WorkspaceContext; - readonly lix: Lix; -} - -/** - * Declares the available sides that panels can mount on. - * - * @example - * const side: PanelSide = "left"; - */ -export type PanelSide = "left" | "right" | "central"; diff --git a/src/extensions/atelier-host-extensions.test.ts b/src/extensions/atelier-host-extensions.test.ts index 9bacad8a..43299bb9 100644 --- a/src/extensions/atelier-host-extensions.test.ts +++ b/src/extensions/atelier-host-extensions.test.ts @@ -2,16 +2,10 @@ import { describe, expect, test } from "vitest"; import { createFlashTypeAtelierExtensions } from "./atelier-host-extensions"; describe("createFlashTypeAtelierExtensions", () => { - test("registers filesystem, Electron-aware history, and agent terminals", () => { - const extensions = createFlashTypeAtelierExtensions({ - ephemeral: false, - path: "/workspace", - name: "workspace", - }); + test("registers only the agent terminals (Files is atelier's bundled view)", () => { + const extensions = createFlashTypeAtelierExtensions(); expect(extensions.map((extension) => extension.manifest.id)).toEqual([ - "atelier_files", - "atelier_history", "flashtype_claude", "flashtype_codex", ]); diff --git a/src/extensions/atelier-host-extensions.ts b/src/extensions/atelier-host-extensions.ts index 08c04465..c977e73d 100644 --- a/src/extensions/atelier-host-extensions.ts +++ b/src/extensions/atelier-host-extensions.ts @@ -1,21 +1,11 @@ -import type { - AtelierBuiltinExtensionId, - AtelierExtensionRegistration, -} from "@opral/atelier"; -import type { WorkspaceContext } from "@/extension-runtime/types"; -import { createFilesExtensionRegistration } from "./files/host-extension"; -import { createHistoryExtensionRegistration } from "./history/host-extension"; +import type { AtelierExtensionRegistration } from "@opral/atelier"; import { FLASHTYPE_ATELIER_EXTENSIONS as TERMINAL_EXTENSIONS } from "./terminal/host-extensions"; -export const FLASHTYPE_FILES_EXTENSION_ID = - "atelier_files" satisfies AtelierBuiltinExtensionId; - -export function createFlashTypeAtelierExtensions( - workspace: WorkspaceContext, -): readonly AtelierExtensionRegistration[] { - return [ - createFilesExtensionRegistration(workspace), - createHistoryExtensionRegistration(), - ...TERMINAL_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]; } diff --git a/src/extensions/csv/index.reactive.test.tsx b/src/extensions/csv/index.reactive.test.tsx index 1a222ad6..04862397 100644 --- a/src/extensions/csv/index.reactive.test.tsx +++ b/src/extensions/csv/index.reactive.test.tsx @@ -277,64 +277,6 @@ test("does not mark unchanged before-to-HEAD CSV files as fully added", async () } }); -test("renders checkpoint CSV diffs without review controls for missing active files", async () => { - const lix = await openLix(); - let utils: ReturnType | undefined; - try { - 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(); - } -}); - async function activeCommitId(lix: Awaited>) { const result = await lix.execute( "SELECT lix_active_branch_commit_id() AS commit_id", diff --git a/src/extensions/csv/index.tsx b/src/extensions/csv/index.tsx index 8f975833..66e02e40 100644 --- a/src/extensions/csv/index.tsx +++ b/src/extensions/csv/index.tsx @@ -19,10 +19,6 @@ import type { ExternalWriteReview, ExternalWriteReviewData, } from "@/extension-runtime/external-write-review"; -import type { - CheckpointDiff, - CheckpointDiffFile, -} from "@/extension-runtime/checkpoint-diff"; import { editorRevisionMode, editorRevisionReviewId, @@ -42,7 +38,6 @@ type CsvViewProps = { readonly filePath?: string; readonly isActiveView?: boolean; readonly isPanelFocused?: boolean; - readonly checkpointDiff?: CheckpointDiff | null; readonly beforeCommitId?: string | null; readonly afterCommitId?: string | null; readonly registerExternalWriteReview?: ( @@ -118,7 +113,6 @@ export function CsvView({ filePath, isActiveView = true, isPanelFocused = true, - checkpointDiff, beforeCommitId, afterCommitId, registerExternalWriteReview, @@ -132,7 +126,6 @@ export function CsvView({ filePath={filePath} isActiveView={isActiveView} isPanelFocused={isPanelFocused} - checkpointDiff={checkpointDiff} beforeCommitId={beforeCommitId} afterCommitId={afterCommitId} registerExternalWriteReview={registerExternalWriteReview} @@ -160,7 +153,6 @@ function CsvViewData({ fileId, filePath, fileRow, - checkpointDiff, beforeCommitId, afterCommitId, registerExternalWriteReview, @@ -179,7 +171,6 @@ function CsvViewData({ fileId={fileId} filePath={filePath} fileRow={fileRow} - checkpointDiff={checkpointDiff} editorRevision={editorRevision} {...props} /> @@ -218,7 +209,6 @@ function CsvHistoricalViewData({ fileId, filePath, fileRow, - checkpointDiff, editorRevision, ...props }: Omit & { @@ -226,22 +216,17 @@ function CsvHistoricalViewData({ readonly fileRow?: CsvFileRow | undefined; readonly editorRevision: EditorRevisionState; }) { - const checkpointDiffFile = useMemo( - () => checkpointDiffFileForRevision(checkpointDiff, fileId, editorRevision), - [checkpointDiff, editorRevision, fileId], - ); const beforeSnapshot = useHistoricalFileSnapshot( fileId, - checkpointDiffFile ? null : editorRevision.beforeCommitId, + editorRevision.beforeCommitId, ); const afterSnapshot = useHistoricalFileSnapshot( fileId, - checkpointDiffFile ? null : editorRevision.afterCommitId, + editorRevision.afterCommitId, ); const historicalSnapshotsLoaded = - Boolean(checkpointDiffFile) || - ((!editorRevision.beforeCommitId || beforeSnapshot.loaded) && - (!editorRevision.afterCommitId || afterSnapshot.loaded)); + (!editorRevision.beforeCommitId || beforeSnapshot.loaded) && + (!editorRevision.afterCommitId || afterSnapshot.loaded); const historicalFile = useMemo( () => historicalSnapshotsLoaded @@ -250,14 +235,12 @@ function CsvHistoricalViewData({ filePath, fileRow, revision: editorRevision, - checkpointDiffFile, beforeSnapshot: beforeSnapshot.snapshot, afterSnapshot: afterSnapshot.snapshot, }) : null, [ beforeSnapshot.snapshot, - checkpointDiffFile, editorRevision, fileId, filePath, @@ -670,39 +653,17 @@ function visibleHistoricalSnapshot( }; } -function checkpointDiffFileForRevision( - checkpointDiff: CheckpointDiff | null | undefined, - fileId: string, - revision: EditorRevisionState, -): CheckpointDiffFile | null { - if (!checkpointDiff) return null; - return ( - checkpointDiff.files.find((file) => { - const afterCommitId = checkpointDiff.afterIsActiveHead - ? null - : file.afterCommitId; - return ( - file.fileId === fileId && - file.beforeCommitId === revision.beforeCommitId && - afterCommitId === revision.afterCommitId - ); - }) ?? null - ); -} - function buildHistoricalCsvFile(args: { readonly fileId: string; readonly filePath: string | undefined; readonly fileRow: CsvFileRow | undefined; readonly revision: EditorRevisionState; - readonly checkpointDiffFile: CheckpointDiffFile | null; readonly beforeSnapshot: HistoricalFileSnapshotRow | undefined; readonly afterSnapshot: HistoricalFileSnapshotRow | undefined; }): HistoricalCsvFile | null { const mode = editorRevisionMode(args.revision); if (mode === "editor") return null; const path = - args.checkpointDiffFile?.path ?? args.afterSnapshot?.path ?? args.beforeSnapshot?.path ?? args.fileRow?.path ?? @@ -710,11 +671,9 @@ function buildHistoricalCsvFile(args: { if (!path) return null; if (mode === "snapshot") { - const data = args.checkpointDiffFile - ? args.checkpointDiffFile.afterData - : args.afterSnapshot - ? decodeFileDataToBytes(args.afterSnapshot.data) - : null; + const data = args.afterSnapshot + ? decodeFileDataToBytes(args.afterSnapshot.data) + : null; if (!data) return null; return { fileRow: { @@ -728,20 +687,16 @@ function buildHistoricalCsvFile(args: { }; } - const beforeData = - args.checkpointDiffFile?.beforeData ?? - (args.beforeSnapshot - ? decodeFileDataToBytes(args.beforeSnapshot.data) - : EMPTY_FILE_DATA); - const afterData = - args.checkpointDiffFile?.afterData ?? - (args.revision.afterCommitId - ? args.afterSnapshot - ? decodeFileDataToBytes(args.afterSnapshot.data) - : EMPTY_FILE_DATA - : args.fileRow - ? decodeFileDataToBytes(args.fileRow.data) - : EMPTY_FILE_DATA); + 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: { @@ -752,14 +707,12 @@ function buildHistoricalCsvFile(args: { review: { fileId: args.fileId, path, - reviewId: - args.checkpointDiffFile?.reviewId ?? - editorRevisionReviewId({ - fileId: args.fileId, - path, - beforeCommitId: args.revision.beforeCommitId, - afterCommitId: args.revision.afterCommitId, - }), + reviewId: editorRevisionReviewId({ + fileId: args.fileId, + path, + beforeCommitId: args.revision.beforeCommitId, + afterCommitId: args.revision.afterCommitId, + }), beforeCommitId: args.revision.beforeCommitId ?? "", afterCommitId: args.revision.afterCommitId ?? "", agentTurnRangeIds: [], diff --git a/src/extensions/files/build-filesystem-tree.test.ts b/src/extensions/files/build-filesystem-tree.test.ts deleted file mode 100644 index 0f1fcdbc..00000000 --- a/src/extensions/files/build-filesystem-tree.test.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { - buildFilesystemTree, - type FilesystemTreeNode, -} from "./build-filesystem-tree.js"; -import type { FilesystemEntryRow } from "@/queries"; - -const baseEntries: FilesystemEntryRow[] = [ - { - id: "dir_docs", - parent_id: null, - path: "/docs/", - display_name: "docs", - kind: "directory", - }, - { - id: "dir_guides", - parent_id: "dir_docs", - path: "/docs/guides/", - display_name: "guides", - kind: "directory", - }, - { - id: "file_root", - parent_id: null, - path: "/README.md", - display_name: "README.md", - kind: "file", - }, - { - id: "file_nested", - parent_id: "dir_guides", - path: "/docs/guides/intro.md", - display_name: "intro.md", - kind: "file", - }, -]; - -describe("buildFilesystemTree", () => { - test("nests directories and files with stable ordering", () => { - const tree = buildFilesystemTree(baseEntries); - expect(tree).toHaveLength(2); - - const [docs, rootFile] = tree; - expect(docs.type).toBe("directory"); - if (docs.type === "directory") { - expect(docs.path).toBe("/docs/"); - expect(docs).not.toHaveProperty("hidden"); - expect(docs.children).toHaveLength(1); - const [guides] = docs.children; - expect(guides.type).toBe("directory"); - if (guides.type === "directory") { - expect(guides.children).toHaveLength(1); - const [nestedFile] = guides.children; - expect(nestedFile.type).toBe("file"); - expect(nestedFile.path).toBe("/docs/guides/intro.md"); - expect(nestedFile).not.toHaveProperty("hidden"); - } - } - - expect(rootFile.type).toBe("file"); - if (rootFile.type === "file") { - expect(rootFile.path).toBe("/README.md"); - expect(rootFile).not.toHaveProperty("hidden"); - } - }); - - test("omits paths with dot-prefixed segments", () => { - const tree = buildFilesystemTree([ - { - id: "file_visible", - parent_id: null, - path: "/visible.md", - display_name: "visible.md", - kind: "file", - }, - { - id: "dir_docs", - parent_id: null, - path: "/docs/", - display_name: "docs", - kind: "directory", - }, - { - id: "file_nested_visible", - parent_id: "dir_docs", - path: "/docs/visible.md", - display_name: "visible.md", - kind: "file", - }, - { - id: "file_dot", - parent_id: null, - path: "/.hidden.md", - display_name: ".hidden.md", - kind: "file", - }, - { - id: "dir_dot", - parent_id: null, - path: "/.lix/", - display_name: ".lix", - kind: "directory", - }, - { - id: "file_dot_child", - parent_id: "dir_dot", - path: "/.lix/config.json", - display_name: "config.json", - kind: "file", - }, - { - id: "dir_nested_dot", - parent_id: "dir_docs", - path: "/docs/.drafts/", - display_name: ".drafts", - kind: "directory", - }, - { - id: "file_nested_dot_child", - parent_id: "dir_nested_dot", - path: "/docs/.drafts/outline.md", - display_name: "outline.md", - kind: "file", - }, - ]); - - expect(collectPaths(tree)).toEqual([ - "/docs/", - "/docs/visible.md", - "/visible.md", - ]); - }); - - test("parents watched rows by path when parent ids do not match Lix ids", () => { - const tree = buildFilesystemTree([ - { - id: "dir_docs", - parent_id: null, - path: "/docs/", - display_name: "docs", - kind: "directory", - source: "lix", - }, - { - id: "watched:/docs/notes.txt", - parent_id: "watched:/docs/", - path: "/docs/notes.txt", - display_name: "notes.txt", - kind: "file", - source: "watched", - }, - ]); - - expect(collectPaths(tree)).toEqual(["/docs/", "/docs/notes.txt"]); - }); - - test("dedupes by normalized path with Lix rows winning", () => { - const tree = buildFilesystemTree([ - { - id: "watched:/docs/", - parent_id: null, - path: "/docs/", - display_name: "docs", - kind: "directory", - source: "watched", - }, - { - id: "watched:/docs/notes.txt", - parent_id: "watched:/docs/", - path: "/docs/notes.txt", - display_name: "notes.txt", - kind: "file", - source: "watched", - }, - { - id: "dir_docs", - parent_id: null, - path: "/docs", - display_name: "docs", - kind: "directory", - source: "lix", - }, - { - id: "file_notes", - parent_id: "dir_docs", - path: "/docs/notes.txt", - display_name: "notes.txt", - kind: "file", - source: "lix", - }, - ]); - - const [docs] = tree; - expect(docs).toMatchObject({ - type: "directory", - id: "dir_docs", - path: "/docs/", - source: "lix", - }); - if (docs?.type !== "directory") throw new Error("expected docs directory"); - expect(docs.children).toHaveLength(1); - expect(docs.children[0]).toMatchObject({ - type: "file", - id: "file_notes", - path: "/docs/notes.txt", - source: "lix", - }); - }); -}); - -function collectPaths(nodes: readonly FilesystemTreeNode[]): string[] { - const paths: string[] = []; - for (const node of nodes) { - paths.push(node.path); - if (node.type === "directory") { - paths.push(...collectPaths(node.children)); - } - } - return paths; -} diff --git a/src/extensions/files/build-filesystem-tree.ts b/src/extensions/files/build-filesystem-tree.ts deleted file mode 100644 index c634cfb2..00000000 --- a/src/extensions/files/build-filesystem-tree.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { FilesystemEntryRow } from "@/queries"; - -export type FilesystemTreeSource = "lix" | "watched" | "checkpoint-diff"; - -export type FilesystemTreeFile = { - type: "file"; - id: string; - name: string; - path: string; - source?: FilesystemTreeSource; -}; - -export type FilesystemTreeDirectory = { - type: "directory"; - id: string; - name: string; - path: string; - source?: FilesystemTreeSource; - children: FilesystemTreeNode[]; -}; - -export type FilesystemTreeNode = FilesystemTreeFile | FilesystemTreeDirectory; - -function sortChildren(nodes: FilesystemTreeNode[]): void { - nodes.sort((a, b) => { - if (a.type !== b.type) { - return a.type === "directory" ? -1 : 1; - } - return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); - }); - for (const node of nodes) { - if (node.type === "directory") { - sortChildren(node.children); - } - } -} - -function hasDotPrefixedSegment(path: string): boolean { - return path.split("/").some((segment) => segment.startsWith(".")); -} - -function normalizeDirectoryPath(path: string): string { - if (path === "/") return "/"; - return path.endsWith("/") ? path : `${path}/`; -} - -function normalizeFilePath(path: string): string { - return path.endsWith("/") ? path.slice(0, -1) : path; -} - -function parentDirectoryPath(path: string, kind: "directory" | "file") { - const normalized = - kind === "directory" - ? normalizeDirectoryPath(path) - : normalizeFilePath(path); - const segments = normalized.split("/").filter(Boolean); - segments.pop(); - if (segments.length === 0) return null; - return `/${segments.join("/")}/`; -} - -function normalizeEntryPath(entry: FilesystemEntryRow): string { - return entry.kind === "directory" - ? normalizeDirectoryPath(entry.path) - : normalizeFilePath(entry.path); -} - -function preferLixEntry( - existing: FilesystemEntryRow | undefined, - next: FilesystemEntryRow, -): boolean { - if (!existing) return true; - if ( - existing.source === "checkpoint-diff" && - next.source !== "checkpoint-diff" - ) { - return true; - } - if ( - next.source === "checkpoint-diff" && - existing.source !== "checkpoint-diff" - ) { - return false; - } - return existing.source === "watched" && next.source !== "watched"; -} - -function normalizeAndDedupeEntries( - entries: readonly FilesystemEntryRow[], -): FilesystemEntryRow[] { - const entriesByPath = new Map(); - for (const entry of entries) { - const path = normalizeEntryPath(entry); - if (hasDotPrefixedSegment(path)) continue; - const normalizedEntry = { - ...entry, - path, - source: entry.source ?? "lix", - } satisfies FilesystemEntryRow; - const existing = entriesByPath.get(path); - if (preferLixEntry(existing, normalizedEntry)) { - entriesByPath.set(path, normalizedEntry); - } - } - return [...entriesByPath.values()]; -} - -/** - * Builds a nested tree from flat filesystem entries. - * - * @example - * const tree = buildFilesystemTree(entries); - */ -export function buildFilesystemTree( - entries: readonly FilesystemEntryRow[], -): FilesystemTreeNode[] { - const normalizedEntries = normalizeAndDedupeEntries(entries); - const directories = new Map(); - const roots: FilesystemTreeNode[] = []; - - for (const entry of normalizedEntries) { - if (entry.kind !== "directory") continue; - const path = entry.path; - directories.set(path, { - type: "directory", - id: entry.id, - name: entry.display_name, - path, - source: entry.source, - children: [], - }); - } - - for (const entry of normalizedEntries) { - if (entry.kind !== "directory") continue; - const path = entry.path; - const node = directories.get(path); - if (!node) continue; - const parentPath = parentDirectoryPath(path, "directory"); - if (parentPath && directories.has(parentPath)) { - const parent = directories.get(parentPath)!; - parent.children.push(node); - } else { - roots.push(node); - } - } - - for (const entry of normalizedEntries) { - if (entry.kind !== "file") continue; - const path = entry.path; - const fileNode: FilesystemTreeFile = { - type: "file", - id: entry.id, - name: entry.display_name, - path, - source: entry.source, - }; - const parentPath = parentDirectoryPath(path, "file"); - if (parentPath && directories.has(parentPath)) { - const parent = directories.get(parentPath)!; - parent.children.push(fileNode); - } else { - roots.push(fileNode); - } - } - - sortChildren(roots); - return roots; -} diff --git a/src/extensions/files/file-tree.test.tsx b/src/extensions/files/file-tree.test.tsx deleted file mode 100644 index 35a70cd8..00000000 --- a/src/extensions/files/file-tree.test.tsx +++ /dev/null @@ -1,531 +0,0 @@ -import { fireEvent, render, waitFor } from "@testing-library/react"; -import { describe, expect, test, vi } from "vitest"; -import type { FilesystemTreeNode } from "@/extensions/files/build-filesystem-tree"; -import { FileTree } from "./file-tree"; - -describe("FileTree", () => { - test("renders directories and files", () => { - const { container } = render(); - - expect(getTreeItem(container, "docs/")).toHaveTextContent("docs"); - expect(queryTreeItem(container, "docs/guides/")).toBeNull(); - expect(queryTreeItem(container, "docs/guides/writing-style.md")).toBeNull(); - }); - - test("starts directories collapsed", () => { - const { container } = render(); - - const docsToggle = getTreeItem(container, "docs/"); - expect(docsToggle).toHaveAttribute("aria-expanded", "false"); - expect(queryTreeItem(container, "docs/README.md")).toBeNull(); - }); - - test("expands and collapses directories", async () => { - const { container } = render(); - - const docsToggle = getTreeItem(container, "docs/"); - fireEvent.click(docsToggle); - - await waitFor(() => { - expect(getTreeItem(container, "docs/guides/")).toHaveAttribute( - "aria-label", - "guides", - ); - }); - - fireEvent.click(docsToggle); - await waitFor(() => { - expect(queryTreeItem(container, "docs/guides/")).toBeNull(); - }); - }); - - test("supports controlled opened directories", async () => { - const handleOpenDirectoriesChange = vi.fn(); - const { container, rerender } = render( - ()} - onOpenDirectoriesChange={handleOpenDirectoriesChange} - />, - ); - - fireEvent.click(getTreeItem(container, "docs/")); - - expect(handleOpenDirectoriesChange).toHaveBeenCalledWith( - new Set(["/docs"]), - ); - expect(queryTreeItem(container, "docs/guides/")).toBeNull(); - - rerender( - , - ); - await waitFor(() => { - expect(getTreeItem(container, "docs/guides/")).toHaveAttribute( - "aria-label", - "guides", - ); - }); - }); - - test("preserves opened directories when the tree data refreshes", async () => { - const { container, rerender } = render(); - - fireEvent.click(getTreeItem(container, "docs/")); - await waitFor(() => { - expect(getTreeItem(container, "docs/guides/")).toBeInTheDocument(); - }); - fireEvent.click(getTreeItem(container, "docs/guides/")); - - await waitFor(() => { - expect( - getTreeItem(container, "docs/guides/writing-style.md"), - ).toBeInTheDocument(); - }); - - rerender(); - - await waitFor(() => { - expect(getTreeItem(container, "docs/guides/")).toBeInTheDocument(); - expect( - getTreeItem(container, "docs/guides/external.md"), - ).toBeInTheDocument(); - }); - }); - - test("preserves collapsed directories when the tree data refreshes", () => { - const { container, rerender } = render(); - - expect(queryTreeItem(container, "docs/guides/")).toBeNull(); - - rerender(); - - expect(queryTreeItem(container, "docs/guides/")).toBeNull(); - expect(queryTreeItem(container, "docs/guides/external.md")).toBeNull(); - expect(getTreeItem(container, "docs/")).toHaveAttribute( - "aria-expanded", - "false", - ); - }); - - test("reports controlled open directory changes", () => { - const handleOpenDirectoriesChange = vi.fn(); - const { container, rerender } = render( - , - ); - - fireEvent.click(getTreeItem(container, "docs/")); - expect([...handleOpenDirectoriesChange.mock.calls.at(-1)![0]]).toEqual([ - "/docs", - ]); - - rerender( - , - ); - fireEvent.click(getTreeItem(container, "docs/")); - expect([...handleOpenDirectoriesChange.mock.calls.at(-1)![0]]).toEqual([ - "/docs/guides", - ]); - }); - - test("invokes openFileView when a file is selected", () => { - const nodes: FilesystemTreeNode[] = [ - { - type: "file", - id: "file-readme", - name: "README.md", - path: "/README.md", - }, - ]; - - const handleOpen = vi.fn(); - const { container } = render( - , - ); - - fireEvent.click(getTreeItem(container, "README.md")); - - expect(handleOpen).toHaveBeenCalledWith("file-readme", "/README.md"); - }); - - test("commits native renames for lix-backed files", async () => { - const nodes: FilesystemTreeNode[] = [ - { - type: "file", - id: "file-readme", - name: "README.md", - path: "/README.md", - source: "lix", - }, - ]; - const handleRenameCommit = vi.fn(); - const { container } = render( - , - ); - - const input = await startTreeRename(container, "README.md"); - expect(input.value).toBe("README.md"); - - fireEvent.input(input, { target: { value: "notes.md" } }); - fireEvent.keyDown(input, { key: "Enter" }); - - await waitFor(() => { - expect(handleRenameCommit).toHaveBeenCalledWith({ - destinationPath: "/notes.md", - id: "file-readme", - kind: "file", - source: "lix", - sourcePath: "/README.md", - }); - }); - }); - - test("commits native renames for watched-only files", async () => { - const nodes: FilesystemTreeNode[] = [ - { - type: "file", - id: "watched:/README.md", - name: "README.md", - path: "/README.md", - source: "watched", - }, - ]; - const handleRenameCommit = vi.fn(); - const { container } = render( - , - ); - - const input = await startTreeRename(container, "README.md"); - expect(input.value).toBe("README.md"); - - fireEvent.input(input, { target: { value: "notes.md" } }); - fireEvent.keyDown(input, { key: "Enter" }); - - await waitFor(() => { - expect(handleRenameCommit).toHaveBeenCalledWith({ - destinationPath: "/notes.md", - id: "watched:/README.md", - kind: "file", - source: "watched", - sourcePath: "/README.md", - }); - }); - }); - - test("does not start native renames for watched-only directories", async () => { - const nodes: FilesystemTreeNode[] = [ - { - type: "directory", - id: "watched:/docs/", - name: "docs", - path: "/docs/", - source: "watched", - children: [], - }, - ]; - const handleRenameCommit = vi.fn(); - const { container } = render( - , - ); - - fireEvent.click(getTreeItem(container, "docs/")); - fireEvent.keyDown( - getTreeRoot(container).activeElement ?? getTreeHost(container), - { - key: "F2", - }, - ); - - await waitFor(() => { - expect(queryTreeRenameInput(container)).toBeNull(); - }); - expect(handleRenameCommit).not.toHaveBeenCalled(); - }); - - test("keeps focus state on file tree rows instead of filename labels", async () => { - const { container } = render(); - fireEvent.click(getTreeItem(container, "docs/")); - - const fileRow = await waitFor(() => - getTreeItem(container, "docs/README.md"), - ); - const fileName = fileRow.querySelector("[data-item-section='content']"); - - expect(fileRow).toHaveAttribute("data-type", "item"); - expect(fileRow).toHaveAttribute("role", "treeitem"); - expect(fileName).not.toHaveAttribute("tabindex"); - }); - - test("renders percent text literally instead of URI-decoding filenames", () => { - const { container } = render( - , - ); - - expect(getTreeItem(container, "%61.md")).toHaveAttribute( - "aria-label", - "%61.md", - ); - expect(getTreeRoot(container)).not.toHaveTextContent("a.md"); - }); - - test("dims the selected file when the files panel is not focused", () => { - const nodes: FilesystemTreeNode[] = [ - { - type: "file", - id: "file-readme", - name: "README.md", - path: "/README.md", - }, - ]; - - const { container, rerender } = render( - , - ); - const host = getTreeHost(container); - expect(getTreeItem(container, "README.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - expect(host.style.getPropertyValue("--trees-selected-bg-override")).toBe( - "var(--color-bg-selection-current)", - ); - - rerender( - , - ); - expect(host.style.getPropertyValue("--trees-selected-bg-override")).toBe( - "var(--color-bg-hover)", - ); - }); - - test("marks pending review files with the tree status lane", async () => { - const nodes: FilesystemTreeNode[] = [ - { - type: "directory", - id: "dir-docs", - name: "docs", - path: "/docs/", - children: [ - { - type: "file", - id: "file-review", - name: "review.md", - path: "/docs/review.md", - }, - { - type: "file", - id: "file-clean", - name: "clean.md", - path: "/docs/clean.md", - }, - ], - }, - ]; - - const { container, rerender } = render( - , - ); - - expect(getTreeItem(container, "docs/review.md")).not.toHaveAttribute( - "data-item-git-status", - ); - - rerender( - , - ); - - await waitFor(() => { - expect(getTreeItem(container, "docs/review.md")).toHaveAttribute( - "data-item-git-status", - "modified", - ); - }); - expect(getTreeItem(container, "docs/")).toHaveAttribute( - "data-item-contains-git-change", - "true", - ); - expect(getTreeItem(container, "docs/clean.md")).not.toHaveAttribute( - "data-item-git-status", - ); - expect( - getTreeItem(container, "docs/review.md").querySelector( - "[data-item-section='git']", - ), - ).toHaveTextContent("M"); - expect( - getTreeHost(container).style.getPropertyValue( - "--trees-git-modified-color-override", - ), - ).toBe("var(--color-warning-600)"); - }); -}); - -function getTreeHost(container: HTMLElement): HTMLElement { - const host = container.querySelector("file-tree-container"); - if (!(host instanceof HTMLElement)) { - throw new Error("file tree host not found"); - } - return host; -} - -function getTreeRoot(container: HTMLElement): ShadowRoot { - const root = getTreeHost(container).shadowRoot; - if (!root) { - throw new Error("file tree shadow root not found"); - } - return root; -} - -function getTreeItem(container: HTMLElement, path: string): HTMLElement { - const item = queryTreeItem(container, path); - if (!item) { - const renderedPaths = [ - ...getTreeRoot(container).querySelectorAll("[data-type='item']"), - ] - .map((element) => element.getAttribute("data-item-path")) - .join(", "); - throw new Error( - `file tree item not found: ${path}; rendered: ${renderedPaths}`, - ); - } - return item; -} - -function queryTreeItem( - container: HTMLElement, - path: string, -): HTMLElement | null { - return getTreeRoot(container).querySelector( - `[data-type='item'][data-item-path='${CSS.escape(path)}']`, - ); -} - -function queryTreeRenameInput(container: HTMLElement): HTMLInputElement | null { - const input = getTreeRoot(container).querySelector( - "[data-item-rename-input]", - ); - return input instanceof HTMLInputElement ? input : null; -} - -async function startTreeRename( - container: HTMLElement, - path: string, -): Promise { - const item = getTreeItem(container, path); - fireEvent.click(item); - await waitFor(() => { - expect(getTreeRoot(container).activeElement).toBe(item); - }); - fireEvent.keyDown(item, { key: "F2" }); - return waitFor(() => { - const input = queryTreeRenameInput(container); - if (!input) { - throw new Error("file tree rename input not found"); - } - return input; - }); -} - -const mockTree: FilesystemTreeNode[] = [ - { - type: "directory", - id: "dir-docs", - name: "docs", - path: "/docs", - children: [ - { - type: "directory", - id: "dir-guides", - name: "guides", - path: "/docs/guides", - children: [ - { - type: "file", - id: "file-writing", - name: "writing-style.md", - path: "/docs/guides/writing-style.md", - }, - ], - }, - { - type: "file", - id: "file-readme", - name: "README.md", - path: "/docs/README.md", - }, - ], - }, -]; - -const mockTreeWithExternalFile: FilesystemTreeNode[] = [ - { - type: "directory", - id: "dir-docs", - name: "docs", - path: "/docs", - children: [ - { - type: "directory", - id: "dir-guides", - name: "guides", - path: "/docs/guides", - children: [ - { - type: "file", - id: "file-external", - name: "external.md", - path: "/docs/guides/external.md", - }, - { - type: "file", - id: "file-writing", - name: "writing-style.md", - path: "/docs/guides/writing-style.md", - }, - ], - }, - { - type: "file", - id: "file-readme", - name: "README.md", - path: "/docs/README.md", - }, - ], - }, -]; diff --git a/src/extensions/files/file-tree.tsx b/src/extensions/files/file-tree.tsx deleted file mode 100644 index e128a74b..00000000 --- a/src/extensions/files/file-tree.tsx +++ /dev/null @@ -1,806 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { CSSProperties } from "react"; -import { FileTree as PierreFileTree, useFileTree } from "@pierre/trees/react"; -import type { - FileTreeDirectoryHandle, - FileTree as PierreFileTreeModel, - FileTreeItemHandle, - FileTreeRenameEvent, - FileTreeRenamingItem, - GitStatusEntry, -} from "@pierre/trees"; -import type { - FilesystemTreeNode, - FilesystemTreeSource, -} from "@/extensions/files/build-filesystem-tree"; - -export type FileTreeCreateRequest = { - readonly id: number; - readonly kind: "file" | "directory"; - readonly directoryPath: string; - readonly initialValue: string; -}; - -export type FileTreeRenameRequest = { - readonly id?: string; - readonly kind: "file" | "directory"; - readonly source: FilesystemTreeSource; - readonly sourcePath: string; - readonly destinationPath: string; -}; - -export type FileTreeProps = { - readonly nodes?: FilesystemTreeNode[]; - readonly openFileView?: ( - fileId: string, - path: string, - ) => Promise | void; - readonly createRequest?: FileTreeCreateRequest | null; - readonly selectedPath?: string; - readonly isPanelFocused?: boolean; - readonly onSelectItem?: ( - path: string, - kind: "file" | "directory", - source?: FilesystemTreeSource, - ) => void; - readonly openDirectories?: ReadonlySet; - readonly reviewPaths?: ReadonlySet; - readonly reviewStatuses?: ReadonlyMap; - readonly onOpenDirectoriesChange?: (paths: ReadonlySet) => void; - readonly onCreateCommit?: ( - request: FileTreeCreateRequest, - value: string, - ) => Promise | void; - readonly onCreateCancel?: (request: FileTreeCreateRequest) => void; - readonly onRenameCommit?: ( - request: FileTreeRenameRequest, - ) => Promise | void; -}; - -type ReviewGitStatus = GitStatusEntry["status"] | "recreated"; - -type ReviewGitStatusEntry = { - readonly path: string; - readonly status: ReviewGitStatus; -}; - -type TreePathInfo = { - readonly appPath: string; - readonly kind: "file" | "directory"; - readonly id?: string; - readonly createRequestId?: number; - readonly source?: FilesystemTreeSource; -}; - -type TreeInput = { - readonly paths: string[]; - readonly pathInfoByTreePath: Map; - readonly directoryTreePaths: string[]; - readonly realDirectoryTreePaths: string[]; - readonly createPlaceholderTreePath: string | null; -}; - -const FILE_TREE_UNSAFE_CSS = ` - [data-item-section='spacing-item'] { - border-left-color: transparent; - opacity: 0; - } - - [data-type='item'][data-item-type='folder'] > [data-item-section='icon'] { - color: var(--color-icon-secondary); - } - - [data-type='item'][data-item-type='folder'] - > [data-item-section='icon'] - > [data-icon-name='file-tree-icon-chevron'] { - display: none; - } - - [data-type='item'][data-item-type='folder'] - > [data-item-section='icon']::before { - content: ""; - display: block; - width: var(--trees-icon-width); - height: var(--trees-icon-width); - background-color: currentColor; - -webkit-mask: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat; - mask: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat; - } - - [data-type='item'][data-item-type='folder'][aria-expanded='true'] - > [data-item-section='icon']::before { - -webkit-mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m6 14 1.5-2.9A2 2 0 0 1 9.24 9H20a2 2 0 0 1 1.74 3l-3.2 5.9A2 2 0 0 1 16.8 19H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v1' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - mask-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m6 14 1.5-2.9A2 2 0 0 1 9.24 9H20a2 2 0 0 1 1.74 3l-3.2 5.9A2 2 0 0 1 16.8 19H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H18a2 2 0 0 1 2 2v1' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - } - - [data-type='item'][data-item-type='file'] > [data-item-section='icon'] { - color: var(--color-icon-secondary); - } - - [data-type='item'][data-item-type='file'] - > [data-item-section='icon'] - > [data-icon-name='file-tree-icon-file'] { - display: none; - } - - [data-type='item'][data-item-type='file'] - > [data-item-section='icon']::before { - content: ""; - display: block; - width: var(--trees-icon-width); - height: var(--trees-icon-width); - background-color: currentColor; - -webkit-mask: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat; - mask: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat; - } - - [data-item-git-status='modified'] > [data-item-section='icon'] - > :where(:not([data-icon-name='file-tree-icon-chevron'])), - [data-item-git-status='modified'] > [data-item-section='content'] { - color: inherit; - } - - [data-item-git-status='modified'] > [data-item-section='git'] { - color: var(--color-warning-600); - font-size: 0; - } - - [data-item-git-status='modified'] > [data-item-section='git'] > span { - width: 6px; - height: 6px; - border-radius: 999px; - background: currentColor; - } - - [data-item-git-status='recreated'] { - --trees-item-git-status-color: var(--trees-git-renamed-color); - } - - [data-item-git-status='recreated'] > [data-item-section='git']::before { - content: "R"; - font-size: var(--trees-font-size); - font-weight: var(--trees-font-weight-semibold); - } - - [data-item-contains-git-change='true'] > [data-item-section='git'] { - color: var(--color-warning-600); - opacity: 0.75; - } - - [data-type='item'][data-item-selected='true'][data-item-type='folder'] - > [data-item-section='icon'] { - color: var(--color-icon-selection-current); - } - - [data-type='item'][data-item-selected='true'][data-item-type='file'] - > [data-item-section='icon'] { - color: var(--color-icon-selection-current); - } - - [data-item-rename-input] { - height: calc(var(--trees-row-height) - 6px); - border: 1px solid var(--color-border-selection-current); - border-radius: 6px; - background: var(--color-bg-panel); - box-shadow: - 0 0 0 2px var(--color-bg-selection-current), - inset 0 1px 0 rgba(255, 255, 255, 0.72); - color: var(--color-text-primary); - caret-color: var(--color-icon-selection-current); - padding-inline: 5px; - } - - [data-item-rename-input]::selection { - background: var(--color-border-selection-current); - color: var(--color-text-primary); - } -`; - -/** - * Adapter between Flashtype's workspace-path model and @pierre/trees. - * - * @example - * console.log(id)} /> - */ -export function FileTree({ - nodes = [], - openFileView, - createRequest, - selectedPath, - isPanelFocused = false, - onSelectItem, - openDirectories, - reviewPaths, - reviewStatuses, - onOpenDirectoriesChange, - onCreateCommit, - onCreateCancel, - onRenameCommit, -}: FileTreeProps) { - const [internalOpenDirectories, setInternalOpenDirectories] = useState( - () => new Set(), - ); - const resolvedOpenDirectories = openDirectories ?? internalOpenDirectories; - const treeInput = useMemo( - () => buildTreeInput(nodes, createRequest), - [nodes, createRequest], - ); - const treePathsKey = useMemo( - () => treeInput.paths.join("\0"), - [treeInput.paths], - ); - const openDirectoryTreePaths = useMemo(() => { - const next = new Set( - [...resolvedOpenDirectories].map(appDirectoryPathToTreePath), - ); - if (createRequest) { - const parentTreePath = appDirectoryPathToTreePath( - createRequest.directoryPath, - ); - if (parentTreePath) { - next.add(parentTreePath); - } - } - return next; - }, [createRequest, resolvedOpenDirectories]); - const openDirectoryTreePathsKey = useMemo( - () => [...openDirectoryTreePaths].sort().join("\0"), - [openDirectoryTreePaths], - ); - const reviewGitStatusEntries = useMemo( - () => buildReviewGitStatusEntries(reviewPaths, reviewStatuses, treeInput), - [reviewPaths, reviewStatuses, treeInput], - ); - const reviewGitStatusKey = useMemo( - () => - reviewGitStatusEntries - .map((entry) => `${entry.path}:${entry.status}`) - .join("\0"), - [reviewGitStatusEntries], - ); - const selectedTreePath = selectedPath - ? appPathToTreePath(selectedPath, selectedPath.endsWith("/")) - : null; - - const stateRef = useRef({ - createRequest, - openDirectories, - openFileView, - onCreateCancel, - onCreateCommit, - onOpenDirectoriesChange, - onSelectItem, - onRenameCommit, - pathInfoByTreePath: treeInput.pathInfoByTreePath, - realDirectoryTreePaths: treeInput.realDirectoryTreePaths, - setInternalOpenDirectories, - }); - stateRef.current = { - createRequest, - openDirectories, - openFileView, - onCreateCancel, - onCreateCommit, - onOpenDirectoriesChange, - onSelectItem, - onRenameCommit, - pathInfoByTreePath: treeInput.pathInfoByTreePath, - realDirectoryTreePaths: treeInput.realDirectoryTreePaths, - setInternalOpenDirectories, - }; - - const modelRef = useRef(null); - const suppressSelectionOpenRef = useRef(false); - const suppressSelectionOpenForClickRef = useRef(false); - const handleSelectionChangeRef = useRef( - (_selectedTreePaths: readonly string[]) => {}, - ); - const handleRenameRef = useRef((_event: FileTreeRenameEvent) => {}); - const handleCanRenameRef = useRef( - (_item: FileTreeRenamingItem): boolean => false, - ); - - handleSelectionChangeRef.current = (selectedTreePaths) => { - const latestTreePath = selectedTreePaths.at(-1); - if (!latestTreePath) return; - const model = modelRef.current; - if (model) { - for (const treePath of selectedTreePaths) { - if (treePath !== latestTreePath) { - model.getItem(treePath)?.deselect(); - } - } - } - const info = pathInfoForTreePath( - stateRef.current.pathInfoByTreePath, - latestTreePath, - ); - if (info) { - stateRef.current.onSelectItem?.(info.appPath, info.kind, info.source); - if ( - !suppressSelectionOpenRef.current && - !suppressSelectionOpenForClickRef.current && - info.kind === "file" && - info.id - ) { - void stateRef.current.openFileView?.(info.id, info.appPath); - } - } - }; - - handleCanRenameRef.current = (item) => { - const request = stateRef.current.createRequest; - const info = pathInfoForTreePath( - stateRef.current.pathInfoByTreePath, - item.path, - ); - if (!info) return false; - if (item.isFolder !== (info.kind === "directory")) return false; - if (request) { - return info.createRequestId === request.id; - } - if (info.createRequestId != null) return false; - if (info.source === "watched") { - return info.kind === "file"; - } - if (info.source === "checkpoint-diff") { - return false; - } - return true; - }; - - handleRenameRef.current = (event) => { - const request = stateRef.current.createRequest; - const sourceInfo = pathInfoForTreePath( - stateRef.current.pathInfoByTreePath, - event.sourcePath, - ); - if (!sourceInfo) return; - if (request && sourceInfo.createRequestId === request.id) { - void stateRef.current.onCreateCommit?.( - request, - leafNameFromTreePath(event.destinationPath), - ); - return; - } - if (request) return; - if (sourceInfo.createRequestId != null) { - return; - } - if (sourceInfo.source === "watched" && sourceInfo.kind !== "file") { - return; - } - if (sourceInfo.source === "checkpoint-diff") { - return; - } - void stateRef.current.onRenameCommit?.({ - destinationPath: - sourceInfo.kind === "directory" - ? treeDirectoryPathToAppPath(event.destinationPath) - : treeFilePathToAppPath(event.destinationPath), - id: sourceInfo.id, - kind: sourceInfo.kind, - source: sourceInfo.source ?? "lix", - sourcePath: sourceInfo.appPath, - }); - }; - - const handleTreeClickCapture = useCallback(() => { - suppressSelectionOpenForClickRef.current = true; - window.setTimeout(() => { - suppressSelectionOpenForClickRef.current = false; - }, 0); - }, []); - - const openFileFromTreeEvent = useCallback((event: Event) => { - const treePath = treePathFromComposedEvent(event); - if (!treePath) return; - const info = pathInfoForTreePath( - stateRef.current.pathInfoByTreePath, - treePath, - ); - if (info?.kind !== "file" || !info.id) return; - stateRef.current.onSelectItem?.(info.appPath, info.kind, info.source); - void stateRef.current.openFileView?.(info.id, info.appPath); - }, []); - - const { model } = useFileTree({ - dragAndDrop: false, - flattenEmptyDirectories: false, - icons: { set: "minimal", colored: false }, - gitStatus: reviewGitStatusEntries as GitStatusEntry[], - initialExpansion: "closed", - itemHeight: 28, - onSelectionChange: (paths) => handleSelectionChangeRef.current(paths), - paths: [], - renaming: { - canRename: (item) => handleCanRenameRef.current(item), - onError: (error) => console.warn("File tree rename failed", error), - onRename: (event) => handleRenameRef.current(event), - }, - stickyFolders: false, - unsafeCSS: FILE_TREE_UNSAFE_CSS, - }); - modelRef.current = model; - - useEffect(() => { - model.resetPaths(treeInput.paths, { - initialExpandedPaths: [...openDirectoryTreePaths], - }); - }, [model, treeInput.paths, treePathsKey]); - - useEffect(() => { - model.setGitStatus(reviewGitStatusEntries as GitStatusEntry[]); - }, [model, reviewGitStatusEntries, reviewGitStatusKey]); - - useEffect(() => { - for (const directoryTreePath of treeInput.directoryTreePaths) { - const item = toDirectoryHandle(model.getItem(directoryTreePath)); - if (!item) continue; - const shouldBeOpen = openDirectoryTreePaths.has(directoryTreePath); - if (shouldBeOpen && !item.isExpanded()) { - item.expand(); - } else if (!shouldBeOpen && item.isExpanded()) { - item.collapse(); - } - } - }, [ - model, - openDirectoryTreePaths, - openDirectoryTreePathsKey, - treeInput.directoryTreePaths, - treePathsKey, - ]); - - useEffect(() => { - for (const treePath of model.getSelectedPaths()) { - if (treePath !== selectedTreePath) { - model.getItem(treePath)?.deselect(); - } - } - if (selectedTreePath && model.getItem(selectedTreePath)) { - suppressSelectionOpenRef.current = true; - try { - model.getItem(selectedTreePath)?.select(); - model.focusPath(selectedTreePath); - } finally { - suppressSelectionOpenRef.current = false; - } - } - }, [model, selectedTreePath, treePathsKey]); - - useEffect(() => { - if (!createRequest || !treeInput.createPlaceholderTreePath) return; - const item = model.getItem(treeInput.createPlaceholderTreePath); - if (!item) return; - model.focusPath(treeInput.createPlaceholderTreePath); - model.startRenaming(treeInput.createPlaceholderTreePath, { - removeIfCanceled: true, - }); - }, [ - createRequest?.id, - model, - treeInput.createPlaceholderTreePath, - treePathsKey, - ]); - - useEffect(() => { - return model.onMutation("remove", (event) => { - const request = stateRef.current.createRequest; - if (!request) return; - const info = pathInfoForTreePath( - stateRef.current.pathInfoByTreePath, - event.path, - ); - if (info?.createRequestId === request.id) { - stateRef.current.onCreateCancel?.(request); - } - }); - }, [model]); - - useEffect(() => { - return model.subscribe(() => { - const next = readExpandedAppDirectoryPaths(model, stateRef.current); - const { openDirectories: controlledOpenDirectories } = stateRef.current; - if (controlledOpenDirectories) { - if (!sameDirectorySet(next, controlledOpenDirectories)) { - stateRef.current.onOpenDirectoriesChange?.(next); - } - return; - } - stateRef.current.setInternalOpenDirectories((prev) => - sameDirectorySet(prev, next) ? prev : next, - ); - }); - }, [model]); - - if (treeInput.paths.length === 0) { - // The "New file" row above the tree is the affordance; no extra copy. - return null; - } - - return ( - openFileFromTreeEvent(event.nativeEvent)} - onClickCapture={handleTreeClickCapture} - style={treeHostStyle(isPanelFocused)} - /> - ); -} - -function buildTreeInput( - nodes: readonly FilesystemTreeNode[], - createRequest: FileTreeCreateRequest | null | undefined, -): TreeInput { - const pathInfoByTreePath = new Map(); - const paths: string[] = []; - const directoryTreePaths: string[] = []; - const realDirectoryTreePaths: string[] = []; - - const addPath = (treePath: string, info: TreePathInfo) => { - if (!pathInfoByTreePath.has(treePath)) { - paths.push(treePath); - if (info.kind === "directory") { - directoryTreePaths.push(treePath); - if (info.createRequestId == null) { - realDirectoryTreePaths.push(treePath); - } - } - } - pathInfoByTreePath.set(treePath, info); - }; - - const visit = (node: FilesystemTreeNode) => { - if (node.type === "directory") { - const treePath = appPathToTreePath(node.path, true); - addPath(treePath, { - appPath: node.path, - id: node.id, - kind: "directory", - source: node.source, - }); - for (const child of node.children) { - visit(child); - } - return; - } - const treePath = appPathToTreePath(node.path, false); - addPath(treePath, { - appPath: node.path, - id: node.id, - kind: "file", - source: node.source, - }); - }; - - for (const node of nodes) { - visit(node); - } - - let createPlaceholderTreePath: string | null = null; - if (createRequest) { - const placeholder = uniqueCreatePlaceholderPath( - createRequest, - pathInfoByTreePath, - ); - createPlaceholderTreePath = placeholder.treePath; - addPath(placeholder.treePath, { - appPath: placeholder.appPath, - createRequestId: createRequest.id, - kind: createRequest.kind, - }); - } - - return { - createPlaceholderTreePath, - directoryTreePaths, - pathInfoByTreePath, - paths, - realDirectoryTreePaths, - }; -} - -function buildReviewGitStatusEntries( - reviewPaths: ReadonlySet | undefined, - reviewStatuses: ReadonlyMap | undefined, - treeInput: TreeInput, -): ReviewGitStatusEntry[] { - if ( - (!reviewPaths || reviewPaths.size === 0) && - (!reviewStatuses || reviewStatuses.size === 0) - ) { - return []; - } - const entries: ReviewGitStatusEntry[] = []; - for (const [appPath, status] of reviewStatuses ?? []) { - const treePath = appPathToTreePath(appPath, false); - const info = treeInput.pathInfoByTreePath.get(treePath); - if (!info || info.kind !== "file" || info.createRequestId != null) { - continue; - } - entries.push({ path: treePath, status }); - } - for (const appPath of reviewPaths ?? []) { - const treePath = appPathToTreePath(appPath, false); - const info = treeInput.pathInfoByTreePath.get(treePath); - if (!info || info.kind !== "file" || info.createRequestId != null) { - continue; - } - entries.push({ path: treePath, status: "modified" }); - } - return entries.sort((left, right) => left.path.localeCompare(right.path)); -} - -function uniqueCreatePlaceholderPath( - request: FileTreeCreateRequest, - pathInfoByTreePath: ReadonlyMap, -): { appPath: string; treePath: string } { - let suffix = 1; - while (suffix < 1000) { - const value = - suffix === 1 ? request.initialValue : `${request.initialValue}-${suffix}`; - const appPath = childAppPath(request.directoryPath, value, request.kind); - const treePath = appPathToTreePath(appPath, request.kind === "directory"); - if (!pathInfoByTreePath.has(treePath)) { - return { appPath, treePath }; - } - suffix += 1; - } - const fallback = childAppPath( - request.directoryPath, - `${request.initialValue}-${request.id}`, - request.kind, - ); - return { - appPath: fallback, - treePath: appPathToTreePath(fallback, request.kind === "directory"), - }; -} - -function readExpandedAppDirectoryPaths( - model: PierreFileTreeModel, - state: { - readonly pathInfoByTreePath: ReadonlyMap; - readonly realDirectoryTreePaths: readonly string[]; - }, -): Set { - const next = new Set(); - for (const treePath of state.realDirectoryTreePaths) { - const item = toDirectoryHandle(model.getItem(treePath)); - if (!item?.isExpanded()) continue; - const info = state.pathInfoByTreePath.get(treePath); - next.add(info?.appPath ?? treeDirectoryPathToAppPath(treePath)); - } - return next; -} - -function toDirectoryHandle( - item: FileTreeItemHandle | null | undefined, -): FileTreeDirectoryHandle | null { - if (!item || !item.isDirectory()) return null; - return item as FileTreeDirectoryHandle; -} - -function treePathFromComposedEvent(event: Event): string | null { - for (const target of event.composedPath()) { - if (!(target instanceof Element)) continue; - const item = target.closest("[data-type='item'][data-item-path]"); - const path = item?.getAttribute("data-item-path"); - if (path) return path; - } - return null; -} - -function pathInfoForTreePath( - pathInfoByTreePath: ReadonlyMap, - treePath: string, -): TreePathInfo | undefined { - const exact = pathInfoByTreePath.get(treePath); - if (exact) return exact; - const alternate = treePath.endsWith("/") - ? treePath.slice(0, -1) - : `${treePath}/`; - return pathInfoByTreePath.get(alternate); -} - -function treeHostStyle(isPanelFocused: boolean) { - return { - "--trees-bg-override": "transparent", - "--trees-bg-muted-override": "var(--color-bg-hover)", - "--trees-border-color-override": "transparent", - "--trees-border-radius-override": "7px", - "--trees-fg-muted-override": "var(--color-text-tertiary)", - "--trees-fg-override": "var(--color-text-secondary)", - "--trees-focus-ring-color-override": "var(--color-ring-focus-visible)", - "--trees-font-family-override": "inherit", - "--trees-font-size-override": "12px", - "--trees-git-modified-color-override": "var(--color-warning-600)", - "--trees-icon-width-override": "13px", - "--trees-input-bg-override": "transparent", - "--trees-item-margin-x-override": "0px", - "--trees-item-padding-x-override": "9px", - "--trees-level-gap-override": "7px", - "--trees-padding-inline-override": "0px", - "--trees-scrollbar-gutter-override": "0px", - "--trees-selected-bg-override": isPanelFocused - ? "var(--color-bg-selection-current)" - : "var(--color-bg-hover)", - "--trees-selected-focused-border-color-override": isPanelFocused - ? "var(--color-border-selection-current)" - : "transparent", - "--trees-selected-fg-override": isPanelFocused - ? "var(--color-text-primary)" - : "var(--color-text-secondary)", - height: "100%", - minHeight: 0, - width: "100%", - } as CSSProperties; -} - -function appPathToTreePath(path: string, isDirectory: boolean): string { - if (path === "/") return ""; - const withoutLeadingSlash = path.startsWith("/") ? path.slice(1) : path; - const withoutDirectorySlash = withoutLeadingSlash.endsWith("/") - ? withoutLeadingSlash.slice(0, -1) - : withoutLeadingSlash; - return isDirectory ? `${withoutDirectorySlash}/` : withoutDirectorySlash; -} - -function appDirectoryPathToTreePath(path: string): string { - return appPathToTreePath(path, true); -} - -function treeDirectoryPathToAppPath(path: string): string { - if (!path) return "/"; - return `/${path.endsWith("/") ? path : `${path}/`}`; -} - -function treeFilePathToAppPath(path: string): string { - return path.startsWith("/") ? path : `/${path}`; -} - -function childAppPath( - directoryPath: string, - name: string, - kind: "file" | "directory", -): string { - const directory = - directoryPath === "/" ? "/" : ensureDirectoryPath(directoryPath); - const childPath = `${directory}${name.replaceAll("/", "")}`; - return kind === "directory" ? ensureDirectoryPath(childPath) : childPath; -} - -function leafNameFromTreePath(path: string): string { - const withoutDirectorySlash = path.endsWith("/") ? path.slice(0, -1) : path; - const slashIndex = withoutDirectorySlash.lastIndexOf("/"); - return slashIndex === -1 - ? withoutDirectorySlash - : withoutDirectorySlash.slice(slashIndex + 1); -} - -function sameDirectorySet( - left: ReadonlySet, - right: ReadonlySet, -): boolean { - if (left.size !== right.size) return false; - const normalizedRight = new Set([...right].map(normalizeDirectoryForCompare)); - for (const path of left) { - if (!normalizedRight.has(normalizeDirectoryForCompare(path))) { - return false; - } - } - return true; -} - -function normalizeDirectoryForCompare(path: string): string { - return path === "/" - ? "/" - : ensureDirectoryPath(path.startsWith("/") ? path : `/${path}`); -} - -function ensureDirectoryPath(path: string): string { - if (path === "/") return "/"; - return path.endsWith("/") ? path : `${path}/`; -} diff --git a/src/extensions/files/files.manifest.json b/src/extensions/files/files.manifest.json deleted file mode 100644 index 5865b75b..00000000 --- a/src/extensions/files/files.manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "apiVersion": 1, - "id": "atelier_files", - "name": "Files", - "description": "Browse files from the local FlashType workspace." -} diff --git a/src/extensions/files/host-extension.test.tsx b/src/extensions/files/host-extension.test.tsx deleted file mode 100644 index 4bbb0f4d..00000000 --- a/src/extensions/files/host-extension.test.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { act } from "@testing-library/react"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import type { AtelierExtensionRuntime } from "@opral/atelier"; -import type { ExtensionContext } from "@/extension-runtime/types"; - -const filesViewMock = vi.hoisted(() => - vi.fn((_props: Record) => null), -); -const executeTakeFirstMock = vi.hoisted(() => vi.fn()); -const qbMock = vi.hoisted(() => vi.fn()); - -vi.mock("./index", () => ({ FilesView: filesViewMock })); -vi.mock("@/lib/lix-kysely", () => ({ qb: qbMock })); - -import { createFilesExtensionRegistration } from "./host-extension"; - -describe("createFilesExtensionRegistration", () => { - afterEach(() => { - document.body.replaceChildren(); - vi.clearAllMocks(); - }); - - test("imports watched files before opening them through Atelier documents", async () => { - const query = { - selectFrom: vi.fn(), - select: vi.fn(), - where: vi.fn(), - executeTakeFirst: executeTakeFirstMock, - }; - query.selectFrom.mockReturnValue(query); - query.select.mockReturnValue(query); - query.where.mockReturnValue(query); - qbMock.mockReturnValue(query); - executeTakeFirstMock - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce({ id: "imported-file" }); - - const importFilesystemPaths = vi.fn().mockResolvedValue(undefined); - const open = vi.fn().mockResolvedValue(undefined); - const closeActive = vi.fn().mockResolvedValue(undefined); - const atelier = { - lix: { importFilesystemPaths }, - documents: { open, closeActive }, - revisions: { - current: { branchId: "checkpoint-1" }, - show: vi.fn(), - clear: vi.fn(), - }, - } as unknown as AtelierExtensionRuntime; - const registration = createFilesExtensionRegistration({ - ephemeral: true, - path: "/workspace", - name: "workspace", - openFilePaths: [], - }); - const element = document.createElement("div"); - document.body.append(element); - - let mounted: ReturnType; - await act(async () => { - mounted = registration.entry.mount({ - element, - atelier, - view: { - instanceId: "files-1", - state: {}, - panel: "left", - isActive: true, - isFocused: true, - registerNewFileDraftHandler: () => () => {}, - }, - signal: new AbortController().signal, - }); - }); - - expect(registration.manifest.id).toBe("atelier_files"); - expect("runtime" in registration).toBe(false); - const context = filesViewMock.mock.calls.at(-1)?.[0] - .context as ExtensionContext; - expect(context.checkpointBranchId).toBe("checkpoint-1"); - await context.openFile?.({ - panel: "central", - fileId: "watched:/docs/note.md", - filePath: "/docs/note.md", - }); - - expect(importFilesystemPaths).toHaveBeenCalledWith(["docs/note.md"]); - expect(open).toHaveBeenCalledWith("/docs/note.md", {}); - context.closeFileViews?.({ fileId: "imported-file" }); - expect(closeActive).toHaveBeenCalledOnce(); - - await act(async () => { - mounted?.dispose?.(); - }); - }); - - test("does not let a slow watched-file import supersede a newer file open", async () => { - const query = { - selectFrom: vi.fn(), - select: vi.fn(), - where: vi.fn(), - executeTakeFirst: executeTakeFirstMock, - }; - query.selectFrom.mockReturnValue(query); - query.select.mockReturnValue(query); - query.where.mockReturnValue(query); - qbMock.mockReturnValue(query); - executeTakeFirstMock - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce({ id: "imported-welcome" }); - - let resolveImport!: () => void; - const importPromise = new Promise((resolve) => { - resolveImport = resolve; - }); - const importFilesystemPaths = vi.fn(() => importPromise); - const open = vi.fn().mockResolvedValue(undefined); - const atelier = { - lix: { importFilesystemPaths }, - documents: { open, closeActive: vi.fn().mockResolvedValue(undefined) }, - revisions: { - current: null, - show: vi.fn(), - clear: vi.fn(), - }, - } as unknown as AtelierExtensionRuntime; - const registration = createFilesExtensionRegistration({ - ephemeral: true, - path: "/workspace", - name: "workspace", - openFilePaths: [], - }); - const element = document.createElement("div"); - document.body.append(element); - - let mounted: ReturnType; - await act(async () => { - mounted = registration.entry.mount({ - element, - atelier, - view: { - instanceId: "files-1", - state: {}, - panel: "left", - isActive: true, - isFocused: true, - registerNewFileDraftHandler: () => () => {}, - }, - signal: new AbortController().signal, - }); - }); - - const context = filesViewMock.mock.calls.at(-1)?.[0] - .context as ExtensionContext; - if (!context.openFile) { - throw new Error("Files extension did not provide openFile"); - } - const staleOpen = context.openFile({ - panel: "central", - fileId: "watched:/welcome.md", - filePath: "/welcome.md", - }); - await vi.waitFor(() => { - expect(importFilesystemPaths).toHaveBeenCalledWith(["welcome.md"]); - }); - - await context.openFile({ - panel: "central", - fileId: "file_metrics", - filePath: "/metrics.csv", - }); - resolveImport(); - await staleOpen; - - expect(open).toHaveBeenCalledTimes(1); - expect(open).toHaveBeenCalledWith("/metrics.csv", {}); - - await act(async () => { - mounted?.dispose?.(); - }); - }); -}); diff --git a/src/extensions/files/host-extension.tsx b/src/extensions/files/host-extension.tsx deleted file mode 100644 index 5efb6f6d..00000000 --- a/src/extensions/files/host-extension.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { createRoot, type Root } from "react-dom/client"; -import { Files } from "lucide-react"; -import type { - AtelierBuiltinExtensionId, - AtelierExtensionRegistration, - AtelierExtensionRuntime, - AtelierExtensionState, - AtelierExtensionView, - ExtensionManifest, -} from "@opral/atelier"; -import { LixProvider } from "@/lib/lix-react"; -import type { Lix } from "@/lib/lix-types"; -import type { - ExtensionContext, - WorkspaceContext, -} from "@/extension-runtime/types"; -import { qb } from "@/lib/lix-kysely"; -import { FilesView } from "./index"; -import manifestJson from "./files.manifest.json"; - -const manifest = { - ...manifestJson, - id: "atelier_files" satisfies AtelierBuiltinExtensionId, -} as ExtensionManifest; - -export function createFilesExtensionRegistration( - workspace: WorkspaceContext, -): AtelierExtensionRegistration { - return { - manifest, - entry: { - icon: Files, - mount: ({ element, atelier, view }) => { - const root = createRoot(element); - const openRequest = { current: 0 }; - renderFilesView(root, workspace, atelier, view, openRequest); - return { - update: ({ atelier: nextAtelier, view: nextView }) => { - renderFilesView( - root, - workspace, - nextAtelier, - nextView, - openRequest, - ); - }, - dispose: () => { - openRequest.current += 1; - root.unmount(); - }, - }; - }, - }, - }; -} - -function renderFilesView( - root: Root, - workspace: WorkspaceContext, - atelier: AtelierExtensionRuntime, - view: AtelierExtensionView, - openRequest: { current: number }, -) { - const lix = atelier.lix as unknown as Lix; - const context: ExtensionContext = { - lix, - workspace, - openFile: ({ fileId, filePath, state, focus, pending, documentOrigin }) => { - const requestId = ++openRequest.current; - return openWorkspaceFile( - atelier, - { - fileId, - filePath, - state, - focus, - pending, - documentOrigin, - }, - () => requestId === openRequest.current, - ); - }, - closeFileViews: () => { - void atelier.documents.closeActive(); - }, - checkpointBranchId: atelier.revisions.current?.branchId ?? null, - isPanelFocused: view.isFocused, - panelSide: view.panel, - viewInstance: view.instanceId, - isActiveView: view.isActive, - registerNewFileDraftHandler: ({ handler }) => - view.registerNewFileDraftHandler(handler), - setTabBadgeCount: () => {}, - }; - - root.render( - - - , - ); -} - -async function openWorkspaceFile( - atelier: AtelierExtensionRuntime, - args: { - readonly fileId: string; - readonly filePath: string; - readonly state?: AtelierExtensionState; - readonly focus?: boolean; - readonly pending?: boolean; - readonly documentOrigin?: "existing" | "new"; - }, - isCurrentRequest: () => boolean, -): Promise { - const lix = atelier.lix as unknown as Lix; - if (args.fileId.startsWith("watched:")) { - let importedFile = await qb(lix) - .selectFrom("lix_file") - .select("id") - .where("path", "=", args.filePath) - .executeTakeFirst(); - if (!isCurrentRequest()) return; - if (!importedFile?.id) { - await lix.importFilesystemPaths([args.filePath.replace(/^\/+/, "")]); - if (!isCurrentRequest()) return; - importedFile = await qb(lix) - .selectFrom("lix_file") - .select("id") - .where("path", "=", args.filePath) - .executeTakeFirst(); - if (!isCurrentRequest()) return; - } - if (!importedFile?.id) { - throw new Error(`Imported file id not found for '${args.filePath}'.`); - } - } - if (!isCurrentRequest()) return; - await atelier.documents.open(args.filePath, { - ...(args.state ? { state: args.state } : {}), - ...(args.focus !== undefined ? { focus: args.focus } : {}), - ...(args.documentOrigin ? { documentOrigin: args.documentOrigin } : {}), - }); -} diff --git a/src/extensions/files/index.test.tsx b/src/extensions/files/index.test.tsx deleted file mode 100644 index dd1f4236..00000000 --- a/src/extensions/files/index.test.tsx +++ /dev/null @@ -1,2251 +0,0 @@ -import React, { Suspense } from "react"; -import { beforeAll, afterAll, describe, expect, test, vi } from "vitest"; -import { act, fireEvent, render, waitFor } from "@testing-library/react"; -import { LixProvider } from "@/lib/lix-react"; -import { openLix } from "@/test-utils/node-lix-sdk"; -import { FilesView } from "./index"; -import type { ExtensionContext } from "../../extension-runtime/types"; -import { qb } from "@/lib/lix-kysely"; -import { - AGENT_TURN_COMMIT_RANGE_KEY_PREFIX, - type AgentTurnCommitRange, -} from "@/shell/agent-turn-review-range"; -import type { - CheckpointDiff, - CheckpointDiffFile, -} from "@/extension-runtime/checkpoint-diff"; - -const createViewContext = ( - lix: Awaited>, - overrides: Partial = {}, -): ExtensionContext => ({ - setTabBadgeCount: () => {}, - lix, - ...overrides, -}); - -const originalPlatformDescriptor = Object.getOwnPropertyDescriptor( - window.navigator, - "platform", -); - -function setNavigatorPlatform(value: string) { - Object.defineProperty(window.navigator, "platform", { - value, - configurable: true, - }); -} - -function isUserPath(path: string): boolean { - return !path.startsWith("/.lix/"); -} - -async function waitForFilesViewReady(utils: ReturnType) { - for (let i = 0; i < 20; i += 1) { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); - const button = utils.queryByRole("button", { name: "New file" }); - if (button) return button; - } - throw new Error("Files view did not finish loading"); -} - -function queryFilesTreeHost( - utils: ReturnType, -): HTMLElement | null { - const host = utils.container.querySelector("file-tree-container"); - return host instanceof HTMLElement ? host : null; -} - -function queryFilesTreeRoot( - utils: ReturnType, -): ShadowRoot | null { - return queryFilesTreeHost(utils)?.shadowRoot ?? null; -} - -function getFilesTreeRoot(utils: ReturnType): ShadowRoot { - const root = queryFilesTreeRoot(utils); - if (!root) { - throw new Error("file tree shadow root not found"); - } - return root; -} - -function queryTreeItemByLabel( - utils: ReturnType, - label: string, -): HTMLElement | null { - const root = queryFilesTreeRoot(utils); - if (!root) return null; - for (const item of root.querySelectorAll("[data-type='item']")) { - if ( - item instanceof HTMLElement && - item.getAttribute("aria-label") === label - ) { - return item; - } - } - return null; -} - -function queryTreeItemByPath( - utils: ReturnType, - path: string, -): HTMLElement | null { - const root = queryFilesTreeRoot(utils); - if (!root) return null; - const item = root.querySelector( - `[data-type='item'][data-item-path='${CSS.escape(path)}']`, - ); - return item instanceof HTMLElement ? item : null; -} - -async function findTreeItemByPath( - utils: ReturnType, - path: string, -): Promise { - return waitFor(() => { - const item = queryTreeItemByPath(utils, path); - if (!item) { - throw new Error(`file tree item not found: ${path}`); - } - return item; - }); -} - -async function findTreeItemByLabel( - utils: ReturnType, - label: string, -): Promise { - return waitFor(() => { - const item = queryTreeItemByLabel(utils, label); - if (!item) { - throw new Error(`file tree item not found: ${label}`); - } - return item; - }); -} - -function queryTreeRenameInput( - utils: ReturnType, -): HTMLInputElement | null { - const input = queryFilesTreeRoot(utils)?.querySelector( - "[data-item-rename-input]", - ); - return input instanceof HTMLInputElement ? input : null; -} - -async function findTreeRenameInput( - utils: ReturnType, -): Promise { - return waitFor(() => { - const input = queryTreeRenameInput(utils); - if (!input) { - throw new Error("file tree rename input not found"); - } - return input; - }); -} - -async function startTreeRenameByLabel( - utils: ReturnType, - label: string, -): Promise { - const item = await findTreeItemByLabel(utils, label); - await act(async () => { - fireEvent.click(item); - }); - await act(async () => { - fireEvent.keyDown(getFilesTreeRoot(utils).activeElement ?? item, { - key: "F2", - }); - }); - return findTreeRenameInput(utils); -} - -describe("FilesView", () => { - beforeAll(() => { - setNavigatorPlatform("MacIntel"); - }); - - afterAll(() => { - if (originalPlatformDescriptor) { - Object.defineProperty( - window.navigator, - "platform", - originalPlatformDescriptor, - ); - } - }); - - test("prevents text selection on the new file row", async () => { - const lix = await openLix(); - - let utils: ReturnType | null = null; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await waitForFilesViewReady(utils!); - expect(utils!.getByRole("button", { name: "New file" })).toHaveClass( - "select-none", - ); - expect(utils!.getByRole("button", { name: "New file" })).toHaveAttribute( - "data-attr", - "file-new", - ); - expect(utils!.getByTestId("files-view-tree-scroll")).toHaveAttribute( - "data-attr", - "file-tree", - ); - - utils!.unmount(); - await lix.close(); - }); - - test("creates an inline draft when Cmd+. is pressed", async () => { - const lix = await openLix(); - const openFile = vi.fn(); - - let utils: ReturnType | null = null; - await act(async () => { - utils = render( - - - - - , - ); - }); - await waitForFilesViewReady(utils!); - - await waitForFilesViewReady(utils!); - - const initialRows = await qb(lix) - .selectFrom("lix_file") - .select(["id", "path"]) - .execute(); - expect(initialRows.filter((row) => isUserPath(row.path))).toHaveLength(0); - - await act(async () => { - fireEvent.keyDown(document, { key: ".", metaKey: true }); - }); - - const input = await findTreeRenameInput(utils!); - expect(input.value).toBe("new-file"); - - await act(async () => { - fireEvent.input(input, { target: { value: "notes" } }); - }); - - await act(async () => { - fireEvent.keyDown(input, { key: "Enter" }); - }); - - await waitFor(async () => { - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["id", "path"]) - .execute(); - const userRows = rows.filter((row) => isUserPath(row.path)); - expect(userRows).toHaveLength(1); - expect(userRows[0]?.path).toBe("/notes.md"); - const createdId = userRows[0]?.id as string; - expect(openFile).toHaveBeenCalledWith({ - panel: "central", - fileId: createdId, - filePath: "/notes.md", - state: { focusOnLoad: true, defaultBlock: "heading1" }, - focus: true, - documentOrigin: "new", - }); - }); - - utils!.unmount(); - await lix.close(); - }); - - test("does not subscribe to the native New File menu item directly", async () => { - const lix = await openLix(); - const originalDesktop = window.flashtypeDesktop; - const onNewFile = vi.fn(); - window.flashtypeDesktop = { - workspace: { - onNewFile, - }, - } as unknown as Window["flashtypeDesktop"]; - - let utils: ReturnType; - try { - await act(async () => { - utils = render( - - - - - , - ); - }); - await waitForFilesViewReady(utils!); - - expect(onNewFile).not.toHaveBeenCalled(); - - utils!.unmount(); - } finally { - window.flashtypeDesktop = originalDesktop; - await lix.close(); - } - }); - - test("moves the highlighted file when the active file path changes", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values([ - { - id: "file_alpha", - path: "/alpha.md", - data: new Uint8Array(), - }, - { - id: "file_beta", - path: "/beta.md", - data: new Uint8Array(), - }, - ]) - .execute(); - - const renderFilesView = (activeFilePath: string | null) => ( - - - - - - ); - - let utils: ReturnType; - await act(async () => { - utils = render(renderFilesView("/alpha.md")); - }); - - await waitFor(() => { - expect(queryTreeItemByPath(utils!, "alpha.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - }); - expect(queryTreeItemByPath(utils!, "beta.md")).not.toHaveAttribute( - "data-item-selected", - "true", - ); - - await act(async () => { - utils!.rerender(renderFilesView("/beta.md")); - }); - - await waitFor(() => { - expect(queryTreeItemByPath(utils!, "beta.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - expect(queryTreeItemByPath(utils!, "alpha.md")).not.toHaveAttribute( - "data-item-selected", - "true", - ); - }); - - utils!.unmount(); - await lix.close(); - }); - - test("derives the active file highlight from Atelier's Lix state", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_lix_active", - path: "/active.md", - data: new Uint8Array(), - }) - .execute(); - await qb(lix) - .insertInto("lix_key_value") - .values({ - key: "atelier_active_file_id", - value: "file_lix_active", - }) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await waitFor(() => { - expect(queryTreeItemByPath(utils!, "active.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - }); - - utils!.unmount(); - await lix.close(); - }); - - test("opens parent directories for the active file highlight", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_directory") - .values({ path: "/docs/" } as any) - .execute(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_nested_active", - path: "/docs/readme.md", - data: new Uint8Array(), - }) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - const nestedItem = await findTreeItemByPath(utils!, "docs/readme.md"); - expect(nestedItem).toHaveAttribute("data-item-selected", "true"); - - utils!.unmount(); - await lix.close(); - }); - - test("focuses the inline draft", async () => { - const lix = await openLix(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - const newFileButton = await waitForFilesViewReady(utils!); - - await act(async () => { - fireEvent.click(newFileButton); - }); - - const input = await findTreeRenameInput(utils!); - await waitFor(() => { - expect(getFilesTreeRoot(utils!).activeElement).toBe(input); - }); - expect(input.value).toBe("new-file"); - - utils!.unmount(); - await lix.close(); - }); - - test("renames selected files with F2", async () => { - const lix = await openLix(); - const openFile = vi.fn(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_rename", - path: "/draft.md", - data: new Uint8Array(), - }) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - const input = await startTreeRenameByLabel(utils!, "draft.md"); - expect(input.value).toBe("draft.md"); - openFile.mockClear(); - - await act(async () => { - fireEvent.input(input, { target: { value: "renamed.md" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter" }); - }); - - await waitFor(async () => { - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["id", "path"]) - .execute(); - expect(rows.some((row) => row.path === "/draft.md")).toBe(false); - expect( - rows.some( - (row) => row.id === "file_rename" && row.path === "/renamed.md", - ), - ).toBe(true); - expect(queryTreeItemByLabel(utils!, "draft.md")).toBeNull(); - expect(queryTreeItemByLabel(utils!, "renamed.md")).toBeInTheDocument(); - }); - expect(openFile).toHaveBeenCalledWith({ - panel: "central", - fileId: "file_rename", - filePath: "/renamed.md", - focus: false, - trackTelemetry: false, - }); - - utils!.unmount(); - await lix.close(); - }); - - test("renames selected directories with F2", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_directory") - .values({ path: "/docs/" } as any) - .execute(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_nested", - path: "/docs/readme.md", - data: new Uint8Array(), - }) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - const input = await startTreeRenameByLabel(utils!, "docs"); - expect(input.value).toBe("docs"); - - await act(async () => { - fireEvent.input(input, { target: { value: "notes" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter" }); - }); - - await waitFor(async () => { - const directoryRows = await qb(lix) - .selectFrom("lix_directory") - .select(["path"]) - .execute(); - const fileRows = await qb(lix) - .selectFrom("lix_file") - .select(["id", "path"]) - .execute(); - expect(directoryRows.some((row) => row.path === "/docs/")).toBe(false); - expect(directoryRows.some((row) => row.path === "/notes/")).toBe(true); - expect(fileRows.some((row) => row.path === "/docs/readme.md")).toBe( - false, - ); - expect( - fileRows.some( - (row) => row.id === "file_nested" && row.path === "/notes/readme.md", - ), - ).toBe(true); - expect(queryTreeItemByLabel(utils!, "docs")).toBeNull(); - expect(queryTreeItemByLabel(utils!, "notes")).toBeInTheDocument(); - }); - - utils!.unmount(); - await lix.close(); - }); - - test("Cmd+Backspace deletes the selected file from the focused file row", async () => { - const lix = await openLix(); - const closeFileViews = vi.fn(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_1", - path: "/hello.md", - data: new Uint8Array(), - }) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await findTreeItemByLabel(utils!, "hello.md"); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "hello.md")); - }); - - await act(async () => { - fireEvent.keyDown(document, { key: "Backspace", metaKey: true }); - }); - - await waitFor(async () => { - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["path"]) - .execute(); - expect(rows.filter((row) => isUserPath(row.path))).toHaveLength(0); - }); - - await waitFor(() => { - expect(queryTreeItemByLabel(utils!, "hello.md")).toBeNull(); - }); - expect(closeFileViews).toHaveBeenCalledWith({ fileId: "file_1" }); - - utils!.unmount(); - await lix.close(); - }); - - test("Cmd+Backspace deletes the active file id, not the active path", async () => { - const lix = await openLix(); - const closeFileViews = vi.fn(); - await qb(lix) - .insertInto("lix_file") - .values([ - { - id: "file_viewed", - path: "/viewed.md", - data: new Uint8Array(), - }, - { - id: "file_at_path", - path: "/path-active.md", - data: new Uint8Array(), - }, - ]) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await waitFor(() => { - expect(queryTreeItemByLabel(utils!, "viewed.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - }); - - await act(async () => { - fireEvent.keyDown(document, { key: "Backspace", metaKey: true }); - }); - - await waitFor(async () => { - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["id", "path"]) - .execute(); - expect(rows.some((row) => row.id === "file_viewed")).toBe(false); - expect(rows).toContainEqual({ - id: "file_at_path", - path: "/path-active.md", - }); - }); - - expect(closeFileViews).toHaveBeenCalledWith({ fileId: "file_viewed" }); - - utils!.unmount(); - await lix.close(); - }); - - test("Cmd+Backspace in an empty editor keeps a newly created selected file and editor event", async () => { - const lix = await openLix(); - const openFile = vi.fn(); - const closeFileViews = vi.fn(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - await waitForFilesViewReady(utils!); - - await waitForFilesViewReady(utils!); - - await act(async () => { - fireEvent.keyDown(document, { key: ".", metaKey: true }); - }); - const input = await findTreeRenameInput(utils!); - await act(async () => { - fireEvent.input(input, { target: { value: "fresh" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter" }); - }); - - await waitFor(() => { - expect(openFile).toHaveBeenCalled(); - }); - - const emptyEditor = document.createElement("div"); - emptyEditor.contentEditable = "true"; - document.body.append(emptyEditor); - let emptyEditorEvent: KeyboardEvent; - await act(async () => { - emptyEditorEvent = new KeyboardEvent("keydown", { - key: "Backspace", - metaKey: true, - bubbles: true, - cancelable: true, - }); - expect(emptyEditor.dispatchEvent(emptyEditorEvent)).toBe(true); - }); - expect(emptyEditorEvent!.defaultPrevented).toBe(false); - - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["path"]) - .execute(); - expect(rows.some((row) => row.path === "/fresh.md")).toBe(true); - expect(closeFileViews).not.toHaveBeenCalled(); - emptyEditor.remove(); - - utils!.unmount(); - await lix.close(); - }); - - test("Cmd+Backspace in a non-empty editor keeps the selected file and editor event", async () => { - const lix = await openLix(); - const closeFileViews = vi.fn(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_with_text", - path: "/keep.md", - data: new TextEncoder().encode("Keep me"), - }) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - const file = await findTreeItemByLabel(utils!, "keep.md"); - await act(async () => { - fireEvent.click(file); - }); - - const editor = document.createElement("div"); - editor.contentEditable = "true"; - editor.textContent = "Keep me"; - document.body.append(editor); - let editorEvent: KeyboardEvent; - await act(async () => { - editorEvent = new KeyboardEvent("keydown", { - key: "Backspace", - metaKey: true, - bubbles: true, - cancelable: true, - }); - expect(editor.dispatchEvent(editorEvent)).toBe(true); - }); - expect(editorEvent!.defaultPrevented).toBe(false); - - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["path"]) - .execute(); - expect(rows.some((row) => row.path === "/keep.md")).toBe(true); - expect(closeFileViews).not.toHaveBeenCalled(); - - editor.remove(); - utils!.unmount(); - await lix.close(); - }); - - test("Cmd+Backspace in a whitespace-only editor keeps the selected file and editor event", async () => { - const lix = await openLix(); - const closeFileViews = vi.fn(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_with_spaces", - path: "/spaces.md", - data: new TextEncoder().encode(" "), - }) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - const file = await findTreeItemByLabel(utils!, "spaces.md"); - await act(async () => { - fireEvent.click(file); - }); - - const editor = document.createElement("div"); - editor.contentEditable = "true"; - editor.textContent = " \n"; - document.body.append(editor); - let editorEvent: KeyboardEvent; - await act(async () => { - editorEvent = new KeyboardEvent("keydown", { - key: "Backspace", - metaKey: true, - bubbles: true, - cancelable: true, - }); - expect(editor.dispatchEvent(editorEvent)).toBe(true); - }); - expect(editorEvent!.defaultPrevented).toBe(false); - - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["path"]) - .execute(); - expect(rows.some((row) => row.path === "/spaces.md")).toBe(true); - expect(closeFileViews).not.toHaveBeenCalled(); - - editor.remove(); - utils!.unmount(); - await lix.close(); - }); - - test("Cmd+Backspace in an editor with non-text content keeps the selected file and editor event", async () => { - const lix = await openLix(); - const closeFileViews = vi.fn(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_with_rule", - path: "/rule.md", - data: new TextEncoder().encode("---"), - }) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - const file = await findTreeItemByLabel(utils!, "rule.md"); - await act(async () => { - fireEvent.click(file); - }); - - const editor = document.createElement("div"); - editor.contentEditable = "true"; - editor.innerHTML = "
"; - document.body.append(editor); - let editorEvent: KeyboardEvent; - await act(async () => { - editorEvent = new KeyboardEvent("keydown", { - key: "Backspace", - metaKey: true, - bubbles: true, - cancelable: true, - }); - expect(editor.dispatchEvent(editorEvent)).toBe(true); - }); - expect(editorEvent!.defaultPrevented).toBe(false); - - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["path"]) - .execute(); - expect(rows.some((row) => row.path === "/rule.md")).toBe(true); - expect(closeFileViews).not.toHaveBeenCalled(); - - editor.remove(); - utils!.unmount(); - await lix.close(); - }); - - test("asks the host to open CSV files", async () => { - const lix = await openLix(); - const openFile = vi.fn(); - 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; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await findTreeItemByLabel(utils!, "data.csv"); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "data.csv")); - }); - - expect(openFile).toHaveBeenCalledWith({ - panel: "central", - fileId: "file_csv", - filePath: "/data.csv", - focus: false, - }); - - utils!.unmount(); - await lix.close(); - }); - - test("forwards clicks even when the active-file projection still matches", async () => { - const lix = await openLix(); - const openFile = vi.fn(); - await qb(lix) - .insertInto("lix_file") - .values([ - { - id: "file_active", - path: "/active.md", - data: new Uint8Array(), - }, - { - id: "file_other", - path: "/other.md", - data: new Uint8Array(), - }, - ]) - .execute(); - const renderFilesView = (activeFileId: string, activeFilePath: string) => ( - - - - - - ); - - let utils: ReturnType; - await act(async () => { - utils = render(renderFilesView("file_active", "/active.md")); - }); - await waitForFilesViewReady(utils!); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "other.md")); - }); - openFile.mockClear(); - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "active.md")); - }); - - expect(openFile).toHaveBeenCalledWith({ - panel: "central", - fileId: "file_active", - filePath: "/active.md", - focus: false, - }); - expect(queryTreeItemByLabel(utils!, "active.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - - utils!.unmount(); - await lix.close(); - }); - - test("keeps a clicked file selected while the active-file projection is stale", async () => { - const lix = await openLix(); - const openFile = vi.fn(); - await qb(lix) - .insertInto("lix_file") - .values([ - { - id: "file_active", - path: "/active.md", - data: new Uint8Array(), - }, - { - id: "file_clicked", - path: "/clicked.md", - data: new Uint8Array(), - }, - ]) - .execute(); - const renderFilesView = (activeFileId: string, activeFilePath: string) => ( - - - - - - ); - - let utils: ReturnType; - await act(async () => { - utils = render(renderFilesView("file_active", "/active.md")); - }); - await waitFor(() => { - expect(queryTreeItemByLabel(utils!, "active.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - }); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "clicked.md")); - }); - expect(queryTreeItemByLabel(utils!, "clicked.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - - // A filesystem refresh can arrive before Atelier publishes the new active - // file id. The stale projection must not overwrite the user's click. - await act(async () => { - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_refresh", - path: "/refresh.md", - data: new Uint8Array(), - }) - .execute(); - }); - await findTreeItemByLabel(utils!, "refresh.md"); - expect(queryTreeItemByLabel(utils!, "clicked.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - expect(queryTreeItemByLabel(utils!, "active.md")).not.toHaveAttribute( - "data-item-selected", - "true", - ); - - await act(async () => { - utils!.rerender(renderFilesView("file_clicked", "/clicked.md")); - }); - await waitFor(() => { - expect(queryTreeItemByLabel(utils!, "clicked.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - }); - await act(async () => { - utils!.rerender(renderFilesView("file_active", "/active.md")); - }); - await waitFor(() => { - expect(queryTreeItemByLabel(utils!, "active.md")).toHaveAttribute( - "data-item-selected", - "true", - ); - expect(queryTreeItemByLabel(utils!, "clicked.md")).not.toHaveAttribute( - "data-item-selected", - "true", - ); - }); - - utils!.unmount(); - await lix.close(); - }); - - test("asks the host to open unsupported files", async () => { - const lix = await openLix(); - const openFile = vi.fn(); - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_txt", - path: "/notes.txt", - data: new TextEncoder().encode("hello"), - }) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await findTreeItemByLabel(utils!, "notes.txt"); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "notes.txt")); - }); - - expect(openFile).toHaveBeenCalledWith({ - panel: "central", - fileId: "file_txt", - filePath: "/notes.txt", - focus: false, - }); - - utils!.unmount(); - await lix.close(); - }); - - test("marks files with pending external write reviews", async () => { - const lix = await openLix(); - try { - await qb(lix) - .insertInto("lix_directory") - .values({ path: "/docs/" } as any) - .execute(); - await writeReviewFile(lix, "file_review", "/docs/review.md", "before"); - await writeReviewFile(lix, "file_clean", "/docs/clean.md", "same"); - const beforeCommitId = await activeCommitId(lix); - await writeReviewFile(lix, "file_review", "/docs/review.md", "after"); - await writeReviewFile(lix, "file_clean", "/docs/clean.md", "same"); - const afterCommitId = await activeCommitId(lix); - const range = agentRange({ - id: "range-files-tree-review", - beforeCommitId, - afterCommitId, - }); - const activeBranchId = await lix.activeBranchId(); - await qb(lix) - .insertInto("lix_key_value_by_branch") - .values({ - key: `${AGENT_TURN_COMMIT_RANGE_KEY_PREFIX}${encodeURIComponent(range.id)}`, - value: range, - lixcol_branch_id: activeBranchId, - lixcol_global: activeBranchId === "global", - lixcol_untracked: true, - }) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await findTreeItemByLabel(utils!, "docs"); - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "docs")); - }); - - await waitFor(() => { - expect(queryTreeItemByLabel(utils!, "review.md")).toHaveAttribute( - "data-item-git-status", - "modified", - ); - }); - expect(queryTreeItemByLabel(utils!, "clean.md")).not.toHaveAttribute( - "data-item-git-status", - ); - expect(queryTreeItemByLabel(utils!, "docs")).toHaveAttribute( - "data-item-contains-git-change", - "true", - ); - - utils!.unmount(); - } finally { - await lix.close(); - } - }); - - test("marks checkpoint diff files and opens virtual paths as revisioned editors", async () => { - const lix = await openLix(); - const openFile = vi.fn(); - try { - await qb(lix) - .insertInto("lix_directory") - .values({ path: "/docs/" } as any) - .execute(); - await writeReviewFile(lix, "file_live", "/docs/live.md", "after"); - await writeReviewFile( - lix, - "file_live_only", - "/docs/live-only.md", - "head", - ); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await findTreeItemByLabel(utils!, "docs"); - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "docs")); - }); - - await waitFor(() => { - expect(queryTreeItemByLabel(utils!, "live.md")).toHaveAttribute( - "data-item-git-status", - "modified", - ); - expect(queryTreeItemByLabel(utils!, "added.md")).toHaveAttribute( - "data-item-git-status", - "added", - ); - expect(queryTreeItemByLabel(utils!, "recreated.md")).toHaveAttribute( - "data-item-git-status", - "recreated", - ); - expect( - queryTreeItemByLabel(utils!, "unchanged.md"), - ).not.toHaveAttribute("data-item-git-status"); - expect(queryTreeItemByLabel(utils!, "live-only.md")).toBeNull(); - }); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "added.md")); - }); - - expect(openFile).toHaveBeenCalledWith({ - panel: "central", - fileId: "file_added", - filePath: "/docs/added.md", - state: { - beforeCommitId: "before-commit", - afterCommitId: "after-commit", - }, - focus: false, - trackTelemetry: false, - trackDocumentOpenAttempt: false, - trackDocumentViewed: false, - }); - - utils!.unmount(); - } finally { - await lix.close(); - } - }); - - test("does not delete selected checkpoint diff files", async () => { - const lix = await openLix(); - try { - await writeReviewFile(lix, "file_live", "/live.md", "after"); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await findTreeItemByLabel(utils!, "live.md"); - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "live.md")); - }); - await act(async () => { - fireEvent.keyDown(document, { key: "Backspace", metaKey: true }); - }); - - const row = await qb(lix) - .selectFrom("lix_file") - .select("path") - .where("id", "=", "file_live") - .executeTakeFirst(); - expect(row?.path).toBe("/live.md"); - - utils!.unmount(); - } finally { - await lix.close(); - } - }); - - test("watches transient directories on demand and delegates watched-only file opens", async () => { - const lix = await openLix(); - const originalDesktop = window.flashtypeDesktop; - const openFile = vi.fn(); - const rootEntries = [ - { - id: "watched:/docs/", - parent_id: null, - path: "/docs/", - display_name: "docs", - kind: "directory" as const, - source: "watched" as const, - }, - { - id: "watched:/notes.txt", - parent_id: null, - path: "/notes.txt", - display_name: "notes.txt", - kind: "file" as const, - source: "watched" as const, - }, - ]; - const nestedEntries = [ - ...rootEntries, - { - id: "watched:/docs/nested.txt", - parent_id: "watched:/docs/", - path: "/docs/nested.txt", - display_name: "nested.txt", - kind: "file" as const, - source: "watched" as const, - }, - ]; - const setEphemeralWatchedDirectories = vi.fn( - async ({ paths }: { paths: string[] }) => - paths.includes("/docs/") ? nestedEntries : rootEntries, - ); - window.flashtypeDesktop = { - workspace: { - setEphemeralWatchedDirectories, - onEphemeralWatchedFileTreeChanged: vi.fn(() => () => {}), - }, - } as unknown as Window["flashtypeDesktop"]; - - let utils: ReturnType; - let cleanup: (() => void) | undefined; - try { - await act(async () => { - utils = render( - - - - - , - ); - cleanup = () => utils.unmount(); - }); - - await findTreeItemByLabel(utils!, "docs"); - await findTreeItemByLabel(utils!, "notes.txt"); - expect(setEphemeralWatchedDirectories).toHaveBeenCalledWith({ - ownerId: "files-view:files-view-test", - paths: ["/"], - }); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "docs")); - }); - await waitFor(() => { - expect(setEphemeralWatchedDirectories).toHaveBeenCalledWith({ - ownerId: "files-view:files-view-test", - paths: ["/", "/docs/"], - }); - expect(queryTreeItemByLabel(utils!, "nested.txt")).toBeInTheDocument(); - }); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "nested.txt")); - }); - - await waitFor(async () => { - const file = await qb(lix) - .selectFrom("lix_file") - .select(["id", "path", "data"]) - .where("path", "=", "/docs/nested.txt") - .executeTakeFirst(); - expect(file).toBeUndefined(); - expect(openFile).toHaveBeenCalledWith({ - panel: "central", - fileId: "watched:/docs/nested.txt", - filePath: "/docs/nested.txt", - focus: false, - }); - }); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "docs")); - }); - await waitFor(() => { - expect(setEphemeralWatchedDirectories).toHaveBeenLastCalledWith({ - ownerId: "files-view:files-view-test", - paths: ["/"], - }); - expect(queryTreeItemByLabel(utils!, "nested.txt")).toBeNull(); - }); - } finally { - cleanup?.(); - window.flashtypeDesktop = originalDesktop; - await lix.close(); - } - }); - - test("Cmd+Backspace deletes the selected directory", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_directory") - .values({ path: "/docs/" } as any) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await findTreeItemByLabel(utils!, "docs"); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "docs")); - }); - - await act(async () => { - fireEvent.keyDown(document, { key: "Backspace", metaKey: true }); - }); - - await waitFor(async () => { - const rows = await qb(lix) - .selectFrom("lix_directory") - .select(["path"]) - .execute(); - expect(rows.some((row) => row.path === "/docs/")).toBe(false); - }); - - await waitFor(() => { - expect(queryTreeItemByLabel(utils!, "docs")).toBeNull(); - }); - - utils!.unmount(); - await lix.close(); - }); - - test("hides dot-prefixed files and folder descendants", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_directory") - .values({ path: "/.hidden-folder/" } as any) - .execute(); - await qb(lix) - .insertInto("lix_file") - .values([ - { - id: "visible_file", - path: "/visible.md", - data: new Uint8Array(), - }, - { - id: "dot_file", - path: "/.hidden.md", - data: new Uint8Array(), - }, - { - id: "dot_folder_child", - path: "/.hidden-folder/inside.md", - data: new Uint8Array(), - }, - ]) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await waitFor(() => { - expect(queryTreeItemByLabel(utils!, "visible.md")).toBeInTheDocument(); - expect(queryTreeItemByLabel(utils!, ".hidden.md")).toBeNull(); - expect(queryTreeItemByLabel(utils!, ".hidden-folder")).toBeNull(); - expect(queryTreeItemByLabel(utils!, "inside.md")).toBeNull(); - }); - - utils!.unmount(); - await lix.close(); - }); - - test("lists ephemeral watched directories and delegates watched-only file opens", async () => { - const lix = await openLix(); - const originalDesktop = window.flashtypeDesktop; - const openFile = vi.fn(); - const rootEntries = [ - { - id: "watched:/docs/", - parent_id: null, - path: "/docs/", - display_name: "docs", - kind: "directory" as const, - source: "watched" as const, - }, - { - id: "watched:/loose.txt", - parent_id: null, - path: "/loose.txt", - display_name: "loose.txt", - kind: "file" as const, - source: "watched" as const, - }, - ]; - const nestedEntries = [ - ...rootEntries, - { - id: "watched:/docs/nested.txt", - parent_id: "watched:/docs/", - path: "/docs/nested.txt", - display_name: "nested.txt", - kind: "file" as const, - source: "watched" as const, - }, - ]; - const setEphemeralWatchedDirectories = vi.fn( - async ({ paths }: { paths: string[] }) => - paths.includes("/docs/") ? nestedEntries : rootEntries, - ); - window.flashtypeDesktop = { - workspace: { - setEphemeralWatchedDirectories, - onEphemeralWatchedFileTreeChanged: vi.fn(() => () => {}), - }, - } as unknown as Window["flashtypeDesktop"]; - - let utils: ReturnType; - try { - await act(async () => { - utils = render( - - - - - , - ); - }); - - await findTreeItemByLabel(utils!, "docs"); - await findTreeItemByLabel(utils!, "loose.txt"); - expect(setEphemeralWatchedDirectories).toHaveBeenCalledWith({ - ownerId: "files-view:files-test", - paths: ["/"], - }); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "docs")); - }); - await waitFor(() => { - expect(queryTreeItemByLabel(utils!, "nested.txt")).toBeInTheDocument(); - }); - expect(setEphemeralWatchedDirectories).toHaveBeenCalledWith({ - ownerId: "files-view:files-test", - paths: ["/", "/docs/"], - }); - - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "loose.txt")); - }); - - await waitFor(async () => { - const row = await qb(lix) - .selectFrom("lix_file") - .select(["id", "path"]) - .where("path", "=", "/loose.txt") - .executeTakeFirst(); - expect(row).toBeUndefined(); - expect(openFile).toHaveBeenCalledWith({ - panel: "central", - fileId: "watched:/loose.txt", - filePath: "/loose.txt", - focus: false, - }); - }); - - utils!.unmount(); - } finally { - window.flashtypeDesktop = originalDesktop; - await lix.close(); - } - }); - - test("Cmd+Backspace resolves a watched file to its canonical Lix id", async () => { - const lix = await openLix(); - const originalDesktop = window.flashtypeDesktop; - const closeFileViews = vi.fn(); - const importFilesystemPaths = vi - .spyOn(lix, "importFilesystemPaths") - .mockImplementation(async ([path]) => { - if (!path) return; - await qb(lix) - .insertInto("lix_file") - .values({ - id: "imported_loose", - path: `/${path.replace(/^\/+/, "")}`, - data: new TextEncoder().encode("from disk"), - }) - .execute(); - }); - const rootEntries = [ - { - id: "watched:/loose.txt", - parent_id: null, - path: "/loose.txt", - display_name: "loose.txt", - kind: "file" as const, - source: "watched" as const, - }, - ]; - window.flashtypeDesktop = { - workspace: { - setEphemeralWatchedDirectories: vi.fn(async () => rootEntries), - onEphemeralWatchedFileTreeChanged: vi.fn(() => () => {}), - }, - } as unknown as Window["flashtypeDesktop"]; - - let utils: ReturnType | undefined; - try { - await act(async () => { - utils = render( - - - - - , - ); - }); - - await waitForFilesViewReady(utils!); - await act(async () => { - fireEvent.click(await findTreeItemByLabel(utils!, "loose.txt")); - }); - await act(async () => { - fireEvent.keyDown(document, { key: "Backspace", metaKey: true }); - }); - - await waitFor(async () => { - expect(importFilesystemPaths).toHaveBeenCalledWith(["loose.txt"]); - const file = await qb(lix) - .selectFrom("lix_file") - .select("id") - .where("path", "=", "/loose.txt") - .executeTakeFirst(); - expect(file).toBeUndefined(); - expect(queryTreeItemByLabel(utils!, "loose.txt")).toBeNull(); - }); - expect(closeFileViews).toHaveBeenCalledWith({ - fileId: "imported_loose", - }); - } finally { - utils?.unmount(); - window.flashtypeDesktop = originalDesktop; - await lix.close(); - } - }); - - test("renames watched-only files by importing them into Lix", async () => { - const lix = await openLix(); - const originalDesktop = window.flashtypeDesktop; - const openFile = vi.fn(); - const importFilesystemPaths = vi - .spyOn(lix, "importFilesystemPaths") - .mockImplementation(async ([path]) => { - if (!path) return; - await qb(lix) - .insertInto("lix_file") - .values({ - id: "imported_loose", - path, - data: new TextEncoder().encode("from disk"), - }) - .execute(); - }); - const rootEntries = [ - { - id: "watched:/loose.txt", - parent_id: null, - path: "/loose.txt", - display_name: "loose.txt", - kind: "file" as const, - source: "watched" as const, - }, - ]; - const setEphemeralWatchedDirectories = vi.fn(async () => rootEntries); - window.flashtypeDesktop = { - workspace: { - setEphemeralWatchedDirectories, - onEphemeralWatchedFileTreeChanged: vi.fn(() => () => {}), - }, - } as unknown as Window["flashtypeDesktop"]; - - let utils: ReturnType; - try { - await act(async () => { - utils = render( - - - - - , - ); - }); - - await findTreeItemByLabel(utils!, "loose.txt"); - const input = await startTreeRenameByLabel(utils!, "loose.txt"); - expect(input.value).toBe("loose.txt"); - openFile.mockClear(); - - await act(async () => { - fireEvent.input(input, { target: { value: "renamed.txt" } }); - }); - await act(async () => { - fireEvent.keyDown(input, { key: "Enter" }); - }); - - await waitFor(async () => { - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["id", "path", "data"]) - .execute(); - expect(rows.some((row) => row.path === "/loose.txt")).toBe(false); - expect( - rows.some( - (row) => row.id === "imported_loose" && row.path === "/renamed.txt", - ), - ).toBe(true); - expect(queryTreeItemByLabel(utils!, "loose.txt")).toBeNull(); - expect(queryTreeItemByLabel(utils!, "renamed.txt")).toBeInTheDocument(); - }); - expect(importFilesystemPaths).toHaveBeenCalledWith(["/loose.txt"]); - expect(openFile).toHaveBeenCalledWith({ - panel: "central", - fileId: "imported_loose", - filePath: "/renamed.txt", - focus: false, - trackTelemetry: false, - }); - - utils!.unmount(); - } finally { - window.flashtypeDesktop = originalDesktop; - await lix.close(); - } - }); - - test("renders the file tree inside a vertical scroll region", async () => { - const lix = await openLix(); - await qb(lix) - .insertInto("lix_file") - .values( - Array.from({ length: 20 }, (_, index) => ({ - id: `file_${index}`, - path: `/file-${String(index + 1).padStart(2, "0")}.md`, - data: new Uint8Array(), - })), - ) - .execute(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - - await findTreeItemByLabel(utils!, "file-01.md"); - - const scrollRegion = utils!.getByTestId("files-view-tree-scroll"); - expect(scrollRegion).toHaveClass("min-h-0"); - expect(scrollRegion).toHaveClass("flex-1"); - expect(scrollRegion).toHaveClass("overflow-y-auto"); - expect(scrollRegion).toHaveClass("overflow-x-hidden"); - - utils!.unmount(); - await lix.close(); - }); - - test("replaces whitespace with dashes when creating files", async () => { - const lix = await openLix(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - await waitForFilesViewReady(utils!); - - await waitForFilesViewReady(utils!); - - await act(async () => { - fireEvent.keyDown(document, { key: ".", metaKey: true }); - }); - - const input = await findTreeRenameInput(utils!); - await act(async () => { - fireEvent.input(input, { target: { value: "hello nice one" } }); - }); - - await act(async () => { - fireEvent.keyDown(input, { key: "Enter" }); - }); - - await waitFor(async () => { - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["path"]) - .execute(); - const userRows = rows.filter((row) => isUserPath(row.path)); - expect(userRows).toHaveLength(1); - expect(userRows[0]?.path).toBe("/hello-nice-one.md"); - }); - - await findTreeItemByLabel(utils!, "hello-nice-one.md"); - - utils!.unmount(); - await lix.close(); - }); - - test("creates an inline directory draft when Shift+Cmd+. is pressed", async () => { - const lix = await openLix(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - await waitForFilesViewReady(utils!); - - await waitForFilesViewReady(utils!); - - await act(async () => { - fireEvent.keyDown(document, { - key: ">", - code: "Period", - metaKey: true, - shiftKey: true, - }); - }); - - const input = await findTreeRenameInput(utils!); - expect(input.value).toBe("new-directory"); - - await act(async () => { - fireEvent.input(input, { target: { value: "docs" } }); - }); - - await act(async () => { - fireEvent.keyDown(input, { key: "Enter" }); - }); - - await waitFor(async () => { - const rows = await qb(lix) - .selectFrom("lix_directory") - .select(["path"]) - .execute(); - expect(rows.some((row) => row.path === "/docs/")).toBe(true); - }); - - utils!.unmount(); - await lix.close(); - }); - - test("ignores Ctrl+. on macOS", async () => { - const lix = await openLix(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - await waitForFilesViewReady(utils!); - - await waitForFilesViewReady(utils!); - - await act(async () => { - fireEvent.keyDown(document, { key: ".", ctrlKey: true }); - }); - - expect(queryTreeRenameInput(utils!)).toBeNull(); - - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["path"]) - .execute(); - expect(rows.filter((row) => isUserPath(row.path))).toHaveLength(0); - - utils!.unmount(); - await lix.close(); - }); - - test("ignores Ctrl+Shift+. on macOS", async () => { - const lix = await openLix(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - await waitForFilesViewReady(utils!); - - await waitForFilesViewReady(utils!); - - await act(async () => { - fireEvent.keyDown(document, { - key: ">", - code: "Period", - ctrlKey: true, - shiftKey: true, - }); - }); - - expect(queryTreeRenameInput(utils!)).toBeNull(); - - const rows = await qb(lix) - .selectFrom("lix_directory") - .select(["path"]) - .execute(); - expect(rows.filter((row) => isUserPath(row.path))).toHaveLength(0); - - utils!.unmount(); - await lix.close(); - }); - - test("cancels the draft when Escape is pressed", async () => { - const lix = await openLix(); - - let utils: ReturnType; - await act(async () => { - utils = render( - - - - - , - ); - }); - await waitForFilesViewReady(utils!); - - await waitForFilesViewReady(utils!); - - await act(async () => { - fireEvent.keyDown(document, { key: ".", metaKey: true }); - }); - - const input = await findTreeRenameInput(utils!); - - await act(async () => { - fireEvent.keyDown(input, { key: "Escape" }); - }); - - await waitFor(() => { - expect(queryTreeRenameInput(utils!)).toBeNull(); - }); - - const rows = await qb(lix) - .selectFrom("lix_file") - .select(["path"]) - .execute(); - expect(rows.filter((row) => isUserPath(row.path))).toHaveLength(0); - - utils!.unmount(); - await lix.close(); - }); -}); - -async function writeReviewFile( - lix: Awaited>, - id: string, - path: string, - text: string, -): Promise { - await qb(lix) - .insertInto("lix_file") - .values({ id, path, data: new TextEncoder().encode(text) }) - .onConflict((oc) => - oc.column("id").doUpdateSet({ - path, - data: new TextEncoder().encode(text), - }), - ) - .execute(); -} - -async function activeCommitId( - lix: Awaited>, -): 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, - }; -} - -function checkpointDiff( - overrides: Partial = {}, -): CheckpointDiff { - return { - branchId: "checkpoint-after", - branchName: "After", - beforeBranchId: "checkpoint-before", - beforeBranchName: "Before", - beforeCommitId: "before-commit", - afterCommitId: "after-commit", - files: [], - ...overrides, - }; -} - -function checkpointDiffFile( - overrides: Partial & - Pick, -): CheckpointDiffFile { - const { fileId, path, reviewId, ...rest } = overrides; - return { - fileId, - path, - beforePath: path, - afterPath: path, - beforeData: new TextEncoder().encode("before"), - afterData: new TextEncoder().encode("after"), - beforeCommitId: "before-commit", - afterCommitId: "after-commit", - reviewId, - status: "modified", - ...rest, - }; -} diff --git a/src/extensions/files/index.tsx b/src/extensions/files/index.tsx deleted file mode 100644 index 4b50777e..00000000 --- a/src/extensions/files/index.tsx +++ /dev/null @@ -1,1540 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ExternalLink, FileUp, FilePlus, Github } from "lucide-react"; -import { useLix, useQuery } from "@/lib/lix-react"; -import { isMarkdownFilePath } from "@/extension-runtime/file-handlers"; -import { selectFilesystemEntries } from "@/queries"; -import { - buildFilesystemTree, - type FilesystemTreeNode, - type FilesystemTreeSource, -} from "@/extensions/files/build-filesystem-tree"; -import type { ExtensionContext } from "../../extension-runtime/types"; -import { - FileTree, - type FileTreeCreateRequest, - type FileTreeRenameRequest, -} from "./file-tree"; -import { qb, sql } from "@/lib/lix-kysely"; -import type { FilesystemEntryRow } from "@/queries"; -import type { - CheckpointDiff, - CheckpointDiffBranchRow, - CheckpointDiffFile, - CheckpointDiffVisibleFile, -} from "@/extension-runtime/checkpoint-diff"; -import type { Lix } from "@/lib/lix-types"; -import { - AGENT_TURN_COMMIT_RANGE_KEY, - agentTurnCommitRangesFromValues, -} from "@/shell/agent-turn-review-range"; -import { - getPendingExternalWriteReviewPaths, - type ExternalWriteReviewFile, -} from "@/shell/external-write-review-history"; -import { resolveCheckpointDiffForBranch } from "@/shell/checkpoint-diff"; - -type FilesViewProps = { - readonly context?: ExtensionContext; -}; - -type FilesSelection = { - readonly path: string; - readonly fileId: string | null; - readonly kind: "file" | "directory"; - readonly source: FilesystemTreeSource; -}; - -type FilesSelectionOverride = { - /** The active selection this local choice was made against. */ - readonly activeSelectionKey: string | null; - readonly selection: FilesSelection | null; -}; - -/** - * Files view - Browse and pin project documents. Owns the Cmd/Ctrl + . shortcut - * that opens the inline creation prompt for a new markdown file. - * - * @example - * - */ -export function FilesView({ context }: FilesViewProps) { - const lix = useLix(); - const entries = useQuery((lix) => - selectFilesystemEntries(lix), - ); - return ( - - ); -} - -function FilesActiveFileLoader({ - context, - lix, - entries, -}: FilesViewProps & { - readonly lix: Lix; - readonly entries: FilesystemEntryRow[]; -}) { - const activeFileRows = useQuery<{ value: unknown }>((queryLix) => - qb(queryLix) - .selectFrom("lix_key_value") - .select("value") - .where("key", "=", "atelier_active_file_id"), - ); - const activeFileId = - typeof activeFileRows[0]?.value === "string" - ? activeFileRows[0].value - : null; - return ( - - ); -} - -function FilesCheckpointBranchesLoader({ - context, - lix, - entries, - activeFileId, -}: FilesViewProps & { - readonly lix: Lix; - readonly entries: FilesystemEntryRow[]; - readonly activeFileId: string | null; -}) { - // Keep the asynchronously resolved diff fresh when HEAD or checkpoint commits - // move while the same revision remains selected. - const checkpointBranches = useQuery((queryLix) => - qb(queryLix) - .selectFrom("lix_branch") - .select(["id", "name", "commit_id"]) - .where( - () => - sql`COALESCE(CAST(lix_branch.hidden AS TEXT), 'false') NOT IN ('true', '1', 't')`, - ) - .orderBy("name", "asc"), - ); - return ( - - ); -} - -function FilesRuntimeState({ - context, - lix, - entries, - activeFileId, - checkpointBranches, -}: FilesViewProps & { - readonly lix: Lix; - readonly entries: FilesystemEntryRow[]; - readonly activeFileId: string | null; - readonly checkpointBranches: readonly CheckpointDiffBranchRow[]; -}) { - const resolvedCheckpointDiff = useResolvedCheckpointDiff( - lix, - context?.checkpointBranchId ?? null, - checkpointBranches, - ); - return ( - {}), - activeFileId: context?.activeFileId ?? activeFileId, - checkpointDiff: context?.checkpointDiff ?? resolvedCheckpointDiff, - }} - lix={lix} - entries={entries} - /> - ); -} - -function useResolvedCheckpointDiff( - lix: Lix, - branchId: string | null, - checkpointBranches: readonly CheckpointDiffBranchRow[], -): CheckpointDiff | null { - const [resolved, setResolved] = useState<{ - readonly branchId: string; - readonly diff: CheckpointDiff | null; - } | null>(null); - useEffect(() => { - if (!branchId) return; - let cancelled = false; - void resolveCheckpointDiffForBranch({ lix, branchId }) - .then((diff) => { - if (!cancelled) setResolved({ branchId, diff }); - }) - .catch((error: unknown) => { - if (cancelled) return; - console.warn("Failed to resolve checkpoint files", error); - setResolved({ branchId, diff: null }); - }); - return () => { - cancelled = true; - }; - }, [branchId, checkpointBranches, lix]); - return branchId && resolved?.branchId === branchId ? resolved.diff : null; -} - -function FilesViewContent({ - context, - lix, - entries, -}: FilesViewProps & { - readonly lix: Lix; - readonly entries: FilesystemEntryRow[]; -}) { - const ownerIdRef = useRef( - `files-view:${context?.viewInstance ?? Math.random().toString(36).slice(2)}`, - ); - const isEphemeralWorkspace = context?.workspace?.ephemeral === true; - const [watchedEntries, setWatchedEntries] = useState( - [], - ); - const [upgradedWatchedFilePaths, setUpgradedWatchedFilePaths] = useState( - () => new Set(), - ); - const [openDirectoryPaths, setOpenDirectoryPaths] = useState( - () => new Set(), - ); - const visibleWatchedEntries = useMemo(() => { - if (!isEphemeralWorkspace) return []; - if (upgradedWatchedFilePaths.size === 0) return watchedEntries; - return watchedEntries.filter((entry) => { - if (entry.kind !== "file") return true; - return !upgradedWatchedFilePaths.has(filesystemEntryPathKey(entry)); - }); - }, [isEphemeralWorkspace, upgradedWatchedFilePaths, watchedEntries]); - const checkpointDiffEntries = useMemo( - () => - checkpointDiffFilesystemEntries( - context?.checkpointDiff?.visibleFiles ?? - context?.checkpointDiff?.files ?? - [], - ), - [context?.checkpointDiff?.files, context?.checkpointDiff?.visibleFiles], - ); - const combinedEntries = useMemo(() => { - if (context?.checkpointDiff) return checkpointDiffEntries; - return unionFilesystemEntries(entries ?? [], visibleWatchedEntries); - }, [ - context?.checkpointDiff, - entries, - visibleWatchedEntries, - checkpointDiffEntries, - ]); - const nodes = useMemo( - () => buildFilesystemTree(combinedEntries), - [combinedEntries], - ); - const pendingReviewPaths = usePendingExternalWriteReviewPaths(lix, nodes); - const checkpointReviewStatuses = useMemo( - () => - new Map( - (context?.checkpointDiff?.files ?? []).map( - (file) => [normalizeFilePath(file.path), file.status] as const, - ), - ), - [context?.checkpointDiff?.files], - ); - const reviewPaths = context?.checkpointDiff ? undefined : pendingReviewPaths; - const reviewStatuses = context?.checkpointDiff - ? checkpointReviewStatuses - : undefined; - const creatingRef = useRef(false); - const renamingRef = useRef(false); - const [pendingPaths, setPendingPaths] = useState([]); - const [pendingDirectoryPaths, setPendingDirectoryPaths] = useState( - [], - ); - const [createRequest, setCreateRequest] = - useState(null); - const nextCreateRequestIdRef = useRef(0); - const [selectionOverride, setSelectionOverride] = - useState(null); - const [isDraggingOver, setIsDraggingOver] = useState(false); - const dragCounterRef = useRef(0); - const entryPathSet = useMemo(() => { - return new Set( - (combinedEntries ?? []) - .filter((entry) => entry.kind === "file") - .map((entry) => entry.path), - ); - }, [combinedEntries]); - const entryDirectorySet = useMemo(() => { - return new Set( - (combinedEntries ?? []) - .filter((entry) => entry.kind === "directory") - .map((entry) => entry.path), - ); - }, [combinedEntries]); - const existingFilePaths = useMemo(() => { - const combined = new Set(entryPathSet); - for (const path of pendingPaths) { - combined.add(path); - } - return combined; - }, [entryPathSet, pendingPaths]); - const existingDirectoryPaths = useMemo(() => { - const combined = new Set(entryDirectorySet); - for (const path of pendingDirectoryPaths) { - combined.add(path); - } - return combined; - }, [entryDirectorySet, pendingDirectoryPaths]); - const activeFileId = - typeof context?.activeFileId === "string" && context.activeFileId.length > 0 - ? context.activeFileId - : null; - const activeFilePath = context?.activeFilePath ?? null; - const normalizedActiveFilePath = - typeof activeFilePath === "string" && activeFilePath.length > 0 - ? normalizeFilePath(activeFilePath) - : null; - const activeIdentity = activeFileId - ? `id:${activeFileId}` - : normalizedActiveFilePath - ? `path:${normalizedActiveFilePath}` - : null; - const activeEntry = activeFileId - ? combinedEntries.find( - (entry) => entry.kind === "file" && entry.id === activeFileId, - ) - : combinedEntries.find( - (entry) => - entry.kind === "file" && - filesystemEntryPathKey(entry) === normalizedActiveFilePath, - ); - const activeSelection = activeEntry - ? { - path: filesystemEntryPathKey(activeEntry), - fileId: activeEntry.id, - kind: "file" as const, - source: activeEntry.source ?? ("lix" as const), - } - : null; - const activeSelectionKey = activeIdentity - ? `${activeIdentity}:${activeSelection?.path ?? "missing"}` - : null; - const hasCurrentSelectionOverride = - selectionOverride?.activeSelectionKey === activeSelectionKey; - const selection = hasCurrentSelectionOverride - ? selectionOverride.selection - : activeSelection; - const selectedPath = selection?.path ?? null; - const selectedFileId = selection?.fileId ?? null; - const selectedKind = selection?.kind ?? null; - const selectedSource = selection?.source ?? null; - const activeSelectionPath = activeSelection?.path ?? null; - useEffect(() => { - setSelectionOverride((current) => { - if (!current || current.activeSelectionKey === activeSelectionKey) { - return current; - } - return null; - }); - }, [activeSelectionKey]); - useEffect(() => { - if (pendingPaths.length === 0) return; - setPendingPaths((prev) => prev.filter((path) => !entryPathSet.has(path))); - }, [entryPathSet, pendingPaths.length]); - useEffect(() => { - if (pendingDirectoryPaths.length === 0) return; - setPendingDirectoryPaths((prev) => - prev.filter((path) => !entryDirectorySet.has(path)), - ); - }, [entryDirectorySet, pendingDirectoryPaths.length]); - useEffect(() => { - if (createRequest || hasCurrentSelectionOverride || !activeSelectionPath) { - return; - } - setOpenDirectoryPaths((prev) => { - const ancestors = ancestorDirectoryPathsForFilePath(activeSelectionPath); - if (ancestors.length === 0) return prev; - const next = new Set(prev); - let changed = false; - for (const ancestor of ancestors) { - if (!next.has(ancestor)) { - next.add(ancestor); - changed = true; - } - } - return changed ? next : prev; - }); - }, [activeSelectionPath, createRequest, hasCurrentSelectionOverride]); - const isMacPlatform = useMemo(() => detectMacPlatform(), []); - const isPanelFocused = context?.isPanelFocused ?? false; - const registerNewFileDraftHandler = context?.registerNewFileDraftHandler; - const panelSide = context?.panelSide; - const viewInstance = context?.viewInstance; - const isActiveView = context?.isActiveView === true; - const openedEphemeralDirectoryPaths = useMemo(() => { - const paths = new Set(openDirectoryPaths); - paths.add("/"); - return [...paths].sort((left, right) => left.localeCompare(right)); - }, [openDirectoryPaths]); - - useEffect(() => { - if (!isEphemeralWorkspace) { - setWatchedEntries([]); - setUpgradedWatchedFilePaths(new Set()); - return; - } - const workspaceApi = window.flashtypeDesktop?.workspace; - if (!workspaceApi?.setEphemeralWatchedDirectories) { - return; - } - const ownerId = ownerIdRef.current; - let cancelled = false; - void workspaceApi - .setEphemeralWatchedDirectories({ - ownerId, - paths: openedEphemeralDirectoryPaths, - }) - .then((entries) => { - if (!cancelled) { - setWatchedEntries(entries); - } - }) - .catch((error: unknown) => { - if (!cancelled) { - console.warn("Failed to list transient workspace files", error); - } - }); - return () => { - cancelled = true; - }; - }, [isEphemeralWorkspace, openedEphemeralDirectoryPaths]); - - useEffect(() => { - if (!isEphemeralWorkspace) { - return; - } - return window.flashtypeDesktop?.workspace.onEphemeralWatchedFileTreeChanged?.( - (entries) => { - setWatchedEntries(entries); - }, - ); - }, [isEphemeralWorkspace]); - - useEffect(() => { - if (!isEphemeralWorkspace) { - return; - } - const workspaceApi = window.flashtypeDesktop?.workspace; - const ownerId = ownerIdRef.current; - return () => { - void workspaceApi?.setEphemeralWatchedDirectories?.({ - ownerId, - paths: [], - }); - }; - }, [isEphemeralWorkspace]); - - const setLocalSelection = useCallback( - (nextSelection: FilesSelection | null) => { - setSelectionOverride({ - activeSelectionKey, - selection: nextSelection, - }); - }, - [activeSelectionKey], - ); - - const resolveCreateDirectory = useCallback(() => { - if (!selectedPath) return "/"; - if (selectedPath.endsWith("/")) return selectedPath; - const parts = selectedPath.split("/").filter(Boolean); - if (parts.length <= 1) return "/"; - return `/${parts.slice(0, -1).join("/")}/`; - }, [selectedPath]); - - const startCreateRequest = useCallback( - (kind: "file" | "directory") => { - if (createRequest) return; - const baseDirectory = resolveCreateDirectory(); - const directoryPath = ensureDirectoryPath(baseDirectory); - setLocalSelection(null); - if (directoryPath !== "/") { - setOpenDirectoryPaths((openPaths) => { - const next = new Set(openPaths); - next.add(directoryPath); - return next; - }); - } - nextCreateRequestIdRef.current += 1; - setCreateRequest({ - directoryPath, - id: nextCreateRequestIdRef.current, - initialValue: kind === "directory" ? "new-directory" : "new-file", - kind, - }); - }, - [createRequest, resolveCreateDirectory, setLocalSelection], - ); - - const handleNewFile = useCallback(() => { - startCreateRequest("file"); - }, [startCreateRequest]); - - const handleCreateCancel = useCallback( - (request: FileTreeCreateRequest) => { - setCreateRequest((prev) => (prev?.id === request.id ? null : prev)); - setLocalSelection(null); - }, - [setLocalSelection], - ); - - const handleCreateCommit = useCallback( - async (request: FileTreeCreateRequest, value: string) => { - if (creatingRef.current) return; - const directoryPath = ensureDirectoryPath(request.directoryPath); - const clearRequest = () => { - setCreateRequest((prev) => (prev?.id === request.id ? null : prev)); - }; - const executeFileCreation = async () => { - const path = deriveMarkdownPathFromStem( - value, - directoryPath, - existingFilePaths, - ); - if (!path) { - clearRequest(); - return; - } - creatingRef.current = true; - try { - await qb(lix) - .insertInto("lix_file") - .values({ - path, - data: new TextEncoder().encode(""), - }) - .execute(); - const id = ( - await qb(lix) - .selectFrom("lix_file") - .select("id") - .where("path", "=", path) - .executeTakeFirst() - )?.id; - if (!id) { - throw new Error(`created file id not found for path '${path}'`); - } - setPendingPaths((prev) => [...prev, path]); - setLocalSelection({ - path, - fileId: id, - kind: "file", - source: "lix", - }); - context?.openFile?.({ - panel: "central", - fileId: id, - filePath: path, - state: { focusOnLoad: true, defaultBlock: "heading1" }, - focus: true, - documentOrigin: "new", - }); - } catch (error) { - console.error("Failed to create file", error); - } finally { - creatingRef.current = false; - clearRequest(); - } - }; - - const executeDirectoryCreation = async () => { - const path = deriveDirectoryPathFromStem( - value, - directoryPath, - existingDirectoryPaths, - ); - if (!path) { - clearRequest(); - return; - } - creatingRef.current = true; - try { - await qb(lix) - .insertInto("lix_directory") - .values({ path } as any) - .execute(); - setPendingDirectoryPaths((prev) => [...prev, path]); - setLocalSelection({ - path, - fileId: null, - kind: "directory", - source: "lix", - }); - } catch (error) { - console.error("Failed to create directory", error); - } finally { - creatingRef.current = false; - clearRequest(); - } - }; - - if (request.kind === "directory") { - return executeDirectoryCreation(); - } - return executeFileCreation(); - }, - [ - context, - existingDirectoryPaths, - existingFilePaths, - lix, - setLocalSelection, - ], - ); - - const handleCreateDirectory = useCallback(() => { - startCreateRequest("directory"); - }, [startCreateRequest]); - - const handleRenameCommit = useCallback( - async (request: FileTreeRenameRequest) => { - if (renamingRef.current) return; - if (request.source === "checkpoint-diff") return; - const sourcePath = - request.kind === "directory" - ? ensureDirectoryPath(request.sourcePath) - : normalizeFilePath(request.sourcePath); - const destinationPath = - request.kind === "directory" - ? ensureDirectoryPath(request.destinationPath) - : normalizeFilePath(request.destinationPath); - if (sourcePath === destinationPath) return; - - const destinationExists = - request.kind === "directory" - ? existingDirectoryPaths.has(destinationPath) - : existingFilePaths.has(destinationPath); - if (destinationExists) { - console.warn(`Cannot rename '${sourcePath}' to '${destinationPath}'`); - return; - } - - renamingRef.current = true; - try { - if (request.kind === "directory") { - await qb(lix) - .updateTable("lix_directory") - .set({ path: destinationPath } as any) - .where("path", "=", sourcePath) - .execute(); - setOpenDirectoryPaths((prev) => - remapDirectoryPathSet(prev, sourcePath, destinationPath), - ); - setPendingDirectoryPaths((prev) => - remapDirectoryPaths(prev, sourcePath, destinationPath), - ); - setPendingPaths((prev) => - remapFilePathsInDirectory(prev, sourcePath, destinationPath), - ); - setLocalSelection({ - path: destinationPath, - fileId: null, - kind: "directory", - source: "lix", - }); - return; - } - - let resolvedFileId = request.source === "watched" ? null : request.id; - if (request.source === "watched") { - await lix.importFilesystemPaths([sourcePath]); - const importedFile = await qb(lix) - .selectFrom("lix_file") - .select("id") - .where("path", "=", sourcePath) - .executeTakeFirst(); - if (!importedFile?.id) { - throw new Error( - `imported watched file id not found for path '${sourcePath}'`, - ); - } - resolvedFileId = importedFile.id as string; - } - await qb(lix) - .updateTable("lix_file") - .set({ path: destinationPath } as any) - .where("path", "=", sourcePath) - .execute(); - setPendingPaths((prev) => - appendUniquePath( - remapFilePaths(prev, sourcePath, destinationPath), - destinationPath, - ), - ); - if (request.source === "watched") { - setUpgradedWatchedFilePaths((prev) => { - const next = new Set(prev); - next.add(sourcePath); - return next; - }); - } - setLocalSelection({ - path: destinationPath, - fileId: resolvedFileId ?? null, - kind: "file", - source: "lix", - }); - if (resolvedFileId) { - void context?.openFile?.({ - panel: "central", - fileId: resolvedFileId, - filePath: destinationPath, - focus: false, - trackTelemetry: false, - }); - } - } catch (error) { - console.error("Failed to rename entry", error); - } finally { - renamingRef.current = false; - } - }, - [ - context, - existingDirectoryPaths, - existingFilePaths, - lix, - setLocalSelection, - ], - ); - - const handleCreateShortcut = useCallback( - (kind: "file" | "directory") => { - if (kind === "directory") { - handleCreateDirectory(); - return; - } - handleNewFile(); - }, - [handleCreateDirectory, handleNewFile], - ); - - useEffect(() => { - if (!registerNewFileDraftHandler || !panelSide || !viewInstance) { - return; - } - return registerNewFileDraftHandler({ - panelSide, - viewInstance, - isActiveView, - handler: handleNewFile, - }); - }, [ - handleNewFile, - isActiveView, - panelSide, - registerNewFileDraftHandler, - viewInstance, - ]); - - const handleOpenFile = useCallback( - (fileId: string, path: string) => { - const checkpointVisibleFile = context?.checkpointDiff - ? ( - context.checkpointDiff.visibleFiles ?? context.checkpointDiff.files - ).find((file) => file.fileId === fileId) - : undefined; - setLocalSelection({ - path, - fileId, - kind: "file", - source: checkpointVisibleFile ? "checkpoint-diff" : "lix", - }); - void context?.openFile?.({ - panel: "central", - fileId, - filePath: path, - state: checkpointVisibleFile - ? { - beforeCommitId: context?.checkpointDiff?.beforeCommitId, - afterCommitId: context?.checkpointDiff?.afterIsActiveHead - ? null - : context?.checkpointDiff?.afterCommitId, - } - : undefined, - focus: false, - trackTelemetry: checkpointVisibleFile ? false : undefined, - trackDocumentOpenAttempt: checkpointVisibleFile ? false : undefined, - trackDocumentViewed: checkpointVisibleFile ? false : undefined, - }); - }, - [context, setLocalSelection], - ); - - const handleOpenDirectoriesChange = useCallback( - (next: ReadonlySet) => { - setOpenDirectoryPaths((prev) => { - const nextPaths = new Set([...next].map(ensureDirectoryPath)); - const closedPaths = [...prev].filter((path) => !nextPaths.has(path)); - for (const closedPath of closedPaths) { - const closedPrefix = ensureDirectoryPath(closedPath); - for (const path of [...nextPaths]) { - if (path !== closedPrefix && path.startsWith(closedPrefix)) { - nextPaths.delete(path); - } - } - } - return nextPaths; - }); - }, - [], - ); - - const handleSelectItem = useCallback( - ( - path: string, - kind: "file" | "directory", - source?: FilesystemTreeSource, - ) => { - setLocalSelection({ - path, - fileId: kind === "directory" ? null : selectedFileId, - kind, - source: source ?? "lix", - }); - }, - [selectedFileId, setLocalSelection], - ); - - const handleDeleteSelection = useCallback(async () => { - if (!selectedPath || !selectedKind) return; - if (selectedSource === "checkpoint-diff") return; - const normalizedPath = - selectedKind === "file" - ? selectedPath - : ensureDirectoryPath(selectedPath); - try { - if (selectedKind === "file") { - if (!selectedFileId) return; - let fileId = selectedFileId; - if ( - selectedSource === "watched" || - selectedFileId.startsWith("watched:") - ) { - let canonicalFile = await qb(lix) - .selectFrom("lix_file") - .select("id") - .where("path", "=", normalizedPath) - .executeTakeFirst(); - if (!canonicalFile?.id) { - await lix.importFilesystemPaths([ - normalizedPath.replace(/^\/+/, ""), - ]); - canonicalFile = await qb(lix) - .selectFrom("lix_file") - .select("id") - .where("path", "=", normalizedPath) - .executeTakeFirst(); - } - if (!canonicalFile?.id) { - throw new Error( - `Imported file id not found for '${normalizedPath}'.`, - ); - } - fileId = canonicalFile.id as string; - setUpgradedWatchedFilePaths((prev) => { - const next = new Set(prev); - next.add(normalizedPath); - return next; - }); - } - await qb(lix).deleteFrom("lix_file").where("id", "=", fileId).execute(); - setPendingPaths((prev) => - prev.filter((path) => path !== normalizedPath), - ); - context?.closeFileViews?.({ fileId }); - } else { - await qb(lix) - .deleteFrom("lix_directory") - .where("path", "=", normalizedPath) - .execute(); - setPendingDirectoryPaths((prev) => - prev.filter((path) => path !== normalizedPath), - ); - } - } catch (error) { - console.error("Failed to delete entry", error); - } finally { - setLocalSelection(null); - } - }, [ - context, - lix, - selectedFileId, - selectedKind, - selectedPath, - selectedSource, - setLocalSelection, - ]); - - useEffect(() => { - const listener = (event: KeyboardEvent) => { - const usesPrimaryModifier = isMacPlatform - ? event.metaKey && !event.ctrlKey - : event.ctrlKey && !event.metaKey; - if (!usesPrimaryModifier || event.altKey) return; - const isDeleteKey = - event.key === "Backspace" || - event.code?.toLowerCase() === "backspace" || - event.key === "Delete" || - event.code?.toLowerCase() === "delete"; - if (isDeleteKey) { - const shouldHandleDelete = !isInteractiveEventTarget(event); - if (!shouldHandleDelete) return; - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation?.(); - event.returnValue = false; - if ( - event.type === "keydown" && - !event.repeat && - !event.shiftKey && - shouldHandleDelete - ) { - void handleDeleteSelection(); - } - return; - } - const isTrigger = - event.key === "." || event.code?.toLowerCase() === "period"; - if (!isTrigger) return; - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation?.(); - event.returnValue = false; - if ( - event.type === "keydown" && - !event.repeat && - !isInteractiveEventTarget(event) - ) { - const kind = event.shiftKey ? "directory" : "file"; - handleCreateShortcut(kind); - } - }; - - const options: AddEventListenerOptions = { capture: true, passive: false }; - const eventTypes: Array<"keydown" | "keypress" | "keyup"> = [ - "keydown", - "keypress", - "keyup", - ]; - const targets: EventTarget[] = [window, document]; - if (document.body) { - targets.push(document.body); - } - for (const target of targets) { - for (const type of eventTypes) { - target.addEventListener(type, listener as EventListener, options); - } - } - return () => { - for (const target of targets) { - for (const type of eventTypes) { - target.removeEventListener(type, listener as EventListener, options); - } - } - }; - }, [handleCreateShortcut, handleDeleteSelection, isMacPlatform]); - - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - dragCounterRef.current += 1; - if (e.dataTransfer.types.includes("Files")) { - setIsDraggingOver(true); - } - }, []); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.dataTransfer) { - e.dataTransfer.dropEffect = "copy"; - } - }, []); - - const handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - dragCounterRef.current -= 1; - if (dragCounterRef.current === 0) { - setIsDraggingOver(false); - } - }, []); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - dragCounterRef.current = 0; - setIsDraggingOver(false); - - const files = Array.from(e.dataTransfer.files); - if (files.length === 0) return; - - // Filter for markdown files only - const markdownFiles = files.filter((file) => - isMarkdownFilePath(file.name), - ); - - if (markdownFiles.length === 0) { - alert( - "Only markdown files (.md) are supported at the moment.\n\nOpen an issue on GitHub for support for CSV, PDF, etc: https://github.com/opral/flashtype/issues", - ); - return; - } - - // Process each markdown file - for (const file of markdownFiles) { - try { - const content = await file.text(); - - const extension = - file.name.match(/\.(md|markdown)$/i)?.[0]?.toLowerCase() === - ".markdown" - ? ".markdown" - : ".md"; - const baseName = normalizeNameStem( - file.name.replace(/\.(md|markdown)$/i, ""), - ); - let filePath = `/${baseName}${extension}`; - - let counter = 2; - while (existingFilePaths.has(filePath)) { - filePath = `/${baseName}-${counter}${extension}`; - counter += 1; - } - - // Add to pending paths immediately for UI feedback - setPendingPaths((prev) => [...prev, filePath]); - - // Create the file in lix - await qb(lix) - .insertInto("lix_file") - .values({ - path: filePath, - data: new TextEncoder().encode(content), - }) - .execute(); - // Open the first dropped file - if (file === markdownFiles[0]) { - const newFile = await qb(lix) - .selectFrom("lix_file") - .select("id") - .where("path", "=", filePath) - .executeTakeFirst(); - - if (newFile?.id) { - context?.openFile?.({ - panel: "central", - fileId: newFile.id as string, - filePath, - documentOrigin: "new", - }); - } - } - } catch (error) { - console.error(`Failed to add file ${file.name}:`, error); - alert(`Failed to add ${file.name}. Please try again.`); - } - } - }, - [existingFilePaths, lix, context], - ); - - return ( -
- {/* New file button row - hidden when creation is active */} - {!createRequest && ( - - )} - {isDraggingOver && ( -
- -

- Drop markdown files here -

-

- Only .md and .markdown files supported -

-

- Open{" "} - - {" "} - for support for CSV, PDF, etc -

-
- )} -
- -
-
- ); -} - -function usePendingExternalWriteReviewPaths( - lix: Lix, - nodes: readonly FilesystemTreeNode[], -): ReadonlySet { - const reviewableFiles = useMemo( - () => collectReviewableTreeFiles(nodes), - [nodes], - ); - const [pendingPaths, setPendingPaths] = useState>( - () => new Set(), - ); - 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; - // A mutation can land between the initial badge query and observer - // startup, making the first snapshot the only notification for it. - setReviewRevision((current) => current + 1); - } - }; - void watchEvents(activeBranchEvents); - void watchEvents(reviewRangeEvents); - return () => { - cancelled = true; - activeBranchEvents.close(); - reviewRangeEvents.close(); - }; - }, [lix]); - - useEffect(() => { - let cancelled = false; - if (reviewableFiles.length === 0) { - setPendingPaths((prev) => (prev.size === 0 ? prev : new Set())); - return; - } - void (async () => { - 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(); - const ranges = agentTurnCommitRangesFromValues( - rangeRows.map((row) => row.value), - ); - if (ranges.length === 0) { - if (!cancelled) { - setPendingPaths((prev) => (prev.size === 0 ? prev : new Set())); - } - return; - } - const nextPaths = await getPendingExternalWriteReviewPaths( - lix, - reviewableFiles, - ranges, - ); - if (cancelled) return; - setPendingPaths((prev) => - sameStringSet(prev, nextPaths) ? prev : nextPaths, - ); - })().catch((error: unknown) => { - if (cancelled) return; - console.warn("Failed to resolve pending file reviews", error); - setPendingPaths((prev) => (prev.size === 0 ? prev : new Set())); - }); - return () => { - cancelled = true; - }; - }, [lix, reviewRevision, reviewableFiles]); - - return pendingPaths; -} - -function collectReviewableTreeFiles( - nodes: readonly FilesystemTreeNode[], -): ExternalWriteReviewFile[] { - const files: ExternalWriteReviewFile[] = []; - const visit = (node: FilesystemTreeNode) => { - if (node.type === "file") { - if (node.source !== "watched" && node.source !== "checkpoint-diff") { - files.push({ fileId: node.id, path: node.path }); - } - return; - } - for (const child of node.children) { - visit(child); - } - }; - for (const node of nodes) { - visit(node); - } - return files; -} - -function sameStringSet( - left: ReadonlySet, - right: ReadonlySet, -): boolean { - if (left.size !== right.size) return false; - for (const value of left) { - if (!right.has(value)) return false; - } - return true; -} - -function isInteractiveTarget(target: EventTarget | null): boolean { - if (!target || !(target instanceof HTMLElement)) { - return false; - } - if (target.isContentEditable) return true; - const tagName = target.tagName; - if (tagName === "INPUT" || tagName === "TEXTAREA") { - return true; - } - return Boolean(target.closest("input, textarea, [contenteditable]")); -} - -function isInteractiveEventTarget(event: Event): boolean { - for (const target of event.composedPath?.() ?? []) { - if (isInteractiveTarget(target)) return true; - } - return isInteractiveTarget(event.target); -} - -function detectMacPlatform(): boolean { - if (typeof navigator === "undefined") return false; - const platformCandidates = [ - ((navigator as any).userAgentData?.platform as string | undefined) ?? null, - navigator.platform ?? null, - navigator.userAgent ?? null, - ].filter(Boolean) as string[]; - const combined = platformCandidates.join(" ").toLowerCase(); - return /mac|iphone|ipad|ipod/.test(combined); -} - -function deriveMarkdownPathFromStem( - stem: string, - directory: string, - existingPaths: Set, -): string | null { - const finalStem = normalizeNameStem(stem); - const sanitizedDirectory = - directory === "/" - ? "/" - : directory.endsWith("/") - ? directory - : `${directory}/`; - const primary = `${sanitizedDirectory}${finalStem}.md`; - if (!existingPaths.has(primary)) { - return primary; - } - let suffix = 2; - while (suffix < 1000) { - const candidate = `${sanitizedDirectory}${finalStem}-${suffix}.md`; - if (!existingPaths.has(candidate)) { - return candidate; - } - suffix += 1; - } - return null; -} - -function deriveDirectoryPathFromStem( - stem: string, - directory: string, - existingPaths: Set, -): string | null { - const finalStem = normalizeNameStem(stem); - const sanitizedDirectory = - directory === "/" - ? "/" - : directory.endsWith("/") - ? directory - : `${directory}/`; - const primary = `${sanitizedDirectory}${finalStem}/`; - if (!existingPaths.has(primary)) { - return primary; - } - let suffix = 2; - while (suffix < 1000) { - const candidate = `${sanitizedDirectory}${finalStem}-${suffix}/`; - if (!existingPaths.has(candidate)) { - return candidate; - } - suffix += 1; - } - return null; -} - -function normalizeNameStem(stem: string): string { - const normalized = (stem ?? "").trim(); - const slashSafe = normalized.replace(/\/+/g, "-"); - const collapsedWhitespace = slashSafe.replace(/\s+/g, "-"); - if ( - collapsedWhitespace.length === 0 || - collapsedWhitespace === "." || - collapsedWhitespace === ".." - ) { - return "untitled"; - } - return collapsedWhitespace; -} - -function ensureDirectoryPath(path: string): string { - if (path === "/") return "/"; - return path.endsWith("/") ? path : `${path}/`; -} - -function normalizeFilePath(path: string): string { - return path.endsWith("/") ? path.slice(0, -1) : path; -} - -function ancestorDirectoryPathsForFilePath(path: string): string[] { - const segments = normalizeFilePath(path).split("/").filter(Boolean); - segments.pop(); - const ancestors: string[] = []; - for (let index = 1; index <= segments.length; index += 1) { - ancestors.push(`/${segments.slice(0, index).join("/")}/`); - } - return ancestors; -} - -function remapDirectoryPath( - path: string, - sourcePath: string, - destinationPath: string, -): string { - const source = ensureDirectoryPath(sourcePath); - const destination = ensureDirectoryPath(destinationPath); - const normalized = ensureDirectoryPath(path); - if (normalized === source) return destination; - if (normalized.startsWith(source)) { - return `${destination}${normalized.slice(source.length)}`; - } - return normalized; -} - -function remapFilePath( - path: string, - sourcePath: string, - destinationPath: string, -): string { - const source = normalizeFilePath(sourcePath); - const destination = normalizeFilePath(destinationPath); - const normalized = normalizeFilePath(path); - return normalized === source ? destination : normalized; -} - -function remapFilePathInDirectory( - path: string, - sourcePath: string, - destinationPath: string, -): string { - const source = ensureDirectoryPath(sourcePath); - const destination = ensureDirectoryPath(destinationPath); - const normalized = normalizeFilePath(path); - if (normalized.startsWith(source)) { - return `${destination}${normalized.slice(source.length)}`; - } - return normalized; -} - -function remapDirectoryPathSet( - paths: ReadonlySet, - sourcePath: string, - destinationPath: string, -): Set { - return new Set( - [...paths].map((path) => - remapDirectoryPath(path, sourcePath, destinationPath), - ), - ); -} - -function remapDirectoryPaths( - paths: readonly string[], - sourcePath: string, - destinationPath: string, -): string[] { - return paths.map((path) => - remapDirectoryPath(path, sourcePath, destinationPath), - ); -} - -function remapFilePaths( - paths: readonly string[], - sourcePath: string, - destinationPath: string, -): string[] { - return paths.map((path) => remapFilePath(path, sourcePath, destinationPath)); -} - -function remapFilePathsInDirectory( - paths: readonly string[], - sourcePath: string, - destinationPath: string, -): string[] { - return paths.map((path) => - remapFilePathInDirectory(path, sourcePath, destinationPath), - ); -} - -function appendUniquePath(paths: readonly string[], path: string): string[] { - return paths.includes(path) ? [...paths] : [...paths, path]; -} - -function unionFilesystemEntries( - lixEntries: readonly FilesystemEntryRow[], - watchedEntries: readonly FilesystemEntryRow[], -): FilesystemEntryRow[] { - const entriesByPath = new Map(); - for (const entry of watchedEntries) { - entriesByPath.set(filesystemEntryPathKey(entry), { - ...entry, - path: filesystemEntryPathKey(entry), - source: "watched", - }); - } - for (const entry of lixEntries) { - entriesByPath.set(filesystemEntryPathKey(entry), { - ...entry, - path: filesystemEntryPathKey(entry), - source: "lix", - }); - } - return [...entriesByPath.values()].sort((left, right) => - left.path.localeCompare(right.path), - ); -} - -function checkpointDiffFilesystemEntries( - files: readonly (CheckpointDiffFile | CheckpointDiffVisibleFile)[], -): FilesystemEntryRow[] { - if (files.length === 0) return []; - const entriesByPath = new Map(); - for (const file of files) { - const path = normalizeFilePath(file.path); - for (const directoryPath of ancestorDirectoryPathsForFilePath(path)) { - if (entriesByPath.has(directoryPath)) continue; - entriesByPath.set(directoryPath, { - id: `checkpoint-diff-dir:${directoryPath}`, - parent_id: null, - path: directoryPath, - display_name: leafNameFromPath(directoryPath), - kind: "directory", - source: "checkpoint-diff", - }); - } - entriesByPath.set(path, { - id: file.fileId, - parent_id: null, - path, - display_name: leafNameFromPath(path), - kind: "file", - source: "checkpoint-diff", - }); - } - return [...entriesByPath.values()]; -} - -function leafNameFromPath(path: string): string { - const normalized = path.endsWith("/") ? path.slice(0, -1) : path; - const segments = normalized.split("/").filter(Boolean); - return segments.at(-1) ?? ""; -} - -function filesystemEntryPathKey(entry: FilesystemEntryRow): string { - if (entry.kind === "directory") { - return ensureDirectoryPath(entry.path); - } - return entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path; -} diff --git a/src/extensions/history/history.manifest.json b/src/extensions/history/history.manifest.json deleted file mode 100644 index 23b80130..00000000 --- a/src/extensions/history/history.manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "apiVersion": 1, - "id": "atelier_history", - "name": "History", - "description": "Create, name, review, and restore local checkpoints." -} diff --git a/src/extensions/history/host-extension.test.tsx b/src/extensions/history/host-extension.test.tsx deleted file mode 100644 index 42a1fa8b..00000000 --- a/src/extensions/history/host-extension.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { act } from "@testing-library/react"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import type { AtelierExtensionRuntime } from "@opral/atelier"; - -const historyViewMock = vi.hoisted(() => - vi.fn((_props: Record) => null), -); -vi.mock("./index", () => ({ HistoryView: historyViewMock })); - -import { createHistoryExtensionRegistration } from "./host-extension"; - -describe("createHistoryExtensionRegistration", () => { - afterEach(() => { - document.body.replaceChildren(); - vi.clearAllMocks(); - }); - - test("registers and disposes the FlashType-owned history view", async () => { - const registration = createHistoryExtensionRegistration(); - const element = document.createElement("div"); - document.body.append(element); - const controller = new AbortController(); - const show = vi.fn(async () => null); - const clear = vi.fn(); - const atelier = { - lix: {}, - revisions: { current: null, show, clear }, - } as unknown as AtelierExtensionRuntime; - - let mounted: ReturnType; - await act(async () => { - mounted = registration.entry.mount({ - element, - atelier, - view: { - instanceId: "flashtype-history-1", - state: {}, - panel: "left", - isActive: true, - isFocused: true, - registerNewFileDraftHandler: () => () => {}, - }, - signal: controller.signal, - }); - }); - - expect(registration.manifest.id).toBe("atelier_history"); - expect(registration.manifest.name).toBe("History"); - expect(historyViewMock).toHaveBeenCalledOnce(); - expect(historyViewMock.mock.calls.at(-1)?.[0]).toEqual( - expect.objectContaining({ - currentRevision: null, - showCheckpointDiff: show, - clearCheckpointDiff: clear, - }), - ); - - await act(async () => { - mounted?.dispose?.(); - }); - }); -}); diff --git a/src/extensions/history/host-extension.tsx b/src/extensions/history/host-extension.tsx deleted file mode 100644 index d596535c..00000000 --- a/src/extensions/history/host-extension.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { createRoot } from "react-dom/client"; -import { History } from "lucide-react"; -import type { - AtelierBuiltinExtensionId, - AtelierExtensionRegistration, - AtelierExtensionRuntime, - ExtensionManifest, -} from "@opral/atelier"; -import { LixProvider } from "@/lib/lix-react"; -import type { Lix } from "@/lib/lix-types"; -import { HistoryView } from "./index"; -import manifestJson from "./history.manifest.json"; - -const manifest = { - ...manifestJson, - id: "atelier_history" satisfies AtelierBuiltinExtensionId, -} as ExtensionManifest; - -/** - * FlashType owns this history view because checkpoint creation synchronizes the - * Electron filesystem and checkpoint naming calls the Electron agent bridge. - */ -export function createHistoryExtensionRegistration(): AtelierExtensionRegistration { - return { - manifest, - entry: { - icon: History, - mount: ({ element, atelier }) => { - const root = createRoot(element); - renderHistoryView(root, atelier); - return { - update: ({ atelier: nextAtelier }) => - renderHistoryView(root, nextAtelier), - dispose: () => root.unmount(), - }; - }, - }, - }; -} - -function renderHistoryView( - root: ReturnType, - atelier: AtelierExtensionRuntime, -) { - root.render( - - - , - ); -} diff --git a/src/extensions/history/index.test.tsx b/src/extensions/history/index.test.tsx deleted file mode 100644 index 02f6c931..00000000 --- a/src/extensions/history/index.test.tsx +++ /dev/null @@ -1,499 +0,0 @@ -import { Suspense, type ComponentProps } from "react"; -import { describe, expect, test, beforeEach, afterEach, vi } from "vitest"; -import { qb } from "@/lib/lix-kysely"; -import { - act, - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import { LixProvider } from "@/lib/lix-react"; -import { openLix, type Lix } from "@/test-utils/node-lix-sdk"; -import { HistoryView } from "."; - -const originalDesktop = window.flashtypeDesktop; -const TIMESTAMP_CHECKPOINT_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/u; - -function selectedCheckpointButtons(): HTMLButtonElement[] { - return [ - ...document.querySelectorAll( - '[data-attr="branch-diff"][data-selected="true"]', - ), - ]; -} - -async function writeLixFile( - lix: Lix, - id: string, - path: string, - text: string, -): Promise { - await qb(lix) - .insertInto("lix_file") - .values({ id, path, data: new TextEncoder().encode(text) }) - .onConflict((oc) => - oc.column("id").doUpdateSet({ - path, - data: new TextEncoder().encode(text), - }), - ) - .execute(); -} - -describe("HistoryView", () => { - let lix: Lix; - let cleanupFns: Array<() => Promise> = []; - - const renderWithProviders = async ( - props: ComponentProps = {}, - ) => { - await act(async () => { - render( - - - - - , - ); - }); - }; - - beforeEach(async () => { - lix = await openLix({}); - cleanupFns.push(() => lix.close()); - - const activeBranchId = await lix.activeBranchId(); - - await qb(lix) - .updateTable("lix_branch") - .set({ name: "main" }) - .where("id", "=", activeBranchId) - .execute(); - }); - - afterEach(async () => { - vi.restoreAllMocks(); - window.flashtypeDesktop = originalDesktop; - cleanup(); - - for (const fn of cleanupFns.splice(0)) { - await fn(); - } - }); - - test("renders main as the current checkpoint", async () => { - await renderWithProviders(); - - const activeCheckpoint = await screen.findByRole("button", { - name: "Current Checkpoint", - }); - expect(activeCheckpoint).toHaveAttribute("aria-current", "true"); - expect(activeCheckpoint).not.toHaveAttribute("data-selected"); - expect(selectedCheckpointButtons()).toHaveLength(0); - }); - - test("clicking another branch without review mode does not restore it", async () => { - const initialActiveBranchId = await lix.activeBranchId(); - const draftName = `draft-${Math.random().toString(36).slice(2, 7)}`; - await lix.createBranch({ name: draftName }); - - await renderWithProviders(); - - const draftItem = await screen.findByRole("button", { name: draftName }); - expect(draftItem).toHaveAttribute("data-attr", "branch-diff"); - expect(draftItem).not.toHaveAttribute("aria-current"); - - await act(async () => { - fireEvent.click(draftItem); - }); - - expect(draftItem).not.toHaveAttribute("data-selected"); - expect(draftItem).not.toHaveAttribute("aria-current"); - expect(selectedCheckpointButtons()).toHaveLength(0); - - const active = await qb(lix) - .selectFrom("lix_key_value") - .where("key", "=", "lix_workspace_branch_id") - .select("value") - .executeTakeFirstOrThrow(); - expect(active.value).toBe(initialActiveBranchId); - }); - - test("passes the selected branch id to the revision API", async () => { - const branchB = await lix.createBranch({ name: "b-checkpoint" }); - await lix.createBranch({ name: "a-checkpoint" }); - const showCheckpointDiff = vi.fn().mockResolvedValue(undefined); - - await renderWithProviders({ showCheckpointDiff }); - - const checkpoint = await screen.findByRole("button", { - name: "b-checkpoint", - }); - await act(async () => { - fireEvent.click(checkpoint); - }); - - expect(showCheckpointDiff).toHaveBeenCalledTimes(1); - expect(showCheckpointDiff).toHaveBeenCalledWith(branchB.id); - expect(checkpoint).not.toHaveAttribute("data-selected"); - expect(selectedCheckpointButtons()).toHaveLength(0); - }); - - test("clicking the active checkpoint diff again clears it", async () => { - const target = await lix.createBranch({ name: "selected-checkpoint" }); - const clearCheckpointDiff = vi.fn(); - - await renderWithProviders({ - currentRevision: { branchId: target.id }, - clearCheckpointDiff, - }); - - const checkpoint = await screen.findByRole("button", { - name: "selected-checkpoint", - }); - const currentCheckpoint = await screen.findByRole("button", { - name: "Current Checkpoint", - }); - await waitFor(() => { - expect(checkpoint).toHaveAttribute("data-selected", "true"); - }); - expect(checkpoint).not.toHaveAttribute("aria-current"); - expect(currentCheckpoint).toHaveAttribute("aria-current", "true"); - expect(currentCheckpoint).not.toHaveAttribute("data-selected"); - - await act(async () => { - fireEvent.click(checkpoint); - }); - - expect(clearCheckpointDiff).toHaveBeenCalledTimes(1); - }); - - test("restores another branch via actions menu", async () => { - const draftName = `draft-${Math.random().toString(36).slice(2, 7)}`; - const newBranch = await lix.createBranch({ name: draftName }); - - await renderWithProviders(); - - const actionsButton = await screen.findByRole("button", { - name: `Checkpoint actions for ${draftName}`, - }); - await act(async () => { - fireEvent.pointerDown(actionsButton, { button: 0 }); - fireEvent.pointerUp(actionsButton, { button: 0 }); - }); - - const restoreItem = await screen.findByRole("menuitem", { - name: "Restore", - }); - expect(restoreItem).toHaveAttribute("data-attr", "branch-switch"); - await act(async () => { - fireEvent.click(restoreItem); - }); - - const draftItem = await screen.findByRole("button", { name: draftName }); - await waitFor(() => { - expect(draftItem).toHaveAttribute("aria-current", "true"); - }); - - await waitFor(async () => { - const active = await qb(lix) - .selectFrom("lix_key_value") - .where("key", "=", "lix_workspace_branch_id") - .select("value") - .executeTakeFirstOrThrow(); - expect(active.value).toBe(newBranch.id); - }); - }); - - test("creates a timestamp branch and prefixes generated names after a delay", async () => { - const initialActiveBranchId = await lix.activeBranchId(); - const branchCreateCalls: string[] = []; - const originalCreateBranch = lix.createBranch.bind(lix); - const syncDiskToLix = vi - .spyOn(lix, "syncDiskToLix") - .mockImplementation(async () => { - branchCreateCalls.push("sync"); - }); - const createBranch = vi - .spyOn(lix, "createBranch") - .mockImplementation(async (options) => { - branchCreateCalls.push("create"); - return await originalCreateBranch(options); - }); - const workspaceDir = vi.fn().mockResolvedValue("/tmp/flashtype-workspace"); - const generateCheckpointName = vi.fn().mockResolvedValue({ - name: "Update onboarding copy", - source: "codex", - }); - window.flashtypeDesktop = { - lix: { - workspaceDir, - }, - terminal: { - generateCheckpointName, - }, - } as unknown as Window["flashtypeDesktop"]; - await writeLixFile( - lix, - "file_onboarding", - "/onboarding.md", - "# Onboarding\nWelcome to the updated flow.\n", - ); - - await renderWithProviders(); - const createButton = await screen.findByRole("button", { - name: "Create checkpoint", - }); - const realSetTimeout = globalThis.setTimeout.bind(globalThis); - let runScheduledRename: (() => void) | null = null; - const setTimeoutSpy = vi - .spyOn(window, "setTimeout") - .mockImplementation((handler, timeout, ...args) => { - if (timeout === 5000 && typeof handler === "function") { - runScheduledRename = () => { - handler(...args); - }; - const timerId = realSetTimeout(() => undefined, 0); - globalThis.clearTimeout(timerId); - return timerId; - } - return realSetTimeout(handler, timeout, ...args); - }); - await act(async () => { - fireEvent.click(createButton); - }); - - let created: { id: string; name: string } | undefined; - await waitFor(() => { - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); - }); - await waitFor(async () => { - const branches = await qb(lix) - .selectFrom("lix_branch") - .select(["id", "name"]) - .execute(); - created = branches.find((branch) => - TIMESTAMP_CHECKPOINT_PATTERN.test(branch.name), - ); - expect(created).toBeDefined(); - }); - - expect( - screen.getByRole("button", { name: "Current Checkpoint" }), - ).toHaveAttribute("aria-current", "true"); - expect(syncDiskToLix).toHaveBeenCalledTimes(1); - expect(createBranch).toHaveBeenCalledTimes(1); - expect(branchCreateCalls).toEqual(["sync", "create"]); - expect( - await screen.findByRole("button", { name: "Naming checkpoint..." }), - ).toBeInTheDocument(); - - const active = await qb(lix) - .selectFrom("lix_key_value") - .where("key", "=", "lix_workspace_branch_id") - .select("value") - .executeTakeFirstOrThrow(); - expect(active.value).toBe(initialActiveBranchId); - - await act(async () => { - runScheduledRename?.(); - }); - - await waitFor(async () => { - const renamed = await qb(lix) - .selectFrom("lix_branch") - .select("name") - .where("id", "=", created?.id ?? "") - .executeTakeFirstOrThrow(); - expect(renamed.name).toBe(`${created?.name}:Update onboarding copy`); - }); - expect(workspaceDir).toHaveBeenCalled(); - expect(generateCheckpointName).toHaveBeenCalledWith({ - cwd: "/tmp/flashtype-workspace", - diffContext: expect.stringContaining("/onboarding.md"), - }); - const diffContext = generateCheckpointName.mock.calls[0]?.[0]?.diffContext; - expect(diffContext).toContain("File: added /onboarding.md"); - expect(diffContext).toContain("Added excerpt"); - expect(diffContext).toContain("Welcome to the updated flow."); - expect( - await screen.findByRole("button", { name: "Update onboarding copy" }), - ).toBeInTheDocument(); - }); - - test("falls back to a local timestamp checkpoint name without the desktop bridge", async () => { - window.flashtypeDesktop = undefined; - vi.spyOn(lix, "syncDiskToLix").mockResolvedValue(); - - await renderWithProviders(); - const createButton = await screen.findByRole("button", { - name: "Create checkpoint", - }); - const realSetTimeout = globalThis.setTimeout.bind(globalThis); - let runScheduledRename: (() => void) | null = null; - vi.spyOn(window, "setTimeout").mockImplementation( - (handler, timeout, ...args) => { - if (timeout === 5000 && typeof handler === "function") { - runScheduledRename = () => { - handler(...args); - }; - const timerId = realSetTimeout(() => undefined, 0); - globalThis.clearTimeout(timerId); - return timerId; - } - return realSetTimeout(handler, timeout, ...args); - }, - ); - await act(async () => { - fireEvent.click(createButton); - }); - - let created: { id: string; name: string } | undefined; - await waitFor(async () => { - const branches = await qb(lix) - .selectFrom("lix_branch") - .select(["id", "name"]) - .execute(); - created = branches.find((branch) => - TIMESTAMP_CHECKPOINT_PATTERN.test(branch.name), - ); - expect(created).toBeDefined(); - }); - expect( - await screen.findByRole("button", { name: "Naming checkpoint..." }), - ).toBeInTheDocument(); - - await act(async () => { - runScheduledRename?.(); - }); - - await waitFor(async () => { - const renamed = await qb(lix) - .selectFrom("lix_branch") - .select("name") - .where("id", "=", created?.id ?? "") - .executeTakeFirstOrThrow(); - expect(renamed.name).toBe(created?.name); - }); - }); - - test("renames a branch via actions menu", async () => { - const baseName = `docs-${Math.random().toString(36).slice(2, 7)}`; - const renamedName = `${baseName}-renamed`; - const target = await lix.createBranch({ name: baseName }); - const promptSpy = vi.fn().mockReturnValue(renamedName); - vi.stubGlobal("prompt", promptSpy); - - await renderWithProviders(); - - const actionsButton = await screen.findByRole("button", { - name: `Checkpoint actions for ${baseName}`, - }); - await act(async () => { - fireEvent.pointerDown(actionsButton, { button: 0 }); - fireEvent.pointerUp(actionsButton, { button: 0 }); - }); - - const renameItem = await screen.findByRole("menuitem", { - name: "Rename checkpoint", - }); - expect(renameItem).toHaveAttribute("data-attr", "branch-rename"); - await act(async () => { - fireEvent.click(renameItem); - }); - - expect(promptSpy).toHaveBeenCalledWith("Rename checkpoint", baseName); - await waitFor(() => { - expect(screen.getByText(renamedName)).toBeInTheDocument(); - }); - - const row = await qb(lix) - .selectFrom("lix_branch") - .select(["id", "name"]) - .where("id", "=", target.id) - .executeTakeFirstOrThrow(); - expect(row.name).toBe(renamedName); - }); - - test("deletes a branch via actions menu", async () => { - const tempName = `temp-${Math.random().toString(36).slice(2, 7)}`; - const target = await lix.createBranch({ name: tempName }); - const confirmSpy = vi.fn().mockReturnValue(true); - vi.stubGlobal("confirm", confirmSpy); - - await renderWithProviders(); - - const actionsButton = await screen.findByRole("button", { - name: `Checkpoint actions for ${tempName}`, - }); - await act(async () => { - fireEvent.pointerDown(actionsButton, { button: 0 }); - fireEvent.pointerUp(actionsButton, { button: 0 }); - }); - - const deleteItem = await screen.findByRole("menuitem", { - name: "Delete checkpoint", - }); - expect(deleteItem).toHaveAttribute("data-attr", "branch-delete"); - await act(async () => { - fireEvent.click(deleteItem); - }); - - expect(confirmSpy).toHaveBeenCalledWith( - `Delete checkpoint "${tempName}"? This will hide it from the list.`, - ); - await waitFor(() => { - expect( - screen.queryByRole("button", { name: tempName }), - ).not.toBeInTheDocument(); - }); - - const row = await qb(lix) - .selectFrom("lix_branch") - .select(["id", "hidden"]) - .where("id", "=", target.id) - .executeTakeFirstOrThrow(); - expect(row.hidden).toBeTruthy(); - - const activeBranchId = await lix.activeBranchId(); - expect(activeBranchId).not.toBe(target.id); - - confirmSpy.mockRestore(); - }); - - test("delete action is disabled for active branch", async () => { - await renderWithProviders(); - - const actionsButton = await screen.findByRole("button", { - name: "Checkpoint actions for Current Checkpoint", - }); - await act(async () => { - fireEvent.pointerDown(actionsButton, { button: 0 }); - fireEvent.pointerUp(actionsButton, { button: 0 }); - }); - - const deleteItem = await screen.findByRole("menuitem", { - name: "Delete checkpoint", - }); - expect(deleteItem).toHaveAttribute("data-disabled"); - }); - - test("restore action is disabled for active branch", async () => { - await renderWithProviders(); - - const actionsButton = await screen.findByRole("button", { - name: "Checkpoint actions for Current Checkpoint", - }); - await act(async () => { - fireEvent.pointerDown(actionsButton, { button: 0 }); - fireEvent.pointerUp(actionsButton, { button: 0 }); - }); - - const restoreItem = await screen.findByRole("menuitem", { - name: "Restore", - }); - expect(restoreItem).toHaveAttribute("data-disabled"); - }); -}); diff --git a/src/extensions/history/index.tsx b/src/extensions/history/index.tsx deleted file mode 100644 index 4e7d705b..00000000 --- a/src/extensions/history/index.tsx +++ /dev/null @@ -1,638 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import clsx from "clsx"; -import { - History as HistoryIcon, - Loader2, - MoreVertical, - PenLine, - Plus, - RotateCcw, - Trash2, -} from "lucide-react"; -import { useLix, useQuery, useQueryTakeFirstOrThrow } from "@/lib/lix-react"; -import { qb, sql } from "@/lib/lix-kysely"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import type { - AtelierRevisionSelection, - AtelierExtensionRuntime, -} from "@opral/atelier"; -import type { - CheckpointDiff, - CheckpointDiffBranchRow, - CheckpointDiffFile, -} from "../../extension-runtime/checkpoint-diff"; -import { resolveCheckpointDiff } from "@/shell/checkpoint-diff"; - -type BranchRow = { - id: string; - name: string; - hidden: boolean | null; - commit_id: string | null; -}; - -type HistoryViewProps = { - readonly currentRevision?: AtelierRevisionSelection | null; - readonly showCheckpointDiff?: AtelierExtensionRuntime["revisions"]["show"]; - readonly clearCheckpointDiff?: () => void; -}; - -const CURRENT_CHECKPOINT_NAME = "main"; -const CURRENT_CHECKPOINT_LABEL = "Current Checkpoint"; -const UNNAMED_CHECKPOINT_LABEL = "Naming checkpoint..."; -const CHECKPOINT_RENAME_DELAY_MS = 5000; -const MAX_CHECKPOINT_DIFF_CONTEXT_LENGTH = 10_000; -const MAX_CHECKPOINT_DIFF_CONTEXT_FILES = 12; -const MAX_CHECKPOINT_DIFF_SNIPPET_LINES = 8; -const MAX_CHECKPOINT_DIFF_SNIPPET_LINE_LENGTH = 160; -const BRANCH_TIMESTAMP_NAME_PATTERN = - /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?::(.*))?$/u; - -type GeneratedCheckpointName = { - readonly name: string; - readonly source: "codex" | "claude" | "timestamp"; -}; - -function displayBranchName(branchName: string): string { - if (branchName === "") { - return UNNAMED_CHECKPOINT_LABEL; - } - if (branchName === CURRENT_CHECKPOINT_NAME) { - return CURRENT_CHECKPOINT_LABEL; - } - const timestampBranch = BRANCH_TIMESTAMP_NAME_PATTERN.exec(branchName); - if (timestampBranch) { - const generatedName = timestampBranch[2]?.trim(); - return generatedName || UNNAMED_CHECKPOINT_LABEL; - } - return branchName; -} - -/** - * Full-height checkpoint history view for the left pane. - * - * @example - * - */ -export function HistoryView(props: HistoryViewProps = {}) { - const lix = useLix(); - return ; -} - -function HistoryBranchesLoader({ - lix, - ...props -}: HistoryViewProps & { - readonly lix: ReturnType; -}) { - const branches = useQuery((lix) => - qb(lix) - .selectFrom("lix_branch") - .select(["id", "name", "hidden", "commit_id"]) - .where( - () => - sql`COALESCE(CAST(lix_branch.hidden AS TEXT), 'false') NOT IN ('true', '1', 't')`, - ) - .orderBy("name", "asc"), - ); - return ; -} - -function HistoryActiveBranchLoader({ - lix, - branches, - currentRevision, - showCheckpointDiff, - clearCheckpointDiff, -}: HistoryViewProps & { - readonly lix: ReturnType; - readonly branches: BranchRow[]; -}) { - const activeBranch = useQueryTakeFirstOrThrow<{ value: string }>((lix) => - qb(lix) - .selectFrom("lix_key_value") - .where("key", "=", "lix_workspace_branch_id") - .select(["value"]), - ); - return ( - - ); -} - -function HistoryViewContent({ - lix, - branches, - activeBranchId, - currentRevision, - showCheckpointDiff, - clearCheckpointDiff, -}: { - readonly lix: ReturnType; - readonly branches: BranchRow[]; - readonly activeBranchId: string; - readonly currentRevision?: AtelierRevisionSelection | null; - readonly showCheckpointDiff?: AtelierExtensionRuntime["revisions"]["show"]; - readonly clearCheckpointDiff?: () => void; -}) { - const activeBranchRow = - branches.find((branch) => branch.id === activeBranchId) ?? - ({ - id: activeBranchId, - name: activeBranchId, - hidden: false, - commit_id: null, - } satisfies BranchRow); - - const [pendingAction, setPendingAction] = useState(null); - const renameTimerIdsRef = useRef>(new Set()); - - useEffect(() => { - const renameTimerIds = renameTimerIdsRef.current; - return () => { - for (const timerId of renameTimerIds) { - window.clearTimeout(timerId); - } - renameTimerIds.clear(); - }; - }, []); - - const handleSwitch = useCallback( - async (branchId: string) => { - if (!lix || branchId === activeBranchRow.id) return; - setPendingAction(branchId); - try { - await lix.switchBranch({ branchId }); - clearCheckpointDiff?.(); - } catch (error) { - console.error("Failed to switch branch", error); - } finally { - setPendingAction(null); - } - }, - [lix, activeBranchRow.id, clearCheckpointDiff], - ); - - const handleSelectBranch = useCallback( - async (branchId: string) => { - if (currentRevision?.branchId === branchId) { - clearCheckpointDiff?.(); - return; - } - if (!showCheckpointDiff) return; - setPendingAction(branchId); - try { - await showCheckpointDiff(branchId); - } catch (error) { - console.error("Failed to resolve checkpoint diff", error); - clearCheckpointDiff?.(); - } finally { - setPendingAction(null); - } - }, - [currentRevision?.branchId, clearCheckpointDiff, showCheckpointDiff], - ); - - const handleCreateBranch = useCallback(async () => { - if (!lix) return; - setPendingAction("create"); - try { - const timestamp = formatLocalTimestamp(); - await lix.syncDiskToLix(); - const created = await lix.createBranch({ - name: timestamp, - }); - const timerId = window.setTimeout(() => { - renameTimerIdsRef.current.delete(timerId); - void buildCheckpointNameDiffContextForBranch(lix, created.id) - .then((diffContext) => generateCheckpointName({ diffContext })) - .then((checkpointName) => { - const generatedName = checkpointName.name.trim(); - if (!generatedName || checkpointName.source === "timestamp") { - return; - } - return qb(lix) - .updateTable("lix_branch") - .set({ name: `${timestamp}:${generatedName}` }) - .where("id", "=", created.id) - .where("name", "=", timestamp) - .execute(); - }) - .catch((error) => { - console.error("Failed to rename branch", error); - }); - }, CHECKPOINT_RENAME_DELAY_MS); - renameTimerIdsRef.current.add(timerId); - } catch (error) { - console.error("Failed to create branch", error); - } finally { - setPendingAction(null); - } - }, [lix]); - - const handleRenameBranch = useCallback( - async (branchId: string, currentName: string) => { - const entered = window.prompt("Rename checkpoint", currentName); - if (entered === null) return; - const trimmed = entered.trim(); - if (trimmed === "" || trimmed === currentName) return; - setPendingAction(branchId); - try { - await qb(lix) - .updateTable("lix_branch") - .set({ name: trimmed }) - .where("id", "=", branchId) - .execute(); - } catch (error) { - console.error("Failed to rename branch", error); - } finally { - setPendingAction(null); - } - }, - [lix], - ); - - const handleDeleteBranch = useCallback( - async (branchId: string, branchName: string) => { - if (branchId === activeBranchRow.id) { - window.alert("Cannot delete the active checkpoint."); - return; - } - const confirmed = window.confirm( - `Delete checkpoint "${branchName}"? This will hide it from the list.`, - ); - if (!confirmed) return; - setPendingAction(branchId); - const currentActiveId = activeBranchRow.id; - try { - await qb(lix) - .updateTable("lix_branch") - .set({ hidden: true }) - .where("id", "=", branchId) - .execute(); - if (currentActiveId) { - await lix.switchBranch({ branchId: currentActiveId }); - } - } catch (error) { - console.error("Failed to delete branch", error); - } finally { - setPendingAction(null); - } - }, - [lix, activeBranchRow.id], - ); - - const isBusy = pendingAction !== null; - - return ( -
-
-
- - - Checkpoints - -
- -
-
- {branches.length === 0 ? ( -
- No checkpoints available -
- ) : ( -
- {branches.map((branch) => { - const isActive = branch.id === activeBranchRow.id; - const isReviewing = currentRevision?.branchId === branch.id; - const isRestoreDisabled = isActive; - const isDeleteDisabled = isActive; - const branchDisplayName = displayBranchName(branch.name); - const isPending = pendingAction === branch.id; - return ( -
- - - - - - - { - if (isRestoreDisabled) return; - void handleSwitch(branch.id); - }} - disabled={isRestoreDisabled} - > - - Restore - - { - event.preventDefault(); - void handleRenameBranch(branch.id, branchDisplayName); - }} - > - - Rename checkpoint - - { - if (isDeleteDisabled) return; - void handleDeleteBranch(branch.id, branchDisplayName); - }} - disabled={isDeleteDisabled} - > - - Delete checkpoint - - - -
- ); - })} -
- )} -
-
- ); -} - -async function generateCheckpointName(args: { - readonly diffContext?: string; -}): Promise { - const desktop = window.flashtypeDesktop; - const terminal = desktop?.terminal; - if (desktop?.lix && terminal?.generateCheckpointName) { - try { - const cwd = await desktop.lix.workspaceDir(); - const result = await terminal.generateCheckpointName({ - cwd, - diffContext: args.diffContext, - }); - const name = result.name.trim(); - if (name) { - return { name, source: result.source }; - } - } catch (error) { - console.warn("Failed to generate checkpoint name", error); - } - } - return { name: formatLocalTimestamp(), source: "timestamp" }; -} - -async function buildCheckpointNameDiffContextForBranch( - lix: ReturnType, - branchId: string, -): Promise { - try { - const branches = await loadVisibleCheckpointBranches(lix); - const diff = await resolveCheckpointDiff({ lix, branches, branchId }); - return buildCheckpointNameDiffContext(diff); - } catch (error) { - console.warn("Failed to build checkpoint name diff context", error); - return buildCheckpointNameDiffContext(null); - } -} - -async function loadVisibleCheckpointBranches( - lix: ReturnType, -): Promise { - return (await qb(lix) - .selectFrom("lix_branch") - .select(["id", "name", "commit_id"]) - .where( - () => - sql`COALESCE(CAST(lix_branch.hidden AS TEXT), 'false') NOT IN ('true', '1', 't')`, - ) - .orderBy("name", "asc") - .execute()) as CheckpointDiffBranchRow[]; -} - -export function buildCheckpointNameDiffContext( - diff: CheckpointDiff | null | undefined, -): string { - if (!diff || diff.files.length === 0) { - return "No file changes were detected."; - } - - const lines = [ - `Checkpoint diff: ${displayBranchName(diff.beforeBranchName)} -> ${displayBranchName(diff.branchName)}`, - `Files changed: ${diff.files.length} (${formatStatusCounts(diff.files)})`, - ]; - const files = diff.files.slice(0, MAX_CHECKPOINT_DIFF_CONTEXT_FILES); - for (const file of files) { - lines.push("", ...summarizeCheckpointDiffFile(file)); - } - if (diff.files.length > files.length) { - lines.push("", `${diff.files.length - files.length} more files omitted.`); - } - - const context = lines.join("\n"); - if (context.length <= MAX_CHECKPOINT_DIFF_CONTEXT_LENGTH) { - return context; - } - return `${context.slice(0, MAX_CHECKPOINT_DIFF_CONTEXT_LENGTH).trimEnd()}\n[diff context truncated]`; -} - -function formatStatusCounts(files: readonly CheckpointDiffFile[]): string { - const counts = new Map(); - for (const file of files) { - counts.set(file.status, (counts.get(file.status) ?? 0) + 1); - } - return [...counts.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([status, count]) => `${count} ${status}`) - .join(", "); -} - -function summarizeCheckpointDiffFile(file: CheckpointDiffFile): string[] { - const beforeText = decodeUtf8ForCheckpointSummary(file.beforeData); - const afterText = decodeUtf8ForCheckpointSummary(file.afterData); - const lines = [ - `File: ${file.status} ${formatCheckpointDiffPath(file)}`, - `Bytes: ${file.beforeData.byteLength} -> ${file.afterData.byteLength}`, - ]; - if (beforeText === null || afterText === null) { - lines.push("Content: binary or non-UTF-8; text excerpt unavailable."); - return lines; - } - - const beforeLines = splitCheckpointSummaryLines(beforeText); - const afterLines = splitCheckpointSummaryLines(afterText); - lines.push(`Lines: ${beforeLines.length} -> ${afterLines.length}`); - if (file.status === "added") { - lines.push(...formatCheckpointSnippet("Added excerpt", afterLines)); - return lines; - } - if (file.status === "deleted") { - lines.push(...formatCheckpointSnippet("Deleted excerpt", beforeLines)); - return lines; - } - - const changed = checkpointChangedLineWindow(beforeLines, afterLines); - if (changed.before.length === 0 && changed.after.length === 0) { - lines.push("Content: unchanged; path or file identity changed."); - return lines; - } - lines.push(...formatCheckpointSnippet("Before excerpt", changed.before)); - lines.push(...formatCheckpointSnippet("After excerpt", changed.after)); - return lines; -} - -function formatCheckpointDiffPath(file: CheckpointDiffFile): string { - if (file.beforePath && file.afterPath && file.beforePath !== file.afterPath) { - return `${file.beforePath} -> ${file.afterPath}`; - } - return file.path; -} - -function decodeUtf8ForCheckpointSummary(data: Uint8Array): string | null { - try { - return new TextDecoder("utf-8", { fatal: true }).decode(data); - } catch { - return null; - } -} - -function splitCheckpointSummaryLines(text: string): string[] { - if (text.length === 0) return []; - return text.replace(/\r\n?/gu, "\n").split("\n"); -} - -function checkpointChangedLineWindow( - beforeLines: readonly string[], - afterLines: readonly string[], -): { - readonly before: readonly string[]; - readonly after: readonly string[]; -} { - let prefix = 0; - while ( - prefix < beforeLines.length && - prefix < afterLines.length && - beforeLines[prefix] === afterLines[prefix] - ) { - prefix += 1; - } - let suffix = 0; - while ( - suffix + prefix < beforeLines.length && - suffix + prefix < afterLines.length && - beforeLines[beforeLines.length - 1 - suffix] === - afterLines[afterLines.length - 1 - suffix] - ) { - suffix += 1; - } - return { - before: beforeLines.slice(prefix, beforeLines.length - suffix), - after: afterLines.slice(prefix, afterLines.length - suffix), - }; -} - -function formatCheckpointSnippet( - label: string, - lines: readonly string[], -): string[] { - if (lines.length === 0) { - return [`${label}: `]; - } - const preview = lines.slice(0, MAX_CHECKPOINT_DIFF_SNIPPET_LINES); - const formatted = [ - `${label}${lines.length > preview.length ? ` (${preview.length} of ${lines.length} changed lines)` : ""}:`, - ...preview.map( - (line) => ` ${truncateCheckpointSnippetLine(line) || ""}`, - ), - ]; - return formatted; -} - -function truncateCheckpointSnippetLine(line: string): string { - if (line.length <= MAX_CHECKPOINT_DIFF_SNIPPET_LINE_LENGTH) { - return line; - } - return `${line.slice(0, MAX_CHECKPOINT_DIFF_SNIPPET_LINE_LENGTH).trimEnd()}...`; -} - -function formatLocalTimestamp(date = new Date()): string { - const pad = (value: number) => String(value).padStart(2, "0"); - return [ - `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`, - `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`, - ].join(" "); -} diff --git a/src/extensions/markdown/index.test.tsx b/src/extensions/markdown/index.test.tsx index 453793e2..461cc6cd 100644 --- a/src/extensions/markdown/index.test.tsx +++ b/src/extensions/markdown/index.test.tsx @@ -593,89 +593,6 @@ describe("MarkdownView", () => { }); }); - test("renders checkpoint diffs without review controls for missing active files", async () => { - const lix = await openLix(); - let utils: ReturnType | undefined; - await qb(lix) - .insertInto("lix_file") - .values({ - id: "file_checkpoint", - path: "/checkpoint.md", - data: new TextEncoder().encode("# Before"), - }) - .execute(); - const beforeCommitId = await activeCommitId(lix); - await qb(lix) - .updateTable("lix_file") - .set({ data: new TextEncoder().encode("# After") }) - .where("id", "=", "file_checkpoint") - .execute(); - const afterCommitId = await activeCommitId(lix); - await qb(lix) - .deleteFrom("lix_file") - .where("id", "=", "file_checkpoint") - .execute(); - - await act(async () => { - utils = render( - - - - - - - , - ); - }); - - await waitFor(() => { - expect( - utils!.container.querySelector(".markdown-review-overlay"), - ).toBeInTheDocument(); - expect( - utils!.container.querySelector("[data-diff-status]"), - ).toBeInTheDocument(); - }); - expect(screen.queryByRole("button", { name: /keep/i })).toBeNull(); - expect(screen.queryByRole("button", { name: /undo/i })).toBeNull(); - expect(screen.queryByTestId("tiptap-editor")).not.toBeInTheDocument(); - - await act(async () => { - utils?.unmount(); - }); - await lix.close(); - }); - test("shows a not found message when the file is missing", async () => { const lix = await openLix(); diff --git a/src/extensions/markdown/index.tsx b/src/extensions/markdown/index.tsx index ee6d068e..59f24920 100644 --- a/src/extensions/markdown/index.tsx +++ b/src/extensions/markdown/index.tsx @@ -32,10 +32,6 @@ import type { ExternalWriteReview, ExternalWriteReviewData, } from "@/extension-runtime/external-write-review"; -import type { - CheckpointDiff, - CheckpointDiffFile, -} from "@/extension-runtime/checkpoint-diff"; import { editorRevisionMode, editorRevisionReviewId, @@ -56,7 +52,6 @@ type MarkdownViewProps = { readonly focusOnLoad?: boolean; readonly defaultBlock?: EmptyMarkdownDefaultBlock; readonly syncActiveFile?: boolean; - readonly checkpointDiff?: CheckpointDiff | null; readonly beforeCommitId?: string | null; readonly afterCommitId?: string | null; readonly registerExternalWriteReview?: ( @@ -120,7 +115,6 @@ export function MarkdownView({ focusOnLoad = false, defaultBlock, syncActiveFile = true, - checkpointDiff, beforeCommitId, afterCommitId, registerExternalWriteReview, @@ -137,7 +131,6 @@ export function MarkdownView({ focusOnLoad={focusOnLoad} defaultBlock={defaultBlock} syncActiveFile={syncActiveFile} - checkpointDiff={checkpointDiff} beforeCommitId={beforeCommitId} afterCommitId={afterCommitId} registerExternalWriteReview={registerExternalWriteReview} @@ -173,7 +166,6 @@ function MarkdownViewLoaded({ focusOnLoad = false, defaultBlock, syncActiveFile = true, - checkpointDiff, beforeCommitId, afterCommitId, registerExternalWriteReview, @@ -196,7 +188,6 @@ function MarkdownViewLoaded({ isActiveView={isActiveView} isPanelFocused={isPanelFocused} syncActiveFile={syncActiveFile} - checkpointDiff={checkpointDiff} editorRevision={editorRevision} /> ); @@ -297,7 +288,6 @@ function MarkdownHistoricalViewLoaded({ fileRow, isActiveView, isPanelFocused, - checkpointDiff, editorRevision, }: { readonly fileId: string; @@ -306,26 +296,20 @@ function MarkdownHistoricalViewLoaded({ readonly isActiveView: boolean; readonly isPanelFocused: boolean; readonly syncActiveFile: boolean; - readonly checkpointDiff: CheckpointDiff | null | undefined; readonly editorRevision: EditorRevisionState; }) { const revisionMode = editorRevisionMode(editorRevision); - const checkpointDiffFile = useMemo( - () => checkpointDiffFileForRevision(checkpointDiff, fileId, editorRevision), - [checkpointDiff, editorRevision, fileId], - ); const beforeSnapshot = useHistoricalFileSnapshot( fileId, - checkpointDiffFile ? null : editorRevision.beforeCommitId, + editorRevision.beforeCommitId, ); const afterSnapshot = useHistoricalFileSnapshot( fileId, - checkpointDiffFile ? null : editorRevision.afterCommitId, + editorRevision.afterCommitId, ); const historicalSnapshotsLoaded = - Boolean(checkpointDiffFile) || - ((!editorRevision.beforeCommitId || beforeSnapshot.loaded) && - (!editorRevision.afterCommitId || afterSnapshot.loaded)); + (!editorRevision.beforeCommitId || beforeSnapshot.loaded) && + (!editorRevision.afterCommitId || afterSnapshot.loaded); const historicalFile = useMemo( () => historicalSnapshotsLoaded @@ -334,14 +318,12 @@ function MarkdownHistoricalViewLoaded({ filePath, fileRow, revision: editorRevision, - checkpointDiffFile, beforeSnapshot: beforeSnapshot.snapshot, afterSnapshot: afterSnapshot.snapshot, }) : null, [ beforeSnapshot.snapshot, - checkpointDiffFile, editorRevision, fileId, filePath, @@ -811,32 +793,11 @@ function visibleHistoricalSnapshot( }; } -function checkpointDiffFileForRevision( - checkpointDiff: CheckpointDiff | null | undefined, - fileId: string, - revision: EditorRevisionState, -): CheckpointDiffFile | null { - if (!checkpointDiff) return null; - return ( - checkpointDiff.files.find((file) => { - const afterCommitId = checkpointDiff.afterIsActiveHead - ? null - : file.afterCommitId; - return ( - file.fileId === fileId && - file.beforeCommitId === revision.beforeCommitId && - afterCommitId === revision.afterCommitId - ); - }) ?? null - ); -} - function buildHistoricalMarkdownFile(args: { readonly fileId: string; readonly filePath: string | undefined; readonly fileRow: MarkdownFileRow | undefined; readonly revision: EditorRevisionState; - readonly checkpointDiffFile: CheckpointDiffFile | null; readonly beforeSnapshot: HistoricalFileSnapshotRow | undefined; readonly afterSnapshot: HistoricalFileSnapshotRow | undefined; }): HistoricalMarkdownFile | null { @@ -844,7 +805,6 @@ function buildHistoricalMarkdownFile(args: { if (mode === "editor") return null; const path = - args.checkpointDiffFile?.path ?? args.afterSnapshot?.path ?? args.beforeSnapshot?.path ?? args.fileRow?.path ?? @@ -852,11 +812,9 @@ function buildHistoricalMarkdownFile(args: { if (!path) return null; if (mode === "snapshot") { - const data = args.checkpointDiffFile - ? args.checkpointDiffFile.afterData - : args.afterSnapshot - ? decodeFileDataToBytes(args.afterSnapshot.data) - : null; + const data = args.afterSnapshot + ? decodeFileDataToBytes(args.afterSnapshot.data) + : null; if (!data) return null; return { fileRow: { @@ -869,20 +827,16 @@ function buildHistoricalMarkdownFile(args: { }; } - const beforeData = - args.checkpointDiffFile?.beforeData ?? - (args.beforeSnapshot - ? decodeFileDataToBytes(args.beforeSnapshot.data) - : EMPTY_FILE_DATA); - const afterData = - args.checkpointDiffFile?.afterData ?? - (args.revision.afterCommitId - ? args.afterSnapshot - ? decodeFileDataToBytes(args.afterSnapshot.data) - : EMPTY_FILE_DATA - : args.fileRow - ? decodeFileDataToBytes(args.fileRow.data) - : EMPTY_FILE_DATA); + 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: { @@ -893,14 +847,12 @@ function buildHistoricalMarkdownFile(args: { review: { fileId: args.fileId, path, - reviewId: - args.checkpointDiffFile?.reviewId ?? - editorRevisionReviewId({ - fileId: args.fileId, - path, - beforeCommitId: args.revision.beforeCommitId, - afterCommitId: args.revision.afterCommitId, - }), + reviewId: editorRevisionReviewId({ + fileId: args.fileId, + path, + beforeCommitId: args.revision.beforeCommitId, + afterCommitId: args.revision.afterCommitId, + }), beforeCommitId: args.revision.beforeCommitId ?? "", afterCommitId: args.revision.afterCommitId ?? "", agentTurnRangeIds: [], diff --git a/src/extensions/terminal/index.test.tsx b/src/extensions/terminal/index.test.tsx index 12ee5949..de237906 100644 --- a/src/extensions/terminal/index.test.tsx +++ b/src/extensions/terminal/index.test.tsx @@ -326,10 +326,6 @@ function createDesktop(terminalApi: DesktopTerminalApi) { function createTerminalApi(): DesktopTerminalApi { return { create: vi.fn(), - generateCheckpointName: vi.fn().mockResolvedValue({ - name: "Silly Markdown Pancake", - source: "codex", - }), getPreferredAgent: vi.fn().mockResolvedValue({ preferredAgent: "claude", autoLaunchAgent: null, diff --git a/src/lib/atelier-files-view.ts b/src/lib/atelier-files-view.ts new file mode 100644 index 00000000..3f6b9603 --- /dev/null +++ b/src/lib/atelier-files-view.ts @@ -0,0 +1,84 @@ +import type { AtelierFilesViewOptions } from "@opral/atelier"; +import { qb } from "@/lib/lix-kysely"; +import type { Lix } from "@/lib/lix-types"; + +/** + * Feeds FlashType's transient-workspace filesystem watchers into atelier's + * bundled Files view: un-imported disk files render as `source: "watched"` + * entries and are imported into lix on first interaction (open/rename). + * + * Only ephemeral (transient) workspaces list watched files; persistent + * workspaces import everything up front and must not pass these options. + * + * @example + * createAtelier({ lix, filesView: createEphemeralFilesViewOptions(lix) }); + */ +export function createEphemeralFilesViewOptions( + lix: Lix, +): AtelierFilesViewOptions { + return { + watchEntries: ({ expandedDirectories, onChange }) => { + const workspaceApi = window.flashtypeDesktop?.workspace; + if (!workspaceApi?.setEphemeralWatchedDirectories) { + return () => {}; + } + const ownerId = `files-view:${Math.random().toString(36).slice(2)}`; + let active = true; + void workspaceApi + .setEphemeralWatchedDirectories({ + ownerId, + paths: [...expandedDirectories], + }) + .then((entries) => { + if (active) { + onChange( + entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + })), + ); + } + }) + .catch((error: unknown) => { + if (active) { + console.warn("Failed to list transient workspace files", error); + } + }); + const unsubscribeChanges = + workspaceApi.onEphemeralWatchedFileTreeChanged?.((entries) => { + if (active) { + onChange( + entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + })), + ); + } + }); + return () => { + active = false; + unsubscribeChanges?.(); + void workspaceApi.setEphemeralWatchedDirectories?.({ + ownerId, + paths: [], + }); + }; + }, + resolveFileForInteraction: async (path) => { + let file = await qb(lix) + .selectFrom("lix_file") + .select("id") + .where("path", "=", path) + .executeTakeFirst(); + if (!file?.id) { + await lix.importFilesystemPaths([path.replace(/^\/+/, "")]); + file = await qb(lix) + .selectFrom("lix_file") + .select("id") + .where("path", "=", path) + .executeTakeFirst(); + } + return file?.id ? { fileId: String(file.id) } : null; + }, + }; +} diff --git a/src/lib/atelier-workspace-bridge.test.ts b/src/lib/atelier-workspace-bridge.test.ts index e4821068..1f8f5426 100644 --- a/src/lib/atelier-workspace-bridge.test.ts +++ b/src/lib/atelier-workspace-bridge.test.ts @@ -306,6 +306,7 @@ function createHarness( const documents: AtelierDocumentsApi = { open: vi.fn(async () => {}), startNew: vi.fn(async () => {}), + close: vi.fn(async () => {}), closeActive: vi.fn(async () => {}), closeAll: vi.fn(async () => {}), }; diff --git a/src/main.tsx b/src/main.tsx index e7e84155..417b454c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,6 +30,7 @@ import { syncPostHogWorkspaceContext, } from "./lib/posthog-client"; import { createFlashTypeAtelierExtensions } from "./extensions/atelier-host-extensions"; +import { createEphemeralFilesViewOptions } from "./lib/atelier-files-view"; import { createAgentPromptTelemetryHandler, createAtelierTelemetryHandler, @@ -79,10 +80,19 @@ export const AppRoot = () => { const atelierExtensions = useMemo( () => workspace - ? createFlashTypeAtelierExtensions(workspace) + ? createFlashTypeAtelierExtensions() : ([] as readonly AtelierExtensionRegistration[]), [workspace], ); + // Transient workspaces surface un-imported disk files through atelier's + // bundled Files view; persistent workspaces import everything up front. + const atelierFilesView = useMemo( + () => + lix && workspace?.ephemeral === true + ? createEphemeralFilesViewOptions(lix) + : undefined, + [lix, workspace?.ephemeral], + ); const handleAtelierEvent = useMemo( () => (lix ? createAtelierTelemetryHandler(lix) : undefined), [lix], @@ -116,7 +126,7 @@ export const AppRoot = () => { // contract but intentionally hides native SDK internals. lix: lix as unknown as AtelierInstance["lix"], extensions: atelierExtensions, - filesViewMode: "sidebar", + ...(atelierFilesView ? { filesView: atelierFilesView } : {}), defaultOpenPanels: defaultOpenAtelierPanels, sessionStateStore: atelierSessionStateStore, onEvent: handleAtelierEvent, @@ -125,6 +135,7 @@ export const AppRoot = () => { [ lix, atelierExtensions, + atelierFilesView, defaultOpenAtelierPanels, atelierSessionStateStore, handleAtelierEvent, diff --git a/src/queries.test.ts b/src/queries.test.ts deleted file mode 100644 index d615d8d6..00000000 --- a/src/queries.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, test, expect } from "vitest"; -import { openLix } from "@/test-utils/node-lix-sdk"; -import { qb } from "@/lib/lix-kysely"; -import { selectFilesystemEntries } from "@/queries"; - -function isUserPath(path: string): boolean { - return !path.startsWith("/.lix/"); -} - -describe("selectFilesystemEntries", () => { - test("returns directories and files with hierarchy metadata", async () => { - const lix = await openLix(); - - await qb(lix) - .insertInto("lix_directory") - .values({ path: "/docs/" } as any) - .execute(); - await qb(lix) - .insertInto("lix_directory") - .values({ path: "/docs/guides/" } as any) - .execute(); - - await qb(lix) - .insertInto("lix_file") - .values([ - { id: "f_root", path: "/README.md", data: new Uint8Array() }, - { - id: "f_nested", - path: "/docs/guides/intro.md", - data: new Uint8Array(), - }, - ]) - .execute(); - - const rows = await selectFilesystemEntries(lix).execute(); - const userRows = rows.filter((row) => isUserPath(row.path)); - expect(userRows.map((row) => row.kind)).toEqual([ - "file", - "directory", - "directory", - "file", - ]); - expect(userRows.map((row) => row.path)).toEqual([ - "/README.md", - "/docs/", - "/docs/guides/", - "/docs/guides/intro.md", - ]); - - const docsRow = userRows.find((row) => row.path === "/docs/"); - expect(docsRow?.parent_id).toBeNull(); - expect(docsRow?.display_name).toBe("docs"); - - const guidesRow = userRows.find((row) => row.path === "/docs/guides/"); - expect(guidesRow?.parent_id).toBe(docsRow?.id); - expect(guidesRow?.display_name).toBe("guides"); - - const nestedFile = userRows.find( - (row) => row.path === "/docs/guides/intro.md", - ); - expect(nestedFile?.parent_id).toBe(guidesRow?.id); - expect(nestedFile?.display_name).toBe("intro.md"); - expect(nestedFile).not.toHaveProperty("hidden"); - }); - - test("distinguishes root files from nested files", async () => { - const lix = await openLix(); - - await qb(lix) - .insertInto("lix_directory") - .values({ path: "/docs/" } as any) - .execute(); - - await qb(lix) - .insertInto("lix_file") - .values([ - { id: "root_file", path: "/root.md", data: new Uint8Array() }, - { id: "nested_file", path: "/docs/deep.md", data: new Uint8Array() }, - ]) - .execute(); - - const rows = await selectFilesystemEntries(lix).execute(); - const rootRow = rows.find((row) => row.id === "root_file"); - expect(rootRow?.parent_id).toBeNull(); - const docsRow = rows.find((row) => row.path === "/docs/"); - const nestedRow = rows.find((row) => row.id === "nested_file"); - expect(docsRow).toBeDefined(); - expect(nestedRow?.parent_id).toBe(docsRow?.id); - }); -}); diff --git a/src/queries.ts b/src/queries.ts deleted file mode 100644 index e53b9953..00000000 --- a/src/queries.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Lix } from "@/lib/lix-types"; -import { qb, sql } from "@/lib/lix-kysely"; - -export type FilesystemEntryRow = { - id: string; - parent_id: string | null; - path: string; - display_name: string; - kind: "directory" | "file"; - source?: "lix" | "watched" | "checkpoint-diff"; -}; - -/** - * Unified filesystem listing containing both directories and files ordered by path. - * - * Each row represents either a directory (with `kind === "directory"`) or a file - * (`kind === "file"`) and is shaped to make tree construction straightforward on - * the client. - */ -export function selectFilesystemEntries(lix: Lix) { - return qb(lix) - .selectFrom("lix_directory") - .select((eb) => [ - eb.ref("lix_directory.id").as("id"), - eb.ref("lix_directory.parent_id").as("parent_id"), - eb.ref("lix_directory.path").as("path"), - eb.ref("lix_directory.name").as("display_name"), - sql`'directory'`.as("kind"), - sql`'lix'`.as("source"), - ]) - .unionAll( - qb(lix) - .selectFrom("lix_file") - .select((eb) => [ - eb.ref("lix_file.id").as("id"), - eb.ref("lix_file.directory_id").as("parent_id"), - eb.ref("lix_file.path").as("path"), - eb.ref("lix_file.name").as("display_name"), - sql`'file'`.as("kind"), - sql`'lix'`.as("source"), - ]), - ) - .orderBy("path", "asc") - .$castTo(); -} diff --git a/src/shell/checkpoint-diff.test.ts b/src/shell/checkpoint-diff.test.ts deleted file mode 100644 index 7ff03c5a..00000000 --- a/src/shell/checkpoint-diff.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { qb } from "@/lib/lix-kysely"; -import { openLix } from "@/test-utils/node-lix-sdk"; -import { - resolveCheckpointDiff, - resolveCheckpointDiffForBranch, -} from "./checkpoint-diff"; - -describe("resolveCheckpointDiff", () => { - test("ignores hidden branches when choosing the previous checkpoint", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "file_doc", "/doc.md", "# Before\n"); - const before = await lix.createBranch({ name: "a-before" }); - await qb(lix) - .deleteFrom("lix_file") - .where("id", "=", "file_doc") - .execute(); - const hidden = await lix.createBranch({ name: "a-hidden" }); - await qb(lix) - .updateTable("lix_branch") - .set({ hidden: true }) - .where("id", "=", hidden.id) - .execute(); - await writeFile(lix, "file_doc", "/doc.md", "# After\n"); - const after = await lix.createBranch({ name: "b-after" }); - - const diff = await resolveCheckpointDiffForBranch({ - lix, - branchId: after.id, - }); - - expect(diff?.beforeCommitId).toBe(before.commitId); - expect(diff?.afterCommitId).toBe(after.commitId); - expect(diff?.files).toHaveLength(1); - expect(diff?.files[0]).toMatchObject({ - fileId: "file_doc", - path: "/doc.md", - status: "modified", - }); - } finally { - await lix.close(); - } - }); - - test("diffs a modified file against the previous visible checkpoint row", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "file_doc", "/doc.md", "# Before\n"); - const before = await lix.createBranch({ name: "a-before" }); - await writeFile(lix, "file_doc", "/doc.md", "# After\n"); - const after = await lix.createBranch({ name: "b-after" }); - - const diff = await resolveCheckpointDiff({ - lix, - branchId: after.id, - branches: [ - { id: before.id, name: before.name, commit_id: before.commitId }, - { id: after.id, name: after.name, commit_id: after.commitId }, - ], - }); - - expect(diff?.beforeCommitId).toBe(before.commitId); - expect(diff?.afterCommitId).toBe(after.commitId); - expect(diff?.files).toHaveLength(1); - expect(diff?.files[0]).toMatchObject({ - fileId: "file_doc", - path: "/doc.md", - status: "modified", - }); - expect(decode(diff?.files[0]?.beforeData)).toBe("# Before\n"); - expect(decode(diff?.files[0]?.afterData)).toBe("# After\n"); - } finally { - await lix.close(); - } - }); - - test("uses empty before data for added files", async () => { - const lix = await openLix(); - try { - const before = await lix.createBranch({ name: "a-before" }); - await writeFile(lix, "file_added", "/added.md", "# Added\n"); - const after = await lix.createBranch({ name: "b-after" }); - - const diff = await resolveCheckpointDiff({ - lix, - branchId: after.id, - branches: [ - { id: before.id, name: before.name, commit_id: before.commitId }, - { id: after.id, name: after.name, commit_id: after.commitId }, - ], - }); - - expect(diff?.files).toHaveLength(1); - expect(diff?.files[0]?.status).toBe("added"); - expect(decode(diff?.files[0]?.beforeData)).toBe(""); - expect(decode(diff?.files[0]?.afterData)).toBe("# Added\n"); - } finally { - await lix.close(); - } - }); - - test("uses empty after data for deleted files", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "file_deleted", "/deleted.md", "# Deleted\n"); - const before = await lix.createBranch({ name: "a-before" }); - await qb(lix) - .deleteFrom("lix_file") - .where("id", "=", "file_deleted") - .execute(); - const after = await lix.createBranch({ name: "b-after" }); - - const diff = await resolveCheckpointDiff({ - lix, - branchId: after.id, - branches: [ - { id: before.id, name: before.name, commit_id: before.commitId }, - { id: after.id, name: after.name, commit_id: after.commitId }, - ], - }); - - expect(diff?.files).toHaveLength(1); - expect(diff?.files[0]?.status).toBe("deleted"); - expect(decode(diff?.files[0]?.beforeData)).toBe("# Deleted\n"); - expect(decode(diff?.files[0]?.afterData)).toBe(""); - } finally { - await lix.close(); - } - }); - - test("diffs the first visible checkpoint against the initial commit", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "file_doc", "/doc.md", "# First\n"); - const first = await lix.createBranch({ name: "a-first" }); - - const diff = await resolveCheckpointDiff({ - lix, - branchId: first.id, - branches: [ - { id: first.id, name: first.name, commit_id: first.commitId }, - ], - }); - - expect(diff?.beforeBranchName).toBe("Initial Commit"); - expect(diff?.beforeCommitId).not.toBe(first.commitId); - expect(diff?.afterCommitId).toBe(first.commitId); - expect(diff?.files).toHaveLength(1); - expect(diff?.files[0]).toMatchObject({ - fileId: "file_doc", - path: "/doc.md", - status: "added", - }); - expect(decode(diff?.files[0]?.beforeData)).toBe(""); - expect(decode(diff?.files[0]?.afterData)).toBe("# First\n"); - } finally { - await lix.close(); - } - }); - - test("diffs every visible file in the first checkpoint snapshot", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "file_alpha", "/alpha.md", "# Alpha\n"); - await writeFile(lix, "file_beta", "/beta.md", "# Beta\n"); - await writeFile(lix, "file_gamma", "/gamma.md", "# Gamma\n"); - const first = await lix.createBranch({ name: "a-first" }); - - const diff = await resolveCheckpointDiff({ - lix, - branchId: first.id, - branches: [ - { id: first.id, name: first.name, commit_id: first.commitId }, - ], - }); - - expect(diff?.beforeBranchName).toBe("Initial Commit"); - expect(diff?.files.map((file) => file.path)).toEqual([ - "/alpha.md", - "/beta.md", - "/gamma.md", - ]); - expect(diff?.files.map((file) => file.status)).toEqual([ - "added", - "added", - "added", - ]); - } finally { - await lix.close(); - } - }); - - test("diffs the current checkpoint against the initial commit when no checkpoint is above it", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "file_current", "/current.md", "# Current\n"); - const activeBranchId = await lix.activeBranchId(); - const activeCommitId = await readBranchCommitId(lix, activeBranchId); - const initialCommitId = await initialCommitIdForCommit( - lix, - activeCommitId, - ); - - const diff = await resolveCheckpointDiff({ - lix, - branchId: activeBranchId, - branches: [ - { id: activeBranchId, name: "main", commit_id: activeCommitId }, - ], - }); - - expect(diff?.branchId).toBe(activeBranchId); - expect(diff?.beforeBranchName).toBe("Initial Commit"); - expect(diff?.beforeCommitId).toBe(initialCommitId); - expect(diff?.afterCommitId).toBe(activeCommitId); - expect(diff?.files).toHaveLength(1); - expect(diff?.files[0]).toMatchObject({ - fileId: "file_current", - path: "/current.md", - status: "added", - }); - } finally { - await lix.close(); - } - }); - - test("returns null for missing commits", async () => { - const lix = await openLix(); - try { - await writeFile(lix, "file_doc", "/doc.md", "# Before\n"); - const first = await lix.createBranch({ name: "a-first" }); - await writeFile(lix, "file_doc", "/doc.md", "# After\n"); - const second = await lix.createBranch({ name: "b-second" }); - - await expect( - resolveCheckpointDiff({ - lix, - branchId: second.id, - branches: [ - { id: first.id, name: first.name, commit_id: null }, - { id: second.id, name: second.name, commit_id: second.commitId }, - ], - }), - ).resolves.toBeNull(); - } finally { - await lix.close(); - } - }); -}); - -async function writeFile( - lix: Awaited>, - id: string, - path: string, - text: string, -): Promise { - await qb(lix) - .insertInto("lix_file") - .values({ id, path, data: new TextEncoder().encode(text) }) - .onConflict((oc) => - oc.column("id").doUpdateSet({ - path, - data: new TextEncoder().encode(text), - }), - ) - .execute(); -} - -function decode(data: Uint8Array | undefined): string { - return new TextDecoder().decode(data); -} - -async function readBranchCommitId( - lix: Awaited>, - branchId: string, -): Promise { - const result = await qb(lix) - .selectFrom("lix_branch") - .select(["commit_id"]) - .where("id", "=", branchId) - .executeTakeFirstOrThrow(); - expect(result.commit_id).toEqual(expect.any(String)); - return result.commit_id as string; -} - -async function initialCommitIdForCommit( - lix: Awaited>, - commitId: string, -): Promise { - const result = await lix.execute( - ` - SELECT h.observed_commit_id AS commit_id - FROM lix_state_history AS h - LEFT JOIN lix_commit_edge AS e - ON e.child_id = h.observed_commit_id - WHERE h.start_commit_id = $1 - AND h.schema_key = 'lix_commit' - AND e.child_id IS NULL - ORDER BY h.depth DESC - LIMIT 1 - `, - [commitId], - ); - const commitIds = result.rows - .map((row) => row.get("commit_id")) - .filter( - (value): value is string => typeof value === "string" && value.length > 0, - ); - expect(commitIds).toHaveLength(1); - return commitIds[0] as string; -} diff --git a/src/shell/checkpoint-diff.ts b/src/shell/checkpoint-diff.ts deleted file mode 100644 index 675e691d..00000000 --- a/src/shell/checkpoint-diff.ts +++ /dev/null @@ -1,333 +0,0 @@ -import type { - CheckpointDiff, - CheckpointDiffBranchRow, - CheckpointDiffFile, - CheckpointDiffFileStatus, - CheckpointDiffVisibleFile, -} from "@/extension-runtime/checkpoint-diff"; -import { decodeFileDataToBytes } from "@/lib/decode-file-data"; -import type { Lix } from "@/lib/lix-types"; -import { qb, sql } from "@/lib/lix-kysely"; - -type FileHistorySnapshot = { - readonly id: string; - readonly path: string | null; - readonly data: unknown | null; - readonly lixcol_depth: number; -}; - -type VisibleFileSnapshot = { - readonly id: string; - readonly path: string; - readonly data: unknown; -}; - -const EMPTY_DATA = new Uint8Array(); - -/** Resolves a checkpoint using the same visible-branch ordering as History. */ -export async function resolveCheckpointDiffForBranch(args: { - readonly lix: Lix; - readonly branchId: string; -}): Promise { - const [branches, activeBranchId] = await Promise.all([ - qb(args.lix) - .selectFrom("lix_branch") - .select(["id", "name", "commit_id"]) - .where( - () => - sql`COALESCE(CAST(lix_branch.hidden AS TEXT), 'false') NOT IN ('true', '1', 't')`, - ) - .orderBy("name", "asc") - .execute() as Promise, - args.lix.activeBranchId().catch(() => null), - ]); - const diff = await resolveCheckpointDiff({ - lix: args.lix, - branches, - branchId: args.branchId, - }); - return diff && activeBranchId === args.branchId - ? { ...diff, afterIsActiveHead: true } - : diff; -} - -export async function resolveCheckpointDiff(args: { - readonly lix: Lix; - readonly branches: readonly CheckpointDiffBranchRow[]; - readonly branchId: string; -}): Promise { - const selectedIndex = args.branches.findIndex( - (branch) => branch.id === args.branchId, - ); - if (selectedIndex < 0) return null; - const afterBranch = args.branches[selectedIndex]; - if (!afterBranch?.commit_id) return null; - const beforeBranch = args.branches[selectedIndex - 1] ?? null; - const beforeCommitId = beforeBranch - ? beforeBranch.commit_id - : await loadInitialCommitId(args.lix, afterBranch.commit_id); - if (!beforeCommitId) return null; - if (beforeCommitId === afterBranch.commit_id) return null; - - const [beforeSnapshots, afterSnapshots] = await Promise.all([ - loadFileSnapshotsAtCommit(args.lix, beforeCommitId), - loadFileSnapshotsAtCommit(args.lix, afterBranch.commit_id), - ]); - const files = buildCheckpointDiffFiles({ - beforeCommitId, - beforeSnapshots, - afterCommitId: afterBranch.commit_id, - afterSnapshots, - }); - if (files.length === 0) return null; - const visibleFiles = buildCheckpointDiffVisibleFiles({ - beforeSnapshots, - afterSnapshots, - }); - return { - branchId: afterBranch.id, - branchName: afterBranch.name, - beforeBranchId: beforeBranch?.id ?? `initial:${beforeCommitId}`, - beforeBranchName: beforeBranch?.name ?? "Initial Commit", - beforeCommitId, - afterCommitId: afterBranch.commit_id, - visibleFiles, - files, - }; -} - -async function loadInitialCommitId( - lix: Lix, - startCommitId: string, -): Promise { - const result = await lix.execute( - ` - SELECT h.observed_commit_id AS commit_id - FROM lix_state_history AS h - LEFT JOIN lix_commit_edge AS e - ON e.child_id = h.observed_commit_id - WHERE h.start_commit_id = $1 - AND h.schema_key = 'lix_commit' - AND e.child_id IS NULL - ORDER BY h.depth DESC - LIMIT 1 - `, - [startCommitId], - ); - const commitIds = result.rows - .map((row) => row.get("commit_id")) - .filter( - (value): value is string => typeof value === "string" && value.length > 0, - ); - return commitIds.length === 1 ? commitIds[0] : null; -} - -async function loadFileSnapshotsAtCommit( - lix: Lix, - commitId: string, -): Promise { - const rows = (await qb(lix) - .selectFrom("lix_file_history") - .select(["id", "path", "data", "lixcol_depth"]) - .where("lixcol_start_commit_id", "=", commitId) - .orderBy("id", "asc") - .orderBy("lixcol_depth", "asc") - .execute()) as FileHistorySnapshot[]; - const latestByFileId = new Map(); - for (const row of rows) { - const existing = latestByFileId.get(row.id); - if (!existing || row.lixcol_depth < existing.lixcol_depth) { - latestByFileId.set(row.id, row); - } - } - return [...latestByFileId.values()] - .filter((row) => typeof row.path === "string" && row.data !== null) - .map((row) => ({ - id: row.id, - path: row.path as string, - data: row.data as unknown, - })) - .sort((left, right) => left.path.localeCompare(right.path)); -} - -function buildCheckpointDiffFiles(args: { - readonly beforeCommitId: string; - readonly beforeSnapshots: readonly VisibleFileSnapshot[]; - readonly afterCommitId: string; - readonly afterSnapshots: readonly VisibleFileSnapshot[]; -}): CheckpointDiffFile[] { - const beforeById = new Map( - args.beforeSnapshots.map((file) => [file.id, file]), - ); - const afterById = new Map(args.afterSnapshots.map((file) => [file.id, file])); - const beforeUnmatched = new Map(beforeById); - const afterUnmatched = new Map(afterById); - const files: CheckpointDiffFile[] = []; - - for (const [fileId, before] of beforeById) { - const after = afterById.get(fileId); - if (!after) continue; - beforeUnmatched.delete(fileId); - afterUnmatched.delete(fileId); - const diffFile = buildDiffFile({ - before, - beforeCommitId: args.beforeCommitId, - after, - afterCommitId: args.afterCommitId, - status: "modified", - }); - if (diffFile) { - files.push(diffFile); - } - } - - for (const [beforeId, before] of [...beforeUnmatched]) { - const after = [...afterUnmatched.values()].find( - (candidate) => candidate.path === before.path, - ); - if (!after) continue; - beforeUnmatched.delete(beforeId); - afterUnmatched.delete(after.id); - const diffFile = buildDiffFile({ - before, - beforeCommitId: args.beforeCommitId, - after, - afterCommitId: args.afterCommitId, - status: "recreated", - }); - if (diffFile) { - files.push(diffFile); - } - } - - for (const before of beforeUnmatched.values()) { - files.push( - buildMissingSideDiffFile({ - before, - beforeCommitId: args.beforeCommitId, - afterCommitId: args.afterCommitId, - status: "deleted", - }), - ); - } - for (const after of afterUnmatched.values()) { - files.push( - buildMissingSideDiffFile({ - after, - beforeCommitId: args.beforeCommitId, - afterCommitId: args.afterCommitId, - status: "added", - }), - ); - } - - return files.sort((left, right) => left.path.localeCompare(right.path)); -} - -function buildCheckpointDiffVisibleFiles(args: { - readonly beforeSnapshots: readonly VisibleFileSnapshot[]; - readonly afterSnapshots: readonly VisibleFileSnapshot[]; -}): CheckpointDiffVisibleFile[] { - const byPath = new Map(); - for (const file of args.beforeSnapshots) { - byPath.set(file.path, { fileId: file.id, path: file.path }); - } - for (const file of args.afterSnapshots) { - byPath.set(file.path, { fileId: file.id, path: file.path }); - } - return [...byPath.values()].sort((left, right) => - left.path.localeCompare(right.path), - ); -} - -function buildDiffFile(args: { - readonly before: VisibleFileSnapshot; - readonly beforeCommitId: string; - readonly after: VisibleFileSnapshot; - readonly afterCommitId: string; - readonly status: Extract; -}): CheckpointDiffFile | null { - const beforeData = decodeFileDataToBytes(args.before.data); - const afterData = decodeFileDataToBytes(args.after.data); - const pathChanged = args.before.path !== args.after.path; - const dataChanged = !fileBytesEqual(beforeData, afterData); - if (args.status === "modified" && !pathChanged && !dataChanged) return null; - return { - fileId: args.after.id, - path: args.after.path, - beforePath: args.before.path, - afterPath: args.after.path, - beforeData, - afterData, - beforeCommitId: args.beforeCommitId, - afterCommitId: args.afterCommitId, - reviewId: checkpointDiffReviewId({ - beforeCommitId: args.beforeCommitId, - afterCommitId: args.afterCommitId, - fileId: args.after.id, - path: args.after.path, - }), - status: args.status, - }; -} - -function buildMissingSideDiffFile( - args: - | { - readonly before: VisibleFileSnapshot; - readonly beforeCommitId: string; - readonly afterCommitId: string; - readonly status: Extract; - } - | { - readonly after: VisibleFileSnapshot; - readonly beforeCommitId: string; - readonly afterCommitId: string; - readonly status: Extract; - }, -): CheckpointDiffFile { - const before = "before" in args ? args.before : null; - const after = "after" in args ? args.after : null; - const fileId = after?.id ?? before?.id ?? ""; - const path = after?.path ?? before?.path ?? ""; - return { - fileId, - path, - beforePath: before?.path ?? null, - afterPath: after?.path ?? null, - beforeData: before ? decodeFileDataToBytes(before.data) : EMPTY_DATA, - afterData: after ? decodeFileDataToBytes(after.data) : EMPTY_DATA, - beforeCommitId: args.beforeCommitId, - afterCommitId: args.afterCommitId, - reviewId: checkpointDiffReviewId({ - beforeCommitId: args.beforeCommitId, - afterCommitId: args.afterCommitId, - fileId, - path, - }), - status: args.status, - }; -} - -function checkpointDiffReviewId(args: { - readonly beforeCommitId: string; - readonly afterCommitId: string; - readonly fileId: string; - readonly path: string; -}): string { - return [ - "checkpoint", - args.beforeCommitId, - args.afterCommitId, - args.fileId, - args.path, - ].join(":"); -} - -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/top-bar/index.test.tsx b/src/shell/top-bar/index.test.tsx index 7f3c4f69..ee419616 100644 --- a/src/shell/top-bar/index.test.tsx +++ b/src/shell/top-bar/index.test.tsx @@ -37,24 +37,9 @@ describe("TopBar", () => { ); }); - test("shows reviewing label after the active file name in checkpoint diff mode", () => { - render( - , - ); + test("shows the active file name after the workspace title", () => { + render(); expect(screen.getByText("note.md")).toBeVisible(); - expect(screen.getByText("Reviewing")).toBeVisible(); - }); - - test("does not show reviewing label without an active file", () => { - render( - , - ); - - expect(screen.queryByText("Reviewing")).toBeNull(); }); }); diff --git a/src/shell/top-bar/index.tsx b/src/shell/top-bar/index.tsx index 15228614..10bb1280 100644 --- a/src/shell/top-bar/index.tsx +++ b/src/shell/top-bar/index.tsx @@ -12,8 +12,6 @@ export type TopBarProps = { readonly workspaceName?: string | null; /** Active document name, shown after the workspace as a breadcrumb. */ readonly activeFileName?: string | null; - /** Whether the active document is being shown as a checkpoint diff. */ - readonly isReviewingCheckpoint?: boolean; /** Leading slot, e.g. the Flashtype menu. Must not require lix. */ readonly menu?: ReactNode; /** Clicking the proxy title opens the directory picker to switch workspaces. */ @@ -37,7 +35,6 @@ export type TopBarProps = { export function TopBar({ workspaceName = null, activeFileName = null, - isReviewingCheckpoint = false, menu, onWorkspaceTitleClick, onToggleLeftSidebar, @@ -133,11 +130,6 @@ export function TopBar({ {activeFileName} - {isReviewingCheckpoint ? ( - - Reviewing - - ) : null} ) : null} diff --git a/submodule/atelier b/submodule/atelier index c5d8a35a..29e5e4a3 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit c5d8a35a505aa57fa8ae60eb064c2b7be977b73b +Subproject commit 29e5e4a3f915c1c1b02bd9602b976e174f234033