From 5014b3960e109c5eb872522d65ef65c7bf8f5908 Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Wed, 4 Feb 2026 23:44:15 +0200 Subject: [PATCH 1/3] Add copy file name/path actions to git menu --- .../git/components/GitDiffPanel.test.tsx | 43 +++++++++++++++++++ src/features/git/components/GitDiffPanel.tsx | 21 +++++++++ 2 files changed, 64 insertions(+) diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index 43f98d2a..e80f26ed 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("/tmp/repo/src/sample.ts"); + }); + it("resolves relative git roots against the workspace path", async () => { revealItemInDir.mockClear(); menuNew.mockClear(); diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index ccf36c37..da8c13f1 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -170,6 +170,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 +1052,7 @@ export function GitDiffPanel({ const absolutePath = resolvedRoot ? joinRootAndPath(resolvedRoot, rawPath) : rawPath; + const fileName = getFileName(rawPath); items.push( await MenuItem.new({ text: "Show in Finder", @@ -1077,6 +1084,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(absolutePath); + }, + }), + ); } // Revert action for all selected files From 8c105844c8a032c2c3550618b038e8cf05d20d16 Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Thu, 5 Feb 2026 09:02:30 +0200 Subject: [PATCH 2/3] Copy git file path relative to workspace --- .../git/components/GitDiffPanel.test.tsx | 31 ++++++++++++++++++- src/features/git/components/GitDiffPanel.tsx | 9 +++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index e80f26ed..359212c3 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -148,7 +148,7 @@ describe("GitDiffPanel", () => { await copyPathItem.action(); expect(clipboardWriteText).toHaveBeenCalledWith("sample.ts"); - expect(clipboardWriteText).toHaveBeenCalledWith("/tmp/repo/src/sample.ts"); + expect(clipboardWriteText).toHaveBeenCalledWith("src/sample.ts"); }); it("resolves relative git roots against the workspace path", async () => { @@ -179,4 +179,33 @@ 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"); + }); }); diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index da8c13f1..b657bdf4 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -1052,6 +1052,13 @@ export function GitDiffPanel({ const absolutePath = resolvedRoot ? joinRootAndPath(resolvedRoot, rawPath) : rawPath; + const projectRelativePath = + workspacePath && resolvedRoot && resolvedRoot.startsWith(workspacePath) + ? joinRootAndPath( + resolvedRoot.slice(workspacePath.length).replace(/^\/+/, ""), + rawPath, + ) + : rawPath; const fileName = getFileName(rawPath); items.push( await MenuItem.new({ @@ -1094,7 +1101,7 @@ export function GitDiffPanel({ await MenuItem.new({ text: "Copy file path", action: async () => { - await navigator.clipboard.writeText(absolutePath); + await navigator.clipboard.writeText(projectRelativePath); }, }), ); From 0bea97a1825acc6299c96284156e63eeaaab79be Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Thu, 5 Feb 2026 12:11:23 +0200 Subject: [PATCH 3/3] fix: bound-check workspace prefix --- .../git/components/GitDiffPanel.test.tsx | 29 ++++++++++++++++ src/features/git/components/GitDiffPanel.tsx | 34 +++++++++++++++---- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index 359212c3..bd309af6 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -208,4 +208,33 @@ describe("GitDiffPanel", () => { 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 b657bdf4..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); } @@ -1052,13 +1075,12 @@ export function GitDiffPanel({ const absolutePath = resolvedRoot ? joinRootAndPath(resolvedRoot, rawPath) : rawPath; + const relativeRoot = + workspacePath && resolvedRoot + ? getRelativePathWithin(workspacePath, resolvedRoot) + : null; const projectRelativePath = - workspacePath && resolvedRoot && resolvedRoot.startsWith(workspacePath) - ? joinRootAndPath( - resolvedRoot.slice(workspacePath.length).replace(/^\/+/, ""), - rawPath, - ) - : rawPath; + relativeRoot !== null ? joinRootAndPath(relativeRoot, rawPath) : rawPath; const fileName = getFileName(rawPath); items.push( await MenuItem.new({