dsh-pocket 2.7.0 → 2.8.0
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/client/api.js +3 -0
- package/client/client.js +193 -82
- package/client/mobile/fileGuard.ts +200 -0
- package/client/mobile/mobile-apply.tsx +38 -6
- package/client/mobile/mobile.css.ts +29 -23
- package/lib/web-rpc.js +53 -0
- package/package.json +1 -1
- package/client/mobile/fileCopy.ts +0 -115
package/client/api.js
CHANGED
|
@@ -15,6 +15,9 @@ export const POCKET_ENDPOINTS = Object.freeze({
|
|
|
15
15
|
lanSetEnabled: 'lan.setEnabled',
|
|
16
16
|
pinSetCustom: 'pin.setCustom',
|
|
17
17
|
pocketReset: 'pocket.reset',
|
|
18
|
+
// 移动端「复制文件内容」(issue #17):手机经此 RPC 让主机读取文件正文,
|
|
19
|
+
// 再写入剪贴板——因为手机无法直接打开电脑上的文件。
|
|
20
|
+
fileRead: 'pocket.fileRead',
|
|
18
21
|
});
|
|
19
22
|
|
|
20
23
|
/** 语义化版本比较:a > b 返回正数,相等 0,a < b 负数(数字段 + 预发布后缀)。 */
|
package/client/client.js
CHANGED
|
@@ -54,7 +54,10 @@ var POCKET_ENDPOINTS = Object.freeze({
|
|
|
54
54
|
lanSetOverride: "lan.setOverride",
|
|
55
55
|
lanSetEnabled: "lan.setEnabled",
|
|
56
56
|
pinSetCustom: "pin.setCustom",
|
|
57
|
-
pocketReset: "pocket.reset"
|
|
57
|
+
pocketReset: "pocket.reset",
|
|
58
|
+
// 移动端「复制文件内容」(issue #17):手机经此 RPC 让主机读取文件正文,
|
|
59
|
+
// 再写入剪贴板——因为手机无法直接打开电脑上的文件。
|
|
60
|
+
fileRead: "pocket.fileRead"
|
|
58
61
|
});
|
|
59
62
|
function compareVersions(a, b) {
|
|
60
63
|
const pa = String(a).replace(/^[vV]/, "").split(".");
|
|
@@ -337,13 +340,21 @@ function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t
|
|
|
337
340
|
));
|
|
338
341
|
}
|
|
339
342
|
|
|
340
|
-
// client/mobile/
|
|
341
|
-
var
|
|
342
|
-
var
|
|
343
|
-
var
|
|
343
|
+
// client/mobile/fileGuard.ts
|
|
344
|
+
var GUARD_MSG = "\u624B\u673A\u4E0A\u65E0\u6CD5\u76F4\u63A5\u6253\u5F00\u7535\u8111\u4E0A\u7684\u6587\u4EF6";
|
|
345
|
+
var WS_LABELS = ["\u6DFB\u52A0\u5DE5\u4F5C\u533A", "\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026", "Add workspace", "Add workspace\u2026"];
|
|
346
|
+
var COPY_LABEL = "\u590D\u5236";
|
|
347
|
+
function looksLikeFilePath(text) {
|
|
348
|
+
const t = (text ?? "").trim();
|
|
349
|
+
if (t.length < 3 || t.length > 320) return false;
|
|
350
|
+
if (/^(\/|~\/|\.\.?\/|[A-Za-z]:\\)/.test(t)) return true;
|
|
351
|
+
if (/\/[\w.\-]+\.\w{1,12}$/.test(t)) return true;
|
|
352
|
+
if (/[\w.\-]+\/[\w.\-]+\.\w{1,12}/.test(t)) return true;
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
344
355
|
async function copyText(text) {
|
|
345
356
|
try {
|
|
346
|
-
if (navigator.clipboard
|
|
357
|
+
if (navigator.clipboard?.writeText) {
|
|
347
358
|
await navigator.clipboard.writeText(text);
|
|
348
359
|
return true;
|
|
349
360
|
}
|
|
@@ -352,69 +363,143 @@ async function copyText(text) {
|
|
|
352
363
|
try {
|
|
353
364
|
const ta = document.createElement("textarea");
|
|
354
365
|
ta.value = text;
|
|
355
|
-
ta.setAttribute("readonly", "");
|
|
356
366
|
ta.style.position = "fixed";
|
|
357
|
-
ta.style.top = "
|
|
358
|
-
ta.style.left = "0";
|
|
367
|
+
ta.style.top = "-9999px";
|
|
359
368
|
ta.style.opacity = "0";
|
|
360
369
|
document.body.appendChild(ta);
|
|
370
|
+
ta.focus();
|
|
361
371
|
ta.select();
|
|
362
|
-
const
|
|
372
|
+
const okCopy = document.execCommand("copy");
|
|
363
373
|
ta.remove();
|
|
364
|
-
return
|
|
374
|
+
return okCopy;
|
|
365
375
|
} catch {
|
|
366
376
|
return false;
|
|
367
377
|
}
|
|
368
378
|
}
|
|
369
|
-
function
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
379
|
+
function startFileGuard(readFile) {
|
|
380
|
+
let toastEl = null;
|
|
381
|
+
let toastTimer = null;
|
|
382
|
+
const showToast = (text) => {
|
|
383
|
+
if (toastEl === null) {
|
|
384
|
+
toastEl = document.createElement("div");
|
|
385
|
+
toastEl.setAttribute("data-mobile-nav", "file-guard-toast");
|
|
386
|
+
Object.assign(toastEl.style, {
|
|
387
|
+
position: "fixed",
|
|
388
|
+
left: "50%",
|
|
389
|
+
bottom: "64px",
|
|
390
|
+
transform: "translateX(-50%)",
|
|
391
|
+
maxWidth: "84vw",
|
|
392
|
+
zIndex: "9999",
|
|
393
|
+
padding: "10px 14px",
|
|
394
|
+
borderRadius: "10px",
|
|
395
|
+
background: "rgba(20,22,28,.92)",
|
|
396
|
+
color: "#fff",
|
|
397
|
+
fontSize: "13px",
|
|
398
|
+
lineHeight: "1.4",
|
|
399
|
+
textAlign: "center",
|
|
400
|
+
fontFamily: "inherit",
|
|
401
|
+
boxShadow: "0 4px 16px rgba(0,0,0,.28)",
|
|
402
|
+
pointerEvents: "none",
|
|
403
|
+
opacity: "0",
|
|
404
|
+
transition: "opacity .18s ease"
|
|
405
|
+
});
|
|
406
|
+
document.body.appendChild(toastEl);
|
|
407
|
+
}
|
|
408
|
+
toastEl.textContent = text;
|
|
409
|
+
requestAnimationFrame(() => {
|
|
410
|
+
if (toastEl !== null) toastEl.style.opacity = "1";
|
|
411
|
+
});
|
|
412
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer);
|
|
413
|
+
toastTimer = window.setTimeout(() => {
|
|
414
|
+
if (toastEl !== null) toastEl.style.opacity = "0";
|
|
415
|
+
}, 2600);
|
|
416
|
+
};
|
|
417
|
+
const onClick = (event) => {
|
|
418
|
+
const target = event.target;
|
|
419
|
+
if (target === null) return;
|
|
420
|
+
const el = target.closest("button, a");
|
|
421
|
+
if (el === null) return;
|
|
422
|
+
if (!looksLikeFilePath(el.textContent)) return;
|
|
376
423
|
event.preventDefault();
|
|
377
|
-
event.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
424
|
+
event.stopImmediatePropagation();
|
|
425
|
+
showToast(GUARD_MSG);
|
|
426
|
+
};
|
|
427
|
+
document.addEventListener("click", onClick, true);
|
|
428
|
+
const injectCopyButtons = () => {
|
|
429
|
+
const links = document.querySelectorAll("button, a");
|
|
430
|
+
links.forEach((el) => {
|
|
431
|
+
if (el.getAttribute("data-mobile-nav-copy") === "1") return;
|
|
432
|
+
const txt = (el.textContent ?? "").trim();
|
|
433
|
+
if (!looksLikeFilePath(txt)) return;
|
|
434
|
+
el.setAttribute("data-mobile-nav-copy", "1");
|
|
435
|
+
const btn = document.createElement("button");
|
|
436
|
+
btn.type = "button";
|
|
437
|
+
btn.setAttribute("data-mobile-nav", "copy-file");
|
|
438
|
+
btn.textContent = COPY_LABEL;
|
|
439
|
+
btn.addEventListener("click", async (e) => {
|
|
440
|
+
e.preventDefault();
|
|
441
|
+
e.stopPropagation();
|
|
442
|
+
const filePath = (el.textContent ?? "").trim();
|
|
443
|
+
btn.disabled = true;
|
|
444
|
+
btn.textContent = "\u2026";
|
|
445
|
+
try {
|
|
446
|
+
const res = await readFile(filePath);
|
|
447
|
+
if (!res?.ok) {
|
|
448
|
+
showToast(res?.error?.message ?? "\u590D\u5236\u5931\u8D25");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const content = res.value?.content ?? "";
|
|
452
|
+
const copied = await copyText(content);
|
|
453
|
+
if (copied) {
|
|
454
|
+
const kb = Math.max(1, Math.round((res.value?.size ?? content.length) / 1024));
|
|
455
|
+
showToast(`\u5DF2\u590D\u5236\u6587\u4EF6\u5185\u5BB9\uFF08${kb} KB\uFF09`);
|
|
456
|
+
} else {
|
|
457
|
+
showToast("\u590D\u5236\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u9009\u62E9");
|
|
458
|
+
}
|
|
459
|
+
} catch (err) {
|
|
460
|
+
showToast(err instanceof Error ? err.message : "\u590D\u5236\u5931\u8D25");
|
|
461
|
+
} finally {
|
|
462
|
+
btn.disabled = false;
|
|
463
|
+
btn.textContent = COPY_LABEL;
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
el.parentElement?.insertBefore(btn, el.nextSibling);
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
injectCopyButtons();
|
|
470
|
+
const copyObserver = new MutationObserver(() => injectCopyButtons());
|
|
471
|
+
copyObserver.observe(document.body, { childList: true, subtree: true });
|
|
472
|
+
const hideWsEntries = () => {
|
|
473
|
+
const checkOne = (node) => {
|
|
474
|
+
if (node.nodeType !== 1) return;
|
|
475
|
+
const el = node;
|
|
476
|
+
const txt = (el.getAttribute("aria-label") ?? el.textContent ?? "").trim();
|
|
477
|
+
if (WS_LABELS.includes(txt)) {
|
|
478
|
+
el.style.display = "none";
|
|
479
|
+
el.setAttribute("data-mobile-nav-hide", "add-workspace");
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
const sel = '[role="menuitem"],[role="option"],li,button,a';
|
|
483
|
+
document.querySelectorAll(sel).forEach(checkOne);
|
|
484
|
+
const observer = new MutationObserver((mutations) => {
|
|
485
|
+
for (const m of mutations) {
|
|
486
|
+
m.addedNodes.forEach((n) => {
|
|
487
|
+
if (n.nodeType !== 1) return;
|
|
488
|
+
checkOne(n);
|
|
489
|
+
n.querySelectorAll?.(sel).forEach(checkOne);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
494
|
+
return () => observer.disconnect();
|
|
412
495
|
};
|
|
413
|
-
const
|
|
414
|
-
observer.observe(document.body, { childList: true, subtree: true });
|
|
415
|
-
scan();
|
|
496
|
+
const disconnectWs = hideWsEntries();
|
|
416
497
|
return () => {
|
|
417
|
-
|
|
498
|
+
document.removeEventListener("click", onClick, true);
|
|
499
|
+
copyObserver.disconnect();
|
|
500
|
+
disconnectWs();
|
|
501
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer);
|
|
502
|
+
toastEl?.remove();
|
|
418
503
|
};
|
|
419
504
|
}
|
|
420
505
|
|
|
@@ -1279,40 +1364,46 @@ var MOBILE_CSS = `
|
|
|
1279
1364
|
gap: 0 !important;
|
|
1280
1365
|
}
|
|
1281
1366
|
|
|
1282
|
-
/* ---------- \
|
|
1283
|
-
\
|
|
1284
|
-
\
|
|
1285
|
-
\
|
|
1367
|
+
/* ---------- \u9690\u85CF\u300C\u6DFB\u52A0\u5DE5\u4F5C\u533A\u300D\u5165\u53E3\uFF08\u624B\u673A\u4E0A\u914D\u5DE5\u4F5C\u533A\u65E0\u610F\u4E49\uFF0Cissue #17 \u4FEE\u6B63\uFF09 ----------
|
|
1368
|
+
\u56FE\u6807\u6309\u94AE\u7684 aria-label \u968F\u8BED\u8A00\u53D8\u5316\uFF08zh\u300C\u6DFB\u52A0\u5DE5\u4F5C\u533A\u300D/ en\u300CAdd workspace\u300D\uFF09\uFF0C
|
|
1369
|
+
\u4E24\u79CD\u90FD\u8986\u76D6\uFF1B\u4E0B\u62C9\u83DC\u5355\u91CC\u7684\u300C\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026\u300D\u9879\u7531 fileGuard.ts \u7684 MutationObserver
|
|
1370
|
+
\u6309\u6587\u6848\u515C\u5E95\u9690\u85CF\uFF08CSS \u9009\u4E0D\u5230\u7EAF\u6587\u672C\u8282\u70B9\uFF09\u3002\u53EA\u5728\u7A84\u5C4F\u751F\u6548\u2014\u2014\u684C\u9762\u7AEF\u7167\u5E38\u4FDD\u7559\u3002 */
|
|
1371
|
+
button[aria-label="\u6DFB\u52A0\u5DE5\u4F5C\u533A"],
|
|
1372
|
+
button[aria-label="\u6DFB\u52A0\u5DE5\u4F5C\u533A\u2026"],
|
|
1373
|
+
button[aria-label="Add workspace"],
|
|
1374
|
+
button[aria-label="Add workspace\u2026"] {
|
|
1375
|
+
display: none !important;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/* ---------- \u6587\u4EF6\u94FE\u63A5\u65C1\u7684\u300C\u590D\u5236\u300D\u6309\u94AE\uFF08issue #17\uFF1A\u590D\u5236\u6587\u4EF6\u5185\u5BB9\uFF09 ----------
|
|
1379
|
+
\u6302\u5728\u5BF9\u8BDD\u91CC\u7684\u6587\u4EF6\u94FE\u63A5\uFF08<button>/<a>\uFF0C\u6587\u6848\u5373\u8DEF\u5F84\uFF09\u7D27\u90BB\u4F4D\u7F6E\uFF0C\u7531 fileGuard.ts
|
|
1380
|
+
\u6CE8\u5165\u3002\u53EA\u5C4F\u5185\u53EF\u89C1\uFF1A\u684C\u9762\u7AEF\u4E0D\u6CE8\u5165\u3001\u4E0D\u663E\u793A\uFF1B\u8FD9\u91CC\u518D\u515C\u5E95\u4E00\u5C42\uFF0C\u907F\u514D\u4EFB\u4F55\u9057\u6F0F\u3002
|
|
1381
|
+
\u6587\u4EF6\u94FE\u63A5\u591A\u4E3A inline\uFF0C\u6309\u94AE\u7528 inline-flex \u7D27\u8DDF\u5176\u540E\u5373\u53EF\u3002 */
|
|
1286
1382
|
[data-mobile-nav="copy-file"] {
|
|
1287
|
-
position: absolute !important;
|
|
1288
|
-
top: 6px !important;
|
|
1289
|
-
right: 6px !important;
|
|
1290
|
-
z-index: 6 !important;
|
|
1291
1383
|
display: inline-flex !important;
|
|
1292
|
-
align-items: center
|
|
1293
|
-
justify-content: center
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1384
|
+
align-items: center;
|
|
1385
|
+
justify-content: center;
|
|
1386
|
+
margin-left: 6px !important;
|
|
1387
|
+
vertical-align: baseline !important;
|
|
1388
|
+
height: 22px !important;
|
|
1389
|
+
padding: 0 8px !important;
|
|
1390
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .14)) !important;
|
|
1391
|
+
border-radius: 6px !important;
|
|
1392
|
+
background: var(--dsw-alias-bg-layer-1, #fff) !important;
|
|
1299
1393
|
color: var(--dsw-alias-label-primary, inherit) !important;
|
|
1300
1394
|
font-family: inherit !important;
|
|
1301
|
-
font-size:
|
|
1395
|
+
font-size: 11px !important;
|
|
1302
1396
|
line-height: 1 !important;
|
|
1303
1397
|
cursor: pointer !important;
|
|
1304
1398
|
-webkit-tap-highlight-color: transparent !important;
|
|
1305
|
-
box-shadow: 0 1px
|
|
1399
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, .12) !important;
|
|
1306
1400
|
}
|
|
1307
1401
|
[data-mobile-nav="copy-file"]:active {
|
|
1308
1402
|
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06)) !important;
|
|
1309
1403
|
}
|
|
1310
|
-
[data-mobile-nav="copy-file"][
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
}
|
|
1314
|
-
[data-mobile-nav="copy-file"][data-copied="0"] {
|
|
1315
|
-
color: var(--dsw-alias-state-danger-primary, #e5484d) !important;
|
|
1404
|
+
[data-mobile-nav="copy-file"][disabled] {
|
|
1405
|
+
opacity: .55 !important;
|
|
1406
|
+
cursor: default !important;
|
|
1316
1407
|
}
|
|
1317
1408
|
}
|
|
1318
1409
|
|
|
@@ -1548,8 +1639,28 @@ function mobileApply(ctx) {
|
|
|
1548
1639
|
ctx.effect(() => {
|
|
1549
1640
|
if (!narrow.matches) return () => {
|
|
1550
1641
|
};
|
|
1551
|
-
|
|
1552
|
-
|
|
1642
|
+
const getWorkspaceCwd = () => {
|
|
1643
|
+
try {
|
|
1644
|
+
const ws = ctx.get?.("workspaces") ?? ctx.workspaces;
|
|
1645
|
+
const list = ws?.list;
|
|
1646
|
+
const arr = Array.isArray(list) ? list : list && typeof list === "object" && "value" in list ? list.value : null;
|
|
1647
|
+
if (Array.isArray(arr)) {
|
|
1648
|
+
for (const w of arr) {
|
|
1649
|
+
const c = w?.cwd ?? w?.root;
|
|
1650
|
+
if (typeof c === "string" && c) return c;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
} catch {
|
|
1654
|
+
}
|
|
1655
|
+
return "";
|
|
1656
|
+
};
|
|
1657
|
+
const readFile = (filePath) => ctx.connection.rpc.call(
|
|
1658
|
+
POCKET_RPC_CHANNEL,
|
|
1659
|
+
POCKET_ENDPOINTS.fileRead,
|
|
1660
|
+
{ path: filePath, cwd: getWorkspaceCwd() }
|
|
1661
|
+
);
|
|
1662
|
+
return startFileGuard(readFile);
|
|
1663
|
+
}, "dsh-mobile-nav: file open guard + copy button + hide add-workspace (issue #17)");
|
|
1553
1664
|
ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
|
|
1554
1665
|
name: "conversation.session.header.actions",
|
|
1555
1666
|
id: "mobile-nav-toggle",
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// 移动端文件守卫(issue #17):dsh-web 在手机上点「文件链接」会触发桌面端
|
|
2
|
+
// workspaces.openPath(open <path>),既打不开(文件在电脑上),又会抛
|
|
3
|
+
// "path open failed". 这里做两件事:
|
|
4
|
+
// 1) 捕获阶段拦截这类激活(点击 / 键盘),改为弹一个提示;
|
|
5
|
+
// 2) 在每个文件链接旁注入一个「复制」按钮,点它经主机 RPC 读文件正文再写剪贴板。
|
|
6
|
+
// 另外隐藏「添加工作区」入口(手机上配工作区无意义)。
|
|
7
|
+
// 移植自 dsh-web-mobile(MIT)。
|
|
8
|
+
//
|
|
9
|
+
// 识别方式:不依赖 dsh-web 的 hash 类名(每次构建都变),只认「文本像文件路径的
|
|
10
|
+
// <button>/<a>」——文件链接按钮的文案就是路径(如 lib/proxy.mjs / /Users/.../x.ts)。
|
|
11
|
+
|
|
12
|
+
/** 手机上点击文件时弹出的提示。 */
|
|
13
|
+
const GUARD_MSG = '手机上无法直接打开电脑上的文件'
|
|
14
|
+
/** 「添加工作区」入口的文案(随语言变化),两种都覆盖。 */
|
|
15
|
+
const WS_LABELS = ['添加工作区', '添加工作区…', 'Add workspace', 'Add workspace…']
|
|
16
|
+
/** 复制按钮文案。 */
|
|
17
|
+
const COPY_LABEL = '复制'
|
|
18
|
+
|
|
19
|
+
/** 主机 fileRead 回调返回结构(与 client/api.js 的 FileReadResult 对齐)。 */
|
|
20
|
+
interface ReadFileResponse {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
value?: { content: string; path: string; size: number };
|
|
23
|
+
error?: { message: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 文本是否像文件路径:绝对路径 / 相对路径 / 带扩展名的目录路径。 */
|
|
27
|
+
function looksLikeFilePath(text: string | null): boolean {
|
|
28
|
+
const t = (text ?? '').trim()
|
|
29
|
+
if (t.length < 3 || t.length > 320) return false
|
|
30
|
+
if (/^(\/|~\/|\.\.?\/|[A-Za-z]:\\)/.test(t)) return true
|
|
31
|
+
if (/\/[\w.\-]+\.\w{1,12}$/.test(t)) return true
|
|
32
|
+
if (/[\w.\-]+\/[\w.\-]+\.\w{1,12}/.test(t)) return true
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 写剪贴板:优先 navigator.clipboard,非安全上下文(局域网 http)回退 execCommand。 */
|
|
37
|
+
async function copyText(text: string): Promise<boolean> {
|
|
38
|
+
try {
|
|
39
|
+
if (navigator.clipboard?.writeText) {
|
|
40
|
+
await navigator.clipboard.writeText(text)
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
} catch { /* 回退 */ }
|
|
44
|
+
try {
|
|
45
|
+
const ta = document.createElement('textarea')
|
|
46
|
+
ta.value = text
|
|
47
|
+
ta.style.position = 'fixed'
|
|
48
|
+
ta.style.top = '-9999px'
|
|
49
|
+
ta.style.opacity = '0'
|
|
50
|
+
document.body.appendChild(ta)
|
|
51
|
+
ta.focus()
|
|
52
|
+
ta.select()
|
|
53
|
+
const okCopy = document.execCommand('copy')
|
|
54
|
+
ta.remove()
|
|
55
|
+
return okCopy
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function startFileGuard(
|
|
62
|
+
readFile: (path: string) => Promise<ReadFileResponse>,
|
|
63
|
+
): () => void {
|
|
64
|
+
// 轻量 toast:自包含,不依赖 dsh-pocket 面板的 React 状态。
|
|
65
|
+
let toastEl: HTMLElement | null = null
|
|
66
|
+
let toastTimer: number | null = null
|
|
67
|
+
const showToast = (text: string): void => {
|
|
68
|
+
if (toastEl === null) {
|
|
69
|
+
toastEl = document.createElement('div')
|
|
70
|
+
toastEl.setAttribute('data-mobile-nav', 'file-guard-toast')
|
|
71
|
+
Object.assign(toastEl.style, {
|
|
72
|
+
position: 'fixed',
|
|
73
|
+
left: '50%',
|
|
74
|
+
bottom: '64px',
|
|
75
|
+
transform: 'translateX(-50%)',
|
|
76
|
+
maxWidth: '84vw',
|
|
77
|
+
zIndex: '9999',
|
|
78
|
+
padding: '10px 14px',
|
|
79
|
+
borderRadius: '10px',
|
|
80
|
+
background: 'rgba(20,22,28,.92)',
|
|
81
|
+
color: '#fff',
|
|
82
|
+
fontSize: '13px',
|
|
83
|
+
lineHeight: '1.4',
|
|
84
|
+
textAlign: 'center',
|
|
85
|
+
fontFamily: 'inherit',
|
|
86
|
+
boxShadow: '0 4px 16px rgba(0,0,0,.28)',
|
|
87
|
+
pointerEvents: 'none',
|
|
88
|
+
opacity: '0',
|
|
89
|
+
transition: 'opacity .18s ease',
|
|
90
|
+
} as CSSStyleDeclaration)
|
|
91
|
+
document.body.appendChild(toastEl)
|
|
92
|
+
}
|
|
93
|
+
toastEl.textContent = text
|
|
94
|
+
requestAnimationFrame(() => {
|
|
95
|
+
if (toastEl !== null) toastEl.style.opacity = '1'
|
|
96
|
+
})
|
|
97
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
98
|
+
toastTimer = window.setTimeout(() => {
|
|
99
|
+
if (toastEl !== null) toastEl.style.opacity = '0'
|
|
100
|
+
}, 2600)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 捕获阶段拦截文件链接的激活。按钮的键盘激活(Enter/Space)会派发 click,
|
|
104
|
+
// 因此只拦 click 即可同时覆盖鼠标与键盘,避免重复处理。
|
|
105
|
+
const onClick = (event: MouseEvent): void => {
|
|
106
|
+
const target = event.target as HTMLElement | null
|
|
107
|
+
if (target === null) return
|
|
108
|
+
const el = target.closest('button, a') as HTMLElement | null
|
|
109
|
+
if (el === null) return
|
|
110
|
+
if (!looksLikeFilePath(el.textContent)) return
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
event.stopImmediatePropagation()
|
|
113
|
+
showToast(GUARD_MSG)
|
|
114
|
+
}
|
|
115
|
+
document.addEventListener('click', onClick, true)
|
|
116
|
+
|
|
117
|
+
// 在文件链接旁注入「复制」按钮:点它经主机 RPC 读文件正文再写剪贴板。
|
|
118
|
+
// 用 data-mobile-nav-copy 标记已处理的链接,避免重复注入;React 重渲染会
|
|
119
|
+
// 产生新元素(无标记),MutationObserver 重新补上按钮。
|
|
120
|
+
const injectCopyButtons = (): void => {
|
|
121
|
+
const links = document.querySelectorAll('button, a')
|
|
122
|
+
links.forEach((el) => {
|
|
123
|
+
if (el.getAttribute('data-mobile-nav-copy') === '1') return
|
|
124
|
+
const txt = (el.textContent ?? '').trim()
|
|
125
|
+
if (!looksLikeFilePath(txt)) return
|
|
126
|
+
el.setAttribute('data-mobile-nav-copy', '1')
|
|
127
|
+
const btn = document.createElement('button')
|
|
128
|
+
btn.type = 'button'
|
|
129
|
+
btn.setAttribute('data-mobile-nav', 'copy-file')
|
|
130
|
+
btn.textContent = COPY_LABEL
|
|
131
|
+
btn.addEventListener('click', async (e) => {
|
|
132
|
+
e.preventDefault()
|
|
133
|
+
e.stopPropagation()
|
|
134
|
+
const filePath = (el.textContent ?? '').trim()
|
|
135
|
+
btn.disabled = true
|
|
136
|
+
btn.textContent = '…'
|
|
137
|
+
try {
|
|
138
|
+
const res = await readFile(filePath)
|
|
139
|
+
if (!res?.ok) {
|
|
140
|
+
showToast(res?.error?.message ?? '复制失败')
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const content = res.value?.content ?? ''
|
|
144
|
+
const copied = await copyText(content)
|
|
145
|
+
if (copied) {
|
|
146
|
+
const kb = Math.max(1, Math.round((res.value?.size ?? content.length) / 1024))
|
|
147
|
+
showToast(`已复制文件内容(${kb} KB)`)
|
|
148
|
+
} else {
|
|
149
|
+
showToast('复制失败,请手动选择')
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
showToast(err instanceof Error ? err.message : '复制失败')
|
|
153
|
+
} finally {
|
|
154
|
+
btn.disabled = false
|
|
155
|
+
btn.textContent = COPY_LABEL
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
el.parentElement?.insertBefore(btn, el.nextSibling)
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
injectCopyButtons()
|
|
162
|
+
const copyObserver = new MutationObserver(() => injectCopyButtons())
|
|
163
|
+
copyObserver.observe(document.body, { childList: true, subtree: true })
|
|
164
|
+
|
|
165
|
+
// 隐藏「添加工作区」入口:图标按钮由 mobile.css.ts 按 aria-label 隐藏;
|
|
166
|
+
// 下拉菜单里的文本项 CSS 选不到,这里按文案兜底(只在新增节点时检查,省开销)。
|
|
167
|
+
const hideWsEntries = (): (() => void) => {
|
|
168
|
+
const checkOne = (node: Node): void => {
|
|
169
|
+
if (node.nodeType !== 1) return
|
|
170
|
+
const el = node as HTMLElement
|
|
171
|
+
const txt = (el.getAttribute('aria-label') ?? el.textContent ?? '').trim()
|
|
172
|
+
if (WS_LABELS.includes(txt)) {
|
|
173
|
+
el.style.display = 'none'
|
|
174
|
+
el.setAttribute('data-mobile-nav-hide', 'add-workspace')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const sel = '[role="menuitem"],[role="option"],li,button,a'
|
|
178
|
+
document.querySelectorAll(sel).forEach(checkOne)
|
|
179
|
+
const observer = new MutationObserver((mutations) => {
|
|
180
|
+
for (const m of mutations) {
|
|
181
|
+
m.addedNodes.forEach((n) => {
|
|
182
|
+
if (n.nodeType !== 1) return
|
|
183
|
+
checkOne(n)
|
|
184
|
+
;(n as HTMLElement).querySelectorAll?.(sel).forEach(checkOne)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
observer.observe(document.body, { childList: true, subtree: true })
|
|
189
|
+
return () => observer.disconnect()
|
|
190
|
+
}
|
|
191
|
+
const disconnectWs = hideWsEntries()
|
|
192
|
+
|
|
193
|
+
return () => {
|
|
194
|
+
document.removeEventListener('click', onClick, true)
|
|
195
|
+
copyObserver.disconnect()
|
|
196
|
+
disconnectWs()
|
|
197
|
+
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
198
|
+
toastEl?.remove()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -3,8 +3,9 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
3
3
|
import { MobileNavToggle } from './MobileNavToggle.tsx'
|
|
4
4
|
import { MobileNavOverlay } from './MobileNavOverlay.tsx'
|
|
5
5
|
import { MobileDrawerFooter } from './MobileDrawerFooter.tsx'
|
|
6
|
-
import {
|
|
6
|
+
import { startFileGuard } from './fileGuard.ts'
|
|
7
7
|
import { MOBILE_CSS } from './mobile.css.ts'
|
|
8
|
+
import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS } from '../api.js'
|
|
8
9
|
import { NS, en, zh } from './locales.ts'
|
|
9
10
|
import type { MobileNavKey } from './locales.ts'
|
|
10
11
|
import { resolveLayout, persistLayoutFromUrl } from './layout-mode.mjs'
|
|
@@ -263,13 +264,44 @@ export function mobileApply(ctx): void {
|
|
|
263
264
|
}
|
|
264
265
|
}, 'dsh-mobile-nav: sheet rise animation replay')
|
|
265
266
|
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
267
|
+
// 移动端文件守卫(issue #17 修正):手机上点 dsh-web 渲染的文件链接会触发桌面
|
|
268
|
+
// 端 workspaces.openPath(open ...) —— 既打不开(路径在电脑上),又会抛
|
|
269
|
+
// "path open failed"。这里在捕获阶段拦截这类点击 / 键盘激活,改为弹一个提示,
|
|
270
|
+
// 并隐藏「添加工作区」入口(手机上配工作区无意义);同时在文件链接旁注入
|
|
271
|
+
// 「复制」按钮,点它经主机 RPC 读取文件正文再写入剪贴板。只挂窄屏。
|
|
269
272
|
ctx.effect(() => {
|
|
270
273
|
if (!narrow.matches) return () => {}
|
|
271
|
-
|
|
272
|
-
|
|
274
|
+
// 尽量拿到当前工作区 cwd(文件链接文案是相对它的),传给主机 RPC 做精确解析;
|
|
275
|
+
// 拿不到就回退到主机 process.cwd()。dsh-web 的 workspaces 服务暴露当前工作区。
|
|
276
|
+
const getWorkspaceCwd = (): string => {
|
|
277
|
+
try {
|
|
278
|
+
const ws = (ctx as unknown as { get?: (k: string) => unknown }).get?.('workspaces')
|
|
279
|
+
?? (ctx as unknown as { workspaces?: unknown }).workspaces
|
|
280
|
+
const list = (ws as { list?: unknown })?.list
|
|
281
|
+
const arr: unknown[] | null = Array.isArray(list)
|
|
282
|
+
? list
|
|
283
|
+
: (list && typeof list === 'object' && 'value' in (list as object)
|
|
284
|
+
? (list as { value: unknown[] }).value
|
|
285
|
+
: null)
|
|
286
|
+
if (Array.isArray(arr)) {
|
|
287
|
+
for (const w of arr) {
|
|
288
|
+
const c = (w as { cwd?: string; root?: string })?.cwd
|
|
289
|
+
?? (w as { cwd?: string; root?: string })?.root
|
|
290
|
+
if (typeof c === 'string' && c) return c
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} catch { /* 忽略,回退 process.cwd() */ }
|
|
294
|
+
return ''
|
|
295
|
+
}
|
|
296
|
+
// 手机侧读文件回调:走 dsh-pocket 的 RPC 通道,由主机侧 fileRead 端点处理。
|
|
297
|
+
const readFile = (filePath: string) =>
|
|
298
|
+
ctx.connection.rpc.call(
|
|
299
|
+
POCKET_RPC_CHANNEL,
|
|
300
|
+
POCKET_ENDPOINTS.fileRead,
|
|
301
|
+
{ path: filePath, cwd: getWorkspaceCwd() },
|
|
302
|
+
) as Promise<{ ok: boolean; value?: { content: string; path: string; size: number }; error?: { message: string } }>
|
|
303
|
+
return startFileGuard(readFile)
|
|
304
|
+
}, 'dsh-mobile-nav: file open guard + copy button + hide add-workspace (issue #17)')
|
|
273
305
|
|
|
274
306
|
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
|
275
307
|
name: 'conversation.session.header.actions',
|
|
@@ -873,40 +873,46 @@ export const MOBILE_CSS = `
|
|
|
873
873
|
gap: 0 !important;
|
|
874
874
|
}
|
|
875
875
|
|
|
876
|
-
/* ----------
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
876
|
+
/* ---------- 隐藏「添加工作区」入口(手机上配工作区无意义,issue #17 修正) ----------
|
|
877
|
+
图标按钮的 aria-label 随语言变化(zh「添加工作区」/ en「Add workspace」),
|
|
878
|
+
两种都覆盖;下拉菜单里的「添加工作区…」项由 fileGuard.ts 的 MutationObserver
|
|
879
|
+
按文案兜底隐藏(CSS 选不到纯文本节点)。只在窄屏生效——桌面端照常保留。 */
|
|
880
|
+
button[aria-label="添加工作区"],
|
|
881
|
+
button[aria-label="添加工作区…"],
|
|
882
|
+
button[aria-label="Add workspace"],
|
|
883
|
+
button[aria-label="Add workspace…"] {
|
|
884
|
+
display: none !important;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/* ---------- 文件链接旁的「复制」按钮(issue #17:复制文件内容) ----------
|
|
888
|
+
挂在对话里的文件链接(<button>/<a>,文案即路径)紧邻位置,由 fileGuard.ts
|
|
889
|
+
注入。只屏内可见:桌面端不注入、不显示;这里再兜底一层,避免任何遗漏。
|
|
890
|
+
文件链接多为 inline,按钮用 inline-flex 紧跟其后即可。 */
|
|
880
891
|
[data-mobile-nav="copy-file"] {
|
|
881
|
-
position: absolute !important;
|
|
882
|
-
top: 6px !important;
|
|
883
|
-
right: 6px !important;
|
|
884
|
-
z-index: 6 !important;
|
|
885
892
|
display: inline-flex !important;
|
|
886
|
-
align-items: center
|
|
887
|
-
justify-content: center
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
+
align-items: center;
|
|
894
|
+
justify-content: center;
|
|
895
|
+
margin-left: 6px !important;
|
|
896
|
+
vertical-align: baseline !important;
|
|
897
|
+
height: 22px !important;
|
|
898
|
+
padding: 0 8px !important;
|
|
899
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .14)) !important;
|
|
900
|
+
border-radius: 6px !important;
|
|
901
|
+
background: var(--dsw-alias-bg-layer-1, #fff) !important;
|
|
893
902
|
color: var(--dsw-alias-label-primary, inherit) !important;
|
|
894
903
|
font-family: inherit !important;
|
|
895
|
-
font-size:
|
|
904
|
+
font-size: 11px !important;
|
|
896
905
|
line-height: 1 !important;
|
|
897
906
|
cursor: pointer !important;
|
|
898
907
|
-webkit-tap-highlight-color: transparent !important;
|
|
899
|
-
box-shadow: 0 1px
|
|
908
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, .12) !important;
|
|
900
909
|
}
|
|
901
910
|
[data-mobile-nav="copy-file"]:active {
|
|
902
911
|
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06)) !important;
|
|
903
912
|
}
|
|
904
|
-
[data-mobile-nav="copy-file"][
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
}
|
|
908
|
-
[data-mobile-nav="copy-file"][data-copied="0"] {
|
|
909
|
-
color: var(--dsw-alias-state-danger-primary, #e5484d) !important;
|
|
913
|
+
[data-mobile-nav="copy-file"][disabled] {
|
|
914
|
+
opacity: .55 !important;
|
|
915
|
+
cursor: default !important;
|
|
910
916
|
}
|
|
911
917
|
}
|
|
912
918
|
|
package/lib/web-rpc.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// dsh-pocket Web RPC(loopback-only):设置页 ⇄ Host 的手机访问通道
|
|
2
2
|
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
3
6
|
import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, redactStatus } from '../client/api.js';
|
|
4
7
|
|
|
8
|
+
/** 单次读取上限:4 MB,避免把大文件塞进剪贴板 / 内存。 */
|
|
9
|
+
const FILE_READ_MAX = 4 * 1024 * 1024;
|
|
10
|
+
|
|
5
11
|
function ok(value) {
|
|
6
12
|
return { ok: true, value };
|
|
7
13
|
}
|
|
@@ -137,6 +143,53 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
|
|
|
137
143
|
if (endpoint === POCKET_ENDPOINTS.version) {
|
|
138
144
|
return ok({ current: runUpdate?.currentVersion?.() ?? null, loaded: runUpdate?.loadedVersion?.() ?? null });
|
|
139
145
|
}
|
|
146
|
+
if (endpoint === POCKET_ENDPOINTS.fileRead) {
|
|
147
|
+
// 移动端「复制文件内容」(issue #17):手机点复制按钮 → 主机读文件正文返回。
|
|
148
|
+
// 路径三种形态:
|
|
149
|
+
// - 绝对路径:直接用;
|
|
150
|
+
// - ~/ 开头:展开为用户 HOME;
|
|
151
|
+
// - 相对路径:相对「客户端传入的 cwd」或「DSH 主机进程 cwd = 工作目录」
|
|
152
|
+
// (用户从自己项目里 `dsh web` 时,二者一致,与 dsh-web 的
|
|
153
|
+
// resolveWorkspacePath(cwd, path) 行为对齐)。安全边界同既有 RPC:
|
|
154
|
+
// 仅本机/隧道经 PIN 可达,等同于你自己操作这台机器。
|
|
155
|
+
const raw = String(payload?.path ?? '').trim();
|
|
156
|
+
if (!raw) return fail('bad-request', '缺少文件路径 | missing path');
|
|
157
|
+
let abs;
|
|
158
|
+
try {
|
|
159
|
+
if (/^~[/\\]?/.test(raw)) {
|
|
160
|
+
// 去掉 ~ 及其后的可选斜杠,再相对 HOME 解析
|
|
161
|
+
abs = path.resolve(os.homedir(), raw.replace(/^~[/\\]?/, ''));
|
|
162
|
+
} else if (path.isAbsolute(raw)) {
|
|
163
|
+
abs = path.resolve(raw);
|
|
164
|
+
} else {
|
|
165
|
+
const base = typeof payload?.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
|
|
166
|
+
abs = path.resolve(base, raw);
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
return fail('bad-request', '路径非法 | invalid path');
|
|
170
|
+
}
|
|
171
|
+
let stat;
|
|
172
|
+
try {
|
|
173
|
+
stat = await fs.stat(abs);
|
|
174
|
+
} catch {
|
|
175
|
+
return fail('bad-request', `文件不存在:${abs} | file not found`);
|
|
176
|
+
}
|
|
177
|
+
if (stat.isDirectory()) return fail('bad-request', '这是目录,不是文件 | it is a directory');
|
|
178
|
+
if (stat.size > FILE_READ_MAX) {
|
|
179
|
+
return fail('bad-request', `文件过大(${(stat.size / 1024 / 1024).toFixed(1)} MB),无法复制 | file too large`);
|
|
180
|
+
}
|
|
181
|
+
let buf;
|
|
182
|
+
try {
|
|
183
|
+
buf = await fs.readFile(abs);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return fail('bad-request', `读取失败:${err?.message ?? String(err)} | read failed`);
|
|
186
|
+
}
|
|
187
|
+
// 二进制检测:前 8KB 含 NUL 字节即视为二进制,文本复制无意义。
|
|
188
|
+
if (buf.subarray(0, 8192).includes(0)) {
|
|
189
|
+
return fail('bad-request', '二进制文件,无法复制文本 | binary file');
|
|
190
|
+
}
|
|
191
|
+
return ok({ content: buf.toString('utf8'), path: abs, size: stat.size });
|
|
192
|
+
}
|
|
140
193
|
if (endpoint === POCKET_ENDPOINTS.update) {
|
|
141
194
|
// 桌面端:更新由 DSH Desktop 管理,这里关闭(不删除,仅禁用)
|
|
142
195
|
if (desktop) return fail('bad-request', '桌面版更新由 DSH Desktop 管理,已在此环境停用 | updates are managed by DSH Desktop here');
|
package/package.json
CHANGED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
// dsh-web-mobile 移植(MIT,见 LICENSE.dsh-web-mobile):移动端「复制文件内容」按钮。
|
|
2
|
-
//
|
|
3
|
-
// issue #17:用户希望手机上能拿到 DSH 在会话窗口里生成的文件。但当前 dsh-web
|
|
4
|
-
// 的 web UI 没有任何下载能力(全部插件 bundle 里搜不到 download / blob: /
|
|
5
|
-
// saveAs / createObjectURL),桌面端的下载按钮在手机经隧道/局域网访问时也拿
|
|
6
|
-
// 不到文件内容。退而求其次——在「文件内容块」(对话里渲染为 <pre> 代码块)的
|
|
7
|
-
// 右上角注入一个复制按钮,把块内文本(即文件内容)写入剪贴板。
|
|
8
|
-
//
|
|
9
|
-
// 关键:只依赖稳定结构 `<pre>`(fenced code 渲染为 `<pre><code>`),不依赖任何
|
|
10
|
-
// hash 类名。dsh-web 每次构建都会换类名,按类名挂会随版本失效。
|
|
11
|
-
|
|
12
|
-
/** 我们注入的按钮标记(复用 mobile-nav 的 data 属性命名空间,避免与插件自身控件冲突)。 */
|
|
13
|
-
const BUTTON_ATTR = 'data-mobile-nav';
|
|
14
|
-
const BUTTON_VALUE = 'copy-file';
|
|
15
|
-
/** 打在代码块容器上:已注入过按钮就不再重复注入(React 重渲染时自愈)。 */
|
|
16
|
-
const MARKER = 'data-mobile-nav-copy';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* 把文本写入剪贴板。优先用 async Clipboard API(隧道/https 下可用);非安全
|
|
20
|
-
* 上下文(局域网 http)下 Clipboard API 会被浏览器拒绝,回退到隐藏 textarea +
|
|
21
|
-
* execCommand('copy')。两者都失败返回 false。
|
|
22
|
-
*/
|
|
23
|
-
export async function copyText(text: string): Promise<boolean> {
|
|
24
|
-
try {
|
|
25
|
-
if (navigator.clipboard && window.isSecureContext) {
|
|
26
|
-
await navigator.clipboard.writeText(text);
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
29
|
-
} catch {
|
|
30
|
-
/* 落到下面的回退路径 */
|
|
31
|
-
}
|
|
32
|
-
try {
|
|
33
|
-
const ta = document.createElement('textarea');
|
|
34
|
-
ta.value = text;
|
|
35
|
-
ta.setAttribute('readonly', '');
|
|
36
|
-
ta.style.position = 'fixed';
|
|
37
|
-
ta.style.top = '0';
|
|
38
|
-
ta.style.left = '0';
|
|
39
|
-
ta.style.opacity = '0';
|
|
40
|
-
document.body.appendChild(ta);
|
|
41
|
-
ta.select();
|
|
42
|
-
const ok = document.execCommand('copy');
|
|
43
|
-
ta.remove();
|
|
44
|
-
return ok;
|
|
45
|
-
} catch {
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** 造一个复制按钮。点击时复制其所在代码卡里的 <pre> 文本。 */
|
|
51
|
-
function createCopyButton(): HTMLButtonElement {
|
|
52
|
-
const btn = document.createElement('button');
|
|
53
|
-
btn.type = 'button';
|
|
54
|
-
btn.setAttribute(BUTTON_ATTR, BUTTON_VALUE);
|
|
55
|
-
btn.textContent = '复制';
|
|
56
|
-
btn.setAttribute('aria-label', '复制文件内容');
|
|
57
|
-
btn.addEventListener('click', async (event) => {
|
|
58
|
-
event.preventDefault();
|
|
59
|
-
event.stopPropagation();
|
|
60
|
-
const card = btn.parentElement;
|
|
61
|
-
const block = card?.querySelector('pre') ?? null;
|
|
62
|
-
const text = block?.textContent ?? '';
|
|
63
|
-
const ok = await copyText(text);
|
|
64
|
-
const prev = btn.textContent;
|
|
65
|
-
btn.textContent = ok ? '已复制' : '复制失败';
|
|
66
|
-
btn.setAttribute('data-copied', ok ? '1' : '0');
|
|
67
|
-
window.setTimeout(() => {
|
|
68
|
-
btn.textContent = prev;
|
|
69
|
-
btn.removeAttribute('data-copied');
|
|
70
|
-
}, 1500);
|
|
71
|
-
});
|
|
72
|
-
return btn;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** 给一个代码块容器注入复制按钮(幂等)。 */
|
|
76
|
-
function injectInto(card: HTMLElement): void {
|
|
77
|
-
if (card.hasAttribute(MARKER)) return;
|
|
78
|
-
card.setAttribute(MARKER, '1');
|
|
79
|
-
if (getComputedStyle(card).position === 'static') card.style.position = 'relative';
|
|
80
|
-
card.appendChild(createCopyButton());
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* 收集对话里需要挂复制按钮的代码块容器。候选 = 任何 `<pre>`(fenced code 与
|
|
85
|
-
* 工具结果/错误块都渲染为 <pre>),跳过已标记 / 空块。返回的是其「容器」
|
|
86
|
-
* (pre 的父元素),按钮挂在容器上、定位在块右上角。
|
|
87
|
-
*/
|
|
88
|
-
export function collectCodeBlocks(root: ParentNode): HTMLElement[] {
|
|
89
|
-
const out: HTMLElement[] = [];
|
|
90
|
-
for (const pre of Array.from(root.querySelectorAll('pre'))) {
|
|
91
|
-
const card = pre.parentElement;
|
|
92
|
-
if (card === null) continue;
|
|
93
|
-
if (card.hasAttribute(MARKER)) continue;
|
|
94
|
-
if ((pre.textContent ?? '').trim().length < 1) continue;
|
|
95
|
-
out.push(card);
|
|
96
|
-
}
|
|
97
|
-
return out;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* 启动注入:MutationObserver 监听全文 DOM,新出现的代码块立即挂按钮;React
|
|
102
|
-
* 重渲染替换了容器时旧按钮随容器消失、新容器会在下次回调里补上(自愈)。
|
|
103
|
-
* @returns 清理函数(断开 observer)。
|
|
104
|
-
*/
|
|
105
|
-
export function startFileCopyInjection(): () => void {
|
|
106
|
-
const scan = (): void => {
|
|
107
|
-
for (const card of collectCodeBlocks(document)) injectInto(card);
|
|
108
|
-
};
|
|
109
|
-
const observer = new MutationObserver(scan);
|
|
110
|
-
observer.observe(document.body, { childList: true, subtree: true });
|
|
111
|
-
scan();
|
|
112
|
-
return () => {
|
|
113
|
-
observer.disconnect();
|
|
114
|
-
};
|
|
115
|
-
}
|