@brimveyn/aimux 1.20.3 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +3 -48
  3. package/src/app-runtime/split-drag-controller.ts +4 -8
  4. package/src/app-runtime/use-mouse-handlers.ts +27 -42
  5. package/src/app-runtime/use-terminal-resize.ts +11 -20
  6. package/src/app.tsx +9 -37
  7. package/src/config.ts +98 -8
  8. package/src/git/pr-merge.ts +64 -0
  9. package/src/git/pr-status-poller.ts +57 -0
  10. package/src/git/pr-status.ts +227 -0
  11. package/src/index.tsx +7 -2
  12. package/src/platform/clipboard.ts +140 -4
  13. package/src/platform/open-url.ts +39 -0
  14. package/src/services/ai-usage/spawn.ts +3 -1
  15. package/src/state/bars.ts +75 -0
  16. package/src/state/git-pane-sizing.ts +0 -9
  17. package/src/state/pr-status-store.ts +39 -0
  18. package/src/state/reducers/git-panel-state.ts +0 -47
  19. package/src/state/reducers/ui-state.ts +83 -13
  20. package/src/state/session-persistence.ts +5 -7
  21. package/src/state/store.ts +28 -35
  22. package/src/state/types.ts +24 -19
  23. package/src/state/workspace-save.ts +5 -7
  24. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
  25. package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
  26. package/src/ui/components/git/pane/git-pane-widget.tsx +30 -16
  27. package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
  28. package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
  29. package/src/ui/components/layout/bar.tsx +191 -0
  30. package/src/ui/components/layout/top-tab-bar.tsx +3 -2
  31. package/src/ui/root.tsx +25 -109
  32. package/src/ui/widgets/registry.tsx +18 -0
  33. package/src/ui/widgets/widget-context-menu.ts +69 -0
  34. package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
  35. package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
@@ -105,58 +105,11 @@ function sameFiles(a: GitFileEntry[], b: GitFileEntry[]): boolean {
105
105
 
106
106
  export function reduceGitPanelState(state: AppState, action: AppAction): AppState | null {
107
107
  switch (action.type) {
108
- case 'toggle-git-pane': {
109
- const nextVisible = !state.gitPane.visible
110
- const sidebarMustShow = state.gitPane.mode === 'embedded' && nextVisible
111
- return {
112
- ...state,
113
- gitPane: { ...state.gitPane, visible: nextVisible },
114
- sidebar: sidebarMustShow ? { ...state.sidebar, visible: true } : state.sidebar,
115
- }
116
- }
117
- case 'resize-git-pane': {
118
- const target = state.gitPane.mode === 'pane' ? 'paneRatio' : 'embeddedRatio'
119
- const nextRatio = clampRatio(state.gitPane[target] + action.delta)
120
- if (nextRatio === state.gitPane[target]) return state
121
- return { ...state, gitPane: { ...state.gitPane, [target]: nextRatio } }
122
- }
123
- case 'set-git-pane-ratio': {
124
- const key = action.target === 'pane' ? 'paneRatio' : 'embeddedRatio'
125
- const nextRatio = clampRatio(action.ratio)
126
- if (nextRatio === state.gitPane[key]) return state
127
- return { ...state, gitPane: { ...state.gitPane, [key]: nextRatio } }
128
- }
129
108
  case 'resize-git-diff-pane': {
130
109
  const nextRatio = clampRatio(state.gitPane.diffModeRatio + action.delta)
131
110
  if (nextRatio === state.gitPane.diffModeRatio) return state
132
111
  return { ...state, gitPane: { ...state.gitPane, diffModeRatio: nextRatio } }
133
112
  }
134
- case 'set-git-pane-mode': {
135
- if (state.gitPane.mode === action.mode) return state
136
- const isEmbedded = action.mode === 'embedded'
137
- const isValidEmbedded =
138
- state.gitPane.position === 'top' || state.gitPane.position === 'bottom'
139
- const isValidPane = state.gitPane.position === 'left' || state.gitPane.position === 'right'
140
- let nextPosition: typeof state.gitPane.position
141
- if (isEmbedded) {
142
- nextPosition = isValidEmbedded ? state.gitPane.position : 'bottom'
143
- } else {
144
- nextPosition = isValidPane ? state.gitPane.position : 'left'
145
- }
146
- return {
147
- ...state,
148
- gitPane: { ...state.gitPane, mode: action.mode, position: nextPosition },
149
- }
150
- }
151
- case 'set-git-pane-position': {
152
- const validForMode =
153
- state.gitPane.mode === 'embedded'
154
- ? action.position === 'top' || action.position === 'bottom'
155
- : action.position === 'left' || action.position === 'right'
156
- if (!validForMode) return state
157
- if (state.gitPane.position === action.position) return state
158
- return { ...state, gitPane: { ...state.gitPane, position: action.position } }
159
- }
160
113
  case 'git-refresh-success': {
161
114
  const prev = state.gitPanel
162
115
  const next = action.payload
@@ -1,21 +1,91 @@
1
- import type { AppAction, AppState } from '../types'
1
+ import type { AppAction, AppState, BarSide, BarState, BarWidget } from '../types'
2
+
3
+ import {
4
+ boundaryDeltaFromRatio,
5
+ clampBarWidth,
6
+ findWidgetBar,
7
+ shiftBoundary,
8
+ visibleWidgets,
9
+ } from '../bars'
10
+
11
+ function withBar(state: AppState, side: BarSide, next: BarState): AppState {
12
+ if (next === state.bars[side]) return state
13
+ return { ...state, bars: { ...state.bars, [side]: next } }
14
+ }
15
+
16
+ function setBarWidth(state: AppState, side: BarSide, width: number): AppState {
17
+ const bar = state.bars[side]
18
+ const next = clampBarWidth(width)
19
+ if (next === bar.width) return state
20
+ return withBar(state, side, { ...bar, width: next })
21
+ }
22
+
23
+ /**
24
+ * Move `widgetId` to `side` at `index`. Covers both cross-bar moves and
25
+ * in-bar reordering — removing then re-inserting is the same operation.
26
+ */
27
+ function moveWidget(state: AppState, widgetId: string, side: BarSide, index: number): AppState {
28
+ const from = findWidgetBar(state.bars, widgetId)
29
+ if (from === null) return state
30
+ const widget = state.bars[from].widgets.find((w) => w.id === widgetId)
31
+ if (!widget) return state
32
+
33
+ const source = state.bars[from].widgets.filter((w) => w.id !== widgetId)
34
+ const target: BarWidget[] = from === side ? source : [...state.bars[side].widgets]
35
+ const at = Math.max(0, Math.min(target.length, index))
36
+ target.splice(at, 0, widget)
37
+
38
+ const bars = { ...state.bars }
39
+ bars[from] = { ...state.bars[from], widgets: from === side ? target : source }
40
+ // Showing a widget in a hidden bar would silently swallow it.
41
+ bars[side] = { ...bars[side], visible: bars[side].visible || widget.visible, widgets: target }
42
+ return { ...state, bars }
43
+ }
2
44
 
3
45
  export function reduceUIState(state: AppState, action: AppAction): AppState | null {
4
46
  switch (action.type) {
5
- case 'toggle-sidebar':
6
- return { ...state, sidebar: { ...state.sidebar, visible: !state.sidebar.visible } }
7
- case 'resize-sidebar': {
8
- const width = Math.min(
9
- state.sidebar.maxWidth,
10
- Math.max(state.sidebar.minWidth, state.sidebar.width + action.delta)
47
+ case 'toggle-bar': {
48
+ const bar = state.bars[action.side]
49
+ return withBar(state, action.side, { ...bar, visible: !bar.visible })
50
+ }
51
+ case 'resize-bar':
52
+ return setBarWidth(state, action.side, state.bars[action.side].width + action.delta)
53
+ case 'set-bar-width':
54
+ return setBarWidth(state, action.side, action.width)
55
+ case 'toggle-widget': {
56
+ const side = findWidgetBar(state.bars, action.widgetId)
57
+ if (side === null) return state
58
+ const bar = state.bars[side]
59
+ const widgets = bar.widgets.map((w) =>
60
+ w.id === action.widgetId ? { ...w, visible: !w.visible } : w
11
61
  )
12
- if (width === state.sidebar.width) return state
13
- return { ...state, sidebar: { ...state.sidebar, width } }
62
+ const revealed = widgets.some((w) => w.id === action.widgetId && w.visible)
63
+ return withBar(state, side, { ...bar, visible: bar.visible || revealed, widgets })
64
+ }
65
+ case 'move-widget':
66
+ return moveWidget(state, action.widgetId, action.side, action.index)
67
+ case 'set-bar-boundary': {
68
+ const bar = state.bars[action.side]
69
+ const delta = boundaryDeltaFromRatio(bar, action.index, action.ratio)
70
+ const widgets = shiftBoundary(bar, action.index, delta)
71
+ if (widgets === bar.widgets) return state
72
+ return withBar(state, action.side, { ...bar, widgets })
14
73
  }
15
- case 'set-sidebar-width': {
16
- const width = Math.min(state.sidebar.maxWidth, Math.max(state.sidebar.minWidth, action.width))
17
- if (width === state.sidebar.width) return state
18
- return { ...state, sidebar: { ...state.sidebar, width } }
74
+ case 'resize-widget': {
75
+ // Keyboard resize: grow/shrink this widget against its neighbour. The
76
+ // widget below owns the boundary above it, so the sign flips there.
77
+ const side = findWidgetBar(state.bars, action.widgetId)
78
+ if (side === null) return state
79
+ const bar = state.bars[side]
80
+ const visible = visibleWidgets(bar)
81
+ const at = visible.findIndex((w) => w.id === action.widgetId)
82
+ if (at === -1) return state
83
+ const total = visible.reduce((sum, w) => sum + w.grow, 0)
84
+ const index = at > 0 ? at - 1 : at
85
+ const deltaGrow = action.delta * total * (at > 0 ? -1 : 1)
86
+ const widgets = shiftBoundary(bar, index, deltaGrow)
87
+ if (widgets === bar.widgets) return state
88
+ return withBar(state, side, { ...bar, widgets })
19
89
  }
20
90
  case 'set-focus-mode':
21
91
  return { ...state, focusMode: action.focusMode }
@@ -42,9 +42,11 @@ export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
42
42
  layoutTree: Object.values(state.layoutTrees)[0] ?? undefined,
43
43
  layoutTrees: Object.keys(state.layoutTrees).length > 0 ? state.layoutTrees : undefined,
44
44
  savedAt: new Date().toISOString(),
45
+ // Mirror of the left bar. `isWorkspaceSnapshotV1` requires this key —
46
+ // dropping it invalidates every entry in the session catalog.
45
47
  sidebar: {
46
- visible: state.sidebar.visible,
47
- width: state.sidebar.width,
48
+ visible: state.bars.left.visible,
49
+ width: state.bars.left.width,
48
50
  },
49
51
  tabGroupMap: Object.keys(state.tabGroupMap).length > 0 ? state.tabGroupMap : undefined,
50
52
  tabs: state.tabs.map((tab) => ({
@@ -277,10 +279,7 @@ export function restoreWorkspaceState(
277
279
  state: AppState,
278
280
  workspaceSnapshot: WorkspaceSnapshotV1 | undefined,
279
281
  options: RestoreOptions = {}
280
- ): Pick<
281
- AppState,
282
- 'tabs' | 'activeTabId' | 'focusMode' | 'sidebar' | 'layoutTrees' | 'tabGroupMap'
283
- > &
282
+ ): Pick<AppState, 'tabs' | 'activeTabId' | 'focusMode' | 'layoutTrees' | 'tabGroupMap'> &
284
283
  Partial<Pick<AppState, 'lastActiveTabByWorktree'>> {
285
284
  const tabs = restoreTabsFromWorkspace(workspaceSnapshot, options)
286
285
  const activeTabId =
@@ -305,7 +304,6 @@ export function restoreWorkspaceState(
305
304
  activeTabId,
306
305
  focusMode: 'navigation',
307
306
  layoutTrees,
308
- sidebar: state.sidebar,
309
307
  tabGroupMap,
310
308
  tabs: orderedTabs,
311
309
  ...(persistedLastActiveTab ? { lastActiveTabByWorktree: persistedLastActiveTab } : {}),
@@ -1,5 +1,6 @@
1
1
  import type { WorktreeTemplate } from '../config'
2
2
 
3
+ import { clampBarWidth, KNOWN_WIDGET_IDS } from './bars'
3
4
  import { reduceAutoCommit } from './reducers/auto-commit-state'
4
5
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
5
6
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
@@ -12,26 +13,22 @@ import { filterSnippets } from './selectors'
12
13
  import {
13
14
  type AppAction,
14
15
  type AppState,
16
+ type BarsState,
15
17
  EMPTY_AUTO_COMMIT_STATE,
16
18
  EMPTY_MULTI_REPO_STATE,
17
19
  type GitModeState,
18
- type GitPaneMode,
19
- type GitPanePosition,
20
20
  type GitPaneState,
21
21
  type SessionRecord,
22
22
  type SnippetRecord,
23
23
  } from './types'
24
24
 
25
- const DEFAULT_SIDEBAR_WIDTH = 28
26
- const DEFAULT_SIDEBAR_MIN_WIDTH = 18
27
- const DEFAULT_SIDEBAR_MAX_WIDTH = 42
28
25
  const DEFAULT_TERMINAL_COLS = 80
29
26
  const DEFAULT_TERMINAL_ROWS = 24
30
27
 
31
28
  export interface InitialStateOverrides {
32
29
  gitMode?: Partial<GitModeState>
33
30
  gitPane?: Partial<GitPaneState>
34
- sidebar?: Pick<AppState['sidebar'], 'visible' | 'width'>
31
+ bars?: BarsState
35
32
  sessionBarVisible?: boolean
36
33
  worktreeTemplates?: WorktreeTemplate[]
37
34
  }
@@ -39,26 +36,37 @@ export interface InitialStateOverrides {
39
36
  const DEFAULT_GIT_PANE: GitPaneState = {
40
37
  diffCount: { enabled: true },
41
38
  diffModeRatio: 0.35,
42
- embeddedRatio: 0.5,
43
39
  fileListMode: 'tree',
44
- mode: 'embedded',
45
- paneRatio: 0.5,
46
40
  path: { enabled: true },
47
- position: 'bottom',
48
41
  prefetchRadius: 5,
49
42
  treeCompaction: true,
50
- visible: true,
51
43
  }
52
44
 
53
- function resolveGitPanePosition(mode: GitPaneMode, position: GitPanePosition): GitPanePosition {
54
- if (mode === 'embedded') {
55
- return position === 'top' || position === 'bottom' ? position : 'bottom'
56
- }
57
- return position === 'left' || position === 'right' ? position : 'left'
45
+ export const DEFAULT_BARS: BarsState = {
46
+ left: {
47
+ visible: true,
48
+ widgets: [
49
+ { grow: 50, id: 'workspaces', visible: true },
50
+ { grow: 50, id: 'git', visible: true },
51
+ ],
52
+ width: 28,
53
+ },
54
+ right: { visible: false, widgets: [], width: 40 },
58
55
  }
59
56
 
60
- function clampSidebarWidth(width: number): number {
61
- return Math.min(DEFAULT_SIDEBAR_MAX_WIDTH, Math.max(DEFAULT_SIDEBAR_MIN_WIDTH, width))
57
+ /**
58
+ * Drop widget ids this build cannot render (config written by a newer or
59
+ * patched version) and normalise widths — the only place unknown ids can enter.
60
+ */
61
+ function sanitizeBars(bars: BarsState): BarsState {
62
+ const sanitizeBar = (bar: BarsState[keyof BarsState]): BarsState[keyof BarsState] => ({
63
+ ...bar,
64
+ widgets: bar.widgets
65
+ .filter((widget) => (KNOWN_WIDGET_IDS as readonly string[]).includes(widget.id))
66
+ .map((widget) => ({ ...widget, grow: Math.max(1, Math.round(widget.grow)) })),
67
+ width: clampBarWidth(bar.width),
68
+ })
69
+ return { left: sanitizeBar(bars.left), right: sanitizeBar(bars.right) }
62
70
  }
63
71
 
64
72
  export function createInitialState(
@@ -68,24 +76,15 @@ export function createInitialState(
68
76
  showSessionPicker = false,
69
77
  overrides: InitialStateOverrides = {}
70
78
  ): AppState {
71
- const gitPaneMode = overrides.gitPane?.mode ?? DEFAULT_GIT_PANE.mode
72
- const gitPanePosition = resolveGitPanePosition(
73
- gitPaneMode,
74
- overrides.gitPane?.position ?? DEFAULT_GIT_PANE.position
75
- )
76
79
  return {
77
80
  activeTabId: null,
78
81
  autoCommit: EMPTY_AUTO_COMMIT_STATE,
82
+ bars: sanitizeBars(overrides.bars ?? DEFAULT_BARS),
79
83
  currentSessionId: null,
80
84
  customCommands,
81
85
  focusMode: showSessionPicker ? 'command-edit' : 'navigation',
82
86
  gitMode: { ...emptyGitMode(), ...overrides.gitMode },
83
- gitPane: {
84
- ...DEFAULT_GIT_PANE,
85
- ...overrides.gitPane,
86
- mode: gitPaneMode,
87
- position: gitPanePosition,
88
- },
87
+ gitPane: { ...DEFAULT_GIT_PANE, ...overrides.gitPane },
89
88
  gitPanel: emptyGitPanel(),
90
89
  lastActiveTabByWorktree: {},
91
90
  layout: {
@@ -109,12 +108,6 @@ export function createInitialState(
109
108
  },
110
109
  sessions,
111
110
  sessionStatuses: {},
112
- sidebar: {
113
- maxWidth: DEFAULT_SIDEBAR_MAX_WIDTH,
114
- minWidth: DEFAULT_SIDEBAR_MIN_WIDTH,
115
- visible: overrides.sidebar?.visible ?? true,
116
- width: clampSidebarWidth(overrides.sidebar?.width ?? DEFAULT_SIDEBAR_WIDTH),
117
- },
118
111
  snippets,
119
112
  tabGroupMap: {},
120
113
  tabs: [],
@@ -211,15 +211,26 @@ export interface TabSession {
211
211
  autoRenameStatus?: 'eligible' | 'attempted'
212
212
  }
213
213
 
214
- export interface SidebarState {
214
+ export type BarSide = 'left' | 'right'
215
+
216
+ /**
217
+ * One widget slot in a bar. `grow` is the flex weight opentui consumes
218
+ * directly; hidden widgets keep their weight but are excluded from the layout.
219
+ */
220
+ export interface BarWidget {
221
+ id: string
222
+ grow: number
223
+ visible: boolean
224
+ }
225
+
226
+ export interface BarState {
215
227
  visible: boolean
216
228
  width: number
217
- minWidth: number
218
- maxWidth: number
229
+ /** Ordered top → bottom. */
230
+ widgets: BarWidget[]
219
231
  }
220
232
 
221
- export type GitPaneMode = 'embedded' | 'pane'
222
- export type GitPanePosition = 'top' | 'bottom' | 'left' | 'right'
233
+ export type BarsState = Record<BarSide, BarState>
223
234
 
224
235
  export type GitPanePathConfig =
225
236
  | { enabled: false }
@@ -230,11 +241,6 @@ export interface GitPaneDiffCountConfig {
230
241
  }
231
242
 
232
243
  export interface GitPaneState {
233
- visible: boolean
234
- mode: GitPaneMode
235
- position: GitPanePosition
236
- paneRatio: number
237
- embeddedRatio: number
238
244
  diffModeRatio: number
239
245
  fileListMode: GitFileListMode
240
246
  treeCompaction: boolean
@@ -611,7 +617,7 @@ export interface AppState {
611
617
  sessionBar: SessionBarState
612
618
  snippets: SnippetRecord[]
613
619
  focusMode: FocusMode
614
- sidebar: SidebarState
620
+ bars: BarsState
615
621
  gitPane: GitPaneState
616
622
  modal: ModalState
617
623
  layout: LayoutState
@@ -803,17 +809,16 @@ export type LayoutAction =
803
809
 
804
810
  // -- UI actions --
805
811
  export type UIAction =
806
- | { type: 'toggle-sidebar' }
807
- | { type: 'resize-sidebar'; delta: number }
808
- | { type: 'set-sidebar-width'; width: number }
812
+ | { type: 'toggle-bar'; side: BarSide }
813
+ | { type: 'resize-bar'; side: BarSide; delta: number }
814
+ | { type: 'set-bar-width'; side: BarSide; width: number }
815
+ | { type: 'toggle-widget'; widgetId: string }
816
+ | { type: 'move-widget'; widgetId: string; side: BarSide; index: number }
817
+ | { type: 'set-bar-boundary'; side: BarSide; index: number; ratio: number }
818
+ | { type: 'resize-widget'; widgetId: string; delta: number }
809
819
  | { type: 'set-focus-mode'; focusMode: FocusMode }
810
820
  | { type: 'set-terminal-size'; cols: number; rows: number }
811
- | { type: 'toggle-git-pane' }
812
- | { type: 'resize-git-pane'; delta: number }
813
- | { type: 'set-git-pane-ratio'; target: 'pane' | 'embedded'; ratio: number }
814
821
  | { type: 'resize-git-diff-pane'; delta: number }
815
- | { type: 'set-git-pane-mode'; mode: GitPaneMode }
816
- | { type: 'set-git-pane-position'; position: GitPanePosition }
817
822
  | { type: 'set-pending-chords'; chords: string[] | null }
818
823
  | { type: 'toggle-session-bar' }
819
824
 
@@ -23,20 +23,18 @@ export function buildSessionsWithCurrentSnapshot(
23
23
  export function saveCurrentWorkspace(state: AppState): void {
24
24
  saveConfig({
25
25
  ...loadConfig(),
26
+ bars: state.bars,
26
27
  customCommands: state.customCommands,
27
28
  gitPane: {
28
29
  diffModeRatio: state.gitPane.diffModeRatio,
29
- embeddedRatio: state.gitPane.embeddedRatio,
30
30
  fileListMode: state.gitPane.fileListMode,
31
- mode: state.gitPane.mode,
32
- paneRatio: state.gitPane.paneRatio,
33
- position: state.gitPane.position,
34
- visible: state.gitPane.visible,
35
31
  },
36
32
  sessionBarVisible: state.sessionBar.visible,
33
+ // Legacy mirror of the left bar: lets an older build (and the workspace
34
+ // snapshot schema) still find a sidebar after a downgrade.
37
35
  sidebar: {
38
- visible: state.sidebar.visible,
39
- width: state.sidebar.width,
36
+ visible: state.bars.left.visible,
37
+ width: state.bars.left.width,
40
38
  },
41
39
  })
42
40
  saveSessionCatalog(
@@ -7,6 +7,7 @@ import type { FoldState } from '../../../../state/types'
7
7
  import type { ThemeId } from '../../../themes'
8
8
 
9
9
  import { useAppStore } from '../../../../state/app-store'
10
+ import { getBarWidth } from '../../../../state/bars'
10
11
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
11
12
  import { useTheme } from '../../../theme'
12
13
  import { buildDiffSegments, firstChangeSegmentOffset, gutterWidth } from './build-rows'
@@ -51,7 +52,7 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
51
52
  const highlights: DiffHighlights = preparation.highlights
52
53
 
53
54
  const terminalCols = useAppStore((s) => s.layout.terminalCols)
54
- const sidebarWidth = useAppStore((s) => s.sidebar.width)
55
+ const sidebarWidth = useAppStore((s) => getBarWidth(s.bars.left))
55
56
  const contentWidth = useMemo(() => {
56
57
  if (!file) return 0
57
58
  const gw = gutterWidth(file)
@@ -4,32 +4,51 @@ import type { GitPanelState } from '../../../../state/types'
4
4
 
5
5
  import { useAppStore } from '../../../../state/app-store'
6
6
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
7
- import { useTheme } from '../../../theme'
7
+ import { selectPrRowVisible, usePrStatusStore } from '../../../../state/pr-status-store'
8
+ import { useTheme, useTransparent } from '../../../theme'
9
+ import { PrStateRow } from './pr-state-row'
10
+
11
+ export type GitPaneTab = 'files' | 'checks'
8
12
 
9
13
  interface GitPaneHeaderProps {
10
14
  gitPanel: GitPanelState
11
15
  projectPath: string | undefined
12
16
  headOffset?: number
13
17
  baseLabel?: string
18
+ /** Only the bar widget switches tabs; full-screen git mode omits these. */
19
+ tab?: GitPaneTab
20
+ onTabChange?: (tab: GitPaneTab) => void
14
21
  }
15
22
 
16
23
  export const GitPaneHeader = memo(function GitPaneHeader({
17
24
  baseLabel,
18
25
  gitPanel,
19
26
  headOffset = 0,
27
+ onTabChange,
20
28
  projectPath,
29
+ tab,
21
30
  }: GitPaneHeaderProps) {
22
31
  const t = useTheme()
32
+ // Transparent mode drops every painted background, so the active tab falls
33
+ // back to colour alone rather than punching a hole in the terminal image.
34
+ const transparent = useTransparent()
35
+ const activeTabBg = transparent ? undefined : t.backgroundElement
36
+ const prRowVisible = usePrStatusStore(selectPrRowVisible)
23
37
  const fileListMode = useAppStore((s) => s.gitPane.fileListMode)
24
38
  const nextFileListMode = fileListMode === 'tree' ? 'flat' : 'tree'
25
39
  const toggleListMode = useCallback(() => {
26
40
  dispatchGlobal({ type: 'git-mode-toggle-file-list-mode' })
27
41
  runSideEffectGlobal({ mode: nextFileListMode, type: 'persist-git-file-list-mode' })
28
42
  }, [nextFileListMode])
43
+ const showFiles = useCallback(() => onTabChange?.('files'), [onTabChange])
44
+ const showChecks = useCallback(() => onTabChange?.('checks'), [onTabChange])
29
45
 
46
+ const hasTabs = tab !== undefined && onTabChange !== undefined
30
47
  const hasProject = projectPath != null && projectPath !== ''
31
48
  if (!hasProject) return null
32
- if (gitPanel.error !== null) return null
49
+ // A git error must not hide the tab row, otherwise there's no way back to
50
+ // `files` (and the PR checks are still worth showing outside a repo).
51
+ if (gitPanel.error !== null && !hasTabs) return null
33
52
 
34
53
  const branch = gitPanel.branch
35
54
  const branchLabel = branch != null && branch !== '' ? branch : 'detached'
@@ -41,63 +60,110 @@ export const GitPaneHeader = memo(function GitPaneHeader({
41
60
  const showBehind = behind > 0
42
61
  const showTracking = showAhead || showBehind
43
62
 
44
- const showToggle = gitPanel.files.length > 0
63
+ // The PR row already carries the branch identity (and the checks tab spells
64
+ // out `base ← head`), so the branch row is only worth a line without a PR.
65
+ const showPrRow = hasTabs && prRowVisible
66
+ const showBranchRow = !showPrRow && gitPanel.error === null
67
+ const showToggle = tab !== 'checks' && gitPanel.files.length > 0
45
68
  const showHistorical = headOffset > 0
46
69
  const showReviewBase = baseLabel != null && baseLabel !== ''
47
70
  const showScope = showHistorical || showReviewBase
48
-
49
- return (
50
- <box flexDirection="column" flexShrink={0} paddingBottom={1}>
51
- <box flexDirection="row" justifyContent="space-between">
52
- <box flexDirection="row" flexShrink={1} overflow="hidden">
53
- <text selectable={false} fg={t.textMuted} wrapMode="none">
54
- {'\u{e702}'}
71
+ const trackingAndToggle = (
72
+ <box flexDirection="row" flexShrink={0} gap={2}>
73
+ {showTracking && tab !== 'checks' ? (
74
+ <box flexDirection="row" gap={1}>
75
+ {showAhead ? (
76
+ <text selectable={false} fg={t.textMuted} wrapMode="none">
77
+ {`↑${ahead}`}
78
+ </text>
79
+ ) : null}
80
+ {showBehind ? (
81
+ <text selectable={false} fg={t.textMuted} wrapMode="none">
82
+ {`↓${behind}`}
83
+ </text>
84
+ ) : null}
85
+ </box>
86
+ ) : null}
87
+ {showToggle ? (
88
+ <box flexDirection="row" gap={1} paddingLeft={1} onMouseDown={toggleListMode}>
89
+ <text
90
+ selectable={false}
91
+ fg={fileListMode === 'tree' ? t.primary : t.textMuted}
92
+ wrapMode="none"
93
+ >
94
+ tree
55
95
  </text>
56
96
  <text selectable={false} fg={t.textMuted} wrapMode="none">
57
- {' '}
97
+ |
58
98
  </text>
59
- <text selectable={false} fg={branchIsResolved ? t.text : t.textMuted} wrapMode="none">
60
- {branchIsResolved ? <strong>{branchLabel}</strong> : branchLabel}
99
+ <text
100
+ selectable={false}
101
+ fg={fileListMode === 'flat' ? t.primary : t.textMuted}
102
+ wrapMode="none"
103
+ >
104
+ flat
61
105
  </text>
62
106
  </box>
63
- <box flexDirection="row" flexShrink={0} gap={2}>
64
- {showTracking ? (
65
- <box flexDirection="row" gap={1}>
66
- {showAhead ? (
67
- <text selectable={false} fg={t.textMuted} wrapMode="none">
68
- {`↑${ahead}`}
69
- </text>
70
- ) : null}
71
- {showBehind ? (
72
- <text selectable={false} fg={t.textMuted} wrapMode="none">
73
- {`↓${behind}`}
74
- </text>
75
- ) : null}
76
- </box>
77
- ) : null}
78
- {showToggle ? (
79
- <box flexDirection="row" gap={1} paddingLeft={1} onMouseDown={toggleListMode}>
107
+ ) : null}
108
+ </box>
109
+ )
110
+
111
+ return (
112
+ <box flexDirection="column" flexShrink={0} paddingBottom={1}>
113
+ {showPrRow && hasProject ? <PrStateRow projectPath={projectPath} /> : null}
114
+ {hasTabs ? (
115
+ <box flexDirection="row" justifyContent="space-between">
116
+ <box flexDirection="row" flexShrink={0} gap={1}>
117
+ <box
118
+ paddingLeft={1}
119
+ paddingRight={1}
120
+ backgroundColor={tab === 'files' ? activeTabBg : undefined}
121
+ onMouseDown={showFiles}
122
+ >
80
123
  <text
81
124
  selectable={false}
82
- fg={fileListMode === 'tree' ? t.primary : t.textMuted}
125
+ fg={tab === 'files' ? t.text : t.textMuted}
126
+ bg={tab === 'files' ? activeTabBg : undefined}
83
127
  wrapMode="none"
84
128
  >
85
- tree
86
- </text>
87
- <text selectable={false} fg={t.textMuted} wrapMode="none">
88
- |
129
+ files
89
130
  </text>
131
+ </box>
132
+ <box
133
+ paddingLeft={1}
134
+ paddingRight={1}
135
+ backgroundColor={tab === 'checks' ? activeTabBg : undefined}
136
+ onMouseDown={showChecks}
137
+ >
90
138
  <text
91
139
  selectable={false}
92
- fg={fileListMode === 'flat' ? t.primary : t.textMuted}
140
+ fg={tab === 'checks' ? t.text : t.textMuted}
141
+ bg={tab === 'checks' ? activeTabBg : undefined}
93
142
  wrapMode="none"
94
143
  >
95
- flat
144
+ checks
96
145
  </text>
97
146
  </box>
98
- ) : null}
147
+ </box>
148
+ {trackingAndToggle}
99
149
  </box>
100
- </box>
150
+ ) : null}
151
+ {showBranchRow ? (
152
+ <box flexDirection="row" justifyContent="space-between">
153
+ <box flexDirection="row" flexShrink={1} overflow="hidden">
154
+ <text selectable={false} fg={t.textMuted} wrapMode="none">
155
+ {'\u{e702}'}
156
+ </text>
157
+ <text selectable={false} fg={t.textMuted} wrapMode="none">
158
+ {' '}
159
+ </text>
160
+ <text selectable={false} fg={branchIsResolved ? t.text : t.textMuted} wrapMode="none">
161
+ {branchIsResolved ? <strong>{branchLabel}</strong> : branchLabel}
162
+ </text>
163
+ </box>
164
+ {hasTabs ? null : trackingAndToggle}
165
+ </box>
166
+ ) : null}
101
167
  {showScope ? (
102
168
  <box flexDirection="row" gap={2}>
103
169
  {showHistorical ? (