dsh-file-finder 0.3.0 → 0.4.0
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 +13 -3
- package/README.zh.md +13 -1
- package/client.js +287 -4
- package/index.js +222 -8
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ A DeepSeek Harness plugin for finding workspace files without leaving DSH: searc
|
|
|
12
12
|
- **Edit in place** when you need to: ⌘/Ctrl+Enter opens the built-in editor, which saves through DSH's workspace filesystem and refuses to overwrite a file that changed after it was loaded.
|
|
13
13
|
- **Native shortcuts**: Cmd/Ctrl+P and Cmd/Ctrl+Shift+F, like VS Code, without claiming a default binding in DSH's shortcut registry.
|
|
14
14
|
- **Optional DS Bot bridge**: when the DS Bot plugin is installed, Cmd/Ctrl+Shift+B flips between Bot mode and classic Agent mode; without it neither the shortcut nor its settings card exists.
|
|
15
|
+
- **Preview, not just text**: images, video, audio, PDFs, HTML pages and Markdown open in the built-in viewer; HTML runs in a sandbox, Markdown is rendered with DSH's own renderer, and relative images inside a Markdown file resolve to the workspace.
|
|
15
16
|
- **A redesigned settings page** with cards, keycaps, steppers, and switches, plus an "Opening" preference that chooses whether Enter previews or edits.
|
|
16
17
|
- **Polished interaction**: a frosted command palette, spring-eased mode switch, selected-row rail, loading skeletons, and reduced-motion support.
|
|
17
18
|
|
|
@@ -65,17 +66,25 @@ Without DS Bot nothing is registered or shown: the command never reaches **Setti
|
|
|
65
66
|
|
|
66
67
|
Choose a result and press Cmd/Ctrl+Enter (or set the built-in editor as the default action). Save writes the full text back through the workspace filesystem and refuses to overwrite a file that changed after it was loaded. Reload, then save again, to take the newer version. The editor is for UTF-8 text under about 1 MB. Binary files and common build or dependency directories are skipped.
|
|
67
68
|
|
|
69
|
+
Files that are not text open in the built-in viewer instead: images, video and audio render inline (byte ranges, so video seeking works), PDFs open in the platform frame, HTML pages run in a sandboxed iframe whose scripts cannot reach DSH, and Markdown is rendered with DSH's Markdown renderer, with an **Edit source** button that hands the file to the text editor.
|
|
70
|
+
|
|
68
71
|
The editor shows a line-number gutter and highlights the caret's line. **Cmd/Ctrl+F** opens in-file search: every match is highlighted, the current one more strongly, with a match count, previous/next buttons, and a **Aa** case-sensitivity toggle. Enter and Shift+Enter step through matches, Escape closes the search and returns focus to the text.
|
|
69
72
|
|
|
70
73
|
## Install
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
The plugin is published to npm as **`dsh-file-finder`**, so **Settings → Plugins → Add plugin** resolves the package name directly:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
dsh plugin add dsh-file-finder
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Or install from this checkout:
|
|
73
82
|
|
|
74
83
|
```sh
|
|
75
84
|
dsh plugin add /Library/aiProject/dsh-file-finder
|
|
76
85
|
```
|
|
77
86
|
|
|
78
|
-
|
|
87
|
+
Restart or refresh the DSH page if the new client module does not appear immediately.
|
|
79
88
|
|
|
80
89
|
The same operations are available to the agent as the `file_finder` tool.
|
|
81
90
|
|
|
@@ -89,10 +98,11 @@ npm test
|
|
|
89
98
|
|
|
90
99
|
`npm test` covers the search helpers, the shortcut-default regression guard, and the UI contract (native preview address, palette hooks, reduced-motion rules, the DS Bot bridge, and the editor's line gutter plus in-file find).
|
|
91
100
|
|
|
92
|
-
The editor has its own browser check for the gutter and in-file find:
|
|
101
|
+
The editor has its own browser check for the gutter and in-file find, and the viewer has one for every preview kind:
|
|
93
102
|
|
|
94
103
|
```sh
|
|
95
104
|
npm run check:find
|
|
105
|
+
npm run check:viewer
|
|
96
106
|
```
|
|
97
107
|
|
|
98
108
|
The DS Bot bridge also has a browser check that runs the real key handler against DS Bot's real selectors, with and without `ds-bot` in the boot graph:
|
package/README.zh.md
CHANGED
|
@@ -12,6 +12,7 @@ DeepSeek Harness 插件:在 DSH 里按文件名或文件内容查找工作区
|
|
|
12
12
|
- **需要时再编辑**:⌘/Ctrl+回车打开内置编辑器,保存通过 DSH 的工作区文件系统写回;文件在打开后被别的程序改过时会拒绝覆盖。
|
|
13
13
|
- **VS Code 习惯的快捷键**:⌘/Ctrl+P 与 ⌘/Ctrl+Shift+F,但不占用 DSH 快捷键注册表的默认键位。
|
|
14
14
|
- **可选的 DS Bot 桥接**:装了 DS Bot 插件时,⌘/Ctrl+Shift+B 在 Bot 模式与经典 Agent 模式之间来回切换;没装时快捷键和设置卡片都不存在。
|
|
15
|
+
- **不只是文本**:图片、视频、音频、PDF、HTML 和 Markdown 用内置预览打开——视频走字节范围请求,可以拖进度条;HTML 在沙箱里跑,脚本碰不到 DSH 页面;Markdown 用 DSH 自己的渲染器,文内相对路径的图片会解析到工作区。
|
|
15
16
|
- **重新设计的设置页**:卡片分组、键帽、步进器和开关,并新增「打开方式」选择,决定回车是预览还是编辑。
|
|
16
17
|
- **更细腻的交互**:磨砂质感的命令面板、弹性切换动画、选中行指示条、加载骨架,以及 reduced-motion 支持。
|
|
17
18
|
|
|
@@ -66,10 +67,20 @@ DS Bot 的模式切换是它自己的私有状态,没有对外暴露客户端
|
|
|
66
67
|
|
|
67
68
|
选中结果后按 ⌘/Ctrl+回车(或把「内置编辑」设为默认动作)打开编辑器。⌘/Ctrl+S 保存;保存使用打开时读到的版本,如果文件期间被别的程序改过,会拒绝覆盖,需要先重新加载。只编辑约 1 MB 以内的 UTF-8 文本。二进制文件,以及 `node_modules`、`.git`、构建目录等,不会进入搜索。
|
|
68
69
|
|
|
70
|
+
不是文本的文件会走内置预览:图片、视频、音频直接渲染(字节范围请求,视频能拖进度),PDF 用平台预览框,HTML 放在沙箱 iframe 里(脚本访问不到 DSH 页面),Markdown 用 DSH 的 Markdown 渲染器,并带一个「编辑源码」按钮切回文本编辑器。
|
|
71
|
+
|
|
69
72
|
编辑器左边有行号栏,并高亮光标所在行。**⌘/Ctrl+F** 打开文件内查找:所有命中都会高亮、当前命中更醒目,带命中计数、上一个/下一个按钮和 **Aa** 区分大小写开关;回车/⇧回车切换命中,Esc 关闭查找并把焦点交还正文。
|
|
70
73
|
|
|
71
74
|
## 安装
|
|
72
75
|
|
|
76
|
+
插件已发布到 npm(包名 **`dsh-file-finder`**),在 **设置 → 插件 → 添加插件** 里直接输入包名即可:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
dsh plugin add dsh-file-finder
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
也可以从本地目录安装:
|
|
83
|
+
|
|
73
84
|
```sh
|
|
74
85
|
dsh plugin add /Library/aiProject/dsh-file-finder
|
|
75
86
|
```
|
|
@@ -88,10 +99,11 @@ npm test
|
|
|
88
99
|
|
|
89
100
|
`npm test` 覆盖搜索函数、快捷键默认值的防回归测试,以及 UI 契约(原生预览地址、面板钩子、reduced-motion 规则、DS Bot 桥接,以及编辑器的行号栏与文件内查找)。
|
|
90
101
|
|
|
91
|
-
|
|
102
|
+
编辑器和预览器各有浏览器检查:一个验证行号与文件内查找,一个验证各种文件类型的预览:
|
|
92
103
|
|
|
93
104
|
```sh
|
|
94
105
|
npm run check:find
|
|
106
|
+
npm run check:viewer
|
|
95
107
|
```
|
|
96
108
|
|
|
97
109
|
DS Bot 桥接还有一个浏览器检查,用 DS Bot 真实的类名跑一遍按键逻辑,分别验证启动清单里有 / 没有 `ds-bot` 两种情况:
|
package/client.js
CHANGED
|
@@ -18,6 +18,11 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
const FileTypeIcon = primitives && typeof primitives.FileTypeIcon === 'function'
|
|
19
19
|
? primitives.FileTypeIcon
|
|
20
20
|
: null
|
|
21
|
+
// DSH's own Markdown renderer, when the shell provides primitives. The
|
|
22
|
+
// viewer falls back to plain text in harnesses without them.
|
|
23
|
+
const MarkdownText = primitives && typeof primitives.MarkdownText === 'function'
|
|
24
|
+
? primitives.MarkdownText
|
|
25
|
+
: null
|
|
21
26
|
|
|
22
27
|
const dict = {
|
|
23
28
|
zh: {
|
|
@@ -109,6 +114,19 @@ window.__ModuleLoader__.load({
|
|
|
109
114
|
findClose: '关闭查找',
|
|
110
115
|
findNoResult: '无结果',
|
|
111
116
|
hintFind: '查找',
|
|
117
|
+
readOnly: '只读预览',
|
|
118
|
+
unpreviewable: '无法预览此类型',
|
|
119
|
+
editText: '编辑源码',
|
|
120
|
+
previewUnsupported: '这种文件暂时不能在面板里预览',
|
|
121
|
+
previewUnsupportedHint: '可以用右侧栏预览,或先把这个文件导出到别处再用系统程序打开。',
|
|
122
|
+
previewHtmlNote: 'HTML 在沙箱中运行,脚本无法访问 DSH 页面。',
|
|
123
|
+
kindImage: '图片',
|
|
124
|
+
kindVideo: '视频',
|
|
125
|
+
kindAudio: '音频',
|
|
126
|
+
kindPdf: 'PDF',
|
|
127
|
+
kindMarkdown: 'Markdown',
|
|
128
|
+
kindHtml: 'HTML',
|
|
129
|
+
kindBinary: '二进制文件',
|
|
112
130
|
},
|
|
113
131
|
en: {
|
|
114
132
|
title: 'File Finder',
|
|
@@ -199,6 +217,19 @@ window.__ModuleLoader__.load({
|
|
|
199
217
|
findClose: 'Close search',
|
|
200
218
|
findNoResult: 'No results',
|
|
201
219
|
hintFind: 'find',
|
|
220
|
+
readOnly: 'Read-only preview',
|
|
221
|
+
unpreviewable: 'No preview for this type',
|
|
222
|
+
editText: 'Edit source',
|
|
223
|
+
previewUnsupported: 'This file type cannot be previewed here yet',
|
|
224
|
+
previewUnsupportedHint: 'Open it in the right-sidebar preview, or export the file and use a system app.',
|
|
225
|
+
previewHtmlNote: 'HTML runs sandboxed; its scripts cannot reach the DSH page.',
|
|
226
|
+
kindImage: 'Image',
|
|
227
|
+
kindVideo: 'Video',
|
|
228
|
+
kindAudio: 'Audio',
|
|
229
|
+
kindPdf: 'PDF',
|
|
230
|
+
kindMarkdown: 'Markdown',
|
|
231
|
+
kindHtml: 'HTML',
|
|
232
|
+
kindBinary: 'Binary file',
|
|
202
233
|
},
|
|
203
234
|
}
|
|
204
235
|
|
|
@@ -479,6 +510,77 @@ window.__ModuleLoader__.load({
|
|
|
479
510
|
return base.slice(dot + 1).toLowerCase()
|
|
480
511
|
}
|
|
481
512
|
|
|
513
|
+
// ── Built-in viewer kinds ──────────────────────────────────────────────
|
|
514
|
+
// Extension-driven, mirroring the host's fileKindOf. Media and documents
|
|
515
|
+
// open in the viewer; everything else goes through the text editor.
|
|
516
|
+
const KIND_IMAGE = /^(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/
|
|
517
|
+
const KIND_VIDEO = /^(mp4|webm|mov|m4v|ogv|mkv)$/
|
|
518
|
+
const KIND_AUDIO = /^(mp3|wav|ogg|oga|m4a|flac|aac|opus)$/
|
|
519
|
+
const KIND_MARKDOWN = /^(md|markdown|mdown|mdx)$/
|
|
520
|
+
const KIND_HTML = /^(html|htm)$/
|
|
521
|
+
const KIND_PDF = /^(pdf)$/
|
|
522
|
+
|
|
523
|
+
function previewKindOf(name) {
|
|
524
|
+
const extension = extOf(name)
|
|
525
|
+
if (KIND_IMAGE.test(extension)) return 'image'
|
|
526
|
+
if (KIND_VIDEO.test(extension)) return 'video'
|
|
527
|
+
if (KIND_AUDIO.test(extension)) return 'audio'
|
|
528
|
+
if (KIND_PDF.test(extension)) return 'pdf'
|
|
529
|
+
if (KIND_MARKDOWN.test(extension)) return 'markdown'
|
|
530
|
+
if (KIND_HTML.test(extension)) return 'html'
|
|
531
|
+
return 'text'
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function base64Url(value) {
|
|
535
|
+
const bytes = new TextEncoder().encode(String(value))
|
|
536
|
+
let binary = ''
|
|
537
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
538
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function rawUrlFor(rootPath, filePath) {
|
|
542
|
+
const relative = relativePath(rootPath, filePath)
|
|
543
|
+
if (!relative) return ''
|
|
544
|
+
return '/dsh-file-finder/ws/' + base64Url(rootPath) + '/'
|
|
545
|
+
+ relative.split('/').map(encodeURIComponent).join('/')
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function normalizeRelative(baseDir, target) {
|
|
549
|
+
const value = String(target || '')
|
|
550
|
+
if (!value || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value)) return ''
|
|
551
|
+
const stack = String(baseDir || '').split('/').filter(Boolean)
|
|
552
|
+
for (const part of value.split('/')) {
|
|
553
|
+
if (!part || part === '.') continue
|
|
554
|
+
if (part === '..') stack.pop()
|
|
555
|
+
else stack.push(part)
|
|
556
|
+
}
|
|
557
|
+
return stack.join('/')
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Markdown images and <img> tags with relative sources are pointed at the raw
|
|
561
|
+
// route, so a README's screenshots show up inside the panel.
|
|
562
|
+
function rewriteMarkdownAssets(text, rootPath, filePath) {
|
|
563
|
+
const baseDir = dirOf(relativePath(rootPath, filePath))
|
|
564
|
+
const token = base64Url(rootPath)
|
|
565
|
+
const toUrl = (target) => {
|
|
566
|
+
const resolved = normalizeRelative(baseDir, target)
|
|
567
|
+
if (!resolved) return ''
|
|
568
|
+
const path = '/dsh-file-finder/ws/' + token + '/' + resolved.split('/').map(encodeURIComponent).join('/')
|
|
569
|
+
// DSH's Markdown renderer only draws http(s) sources; a same-origin
|
|
570
|
+
// absolute URL passes that check and still hits our route.
|
|
571
|
+
try { return new URL(path, location.href).href } catch { return path }
|
|
572
|
+
}
|
|
573
|
+
return String(text || '')
|
|
574
|
+
.replace(/(!\[[^\]]*\]\()([^)\s]+)(\))/g, (whole, head, target, tail) => {
|
|
575
|
+
const url = toUrl(target)
|
|
576
|
+
return url ? head + url + tail : whole
|
|
577
|
+
})
|
|
578
|
+
.replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (whole, head, target, tail) => {
|
|
579
|
+
const url = toUrl(target)
|
|
580
|
+
return url ? head + url + tail : whole
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
|
|
482
584
|
function fileAccent(name) {
|
|
483
585
|
const ext = extOf(name)
|
|
484
586
|
if (/^(js|mjs|cjs|jsx|ts|tsx|mts|cts)$/.test(ext)) return 'amber'
|
|
@@ -1157,8 +1259,19 @@ window.__ModuleLoader__.load({
|
|
|
1157
1259
|
setFindIndex(0)
|
|
1158
1260
|
try {
|
|
1159
1261
|
const data = await post('/dsh-file-finder/read', { root, path: item.path })
|
|
1160
|
-
|
|
1161
|
-
|
|
1262
|
+
const text = typeof data.text === 'string' ? data.text : ''
|
|
1263
|
+
// The host decides the kind from the extension and decodability; media
|
|
1264
|
+
// and PDFs arrive without text and are rendered by the viewer.
|
|
1265
|
+
const kind = typeof data.kind === 'string' && data.kind ? data.kind : previewKindOf(item.path)
|
|
1266
|
+
setFile({
|
|
1267
|
+
path: data.path,
|
|
1268
|
+
version: data.version,
|
|
1269
|
+
text,
|
|
1270
|
+
bytes: data.bytes,
|
|
1271
|
+
line: item.line || 1,
|
|
1272
|
+
kind,
|
|
1273
|
+
})
|
|
1274
|
+
setDraft(text)
|
|
1162
1275
|
setView('editor')
|
|
1163
1276
|
} catch (reason) {
|
|
1164
1277
|
setError(reason instanceof Error ? reason.message : String(reason))
|
|
@@ -1169,6 +1282,12 @@ window.__ModuleLoader__.load({
|
|
|
1169
1282
|
|
|
1170
1283
|
async function openPrimary(item) {
|
|
1171
1284
|
if (busy) return
|
|
1285
|
+
// Media and documents have nothing to edit here, so they always open in
|
|
1286
|
+
// the built-in viewer instead of the sidebar/native split.
|
|
1287
|
+
if (previewKindOf(item.path) !== 'text') {
|
|
1288
|
+
await openEditor(item)
|
|
1289
|
+
return
|
|
1290
|
+
}
|
|
1172
1291
|
if (prefsState.openMode === 'preview') {
|
|
1173
1292
|
if (openNative(item)) {
|
|
1174
1293
|
store.close()
|
|
@@ -1181,6 +1300,10 @@ window.__ModuleLoader__.load({
|
|
|
1181
1300
|
|
|
1182
1301
|
async function openSecondary(item) {
|
|
1183
1302
|
if (busy) return
|
|
1303
|
+
if (previewKindOf(item.path) !== 'text') {
|
|
1304
|
+
await openEditor(item)
|
|
1305
|
+
return
|
|
1306
|
+
}
|
|
1184
1307
|
if (prefsState.openMode === 'preview') {
|
|
1185
1308
|
setNotice('')
|
|
1186
1309
|
await openEditor(item)
|
|
@@ -1252,7 +1375,7 @@ window.__ModuleLoader__.load({
|
|
|
1252
1375
|
}
|
|
1253
1376
|
|
|
1254
1377
|
function onPanelKey(event) {
|
|
1255
|
-
if (view === 'editor' && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
|
|
1378
|
+
if (view === 'editor' && file && file.kind === 'text' && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
|
|
1256
1379
|
event.preventDefault()
|
|
1257
1380
|
event.stopPropagation()
|
|
1258
1381
|
openFind()
|
|
@@ -1562,6 +1685,115 @@ window.__ModuleLoader__.load({
|
|
|
1562
1685
|
h('span', { className: 'dff-rootpath', title: root }, h(FolderIcon, { size: 12 }), h('span', { className: 'dff-rootpath-text' }, h('bdi', null, root)))))
|
|
1563
1686
|
}
|
|
1564
1687
|
|
|
1688
|
+
|
|
1689
|
+
// ── Built-in viewer ──────────────────────────────────────────────────
|
|
1690
|
+
function switchToEditor() {
|
|
1691
|
+
if (!file || typeof file.text !== 'string' || !file.text) return
|
|
1692
|
+
setFile({ ...file, kind: 'text' })
|
|
1693
|
+
setDraft(file.text)
|
|
1694
|
+
setFindOpen(false)
|
|
1695
|
+
setFindQuery('')
|
|
1696
|
+
setFindIndex(0)
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// Links inside a rendered Markdown file: keep external ones to the browser
|
|
1700
|
+
// and route workspace-relative ones back through the viewer.
|
|
1701
|
+
function onMarkdownClick(event) {
|
|
1702
|
+
const anchor = event.target && typeof event.target.closest === 'function'
|
|
1703
|
+
? event.target.closest('a')
|
|
1704
|
+
: null
|
|
1705
|
+
if (!anchor || !file) return
|
|
1706
|
+
const href = anchor.getAttribute('href') || ''
|
|
1707
|
+
if (/^(?:https?:|mailto:)/i.test(href)) return
|
|
1708
|
+
event.preventDefault()
|
|
1709
|
+
const relative = normalizeRelative(dirOf(relativePath(root, file.path)), href)
|
|
1710
|
+
if (!relative) return
|
|
1711
|
+
openEditor({ path: root.replace(/\/+$/, '') + '/' + relative, line: 1 })
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function viewerBody(kind) {
|
|
1715
|
+
const name = file ? baseName(file.path) : ''
|
|
1716
|
+
const src = file ? rawUrlFor(root, file.path) : ''
|
|
1717
|
+
if (kind === 'image') {
|
|
1718
|
+
return h('div', { className: 'dff-media', 'data-dff-viewer': 'image' },
|
|
1719
|
+
h('img', { className: 'dff-media-image', src, alt: name, draggable: false }))
|
|
1720
|
+
}
|
|
1721
|
+
if (kind === 'video') {
|
|
1722
|
+
return h('div', { className: 'dff-media is-dark', 'data-dff-viewer': 'video' },
|
|
1723
|
+
h('video', { className: 'dff-media-video', src, controls: true, preload: 'metadata', playsInline: true }))
|
|
1724
|
+
}
|
|
1725
|
+
if (kind === 'audio') {
|
|
1726
|
+
return h('div', { className: 'dff-media dff-media-audio', 'data-dff-viewer': 'audio' },
|
|
1727
|
+
h('audio', { src, controls: true, preload: 'metadata' }))
|
|
1728
|
+
}
|
|
1729
|
+
if (kind === 'pdf') {
|
|
1730
|
+
return h('iframe', { className: 'dff-frame', src, title: name, 'data-dff-viewer': 'pdf' })
|
|
1731
|
+
}
|
|
1732
|
+
if (kind === 'html') {
|
|
1733
|
+
return h('div', { className: 'dff-frame-wrap', 'data-dff-viewer': 'html' },
|
|
1734
|
+
h('iframe', {
|
|
1735
|
+
className: 'dff-frame',
|
|
1736
|
+
src,
|
|
1737
|
+
title: name,
|
|
1738
|
+
sandbox: 'allow-scripts',
|
|
1739
|
+
referrerPolicy: 'no-referrer',
|
|
1740
|
+
}),
|
|
1741
|
+
h('span', { className: 'dff-frame-note' }, t('previewHtmlNote')))
|
|
1742
|
+
}
|
|
1743
|
+
if (kind === 'markdown') {
|
|
1744
|
+
const source = file && typeof file.text === 'string' ? file.text : ''
|
|
1745
|
+
if (!source) return viewerEmpty()
|
|
1746
|
+
return h('div', { className: 'dff-doc-wrap', 'data-dff-viewer': 'markdown' },
|
|
1747
|
+
MarkdownText
|
|
1748
|
+
? h('div', { className: 'dff-markdown', onClickCapture: onMarkdownClick },
|
|
1749
|
+
h(MarkdownText, { text: rewriteMarkdownAssets(source, root, file.path), variant: 'body' }))
|
|
1750
|
+
: h('pre', { className: 'dff-markdown-fallback' }, source))
|
|
1751
|
+
}
|
|
1752
|
+
return viewerEmpty()
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function viewerEmpty() {
|
|
1756
|
+
return h('div', { className: 'dff-viewer-empty' },
|
|
1757
|
+
h(WarningIcon, { size: 22, className: 'dff-viewer-empty-icon' }),
|
|
1758
|
+
h('strong', null, t('previewUnsupported')),
|
|
1759
|
+
h('p', null, t('previewUnsupportedHint')))
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
function viewerView() {
|
|
1763
|
+
const name = file ? baseName(file.path) : t('title')
|
|
1764
|
+
const kind = file && file.kind ? file.kind : 'binary'
|
|
1765
|
+
const canEdit = Boolean(file && typeof file.text === 'string' && file.text.length > 0
|
|
1766
|
+
&& (kind === 'markdown' || kind === 'html'))
|
|
1767
|
+
return h('div', { className: 'dff-view', key: 'viewer' },
|
|
1768
|
+
h('div', { className: 'dff-head dff-head-editor' },
|
|
1769
|
+
h('button', {
|
|
1770
|
+
type: 'button', className: 'dff-icon-btn', 'aria-label': t('back'),
|
|
1771
|
+
onClick: leaveEditor,
|
|
1772
|
+
}, h(BackIcon, { size: 16 })),
|
|
1773
|
+
h('span', { className: 'dff-editor-glyph' }, h(FileGlyph, { name, size: 20 })),
|
|
1774
|
+
h('span', { className: 'dff-editor-title' },
|
|
1775
|
+
h('strong', null, name),
|
|
1776
|
+
h('small', { title: file ? file.path : '' }, h('bdi', null, file ? (dirOf(relativePath(root, file.path)) || baseName(root) || parentOf(file.path)) : ''))),
|
|
1777
|
+
h('span', { className: 'dff-editor-state', 'data-state': 'readonly' },
|
|
1778
|
+
h('span', { className: 'dff-dot', 'aria-hidden': 'true' }),
|
|
1779
|
+
kind === 'binary' ? t('unpreviewable') : t('readOnly')),
|
|
1780
|
+
h('div', { className: 'dff-editor-actions' },
|
|
1781
|
+
canEdit ? h('button', {
|
|
1782
|
+
type: 'button', className: 'dff-ghost-btn', disabled: busy, onClick: switchToEditor,
|
|
1783
|
+
}, t('editText')) : null,
|
|
1784
|
+
h('button', { type: 'button', className: 'dff-icon-btn', 'aria-label': t('openInSidebar'), title: t('openInSidebar'), disabled: busy, onClick: previewCurrent }, h(SidebarIcon, { size: 16 })),
|
|
1785
|
+
h('button', { type: 'button', className: 'dff-icon-btn', 'aria-label': t('reload'), title: t('reload'), disabled: busy, onClick: reloadFile }, h(RefreshIcon, { size: 15 })),
|
|
1786
|
+
h('button', { type: 'button', className: 'dff-icon-btn', 'aria-label': t('close'), onClick: closePanel }, h(CloseIcon, { size: 16 })))),
|
|
1787
|
+
message(),
|
|
1788
|
+
h('div', { className: 'dff-viewer-body' },
|
|
1789
|
+
h('div', { className: 'dff-editor-meta dff-viewer-meta' },
|
|
1790
|
+
h('span', { className: 'dff-editor-path', title: file ? file.path : '' }, h('bdi', null, file ? file.path : '')),
|
|
1791
|
+
h('span', { className: 'dff-editor-stats' },
|
|
1792
|
+
file && file.bytes ? h('span', null, bytesText(file.bytes)) : null,
|
|
1793
|
+
h('span', null, t('kind' + (kind.charAt(0).toUpperCase() + kind.slice(1)))))),
|
|
1794
|
+
viewerBody(kind)))
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1565
1797
|
function editorView() {
|
|
1566
1798
|
const name = file ? baseName(file.path) : t('title')
|
|
1567
1799
|
const state = dirty ? 'dirty' : status === t('conflict') ? 'conflict' : 'saved'
|
|
@@ -1729,7 +1961,7 @@ window.__ModuleLoader__.load({
|
|
|
1729
1961
|
'aria-modal': 'true',
|
|
1730
1962
|
'aria-label': t('title'),
|
|
1731
1963
|
onKeyDown: onPanelKey,
|
|
1732
|
-
}, view === 'search' ? searchView() : editorView()))
|
|
1964
|
+
}, view === 'search' ? searchView() : file && file.kind && file.kind !== 'text' ? viewerView() : editorView()))
|
|
1733
1965
|
}
|
|
1734
1966
|
|
|
1735
1967
|
function SettingsPage(props) {
|
|
@@ -2421,6 +2653,57 @@ window.__ModuleLoader__.load({
|
|
|
2421
2653
|
.dff-editor::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 10px; background-clip: content-box; background-color: var(--dsw-alias-scrollbar-bg-l1); }
|
|
2422
2654
|
.dff-editor::-webkit-scrollbar-thumb:hover { background-color: var(--dsw-alias-scrollbar-hover-l1); }
|
|
2423
2655
|
|
|
2656
|
+
|
|
2657
|
+
/* ── Built-in viewer ──────────────────────────────────────────────── */
|
|
2658
|
+
.dff-viewer-body { display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 10px 12px 12px; }
|
|
2659
|
+
.dff-viewer-meta { padding-bottom: 8px; }
|
|
2660
|
+
.dff-media {
|
|
2661
|
+
flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center;
|
|
2662
|
+
padding: 14px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px;
|
|
2663
|
+
background: var(--dsw-alias-bg-module-platform); overflow: hidden;
|
|
2664
|
+
}
|
|
2665
|
+
.dff-media.is-dark { background: #0b0d12; }
|
|
2666
|
+
.dff-media-image { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
|
|
2667
|
+
.dff-media-video { max-width: 100%; max-height: 100%; border-radius: 8px; background: #000; }
|
|
2668
|
+
.dff-media-audio { padding: 40px; }
|
|
2669
|
+
.dff-media-audio audio { width: min(520px, 100%); }
|
|
2670
|
+
.dff-frame-wrap { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
|
2671
|
+
.dff-frame { flex: 1; min-height: 0; width: 100%; border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; background: #fff; }
|
|
2672
|
+
.dff-frame-note { flex: none; padding: 8px 2px 0; color: var(--dsw-alias-label-tertiary); font-size: 11px; }
|
|
2673
|
+
.dff-doc-wrap { flex: 1; min-height: 0; display: flex; }
|
|
2674
|
+
.dff-markdown {
|
|
2675
|
+
flex: 1; min-height: 0; overflow: auto; padding: 20px 26px;
|
|
2676
|
+
border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px;
|
|
2677
|
+
background: var(--dsw-alias-markdown-code-block);
|
|
2678
|
+
color: var(--dsw-alias-label-primary);
|
|
2679
|
+
}
|
|
2680
|
+
.dff-markdown img { max-width: 100%; height: auto; border-radius: 8px; }
|
|
2681
|
+
.dff-markdown-fallback {
|
|
2682
|
+
flex: 1; min-height: 0; overflow: auto; margin: 0; padding: 18px 20px;
|
|
2683
|
+
border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px;
|
|
2684
|
+
background: var(--dsw-alias-markdown-code-block);
|
|
2685
|
+
color: var(--dsw-alias-label-primary);
|
|
2686
|
+
font: 12.5px/20px var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
|
2687
|
+
white-space: pre-wrap; word-break: break-word;
|
|
2688
|
+
}
|
|
2689
|
+
.dff-viewer-empty {
|
|
2690
|
+
flex: 1; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
|
|
2691
|
+
border: 1px dashed var(--dsw-alias-border-l2); border-radius: 12px;
|
|
2692
|
+
color: var(--dsw-alias-label-secondary); text-align: center; padding: 24px;
|
|
2693
|
+
}
|
|
2694
|
+
.dff-viewer-empty strong { font-size: 14px; color: var(--dsw-alias-label-primary); }
|
|
2695
|
+
.dff-viewer-empty p { margin: 0; max-width: 420px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
|
|
2696
|
+
.dff-viewer-empty-icon { color: var(--dsw-alias-label-tertiary); }
|
|
2697
|
+
.dff-editor-state[data-state="readonly"] { color: var(--dsw-alias-label-tertiary); }
|
|
2698
|
+
.dff-editor-state[data-state="readonly"] .dff-dot { background: var(--dsw-alias-label-tertiary); opacity: 0.6; animation: none; }
|
|
2699
|
+
.dff-ghost-btn {
|
|
2700
|
+
height: 28px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px;
|
|
2701
|
+
background: transparent; color: var(--dsw-alias-label-secondary);
|
|
2702
|
+
font: inherit; font-size: 12px; cursor: pointer;
|
|
2703
|
+
}
|
|
2704
|
+
.dff-ghost-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-primary); }
|
|
2705
|
+
.dff-ghost-btn:disabled { opacity: 0.4; cursor: default; }
|
|
2706
|
+
|
|
2424
2707
|
.dff-banner {
|
|
2425
2708
|
display: flex; align-items: center; gap: 8px;
|
|
2426
2709
|
margin: 8px 12px 0; padding: 9px 12px;
|
package/index.js
CHANGED
|
@@ -31,6 +31,52 @@ const BINARY_EXT = new Set([
|
|
|
31
31
|
'ipa', 'apk', 'aar',
|
|
32
32
|
])
|
|
33
33
|
|
|
34
|
+
// ── Previewable kinds ──────────────────────────────────────────────────────
|
|
35
|
+
// The built-in viewer renders these without turning them into text; anything
|
|
36
|
+
// else goes through the text editor (and falls back to a binary notice when the
|
|
37
|
+
// bytes are not decodable).
|
|
38
|
+
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'])
|
|
39
|
+
const VIDEO_EXT = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv', 'mkv'])
|
|
40
|
+
const AUDIO_EXT = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'flac', 'aac', 'opus'])
|
|
41
|
+
const MARKDOWN_EXT = new Set(['md', 'markdown', 'mdown', 'mdx'])
|
|
42
|
+
const HTML_EXT = new Set(['html', 'htm'])
|
|
43
|
+
const PDF_EXT = new Set(['pdf'])
|
|
44
|
+
|
|
45
|
+
const MIME_TYPES = {
|
|
46
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
47
|
+
webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', ico: 'image/x-icon',
|
|
48
|
+
svg: 'image/svg+xml',
|
|
49
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', m4v: 'video/x-m4v',
|
|
50
|
+
ogv: 'video/ogg', mkv: 'video/x-matroska',
|
|
51
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', oga: 'audio/ogg',
|
|
52
|
+
m4a: 'audio/mp4', flac: 'audio/flac', aac: 'audio/aac', opus: 'audio/ogg',
|
|
53
|
+
pdf: 'application/pdf',
|
|
54
|
+
html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
|
|
55
|
+
css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8',
|
|
56
|
+
mjs: 'text/javascript; charset=utf-8', cjs: 'text/javascript; charset=utf-8',
|
|
57
|
+
json: 'application/json; charset=utf-8', map: 'application/json; charset=utf-8',
|
|
58
|
+
txt: 'text/plain; charset=utf-8', md: 'text/markdown; charset=utf-8',
|
|
59
|
+
xml: 'application/xml; charset=utf-8', wasm: 'application/wasm',
|
|
60
|
+
woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
|
|
61
|
+
yml: 'text/yaml; charset=utf-8', yaml: 'text/yaml; charset=utf-8',
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Which built-in viewer should open a path. */
|
|
65
|
+
export function fileKindOf(name) {
|
|
66
|
+
const extension = extensionOf(name)
|
|
67
|
+
if (IMAGE_EXT.has(extension)) return 'image'
|
|
68
|
+
if (VIDEO_EXT.has(extension)) return 'video'
|
|
69
|
+
if (AUDIO_EXT.has(extension)) return 'audio'
|
|
70
|
+
if (PDF_EXT.has(extension)) return 'pdf'
|
|
71
|
+
if (MARKDOWN_EXT.has(extension)) return 'markdown'
|
|
72
|
+
if (HTML_EXT.has(extension)) return 'html'
|
|
73
|
+
return 'text'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mimeOf(name) {
|
|
77
|
+
return MIME_TYPES[extensionOf(name)] || 'application/octet-stream'
|
|
78
|
+
}
|
|
79
|
+
|
|
34
80
|
function fold(value, caseSensitive) {
|
|
35
81
|
const text = String(value || '')
|
|
36
82
|
return caseSensitive ? text : text.toLowerCase()
|
|
@@ -310,27 +356,166 @@ async function searchContent(ctx, rootPath, query, signal, options) {
|
|
|
310
356
|
async function readFile(ctx, rootPath, filePath, signal) {
|
|
311
357
|
const { target } = await resolveInside(ctx, rootPath, filePath)
|
|
312
358
|
const info = await ctx.fs.stat(target, signal)
|
|
313
|
-
if (!info || info.type !== 'file') throw Object.assign(new Error('
|
|
359
|
+
if (!info || info.type !== 'file') throw Object.assign(new Error('不是可预览的文件'), { code: 'not-text' })
|
|
360
|
+
const path = visiblePath(ctx, target)
|
|
361
|
+
const name = path.split('/').pop() || path
|
|
362
|
+
const kind = fileKindOf(name)
|
|
363
|
+
const base = {
|
|
364
|
+
ok: true,
|
|
365
|
+
path,
|
|
366
|
+
version: String(info.version),
|
|
367
|
+
bytes: info.size,
|
|
368
|
+
kind,
|
|
369
|
+
}
|
|
370
|
+
// Media and PDFs are served by the raw route; the client never needs their bytes.
|
|
371
|
+
if (kind !== 'text') {
|
|
372
|
+
if ((kind === 'markdown' || kind === 'html')
|
|
373
|
+
&& (typeof info.size !== 'number' || info.size <= READ_BYTE_CAP)) {
|
|
374
|
+
try {
|
|
375
|
+
const text = await ctx.fs.readText(target, signal)
|
|
376
|
+
if (text.length <= WRITE_CHAR_CAP) return { ...base, text }
|
|
377
|
+
} catch { /* a metadata-only preview is still useful */ }
|
|
378
|
+
}
|
|
379
|
+
return base
|
|
380
|
+
}
|
|
314
381
|
if (typeof info.size === 'number' && info.size > READ_BYTE_CAP) {
|
|
315
382
|
throw Object.assign(new Error('文件过大,不能在这里编辑'), { code: 'too-large' })
|
|
316
383
|
}
|
|
317
|
-
|
|
384
|
+
let text
|
|
385
|
+
try {
|
|
386
|
+
text = await ctx.fs.readText(target, signal)
|
|
387
|
+
} catch {
|
|
388
|
+
// A text extension with binary bytes: preview it as an opaque file instead
|
|
389
|
+
// of failing the whole open.
|
|
390
|
+
return { ...base, kind: 'binary' }
|
|
391
|
+
}
|
|
318
392
|
if (text.length > WRITE_CHAR_CAP) {
|
|
319
393
|
throw Object.assign(new Error('文件过大,不能在这里编辑'), { code: 'too-large' })
|
|
320
394
|
}
|
|
321
|
-
return {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
395
|
+
return { ...base, text }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ── Raw bytes for the built-in viewer ──────────────────────────────────────
|
|
399
|
+
// One prefix route per file, so relative URLs inside a previewed HTML page
|
|
400
|
+
// resolve here too. Ranges are capped per response; the browser asks for the
|
|
401
|
+
// next window, which is what makes video seeking work without buffering the
|
|
402
|
+
// whole file in the host.
|
|
403
|
+
const RAW_CHUNK_BYTES = 8 * 1024 * 1024
|
|
404
|
+
const RAW_FULL_CAP = 64 * 1024 * 1024
|
|
405
|
+
|
|
406
|
+
export function parseRange(value, size) {
|
|
407
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(String(value || '').replace(/\s+/g, ''))
|
|
408
|
+
if (!match) return null
|
|
409
|
+
const rawStart = match[1]
|
|
410
|
+
const rawEnd = match[2]
|
|
411
|
+
if (rawStart === '' && rawEnd === '') return null
|
|
412
|
+
let start
|
|
413
|
+
let end
|
|
414
|
+
if (rawStart === '') {
|
|
415
|
+
const suffix = Number(rawEnd)
|
|
416
|
+
if (!Number.isFinite(suffix) || suffix <= 0) return 'invalid'
|
|
417
|
+
start = Math.max(0, size - suffix)
|
|
418
|
+
end = size - 1
|
|
419
|
+
} else {
|
|
420
|
+
start = Number(rawStart)
|
|
421
|
+
end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1)
|
|
422
|
+
}
|
|
423
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) return 'invalid'
|
|
424
|
+
return { start, end }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function serveRaw(ctx, req, res, signal) {
|
|
428
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
429
|
+
res.statusCode = 405
|
|
430
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
431
|
+
res.end('只接受 GET')
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
const prefix = '/dsh-file-finder/ws/'
|
|
435
|
+
const url = new URL(req.url || '/', 'http://dsh-file-finder.invalid')
|
|
436
|
+
const rest = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : ''
|
|
437
|
+
const slash = rest.indexOf('/')
|
|
438
|
+
if (slash <= 0 || slash === rest.length - 1) {
|
|
439
|
+
res.statusCode = 400
|
|
440
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
441
|
+
res.end('缺少路径')
|
|
442
|
+
return
|
|
443
|
+
}
|
|
444
|
+
let rootPath = ''
|
|
445
|
+
let relative = ''
|
|
446
|
+
try {
|
|
447
|
+
rootPath = Buffer.from(decodeURIComponent(rest.slice(0, slash)), 'base64url').toString('utf8')
|
|
448
|
+
relative = rest.slice(slash + 1).split('/').map((segment) => decodeURIComponent(segment)).join('/')
|
|
449
|
+
} catch {
|
|
450
|
+
rootPath = ''
|
|
451
|
+
}
|
|
452
|
+
if (!rootPath || !relative) {
|
|
453
|
+
res.statusCode = 400
|
|
454
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
455
|
+
res.end('缺少路径')
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
const targetPath = rootPath.replace(/\/+$/, '') + '/' + relative
|
|
459
|
+
const { target } = await resolveInside(ctx, rootPath, targetPath)
|
|
460
|
+
const info = await ctx.fs.stat(target, signal)
|
|
461
|
+
if (!info || info.type !== 'file') {
|
|
462
|
+
res.statusCode = 404
|
|
463
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
464
|
+
res.end('没有这个文件')
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
const displayPath = visiblePath(ctx, target)
|
|
468
|
+
const size = Number(info.size) || 0
|
|
469
|
+
res.setHeader('content-type', mimeOf(displayPath))
|
|
470
|
+
res.setHeader('accept-ranges', 'bytes')
|
|
471
|
+
res.setHeader('cache-control', 'no-store')
|
|
472
|
+
res.setHeader('x-content-type-options', 'nosniff')
|
|
473
|
+
const range = parseRange(req.headers.range, size)
|
|
474
|
+
if (range === 'invalid') {
|
|
475
|
+
res.statusCode = 416
|
|
476
|
+
res.setHeader('content-range', `bytes */${size}`)
|
|
477
|
+
res.end()
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
if (range) {
|
|
481
|
+
const start = range.start
|
|
482
|
+
const end = Math.min(range.end, range.start + RAW_CHUNK_BYTES - 1)
|
|
483
|
+
const buffer = await ctx.fs.readByteRange(target, { offset: start, length: end - start + 1 }, signal)
|
|
484
|
+
res.statusCode = 206
|
|
485
|
+
res.setHeader('content-range', `bytes ${start}-${end}/${size}`)
|
|
486
|
+
res.setHeader('content-length', String(buffer.length))
|
|
487
|
+
if (req.method === 'HEAD') {
|
|
488
|
+
res.end()
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
res.end(buffer)
|
|
492
|
+
return
|
|
327
493
|
}
|
|
494
|
+
if (size > RAW_FULL_CAP) {
|
|
495
|
+
res.statusCode = 413
|
|
496
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
497
|
+
res.end('文件过大,请改用 DSH 右侧栏预览或系统应用打开')
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
const buffer = await ctx.fs.readBytes(target, signal, RAW_FULL_CAP)
|
|
501
|
+
res.statusCode = 200
|
|
502
|
+
res.setHeader('content-length', String(buffer.length))
|
|
503
|
+
if (req.method === 'HEAD') {
|
|
504
|
+
res.end()
|
|
505
|
+
return
|
|
506
|
+
}
|
|
507
|
+
res.end(buffer)
|
|
328
508
|
}
|
|
329
509
|
|
|
330
510
|
async function saveFile(ctx, rootPath, filePath, content, version, signal) {
|
|
331
511
|
if (typeof content !== 'string') throw new Error('缺少文件内容')
|
|
332
512
|
if (content.length > WRITE_CHAR_CAP) throw Object.assign(new Error('内容过大,不能保存'), { code: 'too-large' })
|
|
333
513
|
const { target } = await resolveInside(ctx, rootPath, filePath)
|
|
514
|
+
const displayPath = visiblePath(ctx, target)
|
|
515
|
+
const kind = fileKindOf(displayPath.split('/').pop() || displayPath)
|
|
516
|
+
if (kind !== 'text' && kind !== 'markdown' && kind !== 'html') {
|
|
517
|
+
throw Object.assign(new Error('这个文件类型不能在面板里编辑'), { code: 'not-text' })
|
|
518
|
+
}
|
|
334
519
|
const info = await ctx.fs.stat(target, signal)
|
|
335
520
|
if (!info || info.type !== 'file') throw Object.assign(new Error('不是可编辑的文件'), { code: 'not-text' })
|
|
336
521
|
if (version && String(info.version) !== String(version)) {
|
|
@@ -401,6 +586,35 @@ function registerRoutes(ctx) {
|
|
|
401
586
|
},
|
|
402
587
|
}), `dsh-file-finder: /dsh-file-finder/${name}`)
|
|
403
588
|
}
|
|
589
|
+
// The built-in viewer's read-only byte route: images, video, audio, PDFs and
|
|
590
|
+
// HTML previews (plus the assets a previewed page asks for).
|
|
591
|
+
ctx.effect(() => ctx.webServer.register({
|
|
592
|
+
kind: 'prefix',
|
|
593
|
+
// Prefix routes match `${path}/…`: keep this without the trailing slash.
|
|
594
|
+
path: '/dsh-file-finder/ws',
|
|
595
|
+
handler: async (req, res) => {
|
|
596
|
+
const connection = ctx.get('connection')
|
|
597
|
+
const rejected = rejectionStatus(connection, req)
|
|
598
|
+
if (rejected !== undefined) {
|
|
599
|
+
res.statusCode = rejected
|
|
600
|
+
res.end()
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
const controller = new AbortController()
|
|
604
|
+
res.on('close', () => {
|
|
605
|
+
if (!res.writableEnded) controller.abort()
|
|
606
|
+
})
|
|
607
|
+
try {
|
|
608
|
+
await serveRaw(ctx, req, res, controller.signal)
|
|
609
|
+
} catch (error) {
|
|
610
|
+
if (!res.writableEnded) {
|
|
611
|
+
res.statusCode = error && error.code === 'FS_TOO_LARGE' ? 413 : 404
|
|
612
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
613
|
+
res.end(errorText(error))
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
}), 'dsh-file-finder: raw files')
|
|
404
618
|
}
|
|
405
619
|
|
|
406
620
|
function registerTool(ctx) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-file-finder",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "File
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "File finder for DSH: search by name or content, preview images, video, audio, PDF, HTML and Markdown, and edit text files with line numbers and in-file find.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"icon": "./icon.svg",
|
|
@@ -34,10 +34,11 @@
|
|
|
34
34
|
"LICENSE"
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node --test test/search.test.mjs test/boot-safety.test.mjs test/ui-contract.test.mjs",
|
|
37
|
+
"test": "node --test test/search.test.mjs test/raw-route.test.mjs test/boot-safety.test.mjs test/ui-contract.test.mjs",
|
|
38
38
|
"preview": "node preview/screenshot.mjs",
|
|
39
39
|
"check:bot": "node preview/bot-check.mjs",
|
|
40
|
-
"check:find": "node preview/find-check.mjs"
|
|
40
|
+
"check:find": "node preview/find-check.mjs",
|
|
41
|
+
"check:viewer": "node preview/viewer-check.mjs"
|
|
41
42
|
},
|
|
42
43
|
"dsh": {
|
|
43
44
|
"bundle": {
|