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 = (