@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.
Files changed (34) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +3 -48
  3. package/src/app-runtime/split-drag-controller.ts +4 -8
  4. package/src/app-runtime/use-mouse-handlers.ts +27 -42
  5. package/src/app-runtime/use-terminal-resize.ts +11 -20
  6. package/src/app.tsx +9 -37
  7. package/src/config.ts +98 -8
  8. package/src/git/pr-merge.ts +64 -0
  9. package/src/git/pr-status-poller.ts +57 -0
  10. package/src/git/pr-status.ts +227 -0
  11. package/src/index.tsx +7 -2
  12. package/src/platform/open-url.ts +39 -0
  13. package/src/services/ai-usage/spawn.ts +3 -1
  14. package/src/state/bars.ts +75 -0
  15. package/src/state/git-pane-sizing.ts +0 -9
  16. package/src/state/pr-status-store.ts +39 -0
  17. package/src/state/reducers/git-panel-state.ts +0 -47
  18. package/src/state/reducers/ui-state.ts +83 -13
  19. package/src/state/session-persistence.ts +5 -7
  20. package/src/state/store.ts +28 -35
  21. package/src/state/types.ts +24 -19
  22. package/src/state/workspace-save.ts +5 -7
  23. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
  24. package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
  25. package/src/ui/components/git/pane/git-pane-widget.tsx +30 -16
  26. package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
  27. package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
  28. package/src/ui/components/layout/bar.tsx +191 -0
  29. package/src/ui/components/layout/top-tab-bar.tsx +3 -2
  30. package/src/ui/root.tsx +25 -109
  31. package/src/ui/widgets/registry.tsx +18 -0
  32. package/src/ui/widgets/widget-context-menu.ts +69 -0
  33. package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
  34. package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
@@ -0,0 +1,57 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import { prStatusStore } from '../state/pr-status-store'
4
+ import { collectPrStatus } from './pr-status'
5
+
6
+ /** A run in flight is worth watching closely; a settled one barely changes. */
7
+ const ACTIVE_INTERVAL_MS = 15_000
8
+ const IDLE_INTERVAL_MS = 60_000
9
+ const MAX_INTERVAL_MS = 120_000
10
+
11
+ interface Options {
12
+ enabled: boolean
13
+ projectPath: string | undefined
14
+ }
15
+
16
+ /** One-shot refetch, for when an action we took just invalidated the state. */
17
+ export async function refreshPrStatus(projectPath: string): Promise<void> {
18
+ prStatusStore.getState().setResult(await collectPrStatus(projectPath))
19
+ }
20
+
21
+ export function usePrStatusPolling({ enabled, projectPath }: Options): void {
22
+ useEffect(() => {
23
+ if (!enabled || !(projectPath != null && projectPath !== '')) return
24
+
25
+ prStatusStore.getState().reset()
26
+
27
+ let cancelled = false
28
+ let timer: ReturnType<typeof setTimeout> | null = null
29
+ let delay = ACTIVE_INTERVAL_MS
30
+
31
+ const schedule = () => {
32
+ if (cancelled) return
33
+ timer = setTimeout(() => void tick(), delay)
34
+ }
35
+
36
+ const tick = async () => {
37
+ const result = await collectPrStatus(projectPath)
38
+ if (cancelled) return
39
+ prStatusStore.getState().setResult(result)
40
+ if (result.kind === 'error') {
41
+ delay = Math.min(delay * 2, MAX_INTERVAL_MS)
42
+ } else if (result.kind === 'ok' && result.checks.some((c) => c.state === 'pending')) {
43
+ delay = ACTIVE_INTERVAL_MS
44
+ } else {
45
+ delay = IDLE_INTERVAL_MS
46
+ }
47
+ schedule()
48
+ }
49
+
50
+ void tick()
51
+
52
+ return () => {
53
+ cancelled = true
54
+ if (timer) clearTimeout(timer)
55
+ }
56
+ }, [enabled, projectPath])
57
+ }
@@ -0,0 +1,227 @@
1
+ import { runCli } from '../services/ai-usage/spawn'
2
+
3
+ export type PrCheckState = 'pass' | 'fail' | 'pending' | 'skipping' | 'cancel'
4
+
5
+ export interface PrCheck {
6
+ name: string
7
+ workflow: string
8
+ state: PrCheckState
9
+ url: string
10
+ durationMs: number | null
11
+ }
12
+
13
+ export interface PrSummary {
14
+ number: number
15
+ title: string
16
+ body: string
17
+ state: string
18
+ isDraft: boolean
19
+ base: string
20
+ head: string
21
+ reviewDecision: string
22
+ /** MERGEABLE | CONFLICTING | UNKNOWN */
23
+ mergeable: string
24
+ /** CLEAN | BLOCKED | BEHIND | UNSTABLE | DIRTY | DRAFT | HAS_HOOKS | UNKNOWN */
25
+ mergeStateStatus: string
26
+ additions: number
27
+ deletions: number
28
+ changedFiles: number
29
+ url: string
30
+ }
31
+
32
+ export type PrStatusResult =
33
+ | { kind: 'ok'; pr: PrSummary; checks: PrCheck[] }
34
+ | { kind: 'no-pr' }
35
+ | { kind: 'no-gh' }
36
+ | { kind: 'error'; message: string }
37
+
38
+ const PR_VIEW_FIELDS = [
39
+ 'number',
40
+ 'title',
41
+ 'body',
42
+ 'state',
43
+ 'isDraft',
44
+ 'url',
45
+ 'baseRefName',
46
+ 'headRefName',
47
+ 'reviewDecision',
48
+ 'mergeable',
49
+ 'mergeStateStatus',
50
+ 'additions',
51
+ 'deletions',
52
+ 'changedFiles',
53
+ 'statusCheckRollup',
54
+ ].join(',')
55
+
56
+ const NOT_AN_ERROR = [
57
+ 'no pull requests found',
58
+ 'no git remotes',
59
+ 'not a git repository',
60
+ 'none of the git remotes',
61
+ 'no default remote',
62
+ ]
63
+
64
+ function str(value: unknown): string {
65
+ return typeof value === 'string' ? value : ''
66
+ }
67
+
68
+ function num(value: unknown): number {
69
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
70
+ }
71
+
72
+ function duration(startedAt: unknown, completedAt: unknown): number | null {
73
+ const start = Date.parse(str(startedAt))
74
+ const end = Date.parse(str(completedAt))
75
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null
76
+ return end - start
77
+ }
78
+
79
+ // `gh` documents these buckets for `pr checks --json bucket`; we derive the same
80
+ // classification from the raw rollup so a single `pr view` call covers both the
81
+ // summary and the checks.
82
+ function checkRunState(status: string, conclusion: string): PrCheckState {
83
+ if (status !== 'COMPLETED') return 'pending'
84
+ if (conclusion === 'SUCCESS' || conclusion === 'NEUTRAL') return 'pass'
85
+ if (conclusion === 'SKIPPED') return 'skipping'
86
+ if (conclusion === 'CANCELLED') return 'cancel'
87
+ return 'fail'
88
+ }
89
+
90
+ function statusContextState(state: string): PrCheckState {
91
+ if (state === 'SUCCESS') return 'pass'
92
+ if (state === 'PENDING' || state === 'EXPECTED') return 'pending'
93
+ return 'fail'
94
+ }
95
+
96
+ function toCheck(raw: unknown): PrCheck | null {
97
+ if (typeof raw !== 'object' || raw === null) return null
98
+ const entry = raw as Record<string, unknown>
99
+ if (entry.__typename === 'StatusContext') {
100
+ const name = str(entry.context)
101
+ if (name === '') return null
102
+ return {
103
+ durationMs: null,
104
+ name,
105
+ state: statusContextState(str(entry.state)),
106
+ url: str(entry.targetUrl),
107
+ workflow: '',
108
+ }
109
+ }
110
+ const name = str(entry.name)
111
+ if (name === '') return null
112
+ return {
113
+ durationMs: duration(entry.startedAt, entry.completedAt),
114
+ name,
115
+ state: checkRunState(str(entry.status), str(entry.conclusion)),
116
+ url: str(entry.detailsUrl),
117
+ workflow: str(entry.workflowName),
118
+ }
119
+ }
120
+
121
+ export function parsePrView(raw: unknown): PrStatusResult {
122
+ if (typeof raw !== 'object' || raw === null) return { kind: 'no-pr' }
123
+ const pr = raw as Record<string, unknown>
124
+ if (typeof pr.number !== 'number') return { kind: 'no-pr' }
125
+ const rollup = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : []
126
+ return {
127
+ checks: rollup.map(toCheck).filter((c): c is PrCheck => c !== null),
128
+ kind: 'ok',
129
+ pr: {
130
+ additions: num(pr.additions),
131
+ base: str(pr.baseRefName),
132
+ body: str(pr.body).trim(),
133
+ changedFiles: num(pr.changedFiles),
134
+ deletions: num(pr.deletions),
135
+ head: str(pr.headRefName),
136
+ isDraft: pr.isDraft === true,
137
+ mergeable: str(pr.mergeable),
138
+ mergeStateStatus: str(pr.mergeStateStatus),
139
+ number: pr.number,
140
+ reviewDecision: str(pr.reviewDecision),
141
+ state: str(pr.state),
142
+ title: str(pr.title),
143
+ url: str(pr.url),
144
+ },
145
+ }
146
+ }
147
+
148
+ export type PrAction = 'merge' | null
149
+
150
+ export interface PrActionState {
151
+ label: string
152
+ action: PrAction
153
+ tone: 'ok' | 'blocked' | 'neutral'
154
+ }
155
+
156
+ /**
157
+ * The headline GitHub puts on the merge box, and the one action worth wiring to
158
+ * it. Order matters: a terminal state beats everything, then a hard blocker
159
+ * (conflicts, draft), then whatever the checks are doing. Anything we can't
160
+ * offer an action for still gets an honest label rather than a dead button.
161
+ */
162
+ export function prActionState(pr: PrSummary, checks: PrCheck[]): PrActionState {
163
+ if (pr.state === 'MERGED') return { action: null, label: 'Merged', tone: 'neutral' }
164
+ if (pr.state === 'CLOSED') return { action: null, label: 'Closed', tone: 'blocked' }
165
+ if (pr.isDraft) return { action: null, label: 'Draft', tone: 'neutral' }
166
+ if (pr.mergeable === 'CONFLICTING') {
167
+ return { action: null, label: 'Merge conflicts', tone: 'blocked' }
168
+ }
169
+ if (checks.some((c) => c.state === 'pending')) {
170
+ return { action: null, label: 'Checks running', tone: 'neutral' }
171
+ }
172
+ if (pr.mergeStateStatus === 'BLOCKED') return { action: null, label: 'Blocked', tone: 'blocked' }
173
+ if (pr.mergeStateStatus === 'BEHIND')
174
+ return { action: null, label: 'Out of date', tone: 'blocked' }
175
+ // UNSTABLE means a non-required check failed; GitHub still lets you merge.
176
+ if (pr.mergeStateStatus === 'UNSTABLE') {
177
+ return { action: 'merge', label: 'Checks failing', tone: 'blocked' }
178
+ }
179
+ if (pr.mergeStateStatus === 'CLEAN') {
180
+ return { action: 'merge', label: 'Ready to merge', tone: 'ok' }
181
+ }
182
+ return { action: null, label: 'Checking…', tone: 'neutral' }
183
+ }
184
+
185
+ export interface ClampedBody {
186
+ text: string
187
+ truncated: boolean
188
+ }
189
+
190
+ /**
191
+ * A PR body is a whole document; a bar widget gets a preview of it. Clamping on
192
+ * lines alone breaks on a wall-of-text paragraph and clamping on characters
193
+ * alone breaks on a bullet list, so whichever limit bites first wins.
194
+ */
195
+ export function clampPrBody(body: string, maxLines = 5, maxChars = 260): ClampedBody {
196
+ const full = body.trimEnd()
197
+ const lines = full.split('\n')
198
+ let text = lines.slice(0, maxLines).join('\n')
199
+ if (text.length > maxChars) {
200
+ const space = text.lastIndexOf(' ', maxChars)
201
+ // Only respect a word boundary that isn't absurdly early, else hard-cut.
202
+ text = text.slice(0, space > maxChars / 2 ? space : maxChars)
203
+ }
204
+ text = text.trimEnd()
205
+ return { text, truncated: text.length < full.length }
206
+ }
207
+
208
+ export async function collectPrStatus(cwd: string): Promise<PrStatusResult> {
209
+ const gh = Bun.which('gh')
210
+ if (gh === null) return { kind: 'no-gh' }
211
+
212
+ const result = await runCli(gh, ['pr', 'view', '--json', PR_VIEW_FIELDS], 15_000, cwd)
213
+ if (!result.ok) {
214
+ // `gh` exits non-zero for every "there is simply nothing to show" case too:
215
+ // no PR on the branch, no GitHub remote, or a directory that isn't a repo
216
+ // at all (aimux sessions can point anywhere). None of those is an error.
217
+ const stderr = result.stderr.toLowerCase()
218
+ if (NOT_AN_ERROR.some((needle) => stderr.includes(needle))) return { kind: 'no-pr' }
219
+ return { kind: 'error', message: (result.error ?? 'gh failed').slice(0, 200) }
220
+ }
221
+
222
+ try {
223
+ return parsePrView(JSON.parse(result.stdout))
224
+ } catch {
225
+ return { kind: 'no-pr' }
226
+ }
227
+ }
package/src/index.tsx CHANGED
@@ -72,8 +72,14 @@ if (command === '--help' || command === '-h' || command === 'help') {
72
72
  process.exit(await runCli([]))
73
73
  }
74
74
 
75
+ // Sequential ON PURPOSE: @opentui/react evaluates @opentui/core as part of its
76
+ // own module graph. Loading both concurrently races the two evaluations and
77
+ // react's chunk can hit `class X extends TextNodeRenderable` while core is
78
+ // still initializing (ReferenceError: cannot access before initialization).
79
+ // react has to wait on core either way, so this costs nothing.
80
+ const { createCliRenderer } = await import('@opentui/core')
81
+
75
82
  const [
76
- { createCliRenderer },
77
83
  { createRoot },
78
84
  { App },
79
85
  { loadUserConfig },
@@ -82,7 +88,6 @@ const [
82
88
  { createSessionBackend },
83
89
  { maybeAutoInstallCompletion },
84
90
  ] = await Promise.all([
85
- import('@opentui/core'),
86
91
  import('@opentui/react'),
87
92
  import('./app'),
88
93
  import('./config/loader'),
@@ -0,0 +1,39 @@
1
+ import { logDebug } from '../debug/input-log'
2
+ import { toast } from '../state/toast-store'
3
+ import { detectClipboardPlatform } from './clipboard'
4
+
5
+ /**
6
+ * URLs here come from the GitHub API, i.e. outside the process. Handing an
7
+ * arbitrary scheme to the OS opener is a code-execution path (`file://`,
8
+ * `javascript:`, custom app handlers), so only plain https is allowed through.
9
+ */
10
+ function isSafeUrl(url: string): boolean {
11
+ return /^https:\/\/[^\s]+$/.test(url)
12
+ }
13
+
14
+ export function openUrlCommand(platform: string, isWsl: boolean, url: string): string[] | null {
15
+ if (!isSafeUrl(url)) return null
16
+ if (platform === 'darwin') return ['open', url]
17
+ // Under WSL the browser lives on the Windows side; explorer.exe is the one
18
+ // bridge present on every install (wslview/xdg-open often are not).
19
+ if (platform === 'win32' || isWsl) return ['explorer.exe', url]
20
+ return ['xdg-open', url]
21
+ }
22
+
23
+ export function openUrl(url: string): void {
24
+ const { isWsl, platform } = detectClipboardPlatform()
25
+ const argv = openUrlCommand(platform, isWsl, url)
26
+ if (!argv) {
27
+ logDebug('platform.openUrl.rejected', { url })
28
+ return
29
+ }
30
+ try {
31
+ Bun.spawn(argv, { stderr: 'ignore', stdin: 'ignore', stdout: 'ignore' })
32
+ } catch (error) {
33
+ logDebug('platform.openUrl.error', {
34
+ argv,
35
+ error: error instanceof Error ? error.message : String(error),
36
+ })
37
+ toast.error(`Could not open ${argv[0]}`)
38
+ }
39
+ }
@@ -10,9 +10,11 @@ const DEFAULT_TIMEOUT_MS = 15_000
10
10
  export async function runCli(
11
11
  command: string,
12
12
  args: string[],
13
- timeoutMs: number = DEFAULT_TIMEOUT_MS
13
+ timeoutMs: number = DEFAULT_TIMEOUT_MS,
14
+ cwd?: string
14
15
  ): Promise<CliResult> {
15
16
  const proc = Bun.spawn([command, ...args], {
17
+ cwd,
16
18
  stderr: 'pipe',
17
19
  stdin: 'ignore',
18
20
  stdout: 'pipe',
@@ -0,0 +1,75 @@
1
+ import type { BarSide, BarsState, BarState, BarWidget } from './types'
2
+
3
+ export const BAR_MIN_WIDTH = 18
4
+ export const BAR_MAX_WIDTH = 80
5
+
6
+ /** Widget ids the app knows how to render. Unknown ids are pruned on load. */
7
+ export const KNOWN_WIDGET_IDS = ['workspaces', 'git'] as const
8
+
9
+ /** Smallest share of a bar a single widget may shrink to, as a fraction. */
10
+ const MIN_WIDGET_SHARE = 0.1
11
+
12
+ export function clampBarWidth(width: number): number {
13
+ return Math.min(BAR_MAX_WIDTH, Math.max(BAR_MIN_WIDTH, Math.round(width)))
14
+ }
15
+
16
+ export function visibleWidgets(bar: BarState): BarWidget[] {
17
+ return bar.widgets.filter((widget) => widget.visible)
18
+ }
19
+
20
+ /**
21
+ * The single authority on how many columns a bar occupies. Both the `Bar`
22
+ * component and the terminal-size computation must call this — a mismatch
23
+ * silently corrupts PTY columns and mouse hit-testing.
24
+ */
25
+ export function getBarWidth(bar: BarState): number {
26
+ if (!bar.visible || visibleWidgets(bar).length === 0) return 0
27
+ return clampBarWidth(bar.width)
28
+ }
29
+
30
+ export function findWidgetBar(bars: BarsState, widgetId: string): BarSide | null {
31
+ if (bars.left.widgets.some((widget) => widget.id === widgetId)) return 'left'
32
+ if (bars.right.widgets.some((widget) => widget.id === widgetId)) return 'right'
33
+ return null
34
+ }
35
+
36
+ function totalGrow(widgets: BarWidget[]): number {
37
+ return widgets.reduce((sum, widget) => sum + widget.grow, 0)
38
+ }
39
+
40
+ /**
41
+ * Move the boundary between visible widgets `index` and `index + 1` by
42
+ * `deltaGrow`. Only the pair changes, so the bar's total grow is preserved and
43
+ * no renormalisation is ever needed — including when a widget is added.
44
+ */
45
+ export function shiftBoundary(bar: BarState, index: number, deltaGrow: number): BarWidget[] {
46
+ const visible = visibleWidgets(bar)
47
+ const above = visible[index]
48
+ const below = visible[index + 1]
49
+ if (!above || !below) return bar.widgets
50
+
51
+ const min = Math.max(1, Math.round(totalGrow(visible) * MIN_WIDGET_SHARE))
52
+ const pair = above.grow + below.grow
53
+ if (pair < min * 2) return bar.widgets
54
+
55
+ const nextAbove = Math.min(pair - min, Math.max(min, Math.round(above.grow + deltaGrow)))
56
+ if (nextAbove === above.grow) return bar.widgets
57
+
58
+ return bar.widgets.map((widget) => {
59
+ if (widget.id === above.id) return { ...widget, grow: nextAbove }
60
+ if (widget.id === below.id) return { ...widget, grow: pair - nextAbove }
61
+ return widget
62
+ })
63
+ }
64
+
65
+ /**
66
+ * Convert an absolute drag position (a 0..1 fraction of the bar's body) into
67
+ * the grow delta `shiftBoundary` expects for that boundary.
68
+ */
69
+ export function boundaryDeltaFromRatio(bar: BarState, index: number, ratio: number): number {
70
+ const visible = visibleWidgets(bar)
71
+ const target = visible[index]
72
+ if (!target) return 0
73
+ const above = visible.slice(0, index).reduce((sum, widget) => sum + widget.grow, 0)
74
+ return ratio * totalGrow(visible) - above - target.grow
75
+ }
@@ -1,15 +1,6 @@
1
1
  export const GIT_PANE_MIN_RATIO = 0.2
2
2
  export const GIT_PANE_MAX_RATIO = 0.8
3
- export const GIT_PANE_MIN_WIDTH = 20
4
- export const GIT_PANE_MAX_WIDTH = 80
5
3
 
6
4
  export function clampGitPaneRatio(value: number): number {
7
5
  return Math.max(GIT_PANE_MIN_RATIO, Math.min(GIT_PANE_MAX_RATIO, value))
8
6
  }
9
-
10
- export function getGitPaneWidthFromRatio(ratio: number): number {
11
- return Math.max(
12
- GIT_PANE_MIN_WIDTH,
13
- Math.min(GIT_PANE_MAX_WIDTH, Math.round(clampGitPaneRatio(ratio) * GIT_PANE_MAX_WIDTH))
14
- )
15
- }
@@ -0,0 +1,39 @@
1
+ import { useStore } from 'zustand'
2
+ import { createStore } from 'zustand/vanilla'
3
+
4
+ import type { PrStatusResult } from '../git/pr-status'
5
+
6
+ export interface PrStatusState {
7
+ result: PrStatusResult | null
8
+ /** True once a fetch failed but we are still showing the previous good result. */
9
+ stale: boolean
10
+ setResult: (result: PrStatusResult) => void
11
+ reset: () => void
12
+ }
13
+
14
+ export const prStatusStore = createStore<PrStatusState>((set) => ({
15
+ reset: () => set({ result: null, stale: false }),
16
+ result: null,
17
+ setResult: (result: PrStatusResult) =>
18
+ set((state) => {
19
+ // A transient `gh` failure shouldn't blank a PR we already resolved — keep
20
+ // the last good snapshot and mark it stale instead (same contract as
21
+ // ai-usage-store's setSnapshot).
22
+ if (result.kind === 'error' && state.result?.kind === 'ok') return { stale: true }
23
+ return { result, stale: false }
24
+ }),
25
+ stale: false,
26
+ }))
27
+
28
+ /**
29
+ * The PR state row occupies its band both while the first fetch is in flight
30
+ * and once it resolved to a PR — anything else (no PR, no gh, error) gives the
31
+ * row back. Shared so the header and the row itself can never disagree and
32
+ * shift the layout under the user.
33
+ */
34
+ export const selectPrRowVisible = (state: PrStatusState): boolean =>
35
+ state.result === null || state.result.kind === 'ok'
36
+
37
+ export function usePrStatusStore<T>(selector: (state: PrStatusState) => T): T {
38
+ return useStore(prStatusStore, selector)
39
+ }
@@ -105,58 +105,11 @@ function sameFiles(a: GitFileEntry[], b: GitFileEntry[]): boolean {
105
105
 
106
106
  export function reduceGitPanelState(state: AppState, action: AppAction): AppState | null {
107
107
  switch (action.type) {
108
- case 'toggle-git-pane': {
109
- const nextVisible = !state.gitPane.visible
110
- const sidebarMustShow = state.gitPane.mode === 'embedded' && nextVisible
111
- return {
112
- ...state,
113
- gitPane: { ...state.gitPane, visible: nextVisible },
114
- sidebar: sidebarMustShow ? { ...state.sidebar, visible: true } : state.sidebar,
115
- }
116
- }
117
- case 'resize-git-pane': {
118
- const target = state.gitPane.mode === 'pane' ? 'paneRatio' : 'embeddedRatio'
119
- const nextRatio = clampRatio(state.gitPane[target] + action.delta)
120
- if (nextRatio === state.gitPane[target]) return state
121
- return { ...state, gitPane: { ...state.gitPane, [target]: nextRatio } }
122
- }
123
- case 'set-git-pane-ratio': {
124
- const key = action.target === 'pane' ? 'paneRatio' : 'embeddedRatio'
125
- const nextRatio = clampRatio(action.ratio)
126
- if (nextRatio === state.gitPane[key]) return state
127
- return { ...state, gitPane: { ...state.gitPane, [key]: nextRatio } }
128
- }
129
108
  case 'resize-git-diff-pane': {
130
109
  const nextRatio = clampRatio(state.gitPane.diffModeRatio + action.delta)
131
110
  if (nextRatio === state.gitPane.diffModeRatio) return state
132
111
  return { ...state, gitPane: { ...state.gitPane, diffModeRatio: nextRatio } }
133
112
  }
134
- case 'set-git-pane-mode': {
135
- if (state.gitPane.mode === action.mode) return state
136
- const isEmbedded = action.mode === 'embedded'
137
- const isValidEmbedded =
138
- state.gitPane.position === 'top' || state.gitPane.position === 'bottom'
139
- const isValidPane = state.gitPane.position === 'left' || state.gitPane.position === 'right'
140
- let nextPosition: typeof state.gitPane.position
141
- if (isEmbedded) {
142
- nextPosition = isValidEmbedded ? state.gitPane.position : 'bottom'
143
- } else {
144
- nextPosition = isValidPane ? state.gitPane.position : 'left'
145
- }
146
- return {
147
- ...state,
148
- gitPane: { ...state.gitPane, mode: action.mode, position: nextPosition },
149
- }
150
- }
151
- case 'set-git-pane-position': {
152
- const validForMode =
153
- state.gitPane.mode === 'embedded'
154
- ? action.position === 'top' || action.position === 'bottom'
155
- : action.position === 'left' || action.position === 'right'
156
- if (!validForMode) return state
157
- if (state.gitPane.position === action.position) return state
158
- return { ...state, gitPane: { ...state.gitPane, position: action.position } }
159
- }
160
113
  case 'git-refresh-success': {
161
114
  const prev = state.gitPanel
162
115
  const next = action.payload
@@ -1,21 +1,91 @@
1
- import type { AppAction, AppState } from '../types'
1
+ import type { AppAction, AppState, BarSide, BarState, BarWidget } from '../types'
2
+
3
+ import {
4
+ boundaryDeltaFromRatio,
5
+ clampBarWidth,
6
+ findWidgetBar,
7
+ shiftBoundary,
8
+ visibleWidgets,
9
+ } from '../bars'
10
+
11
+ function withBar(state: AppState, side: BarSide, next: BarState): AppState {
12
+ if (next === state.bars[side]) return state
13
+ return { ...state, bars: { ...state.bars, [side]: next } }
14
+ }
15
+
16
+ function setBarWidth(state: AppState, side: BarSide, width: number): AppState {
17
+ const bar = state.bars[side]
18
+ const next = clampBarWidth(width)
19
+ if (next === bar.width) return state
20
+ return withBar(state, side, { ...bar, width: next })
21
+ }
22
+
23
+ /**
24
+ * Move `widgetId` to `side` at `index`. Covers both cross-bar moves and
25
+ * in-bar reordering — removing then re-inserting is the same operation.
26
+ */
27
+ function moveWidget(state: AppState, widgetId: string, side: BarSide, index: number): AppState {
28
+ const from = findWidgetBar(state.bars, widgetId)
29
+ if (from === null) return state
30
+ const widget = state.bars[from].widgets.find((w) => w.id === widgetId)
31
+ if (!widget) return state
32
+
33
+ const source = state.bars[from].widgets.filter((w) => w.id !== widgetId)
34
+ const target: BarWidget[] = from === side ? source : [...state.bars[side].widgets]
35
+ const at = Math.max(0, Math.min(target.length, index))
36
+ target.splice(at, 0, widget)
37
+
38
+ const bars = { ...state.bars }
39
+ bars[from] = { ...state.bars[from], widgets: from === side ? target : source }
40
+ // Showing a widget in a hidden bar would silently swallow it.
41
+ bars[side] = { ...bars[side], visible: bars[side].visible || widget.visible, widgets: target }
42
+ return { ...state, bars }
43
+ }
2
44
 
3
45
  export function reduceUIState(state: AppState, action: AppAction): AppState | null {
4
46
  switch (action.type) {
5
- case 'toggle-sidebar':
6
- return { ...state, sidebar: { ...state.sidebar, visible: !state.sidebar.visible } }
7
- case 'resize-sidebar': {
8
- const width = Math.min(
9
- state.sidebar.maxWidth,
10
- Math.max(state.sidebar.minWidth, state.sidebar.width + action.delta)
47
+ case 'toggle-bar': {
48
+ const bar = state.bars[action.side]
49
+ return withBar(state, action.side, { ...bar, visible: !bar.visible })
50
+ }
51
+ case 'resize-bar':
52
+ return setBarWidth(state, action.side, state.bars[action.side].width + action.delta)
53
+ case 'set-bar-width':
54
+ return setBarWidth(state, action.side, action.width)
55
+ case 'toggle-widget': {
56
+ const side = findWidgetBar(state.bars, action.widgetId)
57
+ if (side === null) return state
58
+ const bar = state.bars[side]
59
+ const widgets = bar.widgets.map((w) =>
60
+ w.id === action.widgetId ? { ...w, visible: !w.visible } : w
11
61
  )
12
- if (width === state.sidebar.width) return state
13
- return { ...state, sidebar: { ...state.sidebar, width } }
62
+ const revealed = widgets.some((w) => w.id === action.widgetId && w.visible)
63
+ return withBar(state, side, { ...bar, visible: bar.visible || revealed, widgets })
64
+ }
65
+ case 'move-widget':
66
+ return moveWidget(state, action.widgetId, action.side, action.index)
67
+ case 'set-bar-boundary': {
68
+ const bar = state.bars[action.side]
69
+ const delta = boundaryDeltaFromRatio(bar, action.index, action.ratio)
70
+ const widgets = shiftBoundary(bar, action.index, delta)
71
+ if (widgets === bar.widgets) return state
72
+ return withBar(state, action.side, { ...bar, widgets })
14
73
  }
15
- case 'set-sidebar-width': {
16
- const width = Math.min(state.sidebar.maxWidth, Math.max(state.sidebar.minWidth, action.width))
17
- if (width === state.sidebar.width) return state
18
- return { ...state, sidebar: { ...state.sidebar, width } }
74
+ case 'resize-widget': {
75
+ // Keyboard resize: grow/shrink this widget against its neighbour. The
76
+ // widget below owns the boundary above it, so the sign flips there.
77
+ const side = findWidgetBar(state.bars, action.widgetId)
78
+ if (side === null) return state
79
+ const bar = state.bars[side]
80
+ const visible = visibleWidgets(bar)
81
+ const at = visible.findIndex((w) => w.id === action.widgetId)
82
+ if (at === -1) return state
83
+ const total = visible.reduce((sum, w) => sum + w.grow, 0)
84
+ const index = at > 0 ? at - 1 : at
85
+ const deltaGrow = action.delta * total * (at > 0 ? -1 : 1)
86
+ const widgets = shiftBoundary(bar, index, deltaGrow)
87
+ if (widgets === bar.widgets) return state
88
+ return withBar(state, side, { ...bar, widgets })
19
89
  }
20
90
  case 'set-focus-mode':
21
91
  return { ...state, focusMode: action.focusMode }