@brimveyn/aimux 1.13.2 → 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/backend-attach-runtime.ts +1 -3
- package/src/app-runtime/multi-click-clipboard-guard.ts +24 -12
- package/src/app-runtime/pty-write.ts +6 -9
- package/src/app-runtime/side-effects.ts +149 -19
- package/src/app-runtime/snippet-actions.ts +2 -3
- package/src/app-runtime/split-drag-controller.ts +3 -1
- package/src/app-runtime/use-backend-runtime.ts +4 -7
- package/src/app-runtime/use-mouse-handlers.ts +30 -1
- package/src/app-runtime/use-renderer-bindings.ts +4 -12
- package/src/app-runtime/use-terminal-resize.ts +7 -22
- package/src/app.tsx +0 -6
- package/src/config.ts +1 -12
- package/src/daemon/daemon.ts +2 -19
- package/src/daemon/session-manager.ts +3 -15
- package/src/daemon/session-registry.ts +11 -30
- package/src/git/worktree-branch-poller.ts +54 -0
- package/src/input/modes/types.ts +2 -0
- package/src/input/terminal-text-extraction.ts +7 -8
- package/src/ipc/manager-protocol.ts +6 -38
- package/src/ipc/protocol.ts +9 -37
- package/src/pty/pty-manager.ts +20 -31
- package/src/pty/terminal-snapshot.ts +43 -0
- package/src/session-backend/local-session-backend.ts +7 -31
- package/src/session-backend/remote-session-backend.ts +5 -32
- package/src/session-backend/types.ts +2 -15
- package/src/state/layout-tree.ts +114 -4
- package/src/state/reducers/session-state.ts +20 -0
- package/src/state/reducers/tab-state.ts +22 -22
- package/src/state/reducers/ui-state.ts +0 -3
- package/src/state/session-persistence.ts +2 -22
- 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 +4 -15
- package/src/state/validation.ts +0 -8
- package/src/state/workspace-save.ts +0 -1
- package/src/terminal-manager/manager-client.ts +5 -29
- package/src/terminal-manager/terminal-manager.ts +2 -17
- 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/src/pty/pty-manager.ts
CHANGED
|
@@ -85,6 +85,17 @@ function getTerminalModes(emulator: XTerm, alternateScrollMode: boolean): Termin
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// Read the scroll position straight from the emulator that owns it. This is the
|
|
89
|
+
// single source of truth for re-anchoring across a resize: deriving it here
|
|
90
|
+
// (zero latency) instead of accepting a frontend-supplied intent avoids the
|
|
91
|
+
// stale-mirror drift that desynced selection/copy from the rendered viewport.
|
|
92
|
+
function deriveEmulatorScrollIntent(emulator: XTerm): ScrollIntent {
|
|
93
|
+
const buffer = emulator.buffer.active
|
|
94
|
+
return buffer.viewportY >= buffer.baseY
|
|
95
|
+
? { kind: 'bottom' }
|
|
96
|
+
: { absoluteLine: buffer.viewportY, kind: 'anchor' }
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
function envInt(name: string, fallback: number): number {
|
|
89
100
|
const raw = process.env[name]
|
|
90
101
|
if (raw === undefined) return fallback
|
|
@@ -374,13 +385,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
374
385
|
session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
|
|
375
386
|
}
|
|
376
387
|
|
|
377
|
-
private applyResize(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
intent
|
|
382
|
-
sync: boolean
|
|
383
|
-
): void {
|
|
388
|
+
private applyResize(session: SessionHandle, cols: number, rows: number, sync: boolean): void {
|
|
389
|
+
// Capture the scroll position from the emulator *before* reflow, so the
|
|
390
|
+
// re-anchor restores where the user actually was. The frontend no longer
|
|
391
|
+
// supplies an intent — the backend owns scroll position end to end.
|
|
392
|
+
const intent = deriveEmulatorScrollIntent(session.emulator)
|
|
384
393
|
const safeCols = Math.max(20, cols)
|
|
385
394
|
const safeRows = Math.max(8, rows)
|
|
386
395
|
session.pty.resize(safeCols, safeRows)
|
|
@@ -406,38 +415,18 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
406
415
|
}
|
|
407
416
|
}
|
|
408
417
|
|
|
409
|
-
resizeAll(
|
|
410
|
-
cols: number,
|
|
411
|
-
rows: number,
|
|
412
|
-
intents?: Map<string, ScrollIntent>,
|
|
413
|
-
options?: { sync?: boolean }
|
|
414
|
-
): void {
|
|
418
|
+
resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void {
|
|
415
419
|
for (const session of this.sessions.values()) {
|
|
416
|
-
this.applyResize(session, cols, rows,
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
resizeSession(
|
|
421
|
-
tabId: string,
|
|
422
|
-
cols: number,
|
|
423
|
-
rows: number,
|
|
424
|
-
intent?: ScrollIntent,
|
|
425
|
-
options?: { sync?: boolean }
|
|
426
|
-
): void {
|
|
427
|
-
const session = this.sessions.get(tabId)
|
|
428
|
-
if (!session) {
|
|
429
|
-
return
|
|
420
|
+
this.applyResize(session, cols, rows, options?.sync ?? false)
|
|
430
421
|
}
|
|
431
|
-
this.applyResize(session, cols, rows, intent, options?.sync ?? false)
|
|
432
422
|
}
|
|
433
423
|
|
|
434
|
-
|
|
424
|
+
resizeSession(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
|
|
435
425
|
const session = this.sessions.get(tabId)
|
|
436
426
|
if (!session) {
|
|
437
427
|
return
|
|
438
428
|
}
|
|
439
|
-
this.
|
|
440
|
-
this.scheduleRender(session)
|
|
429
|
+
this.applyResize(session, cols, rows, options?.sync ?? false)
|
|
441
430
|
}
|
|
442
431
|
|
|
443
432
|
disposeSession(tabId: string): void {
|
|
@@ -100,6 +100,7 @@ function buildLine(
|
|
|
100
100
|
|
|
101
101
|
const cell = terminal.buffer.active.getNullCell()
|
|
102
102
|
const spans: TerminalSpan[] = []
|
|
103
|
+
let visualColumns = 0
|
|
103
104
|
|
|
104
105
|
for (let column = 0; column < terminal.cols; column += 1) {
|
|
105
106
|
const current = line.getCell(column, cell)
|
|
@@ -138,6 +139,48 @@ function buildLine(
|
|
|
138
139
|
text,
|
|
139
140
|
underline: current.isUnderline() ? true : undefined,
|
|
140
141
|
})
|
|
142
|
+
|
|
143
|
+
visualColumns += current.getWidth()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Pad the row to the full terminal width so opentui overwrites every cell.
|
|
147
|
+
// Without padding, cells past the last written character retain content
|
|
148
|
+
// from the previous frame (opentui's `blendCells` preserves the existing
|
|
149
|
+
// char when an overlay space lands on it — see modal-shell.tsx for the
|
|
150
|
+
// same class of bug solved differently for transparent modals).
|
|
151
|
+
if (visualColumns < terminal.cols) {
|
|
152
|
+
const padCount = terminal.cols - visualColumns
|
|
153
|
+
const cursorInPad =
|
|
154
|
+
cursorVisible &&
|
|
155
|
+
cursorColumn !== null &&
|
|
156
|
+
cursorColumn >= visualColumns &&
|
|
157
|
+
cursorColumn < terminal.cols
|
|
158
|
+
|
|
159
|
+
if (cursorInPad && cursorColumn !== null) {
|
|
160
|
+
// cursorColumn is a buffer column while visualColumns is a sum of cell
|
|
161
|
+
// widths; they diverge when wide glyphs precede the gap. Clamp the offset
|
|
162
|
+
// into the pad so leading + cursor + trailing always equals padCount and
|
|
163
|
+
// the row never over/undershoots terminal.cols (cursor may be a column
|
|
164
|
+
// off in pathological wide-char rows, but the row width stays correct).
|
|
165
|
+
const cursorOffset = Math.min(cursorColumn - visualColumns, padCount - 1)
|
|
166
|
+
const leading = cursorOffset
|
|
167
|
+
const trailing = padCount - cursorOffset - 1
|
|
168
|
+
if (leading > 0) {
|
|
169
|
+
pushSpan(spans, { text: ' '.repeat(leading) })
|
|
170
|
+
}
|
|
171
|
+
const tokens = getCurrentTheme()
|
|
172
|
+
pushSpan(spans, {
|
|
173
|
+
bg: tokens.text,
|
|
174
|
+
cursor: true,
|
|
175
|
+
fg: tokens.background,
|
|
176
|
+
text: ' ',
|
|
177
|
+
})
|
|
178
|
+
if (trailing > 0) {
|
|
179
|
+
pushSpan(spans, { text: ' '.repeat(trailing) })
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
pushSpan(spans, { text: ' '.repeat(padCount) })
|
|
183
|
+
}
|
|
141
184
|
}
|
|
142
185
|
|
|
143
186
|
return { spans }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events'
|
|
2
2
|
|
|
3
|
-
import type { AssistantId,
|
|
3
|
+
import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
|
|
4
4
|
import type { SessionBackend, SessionBackendEvents } from './types'
|
|
5
5
|
|
|
6
6
|
import { SessionManager } from '../daemon/session-manager'
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
getSnapshotTrees,
|
|
13
13
|
toTerminalContentSize,
|
|
14
14
|
} from '../state/layout-resize'
|
|
15
|
-
import { getSnapshotScrollIntents } from '../state/session-persistence'
|
|
16
15
|
|
|
17
16
|
export class LocalSessionBackend
|
|
18
17
|
extends EventEmitter<SessionBackendEvents>
|
|
@@ -67,22 +66,15 @@ export class LocalSessionBackend
|
|
|
67
66
|
})
|
|
68
67
|
this.currentSessionId = options.sessionId
|
|
69
68
|
const trees = getSnapshotTrees(options.workspaceSnapshot)
|
|
70
|
-
const intents = getSnapshotScrollIntents(options.workspaceSnapshot)
|
|
71
69
|
const splitTrees = trees.filter((t) => t.type === 'split')
|
|
72
70
|
if (splitTrees.length > 0) {
|
|
73
71
|
const bounds = createTerminalBounds(options.cols, options.rows)
|
|
74
72
|
forEachSplitPaneRect(splitTrees, bounds, (tabId, rect) => {
|
|
75
73
|
const size = toTerminalContentSize(rect)
|
|
76
|
-
this.sessionManager.resizeTab(
|
|
77
|
-
options.sessionId,
|
|
78
|
-
tabId,
|
|
79
|
-
size.cols,
|
|
80
|
-
size.rows,
|
|
81
|
-
intents.get(tabId)
|
|
82
|
-
)
|
|
74
|
+
this.sessionManager.resizeTab(options.sessionId, tabId, size.cols, size.rows)
|
|
83
75
|
})
|
|
84
76
|
} else {
|
|
85
|
-
this.sessionManager.resize(options.sessionId, options.cols, options.rows
|
|
77
|
+
this.sessionManager.resize(options.sessionId, options.cols, options.rows)
|
|
86
78
|
}
|
|
87
79
|
const attachResult = this.sessionManager.attachSession(
|
|
88
80
|
options.sessionId,
|
|
@@ -149,36 +141,20 @@ export class LocalSessionBackend
|
|
|
149
141
|
this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
|
|
150
142
|
}
|
|
151
143
|
|
|
152
|
-
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
153
|
-
if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
|
|
154
|
-
this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
144
|
setActiveTab(tabId: string | null): void {
|
|
158
145
|
if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
|
|
159
146
|
logDebug('backend.local.setActiveTab', { sessionId: this.currentSessionId, tabId })
|
|
160
147
|
this.sessionManager.setActiveTab(this.currentSessionId, tabId)
|
|
161
148
|
}
|
|
162
149
|
|
|
163
|
-
resizeAll(
|
|
164
|
-
cols: number,
|
|
165
|
-
rows: number,
|
|
166
|
-
intents?: Map<string, ScrollIntent>,
|
|
167
|
-
options?: { sync?: boolean }
|
|
168
|
-
): void {
|
|
150
|
+
resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void {
|
|
169
151
|
if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
|
|
170
|
-
this.sessionManager.resize(this.currentSessionId, cols, rows,
|
|
152
|
+
this.sessionManager.resize(this.currentSessionId, cols, rows, options)
|
|
171
153
|
}
|
|
172
154
|
|
|
173
|
-
resizeTab(
|
|
174
|
-
tabId: string,
|
|
175
|
-
cols: number,
|
|
176
|
-
rows: number,
|
|
177
|
-
intent?: ScrollIntent,
|
|
178
|
-
options?: { sync?: boolean }
|
|
179
|
-
): void {
|
|
155
|
+
resizeTab(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
|
|
180
156
|
if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
|
|
181
|
-
this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows,
|
|
157
|
+
this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, options)
|
|
182
158
|
}
|
|
183
159
|
|
|
184
160
|
disposeSession(tabId: string): void {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events'
|
|
2
2
|
import { connect, type Socket } from 'node:net'
|
|
3
3
|
|
|
4
|
-
import type { AssistantId,
|
|
4
|
+
import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
|
|
5
5
|
import type { SessionBackend, SessionBackendEvents } from './types'
|
|
6
6
|
|
|
7
7
|
import { getIpcDaemonSocketPath } from '../daemon/runtime-paths'
|
|
@@ -404,17 +404,6 @@ export class RemoteSessionBackend
|
|
|
404
404
|
)
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
-
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
408
|
-
if (!this.attached) {
|
|
409
|
-
return
|
|
410
|
-
}
|
|
411
|
-
this.dispatchCommand(
|
|
412
|
-
{ id: crypto.randomUUID(), payload: { intent, tabId }, type: 'reapplyScrollIntent' },
|
|
413
|
-
'reapplyScrollIntent',
|
|
414
|
-
tabId
|
|
415
|
-
)
|
|
416
|
-
}
|
|
417
|
-
|
|
418
407
|
setActiveTab(tabId: string | null): void {
|
|
419
408
|
if (!this.attached) {
|
|
420
409
|
return
|
|
@@ -425,40 +414,24 @@ export class RemoteSessionBackend
|
|
|
425
414
|
)
|
|
426
415
|
}
|
|
427
416
|
|
|
428
|
-
resizeAll(
|
|
429
|
-
cols: number,
|
|
430
|
-
rows: number,
|
|
431
|
-
intents?: Map<string, ScrollIntent>,
|
|
432
|
-
_options?: { sync?: boolean }
|
|
433
|
-
): void {
|
|
417
|
+
resizeAll(cols: number, rows: number, _options?: { sync?: boolean }): void {
|
|
434
418
|
if (!this.attached) {
|
|
435
419
|
logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
|
|
436
420
|
return
|
|
437
421
|
}
|
|
438
422
|
logDebug('backend.remote.resize', { cols, rows, sessionId: this.currentSessionId })
|
|
439
|
-
const intentsRecord = intents ? Object.fromEntries(intents.entries()) : undefined
|
|
440
423
|
this.dispatchCommand(
|
|
441
|
-
{
|
|
442
|
-
id: crypto.randomUUID(),
|
|
443
|
-
payload: { cols, intents: intentsRecord, rows },
|
|
444
|
-
type: 'resizeClient',
|
|
445
|
-
},
|
|
424
|
+
{ id: crypto.randomUUID(), payload: { cols, rows }, type: 'resizeClient' },
|
|
446
425
|
'resizeClient'
|
|
447
426
|
)
|
|
448
427
|
}
|
|
449
428
|
|
|
450
|
-
resizeTab(
|
|
451
|
-
tabId: string,
|
|
452
|
-
cols: number,
|
|
453
|
-
rows: number,
|
|
454
|
-
intent?: ScrollIntent,
|
|
455
|
-
_options?: { sync?: boolean }
|
|
456
|
-
): void {
|
|
429
|
+
resizeTab(tabId: string, cols: number, rows: number, _options?: { sync?: boolean }): void {
|
|
457
430
|
if (!this.attached) {
|
|
458
431
|
return
|
|
459
432
|
}
|
|
460
433
|
this.dispatchCommand(
|
|
461
|
-
{ id: crypto.randomUUID(), payload: { cols,
|
|
434
|
+
{ id: crypto.randomUUID(), payload: { cols, rows, tabId }, type: 'resizeTab' },
|
|
462
435
|
'resizeTab',
|
|
463
436
|
tabId
|
|
464
437
|
)
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { EventEmitter } from 'node:events'
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
-
ScrollIntent,
|
|
5
4
|
SessionStatus,
|
|
6
5
|
TabActivity,
|
|
7
6
|
TabSession,
|
|
@@ -49,21 +48,9 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
|
|
|
49
48
|
write(tabId: string, input: string): void
|
|
50
49
|
scrollViewport(tabId: string, deltaLines: number): void
|
|
51
50
|
scrollViewportToBottom(tabId: string): void
|
|
52
|
-
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void
|
|
53
51
|
setActiveTab(tabId: string | null): void
|
|
54
|
-
resizeAll(
|
|
55
|
-
|
|
56
|
-
rows: number,
|
|
57
|
-
intents?: Map<string, ScrollIntent>,
|
|
58
|
-
options?: { sync?: boolean }
|
|
59
|
-
): void
|
|
60
|
-
resizeTab(
|
|
61
|
-
tabId: string,
|
|
62
|
-
cols: number,
|
|
63
|
-
rows: number,
|
|
64
|
-
intent?: ScrollIntent,
|
|
65
|
-
options?: { sync?: boolean }
|
|
66
|
-
): void
|
|
52
|
+
resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void
|
|
53
|
+
resizeTab(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void
|
|
67
54
|
disposeSession(tabId: string): void
|
|
68
55
|
disposeAll(): void
|
|
69
56
|
destroy(keepSessions?: boolean): Promise<void> | void
|
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 (
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AppAction, AppState, TabSession } from '../types'
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
allLeafIds,
|
|
3
5
|
createGroupId,
|
|
@@ -15,13 +17,6 @@ import {
|
|
|
15
17
|
import { normalizeGroupedTabOrder } from '../session-persistence'
|
|
16
18
|
import { orderTabsByWorktree, withActiveWorktree } from '../session-worktrees'
|
|
17
19
|
import { createDefaultTerminalModes } from '../terminal-modes'
|
|
18
|
-
import {
|
|
19
|
-
type AppAction,
|
|
20
|
-
type AppState,
|
|
21
|
-
DEFAULT_SCROLL_INTENT,
|
|
22
|
-
deriveScrollIntent,
|
|
23
|
-
type TabSession,
|
|
24
|
-
} from '../types'
|
|
25
20
|
|
|
26
21
|
const MAX_BUFFER_LENGTH = 50_000
|
|
27
22
|
|
|
@@ -204,7 +199,11 @@ function getCurrentSession(state: AppState) {
|
|
|
204
199
|
: undefined
|
|
205
200
|
}
|
|
206
201
|
|
|
207
|
-
function withActiveTabWorktree(
|
|
202
|
+
function withActiveTabWorktree(
|
|
203
|
+
state: AppState,
|
|
204
|
+
tabId: string | null,
|
|
205
|
+
opts?: { onlyIfMissing?: boolean }
|
|
206
|
+
): AppState {
|
|
208
207
|
if (
|
|
209
208
|
tabId == null ||
|
|
210
209
|
tabId === '' ||
|
|
@@ -214,6 +213,19 @@ function withActiveTabWorktree(state: AppState, tabId: string | null): AppState
|
|
|
214
213
|
const tab = state.tabs.find((entry) => entry.id === tabId)
|
|
215
214
|
if (!(tab?.worktreeId != null && tab?.worktreeId !== '')) return state
|
|
216
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
|
+
}
|
|
217
229
|
return {
|
|
218
230
|
...state,
|
|
219
231
|
sessions: state.sessions.map((session) =>
|
|
@@ -350,7 +362,8 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
350
362
|
tabGroupMap: hydratedGroupMap,
|
|
351
363
|
tabs: normalizeGroupedTabOrder(action.tabs, hydratedTrees, hydratedGroupMap),
|
|
352
364
|
},
|
|
353
|
-
hydratedActiveTabId
|
|
365
|
+
hydratedActiveTabId,
|
|
366
|
+
{ onlyIfMissing: true }
|
|
354
367
|
)
|
|
355
368
|
}
|
|
356
369
|
case 'close-tab':
|
|
@@ -448,7 +461,6 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
448
461
|
buffer: '',
|
|
449
462
|
errorMessage: undefined,
|
|
450
463
|
exitCode: undefined,
|
|
451
|
-
scrollIntent: DEFAULT_SCROLL_INTENT,
|
|
452
464
|
status: 'starting',
|
|
453
465
|
terminalModes: createDefaultTerminalModes(),
|
|
454
466
|
viewport: undefined,
|
|
@@ -470,23 +482,11 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
470
482
|
...state,
|
|
471
483
|
tabs: updateTab(state.tabs, action.tabId, (tab) => ({
|
|
472
484
|
...tab,
|
|
473
|
-
scrollIntent:
|
|
474
|
-
action.source === 'resize' || action.source === 'switch'
|
|
475
|
-
? (tab.scrollIntent ?? DEFAULT_SCROLL_INTENT)
|
|
476
|
-
: deriveScrollIntent(action.viewport),
|
|
477
485
|
status: tab.status === 'starting' ? 'running' : tab.status,
|
|
478
486
|
terminalModes: action.terminalModes,
|
|
479
487
|
viewport: action.viewport,
|
|
480
488
|
})),
|
|
481
489
|
}
|
|
482
|
-
case 'set-scroll-intent':
|
|
483
|
-
return {
|
|
484
|
-
...state,
|
|
485
|
-
tabs: updateTab(state.tabs, action.tabId, (tab) => ({
|
|
486
|
-
...tab,
|
|
487
|
-
scrollIntent: action.intent,
|
|
488
|
-
})),
|
|
489
|
-
}
|
|
490
490
|
case 'set-tab-activity':
|
|
491
491
|
return {
|
|
492
492
|
...state,
|
|
@@ -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
|
}
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
+
import type { AppState, TabSession, TabStatus, WorkspaceSnapshotV1 } from './types'
|
|
2
|
+
|
|
1
3
|
import { allLeafIds, createGroupId, type LayoutNode, pruneLayoutTree } from './layout-tree'
|
|
2
|
-
import {
|
|
3
|
-
type AppState,
|
|
4
|
-
DEFAULT_SCROLL_INTENT,
|
|
5
|
-
type ScrollIntent,
|
|
6
|
-
type TabSession,
|
|
7
|
-
type TabStatus,
|
|
8
|
-
type WorkspaceSnapshotV1,
|
|
9
|
-
} from './types'
|
|
10
4
|
|
|
11
5
|
export function createEmptyWorkspaceSnapshot(): WorkspaceSnapshotV1 {
|
|
12
6
|
return {
|
|
@@ -47,7 +41,6 @@ export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
|
|
|
47
41
|
errorMessage: tab.errorMessage,
|
|
48
42
|
exitCode: tab.exitCode,
|
|
49
43
|
id: tab.id,
|
|
50
|
-
scrollIntent: tab.scrollIntent,
|
|
51
44
|
status: tab.status === 'disconnected' ? 'running' : tab.status,
|
|
52
45
|
terminalModes: tab.terminalModes,
|
|
53
46
|
title: tab.title,
|
|
@@ -76,7 +69,6 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
|
|
|
76
69
|
errorMessage: tab.errorMessage,
|
|
77
70
|
exitCode: tab.exitCode,
|
|
78
71
|
id: tab.id,
|
|
79
|
-
scrollIntent: tab.scrollIntent ?? DEFAULT_SCROLL_INTENT,
|
|
80
72
|
status: getDisconnectedStatus(tab.status),
|
|
81
73
|
terminalModes: tab.terminalModes,
|
|
82
74
|
title: tab.title,
|
|
@@ -85,18 +77,6 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
|
|
|
85
77
|
}))
|
|
86
78
|
}
|
|
87
79
|
|
|
88
|
-
export function getSnapshotScrollIntents(
|
|
89
|
-
snapshot: WorkspaceSnapshotV1 | undefined
|
|
90
|
-
): Map<string, ScrollIntent> {
|
|
91
|
-
if (!snapshot || snapshot.version !== 1) {
|
|
92
|
-
return new Map()
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return new Map(
|
|
96
|
-
snapshot.tabs.map((tab) => [tab.id, tab.scrollIntent ?? DEFAULT_SCROLL_INTENT] as const)
|
|
97
|
-
)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
80
|
export function restoreLayoutTrees(
|
|
101
81
|
snapshot: WorkspaceSnapshotV1 | undefined,
|
|
102
82
|
tabs: TabSession[]
|