@brimveyn/aimux 1.12.6 → 1.13.2
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 +29 -13
- 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 +563 -85
- 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 +21 -11
- 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 +19 -3
- 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 +60 -45
- 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 +32 -24
- 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 +39 -11
- package/src/ui/components/git/diff-renderer/stacked-view.tsx +29 -7
- package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +28 -11
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +10 -8
- package/src/ui/components/git/git-panel.tsx +129 -83
- package/src/ui/components/git/git-view.tsx +96 -28
- package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
- package/src/ui/components/git/image-diff/terminal-image-pane.tsx +14 -13
- 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 +135 -117
- package/src/ui/components/layout/sidebar/sidebar.tsx +244 -82
- package/src/ui/components/layout/sidebar/tab-item.tsx +68 -41
- 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 +27 -20
- package/src/ui/components/layout/terminal-pane.tsx +163 -120
- package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
- package/src/ui/components/modals/app/help-modal.tsx +29 -20
- package/src/ui/components/modals/git/git-commit-modal.tsx +17 -10
- package/src/ui/components/modals/sessions/create-session-modal.tsx +18 -10
- package/src/ui/components/modals/sessions/session-picker-modal.tsx +36 -28
- package/src/ui/components/modals/shared/form.tsx +6 -6
- package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
- package/src/ui/components/modals/shared/picker.tsx +71 -32
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +50 -29
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +209 -25
- package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +111 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +14 -9
- package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
- package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
- 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 +23 -7
- package/src/ui/filter-themes.ts +1 -1
- package/src/ui/root.tsx +121 -38
- 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,28 @@ 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 (async () => {
|
|
512
|
+
try {
|
|
513
|
+
await enqueueGitOp(async () =>
|
|
514
|
+
launchAssistantInNewWorktree(
|
|
515
|
+
ctx,
|
|
516
|
+
option.id,
|
|
517
|
+
worktreeName,
|
|
518
|
+
branchName,
|
|
519
|
+
sourceWorktreeId
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
} catch (error) {
|
|
523
|
+
toast.error(error instanceof Error ? error.message : String(error))
|
|
524
|
+
}
|
|
525
|
+
})()
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
launchAssistant(ctx, option.id, getNewTabTargetWorktreeId(state))
|
|
392
529
|
return
|
|
393
530
|
}
|
|
394
531
|
case 'edit-selected-assistant': {
|
|
@@ -408,6 +545,57 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
408
545
|
handleDeleteSessionEffect(state, backend, dispatch, effect.sessionId)
|
|
409
546
|
return
|
|
410
547
|
}
|
|
548
|
+
case 'delete-worktree': {
|
|
549
|
+
void (async () => {
|
|
550
|
+
try {
|
|
551
|
+
await enqueueGitOp(async () =>
|
|
552
|
+
runDeleteWorktree(
|
|
553
|
+
{ ...ctx, state: ctx.getState() },
|
|
554
|
+
effect.sessionId,
|
|
555
|
+
effect.worktreeId,
|
|
556
|
+
!!(effect.force === true)
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
} catch (error) {
|
|
560
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
561
|
+
const forceable = isForceableWorktreeDeleteError(message)
|
|
562
|
+
const latest = ctx.getState()
|
|
563
|
+
if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
|
|
564
|
+
const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
|
|
565
|
+
const selected = session?.worktrees?.[latest.modal.selectedIndex]
|
|
566
|
+
if (selected && selected.id !== effect.worktreeId) {
|
|
567
|
+
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
ctx.dispatch({
|
|
572
|
+
confirmWorktreeId: forceable ? effect.worktreeId : null,
|
|
573
|
+
message: forceable ? message : `Could not delete worktree: ${message}`,
|
|
574
|
+
type: 'set-new-tab-worktree-delete-state',
|
|
575
|
+
})
|
|
576
|
+
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
577
|
+
}
|
|
578
|
+
})()
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
case 'move-worktree': {
|
|
582
|
+
void (async () => {
|
|
583
|
+
try {
|
|
584
|
+
await enqueueGitOp(async () =>
|
|
585
|
+
runMoveWorktree(
|
|
586
|
+
{ ...ctx, state: ctx.getState() },
|
|
587
|
+
effect.sessionId,
|
|
588
|
+
effect.sourceWorktreeId,
|
|
589
|
+
effect.targetWorktreeId,
|
|
590
|
+
effect.deleteSource === true
|
|
591
|
+
)
|
|
592
|
+
)
|
|
593
|
+
} catch (error) {
|
|
594
|
+
toast.error(error instanceof Error ? error.message : String(error))
|
|
595
|
+
}
|
|
596
|
+
})()
|
|
597
|
+
return
|
|
598
|
+
}
|
|
411
599
|
case 'open-rename-selected-session': {
|
|
412
600
|
openSelectedSessionRename(ctx)
|
|
413
601
|
return
|
|
@@ -476,12 +664,18 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
476
664
|
return
|
|
477
665
|
}
|
|
478
666
|
case 'split-pane': {
|
|
479
|
-
const sourceTab =
|
|
480
|
-
|
|
481
|
-
|
|
667
|
+
const sourceTab =
|
|
668
|
+
effect.sourceTabId != null && effect.sourceTabId !== ''
|
|
669
|
+
? state.tabs.find((t) => t.id === effect.sourceTabId)
|
|
670
|
+
: undefined
|
|
482
671
|
const assistant = sourceTab?.assistant ?? 'terminal'
|
|
483
672
|
const customCommand = state.customCommands[assistant]
|
|
484
|
-
const tab = createTabSession(
|
|
673
|
+
const tab = createTabSession(
|
|
674
|
+
assistant,
|
|
675
|
+
customCommand,
|
|
676
|
+
state.customCommands,
|
|
677
|
+
sourceTab?.worktreeId
|
|
678
|
+
)
|
|
485
679
|
executeSplitPane(ctx, effect.direction, tab)
|
|
486
680
|
return
|
|
487
681
|
}
|
|
@@ -554,42 +748,42 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
554
748
|
return
|
|
555
749
|
}
|
|
556
750
|
case 'git-stage': {
|
|
557
|
-
void enqueueGitOp(() => runGitAction(ctx, ['add', '--', effect.path], effect.path))
|
|
751
|
+
void enqueueGitOp(async () => runGitAction(ctx, ['add', '--', effect.path], effect.path))
|
|
558
752
|
return
|
|
559
753
|
}
|
|
560
754
|
case 'git-unstage': {
|
|
561
|
-
void enqueueGitOp(() =>
|
|
755
|
+
void enqueueGitOp(async () =>
|
|
562
756
|
runGitAction(ctx, ['restore', '--staged', '--', effect.path], effect.path)
|
|
563
757
|
)
|
|
564
758
|
return
|
|
565
759
|
}
|
|
566
760
|
case 'git-stage-all': {
|
|
567
761
|
const paths = ctx.state.gitPanel.files.map((f) => f.path)
|
|
568
|
-
void enqueueGitOp(() => runGitActionAll(ctx, ['add', '-A'], paths))
|
|
762
|
+
void enqueueGitOp(async () => runGitActionAll(ctx, ['add', '-A'], paths))
|
|
569
763
|
return
|
|
570
764
|
}
|
|
571
765
|
case 'git-unstage-all': {
|
|
572
766
|
const paths = ctx.state.gitPanel.files.map((f) => f.path)
|
|
573
|
-
void enqueueGitOp(() => runGitActionAll(ctx, ['reset'], paths))
|
|
767
|
+
void enqueueGitOp(async () => runGitActionAll(ctx, ['reset'], paths))
|
|
574
768
|
return
|
|
575
769
|
}
|
|
576
770
|
case 'git-restore': {
|
|
577
|
-
void enqueueGitOp(() => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
|
|
771
|
+
void enqueueGitOp(async () => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
|
|
578
772
|
return
|
|
579
773
|
}
|
|
580
774
|
case 'git-rm': {
|
|
581
|
-
void enqueueGitOp(() => runGitRm(ctx, effect.path))
|
|
775
|
+
void enqueueGitOp(async () => runGitRm(ctx, effect.path))
|
|
582
776
|
return
|
|
583
777
|
}
|
|
584
778
|
case 'git-commit': {
|
|
585
779
|
const { body, title } = effect
|
|
586
|
-
void enqueueGitOp(() => runGitCommit(ctx, title, body))
|
|
780
|
+
void enqueueGitOp(async () => runGitCommit(ctx, title, body))
|
|
587
781
|
return
|
|
588
782
|
}
|
|
589
783
|
case 'git-commit-auto': {
|
|
590
784
|
if (!isAutoCommitEnabled()) return
|
|
591
785
|
const { body, title } = effect
|
|
592
|
-
void enqueueGitOp(() => runGitCommitAuto(ctx, title, body))
|
|
786
|
+
void enqueueGitOp(async () => runGitCommitAuto(ctx, title, body))
|
|
593
787
|
return
|
|
594
788
|
}
|
|
595
789
|
case 'generate-auto-commit-now': {
|
|
@@ -598,7 +792,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
598
792
|
return
|
|
599
793
|
}
|
|
600
794
|
case 'git-push': {
|
|
601
|
-
void enqueueGitOp(() => runGitPush(ctx))
|
|
795
|
+
void enqueueGitOp(async () => runGitPush(ctx))
|
|
602
796
|
return
|
|
603
797
|
}
|
|
604
798
|
case 'confirm-update-selection': {
|
|
@@ -666,7 +860,7 @@ function openSelectedSnippetSourceInEditor(ctx: SideEffectContext): void {
|
|
|
666
860
|
function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
|
|
667
861
|
const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
|
|
668
862
|
const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
|
|
669
|
-
if (!cwd) {
|
|
863
|
+
if (!(cwd != null && cwd !== '')) {
|
|
670
864
|
ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
|
|
671
865
|
return
|
|
672
866
|
}
|
|
@@ -684,14 +878,14 @@ function launchEditorOnFile(
|
|
|
684
878
|
): void {
|
|
685
879
|
const config = getExternalEditorConfig()
|
|
686
880
|
const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
|
|
687
|
-
if (
|
|
881
|
+
if (rawCommand == null || rawCommand === '' || rawCommand.trim() === '') {
|
|
688
882
|
onError('no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts')
|
|
689
883
|
return
|
|
690
884
|
}
|
|
691
885
|
|
|
692
886
|
const cmdParts = shellSplit(rawCommand)
|
|
693
887
|
const executable = cmdParts[0]
|
|
694
|
-
if (!executable) {
|
|
888
|
+
if (!(executable != null && executable !== '')) {
|
|
695
889
|
onError('invalid editor command')
|
|
696
890
|
return
|
|
697
891
|
}
|
|
@@ -753,7 +947,7 @@ function substituteEditorArgs(template: string[], file: string, line?: string):
|
|
|
753
947
|
// Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
|
|
754
948
|
if (/^[+:]?\{line\}$/.test(arg)) continue
|
|
755
949
|
// Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
|
|
756
|
-
out.push(arg.
|
|
950
|
+
out.push(arg.replaceAll(/[:+]\{line\}/g, '').replaceAll('{file}', file))
|
|
757
951
|
}
|
|
758
952
|
return out
|
|
759
953
|
}
|
|
@@ -816,13 +1010,15 @@ function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): vo
|
|
|
816
1010
|
const stderr = await new Response(child.stderr).text()
|
|
817
1011
|
const code = await child.exited
|
|
818
1012
|
if (code !== 0) {
|
|
819
|
-
const
|
|
1013
|
+
const firstStderrLine = stderr.trim().split('\n')[0]
|
|
1014
|
+
const firstLine =
|
|
1015
|
+
firstStderrLine != null && firstStderrLine !== '' ? firstStderrLine : `exit ${code}`
|
|
820
1016
|
ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
|
|
821
1017
|
}
|
|
822
1018
|
})()
|
|
823
1019
|
child.unref()
|
|
824
|
-
} catch (
|
|
825
|
-
const msg =
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
const msg = error instanceof Error ? error.message : 'failed to spawn'
|
|
826
1022
|
ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
|
|
827
1023
|
}
|
|
828
1024
|
}
|
|
@@ -849,8 +1045,8 @@ async function openEditorInline(
|
|
|
849
1045
|
stdout: 'inherit',
|
|
850
1046
|
})
|
|
851
1047
|
await proc.exited
|
|
852
|
-
} catch (
|
|
853
|
-
const msg =
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
const msg = error instanceof Error ? error.message : 'failed to spawn editor'
|
|
854
1050
|
ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
|
|
855
1051
|
} finally {
|
|
856
1052
|
renderer.currentRenderBuffer.clear()
|
|
@@ -861,9 +1057,9 @@ async function openEditorInline(
|
|
|
861
1057
|
|
|
862
1058
|
function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
|
|
863
1059
|
const { backend, dispatch, state } = ctx
|
|
864
|
-
const ordered = state.sessions
|
|
865
|
-
.
|
|
866
|
-
|
|
1060
|
+
const ordered = [...state.sessions].sort(
|
|
1061
|
+
(a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
|
|
1062
|
+
)
|
|
867
1063
|
const target = ordered[index - 1]
|
|
868
1064
|
if (!target) {
|
|
869
1065
|
logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
|
|
@@ -878,6 +1074,295 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
|
|
|
878
1074
|
handleSwitchSessionEffect(state, backend, dispatch, target)
|
|
879
1075
|
}
|
|
880
1076
|
|
|
1077
|
+
function replaceSession(
|
|
1078
|
+
state: AppState,
|
|
1079
|
+
sessionId: string,
|
|
1080
|
+
next: (session: AppState['sessions'][number]) => AppState['sessions'][number]
|
|
1081
|
+
): AppState['sessions'] {
|
|
1082
|
+
return state.sessions.map((session) => (session.id === sessionId ? next(session) : session))
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function handleSwitchWorktree(ctx: SideEffectContext, sessionId: string, worktreeId: string): void {
|
|
1086
|
+
const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
|
|
1087
|
+
const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
|
|
1088
|
+
if (!session || !worktree) return
|
|
1089
|
+
const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
|
|
1090
|
+
...entry,
|
|
1091
|
+
activeWorktreeId: worktreeId,
|
|
1092
|
+
projectPath: worktree.path,
|
|
1093
|
+
updatedAt: new Date().toISOString(),
|
|
1094
|
+
}))
|
|
1095
|
+
saveSessionCatalog(sessions)
|
|
1096
|
+
ctx.dispatch({ sessions, type: 'set-sessions' })
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function normalizeBranchName(branch: string | undefined): string | undefined {
|
|
1100
|
+
return branch?.replace(/^refs\/heads\//, '').trim()
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function createAimuxTempWorktree(
|
|
1104
|
+
ctx: SideEffectContext,
|
|
1105
|
+
sessionId: string,
|
|
1106
|
+
requestedName?: string,
|
|
1107
|
+
requestedBranchName?: string,
|
|
1108
|
+
requestedBaseRef?: string,
|
|
1109
|
+
sourceWorktreeId?: string
|
|
1110
|
+
): Promise<WorktreeRecord | undefined> {
|
|
1111
|
+
const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
|
|
1112
|
+
const source =
|
|
1113
|
+
session?.worktrees?.find((entry) => entry.id === sourceWorktreeId) ?? getActiveWorktree(session)
|
|
1114
|
+
const sourcePath = source?.path ?? getSessionProjectPath(session)
|
|
1115
|
+
if (!session || !(sourcePath != null && sourcePath !== '')) return undefined
|
|
1116
|
+
|
|
1117
|
+
// Resolve the *main* repo checkout, never the active linked worktree, so the
|
|
1118
|
+
// record's repoRoot stays valid after sibling worktrees are deleted.
|
|
1119
|
+
const repoRoot = (await getMainWorktreeRoot(sourcePath)) ?? source?.repoRoot ?? sourcePath
|
|
1120
|
+
const baseBranch = (await getCurrentBranch(sourcePath)) ?? source?.branch ?? 'HEAD'
|
|
1121
|
+
const baseRef = requestedBaseRef ?? baseBranch
|
|
1122
|
+
const worktreeId = createPrefixedId('worktree')
|
|
1123
|
+
const trimmedName = requestedName?.trim()
|
|
1124
|
+
const worktreeName =
|
|
1125
|
+
trimmedName != null && trimmedName !== ''
|
|
1126
|
+
? trimmedName
|
|
1127
|
+
: `wt-${sanitizePathSegment(session.name, 12)}`
|
|
1128
|
+
const trimmedBranch = requestedBranchName?.trim()
|
|
1129
|
+
const branchName =
|
|
1130
|
+
trimmedBranch != null && trimmedBranch !== ''
|
|
1131
|
+
? trimmedBranch
|
|
1132
|
+
: `aimux/${sanitizePathSegment(worktreeName, 40)}-${Date.now().toString(36)}`
|
|
1133
|
+
const targetPath = makeWorktreePath({ repoRoot, worktreeId, worktreeName })
|
|
1134
|
+
|
|
1135
|
+
const existingWorktree = (await listGitWorktrees(repoRoot)).find(
|
|
1136
|
+
(entry) =>
|
|
1137
|
+
entry.prunable !== true &&
|
|
1138
|
+
normalizeBranchName(entry.branch) === normalizeBranchName(branchName)
|
|
1139
|
+
)
|
|
1140
|
+
if (existingWorktree) {
|
|
1141
|
+
ctx.dispatch({
|
|
1142
|
+
message: `Branch already checked out in another worktree: ${existingWorktree.path}`,
|
|
1143
|
+
type: 'set-new-tab-branch-error',
|
|
1144
|
+
})
|
|
1145
|
+
return undefined
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
await mkdir(dirname(targetPath), { recursive: true })
|
|
1149
|
+
await assertSafeAimuxWorktreePath(targetPath)
|
|
1150
|
+
await createGitWorktree({ baseRef, branchName, repoPath: repoRoot, targetPath })
|
|
1151
|
+
// For JS projects, reuse the source checkout's installed dependencies instead
|
|
1152
|
+
// of forcing a fresh install in the new worktree.
|
|
1153
|
+
const linkedModules = await linkNodeModules(sourcePath, targetPath).catch(() => false)
|
|
1154
|
+
const now = new Date().toISOString()
|
|
1155
|
+
|
|
1156
|
+
const worktree: WorktreeRecord = {
|
|
1157
|
+
baseRef,
|
|
1158
|
+
branch: branchName,
|
|
1159
|
+
commitSha: await getHeadSha(targetPath),
|
|
1160
|
+
createdAt: now,
|
|
1161
|
+
createdByAimux: true,
|
|
1162
|
+
id: worktreeId,
|
|
1163
|
+
name: worktreeName,
|
|
1164
|
+
path: targetPath,
|
|
1165
|
+
repoRoot,
|
|
1166
|
+
source: 'aimux-temp',
|
|
1167
|
+
updatedAt: now,
|
|
1168
|
+
}
|
|
1169
|
+
const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
|
|
1170
|
+
...entry,
|
|
1171
|
+
activeWorktreeId: worktree.id,
|
|
1172
|
+
projectPath: worktree.path,
|
|
1173
|
+
updatedAt: now,
|
|
1174
|
+
worktrees: [...(entry.worktrees ?? []), worktree],
|
|
1175
|
+
}))
|
|
1176
|
+
saveSessionCatalog(sessions)
|
|
1177
|
+
ctx.dispatch({ sessions, type: 'set-sessions' })
|
|
1178
|
+
toast.success(
|
|
1179
|
+
linkedModules
|
|
1180
|
+
? `Created worktree ${branchName} (linked node_modules)`
|
|
1181
|
+
: `Created worktree ${branchName}`
|
|
1182
|
+
)
|
|
1183
|
+
return worktree
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Dispose and close every tab pinned to a worktree (timers, pty session, state).
|
|
1187
|
+
function disposeWorktreeTabs(ctx: SideEffectContext, worktreeId: string): void {
|
|
1188
|
+
for (const tab of ctx.state.tabs.filter((entry) => entry.worktreeId === worktreeId)) {
|
|
1189
|
+
ctx.clearIdleTimer(tab.id)
|
|
1190
|
+
ctx.clearStartupGrace(tab.id)
|
|
1191
|
+
ctx.backend.disposeSession(tab.id)
|
|
1192
|
+
ctx.dispatch({ tabId: tab.id, type: 'close-tab' })
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function runDeleteWorktree(
|
|
1197
|
+
ctx: SideEffectContext,
|
|
1198
|
+
sessionId: string,
|
|
1199
|
+
worktreeId: string,
|
|
1200
|
+
force: boolean
|
|
1201
|
+
): Promise<void> {
|
|
1202
|
+
const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
|
|
1203
|
+
const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
|
|
1204
|
+
if (!session) throw new Error('session not found')
|
|
1205
|
+
if (!worktree) throw new Error('worktree not found')
|
|
1206
|
+
if ((session.worktrees?.length ?? 0) <= 1) throw new Error('at least one worktree must remain')
|
|
1207
|
+
if (worktree.source === 'primary') throw new Error('root worktree cannot be deleted')
|
|
1208
|
+
|
|
1209
|
+
const tabsInWorktree = ctx.state.tabs.filter((tab) => tab.worktreeId === worktreeId)
|
|
1210
|
+
if (tabsInWorktree.length > 0 && !force) {
|
|
1211
|
+
throw new ActiveWorktreeTabsError(tabsInWorktree.length)
|
|
1212
|
+
}
|
|
1213
|
+
disposeWorktreeTabs(ctx, worktreeId)
|
|
1214
|
+
|
|
1215
|
+
if (
|
|
1216
|
+
worktree.source === 'aimux-temp' &&
|
|
1217
|
+
worktree.createdByAimux &&
|
|
1218
|
+
isInsideAimuxWorktreeRoot(worktree.path) &&
|
|
1219
|
+
!existsSync(worktree.path)
|
|
1220
|
+
) {
|
|
1221
|
+
removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
|
|
1222
|
+
return
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (
|
|
1226
|
+
worktree.source === 'aimux-temp' &&
|
|
1227
|
+
worktree.createdByAimux &&
|
|
1228
|
+
isInsideAimuxWorktreeRoot(worktree.path)
|
|
1229
|
+
) {
|
|
1230
|
+
await assertSafeAimuxWorktreePath(worktree.path)
|
|
1231
|
+
await removeGitWorktree({
|
|
1232
|
+
force,
|
|
1233
|
+
repoPath: resolveWorktreeGitDir(session, worktree),
|
|
1234
|
+
targetPath: worktree.path,
|
|
1235
|
+
})
|
|
1236
|
+
} else if (worktree.source === 'aimux-temp' || worktree.createdByAimux) {
|
|
1237
|
+
throw new Error(`refusing unsafe worktree delete: ${worktree.path}`)
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// A worktree's stored repoRoot can point at a sibling worktree that was since
|
|
1244
|
+
// deleted. Git commands only need *some* existing worktree of the repo (they
|
|
1245
|
+
// share the common git dir), so fall back to the main checkout or any live
|
|
1246
|
+
// worktree path rather than letting `git -C` fail on a vanished directory.
|
|
1247
|
+
function resolveWorktreeGitDir(
|
|
1248
|
+
session: NonNullable<SideEffectContext['state']['sessions'][number]>,
|
|
1249
|
+
worktree: WorktreeRecord
|
|
1250
|
+
): string {
|
|
1251
|
+
const candidates = [
|
|
1252
|
+
session.worktrees?.find((entry) => entry.source === 'primary')?.path,
|
|
1253
|
+
worktree.repoRoot,
|
|
1254
|
+
...(session.worktrees ?? [])
|
|
1255
|
+
.filter((entry) => entry.id !== worktree.id)
|
|
1256
|
+
.map((entry) => entry.path),
|
|
1257
|
+
]
|
|
1258
|
+
return candidates.find((path) => path !== undefined && existsSync(path)) ?? worktree.repoRoot
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
async function runMoveWorktree(
|
|
1262
|
+
ctx: SideEffectContext,
|
|
1263
|
+
sessionId: string,
|
|
1264
|
+
sourceWorktreeId: string,
|
|
1265
|
+
targetWorktreeId: string,
|
|
1266
|
+
deleteSource: boolean
|
|
1267
|
+
): Promise<void> {
|
|
1268
|
+
const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
|
|
1269
|
+
const source = session?.worktrees?.find((entry) => entry.id === sourceWorktreeId)
|
|
1270
|
+
const target = session?.worktrees?.find((entry) => entry.id === targetWorktreeId)
|
|
1271
|
+
if (!session) throw new Error('session not found')
|
|
1272
|
+
if (!source || !target) throw new Error('worktree not found')
|
|
1273
|
+
if (source.id === target.id) throw new Error('source and target are the same worktree')
|
|
1274
|
+
if (source.branch == null || source.branch === '') {
|
|
1275
|
+
throw new Error('source worktree has no branch to move')
|
|
1276
|
+
}
|
|
1277
|
+
if (deleteSource && source.source === 'primary') {
|
|
1278
|
+
throw new Error('the primary worktree cannot be deleted')
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const sourceLabel = source.branch ?? source.name
|
|
1282
|
+
const targetLabel = target.branch ?? target.name
|
|
1283
|
+
const result = await moveWorktree({
|
|
1284
|
+
sourceBranch: source.branch,
|
|
1285
|
+
sourcePath: source.path,
|
|
1286
|
+
targetPath: target.path,
|
|
1287
|
+
})
|
|
1288
|
+
|
|
1289
|
+
// Toasts surface the outcome everywhere — the picker can be opened outside git
|
|
1290
|
+
// mode (from a tab menu), where the git-pane message would never be seen.
|
|
1291
|
+
if (result.kind === 'dirty-target') {
|
|
1292
|
+
toast.warning(`Target ${targetLabel} has uncommitted changes — commit or stash it first`)
|
|
1293
|
+
return
|
|
1294
|
+
}
|
|
1295
|
+
if (result.kind === 'conflict') {
|
|
1296
|
+
toast.warning(
|
|
1297
|
+
`Move hit conflicts in ${result.files.length} file(s) — left ${sourceLabel} untouched`
|
|
1298
|
+
)
|
|
1299
|
+
return
|
|
1300
|
+
}
|
|
1301
|
+
if (result.kind === 'error') {
|
|
1302
|
+
toast.error(`Move failed: ${result.message}`)
|
|
1303
|
+
return
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Land on the target. When deleting the source, close its terminals and
|
|
1307
|
+
// switch to the target up front, synchronously, BEFORE the slow
|
|
1308
|
+
// `git worktree remove`. Closing the source's active tab re-syncs the active
|
|
1309
|
+
// worktree to a default tab (withActiveTabWorktree); doing the close+switch in
|
|
1310
|
+
// one batch lands on the target with no intermediate render, so the removal
|
|
1311
|
+
// runs with the target already active instead of flashing/sticking to a
|
|
1312
|
+
// default worktree. Re-read state each step so we never resurrect the source.
|
|
1313
|
+
if (deleteSource) {
|
|
1314
|
+
disposeWorktreeTabs({ ...ctx, state: ctx.getState() }, sourceWorktreeId)
|
|
1315
|
+
handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
|
|
1316
|
+
await runDeleteWorktree({ ...ctx, state: ctx.getState() }, sessionId, sourceWorktreeId, true)
|
|
1317
|
+
} else {
|
|
1318
|
+
handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
|
|
1319
|
+
}
|
|
1320
|
+
toast.success(
|
|
1321
|
+
`Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit`
|
|
1322
|
+
)
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function removeWorktreeRecordFromSession(
|
|
1326
|
+
ctx: SideEffectContext,
|
|
1327
|
+
sessionId: string,
|
|
1328
|
+
session: NonNullable<SideEffectContext['state']['sessions'][number]>,
|
|
1329
|
+
worktreeId: string
|
|
1330
|
+
): void {
|
|
1331
|
+
const remaining = (session.worktrees ?? []).filter((entry) => entry.id !== worktreeId)
|
|
1332
|
+
const nextActive =
|
|
1333
|
+
session.activeWorktreeId === worktreeId ? remaining[0] : getActiveWorktree(session)
|
|
1334
|
+
const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
|
|
1335
|
+
...entry,
|
|
1336
|
+
activeWorktreeId: nextActive?.id,
|
|
1337
|
+
projectPath: nextActive?.path ?? entry.projectPath,
|
|
1338
|
+
updatedAt: new Date().toISOString(),
|
|
1339
|
+
worktrees: remaining,
|
|
1340
|
+
}))
|
|
1341
|
+
saveSessionCatalog(sessions)
|
|
1342
|
+
ctx.dispatch({ sessions, type: 'set-sessions' })
|
|
1343
|
+
if (ctx.state.modal.type === 'new-tab' && ctx.state.modal.step === 'worktree') {
|
|
1344
|
+
ctx.dispatch({
|
|
1345
|
+
index: Math.min(ctx.state.modal.selectedIndex, Math.max(0, remaining.length - 1)),
|
|
1346
|
+
type: 'set-modal-selection-index',
|
|
1347
|
+
})
|
|
1348
|
+
}
|
|
1349
|
+
ctx.dispatch({ message: null, type: 'set-new-tab-worktree-delete-state' })
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function isForceableWorktreeDeleteError(message: string): boolean {
|
|
1353
|
+
return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes/i.test(
|
|
1354
|
+
message
|
|
1355
|
+
)
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
class ActiveWorktreeTabsError extends Error {
|
|
1359
|
+
constructor(tabCount: number) {
|
|
1360
|
+
super(
|
|
1361
|
+
`active assistant tabs are using this worktree (${tabCount}). Click [del] again to close them and delete the worktree.`
|
|
1362
|
+
)
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
881
1366
|
function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
|
|
882
1367
|
const { state } = ctx
|
|
883
1368
|
if (state.modal.type !== 'update-available') {
|
|
@@ -901,14 +1386,15 @@ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
|
|
|
901
1386
|
stdin: 'inherit',
|
|
902
1387
|
stdout: 'inherit',
|
|
903
1388
|
})
|
|
904
|
-
void
|
|
1389
|
+
void (async () => {
|
|
1390
|
+
const code = await proc.exited
|
|
905
1391
|
if (code === 0) {
|
|
906
1392
|
process.stdout.write(`\nUpdated. Run \`aimux\` to start the new version.\n`)
|
|
907
1393
|
} else {
|
|
908
1394
|
process.stderr.write(`\nUpdate failed (exit code ${code}).\n`)
|
|
909
1395
|
}
|
|
910
1396
|
process.exit(code ?? 1)
|
|
911
|
-
})
|
|
1397
|
+
})()
|
|
912
1398
|
}
|
|
913
1399
|
|
|
914
1400
|
async function runGitAction(
|
|
@@ -917,11 +1403,12 @@ async function runGitAction(
|
|
|
917
1403
|
pathToInvalidate?: string
|
|
918
1404
|
): Promise<void> {
|
|
919
1405
|
const fallback = ctx.getCurrentSessionProjectPath()
|
|
920
|
-
const repoPath =
|
|
921
|
-
|
|
922
|
-
|
|
1406
|
+
const repoPath =
|
|
1407
|
+
pathToInvalidate != null && pathToInvalidate !== ''
|
|
1408
|
+
? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
|
|
1409
|
+
: undefined
|
|
923
1410
|
const cwd = repoPath ?? fallback
|
|
924
|
-
if (!cwd) return
|
|
1411
|
+
if (!(cwd != null && cwd !== '')) return
|
|
925
1412
|
const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
|
|
926
1413
|
if (result.exitCode !== 0) {
|
|
927
1414
|
const stderr = result.stderr.toString().trim()
|
|
@@ -929,7 +1416,7 @@ async function runGitAction(
|
|
|
929
1416
|
return
|
|
930
1417
|
}
|
|
931
1418
|
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
932
|
-
if (pathToInvalidate) {
|
|
1419
|
+
if (pathToInvalidate != null && pathToInvalidate !== '') {
|
|
933
1420
|
ctx.dispatch({ path: pathToInvalidate, type: 'git-mode-clear-diff-cache' })
|
|
934
1421
|
}
|
|
935
1422
|
}
|
|
@@ -940,7 +1427,7 @@ async function runGitActionAll(
|
|
|
940
1427
|
pathsToInvalidate: string[]
|
|
941
1428
|
): Promise<void> {
|
|
942
1429
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
943
|
-
if (!cwd) return
|
|
1430
|
+
if (!(cwd != null && cwd !== '')) return
|
|
944
1431
|
const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
|
|
945
1432
|
if (result.exitCode !== 0) {
|
|
946
1433
|
const stderr = result.stderr.toString().trim()
|
|
@@ -956,17 +1443,15 @@ async function runGitActionAll(
|
|
|
956
1443
|
async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
|
|
957
1444
|
const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
|
|
958
1445
|
const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
|
|
959
|
-
if (!cwd) return
|
|
1446
|
+
if (!(cwd != null && cwd !== '')) return
|
|
960
1447
|
const absolute = `${cwd}/${path}`
|
|
961
1448
|
try {
|
|
962
1449
|
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'
|
|
1450
|
+
await (stat.isDirectory()
|
|
1451
|
+
? Bun.$`rm -rf -- ${absolute}`.quiet().nothrow()
|
|
1452
|
+
: Bun.file(absolute).unlink())
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
const message = error instanceof Error ? error.message : 'failed to delete file'
|
|
970
1455
|
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
971
1456
|
return
|
|
972
1457
|
}
|
|
@@ -976,7 +1461,7 @@ async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
|
|
|
976
1461
|
|
|
977
1462
|
async function runGitCommit(ctx: SideEffectContext, title: string, body: string): Promise<void> {
|
|
978
1463
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
979
|
-
if (!cwd) return
|
|
1464
|
+
if (!(cwd != null && cwd !== '')) return
|
|
980
1465
|
if (!title) {
|
|
981
1466
|
ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
|
|
982
1467
|
return
|
|
@@ -986,14 +1471,15 @@ async function runGitCommit(ctx: SideEffectContext, title: string, body: string)
|
|
|
986
1471
|
: await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
|
|
987
1472
|
if (result.exitCode !== 0) {
|
|
988
1473
|
const stderr = result.stderr.toString().trim()
|
|
989
|
-
ctx.dispatch({
|
|
990
|
-
|
|
991
|
-
type: 'git-mode-set-message',
|
|
992
|
-
})
|
|
1474
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1475
|
+
toast.error(stderr || 'Commit failed')
|
|
993
1476
|
return
|
|
994
1477
|
}
|
|
995
1478
|
clearAutoCommitForCurrentSession(ctx)
|
|
996
|
-
|
|
1479
|
+
// Match the push flow: clear any inline git-pane message and surface the
|
|
1480
|
+
// result as a toast so it's seen even after leaving git mode.
|
|
1481
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1482
|
+
toast.success(`Committed: ${title}`)
|
|
997
1483
|
}
|
|
998
1484
|
|
|
999
1485
|
async function runGitCommitAuto(
|
|
@@ -1006,7 +1492,7 @@ async function runGitCommitAuto(
|
|
|
1006
1492
|
return
|
|
1007
1493
|
}
|
|
1008
1494
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
1009
|
-
if (!cwd) return
|
|
1495
|
+
if (!(cwd != null && cwd !== '')) return
|
|
1010
1496
|
|
|
1011
1497
|
// If the user has manually staged files, respect that intent and commit
|
|
1012
1498
|
// only the staged set — don't run `git add -A` which would sweep up
|
|
@@ -1017,10 +1503,8 @@ async function runGitCommitAuto(
|
|
|
1017
1503
|
const addArgs = ['add', '-A']
|
|
1018
1504
|
const addResult = await $`git -C ${cwd} ${addArgs}`.quiet().nothrow()
|
|
1019
1505
|
if (addResult.exitCode !== 0) {
|
|
1020
|
-
ctx.dispatch({
|
|
1021
|
-
|
|
1022
|
-
type: 'git-mode-set-message',
|
|
1023
|
-
})
|
|
1506
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1507
|
+
toast.error(addResult.stderr.toString().trim() || 'Auto-commit: git add failed')
|
|
1024
1508
|
return
|
|
1025
1509
|
}
|
|
1026
1510
|
}
|
|
@@ -1030,10 +1514,8 @@ async function runGitCommitAuto(
|
|
|
1030
1514
|
: await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
|
|
1031
1515
|
|
|
1032
1516
|
if (commitResult.exitCode !== 0) {
|
|
1033
|
-
ctx.dispatch({
|
|
1034
|
-
|
|
1035
|
-
type: 'git-mode-set-message',
|
|
1036
|
-
})
|
|
1517
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1518
|
+
toast.error(commitResult.stderr.toString().trim() || 'Auto-commit: commit failed')
|
|
1037
1519
|
return
|
|
1038
1520
|
}
|
|
1039
1521
|
|
|
@@ -1043,7 +1525,7 @@ async function runGitCommitAuto(
|
|
|
1043
1525
|
|
|
1044
1526
|
function clearAutoCommitForCurrentSession(ctx: SideEffectContext): void {
|
|
1045
1527
|
const sessionId = ctx.state.currentSessionId
|
|
1046
|
-
if (!sessionId) return
|
|
1528
|
+
if (!(sessionId != null && sessionId !== '')) return
|
|
1047
1529
|
ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
|
|
1048
1530
|
}
|
|
1049
1531
|
|
|
@@ -1051,16 +1533,13 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
|
|
|
1051
1533
|
const session = ctx.state.sessions.find((s) => s.id === sessionId)
|
|
1052
1534
|
const panel = ctx.state.gitPanel
|
|
1053
1535
|
if (panel.error !== null) {
|
|
1054
|
-
|
|
1536
|
+
toast.warning('Auto-commit: git panel unavailable')
|
|
1055
1537
|
ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
|
|
1056
1538
|
return
|
|
1057
1539
|
}
|
|
1058
1540
|
const tab = ctx.activeTab
|
|
1059
1541
|
if (!tab) {
|
|
1060
|
-
|
|
1061
|
-
message: 'auto-commit: no active assistant tab — open a claude/codex session first',
|
|
1062
|
-
type: 'git-mode-set-message',
|
|
1063
|
-
})
|
|
1542
|
+
toast.warning('Auto-commit: no active assistant tab — open a claude/codex session first')
|
|
1064
1543
|
ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
|
|
1065
1544
|
return
|
|
1066
1545
|
}
|
|
@@ -1080,7 +1559,7 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
|
|
|
1080
1559
|
|
|
1081
1560
|
async function runGitPush(ctx: SideEffectContext): Promise<void> {
|
|
1082
1561
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
1083
|
-
if (!cwd) return
|
|
1562
|
+
if (!(cwd != null && cwd !== '')) return
|
|
1084
1563
|
ctx.dispatch({ message: 'pushing…', type: 'git-mode-set-message' })
|
|
1085
1564
|
|
|
1086
1565
|
const upstream = await $`git -C ${cwd} rev-parse --abbrev-ref --symbolic-full-name @{u}`
|
|
@@ -1092,13 +1571,12 @@ async function runGitPush(ctx: SideEffectContext): Promise<void> {
|
|
|
1092
1571
|
? await $`git -C ${cwd} push`.quiet().nothrow()
|
|
1093
1572
|
: await $`git -C ${cwd} push --set-upstream origin HEAD`.quiet().nothrow()
|
|
1094
1573
|
|
|
1574
|
+
// Clear the inline "pushing…" progress; surface the result as a toast so it's
|
|
1575
|
+
// visible even after leaving git mode (and so push failures aren't missed).
|
|
1576
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1095
1577
|
if (result.exitCode !== 0) {
|
|
1096
|
-
|
|
1097
|
-
ctx.dispatch({
|
|
1098
|
-
message: stderr || 'push failed',
|
|
1099
|
-
type: 'git-mode-set-message',
|
|
1100
|
-
})
|
|
1578
|
+
toast.error(result.stderr.toString().trim() || 'Push failed')
|
|
1101
1579
|
return
|
|
1102
1580
|
}
|
|
1103
|
-
|
|
1581
|
+
toast.success('Pushed')
|
|
1104
1582
|
}
|