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 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/fileCopy.ts
341
- var BUTTON_ATTR = "data-mobile-nav";
342
- var BUTTON_VALUE = "copy-file";
343
- var MARKER = "data-mobile-nav-copy";
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 && window.isSecureContext) {
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 = "0";
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 ok = document.execCommand("copy");
372
+ const okCopy = document.execCommand("copy");
363
373
  ta.remove();
364
- return ok;
374
+ return okCopy;
365
375
  } catch {
366
376
  return false;
367
377
  }
368
378
  }
369
- function createCopyButton() {
370
- const btn = document.createElement("button");
371
- btn.type = "button";
372
- btn.setAttribute(BUTTON_ATTR, BUTTON_VALUE);
373
- btn.textContent = "\u590D\u5236";
374
- btn.setAttribute("aria-label", "\u590D\u5236\u6587\u4EF6\u5185\u5BB9");
375
- btn.addEventListener("click", async (event) => {
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.stopPropagation();
378
- const card = btn.parentElement;
379
- const block = card?.querySelector("pre") ?? null;
380
- const text = block?.textContent ?? "";
381
- const ok = await copyText(text);
382
- const prev = btn.textContent;
383
- btn.textContent = ok ? "\u5DF2\u590D\u5236" : "\u590D\u5236\u5931\u8D25";
384
- btn.setAttribute("data-copied", ok ? "1" : "0");
385
- window.setTimeout(() => {
386
- btn.textContent = prev;
387
- btn.removeAttribute("data-copied");
388
- }, 1500);
389
- });
390
- return btn;
391
- }
392
- function injectInto(card) {
393
- if (card.hasAttribute(MARKER)) return;
394
- card.setAttribute(MARKER, "1");
395
- if (getComputedStyle(card).position === "static") card.style.position = "relative";
396
- card.appendChild(createCopyButton());
397
- }
398
- function collectCodeBlocks(root) {
399
- const out = [];
400
- for (const pre of Array.from(root.querySelectorAll("pre"))) {
401
- const card = pre.parentElement;
402
- if (card === null) continue;
403
- if (card.hasAttribute(MARKER)) continue;
404
- if ((pre.textContent ?? "").trim().length < 1) continue;
405
- out.push(card);
406
- }
407
- return out;
408
- }
409
- function startFileCopyInjection() {
410
- const scan = () => {
411
- for (const card of collectCodeBlocks(document)) injectInto(card);
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 observer = new MutationObserver(scan);
414
- observer.observe(document.body, { childList: true, subtree: true });
415
- scan();
496
+ const disconnectWs = hideWsEntries();
416
497
  return () => {
417
- observer.disconnect();
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
- /* ---------- \u590D\u5236\u6587\u4EF6\u5185\u5BB9\u6309\u94AE\uFF08issue #17\uFF09 ----------
1283
- \u6302\u5728\u4EE3\u7801/\u6587\u4EF6\u5757\u5BB9\u5668\u53F3\u4E0A\u89D2\uFF08position:relative \u7531 JS \u5728\u6CE8\u5165\u65F6\u8865\u4E0A\uFF09\u3002
1284
- \u53EA\u5C4F\u5185\u53EF\u89C1\uFF1A\u684C\u9762\u7AEF DSH \u81EA\u5E26\u590D\u5236\uFF0C\u4E14\u672C effect \u53EA\u5728 narrow \u4E0B\u6302\u8F7D\uFF0C\u6309\u94AE
1285
- \u6839\u672C\u4E0D\u4F1A\u6CE8\u5165\uFF1B\u8FD9\u91CC\u518D\u515C\u5E95\u4E00\u5C42\uFF0C\u907F\u514D\u4EFB\u4F55\u9057\u6F0F\u3002 */
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 !important;
1293
- justify-content: center !important;
1294
- height: 26px !important;
1295
- padding: 0 10px !important;
1296
- border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)) !important;
1297
- border-radius: 8px !important;
1298
- background: var(--dsw-alias-bg-base, #ffffff) !important;
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: 12px !important;
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 4px rgba(0, 0, 0, .14) !important;
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"][data-copied="1"] {
1311
- color: var(--dsw-alias-state-success-primary, #1a9d54) !important;
1312
- border-color: var(--dsw-alias-state-success-primary, #1a9d54) !important;
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
- return startFileCopyInjection();
1552
- }, "dsh-mobile-nav: file copy button (issue #17)");
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 { startFileCopyInjection } from './fileCopy.ts'
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
- // 复制文件内容按钮(issue #17):dsh-web 在手机上没有可用的下载入口,于是在
267
- // 会话里的代码/文件块右上角注入一个「复制」按钮,把块内文本(文件内容)写
268
- // 入剪贴板。只挂窄屏——桌面端 DSH 自带复制,不需要。
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
- return startFileCopyInjection()
272
- }, 'dsh-mobile-nav: file copy button (issue #17)')
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
- /* ---------- 复制文件内容按钮(issue #17 ----------
877
- 挂在代码/文件块容器右上角(position:relative JS 在注入时补上)。
878
- 只屏内可见:桌面端 DSH 自带复制,且本 effect 只在 narrow 下挂载,按钮
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 !important;
887
- justify-content: center !important;
888
- height: 26px !important;
889
- padding: 0 10px !important;
890
- border: 1px solid var(--dsw-alias-border-l1, rgba(0, 0, 0, .12)) !important;
891
- border-radius: 8px !important;
892
- background: var(--dsw-alias-bg-base, #ffffff) !important;
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: 12px !important;
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 4px rgba(0, 0, 0, .14) !important;
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"][data-copied="1"] {
905
- color: var(--dsw-alias-state-success-primary, #1a9d54) !important;
906
- border-color: var(--dsw-alias-state-success-primary, #1a9d54) !important;
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
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "2.7.0"
83
+ "version": "2.8.0"
84
84
  }
@@ -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
- }