@brimveyn/aimux 1.22.3 → 1.22.5
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 +35 -8
- package/src/app-runtime/workspace-actions.ts +32 -2
- package/src/cli/commands/workspace/create-core.ts +6 -2
- package/src/git/worktree.ts +53 -5
- package/src/input/modes/types.ts +1 -0
- package/src/platform/play-sound.ts +15 -12
- package/src/platform/worktree-paths.ts +18 -6
- package/src/settings/flags.ts +15 -0
- package/src/settings/sections/git.ts +12 -0
- package/src/settings/sections/index.ts +2 -0
- package/src/settings/sections/notifications.ts +3 -3
- package/src/settings/sections/workspace.ts +44 -0
- package/src/state/types.ts +8 -0
- package/src/state/validation.ts +2 -1
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ import { loadConfig, saveConfig } from '../config'
|
|
|
8
8
|
import { logInputDebug } from '../debug/input-log'
|
|
9
9
|
import { enqueueGitOp } from '../git/command-queue'
|
|
10
10
|
import { countDirtyFiles } from '../git/move-workspace'
|
|
11
|
-
import { getDefaultBranch, listLocalBranches } from '../git/worktree'
|
|
11
|
+
import { getCurrentBranch, getDefaultBranch, listLocalBranches } from '../git/worktree'
|
|
12
12
|
import { assistantAcceptsPromptArg } from '../pty/command-registry'
|
|
13
13
|
import { allLeafIds, getGroupIdForTab } from '../state/layout-tree'
|
|
14
14
|
import { saveCurrentProject } from '../state/project-save'
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
isForceableWorkspaceDeleteError,
|
|
73
73
|
runDeleteWorkspace,
|
|
74
74
|
runMoveWorkspace,
|
|
75
|
+
setProjectDefaultBaseRef,
|
|
75
76
|
} from './workspace-actions'
|
|
76
77
|
import { placeholderWorkspaceName, renameWorkspaceFromPrompt } from './workspace-naming'
|
|
77
78
|
|
|
@@ -207,6 +208,12 @@ function applyThemeEffect(
|
|
|
207
208
|
}
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
/** The given branch first, the rest in the order they came. */
|
|
212
|
+
function hoistBranch(branches: string[], first: string | undefined): string[] {
|
|
213
|
+
if (first == null || !branches.includes(first)) return branches
|
|
214
|
+
return [first, ...branches.filter((branch) => branch !== first)]
|
|
215
|
+
}
|
|
216
|
+
|
|
210
217
|
/**
|
|
211
218
|
* Setup runs concurrently with the agent by design, so say so rather than
|
|
212
219
|
* letting the agent run tests against a half-installed tree and draw the wrong
|
|
@@ -359,12 +366,21 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
359
366
|
const project = state.projects.find((entry) => entry.id === state.currentProjectId)
|
|
360
367
|
const sourcePath = getActiveWorkspace(project)?.path ?? getActiveWorkspacePath(project)
|
|
361
368
|
if (!(sourcePath != null && sourcePath !== '')) return
|
|
362
|
-
const [branches, defaultBranch] = await Promise.all([
|
|
369
|
+
const [branches, defaultBranch, currentBranch] = await Promise.all([
|
|
363
370
|
listLocalBranches(sourcePath),
|
|
364
371
|
getDefaultBranch(sourcePath),
|
|
372
|
+
getCurrentBranch(sourcePath),
|
|
365
373
|
])
|
|
366
374
|
if (ctx.getState().modal.type !== 'create-workspace') return
|
|
367
|
-
ctx.dispatch({
|
|
375
|
+
ctx.dispatch({
|
|
376
|
+
// Stacking on the branch you are already on is the other common base,
|
|
377
|
+
// and committer date buries it once anyone else pushes.
|
|
378
|
+
branches: hoistBranch(branches, currentBranch),
|
|
379
|
+
// The project's convention wins over what the repo declares: a gitflow
|
|
380
|
+
// repo still says `main` while everyone branches off `develop`.
|
|
381
|
+
defaultBranch: project?.defaultBaseRef ?? defaultBranch,
|
|
382
|
+
type: 'set-create-workspace-base-branches',
|
|
383
|
+
})
|
|
368
384
|
})()
|
|
369
385
|
return
|
|
370
386
|
}
|
|
@@ -678,6 +694,10 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
678
694
|
handleConfigureSetupScriptEffect(ctx, effect.projectId)
|
|
679
695
|
return
|
|
680
696
|
}
|
|
697
|
+
case 'set-project-default-base-ref': {
|
|
698
|
+
setProjectDefaultBaseRef(ctx, effect.projectId, effect.baseRef)
|
|
699
|
+
return
|
|
700
|
+
}
|
|
681
701
|
case 'ask-agent-for-setup-script': {
|
|
682
702
|
handleAskAgentForSetupScriptEffect(ctx)
|
|
683
703
|
return
|
|
@@ -729,11 +749,18 @@ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
|
|
|
729
749
|
void ctx.backend.destroy(true)
|
|
730
750
|
ctx.renderer.destroy()
|
|
731
751
|
process.stdout.write(`\nUpdating aimux to ${latestVersion}...\n`)
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
752
|
+
// `bun update -g` is a no-op when the global entry pins an exact version
|
|
753
|
+
// (`"@brimveyn/aimux": "1.22.2"`), which is what `install -g pkg@version`
|
|
754
|
+
// writes — the update "succeeded", the version never moved, and the modal
|
|
755
|
+
// came back every launch. Install the resolved version explicitly instead.
|
|
756
|
+
const proc = Bun.spawn(
|
|
757
|
+
['bun', 'install', '-g', `@brimveyn/aimux@${latestVersion}`, '@brimveyn/aimux-config@latest'],
|
|
758
|
+
{
|
|
759
|
+
stderr: 'inherit',
|
|
760
|
+
stdin: 'inherit',
|
|
761
|
+
stdout: 'inherit',
|
|
762
|
+
}
|
|
763
|
+
)
|
|
737
764
|
void (async () => {
|
|
738
765
|
const code = await proc.exited
|
|
739
766
|
if (code === 0) {
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
makeWorktreePath,
|
|
24
24
|
sanitizePathSegment,
|
|
25
25
|
} from '../platform/worktree-paths'
|
|
26
|
+
import { shouldRefreshBase } from '../settings/flags'
|
|
26
27
|
import { saveProjectCatalog } from '../state/project-catalog'
|
|
27
28
|
import { pruneSnapshotOfWorkspace } from '../state/project-persistence'
|
|
28
29
|
import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
|
|
@@ -64,6 +65,27 @@ export function handleSwitchWorkspace(
|
|
|
64
65
|
ctx.dispatch({ projects, type: 'set-projects' })
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Set (or clear, when blank) the branch this project's new workspaces fork from.
|
|
70
|
+
* Here rather than with the project actions: the value is workspace policy that
|
|
71
|
+
* happens to be stored on the project, and this is where the record-update
|
|
72
|
+
* helper and the rest of that policy already live.
|
|
73
|
+
*/
|
|
74
|
+
export function setProjectDefaultBaseRef(
|
|
75
|
+
ctx: SideEffectContext,
|
|
76
|
+
projectId: string,
|
|
77
|
+
baseRef: string
|
|
78
|
+
): void {
|
|
79
|
+
const trimmed = baseRef.trim()
|
|
80
|
+
const projects = replaceProject(ctx, projectId, (entry) => ({
|
|
81
|
+
...entry,
|
|
82
|
+
defaultBaseRef: trimmed === '' ? undefined : trimmed,
|
|
83
|
+
updatedAt: new Date().toISOString(),
|
|
84
|
+
}))
|
|
85
|
+
saveProjectCatalog(projects)
|
|
86
|
+
ctx.dispatch({ projects, type: 'set-projects' })
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
function normalizeBranchName(branch: string | undefined): string | undefined {
|
|
68
90
|
return branch?.replace(/^refs\/heads\//, '').trim()
|
|
69
91
|
}
|
|
@@ -116,11 +138,19 @@ export async function createAimuxTempWorkspace(
|
|
|
116
138
|
|
|
117
139
|
await mkdir(dirname(targetPath), { recursive: true })
|
|
118
140
|
await assertSafeAimuxWorktreePath(targetPath)
|
|
119
|
-
|
|
141
|
+
// What it forked from, not what was asked for: the two differ whenever the
|
|
142
|
+
// base was refreshed from origin, and the record is what "based on" reads.
|
|
143
|
+
const forkRef = await createGitWorktree({
|
|
144
|
+
baseRef,
|
|
145
|
+
branchName,
|
|
146
|
+
refreshBase: shouldRefreshBase(),
|
|
147
|
+
repoPath: repoRoot,
|
|
148
|
+
targetPath,
|
|
149
|
+
})
|
|
120
150
|
const now = new Date().toISOString()
|
|
121
151
|
|
|
122
152
|
const workspace: WorkspaceRecord = {
|
|
123
|
-
baseRef,
|
|
153
|
+
baseRef: forkRef,
|
|
124
154
|
branch: branchName,
|
|
125
155
|
commitSha: await getHeadSha(targetPath),
|
|
126
156
|
createdAt: now,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
makeWorktreePath,
|
|
11
11
|
pruneEmptyWorktreeParent,
|
|
12
12
|
} from '../../../platform/worktree-paths'
|
|
13
|
+
import { shouldRefreshBase } from '../../../settings/flags'
|
|
13
14
|
|
|
14
15
|
export interface CreateWorkspaceParams {
|
|
15
16
|
/** Base ref for the branch (callers default to 'HEAD'). */
|
|
@@ -68,10 +69,12 @@ export async function createProjectWorkspace(
|
|
|
68
69
|
await ensureAimuxWorktreeRoot()
|
|
69
70
|
await assertSafeAimuxWorktreePath(targetPath)
|
|
70
71
|
|
|
72
|
+
let forkRef: string
|
|
71
73
|
try {
|
|
72
|
-
await createGitWorktree({
|
|
74
|
+
forkRef = await createGitWorktree({
|
|
73
75
|
baseRef: base,
|
|
74
76
|
branchName: branch,
|
|
77
|
+
refreshBase: shouldRefreshBase(),
|
|
75
78
|
repoPath: primary.repoRoot,
|
|
76
79
|
targetPath,
|
|
77
80
|
})
|
|
@@ -84,7 +87,8 @@ export async function createProjectWorkspace(
|
|
|
84
87
|
|
|
85
88
|
const now = new Date().toISOString()
|
|
86
89
|
const record: WorkspaceRecord = {
|
|
87
|
-
|
|
90
|
+
// What it forked from: `origin/<base>` when the base was refreshed.
|
|
91
|
+
baseRef: forkRef,
|
|
88
92
|
branch,
|
|
89
93
|
createdAt: now,
|
|
90
94
|
createdByAimux: true,
|
package/src/git/worktree.ts
CHANGED
|
@@ -98,9 +98,48 @@ export async function renameGitBranch(
|
|
|
98
98
|
return result.exitCode === 0
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The ref a new workspace should actually fork from.
|
|
103
|
+
*
|
|
104
|
+
* "Base it on main" means the main the team pushed, not whatever the local main
|
|
105
|
+
* happened to be at the last pull — a workspace started ten commits behind says
|
|
106
|
+
* nothing at the time and shows up as conflicts at merge. So fetch the base's
|
|
107
|
+
* counterpart on origin and fork from that when there is one.
|
|
108
|
+
*
|
|
109
|
+
* Every step is best-effort on purpose. The fetch is `nothrow`, so a machine
|
|
110
|
+
* with no network still gets a workspace off the local ref; and a base with no
|
|
111
|
+
* counterpart on origin — a local-only branch, a SHA, `HEAD`, or an
|
|
112
|
+
* already-qualified `origin/x` — resolves to nothing and is used as given, which
|
|
113
|
+
* is why no ref-shape guessing is needed here.
|
|
114
|
+
*
|
|
115
|
+
* The refspec is spelled out rather than left to `remote.origin.fetch`: a bare
|
|
116
|
+
* `fetch origin main` on a remote with no configured refspec updates only
|
|
117
|
+
* FETCH_HEAD, leaving a stale `origin/main` that resolves perfectly well — so
|
|
118
|
+
* the fork would silently land on exactly the old commit this exists to avoid.
|
|
119
|
+
*
|
|
120
|
+
* ponytail: unpushed commits on the local base are unreachable from the picker
|
|
121
|
+
* once this is on — the remote ref wins whenever it exists, and the toggle is
|
|
122
|
+
* the only way back. Listing `main` and `origin/main` as separate base options
|
|
123
|
+
* is the upgrade path.
|
|
124
|
+
*/
|
|
125
|
+
export async function resolveWorktreeBaseRef(repoPath: string, baseRef: string): Promise<string> {
|
|
126
|
+
const remoteRef = `origin/${baseRef}`
|
|
127
|
+
await $`git -C ${repoPath} fetch origin +${baseRef}:refs/remotes/${remoteRef}`.quiet().nothrow()
|
|
128
|
+
return (await resolveGitRef(repoPath, remoteRef)) !== undefined ? remoteRef : baseRef
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Returns the ref it forked from, which is not always the one it was handed.
|
|
133
|
+
*
|
|
134
|
+
* `refreshBase` is required rather than read from the user's config here: this
|
|
135
|
+
* module wraps git and nothing else, and a caller that has to name the policy
|
|
136
|
+
* cannot forget it exists — which a settings lookup hidden in here would let
|
|
137
|
+
* them do. `shouldRefreshBase` is what both callers name it with.
|
|
138
|
+
*/
|
|
101
139
|
export async function createGitWorktree({
|
|
102
140
|
baseRef,
|
|
103
141
|
branchName,
|
|
142
|
+
refreshBase,
|
|
104
143
|
repoPath,
|
|
105
144
|
targetPath,
|
|
106
145
|
}: {
|
|
@@ -108,10 +147,18 @@ export async function createGitWorktree({
|
|
|
108
147
|
targetPath: string
|
|
109
148
|
branchName: string
|
|
110
149
|
baseRef: string
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
150
|
+
refreshBase: boolean
|
|
151
|
+
}): Promise<string> {
|
|
152
|
+
const forkRef = refreshBase ? await resolveWorktreeBaseRef(repoPath, baseRef) : baseRef
|
|
153
|
+
// `--no-track`: forking from `origin/main` would otherwise set that as the new
|
|
154
|
+
// branch's upstream, and a workspace branch tracking main is not a workspace
|
|
155
|
+
// branch — `git push` either refuses it (push.default=simple) or, worse,
|
|
156
|
+
// pushes the branch onto main. Forking from a local branch never set tracking,
|
|
157
|
+
// so this only pins down what refreshing the base would have changed.
|
|
158
|
+
const result =
|
|
159
|
+
await $`git -C ${repoPath} worktree add --no-track -b ${branchName} ${targetPath} ${forkRef}`
|
|
160
|
+
.quiet()
|
|
161
|
+
.nothrow()
|
|
115
162
|
if (result.exitCode !== 0) {
|
|
116
163
|
// Always name the repository. A bare "fatal: not a valid object name: 'X'"
|
|
117
164
|
// reads as a ref-resolution bug when the real cause is that git ran in a
|
|
@@ -119,6 +166,7 @@ export async function createGitWorktree({
|
|
|
119
166
|
const stderr = result.stderr.toString().trim()
|
|
120
167
|
throw new Error(`${stderr || 'failed to create git worktree'} (in ${repoPath})`)
|
|
121
168
|
}
|
|
169
|
+
return forkRef
|
|
122
170
|
}
|
|
123
171
|
|
|
124
172
|
// Force-delete a local branch. Returns false (without throwing) when git
|
|
@@ -158,7 +206,7 @@ export async function removeGitWorktree({
|
|
|
158
206
|
force: boolean
|
|
159
207
|
}): Promise<void> {
|
|
160
208
|
if (!isInsideAimuxWorktreeRoot(targetPath)) {
|
|
161
|
-
throw new Error(`refusing to delete worktree outside Aimux
|
|
209
|
+
throw new Error(`refusing to delete worktree outside Aimux worktree root: ${targetPath}`)
|
|
162
210
|
}
|
|
163
211
|
const result = force
|
|
164
212
|
? await $`git -C ${repoPath} worktree remove --force ${targetPath}`.quiet().nothrow()
|
package/src/input/modes/types.ts
CHANGED
|
@@ -114,6 +114,7 @@ export type SideEffect =
|
|
|
114
114
|
| { type: 'run-setup' }
|
|
115
115
|
| { type: 'stop-setup' }
|
|
116
116
|
| { type: 'configure-setup-script'; projectId?: string }
|
|
117
|
+
| { type: 'set-project-default-base-ref'; projectId: string; baseRef: string }
|
|
117
118
|
| { type: 'ask-agent-for-setup-script' }
|
|
118
119
|
| { type: 'promote-setup-tab' }
|
|
119
120
|
/** Toggle a checkbox, cycle an enum, run a row's action — whatever the row is. */
|
|
@@ -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,12 +2,23 @@ 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()
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
export function sanitizePathSegment(input: string, maxLength = MAX_SLUG_LENGTH): string {
|
|
@@ -42,9 +53,10 @@ export function makeWorktreePath({
|
|
|
42
53
|
|
|
43
54
|
export function isInsideAimuxWorktreeRoot(path: string): boolean {
|
|
44
55
|
const normalizeTmp = (value: string) => value.replace(/^\/private\/tmp(?=\/|$)/, '/tmp')
|
|
45
|
-
const root = `${normalizeTmp(resolve(getAimuxWorktreeRoot()))}/`
|
|
46
56
|
const target = `${normalizeTmp(resolve(path))}/`
|
|
47
|
-
return
|
|
57
|
+
return [getAimuxWorktreeRoot(), LEGACY_WORKTREE_ROOT].some((root) =>
|
|
58
|
+
target.startsWith(`${normalizeTmp(resolve(root))}/`)
|
|
59
|
+
)
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
export async function ensureAimuxWorktreeRoot(): Promise<string> {
|
|
@@ -80,13 +92,13 @@ export async function pruneEmptyWorktreeParent(worktreePath: string): Promise<vo
|
|
|
80
92
|
export async function assertSafeAimuxWorktreePath(path: string): Promise<void> {
|
|
81
93
|
const root = await ensureAimuxWorktreeRoot()
|
|
82
94
|
if (!isInsideAimuxWorktreeRoot(path)) {
|
|
83
|
-
throw new Error(`refusing worktree path outside Aimux
|
|
95
|
+
throw new Error(`refusing worktree path outside Aimux worktree root: ${path}`)
|
|
84
96
|
}
|
|
85
97
|
// Create the repo-scoped parent (<root>/r-<hash>) before resolving it: git
|
|
86
98
|
// worktree add does not create intermediate dirs, and realpath() would throw
|
|
87
99
|
// ENOENT on the first worktree for a repo. mkdir(recursive) leaves an
|
|
88
100
|
// existing symlink in place, so the realpath check below still catches an
|
|
89
|
-
// escape out of the
|
|
101
|
+
// escape out of the worktree root.
|
|
90
102
|
const parent = resolve(path, '..')
|
|
91
103
|
await mkdir(parent, { recursive: true })
|
|
92
104
|
const realRoot = await realpath(root)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { loadConfig } from '../config'
|
|
2
|
+
import { FETCH_BASE } from './sections/git'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Settings read outside the screen, off the file rather than the store.
|
|
6
|
+
*
|
|
7
|
+
* `live.ts` is the same read side for the running TUI, through hooks over the
|
|
8
|
+
* hydrated store. These have to answer where there is no store: the CLI never
|
|
9
|
+
* hydrates one, and a setting only the TUI honours is a setting that lies.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Whether a new workspace forks from the base as pushed, or as last pulled. */
|
|
13
|
+
export function shouldRefreshBase(): boolean {
|
|
14
|
+
return loadConfig().settings?.[FETCH_BASE] !== false
|
|
15
|
+
}
|
|
@@ -5,6 +5,8 @@ import type { SettingSection, SettingValue } from '../types'
|
|
|
5
5
|
|
|
6
6
|
import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
|
|
7
7
|
|
|
8
|
+
export const FETCH_BASE = 'git.fetchBase'
|
|
9
|
+
|
|
8
10
|
function isFileListMode(value: SettingValue): value is GitFileListMode {
|
|
9
11
|
return value === 'tree' || value === 'flat'
|
|
10
12
|
}
|
|
@@ -81,6 +83,16 @@ export const GIT_SECTION: SettingSection = {
|
|
|
81
83
|
step: 1,
|
|
82
84
|
storage: 'settings',
|
|
83
85
|
},
|
|
86
|
+
{
|
|
87
|
+
// No `apply`: `shouldRefreshBase` reads this off the file, so the CLI —
|
|
88
|
+
// where this screen never hydrates — honours it too.
|
|
89
|
+
description: 'Fetch the base branch before forking from it. Off forks from your local copy.',
|
|
90
|
+
fallback: true,
|
|
91
|
+
id: FETCH_BASE,
|
|
92
|
+
kind: 'toggle',
|
|
93
|
+
label: 'Refresh the base branch',
|
|
94
|
+
storage: 'settings',
|
|
95
|
+
},
|
|
84
96
|
{
|
|
85
97
|
apply: (value) => setMultiRepoConfig({ ...getMultiRepoConfig(), enabled: value === true }),
|
|
86
98
|
description: 'Aggregate the status of git repos nested under the project.',
|
|
@@ -13,6 +13,7 @@ import { LAYOUT_SECTION } from './layout'
|
|
|
13
13
|
import { NOTIFICATIONS_SECTION } from './notifications'
|
|
14
14
|
import { SETUP_SECTION } from './setup'
|
|
15
15
|
import { STATUS_BAR_SECTION } from './status-bar'
|
|
16
|
+
import { WORKSPACE_SECTION } from './workspace'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Every section, in the order the screen lists them. Adding a setting is one
|
|
@@ -24,6 +25,7 @@ export const SETTING_SECTIONS: readonly SettingSection[] = [
|
|
|
24
25
|
AUTOMATION_SECTION,
|
|
25
26
|
NOTIFICATIONS_SECTION,
|
|
26
27
|
SETUP_SECTION,
|
|
28
|
+
WORKSPACE_SECTION,
|
|
27
29
|
GIT_SECTION,
|
|
28
30
|
COMMANDS_SECTION,
|
|
29
31
|
EDITOR_SECTION,
|
|
@@ -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
|
},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ProjectRecord } from '../../state/types'
|
|
2
|
+
import type { SettingRow, SettingSection } from '../types'
|
|
3
|
+
|
|
4
|
+
import { runSideEffectGlobal } from '../../state/dispatch-ref'
|
|
5
|
+
|
|
6
|
+
const BASE_REF_ROW_PREFIX = 'workspace.defaultBaseRef.'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One row per project, over `defaultBaseRef` on its catalog record.
|
|
10
|
+
*
|
|
11
|
+
* Per project rather than global because the convention belongs to the repo:
|
|
12
|
+
* one clone branches off `develop`, the next off `main`, and a single value
|
|
13
|
+
* would be wrong for every repo but one. Left empty it follows whatever the
|
|
14
|
+
* repo declares as its default branch, which is the right answer most of the
|
|
15
|
+
* time and the reason this is a row and not a prompt.
|
|
16
|
+
*/
|
|
17
|
+
function defaultBaseRefRow(project: ProjectRecord): SettingRow {
|
|
18
|
+
return {
|
|
19
|
+
description: "Branch new workspaces fork from. Empty follows the repo's own default.",
|
|
20
|
+
id: `${BASE_REF_ROW_PREFIX}${project.id}`,
|
|
21
|
+
kind: 'text',
|
|
22
|
+
label: project.name,
|
|
23
|
+
placeholder: "the repo's default",
|
|
24
|
+
// Read through the live state, not the record this row was built from: the
|
|
25
|
+
// screen rebuilds its rows from the same list it just wrote to.
|
|
26
|
+
read: (ctx) =>
|
|
27
|
+
ctx.state.projects.find((entry) => entry.id === project.id)?.defaultBaseRef ?? '',
|
|
28
|
+
storage: 'app',
|
|
29
|
+
write: (value) =>
|
|
30
|
+
runSideEffectGlobal({
|
|
31
|
+
baseRef: String(value),
|
|
32
|
+
projectId: project.id,
|
|
33
|
+
type: 'set-project-default-base-ref',
|
|
34
|
+
}),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const WORKSPACE_SECTION: SettingSection = {
|
|
39
|
+
id: 'workspace',
|
|
40
|
+
label: 'Workspaces',
|
|
41
|
+
rowCount: (projects) => projects.length,
|
|
42
|
+
rows: (projects: readonly ProjectRecord[]) =>
|
|
43
|
+
projects.map((project) => defaultBaseRefRow(project)),
|
|
44
|
+
}
|
package/src/state/types.ts
CHANGED
|
@@ -217,6 +217,14 @@ export interface ProjectRecord {
|
|
|
217
217
|
projectSnapshot?: ProjectSnapshotV1
|
|
218
218
|
workspaces?: WorkspaceRecord[]
|
|
219
219
|
activeWorkspaceId?: string
|
|
220
|
+
/**
|
|
221
|
+
* The branch new workspaces fork from, when the repo's own default is the
|
|
222
|
+
* wrong answer — a gitflow repo branches off `develop` while still declaring
|
|
223
|
+
* `main` as its default, and nothing about the repo says so. Per project
|
|
224
|
+
* because the convention is the team's, not the user's: three repos, three
|
|
225
|
+
* answers. Unset means the repo's default branch.
|
|
226
|
+
*/
|
|
227
|
+
defaultBaseRef?: string
|
|
220
228
|
}
|
|
221
229
|
|
|
222
230
|
export interface ProjectBarState {
|
package/src/state/validation.ts
CHANGED
|
@@ -180,7 +180,8 @@ export function isProjectRecord(value: unknown): value is ProjectRecord {
|
|
|
180
180
|
(value.projectSnapshot === undefined || isProjectSnapshotV1(value.projectSnapshot)) &&
|
|
181
181
|
(value.workspaces === undefined ||
|
|
182
182
|
(Array.isArray(value.workspaces) && value.workspaces.every(isWorkspaceRecord))) &&
|
|
183
|
-
(value.activeWorkspaceId === undefined || isString(value.activeWorkspaceId))
|
|
183
|
+
(value.activeWorkspaceId === undefined || isString(value.activeWorkspaceId)) &&
|
|
184
|
+
(value.defaultBaseRef === undefined || isString(value.defaultBaseRef))
|
|
184
185
|
)
|
|
185
186
|
}
|
|
186
187
|
|