@lijian-ui/dsh-file-manager 0.1.20

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 (169) hide show
  1. package/LICENSE +38 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +66 -0
  4. package/README.zh.md +66 -0
  5. package/cordis.patch.yml +13 -0
  6. package/lib/assets/mermaid.min.js +3587 -0
  7. package/lib/client.js +7621 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +3207 -0
  10. package/lib/tsconfig.client.tsbuildinfo +1 -0
  11. package/lib/tsconfig.host.tsbuildinfo +1 -0
  12. package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
  13. package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
  14. package/lib/types/client/PluginSettingsCard.d.ts +104 -0
  15. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  16. package/lib/types/client/api.d.ts +75 -0
  17. package/lib/types/client/api.d.ts.map +1 -0
  18. package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
  19. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
  20. package/lib/types/client/chat/file-ref.d.ts +35 -0
  21. package/lib/types/client/chat/file-ref.d.ts.map +1 -0
  22. package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
  23. package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
  24. package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
  25. package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
  26. package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
  27. package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
  28. package/lib/types/client/components/FileIcon.d.ts +15 -0
  29. package/lib/types/client/components/FileIcon.d.ts.map +1 -0
  30. package/lib/types/client/components/ScmPanel.d.ts +23 -0
  31. package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
  32. package/lib/types/client/components/a11y.d.ts +12 -0
  33. package/lib/types/client/components/a11y.d.ts.map +1 -0
  34. package/lib/types/client/components/icons.d.ts +41 -0
  35. package/lib/types/client/components/icons.d.ts.map +1 -0
  36. package/lib/types/client/components/overlay.d.ts +45 -0
  37. package/lib/types/client/components/overlay.d.ts.map +1 -0
  38. package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
  39. package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
  40. package/lib/types/client/drag/file-drag.d.ts +47 -0
  41. package/lib/types/client/drag/file-drag.d.ts.map +1 -0
  42. package/lib/types/client/drag.d.ts +31 -0
  43. package/lib/types/client/drag.d.ts.map +1 -0
  44. package/lib/types/client/file-source.d.ts +29 -0
  45. package/lib/types/client/file-source.d.ts.map +1 -0
  46. package/lib/types/client/fileType.d.ts +31 -0
  47. package/lib/types/client/fileType.d.ts.map +1 -0
  48. package/lib/types/client/floating.d.ts +27 -0
  49. package/lib/types/client/floating.d.ts.map +1 -0
  50. package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
  51. package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
  52. package/lib/types/client/hooks/useStore.d.ts +10 -0
  53. package/lib/types/client/hooks/useStore.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +57 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/layout.d.ts +104 -0
  57. package/lib/types/client/layout.d.ts.map +1 -0
  58. package/lib/types/client/locales.d.ts +125 -0
  59. package/lib/types/client/locales.d.ts.map +1 -0
  60. package/lib/types/client/maximize.d.ts +24 -0
  61. package/lib/types/client/maximize.d.ts.map +1 -0
  62. package/lib/types/client/mention.d.ts +44 -0
  63. package/lib/types/client/mention.d.ts.map +1 -0
  64. package/lib/types/client/mount.d.ts +22 -0
  65. package/lib/types/client/mount.d.ts.map +1 -0
  66. package/lib/types/client/persist.d.ts +55 -0
  67. package/lib/types/client/persist.d.ts.map +1 -0
  68. package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
  69. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
  70. package/lib/types/client/picker/file-picker.d.ts +62 -0
  71. package/lib/types/client/picker/file-picker.d.ts.map +1 -0
  72. package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
  73. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
  74. package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
  75. package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
  76. package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
  77. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
  78. package/lib/types/client/preview/content.d.ts +43 -0
  79. package/lib/types/client/preview/content.d.ts.map +1 -0
  80. package/lib/types/client/preview/markdown.d.ts +60 -0
  81. package/lib/types/client/preview/markdown.d.ts.map +1 -0
  82. package/lib/types/client/preview/mermaid.d.ts +78 -0
  83. package/lib/types/client/preview/mermaid.d.ts.map +1 -0
  84. package/lib/types/client/preview/selection.d.ts +26 -0
  85. package/lib/types/client/preview/selection.d.ts.map +1 -0
  86. package/lib/types/client/reference.d.ts +75 -0
  87. package/lib/types/client/reference.d.ts.map +1 -0
  88. package/lib/types/client/settings-form.d.ts +206 -0
  89. package/lib/types/client/settings-form.d.ts.map +1 -0
  90. package/lib/types/client/store.d.ts +279 -0
  91. package/lib/types/client/store.d.ts.map +1 -0
  92. package/lib/types/core/types.d.ts +132 -0
  93. package/lib/types/core/types.d.ts.map +1 -0
  94. package/lib/types/host/fs-service.d.ts +96 -0
  95. package/lib/types/host/fs-service.d.ts.map +1 -0
  96. package/lib/types/host/gate.d.ts +45 -0
  97. package/lib/types/host/gate.d.ts.map +1 -0
  98. package/lib/types/host/git-runner.d.ts +80 -0
  99. package/lib/types/host/git-runner.d.ts.map +1 -0
  100. package/lib/types/host/git-service.d.ts +130 -0
  101. package/lib/types/host/git-service.d.ts.map +1 -0
  102. package/lib/types/host/loopback.d.ts +25 -0
  103. package/lib/types/host/loopback.d.ts.map +1 -0
  104. package/lib/types/host/poll-guard.d.ts +55 -0
  105. package/lib/types/host/poll-guard.d.ts.map +1 -0
  106. package/lib/types/host/routes.d.ts +54 -0
  107. package/lib/types/host/routes.d.ts.map +1 -0
  108. package/lib/types/index.d.ts +35 -0
  109. package/lib/types/index.d.ts.map +1 -0
  110. package/lib/types/mount-once.d.ts +25 -0
  111. package/lib/types/mount-once.d.ts.map +1 -0
  112. package/package.json +85 -0
  113. package/src/client/FileManagerSettingsCard.tsx +117 -0
  114. package/src/client/PluginSettingsCard.tsx +337 -0
  115. package/src/client/api.ts +141 -0
  116. package/src/client/chat/file-ref.ts +144 -0
  117. package/src/client/chat/mermaid-chat.tsx +102 -0
  118. package/src/client/chat/placeholder-hint.tsx +46 -0
  119. package/src/client/components/ExplorerPanel.tsx +534 -0
  120. package/src/client/components/FileIcon.tsx +46 -0
  121. package/src/client/components/ScmPanel.tsx +480 -0
  122. package/src/client/components/a11y.ts +20 -0
  123. package/src/client/components/icons.tsx +274 -0
  124. package/src/client/components/overlay.tsx +225 -0
  125. package/src/client/css-modules.d.ts +5 -0
  126. package/src/client/drag/DragFileInlay.tsx +89 -0
  127. package/src/client/drag/file-drag.ts +67 -0
  128. package/src/client/drag.ts +154 -0
  129. package/src/client/file-source.ts +58 -0
  130. package/src/client/fileType.ts +129 -0
  131. package/src/client/floating.ts +63 -0
  132. package/src/client/hooks/useResizableSplit.ts +91 -0
  133. package/src/client/hooks/useStore.ts +15 -0
  134. package/src/client/index.ts +389 -0
  135. package/src/client/layout.ts +546 -0
  136. package/src/client/locales.ts +247 -0
  137. package/src/client/maximize.ts +34 -0
  138. package/src/client/mount.tsx +74 -0
  139. package/src/client/persist.ts +194 -0
  140. package/src/client/picker/FilePickerModal.tsx +506 -0
  141. package/src/client/picker/file-picker.ts +86 -0
  142. package/src/client/preview/PreviewPanel.tsx +233 -0
  143. package/src/client/preview/PreviewTabs.tsx +159 -0
  144. package/src/client/preview/PreviewToolbar.tsx +160 -0
  145. package/src/client/preview/content.tsx +617 -0
  146. package/src/client/preview/markdown.ts +351 -0
  147. package/src/client/preview/mermaid.ts +274 -0
  148. package/src/client/preview/selection.ts +82 -0
  149. package/src/client/reference.ts +161 -0
  150. package/src/client/settings-card.module.css +316 -0
  151. package/src/client/settings-form.ts +451 -0
  152. package/src/client/store.ts +1290 -0
  153. package/src/client/styles/chip.module.css +38 -0
  154. package/src/client/styles/drag.module.css +30 -0
  155. package/src/client/styles/explorer.module.css +371 -0
  156. package/src/client/styles/picker.module.css +344 -0
  157. package/src/client/styles/preview.module.css +904 -0
  158. package/src/client/styles/scm.module.css +380 -0
  159. package/src/client/styles/tokens.module.css +480 -0
  160. package/src/core/types.ts +162 -0
  161. package/src/host/fs-service.ts +513 -0
  162. package/src/host/gate.ts +74 -0
  163. package/src/host/git-runner.ts +116 -0
  164. package/src/host/git-service.ts +395 -0
  165. package/src/host/loopback.ts +63 -0
  166. package/src/host/poll-guard.ts +109 -0
  167. package/src/host/routes.ts +695 -0
  168. package/src/index.ts +97 -0
  169. package/src/mount-once.ts +48 -0
@@ -0,0 +1,617 @@
1
+ /**
2
+ * Preview content routing: the renderers for every content type plus the
3
+ * split-screen editor|preview layout. View mode (source/preview) resets to
4
+ * preview when the displayed FILE changes (keyed on path+type, not tab id —
5
+ * FileManager contract), and the split ratio is persisted under
6
+ * preview-panel-split-ratio with a 20..80 clamp.
7
+ * @module dsh-filemgr/client/preview/content
8
+ */
9
+
10
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
11
+ import type { JSX } from 'react'
12
+ import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
13
+ import type { PreviewTabState } from '../store.ts'
14
+ import { useResizableSplit } from '../hooks/useResizableSplit.ts'
15
+ import { t } from '../locales.ts'
16
+ import { renderMarkdown, resolveMarkdownImage } from './markdown.ts'
17
+ import {
18
+ DATA_MD_SCOPE,
19
+ enhanceMermaidBlocks,
20
+ mermaidTheme,
21
+ rethemeMermaidBlocks,
22
+ shellIsDark,
23
+ watchShellTheme,
24
+ } from './mermaid.ts'
25
+ import previewCss from '../styles/preview.module.css'
26
+
27
+ /** Split-ratio persistence key (FileManager contract). */
28
+ export const KEY_SPLIT_RATIO = 'preview-panel-split-ratio'
29
+
30
+ /** The rendered content of one tab (viewMode/split are controlled by the panel). */
31
+ export function TabContent({
32
+ tab,
33
+ viewMode,
34
+ split,
35
+ onContentChange,
36
+ onSave,
37
+ }: {
38
+ tab: PreviewTabState
39
+ viewMode: 'source' | 'preview'
40
+ split: boolean
41
+ onContentChange: (content: string) => void
42
+ onSave: () => void
43
+ }): JSX.Element {
44
+ if (tab.error !== null) {
45
+ return <div className={previewCss.placeholder}>
46
+ <div className={previewCss.placeholderTitle}>{tab.title}</div>
47
+ <div className={previewCss.placeholderError}>{tab.error}</div>
48
+ </div>
49
+ }
50
+
51
+ const editable = tab.contentType === 'markdown' || tab.contentType === 'html'
52
+ || tab.contentType === 'code' || tab.contentType === 'csv' || tab.contentType === 'text'
53
+
54
+ // Split screen: editable types only; editor | preview with a ratio handle.
55
+ if (split && editable) {
56
+ return (
57
+ <SplitPane
58
+ tab={tab}
59
+ onContentChange={onContentChange}
60
+ onSave={onSave}
61
+ />
62
+ )
63
+ }
64
+
65
+ return (
66
+ <div className={previewCss.content}>
67
+ {tab.truncated && tab.content !== null && (
68
+ <div className={previewCss.truncatedNote}>{t('preview.errorOversized')}</div>
69
+ )}
70
+ {tab.contentType === 'markdown' && tab.content !== null && (
71
+ <MarkdownViewer
72
+ content={tab.content}
73
+ root={tab.root}
74
+ path={tab.path}
75
+ sourceMode={viewMode === 'source'}
76
+ onContentChange={onContentChange}
77
+ />
78
+ )}
79
+ {tab.contentType === 'html' && tab.content !== null && (
80
+ <HtmlViewer
81
+ content={tab.content}
82
+ sourceMode={viewMode === 'source'}
83
+ onContentChange={onContentChange}
84
+ />
85
+ )}
86
+ {(tab.contentType === 'code' || tab.contentType === 'text') && tab.content !== null && (
87
+ <CodeViewer content={tab.content} language={tab.title.split('.').pop() ?? ''} />
88
+ )}
89
+ {tab.contentType === 'csv' && tab.content !== null && <CsvViewer content={tab.content} />}
90
+ {tab.contentType === 'diff' && tab.content !== null && <DiffViewer content={tab.content} />}
91
+ {tab.contentType === 'image' && tab.content !== null && (
92
+ <ImageViewer src={tab.content} meta={`${tab.image?.width ?? ''}${tab.image ? ' x ' : ''}${tab.image?.height ?? ''}`} />
93
+ )}
94
+ {tab.contentType === 'pdf' && tab.content !== null && <PdfViewer dataUrl={tab.content} title={tab.title} />}
95
+ {tab.contentType === 'url' && <UrlViewer tab={tab} />}
96
+ {(tab.contentType === 'word' || tab.contentType === 'excel' || tab.contentType === 'ppt' || tab.contentType === 'unsupported') && (
97
+ <UnsupportedViewer tab={tab} />
98
+ )}
99
+ {tab.content === null && !tab.loading && (
100
+ <div className={previewCss.placeholder}>
101
+ <div className={previewCss.placeholderTitle}>{tab.title}</div>
102
+ <div className={previewCss.placeholderMeta}>{t('preview.downloadHint')}</div>
103
+ </div>
104
+ )}
105
+ {tab.loading && <div className={previewCss.placeholder}>{t('scm.loading')}</div>}
106
+ </div>
107
+ )
108
+ }
109
+
110
+ /** Split screen: textarea editor | rendered preview, ratio persisted. */
111
+ function SplitPane({
112
+ tab,
113
+ onContentChange,
114
+ onSave,
115
+ }: {
116
+ tab: PreviewTabState
117
+ onContentChange: (content: string) => void
118
+ onSave: () => void
119
+ }): JSX.Element {
120
+ const { width: splitRatio, handleProps } = useResizableSplit({
121
+ unit: 'ratio',
122
+ defaultWidth: 50,
123
+ minWidth: 20,
124
+ maxWidth: 80,
125
+ storageKey: KEY_SPLIT_RATIO,
126
+ })
127
+ const content = tab.content ?? ''
128
+
129
+ return (
130
+ <div className={previewCss.splitPane}>
131
+ <div className={previewCss.splitPaneLeft} style={{ width: `${splitRatio}%` }}>
132
+ <div className={previewCss.splitHeader}>{t('preview.editor')}</div>
133
+ <div className={previewCss.splitBody}>
134
+ <textarea
135
+ className={previewCss.textEditor}
136
+ value={content}
137
+ spellCheck={false}
138
+ onChange={(event) => onContentChange(event.target.value)}
139
+ onKeyDown={(event) => {
140
+ if ((event.metaKey || event.ctrlKey) && event.key === 's') {
141
+ event.preventDefault()
142
+ onSave()
143
+ }
144
+ }}
145
+ />
146
+ </div>
147
+ </div>
148
+ <div
149
+ className={previewCss.splitHandle}
150
+ data-reverse="false"
151
+ style={{ left: `calc(${splitRatio}% - 6px)` }}
152
+ {...handleProps}
153
+ />
154
+ <div className={previewCss.splitPaneRight} style={{ width: `${100 - splitRatio}%` }}>
155
+ <div className={previewCss.splitHeader}>{t('preview.preview')}</div>
156
+ <div className={previewCss.splitBody}>
157
+ {tab.contentType === 'markdown' && <MarkdownViewer content={content} root={tab.root} path={tab.path} />}
158
+ {tab.contentType === 'html' && <HtmlViewer content={content} />}
159
+ {tab.contentType === 'csv' && <CsvViewer content={content} />}
160
+ {tab.contentType === 'code' && <CodeViewer content={content} language={tab.title.split('.').pop() ?? ''} />}
161
+ </div>
162
+ </div>
163
+ </div>
164
+ )
165
+ }
166
+
167
+ /** Markdown viewer with an optional source mode (textarea). */
168
+ function MarkdownViewer({
169
+ content,
170
+ root,
171
+ path,
172
+ sourceMode = false,
173
+ onContentChange,
174
+ }: {
175
+ content: string
176
+ /** Project root of the markdown file (image srcs resolve against it). */
177
+ root: string
178
+ /** The markdown file's workspace-relative path (image dir base). */
179
+ path: string
180
+ sourceMode?: boolean
181
+ onContentChange?: (content: string) => void
182
+ }): JSX.Element {
183
+ const resolveImageSrc = useCallback((src: string): string | null => {
184
+ if (root === '' || path === '') return null
185
+ const resolution = resolveMarkdownImage(path, src)
186
+ if (resolution.kind === 'absolute') return src
187
+ if (resolution.kind === 'escape') return null
188
+ // Workspace-relative target: serve the bytes through the host raw route
189
+ // (same origin as the GUI), preserving any ?query#fragment suffix.
190
+ return `/filemgr/raw?root=${encodeURIComponent(root)}&path=${encodeURIComponent(resolution.path)}${resolution.suffix}`
191
+ }, [root, path])
192
+ const html = useMemo(
193
+ () => renderMarkdown(content, { resolveImageSrc }),
194
+ [content, resolveImageSrc],
195
+ )
196
+ if (sourceMode && onContentChange !== undefined) {
197
+ return (
198
+ <div className={previewCss.content}>
199
+ <textarea
200
+ className={previewCss.textEditor}
201
+ value={content}
202
+ spellCheck={false}
203
+ onChange={(event) => onContentChange(event.target.value)}
204
+ />
205
+ </div>
206
+ )
207
+ }
208
+ return <MermaidAwareMarkdown html={html} />
209
+ }
210
+
211
+ /**
212
+ * Rendered markdown body plus the mermaid enhancement lifecycle: fresh
213
+ * blocks render once per html, completed diagrams re-render on shell theme
214
+ * flips. The scope marker lets the chat-transcript enhancer skip this
215
+ * subtree (each surface owns its blocks).
216
+ */
217
+ function MermaidAwareMarkdown({ html }: { html: string }): JSX.Element {
218
+ const ref = useRef<HTMLDivElement | null>(null)
219
+ useEffect(() => {
220
+ const el = ref.current
221
+ if (el === null) return undefined
222
+ void enhanceMermaidBlocks(el, { className: previewCss.mermaidBlock, theme: mermaidTheme(shellIsDark()) })
223
+ return watchShellTheme((isDark) => {
224
+ void rethemeMermaidBlocks(el, { theme: mermaidTheme(isDark) })
225
+ })
226
+ }, [html])
227
+ return (
228
+ <div
229
+ ref={ref}
230
+ className={previewCss.mdViewer}
231
+ {...{ [DATA_MD_SCOPE]: '1' }}
232
+ dangerouslySetInnerHTML={{ __html: html }}
233
+ />
234
+ )
235
+ }
236
+
237
+ /** HTML viewer: sandboxed iframe (scripts off) or source textarea. */
238
+ function HtmlViewer({
239
+ content,
240
+ sourceMode = false,
241
+ onContentChange,
242
+ }: {
243
+ content: string
244
+ sourceMode?: boolean
245
+ onContentChange?: (content: string) => void
246
+ }): JSX.Element {
247
+ const srcDoc = useMemo(() => {
248
+ // Base styles so the embedded page inherits the theme background.
249
+ return `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;font-family:-apple-system,"system-ui","Segoe UI",Roboto,"PingFang SC",sans-serif;color:#1d2129}@media (prefers-color-scheme:dark){body{color:rgba(255,255,255,0.9)}}</style></head><body>${content}</body></html>`
250
+ }, [content])
251
+ if (sourceMode && onContentChange !== undefined) {
252
+ return (
253
+ <div className={previewCss.content}>
254
+ <textarea
255
+ className={previewCss.textEditor}
256
+ value={content}
257
+ spellCheck={false}
258
+ onChange={(event) => onContentChange(event.target.value)}
259
+ />
260
+ </div>
261
+ )
262
+ }
263
+ return <iframe className={previewCss.pdfViewer} srcDoc={srcDoc} sandbox="" title="html preview" />
264
+ }
265
+
266
+ /**
267
+ * Syntax-highlighted code/text viewer (official shiki core via CodeBlock),
268
+ * wrapped in a line-number gutter.
269
+ *
270
+ * Alignment strategy: instead of trusting CSS to mirror the code block's
271
+ * internal metrics (its language banner, `pre` padding and line-height are
272
+ * owned by the primitives package), the gutter is measured against the live
273
+ * `pre` — its top offset inside the shell (the banner's height) and its
274
+ * computed line-height are read after render and applied as inline styles.
275
+ * This keeps the numbers exactly on their lines regardless of the primitives'
276
+ * internal layout. The trailing newline is not counted (CodeBlock trims it).
277
+ */
278
+ export function CodeViewer({ content, language }: { content: string; language: string }): JSX.Element {
279
+ const shellRef = useRef<HTMLDivElement | null>(null)
280
+ const [gutterStyle, setGutterStyle] = useState<{ paddingTop: number; lineHeight: number } | null>(null)
281
+
282
+ const lineCount = useMemo(() => {
283
+ const trimmed = content.endsWith('\n') ? content.slice(0, -1) : content
284
+ return trimmed === '' ? 1 : trimmed.split('\n').length
285
+ }, [content])
286
+ const numbers = useMemo(() => {
287
+ const rows: number[] = []
288
+ for (let i = 1; i <= lineCount; i += 1) rows.push(i)
289
+ return rows
290
+ }, [lineCount])
291
+
292
+ // Measure the live `pre` and align the gutter (banner offset + line height).
293
+ useEffect(() => {
294
+ const shell = shellRef.current
295
+ if (shell === null) return
296
+ const measure = (): void => {
297
+ const pre = shell.querySelector('pre')
298
+ if (pre === null) {
299
+ setGutterStyle(null)
300
+ return
301
+ }
302
+ const shellRect = shell.getBoundingClientRect()
303
+ const preRect = pre.getBoundingClientRect()
304
+ const preStyle = getComputedStyle(pre)
305
+ const lineHeight = parseFloat(preStyle.lineHeight)
306
+ const padTop = parseFloat(preStyle.paddingTop)
307
+ // First code line's top = pre's top offset (banner) + pre's own padding.
308
+ const offsetTop = preRect.top - shellRect.top
309
+ setGutterStyle({
310
+ paddingTop: (Number.isFinite(offsetTop) ? offsetTop : 0) + (Number.isFinite(padTop) ? padTop : 0),
311
+ lineHeight: Number.isFinite(lineHeight) ? lineHeight : 19.2,
312
+ })
313
+ }
314
+ measure()
315
+ // The shiki grammar loads asynchronously and re-renders the pre; watch for
316
+ // structural changes and re-measure (line-height/padding rarely change,
317
+ // but the banner's presence may settle late).
318
+ const observer = new MutationObserver(measure)
319
+ observer.observe(shell, { childList: true, subtree: true })
320
+ return () => observer.disconnect()
321
+ }, [content])
322
+
323
+ return (
324
+ <div className={previewCss.codeShell} ref={shellRef}>
325
+ <div
326
+ className={previewCss.lineGutter}
327
+ aria-hidden="true"
328
+ style={gutterStyle !== null ? { paddingTop: gutterStyle.paddingTop } : undefined}
329
+ >
330
+ {numbers.map((number) => (
331
+ <div
332
+ key={number}
333
+ className={previewCss.lineNo}
334
+ style={gutterStyle !== null ? { height: gutterStyle.lineHeight, lineHeight: `${gutterStyle.lineHeight}px` } : undefined}
335
+ >
336
+ {number}
337
+ </div>
338
+ ))}
339
+ </div>
340
+ <div className={previewCss.codeBody}>
341
+ <CodeBlock code={content} lang={language === '' ? undefined : language} className={previewCss.codeViewer} copyLabel={t('preview.copyCode')} copiedLabel={t('preview.copyCodeDone')} />
342
+ </div>
343
+ </div>
344
+ )
345
+ }
346
+
347
+ /**
348
+ * One memoized CSV row. The cells array reference is stable (it comes from the
349
+ * memoized parsed rows), so an untouched row skips re-rendering when a sibling
350
+ * cell changes or the panel re-renders for another reason.
351
+ */
352
+ const CsvRow = memo(function CsvRow({ cells, isHeader }: { cells: string[]; isHeader: boolean }): JSX.Element {
353
+ return (
354
+ <tr>
355
+ {cells.map((cell, cellIndex) => (
356
+ isHeader ? <th key={cellIndex}>{cell}</th> : <td key={cellIndex}>{cell}</td>
357
+ ))}
358
+ </tr>
359
+ )
360
+ })
361
+
362
+ /**
363
+ * Stable, content-derived key for a CSV row. Rows have no ids, so the cell
364
+ * content (JSON, occurrence-disambiguated for duplicates) anchors the key
365
+ * instead of the array position — a reordered or shifted table keeps stable
366
+ * React identities instead of reusing DOM nodes by index.
367
+ * @param row - the raw row cells.
368
+ * @param occurrence - how many identical rows were already keyed.
369
+ */
370
+ function csvRowKey(row: string[], occurrence: number): string {
371
+ return `${JSON.stringify(row)}\u0000${occurrence}`
372
+ }
373
+
374
+ /** CSV table. */
375
+ function CsvViewer({ content }: { content: string }): JSX.Element {
376
+ const rows = useMemo(() => parseCsv(content), [content])
377
+ const keyedRows = useMemo(() => {
378
+ const counts = new Map<string, number>()
379
+ return rows.map((row) => {
380
+ const seen = counts.get(JSON.stringify(row)) ?? 0
381
+ counts.set(JSON.stringify(row), seen + 1)
382
+ return { cells: row, key: csvRowKey(row, seen) }
383
+ })
384
+ }, [rows])
385
+ return (
386
+ <div className={previewCss.csvViewer}>
387
+ <table className={previewCss.csvTable}>
388
+ {keyedRows.map((entry, index) => (
389
+ <CsvRow key={entry.key} cells={entry.cells} isHeader={index === 0} />
390
+ ))}
391
+ </table>
392
+ </div>
393
+ )
394
+ }
395
+
396
+ /** Parse CSV lines (quoted cells with escaped quotes). */
397
+ export function parseCsv(text: string): string[][] {
398
+ const rows: string[][] = []
399
+ let row: string[] = []
400
+ let cell = ''
401
+ let inQuotes = false
402
+ for (let i = 0; i < text.length; i += 1) {
403
+ const char = text[i]
404
+ if (inQuotes) {
405
+ if (char === '"') {
406
+ if (text[i + 1] === '"') {
407
+ cell += '"'
408
+ i += 1
409
+ } else {
410
+ inQuotes = false
411
+ }
412
+ } else {
413
+ cell += char
414
+ }
415
+ continue
416
+ }
417
+ if (char === '"') {
418
+ inQuotes = true
419
+ continue
420
+ }
421
+ if (char === ',') {
422
+ row.push(cell)
423
+ cell = ''
424
+ continue
425
+ }
426
+ if (char === '\n') {
427
+ row.push(cell)
428
+ rows.push(row)
429
+ row = []
430
+ cell = ''
431
+ continue
432
+ }
433
+ if (char !== '\r') cell += char
434
+ }
435
+ row.push(cell)
436
+ if (row.length > 1 || row[0] !== '') rows.push(row)
437
+ return rows
438
+ }
439
+
440
+ /** Unified diff viewer. */
441
+ function DiffViewer({ content }: { content: string }): JSX.Element {
442
+ const lines = content.split('\n')
443
+ return (
444
+ <div className={previewCss.diffViewer}>
445
+ {lines.map((line, index) => {
446
+ let className = previewCss.diffLineMeta
447
+ if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ') || line.startsWith('index ')) {
448
+ className = previewCss.diffLineMeta
449
+ } else if (line.startsWith('@@')) {
450
+ className = previewCss.diffLineHunk
451
+ } else if (line.startsWith('+')) {
452
+ className = previewCss.diffLineAdd
453
+ } else if (line.startsWith('-')) {
454
+ className = previewCss.diffLineDel
455
+ }
456
+ return (
457
+ <div key={index} className={className}>
458
+ {line === '' ? ' ' : line}
459
+ </div>
460
+ )
461
+ })}
462
+ </div>
463
+ )
464
+ }
465
+
466
+ /** Image viewer. */
467
+ function ImageViewer({ src, meta }: { src: string; meta: string }): JSX.Element {
468
+ return (
469
+ <div className={previewCss.content}>
470
+ <div className={previewCss.imageViewer}>
471
+ <img src={src} alt="" />
472
+ </div>
473
+ {meta.trim() !== '' && <div className={previewCss.imageMeta}>{meta}</div>}
474
+ </div>
475
+ )
476
+ }
477
+
478
+ /** PDF viewer: streamed route URL (iframe src) or a legacy data URL (blob). */
479
+ function PdfViewer({ dataUrl, title }: { dataUrl: string; title: string }): JSX.Element {
480
+ const [url, setUrl] = useState<string | null>(null)
481
+ useEffect(() => {
482
+ if (!dataUrl.startsWith('data:')) {
483
+ // Same-origin /filemgr/raw URL: the iframe loads the stream
484
+ // directly (mime application/pdf, no size cap, no base64 copy).
485
+ setUrl(dataUrl === '' ? null : dataUrl)
486
+ return
487
+ }
488
+ const blob = dataUrlToBlob(dataUrl)
489
+ if (blob === null) {
490
+ setUrl(null)
491
+ return
492
+ }
493
+ const objectUrl = URL.createObjectURL(blob)
494
+ setUrl(objectUrl)
495
+ return () => URL.revokeObjectURL(objectUrl)
496
+ }, [dataUrl])
497
+ return url === null
498
+ ? <div className={previewCss.placeholder}>{t('preview.unsupported')}</div>
499
+ : <iframe className={previewCss.pdfViewer} src={url} title={title} />
500
+ }
501
+
502
+ /** Convert a data URL to a Blob (null on failure). */
503
+ export function dataUrlToBlob(dataUrl: string): Blob | null {
504
+ const comma = dataUrl.indexOf(',')
505
+ if (comma === -1) return null
506
+ const meta = dataUrl.slice(0, comma)
507
+ const mime = /data:([^;]+)/.exec(meta)?.[1] ?? 'application/octet-stream'
508
+ try {
509
+ const binary = atob(dataUrl.slice(comma + 1))
510
+ const bytes = new Uint8Array(binary.length)
511
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
512
+ return new Blob([bytes], { type: mime })
513
+ } catch {
514
+ return null
515
+ }
516
+ }
517
+
518
+ /** URL tab: address bar + iframe. */
519
+ function UrlViewer({ tab }: { tab: PreviewTabState }): JSX.Element {
520
+ const [input, setInput] = useState(tab.content ?? '')
521
+ const [url, setUrl] = useState(() => normalizeUrl(tab.content ?? ''))
522
+ const frameRef = useRef<HTMLIFrameElement>(null)
523
+ useEffect(() => {
524
+ setInput(tab.content ?? '')
525
+ setUrl(normalizeUrl(tab.content ?? ''))
526
+ }, [tab.id, tab.content])
527
+
528
+ // The frame is sandboxed WITH allow-popups: sites like bilibili hardcode
529
+ // target=_blank on their nav links, so popups are permitted rather than
530
+ // silently dropped. allow-same-origin is intentionally OMITTED, so the
531
+ // embedded site runs in an OPAQUE origin: it cannot reach window.parent or
532
+ // touch same-origin storage, which also means localStorage access inside
533
+ // the frame throws. The load guard and normalizeUrl's same-origin block
534
+ // remain as defense-in-depth for any frame that still lands on the GUI
535
+ // origin.
536
+ const guardFrameNavigation = (): void => {
537
+ const frame = frameRef.current
538
+ if (frame === null) return
539
+ try {
540
+ const href = frame.contentWindow?.location.href
541
+ if (href !== undefined && !href.startsWith('about:') && new URL(href).origin === window.location.origin) {
542
+ frame.src = 'about:blank'
543
+ }
544
+ } catch {
545
+ // Cross-origin frame: nothing to guard.
546
+ }
547
+ }
548
+
549
+ return (
550
+ <div className={previewCss.content}>
551
+ <div className={previewCss.urlBar}>
552
+ <input
553
+ className={previewCss.urlInput}
554
+ value={input}
555
+ placeholder={t('preview.url.placeholder')}
556
+ spellCheck={false}
557
+ onChange={(event) => setInput(event.target.value)}
558
+ onKeyDown={(event) => {
559
+ if (event.key === 'Enter') setUrl(normalizeUrl(input))
560
+ if (event.key === 'Escape') {
561
+ setInput(tab.content ?? '')
562
+ setUrl(normalizeUrl(tab.content ?? ''))
563
+ }
564
+ }}
565
+ onFocus={(event) => event.currentTarget.select()}
566
+ />
567
+ </div>
568
+ <iframe
569
+ // Keyed on url + reloadNonce: a refresh (or a new address) remounts
570
+ // the frame, which re-navigates it — cross-origin documents cannot
571
+ // be reloaded in place from the parent, and re-setting the src
572
+ // attribute does not re-navigate when the value is unchanged.
573
+ key={`${url}\u0000${tab.reloadNonce ?? 0}`}
574
+ ref={frameRef}
575
+ className={previewCss.urlFrame}
576
+ src={url}
577
+ title={tab.title}
578
+ sandbox="allow-scripts allow-forms allow-popups"
579
+ allow="autoplay; fullscreen; picture-in-picture; encrypted-media; clipboard-write"
580
+ allowFullScreen
581
+ onLoad={guardFrameNavigation}
582
+ />
583
+ </div>
584
+ )
585
+ }
586
+
587
+ /** Bare domains get https://; whitespace queries go to a search engine. */
588
+ export function normalizeUrl(input: string): string {
589
+ const trimmed = input.trim()
590
+ if (trimmed === '') return 'about:blank'
591
+ if (/\s/.test(trimmed)) return `https://www.bing.com/search?q=${encodeURIComponent(trimmed)}`
592
+ const candidate = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
593
+ // Never embed a URL that points back at the harness host: the url frame
594
+ // runs with allow-scripts + allow-same-origin, so a same-origin page there
595
+ // could reach the shell document (the onLoad guard resets indirect
596
+ // same-origin navigations, but a directly typed address must not land at
597
+ // all). Degrade it instead.
598
+ if (typeof window !== 'undefined') {
599
+ try {
600
+ if (new URL(candidate).origin === window.location.origin) return 'about:blank'
601
+ } catch {
602
+ // Malformed URL: fall through and return the best-effort candidate.
603
+ }
604
+ }
605
+ return candidate
606
+ }
607
+
608
+ /** Office / unsupported placeholder. */
609
+ function UnsupportedViewer({ tab }: { tab: PreviewTabState }): JSX.Element {
610
+ return (
611
+ <div className={previewCss.placeholder}>
612
+ <div className={previewCss.placeholderTitle}>{tab.title}</div>
613
+ <div className={previewCss.placeholderMeta}>{t('preview.unsupported')}</div>
614
+ <div className={previewCss.placeholderMeta}>{t('preview.downloadHint')}</div>
615
+ </div>
616
+ )
617
+ }