@brimveyn/aimux 1.22.4 → 1.22.6
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/workspace-actions.ts +15 -6
- 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/worktree-files.ts +51 -0
- package/src/git/worktree.ts +1 -1
- package/src/platform/play-sound.ts +15 -12
- package/src/platform/worktree-paths.ts +24 -7
- package/src/settings/flags.ts +11 -1
- package/src/settings/sections/git.ts +14 -0
- package/src/settings/sections/notifications.ts +3 -3
- package/src/state/pr-status-store.ts +15 -11
- package/src/ui/components/git/pane/pr-state-row.tsx +3 -12
- package/src/ui/components/layout/terminal-pane.tsx +0 -30
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
pruneGitWorktrees,
|
|
17
17
|
removeGitWorktree,
|
|
18
18
|
} from '../git/worktree'
|
|
19
|
+
import { copyWorktreeFiles } from '../git/worktree-files'
|
|
19
20
|
import { createPrefixedId } from '../platform/id'
|
|
20
21
|
import {
|
|
21
22
|
assertSafeAimuxWorktreePath,
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
makeWorktreePath,
|
|
24
25
|
sanitizePathSegment,
|
|
25
26
|
} from '../platform/worktree-paths'
|
|
26
|
-
import { shouldRefreshBase } from '../settings/flags'
|
|
27
|
+
import { shouldRefreshBase, worktreeCopyPatterns } from '../settings/flags'
|
|
27
28
|
import { saveProjectCatalog } from '../state/project-catalog'
|
|
28
29
|
import { pruneSnapshotOfWorkspace } from '../state/project-persistence'
|
|
29
30
|
import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
|
|
@@ -120,7 +121,11 @@ export async function createAimuxTempWorkspace(
|
|
|
120
121
|
const branchName =
|
|
121
122
|
trimmedBranch != null && trimmedBranch !== ''
|
|
122
123
|
? trimmedBranch
|
|
123
|
-
:
|
|
124
|
+
: // Placeholder only: the model replaces it with a conventional
|
|
125
|
+
// `<type>/<subject>` branch seconds later. Kept in the `aimux/`
|
|
126
|
+
// namespace and lowercased so what survives a failed generation still
|
|
127
|
+
// reads as a throwaway branch rather than a shouted prompt.
|
|
128
|
+
`aimux/${sanitizePathSegment(workspaceName, 40).toLowerCase()}-${Date.now().toString(36)}`
|
|
124
129
|
const targetPath = makeWorktreePath({ repoRoot, workspaceId, workspaceName })
|
|
125
130
|
|
|
126
131
|
const existingWorkspace = (await listGitWorktrees(repoRoot)).find(
|
|
@@ -147,6 +152,7 @@ export async function createAimuxTempWorkspace(
|
|
|
147
152
|
repoPath: repoRoot,
|
|
148
153
|
targetPath,
|
|
149
154
|
})
|
|
155
|
+
await copyWorktreeFiles(repoRoot, targetPath, worktreeCopyPatterns())
|
|
150
156
|
const now = new Date().toISOString()
|
|
151
157
|
|
|
152
158
|
const workspace: WorkspaceRecord = {
|
|
@@ -209,12 +215,15 @@ export async function runDeleteWorkspace(
|
|
|
209
215
|
|
|
210
216
|
const repoPath = resolveWorkspaceGitDir(project, workspace)
|
|
211
217
|
const isAimuxTemp = workspace.source === 'aimux-temp' && workspace.createdByAimux
|
|
212
|
-
// Drop the throwaway
|
|
213
|
-
// workspaces don't accumulate in the repo
|
|
214
|
-
//
|
|
218
|
+
// Drop the throwaway branch alongside the workspace so deleted temp
|
|
219
|
+
// workspaces don't accumulate in the repo. Scoped by the record rather than by
|
|
220
|
+
// a name prefix: auto-naming renames the branch to a conventional
|
|
221
|
+
// `<type>/<subject>`, indistinguishable from a hand-made one, and
|
|
222
|
+
// `aimux-temp` + `createdByAimux` is exactly "aimux created this branch".
|
|
223
|
+
// Best-effort; git refuses while another worktree still has it checked out.
|
|
215
224
|
const cleanupAimuxBranch = async (): Promise<void> => {
|
|
216
225
|
const branch = workspace.branch
|
|
217
|
-
if (isAimuxTemp && branch != null && branch !== ''
|
|
226
|
+
if (isAimuxTemp && branch != null && branch !== '') {
|
|
218
227
|
await deleteGitBranch(repoPath, branch)
|
|
219
228
|
}
|
|
220
229
|
}
|
|
@@ -5,24 +5,22 @@
|
|
|
5
5
|
// the generated one replaces it in place when it arrives. The heuristic result
|
|
6
6
|
// is not a stopgap to be embarrassed about — when no headless CLI is installed
|
|
7
7
|
// it is the final name, and it is still derived from what the user asked for.
|
|
8
|
+
//
|
|
9
|
+
// The branch is not a slug of that name. A tab title belongs to the user and
|
|
10
|
+
// speaks their language; a branch name is read by git, by reviewers and by CI,
|
|
11
|
+
// so the model is asked for it separately, in English and under a
|
|
12
|
+
// conventional-commit type. A branch is only renamed when the model returns one
|
|
13
|
+
// in that shape — the placeholder is a better outcome than a bad convention.
|
|
8
14
|
|
|
9
15
|
import type { AssistantId, WorkspaceRecord } from '../state/types'
|
|
10
16
|
|
|
11
17
|
import { heuristicTitle } from '../auto-rename/heuristic-title'
|
|
12
|
-
import {
|
|
18
|
+
import { generateWorkspaceNaming, type TitleSpawnFn } from '../auto-rename/title-runner'
|
|
13
19
|
import { renameGitBranch } from '../git/worktree'
|
|
14
|
-
import { sanitizePathSegment } from '../platform/worktree-paths'
|
|
15
20
|
|
|
16
21
|
/** Model naming is best-effort background work; it must never outlive the app. */
|
|
17
22
|
const NAMING_TIMEOUT_MS = 30_000
|
|
18
23
|
|
|
19
|
-
export const BRANCH_PREFIX = 'aimux/'
|
|
20
|
-
const BRANCH_SLUG_MAX = 40
|
|
21
|
-
|
|
22
|
-
export function branchNameFor(name: string): string {
|
|
23
|
-
return `${BRANCH_PREFIX}${sanitizePathSegment(name, BRANCH_SLUG_MAX).toLowerCase()}`
|
|
24
|
-
}
|
|
25
|
-
|
|
26
24
|
/**
|
|
27
25
|
* The name a workspace carries until the model answers. Undefined when the
|
|
28
26
|
* prompt yields nothing usable, which lets `createAimuxTempWorkspace` fall back
|
|
@@ -60,7 +58,7 @@ export async function renameWorkspaceFromPrompt(
|
|
|
60
58
|
target: WorkspaceNamingTarget,
|
|
61
59
|
deps: WorkspaceNamingDeps
|
|
62
60
|
): Promise<void> {
|
|
63
|
-
const result = await
|
|
61
|
+
const result = await generateWorkspaceNaming({
|
|
64
62
|
firstPrompt: target.prompt,
|
|
65
63
|
provider: target.provider,
|
|
66
64
|
signal: deps.signal ?? new AbortController().signal,
|
|
@@ -70,21 +68,24 @@ export async function renameWorkspaceFromPrompt(
|
|
|
70
68
|
if (result.status !== 'ok') return
|
|
71
69
|
|
|
72
70
|
const { workspace } = target
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const branch = branchNameFor(result.title)
|
|
71
|
+
const { branch } = result
|
|
76
72
|
const rename = deps.renameBranch ?? renameGitBranch
|
|
77
73
|
// Renamed from inside the workspace, not the main checkout: the branch is
|
|
78
74
|
// that worktree's current branch, which is the form git always accepts.
|
|
79
75
|
// Renaming first means a refusal (the name is taken) leaves the record
|
|
80
76
|
// pointing at the branch that actually exists.
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
const renamedBranch =
|
|
78
|
+
branch != null &&
|
|
79
|
+
workspace.branch != null &&
|
|
80
|
+
workspace.branch !== '' &&
|
|
81
|
+
branch !== workspace.branch &&
|
|
82
|
+
(await rename(workspace.path, workspace.branch, branch))
|
|
83
|
+
? branch
|
|
84
|
+
: undefined
|
|
85
85
|
|
|
86
|
+
if (result.title === workspace.name && renamedBranch == null) return
|
|
86
87
|
deps.applyName(target.projectId, workspace.id, {
|
|
87
88
|
name: result.title,
|
|
88
|
-
...(
|
|
89
|
+
...(renamedBranch == null ? null : { branch: renamedBranch }),
|
|
89
90
|
})
|
|
90
91
|
}
|
package/src/app.tsx
CHANGED
|
@@ -82,12 +82,18 @@ export function App({
|
|
|
82
82
|
resolvedConfig: ResolvedConfig
|
|
83
83
|
userConfig: AimuxUserConfig
|
|
84
84
|
}) {
|
|
85
|
-
// Publish the
|
|
86
|
-
// actions (which live outside React) can read
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
// Publish the config-file baseline into the runtime singletons before any
|
|
86
|
+
// children render, so actions (which live outside React) can read them
|
|
87
|
+
// synchronously. Lazy initializer, not the render body: this component
|
|
88
|
+
// re-renders on every dispatch, and re-running these would overwrite what the
|
|
89
|
+
// settings screen has since applied on top of the baseline (`hydrateSettings`).
|
|
90
|
+
useState(() => {
|
|
91
|
+
setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
|
|
92
|
+
setMultiRepoConfig(resolvedConfig.multiRepo)
|
|
93
|
+
setExternalEditorConfig(resolvedConfig.externalEditor)
|
|
94
|
+
setStatusBarSeparator(resolvedConfig.statusBar?.separator)
|
|
95
|
+
return null
|
|
96
|
+
})
|
|
91
97
|
|
|
92
98
|
const keymapHandlers = useMemo(
|
|
93
99
|
() => {
|
|
@@ -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')) {
|
|
@@ -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
|
@@ -206,7 +206,7 @@ export async function removeGitWorktree({
|
|
|
206
206
|
force: boolean
|
|
207
207
|
}): Promise<void> {
|
|
208
208
|
if (!isInsideAimuxWorktreeRoot(targetPath)) {
|
|
209
|
-
throw new Error(`refusing to delete worktree outside Aimux
|
|
209
|
+
throw new Error(`refusing to delete worktree outside Aimux worktree root: ${targetPath}`)
|
|
210
210
|
}
|
|
211
211
|
const result = force
|
|
212
212
|
? await $`git -C ${repoPath} worktree remove --force ${targetPath}`.quiet().nothrow()
|
|
@@ -141,6 +141,7 @@ function findPlayer(platform: string): string | null {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
let lastPlayedAt = 0
|
|
144
|
+
let playing: { kill: () => void } | null = null
|
|
144
145
|
|
|
145
146
|
/**
|
|
146
147
|
* Whether enough time has passed since the last sound. Separate from the play
|
|
@@ -151,16 +152,17 @@ export function shouldPlayNow(now: number, previous: number): boolean {
|
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
/**
|
|
154
|
-
* Play a sound file. Returns
|
|
155
|
-
* what the settings screen's "Test
|
|
155
|
+
* Play a sound file. Returns false only when nothing could play at all — a
|
|
156
|
+
* missing player or a failed spawn — which is what the settings screen's "Test
|
|
157
|
+
* sound" row reports. A throttled call is not a failure.
|
|
158
|
+
*
|
|
159
|
+
* The throttle covers every caller, the test row included: held down, its key
|
|
160
|
+
* repeats tens of times a second, and one live player per press is enough to
|
|
161
|
+
* take CoreAudio down with it.
|
|
156
162
|
*/
|
|
157
|
-
export function playSoundFile(
|
|
158
|
-
path: string,
|
|
159
|
-
options?: { ignoreThrottle?: boolean; volume?: number }
|
|
160
|
-
): boolean {
|
|
163
|
+
export function playSoundFile(path: string, options?: { volume?: number }): boolean {
|
|
161
164
|
const now = Date.now()
|
|
162
|
-
|
|
163
|
-
if (throttled && !shouldPlayNow(now, lastPlayedAt)) return false
|
|
165
|
+
if (!shouldPlayNow(now, lastPlayedAt)) return true
|
|
164
166
|
const platform = process.platform
|
|
165
167
|
const bin = findPlayer(platform)
|
|
166
168
|
if (bin == null) {
|
|
@@ -169,10 +171,11 @@ export function playSoundFile(
|
|
|
169
171
|
}
|
|
170
172
|
const argv = soundPlayerArgv(platform, bin, path, options?.volume ?? DEFAULT_VOLUME)
|
|
171
173
|
try {
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
// Cut the previous one rather than layer on top of it. Players come and go
|
|
175
|
+
// on their own, so killing an already-exited one has to be harmless.
|
|
176
|
+
playing?.kill()
|
|
177
|
+
playing = Bun.spawn(argv, { stderr: 'ignore', stdin: 'ignore', stdout: 'ignore' })
|
|
178
|
+
lastPlayedAt = now
|
|
176
179
|
return true
|
|
177
180
|
} catch (error) {
|
|
178
181
|
logDebug('platform.playSound.error', {
|
|
@@ -2,16 +2,32 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { lstat, mkdir, realpath, rmdir } from 'node:fs/promises'
|
|
3
3
|
import { join, resolve } from 'node:path'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Worktrees hold uncommitted work, so they live in the XDG data dir, not /tmp:
|
|
6
|
+
// a reboot clears /tmp on macOS and on most Linux distros, and took the work
|
|
7
|
+
// with it. The old root stays recognized (never generated) so worktrees created
|
|
8
|
+
// before this change are still classified and deleted as aimux-managed.
|
|
9
|
+
const LEGACY_WORKTREE_ROOT = '/tmp/aimux-wt'
|
|
6
10
|
const MAX_SLUG_LENGTH = 24
|
|
7
11
|
|
|
12
|
+
function defaultWorktreeRoot(): string {
|
|
13
|
+
const xdgData = process.env.XDG_DATA_HOME
|
|
14
|
+
const base =
|
|
15
|
+
xdgData != null && xdgData !== '' ? xdgData : join(process.env.HOME ?? '.', '.local', 'share')
|
|
16
|
+
return join(base, 'aimux', 'worktrees')
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
export function getAimuxWorktreeRoot(): string {
|
|
9
20
|
const root = process.env.AIMUX_WORKTREE_ROOT
|
|
10
|
-
return root != null && root !== '' ? root :
|
|
21
|
+
return root != null && root !== '' ? root : defaultWorktreeRoot()
|
|
22
|
+
}
|
|
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, '')
|
|
11
27
|
}
|
|
12
28
|
|
|
13
29
|
export function sanitizePathSegment(input: string, maxLength = MAX_SLUG_LENGTH): string {
|
|
14
|
-
const sanitized = input
|
|
30
|
+
const sanitized = foldDiacritics(input)
|
|
15
31
|
.trim()
|
|
16
32
|
.replaceAll(/[^A-Za-z0-9._-]+/g, '-')
|
|
17
33
|
.replaceAll(/\.\.+/g, '-')
|
|
@@ -42,9 +58,10 @@ export function makeWorktreePath({
|
|
|
42
58
|
|
|
43
59
|
export function isInsideAimuxWorktreeRoot(path: string): boolean {
|
|
44
60
|
const normalizeTmp = (value: string) => value.replace(/^\/private\/tmp(?=\/|$)/, '/tmp')
|
|
45
|
-
const root = `${normalizeTmp(resolve(getAimuxWorktreeRoot()))}/`
|
|
46
61
|
const target = `${normalizeTmp(resolve(path))}/`
|
|
47
|
-
return
|
|
62
|
+
return [getAimuxWorktreeRoot(), LEGACY_WORKTREE_ROOT].some((root) =>
|
|
63
|
+
target.startsWith(`${normalizeTmp(resolve(root))}/`)
|
|
64
|
+
)
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
export async function ensureAimuxWorktreeRoot(): Promise<string> {
|
|
@@ -80,13 +97,13 @@ export async function pruneEmptyWorktreeParent(worktreePath: string): Promise<vo
|
|
|
80
97
|
export async function assertSafeAimuxWorktreePath(path: string): Promise<void> {
|
|
81
98
|
const root = await ensureAimuxWorktreeRoot()
|
|
82
99
|
if (!isInsideAimuxWorktreeRoot(path)) {
|
|
83
|
-
throw new Error(`refusing worktree path outside Aimux
|
|
100
|
+
throw new Error(`refusing worktree path outside Aimux worktree root: ${path}`)
|
|
84
101
|
}
|
|
85
102
|
// Create the repo-scoped parent (<root>/r-<hash>) before resolving it: git
|
|
86
103
|
// worktree add does not create intermediate dirs, and realpath() would throw
|
|
87
104
|
// ENOENT on the first worktree for a repo. mkdir(recursive) leaves an
|
|
88
105
|
// existing symlink in place, so the realpath check below still catches an
|
|
89
|
-
// escape out of the
|
|
106
|
+
// escape out of the worktree root.
|
|
90
107
|
const parent = resolve(path, '..')
|
|
91
108
|
await mkdir(parent, { recursive: true })
|
|
92
109
|
const realRoot = await realpath(root)
|
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.',
|
|
@@ -45,10 +45,10 @@ function selectedVolume(): number {
|
|
|
45
45
|
* user has set the row to `off`, which is what makes it safe to call on every
|
|
46
46
|
* status edge.
|
|
47
47
|
*/
|
|
48
|
-
export function playNotificationSound(
|
|
48
|
+
export function playNotificationSound(): boolean {
|
|
49
49
|
const path = resolveSoundPath(selectedSoundId())
|
|
50
50
|
if (path == null) return false
|
|
51
|
-
return playSoundFile(path, {
|
|
51
|
+
return playSoundFile(path, { volume: selectedVolume() })
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export const NOTIFICATIONS_SECTION: SettingSection = {
|
|
@@ -89,7 +89,7 @@ export const NOTIFICATIONS_SECTION: SettingSection = {
|
|
|
89
89
|
toast.info('Notification sound is off')
|
|
90
90
|
return
|
|
91
91
|
}
|
|
92
|
-
if (!playNotificationSound(
|
|
92
|
+
if (!playNotificationSound()) {
|
|
93
93
|
toast.error('Could not play that sound — no audio player found')
|
|
94
94
|
}
|
|
95
95
|
},
|
|
@@ -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)
|
|
@@ -44,18 +44,9 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
|
|
|
44
44
|
})()
|
|
45
45
|
}, [projectPath])
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
if (result === null)
|
|
50
|
-
return (
|
|
51
|
-
<box backgroundColor={bg} paddingLeft={1} paddingRight={1}>
|
|
52
|
-
<text selectable={false} bg={bg} wrapMode="none">
|
|
53
|
-
{' '}
|
|
54
|
-
</text>
|
|
55
|
-
</box>
|
|
56
|
-
)
|
|
57
|
-
}
|
|
58
|
-
if (result.kind !== 'ok' || pr === null) return null
|
|
47
|
+
// Nothing known yet (or nothing to show): stay out of the layout entirely and
|
|
48
|
+
// appear only once a fetch reports a PR.
|
|
49
|
+
if (result?.kind !== 'ok' || pr === null) return null
|
|
59
50
|
const status = prActionState(pr, result.checks)
|
|
60
51
|
let label = status.label
|
|
61
52
|
if (confirming) label = 'Merge this PR?'
|
|
@@ -44,32 +44,6 @@ interface TerminalPaneProps {
|
|
|
44
44
|
junctionEdges?: JunctionEdges
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function getTitle(
|
|
48
|
-
tab: TabSession | undefined,
|
|
49
|
-
isActive: boolean,
|
|
50
|
-
focusMode: TerminalPaneProps['focusMode'],
|
|
51
|
-
emptyContext: { projectName: string; workspaceName: string }
|
|
52
|
-
): string {
|
|
53
|
-
if (!tab) {
|
|
54
|
-
const { projectName, workspaceName } = emptyContext
|
|
55
|
-
if (projectName === '' && workspaceName === '') return 'No active project'
|
|
56
|
-
if (workspaceName === '' || workspaceName === projectName) {
|
|
57
|
-
return `${projectName} · no tabs`
|
|
58
|
-
}
|
|
59
|
-
return `${projectName} / ${workspaceName} · no tabs`
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (isActive && focusMode === 'terminal-input') {
|
|
63
|
-
return `● ${tab.title} · ${tab.status}`
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (isActive) {
|
|
67
|
-
return `▸ ${tab.title} · ${tab.status}`
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return `${tab.title} · ${tab.status}`
|
|
71
|
-
}
|
|
72
|
-
|
|
73
47
|
function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
|
|
74
48
|
const t = getCurrentTheme()
|
|
75
49
|
if (!isActive) return t.border
|
|
@@ -526,10 +500,6 @@ export function TerminalPane({
|
|
|
526
500
|
<ContextMenuBox
|
|
527
501
|
border
|
|
528
502
|
borderColor={getBorderColor(paneIsActive, focusMode)}
|
|
529
|
-
title={getTitle(tab, paneIsActive, focusMode, {
|
|
530
|
-
projectName: emptyProjectName,
|
|
531
|
-
workspaceName: emptyWorkspaceName,
|
|
532
|
-
})}
|
|
533
503
|
padding={0}
|
|
534
504
|
flexDirection="column"
|
|
535
505
|
flexGrow={1}
|