@brimveyn/aimux 1.20.4 → 1.21.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 +3 -48
- package/src/app-runtime/split-drag-controller.ts +4 -8
- package/src/app-runtime/use-mouse-handlers.ts +27 -42
- package/src/app-runtime/use-terminal-resize.ts +11 -20
- package/src/app.tsx +9 -37
- package/src/config.ts +98 -8
- package/src/git/pr-merge.ts +64 -0
- package/src/git/pr-status-poller.ts +57 -0
- package/src/git/pr-status.ts +227 -0
- package/src/index.tsx +7 -2
- package/src/platform/open-url.ts +39 -0
- package/src/services/ai-usage/spawn.ts +3 -1
- package/src/state/bars.ts +75 -0
- package/src/state/git-pane-sizing.ts +0 -9
- package/src/state/pr-status-store.ts +39 -0
- package/src/state/reducers/git-panel-state.ts +0 -47
- package/src/state/reducers/ui-state.ts +83 -13
- package/src/state/session-persistence.ts +5 -7
- package/src/state/store.ts +28 -35
- package/src/state/types.ts +24 -19
- package/src/state/workspace-save.ts +5 -7
- package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
- package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
- package/src/ui/components/git/pane/git-pane-widget.tsx +30 -16
- package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
- package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
- package/src/ui/components/layout/bar.tsx +191 -0
- package/src/ui/components/layout/top-tab-bar.tsx +3 -2
- package/src/ui/root.tsx +25 -109
- package/src/ui/widgets/registry.tsx +18 -0
- package/src/ui/widgets/widget-context-menu.ts +69 -0
- package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
- package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { memo, useCallback, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
clampPrBody,
|
|
5
|
+
type PrCheck,
|
|
6
|
+
type PrCheckState,
|
|
7
|
+
type PrStatusResult,
|
|
8
|
+
} from '../../../../git/pr-status'
|
|
9
|
+
import { openUrl } from '../../../../platform/open-url'
|
|
10
|
+
import { usePrStatusStore } from '../../../../state/pr-status-store'
|
|
11
|
+
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
12
|
+
import { type ResolvedTuiTheme, useTheme, useTransparent } from '../../../theme'
|
|
13
|
+
|
|
14
|
+
/** Below this the workflow column crowds out the check name. */
|
|
15
|
+
const WORKFLOW_MIN_WIDTH = 34
|
|
16
|
+
|
|
17
|
+
const HIDDEN_SCROLLBAR_OPTIONS = { visible: false }
|
|
18
|
+
/** gap 1 separates title / body / checks; the rows inside stay tight. */
|
|
19
|
+
const AIRY_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 1 }
|
|
20
|
+
|
|
21
|
+
const STATE_GLYPH: Record<Exclude<PrCheckState, 'pending'>, string> = {
|
|
22
|
+
cancel: '⊘',
|
|
23
|
+
fail: '✗',
|
|
24
|
+
pass: '✓',
|
|
25
|
+
skipping: '○',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stateColor(state: PrCheckState, t: ResolvedTuiTheme): string {
|
|
29
|
+
if (state === 'pass') return t.success
|
|
30
|
+
if (state === 'fail') return t.error
|
|
31
|
+
if (state === 'pending') return t.warning
|
|
32
|
+
return t.textMuted
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatDuration(ms: number | null): string {
|
|
36
|
+
if (ms === null) return '—'
|
|
37
|
+
const seconds = Math.round(ms / 1000)
|
|
38
|
+
if (seconds < 60) return `${seconds}s`
|
|
39
|
+
const minutes = Math.floor(seconds / 60)
|
|
40
|
+
return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function placeholder(
|
|
44
|
+
result: PrStatusResult | null,
|
|
45
|
+
t: ResolvedTuiTheme
|
|
46
|
+
): { label: string; color: string } | null {
|
|
47
|
+
if (result === null) return { color: t.textMuted, label: '…' }
|
|
48
|
+
if (result.kind === 'no-gh') return { color: t.textMuted, label: 'gh CLI not found' }
|
|
49
|
+
if (result.kind === 'no-pr') return { color: t.textMuted, label: 'No pull request' }
|
|
50
|
+
if (result.kind === 'error') return { color: t.error, label: result.message }
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const CheckRow = memo(function CheckRow({
|
|
55
|
+
bg,
|
|
56
|
+
check,
|
|
57
|
+
showWorkflow,
|
|
58
|
+
spinner,
|
|
59
|
+
}: {
|
|
60
|
+
check: PrCheck
|
|
61
|
+
showWorkflow: boolean
|
|
62
|
+
spinner: string
|
|
63
|
+
bg: string | undefined
|
|
64
|
+
}) {
|
|
65
|
+
const t = useTheme()
|
|
66
|
+
const onOpen = useCallback(() => {
|
|
67
|
+
openUrl(check.url)
|
|
68
|
+
}, [check.url])
|
|
69
|
+
const glyph = check.state === 'pending' ? spinner : STATE_GLYPH[check.state]
|
|
70
|
+
return (
|
|
71
|
+
<box flexDirection="row" gap={1} onMouseDown={onOpen}>
|
|
72
|
+
<box width={1} flexShrink={0}>
|
|
73
|
+
<text selectable={false} fg={stateColor(check.state, t)} bg={bg}>
|
|
74
|
+
{glyph}
|
|
75
|
+
</text>
|
|
76
|
+
</box>
|
|
77
|
+
<box flexGrow={1} overflow="hidden">
|
|
78
|
+
<text selectable={false} fg={t.text} bg={bg} wrapMode="none">
|
|
79
|
+
{check.name}
|
|
80
|
+
</text>
|
|
81
|
+
</box>
|
|
82
|
+
{showWorkflow && check.workflow !== '' ? (
|
|
83
|
+
<box flexShrink={0}>
|
|
84
|
+
<text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none">
|
|
85
|
+
{check.workflow}
|
|
86
|
+
</text>
|
|
87
|
+
</box>
|
|
88
|
+
) : null}
|
|
89
|
+
<box flexShrink={0}>
|
|
90
|
+
<text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none">
|
|
91
|
+
{formatDuration(check.durationMs)}
|
|
92
|
+
</text>
|
|
93
|
+
</box>
|
|
94
|
+
</box>
|
|
95
|
+
)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
export const PrChecksPanel = memo(function PrChecksPanel({
|
|
99
|
+
contentWidth,
|
|
100
|
+
}: {
|
|
101
|
+
contentWidth: number
|
|
102
|
+
}) {
|
|
103
|
+
const t = useTheme()
|
|
104
|
+
// A tone apart from the PR state row above, so the two zones read as
|
|
105
|
+
// separate bands. Transparent mode paints neither.
|
|
106
|
+
const transparent = useTransparent()
|
|
107
|
+
const bg = transparent ? undefined : t.backgroundPanel
|
|
108
|
+
const result = usePrStatusStore((s) => s.result)
|
|
109
|
+
const stale = usePrStatusStore((s) => s.stale)
|
|
110
|
+
const [expanded, setExpanded] = useState(false)
|
|
111
|
+
const toggleBody = useCallback(() => setExpanded((prev) => !prev), [])
|
|
112
|
+
|
|
113
|
+
const checks = result?.kind === 'ok' ? result.checks : []
|
|
114
|
+
const spinner = useBusySpinner(checks.some((c) => c.state === 'pending'))
|
|
115
|
+
|
|
116
|
+
const status = placeholder(result, t)
|
|
117
|
+
if (status !== null || result?.kind !== 'ok') {
|
|
118
|
+
return (
|
|
119
|
+
<box
|
|
120
|
+
flexGrow={1}
|
|
121
|
+
flexDirection="column"
|
|
122
|
+
alignItems="center"
|
|
123
|
+
backgroundColor={bg}
|
|
124
|
+
paddingTop={1}
|
|
125
|
+
>
|
|
126
|
+
<text selectable={false} fg={status?.color ?? t.textMuted} bg={bg}>
|
|
127
|
+
{status?.label ?? '…'}
|
|
128
|
+
</text>
|
|
129
|
+
</box>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const pr = result.pr
|
|
134
|
+
// The padding below costs a column on each side.
|
|
135
|
+
const innerWidth = contentWidth - 2
|
|
136
|
+
const clamped = clampPrBody(pr.body)
|
|
137
|
+
const body = expanded ? pr.body.trimEnd() : clamped.text
|
|
138
|
+
// The body is raw markdown on purpose: rendering it would cost a parser and
|
|
139
|
+
// the source is what a PR author actually wrote.
|
|
140
|
+
return (
|
|
141
|
+
<box
|
|
142
|
+
flexDirection="column"
|
|
143
|
+
flexGrow={1}
|
|
144
|
+
flexShrink={1}
|
|
145
|
+
flexBasis={0}
|
|
146
|
+
overflow="hidden"
|
|
147
|
+
backgroundColor={bg}
|
|
148
|
+
padding={1}
|
|
149
|
+
>
|
|
150
|
+
{/* No viewportCulling here — the wrapped body is one tall child, which
|
|
151
|
+
culling measures badly, and the content is a description plus a
|
|
152
|
+
handful of rows either way. */}
|
|
153
|
+
<scrollbox
|
|
154
|
+
flexGrow={1}
|
|
155
|
+
scrollY
|
|
156
|
+
scrollbarOptions={HIDDEN_SCROLLBAR_OPTIONS}
|
|
157
|
+
contentOptions={AIRY_CONTENT_OPTIONS}
|
|
158
|
+
>
|
|
159
|
+
<text selectable={false} fg={stale ? t.textMuted : t.text} bg={bg}>
|
|
160
|
+
<strong>{pr.title}</strong>
|
|
161
|
+
</text>
|
|
162
|
+
{body !== '' ? (
|
|
163
|
+
<box flexDirection="column">
|
|
164
|
+
<text selectable={false} fg={t.textMuted} bg={bg}>
|
|
165
|
+
{body}
|
|
166
|
+
</text>
|
|
167
|
+
{clamped.truncated ? (
|
|
168
|
+
<text selectable={false} fg={t.primary} bg={bg} onMouseDown={toggleBody}>
|
|
169
|
+
{expanded ? '▴ less' : '▾ more'}
|
|
170
|
+
</text>
|
|
171
|
+
) : null}
|
|
172
|
+
</box>
|
|
173
|
+
) : null}
|
|
174
|
+
<box flexDirection="column">
|
|
175
|
+
<text selectable={false} fg={t.textMuted} bg={bg}>
|
|
176
|
+
Checks
|
|
177
|
+
</text>
|
|
178
|
+
{checks.length === 0 ? (
|
|
179
|
+
<text selectable={false} fg={t.textMuted} bg={bg}>
|
|
180
|
+
No checks
|
|
181
|
+
</text>
|
|
182
|
+
) : (
|
|
183
|
+
checks.map((check) => (
|
|
184
|
+
<CheckRow
|
|
185
|
+
key={`${check.workflow}/${check.name}`}
|
|
186
|
+
bg={bg}
|
|
187
|
+
check={check}
|
|
188
|
+
showWorkflow={innerWidth >= WORKFLOW_MIN_WIDTH}
|
|
189
|
+
spinner={spinner}
|
|
190
|
+
/>
|
|
191
|
+
))
|
|
192
|
+
)}
|
|
193
|
+
</box>
|
|
194
|
+
</scrollbox>
|
|
195
|
+
</box>
|
|
196
|
+
)
|
|
197
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { memo, useCallback, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { approveAndMergePr } from '../../../../git/pr-merge'
|
|
4
|
+
import { type PrActionState, prActionState } from '../../../../git/pr-status'
|
|
5
|
+
import { refreshPrStatus } from '../../../../git/pr-status-poller'
|
|
6
|
+
import { openUrl } from '../../../../platform/open-url'
|
|
7
|
+
import { usePrStatusStore } from '../../../../state/pr-status-store'
|
|
8
|
+
import { toast } from '../../../../state/toast-store'
|
|
9
|
+
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
10
|
+
import { type ResolvedTuiTheme, useTheme, useTransparent } from '../../../theme'
|
|
11
|
+
|
|
12
|
+
function toneColor(tone: PrActionState['tone'], t: ResolvedTuiTheme): string {
|
|
13
|
+
if (tone === 'ok') return t.success
|
|
14
|
+
if (tone === 'blocked') return t.error
|
|
15
|
+
return t.textMuted
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPath: string }) {
|
|
19
|
+
const t = useTheme()
|
|
20
|
+
// Transparent mode drops every painted background rather than punching a hole
|
|
21
|
+
// in whatever the terminal is showing behind aimux.
|
|
22
|
+
const transparent = useTransparent()
|
|
23
|
+
const bg = transparent ? undefined : t.backgroundElement
|
|
24
|
+
const result = usePrStatusStore((s) => s.result)
|
|
25
|
+
const [confirming, setConfirming] = useState(false)
|
|
26
|
+
const [merging, setMerging] = useState(false)
|
|
27
|
+
const spinner = useBusySpinner(merging)
|
|
28
|
+
|
|
29
|
+
const pr = result?.kind === 'ok' ? result.pr : null
|
|
30
|
+
const prUrl = pr?.url ?? ''
|
|
31
|
+
|
|
32
|
+
const openPr = useCallback(() => openUrl(prUrl), [prUrl])
|
|
33
|
+
const askConfirm = useCallback(() => setConfirming(true), [])
|
|
34
|
+
const cancel = useCallback(() => setConfirming(false), [])
|
|
35
|
+
const confirm = useCallback(() => {
|
|
36
|
+
setConfirming(false)
|
|
37
|
+
setMerging(true)
|
|
38
|
+
void (async () => {
|
|
39
|
+
const merged = await approveAndMergePr(projectPath)
|
|
40
|
+
setMerging(false)
|
|
41
|
+
if (merged.ok) toast.success('Pull request merged')
|
|
42
|
+
else toast.error(merged.message)
|
|
43
|
+
await refreshPrStatus(projectPath)
|
|
44
|
+
})()
|
|
45
|
+
}, [projectPath])
|
|
46
|
+
|
|
47
|
+
// First fetch still in flight: hold the band empty so the tabs below don't
|
|
48
|
+
// jump a row once the PR lands.
|
|
49
|
+
if (result === null) {
|
|
50
|
+
return (
|
|
51
|
+
<box backgroundColor={bg} paddingLeft={1} paddingRight={1}>
|
|
52
|
+
<text selectable={false} bg={bg} wrapMode="none">
|
|
53
|
+
{' '}
|
|
54
|
+
</text>
|
|
55
|
+
</box>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
if (result.kind !== 'ok' || pr === null) return null
|
|
59
|
+
const status = prActionState(pr, result.checks)
|
|
60
|
+
let label = status.label
|
|
61
|
+
if (confirming) label = 'Merge this PR?'
|
|
62
|
+
if (merging) label = `${spinner} merging…`
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<box flexDirection="row" gap={1} backgroundColor={bg} paddingLeft={1} paddingRight={1}>
|
|
66
|
+
<box flexDirection="row" flexShrink={0} gap={1} onMouseDown={openPr}>
|
|
67
|
+
<text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none">
|
|
68
|
+
#{pr.number}
|
|
69
|
+
</text>
|
|
70
|
+
<text selectable={false} fg={t.primary} bg={bg} wrapMode="none">
|
|
71
|
+
↗
|
|
72
|
+
</text>
|
|
73
|
+
</box>
|
|
74
|
+
<box flexGrow={1} flexShrink={1} overflow="hidden">
|
|
75
|
+
<text
|
|
76
|
+
selectable={false}
|
|
77
|
+
fg={merging || confirming ? t.warning : toneColor(status.tone, t)}
|
|
78
|
+
bg={bg}
|
|
79
|
+
wrapMode="none"
|
|
80
|
+
>
|
|
81
|
+
{label}
|
|
82
|
+
</text>
|
|
83
|
+
</box>
|
|
84
|
+
{confirming ? (
|
|
85
|
+
<box flexDirection="row" flexShrink={0} gap={1}>
|
|
86
|
+
<text selectable={false} fg={t.success} bg={bg} wrapMode="none" onMouseDown={confirm}>
|
|
87
|
+
<strong>yes</strong>
|
|
88
|
+
</text>
|
|
89
|
+
<text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none" onMouseDown={cancel}>
|
|
90
|
+
no
|
|
91
|
+
</text>
|
|
92
|
+
</box>
|
|
93
|
+
) : null}
|
|
94
|
+
{status.action === 'merge' && !confirming && !merging ? (
|
|
95
|
+
<box flexShrink={0}>
|
|
96
|
+
<text selectable={false} fg={t.primary} bg={bg} wrapMode="none" onMouseDown={askConfirm}>
|
|
97
|
+
<strong>Merge</strong>
|
|
98
|
+
</text>
|
|
99
|
+
</box>
|
|
100
|
+
) : null}
|
|
101
|
+
</box>
|
|
102
|
+
)
|
|
103
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { BarSide } from '../../../state/types'
|
|
6
|
+
|
|
7
|
+
import { useAppStore } from '../../../state/app-store'
|
|
8
|
+
import { getBarWidth, visibleWidgets } from '../../../state/bars'
|
|
9
|
+
import { dispatchGlobal } from '../../../state/dispatch-ref'
|
|
10
|
+
import { useTheme } from '../../theme'
|
|
11
|
+
import { WIDGET_RENDERERS } from '../../widgets/registry'
|
|
12
|
+
import { buildBarContextMenu, buildWidgetContextMenu } from '../../widgets/widget-context-menu'
|
|
13
|
+
import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
|
|
14
|
+
|
|
15
|
+
export interface BarBoundaryResizeInfo {
|
|
16
|
+
containerStart: number
|
|
17
|
+
index: number
|
|
18
|
+
side: BarSide
|
|
19
|
+
totalSize: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface BarProps {
|
|
23
|
+
side: BarSide
|
|
24
|
+
onResizeDrag?: (event: OtuiMouseEvent) => boolean
|
|
25
|
+
onResizeDragEnd?: () => void
|
|
26
|
+
onEdgeResizeStart?: (info: { initialWidth: number; screenStart: number; side: BarSide }) => void
|
|
27
|
+
onBoundaryResizeStart?: (info: BarBoundaryResizeInfo) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const RESIZE_HANDLE = '─'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One edge bar hosting a vertical stack of widgets. Both bars are this
|
|
34
|
+
* component; the only asymmetry is which side the resize handle sits on.
|
|
35
|
+
*/
|
|
36
|
+
export function Bar({
|
|
37
|
+
onBoundaryResizeStart,
|
|
38
|
+
onEdgeResizeStart,
|
|
39
|
+
onResizeDrag,
|
|
40
|
+
onResizeDragEnd,
|
|
41
|
+
side,
|
|
42
|
+
}: BarProps) {
|
|
43
|
+
const t = useTheme()
|
|
44
|
+
const bars = useAppStore((s) => s.bars)
|
|
45
|
+
const focusMode = useAppStore((s) => s.focusMode)
|
|
46
|
+
const bodyRef = useRef<BoxRenderable | null>(null)
|
|
47
|
+
|
|
48
|
+
const bar = bars[side]
|
|
49
|
+
const width = getBarWidth(bar)
|
|
50
|
+
|
|
51
|
+
const handleMouseDown = useCallback(() => {
|
|
52
|
+
if (focusMode === 'terminal-input') {
|
|
53
|
+
dispatchGlobal({ focusMode: 'navigation', type: 'set-focus-mode' })
|
|
54
|
+
}
|
|
55
|
+
}, [focusMode])
|
|
56
|
+
const handleMouseDrag = useCallback(
|
|
57
|
+
(event: OtuiMouseEvent) => {
|
|
58
|
+
if (onResizeDrag?.(event) === true) {
|
|
59
|
+
event.preventDefault()
|
|
60
|
+
event.stopPropagation()
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
[onResizeDrag]
|
|
64
|
+
)
|
|
65
|
+
const handleMouseUp = useCallback(() => {
|
|
66
|
+
onResizeDragEnd?.()
|
|
67
|
+
}, [onResizeDragEnd])
|
|
68
|
+
const handleEdgeMouseDown = useCallback(
|
|
69
|
+
(event: OtuiMouseEvent) => {
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
event.stopPropagation()
|
|
72
|
+
onEdgeResizeStart?.({ initialWidth: width, screenStart: event.x, side })
|
|
73
|
+
},
|
|
74
|
+
[onEdgeResizeStart, side, width]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if (width === 0) return null
|
|
78
|
+
|
|
79
|
+
const visible = visibleWidgets(bar)
|
|
80
|
+
const contentWidth = Math.max(1, width - 1)
|
|
81
|
+
|
|
82
|
+
// Drag handle on the side facing the terminal.
|
|
83
|
+
const edge = (
|
|
84
|
+
<box width={1} flexShrink={0} backgroundColor={t.border} onMouseDown={handleEdgeMouseDown} />
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const body = (
|
|
88
|
+
<box ref={bodyRef} flexDirection="column" flexGrow={1} overflow="hidden">
|
|
89
|
+
{visible.map((widget, index) => (
|
|
90
|
+
<BarWidgetSlot
|
|
91
|
+
key={widget.id}
|
|
92
|
+
bodyRef={bodyRef}
|
|
93
|
+
contentWidth={contentWidth}
|
|
94
|
+
grow={widget.grow}
|
|
95
|
+
handleColor={t.border}
|
|
96
|
+
index={index}
|
|
97
|
+
isLast={index === visible.length - 1}
|
|
98
|
+
onBoundaryResizeStart={onBoundaryResizeStart}
|
|
99
|
+
side={side}
|
|
100
|
+
widgetId={widget.id}
|
|
101
|
+
/>
|
|
102
|
+
))}
|
|
103
|
+
</box>
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<ContextMenuBox
|
|
108
|
+
width={width}
|
|
109
|
+
padding={0}
|
|
110
|
+
flexDirection="row"
|
|
111
|
+
backgroundColor={t.background}
|
|
112
|
+
gap={0}
|
|
113
|
+
overflow="hidden"
|
|
114
|
+
rightClickMenu={buildBarContextMenu(bars, side)}
|
|
115
|
+
onMouseDown={handleMouseDown}
|
|
116
|
+
onMouseDrag={handleMouseDrag}
|
|
117
|
+
onMouseUp={handleMouseUp}
|
|
118
|
+
>
|
|
119
|
+
{side === 'right' ? edge : null}
|
|
120
|
+
<box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
|
|
121
|
+
{body}
|
|
122
|
+
</box>
|
|
123
|
+
{side === 'left' ? edge : null}
|
|
124
|
+
</ContextMenuBox>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function BarWidgetSlot({
|
|
129
|
+
bodyRef,
|
|
130
|
+
contentWidth,
|
|
131
|
+
grow,
|
|
132
|
+
handleColor,
|
|
133
|
+
index,
|
|
134
|
+
isLast,
|
|
135
|
+
onBoundaryResizeStart,
|
|
136
|
+
side,
|
|
137
|
+
widgetId,
|
|
138
|
+
}: {
|
|
139
|
+
bodyRef: React.RefObject<BoxRenderable | null>
|
|
140
|
+
contentWidth: number
|
|
141
|
+
grow: number
|
|
142
|
+
handleColor: string
|
|
143
|
+
index: number
|
|
144
|
+
isLast: boolean
|
|
145
|
+
side: BarSide
|
|
146
|
+
widgetId: string
|
|
147
|
+
onBoundaryResizeStart?: (info: BarBoundaryResizeInfo) => void
|
|
148
|
+
}) {
|
|
149
|
+
const bars = useAppStore((s) => s.bars)
|
|
150
|
+
const render = WIDGET_RENDERERS[widgetId]
|
|
151
|
+
|
|
152
|
+
const handleBoundaryMouseDown = useCallback(
|
|
153
|
+
(event: OtuiMouseEvent) => {
|
|
154
|
+
const body = bodyRef.current
|
|
155
|
+
if (!body) return
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
event.stopPropagation()
|
|
158
|
+
onBoundaryResizeStart?.({
|
|
159
|
+
containerStart: body.y,
|
|
160
|
+
index,
|
|
161
|
+
side,
|
|
162
|
+
totalSize: Math.max(1, body.height),
|
|
163
|
+
})
|
|
164
|
+
},
|
|
165
|
+
[bodyRef, index, onBoundaryResizeStart, side]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if (!render) return null
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<>
|
|
172
|
+
<ContextMenuBox
|
|
173
|
+
flexDirection="column"
|
|
174
|
+
flexGrow={grow}
|
|
175
|
+
flexShrink={1}
|
|
176
|
+
flexBasis={0}
|
|
177
|
+
overflow="hidden"
|
|
178
|
+
rightClickMenu={buildWidgetContextMenu(bars, side, widgetId)}
|
|
179
|
+
>
|
|
180
|
+
{render(contentWidth)}
|
|
181
|
+
</ContextMenuBox>
|
|
182
|
+
{isLast ? null : (
|
|
183
|
+
<box minHeight={1} flexShrink={0} onMouseDown={handleBoundaryMouseDown}>
|
|
184
|
+
<text fg={handleColor} selectable={false}>
|
|
185
|
+
{RESIZE_HANDLE.repeat(Math.max(1, contentWidth))}
|
|
186
|
+
</text>
|
|
187
|
+
</box>
|
|
188
|
+
)}
|
|
189
|
+
</>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
@@ -10,6 +10,7 @@ import type { FocusMode, TabSession } from '../../../state/types'
|
|
|
10
10
|
|
|
11
11
|
import { useWorktreeDivergencePolling } from '../../../git/worktree-divergence-poller'
|
|
12
12
|
import { useAppStore } from '../../../state/app-store'
|
|
13
|
+
import { getBarWidth } from '../../../state/bars'
|
|
13
14
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
|
|
14
15
|
import { filterTabsForActiveWorktree } from '../../../state/session-worktrees'
|
|
15
16
|
import { buildTabEntries, type GroupEntry, type TabEntry } from '../../../state/tab-entries'
|
|
@@ -197,7 +198,7 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
|
|
|
197
198
|
const tabs = useAppStore((s) => s.tabs)
|
|
198
199
|
const activeTabId = useAppStore((s) => s.activeTabId)
|
|
199
200
|
const bar = useAppStore((s) => s.sessionBar)
|
|
200
|
-
const
|
|
201
|
+
const leftBarVisible = useAppStore((s) => getBarWidth(s.bars.left) > 0)
|
|
201
202
|
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
202
203
|
const sessions = useAppStore((s) => s.sessions)
|
|
203
204
|
const focusMode: FocusMode = useAppStore((s) => s.focusMode)
|
|
@@ -206,7 +207,7 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
|
|
|
206
207
|
|
|
207
208
|
// Sidebar now also shows worktree chips with divergence — poll whenever
|
|
208
209
|
// either surface is visible.
|
|
209
|
-
useWorktreeDivergencePolling(bar.visible ||
|
|
210
|
+
useWorktreeDivergencePolling(bar.visible || leftBarVisible || forceVisible)
|
|
210
211
|
|
|
211
212
|
const currentSession = useMemo(
|
|
212
213
|
() =>
|