dsh-jiuling-remote-mobile 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +295 -150
- package/lib/index.js +62 -32
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -38,54 +38,91 @@ window.__ModuleLoader__.load({
|
|
|
38
38
|
var react = require('react');
|
|
39
39
|
var h = react.createElement;
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* 官方 UI 原子组件(对话框、按钮、分段页签、图标)。
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ **这是「长得像官方」的唯一正解**:官方目录选择器
|
|
45
|
+
* (`dsh-client-ui-directory-picker-browse`)自己也是这么 `require` 的,
|
|
46
|
+
* 该模块已在页面模块注册表里,手机与桌面两端都有。
|
|
47
|
+
*
|
|
48
|
+
* 自己手搓 `div` + `style` 的代价是实打实的:先前那版写死了
|
|
49
|
+
* `background:var(--dsw-alias-bg-primary,#1b1b1f)` —— 而
|
|
50
|
+
* **`--dsw-alias-bg-primary` 这个令牌根本不存在**(官方真实名字是
|
|
51
|
+
* `--dsw-alias-bg-layer-1` / `bg-l1` / `bg-base`),于是整块卡片永远走
|
|
52
|
+
* 那个深色 fallback;`--dsw-alias-border-secondary` 同样不存在。
|
|
53
|
+
* 表现就是手机上「两大坨黑色、跟官方 UI 完全脱钩」。
|
|
54
|
+
*/
|
|
55
|
+
var primitives = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
56
|
+
var Modal = primitives.Modal;
|
|
57
|
+
var Button = primitives.Button;
|
|
58
|
+
var SegmentedTabs = primitives.SegmentedTabs;
|
|
59
|
+
var IconFolderCloseRegular = primitives.IconFolderCloseRegular;
|
|
60
|
+
var IconChevronRightOutlineRegular = primitives.IconChevronRightOutlineRegular;
|
|
61
|
+
var IconPlusOutlineRegular = primitives.IconPlusOutlineRegular;
|
|
62
|
+
|
|
41
63
|
/** 宿主桥的路由(同源,直接相对路径)。 */
|
|
42
64
|
var ROUTE_RPC = '/api/dsh-remote/rpc';
|
|
43
65
|
var ROUTE_STREAM = '/api/dsh-remote/stream';
|
|
44
66
|
|
|
45
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* 对话框卡片的 className(Modal 的 `className` 收字符串、不是 style 对象)。
|
|
69
|
+
*
|
|
70
|
+
* 尺寸与留白**逐条照抄**官方目录选择器对 Modal 的覆盖
|
|
71
|
+
* (`.../dsh-client-ui-directory-picker-browse/lib/client.js` 里
|
|
72
|
+
* `dialog{...gap:0;width:min(680px,100%);height:min(500px,100dvh - 32px);padding:0}`),
|
|
73
|
+
* 只把宽度收窄到手机合适的 560px。背景、圆角、阴影、遮罩一律由官方
|
|
74
|
+
* Modal 自己的 CSS 提供 —— 本插件**不再声明任何颜色**。
|
|
75
|
+
*/
|
|
76
|
+
var DIALOG_CLASS = 'jlm_dialog';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 布局样式:取值全部照抄官方目录选择器的同义规则,令牌全部是
|
|
80
|
+
* **真实存在**的官方设计令牌(`--dsw-alias-border-l3`、`bg-layer-*`、
|
|
81
|
+
* `interactive-bg-hover`、`label-*` …),不写死任何颜色值。
|
|
82
|
+
*/
|
|
46
83
|
var CSS = {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'
|
|
52
|
-
'
|
|
53
|
-
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
|
|
57
|
-
title: 'font-size:15px;font-weight:600;letter-spacing:.01em;flex:1;min-width:0;',
|
|
58
|
-
tabs: 'display:flex;gap:6px;padding:0 16px 10px;flex:none;',
|
|
59
|
-
tab:
|
|
60
|
-
'flex:1;height:32px;border-radius:8px;border:1px solid transparent;cursor:pointer;' +
|
|
61
|
-
'font-size:13px;background:transparent;color:var(--dsw-alias-label-secondary,#9a9aa2);',
|
|
62
|
-
tabOn:
|
|
63
|
-
'flex:1;height:32px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;' +
|
|
64
|
-
'border:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.14));' +
|
|
65
|
-
'background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.07));' +
|
|
66
|
-
'color:var(--dsw-alias-label-primary,#e8e8ea);',
|
|
67
|
-
path:
|
|
68
|
-
'display:flex;align-items:center;gap:8px;padding:8px 16px;flex:none;font-size:12px;' +
|
|
69
|
-
'color:var(--dsw-alias-label-tertiary,#7d7d85);' +
|
|
70
|
-
'border-top:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.08));' +
|
|
71
|
-
'border-bottom:1px solid var(--dsw-alias-border-secondary,rgba(255,255,255,.08));',
|
|
84
|
+
dialog: 'gap:0;width:min(560px,100%);height:min(560px,100dvh - 32px);padding:0;',
|
|
85
|
+
// 官方用它把 header / content / footer 抬进 Modal 卡片的 flex 流里。
|
|
86
|
+
editorScope: 'display:contents;',
|
|
87
|
+
header:
|
|
88
|
+
'display:flex;flex-direction:column;flex:none;gap:8px;' +
|
|
89
|
+
'padding:16px 14px 8px 24px;border-bottom:.5px solid var(--dsw-alias-border-l3);',
|
|
90
|
+
title:
|
|
91
|
+
'display:flex;align-items:flex-end;min-height:28px;margin:0;' +
|
|
92
|
+
'font-size:16px;font-weight:510;line-height:24px;color:var(--dsw-alias-label-primary);',
|
|
93
|
+
crumbBar: 'display:flex;align-items:center;gap:8px;min-height:24px;',
|
|
72
94
|
pathText:
|
|
73
|
-
'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
|
74
|
-
|
|
95
|
+
'flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' +
|
|
96
|
+
'color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px;',
|
|
97
|
+
content:
|
|
98
|
+
'display:flex;flex-direction:column;flex:1 1 auto;min-height:0;' +
|
|
99
|
+
'padding:16px 16px 16px 24px;position:relative;overflow:hidden;',
|
|
100
|
+
column:
|
|
101
|
+
'display:flex;flex-direction:column;flex:1 1 auto;gap:2px;min-height:0;' +
|
|
102
|
+
'padding-right:8px;overflow-y:auto;',
|
|
75
103
|
row:
|
|
76
|
-
'display:flex;align-items:center;gap:
|
|
77
|
-
'border
|
|
78
|
-
'
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
'
|
|
82
|
-
'
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
'
|
|
86
|
-
'
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
'display:flex;align-items:center;gap:4px;width:100%;height:28px;padding:4px;' +
|
|
105
|
+
'border:none;border-radius:var(--dsw-radius-sm);background:0 0;' +
|
|
106
|
+
'text-align:left;cursor:pointer;flex:0 0 auto;',
|
|
107
|
+
rowIcon: 'flex:none;color:var(--dsw-alias-label-secondary);',
|
|
108
|
+
rowName:
|
|
109
|
+
'flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' +
|
|
110
|
+
'color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px;',
|
|
111
|
+
rowChevron: 'flex:none;color:var(--dsw-alias-label-tertiary);',
|
|
112
|
+
status:
|
|
113
|
+
'padding:4px 120px 4px 4px;font-size:12px;line-height:18px;' +
|
|
114
|
+
'color:var(--dsw-alias-label-secondary);',
|
|
115
|
+
error:
|
|
116
|
+
'padding:4px 120px 4px 4px;font-size:12px;line-height:18px;' +
|
|
117
|
+
'color:var(--dsw-alias-state-error-primary);word-break:break-all;',
|
|
118
|
+
note:
|
|
119
|
+
'padding:8px 4px 0;font-size:12px;line-height:18px;' +
|
|
120
|
+
'color:var(--dsw-alias-label-tertiary);',
|
|
121
|
+
footerBar:
|
|
122
|
+
'display:flex;flex-wrap:wrap;flex:none;align-items:center;gap:8px;' +
|
|
123
|
+
'padding:16px 24px;border-top:.5px solid var(--dsw-alias-border-l3);',
|
|
124
|
+
footerGap: 'flex:1 1 0;',
|
|
125
|
+
footerAction: 'min-width:72px;',
|
|
89
126
|
};
|
|
90
127
|
|
|
91
128
|
/**
|
|
@@ -121,9 +158,48 @@ window.__ModuleLoader__.load({
|
|
|
121
158
|
return out;
|
|
122
159
|
}
|
|
123
160
|
|
|
161
|
+
// ⚠️ **先把文本留一份副本再转对象**:下面那个 `<style>` 标签拼的是 CSS
|
|
162
|
+
// **文本**,而第 162 行会把 `CSS` 就地改写成对象 —— 顺序反了就会拼出
|
|
163
|
+
// `[object Object]`,卡片尺寸规则整条失效(列表被 flex 压成 0 高、只剩空白)。
|
|
164
|
+
var DIALOG_CSS_TEXT = CSS.dialog;
|
|
165
|
+
|
|
124
166
|
// 就地转成对象:下游一律写 `style: CSS.xxx`,不会再碰到字符串形式。
|
|
125
167
|
for (var styleKey in CSS) CSS[styleKey] = toStyle(CSS[styleKey]);
|
|
126
168
|
|
|
169
|
+
/**
|
|
170
|
+
* 注入官方风格的样式表。
|
|
171
|
+
*
|
|
172
|
+
* ⚠️ 卡片那层(背景/圆角/阴影/遮罩)**全部由官方 Modal 自己的 CSS 提供**,
|
|
173
|
+
* 这里只覆盖尺寸与留白 —— 与官方目录选择器覆盖
|
|
174
|
+
* `dialog` 类的方式完全一致。所以本插件**不声明任何颜色**,
|
|
175
|
+
* 亮色/暗色主题都随官方走,不会再出现「一大坨黑」。
|
|
176
|
+
*
|
|
177
|
+
* 用 `<style data-plugin-css>` 注入(官方各 client 包的标准做法),
|
|
178
|
+
* 而不是把样式塞进 React 的 `style` prop —— Modal 只接受 `className`。
|
|
179
|
+
*/
|
|
180
|
+
var STYLE_TAG_ID = 'dsh-jiuling-remote-mobile/DirectoryTabs.css';
|
|
181
|
+
if (
|
|
182
|
+
typeof document !== 'undefined' &&
|
|
183
|
+
document.querySelector('style[data-plugin-css=' + JSON.stringify(STYLE_TAG_ID) + ']') === null
|
|
184
|
+
) {
|
|
185
|
+
var styleTag = document.createElement('style');
|
|
186
|
+
styleTag.dataset.plugin = 'dsh-jiuling-remote-mobile';
|
|
187
|
+
styleTag.dataset.pluginCss = STYLE_TAG_ID;
|
|
188
|
+
styleTag.textContent =
|
|
189
|
+
'.' +
|
|
190
|
+
DIALOG_CLASS +
|
|
191
|
+
'{' +
|
|
192
|
+
DIALOG_CSS_TEXT +
|
|
193
|
+
'}' +
|
|
194
|
+
'.' +
|
|
195
|
+
DIALOG_CLASS +
|
|
196
|
+
' [role="tablist"]{flex:none;}' +
|
|
197
|
+
'.' +
|
|
198
|
+
DIALOG_CLASS +
|
|
199
|
+
' button:hover{background:var(--dsw-alias-interactive-bg-hover);}';
|
|
200
|
+
document.head.appendChild(styleTag);
|
|
201
|
+
}
|
|
202
|
+
|
|
127
203
|
/**
|
|
128
204
|
* 调宿主桥的一条 RPC(令牌只在引擎进程里,浏览器侧不持有)。
|
|
129
205
|
*
|
|
@@ -203,6 +279,10 @@ window.__ModuleLoader__.load({
|
|
|
203
279
|
var listDirectory = props.listDirectory;
|
|
204
280
|
var createDirectory = props.createDirectory;
|
|
205
281
|
var openSession = props.openSession;
|
|
282
|
+
/** 把宿主刚建好的会话登记进本地列表(见 `settleLocal`)。 */
|
|
283
|
+
var registerLocal = props.registerLocal;
|
|
284
|
+
/** 兜底:拉一次全量会话列表(登记失败时用)。 */
|
|
285
|
+
var refreshSessions = props.refreshSessions;
|
|
206
286
|
|
|
207
287
|
var statePair = react.useState({
|
|
208
288
|
tab: 'local',
|
|
@@ -283,24 +363,75 @@ window.__ModuleLoader__.load({
|
|
|
283
363
|
onPicked(loc.path);
|
|
284
364
|
};
|
|
285
365
|
|
|
286
|
-
/**
|
|
366
|
+
/** 「电脑」页签:在电脑的这个目录上开一个新对话,并跳到它。 */
|
|
287
367
|
var openDesktop = function () {
|
|
288
368
|
if (loc.path === '' || state.creating) return;
|
|
289
369
|
patch({ creating: true, error: null });
|
|
290
370
|
bridge('createSession', { path: loc.path }).then(
|
|
291
371
|
function (result) {
|
|
292
372
|
if (!aliveRef.current) return;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
373
|
+
// ⚠️ **先登记、再打开**,顺序反了就会静默失败(见 `registerLocal`)。
|
|
374
|
+
return settleLocal(result).then(function () {
|
|
375
|
+
if (!aliveRef.current) return;
|
|
376
|
+
patch({ creating: false });
|
|
377
|
+
onCancel();
|
|
378
|
+
if (openSession !== undefined && result && result.sessionId) {
|
|
379
|
+
openSession(result.sessionId);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
298
382
|
},
|
|
299
383
|
function (reason) {
|
|
300
384
|
if (!aliveRef.current) return;
|
|
301
385
|
patch({ creating: false, error: String((reason && reason.message) || reason) });
|
|
302
386
|
},
|
|
303
|
-
)
|
|
387
|
+
).then(null, function (reason) {
|
|
388
|
+
if (!aliveRef.current) return;
|
|
389
|
+
patch({ creating: false, error: String((reason && reason.message) || reason) });
|
|
390
|
+
});
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 把刚在电脑上建好的会话**登记进本地会话列表**。
|
|
395
|
+
*
|
|
396
|
+
* ⚠️ **这一步不能省,而且必须在 `openSession` 之前完成。**
|
|
397
|
+
*
|
|
398
|
+
* `uiWorkspace.openSession(id)` 最终走
|
|
399
|
+
* `SessionManager.resolveTarget(id)`,它有**三道**身份检查,全不过就抛
|
|
400
|
+
* `sessions.retain: unknown session <id>`:
|
|
401
|
+
*
|
|
402
|
+
* 1. `sessions.has(id)` —— 已物化的客户端实例;
|
|
403
|
+
* 2. `summaries.some(...)` —— 客户端列表快照;
|
|
404
|
+
* 3. `addresses` —— 子 agent 地址。
|
|
405
|
+
*
|
|
406
|
+
* 而会话是**宿主**建的,客户端只能靠 `api-session/added` 这条**异步推送**
|
|
407
|
+
* 知道它 —— RPC 的响应会先到。所以直接在 RPC 回执里 `openSession()`
|
|
408
|
+
* 是**必然的竞态**:抛出的异常还被 `openSession()` 同步吞掉,界面
|
|
409
|
+
* 只表现为「点完没反应、又回到默认工作区」,控制台一句错都没有。
|
|
410
|
+
*
|
|
411
|
+
* 正解是走官方 client 自己的 `sessions.create({sessionId, cwd})`:
|
|
412
|
+
* `SessionManager.create()` 在 RPC 返回后立刻
|
|
413
|
+
* `recordMutation({kind:'placeholder'})` 把这一行写进列表快照,
|
|
414
|
+
* 于是 `openSession()` 当场就能解析到它。
|
|
415
|
+
*
|
|
416
|
+
* 幂等性是安全的:宿主侧已经建过这个 id,`createOrAdopt` 看到
|
|
417
|
+
* `agents.get(sessionId)` 活着就直接返回它,不会重复建、也不会撞 cwd。
|
|
418
|
+
*
|
|
419
|
+
* `cwd` 由宿主回执带回(手机本地的工作区路径,不是电脑路径)——
|
|
420
|
+
* 两端各自的 cwd 互不相通,只在 `sessionId` 上对齐。
|
|
421
|
+
*
|
|
422
|
+
* @param result - 宿主 `createSession` 的回执 `{sessionId, cwd}`。
|
|
423
|
+
* @returns 登记完成的 Promise(失败也 resolve,交由调用方决定下一步)。
|
|
424
|
+
*/
|
|
425
|
+
var settleLocal = function (result) {
|
|
426
|
+
if (!result || typeof result.sessionId !== 'string') return Promise.resolve();
|
|
427
|
+
var local = registerLocal === undefined
|
|
428
|
+
? Promise.resolve()
|
|
429
|
+
: Promise.resolve(registerLocal(result.sessionId, result.cwd)).then(null, function () {
|
|
430
|
+
// 登记失败(例如 `session/writer-held`):退一步拉全量列表,
|
|
431
|
+
// 宿主那一行仍然会进来,`openSession` 同样能解析。
|
|
432
|
+
return refreshSessions === undefined ? null : Promise.resolve(refreshSessions()).then(null, function () { return null; });
|
|
433
|
+
});
|
|
434
|
+
return local;
|
|
304
435
|
};
|
|
305
436
|
|
|
306
437
|
var makeFolder = function () {
|
|
@@ -327,89 +458,43 @@ window.__ModuleLoader__.load({
|
|
|
327
458
|
key: full,
|
|
328
459
|
type: 'button',
|
|
329
460
|
style: CSS.row,
|
|
330
|
-
onMouseEnter: function (event) {
|
|
331
|
-
event.currentTarget.style.background =
|
|
332
|
-
'var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.07))';
|
|
333
|
-
},
|
|
334
|
-
onMouseLeave: function (event) {
|
|
335
|
-
event.currentTarget.style.background = 'transparent';
|
|
336
|
-
},
|
|
337
461
|
onClick: function () {
|
|
338
462
|
patch({ path: full });
|
|
339
463
|
},
|
|
340
464
|
},
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
flex: '1',
|
|
347
|
-
minWidth: 0,
|
|
348
|
-
overflow: 'hidden',
|
|
349
|
-
textOverflow: 'ellipsis',
|
|
350
|
-
whiteSpace: 'nowrap',
|
|
351
|
-
},
|
|
352
|
-
},
|
|
353
|
-
nameOf(entry),
|
|
354
|
-
),
|
|
465
|
+
// ⚠️ 图标组件只解构 `size/className/strokeWidth`,**不透传 `style`**
|
|
466
|
+
// —— 上色要靠外层 span(官方图标本来就是 currentColor 描边)。
|
|
467
|
+
h('span', { style: CSS.rowIcon }, h(IconFolderCloseRegular, { size: 16 })),
|
|
468
|
+
h('span', { style: CSS.rowName }, nameOf(entry)),
|
|
469
|
+
h('span', { style: CSS.rowChevron }, h(IconChevronRightOutlineRegular, { size: 12 })),
|
|
355
470
|
);
|
|
356
471
|
});
|
|
357
472
|
|
|
358
|
-
|
|
473
|
+
var body = h(
|
|
359
474
|
'div',
|
|
360
|
-
{
|
|
361
|
-
|
|
362
|
-
onClick: function (event) {
|
|
363
|
-
if (event.target === event.currentTarget) onCancel();
|
|
364
|
-
},
|
|
365
|
-
},
|
|
475
|
+
{ style: CSS.editorScope },
|
|
476
|
+
// —— 头部:标题 + 页签 + 路径行 ——
|
|
366
477
|
h(
|
|
367
478
|
'div',
|
|
368
|
-
{ style: CSS.
|
|
369
|
-
h(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
{
|
|
376
|
-
'
|
|
377
|
-
|
|
378
|
-
),
|
|
479
|
+
{ style: CSS.header },
|
|
480
|
+
h('h2', { style: CSS.title }, '选择工作区目录'),
|
|
481
|
+
h(SegmentedTabs, {
|
|
482
|
+
label: '目录来源',
|
|
483
|
+
value: state.tab,
|
|
484
|
+
onChange: switchTab,
|
|
485
|
+
items: [
|
|
486
|
+
{ value: 'local', label: '手机' },
|
|
487
|
+
{ value: 'desktop', label: '电脑' },
|
|
488
|
+
],
|
|
489
|
+
}),
|
|
379
490
|
h(
|
|
380
491
|
'div',
|
|
381
|
-
{ style: CSS.
|
|
492
|
+
{ style: CSS.crumbBar },
|
|
382
493
|
h(
|
|
383
|
-
|
|
494
|
+
Button,
|
|
384
495
|
{
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
onClick: function () {
|
|
388
|
-
switchTab('local');
|
|
389
|
-
},
|
|
390
|
-
},
|
|
391
|
-
'手机',
|
|
392
|
-
),
|
|
393
|
-
h(
|
|
394
|
-
'button',
|
|
395
|
-
{
|
|
396
|
-
type: 'button',
|
|
397
|
-
style: state.tab === 'desktop' ? CSS.tabOn : CSS.tab,
|
|
398
|
-
onClick: function () {
|
|
399
|
-
switchTab('desktop');
|
|
400
|
-
},
|
|
401
|
-
},
|
|
402
|
-
'电脑',
|
|
403
|
-
),
|
|
404
|
-
),
|
|
405
|
-
h(
|
|
406
|
-
'div',
|
|
407
|
-
{ style: CSS.path },
|
|
408
|
-
h(
|
|
409
|
-
'button',
|
|
410
|
-
{
|
|
411
|
-
type: 'button',
|
|
412
|
-
style: CSS.btn,
|
|
496
|
+
variant: 'outline',
|
|
497
|
+
size: 'sm',
|
|
413
498
|
disabled: loc.parent === '',
|
|
414
499
|
onClick: function () {
|
|
415
500
|
patch({ path: loc.parent === '' ? null : loc.parent });
|
|
@@ -417,16 +502,21 @@ window.__ModuleLoader__.load({
|
|
|
417
502
|
},
|
|
418
503
|
'上一级',
|
|
419
504
|
),
|
|
420
|
-
h('
|
|
505
|
+
h('span', { style: CSS.pathText, title: loc.path }, loc.path || '(根)'),
|
|
421
506
|
),
|
|
422
|
-
|
|
507
|
+
),
|
|
508
|
+
// —— 主体:目录列表 ——
|
|
509
|
+
h(
|
|
510
|
+
'div',
|
|
511
|
+
{ style: CSS.content },
|
|
423
512
|
state.loading
|
|
424
|
-
? h('div', { style: CSS.
|
|
513
|
+
? h('div', { style: CSS.status, role: 'status' }, '加载中…')
|
|
425
514
|
: h(
|
|
426
515
|
'div',
|
|
427
|
-
{ style: CSS.
|
|
428
|
-
rows.length > 0 ? rows : h('div', { style: CSS.
|
|
516
|
+
{ style: CSS.column },
|
|
517
|
+
rows.length > 0 ? rows : h('div', { style: CSS.status }, '这里没有子文件夹'),
|
|
429
518
|
),
|
|
519
|
+
state.error !== null ? h('div', { style: CSS.error, role: 'alert' }, state.error) : null,
|
|
430
520
|
state.tab === 'desktop'
|
|
431
521
|
? h(
|
|
432
522
|
'div',
|
|
@@ -434,36 +524,63 @@ window.__ModuleLoader__.load({
|
|
|
434
524
|
'在电脑的这个目录上开一个新对话,手机上会同步显示它的全部过程。',
|
|
435
525
|
)
|
|
436
526
|
: null,
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
527
|
+
),
|
|
528
|
+
// —— 底部动作栏 ——
|
|
529
|
+
h(
|
|
530
|
+
'div',
|
|
531
|
+
{ style: CSS.footerBar },
|
|
532
|
+
state.tab === 'local' && createDirectory !== undefined
|
|
533
|
+
? h(
|
|
534
|
+
Button,
|
|
535
|
+
{
|
|
536
|
+
variant: 'outline',
|
|
537
|
+
icon: h(IconPlusOutlineRegular, { size: 14 }),
|
|
538
|
+
onClick: makeFolder,
|
|
539
|
+
},
|
|
540
|
+
'新建文件夹',
|
|
541
|
+
)
|
|
542
|
+
: null,
|
|
543
|
+
h('span', { style: CSS.footerGap }),
|
|
544
|
+
h(Button, { variant: 'outline', style: CSS.footerAction, onClick: onCancel }, '取消'),
|
|
545
|
+
state.tab === 'local'
|
|
546
|
+
? h(
|
|
547
|
+
Button,
|
|
548
|
+
{
|
|
549
|
+
variant: 'primary',
|
|
550
|
+
style: CSS.footerAction,
|
|
551
|
+
disabled: loc.path === '' || props.busy,
|
|
552
|
+
onClick: pickLocal,
|
|
553
|
+
},
|
|
554
|
+
props.busy ? '处理中…' : '选择此文件夹',
|
|
555
|
+
)
|
|
556
|
+
: h(
|
|
557
|
+
Button,
|
|
558
|
+
{
|
|
559
|
+
variant: 'primary',
|
|
560
|
+
style: CSS.footerAction,
|
|
561
|
+
disabled: loc.path === '' || state.creating,
|
|
562
|
+
onClick: openDesktop,
|
|
563
|
+
},
|
|
564
|
+
state.creating ? '正在电脑上新建…' : '在此新建对话',
|
|
565
|
+
),
|
|
465
566
|
),
|
|
466
567
|
);
|
|
568
|
+
|
|
569
|
+
// 卡片本身(背景/圆角/阴影/遮罩/关闭按钮)全部交给官方 Modal;
|
|
570
|
+
// 我们只覆盖尺寸与留白(见 DIALOG_CLASS)。`headless` 才不会
|
|
571
|
+
// 再套一层官方自带的标题栏 —— 标题由我们放进 header 里。
|
|
572
|
+
return h(
|
|
573
|
+
Modal,
|
|
574
|
+
{
|
|
575
|
+
open: open,
|
|
576
|
+
onClose: onCancel,
|
|
577
|
+
title: '选择工作区目录',
|
|
578
|
+
closeLabel: '关闭',
|
|
579
|
+
className: DIALOG_CLASS,
|
|
580
|
+
headless: true,
|
|
581
|
+
},
|
|
582
|
+
body,
|
|
583
|
+
);
|
|
467
584
|
}
|
|
468
585
|
|
|
469
586
|
//#region 电脑流式帧 → 本地 transient
|
|
@@ -642,6 +759,34 @@ window.__ModuleLoader__.load({
|
|
|
642
759
|
listDirectory: (path, signal) => ctx.uiWorkspace.listDirectory(path, signal),
|
|
643
760
|
createDirectory: (path, name) => ctx.uiWorkspace.createDirectory(path, name),
|
|
644
761
|
openSession: (sessionId) => ctx.uiWorkspace.openSession(sessionId),
|
|
762
|
+
/**
|
|
763
|
+
* 把宿主刚建好的会话登记进本地列表。
|
|
764
|
+
*
|
|
765
|
+
* 走官方 client 的 `sessions.create({sessionId, cwd})`:
|
|
766
|
+
* `SessionManager.create()` 在 RPC 回执后立刻
|
|
767
|
+
* `recordMutation({kind:'placeholder'})` 把这一行写进列表快照,
|
|
768
|
+
* 于是 `openSession()` 当场就能解析到这个 id。
|
|
769
|
+
* **不登记就直接 `openSession()` 是竞态**,会抛
|
|
770
|
+
* `sessions.retain: unknown session` 并被静默吞掉
|
|
771
|
+
* (详见组件里 `settleLocal` 的注释)。
|
|
772
|
+
*
|
|
773
|
+
* 传 `cwd`(宿主回执里的**手机本地**路径)而不是 `workspaceId`:
|
|
774
|
+
* 宿主侧已经把这个会话挂进工作区了,这里只需要让客户端列表
|
|
775
|
+
* 认识它;带上 cwd 才能让列表把它分进正确的工作区分组。
|
|
776
|
+
*
|
|
777
|
+
* 宿主侧已建过该 id ⇒ `createOrAdopt` 看到活着的 agent 直接返回,
|
|
778
|
+
* 不会重复建。
|
|
779
|
+
*
|
|
780
|
+
* @param sessionId - 会话 id。
|
|
781
|
+
* @param cwd - 宿主回执里的本地 cwd(两端只在 id 上对齐)。
|
|
782
|
+
*/
|
|
783
|
+
registerLocal: (sessionId, cwd) => {
|
|
784
|
+
const options = { sessionId };
|
|
785
|
+
if (cwd !== undefined && cwd !== '') options.cwd = cwd;
|
|
786
|
+
return ctx.sessions.create(options);
|
|
787
|
+
},
|
|
788
|
+
/** 兜底:拉一次全量会话列表。 */
|
|
789
|
+
refreshSessions: () => ctx.sessions.refresh(),
|
|
645
790
|
});
|
|
646
791
|
|
|
647
792
|
// 负 priority:single slot 取 entries[0],entries 按 priority 升序 ⇒ 最先渲染。
|
package/lib/index.js
CHANGED
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
* @module dsh-remote-mobile
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
|
-
import { randomUUID } from 'node:crypto';
|
|
50
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
51
51
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
52
52
|
import { homedir } from 'node:os';
|
|
53
53
|
import { join } from 'node:path';
|
|
@@ -558,44 +558,70 @@ export function apply(ctx) {
|
|
|
558
558
|
}
|
|
559
559
|
|
|
560
560
|
/**
|
|
561
|
-
*
|
|
561
|
+
* 把电脑路径压成一个**文件名安全**的稳定 slug。
|
|
562
562
|
*
|
|
563
|
-
*
|
|
563
|
+
* 只用于镜像目录的命名(用户看不到),所以要求是「同一个电脑路径永远得到
|
|
564
|
+
* 同一个名字,不同路径几乎不可能撞」。`createHash` 那 8 位十六进制就是防撞的:
|
|
565
|
+
* 纯字符替换会把 `C:\a-b` 与 `C:\a\b` 压成同一个名字。
|
|
564
566
|
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
|
|
568
|
-
|
|
567
|
+
* @param desktopPath - 电脑上的绝对路径(win32 语义)。
|
|
568
|
+
* @returns 形如 `c-users-xiaopan-1a2b3c4d` 的 slug。
|
|
569
|
+
*/
|
|
570
|
+
function desktopSlug(desktopPath) {
|
|
571
|
+
const flat = desktopPath.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
572
|
+
const digest = createHash('sha256').update(desktopPath).digest('hex').slice(0, 8);
|
|
573
|
+
return `${flat === '' ? 'root' : flat}-${digest}`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* 取电脑路径的末段当工作区标题。
|
|
578
|
+
*
|
|
579
|
+
* ⚠️ **不能直接用 `node:path` 的 `basename`**:手机是 posix 语义,
|
|
580
|
+
* `basename('C:\\Users\\XIAOPAN')` 会把整串原样返回(反斜杠不是分隔符),
|
|
581
|
+
* 侧边栏就会显示一长条 `C:\Users\XIAOPAN`。这里两种分隔符都切。
|
|
582
|
+
*
|
|
583
|
+
* @param desktopPath - 电脑上的绝对路径。
|
|
584
|
+
* @returns 末段;没有末段(如 `C:\`)时退回去掉尾部斜杠的原串。
|
|
585
|
+
*/
|
|
586
|
+
function desktopTitle(desktopPath) {
|
|
587
|
+
const trimmed = desktopPath.replace(/[/\\]+$/, '');
|
|
588
|
+
const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
|
|
589
|
+
const name = cut < 0 ? trimmed : trimmed.slice(cut + 1);
|
|
590
|
+
return name === '' ? desktopPath : name;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 为电脑上的一个目录,在手机本地建(或复用)一个**同名镜像工作区**。
|
|
595
|
+
*
|
|
596
|
+
* ## 为什么要建工作区,而不是塞进「默认工作区」
|
|
569
597
|
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
598
|
+
* 前端把会话归到哪个工作区,是**按 cwd 路径精确匹配**的
|
|
599
|
+
* (`dsh-workspace/lib/index.js:102` 的 `sessionIds` getter)。
|
|
600
|
+
* 早先的实现把远程会话的 cwd 设成手机默认工作区的路径,于是**所有**远程会话
|
|
601
|
+
* 都堆在「默认工作区」底下 —— 用户在电脑页签里选了 `C:\Users\XIAOPAN`,
|
|
602
|
+
* 侧边栏却显示「默认工作区」,看起来就像「建完又回到默认工作去了」。
|
|
574
603
|
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
604
|
+
* 正解是给每个电脑目录配一个**独立的手机本地镜像目录**,用它注册一个独立
|
|
605
|
+
* 工作区,标题取电脑目录的末段(`XIAOPAN`)。镜像目录是空的、永远不会被写入
|
|
606
|
+
* —— 真正干活的是电脑那台机器,这里只是给前端一个可以挂载的 cwd 锚点。
|
|
578
607
|
*
|
|
579
|
-
* ⚠️
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
* 它会 `rememberSessionPath()` 把这条补进索引。
|
|
608
|
+
* ⚠️ cwd 依然**不能**是电脑路径:Android 是 posix 语义,
|
|
609
|
+
* `path.posix.isAbsolute('C:/Users/...') === false`,而
|
|
610
|
+
* `createOrAdopt()` 里有 `await mkdir(cwd, {recursive:true})` —— 传电脑路径
|
|
611
|
+
* 会真在手机上建出一个叫 `C:\Users\...` 的怪目录。
|
|
584
612
|
*
|
|
585
613
|
* @param ctx - 宿主 Cordis 上下文。
|
|
586
|
-
* @
|
|
614
|
+
* @param desktopPath - 电脑上的绝对路径(仅用于取名字,不会成为 cwd)。
|
|
615
|
+
* @returns 工作区实体(含 `.id` / `.path` / `.title`)。
|
|
587
616
|
*/
|
|
588
|
-
function
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if (all.length === 0) return undefined;
|
|
593
|
-
const preferredId = registry?.state?.defaultWorkspaceId;
|
|
594
|
-
const chosen = all.find((entity) => entity.id === preferredId) ?? all[0];
|
|
595
|
-
return typeof chosen?.path === 'string' && chosen.path !== '' ? chosen : undefined;
|
|
596
|
-
} catch {
|
|
597
|
-
return undefined;
|
|
617
|
+
async function ensureDesktopWorkspace(ctx, desktopPath) {
|
|
618
|
+
const registry = ctx.get?.('workspaceRegistry');
|
|
619
|
+
if (registry === undefined || typeof registry.create !== 'function') {
|
|
620
|
+
throw new Error('workspaceRegistry 不可用,无法为电脑目录建工作区');
|
|
598
621
|
}
|
|
622
|
+
const dir = join(STATE_DIR, 'workspaces', desktopSlug(desktopPath));
|
|
623
|
+
mkdirSync(dir, { recursive: true });
|
|
624
|
+
return await registry.create(dir, desktopTitle(desktopPath));
|
|
599
625
|
}
|
|
600
626
|
|
|
601
627
|
/**
|
|
@@ -633,8 +659,12 @@ async function handleBridge(ctx, config, io) {
|
|
|
633
659
|
// ⚠️ 手机端 cwd 必须是**手机本地**的绝对路径,绝不把电脑路径传进来
|
|
634
660
|
// (Android 是 posix 语义,`path.posix.isAbsolute('C:/...') === false`,
|
|
635
661
|
// 且官方 createOrAdopt 会 `mkdir(cwd, {recursive:true})` 真去建目录)。
|
|
636
|
-
|
|
637
|
-
|
|
662
|
+
//
|
|
663
|
+
// 这里给它一个**电脑目录专属的镜像工作区**(标题 = 电脑目录末段),
|
|
664
|
+
// 而不是手机的「默认工作区」—— 否则所有远程会话都堆在「默认工作区」
|
|
665
|
+
// 底下,看起来就像「建完又回到默认工作去了」(见 `ensureDesktopWorkspace`)。
|
|
666
|
+
const workspace = await ensureDesktopWorkspace(ctx, params.path);
|
|
667
|
+
const cwd = workspace.path;
|
|
638
668
|
// ⚠️ **必须走 `sessionController.create`,不能走 `ctx.sessions.create`。**
|
|
639
669
|
//
|
|
640
670
|
// `ctx.sessions.create()` 只把会话塞进内存 store:持久化层既没
|