| author | |
| committer | |
| log | 9f26656cb958853f90f7215387d604c098937db8 |
| tree | e993c8076734c490a7b23993568e9b59f6898043 |
| parent | 48cba7d93c8c63508f31cce2544d480ace86f929 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 156 insertions(+), 4 deletions(-)
apps/web/src/components/DiffPanel.tsx+5-1| ... | @@ -134,6 +134,9 @@ export default function DiffPanel({ | ... | @@ -134,6 +134,9 @@ export default function DiffPanel({ |
| 134 | : null, | 134 | : null, |
| 135 | ); | 135 | ); |
| 136 | const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; | 136 | const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; |
| 137 | const activeRepositoryRoot = activeThread?.worktreePath | ||
| 138 | ? undefined | ||
| 139 | : activeProject?.repositoryIdentity?.rootPath; | ||
| 137 | const serverConfig = useAtomValue( | 140 | const serverConfig = useAtomValue( |
| 138 | serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), | 141 | serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), |
| 139 | ); | 142 | ); |
| ... | @@ -443,6 +446,7 @@ export default function DiffPanel({ | ... | @@ -443,6 +446,7 @@ export default function DiffPanel({ |
| 443 | threadRef: routeThreadRef, | 446 | threadRef: routeThreadRef, |
| 444 | filePath, | 447 | filePath, |
| 445 | activeCwd, | 448 | activeCwd, |
| 449 | repositoryRoot: activeRepositoryRoot, | ||
| 446 | openInEditor: (targetPath) => { | 450 | openInEditor: (targetPath) => { |
| 447 | void (async () => { | 451 | void (async () => { |
| 448 | const result = await openInPreferredEditor(targetPath); | 452 | const result = await openInPreferredEditor(targetPath); |
| ... | @@ -462,7 +466,7 @@ export default function DiffPanel({ | ... | @@ -462,7 +466,7 @@ export default function DiffPanel({ |
| 462 | }, | 466 | }, |
| 463 | }); | 467 | }); |
| 464 | }, | 468 | }, |
| 465 | [activeCwd, openInPreferredEditor, routeThreadRef], | 469 | [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef], |
| 466 | ); | 470 | ); |
| 467 | const toggleDiffFileCollapsed = useCallback( | 471 | const toggleDiffFileCollapsed = useCallback( |
| 468 | (fileKey: string) => { | 472 | (fileKey: string) => { |
apps/web/src/diffFileActions.test.ts+74-1| ... | @@ -2,7 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | ... | @@ -2,7 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | ||
| 5 | import { openDiffFilePrimaryAction } from "./diffFileActions"; | 5 | import { openDiffFilePrimaryAction, resolveDiffPathForWorkspace } from "./diffFileActions"; |
| 6 | import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore"; | 6 | import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore"; |
| 7 | 7 | ||
| 8 | const THREAD_REF = scopeThreadRef( | 8 | const THREAD_REF = scopeThreadRef( |
| ... | @@ -48,4 +48,77 @@ describe("openDiffFilePrimaryAction", () => { | ... | @@ -48,4 +48,77 @@ describe("openDiffFilePrimaryAction", () => { |
| 48 | "/repo/project/apps/web/src/components/DiffPanel.tsx", | 48 | "/repo/project/apps/web/src/components/DiffPanel.tsx", |
| 49 | ); | 49 | ); |
| 50 | }); | 50 | }); |
| 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 | ); | ||
| 51 | }); | 124 | }); |
apps/web/src/diffFileActions.ts+77-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path"; | ||
| 2 | 3 | ||
| 3 | import { useRightPanelStore } from "./rightPanelStore"; | 4 | import { useRightPanelStore } from "./rightPanelStore"; |
| 4 | import { resolvePathLinkTarget } from "./terminal-links"; | 5 | import { resolvePathLinkTarget } from "./terminal-links"; |
| ... | @@ -7,19 +8,93 @@ interface OpenDiffFilePrimaryActionInput { | ... | @@ -7,19 +8,93 @@ interface OpenDiffFilePrimaryActionInput { |
| 7 | readonly threadRef: ScopedThreadRef | null; | 8 | readonly threadRef: ScopedThreadRef | null; |
| 8 | readonly filePath: string; | 9 | readonly filePath: string; |
| 9 | readonly activeCwd: string | undefined; | 10 | readonly activeCwd: string | undefined; |
| 11 | readonly repositoryRoot?: string | undefined; | ||
| 10 | readonly openInEditor: (targetPath: string) => void; | 12 | readonly openInEditor: (targetPath: string) => void; |
| 11 | } | 13 | } |
| 12 | 14 | ||
| 15 | function 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 | |||
| 28 | function 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 | |||
| 50 | export 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 | |||
| 13 | export function openDiffFilePrimaryAction({ | 80 | export function openDiffFilePrimaryAction({ |
| 14 | threadRef, | 81 | threadRef, |
| 15 | filePath, | 82 | filePath, |
| 16 | activeCwd, | 83 | activeCwd, |
| 84 | repositoryRoot, | ||
| 17 | openInEditor, | 85 | openInEditor, |
| 18 | }: OpenDiffFilePrimaryActionInput): void { | 86 | }: OpenDiffFilePrimaryActionInput): void { |
| 87 | const workspaceFilePath = resolveDiffPathForWorkspace({ | ||
| 88 | filePath, | ||
| 89 | workspaceRoot: activeCwd, | ||
| 90 | repositoryRoot, | ||
| 91 | }); | ||
| 92 | if (!workspaceFilePath) return; | ||
| 93 | |||
| 19 | if (threadRef) { | 94 | if (threadRef) { |
| 20 | useRightPanelStore.getState().openFile(threadRef, filePath); | 95 | useRightPanelStore.getState().openFile(threadRef, workspaceFilePath); |
| 21 | return; | 96 | return; |
| 22 | } | 97 | } |
| 23 | 98 | ||
| 24 | openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath); | 99 | openInEditor(activeCwd ? resolvePathLinkTarget(workspaceFilePath, activeCwd) : workspaceFilePath); |
| 25 | } | 100 | } |