@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.
- package/LICENSE +38 -0
- package/README.i18n.yaml +6 -0
- package/README.md +66 -0
- package/README.zh.md +66 -0
- package/cordis.patch.yml +13 -0
- package/lib/assets/mermaid.min.js +3587 -0
- package/lib/client.js +7621 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3207 -0
- package/lib/tsconfig.client.tsbuildinfo +1 -0
- package/lib/tsconfig.host.tsbuildinfo +1 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +104 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +75 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
- package/lib/types/client/chat/file-ref.d.ts +35 -0
- package/lib/types/client/chat/file-ref.d.ts.map +1 -0
- package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
- package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
- package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
- package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
- package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
- package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
- package/lib/types/client/components/FileIcon.d.ts +15 -0
- package/lib/types/client/components/FileIcon.d.ts.map +1 -0
- package/lib/types/client/components/ScmPanel.d.ts +23 -0
- package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
- package/lib/types/client/components/a11y.d.ts +12 -0
- package/lib/types/client/components/a11y.d.ts.map +1 -0
- package/lib/types/client/components/icons.d.ts +41 -0
- package/lib/types/client/components/icons.d.ts.map +1 -0
- package/lib/types/client/components/overlay.d.ts +45 -0
- package/lib/types/client/components/overlay.d.ts.map +1 -0
- package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
- package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
- package/lib/types/client/drag/file-drag.d.ts +47 -0
- package/lib/types/client/drag/file-drag.d.ts.map +1 -0
- package/lib/types/client/drag.d.ts +31 -0
- package/lib/types/client/drag.d.ts.map +1 -0
- package/lib/types/client/file-source.d.ts +29 -0
- package/lib/types/client/file-source.d.ts.map +1 -0
- package/lib/types/client/fileType.d.ts +31 -0
- package/lib/types/client/fileType.d.ts.map +1 -0
- package/lib/types/client/floating.d.ts +27 -0
- package/lib/types/client/floating.d.ts.map +1 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
- package/lib/types/client/hooks/useStore.d.ts +10 -0
- package/lib/types/client/hooks/useStore.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +57 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/layout.d.ts +104 -0
- package/lib/types/client/layout.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +125 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/maximize.d.ts +24 -0
- package/lib/types/client/maximize.d.ts.map +1 -0
- package/lib/types/client/mention.d.ts +44 -0
- package/lib/types/client/mention.d.ts.map +1 -0
- package/lib/types/client/mount.d.ts +22 -0
- package/lib/types/client/mount.d.ts.map +1 -0
- package/lib/types/client/persist.d.ts +55 -0
- package/lib/types/client/persist.d.ts.map +1 -0
- package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
- package/lib/types/client/picker/file-picker.d.ts +62 -0
- package/lib/types/client/picker/file-picker.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
- package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
- package/lib/types/client/preview/content.d.ts +43 -0
- package/lib/types/client/preview/content.d.ts.map +1 -0
- package/lib/types/client/preview/markdown.d.ts +60 -0
- package/lib/types/client/preview/markdown.d.ts.map +1 -0
- package/lib/types/client/preview/mermaid.d.ts +78 -0
- package/lib/types/client/preview/mermaid.d.ts.map +1 -0
- package/lib/types/client/preview/selection.d.ts +26 -0
- package/lib/types/client/preview/selection.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +75 -0
- package/lib/types/client/reference.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +206 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/store.d.ts +279 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +132 -0
- package/lib/types/core/types.d.ts.map +1 -0
- package/lib/types/host/fs-service.d.ts +96 -0
- package/lib/types/host/fs-service.d.ts.map +1 -0
- package/lib/types/host/gate.d.ts +45 -0
- package/lib/types/host/gate.d.ts.map +1 -0
- package/lib/types/host/git-runner.d.ts +80 -0
- package/lib/types/host/git-runner.d.ts.map +1 -0
- package/lib/types/host/git-service.d.ts +130 -0
- package/lib/types/host/git-service.d.ts.map +1 -0
- package/lib/types/host/loopback.d.ts +25 -0
- package/lib/types/host/loopback.d.ts.map +1 -0
- package/lib/types/host/poll-guard.d.ts +55 -0
- package/lib/types/host/poll-guard.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +54 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/package.json +85 -0
- package/src/client/FileManagerSettingsCard.tsx +117 -0
- package/src/client/PluginSettingsCard.tsx +337 -0
- package/src/client/api.ts +141 -0
- package/src/client/chat/file-ref.ts +144 -0
- package/src/client/chat/mermaid-chat.tsx +102 -0
- package/src/client/chat/placeholder-hint.tsx +46 -0
- package/src/client/components/ExplorerPanel.tsx +534 -0
- package/src/client/components/FileIcon.tsx +46 -0
- package/src/client/components/ScmPanel.tsx +480 -0
- package/src/client/components/a11y.ts +20 -0
- package/src/client/components/icons.tsx +274 -0
- package/src/client/components/overlay.tsx +225 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag/DragFileInlay.tsx +89 -0
- package/src/client/drag/file-drag.ts +67 -0
- package/src/client/drag.ts +154 -0
- package/src/client/file-source.ts +58 -0
- package/src/client/fileType.ts +129 -0
- package/src/client/floating.ts +63 -0
- package/src/client/hooks/useResizableSplit.ts +91 -0
- package/src/client/hooks/useStore.ts +15 -0
- package/src/client/index.ts +389 -0
- package/src/client/layout.ts +546 -0
- package/src/client/locales.ts +247 -0
- package/src/client/maximize.ts +34 -0
- package/src/client/mount.tsx +74 -0
- package/src/client/persist.ts +194 -0
- package/src/client/picker/FilePickerModal.tsx +506 -0
- package/src/client/picker/file-picker.ts +86 -0
- package/src/client/preview/PreviewPanel.tsx +233 -0
- package/src/client/preview/PreviewTabs.tsx +159 -0
- package/src/client/preview/PreviewToolbar.tsx +160 -0
- package/src/client/preview/content.tsx +617 -0
- package/src/client/preview/markdown.ts +351 -0
- package/src/client/preview/mermaid.ts +274 -0
- package/src/client/preview/selection.ts +82 -0
- package/src/client/reference.ts +161 -0
- package/src/client/settings-card.module.css +316 -0
- package/src/client/settings-form.ts +451 -0
- package/src/client/store.ts +1290 -0
- package/src/client/styles/chip.module.css +38 -0
- package/src/client/styles/drag.module.css +30 -0
- package/src/client/styles/explorer.module.css +371 -0
- package/src/client/styles/picker.module.css +344 -0
- package/src/client/styles/preview.module.css +904 -0
- package/src/client/styles/scm.module.css +380 -0
- package/src/client/styles/tokens.module.css +480 -0
- package/src/core/types.ts +162 -0
- package/src/host/fs-service.ts +513 -0
- package/src/host/gate.ts +74 -0
- package/src/host/git-runner.ts +116 -0
- package/src/host/git-service.ts +395 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/poll-guard.ts +109 -0
- package/src/host/routes.ts +695 -0
- package/src/index.ts +97 -0
- 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
|
+
}
|