@brimveyn/aimux 1.13.2 → 1.14.1
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 +1 -3
- package/src/app-runtime/multi-click-clipboard-guard.ts +24 -12
- package/src/app-runtime/pty-write.ts +6 -9
- package/src/app-runtime/side-effects.ts +149 -19
- package/src/app-runtime/snippet-actions.ts +2 -3
- package/src/app-runtime/split-drag-controller.ts +3 -1
- package/src/app-runtime/use-backend-runtime.ts +4 -7
- package/src/app-runtime/use-mouse-handlers.ts +30 -1
- package/src/app-runtime/use-renderer-bindings.ts +4 -12
- package/src/app-runtime/use-terminal-resize.ts +7 -22
- package/src/app.tsx +0 -6
- package/src/config.ts +1 -12
- package/src/daemon/daemon.ts +2 -19
- package/src/daemon/session-manager.ts +3 -15
- package/src/daemon/session-registry.ts +11 -30
- package/src/git/worktree-branch-poller.ts +54 -0
- package/src/input/modes/types.ts +2 -0
- package/src/input/terminal-text-extraction.ts +7 -8
- package/src/ipc/manager-protocol.ts +6 -38
- package/src/ipc/protocol.ts +9 -37
- package/src/pty/pty-manager.ts +20 -31
- package/src/pty/terminal-snapshot.ts +43 -0
- package/src/session-backend/local-session-backend.ts +7 -31
- package/src/session-backend/remote-session-backend.ts +5 -32
- package/src/session-backend/types.ts +2 -15
- package/src/state/layout-tree.ts +114 -4
- package/src/state/reducers/session-state.ts +20 -0
- package/src/state/reducers/tab-state.ts +22 -22
- package/src/state/reducers/ui-state.ts +0 -3
- package/src/state/session-persistence.ts +2 -22
- package/src/state/session-worktrees.ts +45 -2
- package/src/state/store.ts +0 -3
- package/src/state/tab-entries.ts +74 -0
- package/src/state/types.ts +4 -15
- package/src/state/validation.ts +0 -8
- package/src/state/workspace-save.ts +0 -1
- package/src/terminal-manager/manager-client.ts +5 -29
- package/src/terminal-manager/terminal-manager.ts +2 -17
- package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
- package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
- package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
- package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
- package/src/ui/components/layout/split-layout.tsx +20 -39
- package/src/ui/components/layout/terminal-pane.tsx +30 -0
- package/src/ui/components/layout/top-tab-bar.tsx +304 -0
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
- package/src/ui/root.tsx +68 -63
- package/src/ui/components/layout/session-bar.tsx +0 -296
- package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
- package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
- package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +0 -36
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
interface Options {
|
|
6
|
+
scrollRef: React.RefObject<ScrollBoxRenderable | null>
|
|
7
|
+
visible: boolean
|
|
8
|
+
activeTabId: string | null
|
|
9
|
+
idPrefix: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scroll the active tab into view whenever the active tab id changes (and on
|
|
14
|
+
* first reveal). The top bar is a single horizontal row, so this is just a
|
|
15
|
+
* thin wrapper around `scrollChildIntoView` — no wrap-around/edge heuristics.
|
|
16
|
+
*/
|
|
17
|
+
export function useTopTabBarAutoScroll({
|
|
18
|
+
activeTabId,
|
|
19
|
+
idPrefix,
|
|
20
|
+
scrollRef,
|
|
21
|
+
visible,
|
|
22
|
+
}: Options): void {
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!visible) return
|
|
25
|
+
if (activeTabId == null || activeTabId === '') return
|
|
26
|
+
const scrollbox = scrollRef.current
|
|
27
|
+
if (!scrollbox) return
|
|
28
|
+
scrollbox.scrollChildIntoView(`${idPrefix}${activeTabId}`)
|
|
29
|
+
}, [activeTabId, idPrefix, scrollRef, visible])
|
|
30
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
MouseEvent as OtuiMouseEvent,
|
|
4
|
+
ScrollBoxRenderable,
|
|
5
|
+
} from '@opentui/core'
|
|
6
|
+
|
|
7
|
+
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
|
|
8
|
+
|
|
9
|
+
import type { SessionRecord, SessionStatus, WorktreeRecord } from '../../../../state/types'
|
|
10
|
+
|
|
11
|
+
import { useAppStore } from '../../../../state/app-store'
|
|
12
|
+
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
13
|
+
import { formatDivergence, getWorktreeColor } from '../../../../state/session-worktrees'
|
|
14
|
+
// eslint-disable-next-line no-duplicate-imports
|
|
15
|
+
import { IDLE_SESSION_STATUS } from '../../../../state/types'
|
|
16
|
+
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
17
|
+
import { moveIdToIdPosition, orderSessionsForDisplay } from '../../../session-ordering'
|
|
18
|
+
import { useTheme } from '../../../theme'
|
|
19
|
+
import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
|
|
20
|
+
import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
|
|
21
|
+
import { WorktreeRow } from './worktree-row'
|
|
22
|
+
|
|
23
|
+
interface WorkspaceListProps {
|
|
24
|
+
contentWidth: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
|
|
28
|
+
|
|
29
|
+
function arraysEqual(a: string[], b: string[]): boolean {
|
|
30
|
+
if (a.length !== b.length) return false
|
|
31
|
+
for (let i = 0; i < a.length; i++) {
|
|
32
|
+
if (a[i] !== b[i]) return false
|
|
33
|
+
}
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function truncate(label: string, max: number): string {
|
|
38
|
+
if (max <= 0) return ''
|
|
39
|
+
if (label.length <= max) return label
|
|
40
|
+
if (max === 1) return '…'
|
|
41
|
+
return `${label.slice(0, max - 1)}…`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
|
|
45
|
+
const t = useTheme()
|
|
46
|
+
const sessions = useAppStore((s) => s.sessions)
|
|
47
|
+
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
48
|
+
const statusMap = useAppStore((s) => s.sessionStatuses)
|
|
49
|
+
|
|
50
|
+
const [draggingId, setDraggingId] = useState<string | null>(null)
|
|
51
|
+
const [dragOrder, setDragOrder] = useState<string[] | null>(null)
|
|
52
|
+
const lastSwapWithRef = useRef<string | null>(null)
|
|
53
|
+
const rowRefs = useRef(new Map<string, BoxRenderable>())
|
|
54
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
55
|
+
|
|
56
|
+
const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
|
|
57
|
+
const baselineOrder = useMemo(() => ordered.map((s) => s.id), [ordered])
|
|
58
|
+
|
|
59
|
+
const currentSession = useMemo(
|
|
60
|
+
() =>
|
|
61
|
+
currentSessionId != null && currentSessionId !== ''
|
|
62
|
+
? sessions.find((s) => s.id === currentSessionId)
|
|
63
|
+
: undefined,
|
|
64
|
+
[currentSessionId, sessions]
|
|
65
|
+
)
|
|
66
|
+
// The active row can be either a worktree row OR the workspace row
|
|
67
|
+
// (when the primary worktree is active). Both must scroll into view —
|
|
68
|
+
// otherwise the cursor visually "disappears" off-screen when crossing
|
|
69
|
+
// a workspace boundary on a key press.
|
|
70
|
+
const currentWorktrees = currentSession?.worktrees ?? []
|
|
71
|
+
const currentPrimary = currentWorktrees.find((w) => w.source === 'primary') ?? currentWorktrees[0]
|
|
72
|
+
const rawActiveWorktreeId = currentSession?.activeWorktreeId
|
|
73
|
+
const activeOnNonPrimary =
|
|
74
|
+
rawActiveWorktreeId != null &&
|
|
75
|
+
rawActiveWorktreeId !== '' &&
|
|
76
|
+
rawActiveWorktreeId !== currentPrimary?.id
|
|
77
|
+
let activeRowId: string | null = null
|
|
78
|
+
if (activeOnNonPrimary) {
|
|
79
|
+
activeRowId = `sidebar-wt-${rawActiveWorktreeId}`
|
|
80
|
+
} else if (currentSessionId != null && currentSessionId !== '') {
|
|
81
|
+
activeRowId = `sidebar-ws-${currentSessionId}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
useSidebarAutoScroll({
|
|
85
|
+
activeRowId,
|
|
86
|
+
scrollRef,
|
|
87
|
+
visible: true,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const setRowRef = useCallback((id: string, ref: BoxRenderable | null): void => {
|
|
91
|
+
if (ref) rowRefs.current.set(id, ref)
|
|
92
|
+
else rowRefs.current.delete(id)
|
|
93
|
+
}, [])
|
|
94
|
+
|
|
95
|
+
const findRowAtY = useCallback((y: number): string | null => {
|
|
96
|
+
for (const [id, ref] of rowRefs.current) {
|
|
97
|
+
if (y >= ref.y && y < ref.y + ref.height) return id
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
const handleRowDragStart = useCallback(
|
|
103
|
+
(id: string) => {
|
|
104
|
+
setDraggingId(id)
|
|
105
|
+
setDragOrder(baselineOrder)
|
|
106
|
+
lastSwapWithRef.current = null
|
|
107
|
+
},
|
|
108
|
+
[baselineOrder]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const handleRowDrag = useCallback(
|
|
112
|
+
(event: OtuiMouseEvent) => {
|
|
113
|
+
if (!(draggingId != null && draggingId !== '')) return
|
|
114
|
+
const hit = findRowAtY(event.y)
|
|
115
|
+
if (hit === null) {
|
|
116
|
+
lastSwapWithRef.current = null
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (hit === draggingId) {
|
|
120
|
+
lastSwapWithRef.current = null
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
if (hit === lastSwapWithRef.current) return
|
|
124
|
+
setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
|
|
125
|
+
lastSwapWithRef.current = hit
|
|
126
|
+
},
|
|
127
|
+
[draggingId, findRowAtY]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const commitDrop = useCallback(() => {
|
|
131
|
+
const source = draggingId
|
|
132
|
+
const finalOrder = dragOrder
|
|
133
|
+
setDraggingId(null)
|
|
134
|
+
setDragOrder(null)
|
|
135
|
+
lastSwapWithRef.current = null
|
|
136
|
+
|
|
137
|
+
if (source == null || source === '' || !finalOrder) return
|
|
138
|
+
|
|
139
|
+
const changed = !arraysEqual(finalOrder, baselineOrder)
|
|
140
|
+
if (changed) {
|
|
141
|
+
dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-sessions' })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const idx = baselineOrder.indexOf(source)
|
|
146
|
+
if (idx >= 0) {
|
|
147
|
+
runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
|
|
148
|
+
}
|
|
149
|
+
}, [baselineOrder, dragOrder, draggingId])
|
|
150
|
+
|
|
151
|
+
const cancelDrag = useCallback(() => {
|
|
152
|
+
setDraggingId(null)
|
|
153
|
+
setDragOrder(null)
|
|
154
|
+
lastSwapWithRef.current = null
|
|
155
|
+
}, [])
|
|
156
|
+
|
|
157
|
+
const handleNewSession = useCallback((e: OtuiMouseEvent) => {
|
|
158
|
+
e.stopPropagation()
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
dispatchGlobal({ returnToSessionPicker: false, type: 'open-create-session-modal' })
|
|
161
|
+
}, [])
|
|
162
|
+
|
|
163
|
+
const visibleSessions =
|
|
164
|
+
dragOrder !== null
|
|
165
|
+
? dragOrder
|
|
166
|
+
.map((id) => ordered.find((s) => s.id === id))
|
|
167
|
+
.filter((s): s is SessionRecord => !!s)
|
|
168
|
+
: ordered
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
|
|
172
|
+
<scrollbox
|
|
173
|
+
ref={scrollRef}
|
|
174
|
+
scrollY
|
|
175
|
+
flexGrow={1}
|
|
176
|
+
flexShrink={1}
|
|
177
|
+
contentOptions={COLUMN_CONTENT_OPTIONS}
|
|
178
|
+
>
|
|
179
|
+
{(() => {
|
|
180
|
+
// Build a single flat list of items — workspace rows interleaved
|
|
181
|
+
// with their non-primary worktrees. One map, one React keypath per
|
|
182
|
+
// visible row; transitions are a single atomic reconciliation.
|
|
183
|
+
const rows: ReactNode[] = []
|
|
184
|
+
for (const [visibleIdx, session] of visibleSessions.entries()) {
|
|
185
|
+
const sessionIndex = baselineOrder.indexOf(session.id) + 1
|
|
186
|
+
const isCurrentSession = session.id === currentSessionId
|
|
187
|
+
const worktrees = session.worktrees ?? []
|
|
188
|
+
const primaryWorktree = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
|
|
189
|
+
const extraWorktrees = worktrees.filter((w) => w.id !== primaryWorktree?.id)
|
|
190
|
+
const workspaceIsActiveItem =
|
|
191
|
+
isCurrentSession &&
|
|
192
|
+
(session.activeWorktreeId == null ||
|
|
193
|
+
session.activeWorktreeId === '' ||
|
|
194
|
+
session.activeWorktreeId === primaryWorktree?.id)
|
|
195
|
+
rows.push(
|
|
196
|
+
<WorkspaceRow
|
|
197
|
+
key={`ws:${session.id}`}
|
|
198
|
+
session={session}
|
|
199
|
+
isActiveItem={workspaceIsActiveItem}
|
|
200
|
+
inCurrentGroup={isCurrentSession}
|
|
201
|
+
primaryWorktree={primaryWorktree}
|
|
202
|
+
status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
|
|
203
|
+
dragging={draggingId === session.id}
|
|
204
|
+
contentWidth={contentWidth}
|
|
205
|
+
marginTop={visibleIdx > 0 ? 1 : 0}
|
|
206
|
+
setRowRef={setRowRef}
|
|
207
|
+
onDragStart={handleRowDragStart}
|
|
208
|
+
onDrag={handleRowDrag}
|
|
209
|
+
onDrop={commitDrop}
|
|
210
|
+
onDragCancel={cancelDrag}
|
|
211
|
+
/>
|
|
212
|
+
)
|
|
213
|
+
for (const worktree of extraWorktrees) {
|
|
214
|
+
rows.push(
|
|
215
|
+
<WorktreeRow
|
|
216
|
+
key={`wt:${worktree.id}`}
|
|
217
|
+
session={session}
|
|
218
|
+
worktree={worktree}
|
|
219
|
+
sessionIndex={sessionIndex}
|
|
220
|
+
isActiveItem={isCurrentSession && worktree.id === session.activeWorktreeId}
|
|
221
|
+
inCurrentGroup={isCurrentSession}
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return rows
|
|
227
|
+
})()}
|
|
228
|
+
</scrollbox>
|
|
229
|
+
<box
|
|
230
|
+
flexDirection="row"
|
|
231
|
+
flexShrink={0}
|
|
232
|
+
marginTop={1}
|
|
233
|
+
backgroundColor={t.backgroundPanel}
|
|
234
|
+
justifyContent="center"
|
|
235
|
+
onMouseDown={handleNewSession}
|
|
236
|
+
>
|
|
237
|
+
<text fg={t.text} selectable={false}>
|
|
238
|
+
+ New workspace
|
|
239
|
+
</text>
|
|
240
|
+
</box>
|
|
241
|
+
</box>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
interface WorkspaceRowProps {
|
|
246
|
+
session: SessionRecord
|
|
247
|
+
/** True when this row is the active cursor item (workspace's primary active). */
|
|
248
|
+
isActiveItem: boolean
|
|
249
|
+
/** True when this row belongs to the current workspace (selection scope). */
|
|
250
|
+
inCurrentGroup: boolean
|
|
251
|
+
/** The session's primary worktree — its git branch is shown as the workspace's anchor identity. */
|
|
252
|
+
primaryWorktree: WorktreeRecord | undefined
|
|
253
|
+
status: SessionStatus
|
|
254
|
+
dragging: boolean
|
|
255
|
+
contentWidth: number
|
|
256
|
+
/** Vertical spacing above this row — used to separate workspace blocks. */
|
|
257
|
+
marginTop: number
|
|
258
|
+
setRowRef: (id: string, ref: BoxRenderable | null) => void
|
|
259
|
+
onDragStart: (id: string) => void
|
|
260
|
+
onDrag: (event: OtuiMouseEvent) => void
|
|
261
|
+
onDrop: () => void
|
|
262
|
+
onDragCancel: () => void
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const WorkspaceRow = memo(function WorkspaceRow({
|
|
266
|
+
contentWidth,
|
|
267
|
+
dragging,
|
|
268
|
+
inCurrentGroup,
|
|
269
|
+
isActiveItem,
|
|
270
|
+
marginTop,
|
|
271
|
+
onDrag,
|
|
272
|
+
onDragCancel,
|
|
273
|
+
onDragStart,
|
|
274
|
+
onDrop,
|
|
275
|
+
primaryWorktree,
|
|
276
|
+
session,
|
|
277
|
+
setRowRef,
|
|
278
|
+
status,
|
|
279
|
+
}: WorkspaceRowProps) {
|
|
280
|
+
const t = useTheme()
|
|
281
|
+
const showSpinner = status.working
|
|
282
|
+
const showWaiting = status.waiting
|
|
283
|
+
const spinner = useBusySpinner(showSpinner)
|
|
284
|
+
let bgColor: string | undefined
|
|
285
|
+
if (dragging || isActiveItem) {
|
|
286
|
+
bgColor = t.backgroundElement
|
|
287
|
+
} else if (inCurrentGroup) {
|
|
288
|
+
bgColor = t.backgroundPanel
|
|
289
|
+
}
|
|
290
|
+
const workingColor = t.primary
|
|
291
|
+
const waitingColor = t.warning
|
|
292
|
+
const divergence = useAppStore((s) =>
|
|
293
|
+
primaryWorktree ? s.worktreeDivergence[primaryWorktree.id] : undefined
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const handleRef = useCallback(
|
|
297
|
+
(r: BoxRenderable | null) => setRowRef(session.id, r),
|
|
298
|
+
[setRowRef, session.id]
|
|
299
|
+
)
|
|
300
|
+
const handleMouseDown = useCallback(
|
|
301
|
+
(e: OtuiMouseEvent) => {
|
|
302
|
+
e.preventDefault()
|
|
303
|
+
e.stopPropagation()
|
|
304
|
+
onDragStart(session.id)
|
|
305
|
+
},
|
|
306
|
+
[onDragStart, session.id]
|
|
307
|
+
)
|
|
308
|
+
const handleMouseUp = useCallback(
|
|
309
|
+
(e: OtuiMouseEvent) => {
|
|
310
|
+
e.preventDefault()
|
|
311
|
+
onDrop()
|
|
312
|
+
},
|
|
313
|
+
[onDrop]
|
|
314
|
+
)
|
|
315
|
+
const rightClickMenu = useMemo<[string, () => void][]>(
|
|
316
|
+
() => [
|
|
317
|
+
[
|
|
318
|
+
'Rename workspace',
|
|
319
|
+
() =>
|
|
320
|
+
dispatchGlobal({
|
|
321
|
+
initialName: session.name,
|
|
322
|
+
returnToSessionPicker: false,
|
|
323
|
+
sessionTargetId: session.id,
|
|
324
|
+
type: 'open-session-name-modal',
|
|
325
|
+
}),
|
|
326
|
+
],
|
|
327
|
+
[
|
|
328
|
+
'Delete workspace',
|
|
329
|
+
() => runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' }),
|
|
330
|
+
],
|
|
331
|
+
],
|
|
332
|
+
[session.id, session.name]
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
// Color of the left vertical bar — the workspace's stable accent (derived
|
|
336
|
+
// from its primary worktree id). Falls back to a tint for unhydrated sessions.
|
|
337
|
+
const barColor =
|
|
338
|
+
primaryWorktree != null
|
|
339
|
+
? (primaryWorktree.color ?? getWorktreeColor(primaryWorktree.id))
|
|
340
|
+
: t.textMuted
|
|
341
|
+
// Working/waiting indicator overrides the colored bar — the user needs to
|
|
342
|
+
// see assistant activity from across the room.
|
|
343
|
+
let leadingGlyph = '▍'
|
|
344
|
+
let leadingColor = barColor
|
|
345
|
+
if (showWaiting) {
|
|
346
|
+
leadingGlyph = '?'
|
|
347
|
+
leadingColor = waitingColor
|
|
348
|
+
} else if (showSpinner) {
|
|
349
|
+
leadingGlyph = spinner
|
|
350
|
+
leadingColor = workingColor
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const branchText = primaryWorktree?.branch ?? ''
|
|
354
|
+
const divergenceText = formatDivergence(divergence)
|
|
355
|
+
const showBranch = branchText !== ''
|
|
356
|
+
const nameLabel = truncate(session.name, Math.max(0, contentWidth - 4))
|
|
357
|
+
const branchLabel = truncate(
|
|
358
|
+
branchText,
|
|
359
|
+
Math.max(0, contentWidth - 5 - (divergenceText.length + 1))
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<ContextMenuBox
|
|
364
|
+
ref={handleRef}
|
|
365
|
+
id={`sidebar-ws-${session.id}`}
|
|
366
|
+
flexDirection="column"
|
|
367
|
+
flexShrink={0}
|
|
368
|
+
marginTop={marginTop}
|
|
369
|
+
paddingLeft={1}
|
|
370
|
+
paddingRight={1}
|
|
371
|
+
backgroundColor={bgColor}
|
|
372
|
+
rightClickMenu={rightClickMenu}
|
|
373
|
+
onMouseDown={handleMouseDown}
|
|
374
|
+
onMouseDrag={onDrag}
|
|
375
|
+
onMouseUp={handleMouseUp}
|
|
376
|
+
onMouseDragEnd={onDragCancel}
|
|
377
|
+
>
|
|
378
|
+
<box flexDirection="row" alignItems="center">
|
|
379
|
+
<text fg={leadingColor} selectable={false} wrapMode="none">
|
|
380
|
+
{leadingGlyph}
|
|
381
|
+
</text>
|
|
382
|
+
<text fg={isActiveItem ? t.text : t.textMuted} selectable={false} wrapMode="none">
|
|
383
|
+
{' '}
|
|
384
|
+
{nameLabel}
|
|
385
|
+
</text>
|
|
386
|
+
</box>
|
|
387
|
+
{showBranch ? (
|
|
388
|
+
<box flexDirection="row">
|
|
389
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
390
|
+
{' '}
|
|
391
|
+
{'\u{e702}'} {branchLabel}
|
|
392
|
+
{divergenceText !== '' ? ` ${divergenceText}` : ''}
|
|
393
|
+
</text>
|
|
394
|
+
</box>
|
|
395
|
+
) : null}
|
|
396
|
+
</ContextMenuBox>
|
|
397
|
+
)
|
|
398
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { memo, useCallback, useMemo } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { SessionRecord, WorktreeRecord } from '../../../../state/types'
|
|
6
|
+
|
|
7
|
+
import { useAppStore } from '../../../../state/app-store'
|
|
8
|
+
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
9
|
+
import { useTheme } from '../../../theme'
|
|
10
|
+
import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
|
|
11
|
+
|
|
12
|
+
interface WorktreeRowProps {
|
|
13
|
+
session: SessionRecord
|
|
14
|
+
worktree: WorktreeRecord
|
|
15
|
+
/** 1-based index of the workspace in the visible order (for switch-session-by-index). */
|
|
16
|
+
sessionIndex: number
|
|
17
|
+
/** True when this row is the active cursor item. */
|
|
18
|
+
isActiveItem: boolean
|
|
19
|
+
/** True when this row's workspace is the current session (selection scope). */
|
|
20
|
+
inCurrentGroup: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const WorktreeRow = memo(function WorktreeRow({
|
|
24
|
+
inCurrentGroup,
|
|
25
|
+
isActiveItem,
|
|
26
|
+
session,
|
|
27
|
+
sessionIndex,
|
|
28
|
+
worktree,
|
|
29
|
+
}: WorktreeRowProps) {
|
|
30
|
+
const t = useTheme()
|
|
31
|
+
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
32
|
+
const isCurrentSession = session.id === currentSessionId
|
|
33
|
+
|
|
34
|
+
const handleMouseDown = useCallback(
|
|
35
|
+
(event: OtuiMouseEvent) => {
|
|
36
|
+
if (event.button !== 0) return
|
|
37
|
+
event.preventDefault()
|
|
38
|
+
event.stopPropagation()
|
|
39
|
+
dispatchGlobal({
|
|
40
|
+
sessionId: session.id,
|
|
41
|
+
type: 'set-active-worktree',
|
|
42
|
+
worktreeId: worktree.id,
|
|
43
|
+
})
|
|
44
|
+
if (!isCurrentSession) {
|
|
45
|
+
runSideEffectGlobal({ index: sessionIndex, type: 'switch-session-by-index' })
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
[isCurrentSession, session.id, sessionIndex, worktree.id]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const rightClickMenu = useMemo<[string, () => void][] | undefined>(() => {
|
|
52
|
+
if (worktree.source === 'primary') return
|
|
53
|
+
return [
|
|
54
|
+
[
|
|
55
|
+
'Remove worktree',
|
|
56
|
+
() =>
|
|
57
|
+
dispatchGlobal({
|
|
58
|
+
sessionId: session.id,
|
|
59
|
+
type: 'remove-worktree-record',
|
|
60
|
+
worktreeId: worktree.id,
|
|
61
|
+
}),
|
|
62
|
+
],
|
|
63
|
+
]
|
|
64
|
+
}, [session.id, worktree.id, worktree.source])
|
|
65
|
+
|
|
66
|
+
let bgColor: string | undefined
|
|
67
|
+
if (isActiveItem) {
|
|
68
|
+
bgColor = t.backgroundElement
|
|
69
|
+
} else if (inCurrentGroup) {
|
|
70
|
+
bgColor = t.backgroundPanel
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<ContextMenuBox
|
|
75
|
+
id={`sidebar-wt-${worktree.id}`}
|
|
76
|
+
flexDirection="row"
|
|
77
|
+
paddingLeft={1}
|
|
78
|
+
paddingRight={1}
|
|
79
|
+
alignItems="center"
|
|
80
|
+
backgroundColor={bgColor}
|
|
81
|
+
rightClickMenu={rightClickMenu}
|
|
82
|
+
onMouseDown={handleMouseDown}
|
|
83
|
+
>
|
|
84
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
85
|
+
{' > '}
|
|
86
|
+
</text>
|
|
87
|
+
<text fg={isActiveItem ? t.text : t.textMuted} selectable={false} wrapMode="none">
|
|
88
|
+
{worktree.name}
|
|
89
|
+
</text>
|
|
90
|
+
</ContextMenuBox>
|
|
91
|
+
)
|
|
92
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
4
|
|
|
5
5
|
import type { MeasuredPaneRect } from '../../../app-runtime/use-pane-size-report'
|
|
6
6
|
import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
|
|
@@ -8,13 +8,14 @@ import type { FocusMode, TabSession } from '../../../state/types'
|
|
|
8
8
|
|
|
9
9
|
import { logInputDebug } from '../../../debug/input-log'
|
|
10
10
|
import {
|
|
11
|
+
computeJunctionEdges,
|
|
11
12
|
computePaneRects,
|
|
13
|
+
type JunctionEdges,
|
|
12
14
|
type LayoutNode,
|
|
13
15
|
PANE_BORDER,
|
|
14
16
|
type PaneRect,
|
|
15
17
|
type SplitDirection,
|
|
16
18
|
} from '../../../state/layout-tree'
|
|
17
|
-
import { useTheme } from '../../theme'
|
|
18
19
|
import { TerminalPane } from './terminal-pane'
|
|
19
20
|
|
|
20
21
|
const PANE_CHROME = PANE_BORDER
|
|
@@ -45,6 +46,7 @@ interface SplitLayoutProps {
|
|
|
45
46
|
onMeasure?: (tabId: string, rect: MeasuredPaneRect) => void
|
|
46
47
|
contentOrigin: TerminalContentOrigin
|
|
47
48
|
bounds: PaneRect
|
|
49
|
+
junctionEdgesMap?: Map<string, JunctionEdges>
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export function SplitLayout({
|
|
@@ -52,6 +54,7 @@ export function SplitLayout({
|
|
|
52
54
|
bounds,
|
|
53
55
|
contentOrigin,
|
|
54
56
|
focusMode,
|
|
57
|
+
junctionEdgesMap: providedJunctionEdgesMap,
|
|
55
58
|
localScrollbackEnabled,
|
|
56
59
|
mouseForwardingEnabled,
|
|
57
60
|
node,
|
|
@@ -69,7 +72,12 @@ export function SplitLayout({
|
|
|
69
72
|
onTerminalScrollEvent,
|
|
70
73
|
tabs,
|
|
71
74
|
}: SplitLayoutProps) {
|
|
72
|
-
const
|
|
75
|
+
const junctionEdgesMap = useMemo(
|
|
76
|
+
() =>
|
|
77
|
+
providedJunctionEdgesMap ??
|
|
78
|
+
computeJunctionEdges(node, bounds, { x: contentOrigin.x, y: contentOrigin.y }),
|
|
79
|
+
[providedJunctionEdgesMap, node, bounds, contentOrigin]
|
|
80
|
+
)
|
|
73
81
|
const paneOrigin = useMemo<TerminalContentOrigin>(
|
|
74
82
|
() => ({
|
|
75
83
|
cols: Math.max(1, bounds.cols - PANE_CHROME * 2),
|
|
@@ -79,22 +87,6 @@ export function SplitLayout({
|
|
|
79
87
|
}),
|
|
80
88
|
[bounds, contentOrigin]
|
|
81
89
|
)
|
|
82
|
-
const handleSeparatorMouseDown = useCallback(
|
|
83
|
-
(e: OtuiMouseEvent) => {
|
|
84
|
-
e.preventDefault()
|
|
85
|
-
if (node.type !== 'split' || !onSeparatorDragStart) return
|
|
86
|
-
const leafId = getFirstLeafId(node.first)
|
|
87
|
-
if (!(leafId != null && leafId !== '')) return
|
|
88
|
-
onSeparatorDragStart({
|
|
89
|
-
direction: node.direction,
|
|
90
|
-
screenStart:
|
|
91
|
-
node.direction === 'vertical' ? contentOrigin.x + bounds.x : contentOrigin.y + bounds.y,
|
|
92
|
-
tabId: leafId,
|
|
93
|
-
totalSize: node.direction === 'vertical' ? bounds.cols : bounds.rows,
|
|
94
|
-
})
|
|
95
|
-
},
|
|
96
|
-
[bounds, contentOrigin, node, onSeparatorDragStart]
|
|
97
|
-
)
|
|
98
90
|
if (node.type === 'leaf') {
|
|
99
91
|
const tab = tabs.find((t) => t.id === node.tabId)
|
|
100
92
|
const isActive = node.tabId === activeTabId
|
|
@@ -117,6 +109,7 @@ export function SplitLayout({
|
|
|
117
109
|
focusMode={focusMode}
|
|
118
110
|
isActive={isActive}
|
|
119
111
|
contentOrigin={paneOrigin}
|
|
112
|
+
junctionEdges={junctionEdgesMap.get(node.tabId)}
|
|
120
113
|
mouseForwardingEnabled={isActive && mouseForwardingEnabled}
|
|
121
114
|
localScrollbackEnabled={isActive && localScrollbackEnabled}
|
|
122
115
|
onTerminalMouseEvent={onTerminalMouseEvent}
|
|
@@ -127,6 +120,7 @@ export function SplitLayout({
|
|
|
127
120
|
onPaneActivate={onPaneActivate}
|
|
128
121
|
onSeparatorDrag={onSeparatorDrag}
|
|
129
122
|
onSeparatorDragEnd={onSeparatorDragEnd}
|
|
123
|
+
onSeparatorDragStart={onSeparatorDragStart}
|
|
130
124
|
onLeftEdgeMouseDown={onLeftEdgeMouseDown}
|
|
131
125
|
onMeasure={onMeasure}
|
|
132
126
|
/>
|
|
@@ -134,23 +128,19 @@ export function SplitLayout({
|
|
|
134
128
|
}
|
|
135
129
|
|
|
136
130
|
const flexDir = node.direction === 'vertical' ? 'row' : 'column'
|
|
137
|
-
const firstGrow = Math.round(node.ratio * 100)
|
|
138
|
-
const secondGrow = 100 - firstGrow
|
|
139
131
|
|
|
140
|
-
// Compute sub-bounds for each child
|
|
141
132
|
const rects = computePaneRects(node, bounds)
|
|
142
|
-
|
|
143
|
-
// Compute the bounding rect for each subtree
|
|
144
133
|
const firstBounds = subtreeBounds(node.first, rects, bounds)
|
|
145
134
|
const secondBounds = subtreeBounds(node.second, rects, bounds)
|
|
146
135
|
|
|
147
|
-
|
|
148
|
-
|
|
136
|
+
const firstSize = node.direction === 'vertical' ? firstBounds.cols : firstBounds.rows
|
|
137
|
+
const firstSizeProp = node.direction === 'vertical' ? { width: firstSize } : { height: firstSize }
|
|
138
|
+
|
|
149
139
|
const secondLeftEdgeMouseDown = node.direction === 'horizontal' ? onLeftEdgeMouseDown : undefined
|
|
150
140
|
|
|
151
141
|
return (
|
|
152
142
|
<box flexDirection={flexDir} flexGrow={1} gap={0}>
|
|
153
|
-
<box
|
|
143
|
+
<box {...firstSizeProp} flexShrink={0} flexDirection="column" overflow="hidden">
|
|
154
144
|
<SplitLayout
|
|
155
145
|
node={node.first}
|
|
156
146
|
tabs={tabs}
|
|
@@ -172,15 +162,10 @@ export function SplitLayout({
|
|
|
172
162
|
onMeasure={onMeasure}
|
|
173
163
|
contentOrigin={contentOrigin}
|
|
174
164
|
bounds={firstBounds}
|
|
165
|
+
junctionEdgesMap={junctionEdgesMap}
|
|
175
166
|
/>
|
|
176
167
|
</box>
|
|
177
|
-
<box
|
|
178
|
-
minWidth={node.direction === 'vertical' ? 1 : undefined}
|
|
179
|
-
minHeight={node.direction === 'horizontal' ? 1 : undefined}
|
|
180
|
-
backgroundColor={t.border}
|
|
181
|
-
onMouseDown={handleSeparatorMouseDown}
|
|
182
|
-
/>
|
|
183
|
-
<box flexGrow={secondGrow} flexDirection="column" overflow="hidden">
|
|
168
|
+
<box flexGrow={1} flexDirection="column" overflow="hidden">
|
|
184
169
|
<SplitLayout
|
|
185
170
|
node={node.second}
|
|
186
171
|
tabs={tabs}
|
|
@@ -202,17 +187,13 @@ export function SplitLayout({
|
|
|
202
187
|
onMeasure={onMeasure}
|
|
203
188
|
contentOrigin={contentOrigin}
|
|
204
189
|
bounds={secondBounds}
|
|
190
|
+
junctionEdgesMap={junctionEdgesMap}
|
|
205
191
|
/>
|
|
206
192
|
</box>
|
|
207
193
|
</box>
|
|
208
194
|
)
|
|
209
195
|
}
|
|
210
196
|
|
|
211
|
-
function getFirstLeafId(node: LayoutNode): string | null {
|
|
212
|
-
if (node.type === 'leaf') return node.tabId
|
|
213
|
-
return getFirstLeafId(node.first)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
197
|
function subtreeBounds(
|
|
217
198
|
node: LayoutNode,
|
|
218
199
|
rects: Map<string, PaneRect>,
|