diff --git a/src/features/app/components/WorktreeCard.tsx b/src/features/app/components/WorktreeCard.tsx index 853a3fbb..5406ccb8 100644 --- a/src/features/app/components/WorktreeCard.tsx +++ b/src/features/app/components/WorktreeCard.tsx @@ -7,7 +7,7 @@ type WorktreeCardProps = { isActive: boolean; isDeleting?: boolean; onSelectWorkspace: (id: string) => void; - onShowWorktreeMenu: (event: MouseEvent, workspaceId: string) => void; + onShowWorktreeMenu: (event: MouseEvent, worktree: WorkspaceInfo) => void; onToggleWorkspaceCollapse: (workspaceId: string, collapsed: boolean) => void; onConnectWorkspace: (workspace: WorkspaceInfo) => void; children?: React.ReactNode; @@ -41,7 +41,7 @@ export function WorktreeCard({ }} onContextMenu={(event) => { if (!isDeleting) { - onShowWorktreeMenu(event, worktree.id); + onShowWorktreeMenu(event, worktree); } }} onKeyDown={(event) => { diff --git a/src/features/app/components/WorktreeSection.tsx b/src/features/app/components/WorktreeSection.tsx index aea8655a..51695e6f 100644 --- a/src/features/app/components/WorktreeSection.tsx +++ b/src/features/app/components/WorktreeSection.tsx @@ -48,7 +48,7 @@ type WorktreeSectionProps = { threadId: string, canPin: boolean, ) => void; - onShowWorktreeMenu: (event: MouseEvent, workspaceId: string) => void; + onShowWorktreeMenu: (event: MouseEvent, worktree: WorkspaceInfo) => void; onToggleExpanded: (workspaceId: string) => void; onLoadOlderThreads: (workspaceId: string) => void; }; diff --git a/src/features/app/hooks/useSidebarMenus.test.tsx b/src/features/app/hooks/useSidebarMenus.test.tsx new file mode 100644 index 00000000..01189fa1 --- /dev/null +++ b/src/features/app/hooks/useSidebarMenus.test.tsx @@ -0,0 +1,101 @@ +/** @vitest-environment jsdom */ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { WorkspaceInfo } from "../../../types"; +import { useSidebarMenus } from "./useSidebarMenus"; + +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: menuNew }, + MenuItem: { new: menuItemNew }, +})); + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => ({ scaleFactor: () => 1 }), +})); + +vi.mock("@tauri-apps/api/dpi", () => ({ + LogicalPosition: class LogicalPosition { + x: number; + y: number; + constructor(x: number, y: number) { + this.x = x; + this.y = y; + } + }, +})); + +const revealItemInDir = vi.hoisted(() => vi.fn()); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + revealItemInDir: (...args: unknown[]) => revealItemInDir(...args), +})); + +vi.mock("../../../services/toasts", () => ({ + pushErrorToast: vi.fn(), +})); + +describe("useSidebarMenus", () => { + it("adds a show in finder option for worktrees", async () => { + const onDeleteThread = vi.fn(); + const onSyncThread = vi.fn(); + const onPinThread = vi.fn(); + const onUnpinThread = vi.fn(); + const isThreadPinned = vi.fn(() => false); + const onRenameThread = vi.fn(); + const onReloadWorkspaceThreads = vi.fn(); + const onDeleteWorkspace = vi.fn(); + const onDeleteWorktree = vi.fn(); + + const { result } = renderHook(() => + useSidebarMenus({ + onDeleteThread, + onSyncThread, + onPinThread, + onUnpinThread, + isThreadPinned, + onRenameThread, + onReloadWorkspaceThreads, + onDeleteWorkspace, + onDeleteWorktree, + }), + ); + + const worktree: WorkspaceInfo = { + id: "worktree-1", + name: "feature/test", + path: "/tmp/worktree-1", + kind: "worktree", + connected: true, + settings: { + sidebarCollapsed: false, + worktreeSetupScript: "", + }, + worktree: { branch: "feature/test" }, + }; + + const event = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + clientX: 12, + clientY: 34, + } as unknown as ReactMouseEvent; + + await result.current.showWorktreeMenu(event, worktree); + + 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/worktree-1"); + }); +}); diff --git a/src/features/app/hooks/useSidebarMenus.ts b/src/features/app/hooks/useSidebarMenus.ts index 97e0bc37..ff4f1eba 100644 --- a/src/features/app/hooks/useSidebarMenus.ts +++ b/src/features/app/hooks/useSidebarMenus.ts @@ -3,6 +3,9 @@ import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { WorkspaceInfo } from "../../../types"; +import { pushErrorToast } from "../../../services/toasts"; + type SidebarMenuHandlers = { onDeleteThread: (workspaceId: string, threadId: string) => void; onSyncThread: (workspaceId: string, threadId: string) => void; @@ -110,18 +113,43 @@ export function useSidebarMenus({ ); const showWorktreeMenu = useCallback( - async (event: MouseEvent, workspaceId: string) => { + async (event: MouseEvent, worktree: WorkspaceInfo) => { event.preventDefault(); event.stopPropagation(); const reloadItem = await MenuItem.new({ text: "Reload threads", - action: () => onReloadWorkspaceThreads(workspaceId), + action: () => onReloadWorkspaceThreads(worktree.id), + }); + const revealItem = await MenuItem.new({ + text: "Show in Finder", + action: async () => { + if (!worktree.path) { + return; + } + try { + const { revealItemInDir } = await import( + "@tauri-apps/plugin-opener" + ); + await revealItemInDir(worktree.path); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + pushErrorToast({ + title: "Couldn't show worktree in Finder", + message, + }); + console.warn("Failed to reveal worktree", { + message, + workspaceId: worktree.id, + path: worktree.path, + }); + } + }, }); const deleteItem = await MenuItem.new({ text: "Delete worktree", - action: () => onDeleteWorktree(workspaceId), + action: () => onDeleteWorktree(worktree.id), }); - const menu = await Menu.new({ items: [reloadItem, deleteItem] }); + const menu = await Menu.new({ items: [reloadItem, revealItem, deleteItem] }); const window = getCurrentWindow(); const position = new LogicalPosition(event.clientX, event.clientY); await menu.popup(position, window);