@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.
- package/README.md +14 -2
- package/package.json +5 -3
- package/src/app-runtime/side-effects.ts +41 -6
- package/src/app.tsx +17 -5
- package/src/config.ts +29 -4
- package/src/diff-parser/clean-last-newline.ts +5 -0
- package/src/diff-parser/constants.ts +13 -0
- package/src/diff-parser/index.ts +12 -0
- package/src/diff-parser/parse-line-type.ts +29 -0
- package/src/diff-parser/parse-patch-files.ts +369 -0
- package/src/diff-parser/types.ts +75 -0
- package/src/input/modes/bridge.ts +8 -0
- package/src/input/modes/transitions.ts +2 -1
- package/src/input/modes/types.ts +4 -1
- package/src/pty/terminal-snapshot.ts +4 -4
- package/src/state/git-tree.ts +220 -0
- package/src/state/reducers/git-mode-state.ts +232 -35
- package/src/state/reducers/git-panel-state.ts +29 -3
- package/src/state/reducers/modal-state.ts +43 -10
- package/src/state/store.ts +5 -1
- package/src/state/types.ts +41 -5
- package/src/state/workspace-save.ts +2 -0
- package/src/ui/components/create-session-modal.tsx +23 -7
- package/src/ui/components/diff-renderer/build-rows.ts +349 -0
- package/src/ui/components/diff-renderer/filetype.ts +29 -0
- package/src/ui/components/diff-renderer/fold-strip.tsx +84 -0
- package/src/ui/components/diff-renderer/highlight.ts +48 -0
- package/src/ui/components/diff-renderer/index.ts +1 -0
- package/src/ui/components/diff-renderer/pierre-diff.tsx +170 -0
- package/src/ui/components/diff-renderer/split-view.tsx +207 -0
- package/src/ui/components/diff-renderer/stacked-view.tsx +166 -0
- package/src/ui/components/git-commit-modal.tsx +14 -2
- package/src/ui/components/git-pane-widget.tsx +5 -0
- package/src/ui/components/git-panel.tsx +176 -79
- package/src/ui/components/git-view.tsx +89 -87
- package/src/ui/components/help-modal.tsx +43 -11
- package/src/ui/components/input-field.tsx +2 -2
- package/src/ui/components/list-item.tsx +9 -1
- package/src/ui/components/modal-filter-bar.tsx +1 -1
- package/src/ui/components/modal-keybinds-overlay.tsx +2 -2
- package/src/ui/components/modal-shell.tsx +3 -3
- package/src/ui/components/new-tab-modal.tsx +15 -3
- package/src/ui/components/pending-chord-overlay.tsx +3 -3
- package/src/ui/components/session-bar.tsx +10 -5
- package/src/ui/components/session-picker-modal.tsx +26 -9
- package/src/ui/components/sidebar.tsx +22 -10
- package/src/ui/components/snippet-editor-modal.tsx +16 -2
- package/src/ui/components/snippet-picker-modal.tsx +11 -3
- package/src/ui/components/split-layout.tsx +1 -1
- package/src/ui/components/status-bar.tsx +12 -9
- package/src/ui/components/surface.tsx +5 -5
- package/src/ui/components/tab-item.tsx +22 -16
- package/src/ui/components/terminal-pane.tsx +25 -15
- package/src/ui/components/theme-picker-modal.tsx +111 -16
- package/src/ui/components/update-available-modal.tsx +11 -1
- package/src/ui/filter-themes.ts +10 -0
- package/src/ui/house-themes.ts +313 -0
- package/src/ui/keymap-context.ts +10 -2
- package/src/ui/root.tsx +26 -6
- package/src/ui/shiki.ts +49 -0
- package/src/ui/status-bar-model.ts +19 -1
- package/src/ui/theme.ts +22 -10
- package/src/ui/themes.generated.ts +59794 -0
- package/src/ui/themes.ts +68 -274
- 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
|
+
}
|