fish-dock-plugin-filemgr 0.0.0-stage → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2042 @@
1
+ // 文件预览插件页面。所有内容用 DOM API 渲染,不拼 HTML 字符串。
2
+ (() => {
3
+ 'use strict'
4
+
5
+ const $ = id => document.getElementById(id)
6
+ const el = (tag, props = {}, children = []) => {
7
+ const node = document.createElement(tag)
8
+ for (const [key, value] of Object.entries(props)) {
9
+ if (value === undefined || value === null || value === false) continue
10
+ if (key === 'class') node.className = value
11
+ else if (key === 'text') node.textContent = value
12
+ else if (key === 'dataset') Object.assign(node.dataset, value)
13
+ else if (key.startsWith('on')) node[key] = value
14
+ else node.setAttribute(key, value === true ? '' : String(value))
15
+ }
16
+ for (const child of [].concat(children)) {
17
+ if (child === null || child === undefined || child === false) continue
18
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)))
19
+ }
20
+ return node
21
+ }
22
+ const clear = node => { while (node.firstChild) node.removeChild(node.firstChild); return node }
23
+ const bytes = n => {
24
+ if (!Number.isFinite(n) || n < 0) return ''
25
+ if (n < 1024) return `${n} B`
26
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
27
+ if (n < 1024 * 1024 * 1024) return `${(n / 1048576).toFixed(1)} MB`
28
+ return `${(n / 1073741824).toFixed(2)} GB`
29
+ }
30
+ const when = ms => {
31
+ if (!ms) return ''
32
+ const d = new Date(ms)
33
+ const pad = n => String(n).padStart(2, '0')
34
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
35
+ }
36
+ const extOf = name => {
37
+ const i = String(name).lastIndexOf('.')
38
+ return i > 0 ? String(name).slice(i + 1).toLowerCase() : ''
39
+ }
40
+
41
+ const state = {
42
+ roots: [], recent: [], layout: 'single', home: '',
43
+ path: null, parent: null, entries: [],
44
+ file: null, kind: '', busy: false,
45
+ // 左栏宽度(px)。拖动分栏分隔条时改它,松手才上报给后端持久化。
46
+ sideW: 240,
47
+ // 常用文件夹
48
+ favorites: [], pinned: [],
49
+ // —— 文件暂存(filemgr 合并后新增)——
50
+ items: [], groups: [], selected: new Set(), view: 'all', dragCache: new Map(), busy: false,
51
+ // 外层暂存栏宽度(px),由 #fmsplit 拖动控制,持久化到 localStorage。
52
+ stashW: 300,
53
+ // 文件夹列表的排序与「隐藏文件」开关
54
+ sortKey: 'name', sortDir: 1, showHidden: false,
55
+ // 文件夹列表的多选集合(存绝对路径)。点击单行与框选都往这里写,
56
+ // 它是临时状态,不进 prefs.json:换目录就清空。
57
+ browseSel: new Set(),
58
+ }
59
+
60
+ // 默认隐藏的目录名:这些目录只会在文件浏览器里制造噪声。
61
+ const HIDDEN_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', '__pycache__', '.idea', '.vscode', 'target', 'dist', '.next', '.cache'])
62
+ const isHiddenEntry = e => (e.name || '').startsWith('.') || (e.isDir && HIDDEN_DIRS.has(e.name))
63
+
64
+ // 上栏高度边界,与 backend.mjs 的 SIDE_MIN / SIDE_MAX 保持一致。
65
+ const SIDE_MIN = 140
66
+ const SIDE_MAX = 600
67
+ const SIDE_DEFAULT = 420
68
+
69
+ // 把后端/宿主返回的错误规整成一句人话。后端约定返回 {ok:false,error:"文案"},
70
+ // 但宿主协议层错误是 {code,message} 对象(例如后端版本旧、方法不存在),
71
+ // 直接 new Error(对象) 会退化成 "[object Object]",用户完全看不懂。
72
+ const text = error => {
73
+ if (!error) return ''
74
+ if (typeof error === 'string') return error
75
+ if (typeof error.message === 'string' && error.message) return error.message
76
+ if (typeof error.code === 'string') {
77
+ if (error.code === 'UNKNOWN_METHOD') return '插件后台版本过旧,请重启 FISH Dock 让新版本代码生效'
78
+ if (error.code === 'NOT_ACTIVE') return '插件尚未启用,请到设置页开启'
79
+ return error.code
80
+ }
81
+ try { return JSON.stringify(error) } catch { return String(error) }
82
+ }
83
+
84
+ const call = async (method, params = {}) => {
85
+ let row
86
+ try {
87
+ row = await FishDock.call(method, params)
88
+ } catch (error) {
89
+ // 宿主 IPC 本身失败(页面票据失效、宿主拒绝等)也统一成人话
90
+ throw new Error(text(error) || '与插件后台通信失败')
91
+ }
92
+ if (!row || row.ok !== true) throw new Error(text(row && row.error) || '读取失败')
93
+ return row
94
+ }
95
+
96
+ const say = message => { $('state').textContent = message || '' }
97
+ const bad = message => { $('error').textContent = message || '' }
98
+
99
+ // 头部标题右侧的文件元信息:有选中/预览文件时显示「名 · 修改时间」。
100
+ // 用户要求:文件类型与大小在列表列显示,此处不再重复。
101
+ // 仅浏览目录时清空(.fm-meta:empty 会隐藏)。
102
+ function updateFileMeta() {
103
+ const box = $('file-meta')
104
+ if (!box) return
105
+ const file = state.file
106
+ if (!file || !file.path) { box.textContent = ''; box.removeAttribute('title'); return }
107
+ const parts = [file.name]
108
+ const mtime = when(file.mtime)
109
+ if (mtime) parts.push(mtime)
110
+ const text = parts.filter(Boolean).join(' · ')
111
+ box.textContent = text
112
+ if (text) box.title = file.path
113
+ else box.removeAttribute('title')
114
+ }
115
+
116
+ const guard = fn => async (...args) => {
117
+ if (state.busy) return
118
+ state.busy = true
119
+ bad('')
120
+ try { await fn(...args) }
121
+ catch (error) { bad(error && error.message ? error.message : String(error)) }
122
+ finally { state.busy = false }
123
+ }
124
+
125
+ // ---------------------------------------------------------------- 侧栏
126
+
127
+ // 文件的类型文字:目录 → 文件夹,文件 → 大写扩展名(没有扩展名就叫"文件")。
128
+ const typeOf = entry => {
129
+ if (entry.isDir) return '文件夹'
130
+ const m = /\.([^.\\/]+)$/.exec(entry.name || '')
131
+ return m ? m[1].toUpperCase().slice(0, 8) : '文件'
132
+ }
133
+
134
+ // 表头:显示当前排序键与方向(▲/▼)。列宽与 .filelist .row 的 6 列保持一致。
135
+ function drawHead() {
136
+ const head = $('browse-head')
137
+ if (!head) return
138
+ head.hidden = !state.path
139
+ for (const btn of head.querySelectorAll('button[data-sort]')) {
140
+ const on = btn.dataset.sort === state.sortKey
141
+ btn.classList.toggle('on', on)
142
+ btn.dataset.dir = on ? (state.sortDir > 0 ? 'asc' : 'desc') : ''
143
+ }
144
+ const hb = $('browse-hidden')
145
+ if (hb) {
146
+ hb.classList.toggle('on', state.showHidden)
147
+ hb.title = state.showHidden ? '当前显示隐藏项,点击回到只显示常规文件' : '隐藏以 . 开头的文件和 node_modules、dist 等目录'
148
+ }
149
+ }
150
+
151
+ // 左栏 = 文件夹浏览器(双栏时)。渲染当前目录的条目,点目录进目录、点文件在右栏预览。
152
+ function drawBrowse() {
153
+ const crumbs = clear($('browse-crumbs'))
154
+ if (state.path) {
155
+ const root = state.roots.find(r => state.path.toLowerCase().startsWith(r.path.toLowerCase()))
156
+ const base = root ? root.path : state.path
157
+ crumbs.append(el('button', { type: 'button', onclick: guard(() => openDir(base)) }, root ? (root.label || base) : base))
158
+ let walk = base
159
+ for (const part of state.path.slice(base.length).split(/[\\/]/).filter(Boolean)) {
160
+ walk = walk.replace(/[\\/]+$/, '') + '\\' + part
161
+ crumbs.append(el('span', { text: '/' }))
162
+ const target = walk
163
+ crumbs.append(el('button', { type: 'button', onclick: guard(() => openDir(target)) }, part))
164
+ }
165
+ } else {
166
+ crumbs.append(el('span', { text: '选择一个位置' }))
167
+ }
168
+
169
+ const list = clear($('browse-list'))
170
+ // 先按「隐藏项」过滤,再按当前排序键排。目录永远排在最前面。
171
+ const rows = state.entries.filter(e => state.showHidden || !isHiddenEntry(e)).slice()
172
+ const dir = state.sortDir
173
+ const key = state.sortKey
174
+ rows.sort((a, b) => {
175
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
176
+ let r = 0
177
+ if (key === 'name') r = String(a.name).localeCompare(String(b.name), 'zh-Hans-CN')
178
+ else if (key === 'type') r = typeOf(a).localeCompare(typeOf(b), 'zh-Hans-CN')
179
+ else if (key === 'size') r = (a.size || 0) - (b.size || 0)
180
+ else if (key === 'mtime') r = (a.mtime || 0) - (b.mtime || 0)
181
+ if (r === 0) r = String(a.name).localeCompare(String(b.name), 'zh-Hans-CN')
182
+ return r * dir
183
+ })
184
+ drawHead()
185
+ if (!state.path) {
186
+ list.append(el('p', { class: 'empty', text: '还没有打开文件夹' }))
187
+ } else if (!rows.length) {
188
+ list.append(el('p', {
189
+ class: 'empty',
190
+ text: state.entries.length ? '这个目录里的项都被隐藏了,点「隐藏项」查看' : '这个目录是空的',
191
+ }))
192
+ } else {
193
+ if (state.parent) {
194
+ list.append(el('button', {
195
+ type: 'button', class: 'row dir',
196
+ onclick: guard(() => openDir(state.parent)),
197
+ }, [el('span', { class: 'add' }), el('span', { class: 'ni' }), el('span', { class: 'nm', text: '..' }), el('span', { class: 'st', text: '文件夹' }), el('span', { class: 'sz' }), el('span', { class: 'dt' })]))
198
+ }
199
+ for (const entry of rows) {
200
+ // 当前正在预览的文件高亮,让"选中态"一眼可见
201
+ const active = !entry.isDir && state.file && state.file.path === entry.path
202
+ // 已在暂存区/某分组里的文件:打上归属标记,方便识别"加过没加过"。
203
+ // 注意:暂存项里存的是 posix 斜杠(F:/x/y),目录枚举回来是反斜杠(F:\x\y),必须归一化再比。
204
+ const norm = s => String(s || '').replace(/\\/g, '/').toLowerCase()
205
+ const entryKey = entry.isDir ? '' : norm(entry.path)
206
+ const stashed = entryKey ? state.items.find(i => norm(i.path) === entryKey) : null
207
+ {
208
+ const stashBtn = el('span', {
209
+ class: 'add', role: 'button', tabindex: '0',
210
+ title: '加入暂存区', 'aria-label': '加入暂存',
211
+ onclick: ev => { ev.stopPropagation(); guard(() => addToStash(entry.path))() },
212
+ onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); ev.stopPropagation(); guard(() => addToStash(entry.path))() } },
213
+ }, '+')
214
+ list.append(el('button', {
215
+ type: 'button',
216
+ class: 'row' + (entry.isDir ? ' dir' : '') + (active ? ' active' : '') + (stashed ? ' stashed' : '') + (state.browseSel.has(entry.path) ? ' sel' : ''),
217
+ // 框选用:把绝对路径挂在行上,框选/拖动时按 path 匹配,重绘也不会丢选择。
218
+ dataset: { path: entry.path },
219
+ 'aria-current': active ? 'true' : 'false',
220
+ 'aria-stashed': stashed ? 'true' : 'false',
221
+ 'aria-selected': state.browseSel.has(entry.path) ? 'true' : 'false',
222
+ title: stashed ? `${entry.path}\n已在暂存区${stashed.group ? `(分组:${stashed.group})` : '(未分组)'}` : entry.path,
223
+ onclick: guard(() => entry.isDir ? openDir(entry.path) : pickFile(entry.path)),
224
+ }, [
225
+ stashBtn,
226
+ el('span', { class: 'ni' }),
227
+ el('span', { class: 'nm' }, [
228
+ el('span', { class: 'nmt', text: entry.name }),
229
+ stashed ? el('span', { class: 'stash-badge', text: stashed.group ? `已暂存·${stashed.group}` : '已暂存' }) : null,
230
+ ].filter(Boolean)),
231
+ el('span', { class: 'st', text: typeOf(entry) }),
232
+ el('span', { class: 'sz', text: entry.isDir ? '' : bytes(entry.size) }),
233
+ el('span', { class: 'dt', text: when(entry.mtime) }),
234
+ ].filter(Boolean)))
235
+ }
236
+ }
237
+ }
238
+ $('browse-up').disabled = !state.parent && !state.file
239
+ }
240
+
241
+ // 把上栏高度写进 CSS 变量 --side-h。只有双栏时 .workspace 会用它,
242
+ // 单栏时那条规则不生效,所以这里无条件写也无害。
243
+ function applySideHeight() {
244
+ const height = Math.min(SIDE_MAX, Math.max(SIDE_MIN, Math.round(state.sideW) || SIDE_DEFAULT))
245
+ document.documentElement.style.setProperty('--side-h', `${height}px`)
246
+ const split = $('split')
247
+ if (split) split.setAttribute('aria-valuenow', String(height))
248
+ }
249
+
250
+ function drawSide() {
251
+ document.body.dataset.layout = state.layout
252
+ applySideHeight()
253
+ // 必须告诉宿主当前是单栏还是双栏:宿主按 layout==='double' 才把面板放宽
254
+ // (settings.js 里 WIDE_W),否则一律按 extension-compact 的 620px 走。
255
+ // 高度不用管,宿主注入的 bridge 会 ResizeObserver 盯 main 自动上报。
256
+ if (typeof FishDock !== 'undefined' && typeof FishDock.setLayout === 'function') {
257
+ try { FishDock.setLayout(state.layout) } catch { /* 宿主注入的 bridge 不可用时忽略 */ }
258
+ }
259
+
260
+ const roots = clear($('roots'))
261
+ if (!state.roots.length) roots.append(el('p', { class: 'note', text: '没有可用的根目录' }))
262
+ for (const root of state.roots) {
263
+ const active = state.path && state.path.toLowerCase().startsWith(root.path.toLowerCase())
264
+ roots.append(el('button', {
265
+ type: 'button',
266
+ title: root.path,
267
+ 'aria-current': active ? 'true' : 'false',
268
+ onclick: guard(() => openDir(root.path)),
269
+ }, root.label || root.path))
270
+ }
271
+
272
+ const recent = clear($('recent'))
273
+ if (!state.recent.length) recent.append(el('p', { class: 'note', text: '暂无记录' }))
274
+ for (const item of state.recent) {
275
+ recent.append(el('button', {
276
+ type: 'button',
277
+ title: item,
278
+ onclick: guard(() => openPath(item)),
279
+ }, item.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || item))
280
+ }
281
+ $('recent-clear').hidden = !state.recent.length
282
+ $('path-line').textContent = state.file ? state.file.path : (state.path || '')
283
+ $('path-line').title = $('path-line').textContent
284
+ drawBrowse()
285
+ updateFileMeta()
286
+ updatePinBtn()
287
+ }
288
+
289
+ // ---------------------------------------------------------------- 面包屑
290
+
291
+ function drawCrumbs() {
292
+ const box = clear($('crumbs'))
293
+ if (state.file) {
294
+ box.append(el('button', { type: 'button', onclick: guard(() => openDir(dirOf(state.file.path))) }, '← 返回目录'))
295
+ box.append(el('span', { text: state.file.path }))
296
+ return
297
+ }
298
+ if (!state.path) {
299
+ box.append(el('span', { text: '选择一个位置开始' }))
300
+ return
301
+ }
302
+ const root = state.roots.find(r => state.path.toLowerCase().startsWith(r.path.toLowerCase()))
303
+ const base = root ? root.path : state.path
304
+ const parts = state.path.slice(base.length).split(/[\\/]/).filter(Boolean)
305
+ box.append(el('button', { type: 'button', onclick: guard(() => openDir(base)) }, root ? (root.label || base) : base))
306
+ let walk = base
307
+ for (const part of parts) {
308
+ walk = walk.replace(/[\\/]+$/, '') + '\\' + part
309
+ box.append(el('span', { text: '/' }))
310
+ const target = walk
311
+ box.append(el('button', { type: 'button', onclick: guard(() => openDir(target)) }, part))
312
+ }
313
+ }
314
+
315
+ const dirOf = p => String(p).replace(/[\\/][^\\/]*$/, '') || String(p)
316
+
317
+ // ---------------------------------------------------------------- 常用文件夹浮层
318
+
319
+ function drawFavPop() {
320
+ const pinnedBox = clear($('fav-pinned'))
321
+ const recentBox = clear($('fav-recent'))
322
+ // 这两段默认是 hidden(HTML 里写死的),有内容时必须显式打开,
323
+ // 否则 fixed 项渲染进去了却一直看不见。
324
+ pinnedBox.hidden = !state.pinned.length
325
+ recentBox.hidden = false
326
+ // pinned 段
327
+ if (state.pinned.length) {
328
+ const label = el('div', { class: 'fav-label', text: '固定' })
329
+ pinnedBox.append(label)
330
+ for (const f of state.pinned) {
331
+ pinnedBox.append(favItem(f, true))
332
+ }
333
+ }
334
+ // 「最近」段:数据源必须是 state.recent(浏览过的目录,字符串数组),
335
+ // 不是 state.favorites(手动固定的收藏,对象数组)——用错数据源会让
336
+ // 从没固定过任何东西的人永远只看到一句「浏览过的文件夹会出现在这里」。
337
+ const pinnedPaths = new Set(state.pinned.map(p => p.path))
338
+ const recentOnly = state.recent
339
+ .filter(p => !pinnedPaths.has(p))
340
+ .map(p => ({ path: p, name: String(p).replace(/[\\/]+$/, '').split(/[\\/]/).pop() || p }))
341
+ if (recentOnly.length) {
342
+ const label = el('div', { class: 'fav-label', text: '最近' })
343
+ recentBox.append(label)
344
+ for (const f of recentOnly) {
345
+ recentBox.append(favItem(f, false))
346
+ }
347
+ }
348
+ if (!state.pinned.length && !recentOnly.length) {
349
+ recentBox.append(el('div', { class: 'fav-label', text: '浏览过的文件夹会出现在这里' }))
350
+ }
351
+ }
352
+
353
+ function favItem(f, isPinned) {
354
+ // 注意:el 只接受 (tag, props, children) 三个参数,多传的会被静默丢弃。
355
+ // 这里必须把所有子节点包成一个数组,否则整行只剩一个空按钮(名字/删除键全没)。
356
+ return el('button', { type: 'button', class: 'fav-item', title: f.path,
357
+ onclick: guard(async () => { $('fav-pop').hidden = true; await openDir(f.path) }) },
358
+ [
359
+ isPinned ? el('span', { class: 'fav-pin', text: '📌' }) : null,
360
+ el('span', { class: 'fav-name', text: f.name || f.path }),
361
+ el('span', { class: 'fav-del', text: '✕', title: '从列表中移除',
362
+ onclick(event) { event.stopPropagation(); guard(() => removeFav(f.path))() } }),
363
+ ].filter(Boolean))
364
+ }
365
+
366
+ async function removeFav(target) {
367
+ await call('preview.favRemove', { path: target })
368
+ state.favorites = state.favorites.filter(f => f.path !== target)
369
+ state.pinned = state.pinned.filter(f => f.path !== target)
370
+ drawFavPop()
371
+ updatePinBtn()
372
+ }
373
+
374
+ function updatePinBtn() {
375
+ const btn = $('fav-pin-btn')
376
+ if (!btn) return
377
+ const curPath = state.path
378
+ if (!curPath) { btn.hidden = true; return }
379
+ const isPinned = state.pinned.some(p => p.path === curPath)
380
+ btn.textContent = isPinned ? '📌' : '📌'
381
+ btn.style.opacity = isPinned ? '1' : '.4'
382
+ btn.title = isPinned ? '取消固定' : '固定当前文件夹'
383
+ btn.hidden = false
384
+ }
385
+
386
+ async function togglePin() {
387
+ const curPath = state.path
388
+ if (!curPath) return
389
+ // backend 的 preview.favPin 自动确保该目录在 favorites 里
390
+ const name = curPath.split(/[\\/]/).pop() || curPath
391
+ await call('preview.favPin', { path: curPath, name })
392
+ const row = await call('preview.favorites')
393
+ state.favorites = row.favorites
394
+ state.pinned = row.pinned
395
+ drawFavPop()
396
+ updatePinBtn()
397
+ }
398
+
399
+ // ---------------------------------------------------------------- 目录视图
400
+
401
+ // 单栏时目录列表画在右栏(无左栏可用);双栏时左栏已经是文件夹浏览器,
402
+ // 右栏专做预览窗口 —— 这时目录列表不再重复渲染,只提示去左栏选文件。
403
+ function drawDir() {
404
+ const view = clear($('view'))
405
+ if (state.layout === 'double') {
406
+ view.append(el('div', { class: 'hint' }, [
407
+ el('h3', { text: state.path || '已打开文件夹' }),
408
+ el('p', { text: `${state.entries.filter(e => e.isDir).length} 个目录,${state.entries.filter(e => !e.isDir).length} 个文件` }),
409
+ el('p', { text: state.dirTrunc ? `目录太大,只列出前 ${state.entries.length} 项(共 ${state.dirTotal} 项)。` : '在左侧列表里点一个文件,这里就会显示它的预览。' }),
410
+ ]))
411
+ return
412
+ }
413
+ if (!state.entries.length) {
414
+ view.append(el('p', { class: 'empty', text: '这个目录是空的' }))
415
+ return
416
+ }
417
+ const list = el('div', { class: 'filelist' })
418
+ if (state.parent) {
419
+ list.append(el('button', {
420
+ type: 'button', class: 'row dir',
421
+ onclick: guard(() => openDir(state.parent)),
422
+ }, [el('span', { class: 'nm', text: '..' }), el('span', { class: 'sz' }), el('span', { class: 'dt' })]))
423
+ }
424
+ for (const entry of state.entries) {
425
+ const stashBtn = el('span', {
426
+ class: 'stash-add', role: 'button', tabindex: '0',
427
+ title: '加入暂存区', 'aria-label': '加入暂存',
428
+ onclick: ev => { ev.stopPropagation(); guard(() => addToStash(entry.path))() },
429
+ onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); ev.stopPropagation(); guard(() => addToStash(entry.path))() } },
430
+ }, '+暂存')
431
+ const row = el('button', {
432
+ type: 'button',
433
+ class: 'row' + (entry.isDir ? ' dir' : ''),
434
+ title: entry.path,
435
+ onclick: guard(() => entry.isDir ? openDir(entry.path) : openFile(entry.path)),
436
+ }, [
437
+ el('span', { class: 'nm', text: entry.name }),
438
+ el('span', { class: 'sz', text: entry.isDir ? '' : bytes(entry.size) }),
439
+ el('span', { class: 'dt', text: when(entry.mtime) }),
440
+ stashBtn,
441
+ ])
442
+ list.append(row)
443
+ }
444
+ view.append(list)
445
+ view.append(el('p', {
446
+ class: 'note',
447
+ text: `${state.entries.filter(e => e.isDir).length} 个目录,${state.entries.filter(e => !e.isDir).length} 个文件`,
448
+ }))
449
+ updateFileMeta()
450
+ }
451
+
452
+ // ---------------------------------------------------------------- 预览分发
453
+
454
+ function head(title, badge, extra = []) {
455
+ return el('div', { class: 'preview-head' }, [
456
+ el('h2', { text: title }),
457
+ badge ? el('span', { class: 'badge', text: badge }) : null,
458
+ ...extra,
459
+ ])
460
+ }
461
+
462
+ function fallback(title, badge, lines, path) {
463
+ const box = el('div', { class: 'hint' }, [
464
+ el('h3', { text: title }),
465
+ ...lines.map(text => el('p', { text })),
466
+ el('p', { text: path }),
467
+ ])
468
+ return box
469
+ }
470
+
471
+ // ---------------------------------------------------------------- 布局
472
+
473
+ // 切到双栏:左栏文件夹浏览器 + 右栏预览窗口。layout 持久化由后端负责。
474
+ async function gotoDouble() {
475
+ if (state.layout === 'double') return
476
+ state.layout = 'double'
477
+ document.body.dataset.layout = 'double'
478
+ await call('preview.layout', { layout: 'double' }).catch(() => null)
479
+ drawSide()
480
+ }
481
+
482
+ // 用户主动点「打开文件夹」后:自动进双栏,并把左栏定位到该文件夹、
483
+ // 右栏清空等待选文件。这是「打开文件夹 → 自动双栏」的核心。
484
+ async function openFolderAuto(target) {
485
+ // 先切布局再进目录:这样 openDir 内的 drawDir 一次就画成双栏形态,
486
+ // 不会先渲染一版单栏目录列表再被覆盖。
487
+ if (state.layout !== 'double') {
488
+ state.layout = 'double'
489
+ document.body.dataset.layout = 'double'
490
+ await call('preview.layout', { layout: 'double' }).catch(() => null)
491
+ }
492
+ await openDir(target)
493
+ say('已打开文件夹,在左侧点文件即可预览')
494
+ }
495
+
496
+ async function openDir(target) {
497
+ const row = await call('preview.tree', { path: target })
498
+ state.path = row.path
499
+ state.parent = row.parent
500
+ state.entries = row.entries
501
+ state.dirTotal = row.total || row.entries.length
502
+ state.dirTrunc = !!row.truncated
503
+ state.file = null
504
+ state.kind = ''
505
+ // 换目录就清空多选:框选的是「这一屏的若干项」,跨目录保留会让人误以为
506
+ // 上一次选中的还在(而且路径已经不在列表里,后续批量操作会很难解释)。
507
+ state.browseSel.clear()
508
+ say(row.truncated ? `${row.entries.length} 项(共 ${row.total} 项,已截断)` : `${row.entries.length} 项`)
509
+ // 打开目录也要写进「最近」——原来只有 openFile() 记,导致只浏览文件夹、
510
+ // 从不打开文件的人点 ★ 永远是空的(这正是「常用文件夹不生效」的主因)。
511
+ const recentRow = await call('preview.remember', { path: row.path }).catch(() => null)
512
+ if (recentRow) state.recent = recentRow.recent || state.recent
513
+ drawSide(); drawCrumbs(); drawDir()
514
+ // 刷新常用文件夹浮层 + pin 按钮状态
515
+ if ($('fav-pop') && !$('fav-pop').hidden) drawFavPop()
516
+ updatePinBtn()
517
+ }
518
+
519
+ // 左栏点文件:双栏时只换右侧预览,左栏目录纹丝不动;单栏走整页切换。
520
+ async function pickFile(target) {
521
+ if (state.layout !== 'double') return openFile(target)
522
+ const row = await call('preview.meta', { path: target })
523
+ if (row.isDir) return openDir(row.path)
524
+ return openFile(row.path)
525
+ }
526
+
527
+ async function openPath(target) {
528
+ const row = await call('preview.meta', { path: target })
529
+ if (row.isDir) return openDir(row.path)
530
+ return openFile(row.path)
531
+ }
532
+
533
+ async function openFile(target) {
534
+ const row = await call('preview.meta', { path: target })
535
+ if (row.isDir) return openDir(row.path)
536
+ state.file = row
537
+ // 双栏:左栏是文件夹浏览器,预览文件时不能把它的目录列表清掉。
538
+ // 单栏没有左栏,保持原来「目录视图 → 文件视图」的整页切换。
539
+ if (state.layout === 'double' && state.entries.length &&
540
+ dirOf(state.path || '').toLowerCase() === dirOf(row.path).toLowerCase()) {
541
+ // 同一个目录里换文件:只高亮 + 换右栏,不动左栏
542
+ } else {
543
+ state.path = dirOf(row.path)
544
+ state.entries = []
545
+ // 双栏时左栏必须始终有内容,否则从「最近」跳到别处的文件后左栏会变空。
546
+ if (state.layout === 'double') {
547
+ const treeRow = await call('preview.tree', { path: state.path }).catch(() => null)
548
+ if (treeRow) { state.parent = treeRow.parent; state.entries = treeRow.entries }
549
+ }
550
+ }
551
+ state.kind = row.kind
552
+ say(`${row.ext || '未知'} · ${bytes(row.size)}`)
553
+ drawSide(); drawCrumbs(); updateFileMeta()
554
+ const recentRow = await call('preview.remember', { path: row.path }).catch(() => null)
555
+ if (recentRow) { state.recent = recentRow.recent; drawSide() }
556
+ const view = clear($('view'))
557
+ view.append(el('p', { role: 'status', text: `正在读取 ${row.name}…` }))
558
+ const badge = `${row.ext || '未知'} · ${bytes(row.size)} · ${when(row.mtime)}`
559
+ try {
560
+ await render(row, view, badge)
561
+ } catch (error) {
562
+ clear(view).append(head(row.name, badge), fallback(
563
+ '无法预览这个文件',
564
+ '',
565
+ [error && error.message ? error.message : String(error), '文件本身可能损坏,或格式暂不支持。'],
566
+ row.path,
567
+ ))
568
+ }
569
+ }
570
+
571
+ async function render(row, view, badge) {
572
+ switch (row.kind) {
573
+ case 'markdown': return renderText(row, view, badge, true)
574
+ case 'code':
575
+ case 'json':
576
+ case 'text':
577
+ case 'html': return renderText(row, view, badge, false)
578
+ case 'image': return renderImage(row, view, badge)
579
+ case 'office': return renderOffice(row, view, badge)
580
+ case 'pdf': return renderPdf(row, view, badge)
581
+ case 'video':
582
+ case 'audio': return renderMedia(row, view, badge)
583
+ default: return renderBinary(row, view, badge)
584
+ }
585
+ }
586
+
587
+ // ---------------------------------------------------------------- 文本 / 代码 / markdown
588
+
589
+ async function renderText(row, view, badge, markdown) {
590
+ const data = await call('preview.text', { path: row.path })
591
+ clear(view)
592
+ view.append(head(row.name, badge + (data.truncated ? ' · 已截断' : '')))
593
+ if (markdown) {
594
+ const box = el('div', { class: 'md' })
595
+ renderMarkdown(data.content, box, dirOf(row.path))
596
+ view.append(box)
597
+ } else {
598
+ view.append(codeBlock(data.content))
599
+ }
600
+ if (data.truncated) view.append(el('p', { class: 'note', text: `文件较大,只显示前 ${Math.round((data.limit || 819200) / 1024)} KB 内容。` }))
601
+ view.append(footer(row.path))
602
+ }
603
+
604
+ function codeBlock(source) {
605
+ const lines = String(source).split('\n')
606
+ const pre = el('pre', { class: 'body' })
607
+ const limit = Math.min(lines.length, 8000)
608
+ const frag = document.createDocumentFragment()
609
+ for (let i = 0; i < limit; i++) {
610
+ frag.append(el('span', { class: 'ln', text: String(i + 1) }))
611
+ frag.append(document.createTextNode(lines[i] + '\n'))
612
+ }
613
+ pre.append(frag)
614
+ const wrap = el('div')
615
+ wrap.append(pre)
616
+ if (lines.length > limit) wrap.append(el('p', { class: 'note', text: `共 ${lines.length} 行,只显示前 ${limit} 行。` }))
617
+ return wrap
618
+ }
619
+
620
+ // ---------------------------------------------------------------- markdown
621
+
622
+ function renderMarkdown(source, box, baseDir) {
623
+ const lines = String(source).replace(/\r\n?/g, '\n').split('\n')
624
+ let i = 0
625
+ const pendingImages = []
626
+ while (i < lines.length) {
627
+ const line = lines[i]
628
+ const fence = /^\s*(```+|~~~+)\s*([\w+#-]*)\s*$/.exec(line)
629
+ if (fence) {
630
+ const marker = fence[1][0].repeat(3)
631
+ const body = []
632
+ i++
633
+ while (i < lines.length && !new RegExp('^\\s*' + marker).test(lines[i])) { body.push(lines[i]); i++ }
634
+ i++
635
+ const pre = el('pre')
636
+ pre.append(el('code', { text: body.join('\n') }))
637
+ box.append(pre)
638
+ continue
639
+ }
640
+ const atx = /^(#{1,6})\s+(.*)$/.exec(line)
641
+ if (atx) {
642
+ box.append(el('h' + atx[1].length, {}, inline(atx[2].trim())))
643
+ i++; continue
644
+ }
645
+ if (/^\s{0,3}([-*_])\s*\1\s*\1[\s\S]*$/.test(line)) { box.append(el('hr')); i++; continue }
646
+ if (/^\s*>/.test(line)) {
647
+ const body = []
648
+ while (i < lines.length && /^\s*>/.test(lines[i])) { body.push(lines[i].replace(/^\s*>\s?/, '')); i++ }
649
+ const quote = el('blockquote')
650
+ renderMarkdown(body.join('\n'), quote, baseDir)
651
+ box.append(quote); continue
652
+ }
653
+ if (/^\s*\|/.test(line) && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1] || '')) {
654
+ const cell = t => t.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(s => s.trim())
655
+ const head = cell(lines[i])
656
+ i += 2
657
+ const table = el('table')
658
+ const thead = el('thead')
659
+ const tr = el('tr')
660
+ for (const h of head) tr.append(el('th', {}, inline(h)))
661
+ thead.append(tr); table.append(thead)
662
+ const tbody = el('tbody')
663
+ while (i < lines.length && /^\s*\|/.test(lines[i])) {
664
+ const row = el('tr')
665
+ for (const c of cell(lines[i])) row.append(el('td', {}, inline(c)))
666
+ tbody.append(row); i++
667
+ }
668
+ table.append(tbody); box.append(table)
669
+ continue
670
+ }
671
+ const ul = /^\s*([-*+])\s+(.*)$/.exec(line)
672
+ const ol = /^\s*(\d+)[.)]\s+(.*)$/.exec(line)
673
+ if (ul || ol) {
674
+ const ordered = Boolean(ol)
675
+ const list = el(ordered ? 'ol' : 'ul')
676
+ while (i < lines.length) {
677
+ const m = ordered ? /^\s*(\d+)[.)]\s+(.*)$/.exec(lines[i]) : /^\s*([-*+])\s+(.*)$/.exec(lines[i])
678
+ if (!m) break
679
+ let body = m[2]
680
+ const task = /^\[( |x|X)\]\s+(.*)$/.exec(body)
681
+ let item
682
+ if (task) {
683
+ item = el('li', { class: 'task' }, [task[1].toLowerCase() === 'x' ? '☑ ' : '☐ ', ...inline(task[2])])
684
+ } else {
685
+ item = el('li', {}, inline(body))
686
+ }
687
+ list.append(item)
688
+ i++
689
+ }
690
+ box.append(list)
691
+ continue
692
+ }
693
+ if (!line.trim()) { i++; continue }
694
+ const para = []
695
+ while (i < lines.length && lines[i].trim() && !/^(\s*(```+|~~~+)|#{1,6}\s|\s*>|\s*([-*+]|\d+[.)])\s)/.test(lines[i])) {
696
+ para.push(lines[i]); i++
697
+ }
698
+ if (!para.length) para.push(lines[i++])
699
+ box.append(el('p', {}, inline(para.join('\n'))))
700
+ }
701
+ // 异步补齐相对路径图片
702
+ for (const job of pendingImages) void job()
703
+ return box
704
+
705
+ function inline(text) {
706
+ const nodes = []
707
+ const pattern = /(`[^`]+`)|(!\[[^\]]*\]\([^)\s]+(?:\s+"[^"]*")?\))|(\[[^\]]*\]\([^)\s]+\))|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(~~[^~]+~~)/g
708
+ let last = 0, m
709
+ while ((m = pattern.exec(text))) {
710
+ if (m.index > last) nodes.push(document.createTextNode(text.slice(last, m.index)))
711
+ const token = m[0]
712
+ if (m[1]) nodes.push(el('code', { text: token.slice(1, -1) }))
713
+ else if (m[2]) {
714
+ const alt = /^!\[([^\]]*)\]/.exec(token)[1]
715
+ const src = /\(([^)\s]+)/.exec(token)[1]
716
+ const img = el('img', { alt, src: '', title: src })
717
+ nodes.push(img)
718
+ const resolve = async () => {
719
+ const rel = src.replace(/^\.\//, '')
720
+ if (/^[a-z]+:/i.test(rel) || rel.startsWith('//') || rel.startsWith('#')) {
721
+ if (/^data:/i.test(rel)) img.src = rel
722
+ return
723
+ }
724
+ const target = joinPath(baseDir, decodeURI(rel))
725
+ try {
726
+ const data = await loadDataUrl(target)
727
+ img.src = data
728
+ } catch { /* 图片缺失时保留 alt 文本 */ }
729
+ }
730
+ pendingImages.push(resolve)
731
+ if (alt) nodes.push(document.createTextNode(alt))
732
+ } else if (m[3]) {
733
+ const label = /^\[([^\]]*)\]/.exec(token)[1]
734
+ const href = /\(([^)\s]+)/.exec(token)[1]
735
+ const a = el('a', { href: '#', title: href }, label || href)
736
+ a.onclick = event => { event.preventDefault() }
737
+ nodes.push(a)
738
+ } else if (m[4]) nodes.push(el('strong', { text: token.slice(2, -2) }))
739
+ else if (m[5]) nodes.push(el('em', { text: token.slice(1, -1) }))
740
+ else if (m[6]) nodes.push(el('s', { text: token.slice(2, -2) }))
741
+ last = m.index + token.length
742
+ }
743
+ if (last < text.length) nodes.push(document.createTextNode(text.slice(last)))
744
+ return nodes
745
+ }
746
+ }
747
+
748
+ function joinPath(dir, name) {
749
+ const parts = String(name).split(/[\\/]/)
750
+ let base = String(dir).replace(/[\\/]+$/, '')
751
+ for (const part of parts) {
752
+ if (!part || part === '.') continue
753
+ if (part === '..') base = dirOf(base)
754
+ else base = base + '\\' + part
755
+ }
756
+ return base
757
+ }
758
+
759
+ // ---------------------------------------------------------------- 二进制 → data URL
760
+
761
+ const CHUNK = 360 * 1024
762
+
763
+ async function loadDataUrl(target) {
764
+ const meta = await call('preview.meta', { path: target })
765
+ if (meta.size > 24 * 1024 * 1024) throw new Error('图片超过 24 MB,不做内联预览')
766
+ const chunks = []
767
+ let offset = 0
768
+ for (let guardCount = 0; guardCount < 200; guardCount++) {
769
+ const row = await call('preview.raw', { path: target, offset, limit: CHUNK })
770
+ chunks.push(row.base64)
771
+ offset += row.length
772
+ if (row.done || !row.length) break
773
+ }
774
+ const base64 = chunks.join('')
775
+ return `data:${meta.mime || 'application/octet-stream'};base64,${base64}`
776
+ }
777
+
778
+ // ---------------------------------------------------------------- 各类预览
779
+
780
+ async function renderImage(row, view, badge) {
781
+ const url = await loadDataUrl(row.path)
782
+ clear(view)
783
+ view.append(head(row.name, badge))
784
+ const figure = el('div', { class: 'figure' })
785
+ const img = el('img', {
786
+ alt: row.name, src: url, title: '点击放大查看大图',
787
+ class: 'zoomable',
788
+ onclick: () => openLightbox(url, row.name),
789
+ })
790
+ figure.append(img)
791
+ figure.append(el('span', { class: 'meta', text: `${row.ext.toUpperCase()} · ${bytes(row.size)} · 点击图片可放大查看` }))
792
+ view.append(figure)
793
+ view.append(footer(row.path))
794
+ }
795
+
796
+ // ---------------------------------------------------------------- 图片放大查看(灯箱)
797
+ // 点击预览区图片 → 全屏灯箱:滚轮缩放、拖动平移、双击 1:1/适应切换、Esc 或点背景关闭。
798
+ let lb = null // { root, img, scale, fit, tx, ty, dragging, sx, sy, ox, oy }
799
+ function ensureLightbox() {
800
+ if (lb) return lb
801
+ const img = el('img', { class: 'lb-img', alt: '' })
802
+ const tip = el('span', { class: 'lb-tip', text: '滚轮缩放 · 拖动平移 · 双击 1:1/适应 · Esc 关闭' })
803
+ const zoomOut = el('button', { class: 'lb-btn', type: 'button', title: '缩小', text: '−' })
804
+ const zoomIn = el('button', { class: 'lb-btn', type: 'button', title: '放大', text: '+' })
805
+ const fitBtn = el('button', { class: 'lb-btn wide', type: 'button', title: '适应窗口', text: '适应' })
806
+ const realBtn = el('button', { class: 'lb-btn wide', type: 'button', title: '原始大小(1:1)', text: '1:1' })
807
+ const closeBtn = el('button', { class: 'lb-btn wide close', type: 'button', title: '关闭(Esc)', text: '关闭' })
808
+ const bar = el('div', { class: 'lb-bar' }, [zoomOut, zoomIn, fitBtn, realBtn, closeBtn])
809
+ const stage = el('div', { class: 'lb-stage' }, [img])
810
+ const root = el('div', { class: 'lb-overlay', hidden: true }, [stage, bar, tip])
811
+ document.body.append(root)
812
+ const state = { root, img, scale: 1, fit: true, tx: 0, ty: 0, dragging: false, sx: 0, sy: 0, ox: 0, oy: 0 }
813
+
814
+ const apply = () => {
815
+ if (state.fit) { img.style.transform = ''; img.style.maxWidth = '96vw'; img.style.maxHeight = '86vh'; img.style.cursor = 'zoom-in' }
816
+ else {
817
+ img.style.maxWidth = 'none'; img.style.maxHeight = 'none'
818
+ img.style.transform = `translate(${state.tx}px, ${state.ty}px) scale(${state.scale})`
819
+ img.style.cursor = state.dragging ? 'grabbing' : 'grab'
820
+ }
821
+ }
822
+ const setFit = () => { state.fit = true; state.scale = 1; state.tx = 0; state.ty = 0; apply() }
823
+ const zoomAt = (factor, cx, cy) => {
824
+ if (state.fit) { state.fit = false; state.scale = 1 }
825
+ const next = Math.min(16, Math.max(0.05, state.scale * factor))
826
+ // 以光标为中心缩放:调整平移让命中点保持不动
827
+ const k = next / state.scale
828
+ state.tx = cx - k * (cx - state.tx)
829
+ state.ty = cy - k * (cy - state.ty)
830
+ state.scale = next
831
+ apply()
832
+ }
833
+ const close = () => { root.hidden = true; state.dragging = false }
834
+ state.close = close
835
+ state.setFit = setFit
836
+ state.setReal = () => { state.fit = false; state.scale = 1; state.tx = 0; state.ty = 0; apply() }
837
+
838
+ closeBtn.onclick = close
839
+ fitBtn.onclick = setFit
840
+ realBtn.onclick = state.setReal
841
+ zoomIn.onclick = () => zoomAt(1.25, stage.clientWidth / 2, stage.clientHeight / 2)
842
+ zoomOut.onclick = () => zoomAt(0.8, stage.clientWidth / 2, stage.clientHeight / 2)
843
+ root.addEventListener('click', ev => { if (ev.target === root || ev.target === stage) close() })
844
+ img.addEventListener('dblclick', ev => { ev.preventDefault(); if (state.fit) state.setReal(); else setFit() })
845
+ img.addEventListener('mousedown', ev => {
846
+ if (state.fit) return
847
+ ev.preventDefault(); state.dragging = true; state.sx = ev.clientX; state.sy = ev.clientY; state.ox = state.tx; state.oy = state.ty; apply()
848
+ })
849
+ window.addEventListener('mousemove', ev => {
850
+ if (!state.dragging) return
851
+ state.tx = state.ox + (ev.clientX - state.sx); state.ty = state.oy + (ev.clientY - state.sy); apply()
852
+ })
853
+ window.addEventListener('mouseup', () => { if (state.dragging) { state.dragging = false; apply() } })
854
+ stage.addEventListener('wheel', ev => {
855
+ ev.preventDefault(); const r = stage.getBoundingClientRect()
856
+ zoomAt(ev.deltaY < 0 ? 1.15 : 1 / 1.15, ev.clientX - r.left, ev.clientY - r.top)
857
+ }, { passive: false })
858
+ window.addEventListener('keydown', ev => {
859
+ if (root.hidden) return
860
+ if (ev.key === 'Escape') { close() }
861
+ else if (ev.key === '+' || ev.key === '=') { zoomAt(1.25, stage.clientWidth / 2, stage.clientHeight / 2) }
862
+ else if (ev.key === '-') { zoomAt(0.8, stage.clientWidth / 2, stage.clientHeight / 2) }
863
+ else if (ev.key === '0') { setFit() }
864
+ })
865
+ lb = state
866
+ return state
867
+ }
868
+ function openLightbox(url, name) {
869
+ const state = ensureLightbox()
870
+ state.img.src = url
871
+ state.img.alt = name || ''
872
+ state.root.hidden = false
873
+ state.setFit()
874
+ }
875
+
876
+ async function renderOffice(row, view, badge) {
877
+ const data = await call('preview.office', { path: row.path })
878
+ clear(view)
879
+ view.append(head(row.name, badge))
880
+ if (data.kind === 'docx') {
881
+ const doc = el('div', { class: 'doc' })
882
+ let empty = true
883
+ for (const block of data.blocks || []) {
884
+ if (block.type === 'heading') {
885
+ empty = false
886
+ const level = Math.min(6, Math.max(1, Number(block.level) || 3))
887
+ doc.append(el('h4', { class: `dh h${level}`, text: block.text }))
888
+ } else if (block.type === 'para') {
889
+ empty = false
890
+ doc.append(el('p', { class: 'para', text: block.text }))
891
+ } else {
892
+ doc.append(el('div', { class: 'gap' }))
893
+ }
894
+ }
895
+ view.append(empty ? el('p', { class: 'note', text: '这个 Word 文档没有可提取的文字(可能全是图片)。' }) : doc)
896
+ } else if (data.kind === 'pptx') {
897
+ const slides = el('div', { class: 'slides' })
898
+ for (const slide of data.slides || []) {
899
+ const box = el('div', { class: 'slide' }, [el('h3', { text: `第 ${slide.index} 页` })])
900
+ if (!slide.lines.length) box.append(el('p', { class: 'note', text: '(本页无文字)' }))
901
+ for (const line of slide.lines) box.append(el('p', { text: line }))
902
+ slides.append(box)
903
+ }
904
+ view.append((data.slides || []).length ? slides : el('p', { class: 'note', text: '这个演示文稿没有可提取的文字。' }))
905
+ } else if (data.kind === 'xlsx') {
906
+ for (const sheet of data.sheets || []) {
907
+ const box = el('div', { class: 'sheet' }, [el('h3', { text: sheet.name })])
908
+ const grid = el('div', { class: 'grid' })
909
+ const table = el('table')
910
+ const rows = sheet.rows || []
911
+ if (rows.length) {
912
+ const width = Math.max(...rows.map(r => r.length))
913
+ const thead = el('thead')
914
+ const tr = el('tr')
915
+ for (let c = 0; c < width; c++) tr.append(el('th', { text: columnName(c) }))
916
+ thead.append(tr); table.append(thead)
917
+ const tbody = el('tbody')
918
+ rows.slice(0, 1200).forEach((cells, index) => {
919
+ const row = el('tr')
920
+ row.append(el('th', { text: String(index + 1) }))
921
+ for (let c = 0; c < width; c++) row.append(el('td', { title: cells[c] ?? '', text: cells[c] ?? '' }))
922
+ tbody.append(row)
923
+ })
924
+ table.append(tbody); grid.append(table); box.append(grid)
925
+ if (rows.length > 1200) box.append(el('p', { class: 'note', text: `共 ${rows.length} 行,只显示前 1200 行。` }))
926
+ } else box.append(el('p', { class: 'note', text: '空工作表' }))
927
+ view.append(box)
928
+ }
929
+ if (!(data.sheets || []).length) view.append(el('p', { class: 'note', text: '没有读到工作表。' }))
930
+ } else if (data.kind === 'zip') {
931
+ view.append(el('p', { class: 'note', text: `压缩包共 ${data.total} 项。` }))
932
+ const pre = el('pre', { class: 'body' })
933
+ pre.append(document.createTextNode((data.names || []).join('\n')))
934
+ view.append(pre)
935
+ }
936
+ view.append(footer(row.path))
937
+ }
938
+
939
+ function columnName(index) {
940
+ let n = index, name = ''
941
+ do { name = String.fromCharCode(65 + (n % 26)) + name; n = Math.floor(n / 26) - 1 } while (n >= 0)
942
+ return name
943
+ }
944
+
945
+ function renderPdf(row, view, badge) {
946
+ clear(view)
947
+ view.append(head(row.name, badge))
948
+ view.append(fallback('PDF 暂不支持内联预览', '', [
949
+ '插件页面运行在沙箱里,没有 PDF 渲染器,也不允许加载外部对象。',
950
+ '请在系统的文件管理器里双击打开这个 PDF。',
951
+ ], row.path))
952
+ view.append(footer(row.path))
953
+ }
954
+
955
+ function renderMedia(row, view, badge) {
956
+ clear(view)
957
+ view.append(head(row.name, badge))
958
+ view.append(fallback(
959
+ row.kind === 'video' ? '视频暂不支持内联播放' : '音频暂不支持内联播放',
960
+ '',
961
+ ['插件页面不允许加载媒体资源。', '请在系统的文件管理器里双击打开。'],
962
+ row.path,
963
+ ))
964
+ view.append(footer(row.path))
965
+ }
966
+
967
+ function renderBinary(row, view, badge) {
968
+ clear(view)
969
+ view.append(head(row.name, badge))
970
+ view.append(fallback('这是一个二进制文件', '', [
971
+ `无法按文本预览,大小 ${bytes(row.size)}。`,
972
+ '如果需要查看原始字节,可以先用十六进制工具打开。',
973
+ ], row.path))
974
+ view.append(footer(row.path))
975
+ }
976
+
977
+ function footer(path) {
978
+ const box = el('div', { class: 'foot' })
979
+ const copy = el('button', { type: 'button' }, '复制路径')
980
+ copy.onclick = async () => {
981
+ try {
982
+ await navigator.clipboard.writeText(path)
983
+ copy.textContent = '已复制'
984
+ setTimeout(() => { copy.textContent = '复制路径' }, 1200)
985
+ } catch { copy.textContent = '复制失败' }
986
+ }
987
+ box.append(copy)
988
+ return box
989
+ }
990
+
991
+ // ---------------------------------------------------------------- 文件夹选择器
992
+
993
+ // 「打开文件夹」= 直接弹 Windows 系统原生选择文件夹对话框(后端 pick-folder.ps1),
994
+ // 用户选完就跳过去。宿主单次调用有 15s 超时,所以后端先返回 pending,这里轮询。
995
+ let nativeBusy = false
996
+ // ---------------------------------------------------------- 插件内目录选择器
997
+ // 宿主没有 FishDock.hold 时(官方 v1.2.3 及更早)不能弹原生对话框:它会抢走焦点,
998
+ // 宿主 onBlur 判定用户走开就把设置面板收掉,用户选完回来页面已经没了。
999
+ // 这里改用插件自己画的目录树(走 preview.tree),全程不出窗口,焦点不丢。
1000
+ const dirPick = { path: '', sel: '', up: null }
1001
+
1002
+ async function drawDirPick(target) {
1003
+ const box = $('dirpick')
1004
+ if (!box) return
1005
+ box.hidden = false
1006
+ // 依次回落到已知路径。启动还没走完时这些可能全空,先补拉一次 roots,
1007
+ // 否则会拿空串去问后端(只会得到「缺少路径参数」)。
1008
+ let start = target || dirPick.path || state.path || state.home || state.roots[0]?.path || ''
1009
+ if (!start) { await ensureRoots(); start = state.path || state.home || state.roots[0]?.path || '' }
1010
+ dirPick.path = start
1011
+ dirPick.sel = start
1012
+ $('dirpick-path').value = start
1013
+ if (!start) {
1014
+ const note = $('dirpick-note')
1015
+ if (note) note.textContent = '没有可用的根目录,请先在「位置」里添加文件夹'
1016
+ return
1017
+ }
1018
+ await loadDirPick(start)
1019
+ }
1020
+
1021
+ // 拉一次根目录/最近记录;启动未完成时由 drawDirPick 兜底调用。
1022
+ async function ensureRoots() {
1023
+ if (state.roots.length) return true
1024
+ try {
1025
+ const row = await call('preview.roots')
1026
+ state.roots = row.roots || []
1027
+ state.recent = row.recent || []
1028
+ state.home = row.home || ''
1029
+ if (row.favorites) state.favorites = row.favorites
1030
+ if (row.pinned) state.pinned = row.pinned
1031
+ } catch {}
1032
+ return state.roots.length > 0
1033
+ }
1034
+
1035
+ async function loadDirPick(target) {
1036
+ const list = $('dirpick-list')
1037
+ const note = $('dirpick-note')
1038
+ if (!list) return
1039
+ list.replaceChildren()
1040
+ let row
1041
+ try {
1042
+ row = await call('preview.tree', { path: target })
1043
+ } catch (error) {
1044
+ if (note) note.textContent = '读不到这个目录:' + (error.message || error)
1045
+ return
1046
+ }
1047
+ if (!row || row.error) {
1048
+ if (note) note.textContent = (row && row.error) || '读不到这个目录'
1049
+ return
1050
+ }
1051
+ dirPick.path = row.path
1052
+ dirPick.up = row.parent || null
1053
+ $('dirpick-path').value = row.path
1054
+ $('dirpick-title').textContent = '选择文件夹 · ' + row.path
1055
+ const crumbs = $('dirpick-crumbs')
1056
+ if (crumbs) {
1057
+ crumbs.replaceChildren()
1058
+ const parts = row.path.split(/[\\/]/).filter(Boolean)
1059
+ let acc = /^[A-Za-z]:/.test(row.path) ? '' : '/'
1060
+ parts.forEach((part, index) => {
1061
+ acc = index === 0 && /^[A-Za-z]:$/.test(part) ? part + '\\' : (acc.endsWith('\\') || acc.endsWith('/') ? acc + part : acc + '\\' + part)
1062
+ const button = document.createElement('button')
1063
+ button.type = 'button'
1064
+ button.className = 'ghost tiny'
1065
+ button.textContent = part
1066
+ button.onclick = () => guard(loadDirPick(acc))()
1067
+ crumbs.append(button)
1068
+ })
1069
+ }
1070
+ const dirs = (row.entries || []).filter(entry => entry.isDir)
1071
+ if (!dirs.length) {
1072
+ const empty = document.createElement('p')
1073
+ empty.className = 'note'
1074
+ empty.textContent = '这个目录下没有子文件夹'
1075
+ list.append(empty)
1076
+ }
1077
+ for (const entry of dirs) {
1078
+ const line = document.createElement('div')
1079
+ line.className = 'row'
1080
+ line.dataset.path = entry.path
1081
+ line.setAttribute('role', 'option')
1082
+ const name = document.createElement('span')
1083
+ name.className = 'nm'
1084
+ name.textContent = entry.name
1085
+ const into = document.createElement('button')
1086
+ into.type = 'button'
1087
+ into.className = 'ghost tiny'
1088
+ into.textContent = '进入'
1089
+ into.onclick = event => { event.stopPropagation(); guard(loadDirPick(entry.path))() }
1090
+ line.append(name, into)
1091
+ line.onclick = () => {
1092
+ dirPick.sel = entry.path
1093
+ for (const other of list.querySelectorAll('.row')) other.classList.toggle('sel', other === line)
1094
+ $('dirpick-path').value = entry.path
1095
+ }
1096
+ list.append(line)
1097
+ }
1098
+ if (note) note.textContent = row.truncated ? '目录条目过多,只显示前一部分' : ''
1099
+ }
1100
+
1101
+ function closeDirPick() {
1102
+ const box = $('dirpick')
1103
+ if (box) box.hidden = true
1104
+ }
1105
+
1106
+ function wireDirPick() {
1107
+ const box = $('dirpick')
1108
+ if (!box) return
1109
+ $('dirpick-close').onclick = () => closeDirPick()
1110
+ $('dirpick-up').onclick = guard(async () => { if (dirPick.up) await loadDirPick(dirPick.up) })
1111
+ $('dirpick-ok').onclick = guard(async () => {
1112
+ const typed = ($('dirpick-path').value || '').trim()
1113
+ const target = typed || dirPick.sel || dirPick.path
1114
+ closeDirPick()
1115
+ if (target) await openFolderAuto(target)
1116
+ })
1117
+ $('dirpick-path').onkeydown = event => {
1118
+ if (event.key === 'Enter') { event.preventDefault(); guard(loadDirPick(($('dirpick-path').value || '').trim()))() }
1119
+ }
1120
+ box.onclick = event => { if (event.target === box) closeDirPick() }
1121
+ document.addEventListener('keydown', event => { if (event.key === 'Escape' && !box.hidden) closeDirPick() })
1122
+ }
1123
+
1124
+ // 有宿主 hold 就走原生对话框(体验好),没有就走自绘选择器(不失焦)
1125
+ function canHold() { return typeof FishDock !== 'undefined' && typeof FishDock.hold === 'function' }
1126
+ function setHold(on) { try { FishDock.hold?.(!!on) } catch { /* 宿主没注入 hold 时忽略 */ } }
1127
+ async function openNativeFolder() {
1128
+ if (nativeBusy) return
1129
+ if (!canHold()) {
1130
+ await drawDirPick(state.path || state.home || '')
1131
+ return
1132
+ }
1133
+ nativeBusy = true
1134
+ // 只有这一刻才临时锁面板:原生对话框会抢走焦点,宿主 onBlur 会把设置面板收掉,
1135
+ // 用户选完文件夹回来页面就没了。所以先 hold(true),选完在 finally 里放开。
1136
+ // 平时浏览、拖放都不锁,用户仍能照常点外面关闭面板。
1137
+ FishDock.hold?.(true)
1138
+ // 弹系统对话框之前先把当前页面尺寸报给宿主锁定一次,避免对话框关掉后
1139
+ // 宿主按旧高度重排、把刚打开的文件夹列表挤变形。这里只锁尺寸,
1140
+ // 不动「固定文件夹」那一侧的显示优先级。
1141
+ reportHeight()
1142
+ const start = state.file ? dirOf(state.file.path) : (state.path || state.home || '')
1143
+ try {
1144
+ say('请在弹出的系统窗口里选择文件夹…')
1145
+ const first = await call('preview.pickFolder', { initial: start, title: '选择要预览的文件夹' })
1146
+ if (first && first.error) throw new Error(first.error)
1147
+ let done = null
1148
+ for (let i = 0; i < 900; i++) { // 最长等 ~10 分钟,够用户慢慢翻
1149
+ await new Promise(r => setTimeout(r, 700))
1150
+ const row = await call('preview.pickResult')
1151
+ if (row && row.error) throw new Error(row.error)
1152
+ if (!row || row.pending) continue
1153
+ done = row
1154
+ break
1155
+ }
1156
+ if (!done) { say('选择超时'); return }
1157
+ if (done.cancelled) { say('已取消'); return }
1158
+ if (done.roots) state.roots = done.roots
1159
+ const target = done.picked || (done.roots && done.roots[0] && done.roots[0].path)
1160
+ if (target) {
1161
+ $('jump').hidden = true
1162
+ await openFolderAuto(target)
1163
+ }
1164
+ } catch (error) {
1165
+ say('') // 别把「请在弹出的…」留在状态行,错误由 #error 显示
1166
+ throw error
1167
+ } finally {
1168
+ nativeBusy = false
1169
+ // 选完(或取消/报错)立刻放开临时锁,恢复「点外面能关面板」的正常行为。
1170
+ setHold(false)
1171
+ }
1172
+ }
1173
+
1174
+ // ---------------------------------------------------------------- 分栏宽度
1175
+
1176
+ // 拖动分隔条改左栏宽度。拖动过程只改 CSS 变量(不打 IPC,避免每帧一次调用),
1177
+ // 松手才把最终值报给后端存进 prefs.json。
1178
+ function wireSplitter() {
1179
+ const split = $('split')
1180
+ if (!split) return
1181
+ const workspace = document.querySelector('.workspace')
1182
+ let dragging = false
1183
+ let pointerId = null
1184
+ let frame = 0
1185
+ // 用「起点 + 位移」算高度,不去反推分隔条相对容器的偏移:
1186
+ // .workspace 有 gap,分隔条并不紧贴上栏下边缘,按坐标换算会固定差几像素。
1187
+ let startY = 0
1188
+ let startH = SIDE_DEFAULT
1189
+
1190
+ const paint = () => {
1191
+ frame = 0
1192
+ applySideHeight()
1193
+ }
1194
+ const schedule = () => { if (!frame) frame = requestAnimationFrame(paint) }
1195
+
1196
+ const save = () => {
1197
+ const height = Math.round(state.sideW) || SIDE_DEFAULT
1198
+ call('preview.sideWidth', { width: height }).catch(() => null)
1199
+ }
1200
+
1201
+ split.addEventListener('pointerdown', event => {
1202
+ if (event.button !== 0) return
1203
+ dragging = true
1204
+ pointerId = event.pointerId
1205
+ startY = event.clientY
1206
+ startH = state.sideW
1207
+ document.body.dataset.resizing = 'true'
1208
+ split.dataset.dragging = 'true'
1209
+ try { split.setPointerCapture(pointerId) } catch { /* 指针已释放时忽略 */ }
1210
+ event.preventDefault()
1211
+ })
1212
+
1213
+ split.addEventListener('pointermove', event => {
1214
+ if (!dragging) return
1215
+ const next = Math.min(SIDE_MAX, Math.max(SIDE_MIN, Math.round(startH + (event.clientY - startY))))
1216
+ if (next === state.sideW) return
1217
+ state.sideW = next
1218
+ schedule()
1219
+ })
1220
+
1221
+ const stop = () => {
1222
+ if (!dragging) return
1223
+ dragging = false
1224
+ delete document.body.dataset.resizing
1225
+ delete split.dataset.dragging
1226
+ if (pointerId !== null) { try { split.releasePointerCapture(pointerId) } catch { /* 已释放 */ } }
1227
+ pointerId = null
1228
+ if (frame) { cancelAnimationFrame(frame); frame = 0 }
1229
+ applySideHeight()
1230
+ // pointercancel 是拖到一半被打断(比如切窗口),也照样存下当前高度。
1231
+ save()
1232
+ }
1233
+ split.addEventListener('pointerup', stop)
1234
+ split.addEventListener('pointercancel', stop)
1235
+ split.addEventListener('lostpointercapture', stop)
1236
+
1237
+ // 双击恢复默认高度。
1238
+ split.addEventListener('dblclick', () => {
1239
+ state.sideW = SIDE_DEFAULT
1240
+ applySideHeight()
1241
+ save()
1242
+ })
1243
+
1244
+ // 键盘:聚焦分隔条后上下方向键微调(每次 16px),Home 恢复默认。
1245
+ split.addEventListener('keydown', event => {
1246
+ const step = event.shiftKey ? 48 : 16
1247
+ let next = null
1248
+ if (event.key === 'ArrowUp') next = state.sideW - step
1249
+ else if (event.key === 'ArrowDown') next = state.sideW + step
1250
+ else if (event.key === 'Home') next = SIDE_DEFAULT
1251
+ if (next === null) return
1252
+ event.preventDefault()
1253
+ state.sideW = Math.min(SIDE_MAX, Math.max(SIDE_MIN, next))
1254
+ applySideHeight()
1255
+ save()
1256
+ })
1257
+ }
1258
+
1259
+
1260
+ // ---------------------------------------------------------------- 事件绑定
1261
+
1262
+ $('refresh').onclick = guard(() => state.file ? openFile(state.file.path) : (state.path ? openDir(state.path) : boot()))
1263
+ $('browse-refresh').onclick = guard(() => state.path ? openDir(state.path) : null)
1264
+ $('browse-up').onclick = guard(() => state.parent ? openDir(state.parent) : null)
1265
+ // 隐藏项开关 + 表头排序(点同一列切换升降序)
1266
+ $('browse-hidden').onclick = () => { state.showHidden = !state.showHidden; drawBrowse() }
1267
+ for (const btn of $('browse-head').querySelectorAll('button[data-sort]')) {
1268
+ btn.onclick = () => {
1269
+ const k = btn.dataset.sort
1270
+ if (state.sortKey === k) state.sortDir = -state.sortDir
1271
+ else { state.sortKey = k; state.sortDir = 1 }
1272
+ drawBrowse()
1273
+ }
1274
+ }
1275
+ // 「上一级」:双栏时左栏才是目录浏览的主体,应该以左栏当前位置为准;
1276
+ // 单栏时页面已经被文件视图占据,只能用文件所在目录回退。
1277
+ $('up').onclick = guard(() => {
1278
+ if (state.layout === 'double') return state.parent ? openDir(state.parent) : null
1279
+ return state.file ? openDir(dirOf(state.file.path)) : (state.parent ? openDir(state.parent) : null)
1280
+ })
1281
+ $('recent-clear').onclick = guard(async () => { const row = await call('preview.forget'); state.recent = row.recent; drawSide() })
1282
+ $('open').onclick = guard(() => openNativeFolder(false))
1283
+ $('pick-native').onclick = guard(async () => { await openNativeFolder(true); drawSide() })
1284
+ $('jump-cancel').onclick = () => { $('jump').hidden = true }
1285
+ $('fav-open').onclick = guard(async () => {
1286
+ const pop = $('fav-pop')
1287
+ if (pop.hidden) {
1288
+ // 每次打开前从后端刷新最新列表
1289
+ const row = await call('preview.favorites')
1290
+ state.favorites = row.favorites
1291
+ state.pinned = row.pinned
1292
+ drawFavPop()
1293
+ pop.hidden = false
1294
+ } else {
1295
+ pop.hidden = true
1296
+ }
1297
+ })
1298
+ $('fav-pin-btn').onclick = guard(togglePin)
1299
+ // 点击浮层外关闭
1300
+ document.addEventListener('click', event => {
1301
+ const pop = $('fav-pop')
1302
+ if (pop && !pop.hidden && !pop.contains(event.target) && event.target !== $('fav-open')) {
1303
+ pop.hidden = true
1304
+ }
1305
+ })
1306
+ $('jump-form').onsubmit = event => {
1307
+ event.preventDefault()
1308
+ const value = $('jump-input').value.trim()
1309
+ if (!value) return
1310
+ $('jump').hidden = true
1311
+ guard(() => openPath(value))()
1312
+ }
1313
+
1314
+ // ---------------------------------------------------------------- 启动
1315
+
1316
+ async function bootPreview() {
1317
+ wireDirPick()
1318
+ const row = await call('preview.roots')
1319
+ state.roots = row.roots || []
1320
+ state.recent = row.recent || []
1321
+ state.home = row.home || ''
1322
+ state.favorites = row.favorites || []
1323
+ state.pinned = row.pinned || []
1324
+ // 合并页:预览区固定双栏(左文件夹树 + 右预览),不接受后端 single。
1325
+ state.layout = 'double'
1326
+ document.body.dataset.layout = 'double'
1327
+ if (Number.isFinite(row.sideW)) state.sideW = row.sideW
1328
+ drawSide()
1329
+ updatePinBtn()
1330
+ // 固定落到默认文件夹(F:\AI REP),最近记录只留在侧栏,不抢占启动视图。
1331
+ const home = state.home || state.roots[0]?.path || ''
1332
+ if (home) await openDir(home)
1333
+ else { clear($('view')).append(el('p', { class: 'note', text: '没有可访问的根目录。' })); say('就绪') }
1334
+ }
1335
+
1336
+
1337
+ const fail = message => { const n = $('error'); if (n) n.textContent = message }
1338
+
1339
+ // ================================================================ 文件暂存(原 stash.js 逻辑)
1340
+
1341
+
1342
+ // 简易文件类型图标:目录 / 图片 / 音视频 / 压缩包 / 文本代码 / 兜底
1343
+ const ICONS = [
1344
+ [/^(png|jpg|jpeg|gif|webp|svg|bmp|ico)$/, '🖼️'],
1345
+ [/^(mp3|wav|flac|ogg|m4a)$/, '🎵'],
1346
+ [/^(mp4|mkv|avi|mov|webm)$/, '🎬'],
1347
+ [/^(zip|7z|rar|tar|gz)$/, '🗜️'],
1348
+ [/^(js|mjs|ts|py|json|md|txt|html|css|c|cpp|h|rs|go|java|sh|yml|yaml|toml)$/, '📄'],
1349
+ ]
1350
+ const iconOf = item => {
1351
+ if (item.kind === 'dir' || item.kind === 'folder') return '📁'
1352
+ const ext = extOf(item.name)
1353
+ for (const [re, ico] of ICONS) if (re.test(ext)) return ico
1354
+ return '📃'
1355
+ }
1356
+ // DownloadURL 的 mime:按扩展名给个粗略值,宿主资源管理器主要消费文件名与 URL。
1357
+ const MIME = {
1358
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
1359
+ pdf: 'application/pdf', zip: 'application/zip', '7z': 'application/x-7z-compressed', rar: 'application/vnd.rar',
1360
+ mp3: 'audio/mpeg', mp4: 'video/mp4', txt: 'text/plain', md: 'text/markdown',
1361
+ json: 'application/json', js: 'text/javascript', mjs: 'text/javascript', css: 'text/css', html: 'text/html',
1362
+ }
1363
+ const mimeOf = item => MIME[extOf(item.name)] || 'application/octet-stream'
1364
+
1365
+
1366
+ // ---------------------------------------------------------------- 桥封装(照抄 page/page.js 的 call())
1367
+
1368
+
1369
+ // 允许失败的业务调用(拖入合并时逐条容错用)
1370
+ const callSoft = async (method, params = {}) => {
1371
+ try { return await call(method, params) } catch (error) { return { ok: false, error: error.message } }
1372
+ }
1373
+
1374
+ const toast = message => {
1375
+ // 轻量提示:复用页脚,2.6s 后自动恢复
1376
+ const foot = $('foot')
1377
+ foot.textContent = message
1378
+ clearTimeout(toast.t)
1379
+ toast.t = setTimeout(() => { foot.textContent = footerText() }, 2600)
1380
+ }
1381
+ const footerText = () => {
1382
+ const n = state.items.length
1383
+ const s = state.selected.size
1384
+ return n ? `共 ${n} 项${s ? `,已选 ${s} 项` : ''}` : ''
1385
+ }
1386
+
1387
+ const reportHeight = () => {
1388
+ // 必须按真实布局上报:宿主 onLayout 只有在 layout==='double' 时才会把扩展页
1389
+ // 放宽(EXT_WIDE_W=1280)。原来写死 'single',导致无论页面怎么排都按窄宽度渲染。
1390
+ const layout = state.layout === 'double' ? 'double' : 'single'
1391
+ try { parent.postMessage({ type: 'fish:layout', height: document.documentElement.scrollHeight, layout }, '*') } catch { /* 宿主不可用时忽略 */ }
1392
+ }
1393
+
1394
+ // ---------------------------------------------------------------- 数据
1395
+
1396
+ const refresh = async () => {
1397
+ const row = await call('stash.list')
1398
+ state.items = row.items || []
1399
+ state.groups = row.groups || []
1400
+ // 选中项里已不存在的去掉
1401
+ const alive = new Set(state.items.map(i => i.id))
1402
+ for (const id of [...state.selected]) if (!alive.has(id)) state.selected.delete(id)
1403
+ // 当前视图被删组时回到全部
1404
+ if (state.view !== 'all' && state.view !== '' && !state.groups.includes(state.view)) state.view = 'all'
1405
+ draw()
1406
+ }
1407
+ const redraw = () => { draw() }
1408
+
1409
+ const visibleItems = () => state.items.filter(item => {
1410
+ if (state.view === 'all') { /* 全部 */ }
1411
+ else if (state.view === '') { if (item.group) return false }
1412
+ else if (item.group !== state.view) return false
1413
+ return true
1414
+ })
1415
+
1416
+ const selectedItems = () => state.items.filter(i => state.selected.has(i.id))
1417
+
1418
+ // ---------------------------------------------------------------- 渲染
1419
+
1420
+ const draw = () => {
1421
+ drawList()
1422
+ drawMoveSelect()
1423
+ updateToolbar()
1424
+ // 右侧文件列表也要跟着暂存项变化重绘:这样「已暂存」高亮/badge 才能即时反映
1425
+ try { drawBrowse() } catch { /* browse 尚未初始化时忽略 */ }
1426
+ const foot = $('foot')
1427
+ if (!foot.textContent || foot.textContent.startsWith('共 ')) foot.textContent = footerText()
1428
+ reportHeight()
1429
+ }
1430
+
1431
+ // 折叠状态记忆:key = 分组名(未分组用 ''),收起时记 false。默认展开。
1432
+ const collapsed = new Map()
1433
+ const isCollapsed = key => collapsed.get(key) === true
1434
+
1435
+ // 把「移动到组」下拉同步成当前分组列表
1436
+ const drawMoveSelect = () => {
1437
+ const sel = $('move-to'); if (!sel) return
1438
+ clear(sel)
1439
+ sel.append(el('option', { value: '', text: '(未分组)' }))
1440
+ for (const g of state.groups) sel.append(el('option', { value: g, text: g }))
1441
+ sel.value = state.view !== 'all' && state.groups.includes(state.view) ? state.view : ''
1442
+ }
1443
+
1444
+ // 渲染整张列表:按分组拆成若干个可折叠的 <details>(参考 cost-meter 的「今日」标签)。
1445
+ // 每个分组头就是一条标签,带条目计数;点标签收起/展开该组。
1446
+ // state.view === 'all' 时显示全部分组;否则只显示选中的那一组(选中组强制展开)。
1447
+ const drawList = () => {
1448
+ const list = clear($('list'))
1449
+ const buckets = []
1450
+ const push = (key, label) => {
1451
+ if (state.view !== 'all' && String(state.view) !== key) return
1452
+ const items = state.items.filter(i => (key === '' ? !i.group : i.group === key))
1453
+ buckets.push({ key, label, items })
1454
+ }
1455
+ if (state.view === 'all' || state.view === '') push('', '未分组')
1456
+ for (const g of state.groups) push(g, g)
1457
+
1458
+ const totalShown = buckets.reduce((n, b) => n + b.items.length, 0)
1459
+ $('empty').hidden = totalShown > 0 || buckets.length > 0
1460
+
1461
+ const last = { idx: -1 } // Shift 连选锚点(跨组按全局顺序)
1462
+ const flat = [] // 所有可见项顺序,供 Shift 连选
1463
+ buckets.forEach(b => b.items.forEach(i => flat.push(i)))
1464
+
1465
+ for (const bucket of buckets) {
1466
+ const active = state.view !== 'all' && String(state.view) === bucket.key
1467
+ const det = el('details', { class: 'stash-group' + (active ? ' active' : '') })
1468
+ det.open = !isCollapsed(bucket.key)
1469
+ det.addEventListener('toggle', () => {
1470
+ collapsed.set(bucket.key, !det.open)
1471
+ })
1472
+
1473
+ const summary = el('summary', {
1474
+ class: 'stash-tag' + (active ? ' active' : ''),
1475
+ title: `${bucket.label} · ${bucket.items.length} 项(点箭头/整条折叠;点右侧漏斗图标只在看本组与全部间切换)`,
1476
+ })
1477
+ // 单击整条标签 = 折叠/展开(summary 默认行为,不拦截)。
1478
+ // 切换视图交给独立的漏斗按钮,避免和折叠抢点击。
1479
+ const switchView = ev => {
1480
+ ev.preventDefault(); ev.stopPropagation()
1481
+ if (active) { state.view = 'all' } // 已是当前组 → 回到全部
1482
+ else { state.view = bucket.key === '' ? '' : bucket.key } // 切到本组
1483
+ guard(draw)()
1484
+ drawMoveSelect()
1485
+ }
1486
+ summary.append(el('span', { class: 'stash-tag-dot' }))
1487
+ summary.append(el('span', { class: 'stash-tag-name', text: bucket.label }))
1488
+ summary.append(el('span', { class: 'stash-tag-count', text: `(${bucket.items.length})` }))
1489
+ // 组内「全选 / 取消」:只作用于本组条目;多组叠加互不影响(不 clear 其它组的选中)。
1490
+ // drawList 每次都重建,按钮状态直接从 state.selected 现算,天然同步。
1491
+ if (bucket.items.length) {
1492
+ const ids = bucket.items.map(i => i.id)
1493
+ const allOn = ids.every(id => state.selected.has(id))
1494
+ const toggleBucket = ev => {
1495
+ ev.preventDefault(); ev.stopPropagation()
1496
+ if (allOn) { for (const id of ids) state.selected.delete(id) }
1497
+ else { for (const id of ids) state.selected.add(id) }
1498
+ guard(draw)()
1499
+ }
1500
+ summary.append(el('span', {
1501
+ class: 'stash-tag-select' + (allOn ? ' all' : ''), role: 'button', tabindex: '0',
1502
+ 'aria-pressed': String(allOn),
1503
+ title: allOn
1504
+ ? `取消全选「${bucket.label}」(${bucket.items.length} 项)`
1505
+ : `全选「${bucket.label}」(${bucket.items.length} 项,不影响其它分组已选)`,
1506
+ onclick: toggleBucket,
1507
+ onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') toggleBucket(ev) },
1508
+ }, allOn ? '取消' : '全选'))
1509
+ }
1510
+ // 筛选按钮:切换「只看本组 / 全部」。用文字而不是 ⌕/◉ 图标——
1511
+ // 图标在这个尺寸下根本认不出是干什么的(可读优先)。
1512
+ summary.append(el('span', {
1513
+ class: 'stash-tag-filter' + (active ? ' on' : ''), role: 'button', tabindex: '0',
1514
+ title: active ? `正在只看「${bucket.label}」,点击回到全部` : `只看「${bucket.label}」`,
1515
+ onclick: switchView,
1516
+ onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') switchView(ev) },
1517
+ }, active ? '看全部' : '只看'))
1518
+ if (bucket.key !== '') {
1519
+ summary.append(el('span', {
1520
+ class: 'stash-tag-del', role: 'button', tabindex: '0', title: `删除分组「${bucket.label}」(条目回到未分组)`,
1521
+ onclick: ev => { ev.preventDefault(); ev.stopPropagation(); guard(() => removeGroup(bucket.key))() },
1522
+ onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); ev.stopPropagation(); guard(() => removeGroup(bucket.key))() } },
1523
+ }, '×'))
1524
+ }
1525
+ det.append(summary)
1526
+
1527
+ if (!bucket.items.length) {
1528
+ det.append(el('p', { class: 'stash-group-empty', text: '这个分组还没有文件。' }))
1529
+ } else {
1530
+ for (const item of bucket.items) det.append(buildRow(item, flat.indexOf(item), flat, last))
1531
+ }
1532
+ list.append(det)
1533
+ }
1534
+ }
1535
+
1536
+ // 构造单个暂存条目行
1537
+ const buildRow = (item, idx, flat, last) => {
1538
+ const row = el('div', {
1539
+ class: 'row' + (state.selected.has(item.id) ? ' selected' : '') + (item.missing ? ' missing' : ''),
1540
+ role: 'option', 'aria-selected': String(state.selected.has(item.id)), draggable: 'true',
1541
+ dataset: { id: item.id }, title: item.path,
1542
+ })
1543
+ row.append(el('span', { class: 'ico', text: iconOf(item) }))
1544
+ row.append(el('span', { class: 'nm', text: item.name + (item.kind === 'dir' || item.kind === 'folder' ? '/' : '') }))
1545
+ if (item.missing) row.append(el('span', { class: 'miss', text: '文件缺失' }))
1546
+ row.append(el('span', { class: 'note', text: bytes(item.size) }))
1547
+ row.append(el('span', {
1548
+ class: 'stash-preview-btn', role: 'button', tabindex: '0',
1549
+ title: '在右侧预览区打开', 'aria-label': '预览',
1550
+ onclick: ev => { ev.stopPropagation(); guard(() => previewStashItem(item))() },
1551
+ onkeydown: ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); ev.stopPropagation(); guard(() => previewStashItem(item))() } },
1552
+ }, '预览'))
1553
+
1554
+ row.onclick = event => {
1555
+ if (event.shiftKey && last.idx >= 0) {
1556
+ const [a, b] = [Math.min(last.idx, idx), Math.max(last.idx, idx)]
1557
+ state.selected.clear()
1558
+ for (let i = a; i <= b; i++) state.selected.add(flat[i].id)
1559
+ } else if (event.ctrlKey || event.metaKey) {
1560
+ state.selected.has(item.id) ? state.selected.delete(item.id) : state.selected.add(item.id)
1561
+ last.idx = idx
1562
+ } else {
1563
+ state.selected.clear()
1564
+ state.selected.add(item.id)
1565
+ last.idx = idx
1566
+ }
1567
+ draw()
1568
+ }
1569
+ row.ondblclick = guard(() => previewStashItem(item))
1570
+ row.addEventListener('mousedown', () => prefetchDragOut(item))
1571
+ row.addEventListener('pointerdown', () => prefetchDragOut(item))
1572
+ row.addEventListener('dragstart', event => startDragOut(event, item))
1573
+ row.addEventListener('dragend', () => endDragOut(item))
1574
+ return row
1575
+ }
1576
+
1577
+ // 删除一个分组(条目回到未分组)
1578
+ const removeGroup = async name => {
1579
+ // 宿主 WebView2 里 window.confirm 会被拦掉(永远返回 false),
1580
+ // 导致点分组标签右侧的 × 完全没反应。这里不再确认:
1581
+ // 删除只是把条目放回「未分组」,不会丢文件,可以重新建组。
1582
+ await call('stash.group.remove', { name })
1583
+ collapsed.delete(name)
1584
+ if (state.view === name) state.view = 'all'
1585
+ say(`已删除分组「${name}」`)
1586
+ await refresh()
1587
+ }
1588
+
1589
+ const updateToolbar = () => {
1590
+ const has = state.selected.size > 0
1591
+ for (const id of ['btn-copy-path', 'btn-copy-file', 'btn-remove']) $(id).disabled = !has
1592
+ }
1593
+
1594
+ // ---------------------------------------------------------------- 拖出(DownloadURL 必须同步 setData)
1595
+
1596
+ const prefetchDragOut = item => {
1597
+ if (state.dragCache.has(item.id)) return
1598
+ callSoft('stash.dragOut', { id: item.id }).then(row => {
1599
+ if (row && row.ok && row.url) state.dragCache.set(item.id, { url: row.url, name: row.name || item.name, mime: row.mime || mimeOf(item) })
1600
+ else toast('拖出失败:' + ((row && row.error) || '无法生成下载链接'))
1601
+ })
1602
+ }
1603
+ const startDragOut = (event, item) => {
1604
+ const cached = state.dragCache.get(item.id)
1605
+ event.dataTransfer.effectAllowed = 'copy'
1606
+ if (cached) {
1607
+ // 硬要求:dragstart 里同步 setData,URL 必须来自 mousedown 阶段预取的缓存
1608
+ event.dataTransfer.setData('DownloadURL', `${cached.mime}:${cached.name}:${cached.url}`)
1609
+ event.dataTransfer.setData('text/plain', item.path)
1610
+ event.dataTransfer.setData('text/uri-list', cached.url)
1611
+ } else {
1612
+ // 缓存没来得及:退化为只带纯文本路径
1613
+ event.dataTransfer.setData('text/plain', item.path)
1614
+ toast('正在准备拖出,请重新拖一次')
1615
+ }
1616
+ }
1617
+ const endDragOut = item => {
1618
+ state.dragCache.delete(item.id)
1619
+ callSoft('stash.closeDragOut', { id: item.id })
1620
+ }
1621
+
1622
+ // ---------------------------------------------------------------- 拖入(宿主转发 fish:drag)
1623
+
1624
+ window.addEventListener('message', event => {
1625
+ const data = event.data
1626
+ if (!data || data.type !== 'fish:drag') return
1627
+ const stash = $('stash')
1628
+ if (data.phase === 'enter' || data.phase === 'over') {
1629
+ stash.classList.add('drag-over')
1630
+ $('drop-overlay').hidden = false
1631
+ return
1632
+ }
1633
+ if (data.phase === 'leave') {
1634
+ stash.classList.remove('drag-over')
1635
+ $('drop-overlay').hidden = true
1636
+ return
1637
+ }
1638
+ if (data.phase === 'drop') {
1639
+ stash.classList.remove('drag-over')
1640
+ $('drop-overlay').hidden = true
1641
+ const paths = Array.isArray(data.paths) ? data.paths : []
1642
+ if (!paths.length) return
1643
+ guard(async () => {
1644
+ const group = state.view === 'all' ? '' : state.view
1645
+ // 逐条加入,方便把失败的单独进 toast
1646
+ const results = await Promise.all(paths.map(p => callSoft('stash.add', { paths: [p], group })))
1647
+ let added = 0
1648
+ for (const r of results) {
1649
+ if (r && r.ok) added++
1650
+ else toast('加入暂存失败:' + ((r && r.error) || '未知错误'))
1651
+ }
1652
+ if (added) { say(`已加入 ${added} 项`); await refresh() }
1653
+ else fail('没有文件被加入暂存')
1654
+ })()
1655
+ }
1656
+ })
1657
+
1658
+ // ---------------------------------------------------------------- 工具条动作
1659
+
1660
+ const addPaths = async paths => {
1661
+ const group = state.view === 'all' ? '' : state.view
1662
+ const results = await Promise.all(paths.map(p => callSoft('stash.add', { paths: [p], group })))
1663
+ let added = 0
1664
+ for (const r of results) {
1665
+ if (r && r.ok) added++
1666
+ else toast('加入暂存失败:' + ((r && r.error) || '未知错误'))
1667
+ }
1668
+ if (added) { say(`已加入 ${added} 项`); await refresh() }
1669
+ }
1670
+
1671
+ $('btn-copy-path').onclick = guard(async () => {
1672
+ const items = selectedItems()
1673
+ await call('stash.copy', { mode: 'path', ids: items.map(i => i.id) })
1674
+ toast(`已复制 ${items.length} 条路径`)
1675
+ })
1676
+ $('btn-copy-file').onclick = guard(async () => {
1677
+ const items = selectedItems()
1678
+ await call('stash.copy', { mode: 'file', ids: items.map(i => i.id) })
1679
+ toast(`已复制 ${items.length} 个文件,可到资源管理器粘贴`)
1680
+ })
1681
+ $('btn-remove').onclick = guard(async () => {
1682
+ const items = selectedItems()
1683
+ await call('stash.remove', { ids: items.map(i => i.id) })
1684
+ state.selected.clear()
1685
+ say(`已移除 ${items.length} 项`)
1686
+ await refresh()
1687
+ })
1688
+ $('btn-select-all').onclick = () => {
1689
+ // 只切换「当前可见」条目,不再粗暴 clear —— 以免和分组标签上的组内全选互相冲突。
1690
+ const ids = visibleItems().map(i => i.id)
1691
+ if (!ids.length) return
1692
+ const all = ids.every(id => state.selected.has(id))
1693
+ if (all) for (const id of ids) state.selected.delete(id)
1694
+ else for (const id of ids) state.selected.add(id)
1695
+ draw()
1696
+ }
1697
+
1698
+ // 分组操作
1699
+ // 新建组:不用 window.prompt —— WebView2 内嵌页面可能禁用/屏蔽 prompt(返回 null 或抛异常),
1700
+ // 那样 (prompt()||'').trim() 得到空串就会静默什么都不做,用户看到「点了没反应」。
1701
+ // 改成在工具条里就地展开一个输入框,回车/点确定创建,Esc/失焦取消。
1702
+ const openGroupInput = () => {
1703
+ const input = $('group-new-input')
1704
+ const okBtn = $('btn-group-new-ok')
1705
+ if (!input) return
1706
+ input.hidden = false
1707
+ if (okBtn) okBtn.hidden = false
1708
+ input.value = ''
1709
+ input.focus()
1710
+ }
1711
+ const closeGroupInput = () => {
1712
+ const input = $('group-new-input')
1713
+ const okBtn = $('btn-group-new-ok')
1714
+ if (input) { input.hidden = true; input.value = '' }
1715
+ if (okBtn) okBtn.hidden = true
1716
+ }
1717
+ const commitGroupInput = guard(async () => {
1718
+ const input = $('group-new-input')
1719
+ if (!input) return
1720
+ const name = (input.value || '').trim()
1721
+ if (!name) { closeGroupInput(); return }
1722
+ if (state.groups.includes(name)) { fail('分组已存在'); closeGroupInput(); return }
1723
+ closeGroupInput()
1724
+ await call('stash.group.add', { name })
1725
+ state.view = 'all'
1726
+ say(`已创建分组「${name}」`)
1727
+ await refresh()
1728
+ })
1729
+ $('btn-group-new').onclick = () => openGroupInput()
1730
+ if ($('btn-group-new-ok')) $('btn-group-new-ok').onclick = commitGroupInput
1731
+ if ($('group-new-input')) {
1732
+ const input = $('group-new-input')
1733
+ input.addEventListener('keydown', event => {
1734
+ if (event.key === 'Enter') { event.preventDefault(); commitGroupInput() }
1735
+ else if (event.key === 'Escape') { event.preventDefault(); closeGroupInput() }
1736
+ })
1737
+ // 失焦时:如果填了名字就提交,没填就收起(避免点「确定」时先触发 blur 导致重复)
1738
+ input.addEventListener('blur', () => {
1739
+ if (!(input.value || '').trim()) closeGroupInput()
1740
+ })
1741
+ }
1742
+ $('move-to').onchange = guard(async event => {
1743
+ const target = event.target.value
1744
+ const items = selectedItems()
1745
+ if (!items.length) { toast('先选中条目再移动'); return }
1746
+ await call('stash.move', { ids: items.map(i => i.id), group: target })
1747
+ say(`已移动 ${items.length} 项到「${target || '未分组'}」`)
1748
+ await refresh()
1749
+ })
1750
+
1751
+ // ---------------------------------------------------------------- 启动
1752
+
1753
+ // 互关联①:文件列表项 → 加入暂存。当前分组作为落点,加完刷新暂存列表。
1754
+ async function addToStash(target) {
1755
+ const group = state.view === 'all' ? '' : state.view
1756
+ const row = await callSoft('stash.add', { paths: [target], group })
1757
+ if (row && row.ok) {
1758
+ const added = Array.isArray(row.items) ? row.items.length : 1
1759
+ if (added) { say('已加入暂存'); await refresh() } else { toast('已在暂存中,或文件无法加入') }
1760
+ } else {
1761
+ fail('加入暂存失败:' + ((row && row.error) || '未知错误'))
1762
+ }
1763
+ }
1764
+
1765
+ // 批量加入(框选后拖进暂存区走这一条)。
1766
+ // 后端 stash.add 本来就吃 paths 数组,所以只是把多行一次性递过去;
1767
+ // 逐个报告"加了几项/几项重复",比只弹一个含糊的 toast 更好懂。
1768
+ async function addManyToStash(paths) {
1769
+ const list = (paths || []).filter(Boolean)
1770
+ if (!list.length) return
1771
+ const group = state.view === 'all' ? '' : state.view
1772
+ const row = await callSoft('stash.add', { paths: list, group })
1773
+ if (row && row.ok) {
1774
+ const added = Array.isArray(row.items) ? row.items.length : 0
1775
+ const dup = list.length - added
1776
+ const head = added ? `已加入暂存 ${added} 项` : '这些都已经在暂存里了'
1777
+ say(dup > 0 && added > 0 ? `${head},${dup} 项重复已跳过` : head)
1778
+ if (added) await refresh()
1779
+ } else {
1780
+ fail('加入暂存失败:' + ((row && row.error) || '未知错误'))
1781
+ }
1782
+ }
1783
+
1784
+ // ================================================================ 框选 + 拖到暂存区
1785
+
1786
+ // 指针状态机:idle → pending(按下未移动)→ box(空白处拖出选框)/ drag(从行上拖走)。
1787
+ // pending 时原地松手,就交还给原有的 click(打开文件/进入目录),不抢原有交互。
1788
+ function wireBrowseSelect() {
1789
+ const list = $('browse-list')
1790
+ const rubber = $('rubber')
1791
+ const stashCol = $('stash-col')
1792
+ if (!list || !rubber) return
1793
+
1794
+ const THRESHOLD = 5 // 位移小于它算点击,不算拖动
1795
+ let mode = 'idle'
1796
+ let startX = 0, startY = 0
1797
+ let startRow = null // 起点落在哪一行(null = 点在空白处)
1798
+ let baseSel = [] // 拖动/框选开始时已有的选择,按住 Shift 时参与累加
1799
+ let dragPaths = []
1800
+ let moved = false
1801
+ let suppressClick = false
1802
+ let ghost = null
1803
+
1804
+ const rows = () => Array.from(list.querySelectorAll('.row[data-path]'))
1805
+
1806
+ // 只改 class,不整片重绘:框选要跟手,重建 DOM 会闪。
1807
+ const paint = () => {
1808
+ for (const node of rows()) {
1809
+ const on = state.browseSel.has(node.dataset.path)
1810
+ node.classList.toggle('sel', on)
1811
+ node.setAttribute('aria-selected', on ? 'true' : 'false')
1812
+ }
1813
+ }
1814
+
1815
+ const boxSelect = (rect, additive) => {
1816
+ const picked = new Set(additive ? baseSel : [])
1817
+ for (const node of rows()) {
1818
+ const b = node.getBoundingClientRect()
1819
+ // 相交即选中,符合"拖框扫过去就中"的直觉
1820
+ if (!(b.right < rect.left || b.left > rect.right || b.bottom < rect.top || b.top > rect.bottom)) {
1821
+ picked.add(node.dataset.path)
1822
+ }
1823
+ }
1824
+ state.browseSel = picked
1825
+ paint()
1826
+ }
1827
+
1828
+ const reset = () => {
1829
+ mode = 'idle'
1830
+ dragPaths = []
1831
+ startRow = null
1832
+ rubber.classList.remove('on')
1833
+ rubber.style.cssText = ''
1834
+ delete document.body.dataset.boxing
1835
+ delete document.body.dataset.dragging
1836
+ if (ghost) { ghost.remove(); ghost = null }
1837
+ for (const node of list.querySelectorAll('.row.drag-src')) node.classList.remove('drag-src')
1838
+ if (stashCol) stashCol.classList.remove('drop-target')
1839
+ }
1840
+
1841
+ list.addEventListener('pointerdown', event => {
1842
+ if (event.button !== 0) return
1843
+ const target = event.target
1844
+ if (target.closest('.add')) return // + 按钮有自己的 onClick
1845
+ if (target.closest('#browse-head')) return // 表头是排序按钮
1846
+ mode = 'pending'
1847
+ moved = false
1848
+ startX = event.clientX
1849
+ startY = event.clientY
1850
+ startRow = target.closest('.row[data-path]')
1851
+ const additive = event.shiftKey || event.ctrlKey || event.metaKey
1852
+ if (startRow) {
1853
+ const path = startRow.dataset.path
1854
+ if (additive) {
1855
+ // 按住 Ctrl/Shift:切换这一项的选中状态,其余保留
1856
+ if (state.browseSel.has(path)) state.browseSel.delete(path)
1857
+ else state.browseSel.add(path)
1858
+ } else if (!state.browseSel.has(path)) {
1859
+ // 普通左键:只留这一项(保持"选中=当前行"的直觉,也让单击后能直接拖走)
1860
+ state.browseSel = new Set([path])
1861
+ }
1862
+ paint()
1863
+ } else if (!additive) {
1864
+ state.browseSel = new Set()
1865
+ paint()
1866
+ }
1867
+ baseSel = Array.from(state.browseSel)
1868
+ // 注意:这里【不能】立刻 setPointerCapture。行是 <button>,指针被祖先列表捕获后
1869
+ // WebView2 不再派发行的 click → 真机鼠标点文件夹"没反应"(UIA 的 Invoke 直接派
1870
+ // DOM click,所以测试看着正常)。只有确认是拖动(moved 之后)才捕获,纯点击交还原生 click。
1871
+ })
1872
+
1873
+ list.addEventListener('pointermove', event => {
1874
+ if (mode === 'idle') return
1875
+ const dx = event.clientX - startX
1876
+ const dy = event.clientY - startY
1877
+ if (!moved) {
1878
+ if (Math.abs(dx) < THRESHOLD && Math.abs(dy) < THRESHOLD) return
1879
+ moved = true
1880
+ // 起手先一律进框选:列表里行是铺满的,几乎找不到空白区,
1881
+ // 若一按行就判成"拖到暂存区",用户就永远框不到东西。
1882
+ // 只有指针真的离开列表、且手里拿着选中项时,下一条 pointermove 才升级成拖拽。
1883
+ mode = 'box'
1884
+ document.body.dataset.boxing = 'true'
1885
+ rubber.classList.add('on')
1886
+ // 确认是拖动后才捕获指针:保证拖到暂存区上方松手时 pointerup 仍回到列表,
1887
+ // 同时不影响"纯点击不捕获",让行的 click 正常触发打开/预览。
1888
+ try { list.setPointerCapture(event.pointerId) } catch { /* 老 WebView 不支持就靠冒泡 */ }
1889
+ }
1890
+ // 指针已经拖出列表、且起手处有选中项 → 这一趟是想搬去暂存区,切成拖拽态。
1891
+ const inside = !!document.elementFromPoint(event.clientX, event.clientY)?.closest('#browse-list')
1892
+ if (mode === 'box' && !inside && startRow && state.browseSel.size) {
1893
+ mode = 'drag'
1894
+ // 框选过程改动过 browseSel,升级成拖拽时恢复成"起手那一刻选中的项",
1895
+ // 否则拖走的会是半路被框到的东西,不是用户按住的那几个。
1896
+ state.browseSel = new Set(baseSel)
1897
+ paint()
1898
+ dragPaths = Array.from(state.browseSel)
1899
+ delete document.body.dataset.boxing
1900
+ rubber.classList.remove('on')
1901
+ rubber.style.cssText = ''
1902
+ document.body.dataset.dragging = 'true'
1903
+ for (const node of rows()) {
1904
+ if (state.browseSel.has(node.dataset.path)) node.classList.add('drag-src')
1905
+ }
1906
+ ghost = el('div', { class: 'drag-ghost', text: dragPaths.length > 1 ? `搬运 ${dragPaths.length} 项` : '搬运到暂存区' })
1907
+ document.body.append(ghost)
1908
+ }
1909
+ if (mode === 'box') {
1910
+ const left = Math.min(startX, event.clientX)
1911
+ const top = Math.min(startY, event.clientY)
1912
+ const width = Math.abs(dx)
1913
+ const height = Math.abs(dy)
1914
+ rubber.style.left = `${left}px`
1915
+ rubber.style.top = `${top}px`
1916
+ rubber.style.width = `${width}px`
1917
+ rubber.style.height = `${height}px`
1918
+ boxSelect({ left, top, right: left + width, bottom: top + height },
1919
+ event.shiftKey || event.ctrlKey || event.metaKey)
1920
+ } else if (mode === 'drag' && ghost) {
1921
+ ghost.style.left = `${event.clientX + 14}px`
1922
+ ghost.style.top = `${event.clientY + 14}px`
1923
+ const over = stashCol && document.elementFromPoint(event.clientX, event.clientY)?.closest('#stash-col')
1924
+ if (stashCol) stashCol.classList.toggle('drop-target', !!over)
1925
+ }
1926
+ event.preventDefault()
1927
+ })
1928
+
1929
+ const finish = async event => {
1930
+ if (mode === 'idle') return
1931
+ const was = mode
1932
+ const paths = dragPaths.slice()
1933
+ const dropped = was === 'drag' && !!(
1934
+ stashCol && document.elementFromPoint(event.clientX, event.clientY)?.closest('#stash-col')
1935
+ )
1936
+ // 拖动/框选结束后浏览器还会补一个 click,必须吃掉它,
1937
+ // 否则把文件拖进暂存区的同时会在预览区把它打开一遍。
1938
+ if (moved) {
1939
+ suppressClick = true
1940
+ setTimeout(() => { suppressClick = false }, 0)
1941
+ }
1942
+ reset()
1943
+ if (dropped && paths.length) await addManyToStash(paths)
1944
+ }
1945
+
1946
+ list.addEventListener('pointerup', event => { finish(event) })
1947
+ list.addEventListener('pointercancel', () => { reset() })
1948
+
1949
+ // 捕获阶段拦截那一次补发的 click(行是 button,click 会冒泡到列表)
1950
+ list.addEventListener('click', event => {
1951
+ if (!suppressClick) return
1952
+ suppressClick = false
1953
+ event.preventDefault()
1954
+ event.stopPropagation()
1955
+ }, true)
1956
+ }
1957
+ // 互关联②:暂存项 → 在右侧预览区打开。目录则打开该目录,文件则预览。
1958
+ async function previewStashItem(item) {
1959
+ if (item.missing) { toast('文件缺失,无法预览'); return }
1960
+ try {
1961
+ await openPath(item.path)
1962
+ } catch (error) {
1963
+ fail('预览失败:' + (error && error.message ? error.message : String(error)))
1964
+ }
1965
+ }
1966
+
1967
+ async function bootStash() {
1968
+ say('正在加载…')
1969
+ await refresh()
1970
+ say('就绪')
1971
+ }
1972
+
1973
+
1974
+ // ================================================================ 外层分隔条(暂存栏 | 预览区)
1975
+ // 与内层 #split 同一套「起点 + 位移」算法:宽度 = 起点宽 + (当前X - 起点X)。
1976
+ // 用 --stash-w 控制左栏宽度并持久化到 localStorage(外层不做后端命令,简单可靠)。
1977
+
1978
+ const FM_MIN = 200, FM_MAX = 640, FM_DEFAULT = 300
1979
+ const clampFm = n => (!Number.isFinite(n) ? FM_DEFAULT : Math.min(FM_MAX, Math.max(FM_MIN, n)))
1980
+ const applyFmWidth = () => {
1981
+ document.documentElement.style.setProperty('--stash-w', state.stashW + 'px')
1982
+ const sp = $('fmsplit'); if (sp) sp.setAttribute('aria-valuenow', String(state.stashW))
1983
+ }
1984
+ const saveFm = () => { try { localStorage.setItem('fm.stashW', String(state.stashW)) } catch { /* 存储不可用时忽略 */ } }
1985
+
1986
+ function wireFmSplit() {
1987
+ try {
1988
+ const saved = Number(localStorage.getItem('fm.stashW'))
1989
+ if (Number.isFinite(saved) && saved > 0) state.stashW = clampFm(saved)
1990
+ } catch { /* 忽略 */ }
1991
+ applyFmWidth()
1992
+ const split = $('fmsplit')
1993
+ if (!split) return
1994
+ let dragging = false, startX = 0, startW = 0
1995
+ const down = event => {
1996
+ if (event.button !== undefined && event.button !== 0) return
1997
+ dragging = true; startX = event.clientX; startW = state.stashW
1998
+ split.setPointerCapture?.(event.pointerId)
1999
+ split.dataset.dragging = 'true'; document.body.dataset.resizing = 'true'
2000
+ event.preventDefault()
2001
+ }
2002
+ const move = event => {
2003
+ if (!dragging) return
2004
+ state.stashW = clampFm(startW + (event.clientX - startX))
2005
+ applyFmWidth()
2006
+ }
2007
+ const stop = () => {
2008
+ if (!dragging) return
2009
+ dragging = false; delete split.dataset.dragging; delete document.body.dataset.resizing
2010
+ saveFm()
2011
+ }
2012
+ split.addEventListener('pointerdown', down)
2013
+ split.addEventListener('pointermove', move)
2014
+ split.addEventListener('pointerup', stop)
2015
+ split.addEventListener('pointercancel', stop)
2016
+ split.addEventListener('lostpointercapture', stop)
2017
+ split.addEventListener('dblclick', () => { state.stashW = FM_DEFAULT; applyFmWidth(); saveFm() })
2018
+ split.addEventListener('keydown', event => {
2019
+ const step = event.shiftKey ? 48 : 16
2020
+ let next = null
2021
+ if (event.key === 'ArrowLeft') next = state.stashW - step
2022
+ else if (event.key === 'ArrowRight') next = state.stashW + step
2023
+ else if (event.key === 'Home') next = FM_DEFAULT
2024
+ if (next === null) return
2025
+ event.preventDefault()
2026
+ state.stashW = clampFm(next); applyFmWidth(); saveFm()
2027
+ })
2028
+ }
2029
+
2030
+ // ================================================================ 启动(两套都启动)
2031
+
2032
+ try { parent.postMessage({ type: 'fish:ready' }, '*') } catch { /* 宿主不可用时忽略 */ }
2033
+ new ResizeObserver(reportHeight).observe(document.documentElement)
2034
+ wireFmSplit()
2035
+ wireSplitter()
2036
+ wireBrowseSelect()
2037
+ drawSide()
2038
+ // 两套 boot 只读数据、不互斥;不能用 guard(它会因共享 state.busy 把第二个启动挡掉)。
2039
+ const bootSafe = fn => Promise.resolve().then(fn).catch(error => bad(error && error.message ? error.message : String(error)))
2040
+ bootSafe(bootPreview)
2041
+ bootSafe(bootStash)
2042
+ })()