@brimveyn/aimux 1.14.14 → 1.14.16
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/side-effects.ts +242 -38
- package/src/app.tsx +30 -1
- package/src/config.ts +101 -0
- package/src/git/worktree.ts +45 -0
- package/src/input/modes/bridge.ts +4 -0
- package/src/input/modes/transitions.ts +8 -1
- package/src/input/modes/types.ts +15 -1
- package/src/state/reducers/modal-state.ts +232 -33
- package/src/state/reducers/session-state.ts +11 -19
- package/src/state/reducers/tab-state.ts +17 -0
- package/src/state/selectors.ts +55 -1
- package/src/state/session-persistence.ts +23 -1
- package/src/state/store.ts +4 -0
- package/src/state/types.ts +45 -8
- package/src/ui/components/layout/sidebar/worktree-row.tsx +12 -2
- package/src/ui/components/layout/top-tab-bar.tsx +146 -12
- package/src/ui/components/modals/shared/worktree-delete-confirm.tsx +39 -0
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +181 -44
- package/src/ui/root.tsx +21 -2
package/src/state/selectors.ts
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
import type { AssistantOption } from '../pty/command-registry'
|
|
2
|
-
import type { SessionRecord, SnippetRecord } from './types'
|
|
2
|
+
import type { AssistantId, SessionRecord, SnippetRecord, WorktreeRecord } from './types'
|
|
3
|
+
|
|
4
|
+
export interface BaseRefOption {
|
|
5
|
+
/** Git ref the new worktree is forked from. */
|
|
6
|
+
ref: string
|
|
7
|
+
label: string
|
|
8
|
+
kind: 'worktree' | 'branch'
|
|
9
|
+
/** Worktree name, for the 'worktree' kind. */
|
|
10
|
+
detail?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ordered, filtered base-ref candidates for the worktree-create "Base" picker:
|
|
15
|
+
* branches checked out in the session's worktrees first (labelled with the
|
|
16
|
+
* worktree name), then the remaining local branches. A branch already surfaced
|
|
17
|
+
* via a worktree is not repeated. Throwaway `aimux/` branches are skipped unless
|
|
18
|
+
* a live worktree is on them — `git worktree remove` leaves the branch behind,
|
|
19
|
+
* so deleted temp worktrees would otherwise haunt the list as orphan branches.
|
|
20
|
+
*/
|
|
21
|
+
export function buildBaseRefOptions(
|
|
22
|
+
worktrees: WorktreeRecord[],
|
|
23
|
+
localBranches: string[],
|
|
24
|
+
query: string
|
|
25
|
+
): BaseRefOption[] {
|
|
26
|
+
const seen = new Set<string>()
|
|
27
|
+
const options: BaseRefOption[] = []
|
|
28
|
+
for (const worktree of worktrees) {
|
|
29
|
+
if (worktree.branch == null || worktree.branch === '' || seen.has(worktree.branch)) continue
|
|
30
|
+
seen.add(worktree.branch)
|
|
31
|
+
options.push({
|
|
32
|
+
detail: worktree.name,
|
|
33
|
+
kind: 'worktree',
|
|
34
|
+
label: worktree.branch,
|
|
35
|
+
ref: worktree.branch,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
for (const branch of localBranches) {
|
|
39
|
+
if (seen.has(branch) || branch.startsWith('aimux/')) continue
|
|
40
|
+
seen.add(branch)
|
|
41
|
+
options.push({ kind: 'branch', label: branch, ref: branch })
|
|
42
|
+
}
|
|
43
|
+
const trimmed = query.trim().toLowerCase()
|
|
44
|
+
if (trimmed === '') return options
|
|
45
|
+
return options.filter((option) => option.label.toLowerCase().includes(trimmed))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 0 when the template picker should NOT show the "None" fallback (no assistant
|
|
50
|
+
* was picked — typical for the template shortcut path), 1 otherwise. Shared
|
|
51
|
+
* by the modal reducer, the side-effect that resolves templateId, and the
|
|
52
|
+
* picker UI so the three stay in sync.
|
|
53
|
+
*/
|
|
54
|
+
export function getTemplateNoneOffset(selectedAssistantId: AssistantId | null): 0 | 1 {
|
|
55
|
+
return selectedAssistantId == null ? 0 : 1
|
|
56
|
+
}
|
|
3
57
|
|
|
4
58
|
export function filterAssistants(
|
|
5
59
|
options: AssistantOption[],
|
|
@@ -72,6 +72,27 @@ export interface RestoreOptions {
|
|
|
72
72
|
// will overwrite the status with daemon truth — leaving the flag on would
|
|
73
73
|
// briefly flash the "Restored snapshot" hint on every j/k cycle.
|
|
74
74
|
forceDisconnected?: boolean
|
|
75
|
+
// When provided, drop tabs pinned to a worktree id that the session no
|
|
76
|
+
// longer owns. Some delete paths (notably the sidebar's "Remove worktree")
|
|
77
|
+
// historically removed the worktree record without closing its tabs, leaving
|
|
78
|
+
// orphans bound to a vanished id. Those orphans are invisible (filtered out
|
|
79
|
+
// by the active-worktree filter) yet keep a worktree id that a *future*
|
|
80
|
+
// delete can collide with — exactly what closes "another worktree's" tabs.
|
|
81
|
+
// Pruning them on restore both repairs corrupted catalogs and prevents the
|
|
82
|
+
// collision. Tabs with no worktree id (legacy/unbound) are always kept.
|
|
83
|
+
validWorktreeIds?: ReadonlySet<string>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Drop tabs bound to a worktree id the session no longer owns. Unbound tabs
|
|
87
|
+
// (no worktreeId) are kept — they surface under the primary worktree.
|
|
88
|
+
function pruneOrphanedTabs(
|
|
89
|
+
tabs: TabSession[],
|
|
90
|
+
validWorktreeIds: ReadonlySet<string> | undefined
|
|
91
|
+
): TabSession[] {
|
|
92
|
+
if (!validWorktreeIds) return tabs
|
|
93
|
+
return tabs.filter(
|
|
94
|
+
(tab) => tab.worktreeId == null || tab.worktreeId === '' || validWorktreeIds.has(tab.worktreeId)
|
|
95
|
+
)
|
|
75
96
|
}
|
|
76
97
|
|
|
77
98
|
export function restoreTabsFromWorkspace(
|
|
@@ -84,7 +105,7 @@ export function restoreTabsFromWorkspace(
|
|
|
84
105
|
|
|
85
106
|
const forceDisconnected = options.forceDisconnected ?? true
|
|
86
107
|
|
|
87
|
-
|
|
108
|
+
const restored: TabSession[] = snapshot.tabs
|
|
88
109
|
.filter(
|
|
89
110
|
(tab): tab is typeof tab & { status: Exclude<typeof tab.status, 'exited'> } =>
|
|
90
111
|
tab.status !== 'exited'
|
|
@@ -103,6 +124,7 @@ export function restoreTabsFromWorkspace(
|
|
|
103
124
|
viewport: tab.viewport,
|
|
104
125
|
worktreeId: tab.worktreeId,
|
|
105
126
|
}))
|
|
127
|
+
return pruneOrphanedTabs(restored, options.validWorktreeIds)
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
export function restoreLayoutTrees(
|
package/src/state/store.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { WorktreeTemplate } from '../config'
|
|
2
|
+
|
|
1
3
|
import { reduceAutoCommit } from './reducers/auto-commit-state'
|
|
2
4
|
import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
|
|
3
5
|
import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
|
|
@@ -31,6 +33,7 @@ export interface InitialStateOverrides {
|
|
|
31
33
|
gitPane?: Partial<GitPaneState>
|
|
32
34
|
sidebar?: Pick<AppState['sidebar'], 'visible' | 'width'>
|
|
33
35
|
sessionBarVisible?: boolean
|
|
36
|
+
worktreeTemplates?: WorktreeTemplate[]
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
const DEFAULT_GIT_PANE: GitPaneState = {
|
|
@@ -116,6 +119,7 @@ export function createInitialState(
|
|
|
116
119
|
tabGroupMap: {},
|
|
117
120
|
tabs: [],
|
|
118
121
|
worktreeDivergence: {},
|
|
122
|
+
worktreeTemplates: overrides.worktreeTemplates ?? [],
|
|
119
123
|
}
|
|
120
124
|
}
|
|
121
125
|
|
package/src/state/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ModeId, SnippetVar } from '@brimveyn/aimux-config'
|
|
2
2
|
import type { ThemedToken } from 'shiki'
|
|
3
3
|
|
|
4
|
+
import type { WorktreeTemplate } from '../config'
|
|
4
5
|
import type { LayoutNode, SplitDirection } from './layout-tree'
|
|
5
6
|
|
|
6
7
|
export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal' | 'antigravity'
|
|
@@ -46,6 +47,7 @@ export type ModalType =
|
|
|
46
47
|
| 'update-available'
|
|
47
48
|
| 'ai-usage'
|
|
48
49
|
| 'worktree-move'
|
|
50
|
+
| 'worktree-delete-confirm'
|
|
49
51
|
| null
|
|
50
52
|
|
|
51
53
|
export interface TerminalSpan {
|
|
@@ -322,16 +324,21 @@ export interface ModalClosed extends ModalBase {
|
|
|
322
324
|
export interface ModalNewTab extends ModalBase {
|
|
323
325
|
type: 'new-tab'
|
|
324
326
|
editingCommand: AssistantId | null
|
|
325
|
-
activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name'
|
|
327
|
+
activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name' | 'base'
|
|
326
328
|
branchError: string | null
|
|
327
329
|
branchName: string
|
|
328
330
|
createWorktree: boolean
|
|
329
331
|
selectedAssistantId: AssistantId | null
|
|
330
|
-
step: 'assistant' | 'worktree' | 'worktree-create'
|
|
332
|
+
step: 'assistant' | 'worktree' | 'worktree-create' | 'template'
|
|
331
333
|
targetWorktreeIndex: number
|
|
332
|
-
|
|
333
|
-
worktreeDeleteMessage: string | null
|
|
334
|
+
worktreeDeletePrompt: { worktreeId: string; reason: string } | null
|
|
334
335
|
worktreeName: string
|
|
336
|
+
/** Filter text typed into the "Base" picker on the worktree-create step. */
|
|
337
|
+
baseQuery: string
|
|
338
|
+
/** Resolved base ref the new worktree is forked from (branch of a worktree or a local branch). */
|
|
339
|
+
baseRef: string
|
|
340
|
+
/** Local branches available as base refs, loaded when the create step opens. */
|
|
341
|
+
baseBranches: string[]
|
|
335
342
|
}
|
|
336
343
|
|
|
337
344
|
export interface ModalSessionPicker extends ModalBase {
|
|
@@ -416,6 +423,22 @@ export interface ModalWorktreeMove extends ModalBase {
|
|
|
416
423
|
deleteSource: boolean
|
|
417
424
|
}
|
|
418
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Standalone confirmation for a recoverable worktree delete failure triggered
|
|
428
|
+
* outside the new-tab picker (e.g. the sidebar's "Remove worktree"). Carries the
|
|
429
|
+
* params needed to re-run the delete with force once confirmed.
|
|
430
|
+
*/
|
|
431
|
+
export interface ModalWorktreeDeleteConfirm extends ModalBase {
|
|
432
|
+
type: 'worktree-delete-confirm'
|
|
433
|
+
sessionId: string
|
|
434
|
+
worktreeId: string
|
|
435
|
+
worktreeLabel: string
|
|
436
|
+
reason: string
|
|
437
|
+
closeTabs: boolean
|
|
438
|
+
/** Whether confirming force-deletes — true only after a recoverable failure. */
|
|
439
|
+
force: boolean
|
|
440
|
+
}
|
|
441
|
+
|
|
419
442
|
export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
|
|
420
443
|
|
|
421
444
|
export interface DirectoryResult {
|
|
@@ -439,6 +462,7 @@ export type ModalState =
|
|
|
439
462
|
| ModalUpdateAvailable
|
|
440
463
|
| ModalAIUsage
|
|
441
464
|
| ModalWorktreeMove
|
|
465
|
+
| ModalWorktreeDeleteConfirm
|
|
442
466
|
|
|
443
467
|
export interface LayoutState {
|
|
444
468
|
terminalCols: number
|
|
@@ -504,6 +528,8 @@ export interface AppState {
|
|
|
504
528
|
lastActiveTabByWorktree: Record<string, string>
|
|
505
529
|
/** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
|
|
506
530
|
pendingChords: string[] | null
|
|
531
|
+
/** User-defined templates applied at worktree creation. Loaded from aimux.json. */
|
|
532
|
+
worktreeTemplates: WorktreeTemplate[]
|
|
507
533
|
}
|
|
508
534
|
|
|
509
535
|
// -- Modal actions --
|
|
@@ -512,11 +538,13 @@ export type ModalAction =
|
|
|
512
538
|
| { type: 'open-new-tab-modal' }
|
|
513
539
|
| { type: 'set-new-tab-branch-error'; message: string | null }
|
|
514
540
|
| {
|
|
515
|
-
type: 'set-new-tab-worktree-delete-
|
|
516
|
-
|
|
517
|
-
message: string | null
|
|
541
|
+
type: 'set-new-tab-worktree-delete-prompt'
|
|
542
|
+
prompt: { worktreeId: string; reason: string } | null
|
|
518
543
|
}
|
|
544
|
+
| { type: 'set-new-tab-base-branches'; branches: string[] }
|
|
519
545
|
| { type: 'enter-new-tab-worktree-create' }
|
|
546
|
+
| { type: 'enter-new-tab-template-pick' }
|
|
547
|
+
| { type: 'enter-new-tab-template-shortcut' }
|
|
520
548
|
| { type: 'select-new-tab-assistant'; assistantId?: AssistantId }
|
|
521
549
|
| { type: 'toggle-new-tab-worktree'; assistantId?: AssistantId }
|
|
522
550
|
| { type: 'open-edit-custom-command'; assistantId: AssistantId }
|
|
@@ -548,6 +576,15 @@ export type ModalAction =
|
|
|
548
576
|
| { type: 'open-ai-usage-modal' }
|
|
549
577
|
| { type: 'open-worktree-move-modal'; sourceWorktreeId: string }
|
|
550
578
|
| { type: 'toggle-worktree-move-delete' }
|
|
579
|
+
| {
|
|
580
|
+
type: 'open-worktree-delete-confirm'
|
|
581
|
+
sessionId: string
|
|
582
|
+
worktreeId: string
|
|
583
|
+
worktreeLabel: string
|
|
584
|
+
reason: string
|
|
585
|
+
closeTabs: boolean
|
|
586
|
+
force: boolean
|
|
587
|
+
}
|
|
551
588
|
|
|
552
589
|
// -- Session actions --
|
|
553
590
|
export type SessionAction =
|
|
@@ -565,7 +602,6 @@ export type SessionAction =
|
|
|
565
602
|
| { type: 'reorder-active-session'; delta: number }
|
|
566
603
|
| { type: 'set-session-status'; sessionId: string; status: SessionStatus }
|
|
567
604
|
| { type: 'add-worktree-record'; sessionId: string; worktree: WorktreeRecord; activate?: boolean }
|
|
568
|
-
| { type: 'remove-worktree-record'; sessionId: string; worktreeId: string }
|
|
569
605
|
| { type: 'set-active-worktree'; sessionId: string; worktreeId: string }
|
|
570
606
|
| {
|
|
571
607
|
type: 'update-worktree-record'
|
|
@@ -590,6 +626,7 @@ export type TabAction =
|
|
|
590
626
|
| { type: 'set-active-tab'; tabId: string }
|
|
591
627
|
| { type: 'move-active-tab'; delta: number }
|
|
592
628
|
| { type: 'reorder-active-tab'; delta: number }
|
|
629
|
+
| { type: 'reorder-tabs'; orderedTabIds: string[] }
|
|
593
630
|
| { type: 'reset-tab-session'; tabId: string }
|
|
594
631
|
| { type: 'rename-tab'; tabId: string; title: string }
|
|
595
632
|
| { type: 'append-tab-buffer'; tabId: string; chunk: string }
|
|
@@ -68,14 +68,24 @@ export const WorktreeRow = memo(function WorktreeRow({
|
|
|
68
68
|
[
|
|
69
69
|
'Remove worktree',
|
|
70
70
|
() =>
|
|
71
|
+
// Always confirm first. Confirming routes through the full delete side
|
|
72
|
+
// effect (closes the worktree's tabs, disposes their PTYs, prunes the
|
|
73
|
+
// snapshot, removes the git worktree). closeTabs (not force) cleans up
|
|
74
|
+
// the tabs while keeping the non-force `git worktree remove`, so
|
|
75
|
+
// uncommitted work in a temp worktree is still protected — a dirty
|
|
76
|
+
// worktree re-prompts for an explicit force-delete.
|
|
71
77
|
dispatchGlobal({
|
|
78
|
+
closeTabs: true,
|
|
79
|
+
force: false,
|
|
80
|
+
reason: 'Its assistant tabs will be closed and the worktree removed.',
|
|
72
81
|
sessionId: session.id,
|
|
73
|
-
type: '
|
|
82
|
+
type: 'open-worktree-delete-confirm',
|
|
74
83
|
worktreeId: worktree.id,
|
|
84
|
+
worktreeLabel: worktree.branch ?? worktree.name,
|
|
75
85
|
}),
|
|
76
86
|
],
|
|
77
87
|
]
|
|
78
|
-
}, [session.id, worktree.id, worktree.source])
|
|
88
|
+
}, [session.id, worktree.branch, worktree.id, worktree.name, worktree.source])
|
|
79
89
|
|
|
80
90
|
let bgColor: string | undefined
|
|
81
91
|
if (isActiveItem) {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
MouseEvent as OtuiMouseEvent,
|
|
4
|
+
ScrollBoxRenderable,
|
|
5
|
+
} from '@opentui/core'
|
|
2
6
|
|
|
3
|
-
import { memo, type ReactNode, useCallback, useMemo, useRef } from 'react'
|
|
7
|
+
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
|
|
4
8
|
|
|
5
9
|
import type { FocusMode, TabSession } from '../../../state/types'
|
|
6
10
|
|
|
@@ -8,7 +12,8 @@ import { useWorktreeDivergencePolling } from '../../../git/worktree-divergence-p
|
|
|
8
12
|
import { useAppStore } from '../../../state/app-store'
|
|
9
13
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
|
|
10
14
|
import { filterTabsForActiveWorktree } from '../../../state/session-worktrees'
|
|
11
|
-
import { buildTabEntries, type GroupEntry } from '../../../state/tab-entries'
|
|
15
|
+
import { buildTabEntries, type GroupEntry, type TabEntry } from '../../../state/tab-entries'
|
|
16
|
+
import { moveIdToIdPosition } from '../../session-ordering'
|
|
12
17
|
import { useTheme } from '../../theme'
|
|
13
18
|
import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
|
|
14
19
|
import { TabItem } from './sidebar/tab-item'
|
|
@@ -43,27 +48,51 @@ const TopTabCell = memo(function TopTabCell({
|
|
|
43
48
|
backgroundColor,
|
|
44
49
|
children,
|
|
45
50
|
entryId,
|
|
46
|
-
|
|
51
|
+
onDrag,
|
|
52
|
+
onDragCancel,
|
|
53
|
+
onDragStart,
|
|
54
|
+
onDrop,
|
|
55
|
+
setCellRef,
|
|
47
56
|
}: {
|
|
48
57
|
entryId: string
|
|
49
58
|
active: boolean
|
|
50
59
|
backgroundColor: string | undefined
|
|
51
|
-
|
|
60
|
+
setCellRef: (entryId: string, ref: BoxRenderable | null) => void
|
|
61
|
+
onDragStart: (entryId: string) => void
|
|
62
|
+
onDrag: (event: OtuiMouseEvent) => void
|
|
63
|
+
onDrop: () => void
|
|
64
|
+
onDragCancel: () => void
|
|
52
65
|
children: ReactNode
|
|
53
66
|
}) {
|
|
67
|
+
const handleRef = useCallback(
|
|
68
|
+
(r: BoxRenderable | null) => setCellRef(entryId, r),
|
|
69
|
+
[setCellRef, entryId]
|
|
70
|
+
)
|
|
54
71
|
const handleMouseDown = useCallback(
|
|
55
72
|
(event: OtuiMouseEvent) => {
|
|
73
|
+
event.preventDefault()
|
|
56
74
|
event.stopPropagation()
|
|
57
|
-
|
|
75
|
+
onDragStart(entryId)
|
|
76
|
+
},
|
|
77
|
+
[onDragStart, entryId]
|
|
78
|
+
)
|
|
79
|
+
const handleMouseUp = useCallback(
|
|
80
|
+
(event: OtuiMouseEvent) => {
|
|
81
|
+
event.preventDefault()
|
|
82
|
+
onDrop()
|
|
58
83
|
},
|
|
59
|
-
[
|
|
84
|
+
[onDrop]
|
|
60
85
|
)
|
|
61
86
|
return (
|
|
62
87
|
<box
|
|
88
|
+
ref={handleRef}
|
|
63
89
|
backgroundColor={backgroundColor}
|
|
64
90
|
flexDirection="row"
|
|
65
91
|
flexShrink={0}
|
|
66
92
|
onMouseDown={handleMouseDown}
|
|
93
|
+
onMouseDrag={onDrag}
|
|
94
|
+
onMouseUp={handleMouseUp}
|
|
95
|
+
onMouseDragEnd={onDragCancel}
|
|
67
96
|
data-active={active ? 'true' : undefined}
|
|
68
97
|
>
|
|
69
98
|
{children}
|
|
@@ -147,6 +176,19 @@ function GroupTabItem({
|
|
|
147
176
|
)
|
|
148
177
|
}
|
|
149
178
|
|
|
179
|
+
function arraysEqual(a: string[], b: string[]): boolean {
|
|
180
|
+
if (a.length !== b.length) return false
|
|
181
|
+
for (let i = 0; i < a.length; i++) {
|
|
182
|
+
if (a[i] !== b[i]) return false
|
|
183
|
+
}
|
|
184
|
+
return true
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Flatten an entry into the underlying tab ids, in display order. */
|
|
188
|
+
function entryTabIds(entry: TabEntry): string[] {
|
|
189
|
+
return entry.kind === 'single' ? [entry.tab.id] : entry.tabs.map((tab) => tab.id)
|
|
190
|
+
}
|
|
191
|
+
|
|
150
192
|
export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
|
|
151
193
|
const t = useTheme()
|
|
152
194
|
const headerBg = t.backgroundPanel
|
|
@@ -214,6 +256,89 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
|
|
|
214
256
|
[entries, activeTabId]
|
|
215
257
|
)
|
|
216
258
|
|
|
259
|
+
// --- Drag-and-drop reorder of the tab strip ---------------------------------
|
|
260
|
+
// Mirrors the workspace-list drag, but on the horizontal axis: entries are
|
|
261
|
+
// laid out left-to-right inside a scrollX box, so hit-testing is on x/width.
|
|
262
|
+
const [draggingId, setDraggingId] = useState<string | null>(null)
|
|
263
|
+
const [dragOrder, setDragOrder] = useState<string[] | null>(null)
|
|
264
|
+
const lastSwapWithRef = useRef<string | null>(null)
|
|
265
|
+
const cellRefs = useRef(new Map<string, BoxRenderable>())
|
|
266
|
+
|
|
267
|
+
const baselineOrder = useMemo(() => entries.map((e) => e.id), [entries])
|
|
268
|
+
|
|
269
|
+
const setCellRef = useCallback((id: string, ref: BoxRenderable | null): void => {
|
|
270
|
+
if (ref) cellRefs.current.set(id, ref)
|
|
271
|
+
else cellRefs.current.delete(id)
|
|
272
|
+
}, [])
|
|
273
|
+
|
|
274
|
+
const findEntryAtX = useCallback((x: number): string | null => {
|
|
275
|
+
for (const [id, ref] of cellRefs.current) {
|
|
276
|
+
if (x >= ref.x && x < ref.x + ref.width) return id
|
|
277
|
+
}
|
|
278
|
+
return null
|
|
279
|
+
}, [])
|
|
280
|
+
|
|
281
|
+
const handleDragStart = useCallback(
|
|
282
|
+
(id: string) => {
|
|
283
|
+
setDraggingId(id)
|
|
284
|
+
setDragOrder(baselineOrder)
|
|
285
|
+
lastSwapWithRef.current = null
|
|
286
|
+
},
|
|
287
|
+
[baselineOrder]
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
const handleDrag = useCallback(
|
|
291
|
+
(event: OtuiMouseEvent) => {
|
|
292
|
+
if (!(draggingId != null && draggingId !== '')) return
|
|
293
|
+
const hit = findEntryAtX(event.x)
|
|
294
|
+
if (hit === null || hit === draggingId) {
|
|
295
|
+
lastSwapWithRef.current = null
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
if (hit === lastSwapWithRef.current) return
|
|
299
|
+
setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
|
|
300
|
+
lastSwapWithRef.current = hit
|
|
301
|
+
},
|
|
302
|
+
[draggingId, findEntryAtX]
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const commitDrop = useCallback(() => {
|
|
306
|
+
const source = draggingId
|
|
307
|
+
const finalOrder = dragOrder
|
|
308
|
+
setDraggingId(null)
|
|
309
|
+
setDragOrder(null)
|
|
310
|
+
lastSwapWithRef.current = null
|
|
311
|
+
|
|
312
|
+
if (source == null || source === '' || !finalOrder) return
|
|
313
|
+
|
|
314
|
+
if (!arraysEqual(finalOrder, baselineOrder)) {
|
|
315
|
+
// Expand entries (groups collapse multiple tabs) back into a flat tab-id
|
|
316
|
+
// order, then let the reducer rewrite only the visible tabs' slots.
|
|
317
|
+
const byId = new Map(entries.map((e) => [e.id, e]))
|
|
318
|
+
const orderedTabIds = finalOrder.flatMap((id) => {
|
|
319
|
+
const entry = byId.get(id)
|
|
320
|
+
return entry ? entryTabIds(entry) : []
|
|
321
|
+
})
|
|
322
|
+
dispatchGlobal({ orderedTabIds, type: 'reorder-tabs' })
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// No reorder happened → treat as a plain click on the entry.
|
|
327
|
+
handleEntryActivate(source)
|
|
328
|
+
}, [baselineOrder, dragOrder, draggingId, entries, handleEntryActivate])
|
|
329
|
+
|
|
330
|
+
const cancelDrag = useCallback(() => {
|
|
331
|
+
setDraggingId(null)
|
|
332
|
+
setDragOrder(null)
|
|
333
|
+
lastSwapWithRef.current = null
|
|
334
|
+
}, [])
|
|
335
|
+
|
|
336
|
+
const visibleEntries = useMemo(() => {
|
|
337
|
+
if (dragOrder === null) return entries
|
|
338
|
+
const byId = new Map(entries.map((e) => [e.id, e]))
|
|
339
|
+
return dragOrder.map((id) => byId.get(id)).filter((e): e is TabEntry => e != null)
|
|
340
|
+
}, [dragOrder, entries])
|
|
341
|
+
|
|
217
342
|
const handleNewTab = useCallback((e: OtuiMouseEvent) => {
|
|
218
343
|
e.stopPropagation()
|
|
219
344
|
dispatchGlobal({ type: 'open-new-tab-modal' })
|
|
@@ -245,10 +370,11 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
|
|
|
245
370
|
viewportCulling
|
|
246
371
|
contentOptions={ROW_CONTENT_OPTIONS}
|
|
247
372
|
>
|
|
248
|
-
{
|
|
373
|
+
{visibleEntries.map((entry, index) => {
|
|
249
374
|
// [N] is shown only for the first 9 entries — that's the range
|
|
250
375
|
// Leader+1..9 can address.
|
|
251
376
|
const indexLabel = index < 9 ? `[${index + 1}]` : undefined
|
|
377
|
+
const dragging = entry.id === draggingId
|
|
252
378
|
if (entry.kind === 'single') {
|
|
253
379
|
const tab: TabSession = entry.tab
|
|
254
380
|
const isActive = tab.id === activeTabId
|
|
@@ -257,8 +383,12 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
|
|
|
257
383
|
key={entry.id}
|
|
258
384
|
entryId={entry.id}
|
|
259
385
|
active={isActive}
|
|
260
|
-
|
|
261
|
-
|
|
386
|
+
setCellRef={setCellRef}
|
|
387
|
+
onDragStart={handleDragStart}
|
|
388
|
+
onDrag={handleDrag}
|
|
389
|
+
onDrop={commitDrop}
|
|
390
|
+
onDragCancel={cancelDrag}
|
|
391
|
+
backgroundColor={isActive || dragging ? t.backgroundElement : undefined}
|
|
262
392
|
>
|
|
263
393
|
<TabItem
|
|
264
394
|
id={`top-tab-${tab.id}`}
|
|
@@ -277,8 +407,12 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
|
|
|
277
407
|
key={entry.id}
|
|
278
408
|
entryId={entry.id}
|
|
279
409
|
active={isActive}
|
|
280
|
-
|
|
281
|
-
|
|
410
|
+
setCellRef={setCellRef}
|
|
411
|
+
onDragStart={handleDragStart}
|
|
412
|
+
onDrag={handleDrag}
|
|
413
|
+
onDrop={commitDrop}
|
|
414
|
+
onDragCancel={cancelDrag}
|
|
415
|
+
backgroundColor={isActive || dragging ? t.backgroundElement : undefined}
|
|
282
416
|
>
|
|
283
417
|
<GroupTabItem
|
|
284
418
|
entry={entry}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ModeId } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { useTheme } from '../../../theme'
|
|
4
|
+
import { uiTokens } from '../../../ui-tokens'
|
|
5
|
+
import { Form } from './form'
|
|
6
|
+
|
|
7
|
+
interface WorktreeDeleteConfirmProps {
|
|
8
|
+
keybindsModeId: ModeId
|
|
9
|
+
reason: string
|
|
10
|
+
worktreeLabel: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Shared confirmation dialog for a recoverable worktree delete. Used both inside
|
|
15
|
+
* the new-tab picker and as a standalone modal (sidebar "Remove worktree"); the
|
|
16
|
+
* keybinds mode wires Enter/y to confirm and Esc/n to cancel for each context.
|
|
17
|
+
*/
|
|
18
|
+
export function WorktreeDeleteConfirm({
|
|
19
|
+
keybindsModeId,
|
|
20
|
+
reason,
|
|
21
|
+
worktreeLabel,
|
|
22
|
+
}: WorktreeDeleteConfirmProps) {
|
|
23
|
+
const t = useTheme()
|
|
24
|
+
return (
|
|
25
|
+
<Form
|
|
26
|
+
title="Delete worktree?"
|
|
27
|
+
keybindsModeId={keybindsModeId}
|
|
28
|
+
width={uiTokens.modalWidth.md}
|
|
29
|
+
footer={<text fg={t.textMuted}>Enter / y to delete · Esc / n to cancel</text>}
|
|
30
|
+
>
|
|
31
|
+
<box flexDirection="column" gap={1}>
|
|
32
|
+
<text fg={t.text}>
|
|
33
|
+
Delete <strong>{worktreeLabel}</strong>?
|
|
34
|
+
</text>
|
|
35
|
+
<text fg={t.warning}>{reason}</text>
|
|
36
|
+
</box>
|
|
37
|
+
</Form>
|
|
38
|
+
)
|
|
39
|
+
}
|