dsh-pocket 2.7.1 → 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 +128 -7
- package/client/mobile/fileGuard.ts +90 -8
- package/client/mobile/mobile-apply.tsx +34 -3
- package/client/mobile/mobile.css.ts +31 -0
- package/lib/web-rpc.js +53 -0
- package/package.json +1 -1
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(".");
|
|
@@ -340,6 +343,7 @@ function MobileDrawerFooter({ useSessions, downloadSessionLog, toggleSidebar, t
|
|
|
340
343
|
// client/mobile/fileGuard.ts
|
|
341
344
|
var GUARD_MSG = "\u624B\u673A\u4E0A\u65E0\u6CD5\u76F4\u63A5\u6253\u5F00\u7535\u8111\u4E0A\u7684\u6587\u4EF6";
|
|
342
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";
|
|
343
347
|
function looksLikeFilePath(text) {
|
|
344
348
|
const t = (text ?? "").trim();
|
|
345
349
|
if (t.length < 3 || t.length > 320) return false;
|
|
@@ -348,10 +352,31 @@ function looksLikeFilePath(text) {
|
|
|
348
352
|
if (/[\w.\-]+\/[\w.\-]+\.\w{1,12}/.test(t)) return true;
|
|
349
353
|
return false;
|
|
350
354
|
}
|
|
351
|
-
function
|
|
352
|
-
|
|
355
|
+
async function copyText(text) {
|
|
356
|
+
try {
|
|
357
|
+
if (navigator.clipboard?.writeText) {
|
|
358
|
+
await navigator.clipboard.writeText(text);
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
} catch {
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const ta = document.createElement("textarea");
|
|
365
|
+
ta.value = text;
|
|
366
|
+
ta.style.position = "fixed";
|
|
367
|
+
ta.style.top = "-9999px";
|
|
368
|
+
ta.style.opacity = "0";
|
|
369
|
+
document.body.appendChild(ta);
|
|
370
|
+
ta.focus();
|
|
371
|
+
ta.select();
|
|
372
|
+
const okCopy = document.execCommand("copy");
|
|
373
|
+
ta.remove();
|
|
374
|
+
return okCopy;
|
|
375
|
+
} catch {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
353
378
|
}
|
|
354
|
-
function startFileGuard() {
|
|
379
|
+
function startFileGuard(readFile) {
|
|
355
380
|
let toastEl = null;
|
|
356
381
|
let toastTimer = null;
|
|
357
382
|
const showToast = (text) => {
|
|
@@ -391,7 +416,7 @@ function startFileGuard() {
|
|
|
391
416
|
};
|
|
392
417
|
const onClick = (event) => {
|
|
393
418
|
const target = event.target;
|
|
394
|
-
if (target === null
|
|
419
|
+
if (target === null) return;
|
|
395
420
|
const el = target.closest("button, a");
|
|
396
421
|
if (el === null) return;
|
|
397
422
|
if (!looksLikeFilePath(el.textContent)) return;
|
|
@@ -400,6 +425,50 @@ function startFileGuard() {
|
|
|
400
425
|
showToast(GUARD_MSG);
|
|
401
426
|
};
|
|
402
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 });
|
|
403
472
|
const hideWsEntries = () => {
|
|
404
473
|
const checkOne = (node) => {
|
|
405
474
|
if (node.nodeType !== 1) return;
|
|
@@ -427,6 +496,7 @@ function startFileGuard() {
|
|
|
427
496
|
const disconnectWs = hideWsEntries();
|
|
428
497
|
return () => {
|
|
429
498
|
document.removeEventListener("click", onClick, true);
|
|
499
|
+
copyObserver.disconnect();
|
|
430
500
|
disconnectWs();
|
|
431
501
|
if (toastTimer !== null) window.clearTimeout(toastTimer);
|
|
432
502
|
toastEl?.remove();
|
|
@@ -1304,6 +1374,37 @@ var MOBILE_CSS = `
|
|
|
1304
1374
|
button[aria-label="Add workspace\u2026"] {
|
|
1305
1375
|
display: none !important;
|
|
1306
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 */
|
|
1382
|
+
[data-mobile-nav="copy-file"] {
|
|
1383
|
+
display: inline-flex !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;
|
|
1393
|
+
color: var(--dsw-alias-label-primary, inherit) !important;
|
|
1394
|
+
font-family: inherit !important;
|
|
1395
|
+
font-size: 11px !important;
|
|
1396
|
+
line-height: 1 !important;
|
|
1397
|
+
cursor: pointer !important;
|
|
1398
|
+
-webkit-tap-highlight-color: transparent !important;
|
|
1399
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, .12) !important;
|
|
1400
|
+
}
|
|
1401
|
+
[data-mobile-nav="copy-file"]:active {
|
|
1402
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06)) !important;
|
|
1403
|
+
}
|
|
1404
|
+
[data-mobile-nav="copy-file"][disabled] {
|
|
1405
|
+
opacity: .55 !important;
|
|
1406
|
+
cursor: default !important;
|
|
1407
|
+
}
|
|
1307
1408
|
}
|
|
1308
1409
|
|
|
1309
1410
|
/* ---------- desktop: the mobile controls must never appear ---------- */
|
|
@@ -1538,8 +1639,28 @@ function mobileApply(ctx) {
|
|
|
1538
1639
|
ctx.effect(() => {
|
|
1539
1640
|
if (!narrow.matches) return () => {
|
|
1540
1641
|
};
|
|
1541
|
-
|
|
1542
|
-
|
|
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)");
|
|
1543
1664
|
ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
|
|
1544
1665
|
name: "conversation.session.header.actions",
|
|
1545
1666
|
id: "mobile-nav-toggle",
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// 移动端文件守卫(issue #17
|
|
1
|
+
// 移动端文件守卫(issue #17):dsh-web 在手机上点「文件链接」会触发桌面端
|
|
2
2
|
// workspaces.openPath(open <path>),既打不开(文件在电脑上),又会抛
|
|
3
|
-
// "path open failed
|
|
4
|
-
//
|
|
3
|
+
// "path open failed". 这里做两件事:
|
|
4
|
+
// 1) 捕获阶段拦截这类激活(点击 / 键盘),改为弹一个提示;
|
|
5
|
+
// 2) 在每个文件链接旁注入一个「复制」按钮,点它经主机 RPC 读文件正文再写剪贴板。
|
|
6
|
+
// 另外隐藏「添加工作区」入口(手机上配工作区无意义)。
|
|
5
7
|
// 移植自 dsh-web-mobile(MIT)。
|
|
6
8
|
//
|
|
7
9
|
// 识别方式:不依赖 dsh-web 的 hash 类名(每次构建都变),只认「文本像文件路径的
|
|
@@ -11,6 +13,15 @@
|
|
|
11
13
|
const GUARD_MSG = '手机上无法直接打开电脑上的文件'
|
|
12
14
|
/** 「添加工作区」入口的文案(随语言变化),两种都覆盖。 */
|
|
13
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
|
+
}
|
|
14
25
|
|
|
15
26
|
/** 文本是否像文件路径:绝对路径 / 相对路径 / 带扩展名的目录路径。 */
|
|
16
27
|
function looksLikeFilePath(text: string | null): boolean {
|
|
@@ -22,12 +33,34 @@ function looksLikeFilePath(text: string | null): boolean {
|
|
|
22
33
|
return false
|
|
23
34
|
}
|
|
24
35
|
|
|
25
|
-
/**
|
|
26
|
-
function
|
|
27
|
-
|
|
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
|
+
}
|
|
28
59
|
}
|
|
29
60
|
|
|
30
|
-
export function startFileGuard(
|
|
61
|
+
export function startFileGuard(
|
|
62
|
+
readFile: (path: string) => Promise<ReadFileResponse>,
|
|
63
|
+
): () => void {
|
|
31
64
|
// 轻量 toast:自包含,不依赖 dsh-pocket 面板的 React 状态。
|
|
32
65
|
let toastEl: HTMLElement | null = null
|
|
33
66
|
let toastTimer: number | null = null
|
|
@@ -71,7 +104,7 @@ export function startFileGuard(): () => void {
|
|
|
71
104
|
// 因此只拦 click 即可同时覆盖鼠标与键盘,避免重复处理。
|
|
72
105
|
const onClick = (event: MouseEvent): void => {
|
|
73
106
|
const target = event.target as HTMLElement | null
|
|
74
|
-
if (target === null
|
|
107
|
+
if (target === null) return
|
|
75
108
|
const el = target.closest('button, a') as HTMLElement | null
|
|
76
109
|
if (el === null) return
|
|
77
110
|
if (!looksLikeFilePath(el.textContent)) return
|
|
@@ -81,6 +114,54 @@ export function startFileGuard(): () => void {
|
|
|
81
114
|
}
|
|
82
115
|
document.addEventListener('click', onClick, true)
|
|
83
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
|
+
|
|
84
165
|
// 隐藏「添加工作区」入口:图标按钮由 mobile.css.ts 按 aria-label 隐藏;
|
|
85
166
|
// 下拉菜单里的文本项 CSS 选不到,这里按文案兜底(只在新增节点时检查,省开销)。
|
|
86
167
|
const hideWsEntries = (): (() => void) => {
|
|
@@ -111,6 +192,7 @@ export function startFileGuard(): () => void {
|
|
|
111
192
|
|
|
112
193
|
return () => {
|
|
113
194
|
document.removeEventListener('click', onClick, true)
|
|
195
|
+
copyObserver.disconnect()
|
|
114
196
|
disconnectWs()
|
|
115
197
|
if (toastTimer !== null) window.clearTimeout(toastTimer)
|
|
116
198
|
toastEl?.remove()
|
|
@@ -5,6 +5,7 @@ import { MobileNavOverlay } from './MobileNavOverlay.tsx'
|
|
|
5
5
|
import { MobileDrawerFooter } from './MobileDrawerFooter.tsx'
|
|
6
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'
|
|
@@ -266,11 +267,41 @@ export function mobileApply(ctx): void {
|
|
|
266
267
|
// 移动端文件守卫(issue #17 修正):手机上点 dsh-web 渲染的文件链接会触发桌面
|
|
267
268
|
// 端 workspaces.openPath(open ...) —— 既打不开(路径在电脑上),又会抛
|
|
268
269
|
// "path open failed"。这里在捕获阶段拦截这类点击 / 键盘激活,改为弹一个提示,
|
|
269
|
-
//
|
|
270
|
+
// 并隐藏「添加工作区」入口(手机上配工作区无意义);同时在文件链接旁注入
|
|
271
|
+
// 「复制」按钮,点它经主机 RPC 读取文件正文再写入剪贴板。只挂窄屏。
|
|
270
272
|
ctx.effect(() => {
|
|
271
273
|
if (!narrow.matches) return () => {}
|
|
272
|
-
|
|
273
|
-
|
|
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)')
|
|
274
305
|
|
|
275
306
|
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
|
276
307
|
name: 'conversation.session.header.actions',
|
|
@@ -883,6 +883,37 @@ export const MOBILE_CSS = `
|
|
|
883
883
|
button[aria-label="Add workspace…"] {
|
|
884
884
|
display: none !important;
|
|
885
885
|
}
|
|
886
|
+
|
|
887
|
+
/* ---------- 文件链接旁的「复制」按钮(issue #17:复制文件内容) ----------
|
|
888
|
+
挂在对话里的文件链接(<button>/<a>,文案即路径)紧邻位置,由 fileGuard.ts
|
|
889
|
+
注入。只屏内可见:桌面端不注入、不显示;这里再兜底一层,避免任何遗漏。
|
|
890
|
+
文件链接多为 inline,按钮用 inline-flex 紧跟其后即可。 */
|
|
891
|
+
[data-mobile-nav="copy-file"] {
|
|
892
|
+
display: inline-flex !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;
|
|
902
|
+
color: var(--dsw-alias-label-primary, inherit) !important;
|
|
903
|
+
font-family: inherit !important;
|
|
904
|
+
font-size: 11px !important;
|
|
905
|
+
line-height: 1 !important;
|
|
906
|
+
cursor: pointer !important;
|
|
907
|
+
-webkit-tap-highlight-color: transparent !important;
|
|
908
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, .12) !important;
|
|
909
|
+
}
|
|
910
|
+
[data-mobile-nav="copy-file"]:active {
|
|
911
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06)) !important;
|
|
912
|
+
}
|
|
913
|
+
[data-mobile-nav="copy-file"][disabled] {
|
|
914
|
+
opacity: .55 !important;
|
|
915
|
+
cursor: default !important;
|
|
916
|
+
}
|
|
886
917
|
}
|
|
887
918
|
|
|
888
919
|
/* ---------- desktop: the mobile controls must never appear ---------- */
|
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