diff --git a/src/main/ipc/integrate.ts b/src/main/ipc/integrate.ts index 2e21398..db6bda5 100644 --- a/src/main/ipc/integrate.ts +++ b/src/main/ipc/integrate.ts @@ -67,4 +67,9 @@ export function registerIntegrationHandlers(): void { ipcMain.handle(IPC.openFileInEditor, (_e, repoPath: string, path: string) => shell.openPath(join(repoPath, path)).then(() => undefined) ) + // `join` also turns git's POSIX-relative path into native separators, which + // Explorer needs to select the item on Windows. + ipcMain.handle(IPC.revealFile, (_e, repoPath: string, path: string) => + shell.showItemInFolder(join(repoPath, path)) + ) } diff --git a/src/preload/index.ts b/src/preload/index.ts index f8aec4f..99f0f9f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -108,6 +108,7 @@ const api: GitGroveApi = { openMergeTool: (repoPath, path) => ipcRenderer.invoke(IPC.openMergeTool, repoPath, path), mergeToolName: (repoPath) => ipcRenderer.invoke(IPC.mergeToolName, repoPath), openFileInEditor: (repoPath, path) => ipcRenderer.invoke(IPC.openFileInEditor, repoPath, path), + revealFile: (repoPath, path) => ipcRenderer.invoke(IPC.revealFile, repoPath, path), stashList: (repoPath) => ipcRenderer.invoke(IPC.stashList, repoPath), stashFiles: (repoPath, sha) => ipcRenderer.invoke(IPC.stashFiles, repoPath, sha), stashSave: (repoPath, opts) => ipcRenderer.invoke(IPC.stashSave, repoPath, opts), diff --git a/src/renderer/src/components/changes/ChangesView.tsx b/src/renderer/src/components/changes/ChangesView.tsx index 3724a0b..3fcd994 100644 --- a/src/renderer/src/components/changes/ChangesView.tsx +++ b/src/renderer/src/components/changes/ChangesView.tsx @@ -14,6 +14,7 @@ import { DEFAULT_FILTER_TYPES, useFileFilter } from '@/components/common/FileFil import { type FileHistoryMode, fileHistoryItems } from '@/components/common/fileHistoryItems' import { Popover } from '@/components/common/Popover' import { Resizer } from '@/components/common/Resizer' +import { revealFileItem } from '@/components/common/revealFileItem' import { TrimmedPath } from '@/components/common/TrimmedPath' import { WorkingFileList } from '@/components/common/WorkingFileList' import type { FileSelection } from '@/lib/commit-selection' @@ -450,6 +451,7 @@ export function ChangesView({ icon: , onClick: () => gg.openFileInEditor(repoPath, file.path) }, + revealFileItem(file, repoPath), {}, ...copyPathItems([file], repoPath) ] @@ -493,6 +495,7 @@ export function ChangesView({ icon: , onClick: () => gg.openFileInEditor(repoPath, file.path) }, + revealFileItem(file, repoPath), {}, ...copyPathItems([file], repoPath) ] diff --git a/src/renderer/src/components/common/revealFileItem.test.tsx b/src/renderer/src/components/common/revealFileItem.test.tsx new file mode 100644 index 0000000..df8eb07 --- /dev/null +++ b/src/renderer/src/components/common/revealFileItem.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test' +import type { ChangedFile } from '@shared/types' + +// The builder reads `window.gitgrove` (platform at module load, revealFile on +// click); tests run without a DOM, so stub the minimal bridge first and import +// dynamically (a static import would be hoisted above the stub). +const revealed: [string, string][] = [] +;(globalThis as { window?: unknown }).window = { + gitgrove: { + platform: 'darwin', + revealFile: async (repoPath: string, path: string) => { + revealed.push([repoPath, path]) + } + } +} +const { revealFileItem } = await import('./revealFileItem') + +const file = (status: ChangedFile['status']): ChangedFile => ({ + path: 'src/app.ts', + status, + staged: false +}) + +describe('revealFileItem', () => { + test('uses the platform-native label', () => { + expect(revealFileItem(file('modified'), '/repo').label).toBe('Reveal in Finder') + }) + + test('reveals the file by its repo-relative path', () => { + revealFileItem(file('untracked'), '/repo').onClick?.() + expect(revealed).toEqual([['/repo', 'src/app.ts']]) + }) + + test('is enabled for files on disk, disabled for a deleted change', () => { + expect(revealFileItem(file('modified'), '/repo').disabled).toBe(false) + expect(revealFileItem(file('untracked'), '/repo').disabled).toBe(false) + expect(revealFileItem(file('deleted'), '/repo').disabled).toBe(true) + }) +}) diff --git a/src/renderer/src/components/common/revealFileItem.tsx b/src/renderer/src/components/common/revealFileItem.tsx new file mode 100644 index 0000000..8904f28 --- /dev/null +++ b/src/renderer/src/components/common/revealFileItem.tsx @@ -0,0 +1,20 @@ +// The "Reveal in Finder" / "Show in Explorer" item offered for a single file +// that lives in the working tree (Changes, the search palette). Lists of files +// from other commits, branches or stashes don't offer it: the version shown +// there needn't exist on disk, so revealing would point at the wrong file — or +// nothing. A deleted change has no file left to select, so it's disabled. + +import type { ChangedFile } from '@shared/types' +import { Icon } from '@/lib/icons' +import { revealLabel } from '@/lib/repo-actions' +import type { ContextMenuItem } from './ContextMenu' + +/** Context-menu item that selects `file` in the OS file manager. */ +export function revealFileItem(file: ChangedFile, repoPath: string): ContextMenuItem { + return { + label: revealLabel, + icon: , + disabled: file.status === 'deleted', + onClick: () => window.gitgrove.revealFile(repoPath, file.path) + } +} diff --git a/src/renderer/src/components/palette/paletteActions.tsx b/src/renderer/src/components/palette/paletteActions.tsx index f09fd01..cfb8048 100644 --- a/src/renderer/src/components/palette/paletteActions.tsx +++ b/src/renderer/src/components/palette/paletteActions.tsx @@ -16,6 +16,7 @@ import { import type { ContextMenuItem } from '@/components/common/ContextMenu' import { copyPathItems } from '@/components/common/copyPathItems' import { type FileHistoryMode, fileHistoryItems } from '@/components/common/fileHistoryItems' +import { revealFileItem } from '@/components/common/revealFileItem' import type { GraphRevealTarget } from '@/components/graph/reveal' import { Icon } from '@/lib/icons' import { platform } from '@/lib/platform' @@ -158,6 +159,8 @@ export function itemMenu( disabled: change?.status === 'deleted', onClick: () => gg.openFileInEditor(repoPath, path) }, + revealFileItem(file, repoPath), + {}, ...copyPathItems([file], repoPath) ] } diff --git a/src/renderer/src/lib/repo-actions.ts b/src/renderer/src/lib/repo-actions.ts index eb3d37f..eb01a1c 100644 --- a/src/renderer/src/lib/repo-actions.ts +++ b/src/renderer/src/lib/repo-actions.ts @@ -1,5 +1,5 @@ -// Shared labels for the repository context-menu actions. Kept platform-aware so -// the menu reads natively ("Reveal in Finder" on macOS, "Show in Explorer" on +// Shared labels for the repository (and working-tree file) context-menu +// actions. Kept platform-aware so the menu reads natively ("Reveal in Finder" on macOS, "Show in Explorer" on // Windows) and names the remote host the user actually has. import { isMac, platform } from './platform' diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5d807be..a2c8fb2 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -145,6 +145,7 @@ export const IPC = { openMergeTool: 'repo:conflict:merge-tool', mergeToolName: 'repo:conflict:merge-tool-name', openFileInEditor: 'repo:open-file', + revealFile: 'repo:reveal-file', // stash stashList: 'repo:stash:list', stashFiles: 'repo:stash:files', @@ -483,6 +484,8 @@ export interface GitGroveApi { mergeToolName(repoPath: string): Promise /** Open a repo file with the OS default application. */ openFileInEditor(repoPath: string, path: string): Promise + /** Select a working-tree file in the OS file manager (Finder/Explorer/…). */ + revealFile(repoPath: string, path: string): Promise // ── Stash ── stashList(repoPath: string): Promise stashSave(