@brimveyn/aimux 1.9.0 → 1.9.2

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 (73) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +13 -3
  3. package/src/app.tsx +26 -17
  4. package/src/git/git-poller.ts +56 -3
  5. package/src/git/repo-discovery.ts +95 -0
  6. package/src/git/use-repo-discovery.ts +28 -0
  7. package/src/input/keymap/describe-bindings.ts +63 -2
  8. package/src/pty/terminal-snapshot.ts +7 -5
  9. package/src/state/reducers/git-mode-state.ts +58 -0
  10. package/src/state/reducers/git-panel-state.ts +3 -0
  11. package/src/state/reducers/multi-repo-state.ts +60 -0
  12. package/src/state/store.ts +6 -0
  13. package/src/state/types.ts +38 -6
  14. package/src/ui/breaking-update-screen.tsx +2 -2
  15. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  16. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  17. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  18. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  19. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  20. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  21. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  22. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +9 -18
  23. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  24. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  25. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +32 -10
  26. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +73 -38
  27. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  28. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +6 -4
  29. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  30. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  31. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  32. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  33. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  34. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  35. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  36. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  37. package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
  38. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  39. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  40. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  41. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  42. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  43. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  44. package/src/ui/components/modals/shared/form.tsx +164 -0
  45. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  46. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  47. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  48. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  49. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  50. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  51. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  52. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
  53. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  54. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  55. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  56. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  57. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  58. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  59. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  60. package/src/ui/root.tsx +24 -22
  61. package/src/ui/theme-store.ts +30 -26
  62. package/src/ui/themes.ts +1 -0
  63. package/src/ui/components/create-session-modal.tsx +0 -96
  64. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  65. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  66. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  67. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  68. package/src/ui/components/session-name-modal.tsx +0 -11
  69. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  70. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  71. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  72. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  73. /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
@@ -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
- }
@@ -1,106 +0,0 @@
1
- import type { ThemedToken } from 'shiki'
2
-
3
- import { useEffect, useMemo, useState } from 'react'
4
-
5
- import type { FileDiffMetadata } from '../../../diff-parser'
6
-
7
- import { useAppStore } from '../../../state/app-store'
8
- import { dispatchGlobal } from '../../../state/dispatch-ref'
9
- import { getHighlightsTokens, getParsedFile } from '../../../state/types'
10
- import { prepareDiff } from './prepare-diff'
11
-
12
- export interface DiffPreparation {
13
- file: FileDiffMetadata | null
14
- highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
15
- /** True while prepare is running for this cache key + hash. */
16
- preparing: boolean
17
- /** Available once either cache or prepare has produced a valid hash. */
18
- ready: boolean
19
- }
20
-
21
- const EMPTY_HIGHLIGHTS = { add: [] as ThemedToken[][], del: [] as ThemedToken[][] }
22
-
23
- // Consults the parsed + highlight caches first, then falls back to an off-thread
24
- // prepareDiff that dispatches results into the store. Deduplicates in-flight
25
- // preparation per cacheKey+hash by hashing the diff string once.
26
- export function useDiffPreparation(
27
- cacheKey: string,
28
- diff: string,
29
- path: string,
30
- themeId: string
31
- ): DiffPreparation {
32
- const cachedParsed = useAppStore((s) => s.gitMode.parsedFiles[cacheKey])
33
- const highlightKey = `${cacheKey}|${themeId}`
34
- const cachedHighlights = useAppStore((s) => s.gitMode.highlights[highlightKey])
35
-
36
- const [preparing, setPreparing] = useState(false)
37
- const [localFile, setLocalFile] = useState<FileDiffMetadata | null>(null)
38
- const [localHighlights, setLocalHighlights] = useState(EMPTY_HIGHLIGHTS)
39
- const [localHash, setLocalHash] = useState<string | null>(null)
40
-
41
- const { file, highlights, ready } = useMemo(() => {
42
- const parsedFile = cachedParsed ? getParsedFile(cachedParsed) : null
43
- const parsedMatches = !!cachedParsed
44
- const tokens = cachedHighlights ? getHighlightsTokens(cachedHighlights) : null
45
- const hlMatches = !!cachedHighlights && cachedHighlights.hash === cachedParsed?.hash
46
-
47
- if (parsedMatches && hlMatches && tokens) {
48
- return { file: parsedFile, highlights: tokens, ready: true }
49
- }
50
- if (parsedMatches && !hlMatches) {
51
- return {
52
- file: parsedFile,
53
- highlights: tokens ?? EMPTY_HIGHLIGHTS,
54
- ready: true,
55
- }
56
- }
57
- if (localHash && localFile) {
58
- return { file: localFile, highlights: localHighlights, ready: true }
59
- }
60
- return { file: null, highlights: EMPTY_HIGHLIGHTS, ready: false }
61
- }, [cachedParsed, cachedHighlights, localFile, localHighlights, localHash])
62
-
63
- useEffect(() => {
64
- // If caches already cover both parsed and highlights for this theme, bail.
65
- const parsedOk = !!cachedParsed
66
- const hlOk = !!cachedHighlights && cachedParsed && cachedHighlights.hash === cachedParsed.hash
67
- if (parsedOk && hlOk) return
68
-
69
- const controller = new AbortController()
70
- setPreparing(true)
71
- void prepareDiff(diff, path, { signal: controller.signal })
72
- .then((result) => {
73
- if (controller.signal.aborted) return
74
- setLocalFile(result.file)
75
- setLocalHighlights(result.highlights)
76
- setLocalHash(result.hash)
77
- dispatchGlobal({
78
- file: result.file,
79
- hash: result.hash,
80
- key: cacheKey,
81
- type: 'git-mode-set-parsed',
82
- })
83
- dispatchGlobal({
84
- add: result.highlights.add,
85
- del: result.highlights.del,
86
- hash: result.hash,
87
- key: cacheKey,
88
- themeId,
89
- type: 'git-mode-set-highlights',
90
- })
91
- })
92
- .catch((err: unknown) => {
93
- if (err instanceof Error && err.name === 'AbortError') return
94
- // Swallow — the UI falls back to "(could not parse diff)" when file stays null.
95
- })
96
- .finally(() => {
97
- if (!controller.signal.aborted) setPreparing(false)
98
- })
99
-
100
- return () => {
101
- controller.abort()
102
- }
103
- }, [cacheKey, diff, path, themeId, cachedParsed, cachedHighlights])
104
-
105
- return { file, highlights, preparing, ready }
106
- }
@@ -1,11 +0,0 @@
1
- import { uiTokens } from '../ui-tokens'
2
- import { InputField } from './input-field'
3
- import { ModalShell } from './modal-shell'
4
-
5
- export function SessionNameModal({ title, value }: { title: string; value: string }) {
6
- return (
7
- <ModalShell title={title} keybindsModeId="modal.session-name" width={uiTokens.modalWidth.md}>
8
- <InputField active value={value} />
9
- </ModalShell>
10
- )
11
- }
@@ -1,40 +0,0 @@
1
- import { useTokens } from '../theme'
2
- import { uiTokens } from '../ui-tokens'
3
- import { InputField } from './input-field'
4
- import { ModalShell } from './modal-shell'
5
-
6
- interface SnippetEditorModalProps {
7
- activeField: 'name' | 'content'
8
- snippetName: string
9
- snippetContent: string
10
- isEditing: boolean
11
- }
12
-
13
- export function SnippetEditorModal({
14
- activeField,
15
- isEditing,
16
- snippetContent,
17
- snippetName,
18
- }: SnippetEditorModalProps) {
19
- const t = useTokens()
20
- const nameActive = activeField === 'name'
21
- const contentActive = activeField === 'content'
22
-
23
- return (
24
- <ModalShell
25
- title={isEditing ? 'Edit snippet' : 'Create snippet'}
26
- keybindsModeId="modal.snippet-editor"
27
- width={uiTokens.modalWidth.xl}
28
- >
29
- <box flexDirection="column">
30
- <text fg={nameActive ? t.palette.ink : t.muted}>Name</text>
31
- <InputField active={nameActive} value={snippetName} />
32
- </box>
33
-
34
- <box flexDirection="column">
35
- <text fg={contentActive ? t.palette.ink : t.muted}>Content</text>
36
- <InputField active={contentActive} value={snippetContent} />
37
- </box>
38
- </ModalShell>
39
- )
40
- }