@brimveyn/aimux 1.22.5 → 1.22.7
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 +1 -1
- package/src/app-runtime/side-effects.ts +15 -132
- package/src/app-runtime/tab-actions.ts +36 -0
- package/src/app-runtime/workspace-actions.ts +34 -12
- package/src/app-runtime/workspace-launch.ts +135 -0
- package/src/app-runtime/workspace-naming.ts +19 -18
- package/src/app.tsx +12 -6
- package/src/auto-rename/title-runner.ts +105 -15
- package/src/cli/commands/workspace/create-core.ts +3 -1
- package/src/git/pr-status-poller.ts +5 -3
- package/src/git/pr-status.ts +28 -2
- package/src/git/workspace-divergence-poller.ts +21 -14
- package/src/git/worktree-files.ts +51 -0
- package/src/git/worktree.ts +9 -0
- package/src/platform/worktree-paths.ts +6 -1
- package/src/settings/flags.ts +11 -1
- package/src/settings/sections/git.ts +14 -0
- package/src/state/pr-status-store.ts +15 -11
- package/src/ui/components/git/pane/pr-state-row.tsx +62 -18
- package/src/ui/components/layout/sidebar/project-list.tsx +127 -100
- package/src/ui/components/layout/terminal-pane.tsx +0 -30
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +12 -29
- package/src/ui/project-ordering.ts +18 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildHeadlessInvocation, type HeadlessInvocation } from '../auto-commit/headless-commands'
|
|
2
|
+
import { foldDiacritics } from '../platform/worktree-paths'
|
|
2
3
|
import { clampTitle } from './title-format'
|
|
3
4
|
|
|
4
5
|
export type TitleSpawnFn = (
|
|
@@ -16,6 +17,33 @@ export type TitleResult =
|
|
|
16
17
|
| { status: 'failed' }
|
|
17
18
|
| { status: 'unavailable' }
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A workspace needs two different names from one request: a tab title in the
|
|
22
|
+
* user's own language, and a branch name — which is read by git, by reviewers
|
|
23
|
+
* and by CI, so it follows the repo's conventions instead: English, a
|
|
24
|
+
* conventional-commit type, kebab-case. `branch` is null when the model gave
|
|
25
|
+
* nothing that qualifies; the caller keeps the branch it already has.
|
|
26
|
+
*/
|
|
27
|
+
export type NamingResult =
|
|
28
|
+
| { status: 'ok'; title: string; branch: string | null }
|
|
29
|
+
| { status: 'failed' }
|
|
30
|
+
| { status: 'unavailable' }
|
|
31
|
+
|
|
32
|
+
/** Conventional-commit types a generated branch may use; anything else is refused. */
|
|
33
|
+
const BRANCH_TYPES = new Set([
|
|
34
|
+
'build',
|
|
35
|
+
'chore',
|
|
36
|
+
'ci',
|
|
37
|
+
'docs',
|
|
38
|
+
'feat',
|
|
39
|
+
'fix',
|
|
40
|
+
'perf',
|
|
41
|
+
'refactor',
|
|
42
|
+
'style',
|
|
43
|
+
'test',
|
|
44
|
+
])
|
|
45
|
+
const BRANCH_SUBJECT_WORDS = 5
|
|
46
|
+
|
|
19
47
|
export function buildTitlePrompt(firstPrompt: string): string {
|
|
20
48
|
return [
|
|
21
49
|
'Create a concise tab title for the user request below.',
|
|
@@ -26,15 +54,56 @@ export function buildTitlePrompt(firstPrompt: string): string {
|
|
|
26
54
|
].join('\n')
|
|
27
55
|
}
|
|
28
56
|
|
|
29
|
-
export function
|
|
30
|
-
|
|
57
|
+
export function buildWorkspaceNamingPrompt(firstPrompt: string): string {
|
|
58
|
+
return [
|
|
59
|
+
'Name a workspace for the user request below.',
|
|
60
|
+
'Return exactly two lines and nothing else.',
|
|
61
|
+
'Line 1 — a tab title: 2 to 6 words, at most 48 characters, in the same language as the request.',
|
|
62
|
+
'Line 2 — a git branch named <type>/<subject>, always in English whatever the language of the request.',
|
|
63
|
+
'<type> is one of: feat, fix, refactor, perf, docs, test, chore, ci, style, build.',
|
|
64
|
+
'<subject> is 2 to 5 lowercase words joined by hyphens, naming what changes rather than restating the request.',
|
|
65
|
+
'Example line 2: fix/scroll-drift-on-resize',
|
|
66
|
+
'No quotes, no labels, no numbering, no markdown, no ending punctuation.',
|
|
67
|
+
'',
|
|
68
|
+
firstPrompt.slice(0, 8_000),
|
|
69
|
+
].join('\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function nonEmptyLines(raw: string): string[] {
|
|
73
|
+
return raw
|
|
31
74
|
.split(/\r?\n/u)
|
|
32
75
|
.map((line) => line.trim())
|
|
33
|
-
.
|
|
34
|
-
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Strip the wrappers a model reaches for even when told not to: labels, list markers, quotes. */
|
|
80
|
+
function unwrapLine(line: string, label: RegExp): string {
|
|
81
|
+
return line
|
|
82
|
+
.replace(label, '')
|
|
83
|
+
.replace(/^(?:\d+[.)]|[-*])\s*/u, '')
|
|
84
|
+
.replaceAll(/^["'`“”‘’]+|["'`“”‘’]+$/gu, '')
|
|
85
|
+
}
|
|
35
86
|
|
|
36
|
-
|
|
37
|
-
|
|
87
|
+
export function sanitizeGeneratedTitle(raw: string): string | null {
|
|
88
|
+
const first = nonEmptyLines(raw)[0]
|
|
89
|
+
if (first == null) return null
|
|
90
|
+
return clampTitle(unwrapLine(first, /^TITLE\s*:\s*/iu))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function sanitizeGeneratedBranch(raw: string): string | null {
|
|
94
|
+
const line = unwrapLine(raw.trim(), /^BRANCH\s*:\s*/iu)
|
|
95
|
+
const slash = line.indexOf('/')
|
|
96
|
+
if (slash < 0) return null
|
|
97
|
+
const type = line.slice(0, slash).trim().toLowerCase()
|
|
98
|
+
if (!BRANCH_TYPES.has(type)) return null
|
|
99
|
+
// Whole words only: a mid-word cut ("...-bran") names nothing.
|
|
100
|
+
const subject = foldDiacritics(line.slice(slash + 1))
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.split(/[^a-z0-9]+/u)
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.slice(0, BRANCH_SUBJECT_WORDS)
|
|
105
|
+
.join('-')
|
|
106
|
+
return subject === '' ? null : `${type}/${subject}`
|
|
38
107
|
}
|
|
39
108
|
|
|
40
109
|
function executableOnPath(executable: string): boolean {
|
|
@@ -46,7 +115,7 @@ function executableOnPath(executable: string): boolean {
|
|
|
46
115
|
}
|
|
47
116
|
}
|
|
48
117
|
|
|
49
|
-
export
|
|
118
|
+
export interface NamingOptions {
|
|
50
119
|
provider: string
|
|
51
120
|
model?: string
|
|
52
121
|
firstPrompt: string
|
|
@@ -54,12 +123,13 @@ export async function generateTabTitle(options: {
|
|
|
54
123
|
signal: AbortSignal
|
|
55
124
|
spawn?: TitleSpawnFn
|
|
56
125
|
isExecutableAvailable?: (executable: string) => boolean
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runNamingModel(
|
|
129
|
+
options: NamingOptions,
|
|
130
|
+
prompt: string
|
|
131
|
+
): Promise<{ status: 'ok'; stdout: string } | { status: 'failed' } | { status: 'unavailable' }> {
|
|
132
|
+
const invocation = buildHeadlessInvocation(options.provider, prompt, options.model)
|
|
63
133
|
if (!invocation) return { status: 'unavailable' }
|
|
64
134
|
|
|
65
135
|
// A caller-supplied spawn does not go through PATH, so only probe it for the
|
|
@@ -72,13 +142,33 @@ export async function generateTabTitle(options: {
|
|
|
72
142
|
try {
|
|
73
143
|
const result = await (options.spawn ?? defaultSpawn)(invocation, signal)
|
|
74
144
|
if (!result || result.exitCode !== 0 || signal.aborted) return { status: 'failed' }
|
|
75
|
-
|
|
76
|
-
return title == null ? { status: 'failed' } : { status: 'ok', title }
|
|
145
|
+
return { status: 'ok', stdout: result.stdout }
|
|
77
146
|
} catch {
|
|
78
147
|
return { status: 'failed' }
|
|
79
148
|
}
|
|
80
149
|
}
|
|
81
150
|
|
|
151
|
+
export async function generateTabTitle(options: NamingOptions): Promise<TitleResult> {
|
|
152
|
+
const run = await runNamingModel(options, buildTitlePrompt(options.firstPrompt))
|
|
153
|
+
if (run.status !== 'ok') return run
|
|
154
|
+
const title = sanitizeGeneratedTitle(run.stdout)
|
|
155
|
+
return title == null ? { status: 'failed' } : { status: 'ok', title }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One model call for both names — a workspace must not wait on two. */
|
|
159
|
+
export async function generateWorkspaceNaming(options: NamingOptions): Promise<NamingResult> {
|
|
160
|
+
const run = await runNamingModel(options, buildWorkspaceNamingPrompt(options.firstPrompt))
|
|
161
|
+
if (run.status !== 'ok') return run
|
|
162
|
+
const [titleLine, branchLine] = nonEmptyLines(run.stdout)
|
|
163
|
+
const title = titleLine == null ? null : sanitizeGeneratedTitle(titleLine)
|
|
164
|
+
if (title == null) return { status: 'failed' }
|
|
165
|
+
return {
|
|
166
|
+
branch: branchLine == null ? null : sanitizeGeneratedBranch(branchLine),
|
|
167
|
+
status: 'ok',
|
|
168
|
+
title,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
82
172
|
async function defaultSpawn(
|
|
83
173
|
invocation: HeadlessInvocation,
|
|
84
174
|
signal: AbortSignal
|
|
@@ -2,6 +2,7 @@ import type { ProjectRecord, WorkspaceRecord } from '../../../state/types'
|
|
|
2
2
|
import type { DaemonClient } from '../../client/daemon-client'
|
|
3
3
|
|
|
4
4
|
import { createGitWorktree, removeGitWorktree, resolveGitRef } from '../../../git/worktree'
|
|
5
|
+
import { copyWorktreeFiles } from '../../../git/worktree-files'
|
|
5
6
|
import { IPC_CAPABILITY_WORKSPACE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
|
|
6
7
|
import { createPrefixedId } from '../../../platform/id'
|
|
7
8
|
import {
|
|
@@ -10,7 +11,7 @@ import {
|
|
|
10
11
|
makeWorktreePath,
|
|
11
12
|
pruneEmptyWorktreeParent,
|
|
12
13
|
} from '../../../platform/worktree-paths'
|
|
13
|
-
import { shouldRefreshBase } from '../../../settings/flags'
|
|
14
|
+
import { shouldRefreshBase, worktreeCopyPatterns } from '../../../settings/flags'
|
|
14
15
|
|
|
15
16
|
export interface CreateWorkspaceParams {
|
|
16
17
|
/** Base ref for the branch (callers default to 'HEAD'). */
|
|
@@ -84,6 +85,7 @@ export async function createProjectWorkspace(
|
|
|
84
85
|
await pruneEmptyWorktreeParent(targetPath)
|
|
85
86
|
throw error
|
|
86
87
|
}
|
|
88
|
+
await copyWorktreeFiles(primary.repoRoot, targetPath, worktreeCopyPatterns())
|
|
87
89
|
|
|
88
90
|
const now = new Date().toISOString()
|
|
89
91
|
const record: WorkspaceRecord = {
|
|
@@ -15,14 +15,16 @@ interface Options {
|
|
|
15
15
|
|
|
16
16
|
/** One-shot refetch, for when an action we took just invalidated the state. */
|
|
17
17
|
export async function refreshPrStatus(projectPath: string): Promise<void> {
|
|
18
|
-
prStatusStore.getState().setResult(await collectPrStatus(projectPath))
|
|
18
|
+
prStatusStore.getState().setResult(projectPath, await collectPrStatus(projectPath))
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function usePrStatusPolling({ enabled, projectPath }: Options): void {
|
|
22
22
|
useEffect(() => {
|
|
23
23
|
if (!enabled || !(projectPath != null && projectPath !== '')) return
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// Show what we last knew about this path while the fetch runs in the
|
|
26
|
+
// background, rather than blanking the row on every workspace switch.
|
|
27
|
+
prStatusStore.getState().selectPath(projectPath)
|
|
26
28
|
|
|
27
29
|
let cancelled = false
|
|
28
30
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
@@ -36,7 +38,7 @@ export function usePrStatusPolling({ enabled, projectPath }: Options): void {
|
|
|
36
38
|
const tick = async () => {
|
|
37
39
|
const result = await collectPrStatus(projectPath)
|
|
38
40
|
if (cancelled) return
|
|
39
|
-
prStatusStore.getState().setResult(result)
|
|
41
|
+
prStatusStore.getState().setResult(projectPath, result)
|
|
40
42
|
if (result.kind === 'error') {
|
|
41
43
|
delay = Math.min(delay * 2, MAX_INTERVAL_MS)
|
|
42
44
|
} else if (result.kind === 'ok' && result.checks.some((c) => c.state === 'pending')) {
|
package/src/git/pr-status.ts
CHANGED
|
@@ -145,7 +145,7 @@ export function parsePrView(raw: unknown): PrStatusResult {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
export type PrAction = 'merge' | null
|
|
148
|
+
export type PrAction = 'cleanup' | 'merge' | null
|
|
149
149
|
|
|
150
150
|
export interface PrActionState {
|
|
151
151
|
label: string
|
|
@@ -160,7 +160,11 @@ export interface PrActionState {
|
|
|
160
160
|
* offer an action for still gets an honest label rather than a dead button.
|
|
161
161
|
*/
|
|
162
162
|
export function prActionState(pr: PrSummary, checks: PrCheck[]): PrActionState {
|
|
163
|
-
|
|
163
|
+
// Merged is the one terminal state with something left to do: the workspace
|
|
164
|
+
// that carried the branch is now dead weight. The row offers the removal
|
|
165
|
+
// right where the merge happened; whether it's actually removable (not the
|
|
166
|
+
// primary workspace) is the caller's call, not the PR's.
|
|
167
|
+
if (pr.state === 'MERGED') return { action: 'cleanup', label: 'Merged', tone: 'ok' }
|
|
164
168
|
if (pr.state === 'CLOSED') return { action: null, label: 'Closed', tone: 'blocked' }
|
|
165
169
|
if (pr.isDraft) return { action: null, label: 'Draft', tone: 'neutral' }
|
|
166
170
|
if (pr.mergeable === 'CONFLICTING') {
|
|
@@ -182,6 +186,28 @@ export function prActionState(pr: PrSummary, checks: PrCheck[]): PrActionState {
|
|
|
182
186
|
return { action: null, label: 'Checking…', tone: 'neutral' }
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
export type PrCleanupKind = 'branch' | 'worktree' | null
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* What "clean up" means for a merged PR, which depends on where its branch
|
|
193
|
+
* lives. A linked workspace is disposable, so it goes. The repo checkout is not
|
|
194
|
+
* — the equivalent is leaving the merged branch for the one it landed on, which
|
|
195
|
+
* the PR names, so a PR into `develop` doesn't drop you on `main`.
|
|
196
|
+
*
|
|
197
|
+
* `gh pr view` resolves the PR from the checked-out branch, so a PR on screen
|
|
198
|
+
* means we're on its head — but a base already equal to it has nowhere to go.
|
|
199
|
+
*/
|
|
200
|
+
export function prCleanupKind(
|
|
201
|
+
action: PrAction,
|
|
202
|
+
pr: Pick<PrSummary, 'base' | 'head'>,
|
|
203
|
+
workspaceIsRemovable: boolean
|
|
204
|
+
): PrCleanupKind {
|
|
205
|
+
if (action !== 'cleanup') return null
|
|
206
|
+
if (workspaceIsRemovable) return 'worktree'
|
|
207
|
+
if (pr.base === '' || pr.base === pr.head) return null
|
|
208
|
+
return 'branch'
|
|
209
|
+
}
|
|
210
|
+
|
|
185
211
|
export interface ClampedBody {
|
|
186
212
|
text: string
|
|
187
213
|
truncated: boolean
|
|
@@ -9,22 +9,24 @@ import { getBranchDivergence, getWorkspaceDiffStat } from './divergence'
|
|
|
9
9
|
const INTERVAL_MS = 4000
|
|
10
10
|
|
|
11
11
|
// Polls per-workspace base divergence for the current project while enabled and
|
|
12
|
-
// dispatches it into workspaceDivergence.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// dispatches it into workspaceDivergence. aimux-created workspaces are measured
|
|
13
|
+
// against the ref they forked from; the primary and externally-discovered ones
|
|
14
|
+
// never forked, so they fall back to their own upstream — for a root checkout on
|
|
15
|
+
// main that reads as "unpushed commits + dirty work". A branch with no upstream
|
|
16
|
+
// makes git fail, which the poller already renders as nothing.
|
|
15
17
|
export function useWorkspaceDivergencePolling(enabled: boolean): void {
|
|
16
|
-
const currentProjectId = useAppStore((s) => s.currentProjectId)
|
|
17
18
|
const projects = useAppStore((s) => s.projects)
|
|
18
19
|
|
|
19
20
|
useEffect(() => {
|
|
20
21
|
if (!enabled) return
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
// Every project, not just the current one: each dispatch replaces the whole
|
|
23
|
+
// map, so polling one project's workspaces blanks the stats of every other
|
|
24
|
+
// project's rows — which the sidebar shows all of at once.
|
|
25
|
+
// ponytail: one unbounded fan-out per tick, two `git` spawns per workspace.
|
|
26
|
+
// Batch or stagger if a machine with many projects feels it.
|
|
27
|
+
const targets = projects
|
|
28
|
+
.flatMap((project) => project.workspaces ?? [])
|
|
29
|
+
.filter((w) => w.branch != null && w.branch !== '')
|
|
28
30
|
if (targets.length === 0) return
|
|
29
31
|
|
|
30
32
|
let cancelled = false
|
|
@@ -33,9 +35,14 @@ export function useWorkspaceDivergencePolling(enabled: boolean): void {
|
|
|
33
35
|
const tick = async () => {
|
|
34
36
|
const entries = await Promise.all(
|
|
35
37
|
targets.map(async (workspace) => {
|
|
36
|
-
const base = workspace.baseRef
|
|
37
38
|
const branch = workspace.branch
|
|
38
|
-
if (
|
|
39
|
+
if (branch == null) return null
|
|
40
|
+
// `<branch>@{upstream}` rather than a bare `@{upstream}`: the latter
|
|
41
|
+
// resolves against the repo root's HEAD, which is not this workspace.
|
|
42
|
+
const base =
|
|
43
|
+
workspace.baseRef != null && workspace.baseRef !== ''
|
|
44
|
+
? workspace.baseRef
|
|
45
|
+
: `${branch}@{upstream}`
|
|
39
46
|
// Commits come from the repo root (comparing two refs); lines come
|
|
40
47
|
// from the workspace itself, so uncommitted work is counted too.
|
|
41
48
|
const [divergence, stat] = await Promise.all([
|
|
@@ -61,5 +68,5 @@ export function useWorkspaceDivergencePolling(enabled: boolean): void {
|
|
|
61
68
|
cancelled = true
|
|
62
69
|
if (timer != null) clearTimeout(timer)
|
|
63
70
|
}
|
|
64
|
-
}, [enabled,
|
|
71
|
+
}, [enabled, projects])
|
|
65
72
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Glob } from 'bun'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { cp, mkdir } from 'node:fs/promises'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { logDebug } from '../debug/input-log'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Seed a fresh worktree with the untracked local files it needs to run.
|
|
10
|
+
*
|
|
11
|
+
* A worktree checkout holds tracked files and nothing else, so every ignored
|
|
12
|
+
* local file — `.env` above all — is missing the moment the workspace opens.
|
|
13
|
+
* The setup script can recreate some of them from a template; it cannot invent
|
|
14
|
+
* the secrets, which is why they are copied from the main checkout instead.
|
|
15
|
+
*
|
|
16
|
+
* ponytail: the patterns are matched against the whole checkout, gitignore and
|
|
17
|
+
* all — a recursive pattern walks `node_modules` too. Feeding the scan the ignore
|
|
18
|
+
* rules is the upgrade path if that ever costs anything noticeable.
|
|
19
|
+
*
|
|
20
|
+
* Best-effort by design: a pattern that matches nothing, or a file that cannot
|
|
21
|
+
* be read, must not take the workspace down with it. Nothing is ever
|
|
22
|
+
* overwritten — a match that already exists in the new worktree is tracked
|
|
23
|
+
* content, and clobbering it would dirty the workspace before its first commit.
|
|
24
|
+
*/
|
|
25
|
+
export async function copyWorktreeFiles(
|
|
26
|
+
repoPath: string,
|
|
27
|
+
targetPath: string,
|
|
28
|
+
patterns: readonly string[]
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
for (const pattern of patterns) {
|
|
31
|
+
try {
|
|
32
|
+
const matches = new Glob(pattern).scan({ cwd: repoPath, dot: true, onlyFiles: true })
|
|
33
|
+
for await (const relative of matches) {
|
|
34
|
+
const to = join(targetPath, relative)
|
|
35
|
+
if (existsSync(to)) continue
|
|
36
|
+
await mkdir(dirname(to), { recursive: true })
|
|
37
|
+
await cp(join(repoPath, relative), to)
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// A workspace without its .env still opens; a workspace that failed to be
|
|
41
|
+
// created does not. Logged rather than swallowed outright: a file that
|
|
42
|
+
// silently never arrives is otherwise indistinguishable from one the
|
|
43
|
+
// pattern never matched.
|
|
44
|
+
logDebug('worktree.seed.error', {
|
|
45
|
+
error: error instanceof Error ? error.message : String(error),
|
|
46
|
+
pattern,
|
|
47
|
+
repoPath,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/git/worktree.ts
CHANGED
|
@@ -181,6 +181,15 @@ export async function pruneGitWorktrees(repoPath: string): Promise<void> {
|
|
|
181
181
|
await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// Switch a checkout to an existing branch. Throws git's own message rather than
|
|
185
|
+
// a paraphrase: "local changes would be overwritten" is the one thing the user
|
|
186
|
+
// needs to read, and no wording of ours beats it.
|
|
187
|
+
export async function checkoutBranch(cwd: string, branch: string): Promise<void> {
|
|
188
|
+
const result = await $`git -C ${cwd} checkout ${branch}`.quiet().nothrow()
|
|
189
|
+
if (result.exitCode === 0) return
|
|
190
|
+
throw new Error(result.stderr.toString().trim() || `failed to check out ${branch}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
184
193
|
// Drop every `aimux/` branch left behind by deleted temp worktrees. git refuses
|
|
185
194
|
// to delete branches still checked out in a live worktree, so this only removes
|
|
186
195
|
// true orphans. Returns the number of branches removed.
|
|
@@ -21,8 +21,13 @@ export function getAimuxWorktreeRoot(): string {
|
|
|
21
21
|
return root != null && root !== '' ? root : defaultWorktreeRoot()
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** `améliorer` → `ameliorer`, so accents slug as letters instead of separators. */
|
|
25
|
+
export function foldDiacritics(input: string): string {
|
|
26
|
+
return input.normalize('NFD').replaceAll(/\p{Diacritic}/gu, '')
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
export function sanitizePathSegment(input: string, maxLength = MAX_SLUG_LENGTH): string {
|
|
25
|
-
const sanitized = input
|
|
30
|
+
const sanitized = foldDiacritics(input)
|
|
26
31
|
.trim()
|
|
27
32
|
.replaceAll(/[^A-Za-z0-9._-]+/g, '-')
|
|
28
33
|
.replaceAll(/\.\.+/g, '-')
|
package/src/settings/flags.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { loadConfig } from '../config'
|
|
2
|
-
import { FETCH_BASE } from './sections/git'
|
|
2
|
+
import { COPY_FILES, COPY_FILES_DEFAULT, FETCH_BASE } from './sections/git'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Settings read outside the screen, off the file rather than the store.
|
|
@@ -13,3 +13,13 @@ import { FETCH_BASE } from './sections/git'
|
|
|
13
13
|
export function shouldRefreshBase(): boolean {
|
|
14
14
|
return loadConfig().settings?.[FETCH_BASE] !== false
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
/** Untracked files a new workspace is seeded with, as globs relative to the repo. */
|
|
18
|
+
export function worktreeCopyPatterns(): string[] {
|
|
19
|
+
const value = loadConfig().settings?.[COPY_FILES]
|
|
20
|
+
// Only an explicit empty string disables it; an untouched setting gets `.env`.
|
|
21
|
+
return String(value ?? COPY_FILES_DEFAULT)
|
|
22
|
+
.split(',')
|
|
23
|
+
.map((pattern) => pattern.trim())
|
|
24
|
+
.filter((pattern) => pattern !== '')
|
|
25
|
+
}
|
|
@@ -6,6 +6,8 @@ import type { SettingSection, SettingValue } from '../types'
|
|
|
6
6
|
import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
|
|
7
7
|
|
|
8
8
|
export const FETCH_BASE = 'git.fetchBase'
|
|
9
|
+
export const COPY_FILES = 'git.worktreeCopyFiles'
|
|
10
|
+
export const COPY_FILES_DEFAULT = '.env'
|
|
9
11
|
|
|
10
12
|
function isFileListMode(value: SettingValue): value is GitFileListMode {
|
|
11
13
|
return value === 'tree' || value === 'flat'
|
|
@@ -93,6 +95,18 @@ export const GIT_SECTION: SettingSection = {
|
|
|
93
95
|
label: 'Refresh the base branch',
|
|
94
96
|
storage: 'settings',
|
|
95
97
|
},
|
|
98
|
+
{
|
|
99
|
+
// No `apply`, same reason as the row above: `worktreeCopyPatterns` reads
|
|
100
|
+
// this off the file so `aimux workspace create` honours it too.
|
|
101
|
+
description:
|
|
102
|
+
'Untracked files copied into each new workspace. Globs from the repo root: **/.env for nested ones.',
|
|
103
|
+
fallback: COPY_FILES_DEFAULT,
|
|
104
|
+
id: COPY_FILES,
|
|
105
|
+
kind: 'text',
|
|
106
|
+
label: 'Seed new workspaces with',
|
|
107
|
+
placeholder: 'nothing',
|
|
108
|
+
storage: 'settings',
|
|
109
|
+
},
|
|
96
110
|
{
|
|
97
111
|
apply: (value) => setMultiRepoConfig({ ...getMultiRepoConfig(), enabled: value === true }),
|
|
98
112
|
description: 'Aggregate the status of git repos nested under the project.',
|
|
@@ -5,34 +5,38 @@ import type { PrStatusResult } from '../git/pr-status'
|
|
|
5
5
|
|
|
6
6
|
export interface PrStatusState {
|
|
7
7
|
result: PrStatusResult | null
|
|
8
|
+
/** Last result seen per project path, so a workspace switch shows its own
|
|
9
|
+
* previous state instead of blanking while the background fetch runs. */
|
|
10
|
+
byPath: Record<string, PrStatusResult>
|
|
8
11
|
/** True once a fetch failed but we are still showing the previous good result. */
|
|
9
12
|
stale: boolean
|
|
10
|
-
setResult: (result: PrStatusResult) => void
|
|
11
|
-
|
|
13
|
+
setResult: (path: string, result: PrStatusResult) => void
|
|
14
|
+
/** Point the row at a project path, showing whatever we last knew about it. */
|
|
15
|
+
selectPath: (path: string) => void
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export const prStatusStore = createStore<PrStatusState>((set) => ({
|
|
15
|
-
|
|
19
|
+
byPath: {},
|
|
16
20
|
result: null,
|
|
17
|
-
|
|
21
|
+
selectPath: (path: string) =>
|
|
22
|
+
set((state) => ({ result: state.byPath[path] ?? null, stale: false })),
|
|
23
|
+
setResult: (path: string, result: PrStatusResult) =>
|
|
18
24
|
set((state) => {
|
|
19
25
|
// A transient `gh` failure shouldn't blank a PR we already resolved — keep
|
|
20
26
|
// the last good snapshot and mark it stale instead (same contract as
|
|
21
27
|
// ai-usage-store's setSnapshot).
|
|
22
28
|
if (result.kind === 'error' && state.result?.kind === 'ok') return { stale: true }
|
|
23
|
-
return { result, stale: false }
|
|
29
|
+
return { byPath: { ...state.byPath, [path]: result }, result, stale: false }
|
|
24
30
|
}),
|
|
25
31
|
stale: false,
|
|
26
32
|
}))
|
|
27
33
|
|
|
28
34
|
/**
|
|
29
|
-
* The PR state row occupies its band
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* shift the layout under the user.
|
|
35
|
+
* The PR state row only occupies its band once we know there is a PR — an
|
|
36
|
+
* unknown or resolved-to-nothing state gives the row back. Shared so the header
|
|
37
|
+
* and the row itself can never disagree and shift the layout under the user.
|
|
33
38
|
*/
|
|
34
|
-
export const selectPrRowVisible = (state: PrStatusState): boolean =>
|
|
35
|
-
state.result === null || state.result.kind === 'ok'
|
|
39
|
+
export const selectPrRowVisible = (state: PrStatusState): boolean => state.result?.kind === 'ok'
|
|
36
40
|
|
|
37
41
|
export function usePrStatusStore<T>(selector: (state: PrStatusState) => T): T {
|
|
38
42
|
return useStore(prStatusStore, selector)
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { memo, useCallback, useState } from 'react'
|
|
2
2
|
|
|
3
|
+
import { enqueueGitOp } from '../../../../git/command-queue'
|
|
3
4
|
import { approveAndMergePr } from '../../../../git/pr-merge'
|
|
4
|
-
import { type PrActionState, prActionState } from '../../../../git/pr-status'
|
|
5
|
+
import { type PrActionState, prActionState, prCleanupKind } from '../../../../git/pr-status'
|
|
5
6
|
import { refreshPrStatus } from '../../../../git/pr-status-poller'
|
|
7
|
+
import { checkoutBranch } from '../../../../git/worktree'
|
|
6
8
|
import { openUrl } from '../../../../platform/open-url'
|
|
9
|
+
import { useAppStore } from '../../../../state/app-store'
|
|
10
|
+
import { runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
7
11
|
import { usePrStatusStore } from '../../../../state/pr-status-store'
|
|
12
|
+
import { getActiveWorkspace } from '../../../../state/project-workspaces'
|
|
8
13
|
import { toast } from '../../../../state/toast-store'
|
|
9
14
|
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
10
15
|
import { type ResolvedTuiTheme, useTheme, useTransparent } from '../../../theme'
|
|
@@ -22,18 +27,62 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
|
|
|
22
27
|
const transparent = useTransparent()
|
|
23
28
|
const bg = transparent ? undefined : t.backgroundElement
|
|
24
29
|
const result = usePrStatusStore((s) => s.result)
|
|
30
|
+
const currentProjectId = useAppStore((s) => s.currentProjectId)
|
|
31
|
+
const projects = useAppStore((s) => s.projects)
|
|
25
32
|
const [confirming, setConfirming] = useState(false)
|
|
26
33
|
const [merging, setMerging] = useState(false)
|
|
27
34
|
const spinner = useBusySpinner(merging)
|
|
28
35
|
|
|
29
36
|
const pr = result?.kind === 'ok' ? result.pr : null
|
|
37
|
+
const status = result?.kind === 'ok' ? prActionState(result.pr, result.checks) : null
|
|
30
38
|
const prUrl = pr?.url ?? ''
|
|
31
39
|
|
|
40
|
+
// The PR is polled against the active workspace's path, so its branch is this
|
|
41
|
+
// workspace's branch — which is what makes offering the removal here honest.
|
|
42
|
+
const project = projects.find((entry) => entry.id === currentProjectId)
|
|
43
|
+
const workspace = getActiveWorkspace(project)
|
|
44
|
+
const projectId = project?.id ?? null
|
|
45
|
+
const removableWorkspaceId =
|
|
46
|
+
workspace !== undefined && workspace.source !== 'primary' ? workspace.id : null
|
|
47
|
+
|
|
48
|
+
const base = pr?.base ?? ''
|
|
49
|
+
const cleanupKind =
|
|
50
|
+
pr === null ? null : prCleanupKind(status?.action ?? null, pr, removableWorkspaceId !== null)
|
|
51
|
+
|
|
32
52
|
const openPr = useCallback(() => openUrl(prUrl), [prUrl])
|
|
33
53
|
const askConfirm = useCallback(() => setConfirming(true), [])
|
|
34
54
|
const cancel = useCallback(() => setConfirming(false), [])
|
|
35
55
|
const confirm = useCallback(() => {
|
|
36
56
|
setConfirming(false)
|
|
57
|
+
if (cleanupKind === 'worktree') {
|
|
58
|
+
if (projectId === null || removableWorkspaceId === null) return
|
|
59
|
+
// closeTabs (not force) mirrors the sidebar's "Remove workspace": the
|
|
60
|
+
// workspace's tabs are disposed, but a dirty worktree still re-prompts for
|
|
61
|
+
// an explicit force-delete instead of silently discarding work.
|
|
62
|
+
runSideEffectGlobal({
|
|
63
|
+
closeTabs: true,
|
|
64
|
+
projectId,
|
|
65
|
+
type: 'delete-workspace',
|
|
66
|
+
workspaceId: removableWorkspaceId,
|
|
67
|
+
})
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (cleanupKind === 'branch') {
|
|
71
|
+
void (async () => {
|
|
72
|
+
try {
|
|
73
|
+
// Queued like every other mutating git op: the pollers read this same
|
|
74
|
+
// checkout, and a checkout mid-status is how you get a torn panel.
|
|
75
|
+
await enqueueGitOp(async () => checkoutBranch(projectPath, base))
|
|
76
|
+
} catch (error) {
|
|
77
|
+
toast.error(error instanceof Error ? error.message : String(error))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
// The branch poller would catch up within its tick; refreshing here
|
|
81
|
+
// retires the row now instead of leaving a merged PR sitting on screen.
|
|
82
|
+
await refreshPrStatus(projectPath)
|
|
83
|
+
})()
|
|
84
|
+
return
|
|
85
|
+
}
|
|
37
86
|
setMerging(true)
|
|
38
87
|
void (async () => {
|
|
39
88
|
const merged = await approveAndMergePr(projectPath)
|
|
@@ -42,23 +91,18 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
|
|
|
42
91
|
else toast.error(merged.message)
|
|
43
92
|
await refreshPrStatus(projectPath)
|
|
44
93
|
})()
|
|
45
|
-
}, [projectPath])
|
|
94
|
+
}, [base, cleanupKind, projectId, projectPath, removableWorkspaceId])
|
|
46
95
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
if (result === null)
|
|
50
|
-
|
|
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)
|
|
96
|
+
// Nothing known yet (or nothing to show): stay out of the layout entirely and
|
|
97
|
+
// appear only once a fetch reports a PR.
|
|
98
|
+
if (result?.kind !== 'ok' || pr === null || status === null) return null
|
|
99
|
+
const showAction = cleanupKind !== null || status.action === 'merge'
|
|
60
100
|
let label = status.label
|
|
61
|
-
if (confirming)
|
|
101
|
+
if (confirming) {
|
|
102
|
+
if (cleanupKind === 'worktree') label = 'Remove this worktree?'
|
|
103
|
+
else if (cleanupKind === 'branch') label = `Switch to ${base}?`
|
|
104
|
+
else label = 'Merge this PR?'
|
|
105
|
+
}
|
|
62
106
|
if (merging) label = `${spinner} merging…`
|
|
63
107
|
|
|
64
108
|
return (
|
|
@@ -91,10 +135,10 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
|
|
|
91
135
|
</text>
|
|
92
136
|
</box>
|
|
93
137
|
) : null}
|
|
94
|
-
{
|
|
138
|
+
{showAction && !confirming && !merging ? (
|
|
95
139
|
<box flexShrink={0}>
|
|
96
140
|
<text selectable={false} fg={t.primary} bg={bg} wrapMode="none" onMouseDown={askConfirm}>
|
|
97
|
-
<strong>Merge</strong>
|
|
141
|
+
<strong>{cleanupKind === null ? 'Merge' : 'Clean up'}</strong>
|
|
98
142
|
</text>
|
|
99
143
|
</box>
|
|
100
144
|
) : null}
|