@neurocode-ai/session-ui 1.18.8

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 (114) hide show
  1. package/package.json +71 -0
  2. package/src/components/apply-patch-file.test.ts +43 -0
  3. package/src/components/apply-patch-file.ts +78 -0
  4. package/src/components/basic-tool.css +358 -0
  5. package/src/components/basic-tool.stories.tsx +133 -0
  6. package/src/components/basic-tool.tsx +343 -0
  7. package/src/components/dock-prompt.stories.tsx +62 -0
  8. package/src/components/dock-prompt.tsx +23 -0
  9. package/src/components/file-media.tsx +291 -0
  10. package/src/components/file-search.tsx +72 -0
  11. package/src/components/file-ssr.tsx +197 -0
  12. package/src/components/file.css +46 -0
  13. package/src/components/file.tsx +1202 -0
  14. package/src/components/line-comment-annotations.tsx +637 -0
  15. package/src/components/line-comment-styles.ts +292 -0
  16. package/src/components/line-comment.stories.tsx +115 -0
  17. package/src/components/line-comment.tsx +439 -0
  18. package/src/components/markdown-cache.tsx +78 -0
  19. package/src/components/markdown-code-state.test.ts +32 -0
  20. package/src/components/markdown-code-state.ts +22 -0
  21. package/src/components/markdown-inline-code-kind.test.ts +46 -0
  22. package/src/components/markdown-inline-code-kind.ts +1915 -0
  23. package/src/components/markdown-preload.test.ts +18 -0
  24. package/src/components/markdown-shiki.worker.ts +104 -0
  25. package/src/components/markdown-stream.test.ts +194 -0
  26. package/src/components/markdown-stream.ts +110 -0
  27. package/src/components/markdown-worker-protocol.test.ts +81 -0
  28. package/src/components/markdown-worker-protocol.ts +48 -0
  29. package/src/components/markdown-worker-queue.test.ts +49 -0
  30. package/src/components/markdown-worker-queue.ts +64 -0
  31. package/src/components/markdown-worker-transport.test.ts +56 -0
  32. package/src/components/markdown-worker-transport.ts +41 -0
  33. package/src/components/markdown-worker.ts +122 -0
  34. package/src/components/markdown.css +377 -0
  35. package/src/components/markdown.stories.tsx +53 -0
  36. package/src/components/markdown.tsx +678 -0
  37. package/src/components/message-file.test.ts +63 -0
  38. package/src/components/message-file.ts +34 -0
  39. package/src/components/message-nav.css +127 -0
  40. package/src/components/message-nav.stories.tsx +7 -0
  41. package/src/components/message-nav.tsx +102 -0
  42. package/src/components/message-part-text.ts +3 -0
  43. package/src/components/message-part.css +1569 -0
  44. package/src/components/message-part.stories.tsx +7 -0
  45. package/src/components/message-part.test.ts +28 -0
  46. package/src/components/message-part.tsx +2662 -0
  47. package/src/components/session-diff.test.ts +135 -0
  48. package/src/components/session-diff.ts +145 -0
  49. package/src/components/session-retry.tsx +74 -0
  50. package/src/components/session-review.css +247 -0
  51. package/src/components/session-review.stories.tsx +7 -0
  52. package/src/components/session-review.tsx +657 -0
  53. package/src/components/session-turn.css +241 -0
  54. package/src/components/session-turn.stories.tsx +7 -0
  55. package/src/components/session-turn.tsx +543 -0
  56. package/src/components/shell-submessage-motion.stories.tsx +346 -0
  57. package/src/components/shell-submessage.css +23 -0
  58. package/src/components/timeline-playground.stories.tsx +2090 -0
  59. package/src/components/tool-count-label.css +57 -0
  60. package/src/components/tool-count-label.tsx +58 -0
  61. package/src/components/tool-count-summary.css +102 -0
  62. package/src/components/tool-count-summary.stories.tsx +238 -0
  63. package/src/components/tool-count-summary.tsx +52 -0
  64. package/src/components/tool-error-card.css +91 -0
  65. package/src/components/tool-error-card.stories.tsx +92 -0
  66. package/src/components/tool-error-card.tsx +157 -0
  67. package/src/components/tool-status-title.css +89 -0
  68. package/src/components/tool-status-title.tsx +133 -0
  69. package/src/context/data.tsx +64 -0
  70. package/src/context/index.ts +1 -0
  71. package/src/pierre/comment-hover.ts +83 -0
  72. package/src/pierre/commented-lines.ts +91 -0
  73. package/src/pierre/diff-selection.ts +71 -0
  74. package/src/pierre/file-find.ts +485 -0
  75. package/src/pierre/file-runtime.ts +114 -0
  76. package/src/pierre/file-selection.ts +85 -0
  77. package/src/pierre/index.ts +186 -0
  78. package/src/pierre/media.ts +110 -0
  79. package/src/pierre/selection-bridge.ts +132 -0
  80. package/src/pierre/virtualizer.ts +100 -0
  81. package/src/pierre/worker.ts +52 -0
  82. package/src/styles/index.css +14 -0
  83. package/src/v2/components/attachment-card-v2.css +70 -0
  84. package/src/v2/components/attachment-card-v2.tsx +33 -0
  85. package/src/v2/components/basic-tool-v2.css +163 -0
  86. package/src/v2/components/basic-tool-v2.stories.tsx +137 -0
  87. package/src/v2/components/basic-tool-v2.tsx +139 -0
  88. package/src/v2/components/comment-card-v2.tsx +64 -0
  89. package/src/v2/components/line-comment-annotations-v2.tsx +220 -0
  90. package/src/v2/components/prompt-input/attachments.ts +266 -0
  91. package/src/v2/components/prompt-input/index.tsx +706 -0
  92. package/src/v2/components/prompt-input/interaction.ts +482 -0
  93. package/src/v2/components/prompt-input/machine.test.ts +164 -0
  94. package/src/v2/components/prompt-input/machine.ts +261 -0
  95. package/src/v2/components/prompt-input/prompt-input.stories.tsx +221 -0
  96. package/src/v2/components/prompt-input/store.test.ts +116 -0
  97. package/src/v2/components/prompt-input/store.ts +152 -0
  98. package/src/v2/components/prompt-input/types.ts +106 -0
  99. package/src/v2/components/session-file-panel-v2.tsx +43 -0
  100. package/src/v2/components/session-progress-indicator-v2.css +875 -0
  101. package/src/v2/components/session-progress-indicator-v2.stories.tsx +66 -0
  102. package/src/v2/components/session-progress-indicator-v2.tsx +32 -0
  103. package/src/v2/components/session-review-empty-changes-v2.tsx +17 -0
  104. package/src/v2/components/session-review-empty-no-git-v2.tsx +28 -0
  105. package/src/v2/components/session-review-file-preview-v2-virtualize.test.ts +13 -0
  106. package/src/v2/components/session-review-file-preview-v2-virtualize.ts +5 -0
  107. package/src/v2/components/session-review-file-preview-v2.tsx +290 -0
  108. package/src/v2/components/session-review-v2.css +458 -0
  109. package/src/v2/components/session-review-v2.tsx +340 -0
  110. package/src/v2/components/tool-error-card-v2.css +200 -0
  111. package/src/v2/components/tool-error-card-v2.stories.tsx +91 -0
  112. package/src/v2/components/tool-error-card-v2.tsx +166 -0
  113. package/sst-env.d.ts +10 -0
  114. package/tsconfig.json +19 -0
@@ -0,0 +1,1202 @@
1
+ import { sampledChecksum } from "@neurocode-ai/core/util/encode"
2
+ import {
3
+ areFilesEqual,
4
+ areOptionsEqual,
5
+ DEFAULT_VIRTUAL_FILE_METRICS,
6
+ type DiffLineAnnotation,
7
+ type FileContents,
8
+ type FileDiffMetadata,
9
+ File as PierreFile,
10
+ type FileDiffOptions,
11
+ FileDiff,
12
+ type FileOptions,
13
+ type LineAnnotation,
14
+ type SelectedLineRange,
15
+ type VirtualFileMetrics,
16
+ VirtualizedFile,
17
+ VirtualizedFileDiff,
18
+ Virtualizer,
19
+ } from "@pierre/diffs"
20
+ import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
21
+ import { createMediaQuery } from "@solid-primitives/media"
22
+ import { makeEventListener } from "@solid-primitives/event-listener"
23
+ import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
24
+ import { createDefaultOptions, styleVariables } from "../pierre"
25
+ import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
26
+ import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
27
+ import { createFileFind } from "../pierre/file-find"
28
+ import {
29
+ applyViewerScheme,
30
+ clearReadyWatcher,
31
+ createReadyWatcher,
32
+ getViewerHost,
33
+ getViewerRoot,
34
+ notifyShadowReady,
35
+ observeViewerScheme,
36
+ } from "../pierre/file-runtime"
37
+ import {
38
+ findCodeSelectionSide,
39
+ findDiffLineNumber,
40
+ findElement,
41
+ findFileLineNumber,
42
+ readShadowLineSelection,
43
+ } from "../pierre/file-selection"
44
+ import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
45
+ import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
46
+ import { getWorkerPool } from "../pierre/worker"
47
+ import { FileMedia, type FileMediaOptions } from "./file-media"
48
+ import { FileSearchBar } from "./file-search"
49
+
50
+ const VIRTUALIZE_BYTES = 500_000
51
+
52
+ const codeMetrics = {
53
+ ...DEFAULT_VIRTUAL_FILE_METRICS,
54
+ lineHeight: 24,
55
+ spacing: 0,
56
+ } satisfies Partial<VirtualFileMetrics>
57
+
58
+ type SharedProps<T> = {
59
+ annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
60
+ selectedLines?: SelectedLineRange | null
61
+ commentedLines?: SelectedLineRange[]
62
+ onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
63
+ onRendered?: () => void
64
+ class?: string
65
+ classList?: ComponentProps<"div">["classList"]
66
+ media?: FileMediaOptions
67
+ search?: FileSearchControl
68
+ }
69
+
70
+ export type FileSearchHandle = {
71
+ focus: () => void
72
+ }
73
+
74
+ export type FileSearchControl = {
75
+ register: (handle: FileSearchHandle | null) => void
76
+ }
77
+
78
+ export type TextFileProps<T = {}> = FileOptions<T> &
79
+ SharedProps<T> & {
80
+ mode: "text"
81
+ file: FileContents
82
+ annotations?: LineAnnotation<T>[]
83
+ preloadedDiff?: PreloadMultiFileDiffResult<T>
84
+ }
85
+
86
+ type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
87
+
88
+ type DiffBaseProps<T> = FileDiffOptions<T> &
89
+ SharedProps<T> & {
90
+ mode: "diff"
91
+ annotations?: DiffLineAnnotation<T>[]
92
+ preloadedDiff?: DiffPreload<T>
93
+ virtualize?: boolean
94
+ }
95
+
96
+ type DiffPairProps<T> = DiffBaseProps<T> & {
97
+ before: FileContents
98
+ after: FileContents
99
+ fileDiff?: undefined
100
+ }
101
+
102
+ type DiffPatchProps<T> = DiffBaseProps<T> & {
103
+ fileDiff: FileDiffMetadata
104
+ before?: undefined
105
+ after?: undefined
106
+ }
107
+
108
+ export type DiffFileProps<T = {}> = DiffPairProps<T> | DiffPatchProps<T>
109
+
110
+ export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
111
+
112
+ const sharedKeys = [
113
+ "mode",
114
+ "media",
115
+ "class",
116
+ "classList",
117
+ "annotations",
118
+ "selectedLines",
119
+ "commentedLines",
120
+ "search",
121
+ "onLineSelected",
122
+ "onLineSelectionEnd",
123
+ "onLineNumberSelectionEnd",
124
+ "onRendered",
125
+ "preloadedDiff",
126
+ ] as const
127
+
128
+ const textKeys = ["file", ...sharedKeys] as const
129
+ const diffKeys = ["fileDiff", "before", "after", "virtualize", ...sharedKeys] as const
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Shared viewer hook
133
+ // ---------------------------------------------------------------------------
134
+
135
+ type MouseHit = {
136
+ line: number | undefined
137
+ numberColumn: boolean
138
+ side?: DiffSelectionSide
139
+ }
140
+
141
+ type ViewerConfig = {
142
+ enableLineSelection: () => boolean
143
+ selectedLines: () => SelectedLineRange | null | undefined
144
+ commentedLines: () => SelectedLineRange[]
145
+ onLineSelectionEnd: (range: SelectedLineRange | null) => void
146
+
147
+ // mode-specific callbacks
148
+ lineFromMouseEvent: (event: MouseEvent) => MouseHit
149
+ setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
150
+ updateSelection: (preserveTextSelection: boolean) => void
151
+ buildDragSelection: () => SelectedLineRange | undefined
152
+ buildClickSelection: () => SelectedLineRange | undefined
153
+ onDragStart: (hit: MouseHit) => void
154
+ onDragMove: (hit: MouseHit) => void
155
+ onDragReset: () => void
156
+ markCommented: (root: ShadowRoot, ranges: SelectedLineRange[]) => void
157
+ }
158
+
159
+ function useFileViewer(config: ViewerConfig) {
160
+ let wrapper!: HTMLDivElement
161
+ let container!: HTMLDivElement
162
+ let overlay!: HTMLDivElement
163
+ let selectionFrame: number | undefined
164
+ let dragFrame: number | undefined
165
+ let dragStart: number | undefined
166
+ let dragEnd: number | undefined
167
+ let dragMoved = false
168
+ let lastSelection: SelectedLineRange | null = null
169
+ let pendingSelectionEnd = false
170
+
171
+ const ready = createReadyWatcher()
172
+ const bridge = createLineNumberSelectionBridge()
173
+ const [rendered, setRendered] = createSignal(0)
174
+
175
+ const getRoot = () => getViewerRoot(container)
176
+ const getHost = () => getViewerHost(container)
177
+
178
+ const find = createFileFind({
179
+ wrapper: () => wrapper,
180
+ overlay: () => overlay,
181
+ getRoot,
182
+ })
183
+
184
+ // -- selection scheduling --
185
+
186
+ const scheduleSelectionUpdate = () => {
187
+ if (selectionFrame !== undefined) return
188
+ selectionFrame = requestAnimationFrame(() => {
189
+ selectionFrame = undefined
190
+ const finishing = pendingSelectionEnd
191
+ config.updateSelection(finishing)
192
+ if (!pendingSelectionEnd) return
193
+ pendingSelectionEnd = false
194
+ config.onLineSelectionEnd(lastSelection)
195
+ })
196
+ }
197
+
198
+ const scheduleDragUpdate = () => {
199
+ if (dragFrame !== undefined) return
200
+ dragFrame = requestAnimationFrame(() => {
201
+ dragFrame = undefined
202
+ const selected = config.buildDragSelection()
203
+ if (selected) config.setSelectedLines(selected)
204
+ })
205
+ }
206
+
207
+ // -- mouse handlers --
208
+
209
+ const handleMouseDown = (event: MouseEvent) => {
210
+ if (!config.enableLineSelection()) return
211
+ if (event.button !== 0) return
212
+
213
+ const hit = config.lineFromMouseEvent(event)
214
+ if (hit.numberColumn) {
215
+ bridge.begin(true, hit.line)
216
+ return
217
+ }
218
+ if (hit.line === undefined) return
219
+
220
+ bridge.begin(false, hit.line)
221
+ dragStart = hit.line
222
+ dragEnd = hit.line
223
+ dragMoved = false
224
+ config.onDragStart(hit)
225
+ }
226
+
227
+ const handleMouseMove = (event: MouseEvent) => {
228
+ if (!config.enableLineSelection()) return
229
+
230
+ const hit = config.lineFromMouseEvent(event)
231
+ if (bridge.track(event.buttons, hit.line)) return
232
+ if (dragStart === undefined) return
233
+
234
+ if ((event.buttons & 1) === 0) {
235
+ dragStart = undefined
236
+ dragEnd = undefined
237
+ dragMoved = false
238
+ config.onDragReset()
239
+ bridge.finish()
240
+ return
241
+ }
242
+
243
+ if (hit.line === undefined) return
244
+ dragEnd = hit.line
245
+ dragMoved = true
246
+ config.onDragMove(hit)
247
+ scheduleDragUpdate()
248
+ }
249
+
250
+ const handleMouseUp = () => {
251
+ if (!config.enableLineSelection()) return
252
+ if (bridge.finish() === "numbers") return
253
+ if (dragStart === undefined) return
254
+
255
+ if (!dragMoved) {
256
+ pendingSelectionEnd = false
257
+ const selected = config.buildClickSelection()
258
+ if (selected) config.setSelectedLines(selected)
259
+ config.onLineSelectionEnd(lastSelection)
260
+ dragStart = undefined
261
+ dragEnd = undefined
262
+ dragMoved = false
263
+ config.onDragReset()
264
+ return
265
+ }
266
+
267
+ pendingSelectionEnd = true
268
+ scheduleDragUpdate()
269
+ scheduleSelectionUpdate()
270
+
271
+ dragStart = undefined
272
+ dragEnd = undefined
273
+ dragMoved = false
274
+ config.onDragReset()
275
+ }
276
+
277
+ const handleSelectionChange = () => {
278
+ if (!config.enableLineSelection()) return
279
+ if (dragStart === undefined) return
280
+ const selection = window.getSelection()
281
+ if (!selection || selection.isCollapsed) return
282
+ scheduleSelectionUpdate()
283
+ }
284
+
285
+ // -- shared effects --
286
+
287
+ onMount(() => {
288
+ onCleanup(observeViewerScheme(getHost))
289
+ })
290
+
291
+ createEffect(() => {
292
+ rendered()
293
+ const ranges = config.commentedLines()
294
+ requestAnimationFrame(() => {
295
+ const root = getRoot()
296
+ if (!root) return
297
+ config.markCommented(root, ranges)
298
+ })
299
+ })
300
+
301
+ createEffect(() => {
302
+ config.setSelectedLines(config.selectedLines() ?? null)
303
+ })
304
+
305
+ createEffect(() => {
306
+ if (!config.enableLineSelection()) return
307
+
308
+ makeEventListener(container, "mousedown", handleMouseDown)
309
+ makeEventListener(container, "mousemove", handleMouseMove)
310
+ makeEventListener(window, "mouseup", handleMouseUp)
311
+ makeEventListener(document, "selectionchange", handleSelectionChange)
312
+ })
313
+
314
+ onCleanup(() => {
315
+ clearReadyWatcher(ready)
316
+
317
+ if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
318
+ if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
319
+
320
+ selectionFrame = undefined
321
+ dragFrame = undefined
322
+ dragStart = undefined
323
+ dragEnd = undefined
324
+ dragMoved = false
325
+ bridge.reset()
326
+ lastSelection = null
327
+ pendingSelectionEnd = false
328
+ })
329
+
330
+ return {
331
+ get wrapper() {
332
+ return wrapper
333
+ },
334
+ set wrapper(v: HTMLDivElement) {
335
+ wrapper = v
336
+ },
337
+ get container() {
338
+ return container
339
+ },
340
+ set container(v: HTMLDivElement) {
341
+ container = v
342
+ },
343
+ get overlay() {
344
+ return overlay
345
+ },
346
+ set overlay(v: HTMLDivElement) {
347
+ overlay = v
348
+ },
349
+ get dragStart() {
350
+ return dragStart
351
+ },
352
+ get dragEnd() {
353
+ return dragEnd
354
+ },
355
+ get lastSelection() {
356
+ return lastSelection
357
+ },
358
+ set lastSelection(v: SelectedLineRange | null) {
359
+ lastSelection = v
360
+ },
361
+ ready,
362
+ bridge,
363
+ rendered,
364
+ setRendered,
365
+ getRoot,
366
+ getHost,
367
+ find,
368
+ scheduleSelectionUpdate,
369
+ }
370
+ }
371
+
372
+ type Viewer = ReturnType<typeof useFileViewer>
373
+
374
+ type ModeAdapter = Omit<ViewerConfig, "enableLineSelection" | "selectedLines" | "commentedLines" | "onLineSelectionEnd">
375
+
376
+ type ModeConfig = {
377
+ enableLineSelection: () => boolean
378
+ selectedLines: () => SelectedLineRange | null | undefined
379
+ commentedLines: () => SelectedLineRange[] | undefined
380
+ onLineSelectionEnd: (range: SelectedLineRange | null) => void
381
+ }
382
+
383
+ type RenderTarget = {
384
+ cleanUp: () => void
385
+ }
386
+
387
+ type AnnotationTarget<A> = {
388
+ setLineAnnotations: (annotations: A[]) => void
389
+ rerender: () => void
390
+ }
391
+
392
+ type VirtualStrategy = {
393
+ get: () => Virtualizer | undefined
394
+ cleanup: () => void
395
+ }
396
+
397
+ function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
398
+ return useFileViewer({
399
+ enableLineSelection: config.enableLineSelection,
400
+ selectedLines: config.selectedLines,
401
+ commentedLines: () => config.commentedLines() ?? [],
402
+ onLineSelectionEnd: config.onLineSelectionEnd,
403
+ ...adapter,
404
+ })
405
+ }
406
+
407
+ function useSearchHandle(opts: {
408
+ search: () => FileSearchControl | undefined
409
+ find: ReturnType<typeof createFileFind>
410
+ }) {
411
+ createEffect(() => {
412
+ const search = opts.search()
413
+ if (!search) return
414
+
415
+ const handle = {
416
+ focus: () => opts.find.focus(),
417
+ } satisfies FileSearchHandle
418
+
419
+ search.register(handle)
420
+ onCleanup(() => search.register(null))
421
+ })
422
+ }
423
+
424
+ function createLineCallbacks(opts: {
425
+ viewer: Viewer
426
+ normalize?: (range: SelectedLineRange | null) => SelectedLineRange | null | undefined
427
+ onLineSelected?: (range: SelectedLineRange | null) => void
428
+ onLineSelectionEnd?: (range: SelectedLineRange | null) => void
429
+ onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
430
+ }) {
431
+ const select = (range: SelectedLineRange | null) => {
432
+ if (!opts.normalize) return range
433
+ const next = opts.normalize(range)
434
+ if (next !== undefined) return next
435
+ return range
436
+ }
437
+
438
+ return {
439
+ onLineSelected: (range: SelectedLineRange | null) => {
440
+ const next = select(range)
441
+ opts.viewer.lastSelection = next
442
+ opts.onLineSelected?.(next)
443
+ },
444
+ onLineSelectionEnd: (range: SelectedLineRange | null) => {
445
+ const next = select(range)
446
+ opts.viewer.lastSelection = next
447
+ opts.onLineSelectionEnd?.(next)
448
+ if (!opts.viewer.bridge.consume(next)) return
449
+ requestAnimationFrame(() => opts.onLineNumberSelectionEnd?.(next))
450
+ },
451
+ }
452
+ }
453
+
454
+ function useAnnotationRerender<A>(opts: {
455
+ viewer: Viewer
456
+ current: () => AnnotationTarget<A> | undefined
457
+ annotations: () => A[]
458
+ }) {
459
+ const applied = new WeakSet<AnnotationTarget<A>>()
460
+ createEffect(() => {
461
+ opts.viewer.rendered()
462
+ const active = opts.current()
463
+ if (!active) return
464
+ const annotations = opts.annotations()
465
+ // renderViewer always draws with empty annotations, so skip the extra rerender
466
+ // when this instance has nothing applied and nothing to apply.
467
+ if (annotations.length === 0 && !applied.has(active)) return
468
+ if (annotations.length === 0) applied.delete(active)
469
+ else applied.add(active)
470
+ active.setLineAnnotations(annotations)
471
+ active.rerender()
472
+ requestAnimationFrame(() => opts.viewer.find.refresh({ reset: true }))
473
+ })
474
+ }
475
+
476
+ function notifyRendered(opts: {
477
+ viewer: Viewer
478
+ isReady: (root: ShadowRoot) => boolean
479
+ settleFrames?: number
480
+ onReady: () => void
481
+ }) {
482
+ notifyShadowReady({
483
+ state: opts.viewer.ready,
484
+ container: opts.viewer.container,
485
+ getRoot: opts.viewer.getRoot,
486
+ isReady: opts.isReady,
487
+ settleFrames: opts.settleFrames,
488
+ onReady: opts.onReady,
489
+ })
490
+ }
491
+
492
+ function renderViewer<I extends RenderTarget>(opts: {
493
+ viewer: Viewer
494
+ current: I | undefined
495
+ reset?: boolean
496
+ create: () => I
497
+ update?: (value: I) => void
498
+ assign: (value: I) => void
499
+ draw: (value: I) => void
500
+ onReady: () => void
501
+ }) {
502
+ clearReadyWatcher(opts.viewer.ready)
503
+ const reset = opts.reset === true && opts.current !== undefined
504
+ if (reset) opts.current?.cleanUp()
505
+ const next = reset || !opts.current ? opts.create() : opts.current
506
+ if (reset || !opts.current) {
507
+ opts.viewer.container.innerHTML = ""
508
+ opts.assign(next)
509
+ } else {
510
+ opts.update?.(next)
511
+ }
512
+
513
+ opts.draw(next)
514
+
515
+ applyViewerScheme(opts.viewer.getHost())
516
+ opts.viewer.setRendered((value) => value + 1)
517
+ opts.onReady()
518
+ }
519
+
520
+ function preserve(viewer: Viewer) {
521
+ const root = scrollParent(viewer.wrapper)
522
+ if (!root) return () => {}
523
+
524
+ const high = viewer.container.getBoundingClientRect().height
525
+ if (!high) return () => {}
526
+
527
+ const top = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top
528
+ const prev = viewer.container.style.minHeight
529
+ viewer.container.style.minHeight = `${Math.ceil(high)}px`
530
+
531
+ let done = false
532
+ return () => {
533
+ if (done) return
534
+ done = true
535
+ viewer.container.style.minHeight = prev
536
+
537
+ const next = viewer.wrapper.getBoundingClientRect().top - root.getBoundingClientRect().top
538
+ const delta = next - top
539
+ if (delta) root.scrollTop += delta
540
+ }
541
+ }
542
+
543
+ function scrollParent(el: HTMLElement): HTMLElement | undefined {
544
+ let parent = el.parentElement
545
+ while (parent) {
546
+ const style = getComputedStyle(parent)
547
+ if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
548
+ parent = parent.parentElement
549
+ }
550
+ }
551
+
552
+ function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
553
+ let virtualizer: Virtualizer | undefined
554
+ let root: Document | HTMLElement | undefined
555
+
556
+ const release = () => {
557
+ virtualizer?.cleanUp()
558
+ virtualizer = undefined
559
+ root = undefined
560
+ }
561
+
562
+ return {
563
+ get: () => {
564
+ if (!enabled()) {
565
+ release()
566
+ return
567
+ }
568
+ if (typeof document === "undefined") return
569
+
570
+ const wrapper = host()
571
+ if (!wrapper) return
572
+
573
+ const next = scrollParent(wrapper) ?? document
574
+ if (virtualizer && root === next) return virtualizer
575
+
576
+ release()
577
+ virtualizer = new Virtualizer()
578
+ root = next
579
+ virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
580
+ return virtualizer
581
+ },
582
+ cleanup: release,
583
+ }
584
+ }
585
+
586
+ function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
587
+ let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
588
+
589
+ const release = () => {
590
+ shared?.release()
591
+ shared = undefined
592
+ }
593
+
594
+ return {
595
+ get: () => {
596
+ if (!enabled()) {
597
+ release()
598
+ return
599
+ }
600
+ if (shared) return shared.virtualizer
601
+
602
+ const container = host()
603
+ if (!container) return
604
+
605
+ const result = acquireVirtualizer(container)
606
+ if (!result) return
607
+ shared = result
608
+ return result.virtualizer
609
+ },
610
+ cleanup: release,
611
+ }
612
+ }
613
+
614
+ function parseLine(node: HTMLElement) {
615
+ if (!node.dataset.line) return
616
+ const value = parseInt(node.dataset.line, 10)
617
+ if (Number.isNaN(value)) return
618
+ return value
619
+ }
620
+
621
+ function mouseHit(
622
+ event: MouseEvent,
623
+ line: (node: HTMLElement) => number | undefined,
624
+ side?: (node: HTMLElement) => DiffSelectionSide | undefined,
625
+ ): MouseHit {
626
+ const path = event.composedPath()
627
+ let numberColumn = false
628
+ let value: number | undefined
629
+ let branch: DiffSelectionSide | undefined
630
+
631
+ for (const item of path) {
632
+ if (!(item instanceof HTMLElement)) continue
633
+
634
+ numberColumn = numberColumn || item.dataset.columnNumber != null
635
+ if (value === undefined) value = line(item)
636
+ if (branch === undefined && side) branch = side(item)
637
+
638
+ if (numberColumn && value !== undefined && (side == null || branch !== undefined)) break
639
+ }
640
+
641
+ return {
642
+ line: value,
643
+ numberColumn,
644
+ side: branch,
645
+ }
646
+ }
647
+
648
+ function diffMouseSide(node: HTMLElement) {
649
+ const type = node.dataset.lineType
650
+ if (type === "change-deletion") return "deletions" satisfies DiffSelectionSide
651
+ if (type === "change-addition" || type === "change-additions") return "additions" satisfies DiffSelectionSide
652
+ if (node.dataset.code == null) return
653
+ return node.hasAttribute("data-deletions") ? "deletions" : "additions"
654
+ }
655
+
656
+ function diffSelectionSide(node: Node | null) {
657
+ const el = findElement(node)
658
+ if (!el) return
659
+ return findDiffSide(el)
660
+ }
661
+
662
+ // ---------------------------------------------------------------------------
663
+ // Shared JSX shell
664
+ // ---------------------------------------------------------------------------
665
+
666
+ function ViewerShell(props: {
667
+ mode: "text" | "diff"
668
+ viewer: ReturnType<typeof useFileViewer>
669
+ class: string | undefined
670
+ classList: ComponentProps<"div">["classList"] | undefined
671
+ }) {
672
+ return (
673
+ <div
674
+ data-component="file"
675
+ data-mode={props.mode}
676
+ style={styleVariables}
677
+ class="relative outline-none"
678
+ classList={{
679
+ ...props.classList,
680
+ [props.class ?? ""]: !!props.class,
681
+ }}
682
+ ref={(el) => (props.viewer.wrapper = el)}
683
+ tabIndex={0}
684
+ onPointerDown={props.viewer.find.onPointerDown}
685
+ onFocus={props.viewer.find.onFocus}
686
+ >
687
+ <Show when={props.viewer.find.open()}>
688
+ <FileSearchBar
689
+ pos={props.viewer.find.pos}
690
+ query={props.viewer.find.query}
691
+ count={props.viewer.find.count}
692
+ index={props.viewer.find.index}
693
+ setInput={props.viewer.find.setInput}
694
+ onInput={props.viewer.find.setQuery}
695
+ onKeyDown={props.viewer.find.onInputKeyDown}
696
+ onClose={props.viewer.find.close}
697
+ onPrev={() => props.viewer.find.next(-1)}
698
+ onNext={() => props.viewer.find.next(1)}
699
+ />
700
+ </Show>
701
+ <div ref={(el) => (props.viewer.container = el)} />
702
+ <div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
703
+ </div>
704
+ )
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // TextViewer
709
+ // ---------------------------------------------------------------------------
710
+
711
+ function TextViewer<T>(props: TextFileProps<T>) {
712
+ let instance: PierreFile<T> | VirtualizedFile<T> | undefined
713
+ let viewer!: Viewer
714
+
715
+ const [local, others] = splitProps(props, textKeys)
716
+
717
+ const text = () => {
718
+ const value = local.file.contents as unknown
719
+ if (typeof value === "string") return value
720
+ if (Array.isArray(value)) return value.join("\n")
721
+ if (value == null) return ""
722
+ // oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
723
+ return String(value)
724
+ }
725
+
726
+ const lineCount = () => {
727
+ const value = text()
728
+ const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0)
729
+ return Math.max(1, total)
730
+ }
731
+
732
+ const bytes = createMemo(() => {
733
+ const value = local.file.contents as unknown
734
+ if (typeof value === "string") return value.length
735
+ if (Array.isArray(value)) {
736
+ return value.reduce(
737
+ // oxlint-disable-next-line no-base-to-string -- array parts coerced intentionally
738
+ (sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
739
+ 0,
740
+ )
741
+ }
742
+ if (value == null) return 0
743
+ // oxlint-disable-next-line no-base-to-string -- file contents cast to unknown, coercion is intentional
744
+ return String(value).length
745
+ })
746
+
747
+ const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
748
+
749
+ const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
750
+
751
+ const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
752
+
753
+ const applySelection = (range: SelectedLineRange | null) => {
754
+ const current = instance
755
+ if (!current) return false
756
+
757
+ if (virtual()) {
758
+ current.setSelectedLines(range)
759
+ return true
760
+ }
761
+
762
+ const root = viewer.getRoot()
763
+ if (!root) return false
764
+
765
+ const total = lineCount()
766
+ if (root.querySelectorAll("[data-line]").length < total) return false
767
+
768
+ if (!range) {
769
+ current.setSelectedLines(null)
770
+ return true
771
+ }
772
+
773
+ const start = Math.min(range.start, range.end)
774
+ const end = Math.max(range.start, range.end)
775
+ if (start < 1 || end > total) {
776
+ current.setSelectedLines(null)
777
+ return true
778
+ }
779
+
780
+ if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
781
+ current.setSelectedLines(null)
782
+ return true
783
+ }
784
+
785
+ const normalized = (() => {
786
+ if (range.endSide != null) return { start: range.start, end: range.end }
787
+ if (range.side !== "deletions") return range
788
+ if (root.querySelector("[data-deletions]") != null) return range
789
+ return { start: range.start, end: range.end }
790
+ })()
791
+
792
+ current.setSelectedLines(normalized)
793
+ return true
794
+ }
795
+
796
+ const setSelectedLines = (range: SelectedLineRange | null) => {
797
+ viewer.lastSelection = range
798
+ applySelection(range)
799
+ }
800
+
801
+ const adapter: ModeAdapter = {
802
+ lineFromMouseEvent,
803
+ setSelectedLines,
804
+ updateSelection: (preserveTextSelection) => {
805
+ const root = viewer.getRoot()
806
+ if (!root) return
807
+
808
+ const selected = readShadowLineSelection({
809
+ root,
810
+ lineForNode: findFileLineNumber,
811
+ sideForNode: findCodeSelectionSide,
812
+ preserveTextSelection,
813
+ })
814
+ if (!selected) return
815
+
816
+ setSelectedLines(selected.range)
817
+ if (!preserveTextSelection || !selected.text) return
818
+ restoreShadowTextSelection(root, selected.text)
819
+ },
820
+ buildDragSelection: () => {
821
+ if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
822
+ return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
823
+ },
824
+ buildClickSelection: () => {
825
+ if (viewer.dragStart === undefined) return
826
+ return { start: viewer.dragStart, end: viewer.dragStart }
827
+ },
828
+ onDragStart: () => {},
829
+ onDragMove: () => {},
830
+ onDragReset: () => {},
831
+ markCommented: markCommentedFileLines,
832
+ }
833
+
834
+ viewer = useModeViewer(
835
+ {
836
+ enableLineSelection: () => props.enableLineSelection === true,
837
+ selectedLines: () => local.selectedLines,
838
+ commentedLines: () => local.commentedLines,
839
+ onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
840
+ },
841
+ adapter,
842
+ )
843
+
844
+ const lineCallbacks = createLineCallbacks({
845
+ viewer,
846
+ onLineSelected: (range) => local.onLineSelected?.(range),
847
+ onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
848
+ onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
849
+ })
850
+
851
+ const options = createMemo(() => ({
852
+ ...createDefaultOptions<T>("unified"),
853
+ ...others,
854
+ ...lineCallbacks,
855
+ }))
856
+
857
+ const notify = () => {
858
+ notifyRendered({
859
+ viewer,
860
+ isReady: (root) => {
861
+ if (virtual()) return root.querySelector("[data-line]") != null
862
+ return root.querySelectorAll("[data-line]").length >= lineCount()
863
+ },
864
+ onReady: () => {
865
+ applySelection(viewer.lastSelection)
866
+ viewer.find.refresh({ reset: true })
867
+ local.onRendered?.()
868
+ },
869
+ })
870
+ }
871
+
872
+ useSearchHandle({
873
+ search: () => local.search,
874
+ find: viewer.find,
875
+ })
876
+
877
+ // -- render instance --
878
+
879
+ createEffect(() => {
880
+ const opts = options()
881
+ const workerPool = getWorkerPool("unified")
882
+ const virtualizer = virtuals.get()
883
+
884
+ renderViewer({
885
+ viewer,
886
+ current: instance,
887
+ reset: instance !== undefined,
888
+ create: () =>
889
+ virtualizer
890
+ ? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
891
+ : new PierreFile<T>(opts, workerPool),
892
+ assign: (value) => {
893
+ instance = value
894
+ },
895
+ draw: (value) => {
896
+ const contents = text()
897
+ value.render({
898
+ file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents },
899
+ lineAnnotations: [],
900
+ containerWrapper: viewer.container,
901
+ })
902
+ },
903
+ onReady: notify,
904
+ })
905
+ })
906
+
907
+ useAnnotationRerender<LineAnnotation<T>>({
908
+ viewer,
909
+ current: () => instance,
910
+ annotations: () => (local.annotations as LineAnnotation<T>[] | undefined) ?? [],
911
+ })
912
+
913
+ // -- cleanup --
914
+
915
+ onCleanup(() => {
916
+ instance?.cleanUp()
917
+ instance = undefined
918
+ virtuals.cleanup()
919
+ })
920
+
921
+ return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
922
+ }
923
+
924
+ // ---------------------------------------------------------------------------
925
+ // DiffViewer
926
+ // ---------------------------------------------------------------------------
927
+
928
+ function DiffViewer<T>(props: DiffFileProps<T>) {
929
+ let instance: FileDiff<T> | undefined
930
+ let instanceVirtualizer: Virtualizer | undefined
931
+ let instanceWorkerPool: ReturnType<typeof getWorkerPool>
932
+ let instanceVirtualHunkSeparators: FileDiffOptions<T>["hunkSeparators"] | undefined
933
+ let instanceFileDiff: FileDiffMetadata | undefined
934
+ let instanceBefore: FileContents | undefined
935
+ let instanceAfter: FileContents | undefined
936
+ let dragSide: DiffSelectionSide | undefined
937
+ let dragEndSide: DiffSelectionSide | undefined
938
+ let viewer!: Viewer
939
+
940
+ const [local, others] = splitProps(props, diffKeys)
941
+
942
+ const mobile = createMediaQuery("(max-width: 640px)")
943
+
944
+ const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, findDiffLineNumber, diffMouseSide)
945
+
946
+ const setSelectedLines = (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => {
947
+ const active = instance
948
+ if (!active) return
949
+
950
+ const fixed = fixDiffSelection(viewer.getRoot(), range)
951
+ if (fixed === undefined) {
952
+ viewer.lastSelection = range
953
+ return
954
+ }
955
+
956
+ viewer.lastSelection = fixed
957
+ active.setSelectedLines(fixed)
958
+ restoreShadowTextSelection(preserve?.root, preserve?.text)
959
+ }
960
+
961
+ const adapter: ModeAdapter = {
962
+ lineFromMouseEvent,
963
+ setSelectedLines,
964
+ updateSelection: (preserveTextSelection) => {
965
+ const root = viewer.getRoot()
966
+ if (!root) return
967
+
968
+ const selected = readShadowLineSelection({
969
+ root,
970
+ lineForNode: findDiffLineNumber,
971
+ sideForNode: diffSelectionSide,
972
+ preserveTextSelection,
973
+ })
974
+ if (!selected) return
975
+
976
+ if (selected.text) {
977
+ setSelectedLines(selected.range, { root, text: selected.text })
978
+ return
979
+ }
980
+
981
+ setSelectedLines(selected.range)
982
+ },
983
+ buildDragSelection: () => {
984
+ if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
985
+ const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
986
+ if (dragSide) selected.side = dragSide
987
+ if (dragEndSide && dragSide && dragEndSide !== dragSide) selected.endSide = dragEndSide
988
+ return selected
989
+ },
990
+ buildClickSelection: () => {
991
+ if (viewer.dragStart === undefined) return
992
+ const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragStart }
993
+ if (dragSide) selected.side = dragSide
994
+ return selected
995
+ },
996
+ onDragStart: (hit) => {
997
+ dragSide = hit.side
998
+ dragEndSide = hit.side
999
+ },
1000
+ onDragMove: (hit) => {
1001
+ dragEndSide = hit.side
1002
+ },
1003
+ onDragReset: () => {
1004
+ dragSide = undefined
1005
+ dragEndSide = undefined
1006
+ },
1007
+ markCommented: markCommentedDiffLines,
1008
+ }
1009
+
1010
+ viewer = useModeViewer(
1011
+ {
1012
+ enableLineSelection: () => props.enableLineSelection === true,
1013
+ selectedLines: () => local.selectedLines,
1014
+ commentedLines: () => local.commentedLines,
1015
+ onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
1016
+ },
1017
+ adapter,
1018
+ )
1019
+
1020
+ const virtuals = createSharedVirtualStrategy(
1021
+ () => viewer.container,
1022
+ () => local.virtualize !== false,
1023
+ )
1024
+
1025
+ const large = createMemo(() => {
1026
+ if (local.fileDiff) {
1027
+ const before = local.fileDiff.deletionLines.join("")
1028
+ const after = local.fileDiff.additionLines.join("")
1029
+ return Math.max(before.length, after.length) > 500_000
1030
+ }
1031
+
1032
+ const before = typeof local.before?.contents === "string" ? local.before.contents : ""
1033
+ const after = typeof local.after?.contents === "string" ? local.after.contents : ""
1034
+ return Math.max(before.length, after.length) > 500_000
1035
+ })
1036
+
1037
+ const largeOptions = {
1038
+ lineDiffType: "none",
1039
+ maxLineDiffLength: 0,
1040
+ tokenizeMaxLineLength: 1,
1041
+ } satisfies Pick<FileDiffOptions<T>, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
1042
+
1043
+ const lineCallbacks = createLineCallbacks({
1044
+ viewer,
1045
+ normalize: (range) => fixDiffSelection(viewer.getRoot(), range),
1046
+ onLineSelected: (range) => local.onLineSelected?.(range),
1047
+ onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
1048
+ onLineNumberSelectionEnd: (range) => local.onLineNumberSelectionEnd?.(range),
1049
+ })
1050
+
1051
+ const options = createMemo<FileDiffOptions<T>>(() => {
1052
+ const base = {
1053
+ ...createDefaultOptions(props.diffStyle),
1054
+ ...others,
1055
+ ...lineCallbacks,
1056
+ }
1057
+
1058
+ const perf = large() ? { ...base, ...largeOptions } : base
1059
+ if (!mobile()) return perf
1060
+ return { ...perf, disableLineNumbers: true }
1061
+ })
1062
+
1063
+ const notify = (done?: VoidFunction) => {
1064
+ notifyRendered({
1065
+ viewer,
1066
+ isReady: (root) => root.querySelector("[data-line]") != null,
1067
+ settleFrames: 1,
1068
+ onReady: () => {
1069
+ done?.()
1070
+ setSelectedLines(viewer.lastSelection)
1071
+ viewer.find.refresh({ reset: true })
1072
+ local.onRendered?.()
1073
+ },
1074
+ })
1075
+ }
1076
+
1077
+ useSearchHandle({
1078
+ search: () => local.search,
1079
+ find: viewer.find,
1080
+ })
1081
+
1082
+ // -- render instance --
1083
+
1084
+ createEffect(() => {
1085
+ const opts = options()
1086
+ const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
1087
+ const virtualizer = virtuals.get()
1088
+ const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
1089
+ const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
1090
+ const done = preserve(viewer)
1091
+
1092
+ onCleanup(done)
1093
+
1094
+ const cacheKey = (contents: string) => {
1095
+ if (!large()) return sampledChecksum(contents, contents.length)
1096
+ return sampledChecksum(contents)
1097
+ }
1098
+
1099
+ const before = local.before
1100
+ ? { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) }
1101
+ : undefined
1102
+ const after = local.after
1103
+ ? { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) }
1104
+ : undefined
1105
+ const targetChanged =
1106
+ local.fileDiff !== undefined
1107
+ ? instanceFileDiff !== local.fileDiff
1108
+ : instanceFileDiff !== undefined ||
1109
+ before === undefined ||
1110
+ after === undefined ||
1111
+ instanceBefore === undefined ||
1112
+ instanceAfter === undefined ||
1113
+ !areFilesEqual(instanceBefore, before) ||
1114
+ !areFilesEqual(instanceAfter, after)
1115
+ // Pierre beta virtualized instances retain their first diff target and resolve separator metrics at construction.
1116
+ // Plain timeline diffs can retain the instance as content streams; virtualized viewers reset only when that is unsafe.
1117
+ const reset =
1118
+ instance !== undefined &&
1119
+ (instanceVirtualizer !== virtualizer ||
1120
+ instanceWorkerPool !== workerPool ||
1121
+ (virtualizer !== undefined && (instanceVirtualHunkSeparators !== opts.hunkSeparators || targetChanged)))
1122
+ const forceRender = !reset && instance !== undefined && !areOptionsEqual(instance.options, opts)
1123
+
1124
+ renderViewer({
1125
+ viewer,
1126
+ current: instance,
1127
+ reset,
1128
+ create: () =>
1129
+ virtualizer
1130
+ ? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
1131
+ : new FileDiff<T>(opts, workerPool),
1132
+ update: (value) => value.setOptions(opts),
1133
+ assign: (value) => {
1134
+ instance = value
1135
+ instanceVirtualizer = virtualizer
1136
+ instanceWorkerPool = workerPool
1137
+ instanceVirtualHunkSeparators = virtualizer ? opts.hunkSeparators : undefined
1138
+ instanceFileDiff = local.fileDiff
1139
+ instanceBefore = before
1140
+ instanceAfter = after
1141
+ },
1142
+ draw: (value) => {
1143
+ if (local.fileDiff) {
1144
+ value.render({
1145
+ fileDiff: local.fileDiff,
1146
+ forceRender,
1147
+ lineAnnotations: [],
1148
+ containerWrapper: viewer.container,
1149
+ })
1150
+ return
1151
+ }
1152
+
1153
+ if (!before || !after) return
1154
+
1155
+ value.render({
1156
+ oldFile: before,
1157
+ newFile: after,
1158
+ forceRender,
1159
+ lineAnnotations: [],
1160
+ containerWrapper: viewer.container,
1161
+ })
1162
+ },
1163
+ onReady: () => notify(done),
1164
+ })
1165
+ })
1166
+
1167
+ useAnnotationRerender<DiffLineAnnotation<T>>({
1168
+ viewer,
1169
+ current: () => instance,
1170
+ annotations: () => (local.annotations as DiffLineAnnotation<T>[] | undefined) ?? [],
1171
+ })
1172
+
1173
+ // -- cleanup --
1174
+
1175
+ onCleanup(() => {
1176
+ instance?.cleanUp()
1177
+ instance = undefined
1178
+ instanceVirtualizer = undefined
1179
+ instanceWorkerPool = undefined
1180
+ instanceVirtualHunkSeparators = undefined
1181
+ instanceFileDiff = undefined
1182
+ instanceBefore = undefined
1183
+ instanceAfter = undefined
1184
+ virtuals.cleanup()
1185
+ dragSide = undefined
1186
+ dragEndSide = undefined
1187
+ })
1188
+
1189
+ return <ViewerShell mode="diff" viewer={viewer} class={local.class} classList={local.classList} />
1190
+ }
1191
+
1192
+ // ---------------------------------------------------------------------------
1193
+ // Public API
1194
+ // ---------------------------------------------------------------------------
1195
+
1196
+ export function File<T>(props: FileProps<T>) {
1197
+ if (props.mode === "text") {
1198
+ return <FileMedia media={props.media} fallback={() => TextViewer(props)} />
1199
+ }
1200
+
1201
+ return <FileMedia media={props.media} fallback={() => DiffViewer(props)} />
1202
+ }