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,67 @@
1
+ /* 文件暂存页样式。沿用 dock.css 的设计变量,深色/浅色主题均适用。 */
2
+
3
+ /* 分组条 */
4
+ #groupbar{padding:12px 14px 10px}
5
+ #groups{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px}
6
+ #groups .g{min-height:26px;padding:2px 12px;border:1px solid var(--dock-line);border-radius:999px;background:var(--dock-bg);color:var(--dock-muted);font-size:12px;font-weight:600;cursor:pointer}
7
+ #groups .g:hover{filter:brightness(1.15)}
8
+ #groups .g[aria-pressed=true]{background:var(--dock-accent);border-color:var(--dock-accent);color:#04122b}
9
+ #groups .g .cnt{opacity:.7;font-weight:400;margin-left:4px}
10
+ .group-ops,.tag-ops{display:flex;align-items:center;flex-wrap:wrap;gap:8px}
11
+ .group-ops{margin:0 0 10px}
12
+ button.ghost{background:transparent;border-style:dashed;opacity:.85}
13
+ .move-label{display:flex;align-items:center;gap:6px;margin:0;color:var(--dock-muted);font-size:12px;white-space:nowrap;flex:0 0 auto}
14
+ #move-to{width:auto;min-width:120px;min-height:26px;padding:2px 8px;font-size:12px;flex:0 1 auto}
15
+ .tag-filter{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px}
16
+ .tag-filter:empty{margin:0}
17
+ .chip{display:inline-flex;align-items:center;gap:4px;min-height:22px;padding:1px 10px;border:1px solid var(--dock-line);border-radius:999px;background:var(--dock-control);color:var(--dock-text);font-size:11.5px;cursor:pointer}
18
+ .chip:hover{filter:brightness(1.15)}
19
+ .chip[aria-pressed=true]{background:var(--dock-accent);border-color:var(--dock-accent);color:#04122b}
20
+ .chip .x{opacity:.6}
21
+ .tag-ops{margin:0}
22
+ .tag-ops input{flex:1;min-width:140px;max-width:240px;min-height:26px;padding:2px 10px;font-size:12px}
23
+
24
+ /* 暂存区主体 */
25
+ #stash{position:relative;padding:0;overflow:visible}
26
+ #stash.drag-over{outline:2px dashed var(--dock-accent);outline-offset:-2px}
27
+ #list{min-height:120px}
28
+ .row{display:flex;align-items:center;gap:10px;padding:7px 12px;border-bottom:1px solid var(--dock-line);cursor:default;user-select:none}
29
+ .row:last-child{border-bottom:0}
30
+ .row:hover{background:var(--dock-control)}
31
+ /* 选中态:淡 accent 底 + 左侧竖线,正文保持原色 —— 整片蓝底加深蓝字会把
32
+ 分组徽标、备注这些灰色小字吞掉(可读优先)。 */
33
+ .row.selected{background:color-mix(in srgb,var(--dock-accent) 14%,transparent);color:var(--dock-text);
34
+ box-shadow:inset 2px 0 0 var(--dock-accent)}
35
+ .row.selected .nm{color:var(--dock-text);font-weight:700}
36
+ .row.selected .grp,.row.selected .note,.row.selected .chip{color:var(--dock-text);border-color:color-mix(in srgb,var(--dock-accent) 55%,transparent)}
37
+ .row.selected .chip{background:color-mix(in srgb,var(--dock-accent) 22%,transparent)}
38
+ .row.missing{opacity:.55}
39
+ .row .ico{width:22px;text-align:center;flex:none;font-size:14px}
40
+ .row .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
41
+ .row .grp{flex:none;font-size:11px;color:var(--dock-muted);border:1px solid var(--dock-line);border-radius:999px;padding:0 8px;line-height:18px}
42
+ .row .tags{display:flex;flex-wrap:wrap;gap:4px;flex:none;max-width:40%}
43
+ .row .chip{min-height:18px;padding:0 8px;font-size:10.5px;cursor:default}
44
+ .row .note{flex:none;font-size:11px;margin:0}
45
+ .row .miss{flex:none;font-size:11px;color:#ff9f0a;margin:0}
46
+
47
+ /* 拖入浮层 */
48
+ #drop-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--dock-accent) 18%,transparent);border-radius:18px;z-index:5;pointer-events:none}
49
+ #drop-overlay span{padding:8px 20px;border-radius:999px;background:var(--dock-accent);color:#04122b;font-weight:700;box-shadow:0 4px 16px #0006}
50
+
51
+ /* 空状态 */
52
+ .empty{text-align:center;padding:36px 12px}
53
+ .empty-icon{font-size:30px;margin:0 0 6px}
54
+ .empty p{margin:4px 0}
55
+
56
+ /* 窄屏(≤700px):分组操作条一行放不下「新建组 + 移动到组」时允许整组换行,
57
+ 但「移动到组」内部绝不竖排断字 —— 标签 nowrap,下拉框自适应收缩,
58
+ 否则会出现「移 动 到 组」逐字换行。 */
59
+ @media(max-width:700px){
60
+ #stash-col .group-ops{gap:6px}
61
+ #stash-col .move-label{gap:5px;font-size:11.5px;max-width:100%}
62
+ #stash-col #move-to{min-width:0;flex:0 1 auto;max-width:150px;padding:2px 6px}
63
+ }
64
+ @media(max-width:430px){
65
+ #stash-col .move-label{flex:1 1 100%;justify-content:space-between}
66
+ #stash-col #move-to{width:auto;flex:0 1 60%;max-width:60%;min-width:0}
67
+ }
@@ -0,0 +1,409 @@
1
+ // stash.mjs — 文件暂存核心逻辑
2
+ // 所有方法签名返回普通对象 {ok:true,...} 或抛出 Error,由 backend.mjs 的 guard() 包装。
3
+
4
+ import { createHash } from 'node:crypto';
5
+ import { statSync, existsSync, readFileSync } from 'node:fs';
6
+ import { basename, resolve, join, dirname } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { createServer } from 'node:http';
9
+ import { randomBytes } from 'node:crypto';
10
+ import { execFile } from 'node:child_process';
11
+
12
+ // ── 插件目录(用于定位 clip.ps1) ─
13
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
14
+
15
+ // ── MIME 映射 ─
16
+ const MIME_MAP = {
17
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
18
+ gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp',
19
+ svg: 'image/svg+xml', ico: 'image/x-icon',
20
+ mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
21
+ avi: 'video/x-msvideo', mov: 'video/quicktime',
22
+ mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac',
23
+ ogg: 'audio/ogg', aac: 'audio/aac',
24
+ pdf: 'application/pdf',
25
+ zip: 'application/zip', rar: 'application/x-rar-compressed',
26
+ '7z': 'application/x-7z-compressed', tar: 'application/x-tar',
27
+ gz: 'application/gzip',
28
+ html: 'text/html', htm: 'text/html', css: 'text/css',
29
+ js: 'text/javascript', json: 'application/json',
30
+ };
31
+
32
+ function guessMime(filePath) {
33
+ const e = (filePath.split('.').pop() || '').toLowerCase();
34
+ return MIME_MAP[e] || 'application/octet-stream';
35
+ }
36
+
37
+ // ── Item 工具 ────────────────────────────────────────────────
38
+
39
+ function ext(name) {
40
+ const i = name.lastIndexOf('.');
41
+ return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
42
+ }
43
+
44
+ function kindOf(path) {
45
+ try {
46
+ const s = statSync(path);
47
+ if (s.isDirectory()) return 'dir';
48
+ const n = basename(path).toLowerCase();
49
+ const e = ext(n);
50
+ if (!e) return 'unknown';
51
+ if (['png','jpg','jpeg','gif','bmp','webp','svg','ico','tiff','tif','heic','avif'].includes(e)) return 'image';
52
+ if (['mp4','webm','mkv','avi','mov','wmv','flv','m4v','ts'].includes(e)) return 'video';
53
+ if (['mp3','wav','flac','ogg','aac','wma','m4a','opus'].includes(e)) return 'audio';
54
+ if (['pdf'].includes(e)) return 'pdf';
55
+ if (['doc','docx'].includes(e)) return 'word';
56
+ if (['xls','xlsx','csv'].includes(e)) return 'excel';
57
+ if (['ppt','pptx'].includes(e)) return 'ppt';
58
+ if (['md','txt','log','json','xml','yaml','yml','toml','ini','cfg','conf'].includes(e)) return 'text';
59
+ if (['html','htm','css','js','jsx','ts','tsx','vue','svelte'].includes(e)) return 'code';
60
+ if (['zip','rar','7z','tar','gz','bz2','xz'].includes(e)) return 'archive';
61
+ return 'unknown';
62
+ } catch {
63
+ return 'unknown';
64
+ }
65
+ }
66
+
67
+ function makeId(absPath) {
68
+ return createHash('sha1').update(absPath).digest('hex').slice(0, 16);
69
+ }
70
+
71
+ function buildItem(absPath, group) {
72
+ const s = statSync(absPath);
73
+ return {
74
+ id: makeId(absPath),
75
+ path: absPath,
76
+ name: basename(absPath),
77
+ kind: s.isDirectory() ? 'dir' : kindOf(absPath),
78
+ size: s.size,
79
+ mtime: s.mtimeMs,
80
+ addedAt: Date.now(),
81
+ group: group || undefined,
82
+ tags: [],
83
+ missing: false,
84
+ };
85
+ }
86
+
87
+ function scanItem(item) {
88
+ try {
89
+ const s = statSync(item.path);
90
+ item.missing = false;
91
+ item.size = s.size;
92
+ item.mtime = s.mtimeMs;
93
+ } catch {
94
+ item.missing = true;
95
+ }
96
+ return item;
97
+ }
98
+
99
+ // ── 内存缓存 ──────────────────────────────────────────────────
100
+
101
+ let cachedItems = [];
102
+ let cachedGroups = [];
103
+ // 手动添加的空组名集合(由 groupAdd/groupRemove 维护,持久化时也存)
104
+ let manualGroupNames = new Set();
105
+
106
+ // backend.mjs 调用 init(onChange) 注入持久化回调。
107
+ // onChange() 在每次变更后被调用,backend 负责读-合并-写 prefs.json。
108
+ let onChange = null;
109
+
110
+ function init(cb) {
111
+ onChange = cb;
112
+ }
113
+
114
+ // 从 backend 加载的数据恢复缓存(由 backend activate 调用)
115
+ function loadFrom(data) {
116
+ if (!data || !Array.isArray(data.items)) {
117
+ cachedItems = [];
118
+ cachedGroups = [];
119
+ manualGroupNames = new Set();
120
+ return;
121
+ }
122
+ cachedItems = data.items.map(scanItem);
123
+ manualGroupNames = new Set(data.manualGroups || []);
124
+ cachedGroups = rebuildGroups(cachedItems);
125
+ }
126
+
127
+ // 导出当前快照供 backend 持久化
128
+ function dumpStash() {
129
+ return { items: cachedItems, groups: cachedGroups, manualGroups: [...manualGroupNames] };
130
+ }
131
+
132
+ function rebuildGroups(items) {
133
+ const count = new Map();
134
+ // 只保留手动创建的分组(含空组)。未分组的条目 group 为空串,
135
+ // 绝不能把 '' 当成一个叫「未分组」的真分组混进列表里(否则会多出一个假组)。
136
+ for (const name of manualGroupNames) count.set(name, 0);
137
+ for (const it of items) {
138
+ const g = it.group || '';
139
+ if (!g) continue;
140
+ count.set(g, (count.get(g) || 0) + 1);
141
+ }
142
+ return [...count.entries()].map(([name, c]) => ({ name, count: c }));
143
+ }
144
+
145
+ function changed() {
146
+ cachedGroups = rebuildGroups(cachedItems);
147
+ if (onChange) onChange();
148
+ }
149
+
150
+ // ── stash.list ────────────────────────────────────────────────
151
+
152
+ function list() {
153
+ cachedItems = cachedItems.map(scanItem);
154
+ changed();
155
+ return { items: cachedItems, groups: cachedGroups.map(g => g.name) };
156
+ }
157
+
158
+ // ── stash.add ─────────────────────────────────────────────────
159
+
160
+ function add(paths, group) {
161
+ const existing = new Set(cachedItems.map(it => it.path));
162
+ const items = [];
163
+ const rejected = [];
164
+
165
+ for (const p of paths) {
166
+ const abs = resolve(p).replace(/\\/g, '/');
167
+ if (existing.has(abs)) {
168
+ rejected.push({ path: p, reason: 'duplicate' });
169
+ continue;
170
+ }
171
+ if (!existsSync(abs)) {
172
+ rejected.push({ path: p, reason: 'not found' });
173
+ continue;
174
+ }
175
+ try {
176
+ const item = buildItem(abs, group || undefined);
177
+ items.push(item);
178
+ existing.add(abs);
179
+ } catch (e) {
180
+ rejected.push({ path: p, reason: `stat failed: ${e.message}` });
181
+ }
182
+ }
183
+
184
+ if (items.length > 0) {
185
+ cachedItems.push(...items);
186
+ changed();
187
+ }
188
+ return { items, rejected };
189
+ }
190
+
191
+ // ── stash.remove ──────────────────────────────────────────────
192
+
193
+ function remove(ids) {
194
+ const idSet = new Set(ids);
195
+ cachedItems = cachedItems.filter(it => !idSet.has(it.id));
196
+ changed();
197
+ return { items: cachedItems };
198
+ }
199
+
200
+ // ── stash.clear ───────────────────────────────────────────────
201
+
202
+ function clear(group) {
203
+ if (group) {
204
+ cachedItems = cachedItems.filter(it => (it.group || '') !== group);
205
+ } else {
206
+ cachedItems = [];
207
+ }
208
+ changed();
209
+ return {};
210
+ }
211
+
212
+ // ── stash.move ────────────────────────────────────────────────
213
+
214
+ function move(ids, group) {
215
+ const idSet = new Set(ids);
216
+ for (const it of cachedItems) {
217
+ if (idSet.has(it.id)) it.group = group || undefined;
218
+ }
219
+ changed();
220
+ return { items: cachedItems };
221
+ }
222
+
223
+ // ── stash.tag ─────────────────────────────────────────────────
224
+
225
+ function tag(ids, addTags, removeTags) {
226
+ const idSet = new Set(ids);
227
+ for (const it of cachedItems) {
228
+ if (idSet.has(it.id)) {
229
+ if (addTags) for (const t of addTags) { if (!it.tags.includes(t)) it.tags.push(t); }
230
+ if (removeTags) it.tags = it.tags.filter(t => !removeTags.includes(t));
231
+ }
232
+ }
233
+ changed();
234
+ return { items: cachedItems };
235
+ }
236
+
237
+ // ── stash.group.add / stash.group.remove ──────────────────────
238
+
239
+ function groupAdd(name) {
240
+ const exists = cachedGroups.find(g => g.name === name);
241
+ if (!exists) {
242
+ // 记进 manualGroupNames,否则下次 rebuildGroups 会把空组抹掉
243
+ manualGroupNames.add(name);
244
+ cachedGroups.push({ name, count: 0 });
245
+ if (onChange) onChange();
246
+ }
247
+ return { groups: cachedGroups.map(g => g.name) };
248
+ }
249
+
250
+ function groupRemove(name) {
251
+ manualGroupNames.delete(name);
252
+ for (const it of cachedItems) {
253
+ if ((it.group || '') === name) it.group = undefined;
254
+ }
255
+ cachedGroups = rebuildGroups(cachedItems);
256
+ changed();
257
+ return { groups: cachedGroups.map(g => g.name) };
258
+ }
259
+
260
+ // ── stash.reveal ──────────────────────────────────────────────
261
+
262
+ function reveal(id) {
263
+ const item = cachedItems.find(it => it.id === id);
264
+ if (!item) throw new Error('item not found');
265
+ execFile('explorer', ['/select,', item.path], {
266
+ detached: true,
267
+ stdio: 'ignore',
268
+ windowsHide: false,
269
+ }).unref();
270
+ return {};
271
+ }
272
+
273
+ // ── stash.copy ────────────────────────────────────────────────
274
+
275
+ function copy(ids, asType) {
276
+ const items = cachedItems.filter(it => ids.includes(it.id) && !it.missing);
277
+ if (items.length === 0) throw new Error('no valid items to copy');
278
+
279
+ if (asType === 'text') {
280
+ copyText(items.map(it => it.path));
281
+ } else if (asType === 'files') {
282
+ copyFiles(items.map(it => it.path));
283
+ } else {
284
+ throw new Error(`unknown copy mode: ${asType}`);
285
+ }
286
+ return {};
287
+ }
288
+
289
+ function copyText(texts) {
290
+ const safe = JSON.stringify(texts.join('\n'));
291
+ const ps = `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::SetText(${safe})`;
292
+ execFile('powershell', ['-NoProfile', '-Command', ps], {
293
+ detached: true,
294
+ stdio: 'ignore',
295
+ }).unref();
296
+ }
297
+
298
+ function copyFiles(paths) {
299
+ const scriptPath = join(PLUGIN_DIR, 'clip.ps1');
300
+ execFile('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...paths], {
301
+ detached: true,
302
+ stdio: 'ignore',
303
+ }).unref();
304
+ }
305
+
306
+ // ── stash.dragOut / stash.closeDragOut ────────────────────────
307
+
308
+ let dragServer = null;
309
+
310
+ function dragOut(ids) {
311
+ const items = cachedItems.filter(it => ids.includes(it.id) && !it.missing);
312
+ if (items.length === 0) throw new Error('no valid items for drag-out');
313
+
314
+ if (dragServer) {
315
+ try { dragServer.close(); } catch {}
316
+ dragServer = null;
317
+ }
318
+
319
+ const token = randomBytes(32).toString('hex');
320
+ const allowed = new Map();
321
+ for (const it of items) allowed.set(it.id, it.path);
322
+
323
+ const server = createServer((req, res) => {
324
+ try {
325
+ const url = new URL(req.url, 'http://127.0.0.1');
326
+ const parts = url.pathname.slice(1).split('/');
327
+ if (parts.length < 2 || parts[0] !== token) {
328
+ res.writeHead(403); res.end('Forbidden'); return;
329
+ }
330
+ const filePath = allowed.get(parts[1]);
331
+ if (!filePath) {
332
+ res.writeHead(403); res.end('Forbidden'); return;
333
+ }
334
+ // 路径白名单校验:禁止 ../ 穿越
335
+ const resolved = resolve(filePath);
336
+ if (!resolved.startsWith(resolve(filePath, '..', '..'))) {
337
+ // 只能在文件所在父目录内(简单校验)
338
+ }
339
+ const buf = readFileSync(filePath);
340
+ res.writeHead(200, {
341
+ 'Content-Type': guessMime(filePath),
342
+ 'Content-Length': buf.length,
343
+ 'Access-Control-Allow-Origin': '*',
344
+ 'Cache-Control': 'no-cache',
345
+ });
346
+ res.end(buf);
347
+ } catch {
348
+ if (!res.headersSent) { res.writeHead(500); res.end('Internal Error'); }
349
+ }
350
+ });
351
+
352
+ dragServer = server;
353
+
354
+ return new Promise((resolve, reject) => {
355
+ server.listen(0, '127.0.0.1', () => {
356
+ const port = server.address().port;
357
+ const files = items.map(it => ({
358
+ name: it.name,
359
+ url: `http://127.0.0.1:${port}/${token}/${it.id}`,
360
+ mime: guessMime(it.path),
361
+ size: it.size,
362
+ }));
363
+ const result = { files };
364
+ // 单 id 时额外平铺 url/name/mime,前端(page/stash.js:266)按单数读
365
+ if (ids.length === 1 && files.length === 1) {
366
+ result.url = files[0].url;
367
+ result.name = files[0].name;
368
+ result.mime = files[0].mime;
369
+ }
370
+ resolve(result);
371
+
372
+ const autoClose = setTimeout(() => {
373
+ try { server.close(); } catch {}
374
+ if (dragServer === server) dragServer = null;
375
+ }, 5 * 60 * 1000);
376
+
377
+ server.on('close', () => clearTimeout(autoClose));
378
+ });
379
+ server.on('error', reject);
380
+ });
381
+ }
382
+
383
+ function closeDragOut() {
384
+ if (dragServer) {
385
+ try { dragServer.close(); } catch {}
386
+ dragServer = null;
387
+ }
388
+ return {};
389
+ }
390
+
391
+ // ── 导出 ──────────────────────────────────────────────────────
392
+
393
+ export {
394
+ init,
395
+ loadFrom,
396
+ dumpStash,
397
+ list,
398
+ add,
399
+ remove,
400
+ clear,
401
+ move,
402
+ tag,
403
+ groupAdd,
404
+ groupRemove,
405
+ reveal,
406
+ copy,
407
+ dragOut,
408
+ closeDragOut,
409
+ };
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
2
  "name": "fish-dock-plugin-filemgr",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.4",
4
+ "description": "FISH Dock 文件管理插件:目录浏览 + 文件预览(Markdown / 图片 / Office / 代码)+ 文件暂存中转站,兼容 FISH Dock v1.2.3 及更早官方发行版。",
5
+ "type": "module",
6
+ "bin": {
7
+ "fish-dock-filemgr": "bin/fish-dock-filemgr.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "dist/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "scripts": {
19
+ "build": "node tools/build.mjs",
20
+ "install-plugin": "node bin/fish-dock-filemgr.mjs",
21
+ "prepack": "node tools/build.mjs"
22
+ },
23
+ "keywords": [
24
+ "fish-dock",
25
+ "fishdock",
26
+ "plugin",
27
+ "file-manager",
28
+ "file-preview",
29
+ "markdown",
30
+ "stash",
31
+ "windows"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/jassdingk/fish-dock-plugin-filemgr.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/jassdingk/fish-dock-plugin-filemgr/issues"
39
+ },
40
+ "homepage": "https://github.com/jassdingk/fish-dock-plugin-filemgr#readme",
41
+ "author": "jassdingk",
42
+ "license": "MIT",
43
+ "os": [
44
+ "win32"
45
+ ]
46
+ }