From 9fc86d4c45ca0a178d3b142a33ab232b52b5d7d4 Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Wed, 4 Feb 2026 11:07:53 +0200 Subject: [PATCH 1/3] Add show in finder for git files --- .../git/components/GitDiffPanel.test.tsx | 44 +++++++++++++- src/features/git/components/GitDiffPanel.tsx | 60 +++++++++++++++++++ src/features/layout/hooks/useLayoutNodes.tsx | 1 + 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index 3fd7145f..3a7e51c1 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -1,12 +1,17 @@ /** @vitest-environment jsdom */ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { GitLogEntry } from "../../../types"; import { GitDiffPanel } from "./GitDiffPanel"; +const menuNew = vi.hoisted(() => + vi.fn(async ({ items }) => ({ popup: vi.fn(), items })), +); +const menuItemNew = vi.hoisted(() => vi.fn(async (options) => options)); + vi.mock("@tauri-apps/api/menu", () => ({ - Menu: { new: vi.fn(async () => ({ popup: vi.fn() })) }, - MenuItem: { new: vi.fn(async () => ({})) }, + Menu: { new: menuNew }, + MenuItem: { new: menuItemNew }, })); vi.mock("@tauri-apps/api/window", () => ({ @@ -24,14 +29,21 @@ vi.mock("@tauri-apps/api/dpi", () => ({ }, })); +const revealItemInDir = vi.hoisted(() => vi.fn()); + vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(), + revealItemInDir: (...args: unknown[]) => revealItemInDir(...args), })); vi.mock("@tauri-apps/plugin-dialog", () => ({ ask: vi.fn(async () => true), })); +vi.mock("../../../services/toasts", () => ({ + pushErrorToast: vi.fn(), +})); + const logEntries: GitLogEntry[] = []; const baseProps = { @@ -69,4 +81,30 @@ describe("GitDiffPanel", () => { expect(onCommit).toHaveBeenCalledTimes(1); }); + it("adds a show in finder option for file context menus", async () => { + render( + , + ); + + const row = screen.getByText("sample").closest(".diff-row"); + expect(row).not.toBeNull(); + fireEvent.contextMenu(row as Element); + + await waitFor(() => expect(menuNew).toHaveBeenCalled()); + const menuArgs = menuNew.mock.calls[0]?.[0]; + const revealItem = menuArgs.items.find( + (item: { text: string }) => item.text === "Show in Finder", + ); + + expect(revealItem).toBeDefined(); + await revealItem.action(); + expect(revealItemInDir).toHaveBeenCalledWith("/tmp/repo/src/sample.ts"); + }); }); diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index 8e9167c1..56bc2828 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -21,9 +21,11 @@ import X from "lucide-react/dist/esm/icons/x"; import { useMemo, useState, useCallback, useEffect, useRef } from "react"; import { formatRelativeTime } from "../../../utils/time"; import { PanelTabs, type PanelTabId } from "../../layout/components/PanelTabs"; +import { pushErrorToast } from "../../../services/toasts"; type GitDiffPanelProps = { workspaceId?: string | null; + workspacePath?: string | null; mode: "diff" | "log" | "issues" | "prs"; onModeChange: (mode: "diff" | "log" | "issues" | "prs") => void; filePanelMode: PanelTabId; @@ -144,6 +146,15 @@ function normalizeRootPath(value: string | null | undefined) { return value.replace(/\\/g, "/").replace(/\/+$/, ""); } +function joinRootAndPath(root: string, relativePath: string) { + const normalizedRoot = normalizeRootPath(root); + if (!normalizedRoot) { + return relativePath; + } + const normalizedPath = relativePath.replace(/^\/+/, ""); + return `${normalizedRoot}/${normalizedPath}`; +} + function getStatusSymbol(status: string) { switch (status) { case "A": @@ -617,6 +628,7 @@ function GitLogEntryRow({ export function GitDiffPanel({ workspaceId = null, + workspacePath = null, mode, onModeChange, filePanelMode, @@ -968,6 +980,13 @@ export function GitDiffPanel({ const fileCount = targetPaths.length; const plural = fileCount > 1 ? "s" : ""; const countSuffix = fileCount > 1 ? ` (${fileCount})` : ""; + const normalizedRoot = normalizeRootPath(gitRoot); + const inferredRoot = + !normalizedRoot && gitRootCandidates.length === 1 + ? normalizeRootPath(gitRootCandidates[0]) + : ""; + const fallbackRoot = normalizeRootPath(workspacePath); + const resolvedRoot = normalizedRoot || inferredRoot || fallbackRoot; // Separate files by their section for stage/unstage operations const stagedPaths = targetPaths.filter((p) => @@ -1007,6 +1026,44 @@ export function GitDiffPanel({ ); } + if (targetPaths.length === 1) { + const rawPath = targetPaths[0]; + const absolutePath = resolvedRoot + ? joinRootAndPath(resolvedRoot, rawPath) + : rawPath; + items.push( + await MenuItem.new({ + text: "Show in Finder", + action: async () => { + try { + if (!resolvedRoot && !absolutePath.startsWith("/")) { + pushErrorToast({ + title: "Couldn't show file in Finder", + message: "Select a git root first.", + }); + return; + } + const { revealItemInDir } = await import( + "@tauri-apps/plugin-opener" + ); + await revealItemInDir(absolutePath); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + pushErrorToast({ + title: "Couldn't show file in Finder", + message, + }); + console.warn("Failed to reveal file", { + message, + path: absolutePath, + }); + } + }, + }), + ); + } + // Revert action for all selected files if (onRevertFile) { items.push( @@ -1035,6 +1092,9 @@ export function GitDiffPanel({ onStageFile, onRevertFile, discardFiles, + gitRoot, + gitRootCandidates, + workspacePath, ], ); const logCountLabel = logTotal diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 4eed4439..c08d140c 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -780,6 +780,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { gitDiffPanelNode = ( Date: Wed, 4 Feb 2026 16:08:18 +0200 Subject: [PATCH 2/3] Fix git root resolution test and finder path --- .../git/components/GitDiffPanel.test.tsx | 33 +++++++++++++++++-- src/features/git/components/GitDiffPanel.tsx | 19 +++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index 3a7e51c1..87b993be 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -82,7 +82,7 @@ describe("GitDiffPanel", () => { }); it("adds a show in finder option for file context menus", async () => { - render( + const { container } = render( { />, ); - const row = screen.getByText("sample").closest(".diff-row"); + const row = container.querySelector(".diff-row"); expect(row).not.toBeNull(); fireEvent.contextMenu(row as Element); @@ -107,4 +107,33 @@ describe("GitDiffPanel", () => { await revealItem.action(); expect(revealItemInDir).toHaveBeenCalledWith("/tmp/repo/src/sample.ts"); }); + + it("resolves relative git roots against the workspace path", async () => { + revealItemInDir.mockClear(); + menuNew.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.at(-1)?.[0]; + const revealItem = menuArgs.items.find( + (item: { text: string }) => item.text === "Show in Finder", + ); + + expect(revealItem).toBeDefined(); + await revealItem.action(); + expect(revealItemInDir).toHaveBeenCalledWith("/tmp/repo/apps/src/sample.ts"); + }); }); diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index 56bc2828..ccf36c37 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -146,6 +146,21 @@ function normalizeRootPath(value: string | null | undefined) { return value.replace(/\\/g, "/").replace(/\/+$/, ""); } +function isAbsolutePath(value: string) { + return value.startsWith("/") || /^[A-Za-z]:\//.test(value); +} + +function resolveRootPath(root: string | null | undefined, workspacePath: string | null | undefined) { + const normalized = normalizeRootPath(root); + if (!normalized) { + return ""; + } + if (workspacePath && !isAbsolutePath(normalized)) { + return joinRootAndPath(workspacePath, normalized); + } + return normalized; +} + function joinRootAndPath(root: string, relativePath: string) { const normalizedRoot = normalizeRootPath(root); if (!normalizedRoot) { @@ -980,10 +995,10 @@ export function GitDiffPanel({ const fileCount = targetPaths.length; const plural = fileCount > 1 ? "s" : ""; const countSuffix = fileCount > 1 ? ` (${fileCount})` : ""; - const normalizedRoot = normalizeRootPath(gitRoot); + const normalizedRoot = resolveRootPath(gitRoot, workspacePath); const inferredRoot = !normalizedRoot && gitRootCandidates.length === 1 - ? normalizeRootPath(gitRootCandidates[0]) + ? resolveRootPath(gitRootCandidates[0], workspacePath) : ""; const fallbackRoot = normalizeRootPath(workspacePath); const resolvedRoot = normalizedRoot || inferredRoot || fallbackRoot; From c3d7fd17c177143761cf530fd8b26f72ab699788 Mon Sep 17 00:00:00 2001 From: Ivan Borinschi Date: Wed, 4 Feb 2026 16:12:21 +0200 Subject: [PATCH 3/3] Fix typecheck for GitDiffPanel test --- src/features/git/components/GitDiffPanel.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index 87b993be..43f98d2a 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -127,7 +127,7 @@ describe("GitDiffPanel", () => { fireEvent.contextMenu(row as Element); await waitFor(() => expect(menuNew).toHaveBeenCalled()); - const menuArgs = menuNew.mock.calls.at(-1)?.[0]; + const menuArgs = menuNew.mock.calls[menuNew.mock.calls.length - 1]?.[0]; const revealItem = menuArgs.items.find( (item: { text: string }) => item.text === "Show in Finder", );