@brimveyn/aimux 1.20.3 → 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/clipboard.ts +140 -4
- 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'),
|
|
@@ -1,13 +1,135 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
|
|
1
3
|
import { logDebug } from '../debug/input-log'
|
|
2
4
|
import { toast } from '../state/toast-store'
|
|
3
5
|
|
|
6
|
+
export interface ClipboardCandidate {
|
|
7
|
+
argv: string[]
|
|
8
|
+
// powershell's Get-Clipboard emits CRLF line endings and appends a trailing
|
|
9
|
+
// newline of its own; both have to be undone to get the copied text back.
|
|
10
|
+
normalizeWindowsOutput?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ClipboardPlatform {
|
|
14
|
+
env: Record<string, string | undefined>
|
|
15
|
+
isWsl: boolean
|
|
16
|
+
platform: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const POWERSHELL_PASTE: ClipboardCandidate = {
|
|
20
|
+
argv: ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard'],
|
|
21
|
+
normalizeWindowsOutput: true,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isSet(value: string | undefined): boolean {
|
|
25
|
+
return value !== undefined && value !== ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function detectClipboardPlatform(): ClipboardPlatform {
|
|
29
|
+
return {
|
|
30
|
+
env: process.env,
|
|
31
|
+
isWsl: detectWsl(),
|
|
32
|
+
platform: process.platform,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function detectWsl(): boolean {
|
|
37
|
+
if (process.platform !== 'linux') return false
|
|
38
|
+
if (isSet(process.env.WSL_DISTRO_NAME) || isSet(process.env.WSL_INTEROP)) return true
|
|
39
|
+
try {
|
|
40
|
+
return readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft')
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function copyCandidates({ env, isWsl, platform }: ClipboardPlatform): ClipboardCandidate[] {
|
|
47
|
+
if (platform === 'darwin') return [{ argv: ['pbcopy'] }]
|
|
48
|
+
if (platform === 'win32') return [{ argv: ['clip'] }]
|
|
49
|
+
|
|
50
|
+
const candidates: ClipboardCandidate[] = []
|
|
51
|
+
// On WSL the Windows clipboard is the one the user pastes from, and clip.exe
|
|
52
|
+
// always reaches it. The X/Wayland bridges only exist under WSLg.
|
|
53
|
+
if (isWsl) {
|
|
54
|
+
candidates.push({ argv: ['clip.exe'] }, { argv: ['/mnt/c/Windows/System32/clip.exe'] })
|
|
55
|
+
}
|
|
56
|
+
const wayland: ClipboardCandidate = { argv: ['wl-copy'] }
|
|
57
|
+
const xorg: ClipboardCandidate[] = [
|
|
58
|
+
{ argv: ['xclip', '-selection', 'clipboard'] },
|
|
59
|
+
{ argv: ['xsel', '--clipboard', '--input'] },
|
|
60
|
+
]
|
|
61
|
+
candidates.push(...(isSet(env.WAYLAND_DISPLAY) ? [wayland, ...xorg] : [...xorg, wayland]))
|
|
62
|
+
return candidates
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function pasteCandidates({ env, isWsl, platform }: ClipboardPlatform): ClipboardCandidate[] {
|
|
66
|
+
if (platform === 'darwin') return [{ argv: ['pbpaste'] }]
|
|
67
|
+
if (platform === 'win32') return [POWERSHELL_PASTE]
|
|
68
|
+
|
|
69
|
+
const candidates: ClipboardCandidate[] = []
|
|
70
|
+
if (isWsl) candidates.push(POWERSHELL_PASTE)
|
|
71
|
+
const wayland: ClipboardCandidate = { argv: ['wl-paste', '--no-newline'] }
|
|
72
|
+
const xorg: ClipboardCandidate[] = [
|
|
73
|
+
{ argv: ['xclip', '-selection', 'clipboard', '-o'] },
|
|
74
|
+
{ argv: ['xsel', '--clipboard', '--output'] },
|
|
75
|
+
]
|
|
76
|
+
candidates.push(...(isSet(env.WAYLAND_DISPLAY) ? [wayland, ...xorg] : [...xorg, wayland]))
|
|
77
|
+
return candidates
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveCandidate(candidates: ClipboardCandidate[]): ClipboardCandidate | null {
|
|
81
|
+
for (const candidate of candidates) {
|
|
82
|
+
const [bin, ...args] = candidate.argv
|
|
83
|
+
if (bin === undefined) continue
|
|
84
|
+
let resolved: string | null
|
|
85
|
+
if (bin.includes('/')) {
|
|
86
|
+
resolved = existsSync(bin) ? bin : null
|
|
87
|
+
} else {
|
|
88
|
+
resolved = Bun.which(bin)
|
|
89
|
+
}
|
|
90
|
+
if (resolved !== null) return { ...candidate, argv: [resolved, ...args] }
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let cachedCopy: ClipboardCandidate | null | undefined
|
|
96
|
+
let cachedPaste: ClipboardCandidate | null | undefined
|
|
97
|
+
|
|
98
|
+
function copyCommand(): ClipboardCandidate | null {
|
|
99
|
+
cachedCopy ??= resolveCandidate(copyCandidates(detectClipboardPlatform()))
|
|
100
|
+
return cachedCopy
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pasteCommand(): ClipboardCandidate | null {
|
|
104
|
+
cachedPaste ??= resolveCandidate(pasteCandidates(detectClipboardPlatform()))
|
|
105
|
+
return cachedPaste
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const MISSING_TOOL_MESSAGE =
|
|
109
|
+
process.platform === 'darwin'
|
|
110
|
+
? 'Copy failed: pbcopy not found'
|
|
111
|
+
: 'Copy failed: install xclip, wl-clipboard, or xsel'
|
|
112
|
+
|
|
4
113
|
export function copyToSystemClipboard(text: string): void {
|
|
114
|
+
const command = copyCommand()
|
|
115
|
+
if (!command) {
|
|
116
|
+
logDebug('platform.clipboard.noCopyCommand', { platform: process.platform })
|
|
117
|
+
toast.error(MISSING_TOOL_MESSAGE)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
5
120
|
try {
|
|
6
|
-
const proc = Bun.spawn(
|
|
121
|
+
const proc = Bun.spawn(command.argv, { stderr: 'pipe', stdin: 'pipe' })
|
|
7
122
|
void proc.stdin.write(text)
|
|
8
123
|
void proc.stdin.end()
|
|
124
|
+
void (async () => {
|
|
125
|
+
const code = await proc.exited
|
|
126
|
+
if (code === 0) return
|
|
127
|
+
logDebug('platform.clipboard.copyExit', { argv: command.argv, code })
|
|
128
|
+
toast.error('Copy failed')
|
|
129
|
+
})()
|
|
9
130
|
} catch (error) {
|
|
10
131
|
logDebug('platform.clipboard.copyError', {
|
|
132
|
+
argv: command.argv,
|
|
11
133
|
error: error instanceof Error ? error.message : String(error),
|
|
12
134
|
})
|
|
13
135
|
toast.error('Copy failed')
|
|
@@ -15,15 +137,29 @@ export function copyToSystemClipboard(text: string): void {
|
|
|
15
137
|
}
|
|
16
138
|
|
|
17
139
|
export async function readFromSystemClipboard(): Promise<string> {
|
|
140
|
+
const command = pasteCommand()
|
|
141
|
+
if (!command) {
|
|
142
|
+
logDebug('platform.clipboard.noPasteCommand', { platform: process.platform })
|
|
143
|
+
return ''
|
|
144
|
+
}
|
|
18
145
|
try {
|
|
19
|
-
const proc = Bun.spawn(
|
|
146
|
+
const proc = Bun.spawn(command.argv, { stderr: 'pipe', stdout: 'pipe' })
|
|
20
147
|
const text = await new Response(proc.stdout).text()
|
|
21
|
-
await proc.exited
|
|
22
|
-
|
|
148
|
+
const code = await proc.exited
|
|
149
|
+
if (code !== 0) {
|
|
150
|
+
logDebug('platform.clipboard.readExit', { argv: command.argv, code })
|
|
151
|
+
return ''
|
|
152
|
+
}
|
|
153
|
+
return command.normalizeWindowsOutput === true ? normalizeWindowsClipboardText(text) : text
|
|
23
154
|
} catch (error) {
|
|
24
155
|
logDebug('platform.clipboard.readError', {
|
|
156
|
+
argv: command.argv,
|
|
25
157
|
error: error instanceof Error ? error.message : String(error),
|
|
26
158
|
})
|
|
27
159
|
return ''
|
|
28
160
|
}
|
|
29
161
|
}
|
|
162
|
+
|
|
163
|
+
export function normalizeWindowsClipboardText(text: string): string {
|
|
164
|
+
return text.replaceAll('\r\n', '\n').replace(/\n$/, '')
|
|
165
|
+
}
|
|
@@ -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
|
+
}
|