@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,485 @@
1
+ import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
2
+ import { makeEventListener } from "@solid-primitives/event-listener"
3
+ import { createResizeObserver } from "@solid-primitives/resize-observer"
4
+ import { createStore } from "solid-js/store"
5
+
6
+ export type FindHost = {
7
+ element: () => HTMLElement | undefined
8
+ open: () => void
9
+ close: () => void
10
+ next: (dir: 1 | -1) => void
11
+ isOpen: () => boolean
12
+ }
13
+
14
+ const hosts = new Set<FindHost>()
15
+ let target: FindHost | undefined
16
+ let current: FindHost | undefined
17
+ let installed = false
18
+
19
+ function isEditable(node: unknown): boolean {
20
+ if (!(node instanceof HTMLElement)) return false
21
+ if (node.closest("[data-prevent-autofocus]")) return true
22
+ if (node.isContentEditable) return true
23
+ return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
24
+ }
25
+
26
+ function hostForNode(node: unknown) {
27
+ if (!(node instanceof Node)) return
28
+ for (const host of hosts) {
29
+ const el = host.element()
30
+ if (el && el.isConnected && el.contains(node)) return host
31
+ }
32
+ }
33
+
34
+ function installShortcuts() {
35
+ if (installed) return
36
+ if (typeof window === "undefined") return
37
+ installed = true
38
+
39
+ window.addEventListener(
40
+ "keydown",
41
+ (event) => {
42
+ if (event.defaultPrevented) return
43
+ if (isEditable(event.target)) return
44
+
45
+ const mod = event.metaKey || event.ctrlKey
46
+ if (!mod) return
47
+
48
+ const key = event.key.toLowerCase()
49
+ if (key === "g") {
50
+ const host = current
51
+ if (!host || !host.isOpen()) return
52
+ event.preventDefault()
53
+ event.stopPropagation()
54
+ host.next(event.shiftKey ? -1 : 1)
55
+ return
56
+ }
57
+
58
+ if (key !== "f") return
59
+
60
+ const active = current
61
+ if (active && active.isOpen()) {
62
+ event.preventDefault()
63
+ event.stopPropagation()
64
+ active.open()
65
+ return
66
+ }
67
+
68
+ const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0]
69
+ if (!host) return
70
+
71
+ event.preventDefault()
72
+ event.stopPropagation()
73
+ host.open()
74
+ },
75
+ { capture: true },
76
+ )
77
+ }
78
+
79
+ function clearHighlightFind() {
80
+ const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
81
+ if (!api) return
82
+ api.delete("opencode-find")
83
+ api.delete("opencode-find-current")
84
+ }
85
+
86
+ function supportsHighlights() {
87
+ const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
88
+ return typeof g.Highlight === "function" && g.CSS?.highlights != null
89
+ }
90
+
91
+ function scrollParent(el: HTMLElement): HTMLElement | undefined {
92
+ let parent = el.parentElement
93
+ while (parent) {
94
+ const style = getComputedStyle(parent)
95
+ if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
96
+ parent = parent.parentElement
97
+ }
98
+ }
99
+
100
+ type CreateFileFindOptions = {
101
+ wrapper: () => HTMLElement | undefined
102
+ overlay: () => HTMLDivElement | undefined
103
+ getRoot: () => ShadowRoot | undefined
104
+ }
105
+
106
+ export function createFileFind(opts: CreateFileFindOptions) {
107
+ let input: HTMLInputElement | undefined
108
+ let overlayFrame: number | undefined
109
+ let mode: "highlights" | "overlay" = "overlay"
110
+ let hits: Range[] = []
111
+ const [overlayScroll, setOverlayScroll] = createSignal<HTMLElement[]>([])
112
+
113
+ const [state, setState] = createStore({
114
+ open: false,
115
+ query: "",
116
+ index: 0,
117
+ count: 0,
118
+ pos: { top: 8, right: 8 },
119
+ })
120
+ const open = () => state.open
121
+ const query = () => state.query
122
+ const index = () => state.index
123
+ const count = () => state.count
124
+ const pos = () => state.pos
125
+
126
+ const clearOverlayScroll = () => {
127
+ setOverlayScroll([])
128
+ }
129
+
130
+ const clearOverlay = () => {
131
+ const el = opts.overlay()
132
+ if (!el) return
133
+ if (overlayFrame !== undefined) {
134
+ cancelAnimationFrame(overlayFrame)
135
+ overlayFrame = undefined
136
+ }
137
+ el.innerHTML = ""
138
+ }
139
+
140
+ const renderOverlay = () => {
141
+ if (mode !== "overlay") {
142
+ clearOverlay()
143
+ return
144
+ }
145
+
146
+ const wrapper = opts.wrapper()
147
+ const overlay = opts.overlay()
148
+ if (!wrapper || !overlay) return
149
+
150
+ clearOverlay()
151
+ if (hits.length === 0) return
152
+
153
+ const base = wrapper.getBoundingClientRect()
154
+ const currentIndex = index()
155
+ const frag = document.createDocumentFragment()
156
+
157
+ for (let i = 0; i < hits.length; i++) {
158
+ const range = hits[i]
159
+ const active = i === currentIndex
160
+ for (const rect of Array.from(range.getClientRects())) {
161
+ if (!rect.width || !rect.height) continue
162
+
163
+ const mark = document.createElement("div")
164
+ mark.style.position = "absolute"
165
+ mark.style.left = `${Math.round(rect.left - base.left)}px`
166
+ mark.style.top = `${Math.round(rect.top - base.top)}px`
167
+ mark.style.width = `${Math.round(rect.width)}px`
168
+ mark.style.height = `${Math.round(rect.height)}px`
169
+ mark.style.borderRadius = "2px"
170
+ mark.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
171
+ mark.style.opacity = active ? "0.55" : "0.35"
172
+ if (active) mark.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
173
+ frag.appendChild(mark)
174
+ }
175
+ }
176
+
177
+ overlay.appendChild(frag)
178
+ }
179
+
180
+ function scheduleOverlay() {
181
+ if (mode !== "overlay") return
182
+ if (!open()) return
183
+ if (overlayFrame !== undefined) return
184
+
185
+ overlayFrame = requestAnimationFrame(() => {
186
+ overlayFrame = undefined
187
+ renderOverlay()
188
+ })
189
+ }
190
+
191
+ const syncOverlayScroll = () => {
192
+ if (mode !== "overlay") return
193
+ const root = opts.getRoot()
194
+
195
+ const next = root
196
+ ? Array.from(root.querySelectorAll("[data-code]")).filter(
197
+ (node): node is HTMLElement => node instanceof HTMLElement,
198
+ )
199
+ : []
200
+ const current = overlayScroll()
201
+ if (next.length === current.length && next.every((el, i) => el === current[i])) return
202
+
203
+ clearOverlayScroll()
204
+ setOverlayScroll(next)
205
+ }
206
+
207
+ const clearFind = () => {
208
+ clearHighlightFind()
209
+ clearOverlay()
210
+ clearOverlayScroll()
211
+ hits = []
212
+ setState("count", 0)
213
+ setState("index", 0)
214
+ }
215
+
216
+ const positionBar = () => {
217
+ if (typeof window === "undefined") return
218
+ const wrapper = opts.wrapper()
219
+ if (!wrapper) return
220
+
221
+ const root = scrollParent(wrapper) ?? wrapper
222
+ const rect = root.getBoundingClientRect()
223
+ const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
224
+ const header = Number.isNaN(title) ? 0 : title
225
+
226
+ setState("pos", {
227
+ top: Math.round(rect.top) + header - 4,
228
+ right: Math.round(window.innerWidth - rect.right) + 8,
229
+ })
230
+ }
231
+
232
+ const scan = (root: ShadowRoot, value: string) => {
233
+ const needle = value.toLowerCase()
234
+ const ranges: Range[] = []
235
+ const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
236
+ (node): node is HTMLElement => node instanceof HTMLElement,
237
+ )
238
+
239
+ for (const col of cols) {
240
+ const text = col.textContent
241
+ if (!text) continue
242
+
243
+ const hay = text.toLowerCase()
244
+ let at = hay.indexOf(needle)
245
+ if (at === -1) continue
246
+
247
+ const nodes: Text[] = []
248
+ const ends: number[] = []
249
+ const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
250
+ let node = walker.nextNode()
251
+ let pos = 0
252
+ while (node) {
253
+ if (node instanceof Text) {
254
+ pos += node.data.length
255
+ nodes.push(node)
256
+ ends.push(pos)
257
+ }
258
+ node = walker.nextNode()
259
+ }
260
+ if (nodes.length === 0) continue
261
+
262
+ const locate = (offset: number) => {
263
+ let lo = 0
264
+ let hi = ends.length - 1
265
+ while (lo < hi) {
266
+ const mid = (lo + hi) >> 1
267
+ if (ends[mid] >= offset) hi = mid
268
+ else lo = mid + 1
269
+ }
270
+ const prev = lo === 0 ? 0 : ends[lo - 1]
271
+ return { node: nodes[lo], offset: offset - prev }
272
+ }
273
+
274
+ while (at !== -1) {
275
+ const start = locate(at)
276
+ const end = locate(at + value.length)
277
+ const range = document.createRange()
278
+ range.setStart(start.node, start.offset)
279
+ range.setEnd(end.node, end.offset)
280
+ ranges.push(range)
281
+ at = hay.indexOf(needle, at + value.length)
282
+ }
283
+ }
284
+
285
+ return ranges
286
+ }
287
+
288
+ const scrollToRange = (range: Range) => {
289
+ const start = range.startContainer
290
+ const el = start instanceof Element ? start : start.parentElement
291
+ el?.scrollIntoView({ block: "center", inline: "center" })
292
+ }
293
+
294
+ const setHighlights = (ranges: Range[], currentIndex: number) => {
295
+ const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
296
+ const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
297
+ if (!api || typeof Highlight !== "function") return false
298
+
299
+ api.delete("opencode-find")
300
+ api.delete("opencode-find-current")
301
+
302
+ const active = ranges[currentIndex]
303
+ if (active) api.set("opencode-find-current", new Highlight(active))
304
+
305
+ const rest = ranges.filter((_, i) => i !== currentIndex)
306
+ if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
307
+ return true
308
+ }
309
+
310
+ const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
311
+ if (!open()) return
312
+
313
+ const value = query().trim()
314
+ if (!value) {
315
+ clearFind()
316
+ return
317
+ }
318
+
319
+ const root = opts.getRoot()
320
+ if (!root) return
321
+
322
+ mode = supportsHighlights() ? "highlights" : "overlay"
323
+
324
+ const ranges = scan(root, value)
325
+ const total = ranges.length
326
+ const desired = args?.reset ? 0 : index()
327
+ const currentIndex = total ? Math.min(desired, total - 1) : 0
328
+
329
+ hits = ranges
330
+ setState("count", total)
331
+ setState("index", currentIndex)
332
+
333
+ const active = ranges[currentIndex]
334
+ if (mode === "highlights") {
335
+ clearOverlay()
336
+ clearOverlayScroll()
337
+ if (!setHighlights(ranges, currentIndex)) {
338
+ mode = "overlay"
339
+ clearHighlightFind()
340
+ syncOverlayScroll()
341
+ scheduleOverlay()
342
+ }
343
+ if (args?.scroll && active) scrollToRange(active)
344
+ return
345
+ }
346
+
347
+ clearHighlightFind()
348
+ syncOverlayScroll()
349
+ if (args?.scroll && active) scrollToRange(active)
350
+ scheduleOverlay()
351
+ }
352
+
353
+ const close = () => {
354
+ setState("open", false)
355
+ setState("query", "")
356
+ clearFind()
357
+ if (current === host) current = undefined
358
+ }
359
+
360
+ const focus = () => {
361
+ if (current && current !== host) current.close()
362
+ current = host
363
+ target = host
364
+ if (!open()) setState("open", true)
365
+ requestAnimationFrame(() => {
366
+ apply({ scroll: true })
367
+ input?.focus()
368
+ input?.select()
369
+ })
370
+ }
371
+
372
+ const next = (dir: 1 | -1) => {
373
+ if (!open()) return
374
+ const total = count()
375
+ if (total <= 0) return
376
+
377
+ const currentIndex = (index() + dir + total) % total
378
+ setState("index", currentIndex)
379
+
380
+ const active = hits[currentIndex]
381
+ if (!active) return
382
+
383
+ if (mode === "highlights") {
384
+ if (!setHighlights(hits, currentIndex)) {
385
+ mode = "overlay"
386
+ apply({ reset: true, scroll: true })
387
+ return
388
+ }
389
+ scrollToRange(active)
390
+ return
391
+ }
392
+
393
+ clearHighlightFind()
394
+ syncOverlayScroll()
395
+ scrollToRange(active)
396
+ scheduleOverlay()
397
+ }
398
+
399
+ const host: FindHost = {
400
+ element: opts.wrapper,
401
+ isOpen: () => open(),
402
+ next,
403
+ open: focus,
404
+ close,
405
+ }
406
+
407
+ createEffect(() => {
408
+ for (const el of overlayScroll()) makeEventListener(el, "scroll", scheduleOverlay, { passive: true })
409
+ })
410
+
411
+ onMount(() => {
412
+ mode = supportsHighlights() ? "highlights" : "overlay"
413
+ installShortcuts()
414
+ hosts.add(host)
415
+ if (!target) target = host
416
+
417
+ onCleanup(() => {
418
+ hosts.delete(host)
419
+ if (current === host) {
420
+ current = undefined
421
+ clearHighlightFind()
422
+ }
423
+ if (target === host) target = undefined
424
+ })
425
+ })
426
+
427
+ createEffect(() => {
428
+ if (!open()) return
429
+
430
+ const update = () => positionBar()
431
+ requestAnimationFrame(update)
432
+ makeEventListener(window, "resize", update, { passive: true })
433
+
434
+ const wrapper = opts.wrapper()
435
+ if (!wrapper) return
436
+ const root = scrollParent(wrapper) ?? wrapper
437
+ createResizeObserver(root, update)
438
+ })
439
+
440
+ onCleanup(() => {
441
+ clearOverlayScroll()
442
+ clearOverlay()
443
+ if (current === host) {
444
+ current = undefined
445
+ clearHighlightFind()
446
+ }
447
+ })
448
+
449
+ return {
450
+ open,
451
+ query,
452
+ count,
453
+ index,
454
+ pos,
455
+ setInput: (el: HTMLInputElement) => {
456
+ input = el
457
+ },
458
+ setQuery: (value: string) => {
459
+ setState("query", value)
460
+ setState("index", 0)
461
+ apply({ reset: true, scroll: true })
462
+ },
463
+ focus,
464
+ close,
465
+ next,
466
+ refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
467
+ onPointerDown: () => {
468
+ target = host
469
+ opts.wrapper()?.focus({ preventScroll: true })
470
+ },
471
+ onFocus: () => {
472
+ target = host
473
+ },
474
+ onInputKeyDown: (event: KeyboardEvent) => {
475
+ if (event.key === "Escape") {
476
+ event.preventDefault()
477
+ close()
478
+ return
479
+ }
480
+ if (event.key !== "Enter") return
481
+ event.preventDefault()
482
+ next(event.shiftKey ? -1 : 1)
483
+ },
484
+ }
485
+ }
@@ -0,0 +1,114 @@
1
+ type ReadyWatcher = {
2
+ observer?: MutationObserver
3
+ token: number
4
+ }
5
+
6
+ export function createReadyWatcher(): ReadyWatcher {
7
+ return { token: 0 }
8
+ }
9
+
10
+ export function clearReadyWatcher(state: ReadyWatcher) {
11
+ state.observer?.disconnect()
12
+ state.observer = undefined
13
+ }
14
+
15
+ export function getViewerHost(container: HTMLElement | undefined) {
16
+ if (!container) return
17
+ const host = container.querySelector("diffs-container")
18
+ if (!(host instanceof HTMLElement)) return
19
+ return host
20
+ }
21
+
22
+ export function getViewerRoot(container: HTMLElement | undefined) {
23
+ return getViewerHost(container)?.shadowRoot ?? undefined
24
+ }
25
+
26
+ export function applyViewerScheme(host: HTMLElement | undefined) {
27
+ if (!host) return
28
+ if (typeof document === "undefined") return
29
+
30
+ const scheme = document.documentElement.dataset.colorScheme
31
+ if (scheme === "dark" || scheme === "light") {
32
+ host.dataset.colorScheme = scheme
33
+ return
34
+ }
35
+
36
+ host.removeAttribute("data-color-scheme")
37
+ }
38
+
39
+ export function observeViewerScheme(getHost: () => HTMLElement | undefined) {
40
+ if (typeof document === "undefined") return () => {}
41
+
42
+ applyViewerScheme(getHost())
43
+ if (typeof MutationObserver === "undefined") return () => {}
44
+
45
+ const root = document.documentElement
46
+ const monitor = new MutationObserver(() => applyViewerScheme(getHost()))
47
+ monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
48
+ return () => monitor.disconnect()
49
+ }
50
+
51
+ export function notifyShadowReady(opts: {
52
+ state: ReadyWatcher
53
+ container: HTMLElement
54
+ getRoot: () => ShadowRoot | undefined
55
+ isReady: (root: ShadowRoot) => boolean
56
+ onReady: () => void
57
+ settleFrames?: number
58
+ }) {
59
+ clearReadyWatcher(opts.state)
60
+ opts.state.token += 1
61
+
62
+ const token = opts.state.token
63
+ const settle = Math.max(0, opts.settleFrames ?? 0)
64
+
65
+ const runReady = () => {
66
+ const step = (left: number) => {
67
+ if (token !== opts.state.token) return
68
+ if (left <= 0) {
69
+ opts.onReady()
70
+ return
71
+ }
72
+ requestAnimationFrame(() => step(left - 1))
73
+ }
74
+
75
+ requestAnimationFrame(() => step(settle))
76
+ }
77
+
78
+ const observeRoot = (root: ShadowRoot) => {
79
+ if (opts.isReady(root)) {
80
+ runReady()
81
+ return
82
+ }
83
+
84
+ if (typeof MutationObserver === "undefined") return
85
+
86
+ clearReadyWatcher(opts.state)
87
+ opts.state.observer = new MutationObserver(() => {
88
+ if (token !== opts.state.token) return
89
+ if (!opts.isReady(root)) return
90
+
91
+ clearReadyWatcher(opts.state)
92
+ runReady()
93
+ })
94
+ opts.state.observer.observe(root, { childList: true, subtree: true })
95
+ }
96
+
97
+ const root = opts.getRoot()
98
+ if (!root) {
99
+ if (typeof MutationObserver === "undefined") return
100
+
101
+ opts.state.observer = new MutationObserver(() => {
102
+ if (token !== opts.state.token) return
103
+
104
+ const next = opts.getRoot()
105
+ if (!next) return
106
+
107
+ observeRoot(next)
108
+ })
109
+ opts.state.observer.observe(opts.container, { childList: true, subtree: true })
110
+ return
111
+ }
112
+
113
+ observeRoot(root)
114
+ }
@@ -0,0 +1,85 @@
1
+ import { type SelectedLineRange } from "@pierre/diffs"
2
+ import { toRange } from "./selection-bridge"
3
+
4
+ export function findElement(node: Node | null): HTMLElement | undefined {
5
+ if (!node) return
6
+ if (node instanceof HTMLElement) return node
7
+ return node.parentElement ?? undefined
8
+ }
9
+
10
+ export function findFileLineNumber(node: Node | null): number | undefined {
11
+ const el = findElement(node)
12
+ if (!el) return
13
+
14
+ const line = el.closest("[data-line]")
15
+ if (!(line instanceof HTMLElement)) return
16
+
17
+ const value = parseInt(line.dataset.line ?? "", 10)
18
+ if (Number.isNaN(value)) return
19
+ return value
20
+ }
21
+
22
+ export function findDiffLineNumber(node: Node | null): number | undefined {
23
+ const el = findElement(node)
24
+ if (!el) return
25
+
26
+ const line = el.closest("[data-line], [data-alt-line]")
27
+ if (!(line instanceof HTMLElement)) return
28
+
29
+ const primary = parseInt(line.dataset.line ?? "", 10)
30
+ if (!Number.isNaN(primary)) return primary
31
+
32
+ const alt = parseInt(line.dataset.altLine ?? "", 10)
33
+ if (!Number.isNaN(alt)) return alt
34
+ }
35
+
36
+ export function findCodeSelectionSide(node: Node | null): SelectedLineRange["side"] {
37
+ const el = findElement(node)
38
+ if (!el) return
39
+
40
+ const code = el.closest("[data-code]")
41
+ if (!(code instanceof HTMLElement)) return
42
+ if (code.hasAttribute("data-deletions")) return "deletions"
43
+ return "additions"
44
+ }
45
+
46
+ export function readShadowLineSelection(opts: {
47
+ root: ShadowRoot
48
+ lineForNode: (node: Node | null) => number | undefined
49
+ sideForNode?: (node: Node | null) => SelectedLineRange["side"]
50
+ preserveTextSelection?: boolean
51
+ }) {
52
+ const selection =
53
+ (opts.root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
54
+ if (!selection || selection.isCollapsed) return
55
+
56
+ const domRange =
57
+ (
58
+ selection as unknown as {
59
+ getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => StaticRange[]
60
+ }
61
+ ).getComposedRanges?.({ shadowRoots: [opts.root] })?.[0] ??
62
+ (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
63
+
64
+ const startNode = domRange?.startContainer ?? selection.anchorNode
65
+ const endNode = domRange?.endContainer ?? selection.focusNode
66
+ if (!startNode || !endNode) return
67
+ if (!opts.root.contains(startNode) || !opts.root.contains(endNode)) return
68
+
69
+ const start = opts.lineForNode(startNode)
70
+ const end = opts.lineForNode(endNode)
71
+ if (start === undefined || end === undefined) return
72
+
73
+ const startSide = opts.sideForNode?.(startNode)
74
+ const endSide = opts.sideForNode?.(endNode)
75
+ const side = startSide ?? endSide
76
+
77
+ const range: SelectedLineRange = { start, end }
78
+ if (side) range.side = side
79
+ if (endSide && side && endSide !== side) range.endSide = endSide
80
+
81
+ return {
82
+ range,
83
+ text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
84
+ }
85
+ }