@brimveyn/aimux 1.14.15 → 1.15.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 +2 -2
- package/src/app-runtime/side-effects.ts +122 -43
- package/src/app.tsx +26 -1
- package/src/config.ts +4 -0
- package/src/git/move-worktree.ts +87 -17
- package/src/git/worktree.ts +45 -0
- package/src/input/modes/bridge.ts +5 -0
- package/src/input/modes/transitions.ts +10 -1
- package/src/input/modes/types.ts +20 -1
- package/src/ipc/manager-protocol.ts +9 -1
- package/src/ipc/protocol.ts +9 -1
- package/src/pty/ghostty-shell-integration.ts +34 -0
- package/src/pty/pty-manager.ts +60 -6
- package/src/pty/terminal-snapshot.ts +29 -13
- package/src/state/reducers/modal-state.ts +166 -19
- package/src/state/reducers/session-state.ts +11 -19
- package/src/state/reducers/tab-state.ts +17 -0
- package/src/state/selectors.ts +45 -1
- package/src/state/session-persistence.ts +23 -1
- package/src/state/types.ts +83 -7
- package/src/state/validation.ts +9 -1
- package/src/ui/components/layout/sidebar/tab-item.tsx +4 -2
- package/src/ui/components/layout/sidebar/worktree-row.tsx +12 -2
- package/src/ui/components/layout/terminal-pane.tsx +112 -8
- 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 +70 -23
- package/src/ui/components/modals/worktree/worktree-move-confirm-modal.tsx +65 -0
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +18 -1
- package/src/ui/root.tsx +25 -2
- package/src/ui/status-bar-model.ts +2 -0
|
@@ -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
|
+
}
|
|
@@ -5,11 +5,16 @@ import type { AssistantId, WorktreeRecord } from '../../../../state/types'
|
|
|
5
5
|
|
|
6
6
|
import { getAllAssistantOptions, getAssistantOption } from '../../../../pty/command-registry'
|
|
7
7
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildBaseRefOptions,
|
|
10
|
+
filterAssistants,
|
|
11
|
+
getTemplateNoneOffset,
|
|
12
|
+
} from '../../../../state/selectors'
|
|
9
13
|
import { useTheme } from '../../../theme'
|
|
10
14
|
import { uiTokens } from '../../../ui-tokens'
|
|
11
|
-
import { Form, TextField } from '../shared/form'
|
|
15
|
+
import { AutoComplete, Form, type FormOptionItem, TextField } from '../shared/form'
|
|
12
16
|
import { Picker, type PickerItem } from '../shared/picker'
|
|
17
|
+
import { WorktreeDeleteConfirm } from '../shared/worktree-delete-confirm'
|
|
13
18
|
|
|
14
19
|
interface NewTabModalProps {
|
|
15
20
|
selectedIndex: number
|
|
@@ -19,16 +24,18 @@ interface NewTabModalProps {
|
|
|
19
24
|
currentSessionId: string | null
|
|
20
25
|
editingCommand: AssistantId | null
|
|
21
26
|
editBuffer: string
|
|
22
|
-
activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name'
|
|
27
|
+
activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name' | 'base'
|
|
23
28
|
branchError: string | null
|
|
24
29
|
branchName: string
|
|
25
30
|
createWorktree: boolean
|
|
26
31
|
selectedAssistantId: AssistantId | null
|
|
27
32
|
step: 'assistant' | 'worktree' | 'worktree-create' | 'template'
|
|
28
|
-
|
|
29
|
-
worktreeDeleteMessage: string | null
|
|
33
|
+
worktreeDeletePrompt: { worktreeId: string; reason: string } | null
|
|
30
34
|
worktrees: WorktreeRecord[]
|
|
31
35
|
worktreeName: string
|
|
36
|
+
baseQuery: string
|
|
37
|
+
baseRef: string
|
|
38
|
+
baseBranches: string[]
|
|
32
39
|
worktreeTemplates: WorktreeTemplate[]
|
|
33
40
|
/**
|
|
34
41
|
* True when this modal is the worktree-aware new-tab flow. False when the
|
|
@@ -63,6 +70,9 @@ function getDeleteBlockedReason({
|
|
|
63
70
|
export function NewTabModal({
|
|
64
71
|
activeField,
|
|
65
72
|
allowTemplateShortcut,
|
|
73
|
+
baseBranches,
|
|
74
|
+
baseQuery,
|
|
75
|
+
baseRef,
|
|
66
76
|
branchError,
|
|
67
77
|
branchName,
|
|
68
78
|
createWorktree,
|
|
@@ -75,8 +85,7 @@ export function NewTabModal({
|
|
|
75
85
|
selectedAssistantId,
|
|
76
86
|
selectedIndex,
|
|
77
87
|
step,
|
|
78
|
-
|
|
79
|
-
worktreeDeleteMessage,
|
|
88
|
+
worktreeDeletePrompt,
|
|
80
89
|
worktreeName,
|
|
81
90
|
worktrees,
|
|
82
91
|
worktreeTemplates,
|
|
@@ -107,9 +116,8 @@ export function NewTabModal({
|
|
|
107
116
|
active && canDelete && currentSessionId != null && currentSessionId !== ''
|
|
108
117
|
? () => {
|
|
109
118
|
dispatchGlobal({ index, type: 'set-modal-selection-index' })
|
|
110
|
-
dispatchGlobal({ message: null, type: 'set-new-tab-worktree-delete-state' })
|
|
111
119
|
runSideEffectGlobal({
|
|
112
|
-
force:
|
|
120
|
+
force: false,
|
|
113
121
|
sessionId: currentSessionId,
|
|
114
122
|
type: 'delete-worktree',
|
|
115
123
|
worktreeId: worktree.id,
|
|
@@ -127,12 +135,15 @@ export function NewTabModal({
|
|
|
127
135
|
}),
|
|
128
136
|
{
|
|
129
137
|
key: '__create-worktree__',
|
|
130
|
-
onClick: () =>
|
|
138
|
+
onClick: () => {
|
|
139
|
+
dispatchGlobal({ type: 'enter-new-tab-worktree-create' })
|
|
140
|
+
runSideEffectGlobal({ type: 'load-new-tab-base-branches' })
|
|
141
|
+
},
|
|
131
142
|
subtitle: <text fg={t.textMuted}>Create an Aimux temp worktree</text>,
|
|
132
143
|
title: <text fg={createWorktree ? t.text : t.textMuted}>Create new worktree</text>,
|
|
133
144
|
},
|
|
134
145
|
],
|
|
135
|
-
[createWorktree, currentSessionId, selectedIndex, t,
|
|
146
|
+
[createWorktree, currentSessionId, selectedIndex, t, worktrees]
|
|
136
147
|
)
|
|
137
148
|
|
|
138
149
|
const noneOffset = getTemplateNoneOffset(selectedAssistantId)
|
|
@@ -216,6 +227,24 @@ export function NewTabModal({
|
|
|
216
227
|
]
|
|
217
228
|
}, [customCommands, filtered, showShortcutEntry, selectedIndex, t])
|
|
218
229
|
|
|
230
|
+
const baseItems = useMemo<FormOptionItem[]>(
|
|
231
|
+
() =>
|
|
232
|
+
buildBaseRefOptions(worktrees, baseBranches, baseQuery).map((option) => ({
|
|
233
|
+
key: option.ref,
|
|
234
|
+
leading: (
|
|
235
|
+
<text fg={option.kind === 'worktree' ? t.warning : t.textMuted}>
|
|
236
|
+
{option.kind === 'worktree' ? '\u{e728}' : '\u{e702}'}
|
|
237
|
+
</text>
|
|
238
|
+
),
|
|
239
|
+
subtitle:
|
|
240
|
+
option.kind === 'worktree' ? (
|
|
241
|
+
<text fg={t.textMuted}>worktree: {option.detail}</text>
|
|
242
|
+
) : null,
|
|
243
|
+
title: (active) => <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
|
|
244
|
+
})),
|
|
245
|
+
[baseBranches, baseQuery, t, worktrees]
|
|
246
|
+
)
|
|
247
|
+
|
|
219
248
|
if (editingCommand !== null) {
|
|
220
249
|
const option = options.find((o) => o.id === editingCommand) ?? getAssistantOption(0)
|
|
221
250
|
return (
|
|
@@ -260,6 +289,7 @@ export function NewTabModal({
|
|
|
260
289
|
if (step === 'worktree-create') {
|
|
261
290
|
const selectedAssistant =
|
|
262
291
|
options.find((option) => option.id === selectedAssistantId) ?? options[0]
|
|
292
|
+
const baseActive = activeField === 'base'
|
|
263
293
|
return (
|
|
264
294
|
<Form
|
|
265
295
|
title={`New worktree: ${selectedAssistant?.label ?? 'assistant'}`}
|
|
@@ -286,7 +316,20 @@ export function NewTabModal({
|
|
|
286
316
|
<text fg={t.error}>{branchError}</text>
|
|
287
317
|
) : null}
|
|
288
318
|
</box>
|
|
289
|
-
<
|
|
319
|
+
<AutoComplete
|
|
320
|
+
active={baseActive}
|
|
321
|
+
label="Base (fork from)"
|
|
322
|
+
placeholder="branch or worktree to fork from..."
|
|
323
|
+
value={baseQuery}
|
|
324
|
+
displayValue={baseRef !== '' ? baseRef : 'current branch'}
|
|
325
|
+
items={baseItems}
|
|
326
|
+
selectedIndex={selectedIndex}
|
|
327
|
+
cursorPos={baseActive ? cursorPos : undefined}
|
|
328
|
+
maxVisibleRows={6}
|
|
329
|
+
onHover={handleHover}
|
|
330
|
+
emptyState={<text fg={t.textMuted}>No branches found</text>}
|
|
331
|
+
/>
|
|
332
|
+
<text fg={t.textMuted}>Step 3/3: configure new worktree · Tab switches fields</text>
|
|
290
333
|
</box>
|
|
291
334
|
</Form>
|
|
292
335
|
)
|
|
@@ -297,6 +340,19 @@ export function NewTabModal({
|
|
|
297
340
|
options.find((option) => option.id === selectedAssistantId) ??
|
|
298
341
|
filtered[selectedIndex] ??
|
|
299
342
|
options[0]
|
|
343
|
+
|
|
344
|
+
if (worktreeDeletePrompt != null) {
|
|
345
|
+
const target = worktrees.find((worktree) => worktree.id === worktreeDeletePrompt.worktreeId)
|
|
346
|
+
const label = target?.branch ?? target?.name ?? 'this worktree'
|
|
347
|
+
return (
|
|
348
|
+
<WorktreeDeleteConfirm
|
|
349
|
+
keybindsModeId="modal.new-tab.worktree-delete-confirm"
|
|
350
|
+
reason={worktreeDeletePrompt.reason}
|
|
351
|
+
worktreeLabel={label}
|
|
352
|
+
/>
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
300
356
|
const selectedWorktree = worktrees[selectedIndex]
|
|
301
357
|
const deleteBlockedReason = getDeleteBlockedReason({
|
|
302
358
|
currentSessionId,
|
|
@@ -316,17 +372,8 @@ export function NewTabModal({
|
|
|
316
372
|
onHover={handleHover}
|
|
317
373
|
footer={
|
|
318
374
|
<box flexDirection="column">
|
|
319
|
-
{
|
|
320
|
-
|
|
321
|
-
<text
|
|
322
|
-
fg={
|
|
323
|
-
worktreeDeleteMessage != null && worktreeDeleteMessage !== ''
|
|
324
|
-
? t.error
|
|
325
|
-
: t.textMuted
|
|
326
|
-
}
|
|
327
|
-
>
|
|
328
|
-
{worktreeDeleteMessage ?? deleteBlockedReason}
|
|
329
|
-
</text>
|
|
375
|
+
{deleteBlockedReason != null && deleteBlockedReason !== '' ? (
|
|
376
|
+
<text fg={t.textMuted}>{deleteBlockedReason}</text>
|
|
330
377
|
) : null}
|
|
331
378
|
<text fg={t.textMuted}>Step 2/2: choose worktree</text>
|
|
332
379
|
<text fg={t.textMuted}>Enter launches, Ctrl+d deletes selected worktree</text>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useTheme } from '../../../theme'
|
|
2
|
+
import { uiTokens } from '../../../ui-tokens'
|
|
3
|
+
import { Form } from '../shared/form'
|
|
4
|
+
|
|
5
|
+
interface WorktreeMoveConfirmModalProps {
|
|
6
|
+
variant: 'stash-target' | 'keep-conflicts'
|
|
7
|
+
files: string[]
|
|
8
|
+
sourceLabel: string
|
|
9
|
+
targetLabel: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const MAX_LISTED_FILES = 8
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Confirmation dialog after a recoverable move failure. Both worktrees are
|
|
16
|
+
* already back in their original state; confirming re-runs the move with the
|
|
17
|
+
* flag matching the variant (stash the target's changes / keep the conflict
|
|
18
|
+
* markers in the target).
|
|
19
|
+
*/
|
|
20
|
+
export function WorktreeMoveConfirmModal({
|
|
21
|
+
files,
|
|
22
|
+
sourceLabel,
|
|
23
|
+
targetLabel,
|
|
24
|
+
variant,
|
|
25
|
+
}: WorktreeMoveConfirmModalProps) {
|
|
26
|
+
const t = useTheme()
|
|
27
|
+
const listed = files.slice(0, MAX_LISTED_FILES)
|
|
28
|
+
const remaining = files.length - listed.length
|
|
29
|
+
const isStash = variant === 'stash-target'
|
|
30
|
+
return (
|
|
31
|
+
<Form
|
|
32
|
+
title={isStash ? 'Target has conflicting changes' : 'Move hit conflicts'}
|
|
33
|
+
keybindsModeId="modal.worktree-move-confirm"
|
|
34
|
+
width={uiTokens.modalWidth.md}
|
|
35
|
+
footer={
|
|
36
|
+
<text fg={t.textMuted}>
|
|
37
|
+
{isStash
|
|
38
|
+
? 'Enter / y to stash & move · Esc / n to cancel'
|
|
39
|
+
: 'Enter / y to keep markers · Esc / n to cancel'}
|
|
40
|
+
</text>
|
|
41
|
+
}
|
|
42
|
+
>
|
|
43
|
+
<box flexDirection="column" gap={1}>
|
|
44
|
+
<text fg={t.text}>
|
|
45
|
+
{isStash
|
|
46
|
+
? `${targetLabel} has uncommitted changes that the move would overwrite:`
|
|
47
|
+
: `Merging ${sourceLabel} into ${targetLabel} conflicts in ${files.length} file(s):`}
|
|
48
|
+
</text>
|
|
49
|
+
<box flexDirection="column">
|
|
50
|
+
{listed.map((file) => (
|
|
51
|
+
<text key={file} fg={t.warning} wrapMode="none">
|
|
52
|
+
{file}
|
|
53
|
+
</text>
|
|
54
|
+
))}
|
|
55
|
+
{remaining > 0 ? <text fg={t.textMuted}>+{remaining} more</text> : null}
|
|
56
|
+
</box>
|
|
57
|
+
<text fg={t.text}>
|
|
58
|
+
{isStash
|
|
59
|
+
? 'Stash them and continue? The stash is kept — recover with git stash pop.'
|
|
60
|
+
: `Keep conflict markers in ${targetLabel} for manual resolution? ${sourceLabel} stays untouched either way.`}
|
|
61
|
+
</text>
|
|
62
|
+
</box>
|
|
63
|
+
</Form>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useMemo } from 'react'
|
|
2
2
|
|
|
3
|
-
import type { WorktreeRecord } from '../../../../state/types'
|
|
3
|
+
import type { ModalWorktreeMove, WorktreeRecord } from '../../../../state/types'
|
|
4
4
|
|
|
5
5
|
import { dispatchGlobal } from '../../../../state/dispatch-ref'
|
|
6
6
|
import { formatDivergence } from '../../../../state/session-worktrees'
|
|
@@ -14,6 +14,7 @@ interface WorktreeMoveModalProps {
|
|
|
14
14
|
divergence: Record<string, { ahead: number; behind: number }>
|
|
15
15
|
selectedIndex: number
|
|
16
16
|
sourceWorktreeId: string
|
|
17
|
+
stats: ModalWorktreeMove['stats']
|
|
17
18
|
worktrees: WorktreeRecord[]
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -29,6 +30,7 @@ export function WorktreeMoveModal({
|
|
|
29
30
|
divergence,
|
|
30
31
|
selectedIndex,
|
|
31
32
|
sourceWorktreeId,
|
|
33
|
+
stats,
|
|
32
34
|
worktrees,
|
|
33
35
|
}: WorktreeMoveModalProps) {
|
|
34
36
|
const t = useTheme()
|
|
@@ -42,6 +44,18 @@ export function WorktreeMoveModal({
|
|
|
42
44
|
)
|
|
43
45
|
const sourceLabel =
|
|
44
46
|
source?.branch != null && source.branch !== '' ? source.branch : (source?.name ?? 'worktree')
|
|
47
|
+
// What the move would carry: commits ahead of the fork point (when known) plus
|
|
48
|
+
// uncommitted files, loaded async after the modal opens.
|
|
49
|
+
const sourcePreview = useMemo(() => {
|
|
50
|
+
const parts: string[] = []
|
|
51
|
+
const ahead = divergence[sourceWorktreeId]?.ahead ?? 0
|
|
52
|
+
if (ahead > 0) parts.push(`${ahead} commit(s)`)
|
|
53
|
+
if (stats.kind === 'ready') {
|
|
54
|
+
const dirty = stats.dirtyFiles[sourceWorktreeId] ?? 0
|
|
55
|
+
if (dirty > 0) parts.push(`${dirty} uncommitted file(s)`)
|
|
56
|
+
}
|
|
57
|
+
return parts.join(' · ')
|
|
58
|
+
}, [divergence, sourceWorktreeId, stats])
|
|
45
59
|
const handleSelectIndex = useCallback(
|
|
46
60
|
(index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
|
|
47
61
|
[]
|
|
@@ -71,6 +85,7 @@ export function WorktreeMoveModal({
|
|
|
71
85
|
}
|
|
72
86
|
>
|
|
73
87
|
<box flexDirection="column" marginTop={1}>
|
|
88
|
+
{sourcePreview !== '' ? <text fg={t.textMuted}>will move: {sourcePreview}</text> : null}
|
|
74
89
|
{targets.length === 0 ? (
|
|
75
90
|
<text fg={t.textMuted}>No other worktree to move into.</text>
|
|
76
91
|
) : (
|
|
@@ -79,6 +94,7 @@ export function WorktreeMoveModal({
|
|
|
79
94
|
const label =
|
|
80
95
|
worktree.branch != null && worktree.branch !== '' ? worktree.branch : worktree.name
|
|
81
96
|
const ahead = formatDivergence(divergence[worktree.id])
|
|
97
|
+
const dirty = stats.kind === 'ready' && (stats.dirtyFiles[worktree.id] ?? 0) > 0
|
|
82
98
|
return (
|
|
83
99
|
<ListItem
|
|
84
100
|
key={worktree.id}
|
|
@@ -92,6 +108,7 @@ export function WorktreeMoveModal({
|
|
|
92
108
|
{label}
|
|
93
109
|
{worktree.source === 'primary' ? ' (primary)' : ''}
|
|
94
110
|
{ahead !== '' ? ` ${ahead}` : ''}
|
|
111
|
+
{dirty ? <span fg={t.warning}> ●</span> : null}
|
|
95
112
|
</text>
|
|
96
113
|
}
|
|
97
114
|
/>
|
package/src/ui/root.tsx
CHANGED
|
@@ -34,10 +34,12 @@ import { GitCommitModal } from './components/modals/git/git-commit-modal'
|
|
|
34
34
|
import { CreateSessionModal } from './components/modals/sessions/create-session-modal'
|
|
35
35
|
import { SessionNameModal } from './components/modals/sessions/session-name-modal'
|
|
36
36
|
import { SessionPickerModal } from './components/modals/sessions/session-picker-modal'
|
|
37
|
+
import { WorktreeDeleteConfirm } from './components/modals/shared/worktree-delete-confirm'
|
|
37
38
|
import { SnippetEditorModal } from './components/modals/snippets/snippet-editor-modal'
|
|
38
39
|
import { SnippetPickerModal } from './components/modals/snippets/snippet-picker-modal'
|
|
39
40
|
import { NewTabModal } from './components/modals/tabs/new-tab-modal'
|
|
40
41
|
import { ThemePickerModal } from './components/modals/themes/theme-picker-modal'
|
|
42
|
+
import { WorktreeMoveConfirmModal } from './components/modals/worktree/worktree-move-confirm-modal'
|
|
41
43
|
import { WorktreeMoveModal } from './components/modals/worktree/worktree-move-modal'
|
|
42
44
|
import { ContextMenuBox } from './components/overlays/context-menu/context-menu-box'
|
|
43
45
|
import { ContextMenuOverlay } from './components/overlays/context-menu/context-menu-overlay'
|
|
@@ -46,6 +48,7 @@ import { ToastViewport } from './components/overlays/toast/toast-viewport'
|
|
|
46
48
|
import { useTheme } from './theme'
|
|
47
49
|
|
|
48
50
|
const EMPTY_WORKTREES: WorktreeRecord[] = []
|
|
51
|
+
const EMPTY_BASE_BRANCHES: string[] = []
|
|
49
52
|
|
|
50
53
|
function getCreateSessionFields(modal: ModalState) {
|
|
51
54
|
if (modal.type !== 'create-session') {
|
|
@@ -114,8 +117,10 @@ function renderModal(
|
|
|
114
117
|
createWorktree={modal.type === 'new-tab' ? modal.createWorktree : false}
|
|
115
118
|
selectedAssistantId={modal.type === 'new-tab' ? modal.selectedAssistantId : null}
|
|
116
119
|
step={modal.type === 'new-tab' ? modal.step : 'assistant'}
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
baseQuery={modal.type === 'new-tab' ? modal.baseQuery : ''}
|
|
121
|
+
baseRef={modal.type === 'new-tab' ? modal.baseRef : ''}
|
|
122
|
+
baseBranches={modal.type === 'new-tab' ? modal.baseBranches : EMPTY_BASE_BRANCHES}
|
|
123
|
+
worktreeDeletePrompt={modal.type === 'new-tab' ? modal.worktreeDeletePrompt : null}
|
|
119
124
|
worktrees={
|
|
120
125
|
options.currentSessionId != null && options.currentSessionId !== ''
|
|
121
126
|
? (options.sessions.find((session) => session.id === options.currentSessionId)
|
|
@@ -210,10 +215,20 @@ function renderModal(
|
|
|
210
215
|
divergence={options.worktreeDivergence}
|
|
211
216
|
selectedIndex={modal.selectedIndex}
|
|
212
217
|
sourceWorktreeId={modal.sourceWorktreeId}
|
|
218
|
+
stats={modal.stats}
|
|
213
219
|
worktrees={session?.worktrees ?? EMPTY_WORKTREES}
|
|
214
220
|
/>
|
|
215
221
|
)
|
|
216
222
|
}
|
|
223
|
+
case 'worktree-move-confirm':
|
|
224
|
+
return (
|
|
225
|
+
<WorktreeMoveConfirmModal
|
|
226
|
+
variant={modal.variant}
|
|
227
|
+
files={modal.files}
|
|
228
|
+
sourceLabel={modal.sourceLabel}
|
|
229
|
+
targetLabel={modal.targetLabel}
|
|
230
|
+
/>
|
|
231
|
+
)
|
|
217
232
|
case 'help':
|
|
218
233
|
return (
|
|
219
234
|
<HelpModal
|
|
@@ -225,6 +240,14 @@ function renderModal(
|
|
|
225
240
|
)
|
|
226
241
|
case 'ai-usage':
|
|
227
242
|
return <AIUsageModal />
|
|
243
|
+
case 'worktree-delete-confirm':
|
|
244
|
+
return (
|
|
245
|
+
<WorktreeDeleteConfirm
|
|
246
|
+
keybindsModeId="modal.worktree-delete-confirm"
|
|
247
|
+
reason={modal.reason}
|
|
248
|
+
worktreeLabel={modal.worktreeLabel}
|
|
249
|
+
/>
|
|
250
|
+
)
|
|
228
251
|
case 'git-commit': {
|
|
229
252
|
const titleText =
|
|
230
253
|
modal.activeField === 'title' ? (modal.editBuffer ?? '') : modal.contentBuffer
|
|
@@ -133,6 +133,8 @@ function deriveModalModeId(modalType: AppState['modal']['type']): ModeId | null
|
|
|
133
133
|
return 'modal.update-available'
|
|
134
134
|
case 'worktree-move':
|
|
135
135
|
return 'modal.worktree-move'
|
|
136
|
+
case 'worktree-move-confirm':
|
|
137
|
+
return 'modal.worktree-move-confirm'
|
|
136
138
|
default:
|
|
137
139
|
return null
|
|
138
140
|
}
|