dsh-over-ssh 0.1.1 → 0.2.1
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/README.md +10 -8
- package/agents/agent-manifest.json +6 -6
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/lib/client.js +567 -246
- package/lib/file-references.js +20 -8
- package/lib/fs.js +141 -41
- package/lib/index.js +3268 -875
- package/lib/types/src/config.d.ts +76 -0
- package/lib/types/src/host/app/backend.d.ts +35 -2
- package/lib/types/src/host/domain/roots/session-roots.d.ts +23 -0
- package/lib/types/src/host/domain/session/session-admin.d.ts +5 -0
- package/lib/types/src/host/infra/transport/forward.d.ts +75 -0
- package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +5 -0
- package/lib/types/src/host/infra/transport/transport.d.ts +26 -0
- package/lib/types/src/host/integration/fs/fs-provider.d.ts +18 -1
- package/lib/types/src/wiring/helpers.d.ts +10 -1
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -29,6 +29,25 @@ __export(index_exports, {
|
|
|
29
29
|
module.exports = __toCommonJS(index_exports);
|
|
30
30
|
var import_cordis = require("@deepseek-ai/cordis");
|
|
31
31
|
|
|
32
|
+
// src/client/core/abort.ts
|
|
33
|
+
function createAbortGate() {
|
|
34
|
+
let active;
|
|
35
|
+
return {
|
|
36
|
+
begin: () => {
|
|
37
|
+
active?.abort();
|
|
38
|
+
active = new AbortController();
|
|
39
|
+
return active.signal;
|
|
40
|
+
},
|
|
41
|
+
abort: () => {
|
|
42
|
+
active?.abort();
|
|
43
|
+
active = void 0;
|
|
44
|
+
},
|
|
45
|
+
get live() {
|
|
46
|
+
return active !== void 0 && !active.signal.aborted;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
// src/client/core/locale.ts
|
|
33
52
|
var OVER_SSH_NS = "over-ssh";
|
|
34
53
|
var OVER_SSH_DICTS = {
|
|
@@ -43,9 +62,10 @@ var OVER_SSH_DICTS = {
|
|
|
43
62
|
rootsTitle: "\u8FDC\u7A0B\u5DE5\u4F5C\u533A",
|
|
44
63
|
// 本页只列插件已登记的根、并可移除:「增」在审计 0027 已移到工作区目录流,旧文案的
|
|
45
64
|
// 「在这里增删…」是错的。作用域那句(不会自动出现在会话的目录列表里)是审计 0056 的护栏,保留。
|
|
46
|
-
rootsDescription: "\u8FD9\u91CC\u53EA\u5217\u51FA\u63D2\u4EF6\u5DF2\u767B\u8BB0\u7684\u6839\uFF0C\u5E76\u53EF\u4EE5\u79FB\u9664\uFF1B\u8981\u6DFB\u52A0\u76EE\u5F55\u8BF7\u5728\u5DE5\u4F5C\u533A\u7684\u76EE\u5F55\u6D41\u91CC\u505A\u3002\u8FD9\u91CC\u767B\u8BB0\u7684\u6839\u4E0D\u4F1A\u81EA\u52A8\u51FA\u73B0\u5728\u4F1A\u8BDD\u7684\u76EE\u5F55\u5217\u8868\u91CC\uFF1A\u4F1A\u8BDD\u8981\u7528\u5B83\uFF0C\u9700\u5728\u4F1A\u8BDD\u91CC\u70B9\u300C\u6DFB\u52A0\u76EE\u5F55\u300D\u3002profile \u91CC\u58F0\u660E\u7684\u6839\u53EA\u8BFB\u663E\u793A\uFF1A\u5220\u6389\u5B83\u91CD\u542F\u540E\u8FD8\u4F1A\u56DE\u6765\u3002",
|
|
65
|
+
rootsDescription: "\u8FD9\u91CC\u53EA\u5217\u51FA\u63D2\u4EF6\u5DF2\u767B\u8BB0\u7684\u6839\uFF0C\u5E76\u53EF\u4EE5\u79FB\u9664\uFF1B\u8981\u6DFB\u52A0\u76EE\u5F55\u8BF7\u5728\u5DE5\u4F5C\u533A\u7684\u76EE\u5F55\u6D41\u91CC\u505A\u3002\u8FD9\u91CC\u767B\u8BB0\u7684\u6839\u4E0D\u4F1A\u81EA\u52A8\u51FA\u73B0\u5728\u4F1A\u8BDD\u7684\u76EE\u5F55\u5217\u8868\u91CC\uFF1A\u4F1A\u8BDD\u8981\u7528\u5B83\uFF0C\u9700\u5728\u4F1A\u8BDD\u91CC\u70B9\u300C\u6DFB\u52A0\u76EE\u5F55\u300D\u3002\u6A21\u578B\u5DE5\u5177\u53EA\u8BA4\u300C\u843D\u5728\u672C\u4F1A\u8BDD\u5DE5\u4F5C\u533A\u5185\u7684\u6839\u300D\u4E0E\u300C\u951A\u70B9\u5C31\u662F\u672C\u4F1A\u8BDD\u5DE5\u4F5C\u533A\u7684\u8FDC\u7AEF\u6839\u300D\uFF1B\u5176\u4F59\u6839\u5728\u88C5\u5165\u4F1A\u8BDD\u4E4B\u524D\u4F1A\u62A5\u300C\u4F1A\u8BDD\u9694\u79BB\u300D\u3002profile \u91CC\u58F0\u660E\u7684\u6839\u53EA\u8BFB\u663E\u793A\uFF1A\u5220\u6389\u5B83\u91CD\u542F\u540E\u8FD8\u4F1A\u56DE\u6765\u3002",
|
|
47
66
|
rootsAdvancedSummary: "\u9AD8\u7EA7\uFF1A\u6839\u8868\uFF08\u8BCA\u65AD\uFF09",
|
|
48
67
|
rootsAdvancedNote: "\u8FD9\u662F\u63D2\u4EF6\u7684\u6839\u8DEF\u7531\u8868\uFF1A\u6BCF\u6761 = \u4E00\u4E2A\u8FDC\u7AEF\u76EE\u5F55 + \u63D2\u4EF6\u4E3A\u5B83\u5EFA\u7684\u672C\u5730\u951A\u70B9\u76EE\u5F55\u3002\u5220\u6389\u67D0\u6761\u540E\uFF0C\u6B63\u5728\u4F7F\u7528\u8BE5\u951A\u70B9\u7684\u5DE5\u4F5C\u533A\u91CC remote_* \u5DE5\u5177\u4F1A\u7ACB\u523B\u62A5\u300C\u4E0D\u5728\u4EFB\u4F55\u8FDC\u7AEF\u6839\u5185\u300D\uFF1B\u5DE5\u4F5C\u533A\u672C\u8EAB\u4ECD\u5728\u4FA7\u680F\u3002",
|
|
68
|
+
rootsScopeNoSession: "\u6CA1\u6709\u4F1A\u8BDD\u4F5C\u7528\u57DF\uFF1A\u6A21\u578B\u53EA\u5BF9\u843D\u5728\u4F1A\u8BDD\u5DE5\u4F5C\u533A\u5185\u7684\u6839\u53EF\u8FBE\uFF1B\u5176\u4F59\u9700\u5728\u4F1A\u8BDD\u91CC\u70B9\u300C\u6DFB\u52A0\u76EE\u5F55\u300D",
|
|
49
69
|
rootsEmpty: "\u8FD8\u6CA1\u6709\u914D\u7F6E\u4EFB\u4F55\u6839\u3002",
|
|
50
70
|
rootsPending: "\u6B63\u5728\u4E0E\u5BBF\u4E3B\u901A\u4FE1\u2026",
|
|
51
71
|
rootsAuthPassword: "\u7528\u6237\u540D + \u5BC6\u7801",
|
|
@@ -167,7 +187,6 @@ var OVER_SSH_DICTS = {
|
|
|
167
187
|
rootsImportFailed: "\u5BFC\u5165\u5931\u8D25\uFF1A{message}",
|
|
168
188
|
transferTitle: "\u4F20\u8F93\u8FDB\u5EA6",
|
|
169
189
|
transferDescription: "\u5F53\u524D\u4E0E\u6700\u8FD1\u7684\u4F20\u8F93\uFF1A\u901F\u7387\u3001\u5DF2\u5B8C\u6210\u5B57\u8282\u3001\u5269\u4F59\u65F6\u95F4\u3002\u5931\u8D25\u9879\u7559\u5728\u4E0B\u9762\u3002",
|
|
170
|
-
remoteFilesHint: "\u8FDC\u7AEF\u6587\u4EF6\u901A\u8FC7\u5BF9\u8BDD\u8BBF\u95EE\uFF1A@ \u8DEF\u5F84\uFF0C\u6216\u8BA9\u6211\u5217\u76EE\u5F55\u3002",
|
|
171
190
|
transferEmpty: "\u5F53\u524D\u6CA1\u6709\u4F20\u8F93\u3002",
|
|
172
191
|
transferLoading: "\u6B63\u5728\u8BFB\u53D6\u4F20\u8F93\u72B6\u6001\u2026",
|
|
173
192
|
transferRefresh: "\u5237\u65B0",
|
|
@@ -235,7 +254,9 @@ var OVER_SSH_DICTS = {
|
|
|
235
254
|
sessionRootsTitle: "\u672C\u4F1A\u8BDD\u53EF\u7528\u7684\u76EE\u5F55",
|
|
236
255
|
sessionRootsEmpty: "\u8FD8\u6CA1\u6709\u6DFB\u52A0\u76EE\u5F55\u3002\u70B9\u300C\u6D4F\u89C8\u2026\u300D\u9009\u4E00\u4E2A\u672C\u5730\u6216\u8FDC\u7AEF\u76EE\u5F55\u3002",
|
|
237
256
|
sessionRootsLoading: "\u6B63\u5728\u8BFB\u53D6\u8FD9\u4E2A\u4F1A\u8BDD\u7684\u76EE\u5F55\u2026",
|
|
238
|
-
sessionRootsHint: "\u672C\u5730\u76EE\u5F55\u76F4\u63A5\u8BFB\u5199\uFF1B\u8FDC\u7AEF\u76EE\u5F55\u7ECF SSH \u4EE3\u7406\u8BBF\u95EE\u3002\u53EA\u6709\u4F60\u81EA\u5DF1\u52A0\u7684\u76EE\u5F55\u80FD\u5220\u9664\u3002\u63D2\u4EF6\u8BBE\u7F6E\u91CC\u767B\u8BB0\u7684\u6839\u4E0D\u5728\u8FD9\u91CC\u2014\u2014\u8981\u7528\u5B83\uFF0C\u5148\u5728\u4F1A\u8BDD\u91CC\u6DFB\u52A0\u3002",
|
|
257
|
+
sessionRootsHint: "\u672C\u5730\u76EE\u5F55\u76F4\u63A5\u8BFB\u5199\uFF1B\u8FDC\u7AEF\u76EE\u5F55\u7ECF SSH \u4EE3\u7406\u8BBF\u95EE\u3002\u7B2C\u4E00\u884C\u662F\u5F53\u524D\u5DE5\u4F5C\u533A\uFF08\u59CB\u7EC8\u53EF\u7528\u3001\u4E0D\u53EF\u5220\u9664\uFF09\uFF1B\u53EA\u6709\u4F60\u81EA\u5DF1\u52A0\u7684\u76EE\u5F55\u80FD\u5220\u9664\u3002\u63D2\u4EF6\u8BBE\u7F6E\u91CC\u767B\u8BB0\u7684\u6839\u4E0D\u5728\u8FD9\u91CC\u2014\u2014\u8981\u7528\u5B83\uFF0C\u5148\u5728\u4F1A\u8BDD\u91CC\u6DFB\u52A0\u3002",
|
|
258
|
+
sessionRootsWorkspaceBadge: "\u5DE5\u4F5C\u533A",
|
|
259
|
+
sessionRootsWorkspaceNote: "\u4F1A\u8BDD\u7684\u9ED8\u8BA4\u8FB9\u754C\uFF1Bworkspace-write \u4E0B\u4E0E\u5DF2\u6DFB\u52A0\u76EE\u5F55\u4E00\u6837\u53EF\u5199\uFF0Cread-only \u4E0B\u90FD\u4E0D\u53EF\u5199",
|
|
239
260
|
sessionRootsFailed: "\u8BFB\u53D6\u76EE\u5F55\u5217\u8868\u5931\u8D25\uFF1A{reason}",
|
|
240
261
|
sessionAddDone: "\u5DF2\u52A0\u5165\uFF1A{path}\uFF08\u672A\u65B0\u5EFA\u5DE5\u4F5C\u533A\uFF09",
|
|
241
262
|
sessionAddFailed: "\u52A0\u5165\u5931\u8D25\uFF1A{reason}",
|
|
@@ -258,6 +279,11 @@ var OVER_SSH_DICTS = {
|
|
|
258
279
|
sessionResultPurged: "\u5DF2\u6C38\u4E45\u6E05\u9664\u8BE5\u6761\u76EE",
|
|
259
280
|
sessionResultSweepNone: "\u6CA1\u6709\u9700\u8981\u6E05\u7406\u7684\u6761\u76EE",
|
|
260
281
|
sessionResultSweepDone: "\u5DF2\u6E05\u7406 {count} \u4E2A\u6761\u76EE",
|
|
282
|
+
sessionResultSweepPartial: "\u5DF2\u6E05\u7406 {count} \u4E2A\u6761\u76EE\uFF0C\u53E6\u6709 {failed} \u4E2A\u5931\u8D25\uFF08\u539F\u56E0\u89C1\u5BBF\u4E3B\u65E5\u5FD7\uFF09",
|
|
283
|
+
sessionResultDeletedPermanentPartial: "\u5DF2\u6C38\u4E45\u5220\u9664 {size}\uFF08\u4E0D\u53EF\u6062\u590D\uFF09\uFF0C\u53E6\u6709 {refused} \u4E2A\u88AB\u62D2\u7EDD",
|
|
284
|
+
sessionResultRecycledPartial: "\u5DF2\u79FB\u5165\u4F1A\u8BDD\u56DE\u6536\u7AD9 {size}\uFF08\u53EF\u6062\u590D\uFF09\uFF0C\u53E6\u6709 {refused} \u4E2A\u88AB\u62D2\u7EDD",
|
|
285
|
+
sessionResultSweepConfirmTitle: "\u786E\u8BA4\u6E05\u7406\u56DE\u6536\u7AD9",
|
|
286
|
+
sessionResultSweepConfirmBody: "\u5C06\u6309\u4FDD\u7559\u7B56\u7565\u6C38\u4E45\u6E05\u9664\u56DE\u6536\u7AD9\u4E2D\u7684\u6761\u76EE\uFF08\u4E0D\u53EF\u6062\u590D\uFF09\u3002\u786E\u5B9A\u7EE7\u7EED\uFF1F",
|
|
261
287
|
sessionWorkspaceUnknown: "\uFF08\u65E0\u5DE5\u4F5C\u533A\uFF09",
|
|
262
288
|
connectionTestNoAnswer: "\u5BBF\u4E3B\u6CA1\u6709\u56DE\u6D4B\u8BD5\u7ED3\u679C",
|
|
263
289
|
connectionTestFailed: "\u8FDE\u63A5\u6D4B\u8BD5\u5931\u8D25",
|
|
@@ -283,9 +309,10 @@ var OVER_SSH_DICTS = {
|
|
|
283
309
|
rootsTitle: "Remote workspaces",
|
|
284
310
|
// Same correction as the Chinese copy: this page can only remove rows (adding moved to the workspace
|
|
285
311
|
// directory flow, audit 0027). The scope sentence is the audit-0056 guard and stays.
|
|
286
|
-
rootsDescription: "This page only lists the roots the plugin has registered, and lets you remove them; to add a directory, use the workspace directory flow. A root registered here does not appear in a session's directory list automatically: to use it, add it from the session first. Roots declared in the profile are read-only here: deleting one would bring it back after a restart.",
|
|
312
|
+
rootsDescription: "This page only lists the roots the plugin has registered, and lets you remove them; to add a directory, use the workspace directory flow. A root registered here does not appear in a session's directory list automatically: to use it, add it from the session first. The model reaches only roots that fall inside the session workspace (or a remote root whose anchor is that workspace); every other root reports session isolation until a session takes it in. Roots declared in the profile are read-only here: deleting one would bring it back after a restart.",
|
|
287
313
|
rootsAdvancedSummary: "Advanced: root table (diagnostic)",
|
|
288
314
|
rootsAdvancedNote: "This is the plugin's root routing table: each row is a remote directory plus the local anchor directory the plugin created for it. Deleting a row makes the remote_* tools fail immediately in any workspace that already uses that anchor; the workspace itself stays in the sidebar.",
|
|
315
|
+
rootsScopeNoSession: "No session scope: the model reaches only roots inside the session workspace; add it from the session otherwise",
|
|
289
316
|
rootsEmpty: "No root is configured yet.",
|
|
290
317
|
rootsPending: "Talking to the host\u2026",
|
|
291
318
|
rootsAuthPassword: "Username + password",
|
|
@@ -402,7 +429,6 @@ var OVER_SSH_DICTS = {
|
|
|
402
429
|
rootsImportFailed: "Import failed: {message}",
|
|
403
430
|
transferTitle: "Transfers",
|
|
404
431
|
transferDescription: "Current and recent transfers: rate, bytes done, time left. Failures stay listed below.",
|
|
405
|
-
remoteFilesHint: "Remote files are reached through the conversation: @ a path, or ask me to list a directory.",
|
|
406
432
|
transferEmpty: "Nothing is transferring.",
|
|
407
433
|
transferLoading: "Reading transfer state\u2026",
|
|
408
434
|
transferRefresh: "Refresh",
|
|
@@ -470,7 +496,9 @@ var OVER_SSH_DICTS = {
|
|
|
470
496
|
sessionRootsTitle: "Directories available to this session",
|
|
471
497
|
sessionRootsEmpty: "No directory yet. Use \u300CBrowse\u2026\u300D to pick a local or remote one.",
|
|
472
498
|
sessionRootsLoading: "Reading this session's directories\u2026",
|
|
473
|
-
sessionRootsHint: "Local directories are read and written directly; remote ones go through the SSH agent.
|
|
499
|
+
sessionRootsHint: "Local directories are read and written directly; remote ones go through the SSH agent. The first row is the current workspace (always available, cannot be removed); only directories you added can be removed. A root registered in the plugin settings is not listed here \u2014 add it from this session to use it.",
|
|
500
|
+
sessionRootsWorkspaceBadge: "Workspace",
|
|
501
|
+
sessionRootsWorkspaceNote: "The conversation's default boundary; writable under workspace-write like added directories, neither is writable under read-only",
|
|
474
502
|
sessionRootsFailed: "Could not read the directory list: {reason}",
|
|
475
503
|
sessionAddDone: "Added: {path} (no workspace was created)",
|
|
476
504
|
sessionAddFailed: "Could not add it: {reason}",
|
|
@@ -493,6 +521,11 @@ var OVER_SSH_DICTS = {
|
|
|
493
521
|
sessionResultPurged: "Permanently removed that entry",
|
|
494
522
|
sessionResultSweepNone: "Nothing to sweep",
|
|
495
523
|
sessionResultSweepDone: "Swept {count} entries",
|
|
524
|
+
sessionResultSweepPartial: "Swept {count} entries; {failed} failed (see host log)",
|
|
525
|
+
sessionResultDeletedPermanentPartial: "Permanently deleted {size} (unrecoverable); {refused} refused",
|
|
526
|
+
sessionResultRecycledPartial: "Moved {size} to the recycle bin (recoverable); {refused} refused",
|
|
527
|
+
sessionResultSweepConfirmTitle: "Confirm sweeping the bin",
|
|
528
|
+
sessionResultSweepConfirmBody: "This permanently purges bin entries beyond the retention policy (unrecoverable). Continue?",
|
|
496
529
|
sessionWorkspaceUnknown: "(no workspace)",
|
|
497
530
|
connectionTestNoAnswer: "The host returned no test result",
|
|
498
531
|
connectionTestFailed: "The connection test failed",
|
|
@@ -1285,6 +1318,10 @@ var BUTTON_VARIANT_CLASS = {
|
|
|
1285
1318
|
function isEscape(event) {
|
|
1286
1319
|
return event.key === "Escape";
|
|
1287
1320
|
}
|
|
1321
|
+
function fieldValue(event) {
|
|
1322
|
+
const target = event.target;
|
|
1323
|
+
return typeof target?.value === "string" ? target.value : "";
|
|
1324
|
+
}
|
|
1288
1325
|
function Button(props) {
|
|
1289
1326
|
const variantClass = BUTTON_VARIANT_CLASS[props.variant];
|
|
1290
1327
|
return (0, import_react.createElement)(
|
|
@@ -1507,6 +1544,7 @@ function useCardTabTrap(open, container) {
|
|
|
1507
1544
|
// src/shared/routes.ts
|
|
1508
1545
|
var ROOTS_API_PATH = "/api/over-ssh/roots";
|
|
1509
1546
|
var SESSION_API_PATH = "/api/over-ssh/sessions";
|
|
1547
|
+
var GIT_STATUS_API_PATH = "/api/over-ssh/git-status";
|
|
1510
1548
|
|
|
1511
1549
|
// src/shared/root-keys.ts
|
|
1512
1550
|
function isAbsoluteLocalPath(path) {
|
|
@@ -1785,6 +1823,9 @@ function buildRootsView(input) {
|
|
|
1785
1823
|
title: row.label ?? row.path,
|
|
1786
1824
|
subtitle: row.location === "remote" ? `${row.connectionId ?? "?"}:${row.path}` : row.path,
|
|
1787
1825
|
badgeKey: row.location === "remote" ? "rootsBadgeRemote" : "rootsBadgeLocal",
|
|
1826
|
+
// 只有"用户在设置页登记、且没有任何会话作用域"的行才对 AI 不可用;profile 里的根是部署级共享根,
|
|
1827
|
+
// 不属于这个提示的适用范围。
|
|
1828
|
+
...row.source === "user" && (row.workspaces?.length ?? 0) === 0 && (row.sessions?.length ?? 0) === 0 ? { scopeKey: "rootsScopeNoSession" } : {},
|
|
1788
1829
|
// Only a root the panel owns can be deleted: a config root comes back after a restart, so offering a
|
|
1789
1830
|
// delete button for it would be a lie the user cannot see. A root that is both declared and added is
|
|
1790
1831
|
// deletable — the file entry goes away, which is what the user asked for.
|
|
@@ -1942,6 +1983,10 @@ function withoutError(state) {
|
|
|
1942
1983
|
const { error: _error, ...rest } = state;
|
|
1943
1984
|
return rest;
|
|
1944
1985
|
}
|
|
1986
|
+
function withoutAdding(state) {
|
|
1987
|
+
const { addingConnection: _adding, ...rest } = state;
|
|
1988
|
+
return rest;
|
|
1989
|
+
}
|
|
1945
1990
|
function withoutEditing(state) {
|
|
1946
1991
|
const { editingId: _editing, ...rest } = state;
|
|
1947
1992
|
return rest;
|
|
@@ -1980,7 +2025,7 @@ function rootsPanelReducer(state, action) {
|
|
|
1980
2025
|
case "succeeded": {
|
|
1981
2026
|
const saved = action.scope === void 0 ? void 0 : SAVED_MESSAGE[action.scope];
|
|
1982
2027
|
return {
|
|
1983
|
-
...withoutError(withoutFormNotice(withoutEditing(withoutConfirming(state)))),
|
|
2028
|
+
...withoutError(withoutFormNotice(withoutAdding(withoutEditing(withoutConfirming(state))))),
|
|
1984
2029
|
pending: false,
|
|
1985
2030
|
loaded: true,
|
|
1986
2031
|
// The edit dialog is closed by `withoutEditing` whether the request added or removed something, so its
|
|
@@ -1999,11 +2044,16 @@ function rootsPanelReducer(state, action) {
|
|
|
1999
2044
|
if (!canEdit(state)) return state;
|
|
2000
2045
|
return { ...withoutFormNotice(state), pending: true };
|
|
2001
2046
|
}
|
|
2047
|
+
case "add-connection-opened":
|
|
2048
|
+
return { ...withoutEditing(withoutFormNotice(state)), addingConnection: true };
|
|
2049
|
+
case "add-connection-closed":
|
|
2050
|
+
if (state.addingConnection !== true) return state;
|
|
2051
|
+
return withoutAdding(withoutFormNotice(state));
|
|
2002
2052
|
case "edit-started": {
|
|
2003
2053
|
const row = state.snapshot?.connections.find((candidate) => candidate.id === action.id);
|
|
2004
2054
|
if (row === void 0) return state;
|
|
2005
2055
|
return {
|
|
2006
|
-
...withoutFormNotice(state),
|
|
2056
|
+
...withoutFormNotice(withoutAdding(state)),
|
|
2007
2057
|
editingId: row.id,
|
|
2008
2058
|
editDrafts: {
|
|
2009
2059
|
id: row.id,
|
|
@@ -2446,6 +2496,17 @@ function createTransferProgress(options) {
|
|
|
2446
2496
|
listeners.clear();
|
|
2447
2497
|
void running;
|
|
2448
2498
|
},
|
|
2499
|
+
/**
|
|
2500
|
+
* Re-arm after a `dispose()`.
|
|
2501
|
+
*
|
|
2502
|
+
* React's development replay runs an effect's cleanup and then the effect again **on the same controller**
|
|
2503
|
+
* (the instance is kept by `useMemo`). Without `activate()` the second mount's `start()` was silently
|
|
2504
|
+
* ignored — `dispose()` set `disposed = true` and nothing cleared it — so the transfer panel never polled
|
|
2505
|
+
* again for the rest of the session. Every other controller in this half already has this method.
|
|
2506
|
+
*/
|
|
2507
|
+
activate: () => {
|
|
2508
|
+
disposed = false;
|
|
2509
|
+
},
|
|
2449
2510
|
requests: () => issued
|
|
2450
2511
|
};
|
|
2451
2512
|
}
|
|
@@ -2456,6 +2517,7 @@ function TransferProgressSection({ copy }) {
|
|
|
2456
2517
|
const progress = (0, import_react4.useMemo)(() => createTransferProgress({ io }), [io]);
|
|
2457
2518
|
const panel = (0, import_react4.useSyncExternalStore)(progress.subscribe, progress.getState);
|
|
2458
2519
|
(0, import_react4.useEffect)(() => {
|
|
2520
|
+
progress.activate();
|
|
2459
2521
|
progress.start();
|
|
2460
2522
|
return () => progress.dispose();
|
|
2461
2523
|
}, [progress]);
|
|
@@ -2514,6 +2576,7 @@ function createRootsPanelController(options) {
|
|
|
2514
2576
|
const listeners = /* @__PURE__ */ new Set();
|
|
2515
2577
|
let token = 0;
|
|
2516
2578
|
let disposed = false;
|
|
2579
|
+
const requests = createAbortGate();
|
|
2517
2580
|
const dispatch = (action) => {
|
|
2518
2581
|
const next = rootsPanelReducer(state, action);
|
|
2519
2582
|
if (next === state) return;
|
|
@@ -2523,8 +2586,9 @@ function createRootsPanelController(options) {
|
|
|
2523
2586
|
const latest = (mine) => !disposed && mine === token;
|
|
2524
2587
|
const reload = async () => {
|
|
2525
2588
|
const mine = ++token;
|
|
2589
|
+
const signal = requests.begin();
|
|
2526
2590
|
dispatch({ type: "load" });
|
|
2527
|
-
const result = await options.api.load();
|
|
2591
|
+
const result = await options.api.load(signal);
|
|
2528
2592
|
if (!latest(mine)) return;
|
|
2529
2593
|
if (result.ok) dispatch({ type: "loaded", snapshot: result.value });
|
|
2530
2594
|
else dispatch({ type: "failed", error: result.error });
|
|
@@ -2554,6 +2618,7 @@ function createRootsPanelController(options) {
|
|
|
2554
2618
|
};
|
|
2555
2619
|
const submitConnection = async () => {
|
|
2556
2620
|
if (state.editingId !== void 0) return;
|
|
2621
|
+
if (state.addingConnection !== true) return;
|
|
2557
2622
|
if (!canEdit(state)) return;
|
|
2558
2623
|
const { drafts } = state;
|
|
2559
2624
|
const portText = drafts.newPort.trim();
|
|
@@ -2592,6 +2657,12 @@ function createRootsPanelController(options) {
|
|
|
2592
2657
|
const cancelEdit = () => {
|
|
2593
2658
|
dispatch({ type: "edit-ended" });
|
|
2594
2659
|
};
|
|
2660
|
+
const openAddConnection = () => {
|
|
2661
|
+
dispatch({ type: "add-connection-opened" });
|
|
2662
|
+
};
|
|
2663
|
+
const closeAddConnection = () => {
|
|
2664
|
+
dispatch({ type: "add-connection-closed" });
|
|
2665
|
+
};
|
|
2595
2666
|
const defer = options.defer ?? ((run) => {
|
|
2596
2667
|
const handle = setTimeout(run, 0);
|
|
2597
2668
|
return () => clearTimeout(handle);
|
|
@@ -2704,6 +2775,8 @@ function createRootsPanelController(options) {
|
|
|
2704
2775
|
changeEditDraft: (patch) => dispatch({ type: "edit-draft-changed", patch }),
|
|
2705
2776
|
submitRoot,
|
|
2706
2777
|
submitConnection,
|
|
2778
|
+
openAddConnection,
|
|
2779
|
+
closeAddConnection,
|
|
2707
2780
|
remove,
|
|
2708
2781
|
startEdit,
|
|
2709
2782
|
cancelEdit,
|
|
@@ -2722,6 +2795,7 @@ function createRootsPanelController(options) {
|
|
|
2722
2795
|
submitImport,
|
|
2723
2796
|
dispose: () => {
|
|
2724
2797
|
disposed = true;
|
|
2798
|
+
requests.abort();
|
|
2725
2799
|
listeners.clear();
|
|
2726
2800
|
surfaceAttached();
|
|
2727
2801
|
}
|
|
@@ -2733,6 +2807,8 @@ function rootsPanelSectionActions(controller) {
|
|
|
2733
2807
|
onEditDraftChange: controller.changeEditDraft,
|
|
2734
2808
|
onSubmitRoot: (location) => void controller.submitRoot(location),
|
|
2735
2809
|
onSubmitConnection: () => void controller.submitConnection(),
|
|
2810
|
+
onOpenAddConnection: controller.openAddConnection,
|
|
2811
|
+
onCloseAddConnection: controller.closeAddConnection,
|
|
2736
2812
|
onRemove: (key) => void controller.remove(key),
|
|
2737
2813
|
onSubmitEdit: () => void controller.submitEdit(),
|
|
2738
2814
|
onCancelEdit: controller.cancelEdit,
|
|
@@ -2745,9 +2821,43 @@ function rootsPanelSectionActions(controller) {
|
|
|
2745
2821
|
onImportSubmit: () => void controller.submitImport()
|
|
2746
2822
|
};
|
|
2747
2823
|
}
|
|
2748
|
-
function
|
|
2749
|
-
const
|
|
2750
|
-
|
|
2824
|
+
function selectRootsViewForRender(state) {
|
|
2825
|
+
const cached = rootsViewCache;
|
|
2826
|
+
if (cached !== void 0 && cached.snapshot === state.snapshot && cached.error === state.error && cached.pending === state.pending) {
|
|
2827
|
+
return cached.view;
|
|
2828
|
+
}
|
|
2829
|
+
const view = selectRootsView(state);
|
|
2830
|
+
rootsViewCache = { snapshot: state.snapshot, error: state.error, pending: state.pending, view };
|
|
2831
|
+
return view;
|
|
2832
|
+
}
|
|
2833
|
+
var rootsViewCache;
|
|
2834
|
+
var rootsRowElementsCache = /* @__PURE__ */ new WeakMap();
|
|
2835
|
+
function rootsPanelRowElements(view, copy, editable, onRemove) {
|
|
2836
|
+
const cached = rootsRowElementsCache.get(view);
|
|
2837
|
+
if (cached !== void 0 && cached.copy === copy && cached.editable === editable && cached.onRemove === onRemove) {
|
|
2838
|
+
return cached.elements;
|
|
2839
|
+
}
|
|
2840
|
+
const elements = view.rows.map((row) => renderRow(row, copy, editable, onRemove));
|
|
2841
|
+
rootsRowElementsCache.set(view, { copy, editable, onRemove, elements });
|
|
2842
|
+
return elements;
|
|
2843
|
+
}
|
|
2844
|
+
function openRootsDialog(portal, doc) {
|
|
2845
|
+
const origin = captureFocusOrigin(doc);
|
|
2846
|
+
portal.mount();
|
|
2847
|
+
portal.focusCard();
|
|
2848
|
+
return () => {
|
|
2849
|
+
portal.unmount();
|
|
2850
|
+
restoreFocusOrigin(origin);
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
function installRootsDialogEscape(doc, close) {
|
|
2854
|
+
if (doc?.addEventListener === void 0) return () => void 0;
|
|
2855
|
+
const onKeyDown = (event) => {
|
|
2856
|
+
if (event.key !== "Escape") return;
|
|
2857
|
+
close();
|
|
2858
|
+
};
|
|
2859
|
+
doc.addEventListener("keydown", onKeyDown);
|
|
2860
|
+
return () => doc.removeEventListener?.("keydown", onKeyDown);
|
|
2751
2861
|
}
|
|
2752
2862
|
function renderFormNotice(copy, state, scope) {
|
|
2753
2863
|
const notice = state.formNotice;
|
|
@@ -2766,6 +2876,7 @@ function renderRow(row, copy, editable, onRemove) {
|
|
|
2766
2876
|
(0, import_react5.createElement)("span", { className: CLASS.rowTitle }, row.title),
|
|
2767
2877
|
(0, import_react5.createElement)("code", { className: CLASS.badge }, resolveMessage2(copy, { key: row.badgeKey })),
|
|
2768
2878
|
(0, import_react5.createElement)("span", { className: CLASS.rowMeta }, row.subtitle),
|
|
2879
|
+
row.scopeKey === void 0 ? null : (0, import_react5.createElement)("span", { className: CLASS.hint }, resolveMessage2(copy, { key: row.scopeKey })),
|
|
2769
2880
|
// The one and only source of "may this be deleted": the view model's `removable`. A per-row destructive
|
|
2770
2881
|
// action is the `ghost` variant, so the row stays quiet until the operator means it.
|
|
2771
2882
|
row.removable ? (0, import_react5.createElement)(
|
|
@@ -2775,9 +2886,15 @@ function renderRow(row, copy, editable, onRemove) {
|
|
|
2775
2886
|
) : null
|
|
2776
2887
|
);
|
|
2777
2888
|
}
|
|
2778
|
-
function SshConfigImportDialog({
|
|
2889
|
+
function SshConfigImportDialog({
|
|
2890
|
+
copy,
|
|
2891
|
+
state,
|
|
2892
|
+
actions,
|
|
2893
|
+
idPrefix = DEFAULT_ROOTS_ID_PREFIX,
|
|
2894
|
+
editable: editableOverride
|
|
2895
|
+
}) {
|
|
2779
2896
|
if (state.importOpen !== true) return null;
|
|
2780
|
-
const editable = canEdit(state);
|
|
2897
|
+
const editable = editableOverride ?? canEdit(state);
|
|
2781
2898
|
const listing = state.importListing;
|
|
2782
2899
|
const selected = new Set(state.importSelected ?? []);
|
|
2783
2900
|
const busy = state.importBusy === true;
|
|
@@ -2789,10 +2906,12 @@ function SshConfigImportDialog({ copy, state, actions, idPrefix = DEFAULT_ROOTS_
|
|
|
2789
2906
|
"section",
|
|
2790
2907
|
{
|
|
2791
2908
|
className: CLASS.block,
|
|
2792
|
-
|
|
2793
|
-
//
|
|
2794
|
-
//
|
|
2795
|
-
//
|
|
2909
|
+
// A named **region**, not a dialog. It is rendered inline inside the settings page's own scroll flow (it is
|
|
2910
|
+
// not portalled), the page behind it stays interactive, and it has no Tab trap — a `role="dialog"` would
|
|
2911
|
+
// promise modal behaviour the surface does not implement, which is worse than not claiming it. The other
|
|
2912
|
+
// two forms really are portalled modals (`aria-modal`, focus trap), so the two roles stay honest
|
|
2913
|
+
// (audit 2026-11-01b ui P2-2; 2026-11-05 P2-3).
|
|
2914
|
+
role: "region",
|
|
2796
2915
|
"aria-labelledby": `${idPrefix}-import-title`,
|
|
2797
2916
|
// Esc closes it from anywhere inside, like the other five dialogs. It is inline, so this handler is the
|
|
2798
2917
|
// only one that will see the key while the settings page scrolls the caret inside it.
|
|
@@ -2915,10 +3034,154 @@ function SshConfigImportDialog({ copy, state, actions, idPrefix = DEFAULT_ROOTS_
|
|
|
2915
3034
|
)
|
|
2916
3035
|
);
|
|
2917
3036
|
}
|
|
2918
|
-
function
|
|
3037
|
+
function ConnectionAddForm({
|
|
3038
|
+
copy,
|
|
3039
|
+
state,
|
|
3040
|
+
actions,
|
|
3041
|
+
idPrefix = DEFAULT_ROOTS_ID_PREFIX,
|
|
3042
|
+
editable: editableOverride
|
|
3043
|
+
}) {
|
|
3044
|
+
if (state.addingConnection !== true) return null;
|
|
3045
|
+
const editable = editableOverride ?? canEdit(state);
|
|
3046
|
+
const drafts = state.drafts;
|
|
3047
|
+
return (0, import_react5.createElement)(
|
|
3048
|
+
"form",
|
|
3049
|
+
{
|
|
3050
|
+
onSubmit: (event) => {
|
|
3051
|
+
event.preventDefault();
|
|
3052
|
+
actions.onSubmitConnection();
|
|
3053
|
+
},
|
|
3054
|
+
className: CLASS.dialog,
|
|
3055
|
+
role: "dialog",
|
|
3056
|
+
"aria-modal": "true",
|
|
3057
|
+
"aria-label": copy.rootsAddConnection,
|
|
3058
|
+
onKeyDown: (event) => {
|
|
3059
|
+
if (!isEscape(event)) return;
|
|
3060
|
+
event.stopPropagation?.();
|
|
3061
|
+
actions.onCloseAddConnection();
|
|
3062
|
+
}
|
|
3063
|
+
},
|
|
3064
|
+
(0, import_react5.createElement)(
|
|
3065
|
+
"div",
|
|
3066
|
+
{ className: CLASS.dialogHeader },
|
|
3067
|
+
(0, import_react5.createElement)("h3", { className: CLASS.sectionTitle }, copy.rootsAddConnection)
|
|
3068
|
+
),
|
|
3069
|
+
(0, import_react5.createElement)(
|
|
3070
|
+
"div",
|
|
3071
|
+
{ className: CLASS.dialogBody },
|
|
3072
|
+
(0, import_react5.createElement)(
|
|
3073
|
+
"div",
|
|
3074
|
+
{ className: CLASS.form },
|
|
3075
|
+
Field({
|
|
3076
|
+
htmlFor: `${idPrefix}-connection-id`,
|
|
3077
|
+
label: copy.rootsIdLabel,
|
|
3078
|
+
children: TextInput({
|
|
3079
|
+
id: `${idPrefix}-connection-id`,
|
|
3080
|
+
name: "roots-connection-id",
|
|
3081
|
+
value: drafts.newId,
|
|
3082
|
+
disabled: !editable,
|
|
3083
|
+
onChange: (event) => actions.onDraftChange({ newId: fieldValue(event) })
|
|
3084
|
+
})
|
|
3085
|
+
}),
|
|
3086
|
+
Field({
|
|
3087
|
+
htmlFor: `${idPrefix}-connection-host`,
|
|
3088
|
+
label: copy.rootsHostLabel,
|
|
3089
|
+
children: TextInput({
|
|
3090
|
+
id: `${idPrefix}-connection-host`,
|
|
3091
|
+
name: "roots-connection-host",
|
|
3092
|
+
value: drafts.newHost,
|
|
3093
|
+
disabled: !editable,
|
|
3094
|
+
onChange: (event) => actions.onDraftChange({ newHost: fieldValue(event) })
|
|
3095
|
+
})
|
|
3096
|
+
}),
|
|
3097
|
+
Field({
|
|
3098
|
+
htmlFor: `${idPrefix}-connection-user`,
|
|
3099
|
+
label: copy.rootsUserLabel,
|
|
3100
|
+
children: TextInput({
|
|
3101
|
+
id: `${idPrefix}-connection-user`,
|
|
3102
|
+
name: "roots-connection-user",
|
|
3103
|
+
value: drafts.newUser,
|
|
3104
|
+
disabled: !editable,
|
|
3105
|
+
onChange: (event) => actions.onDraftChange({ newUser: fieldValue(event) })
|
|
3106
|
+
})
|
|
3107
|
+
}),
|
|
3108
|
+
Field({
|
|
3109
|
+
htmlFor: `${idPrefix}-connection-auth`,
|
|
3110
|
+
label: copy.rootsAuthLabel,
|
|
3111
|
+
children: Select({
|
|
3112
|
+
id: `${idPrefix}-connection-auth`,
|
|
3113
|
+
name: "roots-connection-auth",
|
|
3114
|
+
value: drafts.newAuthKind,
|
|
3115
|
+
disabled: !editable,
|
|
3116
|
+
onChange: (value) => actions.onDraftChange({ newAuthKind: value === "password" ? "password" : "key" }),
|
|
3117
|
+
options: [
|
|
3118
|
+
{ value: "key", label: copy.rootsAuthKey },
|
|
3119
|
+
{ value: "password", label: copy.rootsAuthPassword }
|
|
3120
|
+
]
|
|
3121
|
+
})
|
|
3122
|
+
}),
|
|
3123
|
+
// Only meaningful for key authentication; shown on demand rather than as a dead field.
|
|
3124
|
+
drafts.newAuthKind === "key" ? Field({
|
|
3125
|
+
htmlFor: `${idPrefix}-connection-key-path`,
|
|
3126
|
+
label: copy.rootsKeyPathLabel,
|
|
3127
|
+
children: TextInput({
|
|
3128
|
+
id: `${idPrefix}-connection-key-path`,
|
|
3129
|
+
name: "roots-connection-key-path",
|
|
3130
|
+
value: drafts.newKeyPath,
|
|
3131
|
+
disabled: !editable,
|
|
3132
|
+
onChange: (event) => actions.onDraftChange({ newKeyPath: fieldValue(event) })
|
|
3133
|
+
})
|
|
3134
|
+
}) : null,
|
|
3135
|
+
// Only meaningful for password authentication. The value never comes back from the host, so this field is
|
|
3136
|
+
// always empty on render: the panel can say a password is stored, never show one.
|
|
3137
|
+
drafts.newAuthKind === "password" ? Field({
|
|
3138
|
+
htmlFor: `${idPrefix}-connection-password`,
|
|
3139
|
+
label: copy.rootsPasswordLabel,
|
|
3140
|
+
children: TextInput({
|
|
3141
|
+
id: `${idPrefix}-connection-password`,
|
|
3142
|
+
name: "roots-connection-password",
|
|
3143
|
+
type: "password",
|
|
3144
|
+
autoComplete: "new-password",
|
|
3145
|
+
value: drafts.newPassword,
|
|
3146
|
+
disabled: !editable,
|
|
3147
|
+
placeholder: copy.rootsPasswordLabel,
|
|
3148
|
+
onChange: (event) => actions.onDraftChange({ newPassword: fieldValue(event) })
|
|
3149
|
+
})
|
|
3150
|
+
}) : null,
|
|
3151
|
+
Field({
|
|
3152
|
+
htmlFor: `${idPrefix}-connection-port`,
|
|
3153
|
+
label: copy.rootsPortLabel,
|
|
3154
|
+
children: TextInput({
|
|
3155
|
+
id: `${idPrefix}-connection-port`,
|
|
3156
|
+
name: "roots-connection-port",
|
|
3157
|
+
value: drafts.newPort,
|
|
3158
|
+
disabled: !editable,
|
|
3159
|
+
inputMode: "numeric",
|
|
3160
|
+
size: "compact",
|
|
3161
|
+
onChange: (event) => actions.onDraftChange({ newPort: fieldValue(event) })
|
|
3162
|
+
})
|
|
3163
|
+
})
|
|
3164
|
+
),
|
|
3165
|
+
renderFormNotice(copy, state, "connection")
|
|
3166
|
+
),
|
|
3167
|
+
(0, import_react5.createElement)(
|
|
3168
|
+
"div",
|
|
3169
|
+
{ className: CLASS.actions },
|
|
3170
|
+
Button({ variant: "primary", type: "submit", disabled: !editable, children: copy.rootsSave }),
|
|
3171
|
+
Button({ variant: "secondary", type: "button", disabled: !editable, onClick: actions.onCloseAddConnection, children: copy.rootsEditCancel })
|
|
3172
|
+
)
|
|
3173
|
+
);
|
|
3174
|
+
}
|
|
3175
|
+
function ConnectionEditForm({
|
|
3176
|
+
copy,
|
|
3177
|
+
state,
|
|
3178
|
+
actions,
|
|
3179
|
+
idPrefix = DEFAULT_ROOTS_ID_PREFIX,
|
|
3180
|
+
editable: editableOverride
|
|
3181
|
+
}) {
|
|
2919
3182
|
const editingId = state.editingId;
|
|
2920
3183
|
if (editingId === void 0) return null;
|
|
2921
|
-
const editable = canEdit(state);
|
|
3184
|
+
const editable = editableOverride ?? canEdit(state);
|
|
2922
3185
|
const drafts = state.editDrafts;
|
|
2923
3186
|
const renamed = drafts.id.trim() !== editingId;
|
|
2924
3187
|
return (0, import_react5.createElement)(
|
|
@@ -3082,12 +3345,16 @@ function RootsPanelSection({
|
|
|
3082
3345
|
actions,
|
|
3083
3346
|
transfers,
|
|
3084
3347
|
portal,
|
|
3085
|
-
idPrefix = DEFAULT_ROOTS_ID_PREFIX
|
|
3348
|
+
idPrefix = DEFAULT_ROOTS_ID_PREFIX,
|
|
3349
|
+
view: precomputedView,
|
|
3350
|
+
rows: precomputedRows,
|
|
3351
|
+
editable: editableOverride
|
|
3086
3352
|
}) {
|
|
3087
|
-
const view =
|
|
3088
|
-
const
|
|
3089
|
-
const
|
|
3090
|
-
const {
|
|
3353
|
+
const view = precomputedView ?? selectRootsViewForRender(state);
|
|
3354
|
+
const editable = editableOverride ?? view.canEdit;
|
|
3355
|
+
const editForm = ConnectionEditForm({ copy, state, actions, idPrefix, editable });
|
|
3356
|
+
const addForm = ConnectionAddForm({ copy, state, actions, idPrefix, editable });
|
|
3357
|
+
const rowElements = precomputedRows ?? rootsPanelRowElements(view, copy, editable, actions.onRemove);
|
|
3091
3358
|
const notice = resolveMessage2(copy, view.notice);
|
|
3092
3359
|
return (0, import_react5.createElement)(
|
|
3093
3360
|
"section",
|
|
@@ -3103,113 +3370,15 @@ function RootsPanelSection({
|
|
|
3103
3370
|
// collapsed diagnostic block at the bottom of this section (see below): it is still the only place a
|
|
3104
3371
|
// root can be removed from, so it is demoted, never dropped.
|
|
3105
3372
|
// -- add a connection ----------------------------------------------------
|
|
3373
|
+
// 2026-11-05(用户反馈):原先是常驻的页内表单,六个字段把页面占满;现在是一个按钮,点开才出现对话框,
|
|
3374
|
+
// 与「编辑」同一种卡片形态(同样的 `CLASS.dialog`、Esc 关闭、字段布局)。
|
|
3106
3375
|
(0, import_react5.createElement)(
|
|
3107
|
-
"
|
|
3108
|
-
{
|
|
3109
|
-
|
|
3110
|
-
event.preventDefault();
|
|
3111
|
-
actions.onSubmitConnection();
|
|
3112
|
-
},
|
|
3113
|
-
className: CLASS.block
|
|
3114
|
-
},
|
|
3115
|
-
(0, import_react5.createElement)("h4", { className: CLASS.blockTitle }, copy.rootsAddConnection),
|
|
3116
|
-
(0, import_react5.createElement)(
|
|
3117
|
-
"div",
|
|
3118
|
-
{ className: CLASS.form },
|
|
3119
|
-
Field({
|
|
3120
|
-
htmlFor: `${idPrefix}-connection-id`,
|
|
3121
|
-
label: copy.rootsIdLabel,
|
|
3122
|
-
children: TextInput({
|
|
3123
|
-
id: `${idPrefix}-connection-id`,
|
|
3124
|
-
name: "roots-connection-id",
|
|
3125
|
-
value: drafts.newId,
|
|
3126
|
-
disabled: !editable,
|
|
3127
|
-
onChange: (event) => actions.onDraftChange({ newId: fieldValue(event) })
|
|
3128
|
-
})
|
|
3129
|
-
}),
|
|
3130
|
-
Field({
|
|
3131
|
-
htmlFor: `${idPrefix}-connection-host`,
|
|
3132
|
-
label: copy.rootsHostLabel,
|
|
3133
|
-
children: TextInput({
|
|
3134
|
-
id: `${idPrefix}-connection-host`,
|
|
3135
|
-
name: "roots-connection-host",
|
|
3136
|
-
value: drafts.newHost,
|
|
3137
|
-
disabled: !editable,
|
|
3138
|
-
onChange: (event) => actions.onDraftChange({ newHost: fieldValue(event) })
|
|
3139
|
-
})
|
|
3140
|
-
}),
|
|
3141
|
-
Field({
|
|
3142
|
-
htmlFor: `${idPrefix}-connection-user`,
|
|
3143
|
-
label: copy.rootsUserLabel,
|
|
3144
|
-
children: TextInput({
|
|
3145
|
-
id: `${idPrefix}-connection-user`,
|
|
3146
|
-
name: "roots-connection-user",
|
|
3147
|
-
value: drafts.newUser,
|
|
3148
|
-
disabled: !editable,
|
|
3149
|
-
onChange: (event) => actions.onDraftChange({ newUser: fieldValue(event) })
|
|
3150
|
-
})
|
|
3151
|
-
}),
|
|
3152
|
-
Field({
|
|
3153
|
-
htmlFor: `${idPrefix}-connection-auth`,
|
|
3154
|
-
label: copy.rootsAuthLabel,
|
|
3155
|
-
children: Select({
|
|
3156
|
-
id: `${idPrefix}-connection-auth`,
|
|
3157
|
-
name: "roots-connection-auth",
|
|
3158
|
-
value: drafts.newAuthKind,
|
|
3159
|
-
disabled: !editable,
|
|
3160
|
-
onChange: (value) => actions.onDraftChange({ newAuthKind: value === "password" ? "password" : "key" }),
|
|
3161
|
-
options: [
|
|
3162
|
-
{ value: "key", label: copy.rootsAuthKey },
|
|
3163
|
-
{ value: "password", label: copy.rootsAuthPassword }
|
|
3164
|
-
]
|
|
3165
|
-
})
|
|
3166
|
-
}),
|
|
3167
|
-
// Only meaningful for key authentication; shown on demand rather than as a dead field.
|
|
3168
|
-
drafts.newAuthKind === "key" ? Field({
|
|
3169
|
-
htmlFor: `${idPrefix}-connection-key-path`,
|
|
3170
|
-
label: copy.rootsKeyPathLabel,
|
|
3171
|
-
children: TextInput({
|
|
3172
|
-
id: `${idPrefix}-connection-key-path`,
|
|
3173
|
-
name: "roots-connection-key-path",
|
|
3174
|
-
value: drafts.newKeyPath,
|
|
3175
|
-
disabled: !editable,
|
|
3176
|
-
onChange: (event) => actions.onDraftChange({ newKeyPath: fieldValue(event) })
|
|
3177
|
-
})
|
|
3178
|
-
}) : null,
|
|
3179
|
-
// Only meaningful for password authentication. The value never comes back from the host, so this
|
|
3180
|
-
// field is always empty on render: the panel can say a password is stored, never show one.
|
|
3181
|
-
drafts.newAuthKind === "password" ? Field({
|
|
3182
|
-
htmlFor: `${idPrefix}-connection-password`,
|
|
3183
|
-
label: copy.rootsPasswordLabel,
|
|
3184
|
-
children: TextInput({
|
|
3185
|
-
id: `${idPrefix}-connection-password`,
|
|
3186
|
-
name: "roots-connection-password",
|
|
3187
|
-
type: "password",
|
|
3188
|
-
autoComplete: "new-password",
|
|
3189
|
-
value: drafts.newPassword,
|
|
3190
|
-
disabled: !editable,
|
|
3191
|
-
placeholder: copy.rootsPasswordLabel,
|
|
3192
|
-
onChange: (event) => actions.onDraftChange({ newPassword: fieldValue(event) })
|
|
3193
|
-
})
|
|
3194
|
-
}) : null,
|
|
3195
|
-
Field({
|
|
3196
|
-
htmlFor: `${idPrefix}-connection-port`,
|
|
3197
|
-
label: copy.rootsPortLabel,
|
|
3198
|
-
children: TextInput({
|
|
3199
|
-
id: `${idPrefix}-connection-port`,
|
|
3200
|
-
name: "roots-connection-port",
|
|
3201
|
-
value: drafts.newPort,
|
|
3202
|
-
disabled: !editable,
|
|
3203
|
-
inputMode: "numeric",
|
|
3204
|
-
size: "compact",
|
|
3205
|
-
onChange: (event) => actions.onDraftChange({ newPort: fieldValue(event) })
|
|
3206
|
-
})
|
|
3207
|
-
}),
|
|
3208
|
-
Button({ variant: "primary", type: "submit", disabled: !editable, children: copy.rootsSave })
|
|
3209
|
-
)
|
|
3376
|
+
"div",
|
|
3377
|
+
{ className: CLASS.block },
|
|
3378
|
+
Button({ variant: "primary", disabled: !editable, onClick: () => actions.onOpenAddConnection(), children: copy.rootsAddConnection })
|
|
3210
3379
|
),
|
|
3380
|
+
// 成功行留在页面上:保存成功后对话框会关闭,提示必须仍在用户眼前。
|
|
3211
3381
|
renderFormSuccess(copy, state, "connection"),
|
|
3212
|
-
renderFormNotice(copy, state, "connection"),
|
|
3213
3382
|
// -- import from ~/.ssh/config (FR-2.1.4) --------------------------------
|
|
3214
3383
|
// The button sits next to the add form because it is an alternative way to *create* a connection, and it
|
|
3215
3384
|
// is disabled for the same reason every other control is (`canEdit`).
|
|
@@ -3218,17 +3387,16 @@ function RootsPanelSection({
|
|
|
3218
3387
|
{ className: CLASS.block },
|
|
3219
3388
|
Button({ variant: "secondary", disabled: !editable, onClick: () => actions.onImportOpen(), children: copy.rootsImportOpen })
|
|
3220
3389
|
),
|
|
3221
|
-
SshConfigImportDialog({ copy, state, actions, idPrefix }),
|
|
3222
|
-
// --
|
|
3223
|
-
//
|
|
3224
|
-
//
|
|
3225
|
-
//
|
|
3226
|
-
// actually lives; the tools behind it (`@` a path, or asking for a listing) are the agent's.
|
|
3227
|
-
(0, import_react5.createElement)("p", { className: CLASS.description }, copy.remoteFilesHint),
|
|
3390
|
+
SshConfigImportDialog({ copy, state, actions, idPrefix, editable }),
|
|
3391
|
+
// -- no file tree, no entry point (audit 0085) ---------------------------
|
|
3392
|
+
// Settings used to carry a sentence pointing at the conversation. It was a signpost for a capability
|
|
3393
|
+
// that deliberately has no UI; the decision still stands (remote files are the agent's), but the
|
|
3394
|
+
// signpost is gone — the settings section is not the place to teach it.
|
|
3228
3395
|
// -- transfer progress (FR-2.6.4) ----------------------------------------
|
|
3229
3396
|
transfers ?? null,
|
|
3230
3397
|
// -- edit one saved connection (a dialog, not a block in this page) -------
|
|
3231
3398
|
editForm === null ? null : portal === void 0 ? editForm : portal.render(editForm, portal.container),
|
|
3399
|
+
addForm === null ? null : portal === void 0 ? addForm : portal.render(addForm, portal.container),
|
|
3232
3400
|
// -- the advanced root table (diagnostic) --------------------------------
|
|
3233
3401
|
// This block used to be the section's headline content, which made it read as "the remote workspaces
|
|
3234
3402
|
// list". It is not one: each row is a *root route* - a remote directory plus the local anchor the
|
|
@@ -3248,7 +3416,7 @@ function RootsPanelSection({
|
|
|
3248
3416
|
void 0,
|
|
3249
3417
|
(0, import_react5.createElement)("summary", { className: CLASS.blockTitle }, copy.rootsAdvancedSummary),
|
|
3250
3418
|
(0, import_react5.createElement)("p", { className: CLASS.description }, copy.rootsAdvancedNote),
|
|
3251
|
-
view.rows.length === 0 ? (0, import_react5.createElement)("p", { className: CLASS.empty }, copy.rootsEmpty) : (0, import_react5.createElement)("ul", { className: CLASS.list }, ...
|
|
3419
|
+
view.rows.length === 0 ? (0, import_react5.createElement)("p", { className: CLASS.empty }, copy.rootsEmpty) : (0, import_react5.createElement)("ul", { className: CLASS.list }, ...rowElements)
|
|
3252
3420
|
)
|
|
3253
3421
|
)
|
|
3254
3422
|
);
|
|
@@ -3257,6 +3425,16 @@ function RootsPanelContainer({ copy, controller }) {
|
|
|
3257
3425
|
const state = (0, import_react5.useSyncExternalStore)(controller.subscribe, controller.getState);
|
|
3258
3426
|
const uid = (0, import_react5.useId)();
|
|
3259
3427
|
const idPrefix = `dsh-rw-${uid.replace(/[^A-Za-z0-9_-]/g, "")}`;
|
|
3428
|
+
const view = (0, import_react5.useMemo)(
|
|
3429
|
+
() => selectRootsViewForRender(state),
|
|
3430
|
+
[state.snapshot, state.error, state.pending]
|
|
3431
|
+
);
|
|
3432
|
+
const editable = view.canEdit;
|
|
3433
|
+
const sectionActions = (0, import_react5.useMemo)(() => rootsPanelSectionActions(controller), [controller]);
|
|
3434
|
+
const rowElements = (0, import_react5.useMemo)(
|
|
3435
|
+
() => rootsPanelRowElements(view, copy, editable, sectionActions.onRemove),
|
|
3436
|
+
[view, copy, editable, sectionActions.onRemove]
|
|
3437
|
+
);
|
|
3260
3438
|
const [portal] = (0, import_react5.useState)(
|
|
3261
3439
|
() => createCardPortal({
|
|
3262
3440
|
document: browserPortalDocument(),
|
|
@@ -3273,23 +3451,27 @@ function RootsPanelContainer({ copy, controller }) {
|
|
|
3273
3451
|
controller.surfaceAttached();
|
|
3274
3452
|
return controller.surfaceDetached;
|
|
3275
3453
|
}, [controller]);
|
|
3276
|
-
|
|
3454
|
+
const cardOpen = state.editingId !== void 0 || state.addingConnection === true;
|
|
3455
|
+
useCardTabTrap(cardOpen, portal.container);
|
|
3277
3456
|
(0, import_react5.useLayoutEffect)(() => {
|
|
3278
|
-
if (
|
|
3279
|
-
portal
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3457
|
+
if (!cardOpen) return;
|
|
3458
|
+
return openRootsDialog(portal, browserPortalDocument());
|
|
3459
|
+
}, [portal, cardOpen]);
|
|
3460
|
+
const closeOpenDialog = state.editingId !== void 0 ? sectionActions.onCancelEdit : state.addingConnection === true ? sectionActions.onCloseAddConnection : void 0;
|
|
3461
|
+
(0, import_react5.useEffect)(() => {
|
|
3462
|
+
if (!cardOpen || closeOpenDialog === void 0) return;
|
|
3463
|
+
return installRootsDialogEscape(browserPortalDocument(), closeOpenDialog);
|
|
3464
|
+
}, [cardOpen, closeOpenDialog]);
|
|
3286
3465
|
return RootsPanelSection({
|
|
3287
3466
|
copy,
|
|
3288
3467
|
state,
|
|
3289
3468
|
idPrefix,
|
|
3469
|
+
view,
|
|
3470
|
+
rows: rowElements,
|
|
3471
|
+
editable,
|
|
3290
3472
|
// No `actions`: the transfer panel owns its own polling and its own buttons (the prop that used to be passed
|
|
3291
3473
|
// here was typed `unknown` and never read — audit 2026-10-31 client.md §2.4).
|
|
3292
|
-
transfers: (0, import_react5.createElement)(TransferProgressSection, { copy
|
|
3474
|
+
transfers: (0, import_react5.createElement)(TransferProgressSection, { copy }),
|
|
3293
3475
|
// The panel needs the connection list, which this section already read; it does not re-fetch it.
|
|
3294
3476
|
actions: sectionActions,
|
|
3295
3477
|
portal
|
|
@@ -3315,14 +3497,14 @@ function renderConnectionRow(row, actions) {
|
|
|
3315
3497
|
"span",
|
|
3316
3498
|
{ className: CLASS.rowActions },
|
|
3317
3499
|
row.test === void 0 || onTest === void 0 ? null : Button({
|
|
3318
|
-
variant: "
|
|
3500
|
+
variant: "secondary",
|
|
3319
3501
|
size: "compact",
|
|
3320
3502
|
// Disabled while its own probe runs: one honest click, and the label says what is happening.
|
|
3321
3503
|
disabled: row.test.status === "running" || disabled === true,
|
|
3322
3504
|
onClick: () => onTest(row.id),
|
|
3323
3505
|
children: row.test.label
|
|
3324
3506
|
}),
|
|
3325
|
-
row.actions === void 0 || onEdit === void 0 ? null : Button({ variant: "
|
|
3507
|
+
row.actions === void 0 || onEdit === void 0 ? null : Button({ variant: "secondary", size: "compact", disabled: disabled === true, onClick: () => onEdit(row.id), children: row.actions.editLabel }),
|
|
3326
3508
|
row.actions === void 0 || onRemove === void 0 ? null : Button({ variant: "ghost", size: "compact", disabled: disabled === true, onClick: () => onRemove(row.id), children: row.actions.removeLabel })
|
|
3327
3509
|
)
|
|
3328
3510
|
);
|
|
@@ -3434,22 +3616,18 @@ function buildPanelModel(copy, options = {}) {
|
|
|
3434
3616
|
acceptLabel: copy.rootsConfirmRemoveAccept,
|
|
3435
3617
|
// The count is the reason this confirmation exists: deleting a connection silently takes its
|
|
3436
3618
|
// remote roots with it (the host does that, and a row showing only `id · host` would not say so).
|
|
3437
|
-
body: (row, remoteRoots) =>
|
|
3619
|
+
body: (row, remoteRoots) => resolveTemplate(copy.rootsConfirmRemoveBody, { id: row.id, count: remoteRoots })
|
|
3438
3620
|
}
|
|
3439
3621
|
}
|
|
3440
3622
|
};
|
|
3441
3623
|
}
|
|
3442
|
-
function resolveCopy(template, params) {
|
|
3443
|
-
return template.replace(/\{(\w+)\}/g, (placeholder, name) => {
|
|
3444
|
-
const value = params[name];
|
|
3445
|
-
return value === void 0 ? placeholder : String(value);
|
|
3446
|
-
});
|
|
3447
|
-
}
|
|
3448
3624
|
|
|
3449
|
-
// src/
|
|
3625
|
+
// src/shared/remote-identity.ts
|
|
3450
3626
|
var SSH_DEV_REMOTE_NAMESPACE = "sshdev";
|
|
3451
|
-
var SSH_DEV_REMOTE_METHOD = "connections";
|
|
3452
3627
|
var SSH_DEV_REMOTE_SERVICE = "sshDevRemote";
|
|
3628
|
+
|
|
3629
|
+
// src/client/core/remote.ts
|
|
3630
|
+
var SSH_DEV_REMOTE_METHOD = "connections";
|
|
3453
3631
|
var SSH_DEV_REMOTE_PACKAGE = "dsh-over-ssh";
|
|
3454
3632
|
var SSH_DEV_CONNECTIONS_DESCRIPTOR = {
|
|
3455
3633
|
id: `${SSH_DEV_REMOTE_PACKAGE}#${SSH_DEV_REMOTE_NAMESPACE}/${SSH_DEV_REMOTE_METHOD}`,
|
|
@@ -3503,6 +3681,7 @@ function createConnectionTester(options) {
|
|
|
3503
3681
|
const listeners = /* @__PURE__ */ new Set();
|
|
3504
3682
|
let state = /* @__PURE__ */ new Map();
|
|
3505
3683
|
let disposed = false;
|
|
3684
|
+
const controllers = /* @__PURE__ */ new Map();
|
|
3506
3685
|
const set = (id, next) => {
|
|
3507
3686
|
if (disposed) return;
|
|
3508
3687
|
const copy = new Map(state);
|
|
@@ -3534,15 +3713,21 @@ function createConnectionTester(options) {
|
|
|
3534
3713
|
};
|
|
3535
3714
|
const test = async (id) => {
|
|
3536
3715
|
if (disposed) return;
|
|
3716
|
+
controllers.get(id)?.abort();
|
|
3717
|
+
const controller = new AbortController();
|
|
3718
|
+
controllers.set(id, controller);
|
|
3537
3719
|
set(id, { status: "running" });
|
|
3538
|
-
const
|
|
3720
|
+
const timeoutSignal = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(timeoutMs) : void 0;
|
|
3721
|
+
const signal = timeoutSignal === void 0 ? controller.signal : typeof AbortSignal.any === "function" ? AbortSignal.any([controller.signal, timeoutSignal]) : timeoutSignal;
|
|
3722
|
+
const current = () => !disposed && controllers.get(id) === controller;
|
|
3539
3723
|
try {
|
|
3540
3724
|
const response = await options.fetchImpl(path, {
|
|
3541
3725
|
method: "POST",
|
|
3542
3726
|
headers: { "content-type": "application/json" },
|
|
3543
3727
|
body: JSON.stringify({ action: "test-connection", id }),
|
|
3544
|
-
|
|
3728
|
+
signal
|
|
3545
3729
|
});
|
|
3730
|
+
if (!current()) return;
|
|
3546
3731
|
if (!response.ok) {
|
|
3547
3732
|
set(id, {
|
|
3548
3733
|
status: "failed",
|
|
@@ -3553,6 +3738,7 @@ function createConnectionTester(options) {
|
|
|
3553
3738
|
}
|
|
3554
3739
|
set(id, parse(await response.json()));
|
|
3555
3740
|
} catch (error) {
|
|
3741
|
+
if (!current()) return;
|
|
3556
3742
|
if (isTimeout(error)) {
|
|
3557
3743
|
set(id, {
|
|
3558
3744
|
status: "failed",
|
|
@@ -3562,6 +3748,8 @@ function createConnectionTester(options) {
|
|
|
3562
3748
|
return;
|
|
3563
3749
|
}
|
|
3564
3750
|
set(id, { status: "failed", code: "UNREACHABLE", message: error instanceof Error ? error.message : String(error) });
|
|
3751
|
+
} finally {
|
|
3752
|
+
if (controllers.get(id) === controller) controllers.delete(id);
|
|
3565
3753
|
}
|
|
3566
3754
|
};
|
|
3567
3755
|
return {
|
|
@@ -3575,6 +3763,8 @@ function createConnectionTester(options) {
|
|
|
3575
3763
|
},
|
|
3576
3764
|
dispose: () => {
|
|
3577
3765
|
disposed = true;
|
|
3766
|
+
for (const controller of controllers.values()) controller.abort();
|
|
3767
|
+
controllers.clear();
|
|
3578
3768
|
listeners.clear();
|
|
3579
3769
|
}
|
|
3580
3770
|
};
|
|
@@ -3597,7 +3787,21 @@ function createLatestOnly() {
|
|
|
3597
3787
|
};
|
|
3598
3788
|
}
|
|
3599
3789
|
|
|
3790
|
+
// src/shared/path-fold.ts
|
|
3791
|
+
function foldPathKey(path) {
|
|
3792
|
+
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3600
3795
|
// src/client/session/session-panel.ts
|
|
3796
|
+
function unblockOrphanSubsessions(rows) {
|
|
3797
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
3798
|
+
return rows.map((row) => {
|
|
3799
|
+
if (row.classification !== "subsession" || row.blockedReason !== "subsession" || row.running) return row;
|
|
3800
|
+
const parent = row.parentSessionId === void 0 ? void 0 : byId.get(row.parentSessionId);
|
|
3801
|
+
if (parent !== void 0 && parent.classification !== "unreadable") return row;
|
|
3802
|
+
return { ...row, deletable: true, blockedReason: void 0 };
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3601
3805
|
function toSessionRowView(raw) {
|
|
3602
3806
|
const id = raw["id"];
|
|
3603
3807
|
if (typeof id !== "string" || id === "") return void 0;
|
|
@@ -3663,20 +3867,18 @@ function workspaceLabel(row) {
|
|
|
3663
3867
|
return "";
|
|
3664
3868
|
}
|
|
3665
3869
|
function visibleSessions(rows) {
|
|
3666
|
-
const
|
|
3870
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
3667
3871
|
return rows.filter((row) => {
|
|
3668
3872
|
if (row.classification !== "subsession") return true;
|
|
3669
3873
|
if (row.parentSessionId === void 0) return true;
|
|
3670
|
-
|
|
3874
|
+
const parent = byId.get(row.parentSessionId);
|
|
3875
|
+
return parent === void 0 || parent.classification === "unreadable";
|
|
3671
3876
|
});
|
|
3672
3877
|
}
|
|
3673
|
-
function hiddenSubsessionCount(rows) {
|
|
3674
|
-
return rows.length - visibleSessions(rows).length;
|
|
3675
|
-
}
|
|
3676
3878
|
function groupByWorkspace(rows) {
|
|
3677
3879
|
const groups = /* @__PURE__ */ new Map();
|
|
3678
3880
|
for (const row of rows) {
|
|
3679
|
-
const key = row.cwd === void 0 || row.cwd === "" ? "" : row.cwd;
|
|
3881
|
+
const key = row.cwd === void 0 || row.cwd === "" ? "" : foldPathKey(row.cwd);
|
|
3680
3882
|
const existing = groups.get(key);
|
|
3681
3883
|
if (existing === void 0) {
|
|
3682
3884
|
groups.set(key, { label: workspaceLabel(row), cwd: row.cwd, sessions: [row] });
|
|
@@ -3714,26 +3916,31 @@ function parseSessionSnapshot(value) {
|
|
|
3714
3916
|
if (body["ok"] !== true) return emptySnapshot();
|
|
3715
3917
|
const available = body["available"] === true;
|
|
3716
3918
|
const sessions = Array.isArray(body["sessions"]) ? body["sessions"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toSessionRowView(raw)].filter((row) => row !== void 0) : []) : [];
|
|
3919
|
+
const unblocked = unblockOrphanSubsessions(sessions);
|
|
3717
3920
|
const bin = Array.isArray(body["bin"]) ? body["bin"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toBinRowView(raw)].filter((row) => row !== void 0) : []) : [];
|
|
3718
3921
|
const rawPolicy = typeof body["policy"] === "object" && body["policy"] !== null ? body["policy"] : {};
|
|
3719
3922
|
const numberOr = (key) => typeof rawPolicy[key] === "number" && Number.isFinite(rawPolicy[key]) ? rawPolicy[key] : 0;
|
|
3720
3923
|
return {
|
|
3721
3924
|
available,
|
|
3722
|
-
sessions,
|
|
3925
|
+
sessions: unblocked,
|
|
3723
3926
|
bin,
|
|
3724
3927
|
policy: { maxEntries: numberOr("maxEntries"), maxBytes: numberOr("maxBytes"), maxAgeMs: numberOr("maxAgeMs") },
|
|
3725
3928
|
totals: {
|
|
3726
|
-
sessions:
|
|
3727
|
-
bytes:
|
|
3728
|
-
subsessions:
|
|
3929
|
+
sessions: unblocked.length,
|
|
3930
|
+
bytes: unblocked.reduce((sum, row) => sum + row.bytes, 0),
|
|
3931
|
+
subsessions: unblocked.filter((row) => row.classification === "subsession").length,
|
|
3729
3932
|
live: sessions.filter((row) => row.live).length,
|
|
3730
3933
|
running: sessions.filter((row) => row.running).length
|
|
3731
3934
|
}
|
|
3732
3935
|
};
|
|
3733
3936
|
}
|
|
3734
|
-
async function readSessionPanel(io) {
|
|
3937
|
+
async function readSessionPanel(io, signal) {
|
|
3735
3938
|
try {
|
|
3736
|
-
const response = await io.fetch(SESSION_API_PATH, {
|
|
3939
|
+
const response = await io.fetch(SESSION_API_PATH, {
|
|
3940
|
+
method: "GET",
|
|
3941
|
+
headers: { accept: "application/json" },
|
|
3942
|
+
...signal === void 0 ? {} : { signal }
|
|
3943
|
+
});
|
|
3737
3944
|
if (!response.ok) return { ok: false, error: `http-${response.status}` };
|
|
3738
3945
|
return { ok: true, snapshot: parseSessionSnapshot(await response.json()) };
|
|
3739
3946
|
} catch (error) {
|
|
@@ -3765,10 +3972,16 @@ async function deleteSessions(io, ids, options = {}) {
|
|
|
3765
3972
|
if (options.permanent === true) body["confirm"] = true;
|
|
3766
3973
|
return postAction(io, body, (value) => {
|
|
3767
3974
|
const recoverable = value["recoverable"] === true;
|
|
3975
|
+
const refused = Array.isArray(value["refusals"]) ? value["refusals"].length : 0;
|
|
3768
3976
|
const size = formatBytes(Number(value["bytes"] ?? 0));
|
|
3769
|
-
if (
|
|
3977
|
+
if (refused > 0 && Number(value["bytes"] ?? 0) === 0) {
|
|
3978
|
+
return { key: "sessionResultDeleteNone" };
|
|
3979
|
+
}
|
|
3980
|
+
if (options.permanent === true) {
|
|
3981
|
+
return refused === 0 ? { key: "sessionResultDeletedPermanent", params: { size } } : { key: "sessionResultDeletedPermanentPartial", params: { size, refused } };
|
|
3982
|
+
}
|
|
3770
3983
|
if (!recoverable) return { key: "sessionResultDeleteNone" };
|
|
3771
|
-
return { key: "sessionResultRecycled", params: { size } };
|
|
3984
|
+
return refused === 0 ? { key: "sessionResultRecycled", params: { size } } : { key: "sessionResultRecycledPartial", params: { size, refused } };
|
|
3772
3985
|
});
|
|
3773
3986
|
}
|
|
3774
3987
|
async function deleteWorkspaceSessions(io, cwd, options = {}) {
|
|
@@ -3780,10 +3993,12 @@ async function deleteWorkspaceSessions(io, cwd, options = {}) {
|
|
|
3780
3993
|
};
|
|
3781
3994
|
if (options.permanent === true) body["confirm"] = true;
|
|
3782
3995
|
return postAction(io, body, (value) => {
|
|
3783
|
-
const
|
|
3784
|
-
const size = formatBytes(Number(value["bytes"] ?? 0));
|
|
3785
|
-
if (count === 0) return { key: "sessionResultWorkspaceEmpty" };
|
|
3996
|
+
const matched = Number(value["matched"] ?? 0);
|
|
3786
3997
|
const refused = Array.isArray(value["refusals"]) ? value["refusals"].length : 0;
|
|
3998
|
+
const count = Math.max(0, matched - refused);
|
|
3999
|
+
const size = formatBytes(Number(value["bytes"] ?? 0));
|
|
4000
|
+
if (matched === 0) return { key: "sessionResultWorkspaceEmpty" };
|
|
4001
|
+
if (count === 0) return { key: "sessionResultDeleteNone" };
|
|
3787
4002
|
const params = refused === 0 ? { count, size } : { count, size, refused };
|
|
3788
4003
|
if (options.permanent === true) {
|
|
3789
4004
|
return { key: refused === 0 ? "sessionResultWorkspacePurged" : "sessionResultWorkspacePurgedRefused", params };
|
|
@@ -3799,10 +4014,14 @@ async function purgeSession(io, entryId) {
|
|
|
3799
4014
|
if (entryId === "") return { kind: "error", message: { key: "sessionResultMissingEntryId" } };
|
|
3800
4015
|
return postAction(io, { action: "purge", entryId, confirm: true }, () => ({ key: "sessionResultPurged" }));
|
|
3801
4016
|
}
|
|
3802
|
-
async function sweepSessions(io) {
|
|
3803
|
-
|
|
4017
|
+
async function sweepSessions(io, options = {}) {
|
|
4018
|
+
const body = { action: "sweep" };
|
|
4019
|
+
if (options.confirm === true) body["confirm"] = true;
|
|
4020
|
+
return postAction(io, body, (value) => {
|
|
3804
4021
|
const purged = Array.isArray(value["purged"]) ? value["purged"].length : 0;
|
|
3805
|
-
|
|
4022
|
+
const failed = Array.isArray(value["failed"]) ? value["failed"].length : 0;
|
|
4023
|
+
if (purged === 0 && failed === 0) return { key: "sessionResultSweepNone" };
|
|
4024
|
+
return failed === 0 ? { key: "sessionResultSweepDone", params: { count: purged } } : { key: "sessionResultSweepPartial", params: { count: purged, failed } };
|
|
3806
4025
|
});
|
|
3807
4026
|
}
|
|
3808
4027
|
async function postAction(io, body, describe) {
|
|
@@ -3820,7 +4039,16 @@ async function postAction(io, body, describe) {
|
|
|
3820
4039
|
};
|
|
3821
4040
|
}
|
|
3822
4041
|
if (!response.ok) return { kind: "error", message: await errorMessage(response), status: response.status };
|
|
3823
|
-
|
|
4042
|
+
let value;
|
|
4043
|
+
try {
|
|
4044
|
+
const decoded = await response.json();
|
|
4045
|
+
if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
|
|
4046
|
+
return { kind: "error", message: { key: "sessionResultActionFailed" }, status: response.status };
|
|
4047
|
+
}
|
|
4048
|
+
value = decoded;
|
|
4049
|
+
} catch {
|
|
4050
|
+
return { kind: "error", message: { key: "sessionResultActionFailed" }, status: response.status };
|
|
4051
|
+
}
|
|
3824
4052
|
if (value["ok"] !== true) {
|
|
3825
4053
|
return {
|
|
3826
4054
|
kind: "error",
|
|
@@ -3895,7 +4123,10 @@ function renderBinRow(props) {
|
|
|
3895
4123
|
(0, import_react7.createElement)(
|
|
3896
4124
|
"div",
|
|
3897
4125
|
{ className: CLASS.row },
|
|
3898
|
-
|
|
4126
|
+
// 回收站条目以前只画完整 uuid,用户没法判断该还原/清除哪一条;宿主本来就带 title 与删除时间。
|
|
4127
|
+
(0, import_react7.createElement)("code", { className: CLASS.badge }, shortId(row.sessionId)),
|
|
4128
|
+
row.title === void 0 || row.title === "" ? null : (0, import_react7.createElement)("span", { className: CLASS.rowTitle, title: `movedBy: ${row.movedBy}` }, row.title),
|
|
4129
|
+
(0, import_react7.createElement)("span", { className: CLASS.rowMeta }, formatStamp(row.deletedAtMs)),
|
|
3899
4130
|
(0, import_react7.createElement)("span", { className: CLASS.rowMeta }, formatBytes(row.bytes)),
|
|
3900
4131
|
(0, import_react7.createElement)(
|
|
3901
4132
|
"span",
|
|
@@ -3979,54 +4210,84 @@ function SessionPanel({ copy, io, close }) {
|
|
|
3979
4210
|
const [selected, setSelected] = (0, import_react7.useState)([]);
|
|
3980
4211
|
const [outcome, setOutcome] = (0, import_react7.useState)(void 0);
|
|
3981
4212
|
const [busy, setBusy] = (0, import_react7.useState)(false);
|
|
4213
|
+
const busyRef = (0, import_react7.useRef)(false);
|
|
4214
|
+
const markBusy = (0, import_react7.useCallback)((value) => {
|
|
4215
|
+
busyRef.current = value;
|
|
4216
|
+
setBusy(value);
|
|
4217
|
+
}, []);
|
|
3982
4218
|
const [pending, setPending] = (0, import_react7.useState)(void 0);
|
|
3983
4219
|
const snapshot = phase.kind === "ready" ? phase.snapshot : EMPTY_SNAPSHOT;
|
|
3984
4220
|
const [reads] = (0, import_react7.useState)(() => createLatestOnly());
|
|
4221
|
+
const [actions] = (0, import_react7.useState)(() => createLatestOnly());
|
|
4222
|
+
const [requests] = (0, import_react7.useState)(() => createAbortGate());
|
|
3985
4223
|
const alive = (0, import_react7.useRef)(true);
|
|
3986
4224
|
(0, import_react7.useEffect)(() => {
|
|
3987
4225
|
alive.current = true;
|
|
3988
4226
|
return () => {
|
|
3989
4227
|
alive.current = false;
|
|
4228
|
+
requests.abort();
|
|
3990
4229
|
};
|
|
3991
|
-
}, []);
|
|
4230
|
+
}, [requests]);
|
|
3992
4231
|
const refresh = (0, import_react7.useCallback)(async () => {
|
|
4232
|
+
if (busyRef.current) return;
|
|
3993
4233
|
const mine = reads.begin();
|
|
3994
|
-
|
|
4234
|
+
const signal = requests.begin();
|
|
4235
|
+
markBusy(true);
|
|
3995
4236
|
try {
|
|
3996
|
-
const result = await readSessionPanel(io);
|
|
4237
|
+
const result = await readSessionPanel(io, signal);
|
|
3997
4238
|
if (!alive.current || !reads.isCurrent(mine)) return;
|
|
3998
4239
|
setPhase(result.ok ? { kind: "ready", snapshot: result.snapshot } : { kind: "failed", error: result.error });
|
|
4240
|
+
if (result.ok) {
|
|
4241
|
+
const stillVisible = new Set(visibleSessions(result.snapshot.sessions).map((row) => row.id));
|
|
4242
|
+
setSelected((previous) => previous.filter((id) => stillVisible.has(id)));
|
|
4243
|
+
}
|
|
3999
4244
|
} finally {
|
|
4000
|
-
if (alive.current && reads.isCurrent(mine))
|
|
4245
|
+
if (alive.current && reads.isCurrent(mine)) markBusy(false);
|
|
4001
4246
|
}
|
|
4002
|
-
}, [io, reads]);
|
|
4247
|
+
}, [io, markBusy, reads, requests]);
|
|
4248
|
+
const refreshRef = (0, import_react7.useRef)(refresh);
|
|
4003
4249
|
(0, import_react7.useEffect)(() => {
|
|
4004
|
-
|
|
4250
|
+
refreshRef.current = refresh;
|
|
4005
4251
|
}, [refresh]);
|
|
4252
|
+
const mounted = (0, import_react7.useRef)(false);
|
|
4253
|
+
(0, import_react7.useEffect)(() => {
|
|
4254
|
+
if (mounted.current) return;
|
|
4255
|
+
mounted.current = true;
|
|
4256
|
+
void refreshRef.current();
|
|
4257
|
+
}, []);
|
|
4006
4258
|
const run = (0, import_react7.useCallback)(
|
|
4007
4259
|
async (action) => {
|
|
4260
|
+
if (busyRef.current) return;
|
|
4008
4261
|
const mine = reads.begin();
|
|
4009
|
-
|
|
4262
|
+
const actionId = actions.begin();
|
|
4263
|
+
const signal = requests.begin();
|
|
4264
|
+
markBusy(true);
|
|
4010
4265
|
try {
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4266
|
+
let result;
|
|
4267
|
+
try {
|
|
4268
|
+
result = await action();
|
|
4269
|
+
} catch {
|
|
4270
|
+
result = { kind: "error", message: { key: "sessionResultActionFailed" } };
|
|
4271
|
+
}
|
|
4272
|
+
if (!alive.current) return;
|
|
4273
|
+
if (actions.isCurrent(actionId)) setOutcome(result);
|
|
4014
4274
|
setSelected([]);
|
|
4015
4275
|
setPending(void 0);
|
|
4016
|
-
const read = await readSessionPanel(io);
|
|
4276
|
+
const read = await readSessionPanel(io, signal);
|
|
4017
4277
|
if (!alive.current || !reads.isCurrent(mine)) return;
|
|
4018
4278
|
setPhase(read.ok ? { kind: "ready", snapshot: read.snapshot } : { kind: "failed", error: read.error });
|
|
4019
4279
|
} finally {
|
|
4020
|
-
if (alive.current &&
|
|
4280
|
+
if (alive.current && actions.isCurrent(actionId)) markBusy(false);
|
|
4021
4281
|
}
|
|
4022
4282
|
},
|
|
4023
|
-
[io, reads]
|
|
4283
|
+
[actions, io, markBusy, reads, requests]
|
|
4024
4284
|
);
|
|
4025
4285
|
const visible = (0, import_react7.useMemo)(() => visibleSessions(snapshot.sessions), [snapshot.sessions]);
|
|
4026
|
-
const
|
|
4286
|
+
const selectedSet = (0, import_react7.useMemo)(() => new Set(selected), [selected]);
|
|
4287
|
+
const hidden = snapshot.sessions.length - visible.length;
|
|
4027
4288
|
const deletable = (0, import_react7.useMemo)(
|
|
4028
|
-
() => visible.filter((row) => row.deletable &&
|
|
4029
|
-
[visible,
|
|
4289
|
+
() => visible.filter((row) => row.deletable && selectedSet.has(row.id)),
|
|
4290
|
+
[visible, selectedSet]
|
|
4030
4291
|
);
|
|
4031
4292
|
const groups = (0, import_react7.useMemo)(() => groupByWorkspace(visible), [visible]);
|
|
4032
4293
|
const toggle = (0, import_react7.useCallback)((id) => {
|
|
@@ -4037,6 +4298,7 @@ function SessionPanel({ copy, io, close }) {
|
|
|
4037
4298
|
if (current === void 0) return;
|
|
4038
4299
|
setPending(void 0);
|
|
4039
4300
|
void run(() => {
|
|
4301
|
+
if (current.kind === "sweep") return sweepSessions(io, { confirm: true });
|
|
4040
4302
|
if (current.kind === "purge") return purgeSession(io, current.ids[0]);
|
|
4041
4303
|
if (current.kind === "delete-workspace") return deleteWorkspaceSessions(io, current.cwd, { permanent: true });
|
|
4042
4304
|
return deleteSessions(io, current.ids, { permanent: true });
|
|
@@ -4089,7 +4351,7 @@ function SessionPanel({ copy, io, close }) {
|
|
|
4089
4351
|
(0, import_react7.createElement)(
|
|
4090
4352
|
"div",
|
|
4091
4353
|
{ className: CLASS.headerActions },
|
|
4092
|
-
Button({ variant: "secondary", size: "compact", onClick: () => void refresh(), children: copy.refresh }),
|
|
4354
|
+
Button({ variant: "secondary", size: "compact", disabled: busy, onClick: () => void refresh(), children: copy.refresh }),
|
|
4093
4355
|
Button({ variant: "secondary", size: "compact", onClick: close, children: copy.close })
|
|
4094
4356
|
)
|
|
4095
4357
|
),
|
|
@@ -4180,7 +4442,7 @@ function SessionPanel({ copy, io, close }) {
|
|
|
4180
4442
|
variant: "secondary",
|
|
4181
4443
|
size: "compact",
|
|
4182
4444
|
disabled: busy,
|
|
4183
|
-
onClick: () =>
|
|
4445
|
+
onClick: () => setPending({ kind: "sweep" }),
|
|
4184
4446
|
children: copy.sweep
|
|
4185
4447
|
})
|
|
4186
4448
|
),
|
|
@@ -4202,7 +4464,11 @@ function renderConfirmationDialog(pending, copy, handlers, titleId, loadedIdle =
|
|
|
4202
4464
|
handlers.cancel();
|
|
4203
4465
|
}
|
|
4204
4466
|
},
|
|
4205
|
-
(0, import_react7.createElement)(
|
|
4467
|
+
(0, import_react7.createElement)(
|
|
4468
|
+
"h3",
|
|
4469
|
+
{ id: titleId, className: CLASS.dialogHeader },
|
|
4470
|
+
pending.kind === "sweep" ? copy.sweepConfirmTitle : copy.confirmTitle
|
|
4471
|
+
),
|
|
4206
4472
|
(0, import_react7.createElement)("p", { className: CLASS.dialogBody }, confirmationBody(pending, copy, loadedIdle)),
|
|
4207
4473
|
(0, import_react7.createElement)(
|
|
4208
4474
|
"div",
|
|
@@ -4217,6 +4483,7 @@ function workspaceDeleteAction(group) {
|
|
|
4217
4483
|
return { kind: "delete-workspace", cwd: group.cwd };
|
|
4218
4484
|
}
|
|
4219
4485
|
function confirmationBody(pending, copy, loadedIdle = 0) {
|
|
4486
|
+
if (pending.kind === "sweep") return copy.sweepConfirmBody;
|
|
4220
4487
|
if (pending.kind === "delete-workspace") return copy.confirmWorkspaceBody(pending.cwd);
|
|
4221
4488
|
const targets = pending.ids.join(", ");
|
|
4222
4489
|
const sentence = pending.kind === "purge" ? copy.confirmPurgeBody(targets) : copy.confirmBody(targets);
|
|
@@ -4290,7 +4557,12 @@ function createSessionRowActionStore(deps) {
|
|
|
4290
4557
|
return current !== void 0 && current.kind === request.kind && current.sessionId === request.sessionId;
|
|
4291
4558
|
};
|
|
4292
4559
|
dispatch({ ...state, busy: true, outcome: void 0 });
|
|
4293
|
-
|
|
4560
|
+
let result;
|
|
4561
|
+
try {
|
|
4562
|
+
result = await deleteSessions(deps.io, [request.sessionId], request.kind === "delete" ? { permanent: true } : {});
|
|
4563
|
+
} catch {
|
|
4564
|
+
result = { kind: "error", message: { key: "sessionResultActionFailed" } };
|
|
4565
|
+
}
|
|
4294
4566
|
if (disposed || !stillPending()) return;
|
|
4295
4567
|
dispatch({ ...state, busy: false, outcome: result });
|
|
4296
4568
|
if (result.kind === "ok") {
|
|
@@ -4557,7 +4829,6 @@ var import_react11 = require("react");
|
|
|
4557
4829
|
|
|
4558
4830
|
// src/client/flows/directory-browser.ts
|
|
4559
4831
|
var import_react10 = require("react");
|
|
4560
|
-
var GIT_STATUS_API_PATH = "/api/over-ssh/git-status";
|
|
4561
4832
|
var GIT_STATUS_BADGE = {
|
|
4562
4833
|
modified: "M",
|
|
4563
4834
|
added: "A",
|
|
@@ -4598,11 +4869,12 @@ function postFailureReason(parsed, status, path) {
|
|
|
4598
4869
|
const error = parsed?.["error"];
|
|
4599
4870
|
return typeof error === "string" ? error : transportFailureReason(status, path);
|
|
4600
4871
|
}
|
|
4601
|
-
async function postRootsJson(fetchImpl, path, body) {
|
|
4872
|
+
async function postRootsJson(fetchImpl, path, body, signal) {
|
|
4602
4873
|
try {
|
|
4603
4874
|
const response = await fetchImpl(path, {
|
|
4604
4875
|
method: "POST",
|
|
4605
4876
|
headers: { "content-type": "application/json" },
|
|
4877
|
+
...signal === void 0 ? {} : { signal },
|
|
4606
4878
|
body: JSON.stringify(body)
|
|
4607
4879
|
});
|
|
4608
4880
|
let parsed;
|
|
@@ -4626,10 +4898,10 @@ function createDirectoryBrowserIo(options) {
|
|
|
4626
4898
|
const result = await rootsApi.load();
|
|
4627
4899
|
return result.ok ? { ok: true, value: result.value.connections } : { ok: false, error: result.error };
|
|
4628
4900
|
};
|
|
4629
|
-
const listLocal = async (target) => {
|
|
4901
|
+
const listLocal = async (target, signal) => {
|
|
4630
4902
|
const body = { action: "list-local" };
|
|
4631
4903
|
if (target !== void 0 && target !== "") body["path"] = target;
|
|
4632
|
-
const result = await postRootsJson(options.fetchImpl, path, body);
|
|
4904
|
+
const result = await postRootsJson(options.fetchImpl, path, body, signal);
|
|
4633
4905
|
if (!result.ok) return result;
|
|
4634
4906
|
const answer = result.value;
|
|
4635
4907
|
const answerPath = answer["path"];
|
|
@@ -4650,9 +4922,9 @@ function createDirectoryBrowserIo(options) {
|
|
|
4650
4922
|
}
|
|
4651
4923
|
return { ok: true, value: { path: answerPath, crumbs, entries, truncated: answer["truncated"] === true } };
|
|
4652
4924
|
};
|
|
4653
|
-
const listRemote = async (input) => {
|
|
4925
|
+
const listRemote = async (input, signal) => {
|
|
4654
4926
|
if (input.connectionId === "") return { ok: false, error: NO_CONNECTION_ERROR };
|
|
4655
|
-
const result = await postRootsJson(options.fetchImpl, path, { action: "list-remote", connectionId: input.connectionId, path: input.path });
|
|
4927
|
+
const result = await postRootsJson(options.fetchImpl, path, { action: "list-remote", connectionId: input.connectionId, path: input.path }, signal);
|
|
4656
4928
|
if (!result.ok) return result;
|
|
4657
4929
|
const raw = result.value["entries"];
|
|
4658
4930
|
const entries = [];
|
|
@@ -4666,9 +4938,9 @@ function createDirectoryBrowserIo(options) {
|
|
|
4666
4938
|
const answerPath = typeof result.value["path"] === "string" && result.value["path"] !== "" ? result.value["path"] : input.path;
|
|
4667
4939
|
return { ok: true, value: { path: answerPath, entries, truncated: result.value["truncated"] === true } };
|
|
4668
4940
|
};
|
|
4669
|
-
const loadGitStatus = async (input) => {
|
|
4941
|
+
const loadGitStatus = async (input, signal) => {
|
|
4670
4942
|
if (input.connectionId === "" || input.path === "") return { ok: false, error: NO_CONNECTION_ERROR };
|
|
4671
|
-
const result = await postRootsJson(options.fetchImpl, GIT_STATUS_API_PATH, { connectionId: input.connectionId, path: input.path });
|
|
4943
|
+
const result = await postRootsJson(options.fetchImpl, GIT_STATUS_API_PATH, { connectionId: input.connectionId, path: input.path }, signal);
|
|
4672
4944
|
if (!result.ok) return result;
|
|
4673
4945
|
return { ok: true, value: parseGitStatusView(result.value) };
|
|
4674
4946
|
};
|
|
@@ -4750,11 +5022,14 @@ function directoryBrowserReducer(state, action) {
|
|
|
4750
5022
|
}))
|
|
4751
5023
|
};
|
|
4752
5024
|
case "git-loaded": {
|
|
4753
|
-
if (action.view.available !== true)
|
|
5025
|
+
if (action.view.available !== true) {
|
|
5026
|
+
const { git: _drop, ...rest } = state;
|
|
5027
|
+
return rest;
|
|
5028
|
+
}
|
|
4754
5029
|
const changes = {};
|
|
4755
5030
|
for (const change of action.view.changes) changes[change.path] = change.status;
|
|
4756
5031
|
return {
|
|
4757
|
-
...
|
|
5032
|
+
...state,
|
|
4758
5033
|
git: {
|
|
4759
5034
|
root: action.view.root ?? "",
|
|
4760
5035
|
directory: action.view.directory ?? "",
|
|
@@ -4827,6 +5102,13 @@ function createDirectoryBrowser(deps) {
|
|
|
4827
5102
|
};
|
|
4828
5103
|
const listeners = /* @__PURE__ */ new Set();
|
|
4829
5104
|
let token = 0;
|
|
5105
|
+
let inFlight;
|
|
5106
|
+
const beginRequest = () => {
|
|
5107
|
+
inFlight?.abort();
|
|
5108
|
+
const controller = new AbortController();
|
|
5109
|
+
inFlight = controller;
|
|
5110
|
+
return controller.signal;
|
|
5111
|
+
};
|
|
4830
5112
|
let gitToken = 0;
|
|
4831
5113
|
let disposed = false;
|
|
4832
5114
|
const dispatch = (action) => {
|
|
@@ -4837,20 +5119,21 @@ function createDirectoryBrowser(deps) {
|
|
|
4837
5119
|
};
|
|
4838
5120
|
const current = (issued) => !disposed && issued === token;
|
|
4839
5121
|
const currentGit = (issued) => !disposed && issued === gitToken;
|
|
4840
|
-
const loadGit = async (connectionId, path) => {
|
|
5122
|
+
const loadGit = async (connectionId, path, signal) => {
|
|
4841
5123
|
const load = deps.io.loadGitStatus;
|
|
4842
5124
|
if (load === void 0 || connectionId === "" || path === "") return;
|
|
4843
5125
|
const issued = ++gitToken;
|
|
4844
|
-
const result = await load({ connectionId, path });
|
|
5126
|
+
const result = await load({ connectionId, path }, signal);
|
|
4845
5127
|
if (!currentGit(issued)) return;
|
|
4846
5128
|
if (!result.ok) return;
|
|
4847
5129
|
dispatch({ type: "git-loaded", view: result.value });
|
|
4848
5130
|
};
|
|
4849
5131
|
const readLocal = async (path) => {
|
|
5132
|
+
const signal = beginRequest();
|
|
4850
5133
|
const issued = ++token;
|
|
4851
5134
|
gitToken += 1;
|
|
4852
5135
|
dispatch({ type: "target", path: path ?? "" });
|
|
4853
|
-
const result = await deps.io.listLocal(path === void 0 || path === "" ? void 0 : path);
|
|
5136
|
+
const result = await deps.io.listLocal(path === void 0 || path === "" ? void 0 : path, signal);
|
|
4854
5137
|
if (!current(issued)) return;
|
|
4855
5138
|
if (!result.ok) {
|
|
4856
5139
|
dispatch({ type: "failed", error: result.error });
|
|
@@ -4859,29 +5142,37 @@ function createDirectoryBrowser(deps) {
|
|
|
4859
5142
|
dispatch({ type: "local-loaded", listing: result.value });
|
|
4860
5143
|
};
|
|
4861
5144
|
const readRemote = async (connectionId, path) => {
|
|
5145
|
+
const signal = beginRequest();
|
|
4862
5146
|
const issued = ++token;
|
|
4863
5147
|
gitToken += 1;
|
|
4864
5148
|
dispatch({ type: "target", path });
|
|
4865
|
-
const result = await deps.io.listRemote({ connectionId, path });
|
|
5149
|
+
const result = await deps.io.listRemote({ connectionId, path }, signal);
|
|
4866
5150
|
if (!current(issued)) return;
|
|
4867
5151
|
if (!result.ok) {
|
|
4868
5152
|
dispatch({ type: "failed", error: result.error });
|
|
4869
5153
|
return;
|
|
4870
5154
|
}
|
|
4871
5155
|
dispatch({ type: "remote-loaded", path, listing: result.value });
|
|
4872
|
-
void loadGit(connectionId, result.value.path);
|
|
5156
|
+
void loadGit(connectionId, result.value.path, signal);
|
|
4873
5157
|
};
|
|
5158
|
+
let connectionsInFlight;
|
|
4874
5159
|
const ensureConnections = async () => {
|
|
4875
5160
|
if (state.connections.length > 0) return true;
|
|
4876
|
-
if (
|
|
5161
|
+
if (connectionsInFlight !== void 0) return connectionsInFlight;
|
|
4877
5162
|
dispatch({ type: "connections-loading" });
|
|
4878
|
-
const
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
return false;
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
5163
|
+
const attempt = (async () => {
|
|
5164
|
+
const result = await deps.io.loadConnections();
|
|
5165
|
+
connectionsInFlight = void 0;
|
|
5166
|
+
if (disposed) return false;
|
|
5167
|
+
if (!result.ok) {
|
|
5168
|
+
dispatch({ type: "connections-failed", error: result.error });
|
|
5169
|
+
return false;
|
|
5170
|
+
}
|
|
5171
|
+
dispatch({ type: "connections-loaded", connections: result.value });
|
|
5172
|
+
return true;
|
|
5173
|
+
})();
|
|
5174
|
+
connectionsInFlight = attempt;
|
|
5175
|
+
return attempt;
|
|
4885
5176
|
};
|
|
4886
5177
|
const refresh = async (path) => {
|
|
4887
5178
|
if (disposed) return;
|
|
@@ -4942,6 +5233,8 @@ function createDirectoryBrowser(deps) {
|
|
|
4942
5233
|
},
|
|
4943
5234
|
dispose: () => {
|
|
4944
5235
|
disposed = true;
|
|
5236
|
+
inFlight?.abort();
|
|
5237
|
+
inFlight = void 0;
|
|
4945
5238
|
listeners.clear();
|
|
4946
5239
|
}
|
|
4947
5240
|
};
|
|
@@ -5212,6 +5505,9 @@ function createSessionDirectoryIo(options) {
|
|
|
5212
5505
|
const loadRoots = async (workspacePath, sessionId) => {
|
|
5213
5506
|
const result = await rootsApi.load();
|
|
5214
5507
|
if (!result.ok) return result;
|
|
5508
|
+
if (result.value.warnings.includes("roots-invalid-answer")) {
|
|
5509
|
+
return { ok: false, error: "roots-invalid-answer" };
|
|
5510
|
+
}
|
|
5215
5511
|
const all = result.value.roots;
|
|
5216
5512
|
if (workspacePath === void 0 || workspacePath === "") return { ok: true, value: [] };
|
|
5217
5513
|
return {
|
|
@@ -5338,15 +5634,26 @@ function selectSessionDirectoryView(state, copy) {
|
|
|
5338
5634
|
workspaceLine: state.workspacePath === void 0 ? copy["sessionAddWorkspaceUnknown"] : resolveMessage2(copy, { key: "sessionAddWorkspace", params: { path: state.workspacePath } }),
|
|
5339
5635
|
...state.workspacePath === void 0 ? {} : { workspacePath: state.workspacePath },
|
|
5340
5636
|
...state.added === void 0 ? {} : { added: state.added },
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5637
|
+
// 工作区永远排在第一行:它是默认边界,`workspace-write` 下与已添加目录一样可写(ADR-0016)。
|
|
5638
|
+
roots: [
|
|
5639
|
+
...state.workspacePath === void 0 ? [] : [{
|
|
5640
|
+
key: `workspace:${state.workspacePath}`,
|
|
5641
|
+
title: state.workspacePath,
|
|
5642
|
+
subtitle: copy["sessionRootsWorkspaceNote"],
|
|
5643
|
+
badge: copy["sessionRootsWorkspaceBadge"],
|
|
5644
|
+
removable: false,
|
|
5645
|
+
workspace: true
|
|
5646
|
+
}],
|
|
5647
|
+
...state.roots.map((row) => ({
|
|
5648
|
+
key: row.key,
|
|
5649
|
+
// A label is what the user called it; without one the path is the only honest title. A remote path alone
|
|
5650
|
+
// would hide which host it is on, so the connection name is part of the title there.
|
|
5651
|
+
title: row.label ?? (row.location === "remote" ? `${row.connectionId ?? ""}:${row.path}` : row.path),
|
|
5652
|
+
subtitle: row.label === void 0 ? "" : row.path,
|
|
5653
|
+
badge: row.location === "remote" ? copy["rootsBadgeRemote"] : copy["rootsBadgeLocal"],
|
|
5654
|
+
removable: row.inUserFile
|
|
5655
|
+
}))
|
|
5656
|
+
],
|
|
5350
5657
|
rootsPending: state.rootsPending,
|
|
5351
5658
|
rootsReading: state.rootsReading,
|
|
5352
5659
|
browsing: state.browsing
|
|
@@ -5390,6 +5697,10 @@ function createSessionDirectory(deps) {
|
|
|
5390
5697
|
dispatch({ type: "roots-failed", reason: result.error });
|
|
5391
5698
|
return;
|
|
5392
5699
|
}
|
|
5700
|
+
if (state.workspacePath !== workspacePath || state.sessionId !== sessionId) {
|
|
5701
|
+
await refreshRoots();
|
|
5702
|
+
return;
|
|
5703
|
+
}
|
|
5393
5704
|
dispatch({ type: "root-removed", key });
|
|
5394
5705
|
};
|
|
5395
5706
|
const commit = async (path, connectionId) => {
|
|
@@ -5508,7 +5819,7 @@ function SessionDirectorySection({
|
|
|
5508
5819
|
{ className: CLASS.block },
|
|
5509
5820
|
(0, import_react11.createElement)("h4", { className: CLASS.blockTitle }, copy.sessionRootsTitle),
|
|
5510
5821
|
(0, import_react11.createElement)("p", { className: CLASS.hint }, copy.sessionRootsHint),
|
|
5511
|
-
view.roots.length === 0 ? view.rootsReading ? Notice({ role: "status", tone: "info", children: copy.sessionRootsLoading }) : (
|
|
5822
|
+
view.roots.filter((row) => row.workspace !== true).length === 0 ? view.rootsReading ? Notice({ role: "status", tone: "info", children: copy.sessionRootsLoading }) : (
|
|
5512
5823
|
// A failed read is not an empty list: the error notice below is the whole truth there, and
|
|
5513
5824
|
// 「还没有添加目录」 next to it would contradict it (same rule as the import dialog, ui P2-2/P2-3).
|
|
5514
5825
|
view.notice === "" ? (0, import_react11.createElement)("p", { className: CLASS.empty }, copy.sessionRootsEmpty) : null
|
|
@@ -5967,10 +6278,6 @@ function createWorkspaceDirectoryFlow(deps) {
|
|
|
5967
6278
|
}
|
|
5968
6279
|
};
|
|
5969
6280
|
}
|
|
5970
|
-
function fieldValue2(event) {
|
|
5971
|
-
const target = event.target;
|
|
5972
|
-
return typeof target?.value === "string" ? target.value : "";
|
|
5973
|
-
}
|
|
5974
6281
|
function WorkspaceDirectoryFlowSection({
|
|
5975
6282
|
copy,
|
|
5976
6283
|
state,
|
|
@@ -6113,7 +6420,7 @@ function WorkspaceDirectoryFlowSection({
|
|
|
6113
6420
|
value: state.drafts.path,
|
|
6114
6421
|
disabled: !canPickLocal2,
|
|
6115
6422
|
placeholder: copy.rootsRemotePathLabel,
|
|
6116
|
-
onChange: (event) => actions.onDraftChange({ path:
|
|
6423
|
+
onChange: (event) => actions.onDraftChange({ path: fieldValue(event) })
|
|
6117
6424
|
}),
|
|
6118
6425
|
Button({
|
|
6119
6426
|
variant: "secondary",
|
|
@@ -6272,6 +6579,8 @@ function apply(ctx) {
|
|
|
6272
6579
|
subsessionBadge: dict.sessionPanelSubsession,
|
|
6273
6580
|
unreadableBadge: dict.sessionPanelUnreadable,
|
|
6274
6581
|
confirmTitle: dict.sessionPanelConfirmTitle,
|
|
6582
|
+
sweepConfirmTitle: dict.sessionResultSweepConfirmTitle,
|
|
6583
|
+
sweepConfirmBody: dict.sessionResultSweepConfirmBody,
|
|
6275
6584
|
confirmBody: (targets) => resolveMessage2(dict, { key: "sessionPanelConfirmBody", params: { targets } }),
|
|
6276
6585
|
confirmPurgeBody: (targets) => resolveMessage2(dict, { key: "sessionPanelConfirmPurgeBody", params: { targets } }),
|
|
6277
6586
|
confirmCancel: dict.sessionPanelConfirmCancel,
|
|
@@ -6295,7 +6604,7 @@ function apply(ctx) {
|
|
|
6295
6604
|
};
|
|
6296
6605
|
};
|
|
6297
6606
|
const io = { fetch: (input, init) => globalThis.fetch(input, init) };
|
|
6298
|
-
|
|
6607
|
+
const register = () => ctx.slots.inject(
|
|
6299
6608
|
"settings.section",
|
|
6300
6609
|
() => ctx.slots.register(
|
|
6301
6610
|
{
|
|
@@ -6303,12 +6612,20 @@ function apply(ctx) {
|
|
|
6303
6612
|
id: SESSION_SECTION_ID,
|
|
6304
6613
|
order: SESSION_SECTION_ORDER,
|
|
6305
6614
|
label: copy().title,
|
|
6306
|
-
inject: () => ({ copy: copy(), io
|
|
6307
|
-
} })
|
|
6615
|
+
inject: () => ({ copy: copy(), io })
|
|
6308
6616
|
},
|
|
6309
6617
|
SessionPanel
|
|
6310
6618
|
)
|
|
6311
6619
|
);
|
|
6620
|
+
let dispose = register();
|
|
6621
|
+
const offLocaleChange = ctx.on("locale/change", () => {
|
|
6622
|
+
dispose();
|
|
6623
|
+
dispose = register();
|
|
6624
|
+
});
|
|
6625
|
+
return () => {
|
|
6626
|
+
offLocaleChange();
|
|
6627
|
+
dispose();
|
|
6628
|
+
};
|
|
6312
6629
|
});
|
|
6313
6630
|
ctx.effect(() => {
|
|
6314
6631
|
let rows;
|
|
@@ -6438,7 +6755,7 @@ function apply(ctx) {
|
|
|
6438
6755
|
let lastOverlay = "";
|
|
6439
6756
|
const overlayOf = () => {
|
|
6440
6757
|
const state = rootsController.getState();
|
|
6441
|
-
return `${state.editingId ?? ""}|${state.confirmingRemove ?? ""}
|
|
6758
|
+
return `${state.editingId ?? ""}|${state.confirmingRemove ?? ""}`;
|
|
6442
6759
|
};
|
|
6443
6760
|
lastOverlay = overlayOf();
|
|
6444
6761
|
const offSnapshot = rootsController.subscribe(() => {
|
|
@@ -6469,15 +6786,18 @@ function apply(ctx) {
|
|
|
6469
6786
|
const api = createRootsApi((input, init) => globalThis.fetch(input, init));
|
|
6470
6787
|
let ids = /* @__PURE__ */ new Set();
|
|
6471
6788
|
let disposed = false;
|
|
6789
|
+
const requests = createAbortGate();
|
|
6472
6790
|
const dict = () => OVER_SSH_DICTS[ctx.locale.getLocale().active === "en" ? "en" : "zh"];
|
|
6473
6791
|
const label = () => dict().rootsTitle;
|
|
6474
6792
|
let loaded = false;
|
|
6475
6793
|
let dispose = () => void 0;
|
|
6476
6794
|
async function reload() {
|
|
6795
|
+
const signal = requests.begin();
|
|
6477
6796
|
try {
|
|
6478
|
-
const [roots, sessions] = await Promise.all([api.load(),
|
|
6797
|
+
const [roots, sessions] = await Promise.all([api.load(signal), readSessionPanel(io, signal)]);
|
|
6479
6798
|
if (disposed || !roots.ok) return;
|
|
6480
|
-
|
|
6799
|
+
if (!sessions.ok || !sessions.snapshot.available) return;
|
|
6800
|
+
const next = remoteSessionIds(sessions.snapshot.sessions, roots.value.anchorRoot);
|
|
6481
6801
|
if (sameIds(next, ids)) return;
|
|
6482
6802
|
ids = next;
|
|
6483
6803
|
refresh();
|
|
@@ -6517,6 +6837,7 @@ function apply(ctx) {
|
|
|
6517
6837
|
const offLocaleChange = ctx.on("locale/change", refresh);
|
|
6518
6838
|
return () => {
|
|
6519
6839
|
disposed = true;
|
|
6840
|
+
requests.abort();
|
|
6520
6841
|
doc?.removeEventListener("visibilitychange", onVisibility);
|
|
6521
6842
|
offLocaleChange();
|
|
6522
6843
|
dispose();
|