fish-dock-plugin-filemgr 0.0.0-stage → 1.0.5
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/README.md +62 -3
- package/bridge.js +53 -0
- package/package.json +36 -4
- package/plugin.json +47 -0
- package/plugins/filemgr/FolderPicker.ps1 +260 -0
- package/plugins/filemgr/backend.mjs +448 -0
- package/plugins/filemgr/clip.ps1 +40 -0
- package/plugins/filemgr/dock.css +4 -0
- package/plugins/filemgr/filemgr.css +271 -0
- package/plugins/filemgr/page.css +218 -0
- package/plugins/filemgr/page.html +168 -0
- package/plugins/filemgr/page.js +2042 -0
- package/plugins/filemgr/pick-files.ps1 +33 -0
- package/plugins/filemgr/pick-folder.ps1 +34 -0
- package/plugins/filemgr/preview.mjs +456 -0
- package/plugins/filemgr/sdk.mjs +123 -0
- package/plugins/filemgr/stash.css +67 -0
- package/plugins/filemgr/stash.mjs +409 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// 插件后端:把预览读取能力暴露给插件页面。
|
|
2
|
+
// 所有业务方法都返回 {ok:true,...} 或 {ok:false,error},便于页面显示真实原因。
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { createPlugin } from './sdk.mjs'
|
|
8
|
+
import { roots, tree, meta, text, office, rawChunk, kindOf } from './preview.mjs'
|
|
9
|
+
import * as stash from './stash.mjs'
|
|
10
|
+
|
|
11
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
|
|
13
|
+
// 插件进程一退出,页面就永久显示“插件尚未运行”,只能靠用户手动重新启用才能恢复。
|
|
14
|
+
// 所以任何漏网异常都只记日志、不让进程退出。
|
|
15
|
+
function logCrash(where, error) {
|
|
16
|
+
try {
|
|
17
|
+
const dir = process.env.FISH_PLUGIN_DATA
|
|
18
|
+
if (dir) fs.appendFileSync(path.join(dir, 'crash.log'), `${new Date().toISOString()} [${where}] ${(error && error.stack) || error}\n`)
|
|
19
|
+
} catch { /* 写日志失败不影响主流程 */ }
|
|
20
|
+
}
|
|
21
|
+
process.on('uncaughtException', error => logCrash('uncaughtException', error))
|
|
22
|
+
process.on('unhandledRejection', error => logCrash('unhandledRejection', error))
|
|
23
|
+
|
|
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
|
+
|
|
31
|
+
// 宿主对插件方法的单次调用有 15 秒硬超时(plugin_runtime.rs:42),
|
|
32
|
+
// 而用户挑文件夹可能盯着一分钟。所以选择操作拆成两步:
|
|
33
|
+
// preview.pickFolder -> 立刻返回 {pending:true},后台把对话框弹出来
|
|
34
|
+
// preview.pickResult -> 页面每 700ms 轮询一次,拿到 picked / cancelled
|
|
35
|
+
const picking = { active: false, picked: null, done: false, error: null, startedAt: 0 }
|
|
36
|
+
const stashPicking = { active: false, picked: null, done: false, error: null, startedAt: 0, paths: null }
|
|
37
|
+
|
|
38
|
+
// 弹出 Windows 原生"选择文件夹"对话框,返回选中的绝对路径;用户取消返回 null。
|
|
39
|
+
// 走 pick-folder.ps1 -> FolderPicker.cs(IFileOpenDialog + FOS_PICKFOLDERS),
|
|
40
|
+
// 与 FISH Dock 宿主 paper.rs:588 是同一个系统对话框,不是自绘的目录树。
|
|
41
|
+
function spawnPickFolder(initial, title) {
|
|
42
|
+
if (process.platform !== 'win32') { picking.active = false; picking.done = true; picking.error = '当前系统不支持文件夹选择对话框'; return }
|
|
43
|
+
const script = path.join(HERE, 'pick-folder.ps1')
|
|
44
|
+
const child = spawn('powershell', [
|
|
45
|
+
'-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', script,
|
|
46
|
+
'-Initial', String(initial || ''), '-Title', String(title || '选择文件夹'),
|
|
47
|
+
], { windowsHide: false, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
48
|
+
|
|
49
|
+
let out = ''
|
|
50
|
+
let err = ''
|
|
51
|
+
const settle = (picked, error) => {
|
|
52
|
+
picking.active = false
|
|
53
|
+
picking.done = true
|
|
54
|
+
picking.picked = picked || null
|
|
55
|
+
picking.error = error || null
|
|
56
|
+
}
|
|
57
|
+
child.stdout.on('data', b => { out += b.toString() })
|
|
58
|
+
child.stderr.on('data', b => { err += b.toString() })
|
|
59
|
+
child.on('error', e => settle(null, '无法启动文件夹选择器:' + e.message))
|
|
60
|
+
child.on('close', code => {
|
|
61
|
+
const picked = out.trim()
|
|
62
|
+
if (code === 0 && picked) settle(picked, null)
|
|
63
|
+
else if (code === 0 || code === 2) settle(null, null) // 用户取消
|
|
64
|
+
else settle(null, '文件夹选择器失败:' + (err.trim() || ('退出码 ' + code)))
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// stash 专用:弹出多选文件对话框
|
|
69
|
+
function spawnStashPickFiles(title) {
|
|
70
|
+
if (process.platform !== 'win32') { stashPicking.active = false; stashPicking.done = true; stashPicking.error = '当前系统不支持文件选择对话框'; return }
|
|
71
|
+
const script = path.join(HERE, 'pick-files.ps1')
|
|
72
|
+
const child = spawn('powershell', [
|
|
73
|
+
'-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', script,
|
|
74
|
+
'-Title', String(title || '选择文件'),
|
|
75
|
+
], { windowsHide: false, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
76
|
+
|
|
77
|
+
let out = ''
|
|
78
|
+
let err = ''
|
|
79
|
+
const settle = (paths, error) => {
|
|
80
|
+
stashPicking.active = false
|
|
81
|
+
stashPicking.done = true
|
|
82
|
+
stashPicking.paths = paths || null
|
|
83
|
+
stashPicking.error = error || null
|
|
84
|
+
}
|
|
85
|
+
child.stdout.on('data', b => { out += b.toString() })
|
|
86
|
+
child.stderr.on('data', b => { err += b.toString() })
|
|
87
|
+
child.on('error', e => settle(null, '无法启动文件选择器:' + e.message))
|
|
88
|
+
child.on('close', code => {
|
|
89
|
+
const lines = out.trim().split(/\r?\n/).filter(Boolean)
|
|
90
|
+
if (code === 0 && lines.length > 0) settle(lines, null)
|
|
91
|
+
else if (code === 0 || code === 2) settle(null, null) // 用户取消
|
|
92
|
+
else settle(null, '文件选择器失败:' + (err.trim() || ('退出码 ' + code)))
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// stash 专用:复用 pick-folder.ps1 弹选择文件夹对话框
|
|
97
|
+
function spawnStashPickFolder(initial, title) {
|
|
98
|
+
if (process.platform !== 'win32') { stashPicking.active = false; stashPicking.done = true; stashPicking.error = '当前系统不支持文件夹选择对话框'; return }
|
|
99
|
+
const script = path.join(HERE, 'pick-folder.ps1')
|
|
100
|
+
const child = spawn('powershell', [
|
|
101
|
+
'-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-File', script,
|
|
102
|
+
'-Initial', String(initial || ''), '-Title', String(title || '选择文件夹'),
|
|
103
|
+
], { windowsHide: false, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
104
|
+
|
|
105
|
+
let out = ''
|
|
106
|
+
let err = ''
|
|
107
|
+
const settle = (picked, error) => {
|
|
108
|
+
stashPicking.active = false
|
|
109
|
+
stashPicking.done = true
|
|
110
|
+
stashPicking.paths = picked ? [picked] : null
|
|
111
|
+
stashPicking.error = error || null
|
|
112
|
+
}
|
|
113
|
+
child.stdout.on('data', b => { out += b.toString() })
|
|
114
|
+
child.stderr.on('data', b => { err += b.toString() })
|
|
115
|
+
child.on('error', e => settle(null, '无法启动文件夹选择器:' + e.message))
|
|
116
|
+
child.on('close', code => {
|
|
117
|
+
const picked = out.trim()
|
|
118
|
+
if (code === 0 && picked) settle(picked, null)
|
|
119
|
+
else if (code === 0 || code === 2) settle(null, null) // 用户取消
|
|
120
|
+
else settle(null, '文件夹选择器失败:' + (err.trim() || ('退出码 ' + code)))
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const MAX_RECENT = 12
|
|
125
|
+
const MAX_FAVORITES = 12
|
|
126
|
+
const MAX_PINNED = 8
|
|
127
|
+
let context = null
|
|
128
|
+
let extraRoots = []
|
|
129
|
+
let layout = 'single'
|
|
130
|
+
// 上栏(文件夹窗口)高度,单位像素。拖动上下分隔条时改它,存在 prefs.json 里,下次打开还是这个高度。
|
|
131
|
+
let sideW = 420
|
|
132
|
+
const SIDE_MIN = 140
|
|
133
|
+
const SIDE_MAX = 600
|
|
134
|
+
const SIDE_DEFAULT = 420
|
|
135
|
+
// 非法值(NaN / 空 / 负数)一律回默认 360,只有合法数字才夹到 [140,600]。
|
|
136
|
+
// 注意别写成 `Number(v) || 0` —— 那样 NaN 会变 0,再被 max 抬成 140,
|
|
137
|
+
// 用户明明没拖过却被记成最窄。
|
|
138
|
+
const clampSide = value => {
|
|
139
|
+
const n = Number(value)
|
|
140
|
+
if (!Number.isFinite(n) || n <= 0) return SIDE_DEFAULT
|
|
141
|
+
return Math.round(Math.min(SIDE_MAX, Math.max(SIDE_MIN, n)))
|
|
142
|
+
}
|
|
143
|
+
let recent = []
|
|
144
|
+
let favorites = []
|
|
145
|
+
let pinned = []
|
|
146
|
+
|
|
147
|
+
function ok(value) { return { ok: true, ...value } }
|
|
148
|
+
function fail(error) { return { ok: false, error: String((error && error.message) || error || '未知错误') } }
|
|
149
|
+
const FRAME_BUDGET = 900 * 1024 // 协议单帧上限 1024 KB,留出 JSON 包装余量
|
|
150
|
+
function guard(fn) {
|
|
151
|
+
return async params => {
|
|
152
|
+
try {
|
|
153
|
+
const value = await fn(params || {})
|
|
154
|
+
// 超限的回复会被 SDK 拒发;旧版 SDK 会随之 close(),整个插件进程退出,
|
|
155
|
+
// 页面就显示“插件尚未运行”。这里统一兜底成一条普通错误,保证进程存活。
|
|
156
|
+
const frame = JSON.stringify({ ok: true, ...value })
|
|
157
|
+
const bytes = frame ? Buffer.byteLength(frame) : 0
|
|
158
|
+
if (bytes > FRAME_BUDGET) {
|
|
159
|
+
return fail(new Error(`返回内容过大(${Math.round(bytes / 1024)} KB,上限 ${Math.round(FRAME_BUDGET / 1024)} KB),已阻止发送`))
|
|
160
|
+
}
|
|
161
|
+
return ok(value)
|
|
162
|
+
} catch (error) { return fail(error) }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function activeRoots() { return roots(extraRoots) }
|
|
167
|
+
|
|
168
|
+
async function loadConfig() {
|
|
169
|
+
const raw = await context.storage.read('config', 'prefs.json').catch(() => null)
|
|
170
|
+
if (!raw) return { roots: [], layout, sideW, recent, favorites, pinned, stash: undefined }
|
|
171
|
+
try {
|
|
172
|
+
const row = JSON.parse(raw)
|
|
173
|
+
if (Array.isArray(row.roots)) extraRoots = row.roots.filter(r => typeof r === 'string' && r.trim()).slice(0, 24)
|
|
174
|
+
if (row.layout === 'double' || row.layout === 'single') layout = row.layout
|
|
175
|
+
if (Number.isFinite(row.sideW)) sideW = clampSide(row.sideW)
|
|
176
|
+
if (Array.isArray(row.recent)) recent = row.recent.filter(r => typeof r === 'string').slice(0, MAX_RECENT)
|
|
177
|
+
if (Array.isArray(row.favorites)) favorites = row.favorites.filter(f => f && typeof f.path === 'string').slice(0, MAX_FAVORITES)
|
|
178
|
+
if (Array.isArray(row.pinned)) pinned = row.pinned.filter(f => f && typeof f.path === 'string').slice(0, MAX_PINNED)
|
|
179
|
+
return { roots: extraRoots, layout, sideW, recent, favorites, pinned, stash: row.stash }
|
|
180
|
+
} catch { return { roots: extraRoots, layout, sideW, recent, favorites, pinned, stash: undefined } }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function saveConfig() {
|
|
184
|
+
try {
|
|
185
|
+
const raw = await context.storage.read('config', 'prefs.json').catch(() => null)
|
|
186
|
+
const prev = raw ? JSON.parse(raw) : {}
|
|
187
|
+
// 合并写入:旧的 roots/layout/sideW/recent/favorites/pinned 恒定覆盖,其他字段(如 stash)保留不变
|
|
188
|
+
await context.storage.write('config', 'prefs.json', JSON.stringify({
|
|
189
|
+
...prev,
|
|
190
|
+
roots: extraRoots,
|
|
191
|
+
layout,
|
|
192
|
+
sideW,
|
|
193
|
+
recent,
|
|
194
|
+
favorites,
|
|
195
|
+
pinned,
|
|
196
|
+
})).catch(() => {})
|
|
197
|
+
} catch {
|
|
198
|
+
// JSON 解析失败时退化写入(丢弃损坏数据)
|
|
199
|
+
await context.storage.write('config', 'prefs.json', JSON.stringify({ roots: extraRoots, layout, sideW, recent, favorites, pinned })).catch(() => {})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function saveStashConfig() {
|
|
204
|
+
try {
|
|
205
|
+
const raw = await context.storage.read('config', 'prefs.json').catch(() => null)
|
|
206
|
+
const prev = raw ? JSON.parse(raw) : {}
|
|
207
|
+
prev.stash = stash.dumpStash()
|
|
208
|
+
await context.storage.write('config', 'prefs.json', JSON.stringify(prev)).catch(() => {})
|
|
209
|
+
} catch { /* 损坏时静默跳过 */ }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
createPlugin({
|
|
213
|
+
// 宿主通过 FISH_PLUGIN_ID 传入当前包声明的 id;缺省时与 plugin.json 保持一致。
|
|
214
|
+
id: process.env.FISH_PLUGIN_ID || 'jassdingk.filemgr',
|
|
215
|
+
activate: async ctx => {
|
|
216
|
+
context = ctx
|
|
217
|
+
const config = await loadConfig()
|
|
218
|
+
stash.init(() => { setImmediate(() => saveStashConfig()) })
|
|
219
|
+
stash.loadFrom(config.stash)
|
|
220
|
+
},
|
|
221
|
+
deactivate: async () => { await saveConfig() },
|
|
222
|
+
// 协议层出错只记日志,绝不设置退出码:exitCode 非 0 会让宿主直接判定插件异常退出,
|
|
223
|
+
// 而 close() 之后事件循环本来就空,插件就再也起不来(页面显示“插件尚未运行”)。
|
|
224
|
+
onError: error => logCrash('onError', error),
|
|
225
|
+
commands: {
|
|
226
|
+
// 根目录与偏好
|
|
227
|
+
'preview.roots': guard(async () => ({ roots: activeRoots(), layout, sideW, recent, favorites, pinned, home: activeRoots()[0]?.path || '' })),
|
|
228
|
+
'preview.layout': guard(async params => {
|
|
229
|
+
if (params.layout === 'single' || params.layout === 'double') { layout = params.layout; await saveConfig() }
|
|
230
|
+
return { layout }
|
|
231
|
+
}),
|
|
232
|
+
// 左栏宽度:拖动分隔条结束时上报一次(拖动过程只在本地改 CSS 变量,不打 IPC)。
|
|
233
|
+
'preview.sideWidth': guard(async params => {
|
|
234
|
+
const next = clampSide(params.width)
|
|
235
|
+
if (next !== sideW) { sideW = next; await saveConfig() }
|
|
236
|
+
return { sideW }
|
|
237
|
+
}),
|
|
238
|
+
'preview.addRoot': guard(async params => {
|
|
239
|
+
const value = String(params.path || '').trim()
|
|
240
|
+
if (!value) throw new Error('请填写目录路径')
|
|
241
|
+
if (!path.isAbsolute(value) && !path.win32.isAbsolute(value)) throw new Error('请填写绝对路径')
|
|
242
|
+
if (!extraRoots.includes(value)) extraRoots = [value, ...extraRoots].slice(0, 24)
|
|
243
|
+
await saveConfig()
|
|
244
|
+
return { roots: activeRoots() }
|
|
245
|
+
}),
|
|
246
|
+
// 弹系统原生"选择文件夹"对话框:立刻返回,后台弹窗,页面轮询 pickResult 拿结果。
|
|
247
|
+
'preview.pickFolder': guard(async params => {
|
|
248
|
+
if (picking.active) return { pending: true, already: true }
|
|
249
|
+
picking.active = true
|
|
250
|
+
picking.picked = null
|
|
251
|
+
picking.done = false
|
|
252
|
+
picking.error = null
|
|
253
|
+
picking.startedAt = Date.now()
|
|
254
|
+
spawnPickFolder(params.initial || params.path || '', params.title || '选择文件夹')
|
|
255
|
+
return { pending: true }
|
|
256
|
+
}),
|
|
257
|
+
// 轮询选择结果:{pending:true} 仍在选;{picked} 选好了;{cancelled:true} 点了取消。
|
|
258
|
+
// 注意:picking.done 为 false 而 picking.active 也为 false,说明对话框还没真正起来
|
|
259
|
+
// (powershell 冷启动要 1~3 秒),这时必须继续报 pending,不能误报成 cancelled —
|
|
260
|
+
// 否则页面会立刻显示"已取消",用户以为按钮没反应,而对话框几秒后才蹦出来。
|
|
261
|
+
'preview.pickResult': guard(async () => {
|
|
262
|
+
if (picking.active) return { pending: true }
|
|
263
|
+
if (!picking.done && picking.startedAt && Date.now() - picking.startedAt < 8000) return { pending: true }
|
|
264
|
+
if (!picking.done) return { pending: false, cancelled: true }
|
|
265
|
+
const row = { pending: false, error: picking.error || null }
|
|
266
|
+
picking.done = false
|
|
267
|
+
if (picking.error) return row
|
|
268
|
+
const picked = picking.picked
|
|
269
|
+
picking.picked = null
|
|
270
|
+
if (!picked) return { ...row, cancelled: true }
|
|
271
|
+
if (!extraRoots.includes(picked)) extraRoots = [picked, ...extraRoots].slice(0, 24)
|
|
272
|
+
await saveConfig()
|
|
273
|
+
return { ...row, picked, roots: activeRoots() }
|
|
274
|
+
}),
|
|
275
|
+
'preview.removeRoot': guard(async params => {
|
|
276
|
+
const value = String(params.path || '').trim()
|
|
277
|
+
extraRoots = extraRoots.filter(r => r !== value)
|
|
278
|
+
await saveConfig()
|
|
279
|
+
return { roots: activeRoots() }
|
|
280
|
+
}),
|
|
281
|
+
'preview.remember': guard(async params => {
|
|
282
|
+
const value = String(params.path || '').trim()
|
|
283
|
+
if (value) {
|
|
284
|
+
recent = [value, ...recent.filter(r => r !== value)].slice(0, MAX_RECENT)
|
|
285
|
+
await saveConfig()
|
|
286
|
+
}
|
|
287
|
+
return { recent }
|
|
288
|
+
}),
|
|
289
|
+
'preview.forget': guard(async () => { recent = []; await saveConfig(); return { recent } }),
|
|
290
|
+
|
|
291
|
+
// 读取
|
|
292
|
+
'preview.tree': guard(async params => {
|
|
293
|
+
const result = await tree(params.path, activeRoots())
|
|
294
|
+
// 自动记录到 favorites
|
|
295
|
+
if (result && result.dir && typeof result.dir === 'string') {
|
|
296
|
+
const p = result.dir
|
|
297
|
+
const name = path.basename(p) || p
|
|
298
|
+
const entry = { path: p, name, ts: Date.now() }
|
|
299
|
+
favorites = [entry, ...favorites.filter(f => f.path !== p)].slice(0, MAX_FAVORITES)
|
|
300
|
+
await saveConfig()
|
|
301
|
+
}
|
|
302
|
+
return result
|
|
303
|
+
}),
|
|
304
|
+
'preview.favorites': guard(async () => ({ favorites, pinned })),
|
|
305
|
+
'preview.favRemove': guard(async params => {
|
|
306
|
+
if (!params.path || typeof params.path !== 'string') throw new Error('path is required')
|
|
307
|
+
favorites = favorites.filter(f => f.path !== params.path)
|
|
308
|
+
pinned = pinned.filter(f => f.path !== params.path)
|
|
309
|
+
await saveConfig()
|
|
310
|
+
return { favorites, pinned }
|
|
311
|
+
}),
|
|
312
|
+
'preview.favPin': guard(async params => {
|
|
313
|
+
if (!params.path || typeof params.path !== 'string') throw new Error('path is required')
|
|
314
|
+
if (!params.name || typeof params.name !== 'string') throw new Error('name is required')
|
|
315
|
+
const idx = pinned.findIndex(f => f.path === params.path)
|
|
316
|
+
if (idx >= 0) {
|
|
317
|
+
pinned.splice(idx, 1)
|
|
318
|
+
} else {
|
|
319
|
+
const entry = { path: params.path, name: params.name, ts: Date.now() }
|
|
320
|
+
pinned = [entry, ...pinned].slice(0, MAX_PINNED)
|
|
321
|
+
// 同时确保该目录在 favorites 里
|
|
322
|
+
favorites = [entry, ...favorites.filter(f => f.path !== params.path)].slice(0, MAX_FAVORITES)
|
|
323
|
+
}
|
|
324
|
+
await saveConfig()
|
|
325
|
+
return { favorites, pinned }
|
|
326
|
+
}),
|
|
327
|
+
'preview.meta': guard(async params => {
|
|
328
|
+
const row = await meta(params.path, activeRoots())
|
|
329
|
+
return { ...row, kind: row.kind }
|
|
330
|
+
}),
|
|
331
|
+
'preview.text': guard(async params => text(params.path, activeRoots())),
|
|
332
|
+
'preview.office': guard(async params => office(params.path, activeRoots())),
|
|
333
|
+
'preview.raw': guard(async params => rawChunk(params.path, activeRoots(), params.offset, params.limit)),
|
|
334
|
+
|
|
335
|
+
// 供页面判断预览方式,不需要读文件内容
|
|
336
|
+
'preview.kind': guard(async params => {
|
|
337
|
+
const row = await meta(params.path, activeRoots())
|
|
338
|
+
return { name: row.name, ext: row.ext, kind: row.isDir ? 'dir' : kindOf(row.ext), size: row.size, mtime: row.mtime }
|
|
339
|
+
}),
|
|
340
|
+
|
|
341
|
+
// ── stash 命令 ──────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
'stash.list': guard(async () => stash.list()),
|
|
344
|
+
|
|
345
|
+
'stash.add': guard(async params => {
|
|
346
|
+
if (!Array.isArray(params.paths) || params.paths.length === 0) throw new Error('paths is required')
|
|
347
|
+
return stash.add(params.paths, params.group)
|
|
348
|
+
}),
|
|
349
|
+
|
|
350
|
+
'stash.remove': guard(async params => {
|
|
351
|
+
if (!Array.isArray(params.ids) || params.ids.length === 0) throw new Error('ids is required')
|
|
352
|
+
return stash.remove(params.ids)
|
|
353
|
+
}),
|
|
354
|
+
|
|
355
|
+
'stash.clear': guard(async params => stash.clear(params.group)),
|
|
356
|
+
|
|
357
|
+
'stash.move': guard(async params => {
|
|
358
|
+
if (!Array.isArray(params.ids) || params.ids.length === 0) throw new Error('ids is required')
|
|
359
|
+
return stash.move(params.ids, params.group)
|
|
360
|
+
}),
|
|
361
|
+
|
|
362
|
+
'stash.tag': guard(async params => {
|
|
363
|
+
if (!Array.isArray(params.ids) || params.ids.length === 0) throw new Error('ids is required')
|
|
364
|
+
return stash.tag(params.ids, params.add, params.remove)
|
|
365
|
+
}),
|
|
366
|
+
|
|
367
|
+
'stash.group.add': guard(async params => {
|
|
368
|
+
if (!params.name || typeof params.name !== 'string') throw new Error('name is required')
|
|
369
|
+
return stash.groupAdd(params.name)
|
|
370
|
+
}),
|
|
371
|
+
|
|
372
|
+
'stash.group.remove': guard(async params => {
|
|
373
|
+
if (!params.name || typeof params.name !== 'string') throw new Error('name is required')
|
|
374
|
+
return stash.groupRemove(params.name)
|
|
375
|
+
}),
|
|
376
|
+
|
|
377
|
+
'stash.reveal': guard(async params => {
|
|
378
|
+
if (!params.id) throw new Error('id is required')
|
|
379
|
+
return stash.reveal(params.id)
|
|
380
|
+
}),
|
|
381
|
+
|
|
382
|
+
'stash.copy': guard(async params => {
|
|
383
|
+
if (!Array.isArray(params.ids) || params.ids.length === 0) throw new Error('ids is required')
|
|
384
|
+
// 前端可能传 {mode:'path'|'file'} 或 {as:'text'|'files'},统一映射
|
|
385
|
+
let asType = params.as
|
|
386
|
+
if (!asType) {
|
|
387
|
+
if (params.mode === 'path') asType = 'text'
|
|
388
|
+
else if (params.mode === 'file') asType = 'files'
|
|
389
|
+
}
|
|
390
|
+
if (asType !== 'text' && asType !== 'files') throw new Error('as must be "text" or "files", or mode "path" or "file"')
|
|
391
|
+
return stash.copy(params.ids, asType)
|
|
392
|
+
}),
|
|
393
|
+
|
|
394
|
+
'stash.dragOut': guard(async params => {
|
|
395
|
+
// 前端可能传 {id:单个} 或 {ids:[...]}
|
|
396
|
+
const ids = params.ids || (params.id ? [params.id] : [])
|
|
397
|
+
if (ids.length === 0) throw new Error('ids or id is required')
|
|
398
|
+
const result = await stash.dragOut(ids)
|
|
399
|
+
// 单 id 时额外平铺 url/name/mime,前端 page/stash.js:266 按单数读
|
|
400
|
+
if (ids.length === 1 && result.files && result.files.length === 1) {
|
|
401
|
+
result.url = result.files[0].url
|
|
402
|
+
result.name = result.files[0].name
|
|
403
|
+
result.mime = result.files[0].mime
|
|
404
|
+
}
|
|
405
|
+
return result
|
|
406
|
+
}),
|
|
407
|
+
|
|
408
|
+
'stash.closeDragOut': guard(async () => stash.closeDragOut()),
|
|
409
|
+
|
|
410
|
+
// stash 专用:弹出多选文件对话框(两步式,同 preview.pickFolder 模式)
|
|
411
|
+
'stash.pickFiles': guard(async params => {
|
|
412
|
+
if (stashPicking.active) return { pending: true, already: true }
|
|
413
|
+
stashPicking.active = true
|
|
414
|
+
stashPicking.paths = null
|
|
415
|
+
stashPicking.done = false
|
|
416
|
+
stashPicking.error = null
|
|
417
|
+
stashPicking.startedAt = Date.now()
|
|
418
|
+
spawnStashPickFiles(params.title || '选择文件')
|
|
419
|
+
return { pending: true }
|
|
420
|
+
}),
|
|
421
|
+
|
|
422
|
+
// stash 专用:弹出选择文件夹对话框(两步式)
|
|
423
|
+
'stash.pickFolder': guard(async params => {
|
|
424
|
+
if (stashPicking.active) return { pending: true, already: true }
|
|
425
|
+
stashPicking.active = true
|
|
426
|
+
stashPicking.paths = null
|
|
427
|
+
stashPicking.done = false
|
|
428
|
+
stashPicking.error = null
|
|
429
|
+
stashPicking.startedAt = Date.now()
|
|
430
|
+
spawnStashPickFolder(params.path || params.initial || '', params.title || '选择文件夹')
|
|
431
|
+
return { pending: true }
|
|
432
|
+
}),
|
|
433
|
+
|
|
434
|
+
// stash 专用:轮询文件/文件夹选择结果
|
|
435
|
+
'stash.pickResult': guard(async () => {
|
|
436
|
+
if (stashPicking.active) return { pending: true }
|
|
437
|
+
if (!stashPicking.done && stashPicking.startedAt && Date.now() - stashPicking.startedAt < 8000) return { pending: true }
|
|
438
|
+
if (!stashPicking.done) return { pending: false, cancelled: true }
|
|
439
|
+
const row = { pending: false, error: stashPicking.error || null }
|
|
440
|
+
stashPicking.done = false
|
|
441
|
+
if (stashPicking.error) return row
|
|
442
|
+
const paths = stashPicking.paths
|
|
443
|
+
stashPicking.paths = null
|
|
444
|
+
if (!paths) return { ...row, cancelled: true }
|
|
445
|
+
return { ...row, paths }
|
|
446
|
+
}),
|
|
447
|
+
},
|
|
448
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# 把命令行参数列出的文件路径设入剪贴板 FileDrop 列表。
|
|
2
|
+
# 用法:powershell -File clip.ps1 <file1> [file2 ...]
|
|
3
|
+
# 无参数时清空 FileDrop 列表。
|
|
4
|
+
|
|
5
|
+
[CmdletBinding()]
|
|
6
|
+
param(
|
|
7
|
+
[Parameter(ValueFromRemainingArguments = $true)]
|
|
8
|
+
[string[]]$Paths
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
# 关键:同 pick-folder.ps1,中文路径必须用 UTF-8 输出
|
|
12
|
+
[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
|
|
13
|
+
$ErrorActionPreference = 'Stop'
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
if ($Paths.Count -eq 0) {
|
|
17
|
+
Set-Clipboard -Path $null
|
|
18
|
+
exit 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
$valid = @()
|
|
22
|
+
foreach ($p in $Paths) {
|
|
23
|
+
if (Test-Path -LiteralPath $p) {
|
|
24
|
+
$valid += (Resolve-Path -LiteralPath $p).Path
|
|
25
|
+
} else {
|
|
26
|
+
[Console]::Error.WriteLine("clip.ps1: not found — $p")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if ($valid.Count -gt 0) {
|
|
31
|
+
Set-Clipboard -Path $valid
|
|
32
|
+
} else {
|
|
33
|
+
# 清空而非报错,让调用方始终能继续
|
|
34
|
+
Set-Clipboard -Path $null
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
[Console]::Error.WriteLine($_.Exception.Message)
|
|
38
|
+
exit 2
|
|
39
|
+
}
|
|
40
|
+
exit 0
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/* 官方插件沿用 Dock 设置页的灰阶、字号和胶囊控件;主题由宿主桥同步。 */
|
|
2
|
+
:root{color-scheme:dark;--dock-bg:#0a0810;--dock-card:#171320;--dock-control:#241e30;--dock-line:#342c44;--dock-muted:#a49cbb;--dock-text:#f2eefc;--dock-accent:#b9a7ff;font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}
|
|
3
|
+
:root[data-dock-theme=light]{color-scheme:light}
|
|
4
|
+
*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:var(--dock-bg);color:var(--dock-text)}body{font:13px/1.45 "Segoe UI","Microsoft YaHei",sans-serif}main{width:100%;padding:4px 0 16px;margin:0}h1{font-size:15px;font-weight:600;margin:0}h2,legend{font-size:13px;font-weight:600}h2{margin:0 0 10px}header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 8px 12px}p{margin:8px 0;color:var(--dock-muted)}button,input,select,textarea{font:inherit;color:inherit}button{min-height:28px;padding:4px 14px;border:1px solid var(--dock-line);border-radius:999px;background:var(--dock-control);font-size:12.5px;font-weight:600;cursor:pointer}button:hover:not(:disabled){filter:brightness(1.15)}button:active:not(:disabled){transform:translateY(1px)}button:disabled,input:disabled,select:disabled{opacity:.5;cursor:default}button.primary,button[type=submit]{background:var(--dock-accent);border-color:var(--dock-accent);color:#1a1035}input:not([type=checkbox]),select,textarea{width:100%;min-width:0;min-height:30px;padding:5px 12px;border:1px solid var(--dock-line);border-radius:15px;background:var(--dock-bg)}input[type=checkbox]{accent-color:#30d158;width:17px;height:17px;flex:none}label{display:grid;gap:6px;margin:12px 0}section,fieldset,aside,details,.card{min-width:0;border:0;border-radius:18px;background:var(--dock-card);padding:16px;margin:0 0 12px}fieldset{padding-top:12px}legend{float:left;width:100%;padding:0 0 8px}fieldset:disabled{opacity:.65}fieldset>p{font-size:12px}.note,.muted{color:var(--dock-muted);font-size:12px}.check{display:flex;align-items:center;gap:8px;min-height:28px;margin:8px 0}.actions,.toolbar,.row,.filters,.layouts{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.actions{margin-top:16px}summary{cursor:pointer;min-height:28px;font-weight:600;display:flex;align-items:center;gap:8px}summary::before{content:'›';color:var(--dock-muted);font-size:18px}details[open]>summary::before{transform:rotate(90deg)}[role=alert],.bad{color:#ff6b7a;overflow-wrap:anywhere}[role=status]:empty,[role=alert]:empty{display:none}:focus-visible{outline:2px solid var(--dock-accent);outline-offset:3px}button[aria-pressed=true],button[aria-current=true]{background:var(--dock-line);border-color:var(--dock-line);color:var(--dock-text)}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-thumb{border-radius:3px;background:var(--dock-line)}[hidden]{display:none!important}@media(max-width:480px){header{align-items:flex-start;flex-wrap:wrap}section,fieldset,aside,details,.card{padding:12px}}@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important;scroll-behavior:auto!important}}
|