@brimveyn/aimux 1.14.0 → 1.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +2 -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/status-bar.tsx +168 -33
- 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/components/overlays/ai-usage/ai-usage-indicator.tsx +22 -93
- package/src/ui/root.tsx +68 -63
- package/src/ui/status-bar-model.ts +36 -37
- 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
|
@@ -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
|
package/src/state/store.ts
CHANGED
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
type GitPaneMode,
|
|
17
17
|
type GitPanePosition,
|
|
18
18
|
type GitPaneState,
|
|
19
|
-
type SessionBarPosition,
|
|
20
19
|
type SessionRecord,
|
|
21
20
|
type SnippetRecord,
|
|
22
21
|
} from './types'
|
|
@@ -32,7 +31,6 @@ export interface InitialStateOverrides {
|
|
|
32
31
|
gitPane?: Partial<GitPaneState>
|
|
33
32
|
sidebar?: Pick<AppState['sidebar'], 'visible' | 'width'>
|
|
34
33
|
sessionBarVisible?: boolean
|
|
35
|
-
sessionBarPosition?: SessionBarPosition
|
|
36
34
|
}
|
|
37
35
|
|
|
38
36
|
const DEFAULT_GIT_PANE: GitPaneState = {
|
|
@@ -103,7 +101,6 @@ export function createInitialState(
|
|
|
103
101
|
multiRepo: EMPTY_MULTI_REPO_STATE,
|
|
104
102
|
pendingChords: null,
|
|
105
103
|
sessionBar: {
|
|
106
|
-
position: overrides.sessionBarPosition ?? 'top',
|
|
107
104
|
visible: overrides.sessionBarVisible ?? true,
|
|
108
105
|
},
|
|
109
106
|
sessions,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { TabSession } from './types'
|
|
2
|
+
|
|
3
|
+
import { allLeafIds, type LayoutNode } from './layout-tree'
|
|
4
|
+
|
|
5
|
+
export interface SingleEntry {
|
|
6
|
+
kind: 'single'
|
|
7
|
+
id: string
|
|
8
|
+
tab: TabSession
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface GroupEntry {
|
|
12
|
+
kind: 'group'
|
|
13
|
+
id: string
|
|
14
|
+
groupId: string
|
|
15
|
+
tabs: TabSession[]
|
|
16
|
+
activeLeafId: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type TabEntry = SingleEntry | GroupEntry
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Collapse a list of visible tabs into "tab strip entries": one entry per
|
|
23
|
+
* standalone tab, one entry per multi-leaf layout group (split). Tabs that
|
|
24
|
+
* belong to a layout tree with only one leaf are treated as standalone.
|
|
25
|
+
*
|
|
26
|
+
* Within a group entry, `activeLeafId` is `activeTabId` when it falls inside
|
|
27
|
+
* the group, otherwise the first leaf in the underlying tabs order.
|
|
28
|
+
*/
|
|
29
|
+
export function buildTabEntries(
|
|
30
|
+
visibleTabs: TabSession[],
|
|
31
|
+
layoutTrees: Record<string, LayoutNode>,
|
|
32
|
+
tabGroupMap: Record<string, string>,
|
|
33
|
+
activeTabId: string | null
|
|
34
|
+
): TabEntry[] {
|
|
35
|
+
// Set of groupIds that are real splits (>= 2 leaves).
|
|
36
|
+
const splitGroupIds = new Set<string>()
|
|
37
|
+
for (const [groupId, tree] of Object.entries(layoutTrees)) {
|
|
38
|
+
if (allLeafIds(tree).length >= 2) {
|
|
39
|
+
splitGroupIds.add(groupId)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const entries: TabEntry[] = []
|
|
44
|
+
const emittedGroupIds = new Set<string>()
|
|
45
|
+
|
|
46
|
+
for (const tab of visibleTabs) {
|
|
47
|
+
const groupId = tabGroupMap[tab.id]
|
|
48
|
+
const inSplit = groupId != null && groupId !== '' && splitGroupIds.has(groupId)
|
|
49
|
+
|
|
50
|
+
if (!inSplit) {
|
|
51
|
+
entries.push({ id: tab.id, kind: 'single', tab })
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (emittedGroupIds.has(groupId)) continue
|
|
56
|
+
emittedGroupIds.add(groupId)
|
|
57
|
+
|
|
58
|
+
const members = visibleTabs.filter((t) => tabGroupMap[t.id] === groupId)
|
|
59
|
+
const activeLeafId =
|
|
60
|
+
activeTabId != null && members.some((t) => t.id === activeTabId)
|
|
61
|
+
? activeTabId
|
|
62
|
+
: (members[0]?.id ?? tab.id)
|
|
63
|
+
|
|
64
|
+
entries.push({
|
|
65
|
+
activeLeafId,
|
|
66
|
+
groupId,
|
|
67
|
+
id: groupId,
|
|
68
|
+
kind: 'group',
|
|
69
|
+
tabs: members,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return entries
|
|
74
|
+
}
|
package/src/state/types.ts
CHANGED
|
@@ -141,11 +141,8 @@ export interface SessionRecord {
|
|
|
141
141
|
activeWorktreeId?: string
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
export type SessionBarPosition = 'top' | 'bottom'
|
|
145
|
-
|
|
146
144
|
export interface SessionBarState {
|
|
147
145
|
visible: boolean
|
|
148
|
-
position: SessionBarPosition
|
|
149
146
|
}
|
|
150
147
|
|
|
151
148
|
export interface TabSession {
|
|
@@ -539,6 +536,7 @@ export type SessionAction =
|
|
|
539
536
|
| { type: 'rename-session-record'; sessionId: string; name: string }
|
|
540
537
|
| { type: 'delete-session-record'; sessionId: string; openSessionPicker?: boolean }
|
|
541
538
|
| { type: 'reorder-sessions'; orderedIds: string[] }
|
|
539
|
+
| { type: 'reorder-active-session'; delta: number }
|
|
542
540
|
| { type: 'set-session-status'; sessionId: string; status: SessionStatus }
|
|
543
541
|
| { type: 'add-worktree-record'; sessionId: string; worktree: WorktreeRecord; activate?: boolean }
|
|
544
542
|
| { type: 'remove-worktree-record'; sessionId: string; worktreeId: string }
|
|
@@ -619,7 +617,6 @@ export type UIAction =
|
|
|
619
617
|
| { type: 'set-git-pane-position'; position: GitPanePosition }
|
|
620
618
|
| { type: 'set-pending-chords'; chords: string[] | null }
|
|
621
619
|
| { type: 'toggle-session-bar' }
|
|
622
|
-
| { type: 'set-session-bar-position'; position: SessionBarPosition }
|
|
623
620
|
|
|
624
621
|
// -- Git panel actions --
|
|
625
622
|
export interface GitRefreshPayload {
|
|
@@ -33,7 +33,6 @@ export function saveCurrentWorkspace(state: AppState): void {
|
|
|
33
33
|
position: state.gitPane.position,
|
|
34
34
|
visible: state.gitPane.visible,
|
|
35
35
|
},
|
|
36
|
-
sessionBarPosition: state.sessionBar.position,
|
|
37
36
|
sessionBarVisible: state.sessionBar.visible,
|
|
38
37
|
sidebar: {
|
|
39
38
|
visible: state.sidebar.visible,
|
|
@@ -1,34 +1,16 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
BoxRenderable,
|
|
3
|
-
MouseEvent as OtuiMouseEvent,
|
|
4
|
-
ScrollBoxRenderable,
|
|
5
|
-
} from '@opentui/core'
|
|
1
|
+
import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
6
2
|
|
|
7
|
-
import {
|
|
3
|
+
import { useCallback, useMemo, useRef } from 'react'
|
|
8
4
|
|
|
9
|
-
import type { BranchDivergence } from '../../../../state/types'
|
|
10
|
-
|
|
11
|
-
import { useWorktreeDivergencePolling } from '../../../../git/worktree-divergence-poller'
|
|
12
5
|
import { useAppStore } from '../../../../state/app-store'
|
|
13
6
|
import { dispatchGlobal } from '../../../../state/dispatch-ref'
|
|
14
|
-
import {
|
|
15
|
-
getActiveWorktree,
|
|
16
|
-
getRenderedTabWorktreeId,
|
|
17
|
-
getSessionProjectPath,
|
|
18
|
-
getWorktreeColor,
|
|
19
|
-
orderTabsByWorktree,
|
|
20
|
-
} from '../../../../state/session-worktrees'
|
|
21
|
-
import { getCurrentTheme, type ResolvedTuiTheme, useTheme } from '../../../theme'
|
|
7
|
+
import { useTheme } from '../../../theme'
|
|
22
8
|
import { buildGitPaneContextMenu } from '../../git/pane/git-pane-context-menu'
|
|
23
9
|
import { GitPaneWidget } from '../../git/pane/git-pane-widget'
|
|
24
10
|
import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
|
|
25
|
-
import {
|
|
26
|
-
import { TabItem } from './tab-item'
|
|
27
|
-
import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
|
|
28
|
-
import { useSidebarBranch } from './use-sidebar-branch'
|
|
11
|
+
import { WorkspaceList } from './workspace-list'
|
|
29
12
|
|
|
30
13
|
interface SidebarProps {
|
|
31
|
-
onTabActivate?: (tabId: string) => void
|
|
32
14
|
onResizeDrag?: (event: OtuiMouseEvent) => boolean
|
|
33
15
|
onResizeDragEnd?: () => void
|
|
34
16
|
onEmbeddedGitResizeStart?: (info: {
|
|
@@ -38,314 +20,9 @@ interface SidebarProps {
|
|
|
38
20
|
}) => void
|
|
39
21
|
}
|
|
40
22
|
|
|
41
|
-
const GUTTER_START = '╭'
|
|
42
|
-
const GUTTER_MIDDLE = '├'
|
|
43
|
-
const GUTTER_END = '╰'
|
|
44
|
-
const GUTTER_PAD = '│'
|
|
45
23
|
const RESIZE_HANDLE = '─'
|
|
46
|
-
const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
|
|
47
|
-
|
|
48
|
-
// Left accent strip + trailing space for a worktree group header.
|
|
49
|
-
const WORKTREE_STRIP = '▍ '
|
|
50
|
-
|
|
51
|
-
// Compact "↑ahead ↓behind" label for a worktree group header; empty when the
|
|
52
|
-
// branch is level with its base (or divergence isn't known yet).
|
|
53
|
-
function formatDivergence(divergence: BranchDivergence | undefined): string {
|
|
54
|
-
if (divergence == null) return ''
|
|
55
|
-
const parts: string[] = []
|
|
56
|
-
if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
|
|
57
|
-
if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
|
|
58
|
-
return parts.join(' ')
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Fit a worktree label into `max` columns, ellipsizing when it would overflow.
|
|
62
|
-
// Branch names are ASCII, so character count tracks rendered column width.
|
|
63
|
-
function truncateLabel(label: string, max: number): string {
|
|
64
|
-
if (max <= 0) return ''
|
|
65
|
-
if (label.length <= max) return label
|
|
66
|
-
if (max === 1) return '…'
|
|
67
|
-
return `${label.slice(0, max - 1)}…`
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function getRowBackground({
|
|
71
|
-
alternate,
|
|
72
|
-
isActive,
|
|
73
|
-
t,
|
|
74
|
-
}: {
|
|
75
|
-
isActive: boolean
|
|
76
|
-
alternate: boolean
|
|
77
|
-
t: ResolvedTuiTheme
|
|
78
|
-
}): string | undefined {
|
|
79
|
-
if (isActive) return t.backgroundElement
|
|
80
|
-
if (alternate) return t.backgroundPanel
|
|
81
|
-
return t.backgroundPanel
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: number }) {
|
|
85
|
-
const t = useTheme()
|
|
86
|
-
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
87
|
-
const sessions = useAppStore((s) => s.sessions)
|
|
88
|
-
const currentSession =
|
|
89
|
-
currentSessionId != null && currentSessionId !== ''
|
|
90
|
-
? sessions.find((s) => s.id === currentSessionId)
|
|
91
|
-
: undefined
|
|
92
|
-
const activeWorktree = getActiveWorktree(currentSession)
|
|
93
|
-
const projectPath = getSessionProjectPath(currentSession)
|
|
94
|
-
const branch = useSidebarBranch(projectPath)
|
|
95
|
-
|
|
96
|
-
const handleNewAssistant = useCallback((e: OtuiMouseEvent) => {
|
|
97
|
-
e.stopPropagation()
|
|
98
|
-
dispatchGlobal({ type: 'open-new-tab-modal' })
|
|
99
|
-
}, [])
|
|
100
|
-
|
|
101
|
-
return (
|
|
102
|
-
<box flexDirection="column" flexShrink={0} gap={0}>
|
|
103
|
-
<text fg={t.text} selectable={false}>
|
|
104
|
-
<strong>aimux</strong>
|
|
105
|
-
</text>
|
|
106
|
-
<text fg={t.text} selectable={false}>
|
|
107
|
-
{currentSession ? currentSession.name : 'No workspace selected'}
|
|
108
|
-
</text>
|
|
109
|
-
{(branch != null && branch !== '') || activeWorktree ? (
|
|
110
|
-
<box flexDirection="row">
|
|
111
|
-
<text fg={t.text} selectable={false}>
|
|
112
|
-
{'\u{e702}'}{' '}
|
|
113
|
-
</text>
|
|
114
|
-
<text fg={t.text} selectable={false}>
|
|
115
|
-
{branch ?? activeWorktree?.branch ?? activeWorktree?.name}
|
|
116
|
-
</text>
|
|
117
|
-
{activeWorktree?.source === 'aimux-temp' ? (
|
|
118
|
-
<text fg={t.textMuted} selectable={false}>
|
|
119
|
-
{' '}
|
|
120
|
-
tmp
|
|
121
|
-
</text>
|
|
122
|
-
) : null}
|
|
123
|
-
</box>
|
|
124
|
-
) : null}
|
|
125
|
-
<box
|
|
126
|
-
flexDirection="row"
|
|
127
|
-
paddingY={1}
|
|
128
|
-
backgroundColor={t.backgroundPanel}
|
|
129
|
-
justifyContent="center"
|
|
130
|
-
marginTop={1}
|
|
131
|
-
onMouseDown={handleNewAssistant}
|
|
132
|
-
>
|
|
133
|
-
<text fg={t.text} selectable={false}>
|
|
134
|
-
+ New assistant
|
|
135
|
-
</text>
|
|
136
|
-
</box>
|
|
137
|
-
<text fg={t.textMuted} selectable={false}>
|
|
138
|
-
{'·'.repeat(Math.max(0, contentWidth - 2))}
|
|
139
|
-
</text>
|
|
140
|
-
</box>
|
|
141
|
-
)
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
function renderGroupGutter(isGroupStart: boolean, isGroupMiddle: boolean, isGroupEnd: boolean) {
|
|
145
|
-
const t = getCurrentTheme()
|
|
146
|
-
return (
|
|
147
|
-
<box flexDirection="column" width={1} overflow="hidden">
|
|
148
|
-
<text fg={t.border} selectable={false}>
|
|
149
|
-
{/* oxlint-disable-next-line no-nested-ternary */}
|
|
150
|
-
{isGroupStart ? GUTTER_START : isGroupMiddle ? GUTTER_MIDDLE : GUTTER_PAD}
|
|
151
|
-
</text>
|
|
152
|
-
<text fg={t.border} selectable={false}>
|
|
153
|
-
{isGroupEnd ? GUTTER_END : GUTTER_PAD}
|
|
154
|
-
</text>
|
|
155
|
-
</box>
|
|
156
|
-
)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const TabRowButton = memo(function TabRowButton({
|
|
160
|
-
backgroundColor,
|
|
161
|
-
children,
|
|
162
|
-
onActivate,
|
|
163
|
-
tabId,
|
|
164
|
-
}: {
|
|
165
|
-
tabId: string
|
|
166
|
-
backgroundColor: string | undefined
|
|
167
|
-
onActivate?: (tabId: string) => void
|
|
168
|
-
children: ReactNode
|
|
169
|
-
}) {
|
|
170
|
-
const handleMouseDown = useCallback(
|
|
171
|
-
(event: OtuiMouseEvent) => {
|
|
172
|
-
event.stopPropagation()
|
|
173
|
-
onActivate?.(tabId)
|
|
174
|
-
},
|
|
175
|
-
[onActivate, tabId]
|
|
176
|
-
)
|
|
177
|
-
return (
|
|
178
|
-
<box backgroundColor={backgroundColor} flexDirection="row" onMouseDown={handleMouseDown}>
|
|
179
|
-
{children}
|
|
180
|
-
</box>
|
|
181
|
-
)
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
interface TabsBodyProps {
|
|
185
|
-
onTabActivate?: (tabId: string) => void
|
|
186
|
-
contentWidth: number
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const TabsBody = memo(function TabsBody({ contentWidth, onTabActivate }: TabsBodyProps) {
|
|
190
|
-
const t = useTheme()
|
|
191
|
-
const tabs = useAppStore((s) => s.tabs)
|
|
192
|
-
const activeTabId = useAppStore((s) => s.activeTabId)
|
|
193
|
-
const focusMode = useAppStore((s) => s.focusMode)
|
|
194
|
-
const layoutTrees = useAppStore((s) => s.layoutTrees)
|
|
195
|
-
const sidebarVisible = useAppStore((s) => s.sidebar.visible)
|
|
196
|
-
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
197
|
-
const sessions = useAppStore((s) => s.sessions)
|
|
198
|
-
const worktreeDivergence = useAppStore((s) => s.worktreeDivergence)
|
|
199
|
-
const currentSession =
|
|
200
|
-
currentSessionId != null && currentSessionId !== ''
|
|
201
|
-
? sessions.find((s) => s.id === currentSessionId)
|
|
202
|
-
: undefined
|
|
203
|
-
const worktrees = useMemo(() => currentSession?.worktrees ?? [], [currentSession?.worktrees])
|
|
204
|
-
const worktreeById = useMemo(
|
|
205
|
-
() => new Map(worktrees.map((worktree) => [worktree.id, worktree])),
|
|
206
|
-
[worktrees]
|
|
207
|
-
)
|
|
208
|
-
const groupedTabs = useMemo(() => {
|
|
209
|
-
return orderTabsByWorktree(tabs, currentSession)
|
|
210
|
-
}, [currentSession, tabs])
|
|
211
|
-
const showWorktreeSeparators = useMemo(() => {
|
|
212
|
-
const ids = new Set<string>()
|
|
213
|
-
for (const tab of groupedTabs) {
|
|
214
|
-
ids.add(getRenderedTabWorktreeId(tab, worktrees))
|
|
215
|
-
}
|
|
216
|
-
const activeWorktreeId =
|
|
217
|
-
currentSession?.activeWorktreeId ?? getActiveWorktree(currentSession)?.id
|
|
218
|
-
if (activeWorktreeId != null && activeWorktreeId !== '') ids.add(activeWorktreeId)
|
|
219
|
-
return ids.size >= 2
|
|
220
|
-
}, [currentSession, groupedTabs, worktrees])
|
|
221
|
-
|
|
222
|
-
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
223
|
-
const activeIndex = groupedTabs.findIndex((tab) => tab.id === activeTabId)
|
|
224
|
-
const tabGroupInfo = useMemo(
|
|
225
|
-
() => buildTabGroupInfo(layoutTrees, groupedTabs),
|
|
226
|
-
[layoutTrees, groupedTabs]
|
|
227
|
-
)
|
|
228
|
-
|
|
229
|
-
useSidebarAutoScroll({
|
|
230
|
-
activeIndex,
|
|
231
|
-
activeTabId,
|
|
232
|
-
scrollRef,
|
|
233
|
-
tabCount: groupedTabs.length,
|
|
234
|
-
visible: sidebarVisible,
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
return (
|
|
238
|
-
<scrollbox
|
|
239
|
-
paddingTop={0}
|
|
240
|
-
ref={scrollRef}
|
|
241
|
-
flexGrow={1}
|
|
242
|
-
scrollY
|
|
243
|
-
viewportCulling
|
|
244
|
-
contentOptions={COLUMN_CONTENT_OPTIONS}
|
|
245
|
-
>
|
|
246
|
-
{tabs.length === 0 ? (
|
|
247
|
-
<box paddingTop={1}>
|
|
248
|
-
<text fg={t.textMuted} selectable={false}>
|
|
249
|
-
No tabs yet. Press Ctrl+n.
|
|
250
|
-
</text>
|
|
251
|
-
</box>
|
|
252
|
-
) : (
|
|
253
|
-
groupedTabs.map((tab, index) => {
|
|
254
|
-
const isActive = tab.id === activeTabId
|
|
255
|
-
const alternate = index % 2 === 1
|
|
256
|
-
const info = tabGroupInfo.get(tab.id)
|
|
257
|
-
const inLayout = !!(info?.inLayout === true)
|
|
258
|
-
const inGroup = info ? index >= info.groupStart && index <= info.groupEnd : false
|
|
259
|
-
const isGroupStart = info ? index === info.groupStart : false
|
|
260
|
-
const isGroupEnd = info ? index === info.groupEnd : false
|
|
261
|
-
const isGroupMiddle = inGroup && !isGroupStart && !isGroupEnd
|
|
262
|
-
const tabOwnWorktree =
|
|
263
|
-
tab.worktreeId != null && tab.worktreeId !== ''
|
|
264
|
-
? worktreeById.get(tab.worktreeId)
|
|
265
|
-
: undefined
|
|
266
|
-
let tabWorktree = tabOwnWorktree
|
|
267
|
-
if (!tabWorktree && worktrees.length > 1) {
|
|
268
|
-
tabWorktree = worktrees[0]
|
|
269
|
-
}
|
|
270
|
-
// A tab's worktree can be moved when it has a branch (squash needs one,
|
|
271
|
-
// so not the primary) and there's at least one other worktree to land in.
|
|
272
|
-
const moveWorktreeId =
|
|
273
|
-
tabOwnWorktree?.branch != null && tabOwnWorktree.branch !== '' && worktrees.length > 1
|
|
274
|
-
? tabOwnWorktree.id
|
|
275
|
-
: undefined
|
|
276
|
-
const prevTab = groupedTabs[index - 1]
|
|
277
|
-
const startsWorktreeGroup =
|
|
278
|
-
!prevTab ||
|
|
279
|
-
getRenderedTabWorktreeId(prevTab, worktrees) !==
|
|
280
|
-
getRenderedTabWorktreeId(tab, worktrees)
|
|
281
|
-
const worktreeColor = tabWorktree
|
|
282
|
-
? (tabWorktree.color ?? getWorktreeColor(tabWorktree.id))
|
|
283
|
-
: t.textMuted
|
|
284
|
-
const worktreeLabel = tabWorktree?.branch ?? tabWorktree?.name ?? 'main'
|
|
285
|
-
const aheadBehind = formatDivergence(
|
|
286
|
-
tabWorktree != null ? worktreeDivergence[tabWorktree.id] : undefined
|
|
287
|
-
)
|
|
288
|
-
const aheadBehindText = aheadBehind === '' ? '' : ` ${aheadBehind}`
|
|
289
|
-
// Reserve room for the strip, the ahead/behind chip and a 1-col gap so
|
|
290
|
-
// the header never wraps; the divider fills whatever remains (min 0).
|
|
291
|
-
const labelBudget = Math.max(
|
|
292
|
-
0,
|
|
293
|
-
contentWidth - WORKTREE_STRIP.length - aheadBehindText.length - 1
|
|
294
|
-
)
|
|
295
|
-
const headerLabel = truncateLabel(worktreeLabel, labelBudget)
|
|
296
24
|
|
|
297
|
-
|
|
298
|
-
<box key={tab.id} flexDirection="column">
|
|
299
|
-
{showWorktreeSeparators && startsWorktreeGroup ? (
|
|
300
|
-
<box flexDirection="row" overflow="hidden" paddingTop={index === 0 ? 0 : 1}>
|
|
301
|
-
<text selectable={false} wrapMode="none" flexShrink={0}>
|
|
302
|
-
<span fg={worktreeColor}>
|
|
303
|
-
{WORKTREE_STRIP}
|
|
304
|
-
{headerLabel}
|
|
305
|
-
</span>
|
|
306
|
-
{aheadBehindText !== '' ? (
|
|
307
|
-
<span fg={t.textMuted}>{aheadBehindText}</span>
|
|
308
|
-
) : null}
|
|
309
|
-
</text>
|
|
310
|
-
<box flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
|
|
311
|
-
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
312
|
-
{' '}
|
|
313
|
-
{'─'.repeat(contentWidth)}
|
|
314
|
-
</text>
|
|
315
|
-
</box>
|
|
316
|
-
</box>
|
|
317
|
-
) : null}
|
|
318
|
-
<TabRowButton
|
|
319
|
-
tabId={tab.id}
|
|
320
|
-
onActivate={onTabActivate}
|
|
321
|
-
backgroundColor={getRowBackground({ alternate, isActive, t })}
|
|
322
|
-
>
|
|
323
|
-
{inGroup ? renderGroupGutter(isGroupStart, isGroupMiddle, isGroupEnd) : null}
|
|
324
|
-
<box flexGrow={1}>
|
|
325
|
-
<TabItem
|
|
326
|
-
id={`sidebar-tab-${tab.id}`}
|
|
327
|
-
tab={tab}
|
|
328
|
-
active={isActive}
|
|
329
|
-
focused={focusMode === 'navigation'}
|
|
330
|
-
inLayout={inLayout}
|
|
331
|
-
moveWorktreeId={moveWorktreeId}
|
|
332
|
-
/>
|
|
333
|
-
</box>
|
|
334
|
-
</TabRowButton>
|
|
335
|
-
</box>
|
|
336
|
-
)
|
|
337
|
-
})
|
|
338
|
-
)}
|
|
339
|
-
</scrollbox>
|
|
340
|
-
)
|
|
341
|
-
})
|
|
342
|
-
|
|
343
|
-
export function Sidebar({
|
|
344
|
-
onEmbeddedGitResizeStart,
|
|
345
|
-
onResizeDrag,
|
|
346
|
-
onResizeDragEnd,
|
|
347
|
-
onTabActivate,
|
|
348
|
-
}: SidebarProps) {
|
|
25
|
+
export function Sidebar({ onEmbeddedGitResizeStart, onResizeDrag, onResizeDragEnd }: SidebarProps) {
|
|
349
26
|
const t = useTheme()
|
|
350
27
|
const sidebarBg = t.background
|
|
351
28
|
const sidebarVisible = useAppStore((s) => s.sidebar.visible)
|
|
@@ -353,7 +30,6 @@ export function Sidebar({
|
|
|
353
30
|
const gitPane = useAppStore((s) => s.gitPane)
|
|
354
31
|
const focusMode = useAppStore((s) => s.focusMode)
|
|
355
32
|
const bodyRef = useRef<BoxRenderable | null>(null)
|
|
356
|
-
useWorktreeDivergencePolling(sidebarVisible)
|
|
357
33
|
|
|
358
34
|
const handleSidebarMouseDown = useCallback(() => {
|
|
359
35
|
if (focusMode === 'terminal-input') {
|
|
@@ -418,15 +94,10 @@ export function Sidebar({
|
|
|
418
94
|
}
|
|
419
95
|
)
|
|
420
96
|
|
|
421
|
-
// flex-grow scaled by 100 (integer preferred);
|
|
422
|
-
const
|
|
97
|
+
// flex-grow scaled by 100 (integer preferred); workspace list gets (1-ratio), git gets ratio.
|
|
98
|
+
const listGrow = gitEmbedded ? Math.max(1, Math.round((1 - gitPane.embeddedRatio) * 100)) : 1
|
|
423
99
|
const gitGrow = gitEmbedded ? Math.max(1, Math.round(gitPane.embeddedRatio * 100)) : 0
|
|
424
100
|
|
|
425
|
-
const separator = (
|
|
426
|
-
<text fg={t.textMuted} selectable={false}>
|
|
427
|
-
{'·'.repeat(Math.max(0, contentWidth - 2))}
|
|
428
|
-
</text>
|
|
429
|
-
)
|
|
430
101
|
const gitBody = gitEmbedded ? (
|
|
431
102
|
<ContextMenuBox
|
|
432
103
|
flexDirection="column"
|
|
@@ -462,7 +133,6 @@ export function Sidebar({
|
|
|
462
133
|
>
|
|
463
134
|
<box flexDirection="row" width={sidebarWidth} flexGrow={1} overflow="hidden">
|
|
464
135
|
<box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
|
|
465
|
-
<SidebarTop contentWidth={contentWidth} />
|
|
466
136
|
<box ref={bodyRef} flexDirection="column" flexGrow={1} overflow="hidden">
|
|
467
137
|
{gitOnTop ? (
|
|
468
138
|
<>
|
|
@@ -472,12 +142,12 @@ export function Sidebar({
|
|
|
472
142
|
) : null}
|
|
473
143
|
<box
|
|
474
144
|
flexDirection="column"
|
|
475
|
-
flexGrow={
|
|
145
|
+
flexGrow={listGrow}
|
|
476
146
|
flexShrink={1}
|
|
477
147
|
flexBasis={0}
|
|
478
148
|
overflow="hidden"
|
|
479
149
|
>
|
|
480
|
-
<
|
|
150
|
+
<WorkspaceList contentWidth={contentWidth} />
|
|
481
151
|
</box>
|
|
482
152
|
{gitOnBottom ? (
|
|
483
153
|
<>
|
|
@@ -486,7 +156,6 @@ export function Sidebar({
|
|
|
486
156
|
</>
|
|
487
157
|
) : null}
|
|
488
158
|
</box>
|
|
489
|
-
{!gitEmbedded ? separator : null}
|
|
490
159
|
</box>
|
|
491
160
|
</box>
|
|
492
161
|
</ContextMenuBox>
|