@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.
- package/package.json +71 -0
- package/src/components/apply-patch-file.test.ts +43 -0
- package/src/components/apply-patch-file.ts +78 -0
- package/src/components/basic-tool.css +358 -0
- package/src/components/basic-tool.stories.tsx +133 -0
- package/src/components/basic-tool.tsx +343 -0
- package/src/components/dock-prompt.stories.tsx +62 -0
- package/src/components/dock-prompt.tsx +23 -0
- package/src/components/file-media.tsx +291 -0
- package/src/components/file-search.tsx +72 -0
- package/src/components/file-ssr.tsx +197 -0
- package/src/components/file.css +46 -0
- package/src/components/file.tsx +1202 -0
- package/src/components/line-comment-annotations.tsx +637 -0
- package/src/components/line-comment-styles.ts +292 -0
- package/src/components/line-comment.stories.tsx +115 -0
- package/src/components/line-comment.tsx +439 -0
- package/src/components/markdown-cache.tsx +78 -0
- package/src/components/markdown-code-state.test.ts +32 -0
- package/src/components/markdown-code-state.ts +22 -0
- package/src/components/markdown-inline-code-kind.test.ts +46 -0
- package/src/components/markdown-inline-code-kind.ts +1915 -0
- package/src/components/markdown-preload.test.ts +18 -0
- package/src/components/markdown-shiki.worker.ts +104 -0
- package/src/components/markdown-stream.test.ts +194 -0
- package/src/components/markdown-stream.ts +110 -0
- package/src/components/markdown-worker-protocol.test.ts +81 -0
- package/src/components/markdown-worker-protocol.ts +48 -0
- package/src/components/markdown-worker-queue.test.ts +49 -0
- package/src/components/markdown-worker-queue.ts +64 -0
- package/src/components/markdown-worker-transport.test.ts +56 -0
- package/src/components/markdown-worker-transport.ts +41 -0
- package/src/components/markdown-worker.ts +122 -0
- package/src/components/markdown.css +377 -0
- package/src/components/markdown.stories.tsx +53 -0
- package/src/components/markdown.tsx +678 -0
- package/src/components/message-file.test.ts +63 -0
- package/src/components/message-file.ts +34 -0
- package/src/components/message-nav.css +127 -0
- package/src/components/message-nav.stories.tsx +7 -0
- package/src/components/message-nav.tsx +102 -0
- package/src/components/message-part-text.ts +3 -0
- package/src/components/message-part.css +1569 -0
- package/src/components/message-part.stories.tsx +7 -0
- package/src/components/message-part.test.ts +28 -0
- package/src/components/message-part.tsx +2662 -0
- package/src/components/session-diff.test.ts +135 -0
- package/src/components/session-diff.ts +145 -0
- package/src/components/session-retry.tsx +74 -0
- package/src/components/session-review.css +247 -0
- package/src/components/session-review.stories.tsx +7 -0
- package/src/components/session-review.tsx +657 -0
- package/src/components/session-turn.css +241 -0
- package/src/components/session-turn.stories.tsx +7 -0
- package/src/components/session-turn.tsx +543 -0
- package/src/components/shell-submessage-motion.stories.tsx +346 -0
- package/src/components/shell-submessage.css +23 -0
- package/src/components/timeline-playground.stories.tsx +2090 -0
- package/src/components/tool-count-label.css +57 -0
- package/src/components/tool-count-label.tsx +58 -0
- package/src/components/tool-count-summary.css +102 -0
- package/src/components/tool-count-summary.stories.tsx +238 -0
- package/src/components/tool-count-summary.tsx +52 -0
- package/src/components/tool-error-card.css +91 -0
- package/src/components/tool-error-card.stories.tsx +92 -0
- package/src/components/tool-error-card.tsx +157 -0
- package/src/components/tool-status-title.css +89 -0
- package/src/components/tool-status-title.tsx +133 -0
- package/src/context/data.tsx +64 -0
- package/src/context/index.ts +1 -0
- package/src/pierre/comment-hover.ts +83 -0
- package/src/pierre/commented-lines.ts +91 -0
- package/src/pierre/diff-selection.ts +71 -0
- package/src/pierre/file-find.ts +485 -0
- package/src/pierre/file-runtime.ts +114 -0
- package/src/pierre/file-selection.ts +85 -0
- package/src/pierre/index.ts +186 -0
- package/src/pierre/media.ts +110 -0
- package/src/pierre/selection-bridge.ts +132 -0
- package/src/pierre/virtualizer.ts +100 -0
- package/src/pierre/worker.ts +52 -0
- package/src/styles/index.css +14 -0
- package/src/v2/components/attachment-card-v2.css +70 -0
- package/src/v2/components/attachment-card-v2.tsx +33 -0
- package/src/v2/components/basic-tool-v2.css +163 -0
- package/src/v2/components/basic-tool-v2.stories.tsx +137 -0
- package/src/v2/components/basic-tool-v2.tsx +139 -0
- package/src/v2/components/comment-card-v2.tsx +64 -0
- package/src/v2/components/line-comment-annotations-v2.tsx +220 -0
- package/src/v2/components/prompt-input/attachments.ts +266 -0
- package/src/v2/components/prompt-input/index.tsx +706 -0
- package/src/v2/components/prompt-input/interaction.ts +482 -0
- package/src/v2/components/prompt-input/machine.test.ts +164 -0
- package/src/v2/components/prompt-input/machine.ts +261 -0
- package/src/v2/components/prompt-input/prompt-input.stories.tsx +221 -0
- package/src/v2/components/prompt-input/store.test.ts +116 -0
- package/src/v2/components/prompt-input/store.ts +152 -0
- package/src/v2/components/prompt-input/types.ts +106 -0
- package/src/v2/components/session-file-panel-v2.tsx +43 -0
- package/src/v2/components/session-progress-indicator-v2.css +875 -0
- package/src/v2/components/session-progress-indicator-v2.stories.tsx +66 -0
- package/src/v2/components/session-progress-indicator-v2.tsx +32 -0
- package/src/v2/components/session-review-empty-changes-v2.tsx +17 -0
- package/src/v2/components/session-review-empty-no-git-v2.tsx +28 -0
- package/src/v2/components/session-review-file-preview-v2-virtualize.test.ts +13 -0
- package/src/v2/components/session-review-file-preview-v2-virtualize.ts +5 -0
- package/src/v2/components/session-review-file-preview-v2.tsx +290 -0
- package/src/v2/components/session-review-v2.css +458 -0
- package/src/v2/components/session-review-v2.tsx +340 -0
- package/src/v2/components/tool-error-card-v2.css +200 -0
- package/src/v2/components/tool-error-card-v2.stories.tsx +91 -0
- package/src/v2/components/tool-error-card-v2.tsx +166 -0
- package/sst-env.d.ts +10 -0
- 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
|
+
}
|