Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 70 additions & 3 deletions src/features/git/components/GitDiffPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -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 = {
Expand Down Expand Up @@ -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(
<GitDiffPanel
{...baseProps}
workspacePath="/tmp/repo"
gitRoot="/tmp/repo/"
unstagedFiles={[
{ path: "src/sample.ts", status: "M", additions: 1, deletions: 0 },
]}
/>,
);

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(
<GitDiffPanel
{...baseProps}
workspacePath="/tmp/repo"
gitRoot="apps"
unstagedFiles={[
{ path: "src/sample.ts", status: "M", additions: 1, deletions: 0 },
]}
/>,
);

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");
});
});
75 changes: 75 additions & 0 deletions src/features/git/components/GitDiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -617,6 +643,7 @@ function GitLogEntryRow({

export function GitDiffPanel({
workspaceId = null,
workspacePath = null,
mode,
onModeChange,
filePanelMode,
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -1007,6 +1041,44 @@ export function GitDiffPanel({
);
}

if (targetPaths.length === 1) {
const rawPath = targetPaths[0];
const absolutePath = resolvedRoot
? joinRootAndPath(resolvedRoot, rawPath)
: rawPath;
Comment on lines +1045 to +1048

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve relative git roots to absolute before reveal

When a user picks a git root inside the workspace, the app stores a relative path (e.g., subdir) in settings, so resolvedRoot here can be relative rather than absolute. In that case absolutePath becomes something like subdir/file, and revealItemInDir will run against a relative path (typically the app’s CWD), which can fail or reveal the wrong file. This shows up whenever the git root is a subfolder of the workspace. Consider resolving relative git roots against workspacePath before calling revealItemInDir.

Useful? React with 👍 / 👎.

items.push(
await MenuItem.new({
text: "Show in Finder",
action: async () => {
try {
if (!resolvedRoot && !absolutePath.startsWith("/")) {
pushErrorToast({
Comment on lines +1053 to +1055

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept Windows absolute paths when git root is empty

The guard !resolvedRoot && !absolutePath.startsWith("/") assumes absolute paths only start with /, so on Windows an absolute path like C:/repo/file (or a UNC path) will incorrectly trigger the "Select a git root first" error even though absolutePath is already absolute. This means the new menu item will fail in cases where the diff path is already absolute but gitRoot is unset, which is plausible for external diffs or non-standard roots. Consider using the existing isAbsolutePath helper (or similar) here so Windows absolute paths are accepted.

Useful? React with 👍 / 👎.

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(
Expand Down Expand Up @@ -1035,6 +1107,9 @@ export function GitDiffPanel({
onStageFile,
onRevertFile,
discardFiles,
gitRoot,
gitRootCandidates,
workspacePath,
],
);
const logCountLabel = logTotal
Expand Down
1 change: 1 addition & 0 deletions src/features/layout/hooks/useLayoutNodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult {
gitDiffPanelNode = (
<GitDiffPanel
workspaceId={options.activeWorkspace?.id ?? null}
workspacePath={options.activeWorkspace?.path ?? null}
mode={options.gitPanelMode}
onModeChange={options.onGitPanelModeChange}
filePanelMode={options.filePanelMode}
Expand Down