@brimveyn/aimux 1.9.1 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -652,7 +652,11 @@ async function runGitAction(
652
652
  args: string[],
653
653
  pathToInvalidate?: string
654
654
  ): Promise<void> {
655
- const cwd = ctx.getCurrentSessionProjectPath()
655
+ const fallback = ctx.getCurrentSessionProjectPath()
656
+ const repoPath = pathToInvalidate
657
+ ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
658
+ : undefined
659
+ const cwd = repoPath ?? fallback
656
660
  if (!cwd) return
657
661
  const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
658
662
  if (result.exitCode !== 0) {
@@ -667,7 +671,8 @@ async function runGitAction(
667
671
  }
668
672
 
669
673
  async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
670
- const cwd = ctx.getCurrentSessionProjectPath()
674
+ const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
675
+ const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
671
676
  if (!cwd) return
672
677
  const absolute = `${cwd}/${path}`
673
678
  try {
package/src/app.tsx CHANGED
@@ -1,4 +1,8 @@
1
- import { type ResolvedConfig, setAutoCommitEnabled } from '@brimveyn/aimux-config'
1
+ import {
2
+ type ResolvedConfig,
3
+ setAutoCommitEnabled,
4
+ setMultiRepoConfig,
5
+ } from '@brimveyn/aimux-config'
2
6
  import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
3
7
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4
8
 
@@ -51,6 +55,7 @@ export function App({
51
55
  // Publish the auto-commit enabled flag before any children render so
52
56
  // actions (which live outside React) can read it synchronously.
53
57
  setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
58
+ setMultiRepoConfig(resolvedConfig.multiRepo)
54
59
 
55
60
  const keymapHandlers = useMemo(
56
61
  () => {
@@ -1,7 +1,10 @@
1
1
  import { useEffect } from 'react'
2
2
 
3
+ import type { DiscoveredRepo, GitFileEntry, GitRefreshPayload } from '../state/types'
4
+
5
+ import { useAppStore } from '../state/app-store'
3
6
  import { dispatchGlobal } from '../state/dispatch-ref'
4
- import { collectGitStatus } from './git-status'
7
+ import { collectGitStatus, type GitCollectResult } from './git-status'
5
8
 
6
9
  const BASE_INTERVAL_MS = 1000
7
10
  const MAX_INTERVAL_MS = 30_000
@@ -12,7 +15,54 @@ interface Options {
12
15
  headOffset: number
13
16
  }
14
17
 
18
+ function tagFiles(files: GitFileEntry[], repoPath: string): GitFileEntry[] {
19
+ return files.map((f) => ({ ...f, repoPath }))
20
+ }
21
+
22
+ async function collectAggregated(
23
+ repos: DiscoveredRepo[],
24
+ fallbackCwd: string,
25
+ headOffset: number
26
+ ): Promise<GitCollectResult> {
27
+ // Historical walking (HEAD~N) is per-repo and doesn't compose; fall back to
28
+ // the root/fallback repo when offset > 0 to preserve existing behaviour.
29
+ if (headOffset > 0) return collectGitStatus(fallbackCwd, { headOffset })
30
+
31
+ const results = await Promise.all(repos.map((r) => collectGitStatus(r.path, { headOffset: 0 })))
32
+ const files: GitFileEntry[] = []
33
+ let branch: string | null = null
34
+ let ahead = 0
35
+ let behind = 0
36
+ let anyOk = false
37
+ for (let i = 0; i < repos.length; i++) {
38
+ const res = results[i]
39
+ const repo = repos[i]
40
+ if (res?.kind !== 'ok' || !repo) continue
41
+ anyOk = true
42
+ if (repo.isRoot) {
43
+ branch = res.payload.branch
44
+ ahead = res.payload.ahead
45
+ behind = res.payload.behind
46
+ }
47
+ files.push(...tagFiles(res.payload.files, repo.path))
48
+ }
49
+ if (!anyOk) return { error: 'not-a-repo', kind: 'error' }
50
+ // If there was no root repo, surface the first discovered repo's branch label.
51
+ if (branch === null) {
52
+ const firstOk = results.find(
53
+ (r): r is Extract<GitCollectResult, { kind: 'ok' }> => r?.kind === 'ok'
54
+ )
55
+ if (firstOk) branch = firstOk.payload.branch
56
+ }
57
+ const payload: GitRefreshPayload = { ahead, behind, branch, files }
58
+ return { kind: 'ok', payload }
59
+ }
60
+
15
61
  export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options): void {
62
+ // Read repos from the store imperatively — changes trigger a fresh effect run
63
+ // because the repos identity is stable across ticks until set-repos fires.
64
+ const repos = useAppStore((s) => s.multiRepo.repos)
65
+
16
66
  useEffect(() => {
17
67
  if (!enabled || !projectPath) return undefined
18
68
 
@@ -28,7 +78,10 @@ export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options
28
78
  }
29
79
 
30
80
  const tick = async () => {
31
- const result = await collectGitStatus(projectPath, { headOffset })
81
+ const result =
82
+ repos.length > 0
83
+ ? await collectAggregated(repos, projectPath, headOffset)
84
+ : await collectGitStatus(projectPath, { headOffset })
32
85
  if (cancelled) return
33
86
  if (result.kind === 'ok') {
34
87
  dispatchGlobal({ payload: result.payload, type: 'git-refresh-success' })
@@ -53,5 +106,5 @@ export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options
53
106
  cancelled = true
54
107
  if (timer) clearTimeout(timer)
55
108
  }
56
- }, [enabled, projectPath, headOffset])
109
+ }, [enabled, projectPath, headOffset, repos])
57
110
  }
@@ -0,0 +1,95 @@
1
+ import type { Dirent } from 'node:fs'
2
+
3
+ import { readdir, stat } from 'node:fs/promises'
4
+ import { basename, join, relative } from 'node:path'
5
+
6
+ import type { DiscoveredRepo } from '../state/types'
7
+
8
+ const IGNORED_DIRS = new Set(['node_modules', 'target', 'dist', 'build', '.git'])
9
+
10
+ async function isGitRepo(path: string): Promise<boolean> {
11
+ try {
12
+ const st = await stat(join(path, '.git'))
13
+ return st.isDirectory() || st.isFile()
14
+ } catch {
15
+ return false
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Recursively scan `projectPath` down to `maxDepth` levels for directories
21
+ * that are themselves git repos. `maxDepth = 1` means only direct children.
22
+ *
23
+ * Internal recursion is breadth-limited: once a `.git` is found at a given
24
+ * path, we don't descend into it further (nested git repos-in-repos are rare
25
+ * and would duplicate status noise).
26
+ */
27
+ async function scan(
28
+ root: string,
29
+ current: string,
30
+ depth: number,
31
+ maxDepth: number,
32
+ out: DiscoveredRepo[]
33
+ ): Promise<void> {
34
+ if (depth > maxDepth) return
35
+ let entries: Dirent[] = []
36
+ try {
37
+ entries = (await readdir(current, { withFileTypes: true })) as Dirent[]
38
+ } catch {
39
+ return
40
+ }
41
+ await Promise.all(
42
+ entries.map(async (entry) => {
43
+ if (!entry.isDirectory()) return
44
+ if (entry.name.startsWith('.')) return
45
+ if (IGNORED_DIRS.has(entry.name)) return
46
+ const childPath = join(current, entry.name)
47
+ if (await isGitRepo(childPath)) {
48
+ const rel = relative(root, childPath)
49
+ out.push({ isRoot: false, name: rel || entry.name, path: childPath })
50
+ return
51
+ }
52
+ await scan(root, childPath, depth + 1, maxDepth, out)
53
+ })
54
+ )
55
+ }
56
+
57
+ async function discoverReposUncached(
58
+ projectPath: string,
59
+ maxDepth: number
60
+ ): Promise<DiscoveredRepo[]> {
61
+ const repos: DiscoveredRepo[] = []
62
+ if (await isGitRepo(projectPath)) {
63
+ repos.push({ isRoot: true, name: basename(projectPath), path: projectPath })
64
+ }
65
+ const children: DiscoveredRepo[] = []
66
+ await scan(projectPath, projectPath, 1, maxDepth, children)
67
+ children.sort((a, b) => a.name.localeCompare(b.name))
68
+ repos.push(...children)
69
+ return repos
70
+ }
71
+
72
+ interface CacheEntry {
73
+ maxDepth: number
74
+ repos: DiscoveredRepo[]
75
+ }
76
+
77
+ const cache = new Map<string, CacheEntry>()
78
+
79
+ /**
80
+ * Cached variant. The cache key is `projectPath` + `maxDepth`: a depth change
81
+ * invalidates the entry. The cache persists until `invalidateRepoCache()` or
82
+ * process exit — discovery is one-shot per session per depth setting.
83
+ */
84
+ export async function discoverRepos(projectPath: string, maxDepth = 1): Promise<DiscoveredRepo[]> {
85
+ const existing = cache.get(projectPath)
86
+ if (existing && existing.maxDepth === maxDepth) return existing.repos
87
+ const repos = await discoverReposUncached(projectPath, maxDepth)
88
+ cache.set(projectPath, { maxDepth, repos })
89
+ return repos
90
+ }
91
+
92
+ export function invalidateRepoCache(projectPath?: string): void {
93
+ if (projectPath === undefined) cache.clear()
94
+ else cache.delete(projectPath)
95
+ }
@@ -0,0 +1,28 @@
1
+ import { getMultiRepoConfig } from '@brimveyn/aimux-config'
2
+ import { useEffect } from 'react'
3
+
4
+ import { dispatchGlobal } from '../state/dispatch-ref'
5
+ import { discoverRepos } from './repo-discovery'
6
+
7
+ /**
8
+ * Discover nested git repos inside `projectPath` once per session and push
9
+ * the result into state. No-op when the multi-repo config flag is off.
10
+ */
11
+ export function useRepoDiscovery(projectPath: string | undefined): void {
12
+ useEffect(() => {
13
+ const cfg = getMultiRepoConfig()
14
+ if (!cfg.enabled || !projectPath) {
15
+ dispatchGlobal({ type: 'multi-repo-clear' })
16
+ return
17
+ }
18
+ let cancelled = false
19
+ void (async () => {
20
+ const repos = await discoverRepos(projectPath, cfg.maxDepth)
21
+ if (cancelled) return
22
+ dispatchGlobal({ repos, type: 'multi-repo-set-repos' })
23
+ })()
24
+ return () => {
25
+ cancelled = true
26
+ }
27
+ }, [projectPath])
28
+ }
@@ -16,6 +16,9 @@ export function sortFilesBySection(files: GitFileEntry[]): GitFileEntry[] {
16
16
  const sa = SECTION_RANK[a.section]
17
17
  const sb = SECTION_RANK[b.section]
18
18
  if (sa !== sb) return sa - sb
19
+ const ra = a.repoPath ?? ''
20
+ const rb = b.repoPath ?? ''
21
+ if (ra !== rb) return ra.localeCompare(rb)
19
22
  return a.path.localeCompare(b.path)
20
23
  })
21
24
  }
@@ -0,0 +1,60 @@
1
+ import {
2
+ type AppAction,
3
+ type AppState,
4
+ type DiscoveredRepo,
5
+ EMPTY_MULTI_REPO_STATE,
6
+ } from '../types'
7
+
8
+ /**
9
+ * Compute the shortest distinguishing prefix for each non-root repo name.
10
+ *
11
+ * Rule: start at length 1, group by that prefix. Any group with a single name
12
+ * is done; groups with collisions retry at length+1 until distinct or the name
13
+ * is exhausted. Root repos receive an empty prefix (no tag needed).
14
+ */
15
+ export function computeRepoPrefixes(repos: DiscoveredRepo[]): Record<string, string> {
16
+ const out: Record<string, string> = {}
17
+ const nonRoot = repos.filter((r) => !r.isRoot)
18
+ for (const r of repos.filter((r) => r.isRoot)) out[r.path] = ''
19
+
20
+ const assign = (group: DiscoveredRepo[], len: number): void => {
21
+ if (group.length === 0) return
22
+ if (group.length === 1) {
23
+ const only = group[0]
24
+ if (!only) return
25
+ out[only.path] = only.name.slice(0, Math.max(1, len))
26
+ return
27
+ }
28
+ const byKey = new Map<string, DiscoveredRepo[]>()
29
+ for (const repo of group) {
30
+ const key = repo.name.slice(0, len)
31
+ const bucket = byKey.get(key)
32
+ if (bucket) bucket.push(repo)
33
+ else byKey.set(key, [repo])
34
+ }
35
+ for (const [key, bucket] of byKey) {
36
+ if (bucket.length === 1 || bucket.every((r) => r.name.length < len)) {
37
+ for (const r of bucket) out[r.path] = key || r.name
38
+ } else {
39
+ assign(bucket, len + 1)
40
+ }
41
+ }
42
+ }
43
+ assign(nonRoot, 1)
44
+ return out
45
+ }
46
+
47
+ export function reduceMultiRepoState(state: AppState, action: AppAction): AppState | null {
48
+ switch (action.type) {
49
+ case 'multi-repo-set-repos': {
50
+ const prefixes = computeRepoPrefixes(action.repos)
51
+ return { ...state, multiRepo: { prefixes, repos: action.repos } }
52
+ }
53
+ case 'multi-repo-clear': {
54
+ if (state.multiRepo.repos.length === 0) return state
55
+ return { ...state, multiRepo: EMPTY_MULTI_REPO_STATE }
56
+ }
57
+ default:
58
+ return null
59
+ }
60
+ }
@@ -2,6 +2,7 @@ import { reduceAutoCommit } from './reducers/auto-commit-state'
2
2
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
3
3
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
4
4
  import { emptyModal, reduceModalState } from './reducers/modal-state'
5
+ import { reduceMultiRepoState } from './reducers/multi-repo-state'
5
6
  import { reduceSessionState } from './reducers/session-state'
6
7
  import { reduceTabState } from './reducers/tab-state'
7
8
  import { reduceUIState } from './reducers/ui-state'
@@ -10,6 +11,7 @@ import {
10
11
  type AppAction,
11
12
  type AppState,
12
13
  EMPTY_AUTO_COMMIT_STATE,
14
+ EMPTY_MULTI_REPO_STATE,
13
15
  type GitModeState,
14
16
  type GitPaneMode,
15
17
  type GitPanePosition,
@@ -98,6 +100,7 @@ export function createInitialState(
98
100
  type: 'session-picker',
99
101
  }
100
102
  : emptyModal(),
103
+ multiRepo: EMPTY_MULTI_REPO_STATE,
101
104
  pendingChords: null,
102
105
  sessionBar: {
103
106
  position: overrides.sessionBarPosition ?? 'top',
@@ -139,6 +142,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
139
142
  const autoCommitState = reduceAutoCommit(state, action)
140
143
  if (autoCommitState) return autoCommitState
141
144
 
145
+ const multiRepoState = reduceMultiRepoState(state, action)
146
+ if (multiRepoState) return multiRepoState
147
+
142
148
  switch (action.type) {
143
149
  case 'set-snippets':
144
150
  return { ...state, snippets: action.snippets }
@@ -188,6 +188,8 @@ export interface GitFileEntry {
188
188
  status: GitFileStatus
189
189
  added: number | null
190
190
  removed: number | null
191
+ /** Absolute path to the originating git repo. Set when the entry comes from a sub-repo. */
192
+ repoPath?: string
191
193
  }
192
194
 
193
195
  export type GitPanelError = 'not-a-repo' | 'unknown'
@@ -384,6 +386,24 @@ export interface SnippetRecord {
384
386
  content: string
385
387
  }
386
388
 
389
+ export interface DiscoveredRepo {
390
+ /** Absolute path to the repo. */
391
+ path: string
392
+ /** Label shown in UI (relative to the workspace root or repo basename). */
393
+ name: string
394
+ /** True when the repo is the session's projectPath itself. */
395
+ isRoot: boolean
396
+ }
397
+
398
+ export interface MultiRepoState {
399
+ /** Discovered sub-repos, ordered so root (if any) comes first. */
400
+ repos: DiscoveredRepo[]
401
+ /** Precomputed disambiguating prefix per repo path — empty string for the root repo. */
402
+ prefixes: Record<string, string>
403
+ }
404
+
405
+ export const EMPTY_MULTI_REPO_STATE: MultiRepoState = { prefixes: {}, repos: [] }
406
+
387
407
  export interface AppState {
388
408
  tabs: TabSession[]
389
409
  activeTabId: string | null
@@ -403,6 +423,7 @@ export interface AppState {
403
423
  gitPanel: GitPanelState
404
424
  gitMode: GitModeState
405
425
  autoCommit: AutoCommitState
426
+ multiRepo: MultiRepoState
406
427
  /** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
407
428
  pendingChords: string[] | null
408
429
  }
@@ -648,6 +669,11 @@ export type DataAction =
648
669
  | { type: 'delete-snippet'; snippetId: string }
649
670
  | { type: 'set-custom-commands'; customCommands: Record<AssistantId, string> }
650
671
 
672
+ // -- Multi-repo actions --
673
+ export type MultiRepoAction =
674
+ | { type: 'multi-repo-set-repos'; repos: DiscoveredRepo[] }
675
+ | { type: 'multi-repo-clear' }
676
+
651
677
  export type AppAction =
652
678
  | ModalAction
653
679
  | SessionAction
@@ -658,3 +684,4 @@ export type AppAction =
658
684
  | GitPanelAction
659
685
  | GitModeAction
660
686
  | AutoCommitAction
687
+ | MultiRepoAction
@@ -102,7 +102,7 @@ export function useDiffPrefetch(
102
102
  runTaskRef.current = async (task: Task) => {
103
103
  if (!projectPath) return
104
104
  try {
105
- const diff = await fetchDiff(projectPath, task.file, headOffset)
105
+ const diff = await fetchDiff(task.file.repoPath ?? projectPath, task.file, headOffset)
106
106
  if (task.controller.signal.aborted) return
107
107
  const hash = diffHash(diff.rawDiff)
108
108
  dispatchGlobal({ diff, hash, key: task.key, type: 'git-mode-set-diff' })
@@ -11,6 +11,7 @@ import type {
11
11
  GitPanePathConfig,
12
12
  } from '../../../state/types'
13
13
 
14
+ import { useAppStore } from '../../../state/app-store'
14
15
  import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
15
16
  import {
16
17
  buildGitTreeRows,
@@ -189,14 +190,20 @@ function renderFileRow(
189
190
  isSelected: boolean,
190
191
  fileListMode: GitFileListMode,
191
192
  pathConfig: GitPanePathConfig,
192
- diffCountConfig: GitPaneDiffCountConfig
193
+ diffCountConfig: GitPaneDiffCountConfig,
194
+ repoPrefixes: Record<string, string>
193
195
  ): ReactNode {
196
+ const t = getCurrentTokens()
194
197
  const file = row.file
195
198
  const hasNumstat = file.added !== null || file.removed !== null
196
- const bg = isSelected && !getTransparent() ? getCurrentTokens().selected : undefined
199
+ const bg = isSelected && !getTransparent() ? t.selected : undefined
197
200
  const onSelect = (): void => {
198
201
  dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
199
202
  }
203
+ // Repo disambiguation prefix: only in flat mode, only when the file came
204
+ // from a sub-repo (root repo files get an empty prefix).
205
+ const repoTag =
206
+ fileListMode === 'flat' && file.repoPath ? (repoPrefixes[file.repoPath] ?? '') : ''
200
207
  return (
201
208
  <box key={row.key} flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
202
209
  <box width={2} flexShrink={0} justifyContent="center">
@@ -204,6 +211,13 @@ function renderFileRow(
204
211
  <strong>{displayStatus(file)}</strong>
205
212
  </text>
206
213
  </box>
214
+ {repoTag ? (
215
+ <box flexShrink={0}>
216
+ <text fg={t.palette.primary} bg={bg}>
217
+ <strong>{repoTag}</strong>
218
+ </text>
219
+ </box>
220
+ ) : null}
207
221
  <box flexGrow={1} overflow="hidden" paddingLeft={fileListMode === 'tree' ? row.depth * 2 : 0}>
208
222
  {renderFileLabel(file, pathConfig, fileListMode)}
209
223
  </box>
@@ -224,7 +238,8 @@ function renderTreeSection(
224
238
  showListModeToggle: boolean,
225
239
  pathConfig: GitPanePathConfig,
226
240
  diffCountConfig: GitPaneDiffCountConfig,
227
- marginTop: number
241
+ marginTop: number,
242
+ repoPrefixes: Record<string, string>
228
243
  ): ReactNode {
229
244
  if (files.length === 0) return null
230
245
  const t = getCurrentTokens()
@@ -259,7 +274,8 @@ function renderTreeSection(
259
274
  row.key === selectedEntryKey,
260
275
  fileListMode,
261
276
  pathConfig,
262
- diffCountConfig
277
+ diffCountConfig,
278
+ repoPrefixes
263
279
  )
264
280
  )}
265
281
  </box>
@@ -317,6 +333,7 @@ export const GitPanel = memo(function GitPanel({
317
333
  }: GitPanelProps) {
318
334
  const t = useTokens()
319
335
  useTransparent()
336
+ const repoPrefixes = useAppStore((s) => s.multiRepo.prefixes)
320
337
  const sectionOrder = headOffset > 0 ? HISTORICAL_SECTION_ORDER : BASE_SECTION_ORDER
321
338
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
322
339
  const tree = useMemo(
@@ -380,7 +397,8 @@ export const GitPanel = memo(function GitPanel({
380
397
  key === toggleSection,
381
398
  pathConfig,
382
399
  diffCountConfig,
383
- priorHasFiles ? 1 : 0
400
+ priorHasFiles ? 1 : 0,
401
+ repoPrefixes
384
402
  )
385
403
  })}
386
404
  </scrollbox>
@@ -7,6 +7,7 @@ import type { ThemeId } from '../../themes'
7
7
  import { diffHash } from '../../../git/diff-hash'
8
8
  import { fetchDiff } from '../../../git/git-diff'
9
9
  import { useGitPanelPolling } from '../../../git/git-poller'
10
+ import { useRepoDiscovery } from '../../../git/use-repo-discovery'
10
11
  import { useAppStore } from '../../../state/app-store'
11
12
  import { dispatchGlobal } from '../../../state/dispatch-ref'
12
13
  import { getSelectedGitFile, gitFileKey } from '../../../state/git-tree'
@@ -122,6 +123,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
122
123
  : undefined
123
124
  const projectPath = currentSession?.projectPath
124
125
 
126
+ useRepoDiscovery(projectPath)
125
127
  useGitPanelPolling({ enabled: focusMode === 'git', headOffset: gitMode.headOffset, projectPath })
126
128
 
127
129
  const fileBarWidth = Math.max(20, Math.floor(dimensions.width * gitPane.diffModeRatio))
@@ -165,7 +167,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
165
167
  if (!selectedDiffKey) return
166
168
  if (diff || loading) return
167
169
  dispatchGlobal({ key: selectedDiffKey, loading: true, type: 'git-mode-set-loading' })
168
- void fetchDiff(projectPath, selectedFile, gitMode.headOffset)
170
+ void fetchDiff(selectedFile.repoPath ?? projectPath, selectedFile, gitMode.headOffset)
169
171
  .then((d) =>
170
172
  dispatchGlobal({
171
173
  diff: d,
@@ -3,6 +3,7 @@ import { memo, useRef } from 'react'
3
3
  import type { GitPanelState } from '../../../../state/types'
4
4
 
5
5
  import { useGitPanelPolling } from '../../../../git/git-poller'
6
+ import { useRepoDiscovery } from '../../../../git/use-repo-discovery'
6
7
  import { useAppStore } from '../../../../state/app-store'
7
8
  import { GitPanel } from '../git-panel'
8
9
 
@@ -24,6 +25,7 @@ export const GitPaneWidget = memo(function GitPaneWidget({ pollingEnabled }: Git
24
25
  : undefined
25
26
  const projectPath = currentSession?.projectPath
26
27
 
28
+ useRepoDiscovery(projectPath)
27
29
  useGitPanelPolling({ enabled: pollingEnabled, headOffset: 0, projectPath })
28
30
 
29
31
  const lastGoodRef = useRef<GitPanelState | null>(null)