diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index 43f98d2a..bd309af6 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -8,6 +8,7 @@ const menuNew = vi.hoisted(() => vi.fn(async ({ items }) => ({ popup: vi.fn(), items })), ); const menuItemNew = vi.hoisted(() => vi.fn(async (options) => options)); +const clipboardWriteText = vi.hoisted(() => vi.fn()); vi.mock("@tauri-apps/api/menu", () => ({ Menu: { new: menuNew }, @@ -44,6 +45,11 @@ vi.mock("../../../services/toasts", () => ({ pushErrorToast: vi.fn(), })); +Object.defineProperty(navigator, "clipboard", { + value: { writeText: (...args: unknown[]) => clipboardWriteText(...args) }, + configurable: true, +}); + const logEntries: GitLogEntry[] = []; const baseProps = { @@ -82,6 +88,7 @@ describe("GitDiffPanel", () => { }); it("adds a show in finder option for file context menus", async () => { + clipboardWriteText.mockClear(); const { container } = render( { expect(revealItemInDir).toHaveBeenCalledWith("/tmp/repo/src/sample.ts"); }); + it("copies file name and path from the context menu", async () => { + clipboardWriteText.mockClear(); + const { container } = render( + , + ); + + const row = container.querySelector(".diff-row"); + expect(row).not.toBeNull(); + fireEvent.contextMenu(row as Element); + + await waitFor(() => expect(menuNew).toHaveBeenCalled()); + const menuArgs = menuNew.mock.calls[menuNew.mock.calls.length - 1]?.[0]; + const copyNameItem = menuArgs.items.find( + (item: { text: string }) => item.text === "Copy file name", + ); + const copyPathItem = menuArgs.items.find( + (item: { text: string }) => item.text === "Copy file path", + ); + + expect(copyNameItem).toBeDefined(); + expect(copyPathItem).toBeDefined(); + + await copyNameItem.action(); + await copyPathItem.action(); + + expect(clipboardWriteText).toHaveBeenCalledWith("sample.ts"); + expect(clipboardWriteText).toHaveBeenCalledWith("src/sample.ts"); + }); + it("resolves relative git roots against the workspace path", async () => { revealItemInDir.mockClear(); menuNew.mockClear(); @@ -136,4 +179,62 @@ describe("GitDiffPanel", () => { await revealItem.action(); expect(revealItemInDir).toHaveBeenCalledWith("/tmp/repo/apps/src/sample.ts"); }); + + it("copies file path relative to the workspace root", async () => { + clipboardWriteText.mockClear(); + const { container } = render( + , + ); + + const row = container.querySelector(".diff-row"); + expect(row).not.toBeNull(); + fireEvent.contextMenu(row as Element); + + await waitFor(() => expect(menuNew).toHaveBeenCalled()); + const menuArgs = menuNew.mock.calls[menuNew.mock.calls.length - 1]?.[0]; + const copyPathItem = menuArgs.items.find( + (item: { text: string }) => item.text === "Copy file path", + ); + + expect(copyPathItem).toBeDefined(); + await copyPathItem.action(); + + expect(clipboardWriteText).toHaveBeenCalledWith("apps/src/sample.ts"); + }); + + it("does not trim paths when the git root only shares a prefix", async () => { + clipboardWriteText.mockClear(); + const { container } = render( + , + ); + + const row = container.querySelector(".diff-row"); + expect(row).not.toBeNull(); + fireEvent.contextMenu(row as Element); + + await waitFor(() => expect(menuNew).toHaveBeenCalled()); + const menuArgs = menuNew.mock.calls[menuNew.mock.calls.length - 1]?.[0]; + const copyPathItem = menuArgs.items.find( + (item: { text: string }) => item.text === "Copy file path", + ); + + expect(copyPathItem).toBeDefined(); + await copyPathItem.action(); + + expect(clipboardWriteText).toHaveBeenCalledWith("src/sample.ts"); + }); }); diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index ccf36c37..3c0100a1 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -146,6 +146,29 @@ function normalizeRootPath(value: string | null | undefined) { return value.replace(/\\/g, "/").replace(/\/+$/, ""); } +function normalizeSegment(segment: string) { + return /^[A-Za-z]:$/.test(segment) ? segment.toLowerCase() : segment; +} + +function getRelativePathWithin(base: string, target: string) { + const normalizedBase = normalizeRootPath(base); + const normalizedTarget = normalizeRootPath(target); + if (!normalizedBase || !normalizedTarget) { + return null; + } + const baseSegments = normalizedBase.split("/").filter(Boolean); + const targetSegments = normalizedTarget.split("/").filter(Boolean); + if (baseSegments.length > targetSegments.length) { + return null; + } + for (let index = 0; index < baseSegments.length; index += 1) { + if (normalizeSegment(baseSegments[index]) !== normalizeSegment(targetSegments[index])) { + return null; + } + } + return targetSegments.slice(baseSegments.length).join("/"); +} + function isAbsolutePath(value: string) { return value.startsWith("/") || /^[A-Za-z]:\//.test(value); } @@ -170,6 +193,12 @@ function joinRootAndPath(root: string, relativePath: string) { return `${normalizedRoot}/${normalizedPath}`; } +function getFileName(value: string) { + const normalized = value.replace(/\\/g, "/"); + const segments = normalized.split("/"); + return segments[segments.length - 1] || normalized; +} + function getStatusSymbol(status: string) { switch (status) { case "A": @@ -1046,6 +1075,13 @@ export function GitDiffPanel({ const absolutePath = resolvedRoot ? joinRootAndPath(resolvedRoot, rawPath) : rawPath; + const relativeRoot = + workspacePath && resolvedRoot + ? getRelativePathWithin(workspacePath, resolvedRoot) + : null; + const projectRelativePath = + relativeRoot !== null ? joinRootAndPath(relativeRoot, rawPath) : rawPath; + const fileName = getFileName(rawPath); items.push( await MenuItem.new({ text: "Show in Finder", @@ -1077,6 +1113,20 @@ export function GitDiffPanel({ }, }), ); + items.push( + await MenuItem.new({ + text: "Copy file name", + action: async () => { + await navigator.clipboard.writeText(fileName); + }, + }), + await MenuItem.new({ + text: "Copy file path", + action: async () => { + await navigator.clipboard.writeText(projectRelativePath); + }, + }), + ); } // Revert action for all selected files