@brimveyn/aimux 1.5.0 → 1.6.0

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.
Files changed (67) hide show
  1. package/README.md +14 -2
  2. package/package.json +5 -3
  3. package/src/app-runtime/side-effects.ts +41 -6
  4. package/src/app-runtime/use-renderer-bindings.ts +1 -1
  5. package/src/app.tsx +17 -5
  6. package/src/config.ts +29 -4
  7. package/src/diff-parser/clean-last-newline.ts +5 -0
  8. package/src/diff-parser/constants.ts +13 -0
  9. package/src/diff-parser/index.ts +12 -0
  10. package/src/diff-parser/parse-line-type.ts +29 -0
  11. package/src/diff-parser/parse-patch-files.ts +369 -0
  12. package/src/diff-parser/types.ts +75 -0
  13. package/src/index.tsx +2 -2
  14. package/src/input/modes/bridge.ts +8 -0
  15. package/src/input/modes/transitions.ts +2 -1
  16. package/src/input/modes/types.ts +4 -1
  17. package/src/pty/terminal-snapshot.ts +4 -4
  18. package/src/state/git-tree.ts +220 -0
  19. package/src/state/reducers/git-mode-state.ts +232 -35
  20. package/src/state/reducers/git-panel-state.ts +29 -3
  21. package/src/state/reducers/modal-state.ts +43 -10
  22. package/src/state/store.ts +5 -1
  23. package/src/state/types.ts +41 -5
  24. package/src/state/workspace-save.ts +2 -0
  25. package/src/ui/components/create-session-modal.tsx +23 -7
  26. package/src/ui/components/diff-renderer/build-rows.ts +349 -0
  27. package/src/ui/components/diff-renderer/filetype.ts +29 -0
  28. package/src/ui/components/diff-renderer/fold-strip.tsx +84 -0
  29. package/src/ui/components/diff-renderer/highlight.ts +48 -0
  30. package/src/ui/components/diff-renderer/index.ts +1 -0
  31. package/src/ui/components/diff-renderer/pierre-diff.tsx +170 -0
  32. package/src/ui/components/diff-renderer/split-view.tsx +207 -0
  33. package/src/ui/components/diff-renderer/stacked-view.tsx +166 -0
  34. package/src/ui/components/git-commit-modal.tsx +14 -2
  35. package/src/ui/components/git-pane-widget.tsx +5 -0
  36. package/src/ui/components/git-panel.tsx +176 -79
  37. package/src/ui/components/git-view.tsx +89 -87
  38. package/src/ui/components/help-modal.tsx +43 -11
  39. package/src/ui/components/input-field.tsx +2 -2
  40. package/src/ui/components/list-item.tsx +9 -1
  41. package/src/ui/components/modal-filter-bar.tsx +1 -1
  42. package/src/ui/components/modal-keybinds-overlay.tsx +2 -2
  43. package/src/ui/components/modal-shell.tsx +3 -3
  44. package/src/ui/components/new-tab-modal.tsx +15 -3
  45. package/src/ui/components/pending-chord-overlay.tsx +3 -3
  46. package/src/ui/components/session-bar.tsx +10 -5
  47. package/src/ui/components/session-picker-modal.tsx +26 -9
  48. package/src/ui/components/sidebar.tsx +22 -10
  49. package/src/ui/components/snippet-editor-modal.tsx +16 -2
  50. package/src/ui/components/snippet-picker-modal.tsx +11 -3
  51. package/src/ui/components/split-layout.tsx +1 -1
  52. package/src/ui/components/status-bar.tsx +12 -9
  53. package/src/ui/components/surface.tsx +5 -5
  54. package/src/ui/components/tab-item.tsx +22 -16
  55. package/src/ui/components/terminal-pane.tsx +25 -15
  56. package/src/ui/components/theme-picker-modal.tsx +111 -16
  57. package/src/ui/components/update-available-modal.tsx +11 -1
  58. package/src/ui/filter-themes.ts +10 -0
  59. package/src/ui/house-themes.ts +313 -0
  60. package/src/ui/keymap-context.ts +10 -2
  61. package/src/ui/root.tsx +26 -6
  62. package/src/ui/shiki.ts +49 -0
  63. package/src/ui/status-bar-model.ts +19 -1
  64. package/src/ui/theme.ts +22 -10
  65. package/src/ui/themes.generated.ts +59794 -0
  66. package/src/ui/themes.ts +68 -274
  67. package/src/ui/syntax.ts +0 -102
@@ -0,0 +1,220 @@
1
+ import type { GitFileEntry, GitFileListMode, GitFileSection } from './types'
2
+
3
+ const SECTION_ORDER: GitFileSection[] = ['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
+ }
@@ -1,14 +1,70 @@
1
- import type { AppAction, AppState, GitFileEntry, GitModeState } from '../types'
1
+ import type { AppAction, AppState, FoldState, GitFileEntry, GitModeState } from '../types'
2
2
 
3
+ import {
4
+ getSelectedGitRow,
5
+ gitFileKey,
6
+ moveGitFileSelection,
7
+ moveGitSelection,
8
+ reconcileSelectedGitEntryKey,
9
+ } from '../git-tree'
3
10
  import { sortFilesBySection } from './git-panel-state'
4
11
 
12
+ function applyFold(state: AppState, key: string, foldId: string, next: FoldState): AppState {
13
+ const perPath = state.gitMode.folds[key] ?? {}
14
+ const prev = perPath[foldId] ?? { bottom: 0, top: 0 }
15
+ if (prev.top === next.top && prev.bottom === next.bottom) return state
16
+ const nextPerPath = { ...perPath }
17
+ if (next.top === 0 && next.bottom === 0) {
18
+ delete nextPerPath[foldId]
19
+ } else {
20
+ nextPerPath[foldId] = next
21
+ }
22
+ const nextFolds = { ...state.gitMode.folds }
23
+ if (Object.keys(nextPerPath).length === 0) {
24
+ delete nextFolds[key]
25
+ } else {
26
+ nextFolds[key] = nextPerPath
27
+ }
28
+ return { ...state, gitMode: { ...state.gitMode, folds: nextFolds } }
29
+ }
30
+
31
+ function clearDiffCacheForPath(state: AppState, path: string): AppState {
32
+ const nextDiffs = { ...state.gitMode.diffs }
33
+ const nextFolds = { ...state.gitMode.folds }
34
+ const nextLoading = { ...state.gitMode.loading }
35
+ let changed = false
36
+ for (const key of Object.keys(nextDiffs)) {
37
+ if (nextDiffs[key]?.path !== path) continue
38
+ delete nextDiffs[key]
39
+ changed = true
40
+ }
41
+ for (const key of Object.keys(nextFolds)) {
42
+ if (!key.endsWith(`:${path}`)) continue
43
+ delete nextFolds[key]
44
+ changed = true
45
+ }
46
+ for (const key of Object.keys(nextLoading)) {
47
+ if (!key.endsWith(`:${path}`)) continue
48
+ delete nextLoading[key]
49
+ changed = true
50
+ }
51
+ if (!changed) return state
52
+ return {
53
+ ...state,
54
+ gitMode: { ...state.gitMode, diffs: nextDiffs, folds: nextFolds, loading: nextLoading },
55
+ }
56
+ }
57
+
5
58
  export function emptyGitMode(): GitModeState {
6
59
  return {
7
60
  actionMessage: null,
61
+ collapsedFolders: {},
8
62
  diffs: {},
63
+ diffView: 'split',
64
+ folds: {},
9
65
  loading: {},
10
66
  pendingDeletePath: null,
11
- selectedFileIndex: 0,
67
+ selectedEntryKey: null,
12
68
  }
13
69
  }
14
70
 
@@ -23,7 +79,12 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
23
79
  ...state.gitMode,
24
80
  actionMessage: null,
25
81
  pendingDeletePath: null,
26
- selectedFileIndex: 0,
82
+ selectedEntryKey: reconcileSelectedGitEntryKey(
83
+ state.gitPanel.files,
84
+ state.gitMode.collapsedFolders,
85
+ state.gitPane.fileListMode,
86
+ null
87
+ ),
27
88
  },
28
89
  }
29
90
  }
@@ -31,28 +92,152 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
31
92
  if (state.focusMode !== 'git') return state
32
93
  return { ...state, focusMode: 'navigation' }
33
94
  }
34
- case 'git-mode-select-file': {
35
- const total = state.gitPanel.files.length
36
- if (total === 0) return state
37
- const next = (state.gitMode.selectedFileIndex + action.delta + total) % total
38
- if (next === state.gitMode.selectedFileIndex) return state
95
+ case 'git-mode-move-selection': {
96
+ const next = moveGitSelection(
97
+ state.gitPanel.files,
98
+ state.gitMode.collapsedFolders,
99
+ state.gitPane.fileListMode,
100
+ state.gitMode.selectedEntryKey,
101
+ action.delta
102
+ )
103
+ if (!next || next === state.gitMode.selectedEntryKey) return state
104
+ return {
105
+ ...state,
106
+ gitMode: {
107
+ ...state.gitMode,
108
+ pendingDeletePath: null,
109
+ selectedEntryKey: next,
110
+ },
111
+ }
112
+ }
113
+ case 'git-mode-move-file-selection': {
114
+ const next = moveGitFileSelection(
115
+ state.gitPanel.files,
116
+ state.gitMode.collapsedFolders,
117
+ state.gitPane.fileListMode,
118
+ state.gitMode.selectedEntryKey,
119
+ action.delta
120
+ )
121
+ if (!next || next === state.gitMode.selectedEntryKey) return state
122
+ return {
123
+ ...state,
124
+ gitMode: {
125
+ ...state.gitMode,
126
+ pendingDeletePath: null,
127
+ selectedEntryKey: next,
128
+ },
129
+ }
130
+ }
131
+ case 'git-mode-select-entry-by-key': {
132
+ const next = reconcileSelectedGitEntryKey(
133
+ state.gitPanel.files,
134
+ state.gitMode.collapsedFolders,
135
+ state.gitPane.fileListMode,
136
+ state.gitMode.selectedEntryKey,
137
+ [action.key]
138
+ )
139
+ if (!next || next !== action.key || next === state.gitMode.selectedEntryKey) return state
140
+ return {
141
+ ...state,
142
+ gitMode: {
143
+ ...state.gitMode,
144
+ pendingDeletePath: null,
145
+ selectedEntryKey: next,
146
+ },
147
+ }
148
+ }
149
+ case 'git-mode-toggle-folder': {
150
+ const row = getSelectedGitRow(state.gitPanel.files, {
151
+ collapsedFolders: state.gitMode.collapsedFolders,
152
+ fileListMode: state.gitPane.fileListMode,
153
+ selectedEntryKey: action.key,
154
+ })
155
+ if (!row || row.kind !== 'folder') return state
156
+ const nextCollapsed = { ...state.gitMode.collapsedFolders }
157
+ if (action.key in nextCollapsed) {
158
+ delete nextCollapsed[action.key]
159
+ } else {
160
+ nextCollapsed[action.key] = true
161
+ }
162
+ return { ...state, gitMode: { ...state.gitMode, collapsedFolders: nextCollapsed } }
163
+ }
164
+ case 'git-mode-toggle-selected-folder': {
165
+ const row = getSelectedGitRow(state.gitPanel.files, {
166
+ collapsedFolders: state.gitMode.collapsedFolders,
167
+ fileListMode: state.gitPane.fileListMode,
168
+ selectedEntryKey: state.gitMode.selectedEntryKey,
169
+ })
170
+ if (!row || row.kind !== 'folder') return state
171
+ const nextCollapsed = { ...state.gitMode.collapsedFolders }
172
+ if (row.key in nextCollapsed) {
173
+ delete nextCollapsed[row.key]
174
+ } else {
175
+ nextCollapsed[row.key] = true
176
+ }
177
+ return { ...state, gitMode: { ...state.gitMode, collapsedFolders: nextCollapsed } }
178
+ }
179
+ case 'git-mode-collapse-selection': {
180
+ const row = getSelectedGitRow(state.gitPanel.files, {
181
+ collapsedFolders: state.gitMode.collapsedFolders,
182
+ fileListMode: state.gitPane.fileListMode,
183
+ selectedEntryKey: state.gitMode.selectedEntryKey,
184
+ })
185
+ if (!row) return state
186
+ if (row.kind === 'folder' && !row.isCollapsed) {
187
+ return {
188
+ ...state,
189
+ gitMode: {
190
+ ...state.gitMode,
191
+ collapsedFolders: { ...state.gitMode.collapsedFolders, [row.key]: true },
192
+ },
193
+ }
194
+ }
195
+ if (!row.parentKey || row.parentKey === state.gitMode.selectedEntryKey) return state
196
+ return {
197
+ ...state,
198
+ gitMode: {
199
+ ...state.gitMode,
200
+ pendingDeletePath: null,
201
+ selectedEntryKey: row.parentKey,
202
+ },
203
+ }
204
+ }
205
+ case 'git-mode-expand-selection': {
206
+ const row = getSelectedGitRow(state.gitPanel.files, {
207
+ collapsedFolders: state.gitMode.collapsedFolders,
208
+ fileListMode: state.gitPane.fileListMode,
209
+ selectedEntryKey: state.gitMode.selectedEntryKey,
210
+ })
211
+ if (!row || row.kind !== 'folder' || !row.isCollapsed) return state
212
+ const nextCollapsed = { ...state.gitMode.collapsedFolders }
213
+ delete nextCollapsed[row.key]
214
+ return { ...state, gitMode: { ...state.gitMode, collapsedFolders: nextCollapsed } }
215
+ }
216
+ case 'git-mode-toggle-file-list-mode': {
217
+ const fileListMode = state.gitPane.fileListMode === 'tree' ? 'flat' : 'tree'
39
218
  return {
40
219
  ...state,
41
220
  gitMode: {
42
221
  ...state.gitMode,
43
222
  pendingDeletePath: null,
44
- selectedFileIndex: next,
223
+ selectedEntryKey: reconcileSelectedGitEntryKey(
224
+ state.gitPanel.files,
225
+ state.gitMode.collapsedFolders,
226
+ fileListMode,
227
+ state.gitMode.selectedEntryKey
228
+ ),
45
229
  },
230
+ gitPane: { ...state.gitPane, fileListMode },
46
231
  }
47
232
  }
48
233
  case 'git-mode-set-diff': {
49
234
  const nextLoading = { ...state.gitMode.loading }
50
- delete nextLoading[action.path]
235
+ delete nextLoading[action.key]
51
236
  return {
52
237
  ...state,
53
238
  gitMode: {
54
239
  ...state.gitMode,
55
- diffs: { ...state.gitMode.diffs, [action.path]: action.diff },
240
+ diffs: { ...state.gitMode.diffs, [action.key]: action.diff },
56
241
  loading: nextLoading,
57
242
  },
58
243
  }
@@ -60,9 +245,9 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
60
245
  case 'git-mode-set-loading': {
61
246
  const nextLoading = { ...state.gitMode.loading }
62
247
  if (action.loading) {
63
- nextLoading[action.path] = true
248
+ nextLoading[action.key] = true
64
249
  } else {
65
- delete nextLoading[action.path]
250
+ delete nextLoading[action.key]
66
251
  }
67
252
  return { ...state, gitMode: { ...state.gitMode, loading: nextLoading } }
68
253
  }
@@ -71,17 +256,33 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
71
256
  return { ...state, gitMode: { ...state.gitMode, pendingDeletePath: action.path } }
72
257
  }
73
258
  case 'git-mode-clear-diff-cache': {
74
- if (!(action.path in state.gitMode.diffs)) return state
75
- const nextDiffs = { ...state.gitMode.diffs }
76
- delete nextDiffs[action.path]
77
- return { ...state, gitMode: { ...state.gitMode, diffs: nextDiffs } }
259
+ return clearDiffCacheForPath(state, action.path)
78
260
  }
79
261
  case 'git-mode-set-message': {
80
262
  if (state.gitMode.actionMessage === action.message) return state
81
263
  return { ...state, gitMode: { ...state.gitMode, actionMessage: action.message } }
82
264
  }
265
+ case 'git-mode-toggle-diff-view': {
266
+ const next = state.gitMode.diffView === 'split' ? 'stacked' : 'split'
267
+ return { ...state, gitMode: { ...state.gitMode, diffView: next } }
268
+ }
269
+ case 'git-mode-fold-adjust': {
270
+ const perPath = state.gitMode.folds[action.key] ?? {}
271
+ const prev = perPath[action.foldId] ?? { bottom: 0, top: 0 }
272
+ const nextSide = Math.max(0, prev[action.side] + action.delta)
273
+ const nextFold =
274
+ action.side === 'top'
275
+ ? { bottom: prev.bottom, top: nextSide }
276
+ : { bottom: nextSide, top: prev.top }
277
+ return applyFold(state, action.key, action.foldId, nextFold)
278
+ }
279
+ case 'git-mode-fold-set': {
280
+ const top = Math.max(0, action.top)
281
+ const bottom = Math.max(0, action.bottom)
282
+ return applyFold(state, action.key, action.foldId, { bottom, top })
283
+ }
83
284
  case 'git-mode-optimistic-move': {
84
- const currentIdx = state.gitMode.selectedFileIndex
285
+ const currentKey = state.gitMode.selectedEntryKey
85
286
  const files = state.gitPanel.files
86
287
  const idx = files.findIndex((f) => f.path === action.path && f.section === action.fromSection)
87
288
  if (idx < 0) return state
@@ -103,29 +304,25 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
103
304
  }
104
305
  }
105
306
  nextFiles = sortFilesBySection(nextFiles)
106
-
107
- const newLen = nextFiles.length
108
- let nextIdx: number
109
- if (newLen === 0) {
110
- nextIdx = 0
111
- } else {
112
- const movedIdx =
113
- toSection === null
114
- ? -1
115
- : nextFiles.findIndex((f) => f.path === action.path && f.section === toSection)
116
- if (movedIdx === currentIdx) {
117
- nextIdx = (currentIdx + 1) % newLen
118
- } else {
119
- nextIdx = Math.min(currentIdx, newLen - 1)
120
- }
121
- }
307
+ const movedCurrentKey = gitFileKey({ path: action.path, section: action.fromSection })
308
+ const preferredSelection =
309
+ currentKey === movedCurrentKey && toSection !== null
310
+ ? [gitFileKey({ path: action.path, section: toSection })]
311
+ : []
312
+ const nextSelectedEntryKey = reconcileSelectedGitEntryKey(
313
+ nextFiles,
314
+ state.gitMode.collapsedFolders,
315
+ state.gitPane.fileListMode,
316
+ currentKey,
317
+ preferredSelection
318
+ )
122
319
 
123
320
  return {
124
321
  ...state,
125
322
  gitMode: {
126
323
  ...state.gitMode,
127
324
  pendingDeletePath: null,
128
- selectedFileIndex: nextIdx,
325
+ selectedEntryKey: nextSelectedEntryKey,
129
326
  },
130
327
  gitPanel: { ...state.gitPanel, files: nextFiles },
131
328
  }
@@ -1,5 +1,7 @@
1
1
  import type { AppAction, AppState, GitFileEntry, GitFileSection, GitPanelState } from '../types'
2
2
 
3
+ import { reconcileSelectedGitEntryKey } from '../git-tree'
4
+
3
5
  export const GIT_PANEL_MIN_RATIO = 0.2
4
6
  export const GIT_PANEL_MAX_RATIO = 0.8
5
7
 
@@ -63,6 +65,11 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
63
65
  if (nextRatio === state.gitPane.ratio) return state
64
66
  return { ...state, gitPane: { ...state.gitPane, ratio: nextRatio } }
65
67
  }
68
+ case 'resize-git-diff-pane': {
69
+ const nextRatio = clampRatio(state.gitPane.diffModeRatio + action.delta)
70
+ if (nextRatio === state.gitPane.diffModeRatio) return state
71
+ return { ...state, gitPane: { ...state.gitPane, diffModeRatio: nextRatio } }
72
+ }
66
73
  case 'set-git-pane-mode': {
67
74
  if (state.gitPane.mode === action.mode) return state
68
75
  const isEmbedded = action.mode === 'embedded'
@@ -93,17 +100,25 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
93
100
  const prev = state.gitPanel
94
101
  const next = action.payload
95
102
  const sortedNext = sortFilesBySection(next.files)
103
+ const nextSelectedEntryKey = reconcileSelectedGitEntryKey(
104
+ sortedNext,
105
+ state.gitMode.collapsedFolders,
106
+ state.gitPane.fileListMode,
107
+ state.gitMode.selectedEntryKey
108
+ )
96
109
  if (
97
110
  prev.branch === next.branch &&
98
111
  prev.ahead === next.ahead &&
99
112
  prev.behind === next.behind &&
100
113
  prev.error === null &&
101
- sameFiles(prev.files, sortedNext)
114
+ sameFiles(prev.files, sortedNext) &&
115
+ nextSelectedEntryKey === state.gitMode.selectedEntryKey
102
116
  ) {
103
117
  return state
104
118
  }
105
119
  return {
106
120
  ...state,
121
+ gitMode: { ...state.gitMode, selectedEntryKey: nextSelectedEntryKey },
107
122
  gitPanel: {
108
123
  ...prev,
109
124
  ahead: next.ahead,
@@ -116,9 +131,16 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
116
131
  }
117
132
  case 'git-refresh-error': {
118
133
  const prev = state.gitPanel
119
- if (prev.error === action.kind && prev.files.length === 0) return state
134
+ if (
135
+ prev.error === action.kind &&
136
+ prev.files.length === 0 &&
137
+ state.gitMode.selectedEntryKey === null
138
+ ) {
139
+ return state
140
+ }
120
141
  return {
121
142
  ...state,
143
+ gitMode: { ...state.gitMode, pendingDeletePath: null, selectedEntryKey: null },
122
144
  gitPanel: { ...prev, error: action.kind, files: [] },
123
145
  }
124
146
  }
@@ -133,7 +155,11 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
133
155
  ) {
134
156
  return state
135
157
  }
136
- return { ...state, gitPanel: emptyGitPanel() }
158
+ return {
159
+ ...state,
160
+ gitMode: { ...state.gitMode, pendingDeletePath: null, selectedEntryKey: null },
161
+ gitPanel: emptyGitPanel(),
162
+ }
137
163
  }
138
164
  default:
139
165
  return null