@brimveyn/aimux 1.5.4 → 1.6.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 (65) hide show
  1. package/README.md +14 -2
  2. package/package.json +5 -3
  3. package/src/app-runtime/side-effects.ts +41 -6
  4. package/src/app.tsx +17 -5
  5. package/src/config.ts +29 -4
  6. package/src/diff-parser/clean-last-newline.ts +5 -0
  7. package/src/diff-parser/constants.ts +13 -0
  8. package/src/diff-parser/index.ts +12 -0
  9. package/src/diff-parser/parse-line-type.ts +29 -0
  10. package/src/diff-parser/parse-patch-files.ts +369 -0
  11. package/src/diff-parser/types.ts +75 -0
  12. package/src/input/modes/bridge.ts +8 -0
  13. package/src/input/modes/transitions.ts +2 -1
  14. package/src/input/modes/types.ts +4 -1
  15. package/src/pty/terminal-snapshot.ts +4 -4
  16. package/src/state/git-tree.ts +220 -0
  17. package/src/state/reducers/git-mode-state.ts +232 -35
  18. package/src/state/reducers/git-panel-state.ts +29 -3
  19. package/src/state/reducers/modal-state.ts +43 -10
  20. package/src/state/store.ts +5 -1
  21. package/src/state/types.ts +41 -5
  22. package/src/state/workspace-save.ts +2 -0
  23. package/src/ui/components/create-session-modal.tsx +23 -7
  24. package/src/ui/components/diff-renderer/build-rows.ts +349 -0
  25. package/src/ui/components/diff-renderer/filetype.ts +29 -0
  26. package/src/ui/components/diff-renderer/fold-strip.tsx +84 -0
  27. package/src/ui/components/diff-renderer/highlight.ts +48 -0
  28. package/src/ui/components/diff-renderer/index.ts +1 -0
  29. package/src/ui/components/diff-renderer/pierre-diff.tsx +170 -0
  30. package/src/ui/components/diff-renderer/split-view.tsx +207 -0
  31. package/src/ui/components/diff-renderer/stacked-view.tsx +166 -0
  32. package/src/ui/components/git-commit-modal.tsx +14 -2
  33. package/src/ui/components/git-pane-widget.tsx +5 -0
  34. package/src/ui/components/git-panel.tsx +176 -79
  35. package/src/ui/components/git-view.tsx +89 -87
  36. package/src/ui/components/help-modal.tsx +43 -11
  37. package/src/ui/components/input-field.tsx +2 -2
  38. package/src/ui/components/list-item.tsx +9 -1
  39. package/src/ui/components/modal-filter-bar.tsx +1 -1
  40. package/src/ui/components/modal-keybinds-overlay.tsx +2 -2
  41. package/src/ui/components/modal-shell.tsx +3 -3
  42. package/src/ui/components/new-tab-modal.tsx +15 -3
  43. package/src/ui/components/pending-chord-overlay.tsx +3 -3
  44. package/src/ui/components/session-bar.tsx +10 -5
  45. package/src/ui/components/session-picker-modal.tsx +26 -9
  46. package/src/ui/components/sidebar.tsx +22 -10
  47. package/src/ui/components/snippet-editor-modal.tsx +16 -2
  48. package/src/ui/components/snippet-picker-modal.tsx +11 -3
  49. package/src/ui/components/split-layout.tsx +1 -1
  50. package/src/ui/components/status-bar.tsx +12 -9
  51. package/src/ui/components/surface.tsx +5 -5
  52. package/src/ui/components/tab-item.tsx +22 -16
  53. package/src/ui/components/terminal-pane.tsx +25 -15
  54. package/src/ui/components/theme-picker-modal.tsx +111 -16
  55. package/src/ui/components/update-available-modal.tsx +11 -1
  56. package/src/ui/filter-themes.ts +10 -0
  57. package/src/ui/house-themes.ts +313 -0
  58. package/src/ui/keymap-context.ts +10 -2
  59. package/src/ui/root.tsx +26 -6
  60. package/src/ui/shiki.ts +49 -0
  61. package/src/ui/status-bar-model.ts +19 -1
  62. package/src/ui/theme.ts +22 -10
  63. package/src/ui/themes.generated.ts +59794 -0
  64. package/src/ui/themes.ts +68 -274
  65. package/src/ui/syntax.ts +0 -102
@@ -0,0 +1,29 @@
1
+ const EXT_TO_SHIKI: Record<string, string> = {
2
+ cjs: 'javascript',
3
+ css: 'css',
4
+ go: 'go',
5
+ html: 'html',
6
+ js: 'javascript',
7
+ json: 'json',
8
+ jsx: 'jsx',
9
+ markdown: 'markdown',
10
+ md: 'markdown',
11
+ mdx: 'mdx',
12
+ mjs: 'javascript',
13
+ py: 'python',
14
+ rs: 'rust',
15
+ sh: 'shellscript',
16
+ toml: 'toml',
17
+ ts: 'typescript',
18
+ tsx: 'tsx',
19
+ yaml: 'yaml',
20
+ yml: 'yaml',
21
+ zig: 'zig',
22
+ }
23
+
24
+ export function filetypeFromPath(path: string): string | undefined {
25
+ const dot = path.lastIndexOf('.')
26
+ if (dot < 0) return undefined
27
+ const ext = path.slice(dot + 1).toLowerCase()
28
+ return EXT_TO_SHIKI[ext]
29
+ }
@@ -0,0 +1,84 @@
1
+ import { theme } from '../../theme'
2
+ import { FOLD_STEP, type FoldInfo } from './build-rows'
3
+ import { type FoldDispatch } from './pierre-diff'
4
+
5
+ interface Props {
6
+ fold: FoldInfo
7
+ dispatch: FoldDispatch
8
+ }
9
+
10
+ function Button({ label, onPress }: { label: string; onPress: () => void }) {
11
+ return (
12
+ <box
13
+ paddingLeft={1}
14
+ paddingRight={1}
15
+ backgroundColor={theme.colors['sideBar.background']}
16
+ onMouseDown={onPress}
17
+ >
18
+ <text fg={theme.colors['textLink.foreground']}>{label}</text>
19
+ </box>
20
+ )
21
+ }
22
+
23
+ function Spacer() {
24
+ return <text> </text>
25
+ }
26
+
27
+ export function FoldStrip({ dispatch, fold }: Props) {
28
+ const { bottomExpanded, foldId, hidden, topExpanded, total } = fold
29
+ const stepUp = Math.min(FOLD_STEP, hidden)
30
+ const stepDown = Math.min(FOLD_STEP, hidden)
31
+ const shrinkUp = Math.min(FOLD_STEP, topExpanded)
32
+ const shrinkDown = Math.min(FOLD_STEP, bottomExpanded)
33
+
34
+ const controls: React.ReactNode[] = []
35
+ if (hidden > 0) {
36
+ controls.push(
37
+ <Button
38
+ key="up"
39
+ label={`↑${stepUp}`}
40
+ onPress={() => dispatch.adjust(foldId, 'top', stepUp)}
41
+ />,
42
+ <Spacer key="sp1" />,
43
+ <Button
44
+ key="down"
45
+ label={`↓${stepDown}`}
46
+ onPress={() => dispatch.adjust(foldId, 'bottom', stepDown)}
47
+ />,
48
+ <Spacer key="sp2" />,
49
+ <Button key="all" label="⇅ all" onPress={() => dispatch.set(foldId, total, 0)} />
50
+ )
51
+ }
52
+ if (shrinkUp > 0) {
53
+ controls.push(
54
+ <Spacer key="sp3" />,
55
+ <Button
56
+ key="shrinkUp"
57
+ label={`−↑${shrinkUp}`}
58
+ onPress={() => dispatch.adjust(foldId, 'top', -shrinkUp)}
59
+ />
60
+ )
61
+ }
62
+ if (shrinkDown > 0) {
63
+ controls.push(
64
+ <Spacer key="sp4" />,
65
+ <Button
66
+ key="shrinkDown"
67
+ label={`−↓${shrinkDown}`}
68
+ onPress={() => dispatch.adjust(foldId, 'bottom', -shrinkDown)}
69
+ />
70
+ )
71
+ }
72
+
73
+ return (
74
+ <box
75
+ flexDirection="row"
76
+ backgroundColor={theme.colors['sideBarSectionHeader.background']}
77
+ paddingLeft={1}
78
+ paddingRight={1}
79
+ >
80
+ <text fg={theme.colors['descriptionForeground']}>{`⋯ ${hidden} hidden `}</text>
81
+ {controls}
82
+ </box>
83
+ )
84
+ }
@@ -0,0 +1,48 @@
1
+ import type { ThemedToken } from 'shiki'
2
+
3
+ import { ensureShikiLang, ensureShikiTheme, 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(
26
+ lines: string[],
27
+ lang: string,
28
+ theme: string
29
+ ): Promise<ThemedToken[][]> {
30
+ if (lines.length === 0) return []
31
+ const highlighter = await getShikiHighlighter()
32
+ const [langOk, themeOk] = await Promise.all([
33
+ ensureShikiLang(highlighter, lang),
34
+ ensureShikiTheme(highlighter, theme),
35
+ ])
36
+ if (!langOk || !themeOk) return []
37
+ try {
38
+ const result = highlighter.codeToTokens(lines.join(''), {
39
+ // eslint-disable-next-line typescript/no-explicit-any
40
+ lang: lang as any,
41
+ // eslint-disable-next-line typescript/no-explicit-any
42
+ theme: theme as any,
43
+ })
44
+ return result.tokens
45
+ } catch {
46
+ return []
47
+ }
48
+ }
@@ -0,0 +1 @@
1
+ export { type DiffView, PierreDiff, type PierreDiffHandle } from './pierre-diff'
@@ -0,0 +1,170 @@
1
+ import type { ScrollBoxRenderable } from '@opentui/core'
2
+ import type { ThemedToken } from 'shiki'
3
+
4
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
5
+
6
+ import type { FoldState } from '../../../state/types'
7
+ import type { ThemeId } from '../../themes'
8
+
9
+ import { parsePatchFiles } from '../../../diff-parser'
10
+ import { useAppStore } from '../../../state/app-store'
11
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
12
+ import { theme } from '../../theme'
13
+ import { buildSplitRows, buildUnifiedRows, firstChangeRowOffset, gutterWidth } from './build-rows'
14
+ import { filetypeFromPath } from './filetype'
15
+ import { tokenizeSide } from './highlight'
16
+ import { SplitView, type SplitViewHandle } from './split-view'
17
+ import { StackedView, type StackedViewHandle } from './stacked-view'
18
+
19
+ export type DiffView = 'split' | 'stacked'
20
+
21
+ export interface PierreDiffHandle {
22
+ leftScroll: ScrollBoxRenderable | null
23
+ rightScroll: ScrollBoxRenderable | null
24
+ }
25
+
26
+ export interface DiffHighlights {
27
+ add: ThemedToken[][]
28
+ del: ThemedToken[][]
29
+ }
30
+
31
+ export interface FoldDispatch {
32
+ adjust: (foldId: string, side: 'top' | 'bottom', delta: number) => void
33
+ set: (foldId: string, top: number, bottom: number) => void
34
+ }
35
+
36
+ interface Props {
37
+ cacheKey: string
38
+ diff: string
39
+ path: string
40
+ themeId: ThemeId
41
+ view: DiffView
42
+ }
43
+
44
+ const EMPTY_HIGHLIGHTS: DiffHighlights = { add: [], del: [] }
45
+ const EMPTY_FOLDS: Record<string, FoldState> = {}
46
+
47
+ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDiff(
48
+ { cacheKey, diff, path, themeId, view },
49
+ ref
50
+ ) {
51
+ const file = useMemo(() => {
52
+ const patches = parsePatchFiles(diff)
53
+ return patches[0]?.files[0]
54
+ }, [diff])
55
+
56
+ const filetype = useMemo(() => filetypeFromPath(path), [path])
57
+
58
+ const terminalCols = useAppStore((s) => s.layout.terminalCols)
59
+ const sidebarWidth = useAppStore((s) => s.sidebar.width)
60
+ const contentWidth = useMemo(() => {
61
+ if (!file) return 0
62
+ const gw = gutterWidth(file)
63
+ const usable = Math.max(0, terminalCols - sidebarWidth - 1)
64
+ const paneCols = view === 'split' ? Math.max(0, Math.floor(usable / 2) - 1) : usable
65
+ const prefix = view === 'split' ? gw + 4 : gw * 2 + 5
66
+ return Math.max(1, paneCols - prefix)
67
+ }, [file, view, terminalCols, sidebarWidth])
68
+
69
+ const folds = useAppStore((s) => s.gitMode.folds[cacheKey]) ?? EMPTY_FOLDS
70
+ const foldDispatch = useMemo<FoldDispatch>(
71
+ () => ({
72
+ adjust: (foldId, side, delta) =>
73
+ dispatchGlobal({ delta, foldId, key: cacheKey, side, type: 'git-mode-fold-adjust' }),
74
+ set: (foldId, top, bottom) =>
75
+ dispatchGlobal({ bottom, foldId, key: cacheKey, top, type: 'git-mode-fold-set' }),
76
+ }),
77
+ [cacheKey]
78
+ )
79
+
80
+ const [highlights, setHighlights] = useState<DiffHighlights>(EMPTY_HIGHLIGHTS)
81
+
82
+ useEffect(() => {
83
+ setHighlights(EMPTY_HIGHLIGHTS)
84
+ if (!file || !filetype) return
85
+ let cancelled = false
86
+ void Promise.all([
87
+ tokenizeSide(file.additionLines, filetype, themeId),
88
+ tokenizeSide(file.deletionLines, filetype, themeId),
89
+ ]).then(([add, del]) => {
90
+ if (cancelled) return
91
+ setHighlights({ add, del })
92
+ })
93
+ return () => {
94
+ cancelled = true
95
+ }
96
+ }, [file, filetype, themeId])
97
+
98
+ const splitRef = useRef<SplitViewHandle | null>(null)
99
+ const stackedRef = useRef<StackedViewHandle | null>(null)
100
+
101
+ useImperativeHandle(
102
+ ref,
103
+ () => ({
104
+ get leftScroll() {
105
+ if (view === 'split') return splitRef.current?.leftScroll ?? null
106
+ return stackedRef.current?.scroll ?? null
107
+ },
108
+ get rightScroll() {
109
+ if (view === 'split') return splitRef.current?.rightScroll ?? null
110
+ return stackedRef.current?.scroll ?? null
111
+ },
112
+ }),
113
+ [view]
114
+ )
115
+
116
+ useEffect(() => {
117
+ if (!file) return
118
+ const rows =
119
+ view === 'split'
120
+ ? buildSplitRows(file, EMPTY_FOLDS, contentWidth)
121
+ : buildUnifiedRows(file, EMPTY_FOLDS, contentWidth)
122
+ const offset = firstChangeRowOffset(rows)
123
+ if (offset < 0) return
124
+ const target = Math.max(0, offset - 2)
125
+ const apply = (): void => {
126
+ if (view === 'split') {
127
+ const left = splitRef.current?.leftScroll
128
+ const right = splitRef.current?.rightScroll
129
+ if (left) left.scrollTop = target
130
+ if (right && right !== left) right.scrollTop = target
131
+ } else {
132
+ const node = stackedRef.current?.scroll
133
+ if (node) node.scrollTop = target
134
+ }
135
+ }
136
+ const raf = requestAnimationFrame(apply)
137
+ return () => cancelAnimationFrame(raf)
138
+ }, [cacheKey, file, view, contentWidth])
139
+
140
+ if (!file) {
141
+ return (
142
+ <box flexGrow={1} padding={1}>
143
+ <text fg={theme.colors['descriptionForeground']}>(could not parse diff)</text>
144
+ </box>
145
+ )
146
+ }
147
+
148
+ if (view === 'stacked') {
149
+ return (
150
+ <StackedView
151
+ ref={stackedRef}
152
+ contentWidth={contentWidth}
153
+ file={file}
154
+ foldDispatch={foldDispatch}
155
+ folds={folds}
156
+ highlights={highlights}
157
+ />
158
+ )
159
+ }
160
+ return (
161
+ <SplitView
162
+ ref={splitRef}
163
+ contentWidth={contentWidth}
164
+ file={file}
165
+ foldDispatch={foldDispatch}
166
+ folds={folds}
167
+ highlights={highlights}
168
+ />
169
+ )
170
+ })
@@ -0,0 +1,207 @@
1
+ import type { ThemedToken } from 'shiki'
2
+
3
+ import {
4
+ type MouseEvent as OtuiMouseEvent,
5
+ type ScrollBoxRenderable,
6
+ TextAttributes,
7
+ } from '@opentui/core'
8
+ import { forwardRef, useImperativeHandle, useRef } from 'react'
9
+
10
+ import type { FileDiffMetadata } from '../../../diff-parser'
11
+ import type { FoldState } from '../../../state/types'
12
+ import type { DiffHighlights, FoldDispatch } from './pierre-diff'
13
+
14
+ import { getScrollViewportDelta } from '../../../app-runtime/terminal-mouse-adapter'
15
+ import { scrollGitDiff } from '../../git-view-controls'
16
+ import { theme } from '../../theme'
17
+ import { buildSplitRows, gutterWidth, type SplitCell, type SplitRowOrHeader } from './build-rows'
18
+ import { FoldStrip } from './fold-strip'
19
+ import { tokenToSpan } from './highlight'
20
+
21
+ export interface SplitViewHandle {
22
+ leftScroll: ScrollBoxRenderable | null
23
+ rightScroll: ScrollBoxRenderable | null
24
+ }
25
+
26
+ interface Props {
27
+ file: FileDiffMetadata
28
+ highlights: DiffHighlights
29
+ folds: Record<string, FoldState>
30
+ foldDispatch: FoldDispatch
31
+ contentWidth: number
32
+ }
33
+
34
+ function handleScroll(e: OtuiMouseEvent): void {
35
+ const delta = getScrollViewportDelta(e)
36
+ if (delta === null) return
37
+ e.preventDefault()
38
+ e.stopPropagation()
39
+ scrollGitDiff(delta)
40
+ }
41
+
42
+ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
43
+ { contentWidth, file, foldDispatch, folds, highlights },
44
+ ref
45
+ ) {
46
+ const leftRef = useRef<ScrollBoxRenderable | null>(null)
47
+ const rightRef = useRef<ScrollBoxRenderable | null>(null)
48
+
49
+ useImperativeHandle(
50
+ ref,
51
+ () => ({
52
+ get leftScroll() {
53
+ return leftRef.current
54
+ },
55
+ get rightScroll() {
56
+ return rightRef.current
57
+ },
58
+ }),
59
+ []
60
+ )
61
+
62
+ const rows = buildSplitRows(file, folds, contentWidth)
63
+ const gw = gutterWidth(file)
64
+
65
+ return (
66
+ <box flexDirection="row" flexGrow={1} overflow="hidden" onMouseScroll={handleScroll}>
67
+ <scrollbox
68
+ ref={leftRef}
69
+ flexGrow={1}
70
+ scrollY
71
+ viewportCulling
72
+ contentOptions={{ flexDirection: 'column', gap: 0 }}
73
+ verticalScrollbarOptions={{ visible: false }}
74
+ onMouseScroll={handleScroll}
75
+ >
76
+ {rows.map((row, i) => (
77
+ <SideRow
78
+ key={i}
79
+ cell={row.type === 'row' ? row.left : null}
80
+ foldDispatch={foldDispatch}
81
+ gw={gw}
82
+ header={row.type === 'hunk-header' ? row : null}
83
+ rowHeight={row.type === 'row' ? row.height : 1}
84
+ tokens={highlights.del}
85
+ />
86
+ ))}
87
+ </scrollbox>
88
+ <box width={1} backgroundColor={theme.colors['editor.background']} />
89
+ <scrollbox
90
+ ref={rightRef}
91
+ flexGrow={1}
92
+ scrollY
93
+ viewportCulling
94
+ contentOptions={{ flexDirection: 'column', gap: 0 }}
95
+ onMouseScroll={handleScroll}
96
+ >
97
+ {rows.map((row, i) => (
98
+ <SideRow
99
+ key={i}
100
+ cell={row.type === 'row' ? row.right : null}
101
+ foldDispatch={foldDispatch}
102
+ gw={gw}
103
+ header={row.type === 'hunk-header' ? row : null}
104
+ rowHeight={row.type === 'row' ? row.height : 1}
105
+ tokens={highlights.add}
106
+ />
107
+ ))}
108
+ </scrollbox>
109
+ </box>
110
+ )
111
+ })
112
+
113
+ function SideRow({
114
+ cell,
115
+ foldDispatch,
116
+ gw,
117
+ header,
118
+ rowHeight,
119
+ tokens,
120
+ }: {
121
+ cell: SplitCell | null
122
+ foldDispatch: FoldDispatch
123
+ gw: number
124
+ header: Extract<SplitRowOrHeader, { type: 'hunk-header' }> | null
125
+ rowHeight: number
126
+ tokens: ThemedToken[][]
127
+ }) {
128
+ if (header) return <HunkHeaderRow row={header} />
129
+ if (!cell) return null
130
+ if (cell.type === 'fold') return <FoldStrip dispatch={foldDispatch} fold={cell.fold} />
131
+ return <HalfRow cell={cell} gw={gw} height={rowHeight} tokens={tokens} />
132
+ }
133
+
134
+ function HunkHeaderRow({ row }: { row: Extract<SplitRowOrHeader, { type: 'hunk-header' }> }) {
135
+ return (
136
+ <box
137
+ flexDirection="row"
138
+ backgroundColor={theme.colors['sideBarSectionHeader.background']}
139
+ paddingLeft={1}
140
+ paddingRight={1}
141
+ >
142
+ <text fg={theme.colors['descriptionForeground']}>{row.spec}</text>
143
+ {row.context ? (
144
+ <text fg={theme.colors['editor.lineHighlightBackground']}> {row.context}</text>
145
+ ) : null}
146
+ </box>
147
+ )
148
+ }
149
+
150
+ function HalfRow({
151
+ cell,
152
+ gw,
153
+ height,
154
+ tokens,
155
+ }: {
156
+ cell: Exclude<SplitCell, { type: 'fold' }>
157
+ gw: number
158
+ height: number
159
+ tokens: ThemedToken[][]
160
+ }) {
161
+ if (cell.type === 'filler') {
162
+ return <box backgroundColor={theme.colors['sideBarSectionHeader.background']} height={height} />
163
+ }
164
+ let bg: string | undefined
165
+ let sign = ' '
166
+ let signColor = theme.colors['descriptionForeground']
167
+ if (cell.type === 'addition') {
168
+ bg = theme.colors['diffEditor.insertedLineBackground']
169
+ sign = '+'
170
+ signColor = theme.colors['gitDecoration.addedResourceForeground']
171
+ } else if (cell.type === 'deletion') {
172
+ bg = theme.colors['diffEditor.removedLineBackground']
173
+ sign = '-'
174
+ signColor = theme.colors['editorError.foreground']
175
+ }
176
+ const num = String(cell.lineNumber).padStart(gw, ' ')
177
+ const lineTokens = tokens[cell.lineIdx]
178
+ return (
179
+ <box flexDirection="row" backgroundColor={bg} height={height}>
180
+ <text fg={theme.colors['descriptionForeground']}>{` ${num} `}</text>
181
+ <text fg={signColor}>{`${sign} `}</text>
182
+ <LineContent content={cell.content} tokens={lineTokens} />
183
+ </box>
184
+ )
185
+ }
186
+
187
+ function LineContent({ content, tokens }: { content: string; tokens: ThemedToken[] | undefined }) {
188
+ if (!tokens || tokens.length === 0) {
189
+ return <text fg={theme.colors['editor.foreground']}>{content}</text>
190
+ }
191
+ return (
192
+ <text>
193
+ {tokens.map((t, i) => {
194
+ const s = tokenToSpan(t)
195
+ let attributes = 0
196
+ if (s.bold) attributes |= TextAttributes.BOLD
197
+ if (s.italic) attributes |= TextAttributes.ITALIC
198
+ if (s.underline) attributes |= TextAttributes.UNDERLINE
199
+ return (
200
+ <span key={i} fg={s.fg ?? theme.colors['editor.foreground']} attributes={attributes}>
201
+ {s.text}
202
+ </span>
203
+ )
204
+ })}
205
+ </text>
206
+ )
207
+ }
@@ -0,0 +1,166 @@
1
+ import type { ThemedToken } from 'shiki'
2
+
3
+ import {
4
+ type MouseEvent as OtuiMouseEvent,
5
+ type ScrollBoxRenderable,
6
+ TextAttributes,
7
+ } from '@opentui/core'
8
+ import { forwardRef, useImperativeHandle, useRef } from 'react'
9
+
10
+ import type { FileDiffMetadata } from '../../../diff-parser'
11
+ import type { FoldState } from '../../../state/types'
12
+ import type { DiffHighlights, FoldDispatch } from './pierre-diff'
13
+
14
+ import { getScrollViewportDelta } from '../../../app-runtime/terminal-mouse-adapter'
15
+ import { scrollGitDiff } from '../../git-view-controls'
16
+ import { theme } from '../../theme'
17
+ import { buildUnifiedRows, gutterWidth, type UnifiedRowOrHeader } from './build-rows'
18
+ import { FoldStrip } from './fold-strip'
19
+ import { tokenToSpan } from './highlight'
20
+
21
+ export interface StackedViewHandle {
22
+ scroll: ScrollBoxRenderable | null
23
+ }
24
+
25
+ interface Props {
26
+ file: FileDiffMetadata
27
+ highlights: DiffHighlights
28
+ folds: Record<string, FoldState>
29
+ foldDispatch: FoldDispatch
30
+ contentWidth: number
31
+ }
32
+
33
+ function handleScroll(e: OtuiMouseEvent): void {
34
+ const delta = getScrollViewportDelta(e)
35
+ if (delta === null) return
36
+ e.preventDefault()
37
+ e.stopPropagation()
38
+ scrollGitDiff(delta)
39
+ }
40
+
41
+ export const StackedView = forwardRef<StackedViewHandle, Props>(function StackedView(
42
+ { contentWidth, file, foldDispatch, folds, highlights },
43
+ ref
44
+ ) {
45
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
46
+
47
+ useImperativeHandle(
48
+ ref,
49
+ () => ({
50
+ get scroll() {
51
+ return scrollRef.current
52
+ },
53
+ }),
54
+ []
55
+ )
56
+
57
+ const rows = buildUnifiedRows(file, folds, contentWidth)
58
+ const gw = gutterWidth(file)
59
+
60
+ return (
61
+ <scrollbox
62
+ ref={scrollRef}
63
+ flexGrow={1}
64
+ scrollY
65
+ viewportCulling
66
+ contentOptions={{ flexDirection: 'column', gap: 0 }}
67
+ onMouseScroll={handleScroll}
68
+ >
69
+ {rows.map((row, i) => (
70
+ <UnifiedRowRender
71
+ key={i}
72
+ foldDispatch={foldDispatch}
73
+ gw={gw}
74
+ highlights={highlights}
75
+ row={row}
76
+ />
77
+ ))}
78
+ </scrollbox>
79
+ )
80
+ })
81
+
82
+ function UnifiedRowRender({
83
+ foldDispatch,
84
+ gw,
85
+ highlights,
86
+ row,
87
+ }: {
88
+ foldDispatch: FoldDispatch
89
+ gw: number
90
+ highlights: DiffHighlights
91
+ row: UnifiedRowOrHeader
92
+ }) {
93
+ if (row.type === 'hunk-header') {
94
+ return (
95
+ <box
96
+ flexDirection="row"
97
+ backgroundColor={theme.colors['sideBarSectionHeader.background']}
98
+ paddingLeft={1}
99
+ paddingRight={1}
100
+ >
101
+ <text fg={theme.colors['descriptionForeground']}>{row.spec}</text>
102
+ {row.context ? (
103
+ <text fg={theme.colors['editor.lineHighlightBackground']}> {row.context}</text>
104
+ ) : null}
105
+ </box>
106
+ )
107
+ }
108
+ if (row.type === 'fold') {
109
+ return <FoldStrip dispatch={foldDispatch} fold={row.fold} />
110
+ }
111
+ const pad = (n: number | undefined): string =>
112
+ n === undefined ? ' '.repeat(gw) : String(n).padStart(gw, ' ')
113
+ if (row.type === 'context') {
114
+ const tokens = highlights.add[row.lineIdx]
115
+ return (
116
+ <box flexDirection="row" height={row.height}>
117
+ <text
118
+ fg={theme.colors['descriptionForeground']}
119
+ >{` ${pad(row.delLineNumber)} ${pad(row.addLineNumber)} `}</text>
120
+ <text fg={theme.colors['editor.foreground']}> </text>
121
+ <LineContent content={row.content} tokens={tokens} />
122
+ </box>
123
+ )
124
+ }
125
+ const bg =
126
+ row.type === 'addition'
127
+ ? theme.colors['diffEditor.insertedLineBackground']
128
+ : theme.colors['diffEditor.removedLineBackground']
129
+ const sign = row.type === 'addition' ? '+' : '-'
130
+ const signColor =
131
+ row.type === 'addition'
132
+ ? theme.colors['gitDecoration.addedResourceForeground']
133
+ : theme.colors['editorError.foreground']
134
+ const delNum = row.type === 'deletion' ? row.lineNumber : undefined
135
+ const addNum = row.type === 'addition' ? row.lineNumber : undefined
136
+ const tokens = row.type === 'addition' ? highlights.add[row.lineIdx] : highlights.del[row.lineIdx]
137
+ return (
138
+ <box flexDirection="row" backgroundColor={bg} height={row.height}>
139
+ <text fg={theme.colors['descriptionForeground']}>{` ${pad(delNum)} ${pad(addNum)} `}</text>
140
+ <text fg={signColor}>{`${sign} `}</text>
141
+ <LineContent content={row.content} tokens={tokens} />
142
+ </box>
143
+ )
144
+ }
145
+
146
+ function LineContent({ content, tokens }: { content: string; tokens: ThemedToken[] | undefined }) {
147
+ if (!tokens || tokens.length === 0) {
148
+ return <text fg={theme.colors['editor.foreground']}>{content}</text>
149
+ }
150
+ return (
151
+ <text>
152
+ {tokens.map((t, i) => {
153
+ const s = tokenToSpan(t)
154
+ let attributes = 0
155
+ if (s.bold) attributes |= TextAttributes.BOLD
156
+ if (s.italic) attributes |= TextAttributes.ITALIC
157
+ if (s.underline) attributes |= TextAttributes.UNDERLINE
158
+ return (
159
+ <span key={i} fg={s.fg ?? theme.colors['editor.foreground']} attributes={attributes}>
160
+ {s.text}
161
+ </span>
162
+ )
163
+ })}
164
+ </text>
165
+ )
166
+ }