@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.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +6 -1
- package/src/app.tsx +20 -16
- package/src/input/keymap/describe-bindings.ts +63 -2
- package/src/input/modes/bridge.ts +1 -0
- package/src/input/modes/transitions.ts +3 -1
- package/src/input/modes/types.ts +1 -0
- package/src/pty/terminal-snapshot.ts +7 -5
- package/src/services/ai-usage/adapters/claude.ts +102 -26
- package/src/services/ai-usage/adapters/codex.ts +83 -32
- package/src/services/ai-usage/cache.ts +60 -0
- package/src/services/ai-usage/pace.ts +79 -0
- package/src/services/ai-usage/provider.ts +43 -20
- package/src/services/ai-usage/types.ts +31 -0
- package/src/state/ai-usage-store.ts +10 -3
- package/src/state/reducers/git-mode-state.ts +58 -0
- package/src/state/reducers/modal-state.ts +13 -0
- package/src/state/types.ts +18 -6
- package/src/ui/breaking-update-screen.tsx +2 -2
- package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
- package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
- package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
- package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
- package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +8 -17
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
- package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
- package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +9 -5
- package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
- package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
- package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +4 -4
- package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
- package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
- package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
- package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
- package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
- package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
- package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
- package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
- package/src/ui/components/modals/app/ai-usage-modal.tsx +190 -0
- package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
- package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
- package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
- package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
- package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
- package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
- package/src/ui/components/modals/shared/form.tsx +164 -0
- package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
- package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
- package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
- package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
- package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
- package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
- package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
- package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +56 -24
- package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
- package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
- package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
- package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
- package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
- package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
- package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
- package/src/ui/root.tsx +26 -23
- package/src/ui/theme-store.ts +30 -26
- package/src/ui/themes.ts +1 -0
- package/src/ui/ai-usage/controller.ts +0 -35
- package/src/ui/components/ai-usage-popover.tsx +0 -152
- package/src/ui/components/create-session-modal.tsx +0 -96
- package/src/ui/components/diff-renderer/build-rows.ts +0 -349
- package/src/ui/components/diff-renderer/highlight.ts +0 -44
- package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
- package/src/ui/components/session-name-modal.tsx +0 -11
- package/src/ui/components/snippet-editor-modal.tsx +0 -40
- /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
- /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
- /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
- /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
|
-
}
|