dsh-coding-sidebar 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +524 -0
  3. package/cordis.patch.yml +49 -0
  4. package/lib/client-editor.js +48229 -0
  5. package/lib/client-mermaid.js +200745 -0
  6. package/lib/client-registry.js +21867 -0
  7. package/lib/client-terminal.js +9361 -0
  8. package/lib/client.js +21867 -0
  9. package/lib/index.js +4556 -0
  10. package/lib/invariant.js +22 -0
  11. package/lib/types/agent-opens.d.ts +65 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +39 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +13 -0
  19. package/lib/types/client/FileTree.d.ts +38 -0
  20. package/lib/types/client/FreeWindow.d.ts +29 -0
  21. package/lib/types/client/GitView.d.ts +10 -0
  22. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  23. package/lib/types/client/OrphanedTab.d.ts +11 -0
  24. package/lib/types/client/PdfView.d.ts +6 -0
  25. package/lib/types/client/RenderBoundary.d.ts +32 -0
  26. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  27. package/lib/types/client/SideCardSection.d.ts +74 -0
  28. package/lib/types/client/SideChatView.d.ts +16 -0
  29. package/lib/types/client/Sidebar.d.ts +6 -0
  30. package/lib/types/client/SubagentView.d.ts +16 -0
  31. package/lib/types/client/TabBar.d.ts +54 -0
  32. package/lib/types/client/TerminalView.d.ts +24 -0
  33. package/lib/types/client/TextEditor.d.ts +10 -0
  34. package/lib/types/client/TreePanel.d.ts +24 -0
  35. package/lib/types/client/UploadOverlay.d.ts +20 -0
  36. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  37. package/lib/types/client/api.d.ts +275 -0
  38. package/lib/types/client/binary-download.d.ts +12 -0
  39. package/lib/types/client/breakpoints.d.ts +17 -0
  40. package/lib/types/client/browser.d.ts +60 -0
  41. package/lib/types/client/builtins/index.d.ts +18 -0
  42. package/lib/types/client/builtins/tabs.d.ts +11 -0
  43. package/lib/types/client/builtins/viewers.d.ts +3 -0
  44. package/lib/types/client/chunk-loader.d.ts +131 -0
  45. package/lib/types/client/chunks/editor.d.ts +9 -0
  46. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  47. package/lib/types/client/chunks/terminal.d.ts +9 -0
  48. package/lib/types/client/cm-themes.d.ts +24 -0
  49. package/lib/types/client/conversation-draft.d.ts +14 -0
  50. package/lib/types/client/desktop-env.d.ts +35 -0
  51. package/lib/types/client/editor-load.d.ts +66 -0
  52. package/lib/types/client/frame-batcher.d.ts +24 -0
  53. package/lib/types/client/icons.d.ts +59 -0
  54. package/lib/types/client/image-types.d.ts +3 -0
  55. package/lib/types/client/ime-guard.d.ts +36 -0
  56. package/lib/types/client/index.d.ts +25 -0
  57. package/lib/types/client/intercept.d.ts +46 -0
  58. package/lib/types/client/lang.d.ts +14 -0
  59. package/lib/types/client/layout-push.d.ts +15 -0
  60. package/lib/types/client/lazy-chunk.d.ts +25 -0
  61. package/lib/types/client/link-intercept.d.ts +42 -0
  62. package/lib/types/client/locales-ar.d.ts +18 -0
  63. package/lib/types/client/locales-de.d.ts +4 -0
  64. package/lib/types/client/locales-fr.d.ts +10 -0
  65. package/lib/types/client/locales-hi.d.ts +17 -0
  66. package/lib/types/client/locales-id.d.ts +15 -0
  67. package/lib/types/client/locales-it.d.ts +8 -0
  68. package/lib/types/client/locales-ja.d.ts +17 -0
  69. package/lib/types/client/locales-ko.d.ts +9 -0
  70. package/lib/types/client/locales-nl.d.ts +16 -0
  71. package/lib/types/client/locales-pl.d.ts +19 -0
  72. package/lib/types/client/locales-pt.d.ts +1 -0
  73. package/lib/types/client/locales-ru.d.ts +13 -0
  74. package/lib/types/client/locales-sv.d.ts +1 -0
  75. package/lib/types/client/locales-th.d.ts +17 -0
  76. package/lib/types/client/locales-tr.d.ts +17 -0
  77. package/lib/types/client/locales-vi.d.ts +17 -0
  78. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  79. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  80. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  81. package/lib/types/client/locales.d.ts +452 -0
  82. package/lib/types/client/markdown-html.d.ts +96 -0
  83. package/lib/types/client/markdown-images.d.ts +45 -0
  84. package/lib/types/client/markdown-labels.d.ts +38 -0
  85. package/lib/types/client/md-toc.d.ts +4 -0
  86. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  87. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  88. package/lib/types/client/mermaid.d.ts +12 -0
  89. package/lib/types/client/open-when-sized.d.ts +20 -0
  90. package/lib/types/client/open-with-settings.d.ts +4 -0
  91. package/lib/types/client/open-with.d.ts +87 -0
  92. package/lib/types/client/openpath-intercept.d.ts +85 -0
  93. package/lib/types/client/paths.d.ts +43 -0
  94. package/lib/types/client/pdf-types.d.ts +2 -0
  95. package/lib/types/client/pinned.d.ts +86 -0
  96. package/lib/types/client/plugin-settings.d.ts +8 -0
  97. package/lib/types/client/plugins-shared.d.ts +27 -0
  98. package/lib/types/client/plugins-tabs.d.ts +3 -0
  99. package/lib/types/client/plugins-viewers.d.ts +3 -0
  100. package/lib/types/client/prefs.d.ts +41 -0
  101. package/lib/types/client/produced-files.d.ts +29 -0
  102. package/lib/types/client/selection-payload.d.ts +27 -0
  103. package/lib/types/client/service.d.ts +439 -0
  104. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  105. package/lib/types/client/shell-presets.d.ts +48 -0
  106. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  107. package/lib/types/client/split-pane.d.ts +36 -0
  108. package/lib/types/client/state.d.ts +432 -0
  109. package/lib/types/client/subagent-detect.d.ts +54 -0
  110. package/lib/types/client/subagent-jobs.d.ts +63 -0
  111. package/lib/types/client/tab-content-memo.d.ts +40 -0
  112. package/lib/types/client/terminal-font.d.ts +78 -0
  113. package/lib/types/client/terminal-links.d.ts +119 -0
  114. package/lib/types/client/theme.d.ts +45 -0
  115. package/lib/types/client/titlebar-strip.d.ts +23 -0
  116. package/lib/types/client/upload.d.ts +69 -0
  117. package/lib/types/client/wco.d.ts +47 -0
  118. package/lib/types/config.d.ts +64 -0
  119. package/lib/types/context-types.d.ts +605 -0
  120. package/lib/types/fs-operations.d.ts +28 -0
  121. package/lib/types/fs-search.d.ts +24 -0
  122. package/lib/types/fs-tree.d.ts +51 -0
  123. package/lib/types/git.d.ts +124 -0
  124. package/lib/types/html-route.d.ts +59 -0
  125. package/lib/types/index.d.ts +47 -0
  126. package/lib/types/invariant.d.ts +15 -0
  127. package/lib/types/jobs-routes.d.ts +45 -0
  128. package/lib/types/open-external.d.ts +24 -0
  129. package/lib/types/path-security.d.ts +20 -0
  130. package/lib/types/prefs-shared.d.ts +236 -0
  131. package/lib/types/pty-deps.d.ts +78 -0
  132. package/lib/types/pty-manager.d.ts +168 -0
  133. package/lib/types/sidechat-core.d.ts +172 -0
  134. package/lib/types/sidechat-routes.d.ts +30 -0
  135. package/lib/types/subagent-activity.d.ts +44 -0
  136. package/lib/types/subagent-live-route.d.ts +44 -0
  137. package/lib/types/tools.d.ts +34 -0
  138. package/lib/types/trust-fence.d.ts +24 -0
  139. package/lib/types/wire.d.ts +38 -0
  140. package/package.json +172 -0
  141. package/scripts/install.ps1 +306 -0
  142. package/scripts/install.sh +287 -0
  143. package/src/agent-opens.ts +265 -0
  144. package/src/agent-pty.ts +527 -0
  145. package/src/browser-probe.ts +26 -0
  146. package/src/bundle-route.ts +129 -0
  147. package/src/client/BrowserView.tsx +289 -0
  148. package/src/client/DiffTab.tsx +114 -0
  149. package/src/client/DiffView.tsx +305 -0
  150. package/src/client/EditorHost.tsx +558 -0
  151. package/src/client/FileTree.tsx +669 -0
  152. package/src/client/FreeWindow.tsx +337 -0
  153. package/src/client/GitView.tsx +717 -0
  154. package/src/client/MarkdownHtml.tsx +296 -0
  155. package/src/client/OrphanedTab.tsx +27 -0
  156. package/src/client/PdfView.tsx +110 -0
  157. package/src/client/RenderBoundary.tsx +49 -0
  158. package/src/client/SandboxStatusBar.tsx +60 -0
  159. package/src/client/SideCardSection.module.css +960 -0
  160. package/src/client/SideCardSection.tsx +1163 -0
  161. package/src/client/SideChatView.module.css +437 -0
  162. package/src/client/SideChatView.tsx +645 -0
  163. package/src/client/Sidebar.tsx +1370 -0
  164. package/src/client/SubagentView.module.css +547 -0
  165. package/src/client/SubagentView.tsx +883 -0
  166. package/src/client/TabBar.tsx +365 -0
  167. package/src/client/TerminalView.tsx +424 -0
  168. package/src/client/TextEditor.tsx +468 -0
  169. package/src/client/TreePanel.tsx +275 -0
  170. package/src/client/UploadOverlay.tsx +62 -0
  171. package/src/client/add-plugin-modal.tsx +199 -0
  172. package/src/client/api.ts +357 -0
  173. package/src/client/binary-download.tsx +23 -0
  174. package/src/client/breakpoints.ts +61 -0
  175. package/src/client/browser.ts +167 -0
  176. package/src/client/builtins/index.ts +37 -0
  177. package/src/client/builtins/tabs.tsx +340 -0
  178. package/src/client/builtins/viewers.tsx +125 -0
  179. package/src/client/chunk-loader.ts +358 -0
  180. package/src/client/chunks/editor.tsx +9 -0
  181. package/src/client/chunks/mermaid.tsx +10 -0
  182. package/src/client/chunks/terminal.tsx +9 -0
  183. package/src/client/cm-themes.ts +129 -0
  184. package/src/client/conversation-draft.ts +29 -0
  185. package/src/client/css-modules.d.ts +5 -0
  186. package/src/client/desktop-env.ts +72 -0
  187. package/src/client/editor-load.ts +92 -0
  188. package/src/client/frame-batcher.ts +56 -0
  189. package/src/client/icons.tsx +152 -0
  190. package/src/client/image-types.ts +8 -0
  191. package/src/client/ime-guard.ts +47 -0
  192. package/src/client/index.tsx +427 -0
  193. package/src/client/intercept.tsx +188 -0
  194. package/src/client/lang.ts +225 -0
  195. package/src/client/layout-push.ts +24 -0
  196. package/src/client/layout.css +90 -0
  197. package/src/client/lazy-chunk.tsx +89 -0
  198. package/src/client/link-intercept.ts +73 -0
  199. package/src/client/locales-ar.ts +368 -0
  200. package/src/client/locales-de.ts +353 -0
  201. package/src/client/locales-fr.ts +360 -0
  202. package/src/client/locales-hi.ts +367 -0
  203. package/src/client/locales-id.ts +365 -0
  204. package/src/client/locales-it.ts +358 -0
  205. package/src/client/locales-ja.ts +367 -0
  206. package/src/client/locales-ko.ts +359 -0
  207. package/src/client/locales-nl.ts +365 -0
  208. package/src/client/locales-pl.ts +369 -0
  209. package/src/client/locales-pt.ts +350 -0
  210. package/src/client/locales-ru.ts +365 -0
  211. package/src/client/locales-sv.ts +350 -0
  212. package/src/client/locales-th.ts +367 -0
  213. package/src/client/locales-tr.ts +367 -0
  214. package/src/client/locales-vi.ts +367 -0
  215. package/src/client/locales-zh-HK.ts +382 -0
  216. package/src/client/locales-zh-MO.ts +382 -0
  217. package/src/client/locales-zh-TW.ts +382 -0
  218. package/src/client/locales.ts +894 -0
  219. package/src/client/markdown-html.ts +331 -0
  220. package/src/client/markdown-images.ts +137 -0
  221. package/src/client/markdown-labels.tsx +52 -0
  222. package/src/client/md-toc.tsx +127 -0
  223. package/src/client/mermaid-blocks.ts +110 -0
  224. package/src/client/mermaid-sanitize.ts +93 -0
  225. package/src/client/mermaid.tsx +406 -0
  226. package/src/client/open-when-sized.ts +43 -0
  227. package/src/client/open-with-settings.tsx +135 -0
  228. package/src/client/open-with.ts +221 -0
  229. package/src/client/openpath-intercept.ts +143 -0
  230. package/src/client/paths.ts +63 -0
  231. package/src/client/pdf-types.ts +4 -0
  232. package/src/client/pinned.ts +183 -0
  233. package/src/client/plugin-settings.ts +43 -0
  234. package/src/client/plugins-shared.ts +29 -0
  235. package/src/client/plugins-tabs.ts +96 -0
  236. package/src/client/plugins-viewers.ts +30 -0
  237. package/src/client/prefs.ts +210 -0
  238. package/src/client/produced-files.ts +120 -0
  239. package/src/client/selection-payload.ts +86 -0
  240. package/src/client/service.ts +859 -0
  241. package/src/client/settings-nav-icon.ts +45 -0
  242. package/src/client/shell-presets.ts +83 -0
  243. package/src/client/sidebar.module.css +2788 -0
  244. package/src/client/sidechat-transcript.ts +321 -0
  245. package/src/client/split-pane.tsx +331 -0
  246. package/src/client/state.ts +1497 -0
  247. package/src/client/subagent-detect.ts +137 -0
  248. package/src/client/subagent-jobs.ts +161 -0
  249. package/src/client/tab-content-memo.ts +55 -0
  250. package/src/client/terminal-font.ts +224 -0
  251. package/src/client/terminal-links.ts +194 -0
  252. package/src/client/theme.ts +110 -0
  253. package/src/client/titlebar-strip.ts +37 -0
  254. package/src/client/upload.ts +187 -0
  255. package/src/client/wco.ts +120 -0
  256. package/src/config.ts +154 -0
  257. package/src/context-types.ts +611 -0
  258. package/src/fs-operations.ts +95 -0
  259. package/src/fs-search.ts +113 -0
  260. package/src/fs-tree.ts +158 -0
  261. package/src/git.ts +446 -0
  262. package/src/html-route.ts +106 -0
  263. package/src/index.ts +1291 -0
  264. package/src/invariant.ts +32 -0
  265. package/src/jobs-routes.ts +257 -0
  266. package/src/open-external.ts +90 -0
  267. package/src/path-security.ts +75 -0
  268. package/src/prefs-shared.ts +280 -0
  269. package/src/pty-deps.ts +240 -0
  270. package/src/pty-manager.ts +376 -0
  271. package/src/sidechat-core.ts +468 -0
  272. package/src/sidechat-routes.ts +344 -0
  273. package/src/subagent-activity.ts +91 -0
  274. package/src/subagent-live-route.ts +93 -0
  275. package/src/tools.ts +482 -0
  276. package/src/trust-fence.ts +84 -0
  277. package/src/wire.ts +100 -0
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Markdown/mermaid fence detection for the markdown preview. The preview
3
+ * renders the WHOLE document through one DSH `MarkdownText` pass (so
4
+ * cross-fence semantics — reference-style links, footnotes, list
5
+ * continuity — stay intact) and the mermaid lazy chunk then swaps the
6
+ * rendered `language-mermaid` code blocks for diagrams. This module's pure
7
+ * splitter exists to detect whether the source contains a mermaid fence at
8
+ * all, so the mermaid chunk is only fetched when needed (unit-tested in
9
+ * tests/mermaid-blocks.spec.ts).
10
+ */
11
+
12
+ /** One fenced mermaid diagram lifted out of the markdown source. */
13
+ export interface MermaidBlock {
14
+ kind: 'mermaid'
15
+ /** The raw diagram source between the fences (info string stripped). */
16
+ code: string
17
+ }
18
+
19
+ /** A span of plain markdown source (may itself contain non-mermaid fences). */
20
+ export interface MarkdownBlock {
21
+ kind: 'markdown'
22
+ text: string
23
+ }
24
+
25
+ export type MdBlock = MarkdownBlock | MermaidBlock
26
+
27
+ /** Props of the chunk-resident `MermaidMarkdown` component (shared contract). */
28
+ export interface MermaidMarkdownProps {
29
+ /** The full markdown source (rendered in a single MarkdownText pass). */
30
+ text: string
31
+ codeLabels: { copyLabel: string; copiedLabel: string }
32
+ }
33
+
34
+ /** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
35
+ export const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
36
+
37
+ /** A closing-fence line: 0-3 spaces indent + 3+ backticks/tildes + trailing spaces only. */
38
+ export const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
39
+
40
+ /** Parse the info string from the line tail after the fence run; null when invalid. */
41
+ export function fenceInfo(rest: string, fence: string): string | null {
42
+ const info = rest.trimStart().split(/\s+/)[0] ?? ''
43
+ // CommonMark: a backtick fence's info string may not contain backticks —
44
+ // such a line is not an opening fence at all.
45
+ if (fence.charAt(0) === '`' && info.includes('`')) return null
46
+ return info
47
+ }
48
+
49
+ /** True when the fence info string names mermaid (bare or `mermaid{...}`). */
50
+ function isMermaidInfo(info: string): boolean {
51
+ const word = info.toLowerCase()
52
+ return word === 'mermaid' || word.startsWith('mermaid{')
53
+ }
54
+
55
+ /**
56
+ * Split markdown source into md/mermaid blocks for detection: only fences
57
+ * whose info string names mermaid are lifted; every other line stays in the
58
+ * markdown stream untouched. CommonMark fence rules are honored — opening
59
+ * fences of 3+ backticks OR tildes, and a closing fence must use the same
60
+ * character with at least as many characters as the opening fence. An
61
+ * unterminated mermaid fence swallows the rest of the file (the same
62
+ * recovery CommonMark applies to open fences).
63
+ */
64
+ export function splitMermaidBlocks(text: string): MdBlock[] {
65
+ if (text === '') return []
66
+ const lines = text.split('\n')
67
+ const blocks: MdBlock[] = []
68
+ let markdown: string[] = []
69
+ let index = 0
70
+ const flushMarkdown = (): void => {
71
+ if (markdown.length === 0) return
72
+ blocks.push({ kind: 'markdown', text: markdown.join('\n') })
73
+ markdown = []
74
+ }
75
+ while (index < lines.length) {
76
+ const line = lines[index] ?? ''
77
+ const fenceMatch = OPEN_FENCE_RE.exec(line)
78
+ if (fenceMatch === null) {
79
+ markdown.push(line)
80
+ index += 1
81
+ continue
82
+ }
83
+ const fence = fenceMatch[1]!
84
+ // The line tail after the whole matched prefix (indent + fence run).
85
+ const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
86
+ const info = fenceInfo(rest, fence)
87
+ if (info === null || !isMermaidInfo(info)) {
88
+ markdown.push(line)
89
+ index += 1
90
+ continue
91
+ }
92
+ flushMarkdown()
93
+ const char = fence.charAt(0)
94
+ const length = fence.length
95
+ const code: string[] = []
96
+ index += 1
97
+ while (index < lines.length) {
98
+ const candidate = lines[index] ?? ''
99
+ const close = CLOSE_FENCE_RE.exec(candidate)
100
+ if (close !== null && close[1]!.charAt(0) === char && close[1]!.length >= length) break
101
+ code.push(candidate)
102
+ index += 1
103
+ }
104
+ // Consume the closing fence (or run off the end on an open fence).
105
+ index += 1
106
+ blocks.push({ kind: 'mermaid', code: code.join('\n') })
107
+ }
108
+ flushMarkdown()
109
+ return blocks
110
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * SVG sanitization for mermaid-rendered diagrams (markdown preview). The
3
+ * diagrams come from untrusted markdown sources, so the emitted SVG is
4
+ * re-sanitized before it reaches dangerouslySetInnerHTML — defense in depth
5
+ * on top of mermaid's own `securityLevel: 'strict'` (labels escaped, click
6
+ * directives inert) and `htmlLabels: false` (labels as real SVG <text>).
7
+ *
8
+ * What is stripped, and why:
9
+ * - `foreignObject`: the only channel that can carry raw HTML inside an
10
+ * SVG document — with it gone, a hostile label cannot smuggle an
11
+ * <img onerror> / <iframe> through the XML parse (any non-SVG element
12
+ * outside foreignObject makes the XML parse fail and the whole diagram
13
+ * is rejected).
14
+ * - `script` and foreign HTML elements (`img`/`iframe`/`object`/`embed`/
15
+ * `video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`): belt and
16
+ * braces — real browsers reject these outside foreignObject at XML parse
17
+ * time (→ '') while lenient parsers keep them, so they are stripped
18
+ * explicitly instead of relying on the parser alone. Element names are
19
+ * matched case-insensitively (XML preserves their case; the output is
20
+ * re-parsed as HTML whose parser normalizes tag casing, so `<sCrIpT>` /
21
+ * `<foreignobject>` would otherwise come back as script/foreignObject).
22
+ * - `@*` / `on*` attributes: event-handler channels (Vue-style directives
23
+ * and native listeners), matched case-insensitively — the output is
24
+ * re-parsed as HTML whose parser normalizes attribute casing, so
25
+ * `oNload`/`OnClick` must not survive either.
26
+ * - ALL `href` / `xlink:href` attributes: the diagrams are static (no
27
+ * bindFunctions, no click navigation), so links carry no value — and an
28
+ * attacker-controlled URL (even an http(s) one) could otherwise navigate
29
+ * the whole GUI away from the sidebar. Removing every href keeps the
30
+ * surface deterministic.
31
+ *
32
+ * Parsing happens with `image/svg+xml`, so a malformed SVG fails the XML
33
+ * parse and the function returns '' (the caller surfaces the error
34
+ * fallback) instead of ever passing the raw string through; only a document
35
+ * whose root is an `<svg>` element is accepted.
36
+ */
37
+ /** Element local names stripped case-insensitively (all lowercase). */
38
+ const STRIP_ELEMENTS = new Set([
39
+ 'foreignobject',
40
+ 'script',
41
+ 'img',
42
+ 'iframe',
43
+ 'object',
44
+ 'embed',
45
+ 'video',
46
+ 'audio',
47
+ 'input',
48
+ 'button',
49
+ 'form',
50
+ 'link',
51
+ 'meta',
52
+ 'base',
53
+ ])
54
+
55
+ /** A parse failure keeps nothing of the input: the caller shows the error. */
56
+ export function sanitizeSvg(svg: string): string {
57
+ if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') return ''
58
+ let doc: Document
59
+ try {
60
+ doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
61
+ } catch {
62
+ return ''
63
+ }
64
+ if (doc.querySelector('parsererror') !== null) return ''
65
+ if (doc.documentElement === null || doc.documentElement.localName !== 'svg') return ''
66
+ doc.querySelectorAll('*').forEach((node) => {
67
+ // XML element names preserve case; the string is later re-parsed as HTML
68
+ // whose parser normalizes tag casing — so mixed-case spellings of the
69
+ // strip list (e.g. `<sCrIpT>`, `<foreignobject>`) must be caught the
70
+ // same way as their canonical forms.
71
+ if (STRIP_ELEMENTS.has(node.localName.toLowerCase())) {
72
+ node.remove()
73
+ return
74
+ }
75
+ for (const attribute of [...node.attributes]) {
76
+ const name = attribute.name
77
+ // XML attribute names preserve case; the string is later re-parsed as
78
+ // HTML (dangerouslySetInnerHTML), whose parser normalizes attribute
79
+ // casing — so a mixed-case `oNload`/`HREF` must be caught the same way
80
+ // as its lowercase form. Compare on the lowercased name, remove with
81
+ // the original.
82
+ const normalized = name.toLowerCase()
83
+ if (normalized.startsWith('@') || normalized.startsWith('on')) {
84
+ node.removeAttribute(name)
85
+ continue
86
+ }
87
+ if (normalized === 'href' || normalized === 'xlink:href') {
88
+ node.removeAttribute(name)
89
+ }
90
+ }
91
+ })
92
+ return new XMLSerializer().serializeToString(doc.documentElement)
93
+ }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Mermaid diagram renderer for the markdown preview, resident in the
3
+ * `mermaid` lazy chunk (src/client/chunks/mermaid.tsx): the mermaid library
4
+ * and its transitive graph deps (d3/dagre/cytoscape) are inlined into
5
+ * lib/client-mermaid.js and fetched only when a previewed markdown file
6
+ * actually contains a mermaid fence (see mermaid-blocks.ts).
7
+ *
8
+ * Rendering architecture: the whole document is rendered ONCE through the
9
+ * DSH `MarkdownText` (so cross-fence semantics — reference-style links,
10
+ * footnotes, ordered-list continuity — stay intact), then a layout effect
11
+ * swaps every rendered `language-mermaid` CodeBlock for a diagram. The
12
+ * swap keeps the React-managed `.md-code-block` host node in the tree and
13
+ * only replaces its children (display:contents suppresses the code-block
14
+ * chrome), so React reconciliation never loses the host; when a swapped
15
+ * block stops being a mermaid fence, the original children are restored.
16
+ *
17
+ * Security: mermaid.render → sanitized SVG injected into the block.
18
+ * `bindFunctions` is intentionally NOT applied (static diagrams; no
19
+ * flowchart click handlers), `securityLevel` stays 'strict' (labels are
20
+ * escaped, no raw-HTML foreignObject), `htmlLabels: false` keeps node text
21
+ * as real SVG <text>, and the emitted SVG is re-sanitized (see
22
+ * mermaid-sanitize.ts) before it reaches dangerouslySetInnerHTML. Clicking
23
+ * a rendered diagram opens a zoom/pan modal (borrowed interaction design
24
+ * from the mermaid PR #75, reimplemented on top of the sanitized SVG —
25
+ * the clone carries no event surface, so the modal adds no attack surface).
26
+ */
27
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
28
+ import { createPortal } from 'react-dom'
29
+ import { createRoot, type Root } from 'react-dom/client'
30
+ import mermaid from 'mermaid'
31
+ import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
32
+ import { isDarkScheme, subscribeColorScheme } from './theme.ts'
33
+ import { markdownTextProps } from './markdown-labels.tsx'
34
+ import { t } from './locales.ts'
35
+ import { sanitizeSvg } from './mermaid-sanitize.ts'
36
+ import type { MermaidMarkdownProps } from './mermaid-blocks.ts'
37
+ import css from './sidebar.module.css'
38
+
39
+ /** Monotonic id seed: every render call gets a fresh, document-unique id. */
40
+ let mermaidSeq = 0
41
+
42
+ /** Configure mermaid for the current color scheme (idempotent). */
43
+ function configureMermaid(): void {
44
+ mermaid.initialize({
45
+ startOnLoad: false,
46
+ securityLevel: 'strict',
47
+ // Labels as real SVG <text>: mermaid's default htmlLabels renders node
48
+ // text inside <foreignObject>, which the sanitizer strips wholesale —
49
+ // forcing pure SVG text keeps labels visible and the HTML label channel
50
+ // closed (strict already escapes label content).
51
+ htmlLabels: false,
52
+ // Mermaid 11 renders a large error SVG into document.body before rejecting
53
+ // invalid diagrams. The component already has its own error fallback, so
54
+ // suppress that global side effect to keep the DSH shell intact.
55
+ suppressErrorRendering: true,
56
+ theme: isDarkScheme() ? 'dark' : 'default',
57
+ })
58
+ }
59
+
60
+ /** First lines of a mermaid error (its dumps are huge; the head explains). */
61
+ function summarizeError(error: unknown): string {
62
+ const message = error instanceof Error ? error.message : String(error)
63
+ return message.split('\n').slice(0, 6).join('\n')
64
+ }
65
+
66
+ /** The zoom/pan modal for one rendered diagram (click-to-enlarge). */
67
+ function MermaidZoomModal({ svg, onClose }: { svg: SVGSVGElement; onClose: () => void }): React.ReactNode {
68
+ const overlayRef = useRef<HTMLDivElement>(null)
69
+ const stageRef = useRef<HTMLDivElement>(null)
70
+ const svgRef = useRef<SVGSVGElement | null>(null)
71
+ const dragRef = useRef({ active: false, startX: 0, startY: 0 })
72
+ const zoomRef = useRef({ scale: 1, tx: 0, ty: 0 })
73
+
74
+ const applyTransform = (): void => {
75
+ const node = svgRef.current
76
+ if (node === null) return
77
+ const { scale, tx, ty } = zoomRef.current
78
+ node.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
79
+ }
80
+
81
+ /** Zoom by `delta` keeping the stage point (centerX/centerY) fixed. */
82
+ const zoom = useCallback((delta: number, centerX?: number, centerY?: number): void => {
83
+ const stage = stageRef.current
84
+ if (stage === null) return
85
+ const rect = stage.getBoundingClientRect()
86
+ const cx = centerX ?? rect.width / 2
87
+ const cy = centerY ?? rect.height / 2
88
+ const current = zoomRef.current
89
+ const newScale = Math.min(8, Math.max(0.2, current.scale * delta))
90
+ // The svg is flex-centered in the stage, so its center sits at
91
+ // (rect.width/2, rect.height/2). Solve for the translate that keeps the
92
+ // mouse point stationary in stage coordinates across the scale change.
93
+ const sx = rect.width / 2
94
+ const sy = rect.height / 2
95
+ const ratio = newScale / current.scale
96
+ current.tx = cx - sx - (cx - sx - current.tx) * ratio
97
+ current.ty = cy - sy - (cy - sy - current.ty) * ratio
98
+ current.scale = newScale
99
+ applyTransform()
100
+ }, [])
101
+
102
+ const reset = useCallback((): void => {
103
+ zoomRef.current = { scale: 1, tx: 0, ty: 0 }
104
+ applyTransform()
105
+ }, [])
106
+
107
+ const close = useCallback((): void => { onClose() }, [onClose])
108
+
109
+ // Mount the caller-provided (sanitized) svg clone imperatively: React
110
+ // types don't accept a raw DOM node as a child, and the modal owns the
111
+ // node's lifetime (removed on unmount; the preview copy is untouched).
112
+ useEffect(() => {
113
+ const stage = stageRef.current
114
+ if (stage === null) return
115
+ svgRef.current = svg
116
+ stage.appendChild(svg)
117
+ return () => {
118
+ svg.remove()
119
+ svgRef.current = null
120
+ }
121
+ }, [svg])
122
+
123
+ useEffect(() => {
124
+ const stage = stageRef.current
125
+ const node = svgRef.current
126
+ const overlay = overlayRef.current
127
+ if (stage === null || node === null || overlay === null) return
128
+
129
+ const onWheel = (event: WheelEvent): void => {
130
+ event.preventDefault()
131
+ const rect = stage.getBoundingClientRect()
132
+ zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX - rect.left, event.clientY - rect.top)
133
+ }
134
+ const onKey = (event: KeyboardEvent): void => {
135
+ if (event.key === 'Escape') { close(); return }
136
+ if (event.key === '+' || event.key === '=') zoom(1.2)
137
+ else if (event.key === '-') zoom(1 / 1.2)
138
+ else if (event.key === '0') reset()
139
+ }
140
+ const onMouseDown = (event: MouseEvent): void => {
141
+ event.preventDefault()
142
+ dragRef.current = {
143
+ active: true,
144
+ startX: event.clientX - zoomRef.current.tx,
145
+ startY: event.clientY - zoomRef.current.ty,
146
+ }
147
+ }
148
+ const onMouseMove = (event: MouseEvent): void => {
149
+ if (!dragRef.current.active) return
150
+ zoomRef.current.tx = event.clientX - dragRef.current.startX
151
+ zoomRef.current.ty = event.clientY - dragRef.current.startY
152
+ applyTransform()
153
+ }
154
+ const onMouseUp = (): void => { dragRef.current.active = false }
155
+ const onOverlayClick = (event: MouseEvent): void => {
156
+ if (event.target === overlay) close()
157
+ }
158
+
159
+ // React's synthetic wheel is passive; a native listener is required to
160
+ // preventDefault (the page must not scroll while zooming the modal).
161
+ stage.addEventListener('wheel', onWheel, { passive: false })
162
+ node.addEventListener('mousedown', onMouseDown)
163
+ window.addEventListener('mousemove', onMouseMove)
164
+ window.addEventListener('mouseup', onMouseUp)
165
+ window.addEventListener('keydown', onKey)
166
+ overlay.addEventListener('click', onOverlayClick)
167
+ return () => {
168
+ stage.removeEventListener('wheel', onWheel)
169
+ node.removeEventListener('mousedown', onMouseDown)
170
+ window.removeEventListener('mousemove', onMouseMove)
171
+ window.removeEventListener('mouseup', onMouseUp)
172
+ window.removeEventListener('keydown', onKey)
173
+ overlay.removeEventListener('click', onOverlayClick)
174
+ }
175
+ }, [zoom, reset, close])
176
+
177
+ return createPortal(
178
+ <div className={css.mermaidModal} data-mermaid-modal ref={overlayRef}>
179
+ <div className={css.mermaidModalToolbar}>
180
+ <button
181
+ type="button"
182
+ className={css.mermaidModalButton}
183
+ title={t('mermaidZoomOut')}
184
+ onClick={() => zoom(1 / 1.2)}
185
+ >
186
+
187
+ </button>
188
+ <button
189
+ type="button"
190
+ className={css.mermaidModalButton}
191
+ title={t('mermaidZoomIn')}
192
+ onClick={() => zoom(1.2)}
193
+ >
194
+ +
195
+ </button>
196
+ <button
197
+ type="button"
198
+ className={css.mermaidModalButton}
199
+ title={t('mermaidZoomReset')}
200
+ onClick={reset}
201
+ >
202
+
203
+ </button>
204
+ <button
205
+ type="button"
206
+ className={css.mermaidModalButton}
207
+ title={t('close')}
208
+ onClick={close}
209
+ >
210
+
211
+ </button>
212
+ </div>
213
+ <div className={css.mermaidModalStage} ref={stageRef} />
214
+ <div className={css.mermaidModalHint}>{t('mermaidZoomHint')}</div>
215
+ </div>,
216
+ document.body,
217
+ )
218
+ }
219
+
220
+ /** One rendered mermaid fence: header chrome + diagram (or error + source). */
221
+ function MermaidDiagram({ code }: { code: string }): React.ReactNode {
222
+ const [svg, setSvg] = useState<string | null>(null)
223
+ const [error, setError] = useState<string | null>(null)
224
+ const [copied, setCopied] = useState(false)
225
+ /** Scheme state: a flip re-renders the diagram with the matching theme. */
226
+ const [dark, setDark] = useState(() => isDarkScheme())
227
+ /** The cloned svg shown in the zoom modal (null = closed). */
228
+ const [zoomSvg, setZoomSvg] = useState<SVGSVGElement | null>(null)
229
+ const copyTimer = useRef<number | undefined>(undefined)
230
+
231
+ useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
232
+
233
+ useEffect(() => {
234
+ let cancelled = false
235
+ setSvg(null)
236
+ setError(null)
237
+ if (code.trim() === '') return () => { cancelled = true }
238
+ configureMermaid()
239
+ const id = `dsh-md-mermaid-${mermaidSeq += 1}`
240
+ mermaid.render(id, code)
241
+ .then(({ svg: rendered }) => {
242
+ if (cancelled) return
243
+ const clean = sanitizeSvg(rendered)
244
+ if (clean === '') {
245
+ // Parse/sanitize rejection: never pass the raw string through.
246
+ setError(t('mermaidError'))
247
+ return
248
+ }
249
+ setSvg(clean)
250
+ })
251
+ .catch((reason: unknown) => {
252
+ if (cancelled) return
253
+ setError(summarizeError(reason))
254
+ })
255
+ return () => { cancelled = true }
256
+ }, [code, dark])
257
+
258
+ const onCopy = useCallback(() => {
259
+ if (copied) return
260
+ writeClipboard(code).then((ok) => {
261
+ if (!ok) return
262
+ setCopied(true)
263
+ window.clearTimeout(copyTimer.current)
264
+ copyTimer.current = window.setTimeout(() => { setCopied(false) }, 1000)
265
+ })
266
+ }, [code, copied])
267
+
268
+ /** Clicking the diagram opens the zoom modal with a sanitized clone. */
269
+ const onBodyClick = (event: ReactMouseEvent<HTMLDivElement>): void => {
270
+ const svgEl = (event.target as Element).closest('svg')
271
+ if (svgEl === null) return
272
+ const clone = svgEl.cloneNode(true) as SVGSVGElement
273
+ // The modal owns sizing/transform; drop the preview's inline geometry.
274
+ clone.removeAttribute('style')
275
+ clone.removeAttribute('width')
276
+ clone.removeAttribute('height')
277
+ setZoomSvg(clone)
278
+ }
279
+
280
+ return (
281
+ <div className={css.mermaidWrap}>
282
+ <div className={css.mermaidHeader}>
283
+ <span className={css.mermaidInfo}>mermaid</span>
284
+ <button
285
+ type="button"
286
+ className={css.mermaidCopy}
287
+ onClick={onCopy}
288
+ aria-label={t('copy')}
289
+ title={t('copy')}
290
+ >
291
+ <IconCopyOutline16 />
292
+ <span>{copied ? t('copied') : t('copy')}</span>
293
+ </button>
294
+ </div>
295
+ {error !== null && <div className={css.mermaidError} title={error}>{t('mermaidError')}</div>}
296
+ {svg !== null && (
297
+ <div
298
+ className={css.mermaidBody}
299
+ data-mermaid-diagram
300
+ onClick={onBodyClick}
301
+ dangerouslySetInnerHTML={{ __html: svg }}
302
+ />
303
+ )}
304
+ {error !== null && <pre className={css.mermaidCode}><code>{code}</code></pre>}
305
+ {zoomSvg !== null && <MermaidZoomModal svg={zoomSvg} onClose={() => { setZoomSvg(null) }} />}
306
+ </div>
307
+ )
308
+ }
309
+
310
+ /** One swapped mount: the diagram root + the CodeBlock children it displaced. */
311
+ interface MermaidMount {
312
+ root: Root
313
+ source: string
314
+ removed: ChildNode[]
315
+ }
316
+
317
+ /**
318
+ * True when a rendered CodeBlock is a mermaid fence. The DSH CodeBlock has
319
+ * two bodies: the shiki path carries the `language-*` class on generated
320
+ * <code> elements, while the plain path (which is always the one mermaid
321
+ * takes — no shiki grammar) only shows the language in the banner
322
+ * infostring (first element of the banner row; CSS-module classes are
323
+ * hashed, so that match is structural).
324
+ */
325
+ function isMermaidBlock(block: HTMLElement): boolean {
326
+ const code = block.querySelector('code')
327
+ if (code !== null && [...code.classList].some(c => c.startsWith('language-mermaid'))) return true
328
+ const infostring = block.firstElementChild?.firstElementChild?.firstElementChild
329
+ return infostring !== null
330
+ && infostring !== undefined
331
+ && (infostring.textContent ?? '').trim() === 'mermaid'
332
+ }
333
+
334
+ /**
335
+ * The chunk-resident markdown preview renderer: ONE MarkdownText pass over
336
+ * the full source (cross-fence reference/footnote/list semantics intact),
337
+ * then every rendered `language-mermaid` code block is swapped for a
338
+ * `MermaidDiagram`. The `.md-code-block` host stays in the React tree —
339
+ * only its children are replaced — so reconciliation never loses the host;
340
+ * a block that stops being a mermaid fence gets its original children back.
341
+ * Only mounted when the source contains at least one mermaid fence (see
342
+ * TextEditor.tsx).
343
+ */
344
+ export function MermaidMarkdown({ text, codeLabels }: MermaidMarkdownProps): React.ReactNode {
345
+ const containerRef = useRef<HTMLDivElement>(null)
346
+ const mountsRef = useRef(new Map<HTMLElement, MermaidMount>())
347
+
348
+ useLayoutEffect(() => {
349
+ const container = containerRef.current
350
+ if (container === null) return
351
+ const mounts = mountsRef.current
352
+ const seen = new Set<HTMLElement>()
353
+
354
+ for (const block of container.querySelectorAll<HTMLElement>('.md-code-block')) {
355
+ const mount = mounts.get(block)
356
+ const isMermaid = isMermaidBlock(block)
357
+ if (!isMermaid) {
358
+ // A previously swapped block that is no longer a mermaid fence:
359
+ // restore the CodeBlock children React still manages, so the plain
360
+ // fence renders normally again.
361
+ if (mount !== undefined) {
362
+ mount.root.unmount()
363
+ block.replaceChildren(...mount.removed)
364
+ block.removeAttribute('data-mermaid-processed')
365
+ mounts.delete(block)
366
+ }
367
+ continue
368
+ }
369
+ seen.add(block)
370
+ // The plain body always carries the fence source in <code>.
371
+ const source = block.querySelector('code')?.textContent ?? ''
372
+ if (mount !== undefined && mount.source === source) continue
373
+ if (mount === undefined) {
374
+ const host = document.createElement('div')
375
+ const removed = [...block.childNodes]
376
+ block.replaceChildren(host)
377
+ block.setAttribute('data-mermaid-processed', 'true')
378
+ const root = createRoot(host)
379
+ mounts.set(block, { root, source, removed })
380
+ root.render(<MermaidDiagram code={source} />)
381
+ } else {
382
+ mount.source = source
383
+ mount.root.render(<MermaidDiagram code={source} />)
384
+ }
385
+ }
386
+
387
+ // Drop mounts whose code block left the tree (fence removed, document
388
+ // restructured): the host node is gone with it.
389
+ for (const [block, mount] of mounts) {
390
+ if (seen.has(block)) continue
391
+ mount.root.unmount()
392
+ mounts.delete(block)
393
+ }
394
+ }, [text])
395
+
396
+ useEffect(() => () => {
397
+ for (const { root } of mountsRef.current.values()) root.unmount()
398
+ mountsRef.current.clear()
399
+ }, [])
400
+
401
+ return (
402
+ <div className={css.mermaidMarkdown} ref={containerRef}>
403
+ <MarkdownText {...markdownTextProps(text, codeLabels)} />
404
+ </div>
405
+ )
406
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Deferred one-shot open for hosts that may not have a real size yet.
3
+ *
4
+ * xterm's `Terminal.open()` must not run in a zero-size container: the
5
+ * renderer creation fails there (the DomRenderer is built from the host's
6
+ * dimensions), leaving the render service's renderer `undefined`, and the
7
+ * next Viewport refresh crashes reading `.dimensions` off it. WebKit-based
8
+ * hosts (WKWebView) reliably report zero while the bottom panel's expand
9
+ * slide is in flight; any `display:none`-hidden ancestor does the same.
10
+ *
11
+ * The caller's `open` callback (open + fit + resize) is invoked exactly
12
+ * once, on the first frame where the host reports a real size. While the
13
+ * host stays zero-sized the polling continues every frame; it stops when
14
+ * the host leaves the document (`isConnected`), so a pending open never
15
+ * fires after unmount. The returned cancel function drops a pending frame
16
+ * immediately (idempotent).
17
+ *
18
+ * `raf`/`caf` are injectable so tests can drive the polling deterministically.
19
+ */
20
+ export function openWhenSized(
21
+ host: HTMLElement,
22
+ open: () => void,
23
+ raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
24
+ caf: (id: number) => void = cancelAnimationFrame,
25
+ ): () => void {
26
+ let frame: number | null = null
27
+ const step = (): void => {
28
+ frame = null
29
+ if (!host.isConnected) return
30
+ if (host.clientWidth > 0 && host.clientHeight > 0) {
31
+ open()
32
+ return
33
+ }
34
+ frame = raf(step)
35
+ }
36
+ frame = raf(step)
37
+ return () => {
38
+ if (frame !== null) {
39
+ caf(frame)
40
+ frame = null
41
+ }
42
+ }
43
+ }