@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,678 @@
|
|
|
1
|
+
import { useMarked } from "@neurocode-ai/ui/context/marked"
|
|
2
|
+
import { useI18n } from "@neurocode-ai/ui/context/i18n"
|
|
3
|
+
import morphdom from "morphdom"
|
|
4
|
+
import { checksum } from "@neurocode-ai/core/util/encode"
|
|
5
|
+
import {
|
|
6
|
+
type Accessor,
|
|
7
|
+
type ComponentProps,
|
|
8
|
+
createEffect,
|
|
9
|
+
createMemo,
|
|
10
|
+
createResource,
|
|
11
|
+
createSignal,
|
|
12
|
+
createUniqueId,
|
|
13
|
+
onCleanup,
|
|
14
|
+
type Setter,
|
|
15
|
+
splitProps,
|
|
16
|
+
} from "solid-js"
|
|
17
|
+
import { isServer, render } from "solid-js/web"
|
|
18
|
+
import { Icon as IconV2 } from "@neurocode-ai/ui/v2/icon"
|
|
19
|
+
import { IconButtonV2 } from "@neurocode-ai/ui/v2/icon-button-v2"
|
|
20
|
+
import { TooltipV2 } from "@neurocode-ai/ui/v2/tooltip-v2"
|
|
21
|
+
import { bundledLanguages } from "shiki"
|
|
22
|
+
import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
|
|
23
|
+
import {
|
|
24
|
+
disposeStreamingCode,
|
|
25
|
+
highlightStreamingCode,
|
|
26
|
+
MarkdownWorkerDisposedError,
|
|
27
|
+
MarkdownWorkerSupersededError,
|
|
28
|
+
MarkdownWorkerUnavailableError,
|
|
29
|
+
} from "./markdown-worker"
|
|
30
|
+
import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol"
|
|
31
|
+
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
|
|
32
|
+
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
|
|
33
|
+
import { inlineCodeKind } from "./markdown-inline-code-kind"
|
|
34
|
+
|
|
35
|
+
type RenderedBlock =
|
|
36
|
+
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
|
|
37
|
+
| {
|
|
38
|
+
key: string
|
|
39
|
+
mode: "code"
|
|
40
|
+
raw: string
|
|
41
|
+
hash: string
|
|
42
|
+
language: string
|
|
43
|
+
complete: boolean
|
|
44
|
+
generation: number
|
|
45
|
+
stable: MarkdownToken[]
|
|
46
|
+
unstable: MarkdownToken[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type RenderResult = {
|
|
50
|
+
text: string
|
|
51
|
+
blocks: RenderedBlock[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
|
|
55
|
+
|
|
56
|
+
function escape(text: string) {
|
|
57
|
+
return text
|
|
58
|
+
.replace(/&/g, "&")
|
|
59
|
+
.replace(/</g, "<")
|
|
60
|
+
.replace(/>/g, ">")
|
|
61
|
+
.replace(/"/g, """)
|
|
62
|
+
.replace(/'/g, "'")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fallback(markdown: string) {
|
|
66
|
+
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function code(text: string, language: string | undefined, key: string, complete = false) {
|
|
70
|
+
const name = language && language in bundledLanguages ? language : "text"
|
|
71
|
+
try {
|
|
72
|
+
const result = await highlightStreamingCode(key, text, name, complete)
|
|
73
|
+
return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable }
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (
|
|
76
|
+
!(error instanceof MarkdownWorkerDisposedError) &&
|
|
77
|
+
!(error instanceof MarkdownWorkerSupersededError) &&
|
|
78
|
+
!(error instanceof MarkdownWorkerUnavailableError)
|
|
79
|
+
)
|
|
80
|
+
console.error("Markdown highlighting worker failed", error)
|
|
81
|
+
return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type CopyLabels = {
|
|
86
|
+
copy: string
|
|
87
|
+
copied: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type CopyButtonState = {
|
|
91
|
+
setLabels: Setter<CopyLabels>
|
|
92
|
+
setCopied: Setter<boolean>
|
|
93
|
+
dispose: () => void
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const copyButtonState = new WeakMap<HTMLElement, CopyButtonState>()
|
|
97
|
+
|
|
98
|
+
const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
|
|
99
|
+
|
|
100
|
+
function codeUrl(text: string) {
|
|
101
|
+
const href = text.trim().replace(/[),.;!?]+$/, "")
|
|
102
|
+
if (!urlPattern.test(href)) return
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(href)
|
|
105
|
+
return url.toString()
|
|
106
|
+
} catch {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createCopyButton(labels: CopyLabels) {
|
|
112
|
+
const host = document.createElement("div")
|
|
113
|
+
host.setAttribute("data-slot", "markdown-copy-button")
|
|
114
|
+
|
|
115
|
+
const state: Partial<CopyButtonState> = {}
|
|
116
|
+
const dispose = render(() => {
|
|
117
|
+
const [labelState, setLabels] = createSignal(labels, { equals: false })
|
|
118
|
+
const [copied, setCopied] = createSignal(false)
|
|
119
|
+
state.setLabels = setLabels
|
|
120
|
+
state.setCopied = setCopied
|
|
121
|
+
return <MarkdownCopyButton labels={labelState} copied={copied} />
|
|
122
|
+
}, host)
|
|
123
|
+
state.dispose = dispose
|
|
124
|
+
copyButtonState.set(host, state as CopyButtonState)
|
|
125
|
+
return host
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function MarkdownCopyButton(props: { labels: Accessor<CopyLabels>; copied: Accessor<boolean> }) {
|
|
129
|
+
const label = () => (props.copied() ? props.labels().copied : props.labels().copy)
|
|
130
|
+
return (
|
|
131
|
+
<TooltipV2 placement="top" value={label()}>
|
|
132
|
+
<IconButtonV2
|
|
133
|
+
type="button"
|
|
134
|
+
size="normal"
|
|
135
|
+
variant="ghost-muted"
|
|
136
|
+
aria-label={label()}
|
|
137
|
+
icon={
|
|
138
|
+
<>
|
|
139
|
+
<IconV2 name="outline-copy" data-copy-icon />
|
|
140
|
+
<IconV2 name="check" data-check-icon />
|
|
141
|
+
</>
|
|
142
|
+
}
|
|
143
|
+
/>
|
|
144
|
+
</TooltipV2>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function setCopyState(host: HTMLElement, labels: CopyLabels, copied: boolean) {
|
|
149
|
+
const state = copyButtonState.get(host)
|
|
150
|
+
state?.setLabels(labels)
|
|
151
|
+
state?.setCopied(copied)
|
|
152
|
+
if (copied) {
|
|
153
|
+
host.setAttribute("data-copied", "true")
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
host.removeAttribute("data-copied")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function disposeCopyButton(host: HTMLElement) {
|
|
160
|
+
copyButtonState.get(host)?.dispose()
|
|
161
|
+
copyButtonState.delete(host)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function disposeCopyButtons(root: Element) {
|
|
165
|
+
const hosts = [
|
|
166
|
+
...(root instanceof HTMLElement && root.getAttribute("data-slot") === "markdown-copy-button" ? [root] : []),
|
|
167
|
+
...Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
|
168
|
+
(el): el is HTMLElement => el instanceof HTMLElement,
|
|
169
|
+
),
|
|
170
|
+
]
|
|
171
|
+
hosts.forEach(disposeCopyButton)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"])
|
|
175
|
+
|
|
176
|
+
function codeKind(language: string | undefined) {
|
|
177
|
+
const value = language?.toLowerCase()
|
|
178
|
+
if (!value) return
|
|
179
|
+
if (shellLanguages.has(value)) return "shell"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function codeLanguage(block: HTMLPreElement) {
|
|
183
|
+
const code = block.querySelector("code")
|
|
184
|
+
if (!(code instanceof HTMLElement)) return
|
|
185
|
+
return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) {
|
|
189
|
+
if (!document.body.hasAttribute("data-new-layout")) {
|
|
190
|
+
delete wrapper.dataset.language
|
|
191
|
+
delete wrapper.dataset.codeKind
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (language) wrapper.dataset.language = language
|
|
196
|
+
else delete wrapper.dataset.language
|
|
197
|
+
|
|
198
|
+
const kind = codeKind(language)
|
|
199
|
+
if (kind) wrapper.dataset.codeKind = kind
|
|
200
|
+
else delete wrapper.dataset.codeKind
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
|
|
204
|
+
const parent = block.parentElement
|
|
205
|
+
if (!parent) return
|
|
206
|
+
const wrapped = parent.getAttribute("data-component") === "markdown-code"
|
|
207
|
+
if (!wrapped) {
|
|
208
|
+
const wrapper = document.createElement("div")
|
|
209
|
+
wrapper.setAttribute("data-component", "markdown-code")
|
|
210
|
+
applyCodeMetadata(wrapper, codeLanguage(block))
|
|
211
|
+
parent.replaceChild(wrapper, block)
|
|
212
|
+
wrapper.appendChild(block)
|
|
213
|
+
wrapper.appendChild(createCopyButton(labels))
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
applyCodeMetadata(parent, codeLanguage(block))
|
|
218
|
+
|
|
219
|
+
const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
|
|
220
|
+
(el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if (buttons.length === 0) {
|
|
224
|
+
parent.appendChild(createCopyButton(labels))
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const button of buttons.slice(1)) {
|
|
229
|
+
disposeCopyButton(button)
|
|
230
|
+
button.remove()
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function markCodeLinks(root: HTMLDivElement) {
|
|
235
|
+
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
|
|
236
|
+
for (const code of codeNodes) {
|
|
237
|
+
const href = codeUrl(code.textContent ?? "")
|
|
238
|
+
const parentLink =
|
|
239
|
+
code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link")
|
|
240
|
+
? code.parentElement
|
|
241
|
+
: null
|
|
242
|
+
|
|
243
|
+
if (!href) {
|
|
244
|
+
if (parentLink) parentLink.replaceWith(code)
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (parentLink) {
|
|
249
|
+
parentLink.href = href
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const link = document.createElement("a")
|
|
254
|
+
link.href = href
|
|
255
|
+
link.className = "external-link"
|
|
256
|
+
link.target = "_blank"
|
|
257
|
+
link.rel = "noopener noreferrer"
|
|
258
|
+
code.parentNode?.replaceChild(link, code)
|
|
259
|
+
link.appendChild(code)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function markInlineCode(root: HTMLDivElement) {
|
|
264
|
+
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
|
|
265
|
+
for (const code of codeNodes) {
|
|
266
|
+
if (!(code instanceof HTMLElement)) continue
|
|
267
|
+
delete code.dataset.inlineCodeKind
|
|
268
|
+
const kind = inlineCodeKind(code.textContent ?? "")
|
|
269
|
+
if (kind) code.dataset.inlineCodeKind = kind
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function decorate(root: HTMLDivElement, labels: CopyLabels) {
|
|
274
|
+
const blocks = Array.from(root.querySelectorAll("pre"))
|
|
275
|
+
for (const block of blocks) {
|
|
276
|
+
ensureCodeWrapper(block, labels)
|
|
277
|
+
}
|
|
278
|
+
if (!document.body.hasAttribute("data-new-layout")) return
|
|
279
|
+
markInlineCode(root)
|
|
280
|
+
markCodeLinks(root)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
|
|
284
|
+
const timeouts = new Map<HTMLElement, ReturnType<typeof setTimeout>>()
|
|
285
|
+
|
|
286
|
+
const updateLabel = (button: HTMLElement) => {
|
|
287
|
+
const labels = getLabels()
|
|
288
|
+
const copied = button.getAttribute("data-copied") === "true"
|
|
289
|
+
setCopyState(button, labels, copied)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const handleClick = async (event: MouseEvent) => {
|
|
293
|
+
const target = event.target
|
|
294
|
+
if (!(target instanceof Element)) return
|
|
295
|
+
|
|
296
|
+
const button = target.closest('[data-slot="markdown-copy-button"]')
|
|
297
|
+
if (!(button instanceof HTMLElement)) return
|
|
298
|
+
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
|
|
299
|
+
const content = code?.textContent ?? ""
|
|
300
|
+
if (!content) return
|
|
301
|
+
const clipboard = navigator?.clipboard
|
|
302
|
+
if (!clipboard) return
|
|
303
|
+
await clipboard.writeText(content)
|
|
304
|
+
const labels = getLabels()
|
|
305
|
+
setCopyState(button, labels, true)
|
|
306
|
+
const existing = timeouts.get(button)
|
|
307
|
+
if (existing) clearTimeout(existing)
|
|
308
|
+
const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
|
|
309
|
+
timeouts.set(button, timeout)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
|
|
313
|
+
for (const button of buttons) {
|
|
314
|
+
if (button instanceof HTMLElement) updateLabel(button)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
root.addEventListener("click", handleClick)
|
|
318
|
+
|
|
319
|
+
return () => {
|
|
320
|
+
root.removeEventListener("click", handleClick)
|
|
321
|
+
for (const timeout of timeouts.values()) {
|
|
322
|
+
clearTimeout(timeout)
|
|
323
|
+
}
|
|
324
|
+
disposeCopyButtons(root)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult {
|
|
329
|
+
if (!text) return { text, blocks: [] }
|
|
330
|
+
const base = key ?? checksum(text)
|
|
331
|
+
if (base) {
|
|
332
|
+
const blocks = projection.blocks.flatMap((block, index) => {
|
|
333
|
+
if (block.mode === "code") return []
|
|
334
|
+
const cacheKey = `${base}:${index}:${block.mode}`
|
|
335
|
+
const cached = getCachedMarkdown(cacheKey)
|
|
336
|
+
if (cached?.raw !== block.raw) return []
|
|
337
|
+
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
|
|
338
|
+
})
|
|
339
|
+
if (blocks.length === projection.blocks.length) return { text, blocks }
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
text,
|
|
343
|
+
blocks: [
|
|
344
|
+
{
|
|
345
|
+
key: "initial",
|
|
346
|
+
mode: "full",
|
|
347
|
+
raw: text,
|
|
348
|
+
hash: checksum(text) ?? "",
|
|
349
|
+
html: fallback(text),
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function Markdown(
|
|
356
|
+
props: ComponentProps<"div"> & {
|
|
357
|
+
text: string
|
|
358
|
+
cacheKey?: string
|
|
359
|
+
streaming?: boolean
|
|
360
|
+
class?: string
|
|
361
|
+
classList?: Record<string, boolean>
|
|
362
|
+
},
|
|
363
|
+
) {
|
|
364
|
+
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
|
|
365
|
+
const marked = useMarked()
|
|
366
|
+
const i18n = useI18n()
|
|
367
|
+
const [root, setRoot] = createSignal<HTMLDivElement>()
|
|
368
|
+
const owner = createUniqueId()
|
|
369
|
+
const activeCodeKeys = new Set<string>()
|
|
370
|
+
const completedCode = new Map<string, Extract<RenderedBlock, { mode: "code" }>>()
|
|
371
|
+
const projection = createMemo((previous: Projection | undefined) =>
|
|
372
|
+
project(previous, local.text, local.streaming ?? false),
|
|
373
|
+
)
|
|
374
|
+
const [html] = createResource(
|
|
375
|
+
() => {
|
|
376
|
+
return {
|
|
377
|
+
text: local.text,
|
|
378
|
+
key: local.cacheKey,
|
|
379
|
+
projection: projection(),
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
async (src) => {
|
|
383
|
+
if (isServer)
|
|
384
|
+
return {
|
|
385
|
+
text: src.text,
|
|
386
|
+
blocks: [
|
|
387
|
+
{
|
|
388
|
+
key: "server",
|
|
389
|
+
mode: "full" as const,
|
|
390
|
+
raw: src.text,
|
|
391
|
+
hash: checksum(src.text) ?? "",
|
|
392
|
+
html: fallback(src.text),
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
} satisfies RenderResult
|
|
396
|
+
if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult
|
|
397
|
+
|
|
398
|
+
const base = src.key ?? checksum(src.text)
|
|
399
|
+
return Promise.all(
|
|
400
|
+
src.projection.blocks.map(async (block, index) => {
|
|
401
|
+
const key = base ? `${base}:${index}:${block.mode}` : undefined
|
|
402
|
+
const blockKey = markdownBlockKey(owner, src.key, index, block.mode)
|
|
403
|
+
|
|
404
|
+
if (block.mode === "code") {
|
|
405
|
+
const cached = completedCode.get(blockKey)
|
|
406
|
+
if (block.complete && cached?.raw === block.raw) return cached
|
|
407
|
+
const result = await code(block.src, block.language, blockKey, block.complete)
|
|
408
|
+
const rendered = {
|
|
409
|
+
key: blockKey,
|
|
410
|
+
mode: block.mode,
|
|
411
|
+
raw: block.raw,
|
|
412
|
+
hash: String(block.raw.length),
|
|
413
|
+
complete: !!block.complete,
|
|
414
|
+
...result,
|
|
415
|
+
}
|
|
416
|
+
if (block.complete) completedCode.set(blockKey, rendered)
|
|
417
|
+
return rendered
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (key) {
|
|
421
|
+
const cached = getCachedMarkdown(key)
|
|
422
|
+
if (cached?.raw === block.raw) {
|
|
423
|
+
touchCachedMarkdown(key, cached)
|
|
424
|
+
return { key: blockKey, mode: block.mode, ...cached }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const hash = checksum(block.raw)
|
|
429
|
+
const safe = sanitizeMarkdown(await Promise.resolve(marked.parse(block.src)))
|
|
430
|
+
if (key && hash) touchCachedMarkdown(key, { raw: block.raw, hash, html: safe })
|
|
431
|
+
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
|
|
432
|
+
}),
|
|
433
|
+
)
|
|
434
|
+
.then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult)
|
|
435
|
+
.catch(
|
|
436
|
+
() =>
|
|
437
|
+
({
|
|
438
|
+
text: src.text,
|
|
439
|
+
blocks: [
|
|
440
|
+
{
|
|
441
|
+
key: base ?? "fallback",
|
|
442
|
+
mode: "full" as const,
|
|
443
|
+
raw: src.text,
|
|
444
|
+
hash: checksum(src.text) ?? "",
|
|
445
|
+
html: fallback(src.text),
|
|
446
|
+
},
|
|
447
|
+
],
|
|
448
|
+
}) satisfies RenderResult,
|
|
449
|
+
)
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
initialValue: initialResult(local.text, local.cacheKey, projection(), owner),
|
|
453
|
+
},
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
let copyCleanup: (() => void) | undefined
|
|
457
|
+
|
|
458
|
+
createEffect(() => {
|
|
459
|
+
const container = root()
|
|
460
|
+
const result = html.latest ?? html()
|
|
461
|
+
const projected = projection()
|
|
462
|
+
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
|
|
463
|
+
if (!container) return
|
|
464
|
+
if (isServer) return
|
|
465
|
+
if (content.length === 0) {
|
|
466
|
+
disposeCopyButtons(container)
|
|
467
|
+
container.innerHTML = ""
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const labels = {
|
|
472
|
+
copy: i18n.t("ui.message.copy"),
|
|
473
|
+
copied: i18n.t("ui.message.copied"),
|
|
474
|
+
}
|
|
475
|
+
const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key))
|
|
476
|
+
activeCodeKeys.forEach((key) => {
|
|
477
|
+
if (!nextCodeKeys.has(key)) disposeCode(key)
|
|
478
|
+
})
|
|
479
|
+
activeCodeKeys.clear()
|
|
480
|
+
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
|
|
481
|
+
content.forEach((block, index) => updateBlock(container, index, block, labels))
|
|
482
|
+
while (container.children.length > content.length) {
|
|
483
|
+
const child = container.lastElementChild
|
|
484
|
+
if (!child) break
|
|
485
|
+
disposeCopyButtons(child)
|
|
486
|
+
child.remove()
|
|
487
|
+
}
|
|
488
|
+
container
|
|
489
|
+
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
|
|
490
|
+
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
|
491
|
+
if (!copyCleanup)
|
|
492
|
+
copyCleanup = setupCodeCopy(container, () => ({
|
|
493
|
+
copy: i18n.t("ui.message.copy"),
|
|
494
|
+
copied: i18n.t("ui.message.copied"),
|
|
495
|
+
}))
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
onCleanup(() => {
|
|
499
|
+
if (copyCleanup) copyCleanup()
|
|
500
|
+
activeCodeKeys.forEach(disposeCode)
|
|
501
|
+
completedCode.clear()
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<div
|
|
506
|
+
data-component="markdown"
|
|
507
|
+
classList={{
|
|
508
|
+
...local.classList,
|
|
509
|
+
[local.class ?? ""]: !!local.class,
|
|
510
|
+
}}
|
|
511
|
+
ref={setRoot}
|
|
512
|
+
{...others}
|
|
513
|
+
/>
|
|
514
|
+
)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function pendingBlocks(
|
|
518
|
+
result: RenderResult | undefined,
|
|
519
|
+
projection: Projection | undefined,
|
|
520
|
+
cacheKey: string | undefined,
|
|
521
|
+
owner: string,
|
|
522
|
+
) {
|
|
523
|
+
if (!result) return []
|
|
524
|
+
if (!projection || result.text === projection.text) return result.blocks
|
|
525
|
+
const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial"
|
|
526
|
+
return projection.blocks.map((block, index) => {
|
|
527
|
+
const current = initial ? undefined : result.blocks[index]
|
|
528
|
+
if (current && canReusePendingBlock(current, block)) return current
|
|
529
|
+
const key = markdownBlockKey(owner, cacheKey, index, block.mode)
|
|
530
|
+
if (block.mode !== "code")
|
|
531
|
+
return { key, mode: block.mode, raw: block.raw, hash: String(block.raw.length), html: fallback(block.src) }
|
|
532
|
+
return {
|
|
533
|
+
key,
|
|
534
|
+
mode: block.mode,
|
|
535
|
+
raw: block.raw,
|
|
536
|
+
hash: String(block.raw.length),
|
|
537
|
+
language: block.language ?? "text",
|
|
538
|
+
complete: !!block.complete,
|
|
539
|
+
stable: [],
|
|
540
|
+
generation: 0,
|
|
541
|
+
unstable: [[block.src, ""] as MarkdownToken],
|
|
542
|
+
}
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function disposeCode(key: string) {
|
|
547
|
+
disposeStreamingCode(key)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function updateBlock(container: HTMLDivElement, index: number, block: RenderedBlock, labels: CopyLabels) {
|
|
551
|
+
const current = container.children[index]
|
|
552
|
+
if (block.mode === "code") {
|
|
553
|
+
updateCodeBlock(container, current, block, labels)
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
if (
|
|
557
|
+
current instanceof HTMLDivElement &&
|
|
558
|
+
current.dataset.markdownKey === block.key &&
|
|
559
|
+
current.dataset.markdownHash === block.hash
|
|
560
|
+
)
|
|
561
|
+
return
|
|
562
|
+
|
|
563
|
+
const next = document.createElement("div")
|
|
564
|
+
next.dataset.markdownBlock = ""
|
|
565
|
+
next.dataset.markdownKey = block.key
|
|
566
|
+
next.dataset.markdownHash = block.hash
|
|
567
|
+
next.style.display = "contents"
|
|
568
|
+
next.innerHTML = block.html
|
|
569
|
+
decorate(next, labels)
|
|
570
|
+
|
|
571
|
+
if (!(current instanceof HTMLDivElement)) {
|
|
572
|
+
container.appendChild(next)
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
morphdom(current, next, {
|
|
577
|
+
onBeforeElUpdated: (fromEl, toEl) => {
|
|
578
|
+
if (
|
|
579
|
+
fromEl instanceof HTMLElement &&
|
|
580
|
+
toEl instanceof HTMLElement &&
|
|
581
|
+
fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
|
|
582
|
+
toEl.getAttribute("data-slot") === "markdown-copy-button"
|
|
583
|
+
) {
|
|
584
|
+
return false
|
|
585
|
+
}
|
|
586
|
+
if (fromEl.isEqualNode(toEl)) return false
|
|
587
|
+
return true
|
|
588
|
+
},
|
|
589
|
+
onBeforeNodeDiscarded: (node) => {
|
|
590
|
+
if (node instanceof Element) disposeCopyButtons(node)
|
|
591
|
+
return true
|
|
592
|
+
},
|
|
593
|
+
})
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function updateCodeBlock(
|
|
597
|
+
container: HTMLDivElement,
|
|
598
|
+
current: Element | undefined,
|
|
599
|
+
block: Extract<RenderedBlock, { mode: "code" }>,
|
|
600
|
+
labels: CopyLabels,
|
|
601
|
+
) {
|
|
602
|
+
const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
|
|
603
|
+
const next = existing ?? document.createElement("div")
|
|
604
|
+
next.dataset.markdownBlock = ""
|
|
605
|
+
next.dataset.markdownKey = block.key
|
|
606
|
+
next.dataset.markdownHash = block.hash
|
|
607
|
+
next.dataset.markdownComplete = block.complete ? "true" : "false"
|
|
608
|
+
next.style.display = "contents"
|
|
609
|
+
|
|
610
|
+
const code = existing?.querySelector("code")
|
|
611
|
+
if (code instanceof HTMLElement) {
|
|
612
|
+
const wrapper = code.closest('[data-component="markdown-code"]')
|
|
613
|
+
if (wrapper instanceof HTMLElement) applyCodeMetadata(wrapper, block.language)
|
|
614
|
+
code.className = `language-${block.language}`
|
|
615
|
+
const previous = renderedCodeTokens.get(next)
|
|
616
|
+
const reset = shouldResetCodeTokens(previous, {
|
|
617
|
+
language: block.language,
|
|
618
|
+
generation: block.generation,
|
|
619
|
+
stableCount: block.stable.length,
|
|
620
|
+
raw: block.raw,
|
|
621
|
+
})
|
|
622
|
+
const stableCount = reset ? 0 : previous!.stableCount
|
|
623
|
+
const tail = [...block.stable.slice(stableCount), ...block.unstable]
|
|
624
|
+
const prior = reset ? [] : previous!.unstable
|
|
625
|
+
const prefix = prior.findIndex((token, index) => !sameToken(token, tail[index]))
|
|
626
|
+
const keep = stableCount + (prefix < 0 ? Math.min(prior.length, tail.length) : prefix)
|
|
627
|
+
while (code.children.length > keep) code.lastElementChild?.remove()
|
|
628
|
+
tail
|
|
629
|
+
.slice(keep - stableCount)
|
|
630
|
+
.map(createTokenSpan)
|
|
631
|
+
.forEach((span) => code.appendChild(span))
|
|
632
|
+
renderedCodeTokens.set(next, {
|
|
633
|
+
language: block.language,
|
|
634
|
+
generation: block.generation,
|
|
635
|
+
stableCount: block.stable.length,
|
|
636
|
+
unstable: block.unstable,
|
|
637
|
+
raw: block.raw,
|
|
638
|
+
})
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const wrapper = document.createElement("div")
|
|
643
|
+
wrapper.setAttribute("data-component", "markdown-code")
|
|
644
|
+
applyCodeMetadata(wrapper, block.language)
|
|
645
|
+
const pre = document.createElement("pre")
|
|
646
|
+
pre.className = "shiki OpenCode"
|
|
647
|
+
const codeElement = document.createElement("code")
|
|
648
|
+
codeElement.className = `language-${block.language}`
|
|
649
|
+
;[...block.stable, ...block.unstable].map(createTokenSpan).forEach((span) => codeElement.appendChild(span))
|
|
650
|
+
pre.appendChild(codeElement)
|
|
651
|
+
wrapper.appendChild(pre)
|
|
652
|
+
wrapper.appendChild(createCopyButton(labels))
|
|
653
|
+
next.appendChild(wrapper)
|
|
654
|
+
renderedCodeTokens.set(next, {
|
|
655
|
+
language: block.language,
|
|
656
|
+
generation: block.generation,
|
|
657
|
+
stableCount: block.stable.length,
|
|
658
|
+
unstable: block.unstable,
|
|
659
|
+
raw: block.raw,
|
|
660
|
+
})
|
|
661
|
+
if (current) {
|
|
662
|
+
disposeCopyButtons(current)
|
|
663
|
+
current.replaceWith(next)
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
container.appendChild(next)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) {
|
|
670
|
+
return !!right && left[0] === right[0] && left[1] === right[1]
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function createTokenSpan(token: MarkdownToken) {
|
|
674
|
+
const span = document.createElement("span")
|
|
675
|
+
span.setAttribute("style", token[1])
|
|
676
|
+
span.textContent = token[0]
|
|
677
|
+
return span
|
|
678
|
+
}
|