@brimveyn/aimux 1.1.0 → 1.2.5
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 +3 -4
- package/src/app-runtime/side-effects.ts +120 -0
- package/src/app-runtime/use-renderer-bindings.ts +6 -0
- package/src/app.tsx +6 -0
- package/src/git/command-queue.ts +7 -0
- package/src/git/git-diff.ts +83 -0
- package/src/git/git-poller.ts +4 -4
- package/src/git/git-status.ts +32 -0
- package/src/index.tsx +0 -0
- package/src/input/modes/bridge.ts +2 -0
- package/src/input/modes/handlers/shared.ts +16 -0
- package/src/input/modes/transitions.ts +3 -0
- package/src/input/modes/types.ts +9 -0
- package/src/state/dispatch-ref.ts +13 -0
- package/src/state/reducers/git-mode-state.ts +136 -0
- package/src/state/reducers/git-panel-state.ts +15 -3
- package/src/state/reducers/modal-state.ts +129 -22
- package/src/state/store.ts +5 -0
- package/src/state/types.ts +56 -1
- package/src/ui/components/git-commit-modal.tsx +42 -0
- package/src/ui/components/git-panel.tsx +42 -13
- package/src/ui/components/git-view.tsx +237 -0
- package/src/ui/components/input-field.tsx +26 -5
- package/src/ui/components/status-bar.tsx +8 -0
- package/src/ui/git-view-controls.ts +11 -0
- package/src/ui/root.tsx +36 -2
- package/src/ui/status-bar-model.ts +18 -0
- package/src/ui/syntax.ts +102 -0
- package/src/ui/theme.ts +9 -0
- package/src/ui/themes.ts +24 -0
- package/src/ui/components/pending-chord-indicator.tsx +0 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.5",
|
|
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",
|
|
@@ -54,11 +54,10 @@
|
|
|
54
54
|
"restart-daemon": "bun run src/index.tsx restart-daemon",
|
|
55
55
|
"lint": "oxlint .",
|
|
56
56
|
"format": "oxfmt --write .",
|
|
57
|
-
"format:check": "oxfmt --check ."
|
|
58
|
-
"prepare": "lefthook install"
|
|
57
|
+
"format:check": "oxfmt --check ."
|
|
59
58
|
},
|
|
60
59
|
"dependencies": {
|
|
61
|
-
"@brimveyn/aimux-config": "
|
|
60
|
+
"@brimveyn/aimux-config": "0.1.0",
|
|
62
61
|
"@opentui/core": "^0.1.90",
|
|
63
62
|
"@opentui/react": "^0.1.90",
|
|
64
63
|
"@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,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
|
+
}
|
package/src/git/git-poller.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect } from 'react'
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
dispatchGlobal({ payload: result.payload, type: 'git-refresh-success' })
|
|
34
34
|
delay = BASE_INTERVAL_MS
|
|
35
35
|
} else {
|
|
36
|
-
|
|
36
|
+
dispatchGlobal({ kind: result.error, type: 'git-refresh-error' })
|
|
37
37
|
delay = Math.min(delay * 2, MAX_INTERVAL_MS)
|
|
38
38
|
}
|
|
39
39
|
schedule()
|
package/src/git/git-status.ts
CHANGED
|
@@ -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 {
|
package/src/index.tsx
CHANGED
|
File without changes
|
|
@@ -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
|
}
|
package/src/input/modes/types.ts
CHANGED
|
@@ -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,
|
|
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:
|
|
86
|
+
files: sortedNext,
|
|
75
87
|
},
|
|
76
88
|
}
|
|
77
89
|
}
|