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.
- package/README.md +27 -42
- package/lib/client-editor.js +449 -4278
- package/lib/client-registry.js +641 -1343
- package/lib/client-terminal.js +172 -262
- package/lib/client.js +645 -1347
- package/lib/index.js +24 -122
- package/lib/types/bundle-route.d.ts +1 -1
- package/lib/types/client/SideCardSection.d.ts +3 -3
- package/lib/types/client/TextEditor.d.ts +20 -10
- package/lib/types/client/add-plugin-modal.d.ts +0 -4
- package/lib/types/client/api.d.ts +8 -11
- package/lib/types/client/builtins/index.d.ts +10 -10
- package/lib/types/client/chunk-loader.d.ts +7 -8
- package/lib/types/client/locales.d.ts +0 -26
- package/lib/types/client/native-avoid.d.ts +2 -0
- package/lib/types/client/plugins-shared.d.ts +6 -6
- package/lib/types/client/service.d.ts +13 -83
- package/lib/types/context-types.d.ts +19 -10
- package/lib/types/index.d.ts +1 -1
- package/lib/types/prefs-shared.d.ts +1 -24
- package/package.json +26 -30
- package/src/bundle-route.ts +1 -1
- package/src/client/EditorHost.tsx +52 -122
- package/src/client/SandboxStatusBar.tsx +12 -9
- package/src/client/SideCardSection.module.css +2 -2
- package/src/client/SideCardSection.tsx +28 -81
- package/src/client/SideChatView.module.css +12 -12
- package/src/client/TextEditor.tsx +89 -266
- package/src/client/add-plugin-modal.tsx +16 -24
- package/src/client/api.ts +1 -25
- package/src/client/builtins/index.ts +10 -14
- package/src/client/chunk-loader.ts +7 -8
- package/src/client/index.tsx +12 -3
- package/src/client/locales-ar.ts +0 -6
- package/src/client/locales-de.ts +0 -6
- package/src/client/locales-fr.ts +0 -6
- package/src/client/locales-hi.ts +0 -6
- package/src/client/locales-id.ts +0 -6
- package/src/client/locales-it.ts +0 -6
- package/src/client/locales-ja.ts +0 -6
- package/src/client/locales-ko.ts +0 -6
- package/src/client/locales-nl.ts +0 -6
- package/src/client/locales-pl.ts +0 -6
- package/src/client/locales-pt.ts +0 -6
- package/src/client/locales-ru.ts +0 -6
- package/src/client/locales-sv.ts +0 -6
- package/src/client/locales-th.ts +0 -6
- package/src/client/locales-tr.ts +0 -6
- package/src/client/locales-vi.ts +0 -6
- package/src/client/locales-zh-HK.ts +0 -6
- package/src/client/locales-zh-MO.ts +0 -6
- package/src/client/locales-zh-TW.ts +0 -6
- package/src/client/locales.ts +0 -52
- package/src/client/native-avoid.ts +155 -0
- package/src/client/plugins-shared.ts +6 -6
- package/src/client/prefs.ts +0 -7
- package/src/client/service.ts +13 -150
- package/src/client/sidebar.module.css +1 -425
- package/src/config.ts +1 -4
- package/src/context-types.ts +12 -7
- package/src/index.ts +12 -78
- package/src/jobs-routes.ts +3 -2
- package/src/prefs-shared.ts +1 -27
- package/src/sidechat-routes.ts +13 -8
- package/src/subagent-live-route.ts +2 -1
- package/lib/client-mermaid.js +0 -200753
- package/lib/types/client/MarkdownHtml.d.ts +0 -32
- package/lib/types/client/PdfView.d.ts +0 -6
- package/lib/types/client/builtins/viewers.d.ts +0 -3
- package/lib/types/client/chunks/mermaid.d.ts +0 -10
- package/lib/types/client/editor-load.d.ts +0 -66
- package/lib/types/client/image-types.d.ts +0 -3
- package/lib/types/client/markdown-html.d.ts +0 -96
- package/lib/types/client/markdown-images.d.ts +0 -45
- package/lib/types/client/md-toc.d.ts +0 -4
- package/lib/types/client/mermaid-blocks.d.ts +0 -47
- package/lib/types/client/mermaid-sanitize.d.ts +0 -2
- package/lib/types/client/mermaid.d.ts +0 -12
- package/lib/types/client/pdf-types.d.ts +0 -2
- package/lib/types/client/plugins-viewers.d.ts +0 -3
- package/lib/types/html-route.d.ts +0 -59
- package/src/client/MarkdownHtml.tsx +0 -296
- package/src/client/PdfView.tsx +0 -110
- package/src/client/builtins/viewers.tsx +0 -125
- package/src/client/chunks/mermaid.tsx +0 -10
- package/src/client/editor-load.ts +0 -92
- package/src/client/image-types.ts +0 -8
- package/src/client/markdown-html.ts +0 -331
- package/src/client/markdown-images.ts +0 -137
- package/src/client/md-toc.tsx +0 -127
- package/src/client/mermaid-blocks.ts +0 -110
- package/src/client/mermaid-sanitize.ts +0 -93
- package/src/client/mermaid.tsx +0 -406
- package/src/client/pdf-types.ts +0 -4
- package/src/client/plugins-viewers.ts +0 -30
- 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
|
-
}
|
package/src/client/PdfView.tsx
DELETED
|
@@ -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
|
-
}
|