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(