@brimveyn/aimux 1.14.0 → 1.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +145 -1
- package/src/app-runtime/split-drag-controller.ts +3 -1
- package/src/app-runtime/use-terminal-resize.ts +1 -4
- package/src/app.tsx +0 -3
- package/src/config.ts +1 -12
- package/src/git/worktree-branch-poller.ts +54 -0
- package/src/input/modes/types.ts +2 -0
- package/src/state/layout-tree.ts +114 -4
- package/src/state/reducers/session-state.ts +20 -0
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/reducers/ui-state.ts +0 -3
- package/src/state/session-worktrees.ts +45 -2
- package/src/state/store.ts +0 -3
- package/src/state/tab-entries.ts +74 -0
- package/src/state/types.ts +1 -4
- package/src/state/workspace-save.ts +0 -1
- package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
- package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
- package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
- package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
- package/src/ui/components/layout/split-layout.tsx +20 -39
- package/src/ui/components/layout/terminal-pane.tsx +30 -0
- package/src/ui/components/layout/top-tab-bar.tsx +304 -0
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
- package/src/ui/root.tsx +68 -63
- package/src/ui/components/layout/session-bar.tsx +0 -296
- package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
- package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
- package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +0 -36
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.1",
|
|
4
4
|
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"bump": "bun run scripts/bump.ts"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@brimveyn/aimux-config": "0.6.
|
|
63
|
+
"@brimveyn/aimux-config": "0.6.4",
|
|
64
64
|
"@opentui/core": "^0.1.90",
|
|
65
65
|
"@opentui/react": "^0.1.90",
|
|
66
66
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
isCommandAvailable,
|
|
44
44
|
parseCommand,
|
|
45
45
|
} from '../pty/command-registry'
|
|
46
|
+
import { appStore } from '../state/app-store'
|
|
46
47
|
import { createTerminalBounds } from '../state/layout-resize'
|
|
47
48
|
import {
|
|
48
49
|
allLeafIds,
|
|
@@ -56,8 +57,15 @@ import {
|
|
|
56
57
|
} from '../state/layout-tree'
|
|
57
58
|
import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
|
|
58
59
|
import { saveSessionCatalog } from '../state/session-catalog'
|
|
59
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
filterTabsForActiveWorktree,
|
|
62
|
+
getActiveWorktree,
|
|
63
|
+
getSessionProjectPath,
|
|
64
|
+
withActiveWorktree,
|
|
65
|
+
} from '../state/session-worktrees'
|
|
60
66
|
import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
|
|
67
|
+
import { appReducer } from '../state/store'
|
|
68
|
+
import { buildTabEntries } from '../state/tab-entries'
|
|
61
69
|
import { createDefaultTerminalModes } from '../state/terminal-modes'
|
|
62
70
|
import { toast } from '../state/toast-store'
|
|
63
71
|
import { saveCurrentWorkspace } from '../state/workspace-save'
|
|
@@ -71,6 +79,7 @@ import {
|
|
|
71
79
|
handleRenameSessionEffect,
|
|
72
80
|
handleSwitchSessionEffect,
|
|
73
81
|
restartTabSession,
|
|
82
|
+
switchSessionRecords,
|
|
74
83
|
} from './session-actions'
|
|
75
84
|
import {
|
|
76
85
|
handleDeleteSnippetEffect,
|
|
@@ -789,6 +798,14 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
789
798
|
handleSwitchSessionByIndex(ctx, effect.index)
|
|
790
799
|
return
|
|
791
800
|
}
|
|
801
|
+
case 'cycle-sidebar-item': {
|
|
802
|
+
handleCycleSidebarItem(ctx, effect.direction)
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
case 'switch-tab-by-index': {
|
|
806
|
+
handleSwitchTabByIndex(ctx, effect.index)
|
|
807
|
+
return
|
|
808
|
+
}
|
|
792
809
|
case 'toggle-transparent': {
|
|
793
810
|
const next = !getTransparent()
|
|
794
811
|
setTransparent(next)
|
|
@@ -1060,6 +1077,133 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
|
|
|
1060
1077
|
handleSwitchSessionEffect(state, backend, dispatch, target)
|
|
1061
1078
|
}
|
|
1062
1079
|
|
|
1080
|
+
interface SidebarItem {
|
|
1081
|
+
sessionId: string
|
|
1082
|
+
worktreeId: string | null
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function buildSidebarItems(state: AppState): SidebarItem[] {
|
|
1086
|
+
const ordered = [...state.sessions].sort(
|
|
1087
|
+
(a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
|
|
1088
|
+
)
|
|
1089
|
+
const items: SidebarItem[] = []
|
|
1090
|
+
for (const session of ordered) {
|
|
1091
|
+
items.push({ sessionId: session.id, worktreeId: null })
|
|
1092
|
+
const worktrees = session.worktrees ?? []
|
|
1093
|
+
const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
|
|
1094
|
+
for (const wt of worktrees) {
|
|
1095
|
+
if (wt.id === primary?.id) continue
|
|
1096
|
+
items.push({ sessionId: session.id, worktreeId: wt.id })
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return items
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function findCurrentSidebarItem(state: AppState, items: SidebarItem[]): number {
|
|
1103
|
+
const sessionId = state.currentSessionId
|
|
1104
|
+
if (sessionId == null || sessionId === '') return -1
|
|
1105
|
+
const session = state.sessions.find((s) => s.id === sessionId)
|
|
1106
|
+
const worktrees = session?.worktrees ?? []
|
|
1107
|
+
const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
|
|
1108
|
+
const activeWtId = session?.activeWorktreeId ?? null
|
|
1109
|
+
// The workspace row IS the primary worktree (no separate row), so an active
|
|
1110
|
+
// primary or undefined active maps to the workspace-item.
|
|
1111
|
+
const targetWorktreeId = activeWtId == null || activeWtId === primary?.id ? null : activeWtId
|
|
1112
|
+
return items.findIndex(
|
|
1113
|
+
(item) => item.sessionId === sessionId && item.worktreeId === targetWorktreeId
|
|
1114
|
+
)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function handleCycleSidebarItem(ctx: SideEffectContext, direction: 1 | -1): void {
|
|
1118
|
+
const { backend, dispatch } = ctx
|
|
1119
|
+
// Read fresh from the store, not ctx.state (which is a per-render
|
|
1120
|
+
// snapshot). Rapid key presses fire before React re-renders, so ctx.state
|
|
1121
|
+
// can lag the actual store.
|
|
1122
|
+
const state = appStore.getState()
|
|
1123
|
+
const items = buildSidebarItems(state)
|
|
1124
|
+
if (items.length === 0) return
|
|
1125
|
+
const currentIdx = findCurrentSidebarItem(state, items)
|
|
1126
|
+
// If we don't know the current, jump to first/last depending on direction.
|
|
1127
|
+
let startIdx: number
|
|
1128
|
+
if (currentIdx >= 0) {
|
|
1129
|
+
startIdx = currentIdx
|
|
1130
|
+
} else {
|
|
1131
|
+
startIdx = direction === 1 ? -1 : 0
|
|
1132
|
+
}
|
|
1133
|
+
const len = items.length
|
|
1134
|
+
const target = items[(((startIdx + direction) % len) + len) % len]
|
|
1135
|
+
if (!target) return
|
|
1136
|
+
|
|
1137
|
+
const session = state.sessions.find((s) => s.id === target.sessionId)
|
|
1138
|
+
if (!session) return
|
|
1139
|
+
|
|
1140
|
+
// Determine the worktree to activate. For workspace-items, that's the
|
|
1141
|
+
// primary; for worktree-items, the specific worktree.
|
|
1142
|
+
const worktrees = session.worktrees ?? []
|
|
1143
|
+
const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
|
|
1144
|
+
const targetWorktreeId = target.worktreeId ?? primary?.id
|
|
1145
|
+
|
|
1146
|
+
const isCrossWorkspace = session.id !== state.currentSessionId
|
|
1147
|
+
const needsWorktreeChange =
|
|
1148
|
+
targetWorktreeId != null && targetWorktreeId !== session.activeWorktreeId
|
|
1149
|
+
|
|
1150
|
+
if (isCrossWorkspace) {
|
|
1151
|
+
// Bundle the worktree change into the session record AND fold the
|
|
1152
|
+
// session switch's two dispatches (set-sessions + load-session) into a
|
|
1153
|
+
// SINGLE Zustand setState call — otherwise each dispatch fires a
|
|
1154
|
+
// separate subscription notification and the @opentui/react reconciler
|
|
1155
|
+
// paints an intermediate frame where the new session is current but
|
|
1156
|
+
// the old activeWorktreeId still holds, producing the visible flicker.
|
|
1157
|
+
const patchedSession = needsWorktreeChange
|
|
1158
|
+
? withActiveWorktree(session, targetWorktreeId)
|
|
1159
|
+
: session
|
|
1160
|
+
const patchedState: AppState = needsWorktreeChange
|
|
1161
|
+
? {
|
|
1162
|
+
...state,
|
|
1163
|
+
sessions: state.sessions.map((s) => (s.id === patchedSession.id ? patchedSession : s)),
|
|
1164
|
+
}
|
|
1165
|
+
: state
|
|
1166
|
+
const sessions = switchSessionRecords(patchedState, patchedSession)
|
|
1167
|
+
saveSessionCatalog(sessions)
|
|
1168
|
+
void backend.destroy(true)
|
|
1169
|
+
appStore.setState((current) => {
|
|
1170
|
+
const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
|
|
1171
|
+
return appReducer(afterSet, {
|
|
1172
|
+
sessionId: patchedSession.id,
|
|
1173
|
+
type: 'load-session',
|
|
1174
|
+
workspaceSnapshot: patchedSession.workspaceSnapshot,
|
|
1175
|
+
})
|
|
1176
|
+
})
|
|
1177
|
+
return
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
if (needsWorktreeChange) {
|
|
1181
|
+
dispatch({
|
|
1182
|
+
sessionId: session.id,
|
|
1183
|
+
type: 'set-active-worktree',
|
|
1184
|
+
worktreeId: targetWorktreeId,
|
|
1185
|
+
})
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function handleSwitchTabByIndex(ctx: SideEffectContext, index: number): void {
|
|
1190
|
+
const { dispatch, state } = ctx
|
|
1191
|
+
const currentSession =
|
|
1192
|
+
state.currentSessionId != null && state.currentSessionId !== ''
|
|
1193
|
+
? state.sessions.find((s) => s.id === state.currentSessionId)
|
|
1194
|
+
: undefined
|
|
1195
|
+
const visible = filterTabsForActiveWorktree(state.tabs, currentSession)
|
|
1196
|
+
const entries = buildTabEntries(visible, state.layoutTrees, state.tabGroupMap, state.activeTabId)
|
|
1197
|
+
const target = entries[index - 1]
|
|
1198
|
+
if (!target) {
|
|
1199
|
+
logInputDebug('app.tabBar.switchOutOfRange', { index, total: entries.length })
|
|
1200
|
+
return
|
|
1201
|
+
}
|
|
1202
|
+
const targetTabId = target.kind === 'single' ? target.tab.id : target.activeLeafId
|
|
1203
|
+
if (targetTabId === state.activeTabId) return
|
|
1204
|
+
dispatch({ tabId: targetTabId, type: 'set-active-tab' })
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1063
1207
|
function replaceSession(
|
|
1064
1208
|
state: AppState,
|
|
1065
1209
|
sessionId: string,
|
|
@@ -29,7 +29,9 @@ function getScreenPosition(event: OtuiMouseEvent, axis: ScreenAxis): number {
|
|
|
29
29
|
|
|
30
30
|
export function getSplitRatioFromDrag(event: OtuiMouseEvent, drag: SplitDragState): number {
|
|
31
31
|
const position = getScreenPosition(event, drag.direction === 'vertical' ? 'x' : 'y')
|
|
32
|
-
|
|
32
|
+
const total = Math.max(1, drag.totalSize)
|
|
33
|
+
const cells = Math.round(position - drag.screenStart)
|
|
34
|
+
return cells / total
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function getAxisDeltaFromDrag(event: OtuiMouseEvent, drag: AxisDragState): number {
|
|
@@ -175,8 +175,7 @@ export function useTerminalResize({
|
|
|
175
175
|
const terminalSize = useMemo(() => {
|
|
176
176
|
const sidebarWidth = state.sidebar.visible ? state.sidebar.width : 0
|
|
177
177
|
const sessionBarRows = state.sessionBar.visible ? 1 : 0
|
|
178
|
-
const sessionBarTopOffset =
|
|
179
|
-
state.sessionBar.visible && state.sessionBar.position === 'top' ? 1 : 0
|
|
178
|
+
const sessionBarTopOffset = sessionBarRows
|
|
180
179
|
const reservedRows =
|
|
181
180
|
MAIN_AREA_VERTICAL_PADDING +
|
|
182
181
|
STATUS_BAR_HEIGHT +
|
|
@@ -205,7 +204,6 @@ export function useTerminalResize({
|
|
|
205
204
|
state.sidebar.visible,
|
|
206
205
|
state.sidebar.width,
|
|
207
206
|
state.sessionBar.visible,
|
|
208
|
-
state.sessionBar.position,
|
|
209
207
|
gitPaneInPaneMode,
|
|
210
208
|
state.gitPane.position,
|
|
211
209
|
state.gitPane.paneRatio,
|
|
@@ -233,7 +231,6 @@ export function useTerminalResize({
|
|
|
233
231
|
state.sidebar.visible,
|
|
234
232
|
state.sidebar.width,
|
|
235
233
|
state.sessionBar.visible,
|
|
236
|
-
state.sessionBar.position,
|
|
237
234
|
gitPaneInPaneMode,
|
|
238
235
|
state.gitPane.position,
|
|
239
236
|
state.gitPane.paneRatio,
|
package/src/app.tsx
CHANGED
|
@@ -100,8 +100,6 @@ export function App({
|
|
|
100
100
|
const json = loadConfig()
|
|
101
101
|
const sessionBarVisible =
|
|
102
102
|
resolvedConfig.sessionBar?.initialVisible ?? json.sessionBarVisible ?? true
|
|
103
|
-
const sessionBarPosition =
|
|
104
|
-
resolvedConfig.sessionBar?.initialPosition ?? json.sessionBarPosition ?? 'top'
|
|
105
103
|
const sidebarOverrides = json.sidebar
|
|
106
104
|
|
|
107
105
|
// Merge config-file gitPane (persisted prefs) with user's resolved gitPane
|
|
@@ -149,7 +147,6 @@ export function App({
|
|
|
149
147
|
sessionCatalog.length === 0,
|
|
150
148
|
{
|
|
151
149
|
gitPane: gitPaneOverrides,
|
|
152
|
-
sessionBarPosition,
|
|
153
150
|
sessionBarVisible,
|
|
154
151
|
sidebar: sidebarOverrides,
|
|
155
152
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
-
import type { GitFileListMode,
|
|
3
|
+
import type { GitFileListMode, WorkspaceSnapshotV1 } from './state/types'
|
|
4
4
|
|
|
5
5
|
import { logDebug } from './debug/input-log'
|
|
6
6
|
import { getProfileConfigDir } from './profile-paths'
|
|
@@ -41,7 +41,6 @@ export interface AimuxConfig {
|
|
|
41
41
|
gitPane?: PersistedGitPane
|
|
42
42
|
sidebar?: PersistedSidebar
|
|
43
43
|
sessionBarVisible?: boolean
|
|
44
|
-
sessionBarPosition?: SessionBarPosition
|
|
45
44
|
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
46
45
|
skippedUpdateVersion?: string
|
|
47
46
|
}
|
|
@@ -161,7 +160,6 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
161
160
|
gitPanelVisible?: unknown
|
|
162
161
|
gitPanelRatio?: unknown
|
|
163
162
|
sessionBarVisible?: unknown
|
|
164
|
-
sessionBarPosition?: unknown
|
|
165
163
|
workspaceSnapshot?: unknown
|
|
166
164
|
skippedUpdateVersion?: unknown
|
|
167
165
|
}
|
|
@@ -233,14 +231,6 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
233
231
|
issues.push('ignored invalid sessionBarVisible')
|
|
234
232
|
}
|
|
235
233
|
|
|
236
|
-
const validSessionBarPosition =
|
|
237
|
-
parsed.sessionBarPosition === 'top' || parsed.sessionBarPosition === 'bottom'
|
|
238
|
-
? parsed.sessionBarPosition
|
|
239
|
-
: undefined
|
|
240
|
-
if (parsed.sessionBarPosition !== undefined && validSessionBarPosition === undefined) {
|
|
241
|
-
issues.push('ignored invalid sessionBarPosition')
|
|
242
|
-
}
|
|
243
|
-
|
|
244
234
|
if (
|
|
245
235
|
parsed.workspaceSnapshot !== undefined &&
|
|
246
236
|
!isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
|
|
@@ -264,7 +254,6 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
264
254
|
config: {
|
|
265
255
|
customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
|
|
266
256
|
gitPane: validGitPane,
|
|
267
|
-
sessionBarPosition: validSessionBarPosition,
|
|
268
257
|
sessionBarVisible: validSessionBarVisible,
|
|
269
258
|
sidebar: validSidebar,
|
|
270
259
|
skippedUpdateVersion: validSkippedUpdateVersion,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
import { appStore } from '../state/app-store'
|
|
4
|
+
import { dispatchGlobal } from '../state/dispatch-ref'
|
|
5
|
+
import { getCurrentBranch } from '../ui/git-branch'
|
|
6
|
+
|
|
7
|
+
const INTERVAL_MS = 4000
|
|
8
|
+
|
|
9
|
+
// Single source of truth for "what git branch is each worktree on right now".
|
|
10
|
+
// Polls every known worktree's path and dispatches update-worktree-record
|
|
11
|
+
// when the live branch differs from the stored one. Components read
|
|
12
|
+
// `worktree.branch` from state — no per-component polling, no flickers.
|
|
13
|
+
//
|
|
14
|
+
// Runs once when enabled; reads sessions from the store on each tick so
|
|
15
|
+
// session updates do NOT re-create the effect (which would otherwise feedback
|
|
16
|
+
// off our own dispatches).
|
|
17
|
+
export function useWorktreeBranchPolling(enabled: boolean): void {
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!enabled) return
|
|
20
|
+
|
|
21
|
+
let cancelled = false
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
23
|
+
|
|
24
|
+
const tick = async () => {
|
|
25
|
+
const sessions = appStore.getState().sessions
|
|
26
|
+
await Promise.all(
|
|
27
|
+
sessions.flatMap((session) =>
|
|
28
|
+
(session.worktrees ?? []).map(async (worktree) => {
|
|
29
|
+
if (worktree.path == null || worktree.path === '') return
|
|
30
|
+
const branch = await getCurrentBranch(worktree.path)
|
|
31
|
+
if (cancelled) return
|
|
32
|
+
if (branch != null && branch !== worktree.branch) {
|
|
33
|
+
dispatchGlobal({
|
|
34
|
+
patch: { branch },
|
|
35
|
+
sessionId: session.id,
|
|
36
|
+
type: 'update-worktree-record',
|
|
37
|
+
worktreeId: worktree.id,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
if (cancelled) return
|
|
44
|
+
timer = setTimeout(() => void tick(), INTERVAL_MS)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
void tick()
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
cancelled = true
|
|
51
|
+
if (timer != null) clearTimeout(timer)
|
|
52
|
+
}
|
|
53
|
+
}, [enabled])
|
|
54
|
+
}
|
package/src/input/modes/types.ts
CHANGED
|
@@ -68,6 +68,8 @@ export type SideEffect =
|
|
|
68
68
|
| { type: 'git-push' }
|
|
69
69
|
| { type: 'confirm-update-selection' }
|
|
70
70
|
| { type: 'switch-session-by-index'; index: number }
|
|
71
|
+
| { type: 'cycle-sidebar-item'; direction: 1 | -1 }
|
|
72
|
+
| { type: 'switch-tab-by-index'; index: number }
|
|
71
73
|
| { type: 'delete-session'; sessionId: string }
|
|
72
74
|
| { type: 'delete-worktree'; sessionId: string; worktreeId: string; force?: boolean }
|
|
73
75
|
| {
|
package/src/state/layout-tree.ts
CHANGED
|
@@ -124,6 +124,116 @@ export function allLeafIds(tree: LayoutNode): string[] {
|
|
|
124
124
|
return [...allLeafIds(tree.first), ...allLeafIds(tree.second)]
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
export type BoundarySide = 'left' | 'right' | 'top' | 'bottom'
|
|
128
|
+
|
|
129
|
+
export function getBoundaryLeafIds(node: LayoutNode, side: BoundarySide): string[] {
|
|
130
|
+
if (node.type === 'leaf') {
|
|
131
|
+
return [node.tabId]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const axisOfSide = side === 'left' || side === 'right' ? 'vertical' : 'horizontal'
|
|
135
|
+
|
|
136
|
+
if (node.direction === axisOfSide) {
|
|
137
|
+
// Split direction matches the side's axis: only one child touches `side`.
|
|
138
|
+
const child = side === 'left' || side === 'top' ? node.first : node.second
|
|
139
|
+
return getBoundaryLeafIds(child, side)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Perpendicular split: both children touch `side`.
|
|
143
|
+
return [...getBoundaryLeafIds(node.first, side), ...getBoundaryLeafIds(node.second, side)]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface JunctionEdgeInfo {
|
|
147
|
+
tabId: string
|
|
148
|
+
direction: SplitDirection
|
|
149
|
+
screenStart: number
|
|
150
|
+
totalSize: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface JunctionEdges {
|
|
154
|
+
left?: JunctionEdgeInfo
|
|
155
|
+
right?: JunctionEdgeInfo
|
|
156
|
+
top?: JunctionEdgeInfo
|
|
157
|
+
bottom?: JunctionEdgeInfo
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function firstLeafIdOf(node: LayoutNode): string {
|
|
161
|
+
return node.type === 'leaf' ? node.tabId : firstLeafIdOf(node.first)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function computeJunctionEdges(
|
|
165
|
+
tree: LayoutNode,
|
|
166
|
+
bounds: PaneRect,
|
|
167
|
+
origin: { x: number; y: number }
|
|
168
|
+
): Map<string, JunctionEdges> {
|
|
169
|
+
const result = new Map<string, JunctionEdges>()
|
|
170
|
+
walk(tree, bounds)
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
function ensure(tabId: string): JunctionEdges {
|
|
174
|
+
let entry = result.get(tabId)
|
|
175
|
+
if (!entry) {
|
|
176
|
+
entry = {}
|
|
177
|
+
result.set(tabId, entry)
|
|
178
|
+
}
|
|
179
|
+
return entry
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function walk(node: LayoutNode, nodeBounds: PaneRect): void {
|
|
183
|
+
if (node.type === 'leaf') {
|
|
184
|
+
ensure(node.tabId)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const info: JunctionEdgeInfo = {
|
|
189
|
+
direction: node.direction,
|
|
190
|
+
screenStart:
|
|
191
|
+
node.direction === 'vertical' ? origin.x + nodeBounds.x : origin.y + nodeBounds.y,
|
|
192
|
+
tabId: firstLeafIdOf(node.first),
|
|
193
|
+
totalSize: node.direction === 'vertical' ? nodeBounds.cols : nodeBounds.rows,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (node.direction === 'vertical') {
|
|
197
|
+
for (const leafId of getBoundaryLeafIds(node.first, 'right')) {
|
|
198
|
+
ensure(leafId).right = info
|
|
199
|
+
}
|
|
200
|
+
for (const leafId of getBoundaryLeafIds(node.second, 'left')) {
|
|
201
|
+
ensure(leafId).left = info
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
for (const leafId of getBoundaryLeafIds(node.first, 'bottom')) {
|
|
205
|
+
ensure(leafId).bottom = info
|
|
206
|
+
}
|
|
207
|
+
for (const leafId of getBoundaryLeafIds(node.second, 'top')) {
|
|
208
|
+
ensure(leafId).top = info
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Recurse with bounds math matching computePaneRects (now without separator gap).
|
|
213
|
+
if (node.direction === 'vertical') {
|
|
214
|
+
const firstCols = Math.max(1, Math.floor(nodeBounds.cols * node.ratio))
|
|
215
|
+
const secondCols = Math.max(1, nodeBounds.cols - firstCols)
|
|
216
|
+
walk(node.first, { cols: firstCols, rows: nodeBounds.rows, x: nodeBounds.x, y: nodeBounds.y })
|
|
217
|
+
walk(node.second, {
|
|
218
|
+
cols: secondCols,
|
|
219
|
+
rows: nodeBounds.rows,
|
|
220
|
+
x: nodeBounds.x + firstCols,
|
|
221
|
+
y: nodeBounds.y,
|
|
222
|
+
})
|
|
223
|
+
} else {
|
|
224
|
+
const firstRows = Math.max(1, Math.floor(nodeBounds.rows * node.ratio))
|
|
225
|
+
const secondRows = Math.max(1, nodeBounds.rows - firstRows)
|
|
226
|
+
walk(node.first, { cols: nodeBounds.cols, rows: firstRows, x: nodeBounds.x, y: nodeBounds.y })
|
|
227
|
+
walk(node.second, {
|
|
228
|
+
cols: nodeBounds.cols,
|
|
229
|
+
rows: secondRows,
|
|
230
|
+
x: nodeBounds.x,
|
|
231
|
+
y: nodeBounds.y + firstRows,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
127
237
|
export function pruneLayoutTree(tree: LayoutNode, validTabIds: Set<string>): LayoutNode | null {
|
|
128
238
|
if (tree.type === 'leaf') {
|
|
129
239
|
return validTabIds.has(tree.tabId) ? tree : null
|
|
@@ -227,13 +337,13 @@ export function computePaneRects(tree: LayoutNode, bounds: PaneRect): Map<string
|
|
|
227
337
|
if (tree.direction === 'vertical') {
|
|
228
338
|
// Split left/right
|
|
229
339
|
const firstCols = Math.max(1, Math.floor(bounds.cols * tree.ratio))
|
|
230
|
-
const secondCols = Math.max(1, bounds.cols - firstCols
|
|
340
|
+
const secondCols = Math.max(1, bounds.cols - firstCols)
|
|
231
341
|
|
|
232
342
|
const firstBounds: PaneRect = { cols: firstCols, rows: bounds.rows, x: bounds.x, y: bounds.y }
|
|
233
343
|
const secondBounds: PaneRect = {
|
|
234
344
|
cols: secondCols,
|
|
235
345
|
rows: bounds.rows,
|
|
236
|
-
x: bounds.x + firstCols
|
|
346
|
+
x: bounds.x + firstCols,
|
|
237
347
|
y: bounds.y,
|
|
238
348
|
}
|
|
239
349
|
|
|
@@ -246,14 +356,14 @@ export function computePaneRects(tree: LayoutNode, bounds: PaneRect): Map<string
|
|
|
246
356
|
} else {
|
|
247
357
|
// Split top/bottom
|
|
248
358
|
const firstRows = Math.max(1, Math.floor(bounds.rows * tree.ratio))
|
|
249
|
-
const secondRows = Math.max(1, bounds.rows - firstRows
|
|
359
|
+
const secondRows = Math.max(1, bounds.rows - firstRows)
|
|
250
360
|
|
|
251
361
|
const firstBounds: PaneRect = { cols: bounds.cols, rows: firstRows, x: bounds.x, y: bounds.y }
|
|
252
362
|
const secondBounds: PaneRect = {
|
|
253
363
|
cols: bounds.cols,
|
|
254
364
|
rows: secondRows,
|
|
255
365
|
x: bounds.x,
|
|
256
|
-
y: bounds.y + firstRows
|
|
366
|
+
y: bounds.y + firstRows,
|
|
257
367
|
}
|
|
258
368
|
|
|
259
369
|
for (const [id, rect] of computePaneRects(tree.first, firstBounds)) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AppAction, AppState } from '../types'
|
|
2
2
|
|
|
3
|
+
import { moveIdToIdPosition, orderSessionsForDisplay } from '../../ui/session-ordering'
|
|
3
4
|
import { filterSessions } from '../selectors'
|
|
4
5
|
import { restoreWorkspaceState } from '../session-persistence'
|
|
5
6
|
import { withActiveWorktree } from '../session-worktrees'
|
|
@@ -107,6 +108,25 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
|
|
|
107
108
|
}
|
|
108
109
|
return { ...state, sessions: ordered }
|
|
109
110
|
}
|
|
111
|
+
case 'reorder-active-session': {
|
|
112
|
+
const currentId = state.currentSessionId
|
|
113
|
+
if (currentId == null || currentId === '') return state
|
|
114
|
+
const ids = orderSessionsForDisplay(state.sessions).map((s) => s.id)
|
|
115
|
+
const from = ids.indexOf(currentId)
|
|
116
|
+
if (from < 0) return state
|
|
117
|
+
const to = from + action.delta
|
|
118
|
+
const targetId = ids[to]
|
|
119
|
+
if (targetId == null) return state
|
|
120
|
+
const nextIds = moveIdToIdPosition(ids, currentId, targetId)
|
|
121
|
+
const byId = new Map(state.sessions.map((s) => [s.id, s]))
|
|
122
|
+
const nextSessions = nextIds.map((id, idx) => {
|
|
123
|
+
const s = byId.get(id)
|
|
124
|
+
return s ? { ...s, order: idx } : s
|
|
125
|
+
})
|
|
126
|
+
const filtered = nextSessions.filter((s): s is NonNullable<typeof s> => s != null)
|
|
127
|
+
if (filtered.length !== state.sessions.length) return state
|
|
128
|
+
return { ...state, sessions: filtered }
|
|
129
|
+
}
|
|
110
130
|
case 'set-session-status': {
|
|
111
131
|
const prev = state.sessionStatuses[action.sessionId]
|
|
112
132
|
if (
|
|
@@ -199,7 +199,11 @@ function getCurrentSession(state: AppState) {
|
|
|
199
199
|
: undefined
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
function withActiveTabWorktree(
|
|
202
|
+
function withActiveTabWorktree(
|
|
203
|
+
state: AppState,
|
|
204
|
+
tabId: string | null,
|
|
205
|
+
opts?: { onlyIfMissing?: boolean }
|
|
206
|
+
): AppState {
|
|
203
207
|
if (
|
|
204
208
|
tabId == null ||
|
|
205
209
|
tabId === '' ||
|
|
@@ -209,6 +213,19 @@ function withActiveTabWorktree(state: AppState, tabId: string | null): AppState
|
|
|
209
213
|
const tab = state.tabs.find((entry) => entry.id === tabId)
|
|
210
214
|
if (!(tab?.worktreeId != null && tab?.worktreeId !== '')) return state
|
|
211
215
|
const worktreeId = tab.worktreeId
|
|
216
|
+
// When `onlyIfMissing` is set, we leave the session's worktree alone if it
|
|
217
|
+
// already points at a known worktree. Used by `hydrate-workspace` so that
|
|
218
|
+
// a backend re-attach after a user-initiated worktree switch (j/k cycling)
|
|
219
|
+
// doesn't clobber the freshly chosen worktree by re-syncing from the
|
|
220
|
+
// restored active tab.
|
|
221
|
+
if (opts?.onlyIfMissing === true) {
|
|
222
|
+
const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
|
|
223
|
+
const hasValidWorktree =
|
|
224
|
+
session?.activeWorktreeId != null &&
|
|
225
|
+
session.activeWorktreeId !== '' &&
|
|
226
|
+
(session.worktrees?.some((w) => w.id === session.activeWorktreeId) ?? false)
|
|
227
|
+
if (hasValidWorktree) return state
|
|
228
|
+
}
|
|
212
229
|
return {
|
|
213
230
|
...state,
|
|
214
231
|
sessions: state.sessions.map((session) =>
|
|
@@ -345,7 +362,8 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
345
362
|
tabGroupMap: hydratedGroupMap,
|
|
346
363
|
tabs: normalizeGroupedTabOrder(action.tabs, hydratedTrees, hydratedGroupMap),
|
|
347
364
|
},
|
|
348
|
-
hydratedActiveTabId
|
|
365
|
+
hydratedActiveTabId,
|
|
366
|
+
{ onlyIfMissing: true }
|
|
349
367
|
)
|
|
350
368
|
}
|
|
351
369
|
case 'close-tab':
|
|
@@ -34,9 +34,6 @@ export function reduceUIState(state: AppState, action: AppAction): AppState | nu
|
|
|
34
34
|
...state,
|
|
35
35
|
sessionBar: { ...state.sessionBar, visible: !state.sessionBar.visible },
|
|
36
36
|
}
|
|
37
|
-
case 'set-session-bar-position':
|
|
38
|
-
if (state.sessionBar.position === action.position) return state
|
|
39
|
-
return { ...state, sessionBar: { ...state.sessionBar, position: action.position } }
|
|
40
37
|
default:
|
|
41
38
|
return null
|
|
42
39
|
}
|
|
@@ -3,13 +3,21 @@ import { createHash } from 'node:crypto'
|
|
|
3
3
|
import { existsSync } from 'node:fs'
|
|
4
4
|
import { basename } from 'node:path'
|
|
5
5
|
|
|
6
|
-
import type { SessionRecord, TabSession, WorktreeRecord } from './types'
|
|
6
|
+
import type { BranchDivergence, SessionRecord, TabSession, WorktreeRecord } from './types'
|
|
7
7
|
|
|
8
8
|
import { createPrefixedId } from '../platform/id'
|
|
9
9
|
import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
|
|
10
10
|
|
|
11
11
|
const WORKTREE_COLORS = ['#7dd3fc', '#86efac', '#facc15', '#f0abfc', '#fb7185', '#c4b5fd']
|
|
12
12
|
|
|
13
|
+
export function formatDivergence(divergence: BranchDivergence | undefined): string {
|
|
14
|
+
if (divergence == null) return ''
|
|
15
|
+
const parts: string[] = []
|
|
16
|
+
if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
|
|
17
|
+
if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
|
|
18
|
+
return parts.join(' ')
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
export function getWorktreeColor(worktreeId: string): string {
|
|
14
22
|
let hash = 0
|
|
15
23
|
for (let i = 0; i < worktreeId.length; i++) {
|
|
@@ -98,7 +106,24 @@ function mergeExistingGitWorktrees(worktrees: WorktreeRecord[], now: string): Wo
|
|
|
98
106
|
? primaryRepoRoot
|
|
99
107
|
: (live[0]?.path ?? anchor.repoRoot)
|
|
100
108
|
for (const entry of live) {
|
|
101
|
-
|
|
109
|
+
const existing = byPath.get(entry.path)
|
|
110
|
+
if (existing) {
|
|
111
|
+
// Patch branch / commitSha from git for entries we already track —
|
|
112
|
+
// notably the primary worktree, whose `branch` is never set at creation
|
|
113
|
+
// time (createPrimaryWorktree doesn't run `git`). Without this, the UI
|
|
114
|
+
// and the divergence poller have no branch for primary worktrees.
|
|
115
|
+
const patchedBranch = entry.branch ?? existing.branch
|
|
116
|
+
const patchedSha = entry.head ?? existing.commitSha
|
|
117
|
+
if (patchedBranch !== existing.branch || patchedSha !== existing.commitSha) {
|
|
118
|
+
byPath.set(entry.path, {
|
|
119
|
+
...existing,
|
|
120
|
+
branch: patchedBranch,
|
|
121
|
+
commitSha: patchedSha,
|
|
122
|
+
updatedAt: now,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
102
127
|
if (isInsideAimuxWorktreeRoot(entry.path)) continue
|
|
103
128
|
const worktree: WorktreeRecord = {
|
|
104
129
|
branch: entry.branch,
|
|
@@ -204,6 +229,24 @@ export function getRenderedTabWorktreeId(
|
|
|
204
229
|
return tab.worktreeId ?? worktrees[0]?.id ?? '__main__'
|
|
205
230
|
}
|
|
206
231
|
|
|
232
|
+
export function filterTabsForActiveWorktree(
|
|
233
|
+
tabs: TabSession[],
|
|
234
|
+
session: SessionRecord | undefined
|
|
235
|
+
): TabSession[] {
|
|
236
|
+
if (!session) return tabs
|
|
237
|
+
const activeWorktreeId = session.activeWorktreeId
|
|
238
|
+
if (activeWorktreeId == null || activeWorktreeId === '') return tabs
|
|
239
|
+
const worktrees = session.worktrees ?? []
|
|
240
|
+
const primaryId = worktrees[0]?.id
|
|
241
|
+
const activeIsPrimary = primaryId != null && primaryId === activeWorktreeId
|
|
242
|
+
return tabs.filter((tab) => {
|
|
243
|
+
const owned = tab.worktreeId != null && tab.worktreeId !== ''
|
|
244
|
+
if (owned) return tab.worktreeId === activeWorktreeId
|
|
245
|
+
// Unbound (legacy) tabs surface only under the primary worktree.
|
|
246
|
+
return activeIsPrimary
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
207
250
|
export function orderTabsByWorktree(
|
|
208
251
|
tabs: TabSession[],
|
|
209
252
|
session: SessionRecord | undefined
|