@brimveyn/aimux 1.22.5 → 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/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 +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
|
+
}
|
|
@@ -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)
|
|
@@ -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}
|