diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 503f9b60c1b5d..604889da5bb68 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -94,6 +94,7 @@ vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null import DiffPanel from "./DiffPanel"; import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; const cwd = "/project-worktree"; const errorMessage = "Could not find a common commit between 'origin/orphan' and HEAD."; @@ -126,6 +127,7 @@ function setup(failedBaseRef: string | null) { branchBaseRefByThreadKey: {}, }); useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); + useRightPanelStore.setState({ byThreadKey: {}, threadPanelVisibilityByThreadKey: {} }); }; reset(); state.diffFilesCollapsed = false; @@ -233,6 +235,36 @@ it.each([ }, ); +it("opens a repository-relative file from a nested project without a remote", async () => { + using context = setup("origin/orphan"); + state.thread = { ...state.thread!, worktreePath: null }; + const path = "project/back\\slash.txt"; + const metadata = preview(); + const source = { + ...metadata.sources[0]!, + files: [{ path, previousPath: null, additions: 1, deletions: 0 }], + }; + state.previews.set("main", Atom.make(AsyncResult.success({ ...metadata, sources: [source] }))); + state.status = Atom.make( + AsyncResult.success({ + ...AsyncResult.getOrElse(context.registry.get(state.status), () => { + throw new Error("Missing status"); + }), + workingTree: { rootPath: "/", files: [], insertions: 0, deletions: 0 }, + }), + ); + useDiffPanelStore.getState().selectBranchBaseRef(context.threadRef, "main"); + await using screen = await render( + + + , + ); + await userEvent.click(screen.getByText(path, { exact: true })); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, context.threadRef), + ).toMatchObject({ activeSurfaceId: "file:back\\slash.txt" }); +}); + it.each([false, true])( "expands an unloaded file on the first click with default collapse %s", async (collapsed) => { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 71c197b6ce363..2ec6d51997ace 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -254,9 +254,6 @@ export default function DiffPanel({ : null, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; - const activeRepositoryRoot = activeThread?.worktreePath - ? undefined - : activeProject?.repositoryIdentity?.rootPath; const serverConfig = useAtomValue( serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), ); @@ -274,6 +271,9 @@ export default function DiffPanel({ }) : null, ); + const activeRepositoryRoot = + gitStatusQuery.data?.workingTree.rootPath ?? + (activeThread?.worktreePath ? undefined : activeProject?.repositoryIdentity?.rootPath); const diffSelection = useDiffPanelStore((state) => selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 1d03161e83014..b62f6f8e4b10f 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -32,6 +32,11 @@ import { readThreadTurnItems } from "../../state/entities"; import { useV2ItemSupport } from "../../state/v2ItemSupport"; import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext"; import { MESSAGE_HEADING_LEVEL, MessageAuthorHeading } from "./TimelineRowParts"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/reactivity"; +import { vcsEnvironment } from "../../state/vcs"; + +const NO_VCS_STATUS = Atom.make(AsyncResult.initial()); export function AssistantTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); @@ -460,6 +465,15 @@ function AssistantChangedFilesSectionInner({ : null, ); const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId); + const repositoryRoot = useAtomValue( + ctx.workspaceRoot + ? vcsEnvironment.status({ + environmentId: ctx.activeThreadEnvironmentId, + input: { cwd: ctx.workspaceRoot }, + }) + : NO_VCS_STATUS, + (status) => AsyncResult.getOrElse(status, () => null)?.workingTree.rootPath, + ); return ( { ).toBe("frontend/Dockerfile"); }); - it("handles Windows roots and mixed diff separators", () => { - expect( - resolveDiffPathForWorkspace({ - filePath: "Frontend/src\\index.ts", - workspaceRoot: "C:\\repo\\frontend", - repositoryRoot: "C:\\repo", - }), - ).toBe("src/index.ts"); - }); + it.each([ + { + filePath: "Frontend/src\\index.ts", + workspaceRoot: "C:\\repo\\frontend", + repositoryRoot: "C:\\repo", + expected: "src/index.ts", + }, + { + filePath: "Frontend\\src\\index.ts", + workspaceRoot: "C:\\repo\\frontend", + repositoryRoot: "C:\\repo", + expected: "src/index.ts", + }, + { + filePath: "frontend/back\\slash.txt", + workspaceRoot: "/repo/frontend", + repositoryRoot: "/repo", + expected: "back\\slash.txt", + }, + { + filePath: "back/slash/a.ts", + workspaceRoot: "/repo/back\\slash", + repositoryRoot: "/repo", + expected: null, + }, + ])( + "resolves $filePath in $workspaceRoot", + ({ filePath, workspaceRoot, repositoryRoot, expected }) => { + expect(resolveDiffPathForWorkspace({ filePath, workspaceRoot, repositoryRoot })).toBe( + expected, + ); + }, + ); // regression: #6174 opening diff files broke for repositories at a filesystem root it.each([ @@ -89,6 +113,7 @@ describe("openDiffFilePrimaryAction", () => { "backend/server.ts", "frontend2/app.ts", "frontend/../secret.ts", + "frontend/..\\secret.ts", "C:secret.ts", "/repo/backend/server.ts", "/repo/frontend/../backend/server.ts", diff --git a/apps/web/src/diffFileActions.ts b/apps/web/src/diffFileActions.ts index c6a3fa3d436fc..046baa4ce5b64 100644 --- a/apps/web/src/diffFileActions.ts +++ b/apps/web/src/diffFileActions.ts @@ -17,16 +17,22 @@ interface OpenDiffFilePrimaryActionInput { readonly openInEditor: (targetPath: string) => void; } -function normalizedRelativePathSegments(filePath: string): ReadonlyArray | null { +function normalizedRelativePathSegments( + filePath: string, + windowsRoot: boolean, +): ReadonlyArray | null { if (filePath.startsWith("/") || isWindowsAbsolutePath(filePath) || /^[a-zA-Z]:/.test(filePath)) { return null; } - const segments = filePath - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment.length > 0 && segment !== "."); - if (segments.length === 0 || segments.includes("..")) return null; + // On POSIX a backslash is part of a file name; Windows treats it as a separator + // (tool-call patches can name files with either), so reject ".." either way. + const parts = filePath.split(/[\\/]/); + if (parts.includes("..")) return null; + const segments = (windowsRoot ? parts : filePath.split("/")).filter( + (segment) => segment.length > 0 && segment !== ".", + ); + if (segments.length === 0) return null; return segments; } @@ -40,16 +46,13 @@ function repositoryRelativeWorkspaceSegments( const normalizedRepositoryRoot = normalizeProjectPathForComparison(repositoryRoot); if (normalizedWorkspaceRoot === normalizedRepositoryRoot) return []; - const separator = normalizedRepositoryRoot.includes("\\") ? "\\" : "/"; + const separator = isWindowsAbsolutePath(normalizedRepositoryRoot) ? "\\" : "/"; const repositoryPrefix = normalizedRepositoryRoot.endsWith(separator) ? normalizedRepositoryRoot : `${normalizedRepositoryRoot}${separator}`; if (!normalizedWorkspaceRoot.startsWith(repositoryPrefix)) return null; - return normalizedWorkspaceRoot - .slice(repositoryPrefix.length) - .split(/[\\/]+/) - .filter(Boolean); + return normalizedWorkspaceRoot.slice(repositoryPrefix.length).split(separator).filter(Boolean); } export function resolveDiffPathForWorkspace(input: { @@ -57,14 +60,18 @@ export function resolveDiffPathForWorkspace(input: { readonly workspaceRoot: string | undefined; readonly repositoryRoot: string | undefined; }): string | null { + const windowsRoot = [input.workspaceRoot, input.repositoryRoot].some( + (root) => root !== undefined && isWindowsAbsolutePath(root), + ); // Claude and ACP tool-call diffs name files by absolute path. if (isAbsolutePath(input.filePath)) { const relativePath = workspaceRelativeFilePath(input.filePath, input.workspaceRoot); - const segments = relativePath === null ? null : normalizedRelativePathSegments(relativePath); + const segments = + relativePath === null ? null : normalizedRelativePathSegments(relativePath, windowsRoot); return segments ? segments.join("/") : null; } - const fileSegments = normalizedRelativePathSegments(input.filePath); + const fileSegments = normalizedRelativePathSegments(input.filePath, windowsRoot); if (!fileSegments) return null; const workspaceSegments = repositoryRelativeWorkspaceSegments(