@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,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-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
return { ...
|
|
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 '
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 }
|