diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index 3fd7145f..43f98d2a 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,59 @@ describe("GitDiffPanel", () => { expect(onCommit).toHaveBeenCalledTimes(1); }); + it("adds a show in finder option for file context menus", async () => { + 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[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"); + }); + + 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[menuNew.mock.calls.length - 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 8e9167c1..ccf36c37 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,30 @@ 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) { + return relativePath; + } + const normalizedPath = relativePath.replace(/^\/+/, ""); + return `${normalizedRoot}/${normalizedPath}`; +} + function getStatusSymbol(status: string) { switch (status) { case "A": @@ -617,6 +643,7 @@ function GitLogEntryRow({ export function GitDiffPanel({ workspaceId = null, + workspacePath = null, mode, onModeChange, filePanelMode, @@ -968,6 +995,13 @@ export function GitDiffPanel({ const fileCount = targetPaths.length; const plural = fileCount > 1 ? "s" : ""; const countSuffix = fileCount > 1 ? ` (${fileCount})` : ""; + const normalizedRoot = resolveRootPath(gitRoot, workspacePath); + const inferredRoot = + !normalizedRoot && gitRootCandidates.length === 1 + ? resolveRootPath(gitRootCandidates[0], workspacePath) + : ""; + 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 +1041,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 +1107,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 = (