fix(app): resolve Changes file actions from the repository root

Diff paths are relative to the worktree root, but the Changes pane resolved
them against the workspace directory. For a project in a repository subfolder,
that repeated the subfolder in the path, so Copy path was wrong and Open file,
Download, Duplicate, and Reveal failed.

Closes #8125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
3ae3ae
2026-11-02 15:08:47 +09:00
co-authored by Claude Opus 5.5
parent da8d58bfee
commit cacd97d663
2 changed files with 106 additions and 12 deletions
+72 -1
View File
@@ -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}`);
+34 -11
View File
@@ -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,