dsh-workbuddy-files 0.1.0 → 0.1.2
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/lib/client.js +919 -892
- package/package.json +3 -3
- package/src/client/app.ts +34 -6
package/lib/client.js
CHANGED
|
@@ -1,645 +1,598 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
function formatSize(n) {
|
|
63
|
-
if (n === null || n === void 0 || !Number.isFinite(n)) return "";
|
|
64
|
-
if (n < 1024) return n + " B";
|
|
65
|
-
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
|
|
66
|
-
if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
|
|
67
|
-
return (n / 1073741824).toFixed(2) + " GB";
|
|
68
|
-
}
|
|
69
|
-
//#endregion
|
|
70
|
-
//#region src/client/lib/transfer.ts
|
|
71
|
-
/** 引用文本格式:无空格直接 @path;含空格/引号用 @"path";目录保留尾斜杠 */
|
|
72
|
-
function mentionFor(path, isDir) {
|
|
73
|
-
let p = String(path);
|
|
74
|
-
if (isDir) p = p.replace(/[\\/]+$/, "") + "/";
|
|
75
|
-
if (/\s|"/.test(p)) return "@\"" + p + "\"";
|
|
76
|
-
return "@" + p;
|
|
77
|
-
}
|
|
78
|
-
/** 从用户消息文本中提取 @ 文件引用(消息发送后的序列化形式) */
|
|
79
|
-
function extractRefs(content) {
|
|
80
|
-
const out = [];
|
|
81
|
-
for (const b of content) if (b !== null && b !== void 0 && b.type === "text" && typeof b.text === "string") {
|
|
82
|
-
const re = /@"([^"]+)"|@([^\s"@]+)/g;
|
|
83
|
-
let m;
|
|
84
|
-
while ((m = re.exec(b.text)) !== null) {
|
|
85
|
-
const p = (m[1] !== void 0 ? m[1] : m[2]).trim();
|
|
86
|
-
if (p !== "" && !out.includes(p)) out.push(p);
|
|
87
|
-
}
|
|
1
|
+
(function() {
|
|
2
|
+
//#region src/client/lib/icons.ts
|
|
3
|
+
/** 文件类型 → 图标/颜色映射(WorkBuddy 风格的类型标识) */
|
|
4
|
+
const ICONS = {
|
|
5
|
+
pdf: "📕",
|
|
6
|
+
doc: "📘",
|
|
7
|
+
docx: "📘",
|
|
8
|
+
xls: "📊",
|
|
9
|
+
xlsx: "📊",
|
|
10
|
+
csv: "📊",
|
|
11
|
+
ppt: "📙",
|
|
12
|
+
pptx: "📙",
|
|
13
|
+
png: "🖼️",
|
|
14
|
+
jpg: "🖼️",
|
|
15
|
+
jpeg: "🖼️",
|
|
16
|
+
gif: "🖼️",
|
|
17
|
+
webp: "🖼️",
|
|
18
|
+
svg: "🖼️",
|
|
19
|
+
bmp: "🖼️",
|
|
20
|
+
zip: "🗜️",
|
|
21
|
+
"7z": "🗜️",
|
|
22
|
+
rar: "🗜️",
|
|
23
|
+
tar: "🗜️",
|
|
24
|
+
gz: "🗜️",
|
|
25
|
+
mp3: "🎵",
|
|
26
|
+
wav: "🎵",
|
|
27
|
+
flac: "🎵",
|
|
28
|
+
mp4: "🎬",
|
|
29
|
+
mov: "🎬",
|
|
30
|
+
mkv: "🎬",
|
|
31
|
+
md: "📝",
|
|
32
|
+
txt: "📄",
|
|
33
|
+
log: "📄",
|
|
34
|
+
js: "💻",
|
|
35
|
+
ts: "💻",
|
|
36
|
+
jsx: "💻",
|
|
37
|
+
tsx: "💻",
|
|
38
|
+
py: "💻",
|
|
39
|
+
go: "💻",
|
|
40
|
+
rs: "💻",
|
|
41
|
+
java: "💻",
|
|
42
|
+
c: "💻",
|
|
43
|
+
h: "💻",
|
|
44
|
+
cpp: "💻",
|
|
45
|
+
cs: "💻",
|
|
46
|
+
json: "💻",
|
|
47
|
+
yaml: "💻",
|
|
48
|
+
yml: "💻",
|
|
49
|
+
toml: "💻",
|
|
50
|
+
sh: "💻",
|
|
51
|
+
ps1: "💻",
|
|
52
|
+
bat: "💻",
|
|
53
|
+
css: "💻",
|
|
54
|
+
html: "💻",
|
|
55
|
+
vue: "💻",
|
|
56
|
+
sql: "💻"
|
|
57
|
+
};
|
|
58
|
+
function iconFor(name) {
|
|
59
|
+
const dot = String(name).lastIndexOf(".");
|
|
60
|
+
const ext = dot >= 0 ? String(name).slice(dot + 1).toLowerCase() : "";
|
|
61
|
+
return ICONS[ext] ?? "📄";
|
|
88
62
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*/
|
|
96
|
-
async function runUploadJobs(jobs, batch, maxFileBytes = 268435456) {
|
|
97
|
-
let ok = 0;
|
|
98
|
-
const failed = [];
|
|
99
|
-
for (const j of jobs) {
|
|
100
|
-
const files = j.kind === "dir" ? await walkEntry(j.entry, "") : [{
|
|
101
|
-
rel: j.rel,
|
|
102
|
-
name: j.name,
|
|
103
|
-
size: j.file.size,
|
|
104
|
-
file: j.file
|
|
105
|
-
}];
|
|
106
|
-
for (const f of files) {
|
|
107
|
-
if (f.file.size > maxFileBytes) {
|
|
108
|
-
failed.push(f.name + "(超过大小上限)");
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
const url = "/workbuddy-drops/save?batch=" + encodeURIComponent(batch) + "&rel=" + encodeURIComponent(f.rel);
|
|
112
|
-
const res = await fetch(url, {
|
|
113
|
-
method: "POST",
|
|
114
|
-
body: f.file
|
|
115
|
-
});
|
|
116
|
-
if (res.ok) {
|
|
117
|
-
const body = await res.json();
|
|
118
|
-
if (body.ok === true) {
|
|
119
|
-
ok += 1;
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
failed.push(f.name + "(" + (body.error ?? "写入失败") + ")");
|
|
123
|
-
} else failed.push(f.name + "(HTTP " + res.status + ")");
|
|
124
|
-
}
|
|
63
|
+
function formatSize(n) {
|
|
64
|
+
if (n === null || n === void 0 || !Number.isFinite(n)) return "";
|
|
65
|
+
if (n < 1024) return n + " B";
|
|
66
|
+
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
|
|
67
|
+
if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
|
|
68
|
+
return (n / 1073741824).toFixed(2) + " GB";
|
|
125
69
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const body = await (await fetch("/workbuddy-drops/home")).json();
|
|
135
|
-
if (body.ok === true && typeof body.root === "string") return body.root;
|
|
136
|
-
return null;
|
|
137
|
-
} catch {
|
|
138
|
-
return null;
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/client/lib/transfer.ts
|
|
72
|
+
/** 引用文本格式:无空格直接 @path;含空格/引号用 @"path";目录保留尾斜杠 */
|
|
73
|
+
function mentionFor(path, isDir) {
|
|
74
|
+
let p = String(path);
|
|
75
|
+
if (isDir) p = p.replace(/[\\/]+$/, "") + "/";
|
|
76
|
+
if (/\s|"/.test(p)) return "@\"" + p + "\"";
|
|
77
|
+
return "@" + p;
|
|
139
78
|
}
|
|
140
|
-
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
return;
|
|
79
|
+
/** 从用户消息文本中提取 @ 文件引用(消息发送后的序列化形式) */
|
|
80
|
+
function extractRefs(content) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const b of content) if (b !== null && b !== void 0 && b.type === "text" && typeof b.text === "string") {
|
|
83
|
+
const re = /@"([^"]+)"|@([^\s"@]+)/g;
|
|
84
|
+
let m;
|
|
85
|
+
while ((m = re.exec(b.text)) !== null) {
|
|
86
|
+
const p = (m[1] !== void 0 ? m[1] : m[2]).trim();
|
|
87
|
+
if (p !== "" && !out.includes(p)) out.push(p);
|
|
88
|
+
}
|
|
151
89
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
90
|
+
return out.slice(0, 40);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 后台上传任务队列:逐个落盘(目录任务先遍历),不阻塞输入。
|
|
94
|
+
* 交付物版本走 webServer 二进制路由(/workbuddy-drops),无 base64、无 JSON 体积上限。
|
|
95
|
+
* @returns 成功数量与失败清单(文件名 + 原因)
|
|
96
|
+
*/
|
|
97
|
+
async function runUploadJobs(jobs, batch, maxFileBytes = 268435456) {
|
|
98
|
+
let ok = 0;
|
|
99
|
+
const failed = [];
|
|
100
|
+
for (const j of jobs) {
|
|
101
|
+
const files = j.kind === "dir" ? await walkEntry(j.entry, "") : [{
|
|
102
|
+
rel: j.rel,
|
|
103
|
+
name: j.name,
|
|
104
|
+
size: j.file.size,
|
|
105
|
+
file: j.file
|
|
106
|
+
}];
|
|
107
|
+
for (const f of files) {
|
|
108
|
+
if (f.file.size > maxFileBytes) {
|
|
109
|
+
failed.push(f.name + "(超过大小上限)");
|
|
110
|
+
continue;
|
|
159
111
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
112
|
+
const url = "/workbuddy-drops/save?batch=" + encodeURIComponent(batch) + "&rel=" + encodeURIComponent(f.rel);
|
|
113
|
+
const res = await fetch(url, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
body: f.file
|
|
116
|
+
});
|
|
117
|
+
if (res.ok) {
|
|
118
|
+
const body = await res.json();
|
|
119
|
+
if (body.ok === true) {
|
|
120
|
+
ok += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
failed.push(f.name + "(" + (body.error ?? "写入失败") + ")");
|
|
124
|
+
} else failed.push(f.name + "(HTTP " + res.status + ")");
|
|
125
|
+
}
|
|
165
126
|
}
|
|
166
|
-
resolve2([]);
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
async function dropsList(query) {
|
|
170
|
-
try {
|
|
171
|
-
return (await (await fetch("/workbuddy-drops/list?query=" + encodeURIComponent(query))).json()).items ?? [];
|
|
172
|
-
} catch {
|
|
173
|
-
return [];
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
async function dropsStat(path) {
|
|
177
|
-
try {
|
|
178
|
-
return await (await fetch("/workbuddy-drops/stat?path=" + encodeURIComponent(path))).json();
|
|
179
|
-
} catch {
|
|
180
127
|
return {
|
|
181
|
-
ok
|
|
182
|
-
|
|
128
|
+
ok,
|
|
129
|
+
failed
|
|
183
130
|
};
|
|
184
131
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
async candidates(_session, req) {
|
|
206
|
-
const items = await dropsList(req && req.query || "");
|
|
207
|
-
const out = [];
|
|
208
|
-
for (const it of items) {
|
|
209
|
-
const isDir = it.type === "directory";
|
|
210
|
-
out.push({
|
|
211
|
-
name: (isDir ? "📁 " : iconFor(it.name) + " ") + it.name + (isDir ? "/" : ""),
|
|
212
|
-
description: it.path,
|
|
213
|
-
section: "文件缓存 · ~/.dsh-drops",
|
|
214
|
-
value: JSON.stringify({
|
|
215
|
-
kind: isDir ? "folder" : "file",
|
|
216
|
-
name: it.name,
|
|
217
|
-
path: it.path
|
|
218
|
-
})
|
|
219
|
-
});
|
|
132
|
+
/** 缓存根目录(~/.dsh-drops) */
|
|
133
|
+
async function dropsHome() {
|
|
134
|
+
try {
|
|
135
|
+
const body = await (await fetch("/workbuddy-drops/home")).json();
|
|
136
|
+
if (body.ok === true && typeof body.root === "string") return body.root;
|
|
137
|
+
return null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function walkEntry(entry, rel) {
|
|
143
|
+
return new Promise((resolve2) => {
|
|
144
|
+
if (entry.isFile) {
|
|
145
|
+
entry.file((f) => resolve2([{
|
|
146
|
+
rel: rel === "" ? entry.name : rel + "/" + entry.name,
|
|
147
|
+
name: entry.name,
|
|
148
|
+
size: f.size,
|
|
149
|
+
file: f
|
|
150
|
+
}]), () => resolve2([]));
|
|
151
|
+
return;
|
|
220
152
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
153
|
+
if (entry.isDirectory) {
|
|
154
|
+
const reader = entry.createReader();
|
|
155
|
+
const found = [];
|
|
156
|
+
const readBatch = () => reader.readEntries((ents) => {
|
|
157
|
+
if (ents.length === 0) {
|
|
158
|
+
Promise.all(found.map((e) => walkEntry(e, rel === "" ? entry.name : rel + "/" + entry.name))).then((rs) => resolve2(rs.flat())).catch(() => resolve2([]));
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
found.push(...ents);
|
|
162
|
+
readBatch();
|
|
163
|
+
}, () => resolve2([]));
|
|
164
|
+
readBatch();
|
|
228
165
|
return;
|
|
229
166
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
} };
|
|
239
|
-
},
|
|
240
|
-
codec: {
|
|
241
|
-
clipboardText: (ref) => ref,
|
|
242
|
-
serialize: (ref) => Promise.resolve(ref)
|
|
167
|
+
resolve2([]);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async function dropsList(query) {
|
|
171
|
+
try {
|
|
172
|
+
return (await (await fetch("/workbuddy-drops/list?query=" + encodeURIComponent(query))).json()).items ?? [];
|
|
173
|
+
} catch {
|
|
174
|
+
return [];
|
|
243
175
|
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
* selector 只匹配含文件引用的轮次(见 definitions.ts),不抢占其他链条目。
|
|
253
|
-
*/
|
|
254
|
-
function createFileCardsComponent(React) {
|
|
255
|
-
function FileCard(props) {
|
|
256
|
-
const { path, openFile } = props;
|
|
257
|
-
const [meta, setMeta] = React.useState(null);
|
|
258
|
-
React.useEffect(() => {
|
|
259
|
-
let live = true;
|
|
260
|
-
dropsStat(path).then((r) => {
|
|
261
|
-
if (live) setMeta(r);
|
|
262
|
-
}, () => {
|
|
263
|
-
if (live) setMeta({
|
|
264
|
-
ok: false,
|
|
265
|
-
path
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
return () => {
|
|
269
|
-
live = false;
|
|
176
|
+
}
|
|
177
|
+
async function dropsStat(path) {
|
|
178
|
+
try {
|
|
179
|
+
return await (await fetch("/workbuddy-drops/stat?path=" + encodeURIComponent(path))).json();
|
|
180
|
+
} catch {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
path
|
|
270
184
|
};
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/client/at-source.ts
|
|
189
|
+
/**
|
|
190
|
+
* @ 触发源「workbuddy」:输入 @ 时在菜单中追加「文件缓存」分组 ——
|
|
191
|
+
* ~/.dsh-drops 中拖拽/粘贴/选择落地的文件与目录树,支持搜索。
|
|
192
|
+
* 选中后由输入管线的 onPick → { insert } 在触发词位置铸造原生气泡。
|
|
193
|
+
*
|
|
194
|
+
* 工作区文件/文件夹的 @ 检索由 DSH 官方 ui-reference 源提供(文件与文件夹 +
|
|
195
|
+
* Session 分组),本插件与之共存,无需重复实现。
|
|
196
|
+
*
|
|
197
|
+
* 所有引用在拖入时已完成落地(真实绝对路径),因此 codec 序列化是恒等函数,
|
|
198
|
+
* 发送消息不可能因文件未落地而失败。
|
|
199
|
+
*/
|
|
200
|
+
function createAtSource() {
|
|
201
|
+
return {
|
|
202
|
+
trigger: "@",
|
|
203
|
+
name: "workbuddy",
|
|
204
|
+
order: 5,
|
|
205
|
+
showGroupTitle: true,
|
|
206
|
+
async candidates(_session, req) {
|
|
207
|
+
const items = await dropsList(req && req.query || "");
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const it of items) {
|
|
210
|
+
const isDir = it.type === "directory";
|
|
211
|
+
out.push({
|
|
212
|
+
name: (isDir ? "📁 " : iconFor(it.name) + " ") + it.name + (isDir ? "/" : ""),
|
|
213
|
+
description: it.path,
|
|
214
|
+
section: "文件缓存 · ~/.dsh-drops",
|
|
215
|
+
value: JSON.stringify({
|
|
216
|
+
kind: isDir ? "folder" : "file",
|
|
217
|
+
name: it.name,
|
|
218
|
+
path: it.path
|
|
219
|
+
})
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return out.slice(0, 60);
|
|
223
|
+
},
|
|
224
|
+
onPick(pick) {
|
|
225
|
+
let v = null;
|
|
226
|
+
try {
|
|
227
|
+
v = JSON.parse(pick.candidate.value || "null");
|
|
228
|
+
} catch {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (v === null || typeof v !== "object" || typeof v.path !== "string") return void 0;
|
|
232
|
+
const mention = mentionFor(v.path, v.kind === "folder");
|
|
233
|
+
return { insert: {
|
|
234
|
+
source: "workbuddy",
|
|
235
|
+
ref: mention,
|
|
236
|
+
label: v.name ?? "",
|
|
237
|
+
appearance: v.kind === "folder" ? "folder" : "file",
|
|
238
|
+
clipboardText: mention
|
|
239
|
+
} };
|
|
240
|
+
},
|
|
241
|
+
codec: {
|
|
242
|
+
clipboardText: (ref) => ref,
|
|
243
|
+
serialize: (ref) => Promise.resolve(ref)
|
|
285
244
|
}
|
|
286
|
-
}
|
|
245
|
+
};
|
|
287
246
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/client/components/file-cards.tsx
|
|
249
|
+
/**
|
|
250
|
+
* 对话区文件卡片(挂在 conversation.chat.turnTail 链式槽):
|
|
251
|
+
* 用户消息发送后,该轮次消息中引用的文件以「类型图标 + 文件名 + 大小/文件夹」卡片
|
|
252
|
+
* 渲染在轮次尾部;点击卡片经 owner 的 openFile 打开文件。
|
|
253
|
+
* selector 只匹配含文件引用的轮次(见 definitions.ts),不抢占其他链条目。
|
|
254
|
+
*/
|
|
255
|
+
function createFileCardsComponent(React) {
|
|
256
|
+
function FileCard(props) {
|
|
257
|
+
const { path, openFile } = props;
|
|
258
|
+
const [meta, setMeta] = React.useState(null);
|
|
259
|
+
React.useEffect(() => {
|
|
260
|
+
let live = true;
|
|
261
|
+
dropsStat(path).then((r) => {
|
|
262
|
+
if (live) setMeta(r);
|
|
263
|
+
}, () => {
|
|
264
|
+
if (live) setMeta({
|
|
265
|
+
ok: false,
|
|
266
|
+
path
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
return () => {
|
|
270
|
+
live = false;
|
|
271
|
+
};
|
|
272
|
+
}, [path]);
|
|
273
|
+
const name = String(path).split(/[\\/]/).pop() || path;
|
|
274
|
+
const good = meta !== null && meta !== void 0 && meta.ok === true && meta.exists === true;
|
|
275
|
+
const isDir = good && meta.type === "directory";
|
|
276
|
+
const icon = isDir ? "📁" : iconFor(name);
|
|
277
|
+
const sub = meta === null ? "…" : !good ? "不可用" : isDir ? "文件夹" : formatSize(meta.size);
|
|
278
|
+
return React.createElement("button", {
|
|
279
|
+
type: "button",
|
|
280
|
+
className: "wbd-card",
|
|
281
|
+
title: path,
|
|
282
|
+
onClick: () => {
|
|
283
|
+
if (typeof openFile === "function") try {
|
|
284
|
+
openFile(path);
|
|
285
|
+
} catch {}
|
|
286
|
+
}
|
|
287
|
+
}, React.createElement("span", { className: "wbd-card-icon" }, icon), React.createElement("span", { className: "wbd-card-name" }, name), React.createElement("span", { className: "wbd-card-sub" }, sub));
|
|
288
|
+
}
|
|
289
|
+
return function FileCards(props) {
|
|
290
|
+
const matched = props.matched;
|
|
291
|
+
if (matched === null || matched === void 0 || !Array.isArray(matched.refs) || matched.refs.length === 0) return null;
|
|
292
|
+
return React.createElement("div", { className: "wbd-cards" }, React.createElement("span", { className: "wbd-cards-label" }, "📎 消息引用的文件"), matched.refs.map((path, i) => React.createElement(FileCard, {
|
|
293
|
+
key: String(path) + ":" + i,
|
|
294
|
+
path,
|
|
295
|
+
openFile: props.openFile
|
|
296
|
+
})));
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/client/components/overlay.tsx
|
|
301
|
+
/**
|
|
302
|
+
* 全屏拖拽遮罩 + toast(挂在 shell.overlay 列表槽)。
|
|
303
|
+
* 遮罩只在拖拽含非图片文件/文件夹时出现(纯图片拖放交给原生图片轨道);
|
|
304
|
+
* 层本身点击穿透,遮罩激活时开启 pointer-events 承接 drop。
|
|
305
|
+
*/
|
|
306
|
+
function createOverlayComponent(React, bus) {
|
|
307
|
+
return function WorkbuddyOverlay() {
|
|
308
|
+
const [state, setState] = React.useState(bus.get());
|
|
309
|
+
React.useEffect(() => bus.subscribe(setState), []);
|
|
310
|
+
return React.createElement("div", { className: "wbd-overlay" }, state.active ? React.createElement("div", { className: "wbd-shield" }, React.createElement("div", { className: "wbd-shield-inner" }, React.createElement("div", { className: "wbd-shield-icon" }, "📥"), React.createElement("div", { className: "wbd-shield-title" }, "松开以接收文件"), React.createElement("div", { className: "wbd-shield-sub" }, state.count + " 项 · 将作为引用气泡插入输入框光标处"), React.createElement("div", { className: "wbd-shield-hint" }, "文件(含图片)将缓存至 ~/.dsh-drops 并引用绝对路径"))) : null, state.toast ? React.createElement("div", { className: "wbd-toast" + (state.toast.level === "error" ? " wbd-error" : "") }, state.toast.text) : null);
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
//#endregion
|
|
314
|
+
//#region src/client/components/pick-button.tsx
|
|
315
|
+
/**
|
|
316
|
+
* 📎 引用按钮(挂在 conversation.input.left 列表槽):
|
|
317
|
+
* 统一走 <input type=file>(多选 / webkitdirectory)→ 立即插入气泡 →
|
|
318
|
+
* 后台缓存(所有浏览器行为一致)。
|
|
319
|
+
*/
|
|
320
|
+
function createPickButtonComponent(React, bus, handlers) {
|
|
321
|
+
return function PickButton() {
|
|
322
|
+
const [open, setOpen] = React.useState(false);
|
|
323
|
+
const newBatch = () => "drop-" + Date.now().toString(36);
|
|
324
|
+
const pickFolder = async () => {
|
|
325
|
+
setOpen(false);
|
|
326
|
+
const input = document.createElement("input");
|
|
327
|
+
input.type = "file";
|
|
328
|
+
input.setAttribute("webkitdirectory", "");
|
|
329
|
+
input.onchange = () => {
|
|
330
|
+
const files = [];
|
|
331
|
+
const tops = [];
|
|
332
|
+
for (const f of Array.from(input.files ?? [])) {
|
|
333
|
+
files.push({
|
|
334
|
+
rel: f.webkitRelativePath || f.name,
|
|
335
|
+
name: f.name,
|
|
336
|
+
size: f.size,
|
|
337
|
+
file: f
|
|
338
|
+
});
|
|
339
|
+
const top = (f.webkitRelativePath || "").split("/")[0];
|
|
340
|
+
if (top !== "" && !tops.includes(top)) tops.push(top);
|
|
341
|
+
}
|
|
342
|
+
if (files.length > 0) handlers.acceptTree(files, tops, newBatch());
|
|
343
|
+
};
|
|
344
|
+
input.click();
|
|
345
|
+
};
|
|
346
|
+
const pickFiles = async () => {
|
|
347
|
+
setOpen(false);
|
|
348
|
+
const input = document.createElement("input");
|
|
349
|
+
input.type = "file";
|
|
350
|
+
input.multiple = true;
|
|
351
|
+
input.onchange = () => {
|
|
352
|
+
const files = [];
|
|
353
|
+
for (const f of Array.from(input.files ?? [])) files.push({
|
|
354
|
+
rel: f.name,
|
|
334
355
|
name: f.name,
|
|
335
356
|
size: f.size,
|
|
336
357
|
file: f
|
|
337
358
|
});
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (files.length > 0) handlers.acceptTree(files, tops, newBatch());
|
|
359
|
+
if (files.length > 0) handlers.acceptTree(files, [], newBatch());
|
|
360
|
+
};
|
|
361
|
+
input.click();
|
|
342
362
|
};
|
|
343
|
-
|
|
363
|
+
return React.createElement("div", { className: "wbd-pick" }, React.createElement("button", {
|
|
364
|
+
type: "button",
|
|
365
|
+
className: "wbd-pick-btn",
|
|
366
|
+
title: "引用文件/文件夹(也可直接拖拽或粘贴)",
|
|
367
|
+
onClick: () => setOpen(!open)
|
|
368
|
+
}, "📎"), open ? React.createElement("div", { className: "wbd-pick-menu" }, React.createElement("button", {
|
|
369
|
+
type: "button",
|
|
370
|
+
onClick: pickFiles
|
|
371
|
+
}, "选择文件…"), React.createElement("button", {
|
|
372
|
+
type: "button",
|
|
373
|
+
onClick: pickFolder
|
|
374
|
+
}, "选择文件夹…(保留目录树)")) : null);
|
|
344
375
|
};
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
376
|
+
}
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region src/client/css.ts
|
|
379
|
+
/** 包内样式(styles.insert 注入,随插件 Run 生命周期清理) */
|
|
380
|
+
const CSS = [
|
|
381
|
+
".wbd-overlay{position:fixed;inset:0;pointer-events:none;z-index:2147483000}",
|
|
382
|
+
".wbd-shield{position:fixed;inset:0;pointer-events:auto;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--dsw-color-bg,#0e1116) 72%,transparent);backdrop-filter:blur(2px)}",
|
|
383
|
+
".wbd-shield-inner{min-width:340px;max-width:520px;padding:28px 36px;text-align:center;border:2px dashed var(--dsw-alias-border-l3,#4c9aff);border-radius:14px;background:var(--dsw-color-bg-elevated,#161b24);box-shadow:0 12px 48px rgba(0,0,0,.45)}",
|
|
384
|
+
".wbd-shield-icon{font-size:34px;line-height:1}",
|
|
385
|
+
".wbd-shield-title{margin-top:10px;font-size:17px;font-weight:600;color:var(--dsw-alias-label-primary,#f2f4f8)}",
|
|
386
|
+
".wbd-shield-sub{margin-top:6px;font-size:13px;color:var(--dsw-alias-label-secondary,#aab2c0)}",
|
|
387
|
+
".wbd-shield-hint{margin-top:10px;font-size:12px;color:var(--dsw-alias-label-tertiary,#6b7280)}",
|
|
388
|
+
".wbd-toast{position:fixed;right:24px;bottom:132px;max-width:360px;padding:9px 14px;border-radius:10px;background:var(--dsw-color-bg-elevated,#1c222e);border:1px solid var(--dsw-alias-border-l3,#3a4252);color:var(--dsw-alias-label-primary,#f2f4f8);font-size:13px;box-shadow:0 8px 28px rgba(0,0,0,.35);pointer-events:auto}",
|
|
389
|
+
".wbd-toast.wbd-error{border-color:#b3453f}",
|
|
390
|
+
".wbd-cards{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:14px 0 4px;font-size:13px}",
|
|
391
|
+
".wbd-cards-label{color:var(--dsw-alias-label-tertiary,#6b7280);margin-right:4px}",
|
|
392
|
+
".wbd-card{display:inline-flex;align-items:center;gap:7px;max-width:340px;padding:4px 10px 4px 8px;border:1px solid var(--dsw-alias-border-l2,#2c3342);border-radius:8px;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.04));color:var(--dsw-alias-label-primary,#f2f4f8);font:inherit;font-size:13px;cursor:pointer;text-align:left}",
|
|
393
|
+
".wbd-card:hover{border-color:var(--dsw-alias-border-l3,#4c9aff)}",
|
|
394
|
+
".wbd-card-icon{flex:none;font-size:15px}",
|
|
395
|
+
".wbd-card-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
396
|
+
".wbd-card-sub{flex:none;color:var(--dsw-alias-label-tertiary,#6b7280);font-size:12px}",
|
|
397
|
+
".wbd-pick{position:relative;display:inline-flex}",
|
|
398
|
+
".wbd-pick-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#aab2c0);font-size:15px;cursor:pointer}",
|
|
399
|
+
".wbd-pick-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06));color:var(--dsw-alias-label-primary,#f2f4f8)}",
|
|
400
|
+
".wbd-pick-menu{position:absolute;left:0;bottom:calc(100% + 8px);display:flex;flex-direction:column;min-width:220px;padding:6px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2,#2c3342);background:var(--dsw-color-bg-elevated,#1c222e);box-shadow:0 10px 32px rgba(0,0,0,.4);z-index:10}",
|
|
401
|
+
".wbd-pick-menu button{display:block;width:100%;padding:8px 10px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary,#f2f4f8);font:inherit;font-size:13px;text-align:left;cursor:pointer}",
|
|
402
|
+
".wbd-pick-menu button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}",
|
|
403
|
+
"/* ---- 引用气泡换肤:主题色圆角矩形(原子删除由输入机原生保证) ---- */",
|
|
404
|
+
"/* 关键:padding 用等量负 margin 抵消、描边用 box-shadow(不占布局)—— */",
|
|
405
|
+
"/* 气泡外部宽度与 textarea 字符宽度完全一致,backdrop 与光标严格对齐 */",
|
|
406
|
+
"[data-decoration=\"chip\"]{padding:0 8px !important;margin:0 -8px !important;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 14%,transparent) !important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 45%,transparent);color:var(--dsw-alias-brand-primary,#4c9aff) !important;font-weight:500}",
|
|
407
|
+
"[data-decoration=\"chip\"]:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 22%,transparent) !important}",
|
|
408
|
+
"[data-decoration=\"chip\"][data-invalid=\"true\"]{color:var(--dsw-alias-state-error-primary,#e56a64) !important;background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e56a64) 14%,transparent) !important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-state-error-primary,#e56a64) 45%,transparent) !important}",
|
|
409
|
+
"[data-decoration=\"chip\"] [class*=\"chipTriggerGlyph\"]{font-size:12px;opacity:.8}"
|
|
410
|
+
].join("\n");
|
|
411
|
+
//#endregion
|
|
412
|
+
//#region src/client/definitions.ts
|
|
413
|
+
const boundaryDef = {
|
|
414
|
+
kind: "workbuddy-turn-boundary",
|
|
415
|
+
match: (event) => event.type === "turn/start" ? {
|
|
416
|
+
id: String(event.data.turn),
|
|
417
|
+
role: "start"
|
|
418
|
+
} : event.type === "turn/end" ? {
|
|
419
|
+
id: String(event.data.turn),
|
|
420
|
+
role: "update"
|
|
421
|
+
} : null,
|
|
422
|
+
start: (_context, match) => {
|
|
423
|
+
if (match.event.type !== "turn/start") throw new Error("workbuddy-turn-boundary start requires turn/start");
|
|
424
|
+
return {
|
|
425
|
+
turn: match.event.data.turn,
|
|
426
|
+
startSeq: match.event.seq
|
|
359
427
|
};
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
return React.createElement("div", { className: "wbd-pick" }, React.createElement("button", {
|
|
363
|
-
type: "button",
|
|
364
|
-
className: "wbd-pick-btn",
|
|
365
|
-
title: "引用文件/文件夹(也可直接拖拽或粘贴)",
|
|
366
|
-
onClick: () => setOpen(!open)
|
|
367
|
-
}, "📎"), open ? React.createElement("div", { className: "wbd-pick-menu" }, React.createElement("button", {
|
|
368
|
-
type: "button",
|
|
369
|
-
onClick: pickFiles
|
|
370
|
-
}, "选择文件…"), React.createElement("button", {
|
|
371
|
-
type: "button",
|
|
372
|
-
onClick: pickFolder
|
|
373
|
-
}, "选择文件夹…(保留目录树)")) : null);
|
|
428
|
+
},
|
|
429
|
+
update: (context) => context.state
|
|
374
430
|
};
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
".wbd-pick-menu button{display:block;width:100%;padding:8px 10px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary,#f2f4f8);font:inherit;font-size:13px;text-align:left;cursor:pointer}",
|
|
401
|
-
".wbd-pick-menu button:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}",
|
|
402
|
-
"/* ---- 引用气泡换肤:主题色圆角矩形(原子删除由输入机原生保证) ---- */",
|
|
403
|
-
"/* 关键:padding 用等量负 margin 抵消、描边用 box-shadow(不占布局)—— */",
|
|
404
|
-
"/* 气泡外部宽度与 textarea 字符宽度完全一致,backdrop 与光标严格对齐 */",
|
|
405
|
-
"[data-decoration=\"chip\"]{padding:0 8px !important;margin:0 -8px !important;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 14%,transparent) !important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 45%,transparent);color:var(--dsw-alias-brand-primary,#4c9aff) !important;font-weight:500}",
|
|
406
|
-
"[data-decoration=\"chip\"]:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4c9aff) 22%,transparent) !important}",
|
|
407
|
-
"[data-decoration=\"chip\"][data-invalid=\"true\"]{color:var(--dsw-alias-state-error-primary,#e56a64) !important;background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#e56a64) 14%,transparent) !important;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dsw-alias-state-error-primary,#e56a64) 45%,transparent) !important}",
|
|
408
|
-
"[data-decoration=\"chip\"] [class*=\"chipTriggerGlyph\"]{font-size:12px;opacity:.8}"
|
|
409
|
-
].join("\n");
|
|
410
|
-
//#endregion
|
|
411
|
-
//#region src/client/definitions.ts
|
|
412
|
-
const boundaryDef = {
|
|
413
|
-
kind: "workbuddy-turn-boundary",
|
|
414
|
-
match: (event) => event.type === "turn/start" ? {
|
|
415
|
-
id: String(event.data.turn),
|
|
416
|
-
role: "start"
|
|
417
|
-
} : event.type === "turn/end" ? {
|
|
418
|
-
id: String(event.data.turn),
|
|
419
|
-
role: "update"
|
|
420
|
-
} : null,
|
|
421
|
-
start: (_context, match) => {
|
|
422
|
-
if (match.event.type !== "turn/start") throw new Error("workbuddy-turn-boundary start requires turn/start");
|
|
423
|
-
return {
|
|
424
|
-
turn: match.event.data.turn,
|
|
425
|
-
startSeq: match.event.seq
|
|
426
|
-
};
|
|
427
|
-
},
|
|
428
|
-
update: (context) => context.state
|
|
429
|
-
};
|
|
430
|
-
const fileRefsDef = {
|
|
431
|
-
kind: "workbuddy-file-refs",
|
|
432
|
-
match: (event) => {
|
|
433
|
-
if (event.type === "user/message" && event.data?.source?.kind === "user" && event.data.id !== void 0) return {
|
|
434
|
-
id: String(event.data.id),
|
|
435
|
-
role: "start"
|
|
436
|
-
};
|
|
437
|
-
if (event.type === "turn/start" && event.data.turn !== void 0) return {
|
|
438
|
-
id: "turn-" + String(event.data.turn),
|
|
439
|
-
role: "start"
|
|
440
|
-
};
|
|
441
|
-
return null;
|
|
442
|
-
},
|
|
443
|
-
start: (context, match, reader) => {
|
|
444
|
-
if (match.event.type === "turn/start") {
|
|
445
|
-
let refs = [];
|
|
446
|
-
const prev = reader.previous("workbuddy-file-refs");
|
|
447
|
-
const prevBoundary = reader.previous("workbuddy-turn-boundary");
|
|
448
|
-
if (prev !== void 0 && prev.state !== void 0 && Array.isArray(prev.state.refs)) {
|
|
449
|
-
if (prevBoundary === void 0 || prevBoundary.state === void 0 || prev.startSeq > prevBoundary.startSeq) refs = prev.state.refs;
|
|
431
|
+
const fileRefsDef = {
|
|
432
|
+
kind: "workbuddy-file-refs",
|
|
433
|
+
match: (event) => {
|
|
434
|
+
if (event.type === "user/message" && event.data?.source?.kind === "user" && event.data.id !== void 0) return {
|
|
435
|
+
id: String(event.data.id),
|
|
436
|
+
role: "start"
|
|
437
|
+
};
|
|
438
|
+
if (event.type === "turn/start" && event.data.turn !== void 0) return {
|
|
439
|
+
id: "turn-" + String(event.data.turn),
|
|
440
|
+
role: "start"
|
|
441
|
+
};
|
|
442
|
+
return null;
|
|
443
|
+
},
|
|
444
|
+
start: (context, match, reader) => {
|
|
445
|
+
if (match.event.type === "turn/start") {
|
|
446
|
+
let refs = [];
|
|
447
|
+
const prev = reader.previous("workbuddy-file-refs");
|
|
448
|
+
const prevBoundary = reader.previous("workbuddy-turn-boundary");
|
|
449
|
+
if (prev !== void 0 && prev.state !== void 0 && Array.isArray(prev.state.refs)) {
|
|
450
|
+
if (prevBoundary === void 0 || prevBoundary.state === void 0 || prev.startSeq > prevBoundary.startSeq) refs = prev.state.refs;
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
turn: match.event.data.turn,
|
|
454
|
+
refs
|
|
455
|
+
};
|
|
450
456
|
}
|
|
457
|
+
const ev = match.event;
|
|
451
458
|
return {
|
|
452
|
-
|
|
453
|
-
refs
|
|
459
|
+
seq: ev.seq,
|
|
460
|
+
refs: extractRefs(ev.data?.content ?? [])
|
|
461
|
+
};
|
|
462
|
+
},
|
|
463
|
+
update: (context) => context.state,
|
|
464
|
+
buildLocationData: (context, scope) => {
|
|
465
|
+
if (scope !== "turn") return null;
|
|
466
|
+
const s = context.state;
|
|
467
|
+
if (s === void 0 || s.turn === void 0 || !Array.isArray(s.refs) || s.refs.length === 0) return null;
|
|
468
|
+
return {
|
|
469
|
+
kind: "turn",
|
|
470
|
+
turn: s.turn,
|
|
471
|
+
key: "workbuddy-file-refs",
|
|
472
|
+
value: { refs: s.refs }
|
|
454
473
|
};
|
|
455
474
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
475
|
+
};
|
|
476
|
+
/** turnTail chain selector:仅当该轮次存在文件引用时返回 matched,避免抢占其他链条目 */
|
|
477
|
+
function selectTurnFileRefs(owner) {
|
|
478
|
+
const t = owner !== null && owner !== void 0 ? owner.turn : void 0;
|
|
479
|
+
if (t === void 0 || t.data === void 0 || typeof t.data.get !== "function") return null;
|
|
480
|
+
const data = t.data.get("workbuddy-file-refs");
|
|
481
|
+
if (data === void 0 || data === null || !Array.isArray(data.refs) || data.refs.length === 0) return null;
|
|
482
|
+
return { refs: data.refs };
|
|
483
|
+
}
|
|
484
|
+
//#endregion
|
|
485
|
+
//#region src/client/lib/bus.ts
|
|
486
|
+
function createDropBus() {
|
|
487
|
+
let state = {
|
|
488
|
+
active: false,
|
|
489
|
+
count: 0,
|
|
490
|
+
toast: null
|
|
460
491
|
};
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
return {
|
|
468
|
-
kind: "turn",
|
|
469
|
-
turn: s.turn,
|
|
470
|
-
key: "workbuddy-file-refs",
|
|
471
|
-
value: { refs: s.refs }
|
|
492
|
+
const subs = /* @__PURE__ */ new Set();
|
|
493
|
+
let timer = null;
|
|
494
|
+
const get = () => state;
|
|
495
|
+
const set = (next) => {
|
|
496
|
+
state = next;
|
|
497
|
+
for (const fn of subs) fn(state);
|
|
472
498
|
};
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
if (t === void 0 || t.data === void 0 || typeof t.data.get !== "function") return null;
|
|
479
|
-
const data = t.data.get("workbuddy-file-refs");
|
|
480
|
-
if (data === void 0 || data === null || !Array.isArray(data.refs) || data.refs.length === 0) return null;
|
|
481
|
-
return { refs: data.refs };
|
|
482
|
-
}
|
|
483
|
-
//#endregion
|
|
484
|
-
//#region src/client/lib/bus.ts
|
|
485
|
-
function createDropBus() {
|
|
486
|
-
let state = {
|
|
487
|
-
active: false,
|
|
488
|
-
count: 0,
|
|
489
|
-
toast: null
|
|
490
|
-
};
|
|
491
|
-
const subs = /* @__PURE__ */ new Set();
|
|
492
|
-
let timer = null;
|
|
493
|
-
const get = () => state;
|
|
494
|
-
const set = (next) => {
|
|
495
|
-
state = next;
|
|
496
|
-
for (const fn of subs) fn(state);
|
|
497
|
-
};
|
|
498
|
-
const subscribe = (fn) => {
|
|
499
|
-
subs.add(fn);
|
|
500
|
-
return () => {
|
|
501
|
-
subs.delete(fn);
|
|
499
|
+
const subscribe = (fn) => {
|
|
500
|
+
subs.add(fn);
|
|
501
|
+
return () => {
|
|
502
|
+
subs.delete(fn);
|
|
503
|
+
};
|
|
502
504
|
};
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (timer !== null) clearTimeout(timer);
|
|
506
|
-
set({
|
|
507
|
-
...state,
|
|
508
|
-
toast: {
|
|
509
|
-
text: String(text),
|
|
510
|
-
level
|
|
511
|
-
}
|
|
512
|
-
});
|
|
513
|
-
timer = setTimeout(() => {
|
|
514
|
-
timer = null;
|
|
505
|
+
const toast = (text, level = "info") => {
|
|
506
|
+
if (timer !== null) clearTimeout(timer);
|
|
515
507
|
set({
|
|
516
508
|
...state,
|
|
517
|
-
toast:
|
|
509
|
+
toast: {
|
|
510
|
+
text: String(text),
|
|
511
|
+
level
|
|
512
|
+
}
|
|
518
513
|
});
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
if (Array.from(dt.types ?? []).includes("Files")) return true;
|
|
534
|
-
if (dt.items) {
|
|
535
|
-
for (const it of Array.from(dt.items)) if (it.kind === "file") return true;
|
|
514
|
+
timer = setTimeout(() => {
|
|
515
|
+
timer = null;
|
|
516
|
+
set({
|
|
517
|
+
...state,
|
|
518
|
+
toast: null
|
|
519
|
+
});
|
|
520
|
+
}, 4600);
|
|
521
|
+
};
|
|
522
|
+
return {
|
|
523
|
+
get,
|
|
524
|
+
set,
|
|
525
|
+
subscribe,
|
|
526
|
+
toast
|
|
527
|
+
};
|
|
536
528
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
* 关键:DataTransfer 只在事件同步阶段有效 —— getAsFile / webkitGetAsEntry
|
|
546
|
-
* 必须在事件处理器内、任何 await 之前完成调用,否则浏览器清空 DataTransfer
|
|
547
|
-
* 后全部返回 null。本函数专门在同步阶段收集 File / Entry。
|
|
548
|
-
*/
|
|
549
|
-
function syncCollect(dt) {
|
|
550
|
-
const synced = [];
|
|
551
|
-
for (const it of Array.from(dt.items)) {
|
|
552
|
-
if (it.kind !== "file") continue;
|
|
553
|
-
let entry = null;
|
|
554
|
-
try {
|
|
555
|
-
const getter = it.webkitGetAsEntry ?? it.getAsEntry;
|
|
556
|
-
entry = typeof getter === "function" ? getter() : null;
|
|
557
|
-
} catch {
|
|
558
|
-
entry = null;
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/client/lib/drop.ts
|
|
531
|
+
/** 任何文件拖拽都接管;items 不可用(Firefox dragenter/dragover)时按 types 兜底 */
|
|
532
|
+
function interceptable(dt) {
|
|
533
|
+
if (dt === null || dt === void 0) return false;
|
|
534
|
+
if (Array.from(dt.types ?? []).includes("Files")) return true;
|
|
535
|
+
if (dt.items) {
|
|
536
|
+
for (const it of Array.from(dt.items)) if (it.kind === "file") return true;
|
|
559
537
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if (entry === null && file === null) continue;
|
|
567
|
-
synced.push({
|
|
568
|
-
entry,
|
|
569
|
-
file
|
|
570
|
-
});
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
function countFiles(dt) {
|
|
541
|
+
let n = 0;
|
|
542
|
+
for (const it of Array.from(dt.items)) if (it.kind === "file") n += 1;
|
|
543
|
+
return n;
|
|
571
544
|
}
|
|
572
|
-
return synced;
|
|
573
|
-
}
|
|
574
|
-
function createDropHandlers(deps) {
|
|
575
|
-
const { bus, insert } = deps;
|
|
576
|
-
const submitRefs = async (refs, jobs, batch) => {
|
|
577
|
-
if (refs.length === 0) {
|
|
578
|
-
bus.toast("无法读取拖入的内容", "error");
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
const inserted = await insert(refs);
|
|
582
|
-
if (inserted > 0) bus.toast("已引用 " + inserted + " 项,文件正在后台缓存");
|
|
583
|
-
else bus.toast("未能插入引用(见上方提示)", "error");
|
|
584
|
-
deps.enqueueUpload(jobs, batch);
|
|
585
|
-
};
|
|
586
|
-
/** 文件选择框路径(已有 TreeFile 列表) */
|
|
587
|
-
const acceptTree = async (files, dirTops, batch) => {
|
|
588
|
-
const root = await deps.ensureRoot();
|
|
589
|
-
if (root === null) {
|
|
590
|
-
bus.toast("无法获取缓存目录,请重试", "error");
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
const refs = [];
|
|
594
|
-
for (const top of dirTops) {
|
|
595
|
-
const dirPath = root + "/" + batch + "/" + top;
|
|
596
|
-
refs.push({
|
|
597
|
-
label: top,
|
|
598
|
-
reference: {
|
|
599
|
-
source: "workbuddy",
|
|
600
|
-
ref: mentionFor(dirPath, true),
|
|
601
|
-
label: top,
|
|
602
|
-
appearance: "folder",
|
|
603
|
-
clipboardText: mentionFor(dirPath, true)
|
|
604
|
-
}
|
|
605
|
-
});
|
|
606
|
-
}
|
|
607
|
-
for (const f of files) {
|
|
608
|
-
const path = root + "/" + batch + "/" + f.rel;
|
|
609
|
-
refs.push({
|
|
610
|
-
label: f.name,
|
|
611
|
-
reference: {
|
|
612
|
-
source: "workbuddy",
|
|
613
|
-
ref: mentionFor(path, false),
|
|
614
|
-
label: f.name,
|
|
615
|
-
appearance: "file",
|
|
616
|
-
clipboardText: mentionFor(path, false)
|
|
617
|
-
}
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
await submitRefs(refs, files.map((f) => ({
|
|
621
|
-
kind: "file",
|
|
622
|
-
file: f.file,
|
|
623
|
-
rel: f.rel,
|
|
624
|
-
name: f.name
|
|
625
|
-
})), batch);
|
|
626
|
-
};
|
|
627
545
|
/**
|
|
628
|
-
*
|
|
629
|
-
*
|
|
546
|
+
* 关键:DataTransfer 只在事件同步阶段有效 —— getAsFile / webkitGetAsEntry
|
|
547
|
+
* 必须在事件处理器内、任何 await 之前完成调用,否则浏览器清空 DataTransfer
|
|
548
|
+
* 后全部返回 null。本函数专门在同步阶段收集 File / Entry。
|
|
630
549
|
*/
|
|
631
|
-
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
550
|
+
function syncCollect(dt) {
|
|
551
|
+
const synced = [];
|
|
552
|
+
for (const it of Array.from(dt.items)) {
|
|
553
|
+
if (it.kind !== "file") continue;
|
|
554
|
+
let entry = null;
|
|
555
|
+
try {
|
|
556
|
+
const getter = it.webkitGetAsEntry ?? it.getAsEntry;
|
|
557
|
+
entry = typeof getter === "function" ? getter() : null;
|
|
558
|
+
} catch {
|
|
559
|
+
entry = null;
|
|
560
|
+
}
|
|
561
|
+
let file = null;
|
|
562
|
+
try {
|
|
563
|
+
file = typeof it.getAsFile === "function" ? it.getAsFile() : null;
|
|
564
|
+
} catch {
|
|
565
|
+
file = null;
|
|
566
|
+
}
|
|
567
|
+
if (entry === null && file === null) continue;
|
|
568
|
+
synced.push({
|
|
569
|
+
entry,
|
|
570
|
+
file
|
|
571
|
+
});
|
|
636
572
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
573
|
+
return synced;
|
|
574
|
+
}
|
|
575
|
+
function createDropHandlers(deps) {
|
|
576
|
+
const { bus, insert } = deps;
|
|
577
|
+
const submitRefs = async (refs, jobs, batch) => {
|
|
578
|
+
if (refs.length === 0) {
|
|
579
|
+
bus.toast("无法读取拖入的内容", "error");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const inserted = await insert(refs);
|
|
583
|
+
if (inserted > 0) bus.toast("已引用 " + inserted + " 项,文件正在后台缓存");
|
|
584
|
+
else bus.toast("未能插入引用(见上方提示)", "error");
|
|
585
|
+
deps.enqueueUpload(jobs, batch);
|
|
586
|
+
};
|
|
587
|
+
/** 文件选择框路径(已有 TreeFile 列表) */
|
|
588
|
+
const acceptTree = async (files, dirTops, batch) => {
|
|
589
|
+
const root = await deps.ensureRoot();
|
|
590
|
+
if (root === null) {
|
|
591
|
+
bus.toast("无法获取缓存目录,请重试", "error");
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const refs = [];
|
|
595
|
+
for (const top of dirTops) {
|
|
643
596
|
const dirPath = root + "/" + batch + "/" + top;
|
|
644
597
|
refs.push({
|
|
645
598
|
label: top,
|
|
@@ -651,334 +604,408 @@ function createDropHandlers(deps) {
|
|
|
651
604
|
clipboardText: mentionFor(dirPath, true)
|
|
652
605
|
}
|
|
653
606
|
});
|
|
654
|
-
jobs.push({
|
|
655
|
-
kind: "dir",
|
|
656
|
-
entry
|
|
657
|
-
});
|
|
658
|
-
continue;
|
|
659
607
|
}
|
|
660
|
-
|
|
661
|
-
const
|
|
662
|
-
const path = root + "/" + batch + "/" + rel;
|
|
608
|
+
for (const f of files) {
|
|
609
|
+
const path = root + "/" + batch + "/" + f.rel;
|
|
663
610
|
refs.push({
|
|
664
|
-
label:
|
|
611
|
+
label: f.name,
|
|
665
612
|
reference: {
|
|
666
613
|
source: "workbuddy",
|
|
667
614
|
ref: mentionFor(path, false),
|
|
668
|
-
label:
|
|
615
|
+
label: f.name,
|
|
669
616
|
appearance: "file",
|
|
670
617
|
clipboardText: mentionFor(path, false)
|
|
671
618
|
}
|
|
672
619
|
});
|
|
673
|
-
jobs.push({
|
|
674
|
-
kind: "file",
|
|
675
|
-
file,
|
|
676
|
-
rel,
|
|
677
|
-
name: file.name
|
|
678
|
-
});
|
|
679
|
-
continue;
|
|
680
620
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
621
|
+
await submitRefs(refs, files.map((f) => ({
|
|
622
|
+
kind: "file",
|
|
623
|
+
file: f.file,
|
|
624
|
+
rel: f.rel,
|
|
625
|
+
name: f.name
|
|
626
|
+
})), batch);
|
|
627
|
+
};
|
|
628
|
+
/**
|
|
629
|
+
* 核心:先按预分配路径立即插入气泡,再交给后台缓存。
|
|
630
|
+
* synced 必须在事件内同步收集完毕(见 syncCollect)。
|
|
631
|
+
*/
|
|
632
|
+
const acceptAndInsert = async (synced, batch) => {
|
|
633
|
+
const root = await deps.ensureRoot();
|
|
634
|
+
if (root === null) {
|
|
635
|
+
bus.toast("无法获取缓存目录,请重试", "error");
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const refs = [];
|
|
639
|
+
const jobs = [];
|
|
640
|
+
for (const s of synced) {
|
|
641
|
+
const { entry, file } = s;
|
|
642
|
+
if (entry !== null && entry !== void 0 && entry.isDirectory) {
|
|
643
|
+
const top = entry.name;
|
|
644
|
+
const dirPath = root + "/" + batch + "/" + top;
|
|
645
|
+
refs.push({
|
|
646
|
+
label: top,
|
|
647
|
+
reference: {
|
|
648
|
+
source: "workbuddy",
|
|
649
|
+
ref: mentionFor(dirPath, true),
|
|
650
|
+
label: top,
|
|
651
|
+
appearance: "folder",
|
|
652
|
+
clipboardText: mentionFor(dirPath, true)
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
jobs.push({
|
|
656
|
+
kind: "dir",
|
|
657
|
+
entry
|
|
658
|
+
});
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (file !== null && file !== void 0) {
|
|
662
|
+
const rel = file.name;
|
|
686
663
|
const path = root + "/" + batch + "/" + rel;
|
|
687
664
|
refs.push({
|
|
688
|
-
label:
|
|
665
|
+
label: file.name,
|
|
689
666
|
reference: {
|
|
690
667
|
source: "workbuddy",
|
|
691
668
|
ref: mentionFor(path, false),
|
|
692
|
-
label:
|
|
669
|
+
label: file.name,
|
|
693
670
|
appearance: "file",
|
|
694
671
|
clipboardText: mentionFor(path, false)
|
|
695
672
|
}
|
|
696
673
|
});
|
|
697
674
|
jobs.push({
|
|
698
675
|
kind: "file",
|
|
699
|
-
file
|
|
676
|
+
file,
|
|
700
677
|
rel,
|
|
701
|
-
name:
|
|
678
|
+
name: file.name
|
|
702
679
|
});
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (entry !== null && entry !== void 0 && entry.isFile) {
|
|
683
|
+
const fe = entry;
|
|
684
|
+
const f = await new Promise((resolve2) => fe.file((ff) => resolve2(ff), () => resolve2(null)));
|
|
685
|
+
if (f !== null && f !== void 0) {
|
|
686
|
+
const rel = f.name;
|
|
687
|
+
const path = root + "/" + batch + "/" + rel;
|
|
688
|
+
refs.push({
|
|
689
|
+
label: f.name,
|
|
690
|
+
reference: {
|
|
691
|
+
source: "workbuddy",
|
|
692
|
+
ref: mentionFor(path, false),
|
|
693
|
+
label: f.name,
|
|
694
|
+
appearance: "file",
|
|
695
|
+
clipboardText: mentionFor(path, false)
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
jobs.push({
|
|
699
|
+
kind: "file",
|
|
700
|
+
file: f,
|
|
701
|
+
rel,
|
|
702
|
+
name: f.name
|
|
703
|
+
});
|
|
704
|
+
}
|
|
703
705
|
}
|
|
704
706
|
}
|
|
705
|
-
|
|
706
|
-
await submitRefs(refs, jobs, batch);
|
|
707
|
-
};
|
|
708
|
-
const installListeners = () => {
|
|
709
|
-
let dragDepth = 0;
|
|
710
|
-
const onDragEnter = (e) => {
|
|
711
|
-
if (!interceptable(e.dataTransfer)) return;
|
|
712
|
-
e.preventDefault();
|
|
713
|
-
e.stopPropagation();
|
|
714
|
-
dragDepth += 1;
|
|
715
|
-
bus.set({
|
|
716
|
-
...bus.get(),
|
|
717
|
-
active: true,
|
|
718
|
-
count: countFiles(e.dataTransfer)
|
|
719
|
-
});
|
|
707
|
+
await submitRefs(refs, jobs, batch);
|
|
720
708
|
};
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
e
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
709
|
+
const installListeners = () => {
|
|
710
|
+
let dragDepth = 0;
|
|
711
|
+
const onDragEnter = (e) => {
|
|
712
|
+
if (!interceptable(e.dataTransfer)) return;
|
|
713
|
+
e.preventDefault();
|
|
714
|
+
e.stopPropagation();
|
|
715
|
+
dragDepth += 1;
|
|
727
716
|
bus.set({
|
|
728
717
|
...bus.get(),
|
|
729
718
|
active: true,
|
|
730
719
|
count: countFiles(e.dataTransfer)
|
|
731
720
|
});
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
}
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
721
|
+
};
|
|
722
|
+
const onDragOver = (e) => {
|
|
723
|
+
if (!interceptable(e.dataTransfer)) return;
|
|
724
|
+
e.preventDefault();
|
|
725
|
+
e.stopPropagation();
|
|
726
|
+
if (!bus.get().active) {
|
|
727
|
+
dragDepth = 1;
|
|
728
|
+
bus.set({
|
|
729
|
+
...bus.get(),
|
|
730
|
+
active: true,
|
|
731
|
+
count: countFiles(e.dataTransfer)
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
const onDragLeave = (e) => {
|
|
736
|
+
if (!interceptable(e.dataTransfer)) return;
|
|
737
|
+
e.preventDefault();
|
|
738
|
+
e.stopPropagation();
|
|
739
|
+
if (dragDepth > 0) dragDepth -= 1;
|
|
740
|
+
if (dragDepth === 0) bus.set({
|
|
741
|
+
...bus.get(),
|
|
742
|
+
active: false,
|
|
743
|
+
count: 0
|
|
744
|
+
});
|
|
745
|
+
};
|
|
746
|
+
const onDrop = (e) => {
|
|
747
|
+
const dt = e.dataTransfer;
|
|
748
|
+
if (dt === null || dt === void 0) return;
|
|
749
|
+
if (!interceptable(dt)) return;
|
|
750
|
+
e.preventDefault();
|
|
751
|
+
e.stopPropagation();
|
|
752
|
+
dragDepth = 0;
|
|
753
|
+
bus.set({
|
|
754
|
+
...bus.get(),
|
|
755
|
+
active: false,
|
|
756
|
+
count: 0
|
|
757
|
+
});
|
|
758
|
+
const synced = syncCollect(dt);
|
|
759
|
+
if (synced.length === 0) return;
|
|
760
|
+
acceptAndInsert(synced, "drop-" + Date.now().toString(36)).catch((err) => {
|
|
761
|
+
console.error("[workbuddy] drop 处理失败:", err);
|
|
762
|
+
bus.toast("拖入处理失败:" + String(err?.message ?? err), "error");
|
|
763
|
+
});
|
|
764
|
+
};
|
|
765
|
+
const onDragEnd = () => {
|
|
766
|
+
dragDepth = 0;
|
|
767
|
+
bus.set({
|
|
768
|
+
...bus.get(),
|
|
769
|
+
active: false,
|
|
770
|
+
count: 0
|
|
771
|
+
});
|
|
772
|
+
};
|
|
773
|
+
const onPaste = (e) => {
|
|
774
|
+
const cd = e.clipboardData;
|
|
775
|
+
if (cd === null || cd === void 0 || !cd.items) return;
|
|
776
|
+
let hasFile = false;
|
|
777
|
+
for (const it of Array.from(cd.items)) if (it.kind === "file") {
|
|
778
|
+
hasFile = true;
|
|
779
|
+
break;
|
|
780
|
+
}
|
|
781
|
+
if (!hasFile) return;
|
|
782
|
+
e.preventDefault();
|
|
783
|
+
e.stopPropagation();
|
|
784
|
+
const synced = syncCollect(cd);
|
|
785
|
+
if (synced.length === 0) return;
|
|
786
|
+
acceptAndInsert(synced, "drop-" + Date.now().toString(36)).catch((err) => {
|
|
787
|
+
console.error("[workbuddy] 粘贴处理失败:", err);
|
|
788
|
+
bus.toast("粘贴处理失败:" + String(err?.message ?? err), "error");
|
|
789
|
+
});
|
|
790
|
+
};
|
|
791
|
+
window.addEventListener("dragenter", onDragEnter, true);
|
|
792
|
+
window.addEventListener("dragover", onDragOver, true);
|
|
793
|
+
window.addEventListener("dragleave", onDragLeave, true);
|
|
794
|
+
window.addEventListener("drop", onDrop, true);
|
|
795
|
+
window.addEventListener("dragend", onDragEnd, true);
|
|
796
|
+
window.addEventListener("paste", onPaste, true);
|
|
797
|
+
return () => {
|
|
798
|
+
window.removeEventListener("dragenter", onDragEnter, true);
|
|
799
|
+
window.removeEventListener("dragover", onDragOver, true);
|
|
800
|
+
window.removeEventListener("dragleave", onDragLeave, true);
|
|
801
|
+
window.removeEventListener("drop", onDrop, true);
|
|
802
|
+
window.removeEventListener("dragend", onDragEnd, true);
|
|
803
|
+
window.removeEventListener("paste", onPaste, true);
|
|
804
|
+
};
|
|
763
805
|
};
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
active: false,
|
|
769
|
-
count: 0
|
|
770
|
-
});
|
|
806
|
+
return {
|
|
807
|
+
acceptAndInsert,
|
|
808
|
+
acceptTree,
|
|
809
|
+
installListeners
|
|
771
810
|
};
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
811
|
+
}
|
|
812
|
+
//#endregion
|
|
813
|
+
//#region src/client/lib/insert.ts
|
|
814
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
815
|
+
function readCaret(fallbackLen) {
|
|
816
|
+
const el = document.activeElement;
|
|
817
|
+
if (el !== null && el !== void 0 && String(el.tagName).toUpperCase() === "TEXTAREA" && typeof el.selectionStart === "number") return el.selectionStart;
|
|
818
|
+
return fallbackLen;
|
|
819
|
+
}
|
|
820
|
+
function createInsertPipeline(deps) {
|
|
821
|
+
return async function insertItems(items) {
|
|
822
|
+
const sessionId = deps.sessions.list.getSnapshot().current;
|
|
823
|
+
if (sessionId === void 0) {
|
|
824
|
+
deps.toast("请先打开或新建一个会话,再拖入文件", "error");
|
|
825
|
+
return 0;
|
|
779
826
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
acceptAndInsert,
|
|
807
|
-
acceptTree,
|
|
808
|
-
installListeners
|
|
809
|
-
};
|
|
810
|
-
}
|
|
811
|
-
//#endregion
|
|
812
|
-
//#region src/client/lib/insert.ts
|
|
813
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
814
|
-
function readCaret(fallbackLen) {
|
|
815
|
-
const el = document.activeElement;
|
|
816
|
-
if (el !== null && el !== void 0 && String(el.tagName).toUpperCase() === "TEXTAREA" && typeof el.selectionStart === "number") return el.selectionStart;
|
|
817
|
-
return fallbackLen;
|
|
818
|
-
}
|
|
819
|
-
function createInsertPipeline(deps) {
|
|
820
|
-
return async function insertItems(items) {
|
|
821
|
-
const sessionId = deps.sessions.list.getSnapshot().current;
|
|
822
|
-
if (sessionId === void 0) {
|
|
823
|
-
deps.toast("请先打开或新建一个会话,再拖入文件", "error");
|
|
824
|
-
return 0;
|
|
825
|
-
}
|
|
826
|
-
let shell = null;
|
|
827
|
-
try {
|
|
828
|
-
shell = deps.conversation.input.shell(sessionId);
|
|
829
|
-
} catch {
|
|
830
|
-
shell = null;
|
|
831
|
-
}
|
|
832
|
-
let inserted = 0;
|
|
833
|
-
let firstCaret = null;
|
|
834
|
-
for (const item of items) if (shell !== null && shell !== void 0) {
|
|
835
|
-
let ok = false;
|
|
836
|
-
for (let attempt = 0; attempt < 8 && !ok; attempt += 1) {
|
|
837
|
-
try {
|
|
838
|
-
const st = shell.state.getSnapshot();
|
|
839
|
-
let caret = st.draft.length;
|
|
840
|
-
if (firstCaret === null) {
|
|
841
|
-
firstCaret = readCaret(st.draft.length);
|
|
842
|
-
caret = firstCaret;
|
|
827
|
+
let shell = null;
|
|
828
|
+
try {
|
|
829
|
+
shell = deps.conversation.input.shell(sessionId);
|
|
830
|
+
} catch {
|
|
831
|
+
shell = null;
|
|
832
|
+
}
|
|
833
|
+
let inserted = 0;
|
|
834
|
+
let firstCaret = null;
|
|
835
|
+
for (const item of items) if (shell !== null && shell !== void 0) {
|
|
836
|
+
let ok = false;
|
|
837
|
+
for (let attempt = 0; attempt < 8 && !ok; attempt += 1) {
|
|
838
|
+
try {
|
|
839
|
+
const st = shell.state.getSnapshot();
|
|
840
|
+
let caret = st.draft.length;
|
|
841
|
+
if (firstCaret === null) {
|
|
842
|
+
firstCaret = readCaret(st.draft.length);
|
|
843
|
+
caret = firstCaret;
|
|
844
|
+
}
|
|
845
|
+
const pos = Math.min(caret, st.draft.length);
|
|
846
|
+
ok = shell.insertReference(item.reference, {
|
|
847
|
+
start: pos,
|
|
848
|
+
end: pos,
|
|
849
|
+
draftRev: st.draftRev
|
|
850
|
+
});
|
|
851
|
+
} catch {
|
|
852
|
+
ok = false;
|
|
843
853
|
}
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
});
|
|
850
|
-
} catch {
|
|
851
|
-
ok = false;
|
|
854
|
+
if (!ok) await sleep(90);
|
|
855
|
+
}
|
|
856
|
+
if (ok) {
|
|
857
|
+
inserted += 1;
|
|
858
|
+
continue;
|
|
852
859
|
}
|
|
853
|
-
if (!ok) await sleep(90);
|
|
854
|
-
}
|
|
855
|
-
if (ok) {
|
|
856
|
-
inserted += 1;
|
|
857
|
-
continue;
|
|
858
|
-
}
|
|
859
|
-
try {
|
|
860
|
-
const st = shell.state.getSnapshot();
|
|
861
|
-
const pos = Math.min(firstCaret !== null ? firstCaret : st.draft.length, st.draft.length);
|
|
862
|
-
const mention = typeof item.reference.ref === "string" ? item.reference.ref : String(item.label);
|
|
863
|
-
const next = st.draft.slice(0, pos) + mention + " " + st.draft.slice(pos);
|
|
864
|
-
shell.setDraft(next);
|
|
865
|
-
inserted += 1;
|
|
866
|
-
continue;
|
|
867
|
-
} catch (err2) {
|
|
868
860
|
try {
|
|
869
|
-
|
|
861
|
+
const st = shell.state.getSnapshot();
|
|
862
|
+
const pos = Math.min(firstCaret !== null ? firstCaret : st.draft.length, st.draft.length);
|
|
863
|
+
const mention = typeof item.reference.ref === "string" ? item.reference.ref : String(item.label);
|
|
864
|
+
const next = st.draft.slice(0, pos) + mention + " " + st.draft.slice(pos);
|
|
865
|
+
shell.setDraft(next);
|
|
866
|
+
inserted += 1;
|
|
867
|
+
continue;
|
|
868
|
+
} catch (err2) {
|
|
869
|
+
try {
|
|
870
|
+
shell.notify("error", "未能插入引用「" + item.label + "」:" + String(err2?.message ?? err2));
|
|
871
|
+
} catch {}
|
|
872
|
+
}
|
|
873
|
+
} else {
|
|
874
|
+
const text = item.reference.ref;
|
|
875
|
+
const el = document.activeElement;
|
|
876
|
+
if (el !== null && el !== void 0 && String(el.tagName).toUpperCase() === "TEXTAREA") try {
|
|
877
|
+
document.execCommand("insertText", false, text + " ");
|
|
878
|
+
inserted += 1;
|
|
870
879
|
} catch {}
|
|
880
|
+
else deps.toast("输入区不可用,未能插入「" + item.label + "」", "error");
|
|
871
881
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const React = require("react");
|
|
900
|
-
return {
|
|
901
|
-
name: "workbuddy-files",
|
|
902
|
-
apply(ctx) {
|
|
903
|
-
const get = (name) => ctx.get(name);
|
|
904
|
-
const sessions = get("sessions");
|
|
905
|
-
const slots = get("slots");
|
|
906
|
-
const conversation = get("conversation");
|
|
907
|
-
const inputTriggers = get("inputTriggers");
|
|
908
|
-
const conversationEvents = get("conversationEvents");
|
|
909
|
-
const styles = get("styles");
|
|
910
|
-
if (slots === void 0) return;
|
|
911
|
-
const insertStyles = () => styles !== void 0 ? styles.insert(CSS) : () => {};
|
|
912
|
-
const effect = ctx.effect.bind(ctx);
|
|
913
|
-
effect(insertStyles, "workbuddy: styles");
|
|
914
|
-
const bus = createDropBus();
|
|
915
|
-
let rootCache = null;
|
|
916
|
-
dropsHome().then((r) => {
|
|
917
|
-
rootCache = r;
|
|
918
|
-
});
|
|
919
|
-
const ensureRoot = async () => {
|
|
920
|
-
if (rootCache !== null) return rootCache;
|
|
921
|
-
const r = await dropsHome();
|
|
922
|
-
rootCache = r;
|
|
923
|
-
return r;
|
|
924
|
-
};
|
|
925
|
-
const enqueueUpload = (jobs, batch) => {
|
|
926
|
-
runUploadJobs(jobs, batch).then(({ ok, failed }) => {
|
|
927
|
-
if (failed.length > 0) bus.toast("缓存失败 " + failed.length + " 项:" + failed.slice(0, 2).join(";") + (failed.length > 2 ? "…" : ""), "error");
|
|
928
|
-
else if (ok > 0) bus.toast("后台缓存完成:" + ok + " 个文件已就绪");
|
|
929
|
-
}).catch((err) => {
|
|
930
|
-
bus.toast("后台缓存失败:" + String(err?.message ?? err), "error");
|
|
931
|
-
});
|
|
932
|
-
};
|
|
933
|
-
const handlers = createDropHandlers({
|
|
934
|
-
bus,
|
|
935
|
-
insert: createInsertPipeline({
|
|
936
|
-
sessions,
|
|
937
|
-
conversation,
|
|
938
|
-
toast: bus.toast
|
|
939
|
-
}),
|
|
940
|
-
ensureRoot,
|
|
941
|
-
enqueueUpload
|
|
942
|
-
});
|
|
943
|
-
effect(() => handlers.installListeners(), "workbuddy: window listeners");
|
|
944
|
-
if (inputTriggers !== void 0) {
|
|
945
|
-
const source = createAtSource();
|
|
946
|
-
effect(() => inputTriggers.registerSource(source), "workbuddy: @ source");
|
|
882
|
+
return inserted;
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/client/app.ts
|
|
887
|
+
/**
|
|
888
|
+
* Client 半侧实现(factory 模式)。
|
|
889
|
+
*
|
|
890
|
+
* DSH 客户端模块加载器要求 client 产物是「普通副作用脚本」:加载时调用
|
|
891
|
+
* window.__ModuleLoader__.load({ id, factory }),factory 签名 (require) => module,
|
|
892
|
+
* 返回 { apply, inject, name }。react 通过 factory 的 require('react') 取得
|
|
893
|
+
* (参考 dsh-pet 的 src/client/app.ts 与 index.ts)。
|
|
894
|
+
*
|
|
895
|
+
* 纯逻辑模块(lib/*、at-source、definitions)不依赖 react,可安全地
|
|
896
|
+
* 被 tsdown 内联到 bundle 顶层;组件在 factory 内以注入的 React 构造。
|
|
897
|
+
*/
|
|
898
|
+
function makeFactory() {
|
|
899
|
+
return function workbuddyClientFactory(require) {
|
|
900
|
+
const React = require("react");
|
|
901
|
+
return {
|
|
902
|
+
name: "workbuddy-files",
|
|
903
|
+
apply(ctx) {
|
|
904
|
+
try {
|
|
905
|
+
applyClient(ctx, React);
|
|
906
|
+
} catch (err) {
|
|
907
|
+
console.error("[workbuddy-files] client apply 异常:", err);
|
|
908
|
+
}
|
|
947
909
|
}
|
|
948
|
-
|
|
949
|
-
const d1 = conversationEvents.register(boundaryDef);
|
|
950
|
-
const d2 = conversationEvents.register(fileRefsDef);
|
|
951
|
-
return () => {
|
|
952
|
-
if (typeof d1 === "function") d1();
|
|
953
|
-
if (typeof d2 === "function") d2();
|
|
954
|
-
};
|
|
955
|
-
}, "workbuddy: conversation definitions");
|
|
956
|
-
slots.inject("shell.overlay", () => slots.register({
|
|
957
|
-
name: "shell.overlay",
|
|
958
|
-
id: "workbuddy-drop",
|
|
959
|
-
order: 300,
|
|
960
|
-
label: "WorkBuddy 拖拽遮罩"
|
|
961
|
-
}, createOverlayComponent(React, bus)));
|
|
962
|
-
slots.inject("conversation.input.left", () => slots.register({
|
|
963
|
-
name: "conversation.input.left",
|
|
964
|
-
id: "workbuddy-pick",
|
|
965
|
-
order: 0,
|
|
966
|
-
label: "引用文件/文件夹"
|
|
967
|
-
}, createPickButtonComponent(React, bus, handlers)));
|
|
968
|
-
slots.inject("conversation.chat.turnTail", () => slots.register({
|
|
969
|
-
name: "conversation.chat.turnTail",
|
|
970
|
-
select: selectTurnFileRefs
|
|
971
|
-
}, createFileCardsComponent(React)));
|
|
972
|
-
console.log("[workbuddy-files] client 就绪:拖入即插气泡 + 后台缓存 / 统一遮罩 / 文件卡片");
|
|
973
|
-
}
|
|
910
|
+
};
|
|
974
911
|
};
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
912
|
+
}
|
|
913
|
+
function applyClient(ctx, React) {
|
|
914
|
+
const get = (name) => ctx.get(name);
|
|
915
|
+
const sessions = get("sessions");
|
|
916
|
+
const slots = get("slots");
|
|
917
|
+
const conversation = get("conversation");
|
|
918
|
+
const inputTriggers = get("inputTriggers");
|
|
919
|
+
const conversationEvents = get("conversationEvents");
|
|
920
|
+
const styles = get("styles");
|
|
921
|
+
if (slots === void 0) return;
|
|
922
|
+
const injectCssViaHead = (css) => {
|
|
923
|
+
if (typeof document === "undefined") return;
|
|
924
|
+
const tagId = "dsh-workbuddy-files/styles";
|
|
925
|
+
if (document.querySelector("style[data-plugin-css=\"" + tagId + "\"]") !== null) return;
|
|
926
|
+
const tag = document.createElement("style");
|
|
927
|
+
tag.dataset.plugin = "dsh-workbuddy-files";
|
|
928
|
+
tag.dataset.pluginCss = tagId;
|
|
929
|
+
tag.textContent = css;
|
|
930
|
+
document.head.appendChild(tag);
|
|
931
|
+
};
|
|
932
|
+
const insertStyles = () => {
|
|
933
|
+
if (styles !== void 0) return styles.insert(CSS);
|
|
934
|
+
injectCssViaHead(CSS);
|
|
935
|
+
return () => {};
|
|
936
|
+
};
|
|
937
|
+
const effect = ctx.effect.bind(ctx);
|
|
938
|
+
effect(insertStyles, "workbuddy: styles");
|
|
939
|
+
console.log("[workbuddy-files] client apply 开始:services slots=" + (slots !== void 0) + " conversation=" + (conversation !== void 0) + " inputTriggers=" + (inputTriggers !== void 0) + " conversationEvents=" + (conversationEvents !== void 0) + " styles=" + (styles !== void 0));
|
|
940
|
+
const bus = createDropBus();
|
|
941
|
+
let rootCache = null;
|
|
942
|
+
dropsHome().then((r) => {
|
|
943
|
+
rootCache = r;
|
|
944
|
+
});
|
|
945
|
+
const ensureRoot = async () => {
|
|
946
|
+
if (rootCache !== null) return rootCache;
|
|
947
|
+
const r = await dropsHome();
|
|
948
|
+
rootCache = r;
|
|
949
|
+
return r;
|
|
950
|
+
};
|
|
951
|
+
const enqueueUpload = (jobs, batch) => {
|
|
952
|
+
runUploadJobs(jobs, batch).then(({ ok, failed }) => {
|
|
953
|
+
if (failed.length > 0) bus.toast("缓存失败 " + failed.length + " 项:" + failed.slice(0, 2).join(";") + (failed.length > 2 ? "…" : ""), "error");
|
|
954
|
+
else if (ok > 0) bus.toast("后台缓存完成:" + ok + " 个文件已就绪");
|
|
955
|
+
}).catch((err) => {
|
|
956
|
+
bus.toast("后台缓存失败:" + String(err?.message ?? err), "error");
|
|
957
|
+
});
|
|
958
|
+
};
|
|
959
|
+
const handlers = createDropHandlers({
|
|
960
|
+
bus,
|
|
961
|
+
insert: createInsertPipeline({
|
|
962
|
+
sessions,
|
|
963
|
+
conversation,
|
|
964
|
+
toast: bus.toast
|
|
965
|
+
}),
|
|
966
|
+
ensureRoot,
|
|
967
|
+
enqueueUpload
|
|
968
|
+
});
|
|
969
|
+
effect(() => {
|
|
970
|
+
const off = handlers.installListeners();
|
|
971
|
+
console.log("[workbuddy-files] 窗口拖拽/粘贴监听已注册");
|
|
972
|
+
return off;
|
|
973
|
+
}, "workbuddy: window listeners");
|
|
974
|
+
if (inputTriggers !== void 0) {
|
|
975
|
+
const source = createAtSource();
|
|
976
|
+
effect(() => inputTriggers.registerSource(source), "workbuddy: @ source");
|
|
977
|
+
}
|
|
978
|
+
if (conversationEvents !== void 0) effect(() => {
|
|
979
|
+
const d1 = conversationEvents.register(boundaryDef);
|
|
980
|
+
const d2 = conversationEvents.register(fileRefsDef);
|
|
981
|
+
return () => {
|
|
982
|
+
if (typeof d1 === "function") d1();
|
|
983
|
+
if (typeof d2 === "function") d2();
|
|
984
|
+
};
|
|
985
|
+
}, "workbuddy: conversation definitions");
|
|
986
|
+
slots.inject("shell.overlay", () => slots.register({
|
|
987
|
+
name: "shell.overlay",
|
|
988
|
+
id: "workbuddy-drop",
|
|
989
|
+
order: 300,
|
|
990
|
+
label: "WorkBuddy 拖拽遮罩"
|
|
991
|
+
}, createOverlayComponent(React, bus)));
|
|
992
|
+
slots.inject("conversation.input.left", () => slots.register({
|
|
993
|
+
name: "conversation.input.left",
|
|
994
|
+
id: "workbuddy-pick",
|
|
995
|
+
order: 0,
|
|
996
|
+
label: "引用文件/文件夹"
|
|
997
|
+
}, createPickButtonComponent(React, bus, handlers)));
|
|
998
|
+
slots.inject("conversation.chat.turnTail", () => slots.register({
|
|
999
|
+
name: "conversation.chat.turnTail",
|
|
1000
|
+
select: selectTurnFileRefs
|
|
1001
|
+
}, createFileCardsComponent(React)));
|
|
1002
|
+
console.log("[workbuddy-files] client 就绪:拖入即插气泡 + 后台缓存 / 统一遮罩 / 文件卡片");
|
|
1003
|
+
}
|
|
1004
|
+
//#endregion
|
|
1005
|
+
//#region src/client/index.ts
|
|
1006
|
+
window.__ModuleLoader__.load({
|
|
1007
|
+
id: "dsh-workbuddy-files",
|
|
1008
|
+
factory: makeFactory()
|
|
1009
|
+
});
|
|
1010
|
+
//#endregion
|
|
1011
|
+
})();
|