@brimveyn/aimux 1.8.0 → 1.9.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.
Files changed (80) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +6 -1
  3. package/src/app.tsx +20 -16
  4. package/src/input/keymap/describe-bindings.ts +63 -2
  5. package/src/input/modes/bridge.ts +1 -0
  6. package/src/input/modes/transitions.ts +3 -1
  7. package/src/input/modes/types.ts +1 -0
  8. package/src/pty/terminal-snapshot.ts +7 -5
  9. package/src/services/ai-usage/adapters/claude.ts +102 -26
  10. package/src/services/ai-usage/adapters/codex.ts +83 -32
  11. package/src/services/ai-usage/cache.ts +60 -0
  12. package/src/services/ai-usage/pace.ts +79 -0
  13. package/src/services/ai-usage/provider.ts +43 -20
  14. package/src/services/ai-usage/types.ts +31 -0
  15. package/src/state/ai-usage-store.ts +10 -3
  16. package/src/state/reducers/git-mode-state.ts +58 -0
  17. package/src/state/reducers/modal-state.ts +13 -0
  18. package/src/state/types.ts +18 -6
  19. package/src/ui/breaking-update-screen.tsx +2 -2
  20. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  21. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  22. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  23. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  24. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  25. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  26. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  27. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +8 -17
  28. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  29. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  30. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +9 -5
  31. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
  32. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  33. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +4 -4
  34. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  35. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  36. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  37. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  38. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  39. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  40. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  41. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  42. package/src/ui/components/modals/app/ai-usage-modal.tsx +190 -0
  43. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  44. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  45. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  46. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  47. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  48. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  49. package/src/ui/components/modals/shared/form.tsx +164 -0
  50. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  51. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  52. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  53. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  54. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  55. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  56. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  57. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +56 -24
  58. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  59. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  60. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  61. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  62. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  63. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  64. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  65. package/src/ui/root.tsx +26 -23
  66. package/src/ui/theme-store.ts +30 -26
  67. package/src/ui/themes.ts +1 -0
  68. package/src/ui/ai-usage/controller.ts +0 -35
  69. package/src/ui/components/ai-usage-popover.tsx +0 -152
  70. package/src/ui/components/create-session-modal.tsx +0 -96
  71. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  72. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  73. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  74. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  75. package/src/ui/components/session-name-modal.tsx +0 -11
  76. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  77. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  78. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  79. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  80. /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
@@ -1,152 +0,0 @@
1
- import type { AIUsageTool } from '@brimveyn/aimux-config'
2
-
3
- import { useKeyboard } from '@opentui/react'
4
- import { useEffect, useState } from 'react'
5
-
6
- import type { UsageSnapshot } from '../../services/ai-usage/types'
7
-
8
- import { useAIUsageStore } from '../../state/ai-usage-store'
9
- import { useAppStore } from '../../state/app-store'
10
- import {
11
- type AIUsagePopoverState,
12
- closeAIUsagePopover,
13
- subscribeAIUsagePopover,
14
- } from '../ai-usage/controller'
15
- import { useTokens } from '../theme'
16
-
17
- const TOOL_TITLE: Record<AIUsageTool, string> = {
18
- claude: 'Claude Code',
19
- codex: 'Codex',
20
- }
21
-
22
- const POPOVER_WIDTH = 38
23
-
24
- function fmt(n: number): string {
25
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
26
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
27
- return String(n)
28
- }
29
-
30
- function buildLines(snap: UsageSnapshot): string[] {
31
- if (snap.error) {
32
- return [`error: ${snap.error.slice(0, 30)}`]
33
- }
34
- const lines: string[] = []
35
- if (snap.percent !== null) {
36
- lines.push(`usage ${snap.percent.toFixed(1)}%`)
37
- }
38
- lines.push(`tokens in ${fmt(snap.tokens.input)} / out ${fmt(snap.tokens.output)}`)
39
- if (snap.tokens.cache > 0) {
40
- lines.push(`cache ${fmt(snap.tokens.cache)}`)
41
- }
42
- lines.push(`total ${fmt(snap.tokens.total)}`)
43
- if (snap.costUSD !== null) {
44
- lines.push(`cost $${snap.costUSD.toFixed(2)}`)
45
- }
46
- if (snap.burnRatePerHour !== null) {
47
- lines.push(`burn ${fmt(Math.round(snap.burnRatePerHour))}/h`)
48
- }
49
- if (snap.timeRemaining) {
50
- lines.push(`resets ${snap.timeRemaining}`)
51
- } else if (snap.resetAt) {
52
- lines.push(`resets ${new Date(snap.resetAt).toLocaleTimeString()}`)
53
- }
54
- return lines
55
- }
56
-
57
- export function AIUsagePopover() {
58
- const [popover, setPopover] = useState<AIUsagePopoverState | null>(null)
59
- const t = useTokens()
60
- const enabled = useAIUsageStore((s) => s.enabled)
61
- const snapshots = useAIUsageStore((s) => s.snapshots)
62
- const terminalCols = useAppStore((s) => s.layout.terminalCols)
63
- const terminalRows = useAppStore((s) => s.layout.terminalRows)
64
-
65
- useEffect(() => subscribeAIUsagePopover(setPopover), [])
66
-
67
- useKeyboard((key) => {
68
- if (!popover) return
69
- if (key.name === 'escape') {
70
- key.preventDefault()
71
- closeAIUsagePopover()
72
- }
73
- })
74
-
75
- if (!enabled || !popover) return null
76
-
77
- const tools: AIUsageTool[] = ['claude', 'codex']
78
- const sections = tools
79
- .map((tool) => ({ snap: snapshots[tool], tool }))
80
- .filter((s) => s.snap !== undefined)
81
-
82
- let bodyLines = 0
83
- if (sections.length === 0) {
84
- bodyLines = 1
85
- } else {
86
- for (const s of sections) {
87
- if (!s.snap) continue
88
- bodyLines += buildLines(s.snap).length + 2
89
- }
90
- }
91
- const height = Math.min(terminalRows - 2, bodyLines + 2)
92
- const width = Math.min(terminalCols - 2, POPOVER_WIDTH)
93
-
94
- const left = Math.max(0, Math.min(popover.anchorX - width + 2, terminalCols - width))
95
- const top = Math.max(0, popover.anchorY - height)
96
-
97
- return (
98
- <box position="absolute" top={0} left={0} width="100%" height="100%">
99
- <box
100
- position="absolute"
101
- top={0}
102
- left={0}
103
- width="100%"
104
- height="100%"
105
- onMouseDown={(e) => {
106
- e.preventDefault()
107
- e.stopPropagation()
108
- closeAIUsagePopover()
109
- }}
110
- />
111
- <box
112
- position="absolute"
113
- top={top}
114
- left={left}
115
- width={width}
116
- flexDirection="column"
117
- border
118
- borderColor={t.palette.primary}
119
- backgroundColor={t.elevated}
120
- onMouseDown={(e) => {
121
- e.stopPropagation()
122
- }}
123
- >
124
- {sections.length === 0 ? (
125
- <box paddingLeft={1} paddingRight={1}>
126
- <text fg={t.muted} selectable={false}>
127
- no data yet — collecting…
128
- </text>
129
- </box>
130
- ) : (
131
- sections.map(({ snap, tool }, idx) => {
132
- if (!snap) return null
133
- const lines = buildLines(snap)
134
- return (
135
- <box key={tool} flexDirection="column" paddingLeft={1} paddingRight={1}>
136
- {idx > 0 ? <text fg={t.muted}> </text> : null}
137
- <text fg={t.accent} selectable={false}>
138
- {TOOL_TITLE[tool]}
139
- </text>
140
- {lines.map((line, i) => (
141
- <text key={`${tool}-${i}`} fg={t.palette.ink} selectable={false}>
142
- {line}
143
- </text>
144
- ))}
145
- </box>
146
- )
147
- })
148
- )}
149
- </box>
150
- </box>
151
- )
152
- }
@@ -1,96 +0,0 @@
1
- import type { DirectoryResult } from '../../state/types'
2
-
3
- import { abbreviatePath } from '../path-format'
4
- import { getCurrentTokens, useTokens } from '../theme'
5
- import { uiTokens } from '../ui-tokens'
6
- import { InputField } from './input-field'
7
- import { ListItem } from './list-item'
8
- import { ModalShell } from './modal-shell'
9
-
10
- const VISIBLE_ROWS = 8
11
-
12
- function getDirectoryResultIcon(result: DirectoryResult): string {
13
- if (result.type === 'worktree') return '\u{e728}'
14
- if (result.type === 'workspace') return '\u{f07c}'
15
- return '\u{e702}'
16
- }
17
-
18
- function getDirectoryResultColor(result: DirectoryResult): string {
19
- const t = getCurrentTokens()
20
- if (result.type === 'worktree') return t.palette.warning
21
- if (result.type === 'workspace') return t.accent
22
- return t.palette.primary
23
- }
24
-
25
- interface CreateSessionModalProps {
26
- activeField: 'directory' | 'name'
27
- directoryQuery: string
28
- sessionName: string
29
- results: DirectoryResult[]
30
- selectedIndex: number
31
- pendingProjectPath: string | null
32
- }
33
-
34
- export function CreateSessionModal({
35
- activeField,
36
- directoryQuery,
37
- pendingProjectPath,
38
- results,
39
- selectedIndex,
40
- sessionName,
41
- }: CreateSessionModalProps) {
42
- const t = useTokens()
43
- const dirActive = activeField === 'directory'
44
- const nameActive = activeField === 'name'
45
-
46
- const scrollOffset = Math.max(0, selectedIndex - VISIBLE_ROWS + 1)
47
- const visibleResults = results.slice(scrollOffset, scrollOffset + VISIBLE_ROWS)
48
-
49
- return (
50
- <ModalShell
51
- title="Create workspace"
52
- keybindsModeId="modal.create-session"
53
- width={uiTokens.modalWidth.xl}
54
- >
55
- <box flexDirection="column">
56
- <text fg={dirActive ? t.palette.ink : t.muted}>Search projects</text>
57
- <InputField
58
- active={dirActive}
59
- placeholder="Type a project name..."
60
- value={
61
- pendingProjectPath && !dirActive ? abbreviatePath(pendingProjectPath) : directoryQuery
62
- }
63
- />
64
- </box>
65
-
66
- <box flexDirection="column" height={VISIBLE_ROWS}>
67
- {results.length === 0 ? (
68
- <text fg={t.muted}>
69
- {directoryQuery.length > 0 ? 'No matches' : 'Type a project name to search...'}
70
- </text>
71
- ) : (
72
- visibleResults.map((result, index) => {
73
- const active = dirActive && scrollOffset + index === selectedIndex
74
- return (
75
- <ListItem
76
- key={result.path}
77
- active={active}
78
- leading={
79
- <text fg={getDirectoryResultColor(result)}>{getDirectoryResultIcon(result)}</text>
80
- }
81
- title={
82
- <text fg={active ? t.palette.ink : t.muted}>{abbreviatePath(result.path)}</text>
83
- }
84
- />
85
- )
86
- })
87
- )}
88
- </box>
89
-
90
- <box flexDirection="column">
91
- <text fg={nameActive ? t.palette.ink : t.muted}>Workspace name</text>
92
- <InputField active={nameActive} value={sessionName} />
93
- </box>
94
- </ModalShell>
95
- )
96
- }
@@ -1,349 +0,0 @@
1
- import type { FileDiffMetadata, Hunk, HunkContent } from '../../../diff-parser'
2
- import type { FoldState } from '../../../state/types'
3
-
4
- export const KEEP_CONTEXT = 3
5
- export const MIN_FOLD_SIZE = 3
6
- export const FOLD_STEP = 5
7
-
8
- export interface FoldInfo {
9
- foldId: string
10
- total: number
11
- hidden: number
12
- topExpanded: number
13
- bottomExpanded: number
14
- }
15
-
16
- export type SplitCell =
17
- | { content: string; lineIdx: number; lineNumber: number; type: 'context' }
18
- | { content: string; lineIdx: number; lineNumber: number; type: 'addition' | 'deletion' }
19
- | { fold: FoldInfo; type: 'fold' }
20
- | { type: 'filler' }
21
-
22
- export interface SplitRow {
23
- type: 'row'
24
- left: SplitCell
25
- right: SplitCell
26
- height: number
27
- }
28
-
29
- export type UnifiedRow =
30
- | {
31
- addLineNumber: number
32
- content: string
33
- lineIdx: number
34
- delLineNumber: number
35
- type: 'context'
36
- height: number
37
- }
38
- | {
39
- content: string
40
- lineIdx: number
41
- lineNumber: number
42
- type: 'addition' | 'deletion'
43
- height: number
44
- }
45
- | { fold: FoldInfo; type: 'fold' }
46
-
47
- export interface HunkHeader {
48
- context?: string
49
- spec: string
50
- type: 'hunk-header'
51
- }
52
-
53
- export type SplitRowOrHeader = HunkHeader | SplitRow
54
- export type UnifiedRowOrHeader = HunkHeader | UnifiedRow
55
-
56
- export type FoldMap = Record<string, FoldState>
57
-
58
- function wrapCount(content: string, width: number): number {
59
- if (width <= 0) return 1
60
- return Math.max(1, Math.ceil(content.length / width))
61
- }
62
-
63
- function cellWraps(content: string, width: number): number {
64
- return wrapCount(content, width)
65
- }
66
-
67
- interface ContextSlice {
68
- // which file-line indices (relative to content start) to show, and which to fold
69
- leadingVisible: number
70
- fold: { length: number; offset: number } | null
71
- trailingVisible: number
72
- }
73
-
74
- function sliceContext(
75
- totalLines: number,
76
- hasChangeBefore: boolean,
77
- hasChangeAfter: boolean,
78
- fold: FoldState
79
- ): ContextSlice {
80
- const preKeep = hasChangeBefore ? Math.min(KEEP_CONTEXT, totalLines) : 0
81
- const postKeep = hasChangeAfter ? Math.min(KEEP_CONTEXT, totalLines - preKeep) : 0
82
- const middle = totalLines - preKeep - postKeep
83
- if (middle < MIN_FOLD_SIZE) {
84
- return { fold: null, leadingVisible: totalLines, trailingVisible: 0 }
85
- }
86
- const top = Math.min(fold.top, middle)
87
- const bottom = Math.min(fold.bottom, middle - top)
88
- const hidden = middle - top - bottom
89
- if (hidden <= 0) {
90
- return { fold: null, leadingVisible: totalLines, trailingVisible: 0 }
91
- }
92
- return {
93
- fold: { length: hidden, offset: preKeep + top },
94
- leadingVisible: preKeep + top,
95
- trailingVisible: postKeep + bottom,
96
- }
97
- }
98
-
99
- function makeFoldInfo(foldId: string, middle: number, fold: FoldState, hidden: number): FoldInfo {
100
- const topExpanded = Math.min(fold.top, middle)
101
- const bottomExpanded = Math.min(fold.bottom, middle - topExpanded)
102
- return { bottomExpanded, foldId, hidden, topExpanded, total: middle }
103
- }
104
-
105
- function contextNeighbors(
106
- contents: HunkContent[],
107
- index: number
108
- ): { hasChangeBefore: boolean; hasChangeAfter: boolean } {
109
- let hasChangeBefore = false
110
- for (let i = index - 1; i >= 0; i--) {
111
- const c = contents[i]
112
- if (c && c.type !== 'context') {
113
- hasChangeBefore = true
114
- break
115
- }
116
- }
117
- let hasChangeAfter = false
118
- for (let i = index + 1; i < contents.length; i++) {
119
- const c = contents[i]
120
- if (c && c.type !== 'context') {
121
- hasChangeAfter = true
122
- break
123
- }
124
- }
125
- return { hasChangeAfter, hasChangeBefore }
126
- }
127
-
128
- export function buildSplitRows(
129
- file: FileDiffMetadata,
130
- folds: FoldMap = {},
131
- contentWidth = 0
132
- ): SplitRowOrHeader[] {
133
- const rows: SplitRowOrHeader[] = []
134
- const pairHeight = (l: SplitCell, r: SplitCell): number => {
135
- const lh =
136
- l.type === 'context' || l.type === 'addition' || l.type === 'deletion'
137
- ? cellWraps(l.content, contentWidth)
138
- : 1
139
- const rh =
140
- r.type === 'context' || r.type === 'addition' || r.type === 'deletion'
141
- ? cellWraps(r.content, contentWidth)
142
- : 1
143
- return Math.max(lh, rh)
144
- }
145
- for (const [hIdx, hunk] of file.hunks.entries()) {
146
- rows.push(makeHeader(hunk))
147
- let delLine = hunk.deletionStart
148
- let addLine = hunk.additionStart
149
- for (const [cIdx, content] of hunk.hunkContent.entries()) {
150
- if (content.type === 'context') {
151
- const foldId = `${hIdx}:${cIdx}`
152
- const { hasChangeAfter, hasChangeBefore } = contextNeighbors(hunk.hunkContent, cIdx)
153
- const foldState = folds[foldId] ?? { bottom: 0, top: 0 }
154
- const slice = sliceContext(content.lines, hasChangeBefore, hasChangeAfter, foldState)
155
-
156
- const pushContext = (start: number, count: number): void => {
157
- for (let i = 0; i < count; i++) {
158
- const offset = start + i
159
- const addIdx = content.additionLineIndex + offset
160
- const delIdx = content.deletionLineIndex + offset
161
- const text = stripNewline(file.additionLines[addIdx] ?? '')
162
- const left: SplitCell = {
163
- content: text,
164
- lineIdx: delIdx,
165
- lineNumber: delLine++,
166
- type: 'context',
167
- }
168
- const right: SplitCell = {
169
- content: text,
170
- lineIdx: addIdx,
171
- lineNumber: addLine++,
172
- type: 'context',
173
- }
174
- rows.push({ height: pairHeight(left, right), left, right, type: 'row' })
175
- }
176
- }
177
-
178
- pushContext(0, slice.leadingVisible)
179
- if (slice.fold) {
180
- const preKeep = hasChangeBefore ? Math.min(KEEP_CONTEXT, content.lines) : 0
181
- const postKeep = hasChangeAfter ? Math.min(KEEP_CONTEXT, content.lines - preKeep) : 0
182
- const middle = content.lines - preKeep - postKeep
183
- const info = makeFoldInfo(foldId, middle, foldState, slice.fold.length)
184
- rows.push({
185
- height: 1,
186
- left: { fold: info, type: 'fold' },
187
- right: { fold: info, type: 'fold' },
188
- type: 'row',
189
- })
190
- delLine += slice.fold.length
191
- addLine += slice.fold.length
192
- pushContext(slice.fold.offset + slice.fold.length, slice.trailingVisible)
193
- }
194
- } else {
195
- const max = Math.max(content.additions, content.deletions)
196
- for (let i = 0; i < max; i++) {
197
- const delIdx = content.deletionLineIndex + i
198
- const addIdx = content.additionLineIndex + i
199
- const left: SplitCell =
200
- i < content.deletions
201
- ? {
202
- content: stripNewline(file.deletionLines[delIdx] ?? ''),
203
- lineIdx: delIdx,
204
- lineNumber: delLine++,
205
- type: 'deletion',
206
- }
207
- : { type: 'filler' }
208
- const right: SplitCell =
209
- i < content.additions
210
- ? {
211
- content: stripNewline(file.additionLines[addIdx] ?? ''),
212
- lineIdx: addIdx,
213
- lineNumber: addLine++,
214
- type: 'addition',
215
- }
216
- : { type: 'filler' }
217
- rows.push({ height: pairHeight(left, right), left, right, type: 'row' })
218
- }
219
- }
220
- }
221
- }
222
- return rows
223
- }
224
-
225
- export function buildUnifiedRows(
226
- file: FileDiffMetadata,
227
- folds: FoldMap = {},
228
- contentWidth = 0
229
- ): UnifiedRowOrHeader[] {
230
- const rows: UnifiedRowOrHeader[] = []
231
- for (const [hIdx, hunk] of file.hunks.entries()) {
232
- rows.push(makeHeader(hunk))
233
- let delLine = hunk.deletionStart
234
- let addLine = hunk.additionStart
235
- for (const [cIdx, content] of hunk.hunkContent.entries()) {
236
- if (content.type === 'context') {
237
- const foldId = `${hIdx}:${cIdx}`
238
- const { hasChangeAfter, hasChangeBefore } = contextNeighbors(hunk.hunkContent, cIdx)
239
- const foldState = folds[foldId] ?? { bottom: 0, top: 0 }
240
- const slice = sliceContext(content.lines, hasChangeBefore, hasChangeAfter, foldState)
241
-
242
- const pushContext = (start: number, count: number): void => {
243
- for (let i = 0; i < count; i++) {
244
- const offset = start + i
245
- const addIdx = content.additionLineIndex + offset
246
- const text = stripNewline(file.additionLines[addIdx] ?? '')
247
- rows.push({
248
- addLineNumber: addLine++,
249
- content: text,
250
- delLineNumber: delLine++,
251
- height: cellWraps(text, contentWidth),
252
- lineIdx: addIdx,
253
- type: 'context',
254
- })
255
- }
256
- }
257
-
258
- pushContext(0, slice.leadingVisible)
259
- if (slice.fold) {
260
- const preKeep = hasChangeBefore ? Math.min(KEEP_CONTEXT, content.lines) : 0
261
- const postKeep = hasChangeAfter ? Math.min(KEEP_CONTEXT, content.lines - preKeep) : 0
262
- const middle = content.lines - preKeep - postKeep
263
- const info = makeFoldInfo(foldId, middle, foldState, slice.fold.length)
264
- rows.push({ fold: info, type: 'fold' })
265
- delLine += slice.fold.length
266
- addLine += slice.fold.length
267
- pushContext(slice.fold.offset + slice.fold.length, slice.trailingVisible)
268
- }
269
- } else {
270
- for (let i = 0; i < content.deletions; i++) {
271
- const delIdx = content.deletionLineIndex + i
272
- const text = stripNewline(file.deletionLines[delIdx] ?? '')
273
- rows.push({
274
- content: text,
275
- height: cellWraps(text, contentWidth),
276
- lineIdx: delIdx,
277
- lineNumber: delLine++,
278
- type: 'deletion',
279
- })
280
- }
281
- for (let i = 0; i < content.additions; i++) {
282
- const addIdx = content.additionLineIndex + i
283
- const text = stripNewline(file.additionLines[addIdx] ?? '')
284
- rows.push({
285
- content: text,
286
- height: cellWraps(text, contentWidth),
287
- lineIdx: addIdx,
288
- lineNumber: addLine++,
289
- type: 'addition',
290
- })
291
- }
292
- }
293
- }
294
- }
295
- return rows
296
- }
297
-
298
- function rowHeight(row: SplitRowOrHeader | UnifiedRowOrHeader): number {
299
- if (row.type === 'hunk-header') return 1
300
- if (row.type === 'row') return row.height
301
- if (row.type === 'fold') return 1
302
- return row.height
303
- }
304
-
305
- function isChangeRow(row: SplitRowOrHeader | UnifiedRowOrHeader): boolean {
306
- if (row.type === 'row') {
307
- return (
308
- row.left.type === 'addition' ||
309
- row.left.type === 'deletion' ||
310
- row.right.type === 'addition' ||
311
- row.right.type === 'deletion'
312
- )
313
- }
314
- return row.type === 'addition' || row.type === 'deletion'
315
- }
316
-
317
- export function firstChangeRowOffset(
318
- rows: readonly (SplitRowOrHeader | UnifiedRowOrHeader)[]
319
- ): number {
320
- let offset = 0
321
- for (const row of rows) {
322
- if (isChangeRow(row)) return offset
323
- offset += rowHeight(row)
324
- }
325
- return -1
326
- }
327
-
328
- function makeHeader(hunk: Hunk): HunkHeader {
329
- const spec = `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`
330
- return { context: hunk.hunkContext, spec, type: 'hunk-header' }
331
- }
332
-
333
- function stripNewline(s: string): string {
334
- if (s.endsWith('\r\n')) return s.slice(0, -2)
335
- if (s.endsWith('\n')) return s.slice(0, -1)
336
- return s
337
- }
338
-
339
- export function gutterWidth(file: FileDiffMetadata): number {
340
- let max = 1
341
- for (const h of file.hunks) {
342
- max = Math.max(
343
- max,
344
- h.deletionStart + h.deletionCount - 1,
345
- h.additionStart + h.additionCount - 1
346
- )
347
- }
348
- return Math.max(2, String(max).length)
349
- }
@@ -1,44 +0,0 @@
1
- import type { ThemedToken } from 'shiki'
2
-
3
- import { ensureActiveShikiTheme, ensureShikiLang, getShikiHighlighter } from '../../shiki'
4
-
5
- export interface HighlightSpan {
6
- bold?: boolean
7
- fg?: string
8
- italic?: boolean
9
- text: string
10
- underline?: boolean
11
- }
12
-
13
- // Shiki FontStyle: 1 = italic, 2 = bold, 4 = underline.
14
- export function tokenToSpan(token: ThemedToken): HighlightSpan {
15
- const fs = token.fontStyle ?? 0
16
- return {
17
- bold: (fs & 2) !== 0 || undefined,
18
- fg: token.color,
19
- italic: (fs & 1) !== 0 || undefined,
20
- text: token.content,
21
- underline: (fs & 4) !== 0 || undefined,
22
- }
23
- }
24
-
25
- export async function tokenizeSide(lines: string[], lang: string): Promise<ThemedToken[][]> {
26
- if (lines.length === 0) return []
27
- const highlighter = await getShikiHighlighter()
28
- const [langOk, themeName] = await Promise.all([
29
- ensureShikiLang(highlighter, lang),
30
- ensureActiveShikiTheme(highlighter),
31
- ])
32
- if (!langOk) return []
33
- try {
34
- const result = highlighter.codeToTokens(lines.join(''), {
35
- // eslint-disable-next-line typescript/no-explicit-any
36
- lang: lang as any,
37
- // eslint-disable-next-line typescript/no-explicit-any
38
- theme: themeName as any,
39
- })
40
- return result.tokens
41
- } catch {
42
- return []
43
- }
44
- }
@@ -1,65 +0,0 @@
1
- import type { ThemedToken } from 'shiki'
2
-
3
- import { type FileDiffMetadata, parsePatchFiles } from '../../../diff-parser'
4
- import { diffHash } from '../../../git/diff-hash'
5
- import { filetypeFromPath } from './filetype'
6
- import { tokenizeSide } from './highlight'
7
-
8
- export interface PreparedDiff {
9
- hash: string
10
- file: FileDiffMetadata | null
11
- filetype: string | null
12
- highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
13
- }
14
-
15
- interface PrepareOptions {
16
- signal?: AbortSignal
17
- /** Skip Shiki for big files to keep prepare under a few tens of ms. */
18
- skipHighlightThreshold?: number
19
- }
20
-
21
- const DEFAULT_SKIP_HIGHLIGHT = 2000
22
-
23
- function yieldToEventLoop(): Promise<void> {
24
- return new Promise((resolve) => {
25
- setImmediate(resolve)
26
- })
27
- }
28
-
29
- function throwIfAborted(signal?: AbortSignal): void {
30
- if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
31
- }
32
-
33
- // Lifts the hot path (parse → tokenize) off the synchronous render tree. The
34
- // parser still blocks briefly on very large diffs, but yielding between parse
35
- // and each tokenize side keeps other UI updates responsive.
36
- export async function prepareDiff(
37
- diff: string,
38
- path: string,
39
- opts: PrepareOptions
40
- ): Promise<PreparedDiff> {
41
- const hash = diffHash(diff)
42
- throwIfAborted(opts.signal)
43
- const patches = parsePatchFiles(diff)
44
- const file = patches[0]?.files[0] ?? null
45
- const filetype = file ? (filetypeFromPath(path) ?? null) : null
46
-
47
- const skipThreshold = opts.skipHighlightThreshold ?? DEFAULT_SKIP_HIGHLIGHT
48
- const totalLines = file ? file.additionLines.length + file.deletionLines.length : 0
49
- const shouldHighlight = !!file && !!filetype && totalLines <= skipThreshold
50
-
51
- if (!shouldHighlight) {
52
- return { file, filetype, hash, highlights: { add: [], del: [] } }
53
- }
54
-
55
- await yieldToEventLoop()
56
- throwIfAborted(opts.signal)
57
- const add = await tokenizeSide(file.additionLines, filetype)
58
- throwIfAborted(opts.signal)
59
- await yieldToEventLoop()
60
- throwIfAborted(opts.signal)
61
- const del = await tokenizeSide(file.deletionLines, filetype)
62
- throwIfAborted(opts.signal)
63
-
64
- return { file, filetype, hash, highlights: { add, del } }
65
- }