From ca2b63328675b9ebf3a9576ecfc5320b30c0cee9 Mon Sep 17 00:00:00 2001 From: nikuscs Date: Mon, 10 Aug 2026 17:18:23 +0100 Subject: [PATCH] feat: add file manager context actions (#3027) * feat(app): add file manager context actions * fix: reshape file context actions --------- Co-authored-by: Mohamed Boudra --- packages/app/e2e/browser/changes-pane.spec.ts | 278 +++++- .../file-explorer-context-actions.spec.ts | 417 +++++++++ .../app/src/components/file-actions-menu.tsx | 242 ++++- .../app/src/components/file-explorer-pane.tsx | 852 ++++++++++++++++-- .../app/src/components/tree-primitives.tsx | 30 +- .../app/src/components/ui/context-menu.tsx | 5 +- .../src/components/ui/menu/menu-overlay.tsx | 4 + packages/app/src/git/actions-store.test.ts | 25 + packages/app/src/git/actions-store.ts | 21 +- packages/app/src/git/diff-folder-row.tsx | 173 +++- packages/app/src/git/diff-pane.tsx | 376 +++++++- .../src/hooks/use-file-explorer-actions.ts | 79 ++ packages/app/src/i18n/resources/ar.ts | 32 + packages/app/src/i18n/resources/en.ts | 32 + packages/app/src/i18n/resources/es.ts | 32 + packages/app/src/i18n/resources/fr.ts | 32 + packages/app/src/i18n/resources/ja.ts | 32 + packages/app/src/i18n/resources/ko.ts | 32 + packages/app/src/i18n/resources/pt-BR.ts | 32 + packages/app/src/i18n/resources/ru.ts | 32 + packages/app/src/i18n/resources/zh-CN.ts | 32 + packages/app/src/utils/explorer-paths.ts | 5 + packages/client/src/daemon-client.test.ts | 175 ++++ packages/client/src/daemon-client.ts | 48 + .../src/messages.file-context-actions.test.ts | 168 ++++ packages/protocol/src/messages.ts | 123 +++ .../src/server/file-explorer/service.test.ts | 215 ++++- .../src/server/file-explorer/service.ts | 210 +++++ packages/server/src/server/session.ts | 10 + .../session/checkout/checkout-session.test.ts | 86 ++ .../session/checkout/checkout-session.ts | 21 + .../files/workspace-files-session.test.ts | 230 ++++- .../session/files/workspace-files-session.ts | 82 ++ .../server/src/server/websocket-server.ts | 6 + .../server/src/utils/checkout-git.test.ts | 147 +++ packages/server/src/utils/checkout-git.ts | 70 ++ 36 files changed, 4159 insertions(+), 227 deletions(-) create mode 100644 packages/app/e2e/browser/file-explorer-context-actions.spec.ts create mode 100644 packages/protocol/src/messages.file-context-actions.test.ts diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 174cea357..98f69808b 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -1,8 +1,10 @@ -import { unlink, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { readFile, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes"; import { test, expect } from "../support/fixtures"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { connectSeedClient } from "../support/helpers/seed-client"; import { createTempGitRepo } from "../support/helpers/workspace"; @@ -15,6 +17,9 @@ interface DirtyWorkspace { interface WorkspaceFixtureOptions { includeDeletedFile?: boolean; + includeNestedFolders?: boolean; + includeRenamedFile?: boolean; + includeUntrackedFile?: boolean; } interface CleanupTask { @@ -23,6 +28,39 @@ interface CleanupTask { const cleanupTasks: CleanupTask[] = []; const APP_SETTINGS_KEY = "@paseo:app-settings"; + +async function failNextDiscardRequest(page: Page): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => { + if (typeof message === "string") { + const envelope = JSON.parse(message) as { + message?: { type?: string; cwd?: string; requestId?: string }; + }; + if (envelope.message?.type === "checkout.discard_changes.request") { + browserSocket.send( + JSON.stringify({ + type: "session", + message: { + type: "checkout.discard_changes.response", + payload: { + cwd: envelope.message.cwd, + success: false, + error: { code: "UNKNOWN", message: "Injected revert failure" }, + requestId: envelope.message.requestId, + }, + }, + }), + ); + return; + } + } + serverSocket.send(message); + }); + serverSocket.onMessage((message) => browserSocket.send(message)); + }); +} + const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences"; const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -209,9 +247,15 @@ test("changes file actions open below the right-click without a reserved kebab", await openWorkspaceChanges(page, workspace); await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts"); + const deletedFileName = page.getByText("zz-deleted.ts", { exact: true }); + await expect(deletedFileName).toHaveCSS("user-select", "none"); + await deletedFileName.dblclick(); + expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe(""); await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0); await page.getByTestId("diff-file-1-toggle").click({ button: "right" }); await expect(page.getByText("Copy path")).toBeVisible(); + await page.getByText("Copy path", { exact: true }).click({ button: "right" }); + await expect(page.getByText("Copy path")).toBeVisible(); await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0); await page.keyboard.press("Escape"); @@ -230,6 +274,183 @@ test("changes file actions open below the right-click without a reserved kebab", await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible(); }); +test("changes context menus duplicate files and folders", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await page.getByTestId("diff-file-0-duplicate").click(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8")) + .toBe(AFTER); + + await page.getByTestId("changes-toggle-view-mode").click(); + await page.getByTestId("diff-folder-src-toggle").click({ button: "right" }); + await page.getByTestId("diff-folder-src-duplicate").click(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8")) + .toBe(AFTER); +}); + +test("changes context menu recursively collapses descendant folders", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + await page.getByTestId("changes-toggle-view-mode").click(); + await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); + await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); + + await page.getByTestId("diff-folder-src-toggle").click({ button: "right" }); + await page.getByTestId("diff-folder-src-collapse-folder").click(); + await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0); + + await page.getByTestId("diff-folder-src-toggle").click(); + await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); + await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0); + + await page.getByTestId("diff-folder-src/zz-folder-toggle").click(); + await expect(page.getByText("root.ts", { exact: true })).toBeVisible(); + await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); + await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0); + + await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click(); + await expect(page.getByText("changed.ts", { exact: true })).toBeVisible(); +}); + +test("changes context menus expose folder revert and restore a file after confirmation", async ({ + page, +}) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + await page.getByTestId("changes-toggle-view-mode").click(); + await page.getByTestId("diff-folder-src-toggle").click({ button: "right" }); + const folderRevert = page.getByTestId("diff-folder-src-revert"); + await expect(folderRevert).toBeVisible(); + const revertLabelColor = await folderRevert + .getByText("Discard changes", { exact: true }) + .evaluate((element) => getComputedStyle(element).color); + await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor); + await page.keyboard.press("Escape"); + + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + const cancelledConfirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + const message = dialog.message(); + await dialog.dismiss(); + resolve(message); + }); + }); + await page.getByTestId("diff-file-0-revert").click(); + expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts"); + await expect(page.getByTestId("diff-file-0")).toBeVisible(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) + .toBe(AFTER); + + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + resolve(message); + }); + }); + await page.getByTestId("diff-file-0-revert").click(); + expect(await confirmation).toContain("src/use-mounted-tab-set.ts"); + + await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 }); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) + .toBe(BEFORE); +}); + +test("discarding a staged rename restores its source path", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + const renamedToggle = page + .getByTestId(/^diff-file-\d+-toggle$/) + .filter({ hasText: "zz-renamed.ts" }); + const toggleTestId = await renamedToggle.getAttribute("data-testid"); + expect(toggleTestId).not.toBeNull(); + const rowTestId = toggleTestId!.slice(0, -"-toggle".length); + await renamedToggle.click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + await dialog.accept(); + resolve(); + }); + }); + await page.getByTestId(`${rowTestId}-revert`).click(); + await confirmation; + + await expect(page.getByText("zz-renamed.ts", { exact: true })).toHaveCount(0, { + timeout: 30_000, + }); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/rename-source.ts"), "utf8")) + .toBe("export const renamed = true;\n"); +}); + +test("discarding an untracked file removes it from the working tree", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true }); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + const untrackedToggle = page + .getByTestId(/^diff-file-\d+-toggle$/) + .filter({ hasText: "zz-untracked.txt" }); + const toggleTestId = await untrackedToggle.getAttribute("data-testid"); + expect(toggleTestId).not.toBeNull(); + const rowTestId = toggleTestId!.slice(0, -"-toggle".length); + await untrackedToggle.click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + await dialog.accept(); + resolve(); + }); + }); + await page.getByTestId(`${rowTestId}-revert`).click(); + await confirmation; + + await expect(page.getByText("zz-untracked.txt", { exact: true })).toHaveCount(0, { + timeout: 30_000, + }); + await expect( + readFile(path.join(workspace.repoPath, "zz-untracked.txt"), "utf8"), + ).rejects.toThrow(); +}); + +test("shows a revert error returned by the daemon", async ({ page }) => { + const workspace = await createWorkspaceWithMountedTabDiff(); + await failNextDiscardRequest(page); + await useUnwrappedDiffLines(page); + await openWorkspaceChanges(page, workspace); + + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + await dialog.accept(); + resolve(); + }); + }); + await page.getByTestId("diff-file-0-revert").click(); + await confirmation; + + await expect(page.getByText("Injected revert failure", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("diff-file-0")).toBeVisible(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) + .toBe(AFTER); +}); + test("Changes switches between inline and full-tab navigation", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); await useUnwrappedDiffLines(page); @@ -327,8 +548,37 @@ test("changes diff switches between flat and tree file lists", async ({ page }) await scrollToLowerUnwrappedDiffRows(page); await page.getByTestId("changes-toggle-view-mode").click(); await expect(page.getByTestId("diff-folder-src")).toBeVisible(); + await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS( + "user-select", + "none", + ); await expect(page.getByTestId("diff-file-0")).toBeVisible(); - await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2); + await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1); + const folderLabelBounds = await page + .getByTestId("diff-folder-src") + .getByText("src", { exact: true }) + .boundingBox(); + const fileLabelBounds = await page + .getByTestId("diff-file-0") + .getByText("use-mounted-tab-set.ts", { exact: true }) + .boundingBox(); + expect(folderLabelBounds).not.toBeNull(); + expect(fileLabelBounds).not.toBeNull(); + expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0); + + const folderToggle = page.getByTestId("diff-folder-src-toggle"); + await folderToggle.click(); + await expect(folderToggle).toHaveAttribute("aria-selected", "true"); + await expect(page.getByTestId("diff-file-0")).toHaveCount(0); + await folderToggle.click(); + await expect(folderToggle).toHaveAttribute("aria-selected", "true"); + await expect(page.getByTestId("diff-file-0")).toBeVisible(); + + const fileToggle = page.getByTestId("diff-file-0-toggle"); + await fileToggle.click({ button: "right" }); + await expect(fileToggle).toHaveAttribute("aria-selected", "true"); + await expect(folderToggle).toHaveAttribute("aria-selected", "false"); await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible(); await page.keyboard.press("Escape"); @@ -488,6 +738,15 @@ async function createWorkspaceWithMountedTabDiff( if (options.includeDeletedFile) { files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" }); } + if (options.includeRenamedFile) { + files.push({ path: "src/rename-source.ts", content: "export const renamed = true;\n" }); + } + if (options.includeNestedFolders) { + files.push( + { path: "src/zz-folder/root.ts", content: "export const root = 1;\n" }, + { path: "src/zz-folder/nested/changed.ts", content: "export const nested = 1;\n" }, + ); + } const repo = await createTempGitRepo("changes-pane-", { files }); const client = await connectSeedClient(); cleanupTasks.push({ @@ -498,9 +757,24 @@ async function createWorkspaceWithMountedTabDiff( }); await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER); + if (options.includeUntrackedFile) { + await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n"); + } if (options.includeDeletedFile) { await unlink(path.join(repo.path, "src/zz-deleted.ts")); } + if (options.includeRenamedFile) { + execFileSync("git", ["mv", "src/rename-source.ts", "src/zz-renamed.ts"], { + cwd: repo.path, + }); + } + if (options.includeNestedFolders) { + await writeFile(path.join(repo.path, "src/zz-folder/root.ts"), "export const root = 2;\n"); + await writeFile( + path.join(repo.path, "src/zz-folder/nested/changed.ts"), + "export const nested = 2;\n", + ); + } const createdWorkspace = await client.createWorkspace({ source: { kind: "directory", path: repo.path }, }); diff --git a/packages/app/e2e/browser/file-explorer-context-actions.spec.ts b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts new file mode 100644 index 000000000..e4643b87e --- /dev/null +++ b/packages/app/e2e/browser/file-explorer-context-actions.spec.ts @@ -0,0 +1,417 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { test } from "../support/fixtures"; +import { openFileExplorer } from "../support/helpers/file-explorer"; +import { gotoWorkspace } from "../support/helpers/launcher"; +import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; + +let workspace: SeededWorkspace; + +async function failNextDeleteRequest(page: Page): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => { + if (typeof message === "string") { + const envelope = JSON.parse(message) as { + message?: { type?: string; cwd?: string; path?: string; requestId?: string }; + }; + if (envelope.message?.type === "fs.entry.delete.request") { + browserSocket.send( + JSON.stringify({ + type: "session", + message: { + type: "fs.entry.delete.response", + payload: { + cwd: envelope.message.cwd, + path: envelope.message.path, + success: false, + error: "Injected delete failure", + requestId: envelope.message.requestId, + }, + }, + }), + ); + return; + } + } + serverSocket.send(message); + }); + serverSocket.onMessage((message) => browserSocket.send(message)); + }); +} + +async function hideContextActionCapabilities(page: Page): Promise { + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => serverSocket.send(message)); + serverSocket.onMessage((message) => { + if (typeof message !== "string") { + browserSocket.send(message); + return; + } + const envelope = JSON.parse(message) as { + message?: { + type?: string; + payload?: { status?: string; features?: Record }; + }; + }; + if ( + envelope.message?.type === "status" && + envelope.message.payload?.status === "server_info" + ) { + envelope.message.payload.features = { + ...envelope.message.payload.features, + fsEntryOps: false, + fsEntryDuplicate: false, + checkoutDiscardChanges: false, + }; + } + browserSocket.send(JSON.stringify(envelope)); + }); + }); +} + +async function disconnectNextContextAction(page: Page): Promise<{ wait: () => Promise }> { + let resolveRequest: (() => void) | undefined; + const requestSeen = new Promise((resolve) => { + resolveRequest = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { + const serverSocket = browserSocket.connectToServer(); + browserSocket.onMessage((message) => { + if (typeof message === "string") { + const envelope = JSON.parse(message) as { message?: { type?: string } }; + const type = envelope.message?.type; + if (type?.startsWith("fs.entry.") || type === "checkout.discard_changes.request") { + resolveRequest?.(); + void browserSocket.close({ + code: 1008, + reason: "Context action connection failed", + }); + return; + } + } + serverSocket.send(message); + }); + serverSocket.onMessage((message) => browserSocket.send(message)); + }); + + return { wait: () => requestSeen }; +} + +test.beforeEach(async () => { + workspace = await seedWorkspace({ repoPrefix: "file-explorer-context-actions-" }); +}); + +test.afterEach(async () => { + await workspace?.cleanup(); +}); + +test("creates, renames, copies, and deletes entries through the file explorer", async ({ + context, + page, +}) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const tree = page.getByTestId("file-explorer-tree-scroll"); + const entry = (name: string) => tree.getByText(name, { exact: true }).first(); + const nameInput = page.getByTestId("file-explorer-name-input"); + + await page.getByTestId("files-new-folder").click(); + await expect(nameInput).toBeVisible(); + await nameInput.press("Tab"); + await expect(nameInput).toBeHidden(); + + await page.getByTestId("files-new-file").click(); + await nameInput.fill("README.md"); + await nameInput.press("Enter"); + await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible(); + + const emptyArea = page.getByTestId("files-empty-area"); + await expect(emptyArea).toBeVisible(); + const emptyAreaBounds = await emptyArea.boundingBox(); + expect(emptyAreaBounds).not.toBeNull(); + await emptyArea.click({ + button: "right", + position: { x: emptyAreaBounds!.width / 2, y: emptyAreaBounds!.height - 24 }, + }); + await expect(page.getByTestId("files-empty-area-new-file")).toBeVisible(); + await page.getByTestId("files-empty-area-new-folder").click(); + await nameInput.fill("folder"); + await nameInput.press("Enter"); + await expect(entry("folder")).toBeVisible(); + + await entry("folder").click({ button: "right" }); + await page.getByText("New file", { exact: true }).click(); + await nameInput.fill("child.txt"); + await nameInput.press("Enter"); + await expect(entry("child.txt")).toBeVisible(); + + await entry("folder").click({ button: "right" }); + await page.getByText("New folder", { exact: true }).click(); + await nameInput.fill("nested"); + await nameInput.press("Enter"); + await entry("nested").click({ button: "right" }); + await page.getByText("New folder", { exact: true }).click(); + await nameInput.fill("deep"); + await nameInput.press("Enter"); + await expect(entry("deep")).toBeVisible(); + + await entry("folder").click({ button: "right" }); + await page.getByText("Collapse folder", { exact: true }).click(); + await expect(entry("child.txt")).toBeHidden(); + await expect(entry("nested")).toBeHidden(); + await expect(entry("deep")).toBeHidden(); + + await entry("folder").click(); + await expect(entry("child.txt")).toBeVisible(); + await expect(entry("nested")).toBeVisible(); + await expect(entry("deep")).toBeHidden(); + await entry("nested").click(); + await expect(entry("deep")).toBeVisible(); + + await page.getByTestId("files-new-file").click(); + await nameInput.fill("created.txt"); + await nameInput.press("Enter"); + await expect(entry("created.txt")).toBeVisible(); + await expect(entry("created.txt")).toHaveCSS("user-select", "none"); + await entry("created.txt").dblclick(); + expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe(""); + + const folderRow = entry("folder").locator( + "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", + ); + const fileRow = entry("created.txt").locator( + "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", + ); + await expect(folderRow.locator("svg")).toHaveCount(2); + await expect(fileRow.locator("svg")).toHaveCount(1); + const gitRow = entry(".git").locator( + "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", + ); + const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox(); + const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox(); + expect(collapsedChevronBounds).not.toBeNull(); + expect(expandedChevronBounds).not.toBeNull(); + const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2; + const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2; + expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0); + const folderLabelBounds = await entry("folder").boundingBox(); + const fileLabelBounds = await entry("created.txt").boundingBox(); + expect(folderLabelBounds).not.toBeNull(); + expect(fileLabelBounds).not.toBeNull(); + expect(fileLabelBounds!.x).toBeCloseTo(folderLabelBounds!.x, 0); + + await folderRow.click(); + await expect(folderRow).toHaveAttribute("aria-selected", "true"); + await expect(entry("child.txt")).toBeHidden(); + await folderRow.click(); + await expect(folderRow).toHaveAttribute("aria-selected", "true"); + await expect(entry("child.txt")).toBeVisible(); + + await entry("folder").click({ button: "right" }); + await page.getByText("Rename", { exact: true }).click(); + await nameInput.fill("renamed-folder"); + await nameInput.press("Tab"); + await expect(entry("renamed-folder")).toBeVisible(); + await expect(entry("folder")).toBeHidden(); + await expect(entry("child.txt")).toBeVisible(); + const renamedFolderRow = entry("renamed-folder").locator( + "xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]", + ); + await expect(renamedFolderRow).toHaveAttribute("aria-selected", "true"); + + await entry("created.txt").click({ button: "right" }); + await expect(fileRow).toHaveAttribute("aria-selected", "true"); + await expect(renamedFolderRow).toHaveAttribute("aria-selected", "false"); + const menu = page.getByTestId(/file-explorer-row-\d+-context-menu$/); + await expect(menu).toBeVisible(); + + await page.getByText("Copy path", { exact: true }).click({ button: "right" }); + await expect(menu).toBeVisible(); + + const backdrop = page.getByTestId(/file-explorer-row-\d+-context-menu-backdrop$/); + await backdrop.click({ button: "right" }); + await expect(menu).toBeHidden(); + + await entry("created.txt").click({ button: "right" }); + await page.getByText("Rename", { exact: true }).click(); + await expect(nameInput).toHaveValue("created.txt"); + await nameInput.press("Escape"); + await expect(entry("created.txt")).toBeVisible(); + + await entry("created.txt").click({ button: "right" }); + await page.getByText("Rename", { exact: true }).click(); + await nameInput.fill("README.md"); + await nameInput.press("Enter"); + await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible(); + await expect(entry("created.txt")).toBeVisible(); + + await entry("created.txt").click({ button: "right" }); + await page.getByText("Rename", { exact: true }).click(); + await nameInput.fill("renamed.txt"); + await nameInput.press("Tab"); + await expect(entry("renamed.txt")).toBeVisible(); + await expect(entry("created.txt")).toBeHidden(); + + await entry("renamed.txt").click({ button: "right" }); + await page.getByText("Copy relative path", { exact: true }).click(); + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("renamed.txt"); + + await entry("renamed.txt").click({ button: "right" }); + const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/); + const deleteLabelColor = await deleteAction + .getByText("Delete", { exact: true }) + .evaluate((element) => getComputedStyle(element).color); + await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor); + const cancelledConfirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + const message = dialog.message(); + await dialog.dismiss(); + resolve(message); + }); + }); + await page.getByText("Delete", { exact: true }).click(); + expect(await cancelledConfirmation).toContain("renamed.txt"); + await expect(entry("renamed.txt")).toBeVisible(); + + await entry("renamed.txt").click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + resolve(message); + }); + }); + await page.getByText("Delete", { exact: true }).click(); + expect(await confirmation).toContain("renamed.txt"); + await expect(entry("renamed.txt")).toBeHidden(); + + await entry("renamed-folder").click({ button: "right" }); + const folderConfirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + const message = dialog.message(); + await dialog.accept(); + resolve(message); + }); + }); + await page.getByText("Delete", { exact: true }).click(); + expect(await folderConfirmation).toContain("renamed-folder"); + await expect(entry("renamed-folder")).toBeHidden(); + await expect(entry("child.txt")).toBeHidden(); +}); + +test("duplicates files and folders with collision-free names", async ({ page }) => { + const sourceFilePath = path.join(workspace.repoPath, "duplicate-source.txt"); + const sourceFolderPath = path.join(workspace.repoPath, "duplicate-folder"); + await writeFile(sourceFilePath, "duplicate contents", "utf8"); + await mkdir(sourceFolderPath); + await writeFile(path.join(sourceFolderPath, "nested.txt"), "nested contents", "utf8"); + + try { + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const tree = page.getByTestId("file-explorer-tree-scroll"); + const entry = (name: string) => tree.getByText(name, { exact: true }).first(); + + await entry("duplicate-source.txt").click({ button: "right" }); + await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click(); + await expect(entry("duplicate-source copy.txt")).toBeVisible(); + await expect + .poll(() => readFile(path.join(workspace.repoPath, "duplicate-source copy.txt"), "utf8")) + .toBe("duplicate contents"); + + await entry("duplicate-source.txt").click({ button: "right" }); + await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click(); + await expect(entry("duplicate-source copy 2.txt")).toBeVisible(); + + await entry("duplicate-folder").click({ button: "right" }); + await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click(); + await expect(entry("duplicate-folder copy")).toBeVisible(); + await expect + .poll(() => + readFile(path.join(workspace.repoPath, "duplicate-folder copy", "nested.txt"), "utf8"), + ) + .toBe("nested contents"); + } finally { + await Promise.all([ + rm(sourceFilePath, { force: true }), + rm(path.join(workspace.repoPath, "duplicate-source copy.txt"), { force: true }), + rm(path.join(workspace.repoPath, "duplicate-source copy 2.txt"), { force: true }), + rm(sourceFolderPath, { recursive: true, force: true }), + rm(path.join(workspace.repoPath, "duplicate-folder copy"), { recursive: true, force: true }), + ]); + } +}); + +test("shows an actionable error when the connection drops during creation", async ({ page }) => { + const disconnect = await disconnectNextContextAction(page); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + await page.getByTestId("files-new-file").click(); + const nameInput = page.getByTestId("file-explorer-name-input"); + await nameInput.fill("disconnected.txt"); + await nameInput.press("Enter"); + await disconnect.wait(); + + await expect(page.getByText(/Context action connection failed/i)).toBeVisible({ + timeout: 30_000, + }); + await expect( + page.getByTestId("file-explorer-tree-scroll").getByText("disconnected.txt", { exact: true }), + ).toHaveCount(0); +}); + +test("keeps an entry visible when deletion fails", async ({ page }) => { + await failNextDeleteRequest(page); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + const readme = page + .getByTestId("file-explorer-tree-scroll") + .getByText("README.md", { exact: true }); + await readme.click({ button: "right" }); + const confirmation = new Promise((resolve) => { + page.once("dialog", async (dialog) => { + await dialog.accept(); + resolve(); + }); + }); + await page.getByText("Delete", { exact: true }).click(); + await confirmation; + + await expect(page.getByText("Injected delete failure", { exact: true })).toBeVisible(); + await expect(readme).toBeVisible(); +}); + +test("hides unsupported file operations and revert actions", async ({ page }) => { + await hideContextActionCapabilities(page); + await writeFile(path.join(workspace.repoPath, "README.md"), "# Changed while unsupported\n"); + await gotoWorkspace(page, workspace.workspaceId); + await openFileExplorer(page); + + await expect(page.getByTestId("files-new-file")).toHaveCount(0); + await expect(page.getByTestId("files-new-folder")).toHaveCount(0); + await page + .getByTestId("file-explorer-tree-scroll") + .getByText("README.md", { exact: true }) + .click({ button: "right" }); + const filesMenu = page.getByTestId(/file-explorer-row-\d+-context-menu$/); + await expect(filesMenu.getByText("Rename", { exact: true })).toHaveCount(0); + await expect(filesMenu.getByText("Duplicate", { exact: true })).toHaveCount(0); + await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.getByTestId("explorer-tab-changes").click(); + await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 }); + await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); + await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0); + await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0); +}); diff --git a/packages/app/src/components/file-actions-menu.tsx b/packages/app/src/components/file-actions-menu.tsx index 185a04fde..63de27475 100644 --- a/packages/app/src/components/file-actions-menu.tsx +++ b/packages/app/src/components/file-actions-menu.tsx @@ -1,6 +1,20 @@ -import { useMemo, type ReactElement, type ReactNode } from "react"; +import { Fragment, useMemo, type ReactElement, type ReactNode } from "react"; import { withUnistyles } from "react-native-unistyles"; -import { Copy, Download, FileText, MessageSquarePlus, type LucideIcon } from "lucide-react-native"; +import { + Copy, + CopyPlus, + Download, + FilePlus, + FileText, + FolderMinus, + FolderOpen, + FolderPlus, + MessageSquarePlus, + Pencil, + Trash2, + Undo2, + type LucideIcon, +} from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { @@ -10,11 +24,14 @@ import { } from "@/components/ui/context-menu"; const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive }); interface FileAction { key: string; label: string; icon: LucideIcon; onSelect: () => void; + destructive?: boolean; + separatorBefore?: boolean; testID?: string; } @@ -23,8 +40,18 @@ interface FileActionsContextMenuContentProps { fileExists?: boolean; onOpenFile?: () => void; onCopyPath?: () => void; + onCopyRelativePath?: () => void; + onReveal?: () => void; + revealTargetName?: string; onDownload?: () => void; onAddToChat?: () => void; + onNewFile?: () => void; + onNewFolder?: () => void; + onCollapseFolder?: () => void; + onRename?: () => void; + onDuplicate?: () => void; + onRevert?: () => void; + onDelete?: () => void; /** Optional metadata block rendered above the actions (e.g. size/modified). */ header?: ReactNode; testIDPrefix?: string; @@ -39,51 +66,161 @@ export function FileActionsContextMenuContent({ fileExists = true, onOpenFile, onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, onDownload, onAddToChat, + onNewFile, + onNewFolder, + onCollapseFolder, + onRename, + onDuplicate, + onRevert, + onDelete, header, testIDPrefix, }: FileActionsContextMenuContentProps): ReactElement | null { const { t } = useTranslation(); const actions = useMemo(() => { const availableFile = fileKind === "file" && fileExists; - const next: FileAction[] = []; - if (availableFile && onOpenFile) { - next.push({ - key: "open-file", - label: t("workspace.fileActions.openFile"), - icon: FileText, - onSelect: onOpenFile, - testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined, - }); - } - if (onCopyPath) { - next.push({ - key: "copy-path", - label: t("workspace.fileActions.copyPath"), - icon: Copy, - onSelect: onCopyPath, - }); - } - if (availableFile && onDownload) { - next.push({ - key: "download", - label: t("workspace.fileActions.download"), - icon: Download, - onSelect: onDownload, - }); - } - if (availableFile && onAddToChat) { - next.push({ - key: "add-to-chat", - label: t("workspace.fileActions.addToChat"), - icon: MessageSquarePlus, - onSelect: onAddToChat, - testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined, - }); - } - return next; - }, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]); + const specs: Array = [ + onNewFile + ? { + key: "new-file", + label: t("workspace.fileActions.newFile"), + icon: FilePlus, + onSelect: onNewFile, + } + : null, + onNewFolder + ? { + key: "new-folder", + label: t("workspace.fileActions.newFolder"), + icon: FolderPlus, + onSelect: onNewFolder, + } + : null, + onCollapseFolder + ? { + key: "collapse-folder", + label: t("workspace.fileActions.collapseFolder"), + icon: FolderMinus, + onSelect: onCollapseFolder, + } + : null, + availableFile && onOpenFile + ? { + key: "open-file", + label: t("workspace.fileActions.openFile"), + icon: FileText, + onSelect: onOpenFile, + } + : null, + onRename + ? { + key: "rename", + label: t("workspace.fileActions.rename"), + icon: Pencil, + onSelect: onRename, + } + : null, + onDuplicate + ? { + key: "duplicate", + label: t("workspace.fileActions.duplicate"), + icon: CopyPlus, + onSelect: onDuplicate, + } + : null, + onCopyPath + ? { + key: "copy-path", + label: t("workspace.fileActions.copyPath"), + icon: Copy, + onSelect: onCopyPath, + } + : null, + onCopyRelativePath + ? { + key: "copy-relative-path", + label: t("workspace.fileActions.copyRelativePath"), + icon: Copy, + onSelect: onCopyRelativePath, + } + : null, + onReveal && revealTargetName + ? { + key: "reveal", + label: t("workspace.fileActions.revealIn", { target: revealTargetName }), + icon: FolderOpen, + onSelect: onReveal, + } + : null, + availableFile && onDownload + ? { + key: "download", + label: t("workspace.fileActions.download"), + icon: Download, + onSelect: onDownload, + } + : null, + availableFile && onAddToChat + ? { + key: "add-to-chat", + label: t("workspace.fileActions.addToChat"), + icon: MessageSquarePlus, + onSelect: onAddToChat, + } + : null, + onRevert + ? { + key: "revert", + label: t("workspace.fileActions.revert"), + icon: Undo2, + onSelect: onRevert, + destructive: true, + } + : null, + onDelete + ? { + key: "delete", + label: t("workspace.fileActions.delete"), + icon: Trash2, + onSelect: onDelete, + destructive: true, + } + : null, + ]; + const availableActions = specs.filter((action): action is FileAction => action !== null); + return availableActions.map((action, index) => + Object.assign(action, { + separatorBefore: Boolean( + action.destructive && index > 0 && !availableActions[index - 1].destructive, + ), + testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined, + }), + ); + }, [ + fileExists, + fileKind, + onAddToChat, + onCollapseFolder, + onCopyPath, + onCopyRelativePath, + onDelete, + onDownload, + onDuplicate, + onNewFile, + onNewFolder, + onOpenFile, + onRename, + onReveal, + onRevert, + revealTargetName, + t, + testIDPrefix, + ]); if (actions.length === 0) { return null; @@ -101,21 +238,32 @@ export function FileActionsContextMenuContent({ ) : null} {actions.map((action) => ( - + + {action.separatorBefore ? : null} + + ))} ); } function FileActionMenuItem({ action }: { action: FileAction }): ReactElement { - const Icon = action.icon; - const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]); - const leading = useMemo( - () => , - [ThemedIcon], - ); + const leading = useMemo(() => { + const ThemedIcon = withUnistyles(action.icon); + return ( + + ); + }, [action.destructive, action.icon]); return ( - + {action.label} ); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 798ad36b9..969a1b596 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, type ReactElement, type RefObject } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, + type RefObject, +} from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { @@ -6,15 +15,27 @@ import { ListRenderItemInfo, Pressable, Text, + TextInput, View, + type NativeSyntheticEvent, type PressableStateCallbackType, type StyleProp, + type TextInputKeyPressEventData, type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; import * as Clipboard from "expo-clipboard"; -import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native"; +import { + ChevronDown, + Eye, + EyeOff, + FilePlus, + Folder, + FolderPlus, + RotateCw, +} from "lucide-react-native"; import { MaterialFileIcon } from "@/components/material-file-icon"; import { TreeChevron, @@ -39,8 +60,9 @@ import type { } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; -import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { useFileDownload } from "@/hooks/use-file-download"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store"; @@ -56,6 +78,9 @@ import { type ExplorerTreeRow, } from "@/file-explorer/tree"; import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { useToast } from "@/contexts/toast-context"; +import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; const SORT_OPTIONS: { value: SortOption }[] = [ { value: "name" }, @@ -64,10 +89,23 @@ const SORT_OPTIONS: { value: SortOption }[] = [ ]; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const ThemedFolder = withUnistyles(Folder); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); +function DirectoryChevronIcon({ loading, expanded }: { loading: boolean; expanded: boolean }) { + if (loading) { + return ( + + ); + } + return ; +} + function formatFileSize({ size }: { size: number }): string { if (size < 1024) { return `${size} B`; @@ -87,9 +125,18 @@ interface TreeRowItemProps { isSelected: boolean; loading: boolean; onEntryPress: (entry: ExplorerEntry) => void; + onSelectEntry: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void; + onCopyRelativePath: (path: string) => void; + onRevealEntry?: (entry: ExplorerEntry) => void; + revealTargetName?: string; onDownloadEntry: (entry: ExplorerEntry) => void; onAddToChat?: (path: string) => void; + onNewEntry?: (parentPath: string, kind: "file" | "directory") => void; + onCollapseDirectory?: (path: string) => void; + onRenameEntry?: (entry: ExplorerEntry) => void; + onDuplicateEntry?: (entry: ExplorerEntry) => void; + onDeleteEntry?: (entry: ExplorerEntry) => void; testID?: string; } @@ -104,8 +151,99 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered]; } -function treeRowKeyExtractor(row: ExplorerTreeRow) { - return row.entry.path; +type ExplorerListRow = + | { type: "entry"; row: ExplorerTreeRow } + | { type: "draft"; parentPath: string; kind: "file" | "directory"; depth: number } + | { type: "rename"; entry: ExplorerEntry; depth: number }; + +type ExplorerPendingEdit = + | { type: "create"; parentPath: string; kind: "file" | "directory" } + | { type: "rename"; entry: ExplorerEntry }; + +function listRowKeyExtractor(row: ExplorerListRow) { + if (row.type === "entry") { + return row.row.entry.path; + } + return row.type === "rename" ? `rename:${row.entry.path}` : `draft:${row.parentPath}:${row.kind}`; +} + +interface EntryNameInputRowProps { + depth: number; + kind: "file" | "directory"; + initialName?: string; + onCommit: (name: string) => void; + onCancel: () => void; +} + +function EntryNameInputRow({ + depth, + kind, + initialName = "", + onCommit, + onCancel, +}: EntryNameInputRowProps) { + const { t } = useTranslation(); + const [name, setName] = useState(initialName); + const settledRef = useRef(false); + + const commit = useCallback(() => { + if (settledRef.current) { + return; + } + settledRef.current = true; + const trimmed = name.trim(); + if (!trimmed) { + onCancel(); + return; + } + onCommit(trimmed); + }, [name, onCancel, onCommit]); + + const handleKeyPress = useCallback( + (event: NativeSyntheticEvent) => { + if (event.nativeEvent.key === "Escape") { + settledRef.current = true; + onCancel(); + } + }, + [onCancel], + ); + + return ( + + + + + {kind === "directory" ? : null} + + + {kind === "directory" ? ( + + ) : ( + + )} + + + + + ); } function TreeRowItem({ @@ -117,9 +255,18 @@ function TreeRowItem({ isSelected, loading, onEntryPress, + onSelectEntry, onCopyPath, + onCopyRelativePath, + onRevealEntry, + revealTargetName, onDownloadEntry, onAddToChat, + onNewEntry, + onCollapseDirectory, + onRenameEntry, + onDuplicateEntry, + onDeleteEntry, testID, }: TreeRowItemProps) { const { t } = useTranslation(); @@ -132,9 +279,18 @@ function TreeRowItem({ }); const handlePress = useCallback(() => { + const selection = isWeb ? window.getSelection() : null; + if (selection && !selection.isCollapsed && selection.toString().length > 0) { + return; + } onEntryPress(entry); }, [onEntryPress, entry]); + const handleSelect = useCallback(() => { + onSelectEntry(entry); + }, [entry, onSelectEntry]); + const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); + const pressableStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.entryRow, @@ -148,6 +304,14 @@ function TreeRowItem({ onCopyPath(entry.path); }, [onCopyPath, entry.path]); + const handleCopyRelativePath = useCallback(() => { + onCopyRelativePath(entry.path); + }, [onCopyRelativePath, entry.path]); + + const handleReveal = useCallback(() => { + onRevealEntry?.(entry); + }, [onRevealEntry, entry]); + const handleDownload = useCallback(() => { onDownloadEntry(entry); }, [onDownloadEntry, entry]); @@ -156,6 +320,30 @@ function TreeRowItem({ onAddToChat?.(entry.path); }, [onAddToChat, entry.path]); + const handleNewFile = useCallback(() => { + onNewEntry?.(entry.path, "file"); + }, [onNewEntry, entry.path]); + + const handleNewFolder = useCallback(() => { + onNewEntry?.(entry.path, "directory"); + }, [onNewEntry, entry.path]); + + const handleCollapseDirectory = useCallback(() => { + onCollapseDirectory?.(entry.path); + }, [entry.path, onCollapseDirectory]); + + const handleRename = useCallback(() => { + onRenameEntry?.(entry); + }, [onRenameEntry, entry]); + + const handleDuplicate = useCallback(() => { + onDuplicateEntry?.(entry); + }, [entry, onDuplicateEntry]); + + const handleDelete = useCallback(() => { + onDeleteEntry?.(entry); + }, [onDeleteEntry, entry]); + const metaHeader = useMemo( () => ( @@ -182,24 +370,29 @@ function TreeRowItem({ return ( - + + + {isDirectory ? : null} + - {(() => { - if (!isDirectory) { - return ; - } - if (loading) { - return ( - - ); - } - return ; - })()} + {isDirectory ? ( + + ) : ( + + )} {entry.name} @@ -209,8 +402,17 @@ function TreeRowItem({ @@ -252,11 +454,33 @@ export function FileExplorerPane({ : undefined, ); - const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({ + const { + requestDirectoryListing, + createEntry, + renameEntry, + duplicateEntry, + deleteEntry, + selectExplorerEntry, + } = useFileExplorerActions({ serverId, workspaceId, workspaceRoot: normalizedWorkspaceRoot, }); + const toast = useToast(); + const isLocalDaemon = useIsLocalDaemon(serverId); + const { targets: desktopOpenTargets } = useDesktopOpenTargets({ + isLocalExecution: isLocalDaemon, + }); + const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); + // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. + const fsEntryOpsEnabled = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryOps === true, + ); + // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. + const fsEntryDuplicateEnabled = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true, + ); + const [pendingEdit, setPendingEdit] = useState(null); const downloadFile = useFileDownload({ serverId, workspaceId, @@ -286,7 +510,7 @@ export function FileExplorerPane({ [isExplorerLoading, pendingRequest], ); - const treeListRef = useRef>(null); + const treeListRef = useRef>(null); const scrollbar = useOverlayFlatListScrollbar(treeListRef, { enabled: !isCompact }); const hasInitializedRef = useRef(false); @@ -333,26 +557,49 @@ export function FileExplorerPane({ ], ); + // Selection is intentionally separate from opening/expansion so future keyboard actions + // (for example, pressing R to rename) have one stable file-or-folder target. + const handleSelectEntry = useCallback( + (entry: ExplorerEntry) => { + if (hasWorkspaceScope) { + selectExplorerEntry(entry.path); + } + }, + [hasWorkspaceScope, selectExplorerEntry], + ); + const handleOpenFile = useCallback( (entry: ExplorerEntry) => { if (!hasWorkspaceScope) { return; } - selectExplorerEntry(entry.path); onOpenFile?.(entry.path); }, - [hasWorkspaceScope, onOpenFile, selectExplorerEntry], + [hasWorkspaceScope, onOpenFile], ); const handleEntryPress = useCallback( (entry: ExplorerEntry) => { + handleSelectEntry(entry); if (entry.kind === "directory") { handleToggleDirectory(entry); return; } handleOpenFile(entry); }, - [handleOpenFile, handleToggleDirectory], + [handleOpenFile, handleSelectEntry, handleToggleDirectory], + ); + + const handleCollapseDirectory = useCallback( + (path: string) => { + if (!workspaceStateKey) { + return; + } + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + currentPaths.filter((expandedPath) => !isExplorerPathWithin(expandedPath, path)), + ); + }, + [setExpandedPathsForWorkspace, workspaceStateKey], ); const handleCopyPath = useCallback( @@ -367,6 +614,33 @@ export function FileExplorerPane({ [normalizedWorkspaceRoot], ); + const handleCopyRelativePath = useCallback(async (path: string) => { + await Clipboard.setStringAsync(path); + }, []); + + const handleRevealEntry = useCallback( + async (entry: ExplorerEntry) => { + if (!fileManagerTarget) { + return; + } + try { + await openDesktopTarget({ + editorId: fileManagerTarget.id, + workspacePath: normalizedWorkspaceRoot, + filePath: buildAbsoluteExplorerPath({ + workspaceRoot: normalizedWorkspaceRoot, + entryPath: entry.path, + }), + }); + } catch (cause) { + toast.error( + cause instanceof Error ? cause.message : t("workspace.fileExplorer.errors.revealFailed"), + ); + } + }, + [fileManagerTarget, normalizedWorkspaceRoot, t, toast], + ); + const handleDownloadEntry = useCallback( (entry: ExplorerEntry) => { if (entry.kind !== "file") { @@ -377,6 +651,197 @@ export function FileExplorerPane({ [downloadFile], ); + const handleNewEntry = useCallback( + (parentPath: string, kind: "file" | "directory") => { + if (!workspaceStateKey) { + return; + } + if (parentPath !== ".") { + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + setExpandedDirectoryPath({ + currentExpandedPaths: currentPaths, + directoryPath: parentPath, + expanded: true, + }), + ); + if (!directories.has(parentPath)) { + void requestDirectoryListing(parentPath, { + recordHistory: false, + setCurrentPath: false, + }); + } + } + setPendingEdit({ type: "create", parentPath, kind }); + }, + [directories, requestDirectoryListing, setExpandedPathsForWorkspace, workspaceStateKey], + ); + + const handleEditCancel = useCallback(() => { + setPendingEdit(null); + }, []); + + const handleRenameEntry = useCallback((entry: ExplorerEntry) => { + setPendingEdit({ type: "rename", entry }); + }, []); + + const handleDraftCommit = useCallback( + async (name: string) => { + const edit = pendingEdit; + setPendingEdit(null); + if (edit?.type !== "create") { + return; + } + try { + const payload = await createEntry({ + parentPath: edit.parentPath, + name, + kind: edit.kind, + }); + if (!payload) { + return; + } + if (!payload.success) { + toast.error(payload.error ?? t("workspace.fileExplorer.errors.createFailed")); + return; + } + if (edit.kind === "file" && payload.path) { + selectExplorerEntry(payload.path); + onOpenFile?.(payload.path); + } + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : String(cause)); + } + }, + [createEntry, onOpenFile, pendingEdit, selectExplorerEntry, t, toast], + ); + + const handleRenameCommit = useCallback( + async (name: string) => { + const edit = pendingEdit; + setPendingEdit(null); + if (edit?.type !== "rename" || name === edit.entry.name) { + return; + } + const { entry } = edit; + try { + const payload = await renameEntry({ + path: entry.path, + name, + }); + if (!payload) { + return; + } + if (!payload.success || !payload.renamedPath) { + toast.error(payload.error ?? t("workspace.fileExplorer.errors.renameFailed")); + return; + } + + const renamedPath = payload.renamedPath; + if (workspaceStateKey && entry.kind === "directory") { + const expandedRenamedPaths = Array.from(expandedPaths) + .filter((expandedPath) => isExplorerPathWithin(expandedPath, entry.path)) + .map((expandedPath) => + replaceExplorerPathPrefix(expandedPath, entry.path, renamedPath), + ); + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + currentPaths.map((currentPath) => + isExplorerPathWithin(currentPath, entry.path) + ? replaceExplorerPathPrefix(currentPath, entry.path, renamedPath) + : currentPath, + ), + ); + await Promise.all( + expandedRenamedPaths.map((expandedPath) => + requestDirectoryListing(expandedPath, { + recordHistory: false, + setCurrentPath: false, + }), + ), + ); + } + if (selectedEntryPath && isExplorerPathWithin(selectedEntryPath, entry.path)) { + const renamedSelection = replaceExplorerPathPrefix( + selectedEntryPath, + entry.path, + renamedPath, + ); + selectExplorerEntry(renamedSelection); + if (entry.kind === "file") { + onOpenFile?.(renamedSelection); + } + } + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : String(cause)); + } + }, + [ + expandedPaths, + onOpenFile, + pendingEdit, + requestDirectoryListing, + renameEntry, + selectExplorerEntry, + selectedEntryPath, + setExpandedPathsForWorkspace, + t, + toast, + workspaceStateKey, + ], + ); + + const handleDuplicateEntry = useCallback( + async (entry: ExplorerEntry) => { + try { + const payload = await duplicateEntry(entry.path); + if (!payload) { + return; + } + if (!payload.success || !payload.duplicatedPath) { + toast.error(payload.error ?? t("workspace.fileExplorer.errors.duplicateFailed")); + return; + } + selectExplorerEntry(payload.duplicatedPath); + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : String(cause)); + } + }, + [duplicateEntry, selectExplorerEntry, t, toast], + ); + + const handleDeleteEntry = useCallback( + async (entry: ExplorerEntry) => { + const confirmed = await confirmDialog({ + title: + entry.kind === "directory" + ? t("workspace.fileActions.confirmDelete.folderTitle") + : t("workspace.fileActions.confirmDelete.fileTitle"), + message: t("workspace.fileActions.confirmDelete.message", { name: entry.name }), + confirmLabel: t("workspace.fileActions.confirmDelete.confirm"), + cancelLabel: t("workspace.fileActions.confirmDelete.cancel"), + destructive: true, + }); + if (!confirmed) { + return; + } + try { + const payload = await deleteEntry(entry.path); + if (!payload) { + return; + } + if (!payload.success) { + toast.error(payload.error ?? t("workspace.fileExplorer.errors.deleteFailed")); + return; + } + if (selectedEntryPath === entry.path) { + selectExplorerEntry(null); + } + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : String(cause)); + } + }, + [deleteEntry, selectExplorerEntry, selectedEntryPath, t, toast], + ); + const handleSortCycle = useCallback(() => { const currentIndex = SORT_OPTIONS.findIndex((opt) => opt.value === sortOption); const nextIndex = (currentIndex + 1) % SORT_OPTIONS.length; @@ -456,6 +921,33 @@ export function FileExplorerPane({ [directories, expandedPaths, showHiddenFiles, sortOption], ); + const listRows = useMemo(() => { + const rows: ExplorerListRow[] = treeRows.map((row) => + pendingEdit?.type === "rename" && pendingEdit.entry.path === row.entry.path + ? { type: "rename", entry: row.entry, depth: row.depth } + : { type: "entry", row }, + ); + if (pendingEdit?.type !== "create") { + return rows; + } + let insertionIndex = 0; + let depth = 0; + if (pendingEdit.parentPath !== ".") { + const parentIndex = treeRows.findIndex((row) => row.entry.path === pendingEdit.parentPath); + if (parentIndex >= 0) { + insertionIndex = parentIndex + 1; + depth = treeRows[parentIndex].depth + 1; + } + } + rows.splice(insertionIndex, 0, { + type: "draft", + parentPath: pendingEdit.parentPath, + kind: pendingEdit.kind, + depth, + }); + return rows; + }, [pendingEdit, treeRows]); + const showInitialLoading = resolveShowInitialLoading({ directories, isExplorerLoading, @@ -465,26 +957,73 @@ export function FileExplorerPane({ const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]); const renderTreeRow = useCallback( - (info: ListRenderItemInfo) => ( - - ), + (info: ListRenderItemInfo) => { + if (info.item.type === "draft") { + return ( + + ); + } + if (info.item.type === "rename") { + return ( + + ); + } + return ( + + ); + }, [ expandedPaths, - handleEntryPress, + fsEntryDuplicateEnabled, + fsEntryOpsEnabled, + handleCollapseDirectory, handleCopyPath, + handleCopyRelativePath, + handleDeleteEntry, handleDownloadEntry, + handleDraftCommit, + handleDuplicateEntry, + handleEditCancel, + handleEntryPress, + handleNewEntry, + handleRenameCommit, + handleRenameEntry, + handleRevealEntry, + handleSelectEntry, isDirectoryLoading, + fileManagerTarget, selectedEntryPath, onAddToChat, serverId, @@ -519,12 +1058,18 @@ export function FileExplorerPane({ } return ( - + void }) => event.preventDefault?.(), + }} + style={styles.container} + > { + if (!enabled || isFileExplorerRowTarget(event.target)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + contextMenu.setAnchorRect({ + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + width: 0, + height: 0, + }); + contextMenu.setOpen(true); + }, + [contextMenu, enabled], + ); + + return ( + + {children} + + ); +} + interface FileExplorerPaneContentProps { error: string | null; showInitialLoading: boolean; showBackFromError: boolean; - treeRows: ExplorerTreeRow[]; + listRows: ExplorerListRow[]; + onNewEntryAtRoot?: (parentPath: string, kind: "file" | "directory") => void; currentSortLabel: string; isRefreshFetching: boolean; - treeListRef: RefObject | null>; + treeListRef: RefObject | null>; scrollbar: OverlayFlatListScrollbar; - renderTreeRow: (info: ListRenderItemInfo) => ReactElement; + renderTreeRow: (info: ListRenderItemInfo) => ReactElement; handleSortCycle: () => void; handleToggleHiddenFiles: () => void; handleRefresh: () => void; @@ -568,7 +1165,8 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { error, showInitialLoading, showBackFromError, - treeRows, + listRows, + onNewEntryAtRoot, currentSortLabel, isRefreshFetching, treeListRef, @@ -585,6 +1183,13 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles); + const handleNewFileAtRoot = useCallback(() => { + onNewEntryAtRoot?.(".", "file"); + }, [onNewEntryAtRoot]); + const handleNewFolderAtRoot = useCallback(() => { + onNewEntryAtRoot?.(".", "directory"); + }, [onNewEntryAtRoot]); + const hiddenFilesToggleAccessibilityLabel = showHiddenFiles ? t("workspace.fileExplorer.actions.hideHiddenFiles") : t("workspace.fileExplorer.actions.showHiddenFiles"); @@ -644,6 +1249,30 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { + {onNewEntryAtRoot ? ( + <> + + + + + + + + ) : null} - {treeRows.length === 0 ? ( - - {emptyLabel} - - ) : ( - - )} - {treeRows.length > 0 ? scrollbar.overlay : null} + + + {listRows.length === 0 ? ( + + {emptyLabel} + + ) : ( + + )} + {listRows.length > 0 ? scrollbar.overlay : null} + + {onNewEntryAtRoot ? ( + + ) : null} + ); } @@ -799,28 +1440,48 @@ function toggleDirectory({ function TreeRowDispatcher({ serverId, workspaceId, - info, + row, + index, expandedPaths, selectedEntryPath, isDirectoryLoading, onEntryPress, + onSelectEntry, onCopyPath, + onCopyRelativePath, + onRevealEntry, + revealTargetName, onDownloadEntry, onAddToChat, + onNewEntry, + onCollapseDirectory, + onRenameEntry, + onDuplicateEntry, + onDeleteEntry, }: { serverId: string; workspaceId?: string | null; - info: ListRenderItemInfo; + row: ExplorerTreeRow; + index: number; expandedPaths: Set; selectedEntryPath: string | null; isDirectoryLoading: (path: string) => boolean; onEntryPress: (entry: ExplorerEntry) => void; + onSelectEntry: (entry: ExplorerEntry) => void; onCopyPath: (path: string) => void | Promise; + onCopyRelativePath: (path: string) => void | Promise; + onRevealEntry?: (entry: ExplorerEntry) => void; + revealTargetName?: string; onDownloadEntry: (entry: ExplorerEntry) => void; onAddToChat?: (path: string) => void; + onNewEntry?: (parentPath: string, kind: "file" | "directory") => void; + onCollapseDirectory?: (path: string) => void; + onRenameEntry?: (entry: ExplorerEntry) => void; + onDuplicateEntry?: (entry: ExplorerEntry) => void; + onDeleteEntry?: (entry: ExplorerEntry) => void; }) { - const entry = info.item.entry; - const depth = info.item.depth; + const entry = row.entry; + const depth = row.depth; const isDirectory = entry.kind === "directory"; const isExpanded = isDirectory && expandedPaths.has(entry.path); const isSelected = selectedEntryPath === entry.path; @@ -836,14 +1497,35 @@ function TreeRowDispatcher({ isSelected={isSelected} loading={loading} onEntryPress={onEntryPress} + onSelectEntry={onSelectEntry} onCopyPath={onCopyPath} + onCopyRelativePath={onCopyRelativePath} + onRevealEntry={onRevealEntry} + revealTargetName={revealTargetName} onDownloadEntry={onDownloadEntry} onAddToChat={onAddToChat} - testID={`file-explorer-row-${info.index}`} + onNewEntry={onNewEntry} + onCollapseDirectory={onCollapseDirectory} + onRenameEntry={onRenameEntry} + onDuplicateEntry={onDuplicateEntry} + onDeleteEntry={onDeleteEntry} + testID={`file-explorer-row-${index}`} /> ); } +function isExplorerPathWithin(candidatePath: string, parentPath: string): boolean { + return candidatePath === parentPath || candidatePath.startsWith(`${parentPath}/`); +} + +function replaceExplorerPathPrefix( + candidatePath: string, + previousPrefix: string, + nextPrefix: string, +): string { + return `${nextPrefix}${candidatePath.slice(previousPrefix.length)}`; +} + async function initializeExplorer({ hasWorkspaceScope, hasInitializedRef, @@ -1023,9 +1705,14 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, }, entriesContent: { + flexGrow: 1, paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, + rootContextTarget: { + flex: 1, + minHeight: 0, + }, centerState: { flex: 1, alignItems: "center", @@ -1085,6 +1772,13 @@ const styles = StyleSheet.create((theme) => ({ gap: WORKSPACE_TREE_ICON_LABEL_GAP, minWidth: 0, }, + entryChevron: { + width: WORKSPACE_TREE_ICON_SIZE, + height: WORKSPACE_TREE_ICON_SIZE, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, entryIcon: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, @@ -1096,6 +1790,14 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, + userSelect: "none", + }, + draftInput: { + flex: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingVertical: 0, + paddingHorizontal: 0, }, contextMetaBlock: { paddingVertical: theme.spacing[1], diff --git a/packages/app/src/components/tree-primitives.tsx b/packages/app/src/components/tree-primitives.tsx index 01c26d5fa..4a0e18a4f 100644 --- a/packages/app/src/components/tree-primitives.tsx +++ b/packages/app/src/components/tree-primitives.tsx @@ -10,22 +10,22 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; // paths) render different data, but their ROWS should look identical — same // indentation, guide lines, and chevron. Keep those here so the two trees can't // drift apart. -export const TREE_INDENT_PER_LEVEL = 16; -export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5]; -export const WORKSPACE_TREE_ICON_SIZE = 16; -export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14; -export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2]; +export const TREE_INDENT_PER_LEVEL = 14; +export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1]; +export const WORKSPACE_TREE_ICON_SIZE = 14; +export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12; +export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1]; /** * Trailing glyph rail shared with the explorer X and Changes options chevron. * The extra 2px is optical: text ink ends inside its layout box, while the * header icons' strokes extend to theirs. */ -export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2; +export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[3] + 2; /** Left padding for a tree row at `depth`. Shared by folder rows and file headers * in the Changes tree so their indentation can't drift apart. */ export function treeRowPaddingLeft(depth: number): number { - return SPACING[3] + depth * TREE_INDENT_PER_LEVEL; + return SPACING[2] + depth * TREE_INDENT_PER_LEVEL; } const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({ @@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) { key: index, style: [ styles.indentGuide, - inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }), + inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }), ], })), [depth], @@ -63,13 +63,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) { /** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */ export function TreeChevron({ expanded }: { expanded: boolean }) { return ( - + ({ justifyContent: "center", flexShrink: 0, }, - // Lucide leaves space around the chevron path. Align its painted right edge inside the fixed - // slot; the rotated glyph is wider, so it needs half the optical offset. - chevronCollapsed: { - left: 4, - }, chevronExpanded: { - left: 2, transform: [{ rotate: "90deg" }], }, })); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 17a2aba75..8b117c801 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -105,6 +105,7 @@ export function ContextMenuTrigger({ enabledOnMobile = true, enabledOnWeb = true, longPressDelayMs, + onContextMenu, triggerRef, ...props }: PropsWithChildren< @@ -115,6 +116,7 @@ export function ContextMenuTrigger({ enabledOnMobile?: boolean; enabledOnWeb?: boolean; longPressDelayMs?: number; + onContextMenu?: (event: unknown) => void; triggerRef?: Ref; } >): ReactElement { @@ -170,9 +172,10 @@ export function ContextMenuTrigger({ if (isCallable(preventDefault)) preventDefault.call(event); if (isCallable(stopPropagation)) stopPropagation.call(event); } + onContextMenu?.(event); openAtEvent(event); }, - [openAtEvent], + [onContextMenu, openAtEvent], ); const pressableStyle = useCallback( diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index ba5f3cf01..fb75fa6bf 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -333,6 +333,7 @@ export function AnchoredSurface({ <> {backdrop ? ( void }) => event.preventDefault?.(), + }} ref={setWebOverlayScope} collapsable={false} style={[ diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index 75a397775..9ab27ba40 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -183,6 +183,31 @@ describe("checkout-git-actions-store", () => { ).toBe("idle"); }); + it("discards selected paths through the shared checkout action workflow", async () => { + const checkoutDiscardChanges = vi.fn(async () => ({ success: true, error: null })); + const client = { checkoutDiscardChanges }; + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); + + await useCheckoutGitActionsStore + .getState() + .discardChanges({ serverId, cwd, paths: ["renamed.ts", "original.ts"] }); + + expect(checkoutDiscardChanges).toHaveBeenCalledWith(cwd, { + paths: ["renamed.ts", "original.ts"], + }); + expect( + useCheckoutGitActionsStore + .getState() + .getStatus({ serverId, cwd, actionId: "discard-changes" }), + ).toBe("success"); + }); + for (const rpc of [ { label: "forge", diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 34e7b5443..853cd2ff8 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -24,7 +24,8 @@ export type CheckoutGitAsyncActionId = | "enable-pr-auto-merge-rebase" | "disable-pr-auto-merge" | "merge-branch" - | "merge-from-base"; + | "merge-from-base" + | "discard-changes"; type CheckoutKey = string; type StatusMap = Partial>; @@ -119,6 +120,7 @@ interface CheckoutGitActionsStoreState { disablePrAutoMerge: (params: { serverId: string; cwd: string }) => Promise; mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise; + discardChanges: (params: { serverId: string; cwd: string; paths: string[] }) => Promise; } async function runCheckoutAction({ @@ -366,6 +368,23 @@ export const useCheckoutGitActionsStore = create() }, }); }, + + discardChanges: async ({ serverId, cwd, paths }) => { + await runCheckoutAction({ + serverId, + cwd, + actionId: "discard-changes", + run: async () => { + const client = resolveClient(serverId); + const payload = await client.checkoutDiscardChanges(cwd, { paths }); + if (!payload.success) { + throw new Error( + payload.error?.message ?? i18n.t("workspace.fileActions.confirmRevert.failed"), + ); + } + }, + }); + }, })); export function __resetCheckoutGitActionsStoreForTests() { diff --git a/packages/app/src/git/diff-folder-row.tsx b/packages/app/src/git/diff-folder-row.tsx index 992165a07..f32bc6859 100644 --- a/packages/app/src/git/diff-folder-row.tsx +++ b/packages/app/src/git/diff-folder-row.tsx @@ -1,12 +1,7 @@ import { useCallback, useMemo } from "react"; -import { - View, - Text, - Pressable, - type LayoutChangeEvent, - type PressableStateCallbackType, -} from "react-native"; -import { StyleSheet } from "react-native-unistyles"; +import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Folder } from "lucide-react-native"; import { DiffStat } from "@/components/diff-stat"; import { TreeChevron, @@ -15,9 +10,16 @@ import { WORKSPACE_FILE_ROW_TRAILING_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING, WORKSPACE_TREE_ICON_LABEL_GAP, + WORKSPACE_TREE_ICON_SIZE, } from "@/components/tree-primitives"; import { type Theme } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; +import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; +import { isWeb } from "@/constants/platform"; + +const ThemedFolder = withUnistyles(Folder); +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); interface DiffFolderRowProps { /** full uncompressed directory path — the collapse identity */ @@ -25,20 +27,27 @@ interface DiffFolderRowProps { displayName: string; depth: number; collapsed: boolean; + isSelected: boolean; additions: number; deletions: number; onToggle: (dirPath: string) => void; + onCollapse: (dirPath: string) => void; + onSelect: (dirPath: string) => void; onHeightChange?: (height: number) => void; + onCopyPath?: (path: string) => void; + onCopyRelativePath?: (path: string) => void; + onReveal?: (path: string) => void; + revealTargetName?: string; + onDuplicate?: (path: string) => void; + onRevert?: (path: string) => void; testID?: string; } -function folderRowPressableStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - // Subtle background highlight on hover/press, matching the Files explorer rows - // (entryRowActive) — no opacity darken. - return [styles.folderRow, (Boolean(hovered) || pressed) && styles.folderRowActive]; +function folderRowPressableStyle( + { hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }, + isSelected: boolean, +) { + return [styles.folderRow, (Boolean(hovered) || pressed || isSelected) && styles.folderRowActive]; } export function DiffFolderRow({ @@ -46,15 +55,39 @@ export function DiffFolderRow({ displayName, depth, collapsed, + isSelected, additions, deletions, onToggle, + onCollapse, + onSelect, onHeightChange, + onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, + onDuplicate, + onRevert, testID, }: DiffFolderRowProps) { + const handleSelect = useCallback(() => { + onSelect(dirPath); + }, [dirPath, onSelect]); + const handlePress = useCallback(() => { + const selection = isWeb ? window.getSelection() : null; + if (selection && !selection.isCollapsed && selection.toString().length > 0) { + return; + } + handleSelect(); onToggle(dirPath); - }, [dirPath, onToggle]); + }, [dirPath, handleSelect, onToggle]); + + const pressableStyle = useCallback( + (state: PressableStateCallbackType & { hovered?: boolean }) => + folderRowPressableStyle(state, isSelected), + [isSelected], + ); const handleLayout = useCallback( (event: LayoutChangeEvent) => { @@ -63,39 +96,88 @@ export function DiffFolderRow({ [onHeightChange], ); + const handleCollapse = useCallback(() => { + onCollapse(dirPath); + }, [dirPath, onCollapse]); + + const handleCopyPath = useCallback(() => { + onCopyPath?.(dirPath); + }, [dirPath, onCopyPath]); + + const handleCopyRelativePath = useCallback(() => { + onCopyRelativePath?.(dirPath); + }, [dirPath, onCopyRelativePath]); + + const handleReveal = useCallback(() => { + onReveal?.(dirPath); + }, [dirPath, onReveal]); + + const handleDuplicate = useCallback(() => { + onDuplicate?.(dirPath); + }, [dirPath, onDuplicate]); + + const handleRevert = useCallback(() => { + onRevert?.(dirPath); + }, [dirPath, onRevert]); + const leftStyle = useMemo( () => [styles.left, inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) })], [depth], ); - const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); + const accessibilityState = useMemo( + () => ({ expanded: !collapsed, selected: isSelected }), + [collapsed, isSelected], + ); return ( - - - - + + + + + + + + + + + {displayName} + - - {displayName} - - - - - - + + + + + + ); } @@ -123,8 +205,16 @@ const styles = StyleSheet.create((theme: Theme) => ({ minWidth: 0, }, chevronOpticalOffset: { - // The Changes directory chevron reads high beside the folder label. - transform: [{ translateY: 2 }], + width: WORKSPACE_TREE_ICON_SIZE, + height: WORKSPACE_TREE_ICON_SIZE, + alignItems: "center", + justifyContent: "center", + }, + folderIcon: { + width: WORKSPACE_TREE_ICON_SIZE, + height: WORKSPACE_TREE_ICON_SIZE, + alignItems: "center", + justifyContent: "center", }, right: { flexDirection: "row", @@ -138,5 +228,6 @@ const styles = StyleSheet.create((theme: Theme) => ({ color: theme.colors.foreground, flexShrink: 1, minWidth: 0, + userSelect: "none", }, })); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 53bc9894b..f0756b34d 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -81,6 +81,7 @@ import * as Clipboard from "expo-clipboard"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu"; import { useFileDownload } from "@/hooks/use-file-download"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { lineNumberGutterWidth } from "@/components/code-insets"; @@ -94,6 +95,7 @@ import type { ForgeAuthState } from "@getpaseo/protocol/messages"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; +import { confirmDialog } from "@/utils/confirm-dialog"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useOverlayFlatListScrollbar } from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; @@ -119,6 +121,7 @@ import { } from "@/review"; import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; import { DiffTooLargeState } from "@/git/diff-too-large-state"; +import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -129,11 +132,14 @@ export function resolveDiffLayout( return canUseSplitLayout ? layout : "unified"; } -function fileHeaderPressableStyle({ - hovered, - pressed, -}: PressableStateCallbackType & { hovered?: boolean }) { - return [styles.fileHeader, (Boolean(hovered) || pressed) && styles.fileHeaderActive]; +function fileHeaderPressableStyle( + { hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }, + isSelected: boolean, +) { + return [ + styles.fileHeader, + (Boolean(hovered) || pressed || isSelected) && styles.fileHeaderActive, + ]; } interface HighlightedTextProps { @@ -208,16 +214,23 @@ interface DiffFileSectionProps { file: ParsedDiffFile; workspaceFileDragScope?: { serverId: string; workspaceId: string }; isExpanded: boolean; + isSelected?: boolean; /** Tree indentation level (0 on the flat/mobile path). */ depth?: number; /** Show the muted directory suffix (flat list); false inside the folder tree. */ showDir?: boolean; interactive?: boolean; onToggle?: (path: string) => void; + onSelect?: (path: string) => void; onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; onCopyPath?: (path: string) => void; + onCopyRelativePath?: (path: string) => void; + onReveal?: (path: string) => void; + revealTargetName?: string; onDownload?: (path: string) => void; + onDuplicate?: (path: string) => void; + onRevert?: (path: string, oldPath?: string) => void; onHeaderHeightChange?: (path: string, height: number) => void; testID?: string; } @@ -226,6 +239,57 @@ const EMPTY_COMMENTS: readonly ReviewDraftComment[] = []; function noopStartComment(): void {} +function useDiscardChangesAction({ + serverId, + cwd, + diffMode, +}: { + serverId: string; + cwd: string; + diffMode: "uncommitted" | "base"; +}): ((path: string, oldPath?: string) => void) | undefined { + const { t } = useTranslation(); + const toast = useToast(); + const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges); + // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. + const discardSupported = useSessionStore( + (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutDiscardChanges === true, + ); + const discardPath = useCallback( + async (path: string, oldPath?: string) => { + const confirmed = await confirmDialog({ + title: t("workspace.fileActions.confirmRevert.title"), + message: t("workspace.fileActions.confirmRevert.message", { name: path }), + confirmLabel: t("workspace.fileActions.confirmRevert.confirm"), + cancelLabel: t("workspace.fileActions.confirmRevert.cancel"), + destructive: true, + }); + if (!confirmed) { + return; + } + try { + await discardChanges({ + serverId, + cwd, + paths: oldPath ? [path, oldPath] : [path], + }); + } catch (cause) { + toast.error( + cause instanceof Error ? cause.message : t("workspace.fileActions.confirmRevert.failed"), + ); + } + }, + [cwd, discardChanges, serverId, t, toast], + ); + const handleDiscardPath = useCallback( + (path: string, oldPath?: string) => { + void discardPath(path, oldPath); + }, + [discardPath], + ); + return discardSupported && diffMode === "uncommitted" ? handleDiscardPath : undefined; +} + const DIFF_LINE_HOVER_STYLE = isWeb ? ({ cursor: "auto" } as const) : null; function LongPressableLine({ @@ -247,6 +311,10 @@ function LongPressableLine({ }) { const onStartComment = reviewActions?.onStartComment; const handlePress = useCallback(() => { + const selection = isWeb ? window.getSelection() : null; + if (selection && !selection.isCollapsed && selection.toString().length > 0) { + return; + } if (reviewTarget && onStartComment) { onStartComment(reviewTarget); } @@ -912,18 +980,84 @@ function SplitDiffColumn({ ); } +function DiffFileActionsContextMenuContent({ + file, + onOpenFile, + onAddToChat, + onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, + onDownload, + onDuplicate, + onRevert, + testID, +}: Pick< + DiffFileSectionProps, + | "file" + | "onOpenFile" + | "onAddToChat" + | "onCopyPath" + | "onCopyRelativePath" + | "onReveal" + | "revealTargetName" + | "onDownload" + | "onDuplicate" + | "onRevert" + | "testID" +>) { + const handleOpenFile = useCallback(() => onOpenFile?.(file.path), [file.path, onOpenFile]); + const handleAddToChat = useCallback(() => onAddToChat?.(file.path), [file.path, onAddToChat]); + const handleCopyPath = useCallback(() => onCopyPath?.(file.path), [file.path, onCopyPath]); + const handleCopyRelativePath = useCallback( + () => onCopyRelativePath?.(file.path), + [file.path, onCopyRelativePath], + ); + const handleReveal = useCallback(() => onReveal?.(file.path), [file.path, onReveal]); + const handleDownload = useCallback(() => onDownload?.(file.path), [file.path, onDownload]); + const handleDuplicate = useCallback(() => onDuplicate?.(file.path), [file.path, onDuplicate]); + const handleRevert = useCallback( + () => onRevert?.(file.path, file.oldPath), + [file.oldPath, file.path, onRevert], + ); + + return ( + + ); +} + const DiffFileHeader = memo(function DiffFileHeader({ file, workspaceFileDragScope, isExpanded, + isSelected = false, depth = 0, showDir = true, interactive = true, onToggle, + onSelect, onOpenFile, onAddToChat, onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, onDownload, + onDuplicate, + onRevert, onHeaderHeightChange, testID, }: DiffFileSectionProps) { @@ -938,29 +1072,24 @@ const DiffFileHeader = memo(function DiffFileHeader({ const pressHandledRef = useRef(false); const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null); + const handleSelect = useCallback(() => { + if (interactive) { + onSelect?.(file.path); + } + }, [file.path, interactive, onSelect]); + const toggleExpanded = useCallback(() => { if (!interactive) { return; } + const selection = isWeb ? window.getSelection() : null; + if (selection && !selection.isCollapsed && selection.toString().length > 0) { + return; + } pressHandledRef.current = true; + handleSelect(); onToggle?.(file.path); - }, [file.path, interactive, onToggle]); - - const handleOpenFile = useCallback(() => { - onOpenFile?.(file.path); - }, [file.path, onOpenFile]); - - const handleAddToChat = useCallback(() => { - onAddToChat?.(file.path); - }, [file.path, onAddToChat]); - - const handleCopyPath = useCallback(() => { - onCopyPath?.(file.path); - }, [file.path, onCopyPath]); - - const handleDownload = useCallback(() => { - onDownload?.(file.path); - }, [file.path, onDownload]); + }, [file.path, handleSelect, interactive, onToggle]); const handleLayout = useCallback( (event: LayoutChangeEvent) => { @@ -981,7 +1110,8 @@ const DiffFileHeader = memo(function DiffFileHeader({ const handleLongPress = useCallback(() => { pressHandledRef.current = true; - }, []); + handleSelect(); + }, [handleSelect]); const handlePressOut = useCallback( (event: { nativeEvent: { pageX: number; pageY: number } }) => { @@ -1008,16 +1138,20 @@ const DiffFileHeader = memo(function DiffFileHeader({ () => [styles.fileSectionHeaderContainer, isExpanded && styles.fileSectionHeaderExpanded], [isExpanded], ); + const accessibilityState = useMemo( + () => ({ expanded: isExpanded, selected: isSelected }), + [isExpanded, isSelected], + ); const headerPressableStyle = useCallback( (state: PressableStateCallbackType) => depth > 0 ? [ - fileHeaderPressableStyle(state), + fileHeaderPressableStyle(state, isSelected), inlineUnistylesStyle({ paddingLeft: treeRowPaddingLeft(depth) }), ] - : fileHeaderPressableStyle(state), - [depth], + : fileHeaderPressableStyle(state, isSelected), + [depth, isSelected], ); const fileName = file.path.split("/").pop() ?? file.path; @@ -1027,9 +1161,10 @@ const DiffFileHeader = memo(function DiffFileHeader({ ref={dragSourceRef} style={showDir ? styles.fileHeaderLeft : [styles.fileHeaderLeft, styles.fileHeaderLeftTree]} > + {showDir ? null : } {showDir ? null : ( - + )} @@ -1060,7 +1195,14 @@ const DiffFileHeader = memo(function DiffFileHeader({ let trigger: ReactElement; if (!interactive) { trigger = ( - {headerContent} + void }) => event.preventDefault?.(), + }} + style={headerPressableStyle({ hovered: false, pressed: false })} + > + {headerContent} + ); } else { trigger = ( @@ -1072,7 +1214,10 @@ const DiffFileHeader = memo(function DiffFileHeader({ onPressIn={handlePressIn} onPressOut={handlePressOut} onLongPress={handleLongPress} + onContextMenu={handleSelect} onPress={toggleExpanded} + accessibilityState={accessibilityState} + aria-selected={isSelected} > {headerContent} @@ -1090,14 +1235,18 @@ const DiffFileHeader = memo(function DiffFileHeader({ {interactive ? ( - ) : null} @@ -1827,7 +1976,12 @@ interface SharedDiffViewProps { onOpenFile?: (path: string) => void; onAddToChat?: (path: string) => void; onCopyPath?: (path: string) => void; + onCopyRelativePath?: (path: string) => void; + onReveal?: (path: string) => void; + revealTargetName?: string; onDownload?: (path: string) => void; + onDuplicate?: (path: string) => void; + onRevert?: (path: string, oldPath?: string) => void; onExpandedPathsChange: (paths: string[]) => void; onCollapsedFoldersChange: (paths: string[]) => void; } @@ -1882,10 +2036,28 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi const workspaceFileDragScope = mode.kind === "working_tree" ? mode.workspaceFileDragScope : undefined; const onCopyPath = mode.kind === "working_tree" ? mode.onCopyPath : undefined; + const onCopyRelativePath = mode.kind === "working_tree" ? mode.onCopyRelativePath : undefined; + const onReveal = mode.kind === "working_tree" ? mode.onReveal : undefined; + const revealTargetName = mode.kind === "working_tree" ? mode.revealTargetName : undefined; const onDownload = mode.kind === "working_tree" ? mode.onDownload : undefined; + const onDuplicate = mode.kind === "working_tree" ? mode.onDuplicate : undefined; + const onRevert = mode.kind === "working_tree" ? mode.onRevert : undefined; + // Keep selection independent from expansion so future keyboard actions (such as R to rename) + // can target the current VCS file or folder without changing its open state. + const [selectedPath, setSelectedPath] = useState(null); + const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []); const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]); const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]); + useEffect(() => { + if ( + selectedPath && + !allFolderPathSet.has(selectedPath) && + !files.some((file) => file.path === selectedPath) + ) { + setSelectedPath(null); + } + }, [allFolderPathSet, files, selectedPath]); const effectiveCollapsedFolders = useMemo( () => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))), [allFolderPathSet, collapsedFolders], @@ -2181,6 +2353,40 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [computeItemOffset, effectiveCollapsedFolders, mode], ); + const handleCollapseFolder = useCallback( + (dirPath: string) => { + if (mode.kind !== "working_tree") { + return; + } + const targetOffset = computeItemOffset( + (item) => item.type === "folder" && item.dirPath === dirPath, + ); + const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current; + if ( + targetOffset !== null && + shouldAnchorHeaderBeforeCollapse({ + headerOffset: targetOffset, + headerHeight: folderHeight, + viewportOffset: diffListScrollOffsetRef.current, + viewportHeight: diffListViewportHeightRef.current, + }) + ) { + diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false }); + } + + const pathPrefix = `${dirPath}/`; + mode.onCollapsedFoldersChange([ + ...new Set([ + ...effectiveCollapsedFolders, + ...allFolderPaths.filter( + (folderPath) => folderPath === dirPath || folderPath.startsWith(pathPrefix), + ), + ]), + ]); + }, + [allFolderPaths, computeItemOffset, effectiveCollapsedFolders, mode], + ); + const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { if (item.type === "folder") { @@ -2190,10 +2396,19 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi displayName={item.displayName} depth={item.depth} collapsed={item.collapsed} + isSelected={selectedPath === item.dirPath} additions={item.additions} deletions={item.deletions} onToggle={handleToggleFolder} + onCollapse={handleCollapseFolder} + onSelect={handleSelectPath} onHeightChange={handleFolderRowHeightChange} + onCopyPath={onCopyPath} + onCopyRelativePath={onCopyRelativePath} + onReveal={onReveal} + revealTargetName={revealTargetName} + onDuplicate={onDuplicate} + onRevert={onRevert} testID={`diff-folder-${item.dirPath}`} /> ); @@ -2204,14 +2419,21 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi file={item.file} workspaceFileDragScope={workspaceFileDragScope} isExpanded={item.isExpanded} + isSelected={selectedPath === item.file.path} depth={item.depth} showDir={viewMode === "flat"} interactive={interactive} onToggle={interactive ? (onFilePress ?? handleToggleExpanded) : undefined} + onSelect={handleSelectPath} onOpenFile={onOpenFile} onAddToChat={onAddToChat} onCopyPath={onCopyPath} + onCopyRelativePath={onCopyRelativePath} + onReveal={onReveal} + revealTargetName={revealTargetName} onDownload={onDownload} + onDuplicate={onDuplicate} + onRevert={onRevert} onHeaderHeightChange={handleHeaderHeightChange} testID={`diff-file-${item.fileIndex}`} /> @@ -2235,6 +2457,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi handleBodyHeightChange, handleFolderRowHeightChange, handleHeaderHeightChange, + handleCollapseFolder, + handleSelectPath, handleToggleExpanded, handleToggleFolder, layout, @@ -2248,7 +2472,13 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi onOpenFile, onAddToChat, onCopyPath, + onCopyRelativePath, + onReveal, + revealTargetName, onDownload, + onDuplicate, + onRevert, + selectedPath, ], ); @@ -2659,6 +2889,11 @@ export function GitDiffPane({ const overflowToggleStyle = useMemo(() => buildOverflowButtonStyle(), []); const toast = useToast(); + const isLocalDaemon = useIsLocalDaemon(serverId); + const { targets: desktopOpenTargets } = useDesktopOpenTargets({ + isLocalExecution: isLocalDaemon, + }); + const fileManagerTarget = desktopOpenTargets.find((target) => target.kind === "file-manager"); const { changesTabOpen, toggleChanges: handleToggleChangesTab, @@ -2668,6 +2903,11 @@ export function GitDiffPane({ const refreshSupported = useSessionStore( (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, ); + const client = useSessionStore((state) => state.sessions[serverId]?.client); + // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. + const fsEntryDuplicateEnabled = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true, + ); const runRefresh = useCheckoutGitActionsStore((s) => s.refresh); const isRefreshing = useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) === @@ -2773,12 +3013,51 @@ export function GitDiffPane({ }, [cwd], ); + const handleCopyRelativePath = useCallback((path: string) => { + void Clipboard.setStringAsync(path); + }, []); + const handleRevealPath = useCallback( + async (path: string) => { + if (!fileManagerTarget) { + return; + } + try { + await openDesktopTarget({ + editorId: fileManagerTarget.id, + workspacePath: cwd, + filePath: buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }), + }); + } catch (cause) { + toast.error( + cause instanceof Error ? cause.message : t("workspace.fileExplorer.errors.revealFailed"), + ); + } + }, + [cwd, fileManagerTarget, t, toast], + ); const handleDownloadPath = useCallback( (path: string) => { downloadFile({ fileName: path.split("/").pop() ?? path, path }); }, [downloadFile], ); + const handleDuplicatePath = useCallback( + async (path: string) => { + if (!client) { + return; + } + try { + const payload = await client.duplicateFileEntry({ cwd, path }); + if (!payload.success) { + toast.error(payload.error ?? t("workspace.fileExplorer.errors.duplicateFailed")); + } + } catch (cause) { + toast.error(cause instanceof Error ? cause.message : String(cause)); + } + }, + [client, cwd, t, toast], + ); + const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode }); const workingTreeMode = useMemo( () => ({ kind: "working_tree" as const, @@ -2791,7 +3070,12 @@ export function GitDiffPane({ onOpenFile, onAddToChat, onCopyPath: handleCopyPath, + onCopyRelativePath: handleCopyRelativePath, + onReveal: fileManagerTarget ? handleRevealPath : undefined, + revealTargetName: fileManagerTarget?.label, onDownload: handleDownloadPath, + onDuplicate: fsEntryDuplicateEnabled ? handleDuplicatePath : undefined, + onRevert: onRevertPath, onExpandedPathsChange: changesTree.updateExpandedPaths, onCollapsedFoldersChange: changesTree.updateCollapsedFolders, }), @@ -2806,7 +3090,13 @@ export function GitDiffPane({ onOpenFile, onAddToChat, handleCopyPath, + handleCopyRelativePath, handleDownloadPath, + handleDuplicatePath, + handleRevealPath, + fileManagerTarget, + fsEntryDuplicateEnabled, + onRevertPath, changesTree.updateExpandedPaths, changesTree.updateCollapsedFolders, ], @@ -2861,7 +3151,12 @@ export function GitDiffPane({ ); return ( - + void }) => event.preventDefault?.(), + }} + style={styles.container} + > {isGit && (currentBranchName || isMobile) ? ( ({ gap: theme.spacing[1], flexShrink: 0, }, + fileChevronSpacer: { + width: WORKSPACE_TREE_ICON_SIZE, + height: WORKSPACE_TREE_ICON_SIZE, + flexShrink: 0, + }, fileIcon: { width: WORKSPACE_TREE_ICON_SIZE, height: WORKSPACE_TREE_ICON_SIZE, @@ -3183,6 +3483,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, flexShrink: 1, minWidth: 0, + userSelect: "none", }, fileDir: { fontSize: theme.fontSize.sm, @@ -3190,6 +3491,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, flex: 1, minWidth: 0, + userSelect: "none", }, fileDirSpacer: { flex: 1, diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index d1bb4c239..53714e37b 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -6,6 +6,7 @@ import { type ExplorerDirectory, } from "@/stores/session-store"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; +import { parentExplorerPath } from "@/utils/explorer-paths"; function createExplorerState(): AgentFileExplorerState { return { @@ -252,6 +253,80 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor [client, normalizedWorkspaceRoot, t], ); + const createEntry = useCallback( + async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => { + if (!client || !normalizedWorkspaceRoot) { + return null; + } + const payload = await client.createFileEntry({ + cwd: normalizedWorkspaceRoot, + ...input, + }); + if (payload.success) { + await requestDirectoryListing(input.parentPath, { + recordHistory: false, + setCurrentPath: false, + }); + } + return payload; + }, + [client, normalizedWorkspaceRoot, requestDirectoryListing], + ); + + const renameEntry = useCallback( + async (input: { path: string; name: string }) => { + if (!client || !normalizedWorkspaceRoot) { + return null; + } + const payload = await client.renameFileEntry({ + cwd: normalizedWorkspaceRoot, + ...input, + }); + if (payload.success) { + await requestDirectoryListing(parentExplorerPath(input.path), { + recordHistory: false, + setCurrentPath: false, + }); + } + return payload; + }, + [client, normalizedWorkspaceRoot, requestDirectoryListing], + ); + + const duplicateEntry = useCallback( + async (path: string) => { + if (!client || !normalizedWorkspaceRoot) { + return null; + } + const payload = await client.duplicateFileEntry({ cwd: normalizedWorkspaceRoot, path }); + if (payload.success) { + await requestDirectoryListing(parentExplorerPath(path), { + recordHistory: false, + setCurrentPath: false, + }); + } + return payload; + }, + [client, normalizedWorkspaceRoot, requestDirectoryListing], + ); + + const deleteEntry = useCallback( + async (path: string) => { + if (!client || !normalizedWorkspaceRoot) { + return null; + } + const payload = await client.deleteFileEntry({ cwd: normalizedWorkspaceRoot, path }); + if (payload.success) { + await requestDirectoryListing(parentExplorerPath(path), { + recordHistory: false, + setCurrentPath: false, + }); + } + return payload; + }, + [client, normalizedWorkspaceRoot, requestDirectoryListing], + ); + const selectExplorerEntry = useCallback( (path: string | null) => { updateExplorerState((state) => ({ @@ -267,6 +342,10 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor requestDirectoryListing, requestFilePreview, requestFileDownloadToken, + createEntry, + renameEntry, + duplicateEntry, + deleteEntry, selectExplorerEntry, }; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index bc3c09323..c38381b72 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -393,9 +393,32 @@ export const ar: TranslationResources = { fileActions: { openFile: "افتح الملف", copyPath: "نسخ المسار", + copyRelativePath: "نسخ المسار النسبي", + revealIn: "إظهار في {{target}}", download: "تحميل", addToChat: "إضافة إلى الدردشة", moreActions: "المزيد من الإجراءات", + newFile: "ملف جديد", + newFolder: "مجلد جديد", + collapseFolder: "طي المجلد", + rename: "إعادة تسمية", + duplicate: "تكرار", + revert: "تجاهل التغييرات", + delete: "حذف", + confirmDelete: { + fileTitle: "حذف الملف؟", + folderTitle: "حذف المجلد؟", + message: 'سيتم حذف "{{name}}" نهائيًا.', + confirm: "حذف", + cancel: "إلغاء", + }, + confirmRevert: { + title: "تجاهل التغييرات؟", + message: 'سيتم تجاهل التغييرات في "{{name}}" نهائيًا.', + confirm: "تجاهل", + cancel: "إلغاء", + failed: "فشل تجاهل التغييرات", + }, }, fileExplorer: { sort: { @@ -425,6 +448,15 @@ export const ar: TranslationResources = { }, errors: { failedToListDirectory: "فشل في سرد ​​الدليل", + createFailed: "فشل إنشاء العنصر", + renameFailed: "فشل إعادة تسمية العنصر", + duplicateFailed: "فشل تكرار العنصر", + revealFailed: "فشل إظهار العنصر", + deleteFailed: "فشل حذف العنصر", + }, + draft: { + filePlaceholder: "اسم الملف", + folderPlaceholder: "اسم المجلد", }, }, setup: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a8694a551..c6954b975 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -392,9 +392,32 @@ export const en = { fileActions: { openFile: "Open file", copyPath: "Copy path", + copyRelativePath: "Copy relative path", + revealIn: "Reveal in {{target}}", download: "Download", addToChat: "Add to chat", moreActions: "More actions", + newFile: "New file", + newFolder: "New folder", + collapseFolder: "Collapse folder", + rename: "Rename", + duplicate: "Duplicate", + revert: "Discard changes", + delete: "Delete", + confirmDelete: { + fileTitle: "Delete file?", + folderTitle: "Delete folder?", + message: '"{{name}}" will be permanently deleted.', + confirm: "Delete", + cancel: "Cancel", + }, + confirmRevert: { + title: "Discard changes?", + message: 'Changes to "{{name}}" will be permanently discarded.', + confirm: "Discard", + cancel: "Cancel", + failed: "Failed to discard changes", + }, }, fileExplorer: { sort: { @@ -424,6 +447,15 @@ export const en = { }, errors: { failedToListDirectory: "Failed to list directory", + createFailed: "Failed to create entry", + renameFailed: "Failed to rename entry", + duplicateFailed: "Failed to duplicate entry", + revealFailed: "Failed to reveal entry", + deleteFailed: "Failed to delete entry", + }, + draft: { + filePlaceholder: "File name", + folderPlaceholder: "Folder name", }, }, setup: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 244dec9d1..7b0fa3875 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -397,9 +397,32 @@ export const es: TranslationResources = { fileActions: { openFile: "Abrir archivo", copyPath: "Copiar ruta", + copyRelativePath: "Copiar ruta relativa", + revealIn: "Mostrar en {{target}}", download: "Descargar", addToChat: "Añadir al chat", moreActions: "Más acciones", + newFile: "Nuevo archivo", + newFolder: "Nueva carpeta", + collapseFolder: "Contraer carpeta", + rename: "Renombrar", + duplicate: "Duplicar", + revert: "Descartar cambios", + delete: "Eliminar", + confirmDelete: { + fileTitle: "¿Eliminar archivo?", + folderTitle: "¿Eliminar carpeta?", + message: '"{{name}}" se eliminará permanentemente.', + confirm: "Eliminar", + cancel: "Cancelar", + }, + confirmRevert: { + title: "¿Descartar cambios?", + message: 'Los cambios en "{{name}}" se descartarán permanentemente.', + confirm: "Descartar", + cancel: "Cancelar", + failed: "No se pudieron descartar los cambios", + }, }, fileExplorer: { sort: { @@ -429,6 +452,15 @@ export const es: TranslationResources = { }, errors: { failedToListDirectory: "No se pudo listar el directorio", + createFailed: "No se pudo crear la entrada", + renameFailed: "No se pudo renombrar la entrada", + duplicateFailed: "No se pudo duplicar la entrada", + revealFailed: "No se pudo mostrar la entrada", + deleteFailed: "No se pudo eliminar la entrada", + }, + draft: { + filePlaceholder: "Nombre del archivo", + folderPlaceholder: "Nombre de la carpeta", }, }, setup: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index cde09ccb2..70d7e2cc1 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -397,9 +397,32 @@ export const fr: TranslationResources = { fileActions: { openFile: "Ouvrir le fichier", copyPath: "Copier le chemin", + copyRelativePath: "Copier le chemin relatif", + revealIn: "Afficher dans {{target}}", download: "Télécharger", addToChat: "Ajouter au chat", moreActions: "Plus de propositions", + newFile: "Nouveau fichier", + newFolder: "Nouveau dossier", + collapseFolder: "Replier le dossier", + rename: "Renommer", + duplicate: "Dupliquer", + revert: "Abandonner les modifications", + delete: "Supprimer", + confirmDelete: { + fileTitle: "Supprimer le fichier ?", + folderTitle: "Supprimer le dossier ?", + message: "« {{name}} » sera définitivement supprimé.", + confirm: "Supprimer", + cancel: "Annuler", + }, + confirmRevert: { + title: "Abandonner les modifications ?", + message: "Les modifications de « {{name}} » seront définitivement abandonnées.", + confirm: "Abandonner", + cancel: "Annuler", + failed: "Échec de l'abandon des modifications", + }, }, fileExplorer: { sort: { @@ -429,6 +452,15 @@ export const fr: TranslationResources = { }, errors: { failedToListDirectory: "Échec de la liste du répertoire", + createFailed: "Échec de la création de l'entrée", + renameFailed: "Échec du renommage de l'entrée", + duplicateFailed: "Échec de la duplication de l'entrée", + revealFailed: "Échec de l'affichage de l'entrée", + deleteFailed: "Échec de la suppression de l'entrée", + }, + draft: { + filePlaceholder: "Nom du fichier", + folderPlaceholder: "Nom du dossier", }, }, setup: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 6aff6de07..85b95d85e 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -397,9 +397,32 @@ export const ja: TranslationResources = { fileActions: { openFile: "ファイルを開く", copyPath: "パスをコピー", + copyRelativePath: "相対パスをコピー", + revealIn: "{{target}}で表示", download: "ダウンロード", addToChat: "チャットに追加", moreActions: "その他のアクション", + newFile: "新規ファイル", + newFolder: "新規フォルダ", + collapseFolder: "フォルダを折りたたむ", + rename: "名前を変更", + duplicate: "複製", + revert: "変更を破棄", + delete: "削除", + confirmDelete: { + fileTitle: "ファイルを削除しますか?", + folderTitle: "フォルダを削除しますか?", + message: "「{{name}}」は完全に削除されます。", + confirm: "削除", + cancel: "キャンセル", + }, + confirmRevert: { + title: "変更を破棄しますか?", + message: "「{{name}}」への変更は完全に破棄されます。", + confirm: "破棄", + cancel: "キャンセル", + failed: "変更の破棄に失敗しました", + }, }, fileExplorer: { sort: { @@ -429,6 +452,15 @@ export const ja: TranslationResources = { }, errors: { failedToListDirectory: "ディレクトリの一覧取得に失敗しました", + createFailed: "エントリの作成に失敗しました", + renameFailed: "エントリの名前変更に失敗しました", + duplicateFailed: "エントリの複製に失敗しました", + revealFailed: "エントリの表示に失敗しました", + deleteFailed: "エントリの削除に失敗しました", + }, + draft: { + filePlaceholder: "ファイル名", + folderPlaceholder: "フォルダ名", }, }, setup: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index aa1b9a9c8..cd8969955 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -394,9 +394,32 @@ export const ko: TranslationResources = { fileActions: { openFile: "파일 열기", copyPath: "경로 복사", + copyRelativePath: "상대 경로 복사", + revealIn: "{{target}}에서 보기", download: "다운로드", addToChat: "채팅에 추가", moreActions: "추가 작업", + newFile: "새 파일", + newFolder: "새 폴더", + collapseFolder: "폴더 접기", + rename: "이름 바꾸기", + duplicate: "복제", + revert: "변경 사항 버리기", + delete: "삭제", + confirmDelete: { + fileTitle: "파일을 삭제할까요?", + folderTitle: "폴더를 삭제할까요?", + message: '"{{name}}"이(가) 영구적으로 삭제됩니다.', + confirm: "삭제", + cancel: "취소", + }, + confirmRevert: { + title: "변경 사항을 버릴까요?", + message: '"{{name}}"의 변경 사항이 영구적으로 삭제됩니다.', + confirm: "버리기", + cancel: "취소", + failed: "변경 사항을 버리지 못했습니다", + }, }, fileExplorer: { sort: { @@ -426,6 +449,15 @@ export const ko: TranslationResources = { }, errors: { failedToListDirectory: "디렉터리 목록을 불러오지 못했습니다", + createFailed: "항목을 만들지 못했습니다", + renameFailed: "항목 이름을 바꾸지 못했습니다", + duplicateFailed: "항목을 복제하지 못했습니다", + revealFailed: "항목을 표시하지 못했습니다", + deleteFailed: "항목을 삭제하지 못했습니다", + }, + draft: { + filePlaceholder: "파일 이름", + folderPlaceholder: "폴더 이름", }, }, setup: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1eaa5e994..aa9fb7a9a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -397,9 +397,32 @@ export const ptBR: TranslationResources = { fileActions: { openFile: "Abrir arquivo", copyPath: "Copiar caminho", + copyRelativePath: "Copiar caminho relativo", + revealIn: "Mostrar no {{target}}", download: "Baixar", addToChat: "Adicionar ao chat", moreActions: "Mais ações", + newFile: "Novo arquivo", + newFolder: "Nova pasta", + collapseFolder: "Recolher pasta", + rename: "Renomear", + duplicate: "Duplicar", + revert: "Descartar alterações", + delete: "Excluir", + confirmDelete: { + fileTitle: "Excluir arquivo?", + folderTitle: "Excluir pasta?", + message: '"{{name}}" será excluído permanentemente.', + confirm: "Excluir", + cancel: "Cancelar", + }, + confirmRevert: { + title: "Descartar alterações?", + message: 'As alterações em "{{name}}" serão descartadas permanentemente.', + confirm: "Descartar", + cancel: "Cancelar", + failed: "Falha ao descartar alterações", + }, }, fileExplorer: { sort: { @@ -429,6 +452,15 @@ export const ptBR: TranslationResources = { }, errors: { failedToListDirectory: "Falha ao listar diretório", + createFailed: "Falha ao criar entrada", + renameFailed: "Falha ao renomear entrada", + duplicateFailed: "Falha ao duplicar entrada", + revealFailed: "Falha ao mostrar entrada", + deleteFailed: "Falha ao excluir entrada", + }, + draft: { + filePlaceholder: "Nome do arquivo", + folderPlaceholder: "Nome da pasta", }, }, setup: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 93427bd6a..753ddafc2 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -396,9 +396,32 @@ export const ru: TranslationResources = { fileActions: { openFile: "Открыть файл", copyPath: "Копировать путь", + copyRelativePath: "Копировать относительный путь", + revealIn: "Показать в {{target}}", download: "Скачать", addToChat: "Добавить в чат", moreActions: "Дополнительные действия", + newFile: "Новый файл", + newFolder: "Новая папка", + collapseFolder: "Свернуть папку", + rename: "Переименовать", + duplicate: "Дублировать", + revert: "Отменить изменения", + delete: "Удалить", + confirmDelete: { + fileTitle: "Удалить файл?", + folderTitle: "Удалить папку?", + message: "«{{name}}» будет удалён безвозвратно.", + confirm: "Удалить", + cancel: "Отмена", + }, + confirmRevert: { + title: "Отменить изменения?", + message: "Изменения в «{{name}}» будут безвозвратно отменены.", + confirm: "Отменить", + cancel: "Отмена", + failed: "Не удалось отменить изменения", + }, }, fileExplorer: { sort: { @@ -428,6 +451,15 @@ export const ru: TranslationResources = { }, errors: { failedToListDirectory: "Не удалось указать каталог", + createFailed: "Не удалось создать элемент", + renameFailed: "Не удалось переименовать элемент", + duplicateFailed: "Не удалось дублировать элемент", + revealFailed: "Не удалось показать элемент", + deleteFailed: "Не удалось удалить элемент", + }, + draft: { + filePlaceholder: "Имя файла", + folderPlaceholder: "Имя папки", }, }, setup: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 561e64376..29167ff8e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -393,9 +393,32 @@ export const zhCN: TranslationResources = { fileActions: { openFile: "打开文件", copyPath: "复制路径", + copyRelativePath: "复制相对路径", + revealIn: "在 {{target}} 中显示", download: "下载", addToChat: "添加到聊天", moreActions: "更多操作", + newFile: "新建文件", + newFolder: "新建文件夹", + collapseFolder: "折叠文件夹", + rename: "重命名", + duplicate: "复制", + revert: "放弃更改", + delete: "删除", + confirmDelete: { + fileTitle: "删除文件?", + folderTitle: "删除文件夹?", + message: "“{{name}}”将被永久删除。", + confirm: "删除", + cancel: "取消", + }, + confirmRevert: { + title: "放弃更改?", + message: "对“{{name}}”的更改将被永久放弃。", + confirm: "放弃", + cancel: "取消", + failed: "放弃更改失败", + }, }, fileExplorer: { sort: { @@ -425,6 +448,15 @@ export const zhCN: TranslationResources = { }, errors: { failedToListDirectory: "列出目录失败", + createFailed: "创建条目失败", + renameFailed: "重命名条目失败", + duplicateFailed: "复制条目失败", + revealFailed: "显示条目失败", + deleteFailed: "删除条目失败", + }, + draft: { + filePlaceholder: "文件名", + folderPlaceholder: "文件夹名称", }, }, setup: { diff --git a/packages/app/src/utils/explorer-paths.ts b/packages/app/src/utils/explorer-paths.ts index 30f9a8f52..5f09541a5 100644 --- a/packages/app/src/utils/explorer-paths.ts +++ b/packages/app/src/utils/explorer-paths.ts @@ -32,3 +32,8 @@ export function buildAbsoluteExplorerPath({ return `${normalizedWorkspaceRoot}${separator}${segments.join(separator)}`; } + +export function parentExplorerPath(entryPath: string): string { + const separatorIndex = entryPath.lastIndexOf("/"); + return separatorIndex > 0 ? entryPath.slice(0, separatorIndex) : "."; +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 1c9b7faf6..af3073c28 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1727,6 +1727,181 @@ test("a candidate measurement that times out under a heartbeat tick does not cou expect(session.teardownCount()).toBe(0); }); +test("file context action RPCs correlate success and error responses", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_context_actions", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const createPromise = client.createFileEntry({ + cwd: "/tmp/project", + parentPath: "src", + name: "new.ts", + kind: "file", + }); + const createRequest = parseSentFrame(mock.sent.at(-1)); + expect(createRequest).toEqual({ + type: "fs.entry.create.request", + cwd: "/tmp/project", + parentPath: "src", + name: "new.ts", + kind: "file", + requestId: expect.any(String), + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "fs.entry.create.response", + payload: { + cwd: "/tmp/project", + parentPath: "src", + path: "src/new.ts", + success: true, + error: null, + requestId: createRequest.requestId, + }, + }), + ); + await expect(createPromise).resolves.toMatchObject({ + path: "src/new.ts", + success: true, + error: null, + }); + + const renamePromise = client.renameFileEntry({ + cwd: "/tmp/project", + path: "src/new.ts", + name: "existing.ts", + }); + const renameRequest = parseSentFrame(mock.sent.at(-1)); + expect(renameRequest).toMatchObject({ + type: "fs.entry.rename.request", + cwd: "/tmp/project", + path: "src/new.ts", + name: "existing.ts", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "fs.entry.rename.response", + payload: { + cwd: "/tmp/project", + path: "src/new.ts", + renamedPath: null, + success: false, + error: '"existing.ts" already exists', + requestId: renameRequest.requestId, + }, + }), + ); + await expect(renamePromise).resolves.toMatchObject({ + renamedPath: null, + success: false, + error: '"existing.ts" already exists', + }); + + const duplicatePromise = client.duplicateFileEntry({ + cwd: "/tmp/project", + path: "src/new.ts", + }); + const duplicateRequest = parseSentFrame(mock.sent.at(-1)); + expect(duplicateRequest).toMatchObject({ + type: "fs.entry.duplicate.request", + cwd: "/tmp/project", + path: "src/new.ts", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "fs.entry.duplicate.response", + payload: { + cwd: "/tmp/project", + path: "src/new.ts", + duplicatedPath: "src/new copy.ts", + success: true, + error: null, + requestId: duplicateRequest.requestId, + }, + }), + ); + await expect(duplicatePromise).resolves.toMatchObject({ + duplicatedPath: "src/new copy.ts", + success: true, + error: null, + }); + + const deletePromise = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/new.ts" }); + const deleteRequest = parseSentFrame(mock.sent.at(-1)); + expect(deleteRequest).toMatchObject({ + type: "fs.entry.delete.request", + cwd: "/tmp/project", + path: "src/new.ts", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "fs.entry.delete.response", + payload: { + cwd: "/tmp/project", + path: "src/new.ts", + success: true, + error: null, + requestId: deleteRequest.requestId, + }, + }), + ); + await expect(deletePromise).resolves.toMatchObject({ success: true, error: null }); + + const discardPromise = client.checkoutDiscardChanges("/tmp/project", { paths: ["src"] }); + const discardRequest = parseSentFrame(mock.sent.at(-1)); + expect(discardRequest).toMatchObject({ + type: "checkout.discard_changes.request", + cwd: "/tmp/project", + paths: ["src"], + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "checkout.discard_changes.response", + payload: { + cwd: "/tmp/project", + success: false, + error: { code: "NOT_GIT_REPO", message: "Not a git repository" }, + requestId: discardRequest.requestId, + }, + }), + ); + await expect(discardPromise).resolves.toMatchObject({ + success: false, + error: { code: "NOT_GIT_REPO", message: "Not a git repository" }, + }); +}); + +test("a connection loss rejects an in-flight file context action", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_context_disconnect", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const pending = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/file.ts" }); + mock.triggerClose({ code: 1006, reason: "network lost" }); + + await expect(pending).rejects.toThrow(/network lost|disconnected|closed/i); +}); + test("listDirectory sends a list file explorer request and returns directory entries", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 745212900..b8d1b2668 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -4329,6 +4329,54 @@ export class DaemonClient { return payload.result; } + async createFileEntry(input: { + cwd: string; + parentPath: string; + name: string; + kind: "file" | "directory"; + }): Promise> { + return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.create.response">({ + message: { type: "fs.entry.create.request", ...input }, + }); + } + + async renameFileEntry(input: { + cwd: string; + path: string; + name: string; + }): Promise> { + return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.rename.response">({ + message: { type: "fs.entry.rename.request", ...input }, + }); + } + + async duplicateFileEntry(input: { + cwd: string; + path: string; + }): Promise> { + return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.duplicate.response">({ + message: { type: "fs.entry.duplicate.request", ...input }, + }); + } + + async deleteFileEntry(input: { + cwd: string; + path: string; + }): Promise> { + return this.sendNamespacedCorrelatedSessionRequest<"fs.entry.delete.response">({ + message: { type: "fs.entry.delete.request", ...input }, + }); + } + + async checkoutDiscardChanges( + cwd: string, + input: { paths: string[] }, + ): Promise> { + return this.sendNamespacedCorrelatedSessionRequest<"checkout.discard_changes.response">({ + message: { type: "checkout.discard_changes.request", cwd, paths: input.paths }, + }); + } + async uploadFile(input: FileUploadInput): Promise { const bytes = asUint8Array(input.bytes); if (!bytes) { diff --git a/packages/protocol/src/messages.file-context-actions.test.ts b/packages/protocol/src/messages.file-context-actions.test.ts new file mode 100644 index 000000000..0032697b7 --- /dev/null +++ b/packages/protocol/src/messages.file-context-actions.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "vitest"; +import { + CheckoutDiscardChangesRequestSchema, + CheckoutDiscardChangesResponseSchema, + FileEntryCreateRequestSchema, + FileEntryCreateResponseSchema, + FileEntryDeleteRequestSchema, + FileEntryDeleteResponseSchema, + FileEntryDuplicateRequestSchema, + FileEntryDuplicateResponseSchema, + FileEntryRenameRequestSchema, + FileEntryRenameResponseSchema, + ServerInfoStatusPayloadSchema, +} from "./messages.js"; + +describe("file context action messages", () => { + test("keeps context-action capabilities optional and preserves advertised support", () => { + const legacy = ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "server-1", + features: {}, + }); + expect(legacy.features?.fsEntryOps).toBeUndefined(); + expect(legacy.features?.fsEntryDuplicate).toBeUndefined(); + expect(legacy.features?.checkoutDiscardChanges).toBeUndefined(); + + const current = ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "server-1", + features: { + fsEntryOps: true, + fsEntryDuplicate: true, + checkoutDiscardChanges: true, + }, + }); + expect(current.features).toMatchObject({ + fsEntryOps: true, + fsEntryDuplicate: true, + checkoutDiscardChanges: true, + }); + }); + + test("rejects discard requests without a path", () => { + expect(() => + CheckoutDiscardChangesRequestSchema.parse({ + type: "checkout.discard_changes.request", + cwd: "/workspace", + paths: [], + requestId: "discard-empty", + }), + ).toThrow(); + }); + + test("round-trips file entry create requests and responses", () => { + const request = { + type: "fs.entry.create.request", + cwd: "/workspace", + parentPath: "src/components", + name: "button.tsx", + kind: "file", + requestId: "create-1", + }; + expect(FileEntryCreateRequestSchema.parse(request)).toEqual(request); + + const response = { + type: "fs.entry.create.response", + payload: { + cwd: "/workspace", + parentPath: "src/components", + path: "src/components/button.tsx", + success: true, + error: null, + requestId: "create-1", + }, + }; + expect(FileEntryCreateResponseSchema.parse(response)).toEqual(response); + }); + + test("round-trips file entry rename requests and responses", () => { + const request = { + type: "fs.entry.rename.request", + cwd: "/workspace", + path: "src/old.ts", + name: "new.ts", + requestId: "rename-1", + }; + expect(FileEntryRenameRequestSchema.parse(request)).toEqual(request); + + const response = { + type: "fs.entry.rename.response", + payload: { + cwd: "/workspace", + path: "src/old.ts", + renamedPath: "src/new.ts", + success: true, + error: null, + requestId: "rename-1", + }, + }; + expect(FileEntryRenameResponseSchema.parse(response)).toEqual(response); + }); + + test("round-trips file entry duplicate requests and responses", () => { + const request = { + type: "fs.entry.duplicate.request", + cwd: "/workspace", + path: "src/button.tsx", + requestId: "duplicate-1", + }; + expect(FileEntryDuplicateRequestSchema.parse(request)).toEqual(request); + + const response = { + type: "fs.entry.duplicate.response", + payload: { + cwd: "/workspace", + path: "src/button.tsx", + duplicatedPath: "src/button copy.tsx", + success: true, + error: null, + requestId: "duplicate-1", + }, + }; + expect(FileEntryDuplicateResponseSchema.parse(response)).toEqual(response); + }); + + test("round-trips file entry delete requests and responses", () => { + const request = { + type: "fs.entry.delete.request", + cwd: "/workspace", + path: "src/old.ts", + requestId: "delete-1", + }; + expect(FileEntryDeleteRequestSchema.parse(request)).toEqual(request); + + const response = { + type: "fs.entry.delete.response", + payload: { + cwd: "/workspace", + path: "src/old.ts", + success: false, + error: "File or folder no longer exists", + requestId: "delete-1", + }, + }; + expect(FileEntryDeleteResponseSchema.parse(response)).toEqual(response); + }); + + test("round-trips checkout discard requests and responses", () => { + const request = { + type: "checkout.discard_changes.request", + cwd: "/workspace", + paths: ["src", "README.md"], + requestId: "discard-1", + }; + expect(CheckoutDiscardChangesRequestSchema.parse(request)).toEqual(request); + + const response = { + type: "checkout.discard_changes.response", + payload: { + cwd: "/workspace", + success: false, + error: { code: "UNKNOWN", message: "discard failed" }, + requestId: "discard-1", + }, + }; + expect(CheckoutDiscardChangesResponseSchema.parse(response)).toEqual(response); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4421080c9..aa297cc94 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1777,6 +1777,13 @@ export const CheckoutRefreshRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutDiscardChangesRequestSchema = z.object({ + type: z.literal("checkout.discard_changes.request"), + cwd: z.string(), + paths: z.array(z.string()).min(1), + requestId: z.string(), +}); + export const CheckoutPrCreateRequestSchema = z.object({ type: z.literal("checkout_pr_create_request"), cwd: z.string(), @@ -2204,6 +2211,8 @@ const DiffHunkSchema = z.object({ const ParsedDiffFileSchema = z.object({ path: z.string(), + // COMPAT(diffOldPath): added in v0.3.0, remove gate after 2027-02-09. + oldPath: z.string().optional(), isNew: z.boolean(), isDeleted: z.boolean(), additions: z.number(), @@ -2291,6 +2300,37 @@ export const FileWriteRequestSchema = z.object({ requestId: z.string(), }); +export const FileEntryCreateRequestSchema = z.object({ + type: z.literal("fs.entry.create.request"), + cwd: z.string(), + parentPath: z.string(), + name: z.string(), + kind: z.enum(["file", "directory"]), + requestId: z.string(), +}); + +export const FileEntryRenameRequestSchema = z.object({ + type: z.literal("fs.entry.rename.request"), + cwd: z.string(), + path: z.string(), + name: z.string(), + requestId: z.string(), +}); + +export const FileEntryDuplicateRequestSchema = z.object({ + type: z.literal("fs.entry.duplicate.request"), + cwd: z.string(), + path: z.string(), + requestId: z.string(), +}); + +export const FileEntryDeleteRequestSchema = z.object({ + type: z.literal("fs.entry.delete.request"), + cwd: z.string(), + path: z.string(), + requestId: z.string(), +}); + export const ProjectIconRequestSchema = z.object({ type: z.literal("project_icon_request"), cwd: z.string(), @@ -2661,6 +2701,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPullRequestSchema, CheckoutPushRequestSchema, CheckoutRefreshRequestSchema, + CheckoutDiscardChangesRequestSchema, CheckoutPrCreateRequestSchema, CheckoutPrMergeRequestSchema, CheckoutForgeSetAutoMergeRequestSchema, @@ -2699,6 +2740,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ FileSubscribeRequestSchema, FileUnsubscribeRequestSchema, FileWriteRequestSchema, + FileEntryCreateRequestSchema, + FileEntryRenameRequestSchema, + FileEntryDuplicateRequestSchema, + FileEntryDeleteRequestSchema, ProjectIconRequestSchema, ProjectIconGetRequestSchema, FileDownloadTokenRequestSchema, @@ -3004,6 +3049,12 @@ export const ServerInfoStatusPayloadSchema = z workspaceScriptManagement: z.boolean().optional(), // COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20. projectCustomIcon: z.boolean().optional(), + // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. + fsEntryOps: z.boolean().optional(), + // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. + fsEntryDuplicate: z.boolean().optional(), + // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. + checkoutDiscardChanges: z.boolean().optional(), }) .optional(), }) @@ -4493,6 +4544,16 @@ export const CheckoutGithubSetAutoMergeResponseSchema = z.object({ }), }); +export const CheckoutDiscardChangesResponseSchema = z.object({ + type: z.literal("checkout.discard_changes.response"), + payload: z.object({ + cwd: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const CheckoutCommitsListResponseSchema = z.object({ type: z.literal("checkout.commits.list.response"), payload: z.object({ @@ -4957,6 +5018,53 @@ export const FileWriteResponseSchema = z.object({ }), }); +export const FileEntryCreateResponseSchema = z.object({ + type: z.literal("fs.entry.create.response"), + payload: z.object({ + cwd: z.string(), + parentPath: z.string(), + path: z.string().nullable(), + success: z.boolean(), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + +export const FileEntryRenameResponseSchema = z.object({ + type: z.literal("fs.entry.rename.response"), + payload: z.object({ + cwd: z.string(), + path: z.string(), + renamedPath: z.string().nullable(), + success: z.boolean(), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + +export const FileEntryDuplicateResponseSchema = z.object({ + type: z.literal("fs.entry.duplicate.response"), + payload: z.object({ + cwd: z.string(), + path: z.string(), + duplicatedPath: z.string().nullable(), + success: z.boolean(), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + +export const FileEntryDeleteResponseSchema = z.object({ + type: z.literal("fs.entry.delete.response"), + payload: z.object({ + cwd: z.string(), + path: z.string(), + success: z.boolean(), + error: z.string().nullable(), + requestId: z.string(), + }), +}); + export const FileUpdateSchema = z.object({ type: z.literal("fs.file.update"), payload: z.object({ @@ -5549,6 +5657,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPullResponseSchema, CheckoutPushResponseSchema, CheckoutRefreshResponseSchema, + CheckoutDiscardChangesResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrMergeResponseSchema, CheckoutForgeSetAutoMergeResponseSchema, @@ -5576,6 +5685,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FileSubscribeResponseSchema, FileUnsubscribeResponseSchema, FileWriteResponseSchema, + FileEntryCreateResponseSchema, + FileEntryRenameResponseSchema, + FileEntryDuplicateResponseSchema, + FileEntryDeleteResponseSchema, FileUpdateSchema, ProjectIconResponseSchema, ProjectIconGetResponseSchema, @@ -5896,6 +6009,8 @@ export type CheckoutPushRequest = z.infer; export type CheckoutPushResponse = z.infer; export type CheckoutRefreshRequest = z.infer; export type CheckoutRefreshResponse = z.infer; +export type CheckoutDiscardChangesRequest = z.infer; +export type CheckoutDiscardChangesResponse = z.infer; export type CheckoutCommitFile = z.infer; export type CheckoutCommit = z.infer; export type CheckoutCommitsListRequest = z.infer; @@ -5999,6 +6114,14 @@ export type FileUnsubscribeRequest = z.infer; export type FileWriteRequest = z.infer; export type FileWriteResponse = z.infer; +export type FileEntryCreateRequest = z.infer; +export type FileEntryCreateResponse = z.infer; +export type FileEntryRenameRequest = z.infer; +export type FileEntryRenameResponse = z.infer; +export type FileEntryDuplicateRequest = z.infer; +export type FileEntryDuplicateResponse = z.infer; +export type FileEntryDeleteRequest = z.infer; +export type FileEntryDeleteResponse = z.infer; export type FileWriteResult = z.infer; export type FileUpdate = z.infer; export type ProjectIconRequest = z.infer; diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index 07f451e83..a191a5fab 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -1,10 +1,25 @@ -import { appendFile, chmod, mkdtemp, rm, stat, truncate, writeFile } from "node:fs/promises"; +import { + appendFile, + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + truncate, + writeFile, +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { runGitCommand } from "../../utils/run-git-command.js"; import { + createExplorerEntry, + deleteExplorerEntry, + duplicateExplorerEntry, getExplorerFileVersion, readExplorerFile, + renameExplorerEntry, streamExplorerFile, writeExplorerFile, } from "./service.js"; @@ -373,4 +388,202 @@ describe("file explorer service", () => { await rm(root, { recursive: true, force: true }); } }); + + it("creates files and directories, refusing duplicates and separator names", async () => { + const root = await createTempDir("paseo-entry-create-"); + try { + const file = await createExplorerEntry({ + root, + parentPath: ".", + name: "notes.txt", + kind: "file", + }); + expect(file).toEqual({ status: "ok", path: "notes.txt" }); + expect((await stat(path.join(root, "notes.txt"))).isFile()).toBe(true); + + const dir = await createExplorerEntry({ + root, + parentPath: ".", + name: "docs", + kind: "directory", + }); + expect(dir).toEqual({ status: "ok", path: "docs" }); + const nested = await createExplorerEntry({ + root, + parentPath: "docs", + name: "guide.md", + kind: "file", + }); + expect(nested).toEqual({ status: "ok", path: "docs/guide.md" }); + + const duplicate = await createExplorerEntry({ + root, + parentPath: ".", + name: "notes.txt", + kind: "file", + }); + expect(duplicate.status).toBe("error"); + + const traversal = await createExplorerEntry({ + root, + parentPath: ".", + name: "../escape", + kind: "directory", + }); + expect(traversal.status).toBe("error"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("duplicates files and folders with collision-free sibling names", async () => { + const root = await createTempDir("paseo-entry-duplicate-"); + try { + await writeFile(path.join(root, "notes.txt"), "original", "utf8"); + const firstFileCopy = await duplicateExplorerEntry({ + root, + relativePath: "notes.txt", + }); + expect(firstFileCopy).toEqual({ status: "ok", path: "notes copy.txt" }); + expect(await readFile(path.join(root, "notes copy.txt"), "utf8")).toBe("original"); + + const secondFileCopy = await duplicateExplorerEntry({ + root, + relativePath: "notes.txt", + }); + expect(secondFileCopy).toEqual({ status: "ok", path: "notes copy 2.txt" }); + + await mkdir(path.join(root, "docs")); + await writeFile(path.join(root, "docs", "guide.md"), "guide", "utf8"); + const folderCopy = await duplicateExplorerEntry({ root, relativePath: "docs" }); + expect(folderCopy).toEqual({ status: "ok", path: "docs copy" }); + expect(await readFile(path.join(root, "docs copy", "guide.md"), "utf8")).toBe("guide"); + + await expect(duplicateExplorerEntry({ root, relativePath: "." })).resolves.toMatchObject({ + status: "error", + }); + await expect(duplicateExplorerEntry({ root, relativePath: "missing.txt" })).resolves.toEqual({ + status: "error", + error: "File or folder no longer exists", + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("renames tracked entries with Git and untracked entries on the filesystem", async () => { + const root = await createTempDir("paseo-entry-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "tracked.txt"), "tracked", "utf8"); + await mkdir(path.join(root, "tracked-folder")); + await writeFile(path.join(root, "tracked-folder", "inside.txt"), "tracked", "utf8"); + await runGitCommand(["add", "."], { cwd: root }); + await runGitCommand( + ["-c", "user.name=Paseo Test", "-c", "user.email=test@paseo.local", "commit", "-m", "base"], + { cwd: root }, + ); + + const tracked = await renameExplorerEntry({ + root, + relativePath: "tracked.txt", + name: "renamed.txt", + }); + expect(tracked).toEqual({ status: "ok", path: "renamed.txt" }); + expect((await runGitCommand(["status", "--short"], { cwd: root })).stdout.trim()).toBe( + "R tracked.txt -> renamed.txt", + ); + + const trackedFolder = await renameExplorerEntry({ + root, + relativePath: "tracked-folder", + name: "renamed-folder", + }); + expect(trackedFolder).toEqual({ status: "ok", path: "renamed-folder" }); + const gitStatus = (await runGitCommand(["status", "--short"], { cwd: root })).stdout; + expect(gitStatus).toContain("tracked-folder/inside.txt -> renamed-folder/inside.txt"); + + await writeFile(path.join(root, "untracked.txt"), "untracked", "utf8"); + const untracked = await renameExplorerEntry({ + root, + relativePath: "untracked.txt", + name: "moved.txt", + }); + expect(untracked).toEqual({ status: "ok", path: "moved.txt" }); + expect((await stat(path.join(root, "moved.txt"))).isFile()).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("supports case-only renames for tracked and untracked files", async () => { + const root = await createTempDir("paseo-entry-case-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "Tracked.txt"), "tracked", "utf8"); + await writeFile(path.join(root, "Loose.txt"), "untracked", "utf8"); + await runGitCommand(["add", "Tracked.txt"], { cwd: root }); + await runGitCommand( + ["-c", "user.name=Paseo Test", "-c", "user.email=test@paseo.local", "commit", "-m", "base"], + { cwd: root }, + ); + + await expect( + renameExplorerEntry({ root, relativePath: "Tracked.txt", name: "tracked.txt" }), + ).resolves.toEqual({ status: "ok", path: "tracked.txt" }); + expect((await stat(path.join(root, "tracked.txt"))).isFile()).toBe(true); + + await expect( + renameExplorerEntry({ root, relativePath: "Loose.txt", name: "loose.txt" }), + ).resolves.toEqual({ status: "ok", path: "loose.txt" }); + expect((await stat(path.join(root, "loose.txt"))).isFile()).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("refuses invalid renames, existing targets, and the workspace root", async () => { + const root = await createTempDir("paseo-entry-rename-errors-"); + try { + await writeFile(path.join(root, "source.txt"), "source", "utf8"); + await writeFile(path.join(root, "existing.txt"), "existing", "utf8"); + + await expect( + renameExplorerEntry({ root, relativePath: "source.txt", name: "existing.txt" }), + ).resolves.toEqual({ status: "error", error: '"existing.txt" already exists' }); + await expect( + renameExplorerEntry({ root, relativePath: "source.txt", name: "../outside.txt" }), + ).resolves.toEqual({ status: "error", error: "Name cannot contain path separators" }); + await expect( + renameExplorerEntry({ root, relativePath: ".", name: "renamed-root" }), + ).resolves.toEqual({ status: "error", error: "Cannot rename the workspace root" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("deletes files and directories but never the workspace root or outside paths", async () => { + const root = await createTempDir("paseo-entry-delete-"); + try { + await writeFile(path.join(root, "doomed.txt"), "bye", "utf8"); + const removedFile = await deleteExplorerEntry({ root, relativePath: "doomed.txt" }); + expect(removedFile).toEqual({ status: "ok", path: "doomed.txt" }); + await expect(stat(path.join(root, "doomed.txt"))).rejects.toThrow(); + + await createExplorerEntry({ root, parentPath: ".", name: "nested", kind: "directory" }); + await writeFile(path.join(root, "nested", "inner.txt"), "hi", "utf8"); + const removedDir = await deleteExplorerEntry({ root, relativePath: "nested" }); + expect(removedDir).toEqual({ status: "ok", path: "nested" }); + await expect(stat(path.join(root, "nested"))).rejects.toThrow(); + + const rootDelete = await deleteExplorerEntry({ root, relativePath: "." }); + expect(rootDelete.status).toBe("error"); + + await expect( + deleteExplorerEntry({ root, relativePath: "../outside" }), + ).resolves.toMatchObject({ status: "error" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index a33007b59..dbc319c10 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -3,6 +3,7 @@ import type { FileHandle } from "fs/promises"; import path from "path"; import { randomUUID } from "crypto"; import { expandUserPath, resolvePathFromBase } from "../path-utils.js"; +import { runGitCommand } from "../../utils/run-git-command.js"; export type ExplorerEntryKind = "file" | "directory"; export type ExplorerFileKind = "text" | "image" | "binary"; @@ -572,6 +573,215 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa } } +export interface ExplorerCreateEntryParams { + root: string; + parentPath: string; + name: string; + kind: ExplorerEntryKind; +} + +export interface ExplorerRenameEntryParams { + root: string; + relativePath: string; + name: string; +} + +export type ExplorerEntryMutationResult = + | { status: "ok"; path: string } + | { status: "error"; error: string }; + +export async function createExplorerEntry({ + root, + parentPath, + name, + kind, +}: ExplorerCreateEntryParams): Promise { + const trimmedName = name.trim(); + if (!trimmedName || trimmedName === "." || trimmedName === "..") { + return { status: "error", error: "Invalid name" }; + } + if (trimmedName.includes("/") || trimmedName.includes("\\")) { + return { status: "error", error: "Name cannot contain path separators" }; + } + + try { + const parent = await resolveScopedPath({ root, relativePath: parentPath }); + const parentStats = await fs.stat(parent.resolvedPath); + if (!parentStats.isDirectory()) { + return { status: "error", error: "Parent path is not a directory" }; + } + const targetPath = path.join(parent.resolvedPath, trimmedName); + if (kind === "directory") { + await fs.mkdir(targetPath); + } else { + const handle = await fs.open(targetPath, "wx", 0o644); + await handle.close(); + } + return { + status: "ok", + path: normalizeRelativePath({ + root, + targetPath: path.join(parent.requestedPath, trimmedName), + }), + }; + } catch (error) { + if (isEntryExistsError(error)) { + return { status: "error", error: `"${trimmedName}" already exists` }; + } + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function duplicateExplorerEntry({ + root, + relativePath, +}: ReadFileParams): Promise { + try { + const source = await resolveScopedPath({ root, relativePath }); + const realRoot = await fs.realpath(expandUserPath(root)); + if (source.resolvedPath === realRoot) { + return { status: "error", error: "Cannot duplicate the workspace root" }; + } + + const stats = await fs.lstat(source.requestedPath); + const sourceName = path.basename(source.requestedPath); + const extension = stats.isDirectory() ? "" : path.extname(sourceName); + const baseName = extension ? sourceName.slice(0, -extension.length) : sourceName; + let targetPath = ""; + for (let copyNumber = 1; ; copyNumber += 1) { + const suffix = copyNumber === 1 ? " copy" : ` copy ${copyNumber}`; + targetPath = path.join( + path.dirname(source.requestedPath), + `${baseName}${suffix}${extension}`, + ); + try { + await fs.lstat(targetPath); + } catch (error) { + if (isMissingEntryError(error)) { + break; + } + throw error; + } + } + + await fs.cp(source.requestedPath, targetPath, { + recursive: stats.isDirectory(), + errorOnExist: true, + force: false, + preserveTimestamps: true, + }); + return { status: "ok", path: normalizeRelativePath({ root, targetPath }) }; + } catch (error) { + if (isMissingEntryError(error)) { + return { status: "error", error: "File or folder no longer exists" }; + } + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function renameExplorerEntry({ + root, + relativePath, + name, +}: ExplorerRenameEntryParams): Promise { + const trimmedName = name.trim(); + if (!trimmedName || trimmedName === "." || trimmedName === "..") { + return { status: "error", error: "Invalid name" }; + } + if (trimmedName.includes("/") || trimmedName.includes("\\")) { + return { status: "error", error: "Name cannot contain path separators" }; + } + + try { + const source = await resolveScopedPath({ root, relativePath }); + const realRoot = await fs.realpath(expandUserPath(root)); + if (source.resolvedPath === realRoot) { + return { status: "error", error: "Cannot rename the workspace root" }; + } + + const targetPath = path.join(path.dirname(source.requestedPath), trimmedName); + const sourceStats = await fs.lstat(source.requestedPath); + const targetStats = await fs.lstat(targetPath).catch((error: unknown) => { + if (isMissingEntryError(error)) { + return null; + } + throw error; + }); + if ( + targetStats && + (targetStats.dev !== sourceStats.dev || targetStats.ino !== sourceStats.ino) + ) { + return { status: "error", error: `"${trimmedName}" already exists` }; + } + + const sourcePath = normalizeRelativePath({ root, targetPath: source.requestedPath }); + const renamedPath = normalizeRelativePath({ root, targetPath }); + if (sourcePath === renamedPath) { + return { status: "ok", path: renamedPath }; + } + const repository = await runGitCommand(["rev-parse", "--is-inside-work-tree"], { + cwd: realRoot, + acceptExitCodes: [0, 128], + }); + const tracked = + repository.exitCode === 0 + ? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot }) + : null; + if (tracked?.stdout) { + await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot }); + } else { + await fs.rename(source.requestedPath, targetPath); + } + return { status: "ok", path: renamedPath }; + } catch (error) { + if (isEntryExistsError(error)) { + return { status: "error", error: `"${trimmedName}" already exists` }; + } + if (isMissingEntryError(error)) { + return { status: "error", error: "File or folder no longer exists" }; + } + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } +} + +export async function deleteExplorerEntry({ + root, + relativePath, +}: ReadFileParams): Promise { + try { + const scoped = await resolveScopedPath({ root, relativePath }); + const realRoot = await fs.realpath(expandUserPath(root)); + if (scoped.resolvedPath === realRoot) { + return { status: "error", error: "Cannot delete the workspace root" }; + } + // Remove the requested path, not the realpath: deleting a symlink must + // remove the link, never its target. lstat keeps the same guarantee. + const stats = await fs.lstat(scoped.requestedPath); + await fs.rm(scoped.requestedPath, { + recursive: stats.isDirectory(), + force: false, + }); + return { + status: "ok", + path: normalizeRelativePath({ root, targetPath: scoped.requestedPath }), + }; + } catch (error) { + if (isMissingEntryError(error)) { + return { status: "error", error: "File or folder no longer exists" }; + } + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } +} + +function isEntryExistsError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "EEXIST" + ); +} + async function resolveScopedPath({ root, relativePath = ".", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f67e14b12..30a90f552 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2106,6 +2106,8 @@ export class Session { return this.checkoutSession.handleCheckoutPushRequest(msg); case "checkout.refresh.request": return this.checkoutSession.handleRefreshRequest(msg); + case "checkout.discard_changes.request": + return this.checkoutSession.handleCheckoutDiscardChangesRequest(msg); case "checkout_pr_create_request": return this.checkoutSession.handleCheckoutPrCreateRequest(msg); case "checkout_pr_merge_request": @@ -2196,6 +2198,14 @@ export class Session { return undefined; case "fs.file.write.request": return this.workspaceFilesSession.handleFileWriteRequest(msg); + case "fs.entry.create.request": + return this.workspaceFilesSession.handleFileEntryCreateRequest(msg); + case "fs.entry.rename.request": + return this.workspaceFilesSession.handleFileEntryRenameRequest(msg); + case "fs.entry.duplicate.request": + return this.workspaceFilesSession.handleFileEntryDuplicateRequest(msg); + case "fs.entry.delete.request": + return this.workspaceFilesSession.handleFileEntryDeleteRequest(msg); case "project_icon_request": return this.workspaceFilesSession.handleProjectIconRequest(msg); case "project.icon.get.request": diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index bebc5e7a9..94e80cc32 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -1,3 +1,7 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import pino from "pino"; import { @@ -439,6 +443,88 @@ describe("CheckoutSession", () => { }); }); + describe("discard changes", () => { + it("discards the paths, notifies git mutation, refreshes diffs, and confirms success", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "checkout-session-discard-")); + const cwd = realpathSync(tempDir); + try { + execFileSync("git", ["init", "-q"], { cwd }); + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd }); + execFileSync("git", ["config", "user.name", "Test User"], { cwd }); + writeFileSync(join(cwd, "file.txt"), "original\n"); + execFileSync("git", ["add", "file.txt"], { cwd }); + execFileSync("git", ["commit", "-qm", "initial"], { cwd }); + writeFileSync(join(cwd, "file.txt"), "changed\n"); + + const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ + cwd: "", + files: [], + error: null, + }); + const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ diff: subscriber }); + + await checkout.handleCheckoutDiscardChangesRequest({ + type: "checkout.discard_changes.request", + cwd, + paths: ["file.txt"], + requestId: "discard-1", + }); + + expect(readFileSync(join(cwd, "file.txt"), "utf8").replaceAll("\r\n", "\n")).toBe( + "original\n", + ); + expect(gitMutationCalls.notifyGitMutation).toEqual([ + { cwd, reason: "discard-changes", options: undefined }, + ]); + expect(refreshedCwds).toEqual([cwd]); + expect(emitted).toEqual([ + { + type: "checkout.discard_changes.response", + payload: { cwd, success: true, error: null, requestId: "discard-1" }, + }, + ]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("returns the checkout error without notifying git or refreshing diffs", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "checkout-session-discard-error-")); + const cwd = realpathSync(tempDir); + try { + const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ + cwd: "", + files: [], + error: null, + }); + const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({ diff: subscriber }); + + await checkout.handleCheckoutDiscardChangesRequest({ + type: "checkout.discard_changes.request", + cwd, + paths: ["file.txt"], + requestId: "discard-error", + }); + + expect(gitMutationCalls.notifyGitMutation).toEqual([]); + expect(refreshedCwds).toEqual([]); + expect(emitted).toEqual([ + { + type: "checkout.discard_changes.response", + payload: { + cwd, + success: false, + error: { code: "NOT_GIT_REPO", message: `Not a git repository: ${cwd}` }, + requestId: "discard-error", + }, + }, + ]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + describe("diff subscriptions", () => { it("opens a subscription, streams updates tagged with the id, and tears down on unsubscribe", async () => { const { subscriber, subscriptions } = createFakeDiffSubscriber({ diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 091ee620f..e349f8edd 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -42,6 +42,7 @@ import type { import { commitChanges, createPullRequest, + discardChanges, forgeAuthStateFromError, isForgeAuthError, mergeFromBase, @@ -605,6 +606,26 @@ export class CheckoutSession { } } + async handleCheckoutDiscardChangesRequest( + msg: Extract, + ): Promise { + const { cwd, paths, requestId } = msg; + try { + await discardChanges(cwd, paths); + await this.gitMutation.notifyGitMutation(cwd, "discard-changes"); + this.scheduleDiffRefresh(cwd); + this.host.emit({ + type: "checkout.discard_changes.response", + payload: { cwd, success: true, error: null, requestId }, + }); + } catch (error) { + this.host.emit({ + type: "checkout.discard_changes.response", + payload: { cwd, success: false, error: toCheckoutError(error), requestId }, + }); + } + } + async handleStashSaveRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts index 0b09c8774..d176e03bf 100644 --- a/packages/server/src/server/session/files/workspace-files-session.test.ts +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -1,4 +1,11 @@ -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; @@ -74,6 +81,227 @@ function uploadFrame(args: Parameters[0]): FileT } describe("WorkspaceFilesSession", () => { + test("creates an entry and emits the complete success response", async () => { + const cwd = makeDir("workspace-files-create-"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryCreateRequest({ + type: "fs.entry.create.request", + cwd, + parentPath: ".", + name: "notes.txt", + kind: "file", + requestId: "req-create", + }); + + expect(existsSync(join(cwd, "notes.txt"))).toBe(true); + expect(emitted).toEqual([ + { + type: "fs.entry.create.response", + payload: { + cwd, + parentPath: ".", + path: "notes.txt", + success: true, + error: null, + requestId: "req-create", + }, + }, + ]); + }); + + test("passes entry creation errors through in the response", async () => { + const cwd = makeDir("workspace-files-create-error-"); + writeFileSync(join(cwd, "notes.txt"), "existing"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryCreateRequest({ + type: "fs.entry.create.request", + cwd, + parentPath: ".", + name: "notes.txt", + kind: "file", + requestId: "req-create-error", + }); + + expect(emitted).toEqual([ + { + type: "fs.entry.create.response", + payload: { + cwd, + parentPath: ".", + path: null, + success: false, + error: '"notes.txt" already exists', + requestId: "req-create-error", + }, + }, + ]); + }); + + test("renames an entry and emits the resulting path", async () => { + const cwd = makeDir("workspace-files-rename-"); + writeFileSync(join(cwd, "notes.txt"), "rename me"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryRenameRequest({ + type: "fs.entry.rename.request", + cwd, + path: "notes.txt", + name: "renamed.txt", + requestId: "req-rename", + }); + + expect(existsSync(join(cwd, "notes.txt"))).toBe(false); + expect(existsSync(join(cwd, "renamed.txt"))).toBe(true); + expect(emitted).toEqual([ + { + type: "fs.entry.rename.response", + payload: { + cwd, + path: "notes.txt", + renamedPath: "renamed.txt", + success: true, + error: null, + requestId: "req-rename", + }, + }, + ]); + }); + + test("passes entry rename errors through in the response", async () => { + const cwd = makeDir("workspace-files-rename-error-"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryRenameRequest({ + type: "fs.entry.rename.request", + cwd, + path: "missing.txt", + name: "renamed.txt", + requestId: "req-rename-error", + }); + + expect(emitted).toEqual([ + { + type: "fs.entry.rename.response", + payload: { + cwd, + path: "missing.txt", + renamedPath: null, + success: false, + error: "File or folder no longer exists", + requestId: "req-rename-error", + }, + }, + ]); + }); + + test("duplicates an entry and emits the resulting path", async () => { + const cwd = makeDir("workspace-files-duplicate-"); + writeFileSync(join(cwd, "notes.txt"), "duplicate me"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryDuplicateRequest({ + type: "fs.entry.duplicate.request", + cwd, + path: "notes.txt", + requestId: "req-duplicate", + }); + + expect(readFileSync(join(cwd, "notes copy.txt"), "utf8")).toBe("duplicate me"); + expect(emitted).toEqual([ + { + type: "fs.entry.duplicate.response", + payload: { + cwd, + path: "notes.txt", + duplicatedPath: "notes copy.txt", + success: true, + error: null, + requestId: "req-duplicate", + }, + }, + ]); + }); + + test("passes entry duplication errors through in the response", async () => { + const cwd = makeDir("workspace-files-duplicate-error-"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryDuplicateRequest({ + type: "fs.entry.duplicate.request", + cwd, + path: "missing.txt", + requestId: "req-duplicate-error", + }); + + expect(emitted).toEqual([ + { + type: "fs.entry.duplicate.response", + payload: { + cwd, + path: "missing.txt", + duplicatedPath: null, + success: false, + error: "File or folder no longer exists", + requestId: "req-duplicate-error", + }, + }, + ]); + }); + + test("deletes an entry and emits the complete success response", async () => { + const cwd = makeDir("workspace-files-delete-"); + writeFileSync(join(cwd, "notes.txt"), "delete me"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryDeleteRequest({ + type: "fs.entry.delete.request", + cwd, + path: "notes.txt", + requestId: "req-delete", + }); + + expect(existsSync(join(cwd, "notes.txt"))).toBe(false); + expect(emitted).toEqual([ + { + type: "fs.entry.delete.response", + payload: { + cwd, + path: "notes.txt", + success: true, + error: null, + requestId: "req-delete", + }, + }, + ]); + }); + + test("passes entry deletion errors through in the response", async () => { + const cwd = makeDir("workspace-files-delete-error-"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileEntryDeleteRequest({ + type: "fs.entry.delete.request", + cwd, + path: "missing.txt", + requestId: "req-delete-error", + }); + + expect(emitted).toEqual([ + { + type: "fs.entry.delete.response", + payload: { + cwd, + path: "missing.txt", + success: false, + error: "File or folder no longer exists", + requestId: "req-delete-error", + }, + }, + ]); + }); + test("lists directory entries", async () => { const cwd = makeDir("workspace-files-list-"); writeFileSync(join(cwd, "a.txt"), "alpha"); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index dd7d7d04d..c74de3aca 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -7,6 +7,10 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import type { FileDownloadTokenRequest, + FileEntryCreateRequest, + FileEntryDeleteRequest, + FileEntryDuplicateRequest, + FileEntryRenameRequest, FileExplorerRequest, FileUploadRequest, FileSubscribeRequest, @@ -18,9 +22,13 @@ import type { import { FileUploadStore } from "../../file-upload/index.js"; import type { DownloadTokenStore } from "../../file-download/token-store.js"; import { + createExplorerEntry, + deleteExplorerEntry, + duplicateExplorerEntry, getDownloadableFileInfo, listDirectoryEntries, readExplorerFile, + renameExplorerEntry, streamExplorerFile, writeExplorerFile, } from "../../file-explorer/service.js"; @@ -131,6 +139,80 @@ export class WorkspaceFilesSession { }); } + async handleFileEntryCreateRequest(request: FileEntryCreateRequest): Promise { + const result = await createExplorerEntry({ + root: request.cwd, + parentPath: request.parentPath, + name: request.name, + kind: request.kind, + }); + this.host.emit({ + type: "fs.entry.create.response", + payload: { + cwd: request.cwd, + parentPath: request.parentPath, + path: result.status === "ok" ? result.path : null, + success: result.status === "ok", + error: result.status === "ok" ? null : result.error, + requestId: request.requestId, + }, + }); + } + + async handleFileEntryRenameRequest(request: FileEntryRenameRequest): Promise { + const result = await renameExplorerEntry({ + root: request.cwd, + relativePath: request.path, + name: request.name, + }); + this.host.emit({ + type: "fs.entry.rename.response", + payload: { + cwd: request.cwd, + path: request.path, + renamedPath: result.status === "ok" ? result.path : null, + success: result.status === "ok", + error: result.status === "ok" ? null : result.error, + requestId: request.requestId, + }, + }); + } + + async handleFileEntryDuplicateRequest(request: FileEntryDuplicateRequest): Promise { + const result = await duplicateExplorerEntry({ + root: request.cwd, + relativePath: request.path, + }); + this.host.emit({ + type: "fs.entry.duplicate.response", + payload: { + cwd: request.cwd, + path: request.path, + duplicatedPath: result.status === "ok" ? result.path : null, + success: result.status === "ok", + error: result.status === "ok" ? null : result.error, + requestId: request.requestId, + }, + }); + } + + async handleFileEntryDeleteRequest(request: FileEntryDeleteRequest): Promise { + const result = await deleteExplorerEntry({ + root: request.cwd, + relativePath: request.path, + }); + this.host.emit({ + type: "fs.entry.delete.response", + payload: { + cwd: request.cwd, + path: request.path, + success: result.status === "ok", + error: result.status === "ok" ? null : result.error, + requestId: request.requestId, + }, + }); + } + dispose(): void { for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe(); this.fileSubscriptions.clear(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cb01f18dc..ff39bedae 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1638,6 +1638,12 @@ export class VoiceAssistantWebSocketServer { workspaceScriptManagement: true, // COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20. projectCustomIcon: true, + // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. + fsEntryOps: true, + // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. + fsEntryDuplicate: true, + // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. + checkoutDiscardChanges: true, }, }; } diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 0c6978407..ba5a7fde1 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -20,6 +20,7 @@ import { __resetPullRequestStatusCacheForTests, __setPullRequestStatusCacheTtlForTests, commitAll, + discardChanges, CHECKOUT_DIFF_MAX_STRUCTURED_BYTES, createPullRequest, getCachedCheckoutShortstat, @@ -108,6 +109,10 @@ function initRepo(): { tempDir: string; repoDir: string } { return { tempDir, repoDir }; } +function readTextFile(path: string): string { + return readFileSync(path, "utf8").replaceAll("\r\n", "\n"); +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -516,6 +521,19 @@ describe("checkout git utilities", () => { expect(message).toBe("update file"); }); + it("includes both paths for a staged rename in structured diffs", async () => { + execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir }); + + const diff = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + + expect(diff.structured).toContainEqual( + expect.objectContaining({ path: "renamed.txt", oldPath: "file.txt" }), + ); + }); + it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => { setupRemoteTrackingMain(repoDir, tempDir); @@ -3698,3 +3716,132 @@ const x = 1; }); }); }); + +describe("discardChanges", () => { + it("discards staged and unstaged modifications, deletions, and untracked files", async () => { + const { tempDir, repoDir } = initRepo(); + try { + writeFileSync(join(repoDir, "file.txt"), "changed\n"); + writeFileSync(join(repoDir, "staged.txt"), "staged\n"); + execFileSync("git", ["add", "staged.txt"], { cwd: repoDir }); + mkdirSync(join(repoDir, "junk"), { recursive: true }); + writeFileSync(join(repoDir, "junk", "scratch.txt"), "scratch\n"); + + await discardChanges(repoDir, ["file.txt", "staged.txt", "junk"]); + + expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n"); + expect(existsSync(join(repoDir, "staged.txt"))).toBe(false); + expect(existsSync(join(repoDir, "junk"))).toBe(false); + const status = execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }) + .toString() + .trim(); + expect(status).toBe(""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("restores both sides of a staged rename", async () => { + const { tempDir, repoDir } = initRepo(); + try { + execFileSync("git", ["mv", "file.txt", "renamed.txt"], { cwd: repoDir }); + + await discardChanges(repoDir, ["file.txt", "renamed.txt"]); + + expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n"); + expect(existsSync(join(repoDir, "renamed.txt"))).toBe(false); + expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe(""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("unstages and removes files in a repository with an unborn HEAD", async () => { + const tempDir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-git-unborn-"))); + const repoDir = join(tempDir, "repo"); + mkdirSync(repoDir, { recursive: true }); + try { + execFileSync("git", ["init", "-b", "main"], { cwd: repoDir }); + writeFileSync(join(repoDir, "new.txt"), "new\n"); + execFileSync("git", ["add", "new.txt"], { cwd: repoDir }); + + await discardChanges(repoDir, ["new.txt"]); + + expect(existsSync(join(repoDir, "new.txt"))).toBe(false); + expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe(""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("discards a nested folder pathspec without touching sibling changes", async () => { + const { tempDir, repoDir } = initRepo(); + try { + mkdirSync(join(repoDir, "nested")); + writeFileSync(join(repoDir, "nested", "tracked.txt"), "original\n"); + execFileSync("git", ["add", "nested/tracked.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, "nested", "tracked.txt"), "changed\n"); + writeFileSync(join(repoDir, "nested", "untracked.txt"), "remove\n"); + writeFileSync(join(repoDir, "outside.txt"), "keep\n"); + + await discardChanges(repoDir, ["nested"]); + + expect(readTextFile(join(repoDir, "nested", "tracked.txt"))).toBe("original\n"); + expect(existsSync(join(repoDir, "nested", "untracked.txt"))).toBe(false); + expect(readFileSync(join(repoDir, "outside.txt"), "utf8")).toBe("keep\n"); + expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe( + "?? outside.txt\n", + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("restores a staged deletion", async () => { + const { tempDir, repoDir } = initRepo(); + try { + execFileSync("git", ["rm", "file.txt"], { cwd: repoDir }); + await discardChanges(repoDir, ["file.txt"]); + expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("leaves files outside the given pathspecs untouched", async () => { + const { tempDir, repoDir } = initRepo(); + try { + writeFileSync(join(repoDir, "file.txt"), "changed\n"); + writeFileSync(join(repoDir, "other.txt"), "keep\n"); + await discardChanges(repoDir, ["file.txt"]); + expect(readTextFile(join(repoDir, "file.txt"))).toBe("hello\n"); + expect(readFileSync(join(repoDir, "other.txt"), "utf8")).toBe("keep\n"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("treats requested filenames as literal pathspecs", async () => { + const { tempDir, repoDir } = initRepo(); + try { + writeFileSync(join(repoDir, "foo[ab].txt"), "literal original\n"); + writeFileSync(join(repoDir, "fooa.txt"), "sibling original\n"); + execFileSync("git", ["add", "foo[ab].txt", "fooa.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add pathspec files"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, "foo[ab].txt"), "literal changed\n"); + writeFileSync(join(repoDir, "fooa.txt"), "sibling changed\n"); + + await discardChanges(repoDir, ["foo[ab].txt"]); + + expect(readTextFile(join(repoDir, "foo[ab].txt"))).toBe("literal original\n"); + expect(readTextFile(join(repoDir, "fooa.txt"))).toBe("sibling changed\n"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 468600de7..16cb20775 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -61,8 +61,10 @@ export type GitMutationRefreshReason = | "create-branch" | "stash-push" | "stash-pop" + | "discard-changes" | "create-worktree"; +const DISCARD_CHANGES_TIMEOUT_MS = 120_000; const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000; const PULL_REQUEST_STATUS_CACHE_MAX = 1_000; const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000; @@ -2084,6 +2086,7 @@ function buildPlaceholderParsedDiffFile( ): ParsedDiffFile { return { path: change.path, + ...(change.oldPath ? { oldPath: change.oldPath } : {}), isNew: change.isNew, isDeleted: change.isDeleted, additions: options.stat?.additions ?? 0, @@ -2918,6 +2921,7 @@ async function buildHighlightedTrackedDiffFile(input: { return { ...highlightedFile, path: change.path, + ...(change.oldPath ? { oldPath: change.oldPath } : {}), isNew: change.isNew, isDeleted: change.isDeleted, status: "ok", @@ -2993,6 +2997,7 @@ async function appendStructuredTrackedDiffs( const file = { path: change.path, + ...(change.oldPath ? { oldPath: change.oldPath } : {}), isNew: change.isNew, isDeleted: change.isDeleted, additions: stat?.additions ?? 0, @@ -3321,6 +3326,71 @@ export async function commitAll(cwd: string, message: string): Promise { await commitChanges(cwd, { message, addAll: true }); } +export async function discardChanges(cwd: string, pathspecs: string[]): Promise { + await requireGitRepo(cwd); + if (pathspecs.length === 0) { + return; + } + try { + await runGitCommand(["--literal-pathspecs", "reset", "-q", "HEAD", "--", ...pathspecs], { + cwd, + timeout: DISCARD_CHANGES_TIMEOUT_MS, + }); + } catch { + // Why: unborn HEAD has no commit for reset, so remove the paths directly from the index. + await runGitCommand( + ["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...pathspecs], + { + cwd, + timeout: DISCARD_CHANGES_TIMEOUT_MS, + }, + ); + } + // With everything unstaged, the remaining state is only worktree + // modifications/deletions (restore from the index) and untracked files + // (clean). Classify from porcelain so each path gets the command that + // actually applies to it. + const status = await runGitCommand( + ["--literal-pathspecs", "status", "--porcelain=v1", "-z", "--", ...pathspecs], + { + cwd, + timeout: DISCARD_CHANGES_TIMEOUT_MS, + }, + ); + const tracked: string[] = []; + const untracked: string[] = []; + const tokens = status.stdout.split("\0"); + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + if (token.length < 4) { + continue; + } + const state = token.slice(0, 2); + const filePath = token.slice(3); + if (state.startsWith("R") || state.startsWith("C")) { + // Rename/copy entries carry the source path as the next NUL token. + index += 1; + } + if (state === "??") { + untracked.push(filePath); + continue; + } + tracked.push(filePath); + } + if (tracked.length > 0) { + await runGitCommand(["--literal-pathspecs", "checkout", "-q", "--", ...tracked], { + cwd, + timeout: DISCARD_CHANGES_TIMEOUT_MS, + }); + } + if (untracked.length > 0) { + await runGitCommand(["--literal-pathspecs", "clean", "-fd", "-q", "--", ...untracked], { + cwd, + timeout: DISCARD_CHANGES_TIMEOUT_MS, + }); + } +} + interface DetectMergeToBaseConflictInput { operationCwd: string; error: unknown;