diff --git a/packages/app/e2e/browser/changes-pane.spec.ts b/packages/app/e2e/browser/changes-pane.spec.ts index 1ff6059460..3e6fecc934 100644 --- a/packages/app/e2e/browser/changes-pane.spec.ts +++ b/packages/app/e2e/browser/changes-pane.spec.ts @@ -16,6 +16,7 @@ interface DirtyWorkspace { } interface WorkspaceFixtureOptions { + workspaceSubdirectory?: string; includeDeletedFile?: boolean; includeNestedFolders?: boolean; includeRenamedFile?: boolean; @@ -435,6 +436,76 @@ 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(); }); +for (const { workspaceSubdirectory, filePath, content, visibleContent } of [ + { + workspaceSubdirectory: ".", + filePath: "src/use-mounted-tab-set.ts", + content: AFTER, + visibleContent: "interface DeriveRenderMountedTabIdsInput", + }, + { + workspaceSubdirectory: "src", + filePath: "src/use-mounted-tab-set.ts", + content: AFTER, + visibleContent: "interface DeriveRenderMountedTabIdsInput", + }, + { + workspaceSubdirectory: "src", + filePath: "a-root-note.txt", + content: "root note\n", + visibleContent: "root note", + }, +]) { + test(`changes file actions resolve ${filePath} from project ${workspaceSubdirectory}`, async ({ + page, + context, + }, testInfo) => { + const workspace = await createWorkspaceWithMountedTabDiff({ + workspaceSubdirectory, + includeRootFileSortingFirst: filePath === "a-root-note.txt", + }); + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await openWorkspaceChanges(page, workspace); + const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); + const header = diffHeaderForPath(panel, filePath).getByTestId(/^diff-file-\d+-toggle$/); + await header.click({ button: "right" }); + await testInfo.attach("changes-file-menu", { + body: await page.screenshot(), + contentType: "image/png", + }); + await test.step("Duplicate creates a copy next to the source", async () => { + const file = path.parse(filePath); + const copyPath = path.join(workspace.repoPath, file.dir, `${file.name} copy${file.ext}`); + await page.getByRole("menuitem", { name: "Duplicate", exact: true }).click(); + await expect + .poll(() => readFileIfPresent(copyPath), { message: `Expected duplicate at ${copyPath}` }) + .toBe(content); + }); + await header.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Copy relative path", exact: true }).click(); + await expectClipboardText(page, filePath); + await header.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Copy path", exact: true }).click(); + await expectClipboardText(page, path.join(workspace.repoPath, filePath)); + + await header.click({ button: "right" }); + const downloaded = page.waitForEvent("download"); + await page.getByRole("menuitem", { name: "Download", exact: true }).click(); + const download = await downloaded; + expect(download.suggestedFilename()).toBe(path.basename(filePath)); + expect(await readFile((await download.path())!, "utf8")).toBe(content); + + await header.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open file", exact: true }).click(); + const editor = page.getByTestId("workspace-file-pane").filter({ visible: true }); + await expect(editor.locator(".cm-content")).toContainText(visibleContent); + await testInfo.attach("opened-changed-file", { + body: await page.screenshot(), + contentType: "image/png", + }); + }); +} + test("canvas file headers select without toggling for context menu and long press", async ({ page, }) => { @@ -1591,7 +1662,7 @@ async function createWorkspaceWithMountedTabDiff( ); } const createdWorkspace = await client.createWorkspace({ - source: { kind: "directory", path: repo.path }, + source: { kind: "directory", path: path.join(repo.path, options.workspaceSubdirectory ?? ".") }, }); if (!createdWorkspace.workspace) { throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 3dd84f256a..aef17bc47a 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -56,6 +56,7 @@ import * as Clipboard from "expo-clipboard"; import { useFileDownload } from "@/hooks/use-file-download"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; +import { isWorkspaceRootPath } from "@/workspace/file-open"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { GitActionsSplitButton } from "@/git/actions-split-button"; import type { GitActions } from "@/git/policy"; @@ -1452,6 +1453,10 @@ function useDiffTabNavigation({ }; } +function resolveChangesFileRoot(status: CheckoutStatusPayload | null, cwd: string): string { + return status?.repoRoot ?? cwd; +} + export function ChangesSurface({ serverId, workspaceId, @@ -1631,14 +1636,30 @@ export function ChangesSurface({ }), [appSettings.monoFontFamily, codeFontSize, effectiveLayout, wrapLines], ); - const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: cwd }); + const fileRoot = resolveChangesFileRoot(status, cwd); + const isRootWorkspace = isWorkspaceRootPath({ path: fileRoot, workspaceRoot: cwd }); + const resolveFilePath = useCallback( + (path: string) => buildAbsoluteExplorerPath({ workspaceRoot: fileRoot, entryPath: path }), + [fileRoot], + ); + const resolveFileTargetPath = useCallback( + (path: string) => (isRootWorkspace ? path : resolveFilePath(path)), + [isRootWorkspace, resolveFilePath], + ); + const handleOpenFile = useCallback( + (path: string) => onOpenFile?.(resolveFileTargetPath(path)), + [onOpenFile, resolveFileTargetPath], + ); + const handleAddToChat = useCallback( + (path: string) => onAddToChat?.(resolveFileTargetPath(path)), + [onAddToChat, resolveFileTargetPath], + ); + const downloadFile = useFileDownload({ serverId, workspaceId, workspaceRoot: fileRoot }); const handleCopyPath = useCallback( (path: string) => { - void Clipboard.setStringAsync( - buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }), - ); + void Clipboard.setStringAsync(resolveFilePath(path)); }, - [cwd], + [resolveFilePath], ); const handleCopyRelativePath = useCallback((path: string) => { void Clipboard.setStringAsync(path); @@ -1652,7 +1673,7 @@ export function ChangesSurface({ await openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: cwd, - filePath: buildAbsoluteExplorerPath({ workspaceRoot: cwd, entryPath: path }), + filePath: resolveFilePath(path), }); } catch (cause) { toast.error( @@ -1660,7 +1681,7 @@ export function ChangesSurface({ ); } }, - [cwd, fileManagerTarget, t, toast], + [cwd, fileManagerTarget, resolveFilePath, t, toast], ); const handleDownloadPath = useCallback( (path: string) => { @@ -1674,7 +1695,7 @@ export function ChangesSurface({ return; } try { - const payload = await client.duplicateFileEntry({ cwd, path }); + const payload = await client.duplicateFileEntry({ cwd: fileRoot, path }); if (!payload.success) { toast.error(payload.error ?? t("workspace.fileExplorer.errors.duplicateFailed")); } @@ -1682,7 +1703,7 @@ export function ChangesSurface({ toast.error(cause instanceof Error ? cause.message : String(cause)); } }, - [client, cwd, t, toast], + [client, fileRoot, t, toast], ); const onRevertPath = useDiscardChangesAction({ serverId, cwd, diffMode }); const [localFocusRequest, setLocalFocusRequest] = useState<{ @@ -1718,9 +1739,9 @@ export function ChangesSurface({ focusPath: documentFocusRequest?.path, focusRequestId: documentFocusRequest?.revision, workspaceFileDragScope: workspaceId ? { serverId, workspaceId } : undefined, - onOpenFile, + onOpenFile: onOpenFile ? handleOpenFile : undefined, onOpenToSide, - onAddToChat, + onAddToChat: onAddToChat ? handleAddToChat : undefined, onCopyPath: handleCopyPath, onCopyRelativePath: handleCopyRelativePath, onReveal: fileManagerTarget ? handleRevealPath : undefined, @@ -1736,8 +1757,10 @@ export function ChangesSurface({ serverId, workspaceId, onOpenFile, + handleOpenFile, onOpenToSide, onAddToChat, + handleAddToChat, handleCopyPath, handleCopyRelativePath, handleDownloadPath,