@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 +1 -1
- package/src/app-runtime/side-effects.ts +7 -2
- package/src/app.tsx +6 -1
- package/src/git/git-poller.ts +56 -3
- package/src/git/repo-discovery.ts +95 -0
- package/src/git/use-repo-discovery.ts +28 -0
- package/src/state/reducers/git-panel-state.ts +3 -0
- package/src/state/reducers/multi-repo-state.ts +60 -0
- package/src/state/store.ts +6 -0
- package/src/state/types.ts +27 -0
- package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +1 -1
- package/src/ui/components/git/git-panel.tsx +23 -5
- package/src/ui/components/git/git-view.tsx +3 -1
- package/src/ui/components/git/pane/git-pane-widget.tsx +2 -0
package/package.json
CHANGED
|
@@ -652,7 +652,11 @@ async function runGitAction(
|
|
|
652
652
|
args: string[],
|
|
653
653
|
pathToInvalidate?: string
|
|
654
654
|
): Promise<void> {
|
|
655
|
-
const
|
|
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
|
|
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 {
|
|
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
|
() => {
|
package/src/git/git-poller.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -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 }
|
package/src/state/types.ts
CHANGED
|
@@ -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() ?
|
|
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)
|