@brimveyn/aimux 1.14.0 → 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/side-effects.ts +145 -1
- package/src/app-runtime/split-drag-controller.ts +3 -1
- package/src/app-runtime/use-terminal-resize.ts +1 -4
- package/src/app.tsx +0 -3
- package/src/config.ts +1 -12
- package/src/git/worktree-branch-poller.ts +54 -0
- package/src/input/modes/types.ts +2 -0
- package/src/state/layout-tree.ts +114 -4
- package/src/state/reducers/session-state.ts +20 -0
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/reducers/ui-state.ts +0 -3
- 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 +1 -4
- package/src/state/workspace-save.ts +0 -1
- 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,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>,
|
|
@@ -3,6 +3,7 @@ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
|
3
3
|
import { memo, type ReactNode, useCallback, useMemo } from 'react'
|
|
4
4
|
|
|
5
5
|
import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
|
|
6
|
+
import type { JunctionEdgeInfo, JunctionEdges } from '../../../state/layout-tree'
|
|
6
7
|
import type { FocusMode, TabSession, TerminalSnapshot, TerminalSpan } from '../../../state/types'
|
|
7
8
|
|
|
8
9
|
import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
|
|
@@ -28,8 +29,10 @@ interface TerminalPaneProps {
|
|
|
28
29
|
onPaneActivate?: (tabId: string) => void
|
|
29
30
|
onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
|
|
30
31
|
onSeparatorDragEnd?: () => void
|
|
32
|
+
onSeparatorDragStart?: (info: JunctionEdgeInfo) => void
|
|
31
33
|
onLeftEdgeMouseDown?: (event: OtuiMouseEvent) => boolean
|
|
32
34
|
onMeasure?: (tabId: string, rect: MeasuredPaneRect) => void
|
|
35
|
+
junctionEdges?: JunctionEdges
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
function getTitle(
|
|
@@ -113,6 +116,7 @@ export function TerminalPane({
|
|
|
113
116
|
contentOrigin,
|
|
114
117
|
focusMode,
|
|
115
118
|
isActive,
|
|
119
|
+
junctionEdges,
|
|
116
120
|
localScrollbackEnabled,
|
|
117
121
|
mouseForwardingEnabled,
|
|
118
122
|
onLeftEdgeMouseDown,
|
|
@@ -120,6 +124,7 @@ export function TerminalPane({
|
|
|
120
124
|
onPaneActivate,
|
|
121
125
|
onSeparatorDrag,
|
|
122
126
|
onSeparatorDragEnd,
|
|
127
|
+
onSeparatorDragStart,
|
|
123
128
|
onTerminalClick,
|
|
124
129
|
onTerminalDrag,
|
|
125
130
|
onTerminalMouseEvent,
|
|
@@ -175,6 +180,16 @@ export function TerminalPane({
|
|
|
175
180
|
event.y === contentOrigin.y + contentOrigin.rows,
|
|
176
181
|
[contentOrigin]
|
|
177
182
|
)
|
|
183
|
+
const getJunctionSideAt = useCallback(
|
|
184
|
+
(event: OtuiMouseEvent): keyof JunctionEdges | null => {
|
|
185
|
+
if (event.x === contentOrigin.x - 1) return 'left'
|
|
186
|
+
if (event.x === contentOrigin.x + contentOrigin.cols) return 'right'
|
|
187
|
+
if (event.y === contentOrigin.y - 1) return 'top'
|
|
188
|
+
if (event.y === contentOrigin.y + contentOrigin.rows) return 'bottom'
|
|
189
|
+
return null
|
|
190
|
+
},
|
|
191
|
+
[contentOrigin]
|
|
192
|
+
)
|
|
178
193
|
const forwardMouseEvent = useCallback(
|
|
179
194
|
(event: OtuiMouseEvent) => {
|
|
180
195
|
if (event.type === 'down' && event.button === 2 && rightClickMenu) {
|
|
@@ -195,6 +210,18 @@ export function TerminalPane({
|
|
|
195
210
|
return
|
|
196
211
|
}
|
|
197
212
|
}
|
|
213
|
+
if (event.type === 'down' && event.button === 0 && junctionEdges && onSeparatorDragStart) {
|
|
214
|
+
const side = getJunctionSideAt(event)
|
|
215
|
+
if (side !== null) {
|
|
216
|
+
const info = junctionEdges[side]
|
|
217
|
+
if (info) {
|
|
218
|
+
event.preventDefault()
|
|
219
|
+
event.stopPropagation()
|
|
220
|
+
onSeparatorDragStart(info)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
198
225
|
// Absorb left-button clicks on pane borders — they should not focus the
|
|
199
226
|
// pane or be forwarded to the terminal. This keeps borders available for
|
|
200
227
|
// resize actions (split separators, sidebar edge) without conflicting
|
|
@@ -248,11 +275,14 @@ export function TerminalPane({
|
|
|
248
275
|
[
|
|
249
276
|
canForwardMouse,
|
|
250
277
|
contentOrigin,
|
|
278
|
+
getJunctionSideAt,
|
|
251
279
|
isOnPaneBorder,
|
|
280
|
+
junctionEdges,
|
|
252
281
|
onLeftEdgeMouseDown,
|
|
253
282
|
onPaneActivate,
|
|
254
283
|
onSeparatorDrag,
|
|
255
284
|
onSeparatorDragEnd,
|
|
285
|
+
onSeparatorDragStart,
|
|
256
286
|
onTerminalClick,
|
|
257
287
|
onTerminalDrag,
|
|
258
288
|
onTerminalMouseEvent,
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import type { MouseEvent as OtuiMouseEvent, ScrollBoxRenderable } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { memo, type ReactNode, useCallback, useMemo, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { FocusMode, TabSession } from '../../../state/types'
|
|
6
|
+
|
|
7
|
+
import { useWorktreeDivergencePolling } from '../../../git/worktree-divergence-poller'
|
|
8
|
+
import { useAppStore } from '../../../state/app-store'
|
|
9
|
+
import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
|
|
10
|
+
import { filterTabsForActiveWorktree } from '../../../state/session-worktrees'
|
|
11
|
+
import { buildTabEntries, type GroupEntry } from '../../../state/tab-entries'
|
|
12
|
+
import { useTheme } from '../../theme'
|
|
13
|
+
import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
|
|
14
|
+
import { TabItem } from './sidebar/tab-item'
|
|
15
|
+
import { useTopTabBarAutoScroll } from './sidebar/use-top-tab-bar-auto-scroll'
|
|
16
|
+
|
|
17
|
+
interface TopTabBarProps {
|
|
18
|
+
forceVisible?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ROW_CONTENT_OPTIONS = {
|
|
22
|
+
flexDirection: 'row' as const,
|
|
23
|
+
gap: 0,
|
|
24
|
+
justifyContent: 'flex-start' as const,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getGroupIndicator(active: boolean, focused: boolean): string {
|
|
28
|
+
if (!active) return '·'
|
|
29
|
+
return focused ? '›' : '•'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getGroupIndicatorColor(
|
|
33
|
+
t: ReturnType<typeof useTheme>,
|
|
34
|
+
active: boolean,
|
|
35
|
+
focused: boolean
|
|
36
|
+
): string {
|
|
37
|
+
if (!active) return t.textMuted
|
|
38
|
+
return focused ? t.primary : t.text
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const TopTabCell = memo(function TopTabCell({
|
|
42
|
+
active,
|
|
43
|
+
backgroundColor,
|
|
44
|
+
children,
|
|
45
|
+
entryId,
|
|
46
|
+
onActivate,
|
|
47
|
+
}: {
|
|
48
|
+
entryId: string
|
|
49
|
+
active: boolean
|
|
50
|
+
backgroundColor: string | undefined
|
|
51
|
+
onActivate?: (entryId: string) => void
|
|
52
|
+
children: ReactNode
|
|
53
|
+
}) {
|
|
54
|
+
const handleMouseDown = useCallback(
|
|
55
|
+
(event: OtuiMouseEvent) => {
|
|
56
|
+
event.stopPropagation()
|
|
57
|
+
onActivate?.(entryId)
|
|
58
|
+
},
|
|
59
|
+
[onActivate, entryId]
|
|
60
|
+
)
|
|
61
|
+
return (
|
|
62
|
+
<box
|
|
63
|
+
backgroundColor={backgroundColor}
|
|
64
|
+
flexDirection="row"
|
|
65
|
+
flexShrink={0}
|
|
66
|
+
onMouseDown={handleMouseDown}
|
|
67
|
+
data-active={active ? 'true' : undefined}
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</box>
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
function GroupTabItem({
|
|
75
|
+
active,
|
|
76
|
+
entry,
|
|
77
|
+
focused,
|
|
78
|
+
indexLabel,
|
|
79
|
+
}: {
|
|
80
|
+
entry: GroupEntry
|
|
81
|
+
active: boolean
|
|
82
|
+
focused: boolean
|
|
83
|
+
indexLabel?: string
|
|
84
|
+
}) {
|
|
85
|
+
const t = useTheme()
|
|
86
|
+
const indicator = getGroupIndicator(active, focused)
|
|
87
|
+
const indicatorColor = getGroupIndicatorColor(t, active, focused)
|
|
88
|
+
|
|
89
|
+
const closeGroup = useCallback(() => {
|
|
90
|
+
for (const tab of entry.tabs) {
|
|
91
|
+
dispatchGlobal({ tabId: tab.id, type: 'close-tab' })
|
|
92
|
+
runSideEffectGlobal({ tabId: tab.id, type: 'close-tab' })
|
|
93
|
+
}
|
|
94
|
+
}, [entry.tabs])
|
|
95
|
+
|
|
96
|
+
const handleCloseMouseDown = useCallback(
|
|
97
|
+
(event: OtuiMouseEvent) => {
|
|
98
|
+
event.stopPropagation()
|
|
99
|
+
closeGroup()
|
|
100
|
+
},
|
|
101
|
+
[closeGroup]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const rightClickMenu = useMemo<[string, () => void][]>(
|
|
105
|
+
() => [['Close group', closeGroup]],
|
|
106
|
+
[closeGroup]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<ContextMenuBox
|
|
111
|
+
id={`top-tab-${entry.id}`}
|
|
112
|
+
paddingLeft={1}
|
|
113
|
+
paddingRight={1}
|
|
114
|
+
flexDirection="row"
|
|
115
|
+
alignItems="center"
|
|
116
|
+
rightClickMenu={rightClickMenu}
|
|
117
|
+
>
|
|
118
|
+
<text fg={indicatorColor} selectable={false}>
|
|
119
|
+
{indicator}{' '}
|
|
120
|
+
</text>
|
|
121
|
+
{indexLabel != null && indexLabel !== '' ? (
|
|
122
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
123
|
+
{indexLabel}{' '}
|
|
124
|
+
</text>
|
|
125
|
+
) : null}
|
|
126
|
+
{entry.tabs.map((tab, i) => {
|
|
127
|
+
const isLeafActive = tab.id === entry.activeLeafId
|
|
128
|
+
return (
|
|
129
|
+
<box key={tab.id} flexDirection="row" flexShrink={0}>
|
|
130
|
+
{i > 0 ? (
|
|
131
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
132
|
+
{' | '}
|
|
133
|
+
</text>
|
|
134
|
+
) : null}
|
|
135
|
+
<text fg={isLeafActive ? t.text : t.textMuted} selectable={false} wrapMode="none">
|
|
136
|
+
{tab.title}
|
|
137
|
+
</text>
|
|
138
|
+
</box>
|
|
139
|
+
)
|
|
140
|
+
})}
|
|
141
|
+
<box paddingLeft={1} onMouseDown={handleCloseMouseDown}>
|
|
142
|
+
<text fg={t.textMuted} selectable={false}>
|
|
143
|
+
×
|
|
144
|
+
</text>
|
|
145
|
+
</box>
|
|
146
|
+
</ContextMenuBox>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
|
|
151
|
+
const t = useTheme()
|
|
152
|
+
const headerBg = t.backgroundPanel
|
|
153
|
+
const tabs = useAppStore((s) => s.tabs)
|
|
154
|
+
const activeTabId = useAppStore((s) => s.activeTabId)
|
|
155
|
+
const bar = useAppStore((s) => s.sessionBar)
|
|
156
|
+
const sidebar = useAppStore((s) => s.sidebar)
|
|
157
|
+
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
158
|
+
const sessions = useAppStore((s) => s.sessions)
|
|
159
|
+
const focusMode: FocusMode = useAppStore((s) => s.focusMode)
|
|
160
|
+
const layoutTrees = useAppStore((s) => s.layoutTrees)
|
|
161
|
+
const tabGroupMap = useAppStore((s) => s.tabGroupMap)
|
|
162
|
+
|
|
163
|
+
// Sidebar now also shows worktree chips with divergence — poll whenever
|
|
164
|
+
// either surface is visible.
|
|
165
|
+
useWorktreeDivergencePolling(bar.visible || sidebar.visible || forceVisible)
|
|
166
|
+
|
|
167
|
+
const currentSession = useMemo(
|
|
168
|
+
() =>
|
|
169
|
+
currentSessionId != null && currentSessionId !== ''
|
|
170
|
+
? sessions.find((s) => s.id === currentSessionId)
|
|
171
|
+
: undefined,
|
|
172
|
+
[currentSessionId, sessions]
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const visibleTabs = useMemo(
|
|
176
|
+
() => filterTabsForActiveWorktree(tabs, currentSession),
|
|
177
|
+
[tabs, currentSession]
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
const entries = useMemo(
|
|
181
|
+
() => buildTabEntries(visibleTabs, layoutTrees, tabGroupMap, activeTabId),
|
|
182
|
+
[visibleTabs, layoutTrees, tabGroupMap, activeTabId]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const activeEntryId = useMemo(() => {
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
if (entry.kind === 'single') {
|
|
188
|
+
if (entry.tab.id === activeTabId) return entry.id
|
|
189
|
+
} else if (entry.tabs.some((tab) => tab.id === activeTabId)) {
|
|
190
|
+
return entry.id
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null
|
|
194
|
+
}, [entries, activeTabId])
|
|
195
|
+
|
|
196
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
197
|
+
useTopTabBarAutoScroll({
|
|
198
|
+
activeTabId: activeEntryId,
|
|
199
|
+
idPrefix: 'top-tab-',
|
|
200
|
+
scrollRef,
|
|
201
|
+
visible: bar.visible || forceVisible,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const handleEntryActivate = useCallback(
|
|
205
|
+
(entryId: string) => {
|
|
206
|
+
const entry = entries.find((e) => e.id === entryId)
|
|
207
|
+
if (!entry) return
|
|
208
|
+
const targetTabId = entry.kind === 'single' ? entry.tab.id : entry.activeLeafId
|
|
209
|
+
if (targetTabId !== activeTabId) {
|
|
210
|
+
dispatchGlobal({ tabId: targetTabId, type: 'set-active-tab' })
|
|
211
|
+
}
|
|
212
|
+
dispatchGlobal({ focusMode: 'terminal-input', type: 'set-focus-mode' })
|
|
213
|
+
},
|
|
214
|
+
[entries, activeTabId]
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const handleNewTab = useCallback((e: OtuiMouseEvent) => {
|
|
218
|
+
e.stopPropagation()
|
|
219
|
+
dispatchGlobal({ type: 'open-new-tab-modal' })
|
|
220
|
+
}, [])
|
|
221
|
+
|
|
222
|
+
if (!bar.visible && !forceVisible) return null
|
|
223
|
+
if (entries.length === 0) return null
|
|
224
|
+
|
|
225
|
+
const isFocused = focusMode === 'terminal-input' || focusMode === 'navigation'
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<box
|
|
229
|
+
width="100%"
|
|
230
|
+
height={1}
|
|
231
|
+
flexDirection="row"
|
|
232
|
+
flexShrink={0}
|
|
233
|
+
backgroundColor={headerBg}
|
|
234
|
+
overflow="hidden"
|
|
235
|
+
>
|
|
236
|
+
<scrollbox
|
|
237
|
+
ref={scrollRef}
|
|
238
|
+
height={1}
|
|
239
|
+
flexGrow={1}
|
|
240
|
+
flexShrink={1}
|
|
241
|
+
flexBasis={0}
|
|
242
|
+
scrollX
|
|
243
|
+
viewportCulling
|
|
244
|
+
contentOptions={ROW_CONTENT_OPTIONS}
|
|
245
|
+
>
|
|
246
|
+
{entries.map((entry, index) => {
|
|
247
|
+
// [N] is shown only for the first 9 entries — that's the range
|
|
248
|
+
// Leader+1..9 can address.
|
|
249
|
+
const indexLabel = index < 9 ? `[${index + 1}]` : undefined
|
|
250
|
+
if (entry.kind === 'single') {
|
|
251
|
+
const tab: TabSession = entry.tab
|
|
252
|
+
const isActive = tab.id === activeTabId
|
|
253
|
+
return (
|
|
254
|
+
<TopTabCell
|
|
255
|
+
key={entry.id}
|
|
256
|
+
entryId={entry.id}
|
|
257
|
+
active={isActive}
|
|
258
|
+
onActivate={handleEntryActivate}
|
|
259
|
+
backgroundColor={isActive ? t.backgroundElement : undefined}
|
|
260
|
+
>
|
|
261
|
+
<TabItem
|
|
262
|
+
id={`top-tab-${tab.id}`}
|
|
263
|
+
tab={tab}
|
|
264
|
+
active={isActive}
|
|
265
|
+
focused={isFocused}
|
|
266
|
+
indexLabel={indexLabel}
|
|
267
|
+
alwaysShowClose
|
|
268
|
+
/>
|
|
269
|
+
</TopTabCell>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
const isActive = entry.tabs.some((tab) => tab.id === activeTabId)
|
|
273
|
+
return (
|
|
274
|
+
<TopTabCell
|
|
275
|
+
key={entry.id}
|
|
276
|
+
entryId={entry.id}
|
|
277
|
+
active={isActive}
|
|
278
|
+
onActivate={handleEntryActivate}
|
|
279
|
+
backgroundColor={isActive ? t.backgroundElement : undefined}
|
|
280
|
+
>
|
|
281
|
+
<GroupTabItem
|
|
282
|
+
entry={entry}
|
|
283
|
+
active={isActive}
|
|
284
|
+
focused={isFocused}
|
|
285
|
+
indexLabel={indexLabel}
|
|
286
|
+
/>
|
|
287
|
+
</TopTabCell>
|
|
288
|
+
)
|
|
289
|
+
})}
|
|
290
|
+
<box
|
|
291
|
+
flexDirection="row"
|
|
292
|
+
flexShrink={0}
|
|
293
|
+
paddingLeft={1}
|
|
294
|
+
paddingRight={1}
|
|
295
|
+
onMouseDown={handleNewTab}
|
|
296
|
+
>
|
|
297
|
+
<text fg={t.textMuted} selectable={false}>
|
|
298
|
+
+
|
|
299
|
+
</text>
|
|
300
|
+
</box>
|
|
301
|
+
</scrollbox>
|
|
302
|
+
</box>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react'
|
|
|
3
3
|
import type { WorktreeRecord } from '../../../../state/types'
|
|
4
4
|
|
|
5
5
|
import { dispatchGlobal } from '../../../../state/dispatch-ref'
|
|
6
|
+
import { formatDivergence } from '../../../../state/session-worktrees'
|
|
6
7
|
import { useTheme } from '../../../theme'
|
|
7
8
|
import { uiTokens } from '../../../ui-tokens'
|
|
8
9
|
import { ListItem } from '../../primitives/list-item'
|
|
@@ -23,14 +24,6 @@ const MOVE_HINTS: [key: string, label: string][] = [
|
|
|
23
24
|
['esc', 'cancel'],
|
|
24
25
|
]
|
|
25
26
|
|
|
26
|
-
function formatDivergence(divergence: { ahead: number; behind: number } | undefined): string {
|
|
27
|
-
if (divergence == null) return ''
|
|
28
|
-
const parts: string[] = []
|
|
29
|
-
if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
|
|
30
|
-
if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
|
|
31
|
-
return parts.join(' ')
|
|
32
|
-
}
|
|
33
|
-
|
|
34
27
|
export function WorktreeMoveModal({
|
|
35
28
|
deleteSource,
|
|
36
29
|
divergence,
|