From 145e23c9cd3392997ba48beb50196af885f2fd46 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:34:51 -0400 Subject: [PATCH 01/10] feat!: adopt atelier's single central-tabs primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps submodule/atelier to 4396919 (central-panel-embedding): the central panel is now always browser-style tabs with a pinned home. FlashType configures no custom home, so the Files view becomes the pinned home tab — documents open as content tabs beside it and closing the last content tab lands back on Files. - drop filesViewMode: "sidebar" (derived by atelier now; a no-op) - closeFileViews closes by path via the new documents.close(path) so deleting a file also closes its background tab (the tree lives in the pinned home tab, so the doc view is rarely active during deletion) - drop the revisions wiring deleted upstream by atelier#59 (checkpoint removal): history view props, checkpointBranchId, atelier_history builtin id; local types keep the optional diff-viewer props compilable - e2e: ensureFilesViewOpenInLeftPanel -> ensureFilesHomeTabActive; the single-central-document invariant becomes "pinned Files home first, one active document content tab beside it"; tree interactions re-activate the home tab; the central empty state is gone (landing on the Files home instead); the two checkpoint-diff tests are skipped with pointers to atelier#59 - unit mocks: documents.close added, revisions removed, closeFileViews assertions carry filePath - pnpm-lock: additive update for new atelier deps BLOCKED on opral/atelier#60 — the submodule pointer targets the central-panel-embedding branch, so the submodule-branch validation in CI fails until it merges; bump to the merge commit afterwards. Co-Authored-By: Claude Fable 5 --- e2e/agent-review.spec.ts | 6 +- e2e/branch-switcher.spec.ts | 63 +- e2e/electron-test-utils.ts | 26 +- e2e/files-click-tour.spec.ts | 27 +- e2e/markdown-editor-fuzz.spec.ts | 4 +- e2e/packaged-macos.spec.ts | 4 +- e2e/workspace-change-stress.spec.ts | 4 +- e2e/workspace-switch.spec.ts | 7 +- e2e/workspace-windows.spec.ts | 30 +- pnpm-lock.yaml | 1090 ++++++++++++++++- src/extension-runtime/types.ts | 1 + src/extensions/files/host-extension.test.tsx | 11 - src/extensions/files/host-extension.tsx | 7 +- src/extensions/files/index.test.tsx | 11 +- src/extensions/files/index.tsx | 8 +- .../history/host-extension.test.tsx | 10 - src/extensions/history/host-extension.tsx | 13 +- src/extensions/history/index.tsx | 17 +- src/lib/atelier-workspace-bridge.test.ts | 1 + src/main.tsx | 1 - submodule/atelier | 2 +- 21 files changed, 1233 insertions(+), 110 deletions(-) diff --git a/e2e/agent-review.spec.ts b/e2e/agent-review.spec.ts index ad5f928..06875b8 100644 --- a/e2e/agent-review.spec.ts +++ b/e2e/agent-review.spec.ts @@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, utimes, writeFile } from "node:fs/promises"; import path from "node:path"; import { closeElectronApp, - ensureFilesViewOpenInLeftPanel, + ensureFilesHomeTabActive, expectWorkspaceSessionOpenFilePaths, fileTreeFile, launchDevElectronAppWithArgs, @@ -101,7 +101,7 @@ test("Atelier reveals a review after Codex edits restored markdown", async ({ await expect( page.getByRole("button", { name: "Undo change" }), ).toBeVisible(); - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/changelog.md")).toHaveAttribute( "data-item-git-status", "modified", @@ -127,7 +127,7 @@ test("Atelier reveals a review after Codex edits restored markdown", async ({ }); async function openWelcomeMarkdown(page: Page): Promise { - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); const file = fileTreeFile(page, "/welcome.md"); await expect(file).toBeVisible(); await file.click(); diff --git a/e2e/branch-switcher.spec.ts b/e2e/branch-switcher.spec.ts index 5f9a3c6..c3b3d47 100644 --- a/e2e/branch-switcher.spec.ts +++ b/e2e/branch-switcher.spec.ts @@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { closeElectronApp, - ensureFilesViewOpenInLeftPanel, + ensureFilesHomeTabActive, ensureHistoryViewOpenInLeftPanel, expectPathMissing, fileTreeFile, @@ -36,7 +36,7 @@ test("persistent workspace branch switching keeps sidebar and disk on the active const page = await electronApp.firstWindow(); registerRendererConsoleLogging(page); - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/shared.md")).toBeVisible(); await expect(fileTreeFile(page, "/main-only.md")).toBeVisible(); await ensureHistoryViewOpenInLeftPanel(page); @@ -48,7 +48,7 @@ test("persistent workspace branch switching keeps sidebar and disk on the active await switchBranchFromUi(page, "Naming checkpoint..."); await expectCheckpointActive(page, "Naming checkpoint..."); - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); await writeDraftBranchState(page); await expect(fileTreeFile(page, "/draft-only.md")).toBeVisible(); await expect(fileTreeFile(page, "/shared.md")).toBeVisible(); @@ -57,7 +57,7 @@ test("persistent workspace branch switching keeps sidebar and disk on the active await switchBranchFromUi(page, "Current Checkpoint"); await expectCurrentCheckpointActive(page); - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(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); @@ -82,7 +82,7 @@ test("ephemeral workspace shows enabled branch UI", async ({ const page = await electronApp.firstWindow(); registerRendererConsoleLogging(page); - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/note.md")).toBeVisible(); await expectPathMissing(path.join(workspaceDir, ".lix")); @@ -96,7 +96,10 @@ test("ephemeral workspace shows enabled branch UI", async ({ } }); -test("checkpoint row click marks files without auto-opening a diff", async ({ +// Skipped: atelier removed its checkpoint/revisions runtime (opral/atelier#59), +// so selecting a checkpoint row no longer resolves a diff or marks files. If +// FlashType reimplements checkpoint-diff selection host-side, restore this. +test.skip("checkpoint row click marks files without auto-opening a diff", async ({ browserName: _browserName, }, testInfo) => { const workspaceDir = testInfo.outputPath("checkpoint-diff-workspace"); @@ -130,7 +133,7 @@ test("checkpoint row click marks files without auto-opening a diff", async ({ .toBe(setup.activeBranchId); await expect(page.locator(".markdown-review-overlay")).toHaveCount(0); - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); for (const [filePath, status] of [ ["/added.md", "added"], ["/removed.md", "deleted"], @@ -170,7 +173,10 @@ test("checkpoint row click marks files without auto-opening a diff", async ({ } }); -test("checkpoint diff selection keeps the active editor and toggles revision state", async ({ +// Skipped: atelier removed its checkpoint/revisions runtime (opral/atelier#59), +// so checkpoint-diff selection and editor revision state are gone. If +// FlashType reimplements checkpoint-diff selection host-side, restore this. +test.skip("checkpoint diff selection keeps the active editor and toggles revision state", async ({ browserName: _browserName, }, testInfo) => { const workspaceDir = testInfo.outputPath( @@ -457,7 +463,7 @@ async function openMarkdownFileFromTree( page: Page, appPath: string, ): Promise { - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); const file = fileTreeFile(page, appPath); await expect(file).toBeVisible(); await file.click(); @@ -923,8 +929,12 @@ async function activeCentralDocumentIdentityFromUi( const state = await atelierSessionStateFromUi(page); const central = state?.panels.central; const views = central?.views ?? []; + // The pinned Files home tab ("central-home") is always the first central + // view; document identity lives on the content tabs beside it. + const contentViews = views.filter((view) => view.instance !== "central-home"); const active = - views.find((view) => view.instance === central?.activeInstance) ?? views[0]; + views.find((view) => view.instance === central?.activeInstance) ?? + contentViews[0]; const instance = active?.instance; const kind = active?.kind; const fileId = active?.state?.fileId; @@ -944,8 +954,12 @@ async function activeCentralFilePathFromUi(page: Page): Promise { const state = await atelierSessionStateFromUi(page); const central = state?.panels.central; const views = central?.views ?? []; + // The pinned Files home tab ("central-home") is always the first central + // view; document identity lives on the content tabs beside it. + const contentViews = views.filter((view) => view.instance !== "central-home"); const active = - views.find((view) => view.instance === central?.activeInstance) ?? views[0]; + views.find((view) => view.instance === central?.activeInstance) ?? + contentViews[0]; const filePath = active?.state?.filePath; return typeof filePath === "string" ? filePath : null; } @@ -966,7 +980,7 @@ 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", + "document views should only exist as the single active central content tab beside the pinned Files home", timeout: 3000, }) .toEqual([]); @@ -994,12 +1008,21 @@ async function documentSlotViolationsFromUi(page: Page): Promise { } const central = panels?.central; const centralViews = central?.views ?? []; - if (centralViews.length > 1) { - violations.push(`central has ${centralViews.length} views`); + // The central panel is always tabs with the Files view pinned first as the + // home tab; documents open as content tabs beside it. + const homeView = centralViews[0]; + if (!homeView || homeView.instance !== "central-home") { + violations.push("central home tab is not the first view"); } - const centralView = centralViews[0]; + const contentViews = centralViews.filter( + (view) => view.instance !== "central-home", + ); + if (contentViews.length > 1) { + violations.push(`central has ${contentViews.length} content views`); + } + const centralView = contentViews[0]; if (centralView && !isDocumentView(centralView)) { - violations.push("central view is not a document view"); + violations.push("central content view is not a document view"); } if ( centralView && @@ -1018,8 +1041,12 @@ async function activeEditorRevisionStateFromUi(page: Page): Promise<{ const state = await atelierSessionStateFromUi(page); const central = state?.panels.central; const views = central?.views ?? []; + // The pinned Files home tab ("central-home") is always the first central + // view; document identity lives on the content tabs beside it. + const contentViews = views.filter((view) => view.instance !== "central-home"); const active = - views.find((view) => view.instance === central?.activeInstance) ?? views[0]; + views.find((view) => view.instance === central?.activeInstance) ?? + contentViews[0]; if (!active) return null; const beforeCommitId = active.state?.beforeCommitId; const afterCommitId = active.state?.afterCommitId; @@ -1040,7 +1067,7 @@ async function expectFileTreeStatuses( expected: Record, message: string, ): Promise { - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); await expect .poll(async () => await fileTreeStatuses(page, Object.keys(expected)), { message, diff --git a/e2e/electron-test-utils.ts b/e2e/electron-test-utils.ts index a57c27a..667ccf9 100644 --- a/e2e/electron-test-utils.ts +++ b/e2e/electron-test-utils.ts @@ -75,22 +75,20 @@ export function registerRendererConsoleLogging(page: Page): void { }); } -export async function ensureFilesViewOpenInLeftPanel( - page: Page, -): Promise { +export async function ensureFilesHomeTabActive(page: Page): Promise { await waitForWorkspaceReady(page); - const leftPanel = page.locator("aside").first(); - const filesTab = leftPanel.locator('[data-view-key="atelier_files"]').first(); - const leftPanelToggle = page.getByLabel("Toggle left panel").first(); - - await expect(leftPanelToggle).toBeVisible(); - if ((await leftPanelToggle.getAttribute("aria-pressed")) !== "true") { - await leftPanelToggle.click(); - } - - await expect(leftPanelToggle).toHaveAttribute("aria-pressed", "true"); + // The central panel is always browser-style tabs with the Files view as + // the pinned home tab (atelier's single central-tabs primitive); it no + // longer lives in the left side panel. + const filesTab = page + .locator('button[data-view-key="atelier_files"][data-pinned="true"]') + .first(); await expect(filesTab).toBeVisible(); - await filesTab.click(); + if ((await filesTab.getAttribute("data-focused")) !== "true") { + // The pinned tab is excluded from drag-sorting, which marks it + // aria-disabled; force past Playwright's actionability check. + await filesTab.click({ force: true }); + } await expect(filesTab).toHaveAttribute("data-focused", "true"); } diff --git a/e2e/files-click-tour.spec.ts b/e2e/files-click-tour.spec.ts index eebd1f0..763bd3c 100644 --- a/e2e/files-click-tour.spec.ts +++ b/e2e/files-click-tour.spec.ts @@ -3,7 +3,7 @@ import type { ElectronApplication } from "playwright"; import seedrandom from "seedrandom"; import { closeElectronApp, - ensureFilesViewOpenInLeftPanel, + ensureFilesHomeTabActive, fileTreeFile, fileTreeFiles, launchDevElectronApp, @@ -29,11 +29,11 @@ test("left files panel survives a seeded random file click tour", async ({ const page = await electronApp.firstWindow(); registerRendererConsoleLogging(page); - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); + // The workspace auto-opens the most recent markdown file; it now lives + // in a content tab beside the pinned Files home tab. await expect( - page.locator( - '[data-panel-side="central"][data-active="true"][data-view-key="atelier_file"]', - ), + page.locator('button[data-view-key="atelier_file"]').first(), ).toBeVisible(); const fileItems = fileTreeFiles(page); @@ -61,6 +61,9 @@ test("left files panel survives a seeded random file click tour", async ({ const file = fileTreeFile(page, `/${treePath}`); await test.step(`click ${index + 1}/${clickCount}: file index ${fileIndex}, delay ${delayMs}ms`, async () => { + // Opening a document activates its tab and hides the Files home + // tree, so return to the home tab before each tree click. + await ensureFilesHomeTabActive(page); await file.click(); await expect(file).toHaveAttribute("data-item-selected", "true"); await expectActiveFileView(page, treePath); @@ -143,7 +146,7 @@ test("deleting the active file closes the central file view", async ({ const page = await electronApp.firstWindow(); registerRendererConsoleLogging(page); - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); const file = fileTreeFile(page, "/welcome.md"); await expect(file).toBeVisible(); @@ -156,7 +159,10 @@ test("deleting the active file closes the central file view", async ({ ).toBeVisible(); await expect(file).toHaveAttribute("data-item-selected", "true"); - await file.click(); + // Opening the document activated its tab; return to the Files home tab + // (the selection persists) to delete the file from the tree. + await ensureFilesHomeTabActive(page); + await expect(file).toHaveAttribute("data-item-selected", "true"); await file.focus(); await page.keyboard.press(deleteShortcut); @@ -164,7 +170,12 @@ test("deleting the active file closes the central file view", async ({ await expect( page.locator('[data-active="true"][data-view-key="atelier_file"]'), ).toHaveCount(0); - await expect(page.getByTestId("central-panel-empty-state")).toBeVisible(); + // Closing the last content tab lands on the pinned Files home tab. + await expect( + page.locator( + '[data-panel-side="central"][data-active="true"][data-view-key="atelier_files"]', + ), + ).toBeVisible(); } finally { await closeElectronApp(electronApp); } diff --git a/e2e/markdown-editor-fuzz.spec.ts b/e2e/markdown-editor-fuzz.spec.ts index 4561827..d4304e1 100644 --- a/e2e/markdown-editor-fuzz.spec.ts +++ b/e2e/markdown-editor-fuzz.spec.ts @@ -24,7 +24,7 @@ import { } from "../src/extensions/markdown/editor/markdown-editor-fuzz"; import { closeElectronApp, - ensureFilesViewOpenInLeftPanel, + ensureFilesHomeTabActive, fileTreeFile, launchDevElectronApp, registerRendererConsoleLogging, @@ -113,7 +113,7 @@ test("fuzzes markdown editor plain text through the Flashtype UI", async ({ const page = await electronApp.firstWindow(); registerRendererConsoleLogging(page); - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); const file = fileTreeFile(page, uiFuzzFilePath); await expect(file).toBeVisible(); await file.click(); diff --git a/e2e/packaged-macos.spec.ts b/e2e/packaged-macos.spec.ts index 22f0b4a..cf69864 100644 --- a/e2e/packaged-macos.spec.ts +++ b/e2e/packaged-macos.spec.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { closeElectronApp, - ensureFilesViewOpenInLeftPanel, + ensureFilesHomeTabActive, expectPathMissing, fileTreeFiles, launchPackagedElectronApp, @@ -117,7 +117,7 @@ test("packaged app launches, seeds, and opens files without Vite", async ({ registerRendererConsoleLogging(page); await expectPathMissing(path.join(workspaceDir, ".lix")); - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); await expect( page.locator( '[data-panel-side="central"][data-active="true"][data-view-key="atelier_file"]', diff --git a/e2e/workspace-change-stress.spec.ts b/e2e/workspace-change-stress.spec.ts index 764cb48..399798a 100644 --- a/e2e/workspace-change-stress.spec.ts +++ b/e2e/workspace-change-stress.spec.ts @@ -7,7 +7,7 @@ import { performance } from "node:perf_hooks"; import seedrandom from "seedrandom"; import { closeElectronApp, - ensureFilesViewOpenInLeftPanel, + ensureFilesHomeTabActive, fileTreeFile, launchDevElectronApp, registerRendererConsoleLogging, @@ -451,7 +451,7 @@ function startLixTraceCapture( } async function openStressMarkdown(page: Page): Promise { - await ensureFilesViewOpenInLeftPanel(page); + await ensureFilesHomeTabActive(page); const file = fileTreeFile(page, stressAppPath); await expect(file).toBeVisible(); await file.click(); diff --git a/e2e/workspace-switch.spec.ts b/e2e/workspace-switch.spec.ts index 7da1cbb..9a4e26c 100644 --- a/e2e/workspace-switch.spec.ts +++ b/e2e/workspace-switch.spec.ts @@ -4,6 +4,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { closeElectronApp, + ensureFilesHomeTabActive, expectPathMissing, fileTreeFile, launchDevElectronApp, @@ -29,6 +30,7 @@ test("switching to an empty workspace closes the previous lix session", async ({ const page = await electronApp.firstWindow(); registerRendererConsoleLogging(page); + await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/old-workspace-marker.md")).toBeVisible(); await expectPathMissing(path.join(firstWorkspaceDir, ".lix")); @@ -40,7 +42,10 @@ test("switching to an empty workspace closes the previous lix session", async ({ await expect(page).toHaveTitle(path.basename(secondWorkspaceDir)); await expect(fileTreeFile(page, "/old-workspace-marker.md")).toHaveCount(0); - await expect(page.getByText("Start writing")).toBeVisible(); + // The pinned Files home tab is the landing for an empty workspace now; + // the "Start writing" central empty state no longer exists. + await ensureFilesHomeTabActive(page); + await expect(page.getByRole("button", { name: "New file" })).toBeVisible(); await expectPathMissing(path.join(secondWorkspaceDir, ".lix")); } finally { await closeElectronApp(electronApp); diff --git a/e2e/workspace-windows.spec.ts b/e2e/workspace-windows.spec.ts index b7e55bd..ed54d1f 100644 --- a/e2e/workspace-windows.spec.ts +++ b/e2e/workspace-windows.spec.ts @@ -5,6 +5,7 @@ import { mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises"; import path from "node:path"; import { closeElectronApp, + ensureFilesHomeTabActive, expectInstalledPluginArchives, fileTreeDirectory, fileTreeFile, @@ -40,8 +41,10 @@ test("launching with multiple workspace args creates independent windows", async registerRendererConsoleLogging(firstPage); registerRendererConsoleLogging(secondPage); + await ensureFilesHomeTabActive(firstPage); await expect(fileTreeFile(firstPage, "/first-marker.md")).toBeVisible(); await expect(fileTreeFile(firstPage, "/second-marker.md")).toHaveCount(0); + await ensureFilesHomeTabActive(secondPage); await expect(fileTreeFile(secondPage, "/second-marker.md")).toBeVisible(); await expect(fileTreeFile(secondPage, "/first-marker.md")).toHaveCount(0); } finally { @@ -81,8 +84,10 @@ test("opening a folder from an existing workspace creates a new window", async ( await expectWindowCount(electronApp, 2); await expect(firstPage).toHaveTitle(path.basename(firstWorkspaceDir)); + await ensureFilesHomeTabActive(firstPage); await expect(fileTreeFile(firstPage, "/first-marker.md")).toBeVisible(); await expect(fileTreeFile(firstPage, "/second-marker.md")).toHaveCount(0); + await ensureFilesHomeTabActive(secondPage); await expect(fileTreeFile(secondPage, "/second-marker.md")).toBeVisible(); } finally { await closeElectronApp(electronApp); @@ -127,6 +132,7 @@ test("relaunch reopens saved directory and transient file workspaces", async ({ registerRendererConsoleLogging(filePage); await expectWindowCount(electronApp, 2); + await ensureFilesHomeTabActive(directoryPage); await expect( fileTreeFile(directoryPage, "/directory-marker.md"), ).toBeVisible(); @@ -159,10 +165,14 @@ test("relaunch restores the last document opened in a transient directory", asyn page.getByRole("heading", { name: "second.md" }), ).toBeVisible(); + await ensureFilesHomeTabActive(page); await fileTreeFile(page, "/first.md").click(); await expect(page.getByRole("heading", { name: "first.md" })).toBeVisible(); + // Tabs keep the auto-opened document open beside the newly opened one; + // the active document is listed first. await expectWorkspaceSessionOpenFilePaths(userDataDir, workspaceDir, [ "first.md", + "second.md", ]); await closeElectronApp(electronApp); @@ -233,7 +243,9 @@ test("relaunch restores saved workspaces with explicit paths deduped last", asyn await expectTitleCount(electronApp, path.basename(firstWorkspaceDir), 1); await expectTitleCount(electronApp, path.basename(secondWorkspaceDir), 1); await expectTitleCount(electronApp, path.basename(standaloneFileDir), 1); + await ensureFilesHomeTabActive(firstPage); await expect(fileTreeFile(firstPage, "/first-marker.md")).toBeVisible(); + await ensureFilesHomeTabActive(secondPage); await expect(fileTreeFile(secondPage, "/second-marker.md")).toBeVisible(); await expect(filePage.getByRole("heading", { name: "Solo" })).toBeVisible(); } finally { @@ -285,6 +297,7 @@ test("closed workspace windows are removed from the relaunch session", async ({ await expectWindowCount(electronApp, 1); await expectTitleCount(electronApp, path.basename(secondWorkspaceDir), 0); + await ensureFilesHomeTabActive(restoredPage); await expect(fileTreeFile(restoredPage, "/first-marker.md")).toBeVisible(); } finally { await closeElectronApp(electronApp); @@ -335,6 +348,7 @@ test("opening a folder from first run reuses the empty window", async ({ await expectWindowCount(electronApp, 1); await expect(page).toHaveTitle(path.basename(workspaceDir)); + await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); } finally { await closeElectronApp(electronApp); @@ -355,6 +369,7 @@ test("Track Changes menu toggles workspace .lix storage", async ({ const page = await pageWithTitle(electronApp, path.basename(workspaceDir)); registerRendererConsoleLogging(page); + await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); await expectTrackChangesMenuChecked(electronApp, false); await expectPathMissing(path.join(workspaceDir, ".lix")); @@ -381,6 +396,7 @@ test("Track Changes menu toggles workspace .lix storage", async ({ await expect(page).toHaveTitle(path.basename(workspaceDir)); await expectTrackChangesSettled(electronApp, workspaceDir, true); await expectLixFilePath(page, "/marker.md"); + await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); await expectInstalledPluginArchives(workspaceDir); @@ -388,6 +404,7 @@ test("Track Changes menu toggles workspace .lix storage", async ({ await expect(page).toHaveTitle(path.basename(workspaceDir)); await expectTrackChangesSettled(electronApp, workspaceDir, false); await expectLixFilePath(page, "/marker.md"); + await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); } finally { await closeElectronApp(electronApp); @@ -452,6 +469,7 @@ test("Track Changes recovery screen can disable tracking", async ({ page.getByRole("button", { name: "Disable Track Changes" }).click(), ]); + await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); await expectTrackChangesMenuChecked(electronApp, false); await expectPathMissing(path.join(workspaceDir, ".lix")); @@ -497,6 +515,7 @@ test("macOS open-file events create workspace windows for folders and files", as await expectWindowCount(electronApp, 3); await expect(firstRunPage).toHaveTitle("Flashtype"); + await ensureFilesHomeTabActive(folderPage); await expect(fileTreeFile(folderPage, "/folder-marker.md")).toBeVisible(); await expect( filePage.getByRole("heading", { name: "file-marker.md" }), @@ -559,7 +578,8 @@ test("macOS open-file events open standalone files as transient workspaces", asy "aria-pressed", "false", ); - await filePage.getByLabel("Toggle left panel").click(); + // The file tree lives in the pinned Files home tab now, not the left panel. + await ensureFilesHomeTabActive(filePage); await expect(fileTreeFile(filePage, "/sibling.md")).toBeVisible(); await expectPathMissing(path.join(directory, ".lix")); await expectPathMissing(path.join(directory, ".lix_system")); @@ -638,7 +658,8 @@ test("launching with multiple standalone markdown files creates one grouped tran "aria-pressed", "false", ); - await groupedPage.getByLabel("Toggle left panel").click(); + // The file tree lives in the pinned Files home tab now, not the left panel. + await ensureFilesHomeTabActive(groupedPage); await fileTreeDirectory(groupedPage, "/standalone-markdown-alpha/").click(); await expect( fileTreeFile(groupedPage, "/standalone-markdown-alpha/alpha.md"), @@ -743,6 +764,7 @@ test("mixed folder and standalone markdown args create folder and grouped file w registerRendererConsoleLogging(groupedFilePage); await expectWindowCount(electronApp, 2); + await ensureFilesHomeTabActive(folderPage); await expect(fileTreeFile(folderPage, "/folder-marker.md")).toBeVisible(); await expect( groupedFilePage.getByRole("heading", { name: "One" }), @@ -750,7 +772,8 @@ test("mixed folder and standalone markdown args create folder and grouped file w await expect( groupedFilePage.getByLabel("Toggle left panel"), ).toHaveAttribute("aria-pressed", "false"); - await groupedFilePage.getByLabel("Toggle left panel").click(); + // The file tree lives in the pinned Files home tab now, not the left panel. + await ensureFilesHomeTabActive(groupedFilePage); await expect( fileTreeFile(groupedFilePage, "/folder-marker.md"), ).toHaveCount(0); @@ -806,6 +829,7 @@ test("relaunch restores grouped transient file workspace", async ({ await expect( restoredPage.getByRole("heading", { name: "First" }), ).toBeVisible(); + await ensureFilesHomeTabActive(restoredPage); await fileTreeDirectory(restoredPage, "/restore-markdown-second/").click(); const restoredSecondItem = fileTreeFile( restoredPage, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daf0aa5..816b8bf 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/types.ts b/src/extension-runtime/types.ts index 35cbf79..1268616 100644 --- a/src/extension-runtime/types.ts +++ b/src/extension-runtime/types.ts @@ -67,6 +67,7 @@ export interface ExtensionContext { readonly closeFileViews?: (args: { readonly panel?: PanelSide; readonly fileId: string; + readonly filePath?: string; }) => void; /** File id for the file currently active in the central editor panel. */ readonly activeFileId?: string | null; diff --git a/src/extensions/files/host-extension.test.tsx b/src/extensions/files/host-extension.test.tsx index 4bbb0f4..ac7c73d 100644 --- a/src/extensions/files/host-extension.test.tsx +++ b/src/extensions/files/host-extension.test.tsx @@ -41,11 +41,6 @@ describe("createFilesExtensionRegistration", () => { 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, @@ -77,7 +72,6 @@ describe("createFilesExtensionRegistration", () => { 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", @@ -118,11 +112,6 @@ describe("createFilesExtensionRegistration", () => { 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, diff --git a/src/extensions/files/host-extension.tsx b/src/extensions/files/host-extension.tsx index 5efb6f6..819753a 100644 --- a/src/extensions/files/host-extension.tsx +++ b/src/extensions/files/host-extension.tsx @@ -80,10 +80,13 @@ function renderFilesView( () => requestId === openRequest.current, ); }, - closeFileViews: () => { + closeFileViews: ({ filePath }) => { + if (filePath) { + void atelier.documents.close(filePath); + return; + } void atelier.documents.closeActive(); }, - checkpointBranchId: atelier.revisions.current?.branchId ?? null, isPanelFocused: view.isFocused, panelSide: view.panel, viewInstance: view.instanceId, diff --git a/src/extensions/files/index.test.tsx b/src/extensions/files/index.test.tsx index dd1f423..a5c693c 100644 --- a/src/extensions/files/index.test.tsx +++ b/src/extensions/files/index.test.tsx @@ -642,7 +642,10 @@ describe("FilesView", () => { await waitFor(() => { expect(queryTreeItemByLabel(utils!, "hello.md")).toBeNull(); }); - expect(closeFileViews).toHaveBeenCalledWith({ fileId: "file_1" }); + expect(closeFileViews).toHaveBeenCalledWith({ + fileId: "file_1", + filePath: "/hello.md", + }); utils!.unmount(); await lix.close(); @@ -707,7 +710,10 @@ describe("FilesView", () => { }); }); - expect(closeFileViews).toHaveBeenCalledWith({ fileId: "file_viewed" }); + expect(closeFileViews).toHaveBeenCalledWith({ + fileId: "file_viewed", + filePath: "/viewed.md", + }); utils!.unmount(); await lix.close(); @@ -1816,6 +1822,7 @@ describe("FilesView", () => { }); expect(closeFileViews).toHaveBeenCalledWith({ fileId: "imported_loose", + filePath: "/loose.txt", }); } finally { utils?.unmount(); diff --git a/src/extensions/files/index.tsx b/src/extensions/files/index.tsx index 4b50777..2b6ed54 100644 --- a/src/extensions/files/index.tsx +++ b/src/extensions/files/index.tsx @@ -861,7 +861,13 @@ function FilesViewContent({ setPendingPaths((prev) => prev.filter((path) => path !== normalizedPath), ); - context?.closeFileViews?.({ fileId }); + // Close by path so the file's view also closes when it is open in + // the background (the file tree lives in the pinned home tab, so + // the document view is rarely the active one during deletion). + context?.closeFileViews?.({ + fileId, + filePath: normalizeFilePath(normalizedPath), + }); } else { await qb(lix) .deleteFrom("lix_directory") diff --git a/src/extensions/history/host-extension.test.tsx b/src/extensions/history/host-extension.test.tsx index 42a1fa8..c54f2f9 100644 --- a/src/extensions/history/host-extension.test.tsx +++ b/src/extensions/history/host-extension.test.tsx @@ -20,11 +20,8 @@ describe("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; @@ -47,13 +44,6 @@ describe("createHistoryExtensionRegistration", () => { 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 index d596535..f868bfe 100644 --- a/src/extensions/history/host-extension.tsx +++ b/src/extensions/history/host-extension.tsx @@ -1,7 +1,6 @@ import { createRoot } from "react-dom/client"; import { History } from "lucide-react"; import type { - AtelierBuiltinExtensionId, AtelierExtensionRegistration, AtelierExtensionRuntime, ExtensionManifest, @@ -11,9 +10,11 @@ import type { Lix } from "@/lib/lix-types"; import { HistoryView } from "./index"; import manifestJson from "./history.manifest.json"; +// Atelier removed its bundled history extension (opral/atelier#59); the id is +// now FlashType-owned but kept stable for persisted layouts. const manifest = { ...manifestJson, - id: "atelier_history" satisfies AtelierBuiltinExtensionId, + id: "atelier_history", } as ExtensionManifest; /** @@ -44,11 +45,9 @@ function renderHistoryView( ) { root.render( - + {/* Atelier's revisions runtime (checkpoint diff viewer) was removed + upstream; the view degrades gracefully without those props. */} + , ); } diff --git a/src/extensions/history/index.tsx b/src/extensions/history/index.tsx index 4e7d705..f197855 100644 --- a/src/extensions/history/index.tsx +++ b/src/extensions/history/index.tsx @@ -18,10 +18,15 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import type { - AtelierRevisionSelection, - AtelierExtensionRuntime, -} from "@opral/atelier"; +/** + * Atelier removed its checkpoint/revisions runtime (opral/atelier#59); these + * local types keep the optional diff-viewer wiring compilable for hosts that + * still provide one. + */ +type AtelierRevisionSelection = { + readonly branchId: string; +}; +type ShowCheckpointDiff = (branchId: string) => Promise; import type { CheckpointDiff, CheckpointDiffBranchRow, @@ -38,7 +43,7 @@ type BranchRow = { type HistoryViewProps = { readonly currentRevision?: AtelierRevisionSelection | null; - readonly showCheckpointDiff?: AtelierExtensionRuntime["revisions"]["show"]; + readonly showCheckpointDiff?: ShowCheckpointDiff; readonly clearCheckpointDiff?: () => void; }; @@ -143,7 +148,7 @@ function HistoryViewContent({ readonly branches: BranchRow[]; readonly activeBranchId: string; readonly currentRevision?: AtelierRevisionSelection | null; - readonly showCheckpointDiff?: AtelierExtensionRuntime["revisions"]["show"]; + readonly showCheckpointDiff?: ShowCheckpointDiff; readonly clearCheckpointDiff?: () => void; }) { const activeBranchRow = diff --git a/src/lib/atelier-workspace-bridge.test.ts b/src/lib/atelier-workspace-bridge.test.ts index e482106..1f8f542 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 e7e8415..2fa0d07 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -116,7 +116,6 @@ export const AppRoot = () => { // contract but intentionally hides native SDK internals. lix: lix as unknown as AtelierInstance["lix"], extensions: atelierExtensions, - filesViewMode: "sidebar", defaultOpenPanels: defaultOpenAtelierPanels, sessionStateStore: atelierSessionStateStore, onEvent: handleAtelierEvent, diff --git a/submodule/atelier b/submodule/atelier index c5d8a35..4396919 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit c5d8a35a505aa57fa8ae60eb064c2b7be977b73b +Subproject commit 4396919f1f79f7ad144933c94138b68937e41a3f From 541d2fd69262d4bd986704b5d102725cfc92245f Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:03:15 -0400 Subject: [PATCH 02/10] feat!: remove the checkpoint feature, mirroring atelier#59 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atelier removed its checkpoint implementation upstream (opral/atelier#59, pulled in by the central-tabs submodule bump); FlashType mirrors that removal instead of keeping a dormant, half-working feature. Deleted: - src/extensions/history/ — the checkpoint UI (list, create, rename, switch, delete) and its registration - electron/checkpoint-name.mjs (+test), the terminal:generateCheckpointName IPC handler, preload method, and DesktopGenerateCheckpointName* types - src/shell/checkpoint-diff.ts (+test) and src/extension-runtime/checkpoint-diff.ts - e2e/branch-switcher.spec.ts — all four tests exercised checkpoint UI or checkpoint diffs (incl. the two previously skipped) Stripped checkpoint branches from the Files view (checkpoint-diff virtual tree, checkpointBranchId/checkpointDiff context, the "checkpoint-diff" entry source), the file tree (reviewStatuses map, "recreated" status styling), the legacy MarkdownView/CsvView (checkpoint-diff rendering; commit-snapshot/diff modes stay), the top bar (isReviewingCheckpoint badge), and editor-revision-state (checkpoint state keys). Kept: shared lix branch plumbing (createBranch/switchBranch/ syncDiskToLix, the lix_workspace_branch_id active-branch key) and the agent-review machinery — separate features that read them. Co-Authored-By: Claude Fable 5 --- e2e/branch-switcher.spec.ts | 1164 ----------------- e2e/electron-test-utils.ts | 34 - electron/checkpoint-name.mjs | 259 ---- electron/checkpoint-name.test.mjs | 189 --- electron/ipc-terminal.mjs | 18 - electron/preload.mjs | 2 - electron/types.d.ts | 15 - src/extension-runtime/checkpoint-diff.ts | 41 - .../editor-revision-state.ts | 2 - src/extension-runtime/types.ts | 3 - .../atelier-host-extensions.test.ts | 3 +- src/extensions/atelier-host-extensions.ts | 7 +- src/extensions/csv/index.reactive.test.tsx | 58 - src/extensions/csv/index.tsx | 93 +- src/extensions/files/build-filesystem-tree.ts | 14 +- src/extensions/files/file-tree.tsx | 40 +- src/extensions/files/index.test.tsx | 200 --- src/extensions/files/index.tsx | 172 +-- src/extensions/history/history.manifest.json | 6 - .../history/host-extension.test.tsx | 52 - src/extensions/history/host-extension.tsx | 53 - src/extensions/history/index.test.tsx | 499 ------- src/extensions/history/index.tsx | 643 --------- src/extensions/markdown/index.test.tsx | 83 -- src/extensions/markdown/index.tsx | 94 +- src/extensions/terminal/index.test.tsx | 4 - src/queries.ts | 2 +- src/shell/checkpoint-diff.test.ts | 312 ----- src/shell/checkpoint-diff.ts | 333 ----- src/shell/top-bar/index.test.tsx | 19 +- src/shell/top-bar/index.tsx | 8 - 31 files changed, 63 insertions(+), 4359 deletions(-) delete mode 100644 e2e/branch-switcher.spec.ts delete mode 100644 electron/checkpoint-name.mjs delete mode 100644 electron/checkpoint-name.test.mjs delete mode 100644 src/extension-runtime/checkpoint-diff.ts delete mode 100644 src/extensions/history/history.manifest.json delete mode 100644 src/extensions/history/host-extension.test.tsx delete mode 100644 src/extensions/history/host-extension.tsx delete mode 100644 src/extensions/history/index.test.tsx delete mode 100644 src/extensions/history/index.tsx delete mode 100644 src/shell/checkpoint-diff.test.ts delete mode 100644 src/shell/checkpoint-diff.ts diff --git a/e2e/branch-switcher.spec.ts b/e2e/branch-switcher.spec.ts deleted file mode 100644 index c3b3d47..0000000 --- a/e2e/branch-switcher.spec.ts +++ /dev/null @@ -1,1164 +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, - ensureFilesHomeTabActive, - 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 ensureFilesHomeTabActive(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 ensureFilesHomeTabActive(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 ensureFilesHomeTabActive(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 ensureFilesHomeTabActive(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); - } -}); - -// Skipped: atelier removed its checkpoint/revisions runtime (opral/atelier#59), -// so selecting a checkpoint row no longer resolves a diff or marks files. If -// FlashType reimplements checkpoint-diff selection host-side, restore this. -test.skip("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 ensureFilesHomeTabActive(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); - } -}); - -// Skipped: atelier removed its checkpoint/revisions runtime (opral/atelier#59), -// so checkpoint-diff selection and editor revision state are gone. If -// FlashType reimplements checkpoint-diff selection host-side, restore this. -test.skip("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 ensureFilesHomeTabActive(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 ?? []; - // The pinned Files home tab ("central-home") is always the first central - // view; document identity lives on the content tabs beside it. - const contentViews = views.filter((view) => view.instance !== "central-home"); - const active = - views.find((view) => view.instance === central?.activeInstance) ?? - contentViews[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 ?? []; - // The pinned Files home tab ("central-home") is always the first central - // view; document identity lives on the content tabs beside it. - const contentViews = views.filter((view) => view.instance !== "central-home"); - const active = - views.find((view) => view.instance === central?.activeInstance) ?? - contentViews[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 content tab beside the pinned Files home", - 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 ?? []; - // The central panel is always tabs with the Files view pinned first as the - // home tab; documents open as content tabs beside it. - const homeView = centralViews[0]; - if (!homeView || homeView.instance !== "central-home") { - violations.push("central home tab is not the first view"); - } - const contentViews = centralViews.filter( - (view) => view.instance !== "central-home", - ); - if (contentViews.length > 1) { - violations.push(`central has ${contentViews.length} content views`); - } - const centralView = contentViews[0]; - if (centralView && !isDocumentView(centralView)) { - violations.push("central content 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 ?? []; - // The pinned Files home tab ("central-home") is always the first central - // view; document identity lives on the content tabs beside it. - const contentViews = views.filter((view) => view.instance !== "central-home"); - const active = - views.find((view) => view.instance === central?.activeInstance) ?? - contentViews[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 ensureFilesHomeTabActive(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 667ccf9..7eaacdb 100644 --- a/e2e/electron-test-utils.ts +++ b/e2e/electron-test-utils.ts @@ -92,40 +92,6 @@ export async function ensureFilesHomeTabActive(page: Page): Promise { 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/electron/checkpoint-name.mjs b/electron/checkpoint-name.mjs deleted file mode 100644 index 78e4dee..0000000 --- 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 7210dca..0000000 --- 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 a9df27f..6273663 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 28381bf..18a82a0 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 9093fa8..6eb2676 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/src/extension-runtime/checkpoint-diff.ts b/src/extension-runtime/checkpoint-diff.ts deleted file mode 100644 index 59f9374..0000000 --- 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 4488d91..6f10f9f 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 1268616..36f3c63 100644 --- a/src/extension-runtime/types.ts +++ b/src/extension-runtime/types.ts @@ -1,5 +1,4 @@ import type { Lix } from "@/lib/lix-types"; -import type { CheckpointDiff } from "./checkpoint-diff"; /** * Persisted view state. Only include values that should survive reloads. @@ -62,8 +61,6 @@ export interface ExtensionContext { 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; diff --git a/src/extensions/atelier-host-extensions.test.ts b/src/extensions/atelier-host-extensions.test.ts index 9bacad8..0d04d0e 100644 --- a/src/extensions/atelier-host-extensions.test.ts +++ b/src/extensions/atelier-host-extensions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { createFlashTypeAtelierExtensions } from "./atelier-host-extensions"; describe("createFlashTypeAtelierExtensions", () => { - test("registers filesystem, Electron-aware history, and agent terminals", () => { + test("registers filesystem and agent terminals", () => { const extensions = createFlashTypeAtelierExtensions({ ephemeral: false, path: "/workspace", @@ -11,7 +11,6 @@ describe("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 08c0446..4d0b28a 100644 --- a/src/extensions/atelier-host-extensions.ts +++ b/src/extensions/atelier-host-extensions.ts @@ -4,7 +4,6 @@ import type { } from "@opral/atelier"; import type { WorkspaceContext } from "@/extension-runtime/types"; import { createFilesExtensionRegistration } from "./files/host-extension"; -import { createHistoryExtensionRegistration } from "./history/host-extension"; import { FLASHTYPE_ATELIER_EXTENSIONS as TERMINAL_EXTENSIONS } from "./terminal/host-extensions"; export const FLASHTYPE_FILES_EXTENSION_ID = @@ -13,9 +12,5 @@ export const FLASHTYPE_FILES_EXTENSION_ID = export function createFlashTypeAtelierExtensions( workspace: WorkspaceContext, ): readonly AtelierExtensionRegistration[] { - return [ - createFilesExtensionRegistration(workspace), - createHistoryExtensionRegistration(), - ...TERMINAL_EXTENSIONS, - ]; + return [createFilesExtensionRegistration(workspace), ...TERMINAL_EXTENSIONS]; } diff --git a/src/extensions/csv/index.reactive.test.tsx b/src/extensions/csv/index.reactive.test.tsx index 1a222ad..0486239 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 8f97583..66e02e4 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.ts b/src/extensions/files/build-filesystem-tree.ts index c634cfb..ef1db1b 100644 --- a/src/extensions/files/build-filesystem-tree.ts +++ b/src/extensions/files/build-filesystem-tree.ts @@ -1,6 +1,6 @@ import type { FilesystemEntryRow } from "@/queries"; -export type FilesystemTreeSource = "lix" | "watched" | "checkpoint-diff"; +export type FilesystemTreeSource = "lix" | "watched"; export type FilesystemTreeFile = { type: "file"; @@ -70,18 +70,6 @@ function preferLixEntry( 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"; } diff --git a/src/extensions/files/file-tree.tsx b/src/extensions/files/file-tree.tsx index e128a74..32b4ffe 100644 --- a/src/extensions/files/file-tree.tsx +++ b/src/extensions/files/file-tree.tsx @@ -45,7 +45,6 @@ export type FileTreeProps = { ) => void; readonly openDirectories?: ReadonlySet; readonly reviewPaths?: ReadonlySet; - readonly reviewStatuses?: ReadonlyMap; readonly onOpenDirectoriesChange?: (paths: ReadonlySet) => void; readonly onCreateCommit?: ( request: FileTreeCreateRequest, @@ -57,11 +56,9 @@ export type FileTreeProps = { ) => Promise | void; }; -type ReviewGitStatus = GitStatusEntry["status"] | "recreated"; - type ReviewGitStatusEntry = { readonly path: string; - readonly status: ReviewGitStatus; + readonly status: GitStatusEntry["status"]; }; type TreePathInfo = { @@ -152,16 +149,6 @@ const FILE_TREE_UNSAFE_CSS = ` 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; @@ -211,7 +198,6 @@ export function FileTree({ onSelectItem, openDirectories, reviewPaths, - reviewStatuses, onOpenDirectoriesChange, onCreateCommit, onCreateCancel, @@ -248,8 +234,8 @@ export function FileTree({ [openDirectoryTreePaths], ); const reviewGitStatusEntries = useMemo( - () => buildReviewGitStatusEntries(reviewPaths, reviewStatuses, treeInput), - [reviewPaths, reviewStatuses, treeInput], + () => buildReviewGitStatusEntries(reviewPaths, treeInput), + [reviewPaths, treeInput], ); const reviewGitStatusKey = useMemo( () => @@ -343,9 +329,6 @@ export function FileTree({ if (info.source === "watched") { return info.kind === "file"; } - if (info.source === "checkpoint-diff") { - return false; - } return true; }; @@ -370,9 +353,6 @@ export function FileTree({ if (sourceInfo.source === "watched" && sourceInfo.kind !== "file") { return; } - if (sourceInfo.source === "checkpoint-diff") { - return; - } void stateRef.current.onRenameCommit?.({ destinationPath: sourceInfo.kind === "directory" @@ -604,24 +584,12 @@ function buildTreeInput( function buildReviewGitStatusEntries( reviewPaths: ReadonlySet | undefined, - reviewStatuses: ReadonlyMap | undefined, treeInput: TreeInput, ): ReviewGitStatusEntry[] { - if ( - (!reviewPaths || reviewPaths.size === 0) && - (!reviewStatuses || reviewStatuses.size === 0) - ) { + if (!reviewPaths || reviewPaths.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); diff --git a/src/extensions/files/index.test.tsx b/src/extensions/files/index.test.tsx index a5c693c..1e4a5ca 100644 --- a/src/extensions/files/index.test.tsx +++ b/src/extensions/files/index.test.tsx @@ -10,10 +10,6 @@ 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>, @@ -1257,167 +1253,6 @@ describe("FilesView", () => { } }); - 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; @@ -2221,38 +2056,3 @@ function agentRange( ...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 index 2b6ed54..b0eab81 100644 --- a/src/extensions/files/index.tsx +++ b/src/extensions/files/index.tsx @@ -14,14 +14,8 @@ import { type FileTreeCreateRequest, type FileTreeRenameRequest, } from "./file-tree"; -import { qb, sql } from "@/lib/lix-kysely"; +import { qb } 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, @@ -31,7 +25,6 @@ import { getPendingExternalWriteReviewPaths, type ExternalWriteReviewFile, } from "@/shell/external-write-review-history"; -import { resolveCheckpointDiffForBranch } from "@/shell/checkpoint-diff"; type FilesViewProps = { readonly context?: ExtensionContext; @@ -85,45 +78,12 @@ function FilesActiveFileLoader({ 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 ( ); } @@ -133,18 +93,11 @@ function FilesRuntimeState({ 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} @@ -160,34 +112,6 @@ function FilesRuntimeState({ ); } -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, @@ -217,42 +141,15 @@ function FilesViewContent({ 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( + () => unionFilesystemEntries(entries ?? [], visibleWatchedEntries), + [entries, visibleWatchedEntries], ); - 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 reviewPaths = usePendingExternalWriteReviewPaths(lix, nodes); const creatingRef = useRef(false); const renamingRef = useRef(false); const [pendingPaths, setPendingPaths] = useState([]); @@ -608,7 +505,6 @@ function FilesViewContent({ const handleRenameCommit = useCallback( async (request: FileTreeRenameRequest) => { if (renamingRef.current) return; - if (request.source === "checkpoint-diff") return; const sourcePath = request.kind === "directory" ? ensureDirectoryPath(request.sourcePath) @@ -748,33 +644,17 @@ function FilesViewContent({ 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", + source: "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], @@ -817,7 +697,6 @@ function FilesViewContent({ const handleDeleteSelection = useCallback(async () => { if (!selectedPath || !selectedKind) return; - if (selectedSource === "checkpoint-diff") return; const normalizedPath = selectedKind === "file" ? selectedPath @@ -1131,7 +1010,6 @@ function FilesViewContent({ nodes={nodes} openFileView={handleOpenFile} reviewPaths={reviewPaths} - reviewStatuses={reviewStatuses} onSelectItem={handleSelectItem} selectedPath={selectedPath ?? undefined} isPanelFocused={isPanelFocused} @@ -1251,7 +1129,7 @@ function collectReviewableTreeFiles( const files: ExternalWriteReviewFile[] = []; const visit = (node: FilesystemTreeNode) => { if (node.type === "file") { - if (node.source !== "watched" && node.source !== "checkpoint-diff") { + if (node.source !== "watched") { files.push({ fileId: node.id, path: node.path }); } return; @@ -1502,42 +1380,6 @@ function unionFilesystemEntries( ); } -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); diff --git a/src/extensions/history/history.manifest.json b/src/extensions/history/history.manifest.json deleted file mode 100644 index 23b8013..0000000 --- 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 c54f2f9..0000000 --- a/src/extensions/history/host-extension.test.tsx +++ /dev/null @@ -1,52 +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 atelier = { - lix: {}, - } 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(); - - 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 f868bfe..0000000 --- a/src/extensions/history/host-extension.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { createRoot } from "react-dom/client"; -import { History } from "lucide-react"; -import type { - 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"; - -// Atelier removed its bundled history extension (opral/atelier#59); the id is -// now FlashType-owned but kept stable for persisted layouts. -const manifest = { - ...manifestJson, - id: "atelier_history", -} 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( - - {/* Atelier's revisions runtime (checkpoint diff viewer) was removed - upstream; the view degrades gracefully without those props. */} - - , - ); -} diff --git a/src/extensions/history/index.test.tsx b/src/extensions/history/index.test.tsx deleted file mode 100644 index 02f6c93..0000000 --- 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 f197855..0000000 --- a/src/extensions/history/index.tsx +++ /dev/null @@ -1,643 +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"; -/** - * Atelier removed its checkpoint/revisions runtime (opral/atelier#59); these - * local types keep the optional diff-viewer wiring compilable for hosts that - * still provide one. - */ -type AtelierRevisionSelection = { - readonly branchId: string; -}; -type ShowCheckpointDiff = (branchId: string) => Promise; -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?: ShowCheckpointDiff; - 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?: ShowCheckpointDiff; - 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 453793e..461cc6c 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 ee6d068..59f2492 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 12ee594..de23790 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/queries.ts b/src/queries.ts index e53b995..5811912 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -7,7 +7,7 @@ export type FilesystemEntryRow = { path: string; display_name: string; kind: "directory" | "file"; - source?: "lix" | "watched" | "checkpoint-diff"; + source?: "lix" | "watched"; }; /** diff --git a/src/shell/checkpoint-diff.test.ts b/src/shell/checkpoint-diff.test.ts deleted file mode 100644 index 7ff03c5..0000000 --- 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 675e691..0000000 --- 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 7f3c4f6..ee41961 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 1522861..10bb128 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} From 0ffd276d5414dc3508fdd4abcea54c1ff71776b4 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:36:34 -0400 Subject: [PATCH 03/10] chore: bump atelier to central-panel-embedding tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up the built-in strip UX parity (file-type glyphs, no active ring) and the central + removal. Co-Authored-By: Claude Fable 5 --- submodule/atelier | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodule/atelier b/submodule/atelier index 4396919..cf6ca2f 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit 4396919f1f79f7ad144933c94138b68937e41a3f +Subproject commit cf6ca2ffbdff11794078083d3b921ee177e57295 From 28c6f3c387cee1ee8bd4859124f833e1f2bc1ec2 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:59:05 -0400 Subject: [PATCH 04/10] =?UTF-8?q?feat!:=20adopt=20the=20final=20central-pa?= =?UTF-8?q?nel=20shape=20=E2=80=94=20Files=20stays=20in=20the=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps submodule/atelier to c8c9a58 (central-panel-embedding tip): pinned home only when configured. FlashType passes no centralPanel.home, so the Files view lives in the left sidebar (open by default on fresh state), the central panel is tabs of documents only, and closing the last tab shows the central empty state again. Restores the e2e specs to their origin/main shape — the sidebar-Files assumptions (tree visible beside documents, "Start writing" empty state, in-place tab navigation) hold again under the tab model — minus the deleted checkpoint helper. The documents.close(path) deletion fix stays. Co-Authored-By: Claude Fable 5 --- e2e/agent-review.spec.ts | 6 +++--- e2e/electron-test-utils.ts | 26 +++++++++++++------------ e2e/files-click-tour.spec.ts | 27 ++++++++------------------ e2e/markdown-editor-fuzz.spec.ts | 4 ++-- e2e/packaged-macos.spec.ts | 4 ++-- e2e/workspace-change-stress.spec.ts | 4 ++-- e2e/workspace-switch.spec.ts | 7 +------ e2e/workspace-windows.spec.ts | 30 +++-------------------------- src/extensions/files/index.tsx | 3 +-- submodule/atelier | 2 +- 10 files changed, 37 insertions(+), 76 deletions(-) diff --git a/e2e/agent-review.spec.ts b/e2e/agent-review.spec.ts index 06875b8..ad5f928 100644 --- a/e2e/agent-review.spec.ts +++ b/e2e/agent-review.spec.ts @@ -4,7 +4,7 @@ import { chmod, mkdir, readFile, utimes, writeFile } from "node:fs/promises"; import path from "node:path"; import { closeElectronApp, - ensureFilesHomeTabActive, + ensureFilesViewOpenInLeftPanel, expectWorkspaceSessionOpenFilePaths, fileTreeFile, launchDevElectronAppWithArgs, @@ -101,7 +101,7 @@ test("Atelier reveals a review after Codex edits restored markdown", async ({ await expect( page.getByRole("button", { name: "Undo change" }), ).toBeVisible(); - await ensureFilesHomeTabActive(page); + await ensureFilesViewOpenInLeftPanel(page); await expect(fileTreeFile(page, "/changelog.md")).toHaveAttribute( "data-item-git-status", "modified", @@ -127,7 +127,7 @@ test("Atelier reveals a review after Codex edits restored markdown", async ({ }); async function openWelcomeMarkdown(page: Page): Promise { - await ensureFilesHomeTabActive(page); + await ensureFilesViewOpenInLeftPanel(page); const file = fileTreeFile(page, "/welcome.md"); await expect(file).toBeVisible(); await file.click(); diff --git a/e2e/electron-test-utils.ts b/e2e/electron-test-utils.ts index 7eaacdb..921f499 100644 --- a/e2e/electron-test-utils.ts +++ b/e2e/electron-test-utils.ts @@ -75,20 +75,22 @@ export function registerRendererConsoleLogging(page: Page): void { }); } -export async function ensureFilesHomeTabActive(page: Page): Promise { +export async function ensureFilesViewOpenInLeftPanel( + page: Page, +): Promise { await waitForWorkspaceReady(page); - // The central panel is always browser-style tabs with the Files view as - // the pinned home tab (atelier's single central-tabs primitive); it no - // longer lives in the left side panel. - const filesTab = page - .locator('button[data-view-key="atelier_files"][data-pinned="true"]') - .first(); - await expect(filesTab).toBeVisible(); - if ((await filesTab.getAttribute("data-focused")) !== "true") { - // The pinned tab is excluded from drag-sorting, which marks it - // aria-disabled; force past Playwright's actionability check. - await filesTab.click({ force: true }); + const leftPanel = page.locator("aside").first(); + const filesTab = leftPanel.locator('[data-view-key="atelier_files"]').first(); + const leftPanelToggle = page.getByLabel("Toggle left panel").first(); + + await expect(leftPanelToggle).toBeVisible(); + if ((await leftPanelToggle.getAttribute("aria-pressed")) !== "true") { + await leftPanelToggle.click(); } + + await expect(leftPanelToggle).toHaveAttribute("aria-pressed", "true"); + await expect(filesTab).toBeVisible(); + await filesTab.click(); await expect(filesTab).toHaveAttribute("data-focused", "true"); } diff --git a/e2e/files-click-tour.spec.ts b/e2e/files-click-tour.spec.ts index 763bd3c..eebd1f0 100644 --- a/e2e/files-click-tour.spec.ts +++ b/e2e/files-click-tour.spec.ts @@ -3,7 +3,7 @@ import type { ElectronApplication } from "playwright"; import seedrandom from "seedrandom"; import { closeElectronApp, - ensureFilesHomeTabActive, + ensureFilesViewOpenInLeftPanel, fileTreeFile, fileTreeFiles, launchDevElectronApp, @@ -29,11 +29,11 @@ test("left files panel survives a seeded random file click tour", async ({ const page = await electronApp.firstWindow(); registerRendererConsoleLogging(page); - await ensureFilesHomeTabActive(page); - // The workspace auto-opens the most recent markdown file; it now lives - // in a content tab beside the pinned Files home tab. + await ensureFilesViewOpenInLeftPanel(page); await expect( - page.locator('button[data-view-key="atelier_file"]').first(), + page.locator( + '[data-panel-side="central"][data-active="true"][data-view-key="atelier_file"]', + ), ).toBeVisible(); const fileItems = fileTreeFiles(page); @@ -61,9 +61,6 @@ test("left files panel survives a seeded random file click tour", async ({ const file = fileTreeFile(page, `/${treePath}`); await test.step(`click ${index + 1}/${clickCount}: file index ${fileIndex}, delay ${delayMs}ms`, async () => { - // Opening a document activates its tab and hides the Files home - // tree, so return to the home tab before each tree click. - await ensureFilesHomeTabActive(page); await file.click(); await expect(file).toHaveAttribute("data-item-selected", "true"); await expectActiveFileView(page, treePath); @@ -146,7 +143,7 @@ test("deleting the active file closes the central file view", async ({ const page = await electronApp.firstWindow(); registerRendererConsoleLogging(page); - await ensureFilesHomeTabActive(page); + await ensureFilesViewOpenInLeftPanel(page); const file = fileTreeFile(page, "/welcome.md"); await expect(file).toBeVisible(); @@ -159,10 +156,7 @@ test("deleting the active file closes the central file view", async ({ ).toBeVisible(); await expect(file).toHaveAttribute("data-item-selected", "true"); - // Opening the document activated its tab; return to the Files home tab - // (the selection persists) to delete the file from the tree. - await ensureFilesHomeTabActive(page); - await expect(file).toHaveAttribute("data-item-selected", "true"); + await file.click(); await file.focus(); await page.keyboard.press(deleteShortcut); @@ -170,12 +164,7 @@ test("deleting the active file closes the central file view", async ({ await expect( page.locator('[data-active="true"][data-view-key="atelier_file"]'), ).toHaveCount(0); - // Closing the last content tab lands on the pinned Files home tab. - await expect( - page.locator( - '[data-panel-side="central"][data-active="true"][data-view-key="atelier_files"]', - ), - ).toBeVisible(); + await expect(page.getByTestId("central-panel-empty-state")).toBeVisible(); } finally { await closeElectronApp(electronApp); } diff --git a/e2e/markdown-editor-fuzz.spec.ts b/e2e/markdown-editor-fuzz.spec.ts index d4304e1..4561827 100644 --- a/e2e/markdown-editor-fuzz.spec.ts +++ b/e2e/markdown-editor-fuzz.spec.ts @@ -24,7 +24,7 @@ import { } from "../src/extensions/markdown/editor/markdown-editor-fuzz"; import { closeElectronApp, - ensureFilesHomeTabActive, + ensureFilesViewOpenInLeftPanel, fileTreeFile, launchDevElectronApp, registerRendererConsoleLogging, @@ -113,7 +113,7 @@ test("fuzzes markdown editor plain text through the Flashtype UI", async ({ const page = await electronApp.firstWindow(); registerRendererConsoleLogging(page); - await ensureFilesHomeTabActive(page); + await ensureFilesViewOpenInLeftPanel(page); const file = fileTreeFile(page, uiFuzzFilePath); await expect(file).toBeVisible(); await file.click(); diff --git a/e2e/packaged-macos.spec.ts b/e2e/packaged-macos.spec.ts index cf69864..22f0b4a 100644 --- a/e2e/packaged-macos.spec.ts +++ b/e2e/packaged-macos.spec.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { closeElectronApp, - ensureFilesHomeTabActive, + ensureFilesViewOpenInLeftPanel, expectPathMissing, fileTreeFiles, launchPackagedElectronApp, @@ -117,7 +117,7 @@ test("packaged app launches, seeds, and opens files without Vite", async ({ registerRendererConsoleLogging(page); await expectPathMissing(path.join(workspaceDir, ".lix")); - await ensureFilesHomeTabActive(page); + await ensureFilesViewOpenInLeftPanel(page); await expect( page.locator( '[data-panel-side="central"][data-active="true"][data-view-key="atelier_file"]', diff --git a/e2e/workspace-change-stress.spec.ts b/e2e/workspace-change-stress.spec.ts index 399798a..764cb48 100644 --- a/e2e/workspace-change-stress.spec.ts +++ b/e2e/workspace-change-stress.spec.ts @@ -7,7 +7,7 @@ import { performance } from "node:perf_hooks"; import seedrandom from "seedrandom"; import { closeElectronApp, - ensureFilesHomeTabActive, + ensureFilesViewOpenInLeftPanel, fileTreeFile, launchDevElectronApp, registerRendererConsoleLogging, @@ -451,7 +451,7 @@ function startLixTraceCapture( } async function openStressMarkdown(page: Page): Promise { - await ensureFilesHomeTabActive(page); + await ensureFilesViewOpenInLeftPanel(page); const file = fileTreeFile(page, stressAppPath); await expect(file).toBeVisible(); await file.click(); diff --git a/e2e/workspace-switch.spec.ts b/e2e/workspace-switch.spec.ts index 9a4e26c..7da1cbb 100644 --- a/e2e/workspace-switch.spec.ts +++ b/e2e/workspace-switch.spec.ts @@ -4,7 +4,6 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { closeElectronApp, - ensureFilesHomeTabActive, expectPathMissing, fileTreeFile, launchDevElectronApp, @@ -30,7 +29,6 @@ test("switching to an empty workspace closes the previous lix session", async ({ const page = await electronApp.firstWindow(); registerRendererConsoleLogging(page); - await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/old-workspace-marker.md")).toBeVisible(); await expectPathMissing(path.join(firstWorkspaceDir, ".lix")); @@ -42,10 +40,7 @@ test("switching to an empty workspace closes the previous lix session", async ({ await expect(page).toHaveTitle(path.basename(secondWorkspaceDir)); await expect(fileTreeFile(page, "/old-workspace-marker.md")).toHaveCount(0); - // The pinned Files home tab is the landing for an empty workspace now; - // the "Start writing" central empty state no longer exists. - await ensureFilesHomeTabActive(page); - await expect(page.getByRole("button", { name: "New file" })).toBeVisible(); + await expect(page.getByText("Start writing")).toBeVisible(); await expectPathMissing(path.join(secondWorkspaceDir, ".lix")); } finally { await closeElectronApp(electronApp); diff --git a/e2e/workspace-windows.spec.ts b/e2e/workspace-windows.spec.ts index ed54d1f..b7e55bd 100644 --- a/e2e/workspace-windows.spec.ts +++ b/e2e/workspace-windows.spec.ts @@ -5,7 +5,6 @@ import { mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises"; import path from "node:path"; import { closeElectronApp, - ensureFilesHomeTabActive, expectInstalledPluginArchives, fileTreeDirectory, fileTreeFile, @@ -41,10 +40,8 @@ test("launching with multiple workspace args creates independent windows", async registerRendererConsoleLogging(firstPage); registerRendererConsoleLogging(secondPage); - await ensureFilesHomeTabActive(firstPage); await expect(fileTreeFile(firstPage, "/first-marker.md")).toBeVisible(); await expect(fileTreeFile(firstPage, "/second-marker.md")).toHaveCount(0); - await ensureFilesHomeTabActive(secondPage); await expect(fileTreeFile(secondPage, "/second-marker.md")).toBeVisible(); await expect(fileTreeFile(secondPage, "/first-marker.md")).toHaveCount(0); } finally { @@ -84,10 +81,8 @@ test("opening a folder from an existing workspace creates a new window", async ( await expectWindowCount(electronApp, 2); await expect(firstPage).toHaveTitle(path.basename(firstWorkspaceDir)); - await ensureFilesHomeTabActive(firstPage); await expect(fileTreeFile(firstPage, "/first-marker.md")).toBeVisible(); await expect(fileTreeFile(firstPage, "/second-marker.md")).toHaveCount(0); - await ensureFilesHomeTabActive(secondPage); await expect(fileTreeFile(secondPage, "/second-marker.md")).toBeVisible(); } finally { await closeElectronApp(electronApp); @@ -132,7 +127,6 @@ test("relaunch reopens saved directory and transient file workspaces", async ({ registerRendererConsoleLogging(filePage); await expectWindowCount(electronApp, 2); - await ensureFilesHomeTabActive(directoryPage); await expect( fileTreeFile(directoryPage, "/directory-marker.md"), ).toBeVisible(); @@ -165,14 +159,10 @@ test("relaunch restores the last document opened in a transient directory", asyn page.getByRole("heading", { name: "second.md" }), ).toBeVisible(); - await ensureFilesHomeTabActive(page); await fileTreeFile(page, "/first.md").click(); await expect(page.getByRole("heading", { name: "first.md" })).toBeVisible(); - // Tabs keep the auto-opened document open beside the newly opened one; - // the active document is listed first. await expectWorkspaceSessionOpenFilePaths(userDataDir, workspaceDir, [ "first.md", - "second.md", ]); await closeElectronApp(electronApp); @@ -243,9 +233,7 @@ test("relaunch restores saved workspaces with explicit paths deduped last", asyn await expectTitleCount(electronApp, path.basename(firstWorkspaceDir), 1); await expectTitleCount(electronApp, path.basename(secondWorkspaceDir), 1); await expectTitleCount(electronApp, path.basename(standaloneFileDir), 1); - await ensureFilesHomeTabActive(firstPage); await expect(fileTreeFile(firstPage, "/first-marker.md")).toBeVisible(); - await ensureFilesHomeTabActive(secondPage); await expect(fileTreeFile(secondPage, "/second-marker.md")).toBeVisible(); await expect(filePage.getByRole("heading", { name: "Solo" })).toBeVisible(); } finally { @@ -297,7 +285,6 @@ test("closed workspace windows are removed from the relaunch session", async ({ await expectWindowCount(electronApp, 1); await expectTitleCount(electronApp, path.basename(secondWorkspaceDir), 0); - await ensureFilesHomeTabActive(restoredPage); await expect(fileTreeFile(restoredPage, "/first-marker.md")).toBeVisible(); } finally { await closeElectronApp(electronApp); @@ -348,7 +335,6 @@ test("opening a folder from first run reuses the empty window", async ({ await expectWindowCount(electronApp, 1); await expect(page).toHaveTitle(path.basename(workspaceDir)); - await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); } finally { await closeElectronApp(electronApp); @@ -369,7 +355,6 @@ test("Track Changes menu toggles workspace .lix storage", async ({ const page = await pageWithTitle(electronApp, path.basename(workspaceDir)); registerRendererConsoleLogging(page); - await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); await expectTrackChangesMenuChecked(electronApp, false); await expectPathMissing(path.join(workspaceDir, ".lix")); @@ -396,7 +381,6 @@ test("Track Changes menu toggles workspace .lix storage", async ({ await expect(page).toHaveTitle(path.basename(workspaceDir)); await expectTrackChangesSettled(electronApp, workspaceDir, true); await expectLixFilePath(page, "/marker.md"); - await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); await expectInstalledPluginArchives(workspaceDir); @@ -404,7 +388,6 @@ test("Track Changes menu toggles workspace .lix storage", async ({ await expect(page).toHaveTitle(path.basename(workspaceDir)); await expectTrackChangesSettled(electronApp, workspaceDir, false); await expectLixFilePath(page, "/marker.md"); - await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); } finally { await closeElectronApp(electronApp); @@ -469,7 +452,6 @@ test("Track Changes recovery screen can disable tracking", async ({ page.getByRole("button", { name: "Disable Track Changes" }).click(), ]); - await ensureFilesHomeTabActive(page); await expect(fileTreeFile(page, "/marker.md")).toBeVisible(); await expectTrackChangesMenuChecked(electronApp, false); await expectPathMissing(path.join(workspaceDir, ".lix")); @@ -515,7 +497,6 @@ test("macOS open-file events create workspace windows for folders and files", as await expectWindowCount(electronApp, 3); await expect(firstRunPage).toHaveTitle("Flashtype"); - await ensureFilesHomeTabActive(folderPage); await expect(fileTreeFile(folderPage, "/folder-marker.md")).toBeVisible(); await expect( filePage.getByRole("heading", { name: "file-marker.md" }), @@ -578,8 +559,7 @@ test("macOS open-file events open standalone files as transient workspaces", asy "aria-pressed", "false", ); - // The file tree lives in the pinned Files home tab now, not the left panel. - await ensureFilesHomeTabActive(filePage); + await filePage.getByLabel("Toggle left panel").click(); await expect(fileTreeFile(filePage, "/sibling.md")).toBeVisible(); await expectPathMissing(path.join(directory, ".lix")); await expectPathMissing(path.join(directory, ".lix_system")); @@ -658,8 +638,7 @@ test("launching with multiple standalone markdown files creates one grouped tran "aria-pressed", "false", ); - // The file tree lives in the pinned Files home tab now, not the left panel. - await ensureFilesHomeTabActive(groupedPage); + await groupedPage.getByLabel("Toggle left panel").click(); await fileTreeDirectory(groupedPage, "/standalone-markdown-alpha/").click(); await expect( fileTreeFile(groupedPage, "/standalone-markdown-alpha/alpha.md"), @@ -764,7 +743,6 @@ test("mixed folder and standalone markdown args create folder and grouped file w registerRendererConsoleLogging(groupedFilePage); await expectWindowCount(electronApp, 2); - await ensureFilesHomeTabActive(folderPage); await expect(fileTreeFile(folderPage, "/folder-marker.md")).toBeVisible(); await expect( groupedFilePage.getByRole("heading", { name: "One" }), @@ -772,8 +750,7 @@ test("mixed folder and standalone markdown args create folder and grouped file w await expect( groupedFilePage.getByLabel("Toggle left panel"), ).toHaveAttribute("aria-pressed", "false"); - // The file tree lives in the pinned Files home tab now, not the left panel. - await ensureFilesHomeTabActive(groupedFilePage); + await groupedFilePage.getByLabel("Toggle left panel").click(); await expect( fileTreeFile(groupedFilePage, "/folder-marker.md"), ).toHaveCount(0); @@ -829,7 +806,6 @@ test("relaunch restores grouped transient file workspace", async ({ await expect( restoredPage.getByRole("heading", { name: "First" }), ).toBeVisible(); - await ensureFilesHomeTabActive(restoredPage); await fileTreeDirectory(restoredPage, "/restore-markdown-second/").click(); const restoredSecondItem = fileTreeFile( restoredPage, diff --git a/src/extensions/files/index.tsx b/src/extensions/files/index.tsx index b0eab81..24924f2 100644 --- a/src/extensions/files/index.tsx +++ b/src/extensions/files/index.tsx @@ -741,8 +741,7 @@ function FilesViewContent({ prev.filter((path) => path !== normalizedPath), ); // Close by path so the file's view also closes when it is open in - // the background (the file tree lives in the pinned home tab, so - // the document view is rarely the active one during deletion). + // a background tab, not just when it is the active document. context?.closeFileViews?.({ fileId, filePath: normalizeFilePath(normalizedPath), diff --git a/submodule/atelier b/submodule/atelier index cf6ca2f..c8c9a58 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit cf6ca2ffbdff11794078083d3b921ee177e57295 +Subproject commit c8c9a58a7612085429b39ea18e2eb60e3085e32d From 305bec8b0048bb28a10f85a9d2d62a27d87be244 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:08:53 -0400 Subject: [PATCH 05/10] refactor!: replace the files-extension fork with atelier's bundled Files view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps submodule/atelier to 207d18e, which adds the host watched-files data source (createAtelier filesView options), and deletes FlashType's ~5,450-line files-extension fork — it existed to surface watcher-backed disk files for transient workspaces, which the bundled view now covers. - src/lib/atelier-files-view.ts relocates the transient-workspace machinery: watchEntries bridges setEphemeralWatchedDirectories (keyed to the tree's expanded directories) plus the onEphemeralWatchedFileTreeChanged subscription, and resolveFileForInteraction imports a watched path into lix before open/rename. Only ephemeral workspaces pass filesView. - Review overlays and deletion-closes-tab are native to the bundled view (it computes pending review paths itself and closes by path), so no host wiring remains. - Deletes src/extensions/files/*, the fork-only ExtensionContext/ WorkspaceContext/PanelSide types, and the dead src/queries.ts. - e2e: the bundled tree auto-reveals the active document's directory, so the grouped-transient-workspace test no longer clicks it (the click would collapse it); all other selectors held. Co-Authored-By: Claude Fable 5 --- e2e/workspace-windows.spec.ts | 3 +- src/extension-runtime/types.ts | 69 - .../atelier-host-extensions.test.ts | 9 +- src/extensions/atelier-host-extensions.ts | 21 +- .../files/build-filesystem-tree.test.ts | 222 -- src/extensions/files/build-filesystem-tree.ts | 157 -- src/extensions/files/file-tree.test.tsx | 531 ----- src/extensions/files/file-tree.tsx | 774 ------- src/extensions/files/files.manifest.json | 6 - src/extensions/files/host-extension.test.tsx | 171 -- src/extensions/files/host-extension.tsx | 146 -- src/extensions/files/index.test.tsx | 2058 ----------------- src/extensions/files/index.tsx | 1387 ----------- src/lib/atelier-files-view.ts | 84 + src/main.tsx | 14 +- src/queries.test.ts | 90 - src/queries.ts | 45 - submodule/atelier | 2 +- 18 files changed, 110 insertions(+), 5679 deletions(-) delete mode 100644 src/extensions/files/build-filesystem-tree.test.ts delete mode 100644 src/extensions/files/build-filesystem-tree.ts delete mode 100644 src/extensions/files/file-tree.test.tsx delete mode 100644 src/extensions/files/file-tree.tsx delete mode 100644 src/extensions/files/files.manifest.json delete mode 100644 src/extensions/files/host-extension.test.tsx delete mode 100644 src/extensions/files/host-extension.tsx delete mode 100644 src/extensions/files/index.test.tsx delete mode 100644 src/extensions/files/index.tsx create mode 100644 src/lib/atelier-files-view.ts delete mode 100644 src/queries.test.ts delete mode 100644 src/queries.ts diff --git a/e2e/workspace-windows.spec.ts b/e2e/workspace-windows.spec.ts index b7e55bd..b641255 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/src/extension-runtime/types.ts b/src/extension-runtime/types.ts index 36f3c63..a762836 100644 --- a/src/extension-runtime/types.ts +++ b/src/extension-runtime/types.ts @@ -1,5 +1,3 @@ -import type { Lix } from "@/lib/lix-types"; - /** * Persisted view state. Only include values that should survive reloads. * @@ -18,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. * @@ -40,55 +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 closeFileViews?: (args: { - readonly panel?: PanelSide; - readonly fileId: string; - readonly filePath?: 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 0d04d0e..43299bb 100644 --- a/src/extensions/atelier-host-extensions.test.ts +++ b/src/extensions/atelier-host-extensions.test.ts @@ -2,15 +2,10 @@ import { describe, expect, test } from "vitest"; import { createFlashTypeAtelierExtensions } from "./atelier-host-extensions"; describe("createFlashTypeAtelierExtensions", () => { - test("registers filesystem 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", "flashtype_claude", "flashtype_codex", ]); diff --git a/src/extensions/atelier-host-extensions.ts b/src/extensions/atelier-host-extensions.ts index 4d0b28a..c977e73 100644 --- a/src/extensions/atelier-host-extensions.ts +++ b/src/extensions/atelier-host-extensions.ts @@ -1,16 +1,11 @@ -import type { - AtelierBuiltinExtensionId, - AtelierExtensionRegistration, -} from "@opral/atelier"; -import type { WorkspaceContext } from "@/extension-runtime/types"; -import { createFilesExtensionRegistration } from "./files/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), ...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/files/build-filesystem-tree.test.ts b/src/extensions/files/build-filesystem-tree.test.ts deleted file mode 100644 index 0f1fcdb..0000000 --- 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 ef1db1b..0000000 --- a/src/extensions/files/build-filesystem-tree.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { FilesystemEntryRow } from "@/queries"; - -export type FilesystemTreeSource = "lix" | "watched"; - -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; - 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 35a70cd..0000000 --- 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 32b4ffe..0000000 --- a/src/extensions/files/file-tree.tsx +++ /dev/null @@ -1,774 +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 onOpenDirectoriesChange?: (paths: ReadonlySet) => void; - readonly onCreateCommit?: ( - request: FileTreeCreateRequest, - value: string, - ) => Promise | void; - readonly onCreateCancel?: (request: FileTreeCreateRequest) => void; - readonly onRenameCommit?: ( - request: FileTreeRenameRequest, - ) => Promise | void; -}; - -type ReviewGitStatusEntry = { - readonly path: string; - readonly status: GitStatusEntry["status"]; -}; - -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-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, - 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, treeInput), - [reviewPaths, 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"; - } - 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; - } - 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, - treeInput: TreeInput, -): ReviewGitStatusEntry[] { - if (!reviewPaths || reviewPaths.size === 0) { - return []; - } - const entries: ReviewGitStatusEntry[] = []; - 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 5865b75..0000000 --- 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 ac7c73d..0000000 --- a/src/extensions/files/host-extension.test.tsx +++ /dev/null @@ -1,171 +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 }, - } 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; - 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) }, - } 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 819753a..0000000 --- a/src/extensions/files/host-extension.tsx +++ /dev/null @@ -1,146 +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: ({ filePath }) => { - if (filePath) { - void atelier.documents.close(filePath); - return; - } - void atelier.documents.closeActive(); - }, - 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 1e4a5ca..0000000 --- a/src/extensions/files/index.test.tsx +++ /dev/null @@ -1,2058 +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"; - -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", - filePath: "/hello.md", - }); - - 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", - filePath: "/viewed.md", - }); - - 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("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", - filePath: "/loose.txt", - }); - } 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, - }; -} diff --git a/src/extensions/files/index.tsx b/src/extensions/files/index.tsx deleted file mode 100644 index 24924f2..0000000 --- a/src/extensions/files/index.tsx +++ /dev/null @@ -1,1387 +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 } from "@/lib/lix-kysely"; -import type { FilesystemEntryRow } from "@/queries"; -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"; - -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 FilesRuntimeState({ - context, - lix, - entries, - activeFileId, -}: FilesViewProps & { - readonly lix: Lix; - readonly entries: FilesystemEntryRow[]; - readonly activeFileId: string | null; -}) { - return ( - {}), - activeFileId: context?.activeFileId ?? activeFileId, - }} - lix={lix} - entries={entries} - /> - ); -} - -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 combinedEntries = useMemo( - () => unionFilesystemEntries(entries ?? [], visibleWatchedEntries), - [entries, visibleWatchedEntries], - ); - const nodes = useMemo( - () => buildFilesystemTree(combinedEntries), - [combinedEntries], - ); - const reviewPaths = usePendingExternalWriteReviewPaths(lix, nodes); - 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; - 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) => { - setLocalSelection({ - path, - fileId, - kind: "file", - source: "lix", - }); - void context?.openFile?.({ - panel: "central", - fileId, - filePath: path, - focus: false, - }); - }, - [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; - 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), - ); - // Close by path so the file's view also closes when it is open in - // a background tab, not just when it is the active document. - context?.closeFileViews?.({ - fileId, - filePath: normalizeFilePath(normalizedPath), - }); - } 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") { - 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 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/lib/atelier-files-view.ts b/src/lib/atelier-files-view.ts new file mode 100644 index 0000000..3f6b960 --- /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/main.tsx b/src/main.tsx index 2fa0d07..417b454 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,6 +126,7 @@ export const AppRoot = () => { // contract but intentionally hides native SDK internals. lix: lix as unknown as AtelierInstance["lix"], extensions: atelierExtensions, + ...(atelierFilesView ? { filesView: atelierFilesView } : {}), defaultOpenPanels: defaultOpenAtelierPanels, sessionStateStore: atelierSessionStateStore, onEvent: handleAtelierEvent, @@ -124,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 d615d8d..0000000 --- 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 5811912..0000000 --- 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"; -}; - -/** - * 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/submodule/atelier b/submodule/atelier index c8c9a58..207d18e 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit c8c9a58a7612085429b39ea18e2eb60e3085e32d +Subproject commit 207d18e8d84f4e1bf6c0ad8fdf5259840527a3c5 From ebde7df3f8b8f72caae672dd4519e46813187dd3 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:17:59 -0400 Subject: [PATCH 06/10] =?UTF-8?q?chore:=20bump=20atelier=20=E2=80=94=20sid?= =?UTF-8?q?e-panel=20add/close=20semantics=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding Files from a side panel focuses the left sidebar; closing the last removable side view leaves the empty state instead of collapsing the island. Co-Authored-By: Claude Fable 5 --- submodule/atelier | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodule/atelier b/submodule/atelier index 207d18e..a6b3cbf 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit 207d18e8d84f4e1bf6c0ad8fdf5259840527a3c5 +Subproject commit a6b3cbf48da6b1c748ee02443e2823bdeba3b8ca From 0d0e96c3b7b029dcfe8e823f1b26e1e08c8288a6 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:23:59 -0400 Subject: [PATCH 07/10] =?UTF-8?q?chore:=20bump=20atelier=20=E2=80=94=20Fil?= =?UTF-8?q?es=20left-only=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The right panel's add-view menu no longer offers Files. Co-Authored-By: Claude Fable 5 --- submodule/atelier | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodule/atelier b/submodule/atelier index a6b3cbf..d30691c 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit a6b3cbf48da6b1c748ee02443e2823bdeba3b8ca +Subproject commit d30691c2ec404b33cfbe41b1d1b2ed4ec6140031 From 11fc90d1c32348a99f1701d1617564921c63ec97 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:31:21 -0400 Subject: [PATCH 08/10] =?UTF-8?q?chore:=20bump=20atelier=20=E2=80=94=20Fil?= =?UTF-8?q?es=20is=20a=20normal,=20removable=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- submodule/atelier | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodule/atelier b/submodule/atelier index d30691c..af05770 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit d30691c2ec404b33cfbe41b1d1b2ed4ec6140031 +Subproject commit af0577078f00217ad46461c60afb4dadbdbf8ada From 9f6e4e79221592bd3efca3cba31f47731c29ba37 Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:33:43 -0400 Subject: [PATCH 09/10] =?UTF-8?q?chore:=20bump=20atelier=20=E2=80=94=20Fil?= =?UTF-8?q?es=20placeable=20in=20both=20side=20panels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- submodule/atelier | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodule/atelier b/submodule/atelier index af05770..5de946d 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit af0577078f00217ad46461c60afb4dadbdbf8ada +Subproject commit 5de946d9259c25607e8572e37656b2c55c8179b3 From fd05f72a295ec3f2c5d6f2016d50d08c17f5c33a Mon Sep 17 00:00:00 2001 From: Samuel Stroschein <35429197+samuelstroschein@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:38:28 -0400 Subject: [PATCH 10/10] chore: point submodule/atelier at main atelier#60 merged; the pointer moves to the squash commit on main. Co-Authored-By: Claude Fable 5 --- submodule/atelier | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodule/atelier b/submodule/atelier index 5de946d..29e5e4a 160000 --- a/submodule/atelier +++ b/submodule/atelier @@ -1 +1 @@ -Subproject commit 5de946d9259c25607e8572e37656b2c55c8179b3 +Subproject commit 29e5e4a3f915c1c1b02bd9602b976e174f234033