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/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. 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.",
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 fieldValue(event) {
2749
- const target = event.target;
2750
- return typeof target?.value === "string" ? target.value : "";
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({ copy, state, actions, idPrefix = DEFAULT_ROOTS_ID_PREFIX }) {
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
- role: "dialog",
2793
- // Named by its own heading. **Not** `aria-modal`: this dialog is rendered inside the settings page's own
2794
- // scroll flow (it is not portalled), the page behind it stays interactive, and there is no Tab trap — a
2795
- // modal claim the behaviour does not back is worse than no claim (audit 2026-11-01b ui P2-2).
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 ConnectionEditForm({ copy, state, actions, idPrefix = DEFAULT_ROOTS_ID_PREFIX }) {
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 = selectRootsView(state);
3088
- const editForm = ConnectionEditForm({ copy, state, actions, idPrefix });
3089
- const editable = view.canEdit;
3090
- const { drafts } = state;
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
- "form",
3108
- {
3109
- onSubmit: (event) => {
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
- // -- remote files are reached through the conversation (audit 0085) -------
3223
- // There is no tree here, and no button that opens one. A file manager inside settings was the wrong
3224
- // artifact: it duplicated the agent's own access, wrote to the remote machine with no audit trail, and
3225
- // competed with the conversation as a second source of truth. The sentence says where the capability
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 }, ...view.rows.map((row) => renderRow(row, copy, editable, actions.onRemove)))
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
- useCardTabTrap(state.editingId !== void 0, portal.container);
3454
+ const cardOpen = state.editingId !== void 0 || state.addingConnection === true;
3455
+ useCardTabTrap(cardOpen, portal.container);
3277
3456
  (0, import_react5.useLayoutEffect)(() => {
3278
- if (state.editingId === void 0) return;
3279
- portal.mount();
3280
- portal.focusCard();
3281
- return () => {
3282
- portal.unmount();
3283
- };
3284
- }, [portal, state.editingId]);
3285
- const sectionActions = (0, import_react5.useMemo)(() => rootsPanelSectionActions(controller), [controller]);
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, state }),
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: "ghost",
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: "ghost", size: "compact", disabled: disabled === true, onClick: () => onEdit(row.id), children: row.actions.editLabel }),
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) => resolveCopy(copy.rootsConfirmRemoveBody, { id: row.id, count: 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/client/core/remote.ts
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 signal = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(timeoutMs) : void 0;
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
- ...signal === void 0 ? {} : { signal }
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 present = new Set(rows.map((row) => row.id));
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
- return !present.has(row.parentSessionId);
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: sessions.length,
3727
- bytes: sessions.reduce((sum, row) => sum + row.bytes, 0),
3728
- subsessions: sessions.filter((row) => row.classification === "subsession").length,
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, { method: "GET", headers: { accept: "application/json" } });
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 (options.permanent === true) return { key: "sessionResultDeletedPermanent", params: { size } };
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 count = Number(value["matched"] ?? 0);
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
- return postAction(io, { action: "sweep" }, (value) => {
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
- return purged === 0 ? { key: "sessionResultSweepNone" } : { key: "sessionResultSweepDone", params: { count: purged } };
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
- const value = await response.json();
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
- (0, import_react7.createElement)("code", { className: CLASS.badge }, row.sessionId),
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
- setBusy(true);
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)) setBusy(false);
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
- void refresh();
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
- setBusy(true);
4262
+ const actionId = actions.begin();
4263
+ const signal = requests.begin();
4264
+ markBusy(true);
4010
4265
  try {
4011
- const result = await action();
4012
- if (!alive.current || !reads.isCurrent(mine)) return;
4013
- setOutcome(result);
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 && reads.isCurrent(mine)) setBusy(false);
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 hidden = hiddenSubsessionCount(snapshot.sessions);
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 && selected.includes(row.id)),
4029
- [visible, selected]
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: () => void run(() => sweepSessions(io)),
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)("h3", { id: titleId, className: CLASS.dialogHeader }, copy.confirmTitle),
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
- const result = await deleteSessions(deps.io, [request.sessionId], request.kind === "delete" ? { permanent: true } : {});
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) return { ...withoutOptional(state) };
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
- ...withoutOptional(state),
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 (state.connectionsPending) return false;
5161
+ if (connectionsInFlight !== void 0) return connectionsInFlight;
4877
5162
  dispatch({ type: "connections-loading" });
4878
- const result = await deps.io.loadConnections();
4879
- if (!result.ok) {
4880
- dispatch({ type: "connections-failed", error: result.error });
4881
- return false;
4882
- }
4883
- dispatch({ type: "connections-loaded", connections: result.value });
4884
- return true;
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
- roots: state.roots.map((row) => ({
5342
- key: row.key,
5343
- // A label is what the user called it; without one the path is the only honest title. A remote path alone
5344
- // would hide which host it is on, so the connection name is part of the title there.
5345
- title: row.label ?? (row.location === "remote" ? `${row.connectionId ?? ""}:${row.path}` : row.path),
5346
- subtitle: row.label === void 0 ? "" : row.path,
5347
- badge: row.location === "remote" ? copy["rootsBadgeRemote"] : copy["rootsBadgeLocal"],
5348
- removable: row.inUserFile
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: fieldValue2(event) })
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
- return ctx.slots.inject(
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, close: () => {
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 ?? ""}|${state.pending ? "busy" : "idle"}`;
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(), loadSessionPanel(io)]);
6797
+ const [roots, sessions] = await Promise.all([api.load(signal), readSessionPanel(io, signal)]);
6479
6798
  if (disposed || !roots.ok) return;
6480
- const next = remoteSessionIds(sessions.sessions, roots.value.anchorRoot);
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();