dsh-coding-sidebar 1.0.2 → 1.0.4

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 (96) hide show
  1. package/README.md +27 -42
  2. package/lib/client-editor.js +449 -4278
  3. package/lib/client-registry.js +641 -1343
  4. package/lib/client-terminal.js +172 -262
  5. package/lib/client.js +645 -1347
  6. package/lib/index.js +24 -122
  7. package/lib/types/bundle-route.d.ts +1 -1
  8. package/lib/types/client/SideCardSection.d.ts +3 -3
  9. package/lib/types/client/TextEditor.d.ts +20 -10
  10. package/lib/types/client/add-plugin-modal.d.ts +0 -4
  11. package/lib/types/client/api.d.ts +8 -11
  12. package/lib/types/client/builtins/index.d.ts +10 -10
  13. package/lib/types/client/chunk-loader.d.ts +7 -8
  14. package/lib/types/client/locales.d.ts +0 -26
  15. package/lib/types/client/native-avoid.d.ts +2 -0
  16. package/lib/types/client/plugins-shared.d.ts +6 -6
  17. package/lib/types/client/service.d.ts +13 -83
  18. package/lib/types/context-types.d.ts +19 -10
  19. package/lib/types/index.d.ts +1 -1
  20. package/lib/types/prefs-shared.d.ts +1 -24
  21. package/package.json +26 -30
  22. package/src/bundle-route.ts +1 -1
  23. package/src/client/EditorHost.tsx +52 -122
  24. package/src/client/SandboxStatusBar.tsx +12 -9
  25. package/src/client/SideCardSection.module.css +2 -2
  26. package/src/client/SideCardSection.tsx +28 -81
  27. package/src/client/SideChatView.module.css +12 -12
  28. package/src/client/TextEditor.tsx +89 -266
  29. package/src/client/add-plugin-modal.tsx +16 -24
  30. package/src/client/api.ts +1 -25
  31. package/src/client/builtins/index.ts +10 -14
  32. package/src/client/chunk-loader.ts +7 -8
  33. package/src/client/index.tsx +12 -3
  34. package/src/client/locales-ar.ts +0 -6
  35. package/src/client/locales-de.ts +0 -6
  36. package/src/client/locales-fr.ts +0 -6
  37. package/src/client/locales-hi.ts +0 -6
  38. package/src/client/locales-id.ts +0 -6
  39. package/src/client/locales-it.ts +0 -6
  40. package/src/client/locales-ja.ts +0 -6
  41. package/src/client/locales-ko.ts +0 -6
  42. package/src/client/locales-nl.ts +0 -6
  43. package/src/client/locales-pl.ts +0 -6
  44. package/src/client/locales-pt.ts +0 -6
  45. package/src/client/locales-ru.ts +0 -6
  46. package/src/client/locales-sv.ts +0 -6
  47. package/src/client/locales-th.ts +0 -6
  48. package/src/client/locales-tr.ts +0 -6
  49. package/src/client/locales-vi.ts +0 -6
  50. package/src/client/locales-zh-HK.ts +0 -6
  51. package/src/client/locales-zh-MO.ts +0 -6
  52. package/src/client/locales-zh-TW.ts +0 -6
  53. package/src/client/locales.ts +0 -52
  54. package/src/client/native-avoid.ts +155 -0
  55. package/src/client/plugins-shared.ts +6 -6
  56. package/src/client/prefs.ts +0 -7
  57. package/src/client/service.ts +13 -150
  58. package/src/client/sidebar.module.css +1 -425
  59. package/src/config.ts +1 -4
  60. package/src/context-types.ts +12 -7
  61. package/src/index.ts +12 -78
  62. package/src/jobs-routes.ts +3 -2
  63. package/src/prefs-shared.ts +1 -27
  64. package/src/sidechat-routes.ts +13 -8
  65. package/src/subagent-live-route.ts +2 -1
  66. package/lib/client-mermaid.js +0 -200753
  67. package/lib/types/client/MarkdownHtml.d.ts +0 -32
  68. package/lib/types/client/PdfView.d.ts +0 -6
  69. package/lib/types/client/builtins/viewers.d.ts +0 -3
  70. package/lib/types/client/chunks/mermaid.d.ts +0 -10
  71. package/lib/types/client/editor-load.d.ts +0 -66
  72. package/lib/types/client/image-types.d.ts +0 -3
  73. package/lib/types/client/markdown-html.d.ts +0 -96
  74. package/lib/types/client/markdown-images.d.ts +0 -45
  75. package/lib/types/client/md-toc.d.ts +0 -4
  76. package/lib/types/client/mermaid-blocks.d.ts +0 -47
  77. package/lib/types/client/mermaid-sanitize.d.ts +0 -2
  78. package/lib/types/client/mermaid.d.ts +0 -12
  79. package/lib/types/client/pdf-types.d.ts +0 -2
  80. package/lib/types/client/plugins-viewers.d.ts +0 -3
  81. package/lib/types/html-route.d.ts +0 -59
  82. package/src/client/MarkdownHtml.tsx +0 -296
  83. package/src/client/PdfView.tsx +0 -110
  84. package/src/client/builtins/viewers.tsx +0 -125
  85. package/src/client/chunks/mermaid.tsx +0 -10
  86. package/src/client/editor-load.ts +0 -92
  87. package/src/client/image-types.ts +0 -8
  88. package/src/client/markdown-html.ts +0 -331
  89. package/src/client/markdown-images.ts +0 -137
  90. package/src/client/md-toc.tsx +0 -127
  91. package/src/client/mermaid-blocks.ts +0 -110
  92. package/src/client/mermaid-sanitize.ts +0 -93
  93. package/src/client/mermaid.tsx +0 -406
  94. package/src/client/pdf-types.ts +0 -4
  95. package/src/client/plugins-viewers.ts +0 -30
  96. package/src/html-route.ts +0 -106
@@ -1,296 +0,0 @@
1
- /**
2
- * The markdown preview's raw-HTML renderer. `markdown-html.ts` lifts HTML
3
- * runs out of the markdown stream; this module renders them as sanitized DOM
4
- * alongside the markdown runs (which keep flowing through the shared
5
- * `MarkdownText`), nests markdown into unclosed block elements the way
6
- * GitHub's linear HTML output does (`<details>` … fence … `</details>`), and
7
- * runs an inline pass that turns literal tag text inside rendered markdown
8
- * (table cells with `<br/>`, `<sub>`, `<img>`) back into elements.
9
- *
10
- * Security posture: every HTML string (block leaves, inline text, wrapper
11
- * open-tag attributes) goes through DOMPurify with an explicit denylist on
12
- * top of its defaults (no script/style/iframe/forms), anchors are forced to
13
- * open in a new tab with noopener, and local media `src` attributes are
14
- * rewritten through the session-scoped `/sidebar/file` media route — the same
15
- * trust fence the markdown image rewriter (`markdown-images.ts`) uses.
16
- */
17
- import { useLayoutEffect, useMemo, useRef } from 'react'
18
- import { createElement, type ReactNode } from 'react'
19
- import DOMPurify from 'dompurify'
20
- import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
21
- import type { ComponentType } from 'react'
22
- import { markdownTextProps } from './markdown-labels.tsx'
23
- import { lazyChunkComponent } from './lazy-chunk.tsx'
24
- import { resolveLocalMediaDest } from './markdown-images.ts'
25
- import {
26
- analyzeHtmlSegment,
27
- type AnalyzedMarkdownHtml,
28
- } from './markdown-html.ts'
29
- import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
30
- import type { SessionScope } from './api.ts'
31
- import css from './sidebar.module.css'
32
-
33
- /** The chunk-resident markdown renderer (mermaid lazy chunk), shared with the
34
- * legacy no-HTML preview path in TextEditor. */
35
- export const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
36
- 'mermaid',
37
- (mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
38
- )
39
-
40
- /** Everything the sanitizers need to resolve local media + scope the route. */
41
- export interface MarkdownHtmlMedia {
42
- scope: SessionScope
43
- path: string
44
- origin: string
45
- }
46
-
47
- /** Tag-like text in a rendered text node — the inline pass gate. */
48
- const TAGLIKE_TEXT_RE = /<\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]/
49
-
50
- /** Explicit denylist on top of DOMPurify's defaults: no active content, no
51
- * form chrome, no document-level elements inside a preview. */
52
- const PURIFY_FORBID_TAGS = [
53
- 'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button',
54
- 'select', 'textarea', 'meta', 'link', 'base', 'frame', 'frameset', 'applet',
55
- ]
56
- const PURIFY_FORBID_ATTR = ['srcdoc', 'formaction']
57
-
58
- /**
59
- * Post-sanitize hardening on a detached element tree: anchors open in a new
60
- * tab (never navigate the GUI), and local media sources go through the media
61
- * route so they render instead of being dropped by protocol allowlists.
62
- */
63
- function postProcessSanitized(root: Element, media: MarkdownHtmlMedia): void {
64
- for (const anchor of root.querySelectorAll('a[href]')) {
65
- anchor.setAttribute('target', '_blank')
66
- anchor.setAttribute('rel', 'noopener noreferrer')
67
- }
68
- for (const element of root.querySelectorAll('img, video, audio, source')) {
69
- const src = element.getAttribute('src')
70
- if (src === null) continue
71
- element.setAttribute('src', resolveLocalMediaDest(src, media.scope, media.path, media.origin))
72
- }
73
- }
74
-
75
- /** Sanitize one balanced HTML span into markup for dangerouslySetInnerHTML. */
76
- function sanitizeHtmlBlock(source: string, media: MarkdownHtmlMedia): string {
77
- const holder = document.createElement('div')
78
- holder.innerHTML = DOMPurify.sanitize(source, {
79
- FORBID_TAGS: PURIFY_FORBID_TAGS,
80
- FORBID_ATTR: PURIFY_FORBID_ATTR,
81
- })
82
- postProcessSanitized(holder, media)
83
- return holder.innerHTML
84
- }
85
-
86
- /**
87
- * Sanitize literal tag text from a rendered markdown text node. Returns null
88
- * when nothing real survived (pure prose like `a < b` — the DOMPurify output
89
- * has no element children), so the caller leaves the text node untouched.
90
- */
91
- function sanitizeInlineHtml(text: string, media: MarkdownHtmlMedia): string | null {
92
- const holder = document.createElement('span')
93
- holder.innerHTML = DOMPurify.sanitize(text, {
94
- FORBID_TAGS: PURIFY_FORBID_TAGS,
95
- FORBID_ATTR: PURIFY_FORBID_ATTR,
96
- })
97
- if (holder.firstElementChild === null) return null
98
- postProcessSanitized(holder, media)
99
- return holder.innerHTML
100
- }
101
-
102
- /**
103
- * Sanitize a wrapper open tag (`<details open>`) into React props. Returns
104
- * null when DOMPurify dropped the whole tag (denied element) — the renderer
105
- * then treats the wrapper as transparent. `class`/`for` map to their React
106
- * names; `style` is dropped (React needs an object; wrappers with inline
107
- * styles are vanishingly rare and not worth a CSS parser).
108
- */
109
- function sanitizeTagProps(tag: string, attrs: string): Record<string, string> | null {
110
- const probe = DOMPurify.sanitize(`<${tag}${attrs}></${tag}>`, {
111
- FORBID_TAGS: PURIFY_FORBID_TAGS,
112
- FORBID_ATTR: PURIFY_FORBID_ATTR,
113
- })
114
- const holder = document.createElement('div')
115
- holder.innerHTML = probe
116
- const element = holder.firstElementChild
117
- if (element === null || element.tagName.toLowerCase() !== tag) return null
118
- const props: Record<string, string> = {}
119
- for (const attr of element.attributes) {
120
- if (/^on/i.test(attr.name) || !/^[a-zA-Z][a-zA-Z0-9:._-]*$/.test(attr.name)) continue
121
- if (attr.name === 'style') continue
122
- props[attr.name === 'class' ? 'className' : attr.name === 'for' ? 'htmlFor' : attr.name] = attr.value
123
- }
124
- return props
125
- }
126
-
127
- /**
128
- * The inline pass: walk the rendered markdown's text nodes and swap any that
129
- * contain tag-like text for a sanitized `<span data-html-inline>`. Rendered
130
- * code (inline `code`, `pre`, the host `.md-code-block`, mermaid mounts, and
131
- * spans this pass already produced) is skipped. The same commit-then-operate
132
- * pattern the mermaid swap uses: React keeps owning the host tree, only leaf
133
- * text nodes are replaced, and a text change re-renders the subtree fresh.
134
- */
135
- function runInlineHtmlPass(container: HTMLElement, media: MarkdownHtmlMedia): void {
136
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
137
- acceptNode: (node) => {
138
- const parent = (node as Text).parentElement
139
- if (parent === null) return NodeFilter.FILTER_REJECT
140
- if (parent.closest('code, pre, .md-code-block, [data-mermaid-processed], [data-html-inline]')) {
141
- return NodeFilter.FILTER_REJECT
142
- }
143
- return TAGLIKE_TEXT_RE.test((node as Text).data)
144
- ? NodeFilter.FILTER_ACCEPT
145
- : NodeFilter.FILTER_REJECT
146
- },
147
- })
148
- const targets: Text[] = []
149
- for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) targets.push(node as Text)
150
- for (const node of targets) {
151
- const html = sanitizeInlineHtml(node.data, media)
152
- if (html === null) continue
153
- const span = document.createElement('span')
154
- span.setAttribute('data-html-inline', '')
155
- span.innerHTML = html
156
- node.replaceWith(span)
157
- }
158
- }
159
-
160
- interface MarkdownSegmentProps {
161
- text: string
162
- hasMermaid: boolean
163
- media: MarkdownHtmlMedia
164
- codeLabels: { copyLabel: string; copiedLabel: string }
165
- }
166
-
167
- /**
168
- * One markdown run of a split document: the shared MarkdownText pass (or the
169
- * mermaid chunk renderer when the run contains a mermaid fence), plus the
170
- * inline HTML pass. The pass runs after every text change and is re-armed by
171
- * a MutationObserver so it also catches content that appears late (the lazy
172
- * mermaid chunk mounting, shiki highlighting settling) — it is idempotent and
173
- * skips its own output, so mutation feedback settles after one extra pass.
174
- */
175
- function MarkdownSegment({ text, hasMermaid, media, codeLabels }: MarkdownSegmentProps): ReactNode {
176
- const containerRef = useRef<HTMLDivElement>(null)
177
- useLayoutEffect(() => {
178
- const container = containerRef.current
179
- if (container === null) return
180
- runInlineHtmlPass(container, media)
181
- let scheduled = false
182
- const observer = new MutationObserver(() => {
183
- if (scheduled) return
184
- scheduled = true
185
- queueMicrotask(() => {
186
- scheduled = false
187
- runInlineHtmlPass(container, media)
188
- })
189
- })
190
- observer.observe(container, { childList: true, subtree: true })
191
- return () => { observer.disconnect() }
192
- }, [text, media])
193
- return (
194
- <div ref={containerRef}>
195
- {hasMermaid
196
- ? <LazyMermaidMarkdown text={text} codeLabels={codeLabels} />
197
- : <MarkdownText {...markdownTextProps(text, codeLabels)} />}
198
- </div>
199
- )
200
- }
201
-
202
- /** A sanitized, balanced HTML span rendered as its own block. */
203
- function HtmlLeaf({ html }: { html: string }): ReactNode {
204
- return (
205
- <div className={css.editorHtmlBlock} data-dsh-html-segment dangerouslySetInnerHTML={{ __html: html }} />
206
- )
207
- }
208
-
209
- /** One fully-prepared HTML run: sanitized leaves + wrapper opens/closes. */
210
- type PreparedHtmlPart =
211
- | { kind: 'html'; html: string }
212
- | { kind: 'open'; tag: string; props: Record<string, string> | null }
213
- | { kind: 'close' }
214
-
215
- type PreparedSegment =
216
- | { kind: 'markdown'; text: string; hasMermaid: boolean }
217
- | { kind: 'html'; parts: PreparedHtmlPart[] }
218
-
219
- interface MarkdownDocumentProps {
220
- info: AnalyzedMarkdownHtml
221
- media: MarkdownHtmlMedia
222
- codeLabels: { copyLabel: string; copiedLabel: string }
223
- }
224
-
225
- /**
226
- * The split-document renderer: markdown runs render through MarkdownSegment,
227
- * HTML runs render as sanitized leaves, and unclosed block elements lower the
228
- * following runs into themselves until their close part pops the frame (the
229
- * renderer's frame stack persists across segments). Stray closes at the top
230
- * level render nothing (the sanitizer/parser would drop them anyway), and
231
- * frames still open at the end of the document are closed like a browser
232
- * parser would. Sanitization runs once per prepared change, in a memo.
233
- */
234
- export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentProps): ReactNode {
235
- const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
236
- if (segment.kind === 'markdown') {
237
- const defs = info.referenceDefinitions
238
- const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
239
- return {
240
- kind: 'markdown',
241
- text,
242
- hasMermaid: splitMermaidBlocks(text).some((block) => block.kind === 'mermaid'),
243
- }
244
- }
245
- return {
246
- kind: 'html',
247
- parts: analyzeHtmlSegment(segment.text).parts.map((part): PreparedHtmlPart => {
248
- if (part.kind === 'html') return { kind: 'html', html: sanitizeHtmlBlock(part.html, media) }
249
- if (part.kind === 'open') return { kind: 'open', tag: part.tag, props: sanitizeTagProps(part.tag, part.attrs) }
250
- return { kind: 'close' }
251
- }),
252
- }
253
- // `media` is a memoized object in the host (TextEditor); identity tracks
254
- // scope/path/origin changes so sanitization re-runs exactly when needed.
255
- }), [info, media])
256
-
257
- const nodes: ReactNode[] = []
258
- const frames: { tag: string; props: Record<string, string> | null; children: ReactNode[] }[] = []
259
- const emit = (node: ReactNode): void => {
260
- const frame = frames[frames.length - 1]
261
- if (frame !== undefined) frame.children.push(node)
262
- else nodes.push(node)
263
- }
264
- let key = 0
265
- for (const segment of prepared) {
266
- if (segment.kind === 'markdown') {
267
- emit(<MarkdownSegment key={`md-${key += 1}`} text={segment.text} hasMermaid={segment.hasMermaid} media={media} codeLabels={codeLabels} />)
268
- continue
269
- }
270
- for (const part of segment.parts) {
271
- if (part.kind === 'html') {
272
- if (part.html.trim() === '') continue
273
- emit(<HtmlLeaf key={`html-${key += 1}`} html={part.html} />)
274
- } else if (part.kind === 'open') {
275
- frames.push({ tag: part.tag, props: part.props, children: [] })
276
- } else {
277
- const frame = frames.pop()
278
- if (frame === undefined) continue
279
- if (frame.props === null) {
280
- for (const child of frame.children) emit(child)
281
- } else {
282
- emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
283
- }
284
- }
285
- }
286
- }
287
- while (frames.length > 0) {
288
- const frame = frames.pop()!
289
- if (frame.props === null) {
290
- for (const child of frame.children) emit(child)
291
- } else {
292
- emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
293
- }
294
- }
295
- return <>{nodes}</>
296
- }
@@ -1,110 +0,0 @@
1
- /** Browser-native PDF preview with an always-available download fallback. */
2
- import { useEffect, useRef, useState } from 'react'
3
- import clsx from 'clsx'
4
- import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
5
- import { t } from './locales.ts'
6
- import css from './sidebar.module.css'
7
-
8
- export function PdfView(props: { scope: SessionScope; path: string; title: string }) {
9
- const { scope, path, title } = props
10
- const [load, setLoad] = useState<
11
- | { status: 'loading' }
12
- | { status: 'ready'; url: string }
13
- | { status: 'error'; message: string }
14
- >({ status: 'loading' })
15
- const [interactionBlocked, setInteractionBlocked] = useState(false)
16
- const frameRef = useRef<HTMLIFrameElement>(null)
17
- const shieldRef = useRef<HTMLDivElement>(null)
18
-
19
- useEffect(() => {
20
- const controller = new AbortController()
21
- let objectUrl: string | undefined
22
- setLoad({ status: 'loading' })
23
- void (async () => {
24
- try {
25
- const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
26
- if (!response.ok) throw new Error(`HTTP ${response.status}`)
27
- const bytes = await response.arrayBuffer()
28
- if (controller.signal.aborted) return
29
- // A direct iframe navigation may download when an old host process or
30
- // proxy cached application/octet-stream. The Blob URL owns an explicit
31
- // PDF MIME and therefore consistently opens the browser PDF viewer.
32
- objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
33
- setLoad({ status: 'ready', url: objectUrl })
34
- } catch (error) {
35
- if (controller.signal.aborted) return
36
- setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
37
- }
38
- })()
39
- return () => {
40
- controller.abort()
41
- if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
42
- }
43
- }, [scope.sessionId, scope.cwd, path])
44
-
45
- useEffect(() => {
46
- const block = (): void => {
47
- setInteractionBlocked(true)
48
- if (frameRef.current !== null) frameRef.current.style.pointerEvents = 'none'
49
- if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'auto'
50
- }
51
- const unblock = (): void => {
52
- setInteractionBlocked(false)
53
- if (frameRef.current !== null) frameRef.current.style.pointerEvents = ''
54
- if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'none'
55
- }
56
- const blockForResize = (event: PointerEvent): void => {
57
- const target = event.target
58
- if (target instanceof Element
59
- && target.closest(`.${css.panelResize}, .${css.divider}`) !== null) {
60
- block()
61
- }
62
- }
63
- // An iframe is a separate browsing context and otherwise swallows the
64
- // dragover/pointer stream as soon as the cursor crosses it. Temporarily
65
- // remove it from hit-testing while a tab drag or pane resize is active.
66
- document.addEventListener('dragstart', block, true)
67
- document.addEventListener('dragend', unblock, true)
68
- document.addEventListener('drop', unblock, true)
69
- window.addEventListener('pointerdown', blockForResize, true)
70
- window.addEventListener('pointerup', unblock, true)
71
- window.addEventListener('pointercancel', unblock, true)
72
- window.addEventListener('blur', unblock)
73
- return () => {
74
- document.removeEventListener('dragstart', block, true)
75
- document.removeEventListener('dragend', unblock, true)
76
- document.removeEventListener('drop', unblock, true)
77
- window.removeEventListener('pointerdown', blockForResize, true)
78
- window.removeEventListener('pointerup', unblock, true)
79
- window.removeEventListener('pointercancel', unblock, true)
80
- window.removeEventListener('blur', unblock)
81
- }
82
- }, [])
83
-
84
- return (
85
- <div className={css.editorPdf}>
86
- <div className={css.editorPdfToolbar}>
87
- <a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
88
- {t('downloadToView')}
89
- </a>
90
- </div>
91
- <div className={css.editorPdfStage}>
92
- {load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
93
- {load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
94
- {load.status === 'ready' && (
95
- <iframe
96
- ref={frameRef}
97
- className={clsx(css.editorPdfFrame, interactionBlocked && css.editorPdfFrameBlocked)}
98
- src={load.url}
99
- title={title}
100
- />
101
- )}
102
- <div
103
- ref={shieldRef}
104
- className={clsx(css.editorPdfDragShield, interactionBlocked && css.editorPdfDragShieldActive)}
105
- aria-hidden="true"
106
- />
107
- </div>
108
- </div>
109
- )
110
- }
@@ -1,125 +0,0 @@
1
- /**
2
- * The 6 built-in file viewer descriptors: every preview surface is a
3
- * registered viewer (image / pdf / markdown / html / code /
4
- * binary-download), exactly like external plugins register theirs. Office
5
- * previews (.docx / .xlsx / .pptx) are NOT built in anymore — they moved to
6
- * the recommended office plugin (see plugins-viewers.ts), which registers
7
- * the same ids through this service.
8
- *
9
- * The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
10
- * binaries and serves legacy doc/xls/ppt by extension; `code` is the
11
- * catch-all (`exts: []`, lowest priority) that claims any file no other
12
- * viewer did.
13
- *
14
- * The heavy viewers (the CodeMirror-backed markdown/html/code) render
15
- * through {@link lazyChunkComponent} wrappers — their libraries are fetched
16
- * only when such a file is first opened (see chunk-loader.ts). The
17
- * descriptor metadata (id/exts/priority/detect) is identical either way,
18
- * so matching semantics and external-plugin overrides are unaffected; the
19
- * `component` wrapper keeps the descriptor contract `(props) => ReactNode`.
20
- *
21
- * Every viewer carries the declarative settings-surface fields — `title`
22
- * and `icon` — so the Side card settings page can render the enable/disable
23
- * inventory without hardcoding (eating our own dogfood).
24
- */
25
- import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
26
- import { lazyChunkComponent } from '../lazy-chunk.tsx'
27
- import { PdfView } from '../PdfView.tsx'
28
- import { BinaryDownload } from '../binary-download.tsx'
29
- import {
30
- IconImageOutline16,
31
- IconMarkdownOutline16,
32
- IconPdfOutline16,
33
- IconHtmlOutline16,
34
- } from '../icons.tsx'
35
- import type { ComponentType } from 'react'
36
- import type { FileViewerDescriptor, FileViewerProps } from '../service.ts'
37
- import { t } from '../locales.ts'
38
- import css from '../sidebar.module.css'
39
-
40
- /**
41
- * Lazy wrapper over the chunk-resident viewer component. The `pick`
42
- * function is module-level (stable identity — the wrapper effect depends
43
- * on it); the cast bridges the chunk exports record to the descriptor prop
44
- * shape (the view reads only its own subset of FileViewerProps).
45
- */
46
- const LazyTextEditor = lazyChunkComponent<FileViewerProps>('editor', (mod) => mod.TextEditor as ComponentType<FileViewerProps> | undefined)
47
-
48
- /** The 6 built-in file viewer descriptors. */
49
- export function builtinViewers(): readonly FileViewerDescriptor[] {
50
- return [
51
- {
52
- id: 'image',
53
- title: () => t('viewerImage'),
54
- icon: (size: number) => <IconImageOutline16 size={size} />,
55
- exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
56
- fetchStrategy: 'mediaUrl',
57
- component: ({ mediaUrl: url, title }) => (
58
- <div className={css.editorImageWrap}>
59
- <img className={css.editorImage} src={url} alt={title} />
60
- </div>
61
- ),
62
- },
63
- {
64
- id: 'pdf',
65
- title: () => t('viewerPdf'),
66
- icon: (size: number) => <IconPdfOutline16 size={size} />,
67
- exts: ['pdf'],
68
- fetchStrategy: 'mediaUrl',
69
- component: ({ scope, path, title }) => (
70
- <PdfView scope={scope} path={path} title={title} />
71
- ),
72
- },
73
- {
74
- id: 'markdown',
75
- title: () => t('viewerMarkdown'),
76
- icon: (size: number) => <IconMarkdownOutline16 size={size} />,
77
- exts: ['md', 'markdown'],
78
- fetchStrategy: 'fsRead',
79
- component: (props) => <LazyTextEditor {...props} />,
80
- },
81
- {
82
- id: 'html',
83
- title: () => t('viewerHtml'),
84
- icon: (size: number) => <IconHtmlOutline16 size={size} />,
85
- exts: ['html', 'htm'],
86
- fetchStrategy: 'fsRead',
87
- // Declarative settings: the sandbox escape hatch and the default-unsafe
88
- // start state render under this viewer's row in the Side card settings
89
- // page (both warned on).
90
- settings: {
91
- toggles: [{
92
- key: 'htmlViewerNoSandbox',
93
- title: () => t('settingsHtmlSandboxTitle'),
94
- desc: () => t('settingsHtmlSandboxDesc'),
95
- }, {
96
- key: 'htmlViewerDefaultUnsafe',
97
- title: () => t('settingsHtmlDefaultUnsafeTitle'),
98
- desc: () => t('settingsHtmlDefaultUnsafeDesc'),
99
- }],
100
- },
101
- component: (props) => <LazyTextEditor {...props} />,
102
- },
103
- {
104
- id: 'code',
105
- title: () => t('viewerCode'),
106
- icon: (size: number) => <IconCodeOutline16 size={size} />,
107
- exts: [],
108
- priority: -100,
109
- fetchStrategy: 'fsRead',
110
- component: (props) => <LazyTextEditor {...props} />,
111
- },
112
- {
113
- id: 'binary-download',
114
- title: () => t('viewerBinary'),
115
- icon: (size: number) => <IconDownloadOutline16 size={size} />,
116
- exts: ['doc', 'xls', 'ppt'],
117
- priority: -50,
118
- fetchStrategy: 'binary-download',
119
- // NUL probe: a file whose head bytes contain a NUL is binary — claimed
120
- // before the catch-all code viewer on the head re-match.
121
- detect: (_path, head) => head.includes(0),
122
- component: ({ scope, path }) => <BinaryDownload scope={scope} path={path} />,
123
- },
124
- ]
125
- }
@@ -1,10 +0,0 @@
1
- /**
2
- * Lazy chunk entry: the mermaid diagram renderer for the markdown preview
3
- * (mermaid + its d3/dagre/cytoscape graph deps). Built as
4
- * lib/client-mermaid.js and registered under the `mermaid` global chunk
5
- * slot — fetched only when a previewed markdown file contains a mermaid
6
- * fence (see chunk-loader.ts and the mermaid markdown preview design doc).
7
- * Never import this module from the core bundle: it pulls mermaid into the
8
- * startup path.
9
- */
10
- export { MermaidMarkdown } from '../mermaid.tsx'
@@ -1,92 +0,0 @@
1
- /**
2
- * Pure editor-load planning: the decision logic the editor host needs to
3
- * turn a matched file viewer + a host fs.read result into a render action.
4
- * Kept dependency-free (no React, no fetch) so the strategy dispatch is
5
- * unit-testable and the wire contract (head bytes, binary flag) is pinned.
6
- *
7
- * The host flow this module drives:
8
- * 1. `matchFileViewer(path)` picks a viewer by extension/priority.
9
- * 2. `planFirstMatch` dispatches its fetchStrategy.
10
- * 3. An fsRead viewer fetches through the host; `planFsReadOutcome`
11
- * decides what to do with the result — including the head-based
12
- * re-match that lets a `detect` viewer claim a binary the extension
13
- * match could not see (the builtin NUL probe on `binary-download`).
14
- */
15
- import type { FileViewerDescriptor } from './service.ts'
16
-
17
- /** One host fs.read result (mirror of the wire; `head` present when binary). */
18
- export interface FsReadOutcome {
19
- binary: boolean
20
- content: string
21
- truncated: boolean
22
- /** base64 of the first bytes (present on binary reads; sniffing material). */
23
- head?: string
24
- }
25
-
26
- /** What the editor host should do next. */
27
- export type EditorLoadAction =
28
- /** No renderer: show the download UI. */
29
- | { kind: 'binary' }
30
- /** Render `viewer`'s component with the carried payload. */
31
- | { kind: 'render'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
32
- /** Fetch the file through the host (fsRead strategy). */
33
- | { kind: 'fetchFsRead'; viewer: FileViewerDescriptor }
34
- /** Call the viewer's load() and render with its return value. */
35
- | { kind: 'customLoad'; viewer: FileViewerDescriptor }
36
-
37
- /** Decode the host's base64 head bytes into the sniffing buffer. */
38
- export function decodeHead(headBase64: string): Uint8Array {
39
- const binary = atob(headBase64)
40
- const bytes = new Uint8Array(binary.length)
41
- for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
42
- return bytes
43
- }
44
-
45
- /**
46
- * Dispatch one matched viewer's fetchStrategy. A missing viewer or a
47
- * `binary-download` strategy both mean "no client-side renderer" → the
48
- * download UI. `mediaUrlOf` builds the media URL for `mediaUrl`/`none`
49
- * strategies (pure, but scope-bound — injected by the host).
50
- */
51
- export function planFirstMatch(
52
- viewer: FileViewerDescriptor | undefined,
53
- mediaUrlOf: () => string,
54
- ): EditorLoadAction {
55
- if (viewer === undefined || viewer.fetchStrategy === 'binary-download') return { kind: 'binary' }
56
- switch (viewer.fetchStrategy) {
57
- case 'mediaUrl':
58
- case 'none':
59
- return { kind: 'render', viewer, mediaUrl: mediaUrlOf() }
60
- case 'custom':
61
- return { kind: 'customLoad', viewer }
62
- case 'fsRead':
63
- return { kind: 'fetchFsRead', viewer }
64
- }
65
- }
66
-
67
- /**
68
- * Decide what an fsRead result means for the editor.
69
- * - Text: the first match stands (content is valid for any fsRead viewer).
70
- * - Binary: the host head bytes enable a re-match — a `detect` viewer (e.g.
71
- * a plugin sniffing a binary format) may claim the file. `custom` viewers
72
- * load their own bytes; `mediaUrl`/`none` viewers render the media route;
73
- * an fsRead viewer or nothing cannot render binary → download UI.
74
- */
75
- export function planFsReadOutcome(
76
- viewer: FileViewerDescriptor,
77
- result: FsReadOutcome,
78
- rematch: (head: Uint8Array) => FileViewerDescriptor | undefined,
79
- mediaUrlOf: () => string,
80
- ): EditorLoadAction {
81
- if (!result.binary) {
82
- return { kind: 'render', viewer, content: result.content, truncated: result.truncated }
83
- }
84
- const claimed = result.head === undefined ? undefined : rematch(decodeHead(result.head))
85
- if (claimed !== undefined && claimed.fetchStrategy === 'custom') {
86
- return { kind: 'customLoad', viewer: claimed }
87
- }
88
- if (claimed !== undefined && (claimed.fetchStrategy === 'mediaUrl' || claimed.fetchStrategy === 'none')) {
89
- return { kind: 'render', viewer: claimed, mediaUrl: mediaUrlOf() }
90
- }
91
- return { kind: 'binary' }
92
- }
@@ -1,8 +0,0 @@
1
- /** Image extension dispatch, separate from React for unit testing. */
2
- export const IMAGE_EXTENSIONS = [
3
- '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif',
4
- ] as readonly string[]
5
-
6
- export function isImageExt(ext: string): boolean {
7
- return IMAGE_EXTENSIONS.includes(ext)
8
- }