@brimveyn/aimux 1.5.4 → 1.6.1
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/README.md +14 -2
- package/package.json +5 -3
- package/src/app-runtime/side-effects.ts +41 -6
- package/src/app.tsx +17 -5
- package/src/config.ts +29 -4
- package/src/diff-parser/clean-last-newline.ts +5 -0
- package/src/diff-parser/constants.ts +13 -0
- package/src/diff-parser/index.ts +12 -0
- package/src/diff-parser/parse-line-type.ts +29 -0
- package/src/diff-parser/parse-patch-files.ts +369 -0
- package/src/diff-parser/types.ts +75 -0
- package/src/git/git-diff.ts +24 -12
- package/src/git/git-poller.ts +11 -3
- package/src/git/git-status.ts +98 -17
- package/src/input/modes/bridge.ts +8 -0
- package/src/input/modes/transitions.ts +2 -1
- package/src/input/modes/types.ts +4 -1
- package/src/pty/terminal-snapshot.ts +5 -5
- package/src/state/git-tree.ts +220 -0
- package/src/state/reducers/git-mode-state.ts +276 -36
- package/src/state/reducers/git-panel-state.ts +35 -4
- package/src/state/reducers/modal-state.ts +43 -10
- package/src/state/store.ts +5 -1
- package/src/state/types.ts +46 -6
- package/src/state/workspace-save.ts +2 -0
- package/src/ui/components/create-session-modal.tsx +25 -8
- package/src/ui/components/diff-renderer/build-rows.ts +349 -0
- package/src/ui/components/diff-renderer/filetype.ts +29 -0
- package/src/ui/components/diff-renderer/fold-strip.tsx +86 -0
- package/src/ui/components/diff-renderer/highlight.ts +48 -0
- package/src/ui/components/diff-renderer/index.ts +1 -0
- package/src/ui/components/diff-renderer/pierre-diff.tsx +171 -0
- package/src/ui/components/diff-renderer/split-view.tsx +211 -0
- package/src/ui/components/diff-renderer/stacked-view.tsx +168 -0
- package/src/ui/components/git-commit-modal.tsx +16 -3
- package/src/ui/components/git-pane-widget.tsx +6 -1
- package/src/ui/components/git-panel.tsx +215 -86
- package/src/ui/components/git-view.tsx +103 -90
- package/src/ui/components/help-modal.tsx +45 -12
- package/src/ui/components/input-field.tsx +4 -3
- package/src/ui/components/list-item.tsx +11 -2
- package/src/ui/components/modal-filter-bar.tsx +3 -2
- package/src/ui/components/modal-keybinds-overlay.tsx +4 -3
- package/src/ui/components/modal-shell.tsx +5 -4
- package/src/ui/components/new-tab-modal.tsx +17 -4
- package/src/ui/components/pending-chord-overlay.tsx +5 -4
- package/src/ui/components/session-bar.tsx +13 -6
- package/src/ui/components/session-picker-modal.tsx +28 -10
- package/src/ui/components/sidebar.tsx +26 -11
- package/src/ui/components/snippet-editor-modal.tsx +18 -3
- package/src/ui/components/snippet-picker-modal.tsx +13 -4
- package/src/ui/components/split-layout.tsx +3 -2
- package/src/ui/components/status-bar.tsx +15 -10
- package/src/ui/components/surface.tsx +6 -6
- package/src/ui/components/tab-item.tsx +28 -17
- package/src/ui/components/terminal-pane.tsx +28 -16
- package/src/ui/components/theme-picker-modal.tsx +113 -17
- package/src/ui/components/update-available-modal.tsx +13 -2
- package/src/ui/filter-themes.ts +15 -0
- package/src/ui/keymap-context.ts +10 -2
- package/src/ui/root.tsx +29 -7
- package/src/ui/shiki.ts +49 -0
- package/src/ui/status-bar-model.ts +32 -4
- package/src/ui/theme-store.ts +36 -0
- package/src/ui/theme.ts +4 -17
- package/src/ui/themes.ts +23 -277
- package/src/ui/syntax.ts +0 -102
package/src/git/git-status.ts
CHANGED
|
@@ -16,6 +16,7 @@ interface NumstatRow {
|
|
|
16
16
|
export type GitCollectResult =
|
|
17
17
|
| { kind: 'ok'; payload: GitRefreshPayload }
|
|
18
18
|
| { kind: 'error'; error: GitPanelError }
|
|
19
|
+
| { kind: 'out-of-range'; maxOffset: number }
|
|
19
20
|
|
|
20
21
|
const STATUS_CODES = new Set(['M', 'A', 'D', 'R', 'C', 'U', '?'])
|
|
21
22
|
|
|
@@ -189,26 +190,106 @@ async function annotateUntrackedCounts(cwd: string, files: GitFileEntry[]): Prom
|
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
interface CollectOptions {
|
|
194
|
+
headOffset?: number
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseNameStatus(
|
|
198
|
+
output: string
|
|
199
|
+
): { path: string; status: GitFileStatus; renamedFrom?: string }[] {
|
|
200
|
+
const rows: { path: string; status: GitFileStatus; renamedFrom?: string }[] = []
|
|
201
|
+
for (const raw of output.split('\n')) {
|
|
202
|
+
if (!raw) continue
|
|
203
|
+
const parts = raw.split('\t')
|
|
204
|
+
const code = parts[0] ?? ''
|
|
205
|
+
const letter = code[0] ?? ''
|
|
206
|
+
const status = toStatus(letter)
|
|
207
|
+
if (!status) continue
|
|
208
|
+
if (letter === 'R' || letter === 'C') {
|
|
209
|
+
const from = parts[1]
|
|
210
|
+
const to = parts[2]
|
|
211
|
+
if (!from || !to) continue
|
|
212
|
+
rows.push({ path: to, renamedFrom: from, status })
|
|
213
|
+
} else {
|
|
214
|
+
const path = parts.slice(1).join('\t')
|
|
215
|
+
if (!path) continue
|
|
216
|
+
rows.push({ path, status })
|
|
202
217
|
}
|
|
218
|
+
}
|
|
219
|
+
return rows
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function collectAgainstHead(cwd: string): Promise<GitCollectResult> {
|
|
223
|
+
const [statusResult, unstagedDiff, stagedDiff] = await Promise.all([
|
|
224
|
+
$`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
|
|
225
|
+
$`git -C ${cwd} -c core.quotePath=false diff --numstat`.quiet().nothrow(),
|
|
226
|
+
$`git -C ${cwd} -c core.quotePath=false diff --cached --numstat`.quiet().nothrow(),
|
|
227
|
+
])
|
|
203
228
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
229
|
+
if (statusResult.exitCode !== 0) {
|
|
230
|
+
return { error: 'not-a-repo', kind: 'error' }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const statusText = statusResult.text()
|
|
234
|
+
const { ahead, behind, branch } = parseBranchLines(statusText)
|
|
235
|
+
const unstagedNumstat = parseNumstat(unstagedDiff.text())
|
|
236
|
+
const stagedNumstat = parseNumstat(stagedDiff.text())
|
|
237
|
+
const files = parsePorcelainEntries(statusText, stagedNumstat, unstagedNumstat)
|
|
238
|
+
await annotateUntrackedCounts(cwd, files)
|
|
210
239
|
|
|
211
|
-
|
|
240
|
+
return { kind: 'ok', payload: { ahead, behind, branch, files } }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function collectAgainstHistorical(
|
|
244
|
+
cwd: string,
|
|
245
|
+
headOffset: number
|
|
246
|
+
): Promise<GitCollectResult> {
|
|
247
|
+
const ref = `HEAD~${headOffset}`
|
|
248
|
+
const revParse = await $`git -C ${cwd} rev-parse ${ref}`.quiet().nothrow()
|
|
249
|
+
if (revParse.exitCode !== 0) {
|
|
250
|
+
const countResult = await $`git -C ${cwd} rev-list --count HEAD`.quiet().nothrow()
|
|
251
|
+
const count = countResult.exitCode === 0 ? Number.parseInt(countResult.text().trim(), 10) : NaN
|
|
252
|
+
const maxOffset = Number.isFinite(count) && count > 0 ? count - 1 : 0
|
|
253
|
+
return { kind: 'out-of-range', maxOffset }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const [statusResult, nameStatus, numstat, untrackedStatus] = await Promise.all([
|
|
257
|
+
$`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
|
|
258
|
+
$`git -C ${cwd} -c core.quotePath=false diff ${ref} --name-status`.quiet().nothrow(),
|
|
259
|
+
$`git -C ${cwd} -c core.quotePath=false diff ${ref} --numstat`.quiet().nothrow(),
|
|
260
|
+
$`git -C ${cwd} status --porcelain=v2 -z --untracked-files=all`.quiet().nothrow(),
|
|
261
|
+
])
|
|
262
|
+
|
|
263
|
+
if (statusResult.exitCode !== 0) {
|
|
264
|
+
return { error: 'not-a-repo', kind: 'error' }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { ahead, behind, branch } = parseBranchLines(statusResult.text())
|
|
268
|
+
const stats = parseNumstat(numstat.text())
|
|
269
|
+
const rows = parseNameStatus(nameStatus.text())
|
|
270
|
+
const files: GitFileEntry[] = rows.map((row) =>
|
|
271
|
+
buildEntry('historical', row.status, row.path, stats, row.renamedFrom)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
// Untracked files live outside any commit — still surface them so `?` files
|
|
275
|
+
// don't vanish when the user walks history.
|
|
276
|
+
const porcelain = parsePorcelainEntries(untrackedStatus.text(), new Map(), new Map())
|
|
277
|
+
const untracked = porcelain.filter((f) => f.section === 'untracked')
|
|
278
|
+
await annotateUntrackedCounts(cwd, untracked)
|
|
279
|
+
files.push(...untracked)
|
|
280
|
+
|
|
281
|
+
return { kind: 'ok', payload: { ahead, behind, branch, files } }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function collectGitStatus(
|
|
285
|
+
cwd: string,
|
|
286
|
+
options: CollectOptions = {}
|
|
287
|
+
): Promise<GitCollectResult> {
|
|
288
|
+
const headOffset = options.headOffset ?? 0
|
|
289
|
+
try {
|
|
290
|
+
return headOffset > 0
|
|
291
|
+
? await collectAgainstHistorical(cwd, headOffset)
|
|
292
|
+
: await collectAgainstHead(cwd)
|
|
212
293
|
} catch {
|
|
213
294
|
return { error: 'unknown', kind: 'error' }
|
|
214
295
|
}
|
|
@@ -19,6 +19,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
|
19
19
|
'session-picker': 'modal.session-picker.filtering',
|
|
20
20
|
'snippet-editor': 'modal.snippet-editor',
|
|
21
21
|
'snippet-picker': 'modal.snippet-picker.filtering',
|
|
22
|
+
'theme-picker': 'modal.theme-picker.filtering',
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
@@ -32,6 +33,13 @@ const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export function deriveModeId(state: AppState): ModeId {
|
|
36
|
+
// Help renders as an overlay on top of git/navigation without flipping
|
|
37
|
+
// focusMode, so it needs modal-first dispatch. Filter sub-mode is tracked
|
|
38
|
+
// by editBuffer presence, not focusMode.
|
|
39
|
+
if (state.modal.type === 'help') {
|
|
40
|
+
return state.modal.editBuffer !== null ? 'modal.help.filtering' : 'modal.help'
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
|
|
36
44
|
if (directMode) {
|
|
37
45
|
return directMode
|
|
@@ -21,7 +21,8 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
21
21
|
'modal.snippet-picker': ['navigation', 'modal.snippet-picker.filtering', 'modal.snippet-editor'],
|
|
22
22
|
'modal.snippet-picker.filtering': ['modal.snippet-picker'],
|
|
23
23
|
'modal.split-picker': ['navigation', 'terminal-input'],
|
|
24
|
-
'modal.theme-picker': ['navigation'],
|
|
24
|
+
'modal.theme-picker': ['navigation', 'modal.theme-picker.filtering'],
|
|
25
|
+
'modal.theme-picker.filtering': ['modal.theme-picker'],
|
|
25
26
|
'modal.update-available': ['navigation'],
|
|
26
27
|
'navigation': [
|
|
27
28
|
'terminal-input',
|
package/src/input/modes/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { KeyEvent } from '@opentui/core'
|
|
2
2
|
|
|
3
|
-
import type { AppAction, AppState, TabSession } from '../../state/types'
|
|
3
|
+
import type { AppAction, AppState, GitFileListMode, TabSession } from '../../state/types'
|
|
4
4
|
|
|
5
5
|
export type ModeId =
|
|
6
6
|
| 'navigation'
|
|
@@ -17,6 +17,7 @@ export type ModeId =
|
|
|
17
17
|
| 'modal.snippet-picker.filtering'
|
|
18
18
|
| 'modal.snippet-editor'
|
|
19
19
|
| 'modal.theme-picker'
|
|
20
|
+
| 'modal.theme-picker.filtering'
|
|
20
21
|
| 'modal.help'
|
|
21
22
|
| 'modal.help.filtering'
|
|
22
23
|
| 'modal.split-picker'
|
|
@@ -46,6 +47,8 @@ export type SideEffect =
|
|
|
46
47
|
| { type: 'split-pane'; direction: import('../../state/layout-tree').SplitDirection }
|
|
47
48
|
| { type: 'confirm-split' }
|
|
48
49
|
| { type: 'scroll-git-diff'; delta: number }
|
|
50
|
+
| { type: 'persist-git-diff-mode-ratio'; ratio: number }
|
|
51
|
+
| { type: 'persist-git-file-list-mode'; mode: GitFileListMode }
|
|
49
52
|
| { type: 'git-stage'; path: string }
|
|
50
53
|
| { type: 'git-unstage'; path: string }
|
|
51
54
|
| { type: 'git-restore'; path: string }
|
|
@@ -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 { getCurrentTheme } from '../ui/theme'
|
|
6
6
|
|
|
7
7
|
const ANSI_PALETTE = [
|
|
8
8
|
'#000000',
|
|
@@ -113,15 +113,15 @@ function buildLine(
|
|
|
113
113
|
let bg = getColorHex(current.getBgColor(), bgMode)
|
|
114
114
|
|
|
115
115
|
if (current.isInverse()) {
|
|
116
|
-
const resolvedFg = fg ??
|
|
117
|
-
const resolvedBg = bg ??
|
|
116
|
+
const resolvedFg = fg ?? getCurrentTheme().colors['editor.foreground']
|
|
117
|
+
const resolvedBg = bg ?? getCurrentTheme().colors['editor.background']
|
|
118
118
|
;[fg, bg] = [resolvedBg, resolvedFg]
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
const isCursorCell = cursorVisible && cursorColumn === column
|
|
122
122
|
if (isCursorCell) {
|
|
123
|
-
const resolvedFg = fg ??
|
|
124
|
-
const resolvedBg = bg ??
|
|
123
|
+
const resolvedFg = fg ?? getCurrentTheme().colors['editor.foreground']
|
|
124
|
+
const resolvedBg = bg ?? getCurrentTheme().colors['editor.background']
|
|
125
125
|
;[fg, bg] = [resolvedBg, resolvedFg]
|
|
126
126
|
}
|
|
127
127
|
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { GitFileEntry, GitFileListMode, GitFileSection } from './types'
|
|
2
|
+
|
|
3
|
+
const SECTION_ORDER: GitFileSection[] = ['historical', 'staged', 'unstaged', 'untracked']
|
|
4
|
+
|
|
5
|
+
interface GitTreeNode {
|
|
6
|
+
files: GitFileEntry[]
|
|
7
|
+
folders: Map<string, GitTreeNode>
|
|
8
|
+
path: string
|
|
9
|
+
section: GitFileSection
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GitTreeFolderRow {
|
|
13
|
+
kind: 'folder'
|
|
14
|
+
key: string
|
|
15
|
+
section: GitFileSection
|
|
16
|
+
depth: number
|
|
17
|
+
name: string
|
|
18
|
+
folderPath: string
|
|
19
|
+
parentKey?: string
|
|
20
|
+
isCollapsed: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GitTreeFileRow {
|
|
24
|
+
kind: 'file'
|
|
25
|
+
key: string
|
|
26
|
+
section: GitFileSection
|
|
27
|
+
depth: number
|
|
28
|
+
file: GitFileEntry
|
|
29
|
+
parentKey?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type GitTreeRow = GitTreeFolderRow | GitTreeFileRow
|
|
33
|
+
|
|
34
|
+
export interface GitTreeSectionRows {
|
|
35
|
+
section: GitFileSection
|
|
36
|
+
files: GitFileEntry[]
|
|
37
|
+
rows: GitTreeRow[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GitTreeRows {
|
|
41
|
+
sections: GitTreeSectionRows[]
|
|
42
|
+
visibleRows: GitTreeRow[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function gitFileKey(file: Pick<GitFileEntry, 'path' | 'section'>): string {
|
|
46
|
+
return `${file.section}:${file.path}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function gitFolderKey(section: GitFileSection, folderPath: string): string {
|
|
50
|
+
return `${section}:dir:${folderPath}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildGitTreeRows(
|
|
54
|
+
files: GitFileEntry[],
|
|
55
|
+
collapsedFolders: Record<string, true>,
|
|
56
|
+
fileListMode: GitFileListMode = 'tree'
|
|
57
|
+
): GitTreeRows {
|
|
58
|
+
const sections = SECTION_ORDER.map((section) => {
|
|
59
|
+
const sectionFiles = files.filter((file) => file.section === section)
|
|
60
|
+
return {
|
|
61
|
+
files: sectionFiles,
|
|
62
|
+
rows:
|
|
63
|
+
fileListMode === 'flat'
|
|
64
|
+
? sectionFiles.map((file) => ({
|
|
65
|
+
depth: 0,
|
|
66
|
+
file,
|
|
67
|
+
key: gitFileKey(file),
|
|
68
|
+
kind: 'file' as const,
|
|
69
|
+
section,
|
|
70
|
+
}))
|
|
71
|
+
: flattenSectionRows(sectionFiles, collapsedFolders),
|
|
72
|
+
section,
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
return { sections, visibleRows: sections.flatMap((section) => section.rows) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getSelectedGitRow(
|
|
79
|
+
files: GitFileEntry[],
|
|
80
|
+
options: {
|
|
81
|
+
collapsedFolders: Record<string, true>
|
|
82
|
+
fileListMode: GitFileListMode
|
|
83
|
+
selectedEntryKey: string | null
|
|
84
|
+
}
|
|
85
|
+
): GitTreeRow | null {
|
|
86
|
+
if (!options.selectedEntryKey) return null
|
|
87
|
+
const { visibleRows } = buildGitTreeRows(files, options.collapsedFolders, options.fileListMode)
|
|
88
|
+
return visibleRows.find((row) => row.key === options.selectedEntryKey) ?? null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getSelectedGitFile(
|
|
92
|
+
files: GitFileEntry[],
|
|
93
|
+
options: {
|
|
94
|
+
collapsedFolders: Record<string, true>
|
|
95
|
+
fileListMode: GitFileListMode
|
|
96
|
+
selectedEntryKey: string | null
|
|
97
|
+
}
|
|
98
|
+
): GitFileEntry | null {
|
|
99
|
+
const row = getSelectedGitRow(files, options)
|
|
100
|
+
return row?.kind === 'file' ? row.file : null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function reconcileSelectedGitEntryKey(
|
|
104
|
+
files: GitFileEntry[],
|
|
105
|
+
collapsedFolders: Record<string, true>,
|
|
106
|
+
fileListMode: GitFileListMode,
|
|
107
|
+
selectedEntryKey: string | null | undefined,
|
|
108
|
+
preferredKeys: string[] = []
|
|
109
|
+
): string | null {
|
|
110
|
+
const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
|
|
111
|
+
if (visibleRows.length === 0) return null
|
|
112
|
+
const candidates = [...preferredKeys, selectedEntryKey ?? '']
|
|
113
|
+
for (const key of candidates) {
|
|
114
|
+
if (!key) continue
|
|
115
|
+
if (visibleRows.some((row) => row.key === key)) return key
|
|
116
|
+
}
|
|
117
|
+
return visibleRows[0]?.key ?? null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function moveGitSelection(
|
|
121
|
+
files: GitFileEntry[],
|
|
122
|
+
collapsedFolders: Record<string, true>,
|
|
123
|
+
fileListMode: GitFileListMode,
|
|
124
|
+
selectedEntryKey: string | null,
|
|
125
|
+
delta: -1 | 1
|
|
126
|
+
): string | null {
|
|
127
|
+
const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
|
|
128
|
+
const total = visibleRows.length
|
|
129
|
+
if (total === 0) return null
|
|
130
|
+
const current = visibleRows.findIndex((row) => row.key === selectedEntryKey)
|
|
131
|
+
const base = current >= 0 ? current : 0
|
|
132
|
+
return visibleRows[(base + delta + total) % total]?.key ?? null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function moveGitFileSelection(
|
|
136
|
+
files: GitFileEntry[],
|
|
137
|
+
collapsedFolders: Record<string, true>,
|
|
138
|
+
fileListMode: GitFileListMode,
|
|
139
|
+
selectedEntryKey: string | null,
|
|
140
|
+
delta: -1 | 1
|
|
141
|
+
): string | null {
|
|
142
|
+
const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
|
|
143
|
+
const fileRows = visibleRows.filter((row) => row.kind === 'file')
|
|
144
|
+
const total = fileRows.length
|
|
145
|
+
if (total === 0) return null
|
|
146
|
+
const current = fileRows.findIndex((row) => row.key === selectedEntryKey)
|
|
147
|
+
let base = current
|
|
148
|
+
if (base < 0) {
|
|
149
|
+
base = delta > 0 ? -1 : 0
|
|
150
|
+
}
|
|
151
|
+
return fileRows[(base + delta + total) % total]?.key ?? null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function flattenSectionRows(
|
|
155
|
+
files: GitFileEntry[],
|
|
156
|
+
collapsedFolders: Record<string, true>
|
|
157
|
+
): GitTreeRow[] {
|
|
158
|
+
if (files.length === 0) return []
|
|
159
|
+
const section = files[0]?.section
|
|
160
|
+
if (!section) return []
|
|
161
|
+
const root = buildSectionTree(files, section)
|
|
162
|
+
return flattenTreeNode(root, collapsedFolders, 0)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildSectionTree(files: GitFileEntry[], section: GitFileSection): GitTreeNode {
|
|
166
|
+
const root: GitTreeNode = { files: [], folders: new Map(), path: '', section }
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
const parts = file.path.split('/')
|
|
169
|
+
let cursor = root
|
|
170
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
171
|
+
const name = parts[i]
|
|
172
|
+
if (!name) continue
|
|
173
|
+
const nextPath = cursor.path ? `${cursor.path}/${name}` : name
|
|
174
|
+
let child = cursor.folders.get(name)
|
|
175
|
+
if (!child) {
|
|
176
|
+
child = { files: [], folders: new Map(), path: nextPath, section }
|
|
177
|
+
cursor.folders.set(name, child)
|
|
178
|
+
}
|
|
179
|
+
cursor = child
|
|
180
|
+
}
|
|
181
|
+
cursor.files.push(file)
|
|
182
|
+
}
|
|
183
|
+
return root
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function flattenTreeNode(
|
|
187
|
+
node: GitTreeNode,
|
|
188
|
+
collapsedFolders: Record<string, true>,
|
|
189
|
+
depth: number,
|
|
190
|
+
parentKey?: string
|
|
191
|
+
): GitTreeRow[] {
|
|
192
|
+
const rows: GitTreeRow[] = []
|
|
193
|
+
for (const child of node.folders.values()) {
|
|
194
|
+
const name = child.path.split('/').pop() ?? child.path
|
|
195
|
+
const key = gitFolderKey(node.section, child.path)
|
|
196
|
+
const isCollapsed = key in collapsedFolders
|
|
197
|
+
rows.push({
|
|
198
|
+
depth,
|
|
199
|
+
folderPath: child.path,
|
|
200
|
+
isCollapsed,
|
|
201
|
+
key,
|
|
202
|
+
kind: 'folder',
|
|
203
|
+
name,
|
|
204
|
+
parentKey,
|
|
205
|
+
section: node.section,
|
|
206
|
+
})
|
|
207
|
+
if (!isCollapsed) rows.push(...flattenTreeNode(child, collapsedFolders, depth + 1, key))
|
|
208
|
+
}
|
|
209
|
+
for (const file of node.files) {
|
|
210
|
+
rows.push({
|
|
211
|
+
depth,
|
|
212
|
+
file,
|
|
213
|
+
key: gitFileKey(file),
|
|
214
|
+
kind: 'file',
|
|
215
|
+
parentKey,
|
|
216
|
+
section: file.section,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
return rows
|
|
220
|
+
}
|