@brimveyn/aimux 1.1.0 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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",
@@ -58,7 +58,7 @@
58
58
  "prepare": "lefthook install"
59
59
  },
60
60
  "dependencies": {
61
- "@brimveyn/aimux-config": "^0.1.0",
61
+ "@brimveyn/aimux-config": "^0.2.0",
62
62
  "@opentui/core": "^0.1.90",
63
63
  "@opentui/react": "^0.1.90",
64
64
  "@xterm/headless": "^6.0.0",
@@ -1,9 +1,12 @@
1
+ import { $ } from 'bun'
2
+
1
3
  import type { SideEffect } from '../input/modes/types'
2
4
  import type { SessionBackend } from '../session-backend/types'
3
5
  import type { AppAction, AppState, AssistantId, TabSession } from '../state/types'
4
6
 
5
7
  import { loadConfig, saveConfig } from '../config'
6
8
  import { logInputDebug } from '../debug/input-log'
9
+ import { enqueueGitOp } from '../git/command-queue'
7
10
  import { createPrefixedId } from '../platform/id'
8
11
  import {
9
12
  getAllAssistantOptions,
@@ -25,6 +28,7 @@ import {
25
28
  import { filterSessions, filterSnippets } from '../state/selectors'
26
29
  import { createDefaultTerminalModes } from '../state/terminal-modes'
27
30
  import { saveCurrentWorkspace } from '../state/workspace-save'
31
+ import { scrollGitDiff } from '../ui/git-view-controls'
28
32
  import { applyTheme } from '../ui/theme'
29
33
  import { THEME_IDS, type ThemeId } from '../ui/themes'
30
34
  import {
@@ -428,7 +432,123 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
428
432
  confirmSplitSelection(ctx)
429
433
  return
430
434
  }
435
+ case 'scroll-git-diff': {
436
+ scrollGitDiff(effect.delta)
437
+ return
438
+ }
439
+ case 'git-stage': {
440
+ void enqueueGitOp(() => runGitAction(ctx, ['add', '--', effect.path], effect.path))
441
+ return
442
+ }
443
+ case 'git-unstage': {
444
+ void enqueueGitOp(() =>
445
+ runGitAction(ctx, ['restore', '--staged', '--', effect.path], effect.path)
446
+ )
447
+ return
448
+ }
449
+ case 'git-restore': {
450
+ void enqueueGitOp(() => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
451
+ return
452
+ }
453
+ case 'git-rm': {
454
+ void enqueueGitOp(() => runGitRm(ctx, effect.path))
455
+ return
456
+ }
457
+ case 'git-commit': {
458
+ const { body, title } = effect
459
+ void enqueueGitOp(() => runGitCommit(ctx, title, body))
460
+ return
461
+ }
462
+ case 'git-push': {
463
+ void enqueueGitOp(() => runGitPush(ctx))
464
+ return
465
+ }
431
466
  default:
432
467
  effect satisfies never
433
468
  }
434
469
  }
470
+
471
+ async function runGitAction(
472
+ ctx: SideEffectContext,
473
+ args: string[],
474
+ pathToInvalidate?: string
475
+ ): Promise<void> {
476
+ const cwd = ctx.getCurrentSessionProjectPath()
477
+ if (!cwd) return
478
+ const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
479
+ if (result.exitCode !== 0) {
480
+ const stderr = result.stderr.toString().trim()
481
+ ctx.dispatch({ message: stderr || 'git action failed', type: 'git-mode-set-message' })
482
+ return
483
+ }
484
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
485
+ if (pathToInvalidate) {
486
+ ctx.dispatch({ path: pathToInvalidate, type: 'git-mode-clear-diff-cache' })
487
+ }
488
+ }
489
+
490
+ async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
491
+ const cwd = ctx.getCurrentSessionProjectPath()
492
+ if (!cwd) return
493
+ const absolute = `${cwd}/${path}`
494
+ try {
495
+ const stat = await Bun.file(absolute).stat()
496
+ if (stat.isDirectory()) {
497
+ await Bun.$`rm -rf -- ${absolute}`.quiet().nothrow()
498
+ } else {
499
+ await Bun.file(absolute).unlink()
500
+ }
501
+ } catch (err) {
502
+ const message = err instanceof Error ? err.message : 'failed to delete file'
503
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
504
+ return
505
+ }
506
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
507
+ ctx.dispatch({ path, type: 'git-mode-clear-diff-cache' })
508
+ }
509
+
510
+ async function runGitCommit(ctx: SideEffectContext, title: string, body: string): Promise<void> {
511
+ const cwd = ctx.getCurrentSessionProjectPath()
512
+ if (!cwd) return
513
+ if (!title) {
514
+ ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
515
+ return
516
+ }
517
+ const result = body
518
+ ? await $`git -C ${cwd} commit -m ${title} -m ${body}`.quiet().nothrow()
519
+ : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
520
+ if (result.exitCode !== 0) {
521
+ const stderr = result.stderr.toString().trim()
522
+ ctx.dispatch({
523
+ message: stderr || 'commit failed',
524
+ type: 'git-mode-set-message',
525
+ })
526
+ return
527
+ }
528
+ ctx.dispatch({ message: `committed: ${title}`, type: 'git-mode-set-message' })
529
+ }
530
+
531
+ async function runGitPush(ctx: SideEffectContext): Promise<void> {
532
+ const cwd = ctx.getCurrentSessionProjectPath()
533
+ if (!cwd) return
534
+ ctx.dispatch({ message: 'pushing…', type: 'git-mode-set-message' })
535
+
536
+ const upstream = await $`git -C ${cwd} rev-parse --abbrev-ref --symbolic-full-name @{u}`
537
+ .quiet()
538
+ .nothrow()
539
+ const hasUpstream = upstream.exitCode === 0
540
+
541
+ const result = hasUpstream
542
+ ? await $`git -C ${cwd} push`.quiet().nothrow()
543
+ : await $`git -C ${cwd} push --set-upstream origin HEAD`.quiet().nothrow()
544
+
545
+ if (result.exitCode !== 0) {
546
+ const stderr = result.stderr.toString().trim()
547
+ ctx.dispatch({
548
+ message: stderr || 'push failed',
549
+ type: 'git-mode-set-message',
550
+ })
551
+ return
552
+ }
553
+ ctx.dispatch({ message: 'pushed', type: 'git-mode-set-message' })
554
+ }
@@ -86,6 +86,12 @@ export function useRendererBindings({
86
86
  focusMode: currentFocusMode,
87
87
  })
88
88
 
89
+ if (currentFocusMode === 'command-edit') {
90
+ const sanitized = payload.replace(/\r\n?/g, '\n')
91
+ dispatch({ char: sanitized, type: 'update-command-edit' })
92
+ return
93
+ }
94
+
89
95
  if (currentFocusMode !== 'terminal-input' || !tabId || !tab) {
90
96
  return
91
97
  }
package/src/app.tsx CHANGED
@@ -21,6 +21,7 @@ import { registerAllModes } from './input/modes/handlers'
21
21
  import { getHandler, transitionTo } from './input/modes/registry'
22
22
  import { type TerminalContentOrigin } from './input/raw-input-handler'
23
23
  import { appStore } from './state/app-store'
24
+ import { setActiveDispatch } from './state/dispatch-ref'
24
25
  import { loadSessionCatalog } from './state/session-catalog'
25
26
  import { loadSnippetCatalog } from './state/snippet-catalog'
26
27
  import { appReducer, createInitialState } from './state/store'
@@ -53,6 +54,11 @@ export function App({ backend }: { backend: SessionBackend }) {
53
54
  appStore.setState(state)
54
55
  }, [state])
55
56
 
57
+ useLayoutEffect(() => {
58
+ setActiveDispatch(dispatch)
59
+ return () => setActiveDispatch(null)
60
+ }, [dispatch])
61
+
56
62
  const resizingRef = useRef(false)
57
63
  const layoutRef = useRef(state.layout)
58
64
  layoutRef.current = state.layout
@@ -0,0 +1,7 @@
1
+ let tail: Promise<unknown> = Promise.resolve()
2
+
3
+ export function enqueueGitOp<T>(op: () => Promise<T>): Promise<T> {
4
+ const next = tail.then(op, op)
5
+ tail = next.catch(() => {})
6
+ return next
7
+ }
@@ -0,0 +1,83 @@
1
+ import { $ } from 'bun'
2
+
3
+ import type { DiffData, DiffFileStatus, GitFileEntry } from '../state/types'
4
+
5
+ function resolveStatus(entry: GitFileEntry): { status: DiffFileStatus; oldPath?: string } {
6
+ if (entry.renamedFrom) return { oldPath: entry.renamedFrom, status: 'renamed' }
7
+ if (entry.section === 'untracked' || entry.status === '?') return { status: 'new' }
8
+ if (entry.status === 'D') return { status: 'deleted' }
9
+ if (entry.status === 'A' && entry.section === 'staged') return { status: 'new' }
10
+ return { status: 'modified' }
11
+ }
12
+
13
+ async function isBinary(cwd: string, path: string): Promise<boolean> {
14
+ const result = await $`git -C ${cwd} diff HEAD --numstat -- ${path}`.quiet().nothrow()
15
+ if (result.exitCode !== 0) return false
16
+ const text = result.text().trim()
17
+ if (!text) return false
18
+ const first = text.split('\n')[0] ?? ''
19
+ return first.startsWith('-\t-\t')
20
+ }
21
+
22
+ async function readHeadSize(cwd: string, path: string): Promise<number> {
23
+ const result = await $`git -C ${cwd} show HEAD:${path}`.quiet().nothrow()
24
+ if (result.exitCode !== 0) return 0
25
+ return result.text().length
26
+ }
27
+
28
+ async function readWorkingSize(cwd: string, path: string): Promise<number> {
29
+ try {
30
+ const file = Bun.file(`${cwd}/${path}`)
31
+ if (await file.exists()) return file.size
32
+ } catch {}
33
+ return 0
34
+ }
35
+
36
+ async function rawUnifiedDiff(cwd: string, path: string, status: DiffFileStatus): Promise<string> {
37
+ if (status === 'new') {
38
+ const result = await $`git -C ${cwd} diff HEAD --no-color --no-textconv -- ${path}`
39
+ .quiet()
40
+ .nothrow()
41
+ if (result.exitCode === 0 && result.text().length > 0) return result.text()
42
+ const untracked =
43
+ await $`git -C ${cwd} diff --no-index --no-color --no-textconv /dev/null -- ${path}`
44
+ .quiet()
45
+ .nothrow()
46
+ return untracked.text()
47
+ }
48
+
49
+ const result =
50
+ await $`git -C ${cwd} diff HEAD --unified=99999 --no-color --no-textconv -- ${path}`
51
+ .quiet()
52
+ .nothrow()
53
+ if (result.exitCode !== 0) return ''
54
+ return result.text()
55
+ }
56
+
57
+ export async function fetchDiff(cwd: string, file: GitFileEntry): Promise<DiffData> {
58
+ const { oldPath, status } = resolveStatus(file)
59
+
60
+ if (await isBinary(cwd, file.path)) {
61
+ const [binarySizeBefore, binarySizeAfter] = await Promise.all([
62
+ readHeadSize(cwd, file.path),
63
+ readWorkingSize(cwd, file.path),
64
+ ])
65
+ return {
66
+ binarySizeAfter,
67
+ binarySizeBefore,
68
+ path: file.path,
69
+ rawDiff: '',
70
+ status: 'binary',
71
+ }
72
+ }
73
+
74
+ const rawDiff = await rawUnifiedDiff(cwd, file.path, status)
75
+
76
+ const data: DiffData = {
77
+ path: file.path,
78
+ rawDiff,
79
+ status,
80
+ }
81
+ if (oldPath) data.oldPath = oldPath
82
+ return data
83
+ }
@@ -1,6 +1,6 @@
1
1
  import { useEffect } from 'react'
2
2
 
3
- import { appStore } from '../state/app-store'
3
+ import { dispatchGlobal } from '../state/dispatch-ref'
4
4
  import { collectGitStatus } from './git-status'
5
5
 
6
6
  const BASE_INTERVAL_MS = 1000
@@ -15,7 +15,7 @@ export function useGitPanelPolling({ enabled, projectPath }: Options): void {
15
15
  useEffect(() => {
16
16
  if (!enabled || !projectPath) return undefined
17
17
 
18
- appStore.getState().dispatch({ type: 'git-panel-reset' })
18
+ dispatchGlobal({ type: 'git-panel-reset' })
19
19
 
20
20
  let cancelled = false
21
21
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -30,10 +30,10 @@ export function useGitPanelPolling({ enabled, projectPath }: Options): void {
30
30
  const result = await collectGitStatus(projectPath)
31
31
  if (cancelled) return
32
32
  if (result.kind === 'ok') {
33
- appStore.getState().dispatch({ payload: result.payload, type: 'git-refresh-success' })
33
+ dispatchGlobal({ payload: result.payload, type: 'git-refresh-success' })
34
34
  delay = BASE_INTERVAL_MS
35
35
  } else {
36
- appStore.getState().dispatch({ kind: result.error, type: 'git-refresh-error' })
36
+ dispatchGlobal({ kind: result.error, type: 'git-refresh-error' })
37
37
  delay = Math.min(delay * 2, MAX_INTERVAL_MS)
38
38
  }
39
39
  schedule()
@@ -158,6 +158,37 @@ export function parsePorcelainEntries(
158
158
  return files
159
159
  }
160
160
 
161
+ async function countUntrackedLines(cwd: string, path: string): Promise<number | null> {
162
+ try {
163
+ const file = Bun.file(`${cwd}/${path}`)
164
+ if (!(await file.exists())) return null
165
+ const text = await file.text()
166
+ if (text.length === 0) return 0
167
+ let count = 0
168
+ for (let i = 0; i < text.length; i++) {
169
+ if (text.charCodeAt(i) === 10) count++
170
+ }
171
+ if (text.charCodeAt(text.length - 1) !== 10) count++
172
+ return count
173
+ } catch {
174
+ return null
175
+ }
176
+ }
177
+
178
+ async function annotateUntrackedCounts(cwd: string, files: GitFileEntry[]): Promise<void> {
179
+ const untracked = files.filter((f) => f.section === 'untracked')
180
+ if (untracked.length === 0) return
181
+ const counts = await Promise.all(untracked.map((f) => countUntrackedLines(cwd, f.path)))
182
+ for (let i = 0; i < untracked.length; i++) {
183
+ const file = untracked[i]
184
+ const count = counts[i]
185
+ if (file && count !== null && count !== undefined) {
186
+ file.added = count
187
+ file.removed = 0
188
+ }
189
+ }
190
+ }
191
+
161
192
  export async function collectGitStatus(cwd: string): Promise<GitCollectResult> {
162
193
  try {
163
194
  const [statusResult, unstagedDiff, stagedDiff] = await Promise.all([
@@ -175,6 +206,7 @@ export async function collectGitStatus(cwd: string): Promise<GitCollectResult> {
175
206
  const unstagedNumstat = parseNumstat(unstagedDiff.text())
176
207
  const stagedNumstat = parseNumstat(stagedDiff.text())
177
208
  const files = parsePorcelainEntries(statusText, stagedNumstat, unstagedNumstat)
209
+ await annotateUntrackedCounts(cwd, files)
178
210
 
179
211
  return { kind: 'ok', payload: { ahead, behind, branch, files } }
180
212
  } catch {
@@ -4,6 +4,7 @@ import type { ModeId } from './types'
4
4
  type SupportedModalType = Exclude<ModalType, null>
5
5
 
6
6
  const DIRECT_FOCUS_MODE_IDS: Partial<Record<FocusMode, ModeId>> = {
7
+ 'git': 'git-mode',
7
8
  'layout': 'layout',
8
9
  'navigation': 'navigation',
9
10
  'terminal-input': 'terminal-input',
@@ -11,6 +12,7 @@ const DIRECT_FOCUS_MODE_IDS: Partial<Record<FocusMode, ModeId>> = {
11
12
 
12
13
  const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
13
14
  'create-session': 'modal.create-session',
15
+ 'git-commit': 'modal.git-commit',
14
16
  'new-tab': 'modal.new-tab.command-edit',
15
17
  'rename-tab': 'modal.rename-tab',
16
18
  'session-name': 'modal.session-name',
@@ -68,3 +68,19 @@ export function handleCtrlNavigation(key: KeyInput): KeyResult | null {
68
68
 
69
69
  return null
70
70
  }
71
+
72
+ export function handleCursorNavigation(key: KeyInput): KeyResult | null {
73
+ if (key.name === 'left' && !key.ctrl && !key.meta) {
74
+ return result([{ delta: -1, type: 'move-modal-cursor' }])
75
+ }
76
+ if (key.name === 'right' && !key.ctrl && !key.meta) {
77
+ return result([{ delta: 1, type: 'move-modal-cursor' }])
78
+ }
79
+ if (key.name === 'home' || (key.ctrl && key.name === 'a')) {
80
+ return result([{ to: 'home', type: 'move-modal-cursor' }])
81
+ }
82
+ if (key.name === 'end' || (key.ctrl && key.name === 'e')) {
83
+ return result([{ to: 'end', type: 'move-modal-cursor' }])
84
+ }
85
+ return null
86
+ }
@@ -1,8 +1,10 @@
1
1
  import type { ModeId } from './types'
2
2
 
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
+ 'git-mode': ['navigation', 'modal.git-commit'],
4
5
  'layout': ['terminal-input', 'navigation', 'modal.split-picker'],
5
6
  'modal.create-session': ['navigation', 'modal.session-picker'],
7
+ 'modal.git-commit': ['git-mode'],
6
8
  'modal.help': ['navigation'],
7
9
  'modal.new-tab': ['navigation', 'modal.new-tab.command-edit'],
8
10
  'modal.new-tab.command-edit': ['modal.new-tab'],
@@ -28,6 +30,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
28
30
  'modal.snippet-picker',
29
31
  'modal.theme-picker',
30
32
  'modal.rename-tab',
33
+ 'git-mode',
31
34
  ],
32
35
  'terminal-input': ['navigation', 'layout'],
33
36
  }
@@ -6,6 +6,7 @@ export type ModeId =
6
6
  | 'navigation'
7
7
  | 'terminal-input'
8
8
  | 'layout'
9
+ | 'git-mode'
9
10
  | 'modal.new-tab'
10
11
  | 'modal.new-tab.command-edit'
11
12
  | 'modal.session-picker'
@@ -19,6 +20,7 @@ export type ModeId =
19
20
  | 'modal.theme-picker'
20
21
  | 'modal.help'
21
22
  | 'modal.split-picker'
23
+ | 'modal.git-commit'
22
24
 
23
25
  export type SideEffect =
24
26
  | { type: 'quit'; state: AppState }
@@ -42,6 +44,13 @@ export type SideEffect =
42
44
  | { type: 'rename-session'; sessionId: string; name: string }
43
45
  | { type: 'split-pane'; direction: import('../../state/layout-tree').SplitDirection }
44
46
  | { type: 'confirm-split' }
47
+ | { type: 'scroll-git-diff'; delta: number }
48
+ | { type: 'git-stage'; path: string }
49
+ | { type: 'git-unstage'; path: string }
50
+ | { type: 'git-restore'; path: string }
51
+ | { type: 'git-rm'; path: string }
52
+ | { type: 'git-commit'; title: string; body: string }
53
+ | { type: 'git-push' }
45
54
 
46
55
  export interface KeyResult {
47
56
  actions: AppAction[]
@@ -0,0 +1,13 @@
1
+ import type { AppAction } from './types'
2
+
3
+ type DispatchFn = (action: AppAction) => void
4
+
5
+ let activeDispatch: DispatchFn | null = null
6
+
7
+ export function setActiveDispatch(dispatch: DispatchFn | null): void {
8
+ activeDispatch = dispatch
9
+ }
10
+
11
+ export function dispatchGlobal(action: AppAction): void {
12
+ activeDispatch?.(action)
13
+ }
@@ -0,0 +1,136 @@
1
+ import type { AppAction, AppState, GitFileEntry, GitModeState } from '../types'
2
+
3
+ import { sortFilesBySection } from './git-panel-state'
4
+
5
+ export function emptyGitMode(): GitModeState {
6
+ return {
7
+ actionMessage: null,
8
+ diffs: {},
9
+ loading: {},
10
+ pendingDeletePath: null,
11
+ selectedFileIndex: 0,
12
+ }
13
+ }
14
+
15
+ export function reduceGitModeState(state: AppState, action: AppAction): AppState | null {
16
+ switch (action.type) {
17
+ case 'enter-git-mode': {
18
+ if (state.focusMode === 'git') return state
19
+ return {
20
+ ...state,
21
+ focusMode: 'git',
22
+ gitMode: {
23
+ ...state.gitMode,
24
+ actionMessage: null,
25
+ pendingDeletePath: null,
26
+ selectedFileIndex: 0,
27
+ },
28
+ }
29
+ }
30
+ case 'exit-git-mode': {
31
+ if (state.focusMode !== 'git') return state
32
+ return { ...state, focusMode: 'navigation' }
33
+ }
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
39
+ return {
40
+ ...state,
41
+ gitMode: {
42
+ ...state.gitMode,
43
+ pendingDeletePath: null,
44
+ selectedFileIndex: next,
45
+ },
46
+ }
47
+ }
48
+ case 'git-mode-set-diff': {
49
+ const nextLoading = { ...state.gitMode.loading }
50
+ delete nextLoading[action.path]
51
+ return {
52
+ ...state,
53
+ gitMode: {
54
+ ...state.gitMode,
55
+ diffs: { ...state.gitMode.diffs, [action.path]: action.diff },
56
+ loading: nextLoading,
57
+ },
58
+ }
59
+ }
60
+ case 'git-mode-set-loading': {
61
+ const nextLoading = { ...state.gitMode.loading }
62
+ if (action.loading) {
63
+ nextLoading[action.path] = true
64
+ } else {
65
+ delete nextLoading[action.path]
66
+ }
67
+ return { ...state, gitMode: { ...state.gitMode, loading: nextLoading } }
68
+ }
69
+ case 'git-mode-set-pending-delete': {
70
+ if (state.gitMode.pendingDeletePath === action.path) return state
71
+ return { ...state, gitMode: { ...state.gitMode, pendingDeletePath: action.path } }
72
+ }
73
+ 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 } }
78
+ }
79
+ case 'git-mode-set-message': {
80
+ if (state.gitMode.actionMessage === action.message) return state
81
+ return { ...state, gitMode: { ...state.gitMode, actionMessage: action.message } }
82
+ }
83
+ case 'git-mode-optimistic-move': {
84
+ const currentIdx = state.gitMode.selectedFileIndex
85
+ const files = state.gitPanel.files
86
+ const idx = files.findIndex((f) => f.path === action.path && f.section === action.fromSection)
87
+ if (idx < 0) return state
88
+
89
+ const toSection = action.toSection
90
+ let nextFiles: GitFileEntry[]
91
+ if (toSection === null) {
92
+ nextFiles = files.filter((_, i) => i !== idx)
93
+ } else {
94
+ const duplicateIdx = files.findIndex(
95
+ (f, i) => i !== idx && f.path === action.path && f.section === toSection
96
+ )
97
+ if (duplicateIdx >= 0) {
98
+ nextFiles = files.filter((_, i) => i !== idx)
99
+ } else {
100
+ nextFiles = files.map((f, i) =>
101
+ i === idx ? ({ ...f, section: toSection } as GitFileEntry) : f
102
+ )
103
+ }
104
+ }
105
+ 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
+ }
122
+
123
+ return {
124
+ ...state,
125
+ gitMode: {
126
+ ...state.gitMode,
127
+ pendingDeletePath: null,
128
+ selectedFileIndex: nextIdx,
129
+ },
130
+ gitPanel: { ...state.gitPanel, files: nextFiles },
131
+ }
132
+ }
133
+ default:
134
+ return null
135
+ }
136
+ }
@@ -1,8 +1,19 @@
1
- import type { AppAction, AppState, GitFileEntry, GitPanelState } from '../types'
1
+ import type { AppAction, AppState, GitFileEntry, GitFileSection, GitPanelState } from '../types'
2
2
 
3
3
  export const GIT_PANEL_MIN_RATIO = 0.2
4
4
  export const GIT_PANEL_MAX_RATIO = 0.8
5
5
 
6
+ const SECTION_RANK: Record<GitFileSection, number> = { staged: 0, unstaged: 1, untracked: 2 }
7
+
8
+ export function sortFilesBySection(files: GitFileEntry[]): GitFileEntry[] {
9
+ return [...files].sort((a, b) => {
10
+ const sa = SECTION_RANK[a.section]
11
+ const sb = SECTION_RANK[b.section]
12
+ if (sa !== sb) return sa - sb
13
+ return a.path.localeCompare(b.path)
14
+ })
15
+ }
16
+
6
17
  function clampRatio(value: number): number {
7
18
  return Math.max(GIT_PANEL_MIN_RATIO, Math.min(GIT_PANEL_MAX_RATIO, value))
8
19
  }
@@ -54,12 +65,13 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
54
65
  case 'git-refresh-success': {
55
66
  const prev = state.gitPanel
56
67
  const next = action.payload
68
+ const sortedNext = sortFilesBySection(next.files)
57
69
  if (
58
70
  prev.branch === next.branch &&
59
71
  prev.ahead === next.ahead &&
60
72
  prev.behind === next.behind &&
61
73
  prev.error === null &&
62
- sameFiles(prev.files, next.files)
74
+ sameFiles(prev.files, sortedNext)
63
75
  ) {
64
76
  return state
65
77
  }
@@ -71,7 +83,7 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
71
83
  behind: next.behind,
72
84
  branch: next.branch,
73
85
  error: null,
74
- files: next.files,
86
+ files: sortedNext,
75
87
  },
76
88
  }
77
89
  }