@brimveyn/aimux 1.13.0 → 1.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app-runtime/backend-attach-runtime.ts +11 -11
- package/src/app-runtime/side-effects.ts +61 -38
- package/src/daemon/daemon.ts +18 -8
- package/src/git/command-queue.ts +18 -2
- package/src/session-backend/remote-session-backend.ts +57 -42
- package/src/ui/components/git/diff-renderer/fold-strip.tsx +30 -23
- package/src/ui/components/git/diff-renderer/split-view.tsx +33 -7
- package/src/ui/components/git/diff-renderer/stacked-view.tsx +23 -3
- package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +9 -5
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +8 -6
- package/src/ui/components/git/git-panel.tsx +114 -75
- package/src/ui/components/git/git-view.tsx +26 -18
- package/src/ui/components/git/image-diff/terminal-image-pane.tsx +10 -10
- package/src/ui/components/layout/session-bar.tsx +134 -116
- package/src/ui/components/layout/sidebar/sidebar.tsx +89 -53
- package/src/ui/components/layout/sidebar/tab-item.tsx +65 -54
- package/src/ui/components/layout/split-layout.tsx +27 -20
- package/src/ui/components/layout/terminal-pane.tsx +154 -114
- package/src/ui/components/modals/app/help-modal.tsx +25 -18
- package/src/ui/components/modals/git/git-commit-modal.tsx +15 -8
- package/src/ui/components/modals/sessions/create-session-modal.tsx +13 -9
- package/src/ui/components/modals/sessions/session-picker-modal.tsx +35 -28
- package/src/ui/components/modals/shared/form.tsx +2 -1
- package/src/ui/components/modals/shared/picker.tsx +49 -33
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +42 -29
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +82 -66
- package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +23 -6
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +11 -6
- package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
- package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
- package/src/ui/components/primitives/list-item.tsx +20 -4
- package/src/ui/root.tsx +77 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.2",
|
|
4
4
|
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"bump": "bun run scripts/bump.ts"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@brimveyn/aimux-config": "0.6.
|
|
63
|
+
"@brimveyn/aimux-config": "0.6.2",
|
|
64
64
|
"@opentui/core": "^0.1.90",
|
|
65
65
|
"@opentui/react": "^0.1.90",
|
|
66
66
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
@@ -109,14 +109,14 @@ export function attachCurrentSession({
|
|
|
109
109
|
attachRequestIdRef.current = attachRequestId
|
|
110
110
|
let cancelled = false
|
|
111
111
|
|
|
112
|
-
void
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
112
|
+
void (async () => {
|
|
113
|
+
try {
|
|
114
|
+
const result = await backend.attach({
|
|
115
|
+
cols: layoutRef.current.terminalCols,
|
|
116
|
+
rows: layoutRef.current.terminalRows,
|
|
117
|
+
sessionId: currentSessionId,
|
|
118
|
+
workspaceSnapshot: currentSessionWorkspaceSnapshot,
|
|
119
|
+
})
|
|
120
120
|
if (cancelled || attachRequestIdRef.current !== attachRequestId) {
|
|
121
121
|
return
|
|
122
122
|
}
|
|
@@ -134,8 +134,7 @@ export function attachCurrentSession({
|
|
|
134
134
|
layoutRef,
|
|
135
135
|
backend
|
|
136
136
|
)
|
|
137
|
-
})
|
|
138
|
-
.catch((error) => {
|
|
137
|
+
} catch (error) {
|
|
139
138
|
if (cancelled || attachRequestIdRef.current !== attachRequestId) {
|
|
140
139
|
return
|
|
141
140
|
}
|
|
@@ -151,7 +150,8 @@ export function attachCurrentSession({
|
|
|
151
150
|
layoutRef,
|
|
152
151
|
backend
|
|
153
152
|
)
|
|
154
|
-
}
|
|
153
|
+
}
|
|
154
|
+
})()
|
|
155
155
|
|
|
156
156
|
return () => {
|
|
157
157
|
cancelled = true
|
|
@@ -508,9 +508,21 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
508
508
|
const worktreeName = state.modal.worktreeName
|
|
509
509
|
const branchName = state.modal.branchName
|
|
510
510
|
const sourceWorktreeId = getNewTabTargetWorktreeId(state)
|
|
511
|
-
void
|
|
512
|
-
|
|
513
|
-
|
|
511
|
+
void (async () => {
|
|
512
|
+
try {
|
|
513
|
+
await enqueueGitOp(async () =>
|
|
514
|
+
launchAssistantInNewWorktree(
|
|
515
|
+
ctx,
|
|
516
|
+
option.id,
|
|
517
|
+
worktreeName,
|
|
518
|
+
branchName,
|
|
519
|
+
sourceWorktreeId
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
} catch (error) {
|
|
523
|
+
toast.error(error instanceof Error ? error.message : String(error))
|
|
524
|
+
}
|
|
525
|
+
})()
|
|
514
526
|
return
|
|
515
527
|
}
|
|
516
528
|
launchAssistant(ctx, option.id, getNewTabTargetWorktreeId(state))
|
|
@@ -534,44 +546,54 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
534
546
|
return
|
|
535
547
|
}
|
|
536
548
|
case 'delete-worktree': {
|
|
537
|
-
void
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
const
|
|
551
|
-
if (
|
|
552
|
-
|
|
553
|
-
|
|
549
|
+
void (async () => {
|
|
550
|
+
try {
|
|
551
|
+
await enqueueGitOp(async () =>
|
|
552
|
+
runDeleteWorktree(
|
|
553
|
+
{ ...ctx, state: ctx.getState() },
|
|
554
|
+
effect.sessionId,
|
|
555
|
+
effect.worktreeId,
|
|
556
|
+
!!(effect.force === true)
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
} catch (error) {
|
|
560
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
561
|
+
const forceable = isForceableWorktreeDeleteError(message)
|
|
562
|
+
const latest = ctx.getState()
|
|
563
|
+
if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
|
|
564
|
+
const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
|
|
565
|
+
const selected = session?.worktrees?.[latest.modal.selectedIndex]
|
|
566
|
+
if (selected && selected.id !== effect.worktreeId) {
|
|
567
|
+
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
568
|
+
return
|
|
569
|
+
}
|
|
554
570
|
}
|
|
571
|
+
ctx.dispatch({
|
|
572
|
+
confirmWorktreeId: forceable ? effect.worktreeId : null,
|
|
573
|
+
message: forceable ? message : `Could not delete worktree: ${message}`,
|
|
574
|
+
type: 'set-new-tab-worktree-delete-state',
|
|
575
|
+
})
|
|
576
|
+
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
555
577
|
}
|
|
556
|
-
|
|
557
|
-
confirmWorktreeId: forceable ? effect.worktreeId : null,
|
|
558
|
-
message: forceable ? message : `Could not delete worktree: ${message}`,
|
|
559
|
-
type: 'set-new-tab-worktree-delete-state',
|
|
560
|
-
})
|
|
561
|
-
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
562
|
-
})
|
|
578
|
+
})()
|
|
563
579
|
return
|
|
564
580
|
}
|
|
565
581
|
case 'move-worktree': {
|
|
566
|
-
void
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
582
|
+
void (async () => {
|
|
583
|
+
try {
|
|
584
|
+
await enqueueGitOp(async () =>
|
|
585
|
+
runMoveWorktree(
|
|
586
|
+
{ ...ctx, state: ctx.getState() },
|
|
587
|
+
effect.sessionId,
|
|
588
|
+
effect.sourceWorktreeId,
|
|
589
|
+
effect.targetWorktreeId,
|
|
590
|
+
effect.deleteSource === true
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
} catch (error) {
|
|
594
|
+
toast.error(error instanceof Error ? error.message : String(error))
|
|
595
|
+
}
|
|
596
|
+
})()
|
|
575
597
|
return
|
|
576
598
|
}
|
|
577
599
|
case 'open-rename-selected-session': {
|
|
@@ -1364,14 +1386,15 @@ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
|
|
|
1364
1386
|
stdin: 'inherit',
|
|
1365
1387
|
stdout: 'inherit',
|
|
1366
1388
|
})
|
|
1367
|
-
void
|
|
1389
|
+
void (async () => {
|
|
1390
|
+
const code = await proc.exited
|
|
1368
1391
|
if (code === 0) {
|
|
1369
1392
|
process.stdout.write(`\nUpdated. Run \`aimux\` to start the new version.\n`)
|
|
1370
1393
|
} else {
|
|
1371
1394
|
process.stderr.write(`\nUpdate failed (exit code ${code}).\n`)
|
|
1372
1395
|
}
|
|
1373
1396
|
process.exit(code ?? 1)
|
|
1374
|
-
})
|
|
1397
|
+
})()
|
|
1375
1398
|
}
|
|
1376
1399
|
|
|
1377
1400
|
async function runGitAction(
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -293,12 +293,16 @@ export async function runDaemon(): Promise<void> {
|
|
|
293
293
|
* previous broadcast state, matching pre-fix behaviour).
|
|
294
294
|
*/
|
|
295
295
|
const updateTmBroadcastForClientCount = (count: number): void => {
|
|
296
|
-
void
|
|
297
|
-
|
|
298
|
-
count
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
296
|
+
void (async () => {
|
|
297
|
+
try {
|
|
298
|
+
await manager.setBroadcastEnabled(count > 0)
|
|
299
|
+
} catch (error) {
|
|
300
|
+
logDebug('daemon.setBroadcastEnabled.error', {
|
|
301
|
+
count,
|
|
302
|
+
error: error instanceof Error ? error.message : String(error),
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
})()
|
|
302
306
|
}
|
|
303
307
|
|
|
304
308
|
// Initial state: no clients yet, ask the TM to suspend broadcast.
|
|
@@ -317,7 +321,13 @@ export async function runDaemon(): Promise<void> {
|
|
|
317
321
|
let processing: Promise<void> = Promise.resolve()
|
|
318
322
|
|
|
319
323
|
socket.on('data', (chunk) => {
|
|
320
|
-
|
|
324
|
+
const previous = processing
|
|
325
|
+
processing = (async () => {
|
|
326
|
+
try {
|
|
327
|
+
await previous
|
|
328
|
+
} catch {
|
|
329
|
+
// A prior chunk's failure shouldn't block later chunks on this socket.
|
|
330
|
+
}
|
|
321
331
|
try {
|
|
322
332
|
for (const message of decoder.push(chunk)) {
|
|
323
333
|
try {
|
|
@@ -556,7 +566,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
556
566
|
decoder.reset()
|
|
557
567
|
send(socket, { id: crypto.randomUUID(), payload: { message }, type: 'error' })
|
|
558
568
|
}
|
|
559
|
-
})
|
|
569
|
+
})()
|
|
560
570
|
})
|
|
561
571
|
|
|
562
572
|
socket.on('close', () => {
|
package/src/git/command-queue.ts
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
let tail: Promise<unknown> = Promise.resolve()
|
|
2
2
|
|
|
3
3
|
export async function enqueueGitOp<T>(op: () => Promise<T>): Promise<T> {
|
|
4
|
-
const
|
|
5
|
-
|
|
4
|
+
const previous = tail
|
|
5
|
+
const run = async (): Promise<T> => {
|
|
6
|
+
try {
|
|
7
|
+
await previous
|
|
8
|
+
} catch {
|
|
9
|
+
// Previous op already surfaced its own failure; keep the queue moving.
|
|
10
|
+
}
|
|
11
|
+
return op()
|
|
12
|
+
}
|
|
13
|
+
const next = run()
|
|
14
|
+
// Advance the queue tail without letting this op's rejection break the chain.
|
|
15
|
+
tail = (async () => {
|
|
16
|
+
try {
|
|
17
|
+
await next
|
|
18
|
+
} catch {
|
|
19
|
+
// Swallow so the next enqueued op still runs.
|
|
20
|
+
}
|
|
21
|
+
})()
|
|
6
22
|
return next
|
|
7
23
|
}
|
|
@@ -144,6 +144,17 @@ export class RemoteSessionBackend
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** Fire-and-forget a command, reporting any failure via reportCommandError. */
|
|
148
|
+
private dispatchCommand(request: ClientRequest, context: string, tabId?: string): void {
|
|
149
|
+
void (async () => {
|
|
150
|
+
try {
|
|
151
|
+
await this.sendExpectOk(request)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
this.reportCommandError(context, error, tabId)
|
|
154
|
+
}
|
|
155
|
+
})()
|
|
156
|
+
}
|
|
157
|
+
|
|
147
158
|
private handleServerEvent(message: ServerEvent): void {
|
|
148
159
|
logDebug('backend.remote.event', { type: message.type })
|
|
149
160
|
switch (message.type) {
|
|
@@ -352,8 +363,10 @@ export class RemoteSessionBackend
|
|
|
352
363
|
return
|
|
353
364
|
}
|
|
354
365
|
|
|
355
|
-
|
|
356
|
-
(
|
|
366
|
+
this.dispatchCommand(
|
|
367
|
+
{ id: crypto.randomUUID(), payload: options, type: 'createTab' },
|
|
368
|
+
'createTab',
|
|
369
|
+
options.tabId
|
|
357
370
|
)
|
|
358
371
|
}
|
|
359
372
|
|
|
@@ -362,55 +375,54 @@ export class RemoteSessionBackend
|
|
|
362
375
|
logDebug('backend.remote.skipWriteBeforeAttach', { inputLength: input.length, tabId })
|
|
363
376
|
return
|
|
364
377
|
}
|
|
365
|
-
|
|
366
|
-
id: crypto.randomUUID(),
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
378
|
+
this.dispatchCommand(
|
|
379
|
+
{ id: crypto.randomUUID(), payload: { data: input, tabId }, type: 'write' },
|
|
380
|
+
'write',
|
|
381
|
+
tabId
|
|
382
|
+
)
|
|
370
383
|
}
|
|
371
384
|
|
|
372
385
|
scrollViewport(tabId: string, deltaLines: number): void {
|
|
373
386
|
if (!this.attached) {
|
|
374
387
|
return
|
|
375
388
|
}
|
|
376
|
-
|
|
377
|
-
id: crypto.randomUUID(),
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
389
|
+
this.dispatchCommand(
|
|
390
|
+
{ id: crypto.randomUUID(), payload: { deltaLines, tabId }, type: 'scroll' },
|
|
391
|
+
'scroll',
|
|
392
|
+
tabId
|
|
393
|
+
)
|
|
381
394
|
}
|
|
382
395
|
|
|
383
396
|
scrollViewportToBottom(tabId: string): void {
|
|
384
397
|
if (!this.attached) {
|
|
385
398
|
return
|
|
386
399
|
}
|
|
387
|
-
|
|
388
|
-
id: crypto.randomUUID(),
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
400
|
+
this.dispatchCommand(
|
|
401
|
+
{ id: crypto.randomUUID(), payload: { tabId }, type: 'scrollToBottom' },
|
|
402
|
+
'scrollToBottom',
|
|
403
|
+
tabId
|
|
404
|
+
)
|
|
392
405
|
}
|
|
393
406
|
|
|
394
407
|
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
395
408
|
if (!this.attached) {
|
|
396
409
|
return
|
|
397
410
|
}
|
|
398
|
-
|
|
399
|
-
id: crypto.randomUUID(),
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
411
|
+
this.dispatchCommand(
|
|
412
|
+
{ id: crypto.randomUUID(), payload: { intent, tabId }, type: 'reapplyScrollIntent' },
|
|
413
|
+
'reapplyScrollIntent',
|
|
414
|
+
tabId
|
|
415
|
+
)
|
|
403
416
|
}
|
|
404
417
|
|
|
405
418
|
setActiveTab(tabId: string | null): void {
|
|
406
419
|
if (!this.attached) {
|
|
407
420
|
return
|
|
408
421
|
}
|
|
409
|
-
|
|
410
|
-
id: crypto.randomUUID(),
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}).catch((error) => this.reportCommandError('setActiveTab', error))
|
|
422
|
+
this.dispatchCommand(
|
|
423
|
+
{ id: crypto.randomUUID(), payload: { tabId }, type: 'setActiveTab' },
|
|
424
|
+
'setActiveTab'
|
|
425
|
+
)
|
|
414
426
|
}
|
|
415
427
|
|
|
416
428
|
resizeAll(
|
|
@@ -425,11 +437,14 @@ export class RemoteSessionBackend
|
|
|
425
437
|
}
|
|
426
438
|
logDebug('backend.remote.resize', { cols, rows, sessionId: this.currentSessionId })
|
|
427
439
|
const intentsRecord = intents ? Object.fromEntries(intents.entries()) : undefined
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
440
|
+
this.dispatchCommand(
|
|
441
|
+
{
|
|
442
|
+
id: crypto.randomUUID(),
|
|
443
|
+
payload: { cols, intents: intentsRecord, rows },
|
|
444
|
+
type: 'resizeClient',
|
|
445
|
+
},
|
|
446
|
+
'resizeClient'
|
|
447
|
+
)
|
|
433
448
|
}
|
|
434
449
|
|
|
435
450
|
resizeTab(
|
|
@@ -442,19 +457,21 @@ export class RemoteSessionBackend
|
|
|
442
457
|
if (!this.attached) {
|
|
443
458
|
return
|
|
444
459
|
}
|
|
445
|
-
|
|
446
|
-
id: crypto.randomUUID(),
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
460
|
+
this.dispatchCommand(
|
|
461
|
+
{ id: crypto.randomUUID(), payload: { cols, intent, rows, tabId }, type: 'resizeTab' },
|
|
462
|
+
'resizeTab',
|
|
463
|
+
tabId
|
|
464
|
+
)
|
|
450
465
|
}
|
|
451
466
|
|
|
452
467
|
disposeSession(tabId: string): void {
|
|
453
468
|
if (!this.attached) {
|
|
454
469
|
return
|
|
455
470
|
}
|
|
456
|
-
|
|
457
|
-
(
|
|
471
|
+
this.dispatchCommand(
|
|
472
|
+
{ id: crypto.randomUUID(), payload: { tabId }, type: 'closeTab' },
|
|
473
|
+
'closeTab',
|
|
474
|
+
tabId
|
|
458
475
|
)
|
|
459
476
|
}
|
|
460
477
|
|
|
@@ -462,9 +479,7 @@ export class RemoteSessionBackend
|
|
|
462
479
|
if (!this.attached) {
|
|
463
480
|
return
|
|
464
481
|
}
|
|
465
|
-
|
|
466
|
-
(error) => this.reportCommandError('disposeAll', error)
|
|
467
|
-
)
|
|
482
|
+
this.dispatchCommand({ id: crypto.randomUUID(), payload: {}, type: 'disposeAll' }, 'disposeAll')
|
|
468
483
|
}
|
|
469
484
|
|
|
470
485
|
async destroy(keepSessions = true): Promise<void> {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { memo, useCallback } from 'react'
|
|
2
|
+
|
|
1
3
|
import type { FoldDispatch } from './pierre-diff'
|
|
2
4
|
|
|
3
5
|
import { useTheme } from '../../../theme'
|
|
@@ -8,7 +10,7 @@ interface Props {
|
|
|
8
10
|
dispatch: FoldDispatch
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
function Button({ label, onPress }: { label: string; onPress: () => void }) {
|
|
13
|
+
const Button = memo(function Button({ label, onPress }: { label: string; onPress: () => void }) {
|
|
12
14
|
const t = useTheme()
|
|
13
15
|
const bg = t.diffContextBg
|
|
14
16
|
return (
|
|
@@ -16,7 +18,7 @@ function Button({ label, onPress }: { label: string; onPress: () => void }) {
|
|
|
16
18
|
<text fg={t.primary}>{label}</text>
|
|
17
19
|
</box>
|
|
18
20
|
)
|
|
19
|
-
}
|
|
21
|
+
})
|
|
20
22
|
|
|
21
23
|
function Spacer() {
|
|
22
24
|
return <text> </text>
|
|
@@ -31,42 +33,47 @@ export function FoldStrip({ dispatch, fold }: Props) {
|
|
|
31
33
|
const shrinkUp = Math.min(FOLD_STEP, topExpanded)
|
|
32
34
|
const shrinkDown = Math.min(FOLD_STEP, bottomExpanded)
|
|
33
35
|
|
|
36
|
+
const handleExpandTop = useCallback(
|
|
37
|
+
() => dispatch.adjust(foldId, 'top', stepUp),
|
|
38
|
+
[dispatch, foldId, stepUp]
|
|
39
|
+
)
|
|
40
|
+
const handleExpandBottom = useCallback(
|
|
41
|
+
() => dispatch.adjust(foldId, 'bottom', stepDown),
|
|
42
|
+
[dispatch, foldId, stepDown]
|
|
43
|
+
)
|
|
44
|
+
const handleExpandAll = useCallback(
|
|
45
|
+
() => dispatch.set(foldId, total, 0),
|
|
46
|
+
[dispatch, foldId, total]
|
|
47
|
+
)
|
|
48
|
+
const handleShrinkTop = useCallback(
|
|
49
|
+
() => dispatch.adjust(foldId, 'top', -shrinkUp),
|
|
50
|
+
[dispatch, foldId, shrinkUp]
|
|
51
|
+
)
|
|
52
|
+
const handleShrinkBottom = useCallback(
|
|
53
|
+
() => dispatch.adjust(foldId, 'bottom', -shrinkDown),
|
|
54
|
+
[dispatch, foldId, shrinkDown]
|
|
55
|
+
)
|
|
56
|
+
|
|
34
57
|
const controls: React.ReactNode[] = []
|
|
35
58
|
if (hidden > 0) {
|
|
36
59
|
controls.push(
|
|
37
|
-
<Button
|
|
38
|
-
key="up"
|
|
39
|
-
label={`↑${stepUp}`}
|
|
40
|
-
onPress={() => dispatch.adjust(foldId, 'top', stepUp)}
|
|
41
|
-
/>,
|
|
60
|
+
<Button key="up" label={`↑${stepUp}`} onPress={handleExpandTop} />,
|
|
42
61
|
<Spacer key="sp1" />,
|
|
43
|
-
<Button
|
|
44
|
-
key="down"
|
|
45
|
-
label={`↓${stepDown}`}
|
|
46
|
-
onPress={() => dispatch.adjust(foldId, 'bottom', stepDown)}
|
|
47
|
-
/>,
|
|
62
|
+
<Button key="down" label={`↓${stepDown}`} onPress={handleExpandBottom} />,
|
|
48
63
|
<Spacer key="sp2" />,
|
|
49
|
-
<Button key="all" label="⇅ all" onPress={
|
|
64
|
+
<Button key="all" label="⇅ all" onPress={handleExpandAll} />
|
|
50
65
|
)
|
|
51
66
|
}
|
|
52
67
|
if (shrinkUp > 0) {
|
|
53
68
|
controls.push(
|
|
54
69
|
<Spacer key="sp3" />,
|
|
55
|
-
<Button
|
|
56
|
-
key="shrinkUp"
|
|
57
|
-
label={`−↑${shrinkUp}`}
|
|
58
|
-
onPress={() => dispatch.adjust(foldId, 'top', -shrinkUp)}
|
|
59
|
-
/>
|
|
70
|
+
<Button key="shrinkUp" label={`−↑${shrinkUp}`} onPress={handleShrinkTop} />
|
|
60
71
|
)
|
|
61
72
|
}
|
|
62
73
|
if (shrinkDown > 0) {
|
|
63
74
|
controls.push(
|
|
64
75
|
<Spacer key="sp4" />,
|
|
65
|
-
<Button
|
|
66
|
-
key="shrinkDown"
|
|
67
|
-
label={`−↓${shrinkDown}`}
|
|
68
|
-
onPress={() => dispatch.adjust(foldId, 'bottom', -shrinkDown)}
|
|
69
|
-
/>
|
|
76
|
+
<Button key="shrinkDown" label={`−↓${shrinkDown}`} onPress={handleShrinkBottom} />
|
|
70
77
|
)
|
|
71
78
|
}
|
|
72
79
|
|
|
@@ -34,6 +34,30 @@ import { tokenToSpan } from './highlight'
|
|
|
34
34
|
import { useSegmentVirtualization } from './use-segment-virtualization'
|
|
35
35
|
|
|
36
36
|
const OVERSCAN = 24
|
|
37
|
+
const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
|
|
38
|
+
const HIDDEN_SCROLLBAR_OPTIONS = { visible: false }
|
|
39
|
+
|
|
40
|
+
// Stable per-row key derived from the line identities in the row, so rows keep a
|
|
41
|
+
// consistent identity across fold expand/collapse rather than relying on position.
|
|
42
|
+
function splitCellKey(cell: SplitCell): string {
|
|
43
|
+
switch (cell.type) {
|
|
44
|
+
case 'context':
|
|
45
|
+
return `c${cell.lineNumber}`
|
|
46
|
+
case 'addition':
|
|
47
|
+
return `a${cell.lineNumber}`
|
|
48
|
+
case 'deletion':
|
|
49
|
+
return `d${cell.lineNumber}`
|
|
50
|
+
case 'fold':
|
|
51
|
+
return `f${cell.fold.foldId}`
|
|
52
|
+
case 'filler':
|
|
53
|
+
return 'x'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function splitRowKey(row: SplitRowOrHeader): string {
|
|
58
|
+
if (row.type === 'hunk-header') return `hh:${row.spec}`
|
|
59
|
+
return `${splitCellKey(row.left)}|${splitCellKey(row.right)}`
|
|
60
|
+
}
|
|
37
61
|
|
|
38
62
|
export interface SplitViewHandle {
|
|
39
63
|
leftScroll: ScrollBoxRenderable | null
|
|
@@ -154,15 +178,15 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
154
178
|
flexGrow={1}
|
|
155
179
|
scrollY
|
|
156
180
|
viewportCulling
|
|
157
|
-
contentOptions={
|
|
158
|
-
verticalScrollbarOptions={
|
|
181
|
+
contentOptions={COLUMN_CONTENT_OPTIONS}
|
|
182
|
+
verticalScrollbarOptions={HIDDEN_SCROLLBAR_OPTIONS}
|
|
159
183
|
onMouseScroll={handleScroll}
|
|
160
184
|
>
|
|
161
185
|
{visibleWindow.topSpacer > 0 ? <box height={visibleWindow.topSpacer} /> : null}
|
|
162
186
|
{renderedSegments.map((rendered) =>
|
|
163
|
-
rendered.rows.map((row
|
|
187
|
+
rendered.rows.map((row) => (
|
|
164
188
|
<SideRow
|
|
165
|
-
key={`${rendered.segment.id}:left:${
|
|
189
|
+
key={`${rendered.segment.id}:left:${splitRowKey(row)}`}
|
|
166
190
|
cell={row.type === 'row' ? row.left : null}
|
|
167
191
|
foldDispatch={foldDispatch}
|
|
168
192
|
gw={gw}
|
|
@@ -180,14 +204,14 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
180
204
|
flexGrow={1}
|
|
181
205
|
scrollY
|
|
182
206
|
viewportCulling
|
|
183
|
-
contentOptions={
|
|
207
|
+
contentOptions={COLUMN_CONTENT_OPTIONS}
|
|
184
208
|
onMouseScroll={handleScroll}
|
|
185
209
|
>
|
|
186
210
|
{visibleWindow.topSpacer > 0 ? <box height={visibleWindow.topSpacer} /> : null}
|
|
187
211
|
{renderedSegments.map((rendered) =>
|
|
188
|
-
rendered.rows.map((row
|
|
212
|
+
rendered.rows.map((row) => (
|
|
189
213
|
<SideRow
|
|
190
|
-
key={`${rendered.segment.id}:right:${
|
|
214
|
+
key={`${rendered.segment.id}:right:${splitRowKey(row)}`}
|
|
191
215
|
cell={row.type === 'row' ? row.right : null}
|
|
192
216
|
foldDispatch={foldDispatch}
|
|
193
217
|
gw={gw}
|
|
@@ -291,6 +315,8 @@ function LineContent({ content, tokens }: { content: string; tokens: ThemedToken
|
|
|
291
315
|
if (s.italic === true) attributes |= TextAttributes.ITALIC
|
|
292
316
|
if (s.underline === true) attributes |= TextAttributes.UNDERLINE
|
|
293
317
|
return (
|
|
318
|
+
// Syntax tokens are positional within a single line and never reorder.
|
|
319
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
294
320
|
<span key={i} fg={s.fg ?? t.text} attributes={attributes}>
|
|
295
321
|
{s.text}
|
|
296
322
|
</span>
|
|
@@ -33,6 +33,24 @@ import { tokenToSpan } from './highlight'
|
|
|
33
33
|
import { useSegmentVirtualization } from './use-segment-virtualization'
|
|
34
34
|
|
|
35
35
|
const OVERSCAN = 24
|
|
36
|
+
const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
|
|
37
|
+
|
|
38
|
+
// Stable per-row key derived from the line identities in the row, so rows keep a
|
|
39
|
+
// consistent identity across fold expand/collapse rather than relying on position.
|
|
40
|
+
function unifiedRowKey(row: UnifiedRowOrHeader): string {
|
|
41
|
+
switch (row.type) {
|
|
42
|
+
case 'hunk-header':
|
|
43
|
+
return `hh:${row.spec}`
|
|
44
|
+
case 'fold':
|
|
45
|
+
return `f:${row.fold.foldId}`
|
|
46
|
+
case 'context':
|
|
47
|
+
return `c:${row.delLineNumber}:${row.addLineNumber}`
|
|
48
|
+
case 'addition':
|
|
49
|
+
return `a:${row.lineNumber}`
|
|
50
|
+
case 'deletion':
|
|
51
|
+
return `d:${row.lineNumber}`
|
|
52
|
+
}
|
|
53
|
+
}
|
|
36
54
|
|
|
37
55
|
export interface StackedViewHandle {
|
|
38
56
|
scroll: ScrollBoxRenderable | null
|
|
@@ -148,14 +166,14 @@ export const StackedView = forwardRef<StackedViewHandle, Props>(function Stacked
|
|
|
148
166
|
flexGrow={1}
|
|
149
167
|
scrollY
|
|
150
168
|
viewportCulling
|
|
151
|
-
contentOptions={
|
|
169
|
+
contentOptions={COLUMN_CONTENT_OPTIONS}
|
|
152
170
|
onMouseScroll={handleScroll}
|
|
153
171
|
>
|
|
154
172
|
{visibleWindow.topSpacer > 0 ? <box height={visibleWindow.topSpacer} /> : null}
|
|
155
173
|
{renderedSegments.map((rendered) =>
|
|
156
|
-
rendered.rows.map((row
|
|
174
|
+
rendered.rows.map((row) => (
|
|
157
175
|
<UnifiedRowRender
|
|
158
|
-
key={`${rendered.segment.id}:${
|
|
176
|
+
key={`${rendered.segment.id}:${unifiedRowKey(row)}`}
|
|
159
177
|
foldDispatch={foldDispatch}
|
|
160
178
|
gw={gw}
|
|
161
179
|
highlights={highlights}
|
|
@@ -236,6 +254,8 @@ function LineContent({ content, tokens }: { content: string; tokens: ThemedToken
|
|
|
236
254
|
if (s.italic === true) attributes |= TextAttributes.ITALIC
|
|
237
255
|
if (s.underline === true) attributes |= TextAttributes.UNDERLINE
|
|
238
256
|
return (
|
|
257
|
+
// Syntax tokens are positional within a single line and never reorder.
|
|
258
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
239
259
|
<span key={i} fg={s.fg ?? t.text} attributes={attributes}>
|
|
240
260
|
{s.text}
|
|
241
261
|
</span>
|