@brimveyn/aimux 1.12.2 → 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/pty-write.ts +52 -0
- 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 +598 -102
- package/src/app-runtime/snippet-actions.ts +25 -11
- 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 +143 -8
- package/src/app.tsx +25 -9
- 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 +13 -1
- package/src/input/raw-input-handler.ts +34 -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 +18 -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 +8 -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 +112 -0
- package/src/snippets/run-shell-var.ts +93 -0
- package/src/snippets/trigger-detector.ts +105 -0
- 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 +385 -24
- 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/snippet-catalog.ts +65 -4
- package/src/state/store.ts +1 -0
- package/src/state/toast-store.ts +95 -0
- package/src/state/types.ts +108 -16
- package/src/state/validation.ts +47 -3
- 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-editor-modal.tsx +10 -6
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +25 -6
- 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 +65 -14
- 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,20 +1,41 @@
|
|
|
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
|
-
import {
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
11
|
+
import { mkdir } from 'node:fs/promises'
|
|
12
|
+
import { dirname, join as joinPath, resolve as resolvePath } from 'node:path'
|
|
10
13
|
|
|
11
14
|
import type { SideEffect } from '../input/modes/types'
|
|
12
15
|
import type { SessionBackend } from '../session-backend/types'
|
|
16
|
+
import type { ThemeId } from '../ui/themes'
|
|
13
17
|
|
|
14
18
|
import { loadConfig, saveConfig } from '../config'
|
|
15
19
|
import { logInputDebug } from '../debug/input-log'
|
|
16
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'
|
|
17
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'
|
|
38
|
+
import { getProfileConfigDir } from '../profile-paths'
|
|
18
39
|
import {
|
|
19
40
|
getAllAssistantOptions,
|
|
20
41
|
getAssistantOption,
|
|
@@ -33,19 +54,23 @@ import {
|
|
|
33
54
|
splitNode,
|
|
34
55
|
} from '../state/layout-tree'
|
|
35
56
|
import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
|
|
57
|
+
import { saveSessionCatalog } from '../state/session-catalog'
|
|
58
|
+
import { getActiveWorktree, getSessionProjectPath } from '../state/session-worktrees'
|
|
59
|
+
import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
|
|
36
60
|
import { createDefaultTerminalModes } from '../state/terminal-modes'
|
|
61
|
+
import { toast } from '../state/toast-store'
|
|
37
62
|
import {
|
|
38
63
|
type AppAction,
|
|
39
64
|
type AppState,
|
|
40
65
|
type AssistantId,
|
|
41
66
|
DEFAULT_SCROLL_INTENT,
|
|
42
67
|
type TabSession,
|
|
68
|
+
type WorktreeRecord,
|
|
43
69
|
} from '../state/types'
|
|
44
70
|
import { saveCurrentWorkspace } from '../state/workspace-save'
|
|
45
71
|
import { filterThemeIds } from '../ui/filter-themes'
|
|
46
72
|
import { scrollGitDiff } from '../ui/git-view-controls'
|
|
47
73
|
import { applyTheme, getCurrentMode, getTransparent, setMode, setTransparent } from '../ui/theme'
|
|
48
|
-
import { type ThemeId } from '../ui/themes'
|
|
49
74
|
import { triggerAutoCommitNow } from './auto-commit-ref'
|
|
50
75
|
import {
|
|
51
76
|
handleCreateSessionEffect,
|
|
@@ -73,11 +98,16 @@ export interface SideEffectContext {
|
|
|
73
98
|
clearIdleTimer: (tabId: string) => void
|
|
74
99
|
clearStartupGrace: (tabId: string) => void
|
|
75
100
|
startStartupGrace: (tabId: string, timeoutMs: number) => void
|
|
101
|
+
getState: () => AppState
|
|
76
102
|
getCurrentSessionProjectPath: () => string | undefined
|
|
77
103
|
}
|
|
78
104
|
|
|
79
105
|
function getSelectedAssistantOption(state: AppState) {
|
|
80
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
|
+
}
|
|
81
111
|
const filter = state.modal.type === 'new-tab' ? state.modal.editBuffer : null
|
|
82
112
|
const list = filterAssistants(all, filter)
|
|
83
113
|
return list[state.modal.selectedIndex] ?? list[0] ?? getAssistantOption(0)
|
|
@@ -136,12 +166,12 @@ function openSelectedSessionRename(ctx: SideEffectContext): void {
|
|
|
136
166
|
function pasteSnippetToActiveGroup(ctx: SideEffectContext): void {
|
|
137
167
|
const { activeTab, backend, state } = ctx
|
|
138
168
|
const snippet = getSelectedSnippet(state)
|
|
139
|
-
if (!snippet || !state.activeTabId) {
|
|
169
|
+
if (!snippet || !(state.activeTabId != null && state.activeTabId !== '')) {
|
|
140
170
|
return
|
|
141
171
|
}
|
|
142
172
|
|
|
143
173
|
const groupId = getGroupIdForTab(state.tabGroupMap, state.activeTabId)
|
|
144
|
-
const groupTree = groupId ? state.layoutTrees[groupId] : null
|
|
174
|
+
const groupTree = groupId != null && groupId !== '' ? state.layoutTrees[groupId] : null
|
|
145
175
|
if (!groupTree) {
|
|
146
176
|
pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet, ctx.dispatch)
|
|
147
177
|
return
|
|
@@ -196,7 +226,7 @@ function applyThemeEffect(
|
|
|
196
226
|
return
|
|
197
227
|
case 'confirm': {
|
|
198
228
|
const selectedId = ids[state.modal.selectedIndex]
|
|
199
|
-
if (selectedId) {
|
|
229
|
+
if (selectedId != null && selectedId !== '') {
|
|
200
230
|
applyTheme(selectedId)
|
|
201
231
|
ctx.setThemeId(selectedId)
|
|
202
232
|
saveConfig({ ...loadConfig(), themeId: selectedId })
|
|
@@ -207,7 +237,7 @@ function applyThemeEffect(
|
|
|
207
237
|
if (ids.length === 0) return
|
|
208
238
|
const nextIndex = (state.modal.selectedIndex + effect.delta + ids.length) % ids.length
|
|
209
239
|
const previewId = ids[nextIndex]
|
|
210
|
-
if (previewId) {
|
|
240
|
+
if (previewId != null && previewId !== '') {
|
|
211
241
|
applyTheme(previewId)
|
|
212
242
|
}
|
|
213
243
|
return
|
|
@@ -220,7 +250,16 @@ function confirmSplitSelection(ctx: SideEffectContext): void {
|
|
|
220
250
|
const option = getSelectedAssistantOption(state)
|
|
221
251
|
const direction = state.modal.type === 'split-picker' ? state.modal.splitDirection : 'vertical'
|
|
222
252
|
const customCommand = state.customCommands[option.id]
|
|
223
|
-
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
|
+
)
|
|
224
263
|
dispatch({ type: 'close-modal' })
|
|
225
264
|
executeSplitPane(ctx, direction, tab)
|
|
226
265
|
dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
|
|
@@ -243,7 +282,8 @@ function getSelectedSnippet(state: AppState) {
|
|
|
243
282
|
export function createTabSession(
|
|
244
283
|
assistant: AssistantId,
|
|
245
284
|
customCommand?: string,
|
|
246
|
-
customCommands?: Record<string, string
|
|
285
|
+
customCommands?: Record<string, string>,
|
|
286
|
+
worktreeId?: string
|
|
247
287
|
): TabSession {
|
|
248
288
|
const allOptions = getAllAssistantOptions(customCommands ?? {})
|
|
249
289
|
const option = allOptions.find((o) => o.id === assistant) ?? getAssistantOption(0)
|
|
@@ -258,6 +298,7 @@ export function createTabSession(
|
|
|
258
298
|
status: 'starting',
|
|
259
299
|
terminalModes: createDefaultTerminalModes(),
|
|
260
300
|
title: option.label,
|
|
301
|
+
worktreeId,
|
|
261
302
|
}
|
|
262
303
|
}
|
|
263
304
|
|
|
@@ -305,10 +346,38 @@ export function startTabSession(
|
|
|
305
346
|
})
|
|
306
347
|
}
|
|
307
348
|
|
|
308
|
-
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 {
|
|
309
368
|
const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
|
|
310
369
|
const customCommand = state.customCommands[assistant]
|
|
311
|
-
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
|
+
)
|
|
312
381
|
logInputDebug('app.launchAssistant', {
|
|
313
382
|
assistant,
|
|
314
383
|
command: tab.command,
|
|
@@ -324,10 +393,60 @@ function launchAssistant(ctx: SideEffectContext, assistant: AssistantId): void {
|
|
|
324
393
|
tab,
|
|
325
394
|
state.layout.terminalCols,
|
|
326
395
|
state.layout.terminalRows,
|
|
327
|
-
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
|
|
328
432
|
)
|
|
329
433
|
}
|
|
330
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
|
+
|
|
331
450
|
function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
|
|
332
451
|
const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
|
|
333
452
|
startTabSession(
|
|
@@ -338,7 +457,7 @@ function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
|
|
|
338
457
|
tab,
|
|
339
458
|
state.layout.terminalCols,
|
|
340
459
|
state.layout.terminalRows,
|
|
341
|
-
ctx
|
|
460
|
+
getTabProjectPath(ctx, tab)
|
|
342
461
|
)
|
|
343
462
|
}
|
|
344
463
|
|
|
@@ -349,7 +468,7 @@ function executeSplitPane(
|
|
|
349
468
|
): void {
|
|
350
469
|
const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
|
|
351
470
|
const activeTabId = state.activeTabId
|
|
352
|
-
if (!activeTabId) {
|
|
471
|
+
if (!(activeTabId != null && activeTabId !== '')) {
|
|
353
472
|
return
|
|
354
473
|
}
|
|
355
474
|
|
|
@@ -368,7 +487,7 @@ function executeSplitPane(
|
|
|
368
487
|
tab,
|
|
369
488
|
Math.max(1, (paneRect?.cols ?? state.layout.terminalCols) - PANE_BORDER * 2),
|
|
370
489
|
Math.max(1, (paneRect?.rows ?? state.layout.terminalRows) - PANE_BORDER * 2),
|
|
371
|
-
ctx
|
|
490
|
+
getTabProjectPath(ctx, tab)
|
|
372
491
|
)
|
|
373
492
|
}
|
|
374
493
|
|
|
@@ -385,7 +504,21 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
385
504
|
}
|
|
386
505
|
case 'launch-selected-assistant': {
|
|
387
506
|
const option = getSelectedAssistantOption(state)
|
|
388
|
-
|
|
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))
|
|
517
|
+
return
|
|
518
|
+
}
|
|
519
|
+
case 'edit-selected-assistant': {
|
|
520
|
+
const option = getSelectedAssistantOption(state)
|
|
521
|
+
dispatch({ assistantId: option.id, type: 'open-edit-custom-command' })
|
|
389
522
|
return
|
|
390
523
|
}
|
|
391
524
|
case 'confirm-selected-session': {
|
|
@@ -400,6 +533,47 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
400
533
|
handleDeleteSessionEffect(state, backend, dispatch, effect.sessionId)
|
|
401
534
|
return
|
|
402
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
|
+
}
|
|
403
577
|
case 'open-rename-selected-session': {
|
|
404
578
|
openSelectedSessionRename(ctx)
|
|
405
579
|
return
|
|
@@ -468,12 +642,18 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
468
642
|
return
|
|
469
643
|
}
|
|
470
644
|
case 'split-pane': {
|
|
471
|
-
const sourceTab =
|
|
472
|
-
|
|
473
|
-
|
|
645
|
+
const sourceTab =
|
|
646
|
+
effect.sourceTabId != null && effect.sourceTabId !== ''
|
|
647
|
+
? state.tabs.find((t) => t.id === effect.sourceTabId)
|
|
648
|
+
: undefined
|
|
474
649
|
const assistant = sourceTab?.assistant ?? 'terminal'
|
|
475
650
|
const customCommand = state.customCommands[assistant]
|
|
476
|
-
const tab = createTabSession(
|
|
651
|
+
const tab = createTabSession(
|
|
652
|
+
assistant,
|
|
653
|
+
customCommand,
|
|
654
|
+
state.customCommands,
|
|
655
|
+
sourceTab?.worktreeId
|
|
656
|
+
)
|
|
477
657
|
executeSplitPane(ctx, effect.direction, tab)
|
|
478
658
|
return
|
|
479
659
|
}
|
|
@@ -546,42 +726,42 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
546
726
|
return
|
|
547
727
|
}
|
|
548
728
|
case 'git-stage': {
|
|
549
|
-
void enqueueGitOp(() => runGitAction(ctx, ['add', '--', effect.path], effect.path))
|
|
729
|
+
void enqueueGitOp(async () => runGitAction(ctx, ['add', '--', effect.path], effect.path))
|
|
550
730
|
return
|
|
551
731
|
}
|
|
552
732
|
case 'git-unstage': {
|
|
553
|
-
void enqueueGitOp(() =>
|
|
733
|
+
void enqueueGitOp(async () =>
|
|
554
734
|
runGitAction(ctx, ['restore', '--staged', '--', effect.path], effect.path)
|
|
555
735
|
)
|
|
556
736
|
return
|
|
557
737
|
}
|
|
558
738
|
case 'git-stage-all': {
|
|
559
739
|
const paths = ctx.state.gitPanel.files.map((f) => f.path)
|
|
560
|
-
void enqueueGitOp(() => runGitActionAll(ctx, ['add', '-A'], paths))
|
|
740
|
+
void enqueueGitOp(async () => runGitActionAll(ctx, ['add', '-A'], paths))
|
|
561
741
|
return
|
|
562
742
|
}
|
|
563
743
|
case 'git-unstage-all': {
|
|
564
744
|
const paths = ctx.state.gitPanel.files.map((f) => f.path)
|
|
565
|
-
void enqueueGitOp(() => runGitActionAll(ctx, ['reset'], paths))
|
|
745
|
+
void enqueueGitOp(async () => runGitActionAll(ctx, ['reset'], paths))
|
|
566
746
|
return
|
|
567
747
|
}
|
|
568
748
|
case 'git-restore': {
|
|
569
|
-
void enqueueGitOp(() => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
|
|
749
|
+
void enqueueGitOp(async () => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
|
|
570
750
|
return
|
|
571
751
|
}
|
|
572
752
|
case 'git-rm': {
|
|
573
|
-
void enqueueGitOp(() => runGitRm(ctx, effect.path))
|
|
753
|
+
void enqueueGitOp(async () => runGitRm(ctx, effect.path))
|
|
574
754
|
return
|
|
575
755
|
}
|
|
576
756
|
case 'git-commit': {
|
|
577
757
|
const { body, title } = effect
|
|
578
|
-
void enqueueGitOp(() => runGitCommit(ctx, title, body))
|
|
758
|
+
void enqueueGitOp(async () => runGitCommit(ctx, title, body))
|
|
579
759
|
return
|
|
580
760
|
}
|
|
581
761
|
case 'git-commit-auto': {
|
|
582
762
|
if (!isAutoCommitEnabled()) return
|
|
583
763
|
const { body, title } = effect
|
|
584
|
-
void enqueueGitOp(() => runGitCommitAuto(ctx, title, body))
|
|
764
|
+
void enqueueGitOp(async () => runGitCommitAuto(ctx, title, body))
|
|
585
765
|
return
|
|
586
766
|
}
|
|
587
767
|
case 'generate-auto-commit-now': {
|
|
@@ -590,7 +770,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
590
770
|
return
|
|
591
771
|
}
|
|
592
772
|
case 'git-push': {
|
|
593
|
-
void enqueueGitOp(() => runGitPush(ctx))
|
|
773
|
+
void enqueueGitOp(async () => runGitPush(ctx))
|
|
594
774
|
return
|
|
595
775
|
}
|
|
596
776
|
case 'confirm-update-selection': {
|
|
@@ -617,34 +797,74 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
617
797
|
openFileInEditor(ctx, effect.path)
|
|
618
798
|
return
|
|
619
799
|
}
|
|
800
|
+
case 'open-selected-snippet-source-in-editor': {
|
|
801
|
+
openSelectedSnippetSourceInEditor(ctx)
|
|
802
|
+
return
|
|
803
|
+
}
|
|
620
804
|
default:
|
|
621
805
|
effect satisfies never
|
|
622
806
|
}
|
|
623
807
|
}
|
|
624
808
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
809
|
+
/**
|
|
810
|
+
* Open the file backing the currently selected snippet in the user's editor.
|
|
811
|
+
* Config-pinned snippets (id starts with `config:`) live in `aimux.config.ts`
|
|
812
|
+
* (or `.js`); user-edited snippets live in `aimux-snippets.json`.
|
|
813
|
+
*
|
|
814
|
+
* On error (no editor, editor not in PATH) the failure is silent: there is no
|
|
815
|
+
* snippet-picker status line. The user can check the debug log.
|
|
816
|
+
*/
|
|
817
|
+
function openSelectedSnippetSourceInEditor(ctx: SideEffectContext): void {
|
|
818
|
+
const snippet = getSelectedSnippet(ctx.state)
|
|
819
|
+
if (!snippet) return
|
|
820
|
+
|
|
821
|
+
const configDir = getProfileConfigDir()
|
|
822
|
+
let absolutePath: string
|
|
823
|
+
|
|
824
|
+
if (isConfigSnippetId(snippet.id)) {
|
|
825
|
+
const tsPath = joinPath(configDir, 'aimux.config.ts')
|
|
826
|
+
const jsPath = joinPath(configDir, 'aimux.config.js')
|
|
827
|
+
absolutePath = existsSync(jsPath) && !existsSync(tsPath) ? jsPath : tsPath
|
|
828
|
+
} else {
|
|
829
|
+
absolutePath = getSnippetsCatalogPath()
|
|
634
830
|
}
|
|
635
831
|
|
|
832
|
+
launchEditorOnFile(ctx, absolutePath, configDir, (message) => {
|
|
833
|
+
logInputDebug('snippets.openInEditor.error', { message, path: absolutePath })
|
|
834
|
+
ctx.dispatch({ message, type: 'snippet-picker-set-message' })
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
|
|
636
839
|
const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
|
|
637
840
|
const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
|
|
638
|
-
if (!cwd) {
|
|
841
|
+
if (!(cwd != null && cwd !== '')) {
|
|
639
842
|
ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
|
|
640
843
|
return
|
|
641
844
|
}
|
|
642
845
|
const absolutePath = resolvePath(cwd, relPath)
|
|
846
|
+
launchEditorOnFile(ctx, absolutePath, cwd, (message) =>
|
|
847
|
+
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
848
|
+
)
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function launchEditorOnFile(
|
|
852
|
+
ctx: SideEffectContext,
|
|
853
|
+
absolutePath: string,
|
|
854
|
+
cwd: string,
|
|
855
|
+
onError: (message: string) => void
|
|
856
|
+
): void {
|
|
857
|
+
const config = getExternalEditorConfig()
|
|
858
|
+
const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
|
|
859
|
+
if (rawCommand == null || rawCommand === '' || rawCommand.trim() === '') {
|
|
860
|
+
onError('no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts')
|
|
861
|
+
return
|
|
862
|
+
}
|
|
643
863
|
|
|
644
864
|
const cmdParts = shellSplit(rawCommand)
|
|
645
865
|
const executable = cmdParts[0]
|
|
646
|
-
if (!executable) {
|
|
647
|
-
|
|
866
|
+
if (!(executable != null && executable !== '')) {
|
|
867
|
+
onError('invalid editor command')
|
|
648
868
|
return
|
|
649
869
|
}
|
|
650
870
|
const baseName = executable.split('/').pop() ?? executable
|
|
@@ -653,16 +873,12 @@ function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
|
|
|
653
873
|
const kind: 'gui' | 'tui' = config.kind ?? (KNOWN_GUI_EDITORS.has(baseName) ? 'gui' : 'tui')
|
|
654
874
|
|
|
655
875
|
const templateArgs = config.args ?? DEFAULT_EDITOR_ARGS[baseName] ?? ['{file}']
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
// editor-side "restore last cursor position" behavior (e.g. vscode).
|
|
876
|
+
// No line target — let substitution strip `{line}` placeholders so we don't
|
|
877
|
+
// defeat the editor's "restore last cursor position" feature.
|
|
659
878
|
const resolvedArgs = [...extraCmdArgs, ...substituteEditorArgs(templateArgs, absolutePath)]
|
|
660
879
|
|
|
661
880
|
if (!isCommandAvailable(executable)) {
|
|
662
|
-
|
|
663
|
-
message: `editor not found in PATH: ${executable}`,
|
|
664
|
-
type: 'git-mode-set-message',
|
|
665
|
-
})
|
|
881
|
+
onError(`editor not found in PATH: ${executable}`)
|
|
666
882
|
return
|
|
667
883
|
}
|
|
668
884
|
|
|
@@ -671,9 +887,6 @@ function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
|
|
|
671
887
|
return
|
|
672
888
|
}
|
|
673
889
|
|
|
674
|
-
// TUI editor — if the user explicitly configured `terminal`, spawn a new
|
|
675
|
-
// terminal window with their template. Otherwise default to inline shellout:
|
|
676
|
-
// suspend the renderer, hand the TTY to the editor, resume on exit.
|
|
677
890
|
if (config.terminal && config.terminal.length > 0) {
|
|
678
891
|
const shellCmd = buildShellCmd(cwd, executable, resolvedArgs)
|
|
679
892
|
const argv = config.terminal.map((a) =>
|
|
@@ -712,7 +925,7 @@ function substituteEditorArgs(template: string[], file: string, line?: string):
|
|
|
712
925
|
// Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
|
|
713
926
|
if (/^[+:]?\{line\}$/.test(arg)) continue
|
|
714
927
|
// Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
|
|
715
|
-
out.push(arg.
|
|
928
|
+
out.push(arg.replaceAll(/[:+]\{line\}/g, '').replaceAll('{file}', file))
|
|
716
929
|
}
|
|
717
930
|
return out
|
|
718
931
|
}
|
|
@@ -775,13 +988,15 @@ function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): vo
|
|
|
775
988
|
const stderr = await new Response(child.stderr).text()
|
|
776
989
|
const code = await child.exited
|
|
777
990
|
if (code !== 0) {
|
|
778
|
-
const
|
|
991
|
+
const firstStderrLine = stderr.trim().split('\n')[0]
|
|
992
|
+
const firstLine =
|
|
993
|
+
firstStderrLine != null && firstStderrLine !== '' ? firstStderrLine : `exit ${code}`
|
|
779
994
|
ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
|
|
780
995
|
}
|
|
781
996
|
})()
|
|
782
997
|
child.unref()
|
|
783
|
-
} catch (
|
|
784
|
-
const msg =
|
|
998
|
+
} catch (error) {
|
|
999
|
+
const msg = error instanceof Error ? error.message : 'failed to spawn'
|
|
785
1000
|
ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
|
|
786
1001
|
}
|
|
787
1002
|
}
|
|
@@ -808,8 +1023,8 @@ async function openEditorInline(
|
|
|
808
1023
|
stdout: 'inherit',
|
|
809
1024
|
})
|
|
810
1025
|
await proc.exited
|
|
811
|
-
} catch (
|
|
812
|
-
const msg =
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
const msg = error instanceof Error ? error.message : 'failed to spawn editor'
|
|
813
1028
|
ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
|
|
814
1029
|
} finally {
|
|
815
1030
|
renderer.currentRenderBuffer.clear()
|
|
@@ -820,9 +1035,9 @@ async function openEditorInline(
|
|
|
820
1035
|
|
|
821
1036
|
function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
|
|
822
1037
|
const { backend, dispatch, state } = ctx
|
|
823
|
-
const ordered = state.sessions
|
|
824
|
-
.
|
|
825
|
-
|
|
1038
|
+
const ordered = [...state.sessions].sort(
|
|
1039
|
+
(a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
|
|
1040
|
+
)
|
|
826
1041
|
const target = ordered[index - 1]
|
|
827
1042
|
if (!target) {
|
|
828
1043
|
logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
|
|
@@ -837,6 +1052,295 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
|
|
|
837
1052
|
handleSwitchSessionEffect(state, backend, dispatch, target)
|
|
838
1053
|
}
|
|
839
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
|
+
|
|
840
1344
|
function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
|
|
841
1345
|
const { state } = ctx
|
|
842
1346
|
if (state.modal.type !== 'update-available') {
|
|
@@ -876,11 +1380,12 @@ async function runGitAction(
|
|
|
876
1380
|
pathToInvalidate?: string
|
|
877
1381
|
): Promise<void> {
|
|
878
1382
|
const fallback = ctx.getCurrentSessionProjectPath()
|
|
879
|
-
const repoPath =
|
|
880
|
-
|
|
881
|
-
|
|
1383
|
+
const repoPath =
|
|
1384
|
+
pathToInvalidate != null && pathToInvalidate !== ''
|
|
1385
|
+
? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
|
|
1386
|
+
: undefined
|
|
882
1387
|
const cwd = repoPath ?? fallback
|
|
883
|
-
if (!cwd) return
|
|
1388
|
+
if (!(cwd != null && cwd !== '')) return
|
|
884
1389
|
const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
|
|
885
1390
|
if (result.exitCode !== 0) {
|
|
886
1391
|
const stderr = result.stderr.toString().trim()
|
|
@@ -888,7 +1393,7 @@ async function runGitAction(
|
|
|
888
1393
|
return
|
|
889
1394
|
}
|
|
890
1395
|
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
891
|
-
if (pathToInvalidate) {
|
|
1396
|
+
if (pathToInvalidate != null && pathToInvalidate !== '') {
|
|
892
1397
|
ctx.dispatch({ path: pathToInvalidate, type: 'git-mode-clear-diff-cache' })
|
|
893
1398
|
}
|
|
894
1399
|
}
|
|
@@ -899,7 +1404,7 @@ async function runGitActionAll(
|
|
|
899
1404
|
pathsToInvalidate: string[]
|
|
900
1405
|
): Promise<void> {
|
|
901
1406
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
902
|
-
if (!cwd) return
|
|
1407
|
+
if (!(cwd != null && cwd !== '')) return
|
|
903
1408
|
const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
|
|
904
1409
|
if (result.exitCode !== 0) {
|
|
905
1410
|
const stderr = result.stderr.toString().trim()
|
|
@@ -915,17 +1420,15 @@ async function runGitActionAll(
|
|
|
915
1420
|
async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
|
|
916
1421
|
const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
|
|
917
1422
|
const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
|
|
918
|
-
if (!cwd) return
|
|
1423
|
+
if (!(cwd != null && cwd !== '')) return
|
|
919
1424
|
const absolute = `${cwd}/${path}`
|
|
920
1425
|
try {
|
|
921
1426
|
const stat = await Bun.file(absolute).stat()
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
} catch (err) {
|
|
928
|
-
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'
|
|
929
1432
|
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
930
1433
|
return
|
|
931
1434
|
}
|
|
@@ -935,7 +1438,7 @@ async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
|
|
|
935
1438
|
|
|
936
1439
|
async function runGitCommit(ctx: SideEffectContext, title: string, body: string): Promise<void> {
|
|
937
1440
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
938
|
-
if (!cwd) return
|
|
1441
|
+
if (!(cwd != null && cwd !== '')) return
|
|
939
1442
|
if (!title) {
|
|
940
1443
|
ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
|
|
941
1444
|
return
|
|
@@ -945,14 +1448,15 @@ async function runGitCommit(ctx: SideEffectContext, title: string, body: string)
|
|
|
945
1448
|
: await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
|
|
946
1449
|
if (result.exitCode !== 0) {
|
|
947
1450
|
const stderr = result.stderr.toString().trim()
|
|
948
|
-
ctx.dispatch({
|
|
949
|
-
|
|
950
|
-
type: 'git-mode-set-message',
|
|
951
|
-
})
|
|
1451
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1452
|
+
toast.error(stderr || 'Commit failed')
|
|
952
1453
|
return
|
|
953
1454
|
}
|
|
954
1455
|
clearAutoCommitForCurrentSession(ctx)
|
|
955
|
-
|
|
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}`)
|
|
956
1460
|
}
|
|
957
1461
|
|
|
958
1462
|
async function runGitCommitAuto(
|
|
@@ -965,7 +1469,7 @@ async function runGitCommitAuto(
|
|
|
965
1469
|
return
|
|
966
1470
|
}
|
|
967
1471
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
968
|
-
if (!cwd) return
|
|
1472
|
+
if (!(cwd != null && cwd !== '')) return
|
|
969
1473
|
|
|
970
1474
|
// If the user has manually staged files, respect that intent and commit
|
|
971
1475
|
// only the staged set — don't run `git add -A` which would sweep up
|
|
@@ -976,10 +1480,8 @@ async function runGitCommitAuto(
|
|
|
976
1480
|
const addArgs = ['add', '-A']
|
|
977
1481
|
const addResult = await $`git -C ${cwd} ${addArgs}`.quiet().nothrow()
|
|
978
1482
|
if (addResult.exitCode !== 0) {
|
|
979
|
-
ctx.dispatch({
|
|
980
|
-
|
|
981
|
-
type: 'git-mode-set-message',
|
|
982
|
-
})
|
|
1483
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1484
|
+
toast.error(addResult.stderr.toString().trim() || 'Auto-commit: git add failed')
|
|
983
1485
|
return
|
|
984
1486
|
}
|
|
985
1487
|
}
|
|
@@ -989,10 +1491,8 @@ async function runGitCommitAuto(
|
|
|
989
1491
|
: await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
|
|
990
1492
|
|
|
991
1493
|
if (commitResult.exitCode !== 0) {
|
|
992
|
-
ctx.dispatch({
|
|
993
|
-
|
|
994
|
-
type: 'git-mode-set-message',
|
|
995
|
-
})
|
|
1494
|
+
ctx.dispatch({ message: null, type: 'git-mode-set-message' })
|
|
1495
|
+
toast.error(commitResult.stderr.toString().trim() || 'Auto-commit: commit failed')
|
|
996
1496
|
return
|
|
997
1497
|
}
|
|
998
1498
|
|
|
@@ -1002,7 +1502,7 @@ async function runGitCommitAuto(
|
|
|
1002
1502
|
|
|
1003
1503
|
function clearAutoCommitForCurrentSession(ctx: SideEffectContext): void {
|
|
1004
1504
|
const sessionId = ctx.state.currentSessionId
|
|
1005
|
-
if (!sessionId) return
|
|
1505
|
+
if (!(sessionId != null && sessionId !== '')) return
|
|
1006
1506
|
ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
|
|
1007
1507
|
}
|
|
1008
1508
|
|
|
@@ -1010,16 +1510,13 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
|
|
|
1010
1510
|
const session = ctx.state.sessions.find((s) => s.id === sessionId)
|
|
1011
1511
|
const panel = ctx.state.gitPanel
|
|
1012
1512
|
if (panel.error !== null) {
|
|
1013
|
-
|
|
1513
|
+
toast.warning('Auto-commit: git panel unavailable')
|
|
1014
1514
|
ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
|
|
1015
1515
|
return
|
|
1016
1516
|
}
|
|
1017
1517
|
const tab = ctx.activeTab
|
|
1018
1518
|
if (!tab) {
|
|
1019
|
-
|
|
1020
|
-
message: 'auto-commit: no active assistant tab — open a claude/codex session first',
|
|
1021
|
-
type: 'git-mode-set-message',
|
|
1022
|
-
})
|
|
1519
|
+
toast.warning('Auto-commit: no active assistant tab — open a claude/codex session first')
|
|
1023
1520
|
ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
|
|
1024
1521
|
return
|
|
1025
1522
|
}
|
|
@@ -1039,7 +1536,7 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
|
|
|
1039
1536
|
|
|
1040
1537
|
async function runGitPush(ctx: SideEffectContext): Promise<void> {
|
|
1041
1538
|
const cwd = ctx.getCurrentSessionProjectPath()
|
|
1042
|
-
if (!cwd) return
|
|
1539
|
+
if (!(cwd != null && cwd !== '')) return
|
|
1043
1540
|
ctx.dispatch({ message: 'pushing…', type: 'git-mode-set-message' })
|
|
1044
1541
|
|
|
1045
1542
|
const upstream = await $`git -C ${cwd} rev-parse --abbrev-ref --symbolic-full-name @{u}`
|
|
@@ -1051,13 +1548,12 @@ async function runGitPush(ctx: SideEffectContext): Promise<void> {
|
|
|
1051
1548
|
? await $`git -C ${cwd} push`.quiet().nothrow()
|
|
1052
1549
|
: await $`git -C ${cwd} push --set-upstream origin HEAD`.quiet().nothrow()
|
|
1053
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' })
|
|
1054
1554
|
if (result.exitCode !== 0) {
|
|
1055
|
-
|
|
1056
|
-
ctx.dispatch({
|
|
1057
|
-
message: stderr || 'push failed',
|
|
1058
|
-
type: 'git-mode-set-message',
|
|
1059
|
-
})
|
|
1555
|
+
toast.error(result.stderr.toString().trim() || 'Push failed')
|
|
1060
1556
|
return
|
|
1061
1557
|
}
|
|
1062
|
-
|
|
1558
|
+
toast.success('Pushed')
|
|
1063
1559
|
}
|