@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,291 @@
1
+ import type { FileContent } from "@neurocode-ai/sdk/v2"
2
+ import { createEffect, createMemo, Match, on, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
3
+ import { createStore } from "solid-js/store"
4
+ import { useI18n } from "@neurocode-ai/ui/context/i18n"
5
+ import {
6
+ dataUrlFromMediaValue,
7
+ hasMediaValue,
8
+ isBinaryContent,
9
+ mediaKindFromPath,
10
+ normalizeMimeType,
11
+ svgTextFromValue,
12
+ } from "../pierre/media"
13
+
14
+ export type FileMediaOptions = {
15
+ mode?: "auto" | "off"
16
+ path?: string
17
+ current?: unknown
18
+ before?: unknown
19
+ after?: unknown
20
+ deleted?: boolean
21
+ readFile?: (path: string) => Promise<FileContent | undefined>
22
+ onLoad?: () => void
23
+ onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
24
+ }
25
+
26
+ function mediaValue(cfg: FileMediaOptions, mode: "image" | "audio") {
27
+ if (cfg.current !== undefined) return cfg.current
28
+ if (mode === "image") return cfg.after ?? cfg.before
29
+ return cfg.after ?? cfg.before
30
+ }
31
+
32
+ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX.Element }) {
33
+ const i18n = useI18n()
34
+ const [remote, setRemote] = createStore<{
35
+ key?: string
36
+ loading?: boolean
37
+ error?: boolean
38
+ src?: string
39
+ mime?: string
40
+ }>({})
41
+ const cfg = () => props.media
42
+ const kind = createMemo(() => {
43
+ const media = cfg()
44
+ if (!media || media.mode === "off") return
45
+ return mediaKindFromPath(media.path)
46
+ })
47
+
48
+ const isBinary = createMemo(() => {
49
+ const media = cfg()
50
+ if (!media || media.mode === "off") return false
51
+ if (kind()) return false
52
+ return isBinaryContent(media.current as any)
53
+ })
54
+
55
+ const onLoad = () => props.media?.onLoad?.()
56
+
57
+ const deleted = createMemo(() => {
58
+ const media = cfg()
59
+ const k = kind()
60
+ if (!media || !k) return false
61
+ if (media.deleted) return true
62
+ if (k === "svg") return false
63
+ if (media.current !== undefined) return false
64
+ return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)
65
+ })
66
+
67
+ const direct = createMemo(() => {
68
+ const media = cfg()
69
+ const k = kind()
70
+ if (!media || (k !== "image" && k !== "audio")) return
71
+ return dataUrlFromMediaValue(mediaValue(media, k), k)
72
+ })
73
+
74
+ const request = createMemo(() => {
75
+ const media = cfg()
76
+ const k = kind()
77
+ if (!media || (k !== "image" && k !== "audio")) return
78
+ if (media.current !== undefined) return
79
+ if (deleted()) return
80
+ if (direct()) return
81
+ if (!media.path || !media.readFile) return
82
+
83
+ return {
84
+ key: `${k}:${media.path}`,
85
+ kind: k,
86
+ path: media.path,
87
+ readFile: media.readFile,
88
+ onError: media.onError,
89
+ }
90
+ })
91
+
92
+ createEffect(() => {
93
+ const input = request()
94
+ if (!input) {
95
+ setRemote({ key: undefined, loading: false, error: false, src: undefined, mime: undefined })
96
+ return
97
+ }
98
+
99
+ let active = true
100
+ // Keep the previous media visible while re-reading the same file (e.g. a vcs
101
+ // diff refresh); only a key change resets to the loading placeholder.
102
+ if (untrack(() => remote.key) === input.key) setRemote({ loading: true, error: false })
103
+ else setRemote({ key: input.key, loading: true, error: false, src: undefined, mime: undefined })
104
+ void input.readFile(input.path).then(
105
+ (result) => {
106
+ if (!active) return
107
+ const src = dataUrlFromMediaValue(result as any, input.kind)
108
+ if (!src) {
109
+ input.onError?.({ kind: input.kind })
110
+ setRemote({ key: input.key, loading: false, error: true, src: undefined, mime: undefined })
111
+ return
112
+ }
113
+
114
+ setRemote({
115
+ key: input.key,
116
+ loading: false,
117
+ error: false,
118
+ src,
119
+ mime: input.kind === "audio" ? normalizeMimeType(result?.mimeType) : undefined,
120
+ })
121
+ },
122
+ () => {
123
+ if (!active) return
124
+ input.onError?.({ kind: input.kind })
125
+ setRemote({ key: input.key, loading: false, error: true, src: undefined, mime: undefined })
126
+ },
127
+ )
128
+
129
+ onCleanup(() => {
130
+ active = false
131
+ })
132
+ })
133
+
134
+ const src = createMemo(() => {
135
+ const input = request()
136
+ if (!input || remote.key !== input.key || remote.error) return direct()
137
+ return direct() ?? remote.src
138
+ })
139
+ const status = createMemo(() => {
140
+ if (direct()) return "ready" as const
141
+ const input = request()
142
+ if (!input) return "idle" as const
143
+ if (remote.key !== input.key || remote.loading) return "loading" as const
144
+ if (remote.error) return "error" as const
145
+ if (src()) return "ready" as const
146
+ return "idle" as const
147
+ })
148
+ const audioMime = createMemo(() => {
149
+ const input = request()
150
+ if (!input || remote.key !== input.key) return
151
+ return remote.mime
152
+ })
153
+
154
+ const svgSource = createMemo(() => {
155
+ const media = cfg()
156
+ if (!media || kind() !== "svg") return
157
+ return svgTextFromValue(media.current as any)
158
+ })
159
+ const svgSrc = createMemo(() => {
160
+ const media = cfg()
161
+ if (!media || kind() !== "svg") return
162
+ return dataUrlFromMediaValue(media.current as any, "svg")
163
+ })
164
+ const svgInvalid = createMemo(() => {
165
+ const media = cfg()
166
+ if (!media || kind() !== "svg") return
167
+ if (svgSource() !== undefined) return
168
+ if (!hasMediaValue(media.current as any)) return
169
+ return [media.path, media.current] as const
170
+ })
171
+
172
+ createEffect(
173
+ on(
174
+ svgInvalid,
175
+ (value) => {
176
+ if (!value) return
177
+ cfg()?.onError?.({ kind: "svg" })
178
+ },
179
+ { defer: true },
180
+ ),
181
+ )
182
+
183
+ const kindLabel = (value: "image" | "audio") =>
184
+ i18n.t(value === "image" ? "ui.fileMedia.kind.image" : "ui.fileMedia.kind.audio")
185
+
186
+ return (
187
+ <Switch>
188
+ <Match when={kind() === "image" || kind() === "audio"}>
189
+ <Show
190
+ when={src()}
191
+ fallback={(() => {
192
+ const media = cfg()
193
+ const k = kind()
194
+ if (!media || (k !== "image" && k !== "audio")) return props.fallback()
195
+ const label = kindLabel(k)
196
+
197
+ if (deleted()) {
198
+ return (
199
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
200
+ {i18n.t("ui.fileMedia.state.removed", { kind: label })}
201
+ </div>
202
+ )
203
+ }
204
+ if (status() === "loading") {
205
+ return (
206
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
207
+ {i18n.t("ui.fileMedia.state.loading", { kind: label })}
208
+ </div>
209
+ )
210
+ }
211
+ if (status() === "error") {
212
+ return (
213
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
214
+ {i18n.t("ui.fileMedia.state.error", { kind: label })}
215
+ </div>
216
+ )
217
+ }
218
+ return (
219
+ <div class="flex min-h-40 items-center justify-center px-6 py-4 text-center text-text-weak">
220
+ {i18n.t("ui.fileMedia.state.unavailable", { kind: label })}
221
+ </div>
222
+ )
223
+ })()}
224
+ >
225
+ {(value) => {
226
+ const k = kind()
227
+ if (k !== "image" && k !== "audio") return props.fallback()
228
+ if (k === "image") {
229
+ return (
230
+ <div class="flex justify-center bg-background-stronger px-6 py-4">
231
+ <img
232
+ src={value()}
233
+ alt={cfg()?.path}
234
+ class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
235
+ onLoad={onLoad}
236
+ />
237
+ </div>
238
+ )
239
+ }
240
+
241
+ return (
242
+ <div class="flex justify-center bg-background-stronger px-6 py-4">
243
+ <audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
244
+ <source src={value()} type={audioMime()} />
245
+ </audio>
246
+ </div>
247
+ )
248
+ }}
249
+ </Show>
250
+ </Match>
251
+ <Match when={kind() === "svg"}>
252
+ {(() => {
253
+ if (svgSource() === undefined && svgSrc() == null) return props.fallback()
254
+
255
+ return (
256
+ <div class="flex flex-col gap-4 px-6 py-4">
257
+ <Show when={svgSource() !== undefined}>{props.fallback()}</Show>
258
+ <Show when={svgSrc()}>
259
+ {(value) => (
260
+ <div class="flex justify-center">
261
+ <img
262
+ src={value()}
263
+ alt={cfg()?.path}
264
+ class="max-h-[60vh] max-w-full rounded border border-border-weak-base bg-background-base object-contain"
265
+ onLoad={onLoad}
266
+ />
267
+ </div>
268
+ )}
269
+ </Show>
270
+ </div>
271
+ )
272
+ })()}
273
+ </Match>
274
+ <Match when={isBinary()}>
275
+ <div class="flex min-h-56 flex-col items-center justify-center gap-2 px-6 py-10 text-center">
276
+ <div class="text-14-semibold text-text-strong">
277
+ {cfg()?.path?.split("/").pop() ?? i18n.t("ui.fileMedia.binary.title")}
278
+ </div>
279
+ <div class="text-14-regular text-text-weak">
280
+ {(() => {
281
+ const path = cfg()?.path
282
+ if (!path) return i18n.t("ui.fileMedia.binary.description.default")
283
+ return i18n.t("ui.fileMedia.binary.description.path", { path })
284
+ })()}
285
+ </div>
286
+ </div>
287
+ </Match>
288
+ <Match when={true}>{props.fallback()}</Match>
289
+ </Switch>
290
+ )
291
+ }
@@ -0,0 +1,72 @@
1
+ import { Portal } from "solid-js/web"
2
+ import { useI18n } from "@neurocode-ai/ui/context/i18n"
3
+ import { Icon } from "@neurocode-ai/ui/icon"
4
+
5
+ export function FileSearchBar(props: {
6
+ pos: () => { top: number; right: number }
7
+ query: () => string
8
+ index: () => number
9
+ count: () => number
10
+ setInput: (el: HTMLInputElement) => void
11
+ onInput: (value: string) => void
12
+ onKeyDown: (event: KeyboardEvent) => void
13
+ onClose: () => void
14
+ onPrev: () => void
15
+ onNext: () => void
16
+ }) {
17
+ const i18n = useI18n()
18
+
19
+ return (
20
+ <Portal>
21
+ <div
22
+ class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
23
+ style={{
24
+ top: `${props.pos().top}px`,
25
+ right: `${props.pos().right}px`,
26
+ }}
27
+ onPointerDown={(e) => e.stopPropagation()}
28
+ >
29
+ <Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
30
+ <input
31
+ ref={props.setInput}
32
+ placeholder={i18n.t("ui.fileSearch.placeholder")}
33
+ value={props.query()}
34
+ class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
35
+ onInput={(e) => props.onInput(e.currentTarget.value)}
36
+ onKeyDown={(e) => props.onKeyDown(e as KeyboardEvent)}
37
+ />
38
+ <div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
39
+ {props.count() ? `${props.index() + 1}/${props.count()}` : "0/0"}
40
+ </div>
41
+ <div class="flex items-center">
42
+ <button
43
+ type="button"
44
+ class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
45
+ disabled={props.count() === 0}
46
+ aria-label={i18n.t("ui.fileSearch.previousMatch")}
47
+ onClick={props.onPrev}
48
+ >
49
+ <Icon name="chevron-down" size="small" class="rotate-180" />
50
+ </button>
51
+ <button
52
+ type="button"
53
+ class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
54
+ disabled={props.count() === 0}
55
+ aria-label={i18n.t("ui.fileSearch.nextMatch")}
56
+ onClick={props.onNext}
57
+ >
58
+ <Icon name="chevron-down" size="small" />
59
+ </button>
60
+ </div>
61
+ <button
62
+ type="button"
63
+ class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
64
+ aria-label={i18n.t("ui.fileSearch.close")}
65
+ onClick={props.onClose}
66
+ >
67
+ <Icon name="close-small" size="small" />
68
+ </button>
69
+ </div>
70
+ </Portal>
71
+ )
72
+ }
@@ -0,0 +1,197 @@
1
+ import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
2
+ import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
3
+ import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
4
+ import { Dynamic, isServer } from "solid-js/web"
5
+ import { useWorkerPool } from "@neurocode-ai/ui/context/worker-pool"
6
+ import { createDefaultOptions, styleVariables } from "../pierre"
7
+ import { markCommentedDiffLines } from "../pierre/commented-lines"
8
+ import { fixDiffSelection } from "../pierre/diff-selection"
9
+ import {
10
+ applyViewerScheme,
11
+ clearReadyWatcher,
12
+ createReadyWatcher,
13
+ notifyShadowReady,
14
+ observeViewerScheme,
15
+ } from "../pierre/file-runtime"
16
+ import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
17
+ import { File, type DiffFileProps, type FileProps } from "./file"
18
+
19
+ type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
20
+
21
+ type SSRDiffFileProps<T> = DiffFileProps<T> & {
22
+ preloadedDiff: DiffPreload<T>
23
+ }
24
+
25
+ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
26
+ let container!: HTMLDivElement
27
+ let fileDiffRef!: HTMLElement
28
+ let fileDiffInstance: FileDiff<T> | undefined
29
+ let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
30
+
31
+ const ready = createReadyWatcher()
32
+ const workerPool = useWorkerPool(props.diffStyle)
33
+
34
+ const [local, others] = splitProps(props, [
35
+ "mode",
36
+ "media",
37
+ "fileDiff",
38
+ "before",
39
+ "after",
40
+ "class",
41
+ "classList",
42
+ "annotations",
43
+ "selectedLines",
44
+ "commentedLines",
45
+ "onLineSelected",
46
+ "onLineSelectionEnd",
47
+ "onLineNumberSelectionEnd",
48
+ "onRendered",
49
+ "preloadedDiff",
50
+ ])
51
+
52
+ const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
53
+
54
+ const getVirtualizer = () => {
55
+ if (sharedVirtualizer) return sharedVirtualizer.virtualizer
56
+ const result = acquireVirtualizer(container)
57
+ if (!result) return
58
+ sharedVirtualizer = result
59
+ return result.virtualizer
60
+ }
61
+
62
+ const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
63
+ const diff = fileDiffInstance
64
+ if (!diff) return
65
+
66
+ const fixed = fixDiffSelection(getRoot(), range ?? null)
67
+ if (fixed === undefined) {
68
+ if (attempt >= 120) return
69
+ requestAnimationFrame(() => setSelectedLines(range ?? null, attempt + 1))
70
+ return
71
+ }
72
+
73
+ diff.setSelectedLines(fixed)
74
+ }
75
+
76
+ const notifyRendered = () => {
77
+ notifyShadowReady({
78
+ state: ready,
79
+ container,
80
+ getRoot,
81
+ isReady: (root) => root.querySelector("[data-line]") != null,
82
+ settleFrames: 1,
83
+ onReady: () => {
84
+ setSelectedLines(local.selectedLines ?? null)
85
+ local.onRendered?.()
86
+ },
87
+ })
88
+ }
89
+
90
+ onMount(() => {
91
+ if (isServer) return
92
+
93
+ onCleanup(observeViewerScheme(() => fileDiffRef))
94
+
95
+ const virtualizer = getVirtualizer()
96
+ const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
97
+ fileDiffInstance = virtualizer
98
+ ? new VirtualizedFileDiff<T>(
99
+ {
100
+ ...createDefaultOptions(props.diffStyle),
101
+ ...others,
102
+ ...local.preloadedDiff.options,
103
+ },
104
+ virtualizer,
105
+ virtualMetrics,
106
+ workerPool,
107
+ )
108
+ : new FileDiff<T>(
109
+ {
110
+ ...createDefaultOptions(props.diffStyle),
111
+ ...others,
112
+ ...local.preloadedDiff.options,
113
+ },
114
+ workerPool,
115
+ )
116
+
117
+ applyViewerScheme(fileDiffRef)
118
+
119
+ // @ts-expect-error private field required for hydration
120
+ fileDiffInstance.fileContainer = fileDiffRef
121
+ fileDiffInstance.hydrate(
122
+ local.fileDiff
123
+ ? {
124
+ fileDiff: local.fileDiff,
125
+ lineAnnotations: annotations,
126
+ fileContainer: fileDiffRef,
127
+ containerWrapper: container,
128
+ prerenderedHTML: local.preloadedDiff.prerenderedHTML,
129
+ }
130
+ : {
131
+ oldFile: local.before
132
+ ? { ...local.before, contents: typeof local.before.contents === "string" ? local.before.contents : "" }
133
+ : local.before,
134
+ newFile: local.after
135
+ ? { ...local.after, contents: typeof local.after.contents === "string" ? local.after.contents : "" }
136
+ : local.after,
137
+ lineAnnotations: annotations,
138
+ fileContainer: fileDiffRef,
139
+ containerWrapper: container,
140
+ prerenderedHTML: local.preloadedDiff.prerenderedHTML,
141
+ },
142
+ )
143
+
144
+ notifyRendered()
145
+ })
146
+
147
+ createEffect(() => {
148
+ const diff = fileDiffInstance
149
+ if (!diff) return
150
+ diff.setLineAnnotations(local.annotations ?? [])
151
+ diff.rerender()
152
+ })
153
+
154
+ createEffect(() => {
155
+ setSelectedLines(local.selectedLines ?? null)
156
+ })
157
+
158
+ createEffect(() => {
159
+ const ranges = local.commentedLines ?? []
160
+ requestAnimationFrame(() => {
161
+ const root = getRoot()
162
+ if (!root) return
163
+ markCommentedDiffLines(root, ranges)
164
+ })
165
+ })
166
+
167
+ onCleanup(() => {
168
+ clearReadyWatcher(ready)
169
+ fileDiffInstance?.cleanUp()
170
+ sharedVirtualizer?.release()
171
+ sharedVirtualizer = undefined
172
+ })
173
+
174
+ return (
175
+ <div
176
+ data-component="file"
177
+ data-mode="diff"
178
+ style={styleVariables}
179
+ class={local.class}
180
+ classList={local.classList}
181
+ ref={container}
182
+ >
183
+ <Dynamic component={DIFFS_TAG_NAME} ref={fileDiffRef} id="ssr-diff">
184
+ <Show when={isServer}>
185
+ <template shadowrootmode="open" innerHTML={local.preloadedDiff.prerenderedHTML} />
186
+ </Show>
187
+ </Dynamic>
188
+ </div>
189
+ )
190
+ }
191
+
192
+ export type FileSSRProps<T = {}> = FileProps<T>
193
+
194
+ export function FileSSR<T>(props: FileSSRProps<T>) {
195
+ if (props.mode !== "diff" || !props.preloadedDiff) return File(props)
196
+ return DiffSSRViewer(props as SSRDiffFileProps<T>)
197
+ }
@@ -0,0 +1,46 @@
1
+ [data-component="file"] {
2
+ content-visibility: auto;
3
+ }
4
+
5
+ [data-timeline-row] [data-component="file"] {
6
+ content-visibility: visible;
7
+ }
8
+
9
+ [data-component="file"][data-mode="text"] {
10
+ overflow: hidden;
11
+ }
12
+
13
+ [data-component="file"][data-mode="diff"] {
14
+ [data-slot="diff-hunk-separator-line-number"] {
15
+ position: sticky;
16
+ left: 0;
17
+ background-color: var(--surface-diff-hidden-strong);
18
+ z-index: 2;
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+
23
+ [data-slot="diff-hunk-separator-line-number-icon"] {
24
+ aspect-ratio: 1;
25
+ width: 24px;
26
+ height: 24px;
27
+ color: var(--icon-strong-base);
28
+ }
29
+ }
30
+
31
+ [data-slot="diff-hunk-separator-content"] {
32
+ position: sticky;
33
+ background-color: var(--surface-diff-hidden-base);
34
+ color: var(--text-base);
35
+ width: var(--diffs-column-content-width);
36
+ left: var(--diffs-column-number-width);
37
+ padding-left: 8px;
38
+ user-select: none;
39
+ cursor: default;
40
+ text-align: left;
41
+
42
+ [data-slot="diff-hunk-separator-content-span"] {
43
+ mix-blend-mode: var(--text-mix-blend-mode);
44
+ }
45
+ }
46
+ }