@brimveyn/aimux 1.12.6 → 1.13.0
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/README.md +3 -0
- package/package.json +4 -3
- package/src/app-runtime/auto-commit-driver.ts +8 -4
- package/src/app-runtime/auto-commit-ref.ts +1 -1
- package/src/app-runtime/backend-attach-runtime.ts +18 -2
- package/src/app-runtime/backend-runtime-events.ts +1 -1
- package/src/app-runtime/selection-scroll.ts +2 -2
- package/src/app-runtime/session-actions.ts +16 -4
- package/src/app-runtime/side-effects.ts +538 -83
- package/src/app-runtime/snippet-actions.ts +3 -3
- package/src/app-runtime/use-auto-commit-driver.ts +7 -4
- package/src/app-runtime/use-backend-runtime.ts +3 -3
- package/src/app-runtime/use-directory-search.ts +7 -5
- package/src/app-runtime/use-mouse-handlers.ts +11 -6
- package/src/app-runtime/use-pane-size-report.ts +1 -1
- package/src/app-runtime/use-renderer-bindings.ts +6 -5
- package/src/app.tsx +12 -7
- package/src/auto-commit/headless-commands.ts +4 -6
- package/src/daemon/daemon.ts +3 -3
- package/src/daemon/runtime-paths.ts +1 -1
- package/src/daemon/session-manager.ts +1 -1
- package/src/daemon/session-registry.ts +10 -3
- package/src/diff-parser/parse-patch-files.ts +18 -18
- package/src/git/command-queue.ts +1 -1
- package/src/git/divergence.ts +46 -0
- package/src/git/git-diff.ts +12 -5
- package/src/git/git-poller.ts +33 -11
- package/src/git/git-status.ts +14 -3
- package/src/git/move-worktree.ts +96 -0
- package/src/git/use-repo-discovery.ts +2 -1
- package/src/git/worktree-divergence-poller.ts +59 -0
- package/src/git/worktree.ts +99 -0
- package/src/index.tsx +1 -1
- package/src/input/keymap/describe-bindings.ts +27 -9
- package/src/input/keymap/key-chord.ts +4 -4
- package/src/input/keymap/key-format.ts +2 -2
- package/src/input/keymap/sequence-resolver.ts +1 -1
- package/src/input/keymap/trie.ts +1 -1
- package/src/input/modes/bridge.ts +7 -0
- package/src/input/modes/transitions.ts +2 -1
- package/src/input/modes/types.ts +11 -1
- package/src/input/raw-input-handler.ts +1 -1
- package/src/input/terminal-text-extraction.ts +9 -5
- package/src/integrations/claude-syntax-overlay.ts +8 -8
- package/src/integrations/claude-theme-sync.ts +12 -12
- package/src/ipc/protocol.ts +3 -3
- package/src/platform/clipboard.ts +4 -2
- package/src/platform/worktree-deps.ts +22 -0
- package/src/platform/worktree-paths.ts +67 -0
- package/src/profile-paths.ts +3 -3
- package/src/pty/assistant-status-detection-loop.ts +2 -2
- package/src/pty/assistant-status-detector.ts +15 -6
- package/src/pty/command-registry.ts +2 -1
- package/src/pty/pty-manager.ts +1 -1
- package/src/services/ai-usage/adapters/claude.ts +5 -5
- package/src/services/ai-usage/adapters/codex.ts +8 -8
- package/src/services/ai-usage/cache.ts +2 -2
- package/src/services/ai-usage/pace.ts +3 -6
- package/src/services/ai-usage/provider.ts +1 -1
- package/src/session-backend/bootstrap.ts +2 -2
- package/src/session-backend/local-session-backend.ts +11 -11
- package/src/session-backend/remote-session-backend.ts +3 -3
- package/src/session-backend/types.ts +2 -2
- package/src/snippets/expand-variables.ts +7 -7
- package/src/snippets/run-shell-var.ts +1 -1
- package/src/snippets/trigger-detector.ts +1 -1
- package/src/state/ai-usage-store.ts +1 -1
- package/src/state/git-tree.ts +8 -4
- package/src/state/layout-tree.ts +8 -5
- package/src/state/reducers/git-mode-state.ts +44 -14
- package/src/state/reducers/git-panel-state.ts +38 -2
- package/src/state/reducers/modal-state.ts +355 -21
- package/src/state/reducers/session-state.ts +60 -1
- package/src/state/reducers/tab-state.ts +151 -85
- package/src/state/selectors.ts +6 -4
- package/src/state/session-catalog.ts +20 -5
- package/src/state/session-persistence.ts +8 -4
- package/src/state/session-worktrees.ts +227 -0
- package/src/state/store.ts +1 -0
- package/src/state/toast-store.ts +95 -0
- package/src/state/types.ts +92 -13
- package/src/state/validation.ts +25 -2
- package/src/terminal-manager/manager-client.ts +15 -13
- package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
- package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
- package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
- package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
- package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
- package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
- package/src/ui/components/git/git-panel.tsx +17 -10
- package/src/ui/components/git/git-view.tsx +79 -19
- package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
- package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
- package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
- package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
- package/src/ui/components/layout/session-bar.tsx +3 -3
- package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
- package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
- package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
- package/src/ui/components/layout/split-layout.tsx +1 -1
- package/src/ui/components/layout/terminal-pane.tsx +43 -40
- package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
- package/src/ui/components/modals/app/help-modal.tsx +4 -2
- package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
- package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
- package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
- package/src/ui/components/modals/shared/form.tsx +4 -5
- package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
- package/src/ui/components/modals/shared/picker.tsx +28 -5
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +11 -3
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
- package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
- package/src/ui/components/overlays/toast/toast-position.ts +45 -0
- package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
- package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
- package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
- package/src/ui/components/primitives/input-field.tsx +1 -1
- package/src/ui/components/primitives/list-item.tsx +3 -3
- package/src/ui/filter-themes.ts +1 -1
- package/src/ui/root.tsx +57 -3
- package/src/ui/session-ordering.ts +2 -2
- package/src/ui/status-bar-model.ts +14 -7
- package/src/ui/terminal-graphics/capabilities.ts +1 -1
- package/src/ui/terminal-graphics/format-fallback.ts +7 -9
- package/src/ui/terminal-graphics/kitty.ts +2 -7
- package/src/update/version-check.ts +1 -1
|
@@ -1,21 +1,40 @@
|
|
|
1
|
+
import type { CliRenderer } from '@opentui/core'
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
DEFAULT_EDITOR_ARGS,
|
|
3
5
|
getExternalEditorConfig,
|
|
4
6
|
isAutoCommitEnabled,
|
|
5
7
|
KNOWN_GUI_EDITORS,
|
|
6
8
|
} from '@brimveyn/aimux-config'
|
|
7
|
-
import { type CliRenderer } from '@opentui/core'
|
|
8
9
|
import { $ } from 'bun'
|
|
9
10
|
import { existsSync } from 'node:fs'
|
|
10
|
-
import {
|
|
11
|
+
import { mkdir } from 'node:fs/promises'
|
|
12
|
+
import { dirname, join as joinPath, resolve as resolvePath } from 'node:path'
|
|
11
13
|
|
|
12
14
|
import type { SideEffect } from '../input/modes/types'
|
|
13
15
|
import type { SessionBackend } from '../session-backend/types'
|
|
16
|
+
import type { ThemeId } from '../ui/themes'
|
|
14
17
|
|
|
15
18
|
import { loadConfig, saveConfig } from '../config'
|
|
16
19
|
import { logInputDebug } from '../debug/input-log'
|
|
17
20
|
import { enqueueGitOp } from '../git/command-queue'
|
|
21
|
+
import { moveWorktree } from '../git/move-worktree'
|
|
22
|
+
import {
|
|
23
|
+
createGitWorktree,
|
|
24
|
+
getCurrentBranch,
|
|
25
|
+
getHeadSha,
|
|
26
|
+
getMainWorktreeRoot,
|
|
27
|
+
listGitWorktrees,
|
|
28
|
+
removeGitWorktree,
|
|
29
|
+
} from '../git/worktree'
|
|
18
30
|
import { createPrefixedId } from '../platform/id'
|
|
31
|
+
import { linkNodeModules } from '../platform/worktree-deps'
|
|
32
|
+
import {
|
|
33
|
+
assertSafeAimuxWorktreePath,
|
|
34
|
+
isInsideAimuxWorktreeRoot,
|
|
35
|
+
makeWorktreePath,
|
|
36
|
+
sanitizePathSegment,
|
|
37
|
+
} from '../platform/worktree-paths'
|
|
19
38
|
import { getProfileConfigDir } from '../profile-paths'
|
|
20
39
|
import {
|
|
21
40
|
getAllAssistantOptions,
|
|
@@ -35,20 +54,23 @@ import {
|
|
|
35
54
|
splitNode,
|
|
36
55
|
} from '../state/layout-tree'
|
|
37
56
|
import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
|
|
57
|
+
import { saveSessionCatalog } from '../state/session-catalog'
|
|
58
|
+
import { getActiveWorktree, getSessionProjectPath } from '../state/session-worktrees'
|
|
38
59
|
import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
|
|
39
60
|
import { createDefaultTerminalModes } from '../state/terminal-modes'
|
|
61
|
+
import { toast } from '../state/toast-store'
|
|
40
62
|
import {
|
|
41
63
|
type AppAction,
|
|
42
64
|
type AppState,
|
|
43
65
|
type AssistantId,
|
|
44
66
|
DEFAULT_SCROLL_INTENT,
|
|
45
67
|
type TabSession,
|
|
68
|
+
type WorktreeRecord,
|
|
46
69
|
} from '../state/types'
|
|
47
70
|
import { saveCurrentWorkspace } from '../state/workspace-save'
|
|
48
71
|
import { filterThemeIds } from '../ui/filter-themes'
|
|
49
72
|
import { scrollGitDiff } from '../ui/git-view-controls'
|
|
50
73
|
import { applyTheme, getCurrentMode, getTransparent, setMode, setTransparent } from '../ui/theme'
|
|
51
|
-
import { type ThemeId } from '../ui/themes'
|
|
52
74
|
import { triggerAutoCommitNow } from './auto-commit-ref'
|
|
53
75
|
import {
|
|
54
76
|
handleCreateSessionEffect,
|
|
@@ -76,11 +98,16 @@ export interface SideEffectContext {
|
|
|
76
98
|
clearIdleTimer: (tabId: string) => void
|
|
77
99
|
clearStartupGrace: (tabId: string) => void
|
|
78
100
|
startStartupGrace: (tabId: string, timeoutMs: number) => void
|
|
101
|
+
getState: () => AppState
|
|
79
102
|
getCurrentSessionProjectPath: () => string | undefined
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
function getSelectedAssistantOption(state: AppState) {
|
|
83
106
|
const all = getAllAssistantOptions(state.customCommands)
|
|
107
|
+
if (state.modal.type === 'new-tab' && state.modal.selectedAssistantId != null) {
|
|
108
|
+
const selectedAssistantId = state.modal.selectedAssistantId
|
|
109
|
+
return all.find((entry) => entry.id === selectedAssistantId) ?? getAssistantOption(0)
|
|
110
|
+
}
|
|
84
111
|
const filter = state.modal.type === 'new-tab' ? state.modal.editBuffer : null
|
|
85
112
|
const list = filterAssistants(all, filter)
|
|
86
113
|
return list[state.modal.selectedIndex] ?? list[0] ?? getAssistantOption(0)
|
|
@@ -139,12 +166,12 @@ function openSelectedSessionRename(ctx: SideEffectContext): void {
|
|
|
139
166
|
function pasteSnippetToActiveGroup(ctx: SideEffectContext): void {
|
|
140
167
|
const { activeTab, backend, state } = ctx
|
|
141
168
|
const snippet = getSelectedSnippet(state)
|
|
142
|
-
if (!snippet || !state.activeTabId) {
|
|
169
|
+
if (!snippet || !(state.activeTabId != null && state.activeTabId !== '')) {
|
|
143
170
|
return
|
|
144
171
|
}
|
|
145
172
|
|
|
146
173
|
const groupId = getGroupIdForTab(state.tabGroupMap, state.activeTabId)
|
|
147
|
-
const groupTree = groupId ? state.layoutTrees[groupId] : null
|
|
174
|
+
const groupTree = groupId != null && groupId !== '' ? state.layoutTrees[groupId] : null
|
|
148
175
|
if (!groupTree) {
|
|
149
176
|
pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet, ctx.dispatch)
|
|
150
177
|
return
|
|
@@ -199,7 +226,7 @@ function applyThemeEffect(
|
|
|
199
226
|
return
|
|
200
227
|
case 'confirm': {
|
|
201
228
|
const selectedId = ids[state.modal.selectedIndex]
|
|
202
|
-
if (selectedId) {
|
|
229
|
+
if (selectedId != null && selectedId !== '') {
|
|
203
230
|
applyTheme(selectedId)
|
|
204
231
|
ctx.setThemeId(selectedId)
|
|
205
232
|
saveConfig({ ...loadConfig(), themeId: selectedId })
|
|
@@ -210,7 +237,7 @@ function applyThemeEffect(
|
|
|
210
237
|
if (ids.length === 0) return
|
|
211
238
|
const nextIndex = (state.modal.selectedIndex + effect.delta + ids.length) % ids.length
|
|
212
239
|
const previewId = ids[nextIndex]
|
|
213
|
-
if (previewId) {
|
|
240
|
+
if (previewId != null && previewId !== '') {
|
|
214
241
|
applyTheme(previewId)
|
|
215
242
|
}
|
|
216
243
|
return
|
|
@@ -223,7 +250,16 @@ function confirmSplitSelection(ctx: SideEffectContext): void {
|
|
|
223
250
|
const option = getSelectedAssistantOption(state)
|
|
224
251
|
const direction = state.modal.type === 'split-picker' ? state.modal.splitDirection : 'vertical'
|
|
225
252
|
const customCommand = state.customCommands[option.id]
|
|
226
|
-
const tab = createTabSession(
|
|
253
|
+
const tab = createTabSession(
|
|
254
|
+
option.id,
|
|
255
|
+
customCommand,
|
|
256
|
+
state.customCommands,
|
|
257
|
+
getActiveWorktree(
|
|
258
|
+
state.currentSessionId != null && state.currentSessionId !== ''
|
|
259
|
+
? state.sessions.find((s) => s.id === state.currentSessionId)
|
|
260
|
+
: undefined
|
|
261
|
+
)?.id
|
|
262
|
+
)
|
|
227
263
|
dispatch({ type: 'close-modal' })
|
|
228
264
|
executeSplitPane(ctx, direction, tab)
|
|
229
265
|
dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
|
|
@@ -246,7 +282,8 @@ function getSelectedSnippet(state: AppState) {
|
|
|
246
282
|
export function createTabSession(
|
|
247
283
|
assistant: AssistantId,
|
|
248
284
|
customCommand?: string,
|
|
249
|
-
customCommands?: Record<string, string
|
|
285
|
+
customCommands?: Record<string, string>,
|
|
286
|
+
worktreeId?: string
|
|
250
287
|
): TabSession {
|
|
251
288
|
const allOptions = getAllAssistantOptions(customCommands ?? {})
|
|
252
289
|
const option = allOptions.find((o) => o.id === assistant) ?? getAssistantOption(0)
|
|
@@ -261,6 +298,7 @@ export function createTabSession(
|
|
|
261
298
|
status: 'starting',
|
|
262
299
|
terminalModes: createDefaultTerminalModes(),
|
|
263
300
|
title: option.label,
|
|
301
|
+
worktreeId,
|
|
264
302
|
}
|
|
265
303
|
}
|
|
266
304
|
|
|
@@ -308,10 +346,38 @@ export function startTabSession(
|
|
|
308
346
|
})
|
|
309
347
|
}
|
|
310
348
|
|
|
311
|
-
function
|
|
349
|
+
function getNewTabTargetWorktreeId(state: AppState): string | undefined {
|
|
350
|
+
if (
|
|
351
|
+
state.modal.type !== 'new-tab' ||
|
|
352
|
+
!(state.currentSessionId != null && state.currentSessionId !== '')
|
|
353
|
+
) {
|
|
354
|
+
return undefined
|
|
355
|
+
}
|
|
356
|
+
const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
|
|
357
|
+
const index = state.modal.createWorktree
|
|
358
|
+
? state.modal.targetWorktreeIndex
|
|
359
|
+
: state.modal.selectedIndex
|
|
360
|
+
return session?.worktrees?.[index]?.id
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function launchAssistant(
|
|
364
|
+
ctx: SideEffectContext,
|
|
365
|
+
assistant: AssistantId,
|
|
366
|
+
worktreeId?: string
|
|
367
|
+
): void {
|
|
312
368
|
const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
|
|
313
369
|
const customCommand = state.customCommands[assistant]
|
|
314
|
-
const tab = createTabSession(
|
|
370
|
+
const tab = createTabSession(
|
|
371
|
+
assistant,
|
|
372
|
+
customCommand,
|
|
373
|
+
state.customCommands,
|
|
374
|
+
worktreeId ??
|
|
375
|
+
getActiveWorktree(
|
|
376
|
+
state.currentSessionId != null && state.currentSessionId !== ''
|
|
377
|
+
? state.sessions.find((s) => s.id === state.currentSessionId)
|
|
378
|
+
: undefined
|
|
379
|
+
)?.id
|
|
380
|
+
)
|
|
315
381
|
logInputDebug('app.launchAssistant', {
|
|
316
382
|
assistant,
|
|
317
383
|
command: tab.command,
|
|
@@ -327,10 +393,60 @@ function launchAssistant(ctx: SideEffectContext, assistant: AssistantId): void {
|
|
|
327
393
|
tab,
|
|
328
394
|
state.layout.terminalCols,
|
|
329
395
|
state.layout.terminalRows,
|
|
330
|
-
ctx
|
|
396
|
+
getTabProjectPath(ctx, tab)
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function launchAssistantInNewWorktree(
|
|
401
|
+
ctx: SideEffectContext,
|
|
402
|
+
assistant: AssistantId,
|
|
403
|
+
worktreeName: string,
|
|
404
|
+
branchName?: string,
|
|
405
|
+
sourceWorktreeId?: string
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
const sessionId = ctx.state.currentSessionId
|
|
408
|
+
if (!(sessionId != null && sessionId !== '')) return
|
|
409
|
+
const worktree = await createAimuxTempWorktree(
|
|
410
|
+
ctx,
|
|
411
|
+
sessionId,
|
|
412
|
+
worktreeName,
|
|
413
|
+
branchName,
|
|
414
|
+
undefined,
|
|
415
|
+
sourceWorktreeId
|
|
416
|
+
)
|
|
417
|
+
if (!worktree) return
|
|
418
|
+
const customCommand = ctx.state.customCommands[assistant]
|
|
419
|
+
const tab = createTabSession(assistant, customCommand, ctx.state.customCommands, worktree.id)
|
|
420
|
+
ctx.dispatch({ tab, type: 'add-tab' })
|
|
421
|
+
ctx.dispatch({ type: 'close-modal' })
|
|
422
|
+
ctx.dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
|
|
423
|
+
startTabSession(
|
|
424
|
+
ctx.backend,
|
|
425
|
+
ctx.dispatch,
|
|
426
|
+
ctx.clearStartupGrace,
|
|
427
|
+
(tabId) => ctx.startStartupGrace(tabId, STARTUP_GRACE_MS),
|
|
428
|
+
tab,
|
|
429
|
+
ctx.state.layout.terminalCols,
|
|
430
|
+
ctx.state.layout.terminalRows,
|
|
431
|
+
worktree.path
|
|
331
432
|
)
|
|
332
433
|
}
|
|
333
434
|
|
|
435
|
+
function getTabProjectPath(
|
|
436
|
+
ctx: SideEffectContext,
|
|
437
|
+
tab: Pick<TabSession, 'worktreeId'>
|
|
438
|
+
): string | undefined {
|
|
439
|
+
const session =
|
|
440
|
+
ctx.state.currentSessionId != null && ctx.state.currentSessionId !== ''
|
|
441
|
+
? ctx.state.sessions.find((entry) => entry.id === ctx.state.currentSessionId)
|
|
442
|
+
: undefined
|
|
443
|
+
if (tab.worktreeId != null && tab.worktreeId !== '') {
|
|
444
|
+
const worktree = session?.worktrees?.find((entry) => entry.id === tab.worktreeId)
|
|
445
|
+
if (worktree) return worktree.path
|
|
446
|
+
}
|
|
447
|
+
return ctx.getCurrentSessionProjectPath()
|
|
448
|
+
}
|
|
449
|
+
|
|
334
450
|
function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
|
|
335
451
|
const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
|
|
336
452
|
startTabSession(
|
|
@@ -341,7 +457,7 @@ function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
|
|
|
341
457
|
tab,
|
|
342
458
|
state.layout.terminalCols,
|
|
343
459
|
state.layout.terminalRows,
|
|
344
|
-
ctx
|
|
460
|
+
getTabProjectPath(ctx, tab)
|
|
345
461
|
)
|
|
346
462
|
}
|
|
347
463
|
|
|
@@ -352,7 +468,7 @@ function executeSplitPane(
|
|
|
352
468
|
): void {
|
|
353
469
|
const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
|
|
354
470
|
const activeTabId = state.activeTabId
|
|
355
|
-
if (!activeTabId) {
|
|
471
|
+
if (!(activeTabId != null && activeTabId !== '')) {
|
|
356
472
|
return
|
|
357
473
|
}
|
|
358
474
|
|
|
@@ -371,7 +487,7 @@ function executeSplitPane(
|
|
|
371
487
|
tab,
|
|
372
488
|
Math.max(1, (paneRect?.cols ?? state.layout.terminalCols) - PANE_BORDER * 2),
|
|
373
489
|
Math.max(1, (paneRect?.rows ?? state.layout.terminalRows) - PANE_BORDER * 2),
|
|
374
|
-
ctx
|
|
490
|
+
getTabProjectPath(ctx, tab)
|
|
375
491
|
)
|
|
376
492
|
}
|
|
377
493
|
|
|
@@ -388,7 +504,16 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
388
504
|
}
|
|
389
505
|
case 'launch-selected-assistant': {
|
|
390
506
|
const option = getSelectedAssistantOption(state)
|
|
391
|
-
|
|
507
|
+
if (state.modal.type === 'new-tab' && state.modal.createWorktree) {
|
|
508
|
+
const worktreeName = state.modal.worktreeName
|
|
509
|
+
const branchName = state.modal.branchName
|
|
510
|
+
const sourceWorktreeId = getNewTabTargetWorktreeId(state)
|
|
511
|
+
void enqueueGitOp(async () =>
|
|
512
|
+
launchAssistantInNewWorktree(ctx, option.id, worktreeName, branchName, sourceWorktreeId)
|
|
513
|
+
).catch((error) => toast.error(error instanceof Error ? error.message : String(error)))
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
launchAssistant(ctx, option.id, getNewTabTargetWorktreeId(state))
|
|
392
517
|
return
|
|
393
518
|
}
|
|
394
519
|
case 'edit-selected-assistant': {
|
|
@@ -408,6 +533,47 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
408
533
|
handleDeleteSessionEffect(state, backend, dispatch, effect.sessionId)
|
|
409
534
|
return
|
|
410
535
|
}
|
|
536
|
+
case 'delete-worktree': {
|
|
537
|
+
void enqueueGitOp(async () =>
|
|
538
|
+
runDeleteWorktree(
|
|
539
|
+
{ ...ctx, state: ctx.getState() },
|
|
540
|
+
effect.sessionId,
|
|
541
|
+
effect.worktreeId,
|
|
542
|
+
!!(effect.force === true)
|
|
543
|
+
)
|
|
544
|
+
).catch((error) => {
|
|
545
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
546
|
+
const forceable = isForceableWorktreeDeleteError(message)
|
|
547
|
+
const latest = ctx.getState()
|
|
548
|
+
if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
|
|
549
|
+
const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
|
|
550
|
+
const selected = session?.worktrees?.[latest.modal.selectedIndex]
|
|
551
|
+
if (selected && selected.id !== effect.worktreeId) {
|
|
552
|
+
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
ctx.dispatch({
|
|
557
|
+
confirmWorktreeId: forceable ? effect.worktreeId : null,
|
|
558
|
+
message: forceable ? message : `Could not delete worktree: ${message}`,
|
|
559
|
+
type: 'set-new-tab-worktree-delete-state',
|
|
560
|
+
})
|
|
561
|
+
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
562
|
+
})
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
case 'move-worktree': {
|
|
566
|
+
void enqueueGitOp(async () =>
|
|
567
|
+
runMoveWorktree(
|
|
568
|
+
{ ...ctx, state: ctx.getState() },
|
|
569
|
+
effect.sessionId,
|
|
570
|
+
effect.sourceWorktreeId,
|
|
571
|
+
effect.targetWorktreeId,
|
|
572
|
+
effect.deleteSource === true
|
|
573
|
+
)
|
|
574
|
+
).catch((error) => toast.error(error instanceof Error ? error.message : String(error)))
|
|
575
|
+
return
|
|
576
|
+
}
|
|
411
577
|
case 'open-rename-selected-session': {
|
|
412
578
|
openSelectedSessionRename(ctx)
|
|
413
579
|
return
|
|
@@ -476,12 +642,18 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
476
642
|
return
|
|
477
643
|
}
|
|
478
644
|
case 'split-pane': {
|
|
479
|
-
const sourceTab =
|
|
480
|
-
|
|
481
|
-
|
|
645
|
+
const sourceTab =
|
|
646
|
+
effect.sourceTabId != null && effect.sourceTabId !== ''
|
|
647
|
+
? state.tabs.find((t) => t.id === effect.sourceTabId)
|
|
648
|
+
: undefined
|
|
482
649
|
const assistant = sourceTab?.assistant ?? 'terminal'
|
|
483
650
|
const customCommand = state.customCommands[assistant]
|
|
484
|
-
const tab = createTabSession(
|
|
651
|
+
const tab = createTabSession(
|
|
652
|
+
assistant,
|
|
653
|
+
customCommand,
|
|
654
|
+
state.customCommands,
|
|
655
|
+
sourceTab?.worktreeId
|
|
656
|
+
)
|
|
485
657
|
executeSplitPane(ctx, effect.direction, tab)
|
|
486
658
|
return
|
|
487
659
|
}
|
|
@@ -554,42 +726,42 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
554
726
|
return
|
|
555
727
|
}
|
|
556
728
|
case 'git-stage': {
|
|
557
|
-
void enqueueGitOp(() => runGitAction(ctx, ['add', '--', effect.path], effect.path))
|
|
729
|
+
void enqueueGitOp(async () => runGitAction(ctx, ['add', '--', effect.path], effect.path))
|
|
558
730
|
return
|
|
559
731
|
}
|
|
560
732
|
case 'git-unstage': {
|
|
561
|
-
void enqueueGitOp(() =>
|
|
733
|
+
void enqueueGitOp(async () =>
|
|
562
734
|
runGitAction(ctx, ['restore', '--staged', '--', effect.path], effect.path)
|
|
563
735
|
)
|
|
564
736
|
return
|
|
565
737
|
}
|
|
566
738
|
case 'git-stage-all': {
|
|
567
739
|
const paths = ctx.state.gitPanel.files.map((f) => f.path)
|
|
568
|
-
void enqueueGitOp(() => runGitActionAll(ctx, ['add', '-A'], paths))
|
|
740
|
+
void enqueueGitOp(async () => runGitActionAll(ctx, ['add', '-A'], paths))
|
|
569
741
|
return
|
|
570
742
|
}
|
|
571
743
|
case 'git-unstage-all': {
|
|
572
744
|
const paths = ctx.state.gitPanel.files.map((f) => f.path)
|
|
573
|
-
void enqueueGitOp(() => runGitActionAll(ctx, ['reset'], paths))
|
|
745
|
+
void enqueueGitOp(async () => runGitActionAll(ctx, ['reset'], paths))
|
|
574
746
|
return
|
|
575
747
|
}
|
|
576
748
|
case 'git-restore': {
|
|
577
|
-
void enqueueGitOp(() => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
|
|
749
|
+
void enqueueGitOp(async () => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
|
|
578
750
|
return
|
|
579
751
|
}
|
|
580
752
|
case 'git-rm': {
|
|
581
|
-
void enqueueGitOp(() => runGitRm(ctx, effect.path))
|
|
753
|
+
void enqueueGitOp(async () => runGitRm(ctx, effect.path))
|
|
582
754
|
return
|
|
583
755
|
}
|
|
584
756
|
case 'git-commit': {
|
|
585
757
|
const { body, title } = effect
|
|
586
|
-
void enqueueGitOp(() => runGitCommit(ctx, title, body))
|
|
758
|
+
void enqueueGitOp(async () => runGitCommit(ctx, title, body))
|
|
587
759
|
return
|
|
588
760
|
}
|
|
589
761
|
case 'git-commit-auto': {
|
|
590
762
|
if (!isAutoCommitEnabled()) return
|
|
591
763
|
const { body, title } = effect
|
|
592
|
-
void enqueueGitOp(() => runGitCommitAuto(ctx, title, body))
|
|
764
|
+
void enqueueGitOp(async () => runGitCommitAuto(ctx, title, body))
|
|
593
765
|
return
|
|
594
766
|
}
|
|
595
767
|
case 'generate-auto-commit-now': {
|
|
@@ -598,7 +770,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
598
770
|
return
|
|
599
771
|
}
|
|
600
772
|
case 'git-push': {
|
|
601
|
-
void enqueueGitOp(() => runGitPush(ctx))
|
|
773
|
+
void enqueueGitOp(async () => runGitPush(ctx))
|
|
602
774
|
return
|
|
603
775
|
}
|
|
604
776
|
case 'confirm-update-selection': {
|
|
@@ -666,7 +838,7 @@ function openSelectedSnippetSourceInEditor(ctx: SideEffectContext): void {
|
|
|
666
838
|
function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
|
|
667
839
|
const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
|
|
668
840
|
const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
|
|
669
|
-
if (!cwd) {
|
|
841
|
+
if (!(cwd != null && cwd !== '')) {
|
|
670
842
|
ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
|
|
671
843
|
return
|
|
672
844
|
}
|
|
@@ -684,14 +856,14 @@ function launchEditorOnFile(
|
|
|
684
856
|
): void {
|
|
685
857
|
const config = getExternalEditorConfig()
|
|
686
858
|
const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
|
|
687
|
-
if (
|
|
859
|
+
if (rawCommand == null || rawCommand === '' || rawCommand.trim() === '') {
|
|
688
860
|
onError('no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts')
|
|
689
861
|
return
|
|
690
862
|
}
|
|
691
863
|
|
|
692
864
|
const cmdParts = shellSplit(rawCommand)
|
|
693
865
|
const executable = cmdParts[0]
|
|
694
|
-
if (!executable) {
|
|
866
|
+
if (!(executable != null && executable !== '')) {
|
|
695
867
|
onError('invalid editor command')
|
|
696
868
|
return
|
|
697
869
|
}
|
|
@@ -753,7 +925,7 @@ function substituteEditorArgs(template: string[], file: string, line?: string):
|
|
|
753
925
|
// Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
|
|
754
926
|
if (/^[+:]?\{line\}$/.test(arg)) continue
|
|
755
927
|
// Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
|
|
756
|
-
out.push(arg.
|
|
928
|
+
out.push(arg.replaceAll(/[:+]\{line\}/g, '').replaceAll('{file}', file))
|
|
757
929
|
}
|
|
758
930
|
return out
|
|
759
931
|
}
|
|
@@ -816,13 +988,15 @@ function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): vo
|
|
|
816
988
|
const stderr = await new Response(child.stderr).text()
|
|
817
989
|
const code = await child.exited
|
|
818
990
|
if (code !== 0) {
|
|
819
|
-
const
|
|
991
|
+
const firstStderrLine = stderr.trim().split('\n')[0]
|
|
992
|
+
const firstLine =
|
|
993
|
+
firstStderrLine != null && firstStderrLine !== '' ? firstStderrLine : `exit ${code}`
|
|
820
994
|
ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
|
|
821
995
|
}
|
|
822
996
|
})()
|
|
823
997
|
child.unref()
|
|
824
|
-
} catch (
|
|
825
|
-
const msg =
|
|
998
|
+
} catch (error) {
|
|
999
|
+
const msg = error instanceof Error ? error.message : 'failed to spawn'
|
|
826
1000
|
ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
|
|
827
1001
|
}
|
|
828
1002
|
}
|
|
@@ -849,8 +1023,8 @@ async function openEditorInline(
|
|
|
849
1023
|
stdout: 'inherit',
|
|
850
1024
|
})
|
|
851
1025
|
await proc.exited
|
|
852
|
-
} catch (
|
|
853
|
-
const msg =
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
const msg = error instanceof Error ? error.message : 'failed to spawn editor'
|
|
854
1028
|
ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
|
|
855
1029
|
} finally {
|
|
856
1030
|
renderer.currentRenderBuffer.clear()
|
|
@@ -861,9 +1035,9 @@ async function openEditorInline(
|
|
|
861
1035
|
|
|
862
1036
|
function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
|
|
863
1037
|
const { backend, dispatch, state } = ctx
|
|
864
|
-
const ordered = state.sessions
|
|
865
|
-
.
|
|
866
|
-
|
|
1038
|
+
const ordered = [...state.sessions].sort(
|
|
1039
|
+
(a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
|
|
1040
|
+
)
|
|
867
1041
|
const target = ordered[index - 1]
|
|
868
1042
|
if (!target) {
|
|
869
1043
|
logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
|
|
@@ -878,6 +1052,295 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
|
|
|
878
1052
|
handleSwitchSessionEffect(state, backend, dispatch, target)
|
|
879
1053
|
}
|
|
880
1054
|
|
|
1055
|
+
function replaceSession(
|
|
1056
|
+
state: AppState,
|
|
1057
|
+
sessionId: string,
|
|
1058
|
+
next: (session: AppState['sessions'][number]) => AppState['sessions'][number]
|
|
1059
|
+
): AppState['sessions'] {
|
|
1060
|
+
return state.sessions.map((session) => (session.id === sessionId ? next(session) : session))
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function handleSwitchWorktree(ctx: SideEffectContext, sessionId: string, worktreeId: string): void {
|
|
1064
|
+
const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
|
|
1065
|
+
const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
|
|
1066
|
+
if (!session || !worktree) return
|
|
1067
|
+
const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
|
|
1068
|
+
...entry,
|
|
1069
|
+
activeWorktreeId: worktreeId,
|
|
1070
|
+
projectPath: worktree.path,
|
|
1071
|
+
updatedAt: new Date().toISOString(),
|
|
1072
|
+
}))
|
|
1073
|
+
saveSessionCatalog(sessions)
|
|
1074
|
+
ctx.dispatch({ sessions, type: 'set-sessions' })
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function normalizeBranchName(branch: string | undefined): string | undefined {
|
|
1078
|
+
return branch?.replace(/^refs\/heads\//, '').trim()
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async function createAimuxTempWorktree(
|
|
1082
|
+
ctx: SideEffectContext,
|
|
1083
|
+
sessionId: string,
|
|
1084
|
+
requestedName?: string,
|
|
1085
|
+
requestedBranchName?: string,
|
|
1086
|
+
requestedBaseRef?: string,
|
|
1087
|
+
sourceWorktreeId?: string
|
|
1088
|
+
): Promise<WorktreeRecord | undefined> {
|
|
1089
|
+
const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
|
|
1090
|
+
const source =
|
|
1091
|
+
session?.worktrees?.find((entry) => entry.id === sourceWorktreeId) ?? getActiveWorktree(session)
|
|
1092
|
+
const sourcePath = source?.path ?? getSessionProjectPath(session)
|
|
1093
|
+
if (!session || !(sourcePath != null && sourcePath !== '')) return undefined
|
|
1094
|
+
|
|
1095
|
+
// Resolve the *main* repo checkout, never the active linked worktree, so the
|
|
1096
|
+
// record's repoRoot stays valid after sibling worktrees are deleted.
|
|
1097
|
+
const repoRoot = (await getMainWorktreeRoot(sourcePath)) ?? source?.repoRoot ?? sourcePath
|
|
1098
|
+
const baseBranch = (await getCurrentBranch(sourcePath)) ?? source?.branch ?? 'HEAD'
|
|
1099
|
+
const baseRef = requestedBaseRef ?? baseBranch
|
|
1100
|
+
const worktreeId = createPrefixedId('worktree')
|
|
1101
|
+
const trimmedName = requestedName?.trim()
|
|
1102
|
+
const worktreeName =
|
|
1103
|
+
trimmedName != null && trimmedName !== ''
|
|
1104
|
+
? trimmedName
|
|
1105
|
+
: `wt-${sanitizePathSegment(session.name, 12)}`
|
|
1106
|
+
const trimmedBranch = requestedBranchName?.trim()
|
|
1107
|
+
const branchName =
|
|
1108
|
+
trimmedBranch != null && trimmedBranch !== ''
|
|
1109
|
+
? trimmedBranch
|
|
1110
|
+
: `aimux/${sanitizePathSegment(worktreeName, 40)}-${Date.now().toString(36)}`
|
|
1111
|
+
const targetPath = makeWorktreePath({ repoRoot, worktreeId, worktreeName })
|
|
1112
|
+
|
|
1113
|
+
const existingWorktree = (await listGitWorktrees(repoRoot)).find(
|
|
1114
|
+
(entry) =>
|
|
1115
|
+
entry.prunable !== true &&
|
|
1116
|
+
normalizeBranchName(entry.branch) === normalizeBranchName(branchName)
|
|
1117
|
+
)
|
|
1118
|
+
if (existingWorktree) {
|
|
1119
|
+
ctx.dispatch({
|
|
1120
|
+
message: `Branch already checked out in another worktree: ${existingWorktree.path}`,
|
|
1121
|
+
type: 'set-new-tab-branch-error',
|
|
1122
|
+
})
|
|
1123
|
+
return undefined
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
await mkdir(dirname(targetPath), { recursive: true })
|
|
1127
|
+
await assertSafeAimuxWorktreePath(targetPath)
|
|
1128
|
+
await createGitWorktree({ baseRef, branchName, repoPath: repoRoot, targetPath })
|
|
1129
|
+
// For JS projects, reuse the source checkout's installed dependencies instead
|
|
1130
|
+
// of forcing a fresh install in the new worktree.
|
|
1131
|
+
const linkedModules = await linkNodeModules(sourcePath, targetPath).catch(() => false)
|
|
1132
|
+
const now = new Date().toISOString()
|
|
1133
|
+
|
|
1134
|
+
const worktree: WorktreeRecord = {
|
|
1135
|
+
baseRef,
|
|
1136
|
+
branch: branchName,
|
|
1137
|
+
commitSha: await getHeadSha(targetPath),
|
|
1138
|
+
createdAt: now,
|
|
1139
|
+
createdByAimux: true,
|
|
1140
|
+
id: worktreeId,
|
|
1141
|
+
name: worktreeName,
|
|
1142
|
+
path: targetPath,
|
|
1143
|
+
repoRoot,
|
|
1144
|
+
source: 'aimux-temp',
|
|
1145
|
+
updatedAt: now,
|
|
1146
|
+
}
|
|
1147
|
+
const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
|
|
1148
|
+
...entry,
|
|
1149
|
+
activeWorktreeId: worktree.id,
|
|
1150
|
+
projectPath: worktree.path,
|
|
1151
|
+
updatedAt: now,
|
|
1152
|
+
worktrees: [...(entry.worktrees ?? []), worktree],
|
|
1153
|
+
}))
|
|
1154
|
+
saveSessionCatalog(sessions)
|
|
1155
|
+
ctx.dispatch({ sessions, type: 'set-sessions' })
|
|
1156
|
+
toast.success(
|
|
1157
|
+
linkedModules
|
|
1158
|
+
? `Created worktree ${branchName} (linked node_modules)`
|
|
1159
|
+
: `Created worktree ${branchName}`
|
|
1160
|
+
)
|
|
1161
|
+
return worktree
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Dispose and close every tab pinned to a worktree (timers, pty session, state).
|
|
1165
|
+
function disposeWorktreeTabs(ctx: SideEffectContext, worktreeId: string): void {
|
|
1166
|
+
for (const tab of ctx.state.tabs.filter((entry) => entry.worktreeId === worktreeId)) {
|
|
1167
|
+
ctx.clearIdleTimer(tab.id)
|
|
1168
|
+
ctx.clearStartupGrace(tab.id)
|
|
1169
|
+
ctx.backend.disposeSession(tab.id)
|
|
1170
|
+
ctx.dispatch({ tabId: tab.id, type: 'close-tab' })
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async function runDeleteWorktree(
|
|
1175
|
+
ctx: SideEffectContext,
|
|
1176
|
+
sessionId: string,
|
|
1177
|
+
worktreeId: string,
|
|
1178
|
+
force: boolean
|
|
1179
|
+
): Promise<void> {
|
|
1180
|
+
const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
|
|
1181
|
+
const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
|
|
1182
|
+
if (!session) throw new Error('session not found')
|
|
1183
|
+
if (!worktree) throw new Error('worktree not found')
|
|
1184
|
+
if ((session.worktrees?.length ?? 0) <= 1) throw new Error('at least one worktree must remain')
|
|
1185
|
+
if (worktree.source === 'primary') throw new Error('root worktree cannot be deleted')
|
|
1186
|
+
|
|
1187
|
+
const tabsInWorktree = ctx.state.tabs.filter((tab) => tab.worktreeId === worktreeId)
|
|
1188
|
+
if (tabsInWorktree.length > 0 && !force) {
|
|
1189
|
+
throw new ActiveWorktreeTabsError(tabsInWorktree.length)
|
|
1190
|
+
}
|
|
1191
|
+
disposeWorktreeTabs(ctx, worktreeId)
|
|
1192
|
+
|
|
1193
|
+
if (
|
|
1194
|
+
worktree.source === 'aimux-temp' &&
|
|
1195
|
+
worktree.createdByAimux &&
|
|
1196
|
+
isInsideAimuxWorktreeRoot(worktree.path) &&
|
|
1197
|
+
!existsSync(worktree.path)
|
|
1198
|
+
) {
|
|
1199
|
+
removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
|
|
1200
|
+
return
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (
|
|
1204
|
+
worktree.source === 'aimux-temp' &&
|
|
1205
|
+
worktree.createdByAimux &&
|
|
1206
|
+
isInsideAimuxWorktreeRoot(worktree.path)
|
|
1207
|
+
) {
|
|
1208
|
+
await assertSafeAimuxWorktreePath(worktree.path)
|
|
1209
|
+
await removeGitWorktree({
|
|
1210
|
+
force,
|
|
1211
|
+
repoPath: resolveWorktreeGitDir(session, worktree),
|
|
1212
|
+
targetPath: worktree.path,
|
|
1213
|
+
})
|
|
1214
|
+
} else if (worktree.source === 'aimux-temp' || worktree.createdByAimux) {
|
|
1215
|
+
throw new Error(`refusing unsafe worktree delete: ${worktree.path}`)
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// A worktree's stored repoRoot can point at a sibling worktree that was since
|
|
1222
|
+
// deleted. Git commands only need *some* existing worktree of the repo (they
|
|
1223
|
+
// share the common git dir), so fall back to the main checkout or any live
|
|
1224
|
+
// worktree path rather than letting `git -C` fail on a vanished directory.
|
|
1225
|
+
function resolveWorktreeGitDir(
|
|
1226
|
+
session: NonNullable<SideEffectContext['state']['sessions'][number]>,
|
|
1227
|
+
worktree: WorktreeRecord
|
|
1228
|
+
): string {
|
|
1229
|
+
const candidates = [
|
|
1230
|
+
session.worktrees?.find((entry) => entry.source === 'primary')?.path,
|
|
1231
|
+
worktree.repoRoot,
|
|
1232
|
+
...(session.worktrees ?? [])
|
|
1233
|
+
.filter((entry) => entry.id !== worktree.id)
|
|
1234
|
+
.map((entry) => entry.path),
|
|
1235
|
+
]
|
|
1236
|
+
return candidates.find((path) => path !== undefined && existsSync(path)) ?? worktree.repoRoot
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function runMoveWorktree(
|
|
1240
|
+
ctx: SideEffectContext,
|
|
1241
|
+
sessionId: string,
|
|
1242
|
+
sourceWorktreeId: string,
|
|
1243
|
+
targetWorktreeId: string,
|
|
1244
|
+
deleteSource: boolean
|
|
1245
|
+
): Promise<void> {
|
|
1246
|
+
const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
|
|
1247
|
+
const source = session?.worktrees?.find((entry) => entry.id === sourceWorktreeId)
|
|
1248
|
+
const target = session?.worktrees?.find((entry) => entry.id === targetWorktreeId)
|
|
1249
|
+
if (!session) throw new Error('session not found')
|
|
1250
|
+
if (!source || !target) throw new Error('worktree not found')
|
|
1251
|
+
if (source.id === target.id) throw new Error('source and target are the same worktree')
|
|
1252
|
+
if (source.branch == null || source.branch === '') {
|
|
1253
|
+
throw new Error('source worktree has no branch to move')
|
|
1254
|
+
}
|
|
1255
|
+
if (deleteSource && source.source === 'primary') {
|
|
1256
|
+
throw new Error('the primary worktree cannot be deleted')
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const sourceLabel = source.branch ?? source.name
|
|
1260
|
+
const targetLabel = target.branch ?? target.name
|
|
1261
|
+
const result = await moveWorktree({
|
|
1262
|
+
sourceBranch: source.branch,
|
|
1263
|
+
sourcePath: source.path,
|
|
1264
|
+
targetPath: target.path,
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
// Toasts surface the outcome everywhere — the picker can be opened outside git
|
|
1268
|
+
// mode (from a tab menu), where the git-pane message would never be seen.
|
|
1269
|
+
if (result.kind === 'dirty-target') {
|
|
1270
|
+
toast.warning(`Target ${targetLabel} has uncommitted changes — commit or stash it first`)
|
|
1271
|
+
return
|
|
1272
|
+
}
|
|
1273
|
+
if (result.kind === 'conflict') {
|
|
1274
|
+
toast.warning(
|
|
1275
|
+
`Move hit conflicts in ${result.files.length} file(s) — left ${sourceLabel} untouched`
|
|
1276
|
+
)
|
|
1277
|
+
return
|
|
1278
|
+
}
|
|
1279
|
+
if (result.kind === 'error') {
|
|
1280
|
+
toast.error(`Move failed: ${result.message}`)
|
|
1281
|
+
return
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Land on the target. When deleting the source, close its terminals and
|
|
1285
|
+
// switch to the target up front, synchronously, BEFORE the slow
|
|
1286
|
+
// `git worktree remove`. Closing the source's active tab re-syncs the active
|
|
1287
|
+
// worktree to a default tab (withActiveTabWorktree); doing the close+switch in
|
|
1288
|
+
// one batch lands on the target with no intermediate render, so the removal
|
|
1289
|
+
// runs with the target already active instead of flashing/sticking to a
|
|
1290
|
+
// default worktree. Re-read state each step so we never resurrect the source.
|
|
1291
|
+
if (deleteSource) {
|
|
1292
|
+
disposeWorktreeTabs({ ...ctx, state: ctx.getState() }, sourceWorktreeId)
|
|
1293
|
+
handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
|
|
1294
|
+
await runDeleteWorktree({ ...ctx, state: ctx.getState() }, sessionId, sourceWorktreeId, true)
|
|
1295
|
+
} else {
|
|
1296
|
+
handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
|
|
1297
|
+
}
|
|
1298
|
+
toast.success(
|
|
1299
|
+
`Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit`
|
|
1300
|
+
)
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function removeWorktreeRecordFromSession(
|
|
1304
|
+
ctx: SideEffectContext,
|
|
1305
|
+
sessionId: string,
|
|
1306
|
+
session: NonNullable<SideEffectContext['state']['sessions'][number]>,
|
|
1307
|
+
worktreeId: string
|
|
1308
|
+
): void {
|
|
1309
|
+
const remaining = (session.worktrees ?? []).filter((entry) => entry.id !== worktreeId)
|
|
1310
|
+
const nextActive =
|
|
1311
|
+
session.activeWorktreeId === worktreeId ? remaining[0] : getActiveWorktree(session)
|
|
1312
|
+
const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
|
|
1313
|
+
...entry,
|
|
1314
|
+
activeWorktreeId: nextActive?.id,
|
|
1315
|
+
projectPath: nextActive?.path ?? entry.projectPath,
|
|
1316
|
+
updatedAt: new Date().toISOString(),
|
|
1317
|
+
worktrees: remaining,
|
|
1318
|
+
}))
|
|
1319
|
+
saveSessionCatalog(sessions)
|
|
1320
|
+
ctx.dispatch({ sessions, type: 'set-sessions' })
|
|
1321
|
+
if (ctx.state.modal.type === 'new-tab' && ctx.state.modal.step === 'worktree') {
|
|
1322
|
+
ctx.dispatch({
|
|
1323
|
+
index: Math.min(ctx.state.modal.selectedIndex, Math.max(0, remaining.length - 1)),
|
|
1324
|
+
type: 'set-modal-selection-index',
|
|
1325
|
+
})
|
|
1326
|
+
}
|
|
1327
|
+
ctx.dispatch({ message: null, type: 'set-new-tab-worktree-delete-state' })
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function isForceableWorktreeDeleteError(message: string): boolean {
|
|
1331
|
+
return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes/i.test(
|
|
1332
|
+
message
|
|
1333
|
+
)
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
class ActiveWorktreeTabsError extends Error {
|
|
1337
|
+
constructor(tabCount: number) {
|
|
1338
|
+
super(
|
|
1339
|
+
`active assistant tabs are using this worktree (${tabCount}). Click [del] again to close them and delete the worktree.`
|
|
1340
|
+
)
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
881
1344
|
function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
|
|
882
1345
|
const { state } = ctx
|
|
883
1346
|
if (state.modal.type !== 'update-available') {
|
|
@@ -917,11 +1380,12 @@ async function runGitAction(
|
|
|
917
1380
|
pathToInvalidate?: string
|
|
918
1381
|
): Promise<void> {
|
|
919
1382
|
const fallback = ctx.getCurrentSessionProjectPath()
|
|
920
|
-
const repoPath =
|
|
921
|
-
|
|
922
|
-
|
|
1383
|
+
const repoPath =
|
|
1384
|
+
pathToInvalidate != null && pathToInvalidate !== ''
|
|
1385
|
+
? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
|
|
1386
|
+
: undefined
|
|
923
1387
|
const cwd = repoPath ?? fallback
|
|
924
|
-
if (!cwd) return
|
|
1388
|
+
if (!(cwd != null && cwd !== '')) return
|
|
925
1389
|
const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
|
|
926
1390
|
if (result.exitCode !== 0) {
|
|
927
1391
|
const stderr = result.stderr.toString().trim()
|
|
@@ -929,7 +1393,7 @@ async function runGitAction(
|
|
|
929
1393
|
return
|
|
930
1394
|
}
|
|
931
1395
|
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
932
|
-
if (pathToInvalidate) {
|
|
1396
|
+
if (pathToInvalidate != null && pathToInvalidate !== '') {
|
|
933
1397
|
ctx.dispatch({ path: pathToInvalidate, type: 'git-mode-clear-diff-cache' })
|
|
934
1398
|
}
|
|
935
1399
|
}
|
|
@@ -940,7 +1404,7 @@ async function runGitActionAll(
|
|
|
940
1404
|
pathsToInvalidate: string[]
|
|
941
1405
|
): Promise<void> {
|
|
942
1406
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
943
|
-
if (!cwd) return
|
|
1407
|
+
if (!(cwd != null && cwd !== '')) return
|
|
944
1408
|
const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
|
|
945
1409
|
if (result.exitCode !== 0) {
|
|
946
1410
|
const stderr = result.stderr.toString().trim()
|
|
@@ -956,17 +1420,15 @@ async function runGitActionAll(
|
|
|
956
1420
|
async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
|
|
957
1421
|
const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
|
|
958
1422
|
const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
|
|
959
|
-
if (!cwd) return
|
|
1423
|
+
if (!(cwd != null && cwd !== '')) return
|
|
960
1424
|
const absolute = `${cwd}/${path}`
|
|
961
1425
|
try {
|
|
962
1426
|
const stat = await Bun.file(absolute).stat()
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
} catch (err) {
|
|
969
|
-
const message = err instanceof Error ? err.message : 'failed to delete file'
|
|
1427
|
+
await (stat.isDirectory()
|
|
1428
|
+
? Bun.$`rm -rf -- ${absolute}`.quiet().nothrow()
|
|
1429
|
+
: Bun.file(absolute).unlink())
|
|
1430
|
+
} catch (error) {
|
|
1431
|
+
const message = error instanceof Error ? error.message : 'failed to delete file'
|
|
970
1432
|
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
971
1433
|
return
|
|
972
1434
|
}
|
|
@@ -976,7 +1438,7 @@ async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
|
|
|
976
1438
|
|
|
977
1439
|
async function runGitCommit(ctx: SideEffectContext, title: string, body: string): Promise<void> {
|
|
978
1440
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
979
|
-
if (!cwd) return
|
|
1441
|
+
if (!(cwd != null && cwd !== '')) return
|
|
980
1442
|
if (!title) {
|
|
981
1443
|
ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
|
|
982
1444
|
return
|
|
@@ -986,14 +1448,15 @@ async function runGitCommit(ctx: SideEffectContext, title: string, body: string)
|
|
|
986
1448
|
: await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
|
|
987
1449
|
if (result.exitCode !== 0) {
|
|
988
1450
|
const stderr = result.stderr.toString().trim()
|
|
989
|
-
ctx.dispatch({
|
|
990
|
-
|
|
991
|
-
type: 'git-mode-set-message',
|
|
992
|
-
})
|
|
1451
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1452
|
+
toast.error(stderr || 'Commit failed')
|
|
993
1453
|
return
|
|
994
1454
|
}
|
|
995
1455
|
clearAutoCommitForCurrentSession(ctx)
|
|
996
|
-
|
|
1456
|
+
// Match the push flow: clear any inline git-pane message and surface the
|
|
1457
|
+
// result as a toast so it's seen even after leaving git mode.
|
|
1458
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1459
|
+
toast.success(`Committed: ${title}`)
|
|
997
1460
|
}
|
|
998
1461
|
|
|
999
1462
|
async function runGitCommitAuto(
|
|
@@ -1006,7 +1469,7 @@ async function runGitCommitAuto(
|
|
|
1006
1469
|
return
|
|
1007
1470
|
}
|
|
1008
1471
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
1009
|
-
if (!cwd) return
|
|
1472
|
+
if (!(cwd != null && cwd !== '')) return
|
|
1010
1473
|
|
|
1011
1474
|
// If the user has manually staged files, respect that intent and commit
|
|
1012
1475
|
// only the staged set — don't run `git add -A` which would sweep up
|
|
@@ -1017,10 +1480,8 @@ async function runGitCommitAuto(
|
|
|
1017
1480
|
const addArgs = ['add', '-A']
|
|
1018
1481
|
const addResult = await $`git -C ${cwd} ${addArgs}`.quiet().nothrow()
|
|
1019
1482
|
if (addResult.exitCode !== 0) {
|
|
1020
|
-
ctx.dispatch({
|
|
1021
|
-
|
|
1022
|
-
type: 'git-mode-set-message',
|
|
1023
|
-
})
|
|
1483
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1484
|
+
toast.error(addResult.stderr.toString().trim() || 'Auto-commit: git add failed')
|
|
1024
1485
|
return
|
|
1025
1486
|
}
|
|
1026
1487
|
}
|
|
@@ -1030,10 +1491,8 @@ async function runGitCommitAuto(
|
|
|
1030
1491
|
: await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
|
|
1031
1492
|
|
|
1032
1493
|
if (commitResult.exitCode !== 0) {
|
|
1033
|
-
ctx.dispatch({
|
|
1034
|
-
|
|
1035
|
-
type: 'git-mode-set-message',
|
|
1036
|
-
})
|
|
1494
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1495
|
+
toast.error(commitResult.stderr.toString().trim() || 'Auto-commit: commit failed')
|
|
1037
1496
|
return
|
|
1038
1497
|
}
|
|
1039
1498
|
|
|
@@ -1043,7 +1502,7 @@ async function runGitCommitAuto(
|
|
|
1043
1502
|
|
|
1044
1503
|
function clearAutoCommitForCurrentSession(ctx: SideEffectContext): void {
|
|
1045
1504
|
const sessionId = ctx.state.currentSessionId
|
|
1046
|
-
if (!sessionId) return
|
|
1505
|
+
if (!(sessionId != null && sessionId !== '')) return
|
|
1047
1506
|
ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
|
|
1048
1507
|
}
|
|
1049
1508
|
|
|
@@ -1051,16 +1510,13 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
|
|
|
1051
1510
|
const session = ctx.state.sessions.find((s) => s.id === sessionId)
|
|
1052
1511
|
const panel = ctx.state.gitPanel
|
|
1053
1512
|
if (panel.error !== null) {
|
|
1054
|
-
|
|
1513
|
+
toast.warning('Auto-commit: git panel unavailable')
|
|
1055
1514
|
ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
|
|
1056
1515
|
return
|
|
1057
1516
|
}
|
|
1058
1517
|
const tab = ctx.activeTab
|
|
1059
1518
|
if (!tab) {
|
|
1060
|
-
|
|
1061
|
-
message: 'auto-commit: no active assistant tab — open a claude/codex session first',
|
|
1062
|
-
type: 'git-mode-set-message',
|
|
1063
|
-
})
|
|
1519
|
+
toast.warning('Auto-commit: no active assistant tab — open a claude/codex session first')
|
|
1064
1520
|
ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
|
|
1065
1521
|
return
|
|
1066
1522
|
}
|
|
@@ -1080,7 +1536,7 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
|
|
|
1080
1536
|
|
|
1081
1537
|
async function runGitPush(ctx: SideEffectContext): Promise<void> {
|
|
1082
1538
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
1083
|
-
if (!cwd) return
|
|
1539
|
+
if (!(cwd != null && cwd !== '')) return
|
|
1084
1540
|
ctx.dispatch({ message: 'pushing…', type: 'git-mode-set-message' })
|
|
1085
1541
|
|
|
1086
1542
|
const upstream = await $`git -C ${cwd} rev-parse --abbrev-ref --symbolic-full-name @{u}`
|
|
@@ -1092,13 +1548,12 @@ async function runGitPush(ctx: SideEffectContext): Promise<void> {
|
|
|
1092
1548
|
? await $`git -C ${cwd} push`.quiet().nothrow()
|
|
1093
1549
|
: await $`git -C ${cwd} push --set-upstream origin HEAD`.quiet().nothrow()
|
|
1094
1550
|
|
|
1551
|
+
// Clear the inline "pushing…" progress; surface the result as a toast so it's
|
|
1552
|
+
// visible even after leaving git mode (and so push failures aren't missed).
|
|
1553
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1095
1554
|
if (result.exitCode !== 0) {
|
|
1096
|
-
|
|
1097
|
-
ctx.dispatch({
|
|
1098
|
-
message: stderr || 'push failed',
|
|
1099
|
-
type: 'git-mode-set-message',
|
|
1100
|
-
})
|
|
1555
|
+
toast.error(result.stderr.toString().trim() || 'Push failed')
|
|
1101
1556
|
return
|
|
1102
1557
|
}
|
|
1103
|
-
|
|
1558
|
+
toast.success('Pushed')
|
|
1104
1559
|
}
|