@keith9681/dsh-sidebar 0.15.2

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 (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -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 +22 -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 +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. 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
+ 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
+ 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
+ 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,401 @@
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 { t } from './locales.ts'
34
+ import { sanitizeSvg } from './mermaid-sanitize.ts'
35
+ import type { MermaidMarkdownProps } from './mermaid-blocks.ts'
36
+ import css from './sidebar.module.css'
37
+
38
+ /** Monotonic id seed: every render call gets a fresh, document-unique id. */
39
+ let mermaidSeq = 0
40
+
41
+ /** Configure mermaid for the current color scheme (idempotent). */
42
+ function configureMermaid(): void {
43
+ mermaid.initialize({
44
+ startOnLoad: false,
45
+ securityLevel: 'strict',
46
+ // Labels as real SVG <text>: mermaid's default htmlLabels renders node
47
+ // text inside <foreignObject>, which the sanitizer strips wholesale —
48
+ // forcing pure SVG text keeps labels visible and the HTML label channel
49
+ // closed (strict already escapes label content).
50
+ htmlLabels: false,
51
+ theme: isDarkScheme() ? 'dark' : 'default',
52
+ })
53
+ }
54
+
55
+ /** First lines of a mermaid error (its dumps are huge; the head explains). */
56
+ function summarizeError(error: unknown): string {
57
+ const message = error instanceof Error ? error.message : String(error)
58
+ return message.split('\n').slice(0, 6).join('\n')
59
+ }
60
+
61
+ /** The zoom/pan modal for one rendered diagram (click-to-enlarge). */
62
+ function MermaidZoomModal({ svg, onClose }: { svg: SVGSVGElement; onClose: () => void }): React.ReactNode {
63
+ const overlayRef = useRef<HTMLDivElement>(null)
64
+ const stageRef = useRef<HTMLDivElement>(null)
65
+ const svgRef = useRef<SVGSVGElement | null>(null)
66
+ const dragRef = useRef({ active: false, startX: 0, startY: 0 })
67
+ const zoomRef = useRef({ scale: 1, tx: 0, ty: 0 })
68
+
69
+ const applyTransform = (): void => {
70
+ const node = svgRef.current
71
+ if (node === null) return
72
+ const { scale, tx, ty } = zoomRef.current
73
+ node.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
74
+ }
75
+
76
+ /** Zoom by `delta` keeping the stage point (centerX/centerY) fixed. */
77
+ const zoom = useCallback((delta: number, centerX?: number, centerY?: number): void => {
78
+ const stage = stageRef.current
79
+ if (stage === null) return
80
+ const rect = stage.getBoundingClientRect()
81
+ const cx = centerX ?? rect.width / 2
82
+ const cy = centerY ?? rect.height / 2
83
+ const current = zoomRef.current
84
+ const newScale = Math.min(8, Math.max(0.2, current.scale * delta))
85
+ // The svg is flex-centered in the stage, so its center sits at
86
+ // (rect.width/2, rect.height/2). Solve for the translate that keeps the
87
+ // mouse point stationary in stage coordinates across the scale change.
88
+ const sx = rect.width / 2
89
+ const sy = rect.height / 2
90
+ const ratio = newScale / current.scale
91
+ current.tx = cx - sx - (cx - sx - current.tx) * ratio
92
+ current.ty = cy - sy - (cy - sy - current.ty) * ratio
93
+ current.scale = newScale
94
+ applyTransform()
95
+ }, [])
96
+
97
+ const reset = useCallback((): void => {
98
+ zoomRef.current = { scale: 1, tx: 0, ty: 0 }
99
+ applyTransform()
100
+ }, [])
101
+
102
+ const close = useCallback((): void => { onClose() }, [onClose])
103
+
104
+ // Mount the caller-provided (sanitized) svg clone imperatively: React
105
+ // types don't accept a raw DOM node as a child, and the modal owns the
106
+ // node's lifetime (removed on unmount; the preview copy is untouched).
107
+ useEffect(() => {
108
+ const stage = stageRef.current
109
+ if (stage === null) return
110
+ svgRef.current = svg
111
+ stage.appendChild(svg)
112
+ return () => {
113
+ svg.remove()
114
+ svgRef.current = null
115
+ }
116
+ }, [svg])
117
+
118
+ useEffect(() => {
119
+ const stage = stageRef.current
120
+ const node = svgRef.current
121
+ const overlay = overlayRef.current
122
+ if (stage === null || node === null || overlay === null) return
123
+
124
+ const onWheel = (event: WheelEvent): void => {
125
+ event.preventDefault()
126
+ const rect = stage.getBoundingClientRect()
127
+ zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX - rect.left, event.clientY - rect.top)
128
+ }
129
+ const onKey = (event: KeyboardEvent): void => {
130
+ if (event.key === 'Escape') { close(); return }
131
+ if (event.key === '+' || event.key === '=') zoom(1.2)
132
+ else if (event.key === '-') zoom(1 / 1.2)
133
+ else if (event.key === '0') reset()
134
+ }
135
+ const onMouseDown = (event: MouseEvent): void => {
136
+ event.preventDefault()
137
+ dragRef.current = {
138
+ active: true,
139
+ startX: event.clientX - zoomRef.current.tx,
140
+ startY: event.clientY - zoomRef.current.ty,
141
+ }
142
+ }
143
+ const onMouseMove = (event: MouseEvent): void => {
144
+ if (!dragRef.current.active) return
145
+ zoomRef.current.tx = event.clientX - dragRef.current.startX
146
+ zoomRef.current.ty = event.clientY - dragRef.current.startY
147
+ applyTransform()
148
+ }
149
+ const onMouseUp = (): void => { dragRef.current.active = false }
150
+ const onOverlayClick = (event: MouseEvent): void => {
151
+ if (event.target === overlay) close()
152
+ }
153
+
154
+ // React's synthetic wheel is passive; a native listener is required to
155
+ // preventDefault (the page must not scroll while zooming the modal).
156
+ stage.addEventListener('wheel', onWheel, { passive: false })
157
+ node.addEventListener('mousedown', onMouseDown)
158
+ window.addEventListener('mousemove', onMouseMove)
159
+ window.addEventListener('mouseup', onMouseUp)
160
+ window.addEventListener('keydown', onKey)
161
+ overlay.addEventListener('click', onOverlayClick)
162
+ return () => {
163
+ stage.removeEventListener('wheel', onWheel)
164
+ node.removeEventListener('mousedown', onMouseDown)
165
+ window.removeEventListener('mousemove', onMouseMove)
166
+ window.removeEventListener('mouseup', onMouseUp)
167
+ window.removeEventListener('keydown', onKey)
168
+ overlay.removeEventListener('click', onOverlayClick)
169
+ }
170
+ }, [zoom, reset, close])
171
+
172
+ return createPortal(
173
+ <div className={css.mermaidModal} data-mermaid-modal ref={overlayRef}>
174
+ <div className={css.mermaidModalToolbar}>
175
+ <button
176
+ type="button"
177
+ className={css.mermaidModalButton}
178
+ title={t('mermaidZoomOut')}
179
+ onClick={() => zoom(1 / 1.2)}
180
+ >
181
+
182
+ </button>
183
+ <button
184
+ type="button"
185
+ className={css.mermaidModalButton}
186
+ title={t('mermaidZoomIn')}
187
+ onClick={() => zoom(1.2)}
188
+ >
189
+ +
190
+ </button>
191
+ <button
192
+ type="button"
193
+ className={css.mermaidModalButton}
194
+ title={t('mermaidZoomReset')}
195
+ onClick={reset}
196
+ >
197
+
198
+ </button>
199
+ <button
200
+ type="button"
201
+ className={css.mermaidModalButton}
202
+ title={t('close')}
203
+ onClick={close}
204
+ >
205
+
206
+ </button>
207
+ </div>
208
+ <div className={css.mermaidModalStage} ref={stageRef} />
209
+ <div className={css.mermaidModalHint}>{t('mermaidZoomHint')}</div>
210
+ </div>,
211
+ document.body,
212
+ )
213
+ }
214
+
215
+ /** One rendered mermaid fence: header chrome + diagram (or error + source). */
216
+ function MermaidDiagram({ code }: { code: string }): React.ReactNode {
217
+ const [svg, setSvg] = useState<string | null>(null)
218
+ const [error, setError] = useState<string | null>(null)
219
+ const [copied, setCopied] = useState(false)
220
+ /** Scheme state: a flip re-renders the diagram with the matching theme. */
221
+ const [dark, setDark] = useState(() => isDarkScheme())
222
+ /** The cloned svg shown in the zoom modal (null = closed). */
223
+ const [zoomSvg, setZoomSvg] = useState<SVGSVGElement | null>(null)
224
+ const copyTimer = useRef<number | undefined>(undefined)
225
+
226
+ useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
227
+
228
+ useEffect(() => {
229
+ let cancelled = false
230
+ setSvg(null)
231
+ setError(null)
232
+ if (code.trim() === '') return () => { cancelled = true }
233
+ configureMermaid()
234
+ const id = `dsh-md-mermaid-${mermaidSeq += 1}`
235
+ mermaid.render(id, code)
236
+ .then(({ svg: rendered }) => {
237
+ if (cancelled) return
238
+ const clean = sanitizeSvg(rendered)
239
+ if (clean === '') {
240
+ // Parse/sanitize rejection: never pass the raw string through.
241
+ setError(t('mermaidError'))
242
+ return
243
+ }
244
+ setSvg(clean)
245
+ })
246
+ .catch((reason: unknown) => {
247
+ if (cancelled) return
248
+ setError(summarizeError(reason))
249
+ })
250
+ return () => { cancelled = true }
251
+ }, [code, dark])
252
+
253
+ const onCopy = useCallback(() => {
254
+ if (copied) return
255
+ writeClipboard(code).then((ok) => {
256
+ if (!ok) return
257
+ setCopied(true)
258
+ window.clearTimeout(copyTimer.current)
259
+ copyTimer.current = window.setTimeout(() => { setCopied(false) }, 1000)
260
+ })
261
+ }, [code, copied])
262
+
263
+ /** Clicking the diagram opens the zoom modal with a sanitized clone. */
264
+ const onBodyClick = (event: ReactMouseEvent<HTMLDivElement>): void => {
265
+ const svgEl = (event.target as Element).closest('svg')
266
+ if (svgEl === null) return
267
+ const clone = svgEl.cloneNode(true) as SVGSVGElement
268
+ // The modal owns sizing/transform; drop the preview's inline geometry.
269
+ clone.removeAttribute('style')
270
+ clone.removeAttribute('width')
271
+ clone.removeAttribute('height')
272
+ setZoomSvg(clone)
273
+ }
274
+
275
+ return (
276
+ <div className={css.mermaidWrap}>
277
+ <div className={css.mermaidHeader}>
278
+ <span className={css.mermaidInfo}>mermaid</span>
279
+ <button
280
+ type="button"
281
+ className={css.mermaidCopy}
282
+ onClick={onCopy}
283
+ aria-label={t('copy')}
284
+ title={t('copy')}
285
+ >
286
+ <IconCopyOutline16 />
287
+ <span>{copied ? t('copied') : t('copy')}</span>
288
+ </button>
289
+ </div>
290
+ {error !== null && <div className={css.mermaidError} title={error}>{t('mermaidError')}</div>}
291
+ {svg !== null && (
292
+ <div
293
+ className={css.mermaidBody}
294
+ data-mermaid-diagram
295
+ onClick={onBodyClick}
296
+ dangerouslySetInnerHTML={{ __html: svg }}
297
+ />
298
+ )}
299
+ {error !== null && <pre className={css.mermaidCode}><code>{code}</code></pre>}
300
+ {zoomSvg !== null && <MermaidZoomModal svg={zoomSvg} onClose={() => { setZoomSvg(null) }} />}
301
+ </div>
302
+ )
303
+ }
304
+
305
+ /** One swapped mount: the diagram root + the CodeBlock children it displaced. */
306
+ interface MermaidMount {
307
+ root: Root
308
+ source: string
309
+ removed: ChildNode[]
310
+ }
311
+
312
+ /**
313
+ * True when a rendered CodeBlock is a mermaid fence. The DSH CodeBlock has
314
+ * two bodies: the shiki path carries the `language-*` class on generated
315
+ * <code> elements, while the plain path (which is always the one mermaid
316
+ * takes — no shiki grammar) only shows the language in the banner
317
+ * infostring (first element of the banner row; CSS-module classes are
318
+ * hashed, so that match is structural).
319
+ */
320
+ function isMermaidBlock(block: HTMLElement): boolean {
321
+ const code = block.querySelector('code')
322
+ if (code !== null && [...code.classList].some(c => c.startsWith('language-mermaid'))) return true
323
+ const infostring = block.firstElementChild?.firstElementChild?.firstElementChild
324
+ return infostring !== null
325
+ && infostring !== undefined
326
+ && (infostring.textContent ?? '').trim() === 'mermaid'
327
+ }
328
+
329
+ /**
330
+ * The chunk-resident markdown preview renderer: ONE MarkdownText pass over
331
+ * the full source (cross-fence reference/footnote/list semantics intact),
332
+ * then every rendered `language-mermaid` code block is swapped for a
333
+ * `MermaidDiagram`. The `.md-code-block` host stays in the React tree —
334
+ * only its children are replaced — so reconciliation never loses the host;
335
+ * a block that stops being a mermaid fence gets its original children back.
336
+ * Only mounted when the source contains at least one mermaid fence (see
337
+ * TextEditor.tsx).
338
+ */
339
+ export function MermaidMarkdown({ text, codeLabels }: MermaidMarkdownProps): React.ReactNode {
340
+ const containerRef = useRef<HTMLDivElement>(null)
341
+ const mountsRef = useRef(new Map<HTMLElement, MermaidMount>())
342
+
343
+ useLayoutEffect(() => {
344
+ const container = containerRef.current
345
+ if (container === null) return
346
+ const mounts = mountsRef.current
347
+ const seen = new Set<HTMLElement>()
348
+
349
+ for (const block of container.querySelectorAll<HTMLElement>('.md-code-block')) {
350
+ const mount = mounts.get(block)
351
+ const isMermaid = isMermaidBlock(block)
352
+ if (!isMermaid) {
353
+ // A previously swapped block that is no longer a mermaid fence:
354
+ // restore the CodeBlock children React still manages, so the plain
355
+ // fence renders normally again.
356
+ if (mount !== undefined) {
357
+ mount.root.unmount()
358
+ block.replaceChildren(...mount.removed)
359
+ block.removeAttribute('data-mermaid-processed')
360
+ mounts.delete(block)
361
+ }
362
+ continue
363
+ }
364
+ seen.add(block)
365
+ // The plain body always carries the fence source in <code>.
366
+ const source = block.querySelector('code')?.textContent ?? ''
367
+ if (mount !== undefined && mount.source === source) continue
368
+ if (mount === undefined) {
369
+ const host = document.createElement('div')
370
+ const removed = [...block.childNodes]
371
+ block.replaceChildren(host)
372
+ block.setAttribute('data-mermaid-processed', 'true')
373
+ const root = createRoot(host)
374
+ mounts.set(block, { root, source, removed })
375
+ root.render(<MermaidDiagram code={source} />)
376
+ } else {
377
+ mount.source = source
378
+ mount.root.render(<MermaidDiagram code={source} />)
379
+ }
380
+ }
381
+
382
+ // Drop mounts whose code block left the tree (fence removed, document
383
+ // restructured): the host node is gone with it.
384
+ for (const [block, mount] of mounts) {
385
+ if (seen.has(block)) continue
386
+ mount.root.unmount()
387
+ mounts.delete(block)
388
+ }
389
+ }, [text])
390
+
391
+ useEffect(() => () => {
392
+ for (const { root } of mountsRef.current.values()) root.unmount()
393
+ mountsRef.current.clear()
394
+ }, [])
395
+
396
+ return (
397
+ <div className={css.mermaidMarkdown} ref={containerRef}>
398
+ <MarkdownText text={text} codeLabels={codeLabels} />
399
+ </div>
400
+ )
401
+ }
@@ -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
+ }