Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/web/src/components/DiffPanel.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,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.";
Expand Down Expand Up @@ -123,6 +124,7 @@ function setup(failedBaseRef: string | null) {
branchBaseRefByThreadKey: {},
});
useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} });
useRightPanelStore.setState({ byThreadKey: {}, threadPanelVisibilityByThreadKey: {} });
};
reset();
state.diffFilesCollapsed = false;
Expand Down Expand Up @@ -228,6 +230,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(
<RegistryContext.Provider value={context.registry}>
<DiffPanel composerDraftTarget={context.threadRef} workspaceMutationId={null} visible />
</RegistryContext.Provider>,
);
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) => {
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,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),
);
Expand All @@ -281,6 +278,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),
);
Expand Down
19 changes: 17 additions & 2 deletions apps/web/src/components/chat/TimelineAssistantRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../times
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<TimelineRow, { kind: "message" }> }) {
const ctx = use(TimelineRowCtx);
Expand Down Expand Up @@ -418,6 +423,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 (
<ChangedFilesCard
Expand All @@ -437,9 +451,10 @@ function AssistantChangedFilesSectionInner({
filePath,
workspaceRoot: ctx.workspaceRoot,
repositoryRoot:
thread?.worktreePath == null
repositoryRoot ??
(thread?.worktreePath == null
? activeProject?.repositoryIdentity?.rootPath
: undefined,
: undefined),
},
event,
)
Expand Down
55 changes: 46 additions & 9 deletions apps/web/src/diffFileActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,51 @@ describe("openDiffFilePrimaryAction", () => {
).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\\src\\index.ts",
workspaceRoot: "C:\\repo\\frontend",
repositoryRoot: "C:\\repo",
expected: "src/index.ts",
},
{
filePath: "Frontend/Src/index.ts",
workspaceRoot: "C:\\repo\\frontend\\src",
repositoryRoot: "C:\\repo",
expected: "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: "a.ts",
},
])(
"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([
Expand Down Expand Up @@ -89,6 +125,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",
Expand Down
33 changes: 20 additions & 13 deletions apps/web/src/diffFileActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,22 @@ interface OpenDiffFilePrimaryActionInput {
readonly openInEditor: (targetPath: string) => void;
}

function normalizedRelativePathSegments(filePath: string): ReadonlyArray<string> | null {
function normalizedRelativePathSegments(
filePath: string,
windowsRoot: boolean,
): ReadonlyArray<string> | 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;
}

Expand All @@ -40,31 +46,32 @@ 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: {
readonly filePath: string;
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(
Expand Down
Loading