@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,18 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import { preloadMarkdown } from "./markdown-cache"
|
|
3
|
+
|
|
4
|
+
test("preloads completed markdown into the render cache", async () => {
|
|
5
|
+
const parsed: string[] = []
|
|
6
|
+
const parser = {
|
|
7
|
+
parse(text: string) {
|
|
8
|
+
parsed.push(text)
|
|
9
|
+
return `<p>${text}</p>`
|
|
10
|
+
},
|
|
11
|
+
}
|
|
12
|
+
const key = `markdown-preload-${crypto.randomUUID()}`
|
|
13
|
+
|
|
14
|
+
await preloadMarkdown("prepared response", key, parser)
|
|
15
|
+
await preloadMarkdown("prepared response", key, parser)
|
|
16
|
+
|
|
17
|
+
expect(parsed).toEqual(["prepared response"])
|
|
18
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
|
|
3
|
+
import { ShikiStreamTokenizer } from "@shikijs/stream"
|
|
4
|
+
import {
|
|
5
|
+
bundledLanguages,
|
|
6
|
+
createHighlighter,
|
|
7
|
+
getTokenStyleObject,
|
|
8
|
+
stringifyTokenStyle,
|
|
9
|
+
type BundledLanguage,
|
|
10
|
+
type ThemedToken,
|
|
11
|
+
} from "shiki"
|
|
12
|
+
import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol"
|
|
13
|
+
import { createLatestWorkerQueue } from "./markdown-worker-queue"
|
|
14
|
+
|
|
15
|
+
type Stream = {
|
|
16
|
+
language: string
|
|
17
|
+
source: string
|
|
18
|
+
tokenizer: ShikiStreamTokenizer
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const streams = new Map<string, Stream>()
|
|
22
|
+
let highlighter: ReturnType<typeof createHighlighter> | undefined
|
|
23
|
+
const queue = createLatestWorkerQueue<Extract<MarkdownWorkerRequest, { type: "highlight" }>>({
|
|
24
|
+
run: highlight,
|
|
25
|
+
supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }),
|
|
26
|
+
dispose: (key) => void streams.delete(key),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
|
|
30
|
+
if (event.data.type === "init") {
|
|
31
|
+
highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] })
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if (event.data.type === "dispose") {
|
|
35
|
+
queue.dispose(event.data.key)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
queue.highlight(event.data)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function highlight(request: Extract<MarkdownWorkerRequest, { type: "highlight" }>) {
|
|
43
|
+
try {
|
|
44
|
+
const instance = await highlighter
|
|
45
|
+
if (!instance) throw new Error("Shiki worker is not initialized")
|
|
46
|
+
const language = request.language in bundledLanguages ? request.language : "text"
|
|
47
|
+
if (!instance.getLoadedLanguages().includes(language))
|
|
48
|
+
await instance.loadLanguage(bundledLanguages[language as BundledLanguage])
|
|
49
|
+
|
|
50
|
+
if (request.complete) {
|
|
51
|
+
const result = instance.codeToTokens(request.text, { lang: language as BundledLanguage, theme: "OpenCode" })
|
|
52
|
+
streams.delete(request.key)
|
|
53
|
+
post({
|
|
54
|
+
type: "highlight",
|
|
55
|
+
id: request.id,
|
|
56
|
+
key: request.key,
|
|
57
|
+
reset: true,
|
|
58
|
+
stable: result.tokens
|
|
59
|
+
.flatMap((line, index) =>
|
|
60
|
+
index === result.tokens.length - 1 ? line : [...line, { content: "\n", offset: 0 }],
|
|
61
|
+
)
|
|
62
|
+
.map(token),
|
|
63
|
+
unstable: [],
|
|
64
|
+
})
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const previous = streams.get(request.key)
|
|
69
|
+
const reset = !previous || previous.language !== language || !request.text.startsWith(previous.source)
|
|
70
|
+
const stream = reset
|
|
71
|
+
? {
|
|
72
|
+
language,
|
|
73
|
+
source: "",
|
|
74
|
+
tokenizer: new ShikiStreamTokenizer({ highlighter: instance, lang: language, theme: "OpenCode" }),
|
|
75
|
+
}
|
|
76
|
+
: previous
|
|
77
|
+
const result = await stream.tokenizer.enqueue(request.text.slice(stream.source.length))
|
|
78
|
+
stream.source = request.text
|
|
79
|
+
streams.set(request.key, stream)
|
|
80
|
+
post({
|
|
81
|
+
type: "highlight",
|
|
82
|
+
id: request.id,
|
|
83
|
+
key: request.key,
|
|
84
|
+
reset,
|
|
85
|
+
stable: result.stable.filter((token) => token.content.length > 0).map(token),
|
|
86
|
+
unstable: result.unstable.filter((token) => token.content.length > 0).map(token),
|
|
87
|
+
})
|
|
88
|
+
} catch (error) {
|
|
89
|
+
post({
|
|
90
|
+
type: "error",
|
|
91
|
+
id: request.id,
|
|
92
|
+
key: request.key,
|
|
93
|
+
message: error instanceof Error ? error.message : String(error),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function post(response: MarkdownWorkerResponse) {
|
|
99
|
+
self.postMessage(response)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function token(value: ThemedToken): MarkdownToken {
|
|
103
|
+
return [value.content, stringifyTokenStyle(value.htmlStyle ?? getTokenStyleObject(value))]
|
|
104
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { canReusePendingBlock, project, stream } from "./markdown-stream"
|
|
3
|
+
|
|
4
|
+
describe("markdown stream", () => {
|
|
5
|
+
test("heals incomplete emphasis while streaming", () => {
|
|
6
|
+
expect(stream("hello **world", true)).toEqual([{ raw: "hello **world", src: "hello **world**", mode: "live" }])
|
|
7
|
+
expect(stream("say `code", true)).toEqual([{ raw: "say `code", src: "say `code`", mode: "live" }])
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test("keeps incomplete links non-clickable until they finish", () => {
|
|
11
|
+
expect(stream("see [docs](https://example.com/gu", true)).toEqual([
|
|
12
|
+
{ raw: "see [docs](https://example.com/gu", src: "see docs", mode: "live" },
|
|
13
|
+
])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("splits an unfinished trailing code fence from stable content", () => {
|
|
17
|
+
expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([
|
|
18
|
+
{ raw: "before\n\n", src: "before\n\n", mode: "full" },
|
|
19
|
+
{ raw: "```ts\nconst x = 1", src: "const x = 1", mode: "code", language: "ts" },
|
|
20
|
+
])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("fully parses a code fence once it closes", () => {
|
|
24
|
+
const text = "before\n\n```ts\nconst x = 1\n```"
|
|
25
|
+
expect(stream(text, true)).toEqual([
|
|
26
|
+
{ raw: "before\n\n", src: "before\n\n", mode: "full" },
|
|
27
|
+
{ raw: "```ts\nconst x = 1\n```", src: "const x = 1", mode: "code", language: "ts", complete: true },
|
|
28
|
+
])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("keeps a completed code fence in worker-rendered code mode when prose follows", () => {
|
|
32
|
+
expect(stream("```ts\nconst x = 1\n```\n\nafter", true)).toEqual([
|
|
33
|
+
{ raw: "```ts\nconst x = 1\n```\n\n", src: "const x = 1", mode: "code", language: "ts", complete: true },
|
|
34
|
+
{ raw: "after", src: "after", mode: "live" },
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("freezes completed top-level blocks and only keeps the tail live", () => {
|
|
39
|
+
expect(stream("# Plan\n\nFinished paragraph.\n\n- live item", true)).toEqual([
|
|
40
|
+
{ raw: "# Plan\n\n", src: "# Plan\n\n", mode: "full" },
|
|
41
|
+
{ raw: "Finished paragraph.\n\n", src: "Finished paragraph.\n\n", mode: "full" },
|
|
42
|
+
{ raw: "- live item", src: "- live item", mode: "live" },
|
|
43
|
+
])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("keeps a growing table together until a later block freezes it", () => {
|
|
47
|
+
expect(stream("| a | b |\n|---|---|\n| 1 | 2 |", true)).toEqual([
|
|
48
|
+
{ raw: "| a | b |\n|---|---|\n| 1 | 2 |", src: "| a | b |\n|---|---|\n| 1 | 2 |", mode: "live" },
|
|
49
|
+
])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("reprojects non-prefix replacements from current content", () => {
|
|
53
|
+
expect(stream("# Replacement\n\nNew body", true)).toEqual([
|
|
54
|
+
{ raw: "# Replacement\n\n", src: "# Replacement\n\n", mode: "full" },
|
|
55
|
+
{ raw: "New body", src: "New body", mode: "live" },
|
|
56
|
+
])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("reprojects truncation without retaining removed blocks", () => {
|
|
60
|
+
expect(stream("Only the restored prefix", true)).toEqual([
|
|
61
|
+
{ raw: "Only the restored prefix", src: "Only the restored prefix", mode: "live" },
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("shifts later blocks when an earlier block is inserted", () => {
|
|
66
|
+
expect(stream("# Inserted\n\nFirst body\n\nSecond body", true)).toEqual([
|
|
67
|
+
{ raw: "# Inserted\n\n", src: "# Inserted\n\n", mode: "full" },
|
|
68
|
+
{ raw: "First body\n\n", src: "First body\n\n", mode: "full" },
|
|
69
|
+
{ raw: "Second body", src: "Second body", mode: "live" },
|
|
70
|
+
])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test("keeps reference-style markdown as one block", () => {
|
|
74
|
+
expect(stream("[docs][1]\n\n[1]: https://example.com", true)).toEqual([
|
|
75
|
+
{
|
|
76
|
+
raw: "[docs][1]\n\n[1]: https://example.com",
|
|
77
|
+
src: "[docs][1]\n\n[1]: https://example.com",
|
|
78
|
+
mode: "live",
|
|
79
|
+
},
|
|
80
|
+
])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("keeps compact and indented reference definitions with their uses", () => {
|
|
84
|
+
expect(stream("[docs]\n\n [docs]:/guide", true)).toEqual([
|
|
85
|
+
{
|
|
86
|
+
raw: "[docs]\n\n [docs]:/guide",
|
|
87
|
+
src: "[docs]\n\n [docs]:/guide",
|
|
88
|
+
mode: "live",
|
|
89
|
+
},
|
|
90
|
+
])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("keeps multiline reference definitions with their uses", () => {
|
|
94
|
+
expect(stream("[docs][id]\n\n[id]:\n /guide", true)).toEqual([
|
|
95
|
+
{
|
|
96
|
+
raw: "[docs][id]\n\n[id]:\n /guide",
|
|
97
|
+
src: "[docs][id]\n\n[id]:\n /guide",
|
|
98
|
+
mode: "live",
|
|
99
|
+
},
|
|
100
|
+
])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test("uses only the language portion of fence metadata", () => {
|
|
104
|
+
expect(stream("```ts title=example\nconst x = 1", true)).toEqual([
|
|
105
|
+
{
|
|
106
|
+
raw: "```ts title=example\nconst x = 1",
|
|
107
|
+
src: "const x = 1",
|
|
108
|
+
mode: "code",
|
|
109
|
+
language: "ts",
|
|
110
|
+
},
|
|
111
|
+
])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test("preserves trailing newlines in open code fences", () => {
|
|
115
|
+
expect(stream("```ts\nconst x = 1\n", true)).toEqual([
|
|
116
|
+
{
|
|
117
|
+
raw: "```ts\nconst x = 1\n",
|
|
118
|
+
src: "const x = 1\n",
|
|
119
|
+
mode: "code",
|
|
120
|
+
language: "ts",
|
|
121
|
+
},
|
|
122
|
+
])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("only reuses pending blocks with compatible identity and content", () => {
|
|
126
|
+
expect(
|
|
127
|
+
canReusePendingBlock({ mode: "full", raw: "First\n\n" }, { mode: "full", raw: "# Inserted\n\n", src: "" }),
|
|
128
|
+
).toBe(false)
|
|
129
|
+
expect(
|
|
130
|
+
canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" }),
|
|
131
|
+
).toBe(true)
|
|
132
|
+
expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("appends plain code deltas without reprojecting frozen blocks", () => {
|
|
136
|
+
const previous = project(undefined, "# Plan\n\n```ts\nconst one = 1\n", true)
|
|
137
|
+
const next = project(previous, `${previous.text}const two = 2\n`, true)
|
|
138
|
+
|
|
139
|
+
expect(next.blocks[0]).toBe(previous.blocks[0])
|
|
140
|
+
expect(next.blocks.at(-1)).toEqual({
|
|
141
|
+
raw: "```ts\nconst one = 1\nconst two = 2\n",
|
|
142
|
+
src: "const one = 1\nconst two = 2\n",
|
|
143
|
+
mode: "code",
|
|
144
|
+
language: "ts",
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test("does not add a blank line before the first streamed code", () => {
|
|
149
|
+
const previous = project(undefined, "```ts\n", true)
|
|
150
|
+
const next = project(previous, `${previous.text}const x = 1`, true)
|
|
151
|
+
|
|
152
|
+
expect(next.blocks.at(-1)).toEqual({
|
|
153
|
+
raw: "```ts\nconst x = 1",
|
|
154
|
+
src: "const x = 1",
|
|
155
|
+
mode: "code",
|
|
156
|
+
language: "ts",
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test("closes code fences split across provider deltas", () => {
|
|
161
|
+
const open = project(undefined, "```ts\nconst x = 1\n", true)
|
|
162
|
+
const one = project(open, `${open.text}\``, true)
|
|
163
|
+
const two = project(one, `${one.text}\``, true)
|
|
164
|
+
const closed = project(two, `${two.text}\``, true)
|
|
165
|
+
const prose = project(closed, `${closed.text}\nafter`, true)
|
|
166
|
+
|
|
167
|
+
expect(closed.blocks.at(-1)).toEqual({
|
|
168
|
+
raw: "```ts\nconst x = 1\n```",
|
|
169
|
+
src: "const x = 1",
|
|
170
|
+
mode: "code",
|
|
171
|
+
language: "ts",
|
|
172
|
+
complete: true,
|
|
173
|
+
})
|
|
174
|
+
expect(prose.blocks).toEqual([
|
|
175
|
+
{ raw: "```ts\nconst x = 1\n```\n", src: "const x = 1", mode: "code", language: "ts", complete: true },
|
|
176
|
+
{ raw: "after", src: "after", mode: "live" },
|
|
177
|
+
])
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test("closes tilde fences split across provider deltas", () => {
|
|
181
|
+
const open = project(undefined, "~~~ts\nconst x = 1\n", true)
|
|
182
|
+
const one = project(open, `${open.text}~`, true)
|
|
183
|
+
const two = project(one, `${one.text}~`, true)
|
|
184
|
+
const closed = project(two, `${two.text}~`, true)
|
|
185
|
+
|
|
186
|
+
expect(closed.blocks.at(-1)).toEqual({
|
|
187
|
+
raw: "~~~ts\nconst x = 1\n~~~",
|
|
188
|
+
src: "const x = 1",
|
|
189
|
+
mode: "code",
|
|
190
|
+
language: "ts",
|
|
191
|
+
complete: true,
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { marked, type Tokens } from "marked"
|
|
2
|
+
import remend from "remend"
|
|
3
|
+
|
|
4
|
+
export type Block = {
|
|
5
|
+
raw: string
|
|
6
|
+
src: string
|
|
7
|
+
mode: "full" | "live" | "code"
|
|
8
|
+
language?: string
|
|
9
|
+
complete?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Projection = {
|
|
13
|
+
text: string
|
|
14
|
+
blocks: Block[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function refs(text: string) {
|
|
18
|
+
if (!text.includes("]:")) return false
|
|
19
|
+
return /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(?:\S+|\r?\n[ \t]+\S+)/m.test(text)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function language(value: string | undefined) {
|
|
23
|
+
return value?.trim().split(/\s+/, 1)[0] || undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function openCode(raw: string) {
|
|
27
|
+
const newline = raw.indexOf("\n")
|
|
28
|
+
return newline < 0 ? "" : raw.slice(newline + 1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function open(raw: string) {
|
|
32
|
+
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
|
|
33
|
+
if (!match) return false
|
|
34
|
+
const mark = match[1]
|
|
35
|
+
if (!mark) return false
|
|
36
|
+
const char = mark[0]
|
|
37
|
+
const size = mark.length
|
|
38
|
+
const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
|
|
39
|
+
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function closesFence(raw: string, suffix: string) {
|
|
43
|
+
const mark = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)?.[1]
|
|
44
|
+
if (!mark) return suffix.includes("```") || suffix.includes("~~~")
|
|
45
|
+
return `${raw.slice(-(mark.length - 1))}${suffix}`.includes(mark)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function heal(text: string) {
|
|
49
|
+
return remend(text, { linkMode: "text-only" })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function stream(text: string, live: boolean): Block[] {
|
|
53
|
+
if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[]
|
|
54
|
+
if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
|
55
|
+
const tokens = marked.lexer(text)
|
|
56
|
+
const tail = tokens.findLastIndex((token) => token.type !== "space")
|
|
57
|
+
if (tail < 0) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
|
58
|
+
const last = tokens[tail]
|
|
59
|
+
if (!last) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
|
60
|
+
|
|
61
|
+
const result: Block[] = []
|
|
62
|
+
for (let index = 0; index < tail; index++) {
|
|
63
|
+
const token = tokens[index]
|
|
64
|
+
if (!token || token.type === "space") continue
|
|
65
|
+
let raw = token.raw
|
|
66
|
+
while (tokens[index + 1]?.type === "space" && index + 1 < tail) raw += tokens[++index]!.raw
|
|
67
|
+
if (token.type === "code") {
|
|
68
|
+
const code = token as Tokens.Code
|
|
69
|
+
result.push({ raw, src: code.text, mode: "code", language: language(code.lang), complete: true })
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
result.push({ raw, src: raw, mode: "full" })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const raw = tokens
|
|
76
|
+
.slice(tail)
|
|
77
|
+
.map((token) => token.raw)
|
|
78
|
+
.join("")
|
|
79
|
+
if (last.type !== "code") return [...result, { raw, src: heal(raw), mode: "live" }]
|
|
80
|
+
|
|
81
|
+
const code = last as Tokens.Code
|
|
82
|
+
if (!open(code.raw))
|
|
83
|
+
return [...result, { raw, src: code.text, mode: "code", language: language(code.lang), complete: true }]
|
|
84
|
+
return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function canReusePendingBlock(current: Pick<Block, "mode" | "raw"> | undefined, next: Block) {
|
|
88
|
+
if (!current || current.mode !== next.mode) return false
|
|
89
|
+
if (next.mode === "code") return next.raw.startsWith(current.raw)
|
|
90
|
+
return current.raw === next.raw
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
|
|
94
|
+
if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) }
|
|
95
|
+
const tail = previous.blocks.at(-1)
|
|
96
|
+
const suffix = text.slice(previous.text.length)
|
|
97
|
+
if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix))
|
|
98
|
+
return { text, blocks: stream(text, live) }
|
|
99
|
+
return {
|
|
100
|
+
text,
|
|
101
|
+
blocks: [
|
|
102
|
+
...previous.blocks.slice(0, -1),
|
|
103
|
+
{
|
|
104
|
+
...tail,
|
|
105
|
+
raw: tail.raw + suffix,
|
|
106
|
+
src: tail.src + suffix,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
applyMarkdownWorkerResponse,
|
|
4
|
+
markdownBlockKey,
|
|
5
|
+
shouldReleaseMarkdownWorkerState,
|
|
6
|
+
} from "./markdown-worker-protocol"
|
|
7
|
+
|
|
8
|
+
const token = (content: string): [string, string] => [content, ""]
|
|
9
|
+
const response = (id: number, reset: boolean, stable: [string, string][], unstable: [string, string][]) => ({
|
|
10
|
+
type: "highlight" as const,
|
|
11
|
+
id,
|
|
12
|
+
key: "code",
|
|
13
|
+
reset,
|
|
14
|
+
stable,
|
|
15
|
+
unstable,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("accumulates stable worker tokens and replaces the unstable tail", () => {
|
|
19
|
+
const first = applyMarkdownWorkerResponse(undefined, {
|
|
20
|
+
type: "highlight",
|
|
21
|
+
id: 1,
|
|
22
|
+
key: "code",
|
|
23
|
+
reset: true,
|
|
24
|
+
stable: [token("one\n")],
|
|
25
|
+
unstable: [token("tw")],
|
|
26
|
+
})
|
|
27
|
+
const second = applyMarkdownWorkerResponse(first, {
|
|
28
|
+
type: "highlight",
|
|
29
|
+
id: 2,
|
|
30
|
+
key: "code",
|
|
31
|
+
reset: false,
|
|
32
|
+
stable: [token("two\n")],
|
|
33
|
+
unstable: [token("three")],
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
expect(second.stable.map((item) => item[0])).toEqual(["one\n", "two\n"])
|
|
37
|
+
expect(second.unstable.map((item) => item[0])).toEqual(["three"])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("increments generation only when the worker resets token identity", () => {
|
|
41
|
+
const first = applyMarkdownWorkerResponse(undefined, response(1, true, [["const", ""]], []))
|
|
42
|
+
const append = applyMarkdownWorkerResponse(first, response(2, false, [[" x", ""]], []))
|
|
43
|
+
const replacement = applyMarkdownWorkerResponse(append, response(3, true, [["let y", ""]], []))
|
|
44
|
+
expect([first.generation, append.generation, replacement.generation]).toEqual([1, 1, 2])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("ignores stale worker responses and resets replacement streams", () => {
|
|
48
|
+
const current = { id: 2, generation: 1, stable: [token("current")], unstable: [] }
|
|
49
|
+
expect(
|
|
50
|
+
applyMarkdownWorkerResponse(current, {
|
|
51
|
+
type: "highlight",
|
|
52
|
+
id: 1,
|
|
53
|
+
key: "code",
|
|
54
|
+
reset: false,
|
|
55
|
+
stable: [token("stale")],
|
|
56
|
+
unstable: [],
|
|
57
|
+
}),
|
|
58
|
+
).toBe(current)
|
|
59
|
+
|
|
60
|
+
expect(
|
|
61
|
+
applyMarkdownWorkerResponse(current, {
|
|
62
|
+
type: "highlight",
|
|
63
|
+
id: 3,
|
|
64
|
+
key: "code",
|
|
65
|
+
reset: true,
|
|
66
|
+
stable: [token("replacement")],
|
|
67
|
+
unstable: [],
|
|
68
|
+
}).stable.map((item) => item[0]),
|
|
69
|
+
).toEqual(["replacement"])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("releases only the latest completed worker state", () => {
|
|
73
|
+
expect(shouldReleaseMarkdownWorkerState(true, 4, 4)).toBe(true)
|
|
74
|
+
expect(shouldReleaseMarkdownWorkerState(true, 5, 4)).toBe(false)
|
|
75
|
+
expect(shouldReleaseMarkdownWorkerState(false, 4, 4)).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("prefixes pending and dispatched block keys with the component owner", () => {
|
|
79
|
+
expect(markdownBlockKey("owner", "message", 2, "code")).toBe("owner:message:2:code")
|
|
80
|
+
expect(markdownBlockKey("owner", undefined, 2, "code")).toBe("owner:block:2")
|
|
81
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ThemeRegistrationResolved } from "shiki"
|
|
2
|
+
|
|
3
|
+
export type MarkdownToken = [content: string, style: string]
|
|
4
|
+
|
|
5
|
+
export type MarkdownWorkerRequest =
|
|
6
|
+
| { type: "init"; theme: ThemeRegistrationResolved }
|
|
7
|
+
| { type: "highlight"; id: number; key: string; text: string; language: string; complete?: boolean }
|
|
8
|
+
| { type: "dispose"; key: string }
|
|
9
|
+
|
|
10
|
+
export type MarkdownWorkerResponse =
|
|
11
|
+
| {
|
|
12
|
+
type: "highlight"
|
|
13
|
+
id: number
|
|
14
|
+
key: string
|
|
15
|
+
reset: boolean
|
|
16
|
+
stable: MarkdownToken[]
|
|
17
|
+
unstable: MarkdownToken[]
|
|
18
|
+
}
|
|
19
|
+
| { type: "error"; id: number; key: string; message: string }
|
|
20
|
+
| { type: "superseded"; id: number; key: string }
|
|
21
|
+
|
|
22
|
+
export type MarkdownWorkerState = {
|
|
23
|
+
id: number
|
|
24
|
+
generation: number
|
|
25
|
+
stable: MarkdownToken[]
|
|
26
|
+
unstable: MarkdownToken[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function shouldReleaseMarkdownWorkerState(complete: boolean, latestID: number | undefined, responseID: number) {
|
|
30
|
+
return complete && latestID === responseID
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function markdownBlockKey(owner: string, cacheKey: string | undefined, index: number, mode: string) {
|
|
34
|
+
return `${owner}:${cacheKey ? `${cacheKey}:${index}:${mode}` : `block:${index}`}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function applyMarkdownWorkerResponse(
|
|
38
|
+
state: MarkdownWorkerState | undefined,
|
|
39
|
+
response: Extract<MarkdownWorkerResponse, { type: "highlight" }>,
|
|
40
|
+
) {
|
|
41
|
+
if (state && response.id <= state.id) return state
|
|
42
|
+
return {
|
|
43
|
+
id: response.id,
|
|
44
|
+
generation: (state?.generation ?? 0) + (response.reset ? 1 : 0),
|
|
45
|
+
stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable],
|
|
46
|
+
unstable: response.unstable,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import { createLatestWorkerQueue } from "./markdown-worker-queue"
|
|
3
|
+
|
|
4
|
+
test("keeps only the latest queued request for each key", async () => {
|
|
5
|
+
const processed: number[] = []
|
|
6
|
+
const superseded: number[] = []
|
|
7
|
+
let release = () => {}
|
|
8
|
+
const blocked = new Promise<void>((resolve) => {
|
|
9
|
+
release = resolve
|
|
10
|
+
})
|
|
11
|
+
const queue = createLatestWorkerQueue<{ id: number; key: string }>({
|
|
12
|
+
run: async (request) => {
|
|
13
|
+
processed.push(request.id)
|
|
14
|
+
if (request.id === 1) await blocked
|
|
15
|
+
},
|
|
16
|
+
supersede: (request) => superseded.push(request.id),
|
|
17
|
+
dispose: () => {},
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
queue.highlight({ id: 1, key: "code" })
|
|
21
|
+
await Promise.resolve()
|
|
22
|
+
queue.highlight({ id: 2, key: "code" })
|
|
23
|
+
queue.highlight({ id: 3, key: "code" })
|
|
24
|
+
queue.highlight({ id: 4, key: "code" })
|
|
25
|
+
|
|
26
|
+
expect(queue.pending()).toBe(1)
|
|
27
|
+
expect(superseded).toEqual([2, 3])
|
|
28
|
+
release()
|
|
29
|
+
await queue.idle()
|
|
30
|
+
expect(processed).toEqual([1, 4])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("serializes disposal before a later request for the same key", async () => {
|
|
34
|
+
const events: string[] = []
|
|
35
|
+
const queue = createLatestWorkerQueue<{ id: number; key: string }>({
|
|
36
|
+
run: async (request) => {
|
|
37
|
+
events.push(`highlight:${request.id}`)
|
|
38
|
+
},
|
|
39
|
+
supersede: (request) => events.push(`supersede:${request.id}`),
|
|
40
|
+
dispose: (key) => events.push(`dispose:${key}`),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
queue.highlight({ id: 1, key: "code" })
|
|
44
|
+
queue.dispose("code")
|
|
45
|
+
queue.highlight({ id: 2, key: "code" })
|
|
46
|
+
await queue.idle()
|
|
47
|
+
|
|
48
|
+
expect(events).toEqual(["supersede:1", "dispose:code", "highlight:2"])
|
|
49
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export function createLatestWorkerQueue<T extends { key: string }>(input: {
|
|
2
|
+
run: (request: T) => Promise<void>
|
|
3
|
+
supersede: (request: T) => void
|
|
4
|
+
dispose: (key: string) => void
|
|
5
|
+
}) {
|
|
6
|
+
type Slot = { type: "highlight"; key: string; request?: T }
|
|
7
|
+
const jobs: Array<Slot | { type: "dispose"; key: string }> = []
|
|
8
|
+
const slots = new Map<string, Slot>()
|
|
9
|
+
let running: Promise<void> | undefined
|
|
10
|
+
let cursor = 0
|
|
11
|
+
|
|
12
|
+
const schedule = () => {
|
|
13
|
+
if (running) return
|
|
14
|
+
running = Promise.resolve()
|
|
15
|
+
.then(async () => {
|
|
16
|
+
while (cursor < jobs.length) {
|
|
17
|
+
const job = jobs[cursor++]!
|
|
18
|
+
if (job.type === "dispose") {
|
|
19
|
+
input.dispose(job.key)
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
if (slots.get(job.key) === job) slots.delete(job.key)
|
|
23
|
+
const request = job.request
|
|
24
|
+
job.request = undefined
|
|
25
|
+
if (request) await input.run(request)
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
.finally(() => {
|
|
29
|
+
jobs.splice(0, cursor)
|
|
30
|
+
cursor = 0
|
|
31
|
+
running = undefined
|
|
32
|
+
if (jobs.length > 0) schedule()
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
highlight(request: T) {
|
|
38
|
+
const slot = slots.get(request.key)
|
|
39
|
+
if (slot) {
|
|
40
|
+
if (slot.request) input.supersede(slot.request)
|
|
41
|
+
slot.request = request
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const next: Slot = { type: "highlight", key: request.key, request }
|
|
45
|
+
slots.set(request.key, next)
|
|
46
|
+
jobs.push(next)
|
|
47
|
+
schedule()
|
|
48
|
+
},
|
|
49
|
+
dispose(key: string) {
|
|
50
|
+
const slot = slots.get(key)
|
|
51
|
+
if (slot?.request) input.supersede(slot.request)
|
|
52
|
+
if (slot) {
|
|
53
|
+
slot.request = undefined
|
|
54
|
+
slots.delete(key)
|
|
55
|
+
}
|
|
56
|
+
jobs.push({ type: "dispose", key })
|
|
57
|
+
schedule()
|
|
58
|
+
},
|
|
59
|
+
pending: () => slots.size,
|
|
60
|
+
async idle() {
|
|
61
|
+
while (running) await running
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|