@brimveyn/aimux 1.14.14 → 1.14.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +242 -38
- package/src/app.tsx +30 -1
- package/src/config.ts +101 -0
- package/src/git/worktree.ts +45 -0
- package/src/input/modes/bridge.ts +4 -0
- package/src/input/modes/transitions.ts +8 -1
- package/src/input/modes/types.ts +15 -1
- package/src/state/reducers/modal-state.ts +232 -33
- package/src/state/reducers/session-state.ts +11 -19
- package/src/state/reducers/tab-state.ts +17 -0
- package/src/state/selectors.ts +55 -1
- package/src/state/session-persistence.ts +23 -1
- package/src/state/store.ts +4 -0
- package/src/state/types.ts +45 -8
- package/src/ui/components/layout/sidebar/worktree-row.tsx +12 -2
- package/src/ui/components/layout/top-tab-bar.tsx +146 -12
- package/src/ui/components/modals/shared/worktree-delete-confirm.tsx +39 -0
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +181 -44
- package/src/ui/root.tsx +21 -2
package/src/git/worktree.ts
CHANGED
|
@@ -33,6 +33,24 @@ export async function getHeadSha(cwd: string): Promise<string | undefined> {
|
|
|
33
33
|
return result.text().trim() || undefined
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Local branch names, ordered most-recently-committed first so the likely base
|
|
37
|
+
// surfaces near the top of the picker.
|
|
38
|
+
export async function listLocalBranches(cwd: string): Promise<string[]> {
|
|
39
|
+
// The format must be interpolated, not inlined: Bun's shell parses a bare
|
|
40
|
+
// `%(refname:short)` and chokes on the parentheses.
|
|
41
|
+
const format = '%(refname:short)'
|
|
42
|
+
const result =
|
|
43
|
+
await $`git -C ${cwd} for-each-ref --sort=-committerdate refs/heads --format=${format}`
|
|
44
|
+
.quiet()
|
|
45
|
+
.nothrow()
|
|
46
|
+
if (result.exitCode !== 0) return []
|
|
47
|
+
return result
|
|
48
|
+
.text()
|
|
49
|
+
.split('\n')
|
|
50
|
+
.map((line) => line.trim())
|
|
51
|
+
.filter((line) => line !== '')
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
export async function createGitWorktree({
|
|
37
55
|
baseRef,
|
|
38
56
|
branchName,
|
|
@@ -52,6 +70,33 @@ export async function createGitWorktree({
|
|
|
52
70
|
}
|
|
53
71
|
}
|
|
54
72
|
|
|
73
|
+
// Force-delete a local branch. Returns false (without throwing) when git
|
|
74
|
+
// refuses — notably when the branch is still checked out in a live worktree,
|
|
75
|
+
// which is exactly how orphan-pruning stays safe.
|
|
76
|
+
export async function deleteGitBranch(repoPath: string, branch: string): Promise<boolean> {
|
|
77
|
+
const result = await $`git -C ${repoPath} branch -D ${branch}`.quiet().nothrow()
|
|
78
|
+
return result.exitCode === 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function pruneGitWorktrees(repoPath: string): Promise<void> {
|
|
82
|
+
await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Drop every `aimux/` branch left behind by deleted temp worktrees. git refuses
|
|
86
|
+
// to delete branches still checked out in a live worktree, so this only removes
|
|
87
|
+
// true orphans. Returns the number of branches removed.
|
|
88
|
+
export async function pruneOrphanAimuxBranches(repoPath: string): Promise<number> {
|
|
89
|
+
await pruneGitWorktrees(repoPath)
|
|
90
|
+
const branches = (await listLocalBranches(repoPath)).filter((branch) =>
|
|
91
|
+
branch.startsWith('aimux/')
|
|
92
|
+
)
|
|
93
|
+
let removed = 0
|
|
94
|
+
for (const branch of branches) {
|
|
95
|
+
if (await deleteGitBranch(repoPath, branch)) removed++
|
|
96
|
+
}
|
|
97
|
+
return removed
|
|
98
|
+
}
|
|
99
|
+
|
|
55
100
|
export async function removeGitWorktree({
|
|
56
101
|
force,
|
|
57
102
|
repoPath,
|
|
@@ -26,6 +26,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
|
26
26
|
const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
27
27
|
'ai-usage': 'modal.ai-usage',
|
|
28
28
|
'update-available': 'modal.update-available',
|
|
29
|
+
'worktree-delete-confirm': 'modal.worktree-delete-confirm',
|
|
29
30
|
'worktree-move': 'modal.worktree-move',
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -51,6 +52,9 @@ export function deriveModeId(state: AppState): ModeId {
|
|
|
51
52
|
if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
|
|
52
53
|
return 'modal.new-tab.editing-command'
|
|
53
54
|
}
|
|
55
|
+
if (state.modal.type === 'new-tab' && state.modal.worktreeDeletePrompt !== null) {
|
|
56
|
+
return 'modal.new-tab.worktree-delete-confirm'
|
|
57
|
+
}
|
|
54
58
|
if (state.modal.type === 'git-commit' && state.modal.stage === 'confirm') {
|
|
55
59
|
return 'modal.git-commit.confirm'
|
|
56
60
|
}
|
|
@@ -8,8 +8,13 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
8
8
|
'modal.git-commit.confirm': ['modal.git-commit', 'git-mode'],
|
|
9
9
|
'modal.git-commit.generating': ['modal.git-commit', 'modal.git-commit.confirm', 'git-mode'],
|
|
10
10
|
'modal.help.filtering': ['navigation'],
|
|
11
|
-
'modal.new-tab.command-edit': [
|
|
11
|
+
'modal.new-tab.command-edit': [
|
|
12
|
+
'navigation',
|
|
13
|
+
'modal.new-tab.editing-command',
|
|
14
|
+
'modal.new-tab.worktree-delete-confirm',
|
|
15
|
+
],
|
|
12
16
|
'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
|
|
17
|
+
'modal.new-tab.worktree-delete-confirm': ['navigation', 'modal.new-tab.command-edit'],
|
|
13
18
|
'modal.rename-tab': ['navigation'],
|
|
14
19
|
'modal.session-name': ['modal.session-picker.filtering', 'navigation'],
|
|
15
20
|
'modal.session-picker.filtering': ['navigation', 'modal.session-name', 'modal.create-session'],
|
|
@@ -18,6 +23,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
18
23
|
'modal.split-picker': ['navigation', 'terminal-input'],
|
|
19
24
|
'modal.theme-picker.filtering': ['navigation'],
|
|
20
25
|
'modal.update-available': ['navigation'],
|
|
26
|
+
'modal.worktree-delete-confirm': ['navigation'],
|
|
21
27
|
'modal.worktree-move': ['git-mode', 'navigation'],
|
|
22
28
|
'navigation': [
|
|
23
29
|
'terminal-input',
|
|
@@ -30,6 +36,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
30
36
|
'modal.rename-tab',
|
|
31
37
|
'modal.update-available',
|
|
32
38
|
'modal.ai-usage',
|
|
39
|
+
'modal.worktree-delete-confirm',
|
|
33
40
|
'git-mode',
|
|
34
41
|
],
|
|
35
42
|
'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage'],
|
package/src/input/modes/types.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type ModeId =
|
|
|
9
9
|
| 'git-mode'
|
|
10
10
|
| 'modal.new-tab.command-edit'
|
|
11
11
|
| 'modal.new-tab.editing-command'
|
|
12
|
+
| 'modal.new-tab.worktree-delete-confirm'
|
|
13
|
+
| 'modal.worktree-delete-confirm'
|
|
12
14
|
| 'modal.session-picker.filtering'
|
|
13
15
|
| 'modal.session-name'
|
|
14
16
|
| 'modal.create-session'
|
|
@@ -28,6 +30,7 @@ export type ModeId =
|
|
|
28
30
|
export type SideEffect =
|
|
29
31
|
| { type: 'quit'; state: AppState }
|
|
30
32
|
| { type: 'launch-selected-assistant' }
|
|
33
|
+
| { type: 'load-new-tab-base-branches' }
|
|
31
34
|
| { type: 'edit-selected-assistant' }
|
|
32
35
|
| { type: 'confirm-selected-session' }
|
|
33
36
|
| { type: 'delete-selected-session' }
|
|
@@ -71,7 +74,18 @@ export type SideEffect =
|
|
|
71
74
|
| { type: 'cycle-sidebar-item'; direction: 1 | -1 }
|
|
72
75
|
| { type: 'switch-tab-by-index'; index: number }
|
|
73
76
|
| { type: 'delete-session'; sessionId: string }
|
|
74
|
-
| {
|
|
77
|
+
| {
|
|
78
|
+
type: 'delete-worktree'
|
|
79
|
+
sessionId: string
|
|
80
|
+
worktreeId: string
|
|
81
|
+
// Force the git worktree removal (discards uncommitted changes in the
|
|
82
|
+
// worktree). Also implies closing the worktree's tabs.
|
|
83
|
+
force?: boolean
|
|
84
|
+
// Close the worktree's tabs without forcing the git removal. Lets the
|
|
85
|
+
// sidebar "Remove worktree" clean up tabs (avoiding orphans) while still
|
|
86
|
+
// refusing to discard uncommitted work in a temp worktree.
|
|
87
|
+
closeTabs?: boolean
|
|
88
|
+
}
|
|
75
89
|
| {
|
|
76
90
|
type: 'move-worktree'
|
|
77
91
|
sessionId: string
|
|
@@ -6,7 +6,14 @@ import { collectHelpEntries } from '../../input/keymap/help-entries'
|
|
|
6
6
|
import { getActiveKeymap } from '../../input/keymap/keymap-ref'
|
|
7
7
|
import { getAllAssistantOptions } from '../../pty/command-registry'
|
|
8
8
|
import { filterThemeIds } from '../../ui/filter-themes'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type BaseRefOption,
|
|
11
|
+
buildBaseRefOptions,
|
|
12
|
+
filterAssistants,
|
|
13
|
+
filterSessions,
|
|
14
|
+
filterSnippets,
|
|
15
|
+
getTemplateNoneOffset,
|
|
16
|
+
} from '../selectors'
|
|
10
17
|
import { reduceAutoCommitState } from './auto-commit-state'
|
|
11
18
|
|
|
12
19
|
function emptyModal() {
|
|
@@ -39,6 +46,17 @@ function getCurrentWorktreeIndex(state: AppState): number {
|
|
|
39
46
|
)
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
function getNewTabBaseOptions(state: AppState, queryOverride?: string): BaseRefOption[] {
|
|
50
|
+
if (state.modal.type !== 'new-tab') return []
|
|
51
|
+
const worktrees =
|
|
52
|
+
state.sessions.find((entry) => entry.id === state.currentSessionId)?.worktrees ?? []
|
|
53
|
+
return buildBaseRefOptions(
|
|
54
|
+
worktrees,
|
|
55
|
+
state.modal.baseBranches,
|
|
56
|
+
queryOverride ?? state.modal.baseQuery
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function getSelectedNewTabAssistant(state: AppState, assistantId?: string) {
|
|
43
61
|
if (assistantId != null && assistantId !== '') {
|
|
44
62
|
return getAllAssistantOptions(state.customCommands).find((entry) => entry.id === assistantId)
|
|
@@ -58,6 +76,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
58
76
|
focusMode: 'command-edit',
|
|
59
77
|
modal: {
|
|
60
78
|
activeField: 'assistant',
|
|
79
|
+
baseBranches: [],
|
|
80
|
+
baseQuery: '',
|
|
81
|
+
baseRef: '',
|
|
61
82
|
branchError: null,
|
|
62
83
|
branchName: '',
|
|
63
84
|
createWorktree: false,
|
|
@@ -70,19 +91,25 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
70
91
|
step: 'assistant',
|
|
71
92
|
targetWorktreeIndex,
|
|
72
93
|
type: 'new-tab',
|
|
73
|
-
|
|
74
|
-
worktreeDeleteMessage: null,
|
|
94
|
+
worktreeDeletePrompt: null,
|
|
75
95
|
worktreeName: '',
|
|
76
96
|
},
|
|
77
97
|
}
|
|
78
98
|
}
|
|
79
99
|
case 'enter-new-tab-worktree-create': {
|
|
80
100
|
if (state.modal.type !== 'new-tab') return state
|
|
101
|
+
// Default the base to the branch of the worktree we're forking from (the
|
|
102
|
+
// current target), preserving the previous always-fork-from-source
|
|
103
|
+
// behaviour until the user picks another base.
|
|
104
|
+
const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
|
|
105
|
+
const sourceWorktree = session?.worktrees?.[state.modal.targetWorktreeIndex]
|
|
81
106
|
return {
|
|
82
107
|
...state,
|
|
83
108
|
modal: {
|
|
84
109
|
...state.modal,
|
|
85
110
|
activeField: 'worktree-name',
|
|
111
|
+
baseQuery: '',
|
|
112
|
+
baseRef: sourceWorktree?.branch ?? '',
|
|
86
113
|
createWorktree: true,
|
|
87
114
|
cursorPos: state.modal.worktreeName.length,
|
|
88
115
|
selectedIndex: 0,
|
|
@@ -90,14 +117,55 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
90
117
|
},
|
|
91
118
|
}
|
|
92
119
|
}
|
|
93
|
-
case 'set-new-tab-
|
|
120
|
+
case 'set-new-tab-base-branches': {
|
|
121
|
+
if (state.modal.type !== 'new-tab') return state
|
|
122
|
+
const modalWithBranches = { ...state.modal, baseBranches: action.branches }
|
|
123
|
+
const withBranches: AppState = { ...state, modal: modalWithBranches }
|
|
124
|
+
// Backfill a default base if none resolved yet (e.g. detached source).
|
|
125
|
+
if (state.modal.baseRef !== '') return withBranches
|
|
126
|
+
const firstOption = getNewTabBaseOptions(withBranches)[0]
|
|
127
|
+
return {
|
|
128
|
+
...withBranches,
|
|
129
|
+
modal: { ...modalWithBranches, baseRef: firstOption?.ref ?? '' },
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
case 'enter-new-tab-template-pick': {
|
|
94
133
|
if (state.modal.type !== 'new-tab') return state
|
|
95
134
|
return {
|
|
96
135
|
...state,
|
|
97
136
|
modal: {
|
|
98
137
|
...state.modal,
|
|
99
|
-
|
|
100
|
-
|
|
138
|
+
activeField: 'target-worktree',
|
|
139
|
+
cursorPos: 0,
|
|
140
|
+
selectedIndex: 0,
|
|
141
|
+
step: 'template',
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
case 'enter-new-tab-template-shortcut': {
|
|
146
|
+
if (state.modal.type !== 'new-tab') return state
|
|
147
|
+
const defaultName = state.modal.worktreeName || 'wt-template'
|
|
148
|
+
return {
|
|
149
|
+
...state,
|
|
150
|
+
modal: {
|
|
151
|
+
...state.modal,
|
|
152
|
+
activeField: 'worktree-name',
|
|
153
|
+
createWorktree: true,
|
|
154
|
+
cursorPos: defaultName.length,
|
|
155
|
+
selectedAssistantId: null,
|
|
156
|
+
selectedIndex: 0,
|
|
157
|
+
step: 'worktree-create',
|
|
158
|
+
worktreeName: defaultName,
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
case 'set-new-tab-worktree-delete-prompt': {
|
|
163
|
+
if (state.modal.type !== 'new-tab') return state
|
|
164
|
+
return {
|
|
165
|
+
...state,
|
|
166
|
+
modal: {
|
|
167
|
+
...state.modal,
|
|
168
|
+
worktreeDeletePrompt: action.prompt,
|
|
101
169
|
},
|
|
102
170
|
}
|
|
103
171
|
}
|
|
@@ -115,6 +183,32 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
115
183
|
}
|
|
116
184
|
case 'select-new-tab-assistant': {
|
|
117
185
|
if (state.modal.type !== 'new-tab' || state.modal.editingCommand !== null) return state
|
|
186
|
+
if (
|
|
187
|
+
action.assistantId === undefined &&
|
|
188
|
+
state.modal.step === 'assistant' &&
|
|
189
|
+
state.worktreeTemplates.length > 0
|
|
190
|
+
) {
|
|
191
|
+
const filtered = filterAssistants(
|
|
192
|
+
getAllAssistantOptions(state.customCommands),
|
|
193
|
+
state.modal.editBuffer
|
|
194
|
+
)
|
|
195
|
+
if (state.modal.selectedIndex >= filtered.length) {
|
|
196
|
+
const defaultName = state.modal.worktreeName || 'wt-template'
|
|
197
|
+
return {
|
|
198
|
+
...state,
|
|
199
|
+
modal: {
|
|
200
|
+
...state.modal,
|
|
201
|
+
activeField: 'worktree-name',
|
|
202
|
+
createWorktree: true,
|
|
203
|
+
cursorPos: defaultName.length,
|
|
204
|
+
selectedAssistantId: null,
|
|
205
|
+
selectedIndex: 0,
|
|
206
|
+
step: 'worktree-create',
|
|
207
|
+
worktreeName: defaultName,
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
118
212
|
const option = getSelectedNewTabAssistant(state, action.assistantId)
|
|
119
213
|
if (!option) return state
|
|
120
214
|
const targetWorktreeIndex = getCurrentWorktreeIndex(state)
|
|
@@ -131,8 +225,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
131
225
|
selectedIndex: worktreeCount === 0 ? 0 : targetWorktreeIndex,
|
|
132
226
|
step: 'worktree',
|
|
133
227
|
targetWorktreeIndex,
|
|
134
|
-
|
|
135
|
-
worktreeDeleteMessage: null,
|
|
228
|
+
worktreeDeletePrompt: null,
|
|
136
229
|
worktreeName: state.modal.worktreeName || `wt-${option.label}`,
|
|
137
230
|
},
|
|
138
231
|
}
|
|
@@ -167,8 +260,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
167
260
|
: Math.min(state.modal.selectedIndex, Math.max(0, worktreeCount - 1)),
|
|
168
261
|
step: option && createWorktree ? 'worktree' : state.modal.step,
|
|
169
262
|
targetWorktreeIndex,
|
|
170
|
-
|
|
171
|
-
worktreeDeleteMessage: null,
|
|
263
|
+
worktreeDeletePrompt: null,
|
|
172
264
|
worktreeName: defaultName,
|
|
173
265
|
},
|
|
174
266
|
}
|
|
@@ -223,6 +315,24 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
223
315
|
modal: { ...state.modal, deleteSource: !state.modal.deleteSource },
|
|
224
316
|
}
|
|
225
317
|
}
|
|
318
|
+
case 'open-worktree-delete-confirm': {
|
|
319
|
+
return {
|
|
320
|
+
...state,
|
|
321
|
+
focusMode: 'modal',
|
|
322
|
+
modal: {
|
|
323
|
+
closeTabs: action.closeTabs,
|
|
324
|
+
editBuffer: null,
|
|
325
|
+
force: action.force,
|
|
326
|
+
reason: action.reason,
|
|
327
|
+
selectedIndex: 0,
|
|
328
|
+
sessionId: action.sessionId,
|
|
329
|
+
sessionTargetId: null,
|
|
330
|
+
type: 'worktree-delete-confirm',
|
|
331
|
+
worktreeId: action.worktreeId,
|
|
332
|
+
worktreeLabel: action.worktreeLabel,
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
}
|
|
226
336
|
case 'open-help-modal': {
|
|
227
337
|
const keymap = getActiveKeymap()
|
|
228
338
|
const scope = action.scope ?? null
|
|
@@ -555,18 +665,31 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
555
665
|
return state
|
|
556
666
|
}
|
|
557
667
|
if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
|
|
558
|
-
return state
|
|
668
|
+
if (state.modal.activeField !== 'base') return state
|
|
669
|
+
const options = getNewTabBaseOptions(state)
|
|
670
|
+
if (options.length === 0) return state
|
|
671
|
+
const next = (state.modal.selectedIndex + action.delta + options.length) % options.length
|
|
672
|
+
return {
|
|
673
|
+
...state,
|
|
674
|
+
modal: {
|
|
675
|
+
...state.modal,
|
|
676
|
+
baseRef: options[next]?.ref ?? state.modal.baseRef,
|
|
677
|
+
selectedIndex: next,
|
|
678
|
+
},
|
|
679
|
+
}
|
|
559
680
|
}
|
|
560
681
|
let optionCount: number
|
|
561
682
|
if (state.modal.type === 'new-tab') {
|
|
562
683
|
if (state.modal.step === 'worktree') {
|
|
563
684
|
if (state.modal.activeField === 'worktree-name') return state
|
|
564
685
|
optionCount = getCurrentWorktreeCount(state) + 1
|
|
686
|
+
} else if (state.modal.step === 'template') {
|
|
687
|
+
optionCount =
|
|
688
|
+
state.worktreeTemplates.length + getTemplateNoneOffset(state.modal.selectedAssistantId)
|
|
565
689
|
} else {
|
|
566
|
-
optionCount =
|
|
567
|
-
getAllAssistantOptions(state.customCommands),
|
|
568
|
-
|
|
569
|
-
).length
|
|
690
|
+
optionCount =
|
|
691
|
+
filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
|
|
692
|
+
.length + (state.worktreeTemplates.length > 0 ? 1 : 0)
|
|
570
693
|
}
|
|
571
694
|
} else if (state.modal.type === 'split-picker') {
|
|
572
695
|
optionCount = getAllAssistantOptions(state.customCommands).length
|
|
@@ -598,8 +721,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
598
721
|
createWorktree:
|
|
599
722
|
(state.modal.selectedIndex + action.delta + optionCount) % optionCount ===
|
|
600
723
|
optionCount - 1,
|
|
601
|
-
|
|
602
|
-
worktreeDeleteMessage: null,
|
|
724
|
+
worktreeDeletePrompt: null,
|
|
603
725
|
}
|
|
604
726
|
: null),
|
|
605
727
|
},
|
|
@@ -620,17 +742,34 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
620
742
|
return state
|
|
621
743
|
}
|
|
622
744
|
if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
|
|
623
|
-
return state
|
|
745
|
+
if (state.modal.activeField !== 'base') return state
|
|
746
|
+
const options = getNewTabBaseOptions(state)
|
|
747
|
+
if (options.length === 0) return state
|
|
748
|
+
const clamped = Math.max(0, Math.min(options.length - 1, action.index))
|
|
749
|
+
if (clamped === state.modal.selectedIndex) return state
|
|
750
|
+
return {
|
|
751
|
+
...state,
|
|
752
|
+
modal: {
|
|
753
|
+
...state.modal,
|
|
754
|
+
baseRef: options[clamped]?.ref ?? state.modal.baseRef,
|
|
755
|
+
selectedIndex: clamped,
|
|
756
|
+
},
|
|
757
|
+
}
|
|
624
758
|
}
|
|
625
759
|
let optionCount: number
|
|
626
760
|
if (state.modal.type === 'help') {
|
|
627
761
|
optionCount = state.modal.entryCount
|
|
628
762
|
} else if (state.modal.type === 'new-tab') {
|
|
629
|
-
|
|
630
|
-
state
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
763
|
+
if (state.modal.step === 'worktree') {
|
|
764
|
+
optionCount = getCurrentWorktreeCount(state) + 1
|
|
765
|
+
} else if (state.modal.step === 'template') {
|
|
766
|
+
optionCount =
|
|
767
|
+
state.worktreeTemplates.length + getTemplateNoneOffset(state.modal.selectedAssistantId)
|
|
768
|
+
} else {
|
|
769
|
+
optionCount =
|
|
770
|
+
filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
|
|
771
|
+
.length + (state.worktreeTemplates.length > 0 ? 1 : 0)
|
|
772
|
+
}
|
|
634
773
|
} else if (state.modal.type === 'split-picker') {
|
|
635
774
|
optionCount = getAllAssistantOptions(state.customCommands).length
|
|
636
775
|
} else if (state.modal.type === 'create-session') {
|
|
@@ -656,8 +795,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
656
795
|
...state.modal,
|
|
657
796
|
createWorktree: clamped === optionCount - 1,
|
|
658
797
|
selectedIndex: clamped,
|
|
659
|
-
|
|
660
|
-
worktreeDeleteMessage: null,
|
|
798
|
+
worktreeDeletePrompt: null,
|
|
661
799
|
},
|
|
662
800
|
}
|
|
663
801
|
}
|
|
@@ -677,8 +815,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
677
815
|
if (
|
|
678
816
|
state.modal.type === 'new-tab' &&
|
|
679
817
|
state.modal.editingCommand === null &&
|
|
680
|
-
state.modal.step === '
|
|
681
|
-
|
|
818
|
+
(state.modal.step === 'template' ||
|
|
819
|
+
(state.modal.step === 'worktree' && state.modal.activeField === 'target-worktree'))
|
|
682
820
|
) {
|
|
683
821
|
return state
|
|
684
822
|
}
|
|
@@ -702,13 +840,15 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
702
840
|
buffer = state.modal.worktreeName
|
|
703
841
|
} else if (state.modal.activeField === 'branch-name') {
|
|
704
842
|
buffer = state.modal.branchName
|
|
843
|
+
} else if (state.modal.activeField === 'base') {
|
|
844
|
+
buffer = state.modal.baseQuery
|
|
705
845
|
}
|
|
706
846
|
}
|
|
707
847
|
if (
|
|
708
848
|
state.modal.type === 'new-tab' &&
|
|
709
849
|
state.modal.editingCommand === null &&
|
|
710
|
-
state.modal.step === '
|
|
711
|
-
|
|
850
|
+
(state.modal.step === 'template' ||
|
|
851
|
+
(state.modal.step === 'worktree' && state.modal.activeField === 'target-worktree'))
|
|
712
852
|
) {
|
|
713
853
|
return state
|
|
714
854
|
}
|
|
@@ -753,6 +893,24 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
753
893
|
},
|
|
754
894
|
}
|
|
755
895
|
}
|
|
896
|
+
if (
|
|
897
|
+
state.modal.type === 'new-tab' &&
|
|
898
|
+
state.modal.editingCommand === null &&
|
|
899
|
+
state.modal.activeField === 'base'
|
|
900
|
+
) {
|
|
901
|
+
// Re-filter and snap the selection/base to the top match as the query changes.
|
|
902
|
+
const topOption = getNewTabBaseOptions(state, nextBuffer)[0]
|
|
903
|
+
return {
|
|
904
|
+
...state,
|
|
905
|
+
modal: {
|
|
906
|
+
...state.modal,
|
|
907
|
+
baseQuery: nextBuffer,
|
|
908
|
+
baseRef: topOption?.ref ?? state.modal.baseRef,
|
|
909
|
+
cursorPos: nextCursor,
|
|
910
|
+
selectedIndex: 0,
|
|
911
|
+
},
|
|
912
|
+
}
|
|
913
|
+
}
|
|
756
914
|
const resetIndex =
|
|
757
915
|
!isNewTabEditing &&
|
|
758
916
|
(state.modal.type === 'session-picker' ||
|
|
@@ -785,6 +943,18 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
785
943
|
}
|
|
786
944
|
}
|
|
787
945
|
if (state.modal.type === 'new-tab') {
|
|
946
|
+
if (state.modal.step === 'template') {
|
|
947
|
+
return {
|
|
948
|
+
...state,
|
|
949
|
+
modal: {
|
|
950
|
+
...state.modal,
|
|
951
|
+
activeField: 'worktree-name',
|
|
952
|
+
cursorPos: state.modal.worktreeName.length,
|
|
953
|
+
selectedIndex: 0,
|
|
954
|
+
step: 'worktree-create',
|
|
955
|
+
},
|
|
956
|
+
}
|
|
957
|
+
}
|
|
788
958
|
if (state.modal.step === 'worktree-create') {
|
|
789
959
|
const optionCount = getCurrentWorktreeCount(state) + 1
|
|
790
960
|
return {
|
|
@@ -821,8 +991,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
821
991
|
cursorPos: state.modal.editBuffer?.length ?? 0,
|
|
822
992
|
selectedIndex: Math.max(0, assistantIndex),
|
|
823
993
|
step: 'assistant',
|
|
824
|
-
|
|
825
|
-
worktreeDeleteMessage: null,
|
|
994
|
+
worktreeDeletePrompt: null,
|
|
826
995
|
},
|
|
827
996
|
}
|
|
828
997
|
}
|
|
@@ -911,8 +1080,38 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
911
1080
|
if (state.modal.type === 'new-tab') {
|
|
912
1081
|
if (state.modal.step === 'assistant') return state
|
|
913
1082
|
if (state.modal.step === 'worktree-create') {
|
|
914
|
-
const
|
|
915
|
-
|
|
1083
|
+
const cycle: Record<
|
|
1084
|
+
'worktree-name' | 'branch-name' | 'base',
|
|
1085
|
+
typeof state.modal.activeField
|
|
1086
|
+
> = {
|
|
1087
|
+
'base': 'worktree-name',
|
|
1088
|
+
'branch-name': 'base',
|
|
1089
|
+
'worktree-name': 'branch-name',
|
|
1090
|
+
}
|
|
1091
|
+
const nextField =
|
|
1092
|
+
state.modal.activeField === 'worktree-name' ||
|
|
1093
|
+
state.modal.activeField === 'branch-name' ||
|
|
1094
|
+
state.modal.activeField === 'base'
|
|
1095
|
+
? cycle[state.modal.activeField]
|
|
1096
|
+
: 'worktree-name'
|
|
1097
|
+
if (nextField === 'base') {
|
|
1098
|
+
// Highlight the row matching the resolved base ref when entering it.
|
|
1099
|
+
const currentBaseRef = state.modal.baseRef
|
|
1100
|
+
const options = getNewTabBaseOptions(state)
|
|
1101
|
+
const baseIndex = Math.max(
|
|
1102
|
+
0,
|
|
1103
|
+
options.findIndex((option) => option.ref === currentBaseRef)
|
|
1104
|
+
)
|
|
1105
|
+
return {
|
|
1106
|
+
...state,
|
|
1107
|
+
modal: {
|
|
1108
|
+
...state.modal,
|
|
1109
|
+
activeField: nextField,
|
|
1110
|
+
cursorPos: state.modal.baseQuery.length,
|
|
1111
|
+
selectedIndex: baseIndex,
|
|
1112
|
+
},
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
916
1115
|
const nextValue =
|
|
917
1116
|
nextField === 'branch-name' ? state.modal.branchName : state.modal.worktreeName
|
|
918
1117
|
return {
|
|
@@ -18,15 +18,23 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
|
|
|
18
18
|
const snapshot =
|
|
19
19
|
action.workspaceSnapshot ??
|
|
20
20
|
state.sessions.find((entry) => entry.id === action.sessionId)?.workspaceSnapshot
|
|
21
|
-
const restored = restoreWorkspaceState(state, snapshot, {
|
|
22
|
-
forceDisconnected: action.forceDisconnected ?? true,
|
|
23
|
-
})
|
|
24
21
|
// The session's activeWorktreeId may have been patched right before
|
|
25
22
|
// this load (e.g. the cross-workspace branch of handleCycleSidebarItem
|
|
26
23
|
// sets it to the worktree the user just clicked). The snapshot's
|
|
27
24
|
// activeTabId still reflects the *last* worktree they were on, so
|
|
28
25
|
// honor the patched worktree by filtering the restored tab list.
|
|
29
26
|
const loadedSession = state.sessions.find((entry) => entry.id === action.sessionId)
|
|
27
|
+
const loadedWorktrees = loadedSession?.worktrees ?? []
|
|
28
|
+
const restored = restoreWorkspaceState(state, snapshot, {
|
|
29
|
+
forceDisconnected: action.forceDisconnected ?? true,
|
|
30
|
+
// Drop tabs bound to worktrees this session no longer owns so a stale
|
|
31
|
+
// id can't be caught by a later worktree delete (closing "another
|
|
32
|
+
// worktree's" tabs) and so corrupted catalogs self-heal on load. Only
|
|
33
|
+
// prune once the session has worktrees — an empty list means it hasn't
|
|
34
|
+
// been initialized yet, and pruning then would wrongly drop bound tabs.
|
|
35
|
+
validWorktreeIds:
|
|
36
|
+
loadedWorktrees.length > 0 ? new Set(loadedWorktrees.map((w) => w.id)) : undefined,
|
|
37
|
+
})
|
|
30
38
|
const visible = filterTabsForActiveWorktree(restored.tabs, loadedSession)
|
|
31
39
|
// Prefer the snapshot's tab; if it isn't visible under the active
|
|
32
40
|
// worktree (e.g. a multi-worktree session restored onto a different
|
|
@@ -188,22 +196,6 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
|
|
|
188
196
|
: withActiveWorktree(next, action.worktree.id)
|
|
189
197
|
}),
|
|
190
198
|
}
|
|
191
|
-
case 'remove-worktree-record':
|
|
192
|
-
return {
|
|
193
|
-
...state,
|
|
194
|
-
sessions: state.sessions.map((session) => {
|
|
195
|
-
if (session.id !== action.sessionId) return session
|
|
196
|
-
const remaining = (session.worktrees ?? []).filter((w) => w.id !== action.worktreeId)
|
|
197
|
-
if (remaining.length === 0) return session
|
|
198
|
-
if (session.activeWorktreeId !== action.worktreeId) {
|
|
199
|
-
return { ...session, updatedAt: new Date().toISOString(), worktrees: remaining }
|
|
200
|
-
}
|
|
201
|
-
return withActiveWorktree(
|
|
202
|
-
{ ...session, activeWorktreeId: remaining[0]?.id, worktrees: remaining },
|
|
203
|
-
remaining[0]?.id ?? ''
|
|
204
|
-
)
|
|
205
|
-
}),
|
|
206
|
-
}
|
|
207
199
|
case 'set-active-worktree': {
|
|
208
200
|
const sessions = state.sessions.map((session) =>
|
|
209
201
|
session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
|
|
@@ -469,6 +469,23 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
469
469
|
}
|
|
470
470
|
return { ...state, tabs }
|
|
471
471
|
}
|
|
472
|
+
case 'reorder-tabs': {
|
|
473
|
+
// Drag-and-drop reorder of the visible tab strip. `orderedTabIds` is the
|
|
474
|
+
// flattened new order of the currently-visible tabs (group members already
|
|
475
|
+
// expanded into contiguous runs). We only rewrite the slots those tabs
|
|
476
|
+
// occupy in `state.tabs`, leaving tabs from other worktrees anchored in
|
|
477
|
+
// place. Because group members arrive contiguous, splits stay intact.
|
|
478
|
+
const visibleSet = new Set(action.orderedTabIds)
|
|
479
|
+
const byId = new Map(state.tabs.map((tab) => [tab.id, tab]))
|
|
480
|
+
let cursor = 0
|
|
481
|
+
const tabs = state.tabs.map((tab) => {
|
|
482
|
+
if (!visibleSet.has(tab.id)) return tab
|
|
483
|
+
const nextId = action.orderedTabIds[cursor]
|
|
484
|
+
cursor++
|
|
485
|
+
return (nextId != null ? byId.get(nextId) : undefined) ?? tab
|
|
486
|
+
})
|
|
487
|
+
return { ...state, tabs }
|
|
488
|
+
}
|
|
472
489
|
case 'reset-tab-session':
|
|
473
490
|
return withActiveTabWorktree(
|
|
474
491
|
{
|