fish-dock-plugin-filemgr 1.0.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fish-dock-plugin-filemgr",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "FISH Dock 文件管理插件:目录浏览 + 文件预览(Markdown / 图片 / Office / 代码)+ 文件暂存中转站,兼容 FISH Dock v1.2.3 及更早官方发行版。",
5
5
  "type": "module",
6
6
  "files": [
package/plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "schema": 1,
3
3
  "id": "jassdingk.filemgr",
4
4
  "name": "文件管理",
5
- "version": "1.0.6",
5
+ "version": "1.0.7",
6
6
  "hostApi": 1,
7
7
  "platforms": [
8
8
  "windows-x86_64"
@@ -27,15 +27,15 @@
27
27
  ],
28
28
  "files": {
29
29
  "bridge.js": "f38f93994e3cb2b71f2ff1810b3f68fb42f4261550f8ab01b0bfa3eecd5f94ee",
30
- "package.json": "d2b066289c54ebfb40da8591f8dbaf261c8cbd0dcb80ea03f5206c7f0aa50e35",
31
- "plugins/filemgr/backend.mjs": "b448aa688ff11f2abc4c5281b4714646ed6c384473d873cfe431a8e64b066d26",
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": "79b878b29499284935d71dfa877716232f85ed40d864437382a059fa8ced0479",
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": "3cfda31be29cd23b1e0f7f6004b6c03522c3526b78f9df4711e320962a660831",
38
- "plugins/filemgr/page.js": "74a7ccae18f7857be9656f14db07c2bddbf4591fff57a7eb16722ee4128e739d",
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",
@@ -43,5 +43,8 @@
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
 
@@ -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>
@@ -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() {
@@ -2046,6 +2134,7 @@
2046
2134
  wireFmSplit()
2047
2135
  wireSplitter()
2048
2136
  wireBrowseSelect()
2137
+ wireSearch()
2049
2138
  drawSide()
2050
2139
  // 两套 boot 只读数据、不互斥;不能用 guard(它会因共享 state.busy 把第二个启动挡掉)。
2051
2140
  const bootSafe = fn => Promise.resolve().then(fn).catch(error => bad(error && error.message ? error.message : String(error)))