@brimveyn/aimux 1.9.0 → 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 +2 -2
- package/src/app-runtime/side-effects.ts +13 -3
- package/src/app.tsx +26 -17
- 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/input/keymap/describe-bindings.ts +63 -2
- package/src/pty/terminal-snapshot.ts +7 -5
- package/src/state/reducers/git-mode-state.ts +58 -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 +38 -6
- package/src/ui/breaking-update-screen.tsx +2 -2
- package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
- package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
- package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
- package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
- package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +9 -18
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
- package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
- package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +32 -10
- package/src/ui/components/{git-view.tsx → git/git-view.tsx} +73 -38
- package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
- package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +6 -4
- package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
- package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
- package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
- package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
- package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
- package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
- package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
- package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
- package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
- package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
- package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
- package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
- package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
- package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
- package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
- package/src/ui/components/modals/shared/form.tsx +164 -0
- package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
- package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
- package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
- package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
- package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
- package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
- package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
- package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
- package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
- package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
- package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
- package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
- package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
- package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
- package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
- package/src/ui/root.tsx +24 -22
- package/src/ui/theme-store.ts +30 -26
- package/src/ui/themes.ts +1 -0
- package/src/ui/components/create-session-modal.tsx +0 -96
- package/src/ui/components/diff-renderer/build-rows.ts +0 -349
- package/src/ui/components/diff-renderer/highlight.ts +0 -44
- package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
- package/src/ui/components/session-name-modal.tsx +0 -11
- package/src/ui/components/snippet-editor-modal.tsx +0 -40
- /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
- /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
- /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
- /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.9.
|
|
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",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"bump": "bun run scripts/bump.ts"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@brimveyn/aimux-config": "0.5.
|
|
63
|
+
"@brimveyn/aimux-config": "0.5.2",
|
|
64
64
|
"@opentui/core": "^0.1.90",
|
|
65
65
|
"@opentui/react": "^0.1.90",
|
|
66
66
|
"@xterm/headless": "^6.0.0",
|
|
@@ -605,7 +605,12 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
|
|
|
605
605
|
logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
|
|
606
606
|
return
|
|
607
607
|
}
|
|
608
|
-
if (target.id === state.currentSessionId)
|
|
608
|
+
if (target.id === state.currentSessionId) {
|
|
609
|
+
if (state.focusMode === 'git') {
|
|
610
|
+
dispatch({ type: 'exit-git-mode' })
|
|
611
|
+
}
|
|
612
|
+
return
|
|
613
|
+
}
|
|
609
614
|
handleSwitchSessionEffect(state, backend, dispatch, target)
|
|
610
615
|
}
|
|
611
616
|
|
|
@@ -647,7 +652,11 @@ async function runGitAction(
|
|
|
647
652
|
args: string[],
|
|
648
653
|
pathToInvalidate?: string
|
|
649
654
|
): Promise<void> {
|
|
650
|
-
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
|
|
651
660
|
if (!cwd) return
|
|
652
661
|
const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
|
|
653
662
|
if (result.exitCode !== 0) {
|
|
@@ -662,7 +671,8 @@ async function runGitAction(
|
|
|
662
671
|
}
|
|
663
672
|
|
|
664
673
|
async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
|
|
665
|
-
const
|
|
674
|
+
const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
|
|
675
|
+
const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
|
|
666
676
|
if (!cwd) return
|
|
667
677
|
const absolute = `${cwd}/${path}`
|
|
668
678
|
try {
|
package/src/app.tsx
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import { type ResolvedConfig, setAutoCommitEnabled } from '@brimveyn/aimux-config'
|
|
2
|
-
import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
|
|
3
1
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
useState,
|
|
11
|
-
} from 'react'
|
|
2
|
+
type ResolvedConfig,
|
|
3
|
+
setAutoCommitEnabled,
|
|
4
|
+
setMultiRepoConfig,
|
|
5
|
+
} from '@brimveyn/aimux-config'
|
|
6
|
+
import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
|
|
7
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
12
8
|
|
|
13
9
|
import type { KeyChord } from './input/keymap/key-chord'
|
|
14
10
|
import type { TrieBinding } from './input/keymap/trie'
|
|
@@ -32,11 +28,11 @@ import { type TerminalContentOrigin } from './input/raw-input-handler'
|
|
|
32
28
|
import { getProfileConfigDir, getProfileName } from './profile-paths'
|
|
33
29
|
import { startAIUsageService } from './services/ai-usage/provider'
|
|
34
30
|
import { aiUsageStore } from './state/ai-usage-store'
|
|
35
|
-
import { appStore } from './state/app-store'
|
|
31
|
+
import { appStore, useAppStore } from './state/app-store'
|
|
36
32
|
import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
|
|
37
33
|
import { loadSessionCatalog } from './state/session-catalog'
|
|
38
34
|
import { loadSnippetCatalog } from './state/snippet-catalog'
|
|
39
|
-
import {
|
|
35
|
+
import { createInitialState } from './state/store'
|
|
40
36
|
import { KeymapContext } from './ui/keymap-context'
|
|
41
37
|
import { RootView } from './ui/root'
|
|
42
38
|
import { applyTheme, setTransparent } from './ui/theme'
|
|
@@ -59,6 +55,7 @@ export function App({
|
|
|
59
55
|
// Publish the auto-commit enabled flag before any children render so
|
|
60
56
|
// actions (which live outside React) can read it synchronously.
|
|
61
57
|
setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
|
|
58
|
+
setMultiRepoConfig(resolvedConfig.multiRepo)
|
|
62
59
|
|
|
63
60
|
const keymapHandlers = useMemo(
|
|
64
61
|
() => {
|
|
@@ -81,7 +78,10 @@ export function App({
|
|
|
81
78
|
setTransparent(config.themeTransparent ?? false)
|
|
82
79
|
return initial
|
|
83
80
|
})
|
|
84
|
-
|
|
81
|
+
// Initialize zustand store with computed initial state on first render. Using
|
|
82
|
+
// useState's lazy initializer guarantees this runs exactly once before any
|
|
83
|
+
// selector reads. We discard the value — the store is the source of truth.
|
|
84
|
+
useState(() => {
|
|
85
85
|
const json = loadConfig()
|
|
86
86
|
const sessionBarVisible =
|
|
87
87
|
resolvedConfig.sessionBar?.initialVisible ?? json.sessionBarVisible ?? true
|
|
@@ -125,7 +125,7 @@ export function App({
|
|
|
125
125
|
...(userGitPane?.diffCount !== undefined ? { diffCount: userGitPane.diffCount } : {}),
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
const initial = createInitialState(
|
|
129
129
|
json.customCommands,
|
|
130
130
|
loadSessionCatalog(),
|
|
131
131
|
loadSnippetCatalog(),
|
|
@@ -137,11 +137,18 @@ export function App({
|
|
|
137
137
|
sidebar: sidebarOverrides,
|
|
138
138
|
}
|
|
139
139
|
)
|
|
140
|
+
// Replace the module-level default with the fully-resolved initial state.
|
|
141
|
+
// Preserves the dispatch baked into the store by app-store.ts.
|
|
142
|
+
appStore.setState(initial)
|
|
143
|
+
return null
|
|
140
144
|
})
|
|
141
145
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
146
|
+
// Single source of truth: read state from zustand. The selector returns the
|
|
147
|
+
// entire store object, so this component re-renders on every dispatch (same
|
|
148
|
+
// cadence as the previous useReducer setup), but selectors elsewhere only
|
|
149
|
+
// re-render on the slices they actually subscribe to.
|
|
150
|
+
const state = useAppStore((s) => s)
|
|
151
|
+
const dispatch = state.dispatch
|
|
145
152
|
|
|
146
153
|
useLayoutEffect(() => {
|
|
147
154
|
setActiveDispatch(dispatch)
|
|
@@ -191,6 +198,8 @@ export function App({
|
|
|
191
198
|
return () => {
|
|
192
199
|
cancelled = true
|
|
193
200
|
}
|
|
201
|
+
// dispatch is stable (same fn reference from the store) — safe to omit
|
|
202
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
194
203
|
}, [])
|
|
195
204
|
|
|
196
205
|
const resizingRef = useRef(false)
|
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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
|
|
1
|
+
import type { Action, ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
3
|
import { parseKeyNotation } from './key-chord'
|
|
4
4
|
import { formatNotationForDisplay } from './key-format'
|
|
@@ -18,6 +18,24 @@ export interface DescribedBindingGroup {
|
|
|
18
18
|
interface DescribeOptions {
|
|
19
19
|
withDescriptionOnly?: boolean
|
|
20
20
|
dedupeByDescription?: boolean
|
|
21
|
+
mergeAlternativesByDescription?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const actionObjectIds = new WeakMap<object, number>()
|
|
25
|
+
let nextActionObjectId = 0
|
|
26
|
+
|
|
27
|
+
function getActionSignature(action: Action): string {
|
|
28
|
+
if (typeof action === 'function') {
|
|
29
|
+
let id = actionObjectIds.get(action)
|
|
30
|
+
if (id === undefined) {
|
|
31
|
+
id = nextActionObjectId
|
|
32
|
+
nextActionObjectId += 1
|
|
33
|
+
actionObjectIds.set(action, id)
|
|
34
|
+
}
|
|
35
|
+
return `fn:${id}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `json:${JSON.stringify(action)}`
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
export function describeBindings(
|
|
@@ -31,9 +49,52 @@ export function describeBindings(
|
|
|
31
49
|
const leaderChord = parseKeyNotation(config.leader)[0]
|
|
32
50
|
const seenDescriptions = new Set<string>()
|
|
33
51
|
const result: DescribedBinding[] = []
|
|
52
|
+
const resultIndexByDescription = new Map<string, number>()
|
|
53
|
+
const resultIndexByAction = new Map<string, number>()
|
|
54
|
+
const seenKeysDisplayByIndex = new Map<number, Set<string>>()
|
|
34
55
|
|
|
35
56
|
for (const binding of mode.bindings) {
|
|
57
|
+
const keysDisplay = formatNotationForDisplay(binding.keys, leaderChord)
|
|
58
|
+
|
|
59
|
+
if (options.mergeAlternativesByDescription) {
|
|
60
|
+
const actionSignature = getActionSignature(binding.result)
|
|
61
|
+
const existingIndex = binding.description
|
|
62
|
+
? resultIndexByDescription.get(binding.description)
|
|
63
|
+
: resultIndexByAction.get(actionSignature)
|
|
64
|
+
|
|
65
|
+
if (existingIndex === undefined) {
|
|
66
|
+
if (options.withDescriptionOnly && !binding.description) continue
|
|
67
|
+
const nextIndex = result.length
|
|
68
|
+
if (binding.description) resultIndexByDescription.set(binding.description, nextIndex)
|
|
69
|
+
resultIndexByAction.set(actionSignature, nextIndex)
|
|
70
|
+
seenKeysDisplayByIndex.set(nextIndex, new Set([keysDisplay]))
|
|
71
|
+
result.push({
|
|
72
|
+
description: binding.description,
|
|
73
|
+
group: binding.group,
|
|
74
|
+
keys: binding.keys,
|
|
75
|
+
keysDisplay,
|
|
76
|
+
})
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const seenKeysDisplay = seenKeysDisplayByIndex.get(existingIndex)
|
|
81
|
+
if (!seenKeysDisplay || seenKeysDisplay.has(keysDisplay)) continue
|
|
82
|
+
|
|
83
|
+
seenKeysDisplay.add(keysDisplay)
|
|
84
|
+
const existing = result[existingIndex]
|
|
85
|
+
if (!existing) continue
|
|
86
|
+
if (!existing.description && binding.description) {
|
|
87
|
+
existing.description = binding.description
|
|
88
|
+
existing.group = binding.group
|
|
89
|
+
resultIndexByDescription.set(binding.description, existingIndex)
|
|
90
|
+
}
|
|
91
|
+
existing.keys = `${existing.keys} / ${binding.keys}`
|
|
92
|
+
existing.keysDisplay = `${existing.keysDisplay} / ${keysDisplay}`
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
36
96
|
if (options.withDescriptionOnly && !binding.description) continue
|
|
97
|
+
|
|
37
98
|
if (options.dedupeByDescription && binding.description) {
|
|
38
99
|
if (seenDescriptions.has(binding.description)) continue
|
|
39
100
|
seenDescriptions.add(binding.description)
|
|
@@ -42,7 +103,7 @@ export function describeBindings(
|
|
|
42
103
|
description: binding.description,
|
|
43
104
|
group: binding.group,
|
|
44
105
|
keys: binding.keys,
|
|
45
|
-
keysDisplay
|
|
106
|
+
keysDisplay,
|
|
46
107
|
})
|
|
47
108
|
}
|
|
48
109
|
|
|
@@ -2,7 +2,7 @@ import type { Terminal } from '@xterm/headless'
|
|
|
2
2
|
|
|
3
3
|
import type { TerminalLine, TerminalSnapshot, TerminalSpan } from '../state/types'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { getCurrentTokens } from '../ui/theme'
|
|
6
6
|
|
|
7
7
|
const SNAPSHOT_TAIL_LINE_COUNT = 10
|
|
8
8
|
|
|
@@ -115,15 +115,17 @@ function buildLine(
|
|
|
115
115
|
let bg = getColorHex(current.getBgColor(), bgMode)
|
|
116
116
|
|
|
117
117
|
if (current.isInverse()) {
|
|
118
|
-
const
|
|
119
|
-
const
|
|
118
|
+
const tokens = getCurrentTokens()
|
|
119
|
+
const resolvedFg = fg ?? tokens.palette.ink
|
|
120
|
+
const resolvedBg = bg ?? tokens.bg
|
|
120
121
|
;[fg, bg] = [resolvedBg, resolvedFg]
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
const isCursorCell = cursorVisible && cursorColumn === column
|
|
124
125
|
if (isCursorCell) {
|
|
125
|
-
const
|
|
126
|
-
const
|
|
126
|
+
const tokens = getCurrentTokens()
|
|
127
|
+
const resolvedFg = fg ?? tokens.palette.ink
|
|
128
|
+
const resolvedBg = bg ?? tokens.bg
|
|
127
129
|
;[fg, bg] = [resolvedBg, resolvedFg]
|
|
128
130
|
}
|
|
129
131
|
|
|
@@ -274,6 +274,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
case 'git-mode-set-parsed': {
|
|
277
|
+
const prevParsed = state.gitMode.parsedFiles[action.key]
|
|
278
|
+
if (prevParsed && prevParsed.hash === action.hash) return state
|
|
277
279
|
return {
|
|
278
280
|
...state,
|
|
279
281
|
gitMode: {
|
|
@@ -303,6 +305,62 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
|
|
|
303
305
|
},
|
|
304
306
|
}
|
|
305
307
|
}
|
|
308
|
+
case 'git-mode-merge-highlights': {
|
|
309
|
+
const k = `${action.key}|${action.themeId}`
|
|
310
|
+
const existing = state.gitMode.highlights[k]
|
|
311
|
+
const sameContent = existing && existing.hash === action.hash
|
|
312
|
+
if (sameContent) {
|
|
313
|
+
const existingAdd = existing.add as unknown[]
|
|
314
|
+
const existingDel = existing.del as unknown[]
|
|
315
|
+
let changesAnything = false
|
|
316
|
+
outer: for (const patch of action.add) {
|
|
317
|
+
const tokens = patch.tokens as unknown[]
|
|
318
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
319
|
+
if (existingAdd[patch.start + i] === undefined) {
|
|
320
|
+
changesAnything = true
|
|
321
|
+
break outer
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!changesAnything) {
|
|
326
|
+
outer: for (const patch of action.del) {
|
|
327
|
+
const tokens = patch.tokens as unknown[]
|
|
328
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
329
|
+
if (existingDel[patch.start + i] === undefined) {
|
|
330
|
+
changesAnything = true
|
|
331
|
+
break outer
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!changesAnything) return state
|
|
337
|
+
}
|
|
338
|
+
const nextAdd = sameContent ? ([...(existing.add as unknown[])] as unknown[]) : []
|
|
339
|
+
const nextDel = sameContent ? ([...(existing.del as unknown[])] as unknown[]) : []
|
|
340
|
+
for (const patch of action.add) {
|
|
341
|
+
const tokens = patch.tokens as unknown[]
|
|
342
|
+
for (let i = 0; i < tokens.length; i++) nextAdd[patch.start + i] = tokens[i]
|
|
343
|
+
}
|
|
344
|
+
for (const patch of action.del) {
|
|
345
|
+
const tokens = patch.tokens as unknown[]
|
|
346
|
+
for (let i = 0; i < tokens.length; i++) nextDel[patch.start + i] = tokens[i]
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
...state,
|
|
350
|
+
gitMode: {
|
|
351
|
+
...state.gitMode,
|
|
352
|
+
highlights: {
|
|
353
|
+
...state.gitMode.highlights,
|
|
354
|
+
[k]: {
|
|
355
|
+
add: nextAdd,
|
|
356
|
+
del: nextDel,
|
|
357
|
+
hash: action.hash,
|
|
358
|
+
themeId: action.themeId,
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
}
|
|
306
364
|
case 'git-mode-invalidate-diffs': {
|
|
307
365
|
if (action.paths.length === 0) return state
|
|
308
366
|
return clearDiffCacheForPaths(state, new Set(action.paths))
|
|
@@ -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 }
|