fish-dock-plugin-filemgr 1.0.5 → 1.0.7
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/package.json +1 -1
- package/plugin.json +10 -7
- package/plugins/filemgr/backend.mjs +167 -6
- package/plugins/filemgr/filemgr.css +21 -0
- package/plugins/filemgr/page.html +4 -0
- package/plugins/filemgr/page.js +106 -5
- package/plugins/filemgr/sdk.mjs +5 -2
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"id": "jassdingk.filemgr",
|
|
4
4
|
"name": "文件管理",
|
|
5
|
-
"version": "1.0.
|
|
5
|
+
"version": "1.0.7",
|
|
6
6
|
"hostApi": 1,
|
|
7
7
|
"platforms": [
|
|
8
8
|
"windows-x86_64"
|
|
@@ -27,21 +27,24 @@
|
|
|
27
27
|
],
|
|
28
28
|
"files": {
|
|
29
29
|
"bridge.js": "f38f93994e3cb2b71f2ff1810b3f68fb42f4261550f8ab01b0bfa3eecd5f94ee",
|
|
30
|
-
"package.json": "
|
|
31
|
-
"plugins/filemgr/backend.mjs": "
|
|
30
|
+
"package.json": "330dcaaf54a8291462d17ef6a28d7b49465d7a603add478f03fb35feb49a4bee",
|
|
31
|
+
"plugins/filemgr/backend.mjs": "7cd37f01f6d4ae8b9693d2b1642f5078855fd405dae1e1cf9a6c10d7a8c15b15",
|
|
32
32
|
"plugins/filemgr/clip.ps1": "b0a32fca35cf2d25133338f46e20a0db20d78c5bb6b5c2aab899998bfbd6c6ab",
|
|
33
33
|
"plugins/filemgr/dock.css": "e795214dd39de3b473ac0b1a7b79f6922a88c761fca4d3ea4da8fd9b60188bcf",
|
|
34
|
-
"plugins/filemgr/filemgr.css": "
|
|
34
|
+
"plugins/filemgr/filemgr.css": "c6d6e4041901e37d023205d8d7025b597294a8503bb74be74ed774eeb86fe1e3",
|
|
35
35
|
"plugins/filemgr/FolderPicker.ps1": "4b7f86c543e06fd8f93fa5d8002997d944c8189cb0ae031bedf864ea89196427",
|
|
36
36
|
"plugins/filemgr/page.css": "0801a05b5148cd091a68957beac21cc982a7818ef4707cd06fd11b20adbc7765",
|
|
37
|
-
"plugins/filemgr/page.html": "
|
|
38
|
-
"plugins/filemgr/page.js": "
|
|
37
|
+
"plugins/filemgr/page.html": "415e9ae0d7ed85040974498d4d5581d707a668d55c917181025d4fee4dda67de",
|
|
38
|
+
"plugins/filemgr/page.js": "ab67c2a1f5e4a48c1912b630a712524ed3a65a592ae1a6a69d9a12058ca84ee4",
|
|
39
39
|
"plugins/filemgr/pick-files.ps1": "88bff92d9a2e5b69e9411bb372441ff0b86899b7f5b31f5ed8d154c4f6fc2f37",
|
|
40
40
|
"plugins/filemgr/pick-folder.ps1": "ff031c14df32149c1c80c7ab6a21710c1a68e823e96e3068c458a60902ae3b6f",
|
|
41
41
|
"plugins/filemgr/preview.mjs": "a5596e77180fe7928443244a0148b6d3ea8381a1f886ee01f9a08be038c7ee80",
|
|
42
|
-
"plugins/filemgr/sdk.mjs": "
|
|
42
|
+
"plugins/filemgr/sdk.mjs": "91061dae337157c88e2303d1899f9149ccecd656df4675c76dd5721bc405bf07",
|
|
43
43
|
"plugins/filemgr/stash.css": "fce2aaa13a86dbd7675b685c533f0bb916c7e610fc612a3ade660c67069f5662",
|
|
44
44
|
"plugins/filemgr/stash.mjs": "c33cea6b845a3847950d26a10b9aba1ef68d39b1f9fc423b23055dead7638866",
|
|
45
45
|
"README.md": "e9fb4ca9831b778a1d530b67518fcaf2b42cf02494b9d68deaad16bf2b947687"
|
|
46
|
+
},
|
|
47
|
+
"behaviors": {
|
|
48
|
+
"autostart": true
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// 所有业务方法都返回 {ok:true,...} 或 {ok:false,error},便于页面显示真实原因。
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import fs from 'node:fs'
|
|
5
|
+
import http from 'node:http'
|
|
5
6
|
import { spawn } from 'node:child_process'
|
|
6
7
|
import { fileURLToPath } from 'node:url'
|
|
7
8
|
import { createPlugin } from './sdk.mjs'
|
|
@@ -21,12 +22,6 @@ function logCrash(where, error) {
|
|
|
21
22
|
process.on('uncaughtException', error => logCrash('uncaughtException', error))
|
|
22
23
|
process.on('unhandledRejection', error => logCrash('unhandledRejection', error))
|
|
23
24
|
|
|
24
|
-
// [诊断探针] 宿主每次 spawn 本后端都会留下这行痕迹,用于判定"宿主到底有没有拉起插件"。
|
|
25
|
-
try {
|
|
26
|
-
const fs = await import('node:fs')
|
|
27
|
-
fs.appendFileSync(path.join(HERE, '_spawn-probe.log'),
|
|
28
|
-
`${new Date().toISOString()} pid=${process.pid} argv=${JSON.stringify(process.argv)} cwd=${process.cwd()} node=${process.execPath}\n`)
|
|
29
|
-
} catch { /* 探针失败不影响业务 */ }
|
|
30
25
|
|
|
31
26
|
// 宿主对插件方法的单次调用有 15 秒硬超时(plugin_runtime.rs:42),
|
|
32
27
|
// 而用户挑文件夹可能盯着一分钟。所以选择操作拆成两步:
|
|
@@ -165,6 +160,161 @@ function guard(fn) {
|
|
|
165
160
|
|
|
166
161
|
function activeRoots() { return roots(extraRoots) }
|
|
167
162
|
|
|
163
|
+
// ---- 全局文件搜索:遍历「位置」里所有根目录,按文件名匹配关键字 ----
|
|
164
|
+
// 宿主对单次插件调用有 15 秒硬超时,这里用 8 秒 deadline + 扫描/命中上限主动收敛,
|
|
165
|
+
// 并把系统/缓存等超大类目录跳过,避免全盘扫描拖死页面。
|
|
166
|
+
const SEARCH_HIDDEN = new Set([
|
|
167
|
+
'node_modules', '.git', '.svn', '.hg', '__pycache__', '.idea', '.vscode', 'target', 'dist', '.next', '.cache',
|
|
168
|
+
'$RECYCLE.BIN', 'System Volume Information', 'AppData', 'Windows', 'Program Files', 'Program Files (x86)',
|
|
169
|
+
'ProgramData', 'Recovery', 'PerfLogs', '$WinREAgent', 'OneDriveTemp',
|
|
170
|
+
])
|
|
171
|
+
async function searchFiles(query, list) {
|
|
172
|
+
const raw = String(query || '').trim()
|
|
173
|
+
const needle = raw.toLowerCase()
|
|
174
|
+
if (!needle) return { query: raw, results: [], scanned: 0, dirs: 0, truncated: false, timedOut: false }
|
|
175
|
+
const MAX_RESULTS = 300
|
|
176
|
+
const MAX_SCAN = 80000
|
|
177
|
+
const MAX_DEPTH = 14
|
|
178
|
+
const deadline = Date.now() + 8000
|
|
179
|
+
const results = []
|
|
180
|
+
let scanned = 0
|
|
181
|
+
let dirs = 0
|
|
182
|
+
let truncated = false
|
|
183
|
+
let timedOut = false
|
|
184
|
+
const stack = list.map(r => ({ dir: r.path, depth: 0, root: r.label }))
|
|
185
|
+
while (stack.length) {
|
|
186
|
+
if (results.length >= MAX_RESULTS || scanned >= MAX_SCAN || Date.now() > deadline) { truncated = true; timedOut = Date.now() > deadline; break }
|
|
187
|
+
const cur = stack.pop()
|
|
188
|
+
let ents
|
|
189
|
+
try { ents = await fs.promises.readdir(cur.dir, { withFileTypes: true }) } catch { continue }
|
|
190
|
+
dirs++
|
|
191
|
+
for (const e of ents) {
|
|
192
|
+
if (results.length >= MAX_RESULTS || scanned >= MAX_SCAN || Date.now() > deadline) { truncated = true; timedOut = Date.now() > deadline; break }
|
|
193
|
+
const name = e.name
|
|
194
|
+
if (!name || name.startsWith('.')) continue
|
|
195
|
+
if (e.isSymbolicLink && e.isSymbolicLink()) continue
|
|
196
|
+
const isDir = e.isDirectory()
|
|
197
|
+
if (isDir && SEARCH_HIDDEN.has(name)) continue
|
|
198
|
+
const full = path.join(cur.dir, name)
|
|
199
|
+
scanned++
|
|
200
|
+
if (!isDir) {
|
|
201
|
+
if (name.toLowerCase().includes(needle)) {
|
|
202
|
+
let size = 0, mtime = 0
|
|
203
|
+
try { const s = await fs.promises.stat(full); size = s.size; mtime = s.mtimeMs } catch { /* 用 0 兜底 */ }
|
|
204
|
+
results.push({ name, path: full, dir: cur.dir, root: cur.root, size, mtime })
|
|
205
|
+
}
|
|
206
|
+
} else if (cur.depth < MAX_DEPTH) {
|
|
207
|
+
stack.push({ dir: full, depth: cur.depth + 1, root: cur.root })
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
results.sort((a, b) => {
|
|
212
|
+
const ea = a.name.toLowerCase() === needle ? 1 : 0
|
|
213
|
+
const eb = b.name.toLowerCase() === needle ? 1 : 0
|
|
214
|
+
if (ea !== eb) return eb - ea
|
|
215
|
+
return (b.mtime || 0) - (a.mtime || 0)
|
|
216
|
+
})
|
|
217
|
+
return { query: raw, results, scanned, dirs, truncated, timedOut }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- Everything 索引搜索(优先):调用本机 Everything 的 HTTP JSON 接口 ----
|
|
221
|
+
// Everything 用 NTFS USN/MFT 索引,全盘搜索是毫秒级;没装/没开则返回 null,让上层回退到 searchFiles 遍历。
|
|
222
|
+
// 端口优先 8090(本机已配),再试默认 80 / 8080;命中后缓存端口。
|
|
223
|
+
const EVERYTHING_PORTS = [8090, 80, 8080]
|
|
224
|
+
let everythingPort = null
|
|
225
|
+
|
|
226
|
+
function httpGetJson(url, timeoutMs) {
|
|
227
|
+
return new Promise(resolve => {
|
|
228
|
+
let req
|
|
229
|
+
try {
|
|
230
|
+
req = http.get(url, res => {
|
|
231
|
+
let body = ''
|
|
232
|
+
res.on('data', d => { body += d })
|
|
233
|
+
res.on('end', () => { try { resolve(JSON.parse(body)) } catch { resolve(null) } })
|
|
234
|
+
res.on('error', () => resolve(null))
|
|
235
|
+
})
|
|
236
|
+
} catch { return resolve(null) }
|
|
237
|
+
req.on('error', () => resolve(null))
|
|
238
|
+
req.setTimeout(timeoutMs, () => { try { req.destroy() } catch { /* ignore */ } resolve(null) })
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function probeEverything(requireHits) {
|
|
243
|
+
// 缓存端口也必须验证存活:用户可能随时关掉 Everything,不能盲信缓存否则不会触发重新拉起
|
|
244
|
+
if (everythingPort) {
|
|
245
|
+
const j = await httpGetJson(`http://127.0.0.1:${everythingPort}/?json=1&search=&count=1`, 1500)
|
|
246
|
+
if (j && typeof j.totalResults === 'number' && (!requireHits || j.totalResults > 0)) return everythingPort
|
|
247
|
+
everythingPort = null // 已失效,清缓存,下面重新探测(必要时由 searchViaEverything 拉起)
|
|
248
|
+
}
|
|
249
|
+
for (const p of EVERYTHING_PORTS) {
|
|
250
|
+
const j = await httpGetJson(`http://127.0.0.1:${p}/?json=1&search=&count=1`, 1500)
|
|
251
|
+
if (j && typeof j.totalResults === 'number' && (!requireHits || j.totalResults > 0)) { everythingPort = p; return p }
|
|
252
|
+
}
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 首次启用 / 用户没开 Everything 时:后台拉起主程序,不弹出窗口(-minimized + windowsHide)。
|
|
257
|
+
function findEverythingExe() {
|
|
258
|
+
const cands = [
|
|
259
|
+
process.env.EVERYTHING_EXE,
|
|
260
|
+
'D:\\Everything\\Everything.exe',
|
|
261
|
+
'C:\\Program Files\\Everything\\Everything.exe',
|
|
262
|
+
'C:\\Program Files (x86)\\Everything\\Everything.exe',
|
|
263
|
+
]
|
|
264
|
+
for (const p of cands) { try { if (p && fs.existsSync(p)) return p } catch { /* ignore */ } }
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function spawnEverythingBackground() {
|
|
269
|
+
const exe = findEverythingExe()
|
|
270
|
+
if (!exe) return false
|
|
271
|
+
try {
|
|
272
|
+
// -startup:按 ini 的 minimize_to_tray=1 隐藏到托盘(-minimized 只是最小化到任务栏,仍占任务栏)
|
|
273
|
+
const child = spawn(exe, ['-startup'], { detached: true, stdio: 'ignore', windowsHide: true })
|
|
274
|
+
child.unref()
|
|
275
|
+
return true
|
|
276
|
+
} catch { return false }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function waitForEverything(maxWaitMs) {
|
|
280
|
+
const deadline = Date.now() + maxWaitMs
|
|
281
|
+
while (Date.now() < deadline) {
|
|
282
|
+
const p = await probeEverything(true) // 等到索引真的有命中,而不只是 HTTP 通了
|
|
283
|
+
if (p) return p
|
|
284
|
+
await new Promise(r => setTimeout(r, 800))
|
|
285
|
+
}
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function filetimeToMs(ft) {
|
|
290
|
+
const n = Number(ft)
|
|
291
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n / 10000 - 11644473600000) : 0
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function searchViaEverything(query, maxResults) {
|
|
295
|
+
let port = await probeEverything()
|
|
296
|
+
if (!port) {
|
|
297
|
+
// 首次启用 / 用户没开 Everything:后台拉起(-minimized + windowsHide,不显示窗口),最多等 10 秒索引就绪
|
|
298
|
+
if (spawnEverythingBackground()) port = await waitForEverything(10000)
|
|
299
|
+
if (!port) return null
|
|
300
|
+
}
|
|
301
|
+
// 用双引号包裹用户输入,避免 . * ? 空格等被 Everything 当作搜索语法解析
|
|
302
|
+
const wrapped = '"' + String(query).replace(/"/g, '') + '"'
|
|
303
|
+
const count = Math.max(1, Math.min(maxResults || 300, 1000))
|
|
304
|
+
const url = `http://127.0.0.1:${port}/?json=1&search=${encodeURIComponent(wrapped)}&count=${count}&path_column=1&size_column=1&date_modified_column=1`
|
|
305
|
+
const j = await httpGetJson(url, 8000)
|
|
306
|
+
if (!j || !Array.isArray(j.results)) return null
|
|
307
|
+
const results = j.results.map(x => {
|
|
308
|
+
const parent = x.path || ''
|
|
309
|
+
const full = parent ? path.join(parent, x.name || '') : (x.name || '')
|
|
310
|
+
return {
|
|
311
|
+
name: x.name || '', path: full, dir: parent,
|
|
312
|
+
isDir: x.type === 'folder', size: Number(x.size) || 0, mtime: filetimeToMs(x.date_modified),
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
return { query, results, total: j.totalResults || results.length, source: 'everything' }
|
|
316
|
+
}
|
|
317
|
+
|
|
168
318
|
async function loadConfig() {
|
|
169
319
|
const raw = await context.storage.read('config', 'prefs.json').catch(() => null)
|
|
170
320
|
if (!raw) return { roots: [], layout, sideW, recent, favorites, pinned, stash: undefined }
|
|
@@ -338,6 +488,17 @@ createPlugin({
|
|
|
338
488
|
return { name: row.name, ext: row.ext, kind: row.isDir ? 'dir' : kindOf(row.ext), size: row.size, mtime: row.mtime }
|
|
339
489
|
}),
|
|
340
490
|
|
|
491
|
+
// 全局文件搜索:有 Everything 走它的索引(秒级全盘),没有则回退 searchFiles 遍历
|
|
492
|
+
'preview.search': guard(async params => {
|
|
493
|
+
const q = String(params.query || '').trim()
|
|
494
|
+
if (q) {
|
|
495
|
+
const via = await searchViaEverything(q, 300)
|
|
496
|
+
if (via) return { ...via, scanned: null, dirs: null, truncated: via.total > via.results.length, timedOut: false }
|
|
497
|
+
}
|
|
498
|
+
const r = await searchFiles(q, activeRoots())
|
|
499
|
+
return { ...r, source: 'scan' }
|
|
500
|
+
}),
|
|
501
|
+
|
|
341
502
|
// ── stash 命令 ──────────────────────────────────────────────
|
|
342
503
|
|
|
343
504
|
'stash.list': guard(async () => stash.list()),
|
|
@@ -11,6 +11,19 @@ body.fm-layout, main.fm{height:820px;display:flex;flex-direction:column;padding:
|
|
|
11
11
|
.fm-head .fm-meta{color:var(--dock-muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;max-width:52vw}
|
|
12
12
|
.fm-head .fm-meta:empty{display:none}
|
|
13
13
|
|
|
14
|
+
/* 预览工具栏右侧的全局搜索框(在「📌 固定当前文件夹」右边) */
|
|
15
|
+
.search-box{display:flex;align-items:center;gap:4px;margin-left:auto;min-width:0;max-width:260px;flex:0 1 auto}
|
|
16
|
+
.search-box input[type=search]{width:180px;min-width:80px;height:26px;padding:0 9px;font:inherit;font-size:12px;color:var(--dock-fg);background:var(--dock-control);border:1px solid var(--dock-line);border-radius:999px;outline:none}
|
|
17
|
+
.search-box input[type=search]:focus{border-color:var(--dock-accent)}
|
|
18
|
+
.search-box #search-clear{margin:0;padding:2px 7px;border-radius:999px;flex:none}
|
|
19
|
+
.search-head{padding:6px 2px 8px;color:var(--dock-muted);font-size:12px}
|
|
20
|
+
/* 搜索结果行用独立类 srow,彻底避开 .filelist .row 的多套列宽/隐藏规则冲突 */
|
|
21
|
+
.search-list .srow{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(0,0.9fr) 74px;align-items:center;gap:8px;width:100%;text-align:left;border:0;background:transparent;border-radius:9px;padding:5px 9px;min-height:28px;cursor:pointer;font:inherit;color:inherit}
|
|
22
|
+
.search-list .srow:hover{background:var(--dock-control)}
|
|
23
|
+
.search-list .srow .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;text-align:left;color:var(--dock-fg);font-size:13px}
|
|
24
|
+
.search-list .srow .dt{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dock-muted);font-size:11px;text-align:left}
|
|
25
|
+
.search-list .srow .sz{text-align:right;color:var(--dock-muted);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
26
|
+
|
|
14
27
|
/* 错误条跨全宽 */
|
|
15
28
|
main.fm > #error{margin:0 8px 8px;flex:none}
|
|
16
29
|
|
|
@@ -71,6 +84,14 @@ body[data-resizing=true]{user-select:none;cursor:col-resize}
|
|
|
71
84
|
.stash-group>.stash-tag.active>.stash-tag-count{opacity:.8}
|
|
72
85
|
.stash-group>.stash-tag.active>.stash-tag-del{color:#1a1035;opacity:.75}
|
|
73
86
|
.stash-group>.stash-tag.active>.stash-tag-del:hover{color:#7a1010}
|
|
87
|
+
/* 拖动文件悬停到某个分组标签上:高亮该分组名,提示松手即落入此分组 */
|
|
88
|
+
.stash-group>.stash-tag.drop-hover{
|
|
89
|
+
color:#1a1035;background:var(--dock-accent);border-color:var(--dock-accent);
|
|
90
|
+
box-shadow:0 0 0 2px color-mix(in srgb,var(--dock-accent) 40%,transparent);
|
|
91
|
+
}
|
|
92
|
+
.stash-group>.stash-tag.drop-hover>.stash-tag-dot{background:#1a1035}
|
|
93
|
+
.stash-group>.stash-tag.drop-hover>.stash-tag-count{opacity:.85}
|
|
94
|
+
.stash-group>.stash-tag.drop-hover>.stash-tag-del{color:#1a1035;opacity:.75}
|
|
74
95
|
/* 右侧文件列表:已在暂存/分组里的文件 */
|
|
75
96
|
.filelist .row.stashed .nm{color:var(--dock-accent)}
|
|
76
97
|
.stash-badge{flex:none;font-size:10px;line-height:1;padding:2px 6px;border-radius:999px;
|
|
@@ -76,6 +76,10 @@
|
|
|
76
76
|
<button id="refresh" title="刷新">刷新</button>
|
|
77
77
|
<button id="fav-open" title="常用文件夹" class="ghost">★</button>
|
|
78
78
|
<button id="fav-pin-btn" title="固定当前文件夹" class="ghost" hidden>📌</button>
|
|
79
|
+
<div id="search-box" class="search-box">
|
|
80
|
+
<input id="search-input" type="search" placeholder="搜索所有磁盘…" autocomplete="off" spellcheck="false" aria-label="搜索所有磁盘里的文件" title="输入关键字后停顿或回车,检索「位置」里所有磁盘/根目录下的文件">
|
|
81
|
+
<button id="search-clear" class="ghost tiny" type="button" title="清除搜索" hidden>✕</button>
|
|
82
|
+
</div>
|
|
79
83
|
<!-- 浮层必须放在 .preview-toolbar 里面:它是 position:relative 的定位祖先,
|
|
80
84
|
放到 nav 外面会以 #preview-col 为基准、top:100% 直接掉到整个预览列下面看不见。 -->
|
|
81
85
|
<div id="fav-pop" class="fav-pop" hidden>
|
package/plugins/filemgr/page.js
CHANGED
|
@@ -1311,6 +1311,94 @@
|
|
|
1311
1311
|
guard(() => openPath(value))()
|
|
1312
1312
|
}
|
|
1313
1313
|
|
|
1314
|
+
// ---------------------------------------------------------------- 全局文件搜索
|
|
1315
|
+
// 固定当前文件夹按钮右侧的搜索框:停止输入 350ms 或回车后,检索所有已添加磁盘/根目录的文件。
|
|
1316
|
+
let searchTimer = null
|
|
1317
|
+
let searchSeq = 0
|
|
1318
|
+
|
|
1319
|
+
function twoUp(dir) {
|
|
1320
|
+
const parts = String(dir || '').split(/[\\/]+/).filter(Boolean)
|
|
1321
|
+
if (parts.length <= 2) return parts.join('\\')
|
|
1322
|
+
return '…\\' + parts.slice(-2).join('\\')
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function drawSearchResults(query, row) {
|
|
1326
|
+
const view = clear($('view'))
|
|
1327
|
+
const count = row ? row.results.length : 0
|
|
1328
|
+
let headText
|
|
1329
|
+
if (row && row.source === 'everything') {
|
|
1330
|
+
headText = `搜索「${query}」:找到 ${count} 项(Everything 索引,共匹配 ${row.total} 项)`
|
|
1331
|
+
if (row.truncated) headText += `,仅显示前 ${count} 项`
|
|
1332
|
+
} else {
|
|
1333
|
+
headText = `搜索「${query}」:找到 ${count} 项(扫描 ${row ? row.scanned : 0} 个文件)`
|
|
1334
|
+
if (row && row.truncated) headText += row.timedOut ? ',已达时间上限,结果可能不全' : ',已达数量上限,结果可能不全'
|
|
1335
|
+
}
|
|
1336
|
+
view.append(el('div', { class: 'search-head' }, [el('span', { text: headText })]))
|
|
1337
|
+
if (!row || !count) {
|
|
1338
|
+
view.append(el('p', { class: 'empty', text: row && row.scanned ? '没有匹配的文件' : '没有可搜索的磁盘或目录' }))
|
|
1339
|
+
return
|
|
1340
|
+
}
|
|
1341
|
+
const list = el('div', { class: 'filelist search-list' })
|
|
1342
|
+
for (const item of row.results) {
|
|
1343
|
+
list.append(el('button', {
|
|
1344
|
+
type: 'button', class: 'srow',
|
|
1345
|
+
title: item.path,
|
|
1346
|
+
onclick: guard(async () => {
|
|
1347
|
+
$('search-input').value = ''
|
|
1348
|
+
$('search-clear').hidden = true
|
|
1349
|
+
await openPath(item.path)
|
|
1350
|
+
}),
|
|
1351
|
+
}, [
|
|
1352
|
+
el('span', { class: 'nm', text: item.name }),
|
|
1353
|
+
el('span', { class: 'dt', text: twoUp(item.dir) }),
|
|
1354
|
+
el('span', { class: 'sz', text: bytes(item.size) }),
|
|
1355
|
+
]))
|
|
1356
|
+
}
|
|
1357
|
+
view.append(list)
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function wireSearch() {
|
|
1361
|
+
const box = $('search-input')
|
|
1362
|
+
const clearBtn = $('search-clear')
|
|
1363
|
+
if (!box) return
|
|
1364
|
+
const run = async () => {
|
|
1365
|
+
const query = box.value.trim()
|
|
1366
|
+
if (clearBtn) clearBtn.hidden = !query
|
|
1367
|
+
if (!query) {
|
|
1368
|
+
// 清空搜索:恢复当前目录/文件视图
|
|
1369
|
+
if (state.file) guard(() => openFile(state.file.path))()
|
|
1370
|
+
else if (state.path) guard(() => openDir(state.path))()
|
|
1371
|
+
else drawDir()
|
|
1372
|
+
return
|
|
1373
|
+
}
|
|
1374
|
+
const seq = ++searchSeq
|
|
1375
|
+
say('正在搜索…')
|
|
1376
|
+
bad('')
|
|
1377
|
+
try {
|
|
1378
|
+
const row = await call('preview.search', { query })
|
|
1379
|
+
if (seq !== searchSeq) return
|
|
1380
|
+
say('')
|
|
1381
|
+
drawSearchResults(query, row)
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
if (seq !== searchSeq) return
|
|
1384
|
+
say('')
|
|
1385
|
+
bad(error && error.message ? error.message : String(error))
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
box.addEventListener('input', () => {
|
|
1389
|
+
const query = box.value.trim()
|
|
1390
|
+
if (clearBtn) clearBtn.hidden = !query
|
|
1391
|
+
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
|
1392
|
+
if (!query) { run(); return }
|
|
1393
|
+
searchTimer = setTimeout(() => { searchTimer = null; run() }, 350)
|
|
1394
|
+
})
|
|
1395
|
+
box.addEventListener('keydown', event => {
|
|
1396
|
+
if (event.key === 'Enter') { event.preventDefault(); if (searchTimer) { clearTimeout(searchTimer); searchTimer = null } run() }
|
|
1397
|
+
else if (event.key === 'Escape') { box.value = ''; if (clearBtn) clearBtn.hidden = true; run() }
|
|
1398
|
+
})
|
|
1399
|
+
if (clearBtn) clearBtn.onclick = () => { box.value = ''; clearBtn.hidden = true; run() }
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1314
1402
|
// ---------------------------------------------------------------- 启动
|
|
1315
1403
|
|
|
1316
1404
|
async function bootPreview() {
|
|
@@ -1472,6 +1560,7 @@
|
|
|
1472
1560
|
|
|
1473
1561
|
const summary = el('summary', {
|
|
1474
1562
|
class: 'stash-tag' + (active ? ' active' : ''),
|
|
1563
|
+
dataset: { group: bucket.key }, // 供拖拽命中:拖到此标签上即高亮该组名并落入该分组
|
|
1475
1564
|
title: `${bucket.label} · ${bucket.items.length} 项(点箭头/整条折叠;点右侧漏斗图标只在看本组与全部间切换)`,
|
|
1476
1565
|
})
|
|
1477
1566
|
// 单击整条标签 = 折叠/展开(summary 默认行为,不拦截)。
|
|
@@ -1765,11 +1854,12 @@
|
|
|
1765
1854
|
// 批量加入(框选后拖进暂存区走这一条)。
|
|
1766
1855
|
// 后端 stash.add 本来就吃 paths 数组,所以只是把多行一次性递过去;
|
|
1767
1856
|
// 逐个报告"加了几项/几项重复",比只弹一个含糊的 toast 更好懂。
|
|
1768
|
-
async function addManyToStash(paths) {
|
|
1857
|
+
async function addManyToStash(paths, group) {
|
|
1769
1858
|
const list = (paths || []).filter(Boolean)
|
|
1770
1859
|
if (!list.length) return
|
|
1771
|
-
|
|
1772
|
-
const
|
|
1860
|
+
// 显式传入的分组(拖到某个分组标签上)优先;未指定时回退到当前视图分组。
|
|
1861
|
+
const targetGroup = group === undefined ? (state.view === 'all' ? '' : state.view) : group
|
|
1862
|
+
const row = await callSoft('stash.add', { paths: list, group: targetGroup })
|
|
1773
1863
|
if (row && row.ok) {
|
|
1774
1864
|
const added = Array.isArray(row.items) ? row.items.length : 0
|
|
1775
1865
|
const dup = list.length - added
|
|
@@ -1800,6 +1890,7 @@
|
|
|
1800
1890
|
let moved = false
|
|
1801
1891
|
let suppressClick = false
|
|
1802
1892
|
let ghost = null
|
|
1893
|
+
let dropGroup = undefined // 拖拽时命中的分组标签 key(undefined=未命中,回退当前视图)
|
|
1803
1894
|
|
|
1804
1895
|
const rows = () => Array.from(list.querySelectorAll('.row[data-path]'))
|
|
1805
1896
|
|
|
@@ -1836,6 +1927,8 @@
|
|
|
1836
1927
|
if (ghost) { ghost.remove(); ghost = null }
|
|
1837
1928
|
for (const node of list.querySelectorAll('.row.drag-src')) node.classList.remove('drag-src')
|
|
1838
1929
|
if (stashCol) stashCol.classList.remove('drop-target')
|
|
1930
|
+
dropGroup = undefined
|
|
1931
|
+
for (const node of document.querySelectorAll('.stash-tag.drop-hover')) node.classList.remove('drop-hover')
|
|
1839
1932
|
}
|
|
1840
1933
|
|
|
1841
1934
|
list.addEventListener('pointerdown', event => {
|
|
@@ -1920,8 +2013,14 @@
|
|
|
1920
2013
|
} else if (mode === 'drag' && ghost) {
|
|
1921
2014
|
ghost.style.left = `${event.clientX + 14}px`
|
|
1922
2015
|
ghost.style.top = `${event.clientY + 14}px`
|
|
1923
|
-
const
|
|
2016
|
+
const under = document.elementFromPoint(event.clientX, event.clientY)
|
|
2017
|
+
const over = stashCol && under?.closest('#stash-col')
|
|
1924
2018
|
if (stashCol) stashCol.classList.toggle('drop-target', !!over)
|
|
2019
|
+
// 命中哪个分组标签,就高亮哪个分组名并把它作为落点;未命中分组标签则回退当前视图分组。
|
|
2020
|
+
const hitTag = under && under.closest('.stash-tag[data-group]')
|
|
2021
|
+
dropGroup = hitTag ? hitTag.dataset.group : undefined
|
|
2022
|
+
for (const node of document.querySelectorAll('.stash-tag.drop-hover')) node.classList.remove('drop-hover')
|
|
2023
|
+
if (hitTag) hitTag.classList.add('drop-hover')
|
|
1925
2024
|
}
|
|
1926
2025
|
event.preventDefault()
|
|
1927
2026
|
})
|
|
@@ -1930,6 +2029,7 @@
|
|
|
1930
2029
|
if (mode === 'idle') return
|
|
1931
2030
|
const was = mode
|
|
1932
2031
|
const paths = dragPaths.slice()
|
|
2032
|
+
const group = dropGroup // 松手前记录命中的分组(reset 会清空)
|
|
1933
2033
|
const dropped = was === 'drag' && !!(
|
|
1934
2034
|
stashCol && document.elementFromPoint(event.clientX, event.clientY)?.closest('#stash-col')
|
|
1935
2035
|
)
|
|
@@ -1940,7 +2040,7 @@
|
|
|
1940
2040
|
setTimeout(() => { suppressClick = false }, 0)
|
|
1941
2041
|
}
|
|
1942
2042
|
reset()
|
|
1943
|
-
if (dropped && paths.length) await addManyToStash(paths)
|
|
2043
|
+
if (dropped && paths.length) await addManyToStash(paths, group)
|
|
1944
2044
|
}
|
|
1945
2045
|
|
|
1946
2046
|
list.addEventListener('pointerup', event => { finish(event) })
|
|
@@ -2034,6 +2134,7 @@
|
|
|
2034
2134
|
wireFmSplit()
|
|
2035
2135
|
wireSplitter()
|
|
2036
2136
|
wireBrowseSelect()
|
|
2137
|
+
wireSearch()
|
|
2037
2138
|
drawSide()
|
|
2038
2139
|
// 两套 boot 只读数据、不互斥;不能用 guard(它会因共享 state.busy 把第二个启动挡掉)。
|
|
2039
2140
|
const bootSafe = fn => Promise.resolve().then(fn).catch(error => bad(error && error.message ? error.message : String(error)))
|
package/plugins/filemgr/sdk.mjs
CHANGED
|
@@ -3,7 +3,7 @@ const CATEGORIES = new Set(['credentials','roster','keys','config','exports','ba
|
|
|
3
3
|
const DEFAULT_LIMIT = 1024 * 1024;
|
|
4
4
|
export function createPlugin({ id, input = process.stdin, output = process.stdout,
|
|
5
5
|
activate = async () => {}, deactivate = async () => {}, onEvent = async () => {}, commands = {},
|
|
6
|
-
onError = () => {}, maxLine = DEFAULT_LIMIT, requestTimeout =
|
|
6
|
+
onError = () => {}, maxLine = DEFAULT_LIMIT, requestTimeout = 12000 } = {}) {
|
|
7
7
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id ?? '')) throw new Error('插件 ID 格式无效');
|
|
8
8
|
if (!Number.isSafeInteger(maxLine) || maxLine < 128 || maxLine > DEFAULT_LIMIT) throw new Error('协议帧大小无效');
|
|
9
9
|
if (!Number.isSafeInteger(requestTimeout) || requestTimeout < 1 || requestTimeout > 30000) throw new Error('宿主调用时限无效');
|
|
@@ -94,7 +94,10 @@ export function createPlugin({ id, input = process.stdin, output = process.stdou
|
|
|
94
94
|
if (!row || typeof row !== 'object' || Array.isArray(row)) throw new Error('插件协议应为 JSON 对象');
|
|
95
95
|
if (!Object.hasOwn(row,'method')) {
|
|
96
96
|
const waiter = pending.get(row.id);
|
|
97
|
-
|
|
97
|
+
// 迟到/重复的宿主响应(原请求已因超时被清理):静默丢弃,绝不让整个插件会话崩溃。
|
|
98
|
+
// 原实现此处 throw 会经 receive() → close()+onError() 杀死插件;任何一次超过
|
|
99
|
+
// requestTimeout 的存储调用,其迟到响应都会触发。调用方早已通过超时 reject 感知失败。
|
|
100
|
+
if (!waiter) return;
|
|
98
101
|
pending.delete(row.id); clearTimeout(waiter.timer);
|
|
99
102
|
if (Object.hasOwn(row,'result') === Object.hasOwn(row,'error')) return waiter.reject(new Error('宿主响应结构无效'));
|
|
100
103
|
if (row.error) waiter.reject(new Error('宿主存储操作失败')); else waiter.resolve(row.result);
|