authorgravatar for gbarros1095@gmail.comGuilherme Barros <gbarros1095@gmail.com> 2026-08-15 12:44:22+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:44:22+02:00
log9f26656cb958853f90f7215387d604c098937db8
treee993c8076734c490a7b23993568e9b59f6898043
parent48cba7d93c8c63508f31cce2544d480ace86f929
signature Signed by PGP key B5690EEEBB952194

fix(web): open diff files from nested projects (#6174)


3 files changed, 156 insertions(+), 4 deletions(-)

apps/web/src/components/DiffPanel.tsx+5-1
......@@ -134,6 +134,9 @@ export default function DiffPanel({
134134 : null,
135135 );
136136 const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot;
137 const activeRepositoryRoot = activeThread?.worktreePath
138 ? undefined
139 : activeProject?.repositoryIdentity?.rootPath;
137140 const serverConfig = useAtomValue(
138141 serverEnvironment.configValueAtom(activeThread?.environmentId ?? null),
139142 );
......@@ -443,6 +446,7 @@ export default function DiffPanel({
443446 threadRef: routeThreadRef,
444447 filePath,
445448 activeCwd,
449 repositoryRoot: activeRepositoryRoot,
446450 openInEditor: (targetPath) => {
447451 void (async () => {
448452 const result = await openInPreferredEditor(targetPath);
......@@ -462,7 +466,7 @@ export default function DiffPanel({
462466 },
463467 });
464468 },
465 [activeCwd, openInPreferredEditor, routeThreadRef],
469 [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef],
466470 );
467471 const toggleDiffFileCollapsed = useCallback(
468472 (fileKey: string) => {
apps/web/src/diffFileActions.test.ts+74-1
......@@ -2,7 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
22import { EnvironmentId, ThreadId } from "@t3tools/contracts";
33import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
44
5import { openDiffFilePrimaryAction } from "./diffFileActions";
5import { openDiffFilePrimaryAction, resolveDiffPathForWorkspace } from "./diffFileActions";
66import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore";
77
88const THREAD_REF = scopeThreadRef(
......@@ -48,4 +48,77 @@ describe("openDiffFilePrimaryAction", () => {
4848 "/repo/project/apps/web/src/components/DiffPanel.tsx",
4949 );
5050 });
51
52 it("opens repository-relative diff files from a nested project", () => {
53 const openInEditor = vi.fn();
54
55 openDiffFilePrimaryAction({
56 threadRef: THREAD_REF,
57 filePath: "frontend/Dockerfile",
58 activeCwd: "/repo/frontend",
59 repositoryRoot: "/repo",
60 openInEditor,
61 });
62
63 expect(
64 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF),
65 ).toMatchObject({
66 isOpen: true,
67 activeSurfaceId: "file:Dockerfile",
68 });
69 expect(openInEditor).not.toHaveBeenCalled();
70 });
71
72 it("preserves repository-relative paths in a separate worktree", () => {
73 expect(
74 resolveDiffPathForWorkspace({
75 filePath: "frontend/Dockerfile",
76 workspaceRoot: "/worktrees/feature",
77 repositoryRoot: "/repo",
78 }),
79 ).toBe("frontend/Dockerfile");
80 });
81
82 it("handles Windows roots and mixed diff separators", () => {
83 expect(
84 resolveDiffPathForWorkspace({
85 filePath: "Frontend/src\\index.ts",
86 workspaceRoot: "C:\\repo\\frontend",
87 repositoryRoot: "C:\\repo",
88 }),
89 ).toBe("src/index.ts");
90 });
91
92 it.each([
93 { workspaceRoot: "/frontend", repositoryRoot: "/" },
94 { workspaceRoot: "C:\\frontend", repositoryRoot: "C:\\" },
95 ])("handles filesystem roots: $repositoryRoot", ({ workspaceRoot, repositoryRoot }) => {
96 expect(
97 resolveDiffPathForWorkspace({
98 filePath: "frontend/index.ts",
99 workspaceRoot,
100 repositoryRoot,
101 }),
102 ).toBe("index.ts");
103 });
104
105 it.each(["backend/server.ts", "frontend2/app.ts", "frontend/../secret.ts", "C:secret.ts"])(
106 "does not open an out-of-project diff path: %s",
107 (filePath) => {
108 const openInEditor = vi.fn();
109
110 openDiffFilePrimaryAction({
111 threadRef: THREAD_REF,
112 filePath,
113 activeCwd: "/repo/frontend",
114 repositoryRoot: "/repo",
115 openInEditor,
116 });
117
118 expect(
119 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF),
120 ).toMatchObject({ isOpen: false });
121 expect(openInEditor).not.toHaveBeenCalled();
122 },
123 );
51124});
apps/web/src/diffFileActions.ts+77-2
......@@ -1,4 +1,5 @@
11import type { ScopedThreadRef } from "@t3tools/contracts";
2import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path";
23
34import { useRightPanelStore } from "./rightPanelStore";
45import { resolvePathLinkTarget } from "./terminal-links";
......@@ -7,19 +8,93 @@ interface OpenDiffFilePrimaryActionInput {
78 readonly threadRef: ScopedThreadRef | null;
89 readonly filePath: string;
910 readonly activeCwd: string | undefined;
11 readonly repositoryRoot?: string | undefined;
1012 readonly openInEditor: (targetPath: string) => void;
1113}
1214
15function normalizedRelativePathSegments(filePath: string): ReadonlyArray<string> | null {
16 if (filePath.startsWith("/") || isWindowsAbsolutePath(filePath) || /^[a-zA-Z]:/.test(filePath)) {
17 return null;
18 }
19
20 const segments = filePath
21 .replaceAll("\\", "/")
22 .split("/")
23 .filter((segment) => segment.length > 0 && segment !== ".");
24 if (segments.length === 0 || segments.includes("..")) return null;
25 return segments;
26}
27
28function repositoryRelativeWorkspaceSegments(
29 workspaceRoot: string | undefined,
30 repositoryRoot: string | undefined,
31): ReadonlyArray<string> | null {
32 if (!workspaceRoot || !repositoryRoot) return null;
33
34 const normalizedWorkspaceRoot = normalizeProjectPathForComparison(workspaceRoot);
35 const normalizedRepositoryRoot = normalizeProjectPathForComparison(repositoryRoot);
36 if (normalizedWorkspaceRoot === normalizedRepositoryRoot) return [];
37
38 const separator = normalizedRepositoryRoot.includes("\\") ? "\\" : "/";
39 const repositoryPrefix = normalizedRepositoryRoot.endsWith(separator)
40 ? normalizedRepositoryRoot
41 : `${normalizedRepositoryRoot}${separator}`;
42 if (!normalizedWorkspaceRoot.startsWith(repositoryPrefix)) return null;
43
44 return normalizedWorkspaceRoot
45 .slice(repositoryPrefix.length)
46 .split(/[\\/]+/)
47 .filter(Boolean);
48}
49
50export function resolveDiffPathForWorkspace(input: {
51 readonly filePath: string;
52 readonly workspaceRoot: string | undefined;
53 readonly repositoryRoot: string | undefined;
54}): string | null {
55 const fileSegments = normalizedRelativePathSegments(input.filePath);
56 if (!fileSegments) return null;
57
58 const workspaceSegments = repositoryRelativeWorkspaceSegments(
59 input.workspaceRoot,
60 input.repositoryRoot,
61 );
62 if (!workspaceSegments || workspaceSegments.length === 0) {
63 return fileSegments.join("/");
64 }
65
66 const caseInsensitive = input.repositoryRoot
67 ? isWindowsAbsolutePath(input.repositoryRoot)
68 : false;
69 const belongsToWorkspace = workspaceSegments.every((segment, index) => {
70 const candidate = fileSegments[index];
71 if (candidate === undefined) return false;
72 return caseInsensitive ? candidate.toLowerCase() === segment : candidate === segment;
73 });
74 if (!belongsToWorkspace) return null;
75
76 const relativeSegments = fileSegments.slice(workspaceSegments.length);
77 return relativeSegments.length > 0 ? relativeSegments.join("/") : null;
78}
79
1380export function openDiffFilePrimaryAction({
1481 threadRef,
1582 filePath,
1683 activeCwd,
84 repositoryRoot,
1785 openInEditor,
1886}: OpenDiffFilePrimaryActionInput): void {
87 const workspaceFilePath = resolveDiffPathForWorkspace({
88 filePath,
89 workspaceRoot: activeCwd,
90 repositoryRoot,
91 });
92 if (!workspaceFilePath) return;
93
1994 if (threadRef) {
20 useRightPanelStore.getState().openFile(threadRef, filePath);
95 useRightPanelStore.getState().openFile(threadRef, workspaceFilePath);
2196 return;
2297 }
2398
24 openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath);
99 openInEditor(activeCwd ? resolvePathLinkTarget(workspaceFilePath, activeCwd) : workspaceFilePath);
25100}