Skip to content
Merged
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
6 changes: 5 additions & 1 deletion apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ 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 Down Expand Up @@ -443,6 +446,7 @@ export default function DiffPanel({
threadRef: routeThreadRef,
filePath,
activeCwd,
repositoryRoot: activeRepositoryRoot,
openInEditor: (targetPath) => {
void (async () => {
const result = await openInPreferredEditor(targetPath);
Expand All @@ -462,7 +466,7 @@ export default function DiffPanel({
},
});
},
[activeCwd, openInPreferredEditor, routeThreadRef],
[activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef],
);
const toggleDiffFileCollapsed = useCallback(
(fileKey: string) => {
Expand Down
75 changes: 74 additions & 1 deletion apps/web/src/diffFileActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { openDiffFilePrimaryAction } from "./diffFileActions";
import { openDiffFilePrimaryAction, resolveDiffPathForWorkspace } from "./diffFileActions";
import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore";

const THREAD_REF = scopeThreadRef(
Expand Down Expand Up @@ -48,4 +48,77 @@ describe("openDiffFilePrimaryAction", () => {
"/repo/project/apps/web/src/components/DiffPanel.tsx",
);
});

it("opens repository-relative diff files from a nested project", () => {
const openInEditor = vi.fn();

openDiffFilePrimaryAction({
threadRef: THREAD_REF,
filePath: "frontend/Dockerfile",
activeCwd: "/repo/frontend",
repositoryRoot: "/repo",
openInEditor,
});

expect(
selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF),
).toMatchObject({
isOpen: true,
activeSurfaceId: "file:Dockerfile",
});
expect(openInEditor).not.toHaveBeenCalled();
});

it("preserves repository-relative paths in a separate worktree", () => {
expect(
resolveDiffPathForWorkspace({
filePath: "frontend/Dockerfile",
workspaceRoot: "/worktrees/feature",
repositoryRoot: "/repo",
}),
).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([
{ workspaceRoot: "/frontend", repositoryRoot: "/" },
{ workspaceRoot: "C:\\frontend", repositoryRoot: "C:\\" },
])("handles filesystem roots: $repositoryRoot", ({ workspaceRoot, repositoryRoot }) => {
expect(
resolveDiffPathForWorkspace({
filePath: "frontend/index.ts",
workspaceRoot,
repositoryRoot,
}),
).toBe("index.ts");
});

it.each(["backend/server.ts", "frontend2/app.ts", "frontend/../secret.ts", "C:secret.ts"])(
"does not open an out-of-project diff path: %s",
(filePath) => {
const openInEditor = vi.fn();

openDiffFilePrimaryAction({
threadRef: THREAD_REF,
filePath,
activeCwd: "/repo/frontend",
repositoryRoot: "/repo",
openInEditor,
});

expect(
selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF),
).toMatchObject({ isOpen: false });
expect(openInEditor).not.toHaveBeenCalled();
},
);
});
79 changes: 77 additions & 2 deletions apps/web/src/diffFileActions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path";

import { useRightPanelStore } from "./rightPanelStore";
import { resolvePathLinkTarget } from "./terminal-links";
Expand All @@ -7,19 +8,93 @@ interface OpenDiffFilePrimaryActionInput {
readonly threadRef: ScopedThreadRef | null;
readonly filePath: string;
readonly activeCwd: string | undefined;
readonly repositoryRoot?: string | undefined;
readonly openInEditor: (targetPath: string) => void;
}

function normalizedRelativePathSegments(filePath: string): 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;
return segments;
}

function repositoryRelativeWorkspaceSegments(
workspaceRoot: string | undefined,
repositoryRoot: string | undefined,
): ReadonlyArray<string> | null {
if (!workspaceRoot || !repositoryRoot) return null;

const normalizedWorkspaceRoot = normalizeProjectPathForComparison(workspaceRoot);
const normalizedRepositoryRoot = normalizeProjectPathForComparison(repositoryRoot);
if (normalizedWorkspaceRoot === normalizedRepositoryRoot) return [];

const separator = normalizedRepositoryRoot.includes("\\") ? "\\" : "/";
const repositoryPrefix = normalizedRepositoryRoot.endsWith(separator)
? normalizedRepositoryRoot
: `${normalizedRepositoryRoot}${separator}`;
if (!normalizedWorkspaceRoot.startsWith(repositoryPrefix)) return null;

return normalizedWorkspaceRoot
.slice(repositoryPrefix.length)
.split(/[\\/]+/)
.filter(Boolean);
}

export function resolveDiffPathForWorkspace(input: {
readonly filePath: string;
readonly workspaceRoot: string | undefined;
readonly repositoryRoot: string | undefined;
}): string | null {
const fileSegments = normalizedRelativePathSegments(input.filePath);
if (!fileSegments) return null;

const workspaceSegments = repositoryRelativeWorkspaceSegments(
input.workspaceRoot,
input.repositoryRoot,
);
if (!workspaceSegments || workspaceSegments.length === 0) {
return fileSegments.join("/");
}

const caseInsensitive = input.repositoryRoot
? isWindowsAbsolutePath(input.repositoryRoot)
: false;
const belongsToWorkspace = workspaceSegments.every((segment, index) => {
const candidate = fileSegments[index];
if (candidate === undefined) return false;
return caseInsensitive ? candidate.toLowerCase() === segment : candidate === segment;
});
if (!belongsToWorkspace) return null;

const relativeSegments = fileSegments.slice(workspaceSegments.length);
return relativeSegments.length > 0 ? relativeSegments.join("/") : null;
}

export function openDiffFilePrimaryAction({
threadRef,
filePath,
activeCwd,
repositoryRoot,
openInEditor,
}: OpenDiffFilePrimaryActionInput): void {
const workspaceFilePath = resolveDiffPathForWorkspace({
filePath,
workspaceRoot: activeCwd,
repositoryRoot,
});
if (!workspaceFilePath) return;

if (threadRef) {
useRightPanelStore.getState().openFile(threadRef, filePath);
useRightPanelStore.getState().openFile(threadRef, workspaceFilePath);
return;
}

openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath);
openInEditor(activeCwd ? resolvePathLinkTarget(workspaceFilePath, activeCwd) : workspaceFilePath);
}
Loading