dsh-archive-preview 0.0.0-stage → 0.1.3

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/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.3
4
+
5
+ - 配置不再写死可信域名:默认读取 dsh web 启动参数里的 `--trusted-host`。
6
+
7
+ ## 0.1.2
8
+
9
+ - 更新 README。
10
+
11
+ ## 0.1.1
12
+
13
+ - 修复 TAR 条目读取的二次复杂度:16MB 条目从约 2.5 秒降到约 0.3 秒。
14
+
15
+ ## 0.1.0
16
+
17
+ - 首个版本:右侧栏文档预览查看 zip/7z/rar/tar 等压缩包。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jackylong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ # dsh-archive-preview
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 在 DSH 右侧栏「文档预览」里查看压缩包:目录树、文本、图片、单文件下载。
4
+
5
+ 注册在核心文档预览注册表(`documentPreviews`)。走核心右侧栏打开的文件都会命中:工作区文件树、聊天里的文件引用,以及其它插件通过 `openResource` 打开的路径。不依赖其它插件。
6
+
7
+ ## 功能
8
+
9
+ - **目录浏览**:逐层进入文件夹,面包屑返回,按名称筛选;显示每个文件的大小与修改时间;加密条目单独标注。
10
+ - **在线预览**:文本(txt/md/json/csv/代码等,支持 UTF-8 与 GBK)与图片(png/jpg/gif/webp/bmp/ico/avif);其余类型只提供下载。
11
+ - **单文件下载**:文件名按 RFC 5987 编码。
12
+ - **只在内存解压**:条目不写入磁盘。
13
+
14
+ ## 支持的格式
15
+
16
+ | 格式 | 读取方式 | 说明 |
17
+ |---|---|---|
18
+ | `.zip` | 本插件纯 JS(零依赖) | 只读中央目录,按需解压 |
19
+ | `.tar` | 本插件纯 JS 流式解析 | 支持 GNU 长名与 PAX 头 |
20
+ | `.tar.gz` / `.tgz` | Node `zlib` + 纯 JS TAR | |
21
+ | `.tar.bz2` / `.tbz2` / `.tbz` | 系统 `bzip2` 解压流 + 纯 JS TAR | |
22
+ | `.tar.xz` / `.txz` | 系统 `xz` 解压流 + 纯 JS TAR | |
23
+ | `.tar.zst` / `.tar.zstd` / `.tzst` | 系统 `zstd` 解压流 + 纯 JS TAR | |
24
+ | `.7z` / `.rar` | 系统 `7z`(RAR 无 7z 时退 `unrar`) | 需要 7z / unrar |
25
+ | `.gz` / `.bz2` / `.xz` / `.zst`(单文件) | 系统 `7z` | 条目名取自文件名 |
26
+
27
+ 不支持的格式、加密条目、超出上限的条目会返回具体原因,并提供下载入口。
28
+
29
+ ## 安全与上限
30
+
31
+ - 仅服务 loopback 与 `trustedHosts` 中的域名;跨站请求返回 403。
32
+ - 仅接受常见压缩包扩展名;`realpath` 后必须位于 `allowedRoots` 内,软链越界返回 403。
33
+ - 上限:条目解压 32MB、文本预览 1.5MB、图片 24MB;超过上限返回「请下载」。
34
+ - 列表最多 20000 条,超出截断。
35
+ - 外部工具按参数数组调用,不经过 shell。
36
+ - 内联 SVG 以图片 MIME 返回,并附 `sandbox` CSP。
37
+
38
+ ## 安装
39
+
40
+ 在 DSH 的插件管理里安装 `dsh-archive-preview`,装完重启 dsh web。
41
+
42
+ 需要 7z / rar 时,主机要有 `7z`(RAR 没有 7z 时用 `unrar`);zip、tar、tar.gz 不需要额外工具。
43
+
44
+ ## 配置(`cordis.patch.yml`)
45
+
46
+ ```yaml
47
+ - insert:
48
+ - id: dsh-archive-preview
49
+ name: dsh-archive-preview
50
+ config:
51
+ # 可选;不写就跟 dsh web 的 --trusted-host 一致
52
+ trustedHosts: ['<your-domain>']
53
+ allowedRoots: !!js "[dshHomePath('..')]"
54
+ ```
55
+
56
+ - `trustedHosts`:额外放行的反代域名;不写默认读 dsh web 启动参数里的 `--trusted-host`(loopback 始终放行)。
57
+ - `allowedRoots`:允许读取压缩包的根目录;不写默认 `$DSH_HOME` 的父目录(通常是用户家目录)。
58
+
59
+ ## 接口
60
+
61
+ - `GET /api/dsh-archive-preview/list?path=…[&cwd=…]` → 条目列表 JSON
62
+ - `GET /api/dsh-archive-preview/entry?path=…&name=…[&cwd=…][&download=1]` → 文本/图片原样返回,其余下载
63
+
64
+ 两个路由都要求可信 Host/Origin;`path` 可为绝对路径,或相对 `cwd` / 工作区根的路径。
65
+
66
+ ## 已知限制
67
+
68
+ - 加密压缩包不解密,只列出并标注。
69
+ - 嵌套压缩包(zip 里套 zip)不递归展开。
70
+ - TAR 每次取条目都会重扫整包(压缩流没有随机访问),大包连续点多个文件会重复解压。
71
+ - 单流格式(`.gz`/`.bz2`/`.xz`/`.zst`)的条目名取自文件名;`.gz` 头里带原始名时用原始名。
72
+ - RAR 依赖 `7z`/`unrar`,开发环境没有 RAR 样本,未做真机用例。
73
+ - 大包的目录列表一次性读完再渲染;条目数超过 2 万会截断。
74
+
75
+ ## 开发
76
+
77
+ ```sh
78
+ node --test test/*.test.mjs
79
+ ```
80
+
81
+ 改 `client/client.js` 刷新页面即可;改 `lib/` 需要重启 dsh web。
82
+
83
+ 测试自带最小 ZIP/TAR 写入器(不引第三方包),覆盖 store/deflate、GBK 文件名、加密标记、TAR 长名/PAX、路径白名单与信任门;有 `7z` 时会真跑一遍 7z 与单流格式。
84
+
85
+ ## 目录结构
86
+
87
+ ```
88
+ lib/zip.js ZIP 中央目录 + 条目解压(零依赖)
89
+ lib/tar.js TAR 流式解析 + gzip/xz/bz2/zstd 解压流
90
+ lib/sevenzip.js 7z / unrar 调用与输出解析
91
+ lib/archive.js 类型识别与统一读取入口
92
+ lib/encoding.js UTF-8 → GBK → latin1 容错解码
93
+ lib/index.js 两个 HTTP 路由、信任门、路径白名单、上限
94
+ client/client.js React 预览正文(目录树/搜索/文本/图片/下载)
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT
@@ -0,0 +1,464 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-archive-preview',
3
+ factory: (require) => {
4
+ const module = { exports: {} }
5
+ const exports = module.exports
6
+ const React = require('react')
7
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
8
+
9
+ // ===== dsh-archive-preview: 压缩包文档预览 =====
10
+ // 只做一件事:把常见压缩包(zip/7z/rar/tar/tar.gz…)注册进核心的文档预览注册表,
11
+ // 并用**同一个 id** 提供 React 正文。
12
+ // · 元数据契约:ctx.documentPreviews.register({ id, extensions, binaryExtensions, priority, title, loading, wrap })
13
+ // · 正文契约:同 id 注册 keyed / Session 作用域子 slot `sidebar.right.tab.document`;
14
+ // `loading:'renderer'` ⇒ 正文自己取内容,并拿到 `content: { kind:'renderer', revision, loaded, failed, reload }`。
15
+ // · `priority:'extension'` 才排在核心的纯文本兜底之前(否则 .zip 会被核心当"二进制容器"显示不支持)。
16
+ // · 数据全走本插件自己的两个 JSON 路由(见 lib/index.js),不碰核心、不改别的插件。
17
+ //
18
+ // 覆盖的四个入口(都经核心 `sidebarRight.openResource` 到同一个注册表):
19
+ // 会话产物卡片 / 会话文件面板 / 文件管理页签 / 原生工作区文件树。
20
+
21
+ const PREVIEW_ID = 'dsh-archive-preview/preview'
22
+ const LIST_ROUTE = '/api/dsh-archive-preview/list'
23
+ const ENTRY_ROUTE = '/api/dsh-archive-preview/entry'
24
+
25
+ const EXTENSIONS = [
26
+ '.zip', '.7z', '.rar',
27
+ '.tar', '.tar.gz', '.tgz', '.tar.bz2', '.tbz2', '.tbz', '.tar.xz', '.txz', '.tar.zst', '.tar.zstd', '.tzst',
28
+ '.gz', '.bz2', '.xz', '.zst', '.zstd',
29
+ ]
30
+ const BINARY_EXTENSIONS = [
31
+ 'zip', '7z', 'rar', 'tar', 'tar.gz', 'tgz', 'tar.bz2', 'tbz2', 'tbz', 'tar.xz', 'txz', 'tar.zst', 'tar.zstd', 'tzst',
32
+ 'gz', 'bz2', 'xz', 'zst', 'zstd',
33
+ ]
34
+
35
+ const TEXT_EXTENSIONS = /\.(txt|text|md|markdown|log|json|jsonl|ndjson|csv|tsv|xml|html?|css|scss|less|js|mjs|cjs|jsx|ts|tsx|yml|yaml|toml|ini|conf|cfg|properties|env|sh|bash|zsh|fish|ps1|bat|cmd|py|rb|go|rs|java|kt|swift|c|h|cc|cpp|hpp|cs|php|lua|pl|r|sql|vue|svelte|astro|svg|patch|diff)$/i
36
+ const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i
37
+
38
+ /** 只在"确实像编码"时才解:逐段 decodeURIComponent,解不开就原样。 */
39
+ const pathMaybeDecoded = (value) => {
40
+ let text = typeof value === 'string' ? value : ''
41
+ if (text === '' || text.indexOf('%') === -1) return text
42
+ for (let i = 0; i < 3; i += 1) {
43
+ try {
44
+ const decoded = text.split('/').map((segment) => decodeURIComponent(segment)).join('/')
45
+ if (decoded === text) break
46
+ text = decoded
47
+ } catch { break }
48
+ }
49
+ return text
50
+ }
51
+
52
+ /** 从核心资源地址里抠出文件路径(纯函数);认不出返回 `''`(服务端按 cwd / 工作区根兜底)。 */
53
+ const pathFromResourceAddress = (address) => {
54
+ try {
55
+ const PREFIX = 'dsh-resource://file/'
56
+ const raw = String(address || '')
57
+ if (raw.indexOf(PREFIX) !== 0) return ''
58
+ const cut = raw.search(/[?#]/)
59
+ const parts = (cut === -1 ? raw.slice(PREFIX.length) : raw.slice(PREFIX.length, cut)).split('/')
60
+ const scope = parts.shift()
61
+ if (scope === 'session') {
62
+ if (parts.length < 2 || parts[0] === '') return ''
63
+ return parts.slice(1).map(decodeURIComponent).join('/')
64
+ }
65
+ if (scope === 'absolute') {
66
+ const rest = parts.filter((segment) => segment !== '')
67
+ return rest.length === 0 ? '' : '/' + rest.map(decodeURIComponent).join('/')
68
+ }
69
+ return ''
70
+ } catch { return '' }
71
+ }
72
+
73
+ /** 会话当前工作目录(核心挂在 window 上的约定值)。 */
74
+ const currentCwd = () => {
75
+ try { return String(window.__dshCurrentCwd || '') } catch { return '' }
76
+ }
77
+
78
+ const formatSize = (bytes) => {
79
+ const value = Number(bytes) || 0
80
+ if (value < 1024) return value + ' B'
81
+ if (value < 1048576) return (value / 1024).toFixed(1) + ' KiB'
82
+ if (value < 1073741824) return (value / 1048576).toFixed(1) + ' MiB'
83
+ return (value / 1073741824).toFixed(2) + ' GiB'
84
+ }
85
+
86
+ const formatTime = (ms) => {
87
+ const value = Number(ms) || 0
88
+ if (value <= 0) return ''
89
+ try {
90
+ const date = new Date(value)
91
+ const pad = (n) => String(n).padStart(2, '0')
92
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
93
+ } catch { return '' }
94
+ }
95
+
96
+ const baseNameOf = (name) => String(name || '').replace(/\/+$/, '').split('/').pop() || String(name || '')
97
+
98
+ const extensionOf = (name) => {
99
+ const match = /\.([A-Za-z0-9]+)$/.exec(String(name || ''))
100
+ return match ? match[1].toLowerCase() : ''
101
+ }
102
+
103
+ const iconOf = (name) => {
104
+ const extension = extensionOf(name)
105
+ if (IMAGE_EXTENSIONS.test(name)) return '🖼️'
106
+ if (TEXT_EXTENSIONS.test(name)) return '📝'
107
+ if (extension === 'pdf') return '📕'
108
+ if (extension === 'docx' || extension === 'doc') return '📄'
109
+ if (extension === 'xlsx' || extension === 'xls' || extension === 'csv') return '📊'
110
+ if (extension === 'pptx' || extension === 'ppt') return '📽️'
111
+ if (/^(zip|7z|rar|tar|gz|bz2|xz|zst|tgz|tbz2|txz)$/.test(extension)) return '🗜️'
112
+ return '📄'
113
+ }
114
+
115
+ /** 把条目列表建成目录树(纯函数;目录条目与隐式父目录都算)。 */
116
+ const buildTree = (entries) => {
117
+ const root = { name: '', dirs: new Map(), files: [] }
118
+ const ensure = (parts) => {
119
+ let node = root
120
+ for (const part of parts) {
121
+ if (!node.dirs.has(part)) node.dirs.set(part, { name: part, dirs: new Map(), files: [] })
122
+ node = node.dirs.get(part)
123
+ }
124
+ return node
125
+ }
126
+ for (const entry of entries || []) {
127
+ const raw = String(entry.name || '').replace(/\\/g, '/').replace(/\/+$/, '')
128
+ if (raw === '') continue
129
+ const parts = raw.split('/').filter((part) => part !== '' && part !== '.')
130
+ if (parts.length === 0) continue
131
+ if (entry.dir === true) { ensure(parts); continue }
132
+ const parent = ensure(parts.slice(0, -1))
133
+ parent.files.push({ ...entry, name: raw, base: parts[parts.length - 1] })
134
+ }
135
+ return root
136
+ }
137
+
138
+ const nodeAt = (tree, dirPath) => {
139
+ let node = tree
140
+ for (const part of String(dirPath || '').split('/').filter(Boolean)) {
141
+ if (!node.dirs.has(part)) return node
142
+ node = node.dirs.get(part)
143
+ }
144
+ return node
145
+ }
146
+
147
+ const countFiles = (node) => {
148
+ let total = node.files.length
149
+ for (const child of node.dirs.values()) total += countFiles(child)
150
+ return total
151
+ }
152
+
153
+ /** 全部文件(搜索时用平铺列表)。 */
154
+ const flattenFiles = (node, prefix, out) => {
155
+ for (const file of node.files) out.push({ ...file, displayPath: prefix === '' ? file.base : prefix + '/' + file.base })
156
+ for (const [name, child] of node.dirs) flattenFiles(child, prefix === '' ? name : prefix + '/' + name, out)
157
+ return out
158
+ }
159
+
160
+ const CSS = `
161
+ .dsh-archive{position:absolute;inset:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-document-preview,#fff);color:var(--dsw-alias-label-primary);font:12px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;overflow:hidden}
162
+ .dsh-archive-toolbar{flex:none;display:flex;align-items:center;gap:8px;padding:8px 12px;border-bottom:.5px solid var(--dsw-alias-border-l2);flex-wrap:wrap}
163
+ .dsh-archive-kind{flex:none;font-size:10px;line-height:16px;padding:1px 6px;border-radius:5px;border:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap}
164
+ .dsh-archive-crumbs{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:2px;overflow:hidden}
165
+ .dsh-archive-crumb{border:0;background:transparent;color:var(--dsw-alias-state-business-primary);font:inherit;font-size:12px;padding:1px 4px;border-radius:5px;cursor:pointer;white-space:nowrap}
166
+ .dsh-archive-crumb:hover{background:var(--dsw-alias-interactive-bg-hover)}
167
+ .dsh-archive-crumb-current{color:var(--dsw-alias-label-primary);cursor:default}
168
+ .dsh-archive-crumb-current:hover{background:transparent}
169
+ .dsh-archive-search{flex:0 1 150px;min-width:96px;box-sizing:border-box;padding:3px 8px;border:.5px solid var(--dsw-alias-border-l2);border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:12px}
170
+ .dsh-archive-scroll{flex:1 1 auto;min-height:0;overflow:auto;padding:6px 8px 12px}
171
+ .dsh-archive-meta{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;padding:2px 2px 6px}
172
+ .dsh-archive-banner{flex:none;margin:0 0 8px;padding:6px 10px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:11px;background:var(--dsw-alias-bg-layer-2)}
173
+ .dsh-archive-row{display:flex;align-items:center;gap:8px;width:100%;box-sizing:border-box;padding:6px 10px;border:0;border-radius:7px;background:transparent;color:inherit;font:inherit;font-size:12px;text-align:left;cursor:pointer}
174
+ .dsh-archive-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
175
+ .dsh-archive-ico{flex:none;width:18px;text-align:center}
176
+ .dsh-archive-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
177
+ .dsh-archive-size,.dsh-archive-time{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;white-space:nowrap}
178
+ .dsh-archive-size{width:78px;text-align:right}
179
+ .dsh-archive-time{width:112px;text-align:right}
180
+ .dsh-archive-lock{flex:none;font-size:11px;color:var(--dsw-alias-label-tertiary)}
181
+ .dsh-archive-state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;height:100%;min-height:160px;padding:24px;text-align:center;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.8}
182
+ .dsh-archive-state strong{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600}
183
+ .dsh-archive-state button{border:.5px solid var(--dsw-alias-border-l2);background:transparent;color:inherit;font:inherit;padding:4px 14px;border-radius:7px;cursor:pointer}
184
+ .dsh-archive-state button:hover{background:var(--dsw-alias-interactive-bg-hover)}
185
+ .dsh-archive-detail{display:flex;flex-direction:column;height:100%;min-height:0}
186
+ .dsh-archive-detail-head{flex:none;display:flex;align-items:center;gap:8px;padding:2px 2px 8px}
187
+ .dsh-archive-back{flex:none;border:.5px solid var(--dsw-alias-border-l2);background:transparent;color:inherit;font:inherit;font-size:12px;padding:3px 10px;border-radius:7px;cursor:pointer}
188
+ .dsh-archive-back:hover{background:var(--dsw-alias-interactive-bg-hover)}
189
+ .dsh-archive-detail-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:600}
190
+ .dsh-archive-detail-size{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px}
191
+ .dsh-archive-download{flex:none;border:.5px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-state-business-primary);font:inherit;font-size:12px;padding:3px 10px;border-radius:7px;text-decoration:none;cursor:pointer}
192
+ .dsh-archive-download:hover{background:var(--dsw-alias-interactive-bg-hover)}
193
+ .dsh-archive-detail-body{flex:1 1 auto;min-height:0;overflow:auto}
194
+ .dsh-archive-text{margin:0;padding:10px 12px;white-space:pre-wrap;word-break:break-word;font:12px/1.7 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--dsw-alias-label-primary)}
195
+ .dsh-archive-image{display:block;max-width:100%;height:auto;margin:8px auto;border-radius:8px}
196
+ @media (max-width:560px){.dsh-archive-time{display:none}.dsh-archive-size{width:auto}}
197
+ `
198
+
199
+ /** 正文:目录浏览(树 + 搜索)+ 条目预览(文本/图片/下载)。 */
200
+ const ArchivePreviewBody = (props) => {
201
+ const content = props ? props.content : undefined
202
+ const resourceAddress = String((props && props.resourceAddress) || '')
203
+ const resource = props && typeof props.useResource === 'function' ? props.useResource(resourceAddress) : undefined
204
+ const value = resource && resource.value ? resource.value : undefined
205
+ const absolutePath = pathMaybeDecoded(value && typeof value.absolutePath === 'string' ? value.absolutePath : '')
206
+ const version = value ? value.version : undefined
207
+ const revision = content && content.kind === 'renderer' ? content.revision : 0
208
+ const filePath = absolutePath !== '' ? absolutePath : pathFromResourceAddress(resourceAddress)
209
+ const cwd = absolutePath !== '' ? '' : currentCwd()
210
+
211
+ const [listing, setListing] = React.useState({ status: 'loading' })
212
+ const [dirPath, setDirPath] = React.useState('')
213
+ const [query, setQuery] = React.useState('')
214
+ const [selected, setSelected] = React.useState(null)
215
+ const [detail, setDetail] = React.useState({ status: 'idle' })
216
+ const reportedRef = React.useRef('')
217
+
218
+ React.useEffect(() => {
219
+ let alive = true
220
+ setListing({ status: 'loading' })
221
+ setSelected(null)
222
+ setDetail({ status: 'idle' })
223
+ setDirPath('')
224
+ setQuery('')
225
+ if (filePath === '') {
226
+ setListing({ status: 'error', error: '拿不到文件路径(这个入口没有给出可预览的本地路径)' })
227
+ return undefined
228
+ }
229
+ let url = LIST_ROUTE + '?path=' + encodeURIComponent(filePath)
230
+ if (cwd !== '') url += '&cwd=' + encodeURIComponent(cwd)
231
+ fetch(url, { cache: 'no-store', credentials: 'same-origin' })
232
+ .then(async (response) => {
233
+ const body = await response.json().catch(() => ({}))
234
+ if (!response.ok || body.ok !== true) throw new Error(body.error || ('HTTP ' + response.status))
235
+ return body
236
+ })
237
+ .then((data) => {
238
+ if (!alive) return
239
+ setListing({ status: 'ready', data })
240
+ if (version !== undefined && content && content.kind === 'renderer') {
241
+ const key = String(revision) + ':' + String(version)
242
+ if (reportedRef.current !== key) {
243
+ reportedRef.current = key
244
+ try { content.loaded(version) } catch { /* 忽略 */ }
245
+ }
246
+ }
247
+ })
248
+ .catch((error) => {
249
+ if (!alive) return
250
+ setListing({ status: 'error', error: String((error && error.message) || error) })
251
+ try { if (content && content.kind === 'renderer') content.failed() } catch { /* 忽略 */ }
252
+ })
253
+ return () => { alive = false }
254
+ }, [filePath, cwd, revision])
255
+
256
+ // 条目预览:文本拉回来、图片直接交给 <img>、其余类型只给下载。
257
+ React.useEffect(() => {
258
+ if (selected === null) { setDetail({ status: 'idle' }); return undefined }
259
+ if (selected.encrypted === true) { setDetail({ status: 'encrypted' }); return undefined }
260
+ if (IMAGE_EXTENSIONS.test(selected.name)) { setDetail({ status: 'image' }); return undefined }
261
+ if (!TEXT_EXTENSIONS.test(selected.name)) { setDetail({ status: 'binary' }); return undefined }
262
+ let alive = true
263
+ const controller = typeof AbortController === 'function' ? new AbortController() : null
264
+ setDetail({ status: 'loading' })
265
+ let url = ENTRY_ROUTE + '?path=' + encodeURIComponent(filePath) + '&name=' + encodeURIComponent(selected.name)
266
+ if (cwd !== '') url += '&cwd=' + encodeURIComponent(cwd)
267
+ fetch(url, { cache: 'no-store', credentials: 'same-origin', signal: controller ? controller.signal : undefined })
268
+ .then(async (response) => {
269
+ if (!response.ok) {
270
+ const body = await response.json().catch(() => ({}))
271
+ throw new Error(body.error || ('HTTP ' + response.status))
272
+ }
273
+ return response.text()
274
+ })
275
+ .then((text) => { if (alive) setDetail({ status: 'text', text }) })
276
+ .catch((error) => {
277
+ if (!alive) return
278
+ if (error && error.name === 'AbortError') return
279
+ setDetail({ status: 'error', error: String((error && error.message) || error) })
280
+ })
281
+ return () => { alive = false; try { if (controller) controller.abort() } catch { /* 忽略 */ } }
282
+ }, [selected, filePath, cwd])
283
+
284
+ const reload = () => { try { if (content && content.kind === 'renderer') content.reload() } catch { /* 忽略 */ } }
285
+
286
+ const rootStyle = { position: 'absolute', inset: '0' }
287
+ const styleElement = React.createElement('style', { key: 'css' }, CSS)
288
+
289
+ if (listing.status === 'loading') {
290
+ return React.createElement('div', { className: 'dsh-archive', style: rootStyle }, styleElement,
291
+ React.createElement('div', { className: 'dsh-archive-state' },
292
+ React.createElement('strong', null, '正在读取压缩包…'),
293
+ React.createElement('span', null, absolutePath !== '' ? absolutePath : filePath)))
294
+ }
295
+ if (listing.status === 'error') {
296
+ return React.createElement('div', { className: 'dsh-archive', style: rootStyle }, styleElement,
297
+ React.createElement('div', { className: 'dsh-archive-state' },
298
+ React.createElement('strong', null, '打不开这个压缩包'),
299
+ React.createElement('span', null, listing.error),
300
+ React.createElement('button', { type: 'button', onClick: reload }, '重试')))
301
+ }
302
+
303
+ const data = listing.data
304
+ const tree = buildTree(data.entries)
305
+ const baseUrl = (name, download) => {
306
+ let url = ENTRY_ROUTE + '?path=' + encodeURIComponent(filePath) + '&name=' + encodeURIComponent(name)
307
+ if (cwd !== '') url += '&cwd=' + encodeURIComponent(cwd)
308
+ if (download === true) url += '&download=1'
309
+ return url
310
+ }
311
+
312
+ const openEntry = (file) => { setSelected(file); }
313
+ const goDir = (next) => { setDirPath(next); setQuery(''); setSelected(null) }
314
+ const back = () => { setSelected(null); setDetail({ status: 'idle' }) }
315
+
316
+ const toolbar = React.createElement('div', { className: 'dsh-archive-toolbar', key: 'toolbar' }, [
317
+ React.createElement('span', { className: 'dsh-archive-kind', key: 'kind', title: data.note || '' }, data.label || '压缩包'),
318
+ React.createElement('div', { className: 'dsh-archive-crumbs', key: 'crumbs' }, (() => {
319
+ const items = [React.createElement('button', {
320
+ key: 'root', type: 'button', className: 'dsh-archive-crumb' + (dirPath === '' && query === '' ? ' dsh-archive-crumb-current' : ''),
321
+ onClick: () => goDir(''),
322
+ }, data.name)]
323
+ const segments = dirPath === '' ? [] : dirPath.split('/')
324
+ segments.forEach((segment, index) => {
325
+ const target = segments.slice(0, index + 1).join('/')
326
+ const isLast = index === segments.length - 1
327
+ items.push(React.createElement('span', { key: 'sep' + index, className: 'dsh-archive-crumb-current' }, '/'))
328
+ items.push(React.createElement('button', {
329
+ key: 'seg' + index, type: 'button',
330
+ className: 'dsh-archive-crumb' + (isLast ? ' dsh-archive-crumb-current' : ''),
331
+ onClick: () => goDir(target),
332
+ }, segment))
333
+ })
334
+ return items
335
+ })()),
336
+ React.createElement('input', {
337
+ key: 'search', className: 'dsh-archive-search', type: 'search',
338
+ placeholder: '筛选条目…', value: query, spellCheck: false,
339
+ onChange: (event) => { setQuery(event.target.value); setSelected(null) },
340
+ }),
341
+ ])
342
+
343
+ const meta = React.createElement('div', { className: 'dsh-archive-meta', key: 'meta' },
344
+ `${data.total} 个条目 · 共 ${formatSize(data.bytes)}` + (data.truncated === true ? ' · 条目太多,只显示前一部分' : ''))
345
+
346
+ let body
347
+ if (selected !== null) {
348
+ const detailHead = React.createElement('div', { className: 'dsh-archive-detail-head', key: 'head' }, [
349
+ React.createElement('button', { className: 'dsh-archive-back', key: 'back', type: 'button', onClick: back }, '← 返回'),
350
+ React.createElement('span', { className: 'dsh-archive-detail-name', key: 'name', title: selected.name }, baseNameOf(selected.name)),
351
+ React.createElement('span', { className: 'dsh-archive-detail-size', key: 'size' }, formatSize(selected.size)),
352
+ React.createElement('a', {
353
+ className: 'dsh-archive-download', key: 'dl', href: baseUrl(selected.name, true),
354
+ download: baseNameOf(selected.name),
355
+ }, '下载'),
356
+ ])
357
+
358
+ let detailBody
359
+ if (detail.status === 'loading') detailBody = React.createElement('div', { className: 'dsh-archive-state' }, '正在读取…')
360
+ else if (detail.status === 'text') detailBody = React.createElement('pre', { className: 'dsh-archive-text' }, detail.text)
361
+ else if (detail.status === 'image') detailBody = React.createElement('img', {
362
+ className: 'dsh-archive-image', src: baseUrl(selected.name, false), alt: baseNameOf(selected.name),
363
+ onError: () => setDetail({ status: 'error', error: '图片加载失败(可能不是有效图片或体积过大)' }),
364
+ })
365
+ else if (detail.status === 'encrypted') detailBody = React.createElement('div', { className: 'dsh-archive-state' }, '这个条目已加密,无法在线预览。可下载后用对应工具解密查看。')
366
+ else if (detail.status === 'binary') detailBody = React.createElement('div', { className: 'dsh-archive-state' }, '这种类型不能在线预览,点右上角「下载」查看。')
367
+ else if (detail.status === 'error') detailBody = React.createElement('div', { className: 'dsh-archive-state' }, detail.error)
368
+ else detailBody = React.createElement('div', { className: 'dsh-archive-state' }, '正在准备…')
369
+
370
+ body = React.createElement('div', { className: 'dsh-archive-detail', key: 'detail' },
371
+ detailHead,
372
+ React.createElement('div', { className: 'dsh-archive-detail-body' }, detailBody))
373
+ } else {
374
+ const children = [meta]
375
+ if (query.trim() !== '') {
376
+ const keyword = query.trim().toLowerCase()
377
+ const hits = flattenFiles(tree, '', []).filter((file) => file.displayPath.toLowerCase().indexOf(keyword) !== -1)
378
+ if (hits.length === 0) children.push(React.createElement('div', { className: 'dsh-archive-state' }, '没有匹配的条目'))
379
+ for (const file of hits) children.push(React.createElement('button', {
380
+ key: 'f' + file.name, type: 'button', className: 'dsh-archive-row',
381
+ onClick: () => openEntry(file), title: file.displayPath,
382
+ }, [
383
+ React.createElement('span', { className: 'dsh-archive-ico', key: 'i' }, iconOf(file.base)),
384
+ React.createElement('span', { className: 'dsh-archive-name', key: 'n' }, file.displayPath),
385
+ file.encrypted === true ? React.createElement('span', { className: 'dsh-archive-lock', key: 'l' }, '🔒') : null,
386
+ React.createElement('span', { className: 'dsh-archive-size', key: 's' }, formatSize(file.size)),
387
+ ]))
388
+ } else {
389
+ const node = nodeAt(tree, dirPath)
390
+ const dirs = Array.from(node.dirs.keys()).sort((a, b) => a.localeCompare(b, 'zh'))
391
+ if (dirs.length === 0 && node.files.length === 0) {
392
+ children.push(React.createElement('div', { className: 'dsh-archive-state' }, dirPath === '' && data.total === 0 ? '这个压缩包是空的' : '这个目录是空的'))
393
+ }
394
+ for (const name of dirs) {
395
+ const child = node.dirs.get(name)
396
+ const next = dirPath === '' ? name : dirPath + '/' + name
397
+ children.push(React.createElement('button', {
398
+ key: 'd' + next, type: 'button', className: 'dsh-archive-row',
399
+ onClick: () => goDir(next),
400
+ }, [
401
+ React.createElement('span', { className: 'dsh-archive-ico', key: 'i' }, '📁'),
402
+ React.createElement('span', { className: 'dsh-archive-name', key: 'n' }, name),
403
+ React.createElement('span', { className: 'dsh-archive-size', key: 's' }, `${countFiles(child)} 项`),
404
+ ]))
405
+ }
406
+ const files = node.files.slice().sort((a, b) => a.base.localeCompare(b.base, 'zh'))
407
+ for (const file of files) {
408
+ children.push(React.createElement('button', {
409
+ key: 'f' + file.name, type: 'button', className: 'dsh-archive-row',
410
+ onClick: () => openEntry(file), title: file.name,
411
+ }, [
412
+ React.createElement('span', { className: 'dsh-archive-ico', key: 'i' }, iconOf(file.base)),
413
+ React.createElement('span', { className: 'dsh-archive-name', key: 'n' }, file.base),
414
+ file.encrypted === true ? React.createElement('span', { className: 'dsh-archive-lock', key: 'l' }, '🔒') : null,
415
+ React.createElement('span', { className: 'dsh-archive-size', key: 's' }, formatSize(file.size)),
416
+ React.createElement('span', { className: 'dsh-archive-time', key: 't' }, formatTime(file.mtime)),
417
+ ]))
418
+ }
419
+ }
420
+ body = React.createElement('div', { className: 'dsh-archive-scroll', key: 'scroll', ref: props ? props.scrollportRef : undefined }, children)
421
+ }
422
+
423
+ return React.createElement('div', { className: 'dsh-archive', style: rootStyle }, [styleElement, toolbar, body])
424
+ }
425
+ // <<< dsh-archive-preview: preview body <<<
426
+
427
+ /** @param ctx - 客户端插件上下文(用到 `documentPreviews` 与 `slots` 两个服务)。 */
428
+ function apply(ctx) {
429
+ // 注册与样式各自独立兜底:样式失败绝不能连累注册(否则界面只表现"没反应",极难定位)。
430
+ try {
431
+ ctx.inject(['documentPreviews'], (scope) => {
432
+ scope.effect(() => scope.documentPreviews.register({
433
+ id: PREVIEW_ID,
434
+ extensions: EXTENSIONS,
435
+ binaryExtensions: BINARY_EXTENSIONS,
436
+ priority: 'extension',
437
+ title: () => '压缩包预览',
438
+ loading: 'renderer',
439
+ wrap: false,
440
+ }), 'dsh-archive-preview: preview metadata')
441
+ scope.effect(() => scope.slots.inject('sidebar.right.tab.document', () => scope.slots.register({
442
+ name: 'sidebar.right.tab.document',
443
+ key: PREVIEW_ID,
444
+ }, ArchivePreviewBody)), 'dsh-archive-preview: preview body')
445
+ })
446
+ } catch (error) {
447
+ warn('preview registration failed:', error)
448
+ }
449
+ }
450
+
451
+ const warn = (...args) => {
452
+ try { console.warn('[dsh-archive-preview]', ...args) } catch { /* 忽略 */ }
453
+ }
454
+
455
+ // ⚠️ 插件级 inject 必须声明 `slots`:正文要注册到 keyed slot `sidebar.right.tab.document`,
456
+ // 而 `scope.slots` 只在插件级声明过该服务时才在注入作用域里可见。漏了它 ⇒ 静默无变化。
457
+ const inject = ['slots']
458
+ exports.apply = apply
459
+ exports.inject = inject
460
+ // 离线用例的纯函数钩子(宿主不会用到)。
461
+ exports.__test__ = { buildTree, nodeAt, pathFromResourceAddress, formatSize, formatTime, baseNameOf, iconOf, PREVIEW_ID, EXTENSIONS }
462
+ return module.exports
463
+ },
464
+ })
@@ -0,0 +1,14 @@
1
+ # dsh-archive-preview 的 bundle patch:把插件插进 profile 的层栈。
2
+ # 只声明本插件自己(入口、ID、它自己的配置);不替核心或别的插件写配置。
3
+ #
4
+ # trustedHosts 复用宿主 Web 运行时那份可信域名/来源(与 dsh-long-plugins / dsh-long-cad 同一口径):
5
+ # 路由只服务 loopback 与这些反代域名,别的 Host/Origin 一律 403。
6
+ - insert:
7
+ - id: dsh-archive-preview
8
+ name: dsh-archive-preview
9
+ config:
10
+ # 可信域名默认跟 dsh web 启动参数里的 --trusted-host 走(loopback 始终放行);
11
+ # 需要额外放行时在这里加 trustedHosts: ['<your-domain>']。
12
+ # 允许读取压缩包的路径根。不写=代码默认($DSH_HOME 的父目录 = 用户家目录)。
13
+ # 换机/换盘符:删掉或改成那台机器的路径即可。
14
+ allowedRoots: !!js "[dshHomePath('..')]"
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "dsh-archive-preview",
3
+ "description": "在右侧栏「文档预览」里直接看压缩包:列出 zip/7z/rar/tar/tar.gz/tar.bz2/tar.xz/gz 等常见格式的条目,预览文本与图片,下载单个文件。不落盘、不整包读入内存,条目解压有上限。 | Preview common archives in the DSH sidebar document preview — zip, 7z, rar, tar, tar.gz, tar.bz2, tar.xz, gz: browse entries, preview text and images, download individual files.",
4
+ "version": "0.1.3",
5
+ "entry": {
6
+ "name": "dsh-archive-preview"
7
+ },
8
+ "client": {
9
+ "platform": "web"
10
+ }
11
+ }
package/icon.svg ADDED
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="压缩包">
2
+ <path d="M4.2 8h15.6v11.2a1.6 1.6 0 0 1-1.6 1.6H5.8a1.6 1.6 0 0 1-1.6-1.6z"/>
3
+ <path d="M3 4.6h18v3.4H3z"/>
4
+ <path d="M11.2 4.6v3.4M12.8 8v3.2M11.2 11.2v3.2M12.8 14.4v2.6"/>
5
+ <circle cx="12" cy="18.6" r="1.1"/>
6
+ </svg>