dsh-over-ssh 0.2.0 → 0.2.2

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 = {
@@ -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",
@@ -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) {
@@ -1660,8 +1698,8 @@ function parseRootsSnapshot(value) {
1660
1698
  const row = toRootRow(entry, "config", false);
1661
1699
  const identity = row === void 0 ? void 0 : rootKeyIdentity(row.key);
1662
1700
  if (row !== void 0 && identity !== void 0 && !seen.has(identity)) {
1663
- const scoped = userWorkspaces.get(identity);
1664
- const scopedSessions = userSessions.get(identity);
1701
+ const scoped = row.workspaces ?? userWorkspaces.get(identity);
1702
+ const scopedSessions = row.sessions ?? userSessions.get(identity);
1665
1703
  roots.push({
1666
1704
  ...row,
1667
1705
  inUserFile: userKeys.has(identity),
@@ -1685,7 +1723,14 @@ function parseRootsSnapshot(value) {
1685
1723
  }
1686
1724
  const warnings = (Array.isArray(payload.warnings) ? payload.warnings : []).filter((item) => typeof item === "string");
1687
1725
  const anchorRoot = readString(payload, "anchorRoot");
1688
- return { roots, connections, warnings, ...anchorRoot === void 0 ? {} : { anchorRoot } };
1726
+ const scopedForSession = readString(payload, "scopedForSession");
1727
+ return {
1728
+ roots,
1729
+ connections,
1730
+ warnings,
1731
+ ...anchorRoot === void 0 ? {} : { anchorRoot },
1732
+ ...scopedForSession === void 0 ? {} : { scopedForSession }
1733
+ };
1689
1734
  }
1690
1735
  function isAbsoluteRemotePath(path) {
1691
1736
  if (!path.startsWith("/")) return false;
@@ -1874,13 +1919,27 @@ async function requestSnapshot(fetchImpl, path, init) {
1874
1919
  const reason = typeof body?.error === "string" ? body.error : transportFailureReason(response.status, path);
1875
1920
  return { ok: false, error: reason };
1876
1921
  }
1922
+ if (typeof body === "object" && body !== null && body["ok"] === false && typeof body["error"] === "string") {
1923
+ return { ok: false, error: body["error"] };
1924
+ }
1877
1925
  return { ok: true, value: parseRootsSnapshot(body) };
1878
1926
  } catch (error) {
1879
1927
  return { ok: false, error: error instanceof Error ? error.name : "network" };
1880
1928
  }
1881
1929
  }
1882
1930
  function createRootsApi(fetchImpl, path = ROOTS_API_PATH) {
1883
- const load = async (signal) => requestSnapshot(fetchImpl, path, { method: "GET", headers: { accept: "application/json" }, ...signal === void 0 ? {} : { signal } });
1931
+ const load = async (options = {}) => {
1932
+ const normalized = options instanceof AbortSignal ? { signal: options } : options;
1933
+ const query = new URLSearchParams();
1934
+ if (normalized.session !== void 0 && normalized.session !== "") query.set("session", normalized.session);
1935
+ if (normalized.workspace !== void 0 && normalized.workspace !== "") query.set("workspace", normalized.workspace);
1936
+ const suffix = query.size === 0 ? "" : `?${query.toString()}`;
1937
+ return requestSnapshot(fetchImpl, `${path}${suffix}`, {
1938
+ method: "GET",
1939
+ headers: { accept: "application/json" },
1940
+ ...normalized.signal === void 0 ? {} : { signal: normalized.signal }
1941
+ });
1942
+ };
1884
1943
  const send = async (payload) => requestSnapshot(fetchImpl, path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
1885
1944
  const sendRaw = async (payload) => {
1886
1945
  try {
@@ -1945,6 +2004,10 @@ function withoutError(state) {
1945
2004
  const { error: _error, ...rest } = state;
1946
2005
  return rest;
1947
2006
  }
2007
+ function withoutAdding(state) {
2008
+ const { addingConnection: _adding, ...rest } = state;
2009
+ return rest;
2010
+ }
1948
2011
  function withoutEditing(state) {
1949
2012
  const { editingId: _editing, ...rest } = state;
1950
2013
  return rest;
@@ -1983,7 +2046,7 @@ function rootsPanelReducer(state, action) {
1983
2046
  case "succeeded": {
1984
2047
  const saved = action.scope === void 0 ? void 0 : SAVED_MESSAGE[action.scope];
1985
2048
  return {
1986
- ...withoutError(withoutFormNotice(withoutEditing(withoutConfirming(state)))),
2049
+ ...withoutError(withoutFormNotice(withoutAdding(withoutEditing(withoutConfirming(state))))),
1987
2050
  pending: false,
1988
2051
  loaded: true,
1989
2052
  // The edit dialog is closed by `withoutEditing` whether the request added or removed something, so its
@@ -2002,11 +2065,16 @@ function rootsPanelReducer(state, action) {
2002
2065
  if (!canEdit(state)) return state;
2003
2066
  return { ...withoutFormNotice(state), pending: true };
2004
2067
  }
2068
+ case "add-connection-opened":
2069
+ return { ...withoutEditing(withoutFormNotice(state)), addingConnection: true };
2070
+ case "add-connection-closed":
2071
+ if (state.addingConnection !== true) return state;
2072
+ return withoutAdding(withoutFormNotice(state));
2005
2073
  case "edit-started": {
2006
2074
  const row = state.snapshot?.connections.find((candidate) => candidate.id === action.id);
2007
2075
  if (row === void 0) return state;
2008
2076
  return {
2009
- ...withoutFormNotice(state),
2077
+ ...withoutFormNotice(withoutAdding(state)),
2010
2078
  editingId: row.id,
2011
2079
  editDrafts: {
2012
2080
  id: row.id,
@@ -2388,7 +2456,16 @@ function createTransferProgress(options) {
2388
2456
  setState(transferPanelPollStarted(state, showLoading));
2389
2457
  const options_ = mode === "park" ? { waitMs, signal: controller.signal } : { signal: controller.signal };
2390
2458
  issued += 1;
2391
- const result = await options.io.list(options_);
2459
+ let result;
2460
+ try {
2461
+ result = await options.io.list(options_);
2462
+ } catch (error) {
2463
+ if (controller.signal.aborted || disposed) {
2464
+ if (inFlight === controller) inFlight = void 0;
2465
+ continue;
2466
+ }
2467
+ result = { ok: false, error: error instanceof Error ? error.message : "list-failed" };
2468
+ }
2392
2469
  if (inFlight === controller) inFlight = void 0;
2393
2470
  if (disposed) return;
2394
2471
  if (mine !== generation) {
@@ -2429,6 +2506,7 @@ function createTransferProgress(options) {
2429
2506
  },
2430
2507
  clear: () => {
2431
2508
  if (disposed) return;
2509
+ if (state.clearing) return;
2432
2510
  setState(transferPanelClearing(state));
2433
2511
  void options.io.clear().then((result) => {
2434
2512
  if (disposed) return;
@@ -2527,29 +2605,41 @@ var ROOTS_EDIT_BACKDROP_CLASS = classes(CLASS.backdrop, CLASS.backdropSettings);
2527
2605
  function createRootsPanelController(options) {
2528
2606
  let state = initialRootsPanelState();
2529
2607
  const listeners = /* @__PURE__ */ new Set();
2530
- let token = 0;
2608
+ let readToken = 0;
2609
+ let writeToken = 0;
2610
+ let epoch = 0;
2531
2611
  let disposed = false;
2612
+ const requests = createAbortGate();
2532
2613
  const dispatch = (action) => {
2533
2614
  const next = rootsPanelReducer(state, action);
2534
2615
  if (next === state) return;
2535
2616
  state = next;
2536
2617
  for (const listener of [...listeners]) listener();
2537
2618
  };
2538
- const latest = (mine) => !disposed && mine === token;
2619
+ const latestRead = (mine, issuedEpoch) => !disposed && mine === readToken && issuedEpoch === epoch;
2620
+ const latestWrite = (mine) => !disposed && mine === writeToken;
2539
2621
  const reload = async () => {
2540
- const mine = ++token;
2622
+ const mine = ++readToken;
2623
+ const issuedEpoch = epoch;
2624
+ const signal = requests.begin();
2541
2625
  dispatch({ type: "load" });
2542
- const result = await options.api.load();
2543
- if (!latest(mine)) return;
2626
+ const result = await options.api.load(signal);
2627
+ if (!latestRead(mine, issuedEpoch)) return;
2628
+ if (result.ok && result.value.warnings.includes("roots-invalid-answer")) {
2629
+ dispatch({ type: "failed", error: "roots-invalid-answer" });
2630
+ return;
2631
+ }
2544
2632
  if (result.ok) dispatch({ type: "loaded", snapshot: result.value });
2545
2633
  else dispatch({ type: "failed", error: result.error });
2546
2634
  };
2547
2635
  const settle = async (mine, scope, result, resetDrafts) => {
2548
- if (!latest(mine)) return;
2636
+ if (!latestWrite(mine)) return;
2549
2637
  if (!result.ok) {
2550
2638
  dispatch({ type: "failed", error: result.error, scope });
2551
2639
  return;
2552
2640
  }
2641
+ epoch += 1;
2642
+ void result.value;
2553
2643
  dispatch({ type: "succeeded", scope, ...resetDrafts ? { resetDrafts: true } : {} });
2554
2644
  await reload();
2555
2645
  };
@@ -2562,13 +2652,14 @@ function createRootsPanelController(options) {
2562
2652
  dispatch({ type: "refused", scope: "roots", message: validated.message });
2563
2653
  return;
2564
2654
  }
2565
- const mine = ++token;
2655
+ const mine = ++writeToken;
2566
2656
  dispatch({ type: "submitted" });
2567
2657
  const result = await options.api.send({ action: "add-root", ...validated.value });
2568
2658
  await settle(mine, "roots", result, true);
2569
2659
  };
2570
2660
  const submitConnection = async () => {
2571
2661
  if (state.editingId !== void 0) return;
2662
+ if (state.addingConnection !== true) return;
2572
2663
  if (!canEdit(state)) return;
2573
2664
  const { drafts } = state;
2574
2665
  const portText = drafts.newPort.trim();
@@ -2587,7 +2678,7 @@ function createRootsPanelController(options) {
2587
2678
  dispatch({ type: "refused", scope: "connection", message: validated.message });
2588
2679
  return;
2589
2680
  }
2590
- const mine = ++token;
2681
+ const mine = ++writeToken;
2591
2682
  dispatch({ type: "submitted" });
2592
2683
  const result = await options.api.send({ action: "add-connection", ...validated.value });
2593
2684
  await settle(mine, "connection", result, true);
@@ -2596,7 +2687,7 @@ function createRootsPanelController(options) {
2596
2687
  const row = selectRootsView(state).rows.find((candidate) => candidate.key === key);
2597
2688
  if (row === void 0 || !row.removable) return;
2598
2689
  if (!canEdit(state)) return;
2599
- const mine = ++token;
2690
+ const mine = ++writeToken;
2600
2691
  dispatch({ type: "remove", key });
2601
2692
  const result = await options.api.send({ action: "remove-root", key });
2602
2693
  await settle(mine, "roots", result, false);
@@ -2607,6 +2698,12 @@ function createRootsPanelController(options) {
2607
2698
  const cancelEdit = () => {
2608
2699
  dispatch({ type: "edit-ended" });
2609
2700
  };
2701
+ const openAddConnection = () => {
2702
+ dispatch({ type: "add-connection-opened" });
2703
+ };
2704
+ const closeAddConnection = () => {
2705
+ dispatch({ type: "add-connection-closed" });
2706
+ };
2610
2707
  const defer = options.defer ?? ((run) => {
2611
2708
  const handle = setTimeout(run, 0);
2612
2709
  return () => clearTimeout(handle);
@@ -2646,7 +2743,7 @@ function createRootsPanelController(options) {
2646
2743
  dispatch({ type: "refused", scope: "edit", message: validated.message });
2647
2744
  return;
2648
2745
  }
2649
- const mine = ++token;
2746
+ const mine = ++writeToken;
2650
2747
  dispatch({ type: "submitted" });
2651
2748
  const result = await options.api.send({ action: "update-connection", id: editingId, ...validated.value });
2652
2749
  await settle(mine, "edit", result, true);
@@ -2660,16 +2757,16 @@ function createRootsPanelController(options) {
2660
2757
  const confirmRemoveConnection = async (id) => {
2661
2758
  if (state.confirmingRemove !== id) return;
2662
2759
  if (!canEdit(state)) return;
2663
- const mine = ++token;
2760
+ const mine = ++writeToken;
2664
2761
  dispatch({ type: "submitted" });
2665
2762
  const result = await options.api.send({ action: "remove-connection", id });
2666
2763
  await settle(mine, "edit", result, true);
2667
2764
  };
2668
2765
  const loadImport = async () => {
2669
- const mine = ++token;
2766
+ const mine = ++readToken;
2670
2767
  dispatch({ type: "import-busy" });
2671
2768
  const result = await options.api.sendRaw({ action: "list-ssh-config" });
2672
- if (!latest(mine)) return;
2769
+ if (disposed || mine !== readToken) return;
2673
2770
  if (!result.ok) {
2674
2771
  dispatch({ type: "import-failed", error: result.error });
2675
2772
  return;
@@ -2683,10 +2780,10 @@ function createRootsPanelController(options) {
2683
2780
  const submitImport = async () => {
2684
2781
  const selected = state.importSelected ?? [];
2685
2782
  if (selected.length === 0) return;
2686
- const mine = ++token;
2783
+ const mine = ++writeToken;
2687
2784
  dispatch({ type: "import-busy" });
2688
2785
  const result = await options.api.sendRaw({ action: "import-ssh-config", aliases: selected });
2689
- if (!latest(mine)) return;
2786
+ if (!latestWrite(mine)) return;
2690
2787
  if (!result.ok) {
2691
2788
  dispatch({ type: "import-failed", error: result.error });
2692
2789
  return;
@@ -2719,6 +2816,8 @@ function createRootsPanelController(options) {
2719
2816
  changeEditDraft: (patch) => dispatch({ type: "edit-draft-changed", patch }),
2720
2817
  submitRoot,
2721
2818
  submitConnection,
2819
+ openAddConnection,
2820
+ closeAddConnection,
2722
2821
  remove,
2723
2822
  startEdit,
2724
2823
  cancelEdit,
@@ -2737,6 +2836,7 @@ function createRootsPanelController(options) {
2737
2836
  submitImport,
2738
2837
  dispose: () => {
2739
2838
  disposed = true;
2839
+ requests.abort();
2740
2840
  listeners.clear();
2741
2841
  surfaceAttached();
2742
2842
  }
@@ -2748,6 +2848,8 @@ function rootsPanelSectionActions(controller) {
2748
2848
  onEditDraftChange: controller.changeEditDraft,
2749
2849
  onSubmitRoot: (location) => void controller.submitRoot(location),
2750
2850
  onSubmitConnection: () => void controller.submitConnection(),
2851
+ onOpenAddConnection: controller.openAddConnection,
2852
+ onCloseAddConnection: controller.closeAddConnection,
2751
2853
  onRemove: (key) => void controller.remove(key),
2752
2854
  onSubmitEdit: () => void controller.submitEdit(),
2753
2855
  onCancelEdit: controller.cancelEdit,
@@ -2760,9 +2862,43 @@ function rootsPanelSectionActions(controller) {
2760
2862
  onImportSubmit: () => void controller.submitImport()
2761
2863
  };
2762
2864
  }
2763
- function fieldValue(event) {
2764
- const target = event.target;
2765
- return typeof target?.value === "string" ? target.value : "";
2865
+ function selectRootsViewForRender(state) {
2866
+ const cached = rootsViewCache;
2867
+ if (cached !== void 0 && cached.snapshot === state.snapshot && cached.error === state.error && cached.pending === state.pending) {
2868
+ return cached.view;
2869
+ }
2870
+ const view = selectRootsView(state);
2871
+ rootsViewCache = { snapshot: state.snapshot, error: state.error, pending: state.pending, view };
2872
+ return view;
2873
+ }
2874
+ var rootsViewCache;
2875
+ var rootsRowElementsCache = /* @__PURE__ */ new WeakMap();
2876
+ function rootsPanelRowElements(view, copy, editable, onRemove) {
2877
+ const cached = rootsRowElementsCache.get(view);
2878
+ if (cached !== void 0 && cached.copy === copy && cached.editable === editable && cached.onRemove === onRemove) {
2879
+ return cached.elements;
2880
+ }
2881
+ const elements = view.rows.map((row) => renderRow(row, copy, editable, onRemove));
2882
+ rootsRowElementsCache.set(view, { copy, editable, onRemove, elements });
2883
+ return elements;
2884
+ }
2885
+ function openRootsDialog(portal, doc) {
2886
+ const origin = captureFocusOrigin(doc);
2887
+ portal.mount();
2888
+ portal.focusCard();
2889
+ return () => {
2890
+ portal.unmount();
2891
+ restoreFocusOrigin(origin);
2892
+ };
2893
+ }
2894
+ function installRootsDialogEscape(doc, close) {
2895
+ if (doc?.addEventListener === void 0) return () => void 0;
2896
+ const onKeyDown = (event) => {
2897
+ if (event.key !== "Escape") return;
2898
+ close();
2899
+ };
2900
+ doc.addEventListener("keydown", onKeyDown);
2901
+ return () => doc.removeEventListener?.("keydown", onKeyDown);
2766
2902
  }
2767
2903
  function renderFormNotice(copy, state, scope) {
2768
2904
  const notice = state.formNotice;
@@ -2791,9 +2927,15 @@ function renderRow(row, copy, editable, onRemove) {
2791
2927
  ) : null
2792
2928
  );
2793
2929
  }
2794
- function SshConfigImportDialog({ copy, state, actions, idPrefix = DEFAULT_ROOTS_ID_PREFIX }) {
2930
+ function SshConfigImportDialog({
2931
+ copy,
2932
+ state,
2933
+ actions,
2934
+ idPrefix = DEFAULT_ROOTS_ID_PREFIX,
2935
+ editable: editableOverride
2936
+ }) {
2795
2937
  if (state.importOpen !== true) return null;
2796
- const editable = canEdit(state);
2938
+ const editable = editableOverride ?? canEdit(state);
2797
2939
  const listing = state.importListing;
2798
2940
  const selected = new Set(state.importSelected ?? []);
2799
2941
  const busy = state.importBusy === true;
@@ -2805,10 +2947,12 @@ function SshConfigImportDialog({ copy, state, actions, idPrefix = DEFAULT_ROOTS_
2805
2947
  "section",
2806
2948
  {
2807
2949
  className: CLASS.block,
2808
- role: "dialog",
2809
- // Named by its own heading. **Not** `aria-modal`: this dialog is rendered inside the settings page's own
2810
- // scroll flow (it is not portalled), the page behind it stays interactive, and there is no Tab trap — a
2811
- // modal claim the behaviour does not back is worse than no claim (audit 2026-11-01b ui P2-2).
2950
+ // A named **region**, not a dialog. It is rendered inline inside the settings page's own scroll flow (it is
2951
+ // not portalled), the page behind it stays interactive, and it has no Tab trap — a `role="dialog"` would
2952
+ // promise modal behaviour the surface does not implement, which is worse than not claiming it. The other
2953
+ // two forms really are portalled modals (`aria-modal`, focus trap), so the two roles stay honest
2954
+ // (audit 2026-11-01b ui P2-2; 2026-11-05 P2-3).
2955
+ role: "region",
2812
2956
  "aria-labelledby": `${idPrefix}-import-title`,
2813
2957
  // Esc closes it from anywhere inside, like the other five dialogs. It is inline, so this handler is the
2814
2958
  // only one that will see the key while the settings page scrolls the caret inside it.
@@ -2931,10 +3075,154 @@ function SshConfigImportDialog({ copy, state, actions, idPrefix = DEFAULT_ROOTS_
2931
3075
  )
2932
3076
  );
2933
3077
  }
2934
- function ConnectionEditForm({ copy, state, actions, idPrefix = DEFAULT_ROOTS_ID_PREFIX }) {
3078
+ function ConnectionAddForm({
3079
+ copy,
3080
+ state,
3081
+ actions,
3082
+ idPrefix = DEFAULT_ROOTS_ID_PREFIX,
3083
+ editable: editableOverride
3084
+ }) {
3085
+ if (state.addingConnection !== true) return null;
3086
+ const editable = editableOverride ?? canEdit(state);
3087
+ const drafts = state.drafts;
3088
+ return (0, import_react5.createElement)(
3089
+ "form",
3090
+ {
3091
+ onSubmit: (event) => {
3092
+ event.preventDefault();
3093
+ actions.onSubmitConnection();
3094
+ },
3095
+ className: CLASS.dialog,
3096
+ role: "dialog",
3097
+ "aria-modal": "true",
3098
+ "aria-label": copy.rootsAddConnection,
3099
+ onKeyDown: (event) => {
3100
+ if (!isEscape(event)) return;
3101
+ event.stopPropagation?.();
3102
+ actions.onCloseAddConnection();
3103
+ }
3104
+ },
3105
+ (0, import_react5.createElement)(
3106
+ "div",
3107
+ { className: CLASS.dialogHeader },
3108
+ (0, import_react5.createElement)("h3", { className: CLASS.sectionTitle }, copy.rootsAddConnection)
3109
+ ),
3110
+ (0, import_react5.createElement)(
3111
+ "div",
3112
+ { className: CLASS.dialogBody },
3113
+ (0, import_react5.createElement)(
3114
+ "div",
3115
+ { className: CLASS.form },
3116
+ Field({
3117
+ htmlFor: `${idPrefix}-connection-id`,
3118
+ label: copy.rootsIdLabel,
3119
+ children: TextInput({
3120
+ id: `${idPrefix}-connection-id`,
3121
+ name: "roots-connection-id",
3122
+ value: drafts.newId,
3123
+ disabled: !editable,
3124
+ onChange: (event) => actions.onDraftChange({ newId: fieldValue(event) })
3125
+ })
3126
+ }),
3127
+ Field({
3128
+ htmlFor: `${idPrefix}-connection-host`,
3129
+ label: copy.rootsHostLabel,
3130
+ children: TextInput({
3131
+ id: `${idPrefix}-connection-host`,
3132
+ name: "roots-connection-host",
3133
+ value: drafts.newHost,
3134
+ disabled: !editable,
3135
+ onChange: (event) => actions.onDraftChange({ newHost: fieldValue(event) })
3136
+ })
3137
+ }),
3138
+ Field({
3139
+ htmlFor: `${idPrefix}-connection-user`,
3140
+ label: copy.rootsUserLabel,
3141
+ children: TextInput({
3142
+ id: `${idPrefix}-connection-user`,
3143
+ name: "roots-connection-user",
3144
+ value: drafts.newUser,
3145
+ disabled: !editable,
3146
+ onChange: (event) => actions.onDraftChange({ newUser: fieldValue(event) })
3147
+ })
3148
+ }),
3149
+ Field({
3150
+ htmlFor: `${idPrefix}-connection-auth`,
3151
+ label: copy.rootsAuthLabel,
3152
+ children: Select({
3153
+ id: `${idPrefix}-connection-auth`,
3154
+ name: "roots-connection-auth",
3155
+ value: drafts.newAuthKind,
3156
+ disabled: !editable,
3157
+ onChange: (value) => actions.onDraftChange({ newAuthKind: value === "password" ? "password" : "key" }),
3158
+ options: [
3159
+ { value: "key", label: copy.rootsAuthKey },
3160
+ { value: "password", label: copy.rootsAuthPassword }
3161
+ ]
3162
+ })
3163
+ }),
3164
+ // Only meaningful for key authentication; shown on demand rather than as a dead field.
3165
+ drafts.newAuthKind === "key" ? Field({
3166
+ htmlFor: `${idPrefix}-connection-key-path`,
3167
+ label: copy.rootsKeyPathLabel,
3168
+ children: TextInput({
3169
+ id: `${idPrefix}-connection-key-path`,
3170
+ name: "roots-connection-key-path",
3171
+ value: drafts.newKeyPath,
3172
+ disabled: !editable,
3173
+ onChange: (event) => actions.onDraftChange({ newKeyPath: fieldValue(event) })
3174
+ })
3175
+ }) : null,
3176
+ // Only meaningful for password authentication. The value never comes back from the host, so this field is
3177
+ // always empty on render: the panel can say a password is stored, never show one.
3178
+ drafts.newAuthKind === "password" ? Field({
3179
+ htmlFor: `${idPrefix}-connection-password`,
3180
+ label: copy.rootsPasswordLabel,
3181
+ children: TextInput({
3182
+ id: `${idPrefix}-connection-password`,
3183
+ name: "roots-connection-password",
3184
+ type: "password",
3185
+ autoComplete: "new-password",
3186
+ value: drafts.newPassword,
3187
+ disabled: !editable,
3188
+ placeholder: copy.rootsPasswordLabel,
3189
+ onChange: (event) => actions.onDraftChange({ newPassword: fieldValue(event) })
3190
+ })
3191
+ }) : null,
3192
+ Field({
3193
+ htmlFor: `${idPrefix}-connection-port`,
3194
+ label: copy.rootsPortLabel,
3195
+ children: TextInput({
3196
+ id: `${idPrefix}-connection-port`,
3197
+ name: "roots-connection-port",
3198
+ value: drafts.newPort,
3199
+ disabled: !editable,
3200
+ inputMode: "numeric",
3201
+ size: "compact",
3202
+ onChange: (event) => actions.onDraftChange({ newPort: fieldValue(event) })
3203
+ })
3204
+ })
3205
+ ),
3206
+ renderFormNotice(copy, state, "connection")
3207
+ ),
3208
+ (0, import_react5.createElement)(
3209
+ "div",
3210
+ { className: CLASS.actions },
3211
+ Button({ variant: "primary", type: "submit", disabled: !editable, children: copy.rootsSave }),
3212
+ Button({ variant: "secondary", type: "button", disabled: !editable, onClick: actions.onCloseAddConnection, children: copy.rootsEditCancel })
3213
+ )
3214
+ );
3215
+ }
3216
+ function ConnectionEditForm({
3217
+ copy,
3218
+ state,
3219
+ actions,
3220
+ idPrefix = DEFAULT_ROOTS_ID_PREFIX,
3221
+ editable: editableOverride
3222
+ }) {
2935
3223
  const editingId = state.editingId;
2936
3224
  if (editingId === void 0) return null;
2937
- const editable = canEdit(state);
3225
+ const editable = editableOverride ?? canEdit(state);
2938
3226
  const drafts = state.editDrafts;
2939
3227
  const renamed = drafts.id.trim() !== editingId;
2940
3228
  return (0, import_react5.createElement)(
@@ -3098,12 +3386,16 @@ function RootsPanelSection({
3098
3386
  actions,
3099
3387
  transfers,
3100
3388
  portal,
3101
- idPrefix = DEFAULT_ROOTS_ID_PREFIX
3389
+ idPrefix = DEFAULT_ROOTS_ID_PREFIX,
3390
+ view: precomputedView,
3391
+ rows: precomputedRows,
3392
+ editable: editableOverride
3102
3393
  }) {
3103
- const view = selectRootsView(state);
3104
- const editForm = ConnectionEditForm({ copy, state, actions, idPrefix });
3105
- const editable = view.canEdit;
3106
- const { drafts } = state;
3394
+ const view = precomputedView ?? selectRootsViewForRender(state);
3395
+ const editable = editableOverride ?? view.canEdit;
3396
+ const editForm = ConnectionEditForm({ copy, state, actions, idPrefix, editable });
3397
+ const addForm = ConnectionAddForm({ copy, state, actions, idPrefix, editable });
3398
+ const rowElements = precomputedRows ?? rootsPanelRowElements(view, copy, editable, actions.onRemove);
3107
3399
  const notice = resolveMessage2(copy, view.notice);
3108
3400
  return (0, import_react5.createElement)(
3109
3401
  "section",
@@ -3119,113 +3411,15 @@ function RootsPanelSection({
3119
3411
  // collapsed diagnostic block at the bottom of this section (see below): it is still the only place a
3120
3412
  // root can be removed from, so it is demoted, never dropped.
3121
3413
  // -- add a connection ----------------------------------------------------
3414
+ // 2026-11-05(用户反馈):原先是常驻的页内表单,六个字段把页面占满;现在是一个按钮,点开才出现对话框,
3415
+ // 与「编辑」同一种卡片形态(同样的 `CLASS.dialog`、Esc 关闭、字段布局)。
3122
3416
  (0, import_react5.createElement)(
3123
- "form",
3124
- {
3125
- onSubmit: (event) => {
3126
- event.preventDefault();
3127
- actions.onSubmitConnection();
3128
- },
3129
- className: CLASS.block
3130
- },
3131
- (0, import_react5.createElement)("h4", { className: CLASS.blockTitle }, copy.rootsAddConnection),
3132
- (0, import_react5.createElement)(
3133
- "div",
3134
- { className: CLASS.form },
3135
- Field({
3136
- htmlFor: `${idPrefix}-connection-id`,
3137
- label: copy.rootsIdLabel,
3138
- children: TextInput({
3139
- id: `${idPrefix}-connection-id`,
3140
- name: "roots-connection-id",
3141
- value: drafts.newId,
3142
- disabled: !editable,
3143
- onChange: (event) => actions.onDraftChange({ newId: fieldValue(event) })
3144
- })
3145
- }),
3146
- Field({
3147
- htmlFor: `${idPrefix}-connection-host`,
3148
- label: copy.rootsHostLabel,
3149
- children: TextInput({
3150
- id: `${idPrefix}-connection-host`,
3151
- name: "roots-connection-host",
3152
- value: drafts.newHost,
3153
- disabled: !editable,
3154
- onChange: (event) => actions.onDraftChange({ newHost: fieldValue(event) })
3155
- })
3156
- }),
3157
- Field({
3158
- htmlFor: `${idPrefix}-connection-user`,
3159
- label: copy.rootsUserLabel,
3160
- children: TextInput({
3161
- id: `${idPrefix}-connection-user`,
3162
- name: "roots-connection-user",
3163
- value: drafts.newUser,
3164
- disabled: !editable,
3165
- onChange: (event) => actions.onDraftChange({ newUser: fieldValue(event) })
3166
- })
3167
- }),
3168
- Field({
3169
- htmlFor: `${idPrefix}-connection-auth`,
3170
- label: copy.rootsAuthLabel,
3171
- children: Select({
3172
- id: `${idPrefix}-connection-auth`,
3173
- name: "roots-connection-auth",
3174
- value: drafts.newAuthKind,
3175
- disabled: !editable,
3176
- onChange: (value) => actions.onDraftChange({ newAuthKind: value === "password" ? "password" : "key" }),
3177
- options: [
3178
- { value: "key", label: copy.rootsAuthKey },
3179
- { value: "password", label: copy.rootsAuthPassword }
3180
- ]
3181
- })
3182
- }),
3183
- // Only meaningful for key authentication; shown on demand rather than as a dead field.
3184
- drafts.newAuthKind === "key" ? Field({
3185
- htmlFor: `${idPrefix}-connection-key-path`,
3186
- label: copy.rootsKeyPathLabel,
3187
- children: TextInput({
3188
- id: `${idPrefix}-connection-key-path`,
3189
- name: "roots-connection-key-path",
3190
- value: drafts.newKeyPath,
3191
- disabled: !editable,
3192
- onChange: (event) => actions.onDraftChange({ newKeyPath: fieldValue(event) })
3193
- })
3194
- }) : null,
3195
- // Only meaningful for password authentication. The value never comes back from the host, so this
3196
- // field is always empty on render: the panel can say a password is stored, never show one.
3197
- drafts.newAuthKind === "password" ? Field({
3198
- htmlFor: `${idPrefix}-connection-password`,
3199
- label: copy.rootsPasswordLabel,
3200
- children: TextInput({
3201
- id: `${idPrefix}-connection-password`,
3202
- name: "roots-connection-password",
3203
- type: "password",
3204
- autoComplete: "new-password",
3205
- value: drafts.newPassword,
3206
- disabled: !editable,
3207
- placeholder: copy.rootsPasswordLabel,
3208
- onChange: (event) => actions.onDraftChange({ newPassword: fieldValue(event) })
3209
- })
3210
- }) : null,
3211
- Field({
3212
- htmlFor: `${idPrefix}-connection-port`,
3213
- label: copy.rootsPortLabel,
3214
- children: TextInput({
3215
- id: `${idPrefix}-connection-port`,
3216
- name: "roots-connection-port",
3217
- value: drafts.newPort,
3218
- disabled: !editable,
3219
- inputMode: "numeric",
3220
- size: "compact",
3221
- onChange: (event) => actions.onDraftChange({ newPort: fieldValue(event) })
3222
- })
3223
- }),
3224
- Button({ variant: "primary", type: "submit", disabled: !editable, children: copy.rootsSave })
3225
- )
3417
+ "div",
3418
+ { className: CLASS.block },
3419
+ Button({ variant: "primary", disabled: !editable, onClick: () => actions.onOpenAddConnection(), children: copy.rootsAddConnection })
3226
3420
  ),
3421
+ // 成功行留在页面上:保存成功后对话框会关闭,提示必须仍在用户眼前。
3227
3422
  renderFormSuccess(copy, state, "connection"),
3228
- renderFormNotice(copy, state, "connection"),
3229
3423
  // -- import from ~/.ssh/config (FR-2.1.4) --------------------------------
3230
3424
  // The button sits next to the add form because it is an alternative way to *create* a connection, and it
3231
3425
  // is disabled for the same reason every other control is (`canEdit`).
@@ -3234,7 +3428,7 @@ function RootsPanelSection({
3234
3428
  { className: CLASS.block },
3235
3429
  Button({ variant: "secondary", disabled: !editable, onClick: () => actions.onImportOpen(), children: copy.rootsImportOpen })
3236
3430
  ),
3237
- SshConfigImportDialog({ copy, state, actions, idPrefix }),
3431
+ SshConfigImportDialog({ copy, state, actions, idPrefix, editable }),
3238
3432
  // -- no file tree, no entry point (audit 0085) ---------------------------
3239
3433
  // Settings used to carry a sentence pointing at the conversation. It was a signpost for a capability
3240
3434
  // that deliberately has no UI; the decision still stands (remote files are the agent's), but the
@@ -3243,6 +3437,7 @@ function RootsPanelSection({
3243
3437
  transfers ?? null,
3244
3438
  // -- edit one saved connection (a dialog, not a block in this page) -------
3245
3439
  editForm === null ? null : portal === void 0 ? editForm : portal.render(editForm, portal.container),
3440
+ addForm === null ? null : portal === void 0 ? addForm : portal.render(addForm, portal.container),
3246
3441
  // -- the advanced root table (diagnostic) --------------------------------
3247
3442
  // This block used to be the section's headline content, which made it read as "the remote workspaces
3248
3443
  // list". It is not one: each row is a *root route* - a remote directory plus the local anchor the
@@ -3262,7 +3457,7 @@ function RootsPanelSection({
3262
3457
  void 0,
3263
3458
  (0, import_react5.createElement)("summary", { className: CLASS.blockTitle }, copy.rootsAdvancedSummary),
3264
3459
  (0, import_react5.createElement)("p", { className: CLASS.description }, copy.rootsAdvancedNote),
3265
- 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)))
3460
+ view.rows.length === 0 ? (0, import_react5.createElement)("p", { className: CLASS.empty }, copy.rootsEmpty) : (0, import_react5.createElement)("ul", { className: CLASS.list }, ...rowElements)
3266
3461
  )
3267
3462
  )
3268
3463
  );
@@ -3271,6 +3466,16 @@ function RootsPanelContainer({ copy, controller }) {
3271
3466
  const state = (0, import_react5.useSyncExternalStore)(controller.subscribe, controller.getState);
3272
3467
  const uid = (0, import_react5.useId)();
3273
3468
  const idPrefix = `dsh-rw-${uid.replace(/[^A-Za-z0-9_-]/g, "")}`;
3469
+ const view = (0, import_react5.useMemo)(
3470
+ () => selectRootsViewForRender(state),
3471
+ [state.snapshot, state.error, state.pending]
3472
+ );
3473
+ const editable = view.canEdit;
3474
+ const sectionActions = (0, import_react5.useMemo)(() => rootsPanelSectionActions(controller), [controller]);
3475
+ const rowElements = (0, import_react5.useMemo)(
3476
+ () => rootsPanelRowElements(view, copy, editable, sectionActions.onRemove),
3477
+ [view, copy, editable, sectionActions.onRemove]
3478
+ );
3274
3479
  const [portal] = (0, import_react5.useState)(
3275
3480
  () => createCardPortal({
3276
3481
  document: browserPortalDocument(),
@@ -3287,23 +3492,27 @@ function RootsPanelContainer({ copy, controller }) {
3287
3492
  controller.surfaceAttached();
3288
3493
  return controller.surfaceDetached;
3289
3494
  }, [controller]);
3290
- useCardTabTrap(state.editingId !== void 0, portal.container);
3495
+ const cardOpen = state.editingId !== void 0 || state.addingConnection === true;
3496
+ useCardTabTrap(cardOpen, portal.container);
3291
3497
  (0, import_react5.useLayoutEffect)(() => {
3292
- if (state.editingId === void 0) return;
3293
- portal.mount();
3294
- portal.focusCard();
3295
- return () => {
3296
- portal.unmount();
3297
- };
3298
- }, [portal, state.editingId]);
3299
- const sectionActions = (0, import_react5.useMemo)(() => rootsPanelSectionActions(controller), [controller]);
3498
+ if (!cardOpen) return;
3499
+ return openRootsDialog(portal, browserPortalDocument());
3500
+ }, [portal, cardOpen]);
3501
+ const closeOpenDialog = state.editingId !== void 0 ? sectionActions.onCancelEdit : state.addingConnection === true ? sectionActions.onCloseAddConnection : void 0;
3502
+ (0, import_react5.useEffect)(() => {
3503
+ if (!cardOpen || closeOpenDialog === void 0) return;
3504
+ return installRootsDialogEscape(browserPortalDocument(), closeOpenDialog);
3505
+ }, [cardOpen, closeOpenDialog]);
3300
3506
  return RootsPanelSection({
3301
3507
  copy,
3302
3508
  state,
3303
3509
  idPrefix,
3510
+ view,
3511
+ rows: rowElements,
3512
+ editable,
3304
3513
  // No `actions`: the transfer panel owns its own polling and its own buttons (the prop that used to be passed
3305
3514
  // here was typed `unknown` and never read — audit 2026-10-31 client.md §2.4).
3306
- transfers: (0, import_react5.createElement)(TransferProgressSection, { copy, state }),
3515
+ transfers: (0, import_react5.createElement)(TransferProgressSection, { copy }),
3307
3516
  // The panel needs the connection list, which this section already read; it does not re-fetch it.
3308
3517
  actions: sectionActions,
3309
3518
  portal
@@ -3329,14 +3538,14 @@ function renderConnectionRow(row, actions) {
3329
3538
  "span",
3330
3539
  { className: CLASS.rowActions },
3331
3540
  row.test === void 0 || onTest === void 0 ? null : Button({
3332
- variant: "ghost",
3541
+ variant: "secondary",
3333
3542
  size: "compact",
3334
3543
  // Disabled while its own probe runs: one honest click, and the label says what is happening.
3335
3544
  disabled: row.test.status === "running" || disabled === true,
3336
3545
  onClick: () => onTest(row.id),
3337
3546
  children: row.test.label
3338
3547
  }),
3339
- row.actions === void 0 || onEdit === void 0 ? null : Button({ variant: "ghost", size: "compact", disabled: disabled === true, onClick: () => onEdit(row.id), children: row.actions.editLabel }),
3548
+ row.actions === void 0 || onEdit === void 0 ? null : Button({ variant: "secondary", size: "compact", disabled: disabled === true, onClick: () => onEdit(row.id), children: row.actions.editLabel }),
3340
3549
  row.actions === void 0 || onRemove === void 0 ? null : Button({ variant: "ghost", size: "compact", disabled: disabled === true, onClick: () => onRemove(row.id), children: row.actions.removeLabel })
3341
3550
  )
3342
3551
  );
@@ -3448,23 +3657,19 @@ function buildPanelModel(copy, options = {}) {
3448
3657
  acceptLabel: copy.rootsConfirmRemoveAccept,
3449
3658
  // The count is the reason this confirmation exists: deleting a connection silently takes its
3450
3659
  // remote roots with it (the host does that, and a row showing only `id · host` would not say so).
3451
- body: (row, remoteRoots) => resolveCopy(copy.rootsConfirmRemoveBody, { id: row.id, count: remoteRoots })
3660
+ body: (row, remoteRoots) => resolveTemplate(copy.rootsConfirmRemoveBody, { id: row.id, count: remoteRoots })
3452
3661
  }
3453
3662
  }
3454
3663
  };
3455
3664
  }
3456
- function resolveCopy(template, params) {
3457
- return template.replace(/\{(\w+)\}/g, (placeholder, name) => {
3458
- const value = params[name];
3459
- return value === void 0 ? placeholder : String(value);
3460
- });
3461
- }
3462
3665
 
3463
- // src/client/core/remote.ts
3666
+ // src/shared/remote-identity.ts
3464
3667
  var SSH_DEV_REMOTE_NAMESPACE = "sshdev";
3465
- var SSH_DEV_REMOTE_METHOD = "connections";
3466
3668
  var SSH_DEV_REMOTE_SERVICE = "sshDevRemote";
3669
+ var SSH_DEV_REMOTE_METHOD = "connections";
3467
3670
  var SSH_DEV_REMOTE_PACKAGE = "dsh-over-ssh";
3671
+
3672
+ // src/client/core/remote.ts
3468
3673
  var SSH_DEV_CONNECTIONS_DESCRIPTOR = {
3469
3674
  id: `${SSH_DEV_REMOTE_PACKAGE}#${SSH_DEV_REMOTE_NAMESPACE}/${SSH_DEV_REMOTE_METHOD}`,
3470
3675
  service: SSH_DEV_REMOTE_SERVICE,
@@ -3517,6 +3722,7 @@ function createConnectionTester(options) {
3517
3722
  const listeners = /* @__PURE__ */ new Set();
3518
3723
  let state = /* @__PURE__ */ new Map();
3519
3724
  let disposed = false;
3725
+ const controllers = /* @__PURE__ */ new Map();
3520
3726
  const set = (id, next) => {
3521
3727
  if (disposed) return;
3522
3728
  const copy = new Map(state);
@@ -3548,15 +3754,21 @@ function createConnectionTester(options) {
3548
3754
  };
3549
3755
  const test = async (id) => {
3550
3756
  if (disposed) return;
3757
+ controllers.get(id)?.abort();
3758
+ const controller = new AbortController();
3759
+ controllers.set(id, controller);
3551
3760
  set(id, { status: "running" });
3552
- const signal = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(timeoutMs) : void 0;
3761
+ const timeoutSignal = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(timeoutMs) : void 0;
3762
+ const signal = timeoutSignal === void 0 ? controller.signal : typeof AbortSignal.any === "function" ? AbortSignal.any([controller.signal, timeoutSignal]) : timeoutSignal;
3763
+ const current = () => !disposed && controllers.get(id) === controller;
3553
3764
  try {
3554
3765
  const response = await options.fetchImpl(path, {
3555
3766
  method: "POST",
3556
3767
  headers: { "content-type": "application/json" },
3557
3768
  body: JSON.stringify({ action: "test-connection", id }),
3558
- ...signal === void 0 ? {} : { signal }
3769
+ signal
3559
3770
  });
3771
+ if (!current()) return;
3560
3772
  if (!response.ok) {
3561
3773
  set(id, {
3562
3774
  status: "failed",
@@ -3567,6 +3779,7 @@ function createConnectionTester(options) {
3567
3779
  }
3568
3780
  set(id, parse(await response.json()));
3569
3781
  } catch (error) {
3782
+ if (!current()) return;
3570
3783
  if (isTimeout(error)) {
3571
3784
  set(id, {
3572
3785
  status: "failed",
@@ -3576,6 +3789,8 @@ function createConnectionTester(options) {
3576
3789
  return;
3577
3790
  }
3578
3791
  set(id, { status: "failed", code: "UNREACHABLE", message: error instanceof Error ? error.message : String(error) });
3792
+ } finally {
3793
+ if (controllers.get(id) === controller) controllers.delete(id);
3579
3794
  }
3580
3795
  };
3581
3796
  return {
@@ -3589,6 +3804,8 @@ function createConnectionTester(options) {
3589
3804
  },
3590
3805
  dispose: () => {
3591
3806
  disposed = true;
3807
+ for (const controller of controllers.values()) controller.abort();
3808
+ controllers.clear();
3592
3809
  listeners.clear();
3593
3810
  }
3594
3811
  };
@@ -3611,7 +3828,21 @@ function createLatestOnly() {
3611
3828
  };
3612
3829
  }
3613
3830
 
3831
+ // src/shared/path-fold.ts
3832
+ function foldPathKey(path) {
3833
+ return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
3834
+ }
3835
+
3614
3836
  // src/client/session/session-panel.ts
3837
+ function unblockOrphanSubsessions(rows) {
3838
+ const byId = new Map(rows.map((row) => [row.id, row]));
3839
+ return rows.map((row) => {
3840
+ if (row.classification !== "subsession" || row.blockedReason !== "subsession" || row.running) return row;
3841
+ const parent = row.parentSessionId === void 0 ? void 0 : byId.get(row.parentSessionId);
3842
+ if (parent !== void 0 && parent.classification !== "unreadable") return row;
3843
+ return { ...row, deletable: true, blockedReason: void 0 };
3844
+ });
3845
+ }
3615
3846
  function toSessionRowView(raw) {
3616
3847
  const id = raw["id"];
3617
3848
  if (typeof id !== "string" || id === "") return void 0;
@@ -3677,20 +3908,18 @@ function workspaceLabel(row) {
3677
3908
  return "";
3678
3909
  }
3679
3910
  function visibleSessions(rows) {
3680
- const present = new Set(rows.map((row) => row.id));
3911
+ const byId = new Map(rows.map((row) => [row.id, row]));
3681
3912
  return rows.filter((row) => {
3682
3913
  if (row.classification !== "subsession") return true;
3683
3914
  if (row.parentSessionId === void 0) return true;
3684
- return !present.has(row.parentSessionId);
3915
+ const parent = byId.get(row.parentSessionId);
3916
+ return parent === void 0 || parent.classification === "unreadable";
3685
3917
  });
3686
3918
  }
3687
- function hiddenSubsessionCount(rows) {
3688
- return rows.length - visibleSessions(rows).length;
3689
- }
3690
3919
  function groupByWorkspace(rows) {
3691
3920
  const groups = /* @__PURE__ */ new Map();
3692
3921
  for (const row of rows) {
3693
- const key = row.cwd === void 0 || row.cwd === "" ? "" : row.cwd;
3922
+ const key = row.cwd === void 0 || row.cwd === "" ? "" : foldPathKey(row.cwd);
3694
3923
  const existing = groups.get(key);
3695
3924
  if (existing === void 0) {
3696
3925
  groups.set(key, { label: workspaceLabel(row), cwd: row.cwd, sessions: [row] });
@@ -3728,26 +3957,31 @@ function parseSessionSnapshot(value) {
3728
3957
  if (body["ok"] !== true) return emptySnapshot();
3729
3958
  const available = body["available"] === true;
3730
3959
  const sessions = Array.isArray(body["sessions"]) ? body["sessions"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toSessionRowView(raw)].filter((row) => row !== void 0) : []) : [];
3960
+ const unblocked = unblockOrphanSubsessions(sessions);
3731
3961
  const bin = Array.isArray(body["bin"]) ? body["bin"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toBinRowView(raw)].filter((row) => row !== void 0) : []) : [];
3732
3962
  const rawPolicy = typeof body["policy"] === "object" && body["policy"] !== null ? body["policy"] : {};
3733
3963
  const numberOr = (key) => typeof rawPolicy[key] === "number" && Number.isFinite(rawPolicy[key]) ? rawPolicy[key] : 0;
3734
3964
  return {
3735
3965
  available,
3736
- sessions,
3966
+ sessions: unblocked,
3737
3967
  bin,
3738
3968
  policy: { maxEntries: numberOr("maxEntries"), maxBytes: numberOr("maxBytes"), maxAgeMs: numberOr("maxAgeMs") },
3739
3969
  totals: {
3740
- sessions: sessions.length,
3741
- bytes: sessions.reduce((sum, row) => sum + row.bytes, 0),
3742
- subsessions: sessions.filter((row) => row.classification === "subsession").length,
3970
+ sessions: unblocked.length,
3971
+ bytes: unblocked.reduce((sum, row) => sum + row.bytes, 0),
3972
+ subsessions: unblocked.filter((row) => row.classification === "subsession").length,
3743
3973
  live: sessions.filter((row) => row.live).length,
3744
3974
  running: sessions.filter((row) => row.running).length
3745
3975
  }
3746
3976
  };
3747
3977
  }
3748
- async function readSessionPanel(io) {
3978
+ async function readSessionPanel(io, signal) {
3749
3979
  try {
3750
- const response = await io.fetch(SESSION_API_PATH, { method: "GET", headers: { accept: "application/json" } });
3980
+ const response = await io.fetch(SESSION_API_PATH, {
3981
+ method: "GET",
3982
+ headers: { accept: "application/json" },
3983
+ ...signal === void 0 ? {} : { signal }
3984
+ });
3751
3985
  if (!response.ok) return { ok: false, error: `http-${response.status}` };
3752
3986
  return { ok: true, snapshot: parseSessionSnapshot(await response.json()) };
3753
3987
  } catch (error) {
@@ -3779,10 +4013,16 @@ async function deleteSessions(io, ids, options = {}) {
3779
4013
  if (options.permanent === true) body["confirm"] = true;
3780
4014
  return postAction(io, body, (value) => {
3781
4015
  const recoverable = value["recoverable"] === true;
4016
+ const refused = Array.isArray(value["refusals"]) ? value["refusals"].length : 0;
3782
4017
  const size = formatBytes(Number(value["bytes"] ?? 0));
3783
- if (options.permanent === true) return { key: "sessionResultDeletedPermanent", params: { size } };
4018
+ if (refused > 0 && Number(value["bytes"] ?? 0) === 0) {
4019
+ return { key: "sessionResultDeleteNone" };
4020
+ }
4021
+ if (options.permanent === true) {
4022
+ return refused === 0 ? { key: "sessionResultDeletedPermanent", params: { size } } : { key: "sessionResultDeletedPermanentPartial", params: { size, refused } };
4023
+ }
3784
4024
  if (!recoverable) return { key: "sessionResultDeleteNone" };
3785
- return { key: "sessionResultRecycled", params: { size } };
4025
+ return refused === 0 ? { key: "sessionResultRecycled", params: { size } } : { key: "sessionResultRecycledPartial", params: { size, refused } };
3786
4026
  });
3787
4027
  }
3788
4028
  async function deleteWorkspaceSessions(io, cwd, options = {}) {
@@ -3794,10 +4034,12 @@ async function deleteWorkspaceSessions(io, cwd, options = {}) {
3794
4034
  };
3795
4035
  if (options.permanent === true) body["confirm"] = true;
3796
4036
  return postAction(io, body, (value) => {
3797
- const count = Number(value["matched"] ?? 0);
3798
- const size = formatBytes(Number(value["bytes"] ?? 0));
3799
- if (count === 0) return { key: "sessionResultWorkspaceEmpty" };
4037
+ const matched = Number(value["matched"] ?? 0);
3800
4038
  const refused = Array.isArray(value["refusals"]) ? value["refusals"].length : 0;
4039
+ const count = Math.max(0, matched - refused);
4040
+ const size = formatBytes(Number(value["bytes"] ?? 0));
4041
+ if (matched === 0) return { key: "sessionResultWorkspaceEmpty" };
4042
+ if (count === 0) return { key: "sessionResultDeleteNone" };
3801
4043
  const params = refused === 0 ? { count, size } : { count, size, refused };
3802
4044
  if (options.permanent === true) {
3803
4045
  return { key: refused === 0 ? "sessionResultWorkspacePurged" : "sessionResultWorkspacePurgedRefused", params };
@@ -3813,10 +4055,14 @@ async function purgeSession(io, entryId) {
3813
4055
  if (entryId === "") return { kind: "error", message: { key: "sessionResultMissingEntryId" } };
3814
4056
  return postAction(io, { action: "purge", entryId, confirm: true }, () => ({ key: "sessionResultPurged" }));
3815
4057
  }
3816
- async function sweepSessions(io) {
3817
- return postAction(io, { action: "sweep" }, (value) => {
4058
+ async function sweepSessions(io, options = {}) {
4059
+ const body = { action: "sweep" };
4060
+ if (options.confirm === true) body["confirm"] = true;
4061
+ return postAction(io, body, (value) => {
3818
4062
  const purged = Array.isArray(value["purged"]) ? value["purged"].length : 0;
3819
- return purged === 0 ? { key: "sessionResultSweepNone" } : { key: "sessionResultSweepDone", params: { count: purged } };
4063
+ const failed = Array.isArray(value["failed"]) ? value["failed"].length : 0;
4064
+ if (purged === 0 && failed === 0) return { key: "sessionResultSweepNone" };
4065
+ return failed === 0 ? { key: "sessionResultSweepDone", params: { count: purged } } : { key: "sessionResultSweepPartial", params: { count: purged, failed } };
3820
4066
  });
3821
4067
  }
3822
4068
  async function postAction(io, body, describe) {
@@ -3834,7 +4080,16 @@ async function postAction(io, body, describe) {
3834
4080
  };
3835
4081
  }
3836
4082
  if (!response.ok) return { kind: "error", message: await errorMessage(response), status: response.status };
3837
- const value = await response.json();
4083
+ let value;
4084
+ try {
4085
+ const decoded = await response.json();
4086
+ if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
4087
+ return { kind: "error", message: { key: "sessionResultActionFailed" }, status: response.status };
4088
+ }
4089
+ value = decoded;
4090
+ } catch {
4091
+ return { kind: "error", message: { key: "sessionResultActionFailed" }, status: response.status };
4092
+ }
3838
4093
  if (value["ok"] !== true) {
3839
4094
  return {
3840
4095
  kind: "error",
@@ -3909,7 +4164,10 @@ function renderBinRow(props) {
3909
4164
  (0, import_react7.createElement)(
3910
4165
  "div",
3911
4166
  { className: CLASS.row },
3912
- (0, import_react7.createElement)("code", { className: CLASS.badge }, row.sessionId),
4167
+ // 回收站条目以前只画完整 uuid,用户没法判断该还原/清除哪一条;宿主本来就带 title 与删除时间。
4168
+ (0, import_react7.createElement)("code", { className: CLASS.badge }, shortId(row.sessionId)),
4169
+ row.title === void 0 || row.title === "" ? null : (0, import_react7.createElement)("span", { className: CLASS.rowTitle, title: `movedBy: ${row.movedBy}` }, row.title),
4170
+ (0, import_react7.createElement)("span", { className: CLASS.rowMeta }, formatStamp(row.deletedAtMs)),
3913
4171
  (0, import_react7.createElement)("span", { className: CLASS.rowMeta }, formatBytes(row.bytes)),
3914
4172
  (0, import_react7.createElement)(
3915
4173
  "span",
@@ -3993,54 +4251,84 @@ function SessionPanel({ copy, io, close }) {
3993
4251
  const [selected, setSelected] = (0, import_react7.useState)([]);
3994
4252
  const [outcome, setOutcome] = (0, import_react7.useState)(void 0);
3995
4253
  const [busy, setBusy] = (0, import_react7.useState)(false);
4254
+ const busyRef = (0, import_react7.useRef)(false);
4255
+ const markBusy = (0, import_react7.useCallback)((value) => {
4256
+ busyRef.current = value;
4257
+ setBusy(value);
4258
+ }, []);
3996
4259
  const [pending, setPending] = (0, import_react7.useState)(void 0);
3997
4260
  const snapshot = phase.kind === "ready" ? phase.snapshot : EMPTY_SNAPSHOT;
3998
4261
  const [reads] = (0, import_react7.useState)(() => createLatestOnly());
4262
+ const [actions] = (0, import_react7.useState)(() => createLatestOnly());
4263
+ const [requests] = (0, import_react7.useState)(() => createAbortGate());
3999
4264
  const alive = (0, import_react7.useRef)(true);
4000
4265
  (0, import_react7.useEffect)(() => {
4001
4266
  alive.current = true;
4002
4267
  return () => {
4003
4268
  alive.current = false;
4269
+ requests.abort();
4004
4270
  };
4005
- }, []);
4271
+ }, [requests]);
4006
4272
  const refresh = (0, import_react7.useCallback)(async () => {
4273
+ if (busyRef.current) return;
4007
4274
  const mine = reads.begin();
4008
- setBusy(true);
4275
+ const signal = requests.begin();
4276
+ markBusy(true);
4009
4277
  try {
4010
- const result = await readSessionPanel(io);
4278
+ const result = await readSessionPanel(io, signal);
4011
4279
  if (!alive.current || !reads.isCurrent(mine)) return;
4012
4280
  setPhase(result.ok ? { kind: "ready", snapshot: result.snapshot } : { kind: "failed", error: result.error });
4281
+ if (result.ok) {
4282
+ const stillVisible = new Set(visibleSessions(result.snapshot.sessions).map((row) => row.id));
4283
+ setSelected((previous) => previous.filter((id) => stillVisible.has(id)));
4284
+ }
4013
4285
  } finally {
4014
- if (alive.current && reads.isCurrent(mine)) setBusy(false);
4286
+ if (alive.current && reads.isCurrent(mine)) markBusy(false);
4015
4287
  }
4016
- }, [io, reads]);
4288
+ }, [io, markBusy, reads, requests]);
4289
+ const refreshRef = (0, import_react7.useRef)(refresh);
4017
4290
  (0, import_react7.useEffect)(() => {
4018
- void refresh();
4291
+ refreshRef.current = refresh;
4019
4292
  }, [refresh]);
4293
+ const mounted = (0, import_react7.useRef)(false);
4294
+ (0, import_react7.useEffect)(() => {
4295
+ if (mounted.current) return;
4296
+ mounted.current = true;
4297
+ void refreshRef.current();
4298
+ }, []);
4020
4299
  const run = (0, import_react7.useCallback)(
4021
4300
  async (action) => {
4301
+ if (busyRef.current) return;
4022
4302
  const mine = reads.begin();
4023
- setBusy(true);
4303
+ const actionId = actions.begin();
4304
+ const signal = requests.begin();
4305
+ markBusy(true);
4024
4306
  try {
4025
- const result = await action();
4026
- if (!alive.current || !reads.isCurrent(mine)) return;
4027
- setOutcome(result);
4307
+ let result;
4308
+ try {
4309
+ result = await action();
4310
+ } catch {
4311
+ result = { kind: "error", message: { key: "sessionResultActionFailed" } };
4312
+ }
4313
+ if (!alive.current) return;
4314
+ if (actions.isCurrent(actionId)) setOutcome(result);
4028
4315
  setSelected([]);
4029
4316
  setPending(void 0);
4030
- const read = await readSessionPanel(io);
4317
+ const read = await readSessionPanel(io, signal);
4031
4318
  if (!alive.current || !reads.isCurrent(mine)) return;
4032
4319
  setPhase(read.ok ? { kind: "ready", snapshot: read.snapshot } : { kind: "failed", error: read.error });
4033
4320
  } finally {
4034
- if (alive.current && reads.isCurrent(mine)) setBusy(false);
4321
+ if (alive.current && actions.isCurrent(actionId)) markBusy(false);
4035
4322
  }
4036
4323
  },
4037
- [io, reads]
4324
+ [actions, io, markBusy, reads, requests]
4038
4325
  );
4039
4326
  const visible = (0, import_react7.useMemo)(() => visibleSessions(snapshot.sessions), [snapshot.sessions]);
4040
- const hidden = hiddenSubsessionCount(snapshot.sessions);
4327
+ const selectedSet = (0, import_react7.useMemo)(() => new Set(selected), [selected]);
4328
+ const hidden = snapshot.sessions.length - visible.length;
4041
4329
  const deletable = (0, import_react7.useMemo)(
4042
- () => visible.filter((row) => row.deletable && selected.includes(row.id)),
4043
- [visible, selected]
4330
+ () => visible.filter((row) => row.deletable && selectedSet.has(row.id)),
4331
+ [visible, selectedSet]
4044
4332
  );
4045
4333
  const groups = (0, import_react7.useMemo)(() => groupByWorkspace(visible), [visible]);
4046
4334
  const toggle = (0, import_react7.useCallback)((id) => {
@@ -4051,6 +4339,7 @@ function SessionPanel({ copy, io, close }) {
4051
4339
  if (current === void 0) return;
4052
4340
  setPending(void 0);
4053
4341
  void run(() => {
4342
+ if (current.kind === "sweep") return sweepSessions(io, { confirm: true });
4054
4343
  if (current.kind === "purge") return purgeSession(io, current.ids[0]);
4055
4344
  if (current.kind === "delete-workspace") return deleteWorkspaceSessions(io, current.cwd, { permanent: true });
4056
4345
  return deleteSessions(io, current.ids, { permanent: true });
@@ -4103,7 +4392,7 @@ function SessionPanel({ copy, io, close }) {
4103
4392
  (0, import_react7.createElement)(
4104
4393
  "div",
4105
4394
  { className: CLASS.headerActions },
4106
- Button({ variant: "secondary", size: "compact", onClick: () => void refresh(), children: copy.refresh }),
4395
+ Button({ variant: "secondary", size: "compact", disabled: busy, onClick: () => void refresh(), children: copy.refresh }),
4107
4396
  Button({ variant: "secondary", size: "compact", onClick: close, children: copy.close })
4108
4397
  )
4109
4398
  ),
@@ -4194,7 +4483,7 @@ function SessionPanel({ copy, io, close }) {
4194
4483
  variant: "secondary",
4195
4484
  size: "compact",
4196
4485
  disabled: busy,
4197
- onClick: () => void run(() => sweepSessions(io)),
4486
+ onClick: () => setPending({ kind: "sweep" }),
4198
4487
  children: copy.sweep
4199
4488
  })
4200
4489
  ),
@@ -4216,7 +4505,11 @@ function renderConfirmationDialog(pending, copy, handlers, titleId, loadedIdle =
4216
4505
  handlers.cancel();
4217
4506
  }
4218
4507
  },
4219
- (0, import_react7.createElement)("h3", { id: titleId, className: CLASS.dialogHeader }, copy.confirmTitle),
4508
+ (0, import_react7.createElement)(
4509
+ "h3",
4510
+ { id: titleId, className: CLASS.dialogHeader },
4511
+ pending.kind === "sweep" ? copy.sweepConfirmTitle : copy.confirmTitle
4512
+ ),
4220
4513
  (0, import_react7.createElement)("p", { className: CLASS.dialogBody }, confirmationBody(pending, copy, loadedIdle)),
4221
4514
  (0, import_react7.createElement)(
4222
4515
  "div",
@@ -4231,6 +4524,7 @@ function workspaceDeleteAction(group) {
4231
4524
  return { kind: "delete-workspace", cwd: group.cwd };
4232
4525
  }
4233
4526
  function confirmationBody(pending, copy, loadedIdle = 0) {
4527
+ if (pending.kind === "sweep") return copy.sweepConfirmBody;
4234
4528
  if (pending.kind === "delete-workspace") return copy.confirmWorkspaceBody(pending.cwd);
4235
4529
  const targets = pending.ids.join(", ");
4236
4530
  const sentence = pending.kind === "purge" ? copy.confirmPurgeBody(targets) : copy.confirmBody(targets);
@@ -4273,6 +4567,7 @@ function createSessionRowActionStore(deps) {
4273
4567
  };
4274
4568
  });
4275
4569
  let cancelAutoClose;
4570
+ let postInFlight = false;
4276
4571
  const stopAutoClose = () => {
4277
4572
  cancelAutoClose?.();
4278
4573
  cancelAutoClose = void 0;
@@ -4297,14 +4592,22 @@ function createSessionRowActionStore(deps) {
4297
4592
  const confirm = async () => {
4298
4593
  const request = state.request;
4299
4594
  const row = state.row;
4300
- if (disposed || request === void 0 || state.busy) return;
4595
+ if (disposed || request === void 0 || state.busy || postInFlight) return;
4301
4596
  if (state.phase !== "ready" || row === void 0 || !row.deletable) return;
4302
4597
  const stillPending = () => {
4303
4598
  const current = state.request;
4304
4599
  return current !== void 0 && current.kind === request.kind && current.sessionId === request.sessionId;
4305
4600
  };
4601
+ postInFlight = true;
4306
4602
  dispatch({ ...state, busy: true, outcome: void 0 });
4307
- const result = await deleteSessions(deps.io, [request.sessionId], request.kind === "delete" ? { permanent: true } : {});
4603
+ let result;
4604
+ try {
4605
+ result = await deleteSessions(deps.io, [request.sessionId], request.kind === "delete" ? { permanent: true } : {});
4606
+ } catch {
4607
+ result = { kind: "error", message: { key: "sessionResultActionFailed" } };
4608
+ } finally {
4609
+ postInFlight = false;
4610
+ }
4308
4611
  if (disposed || !stillPending()) return;
4309
4612
  dispatch({ ...state, busy: false, outcome: result });
4310
4613
  if (result.kind === "ok") {
@@ -4326,7 +4629,7 @@ function createSessionRowActionStore(deps) {
4326
4629
  request: (request) => {
4327
4630
  if (disposed) return;
4328
4631
  stopAutoClose();
4329
- dispatch({ request, phase: "loading", row: void 0, busy: false, outcome: void 0 });
4632
+ dispatch({ request, phase: "loading", row: void 0, busy: postInFlight, outcome: void 0 });
4330
4633
  },
4331
4634
  load,
4332
4635
  confirm,
@@ -4571,7 +4874,6 @@ var import_react11 = require("react");
4571
4874
 
4572
4875
  // src/client/flows/directory-browser.ts
4573
4876
  var import_react10 = require("react");
4574
- var GIT_STATUS_API_PATH = "/api/over-ssh/git-status";
4575
4877
  var GIT_STATUS_BADGE = {
4576
4878
  modified: "M",
4577
4879
  added: "A",
@@ -4898,17 +5200,24 @@ function createDirectoryBrowser(deps) {
4898
5200
  dispatch({ type: "remote-loaded", path, listing: result.value });
4899
5201
  void loadGit(connectionId, result.value.path, signal);
4900
5202
  };
5203
+ let connectionsInFlight;
4901
5204
  const ensureConnections = async () => {
4902
5205
  if (state.connections.length > 0) return true;
4903
- if (state.connectionsPending) return false;
5206
+ if (connectionsInFlight !== void 0) return connectionsInFlight;
4904
5207
  dispatch({ type: "connections-loading" });
4905
- const result = await deps.io.loadConnections();
4906
- if (!result.ok) {
4907
- dispatch({ type: "connections-failed", error: result.error });
4908
- return false;
4909
- }
4910
- dispatch({ type: "connections-loaded", connections: result.value });
4911
- return true;
5208
+ const attempt = (async () => {
5209
+ const result = await deps.io.loadConnections();
5210
+ connectionsInFlight = void 0;
5211
+ if (disposed) return false;
5212
+ if (!result.ok) {
5213
+ dispatch({ type: "connections-failed", error: result.error });
5214
+ return false;
5215
+ }
5216
+ dispatch({ type: "connections-loaded", connections: result.value });
5217
+ return true;
5218
+ })();
5219
+ connectionsInFlight = attempt;
5220
+ return attempt;
4912
5221
  };
4913
5222
  const refresh = async (path) => {
4914
5223
  if (disposed) return;
@@ -5017,7 +5326,7 @@ function DirectoryBrowserSection({
5017
5326
  listRef
5018
5327
  }) {
5019
5328
  const view = selectDirectoryBrowserView(state);
5020
- const gate = disabled;
5329
+ const gate = disabled || state.pending;
5021
5330
  const connectionFieldId = `${idPrefix}-connection`;
5022
5331
  const modeButton = (mode, label) => Button({
5023
5332
  // The active world is the brand-filled one: the switch reads without an extra attribute, and both
@@ -5239,12 +5548,18 @@ function createSessionDirectoryIo(options) {
5239
5548
  });
5240
5549
  const rootsApi = createRootsApi(options.fetchImpl, path);
5241
5550
  const loadRoots = async (workspacePath, sessionId) => {
5242
- const result = await rootsApi.load();
5551
+ const result = await rootsApi.load({
5552
+ ...sessionId === void 0 || sessionId === "" ? {} : { session: sessionId },
5553
+ ...workspacePath === void 0 || workspacePath === "" ? {} : { workspace: workspacePath }
5554
+ });
5243
5555
  if (!result.ok) return result;
5244
5556
  if (result.value.warnings.includes("roots-invalid-answer")) {
5245
5557
  return { ok: false, error: "roots-invalid-answer" };
5246
5558
  }
5247
5559
  const all = result.value.roots;
5560
+ if (sessionId !== void 0 && sessionId !== "" && result.value.scopedForSession === sessionId) {
5561
+ return { ok: true, value: all };
5562
+ }
5248
5563
  if (workspacePath === void 0 || workspacePath === "") return { ok: true, value: [] };
5249
5564
  return {
5250
5565
  ok: true,
@@ -5370,15 +5685,30 @@ function selectSessionDirectoryView(state, copy) {
5370
5685
  workspaceLine: state.workspacePath === void 0 ? copy["sessionAddWorkspaceUnknown"] : resolveMessage2(copy, { key: "sessionAddWorkspace", params: { path: state.workspacePath } }),
5371
5686
  ...state.workspacePath === void 0 ? {} : { workspacePath: state.workspacePath },
5372
5687
  ...state.added === void 0 ? {} : { added: state.added },
5373
- roots: state.roots.map((row) => ({
5374
- key: row.key,
5375
- // A label is what the user called it; without one the path is the only honest title. A remote path alone
5376
- // would hide which host it is on, so the connection name is part of the title there.
5377
- title: row.label ?? (row.location === "remote" ? `${row.connectionId ?? ""}:${row.path}` : row.path),
5378
- subtitle: row.label === void 0 ? "" : row.path,
5379
- badge: row.location === "remote" ? copy["rootsBadgeRemote"] : copy["rootsBadgeLocal"],
5380
- removable: row.inUserFile
5381
- })),
5688
+ // 工作区永远排在第一行:它是默认边界,`workspace-write` 下与已添加目录一样可写(ADR-0016)。
5689
+ roots: [
5690
+ ...state.workspacePath === void 0 ? [] : [{
5691
+ key: `workspace:${state.workspacePath}`,
5692
+ title: state.workspacePath,
5693
+ subtitle: copy["sessionRootsWorkspaceNote"],
5694
+ badge: copy["sessionRootsWorkspaceBadge"],
5695
+ removable: false,
5696
+ workspace: true
5697
+ }],
5698
+ ...state.roots.map((row) => ({
5699
+ key: row.key,
5700
+ // A label is what the user called it; without one the path is the only honest title. A remote path alone
5701
+ // would hide which host it is on, so the connection name is part of the title there.
5702
+ title: row.label ?? (row.location === "remote" ? `${row.connectionId ?? ""}:${row.path}` : row.path),
5703
+ subtitle: row.label === void 0 ? "" : row.path,
5704
+ badge: row.location === "remote" ? copy["rootsBadgeRemote"] : copy["rootsBadgeLocal"],
5705
+ // fork 继承来的行没有删除按钮:它在父亲那里才是"本会话添加的",在这里点删除会把父会话的
5706
+ // 目录拿走(宿主也会拒绝)。没有 sessions 字段的历史条目按文件权限判定,保持旧行为。
5707
+ removable: row.inUserFile && // 会话型条目只有**本会话**能删;`state.sessionId` 还没到也不能放行(那会把父会话的目录
5708
+ // 交给 fork 去删,宿主会拒,用户看到的是一次"按了没反应")。历史条目(无 sessions)按文件权限。
5709
+ (row.sessions === void 0 || state.sessionId !== void 0 && row.sessions.includes(state.sessionId))
5710
+ }))
5711
+ ],
5382
5712
  rootsPending: state.rootsPending,
5383
5713
  rootsReading: state.rootsReading,
5384
5714
  browsing: state.browsing
@@ -5431,11 +5761,14 @@ function createSessionDirectory(deps) {
5431
5761
  const commit = async (path, connectionId) => {
5432
5762
  if (disposed || state.pending) return;
5433
5763
  const location = connectionId === void 0 ? "local" : "remote";
5434
- const validated = validateRootDraft({
5435
- location,
5436
- path,
5437
- ...connectionId === void 0 ? {} : { connectionId }
5438
- });
5764
+ const validated = validateRootDraft(
5765
+ {
5766
+ location,
5767
+ path,
5768
+ ...connectionId === void 0 ? {} : { connectionId }
5769
+ },
5770
+ state.roots
5771
+ );
5439
5772
  if (!validated.ok) {
5440
5773
  dispatch({ type: "refused", message: validated.message });
5441
5774
  return;
@@ -5544,6 +5877,9 @@ function SessionDirectorySection({
5544
5877
  { className: CLASS.block },
5545
5878
  (0, import_react11.createElement)("h4", { className: CLASS.blockTitle }, copy.sessionRootsTitle),
5546
5879
  (0, import_react11.createElement)("p", { className: CLASS.hint }, copy.sessionRootsHint),
5880
+ // **有行就画列表**:工作区那一行是合成行,它不属于"已添加目录",但它是这张卡片最重要的一行。
5881
+ // 旧条件按"有没有已添加目录"决定画不画整张列表,结果是新会话只看到「还没有添加目录」,连会话
5882
+ // 自己在哪个工作区都不显示,必须先去添加一个目录才冒出来(审计 2026-11-06,用户截图)。
5547
5883
  view.roots.length === 0 ? view.rootsReading ? Notice({ role: "status", tone: "info", children: copy.sessionRootsLoading }) : (
5548
5884
  // A failed read is not an empty list: the error notice below is the whole truth there, and
5549
5885
  // 「还没有添加目录」 next to it would contradict it (same rule as the import dialog, ui P2-2/P2-3).
@@ -5564,7 +5900,9 @@ function SessionDirectorySection({
5564
5900
  Button({
5565
5901
  variant: "ghost",
5566
5902
  size: "compact",
5567
- disabled: view.rootsPending,
5903
+ // 刷新在飞时也禁用:否则一次点击会叠加"删除 + 一次刷新"两次远端写
5904
+ // (审计 2026-11-06 客户端 P2)。
5905
+ disabled: view.rootsPending || view.rootsReading,
5568
5906
  onClick: () => actions.onRemoveRoot(row.key),
5569
5907
  children: copy.rootsRemove
5570
5908
  })
@@ -5572,6 +5910,8 @@ function SessionDirectorySection({
5572
5910
  )
5573
5911
  )
5574
5912
  ),
5913
+ // 列表里只剩工作区行时,"还没有添加目录"仍然是有用的下一步提示;但它不再替换列表。
5914
+ view.roots.length > 0 && view.roots.filter((row) => row.workspace !== true).length === 0 && !view.rootsReading && view.notice === "" ? (0, import_react11.createElement)("p", { className: CLASS.empty }, copy.sessionRootsEmpty) : null,
5575
5915
  (0, import_react11.createElement)(
5576
5916
  "div",
5577
5917
  { className: CLASS.actions },
@@ -5611,20 +5951,22 @@ function readWorkspacePath(props) {
5611
5951
  const fromWorkspace = workspaces === void 0 ? void 0 : workspaces(
5612
5952
  (snapshot) => sessionId === void 0 || sessionId === "" ? void 0 : snapshot?.items?.find((item) => item.sessionIds?.includes(sessionId) === true)?.path
5613
5953
  );
5614
- if (typeof fromSession === "string" && fromSession !== "") return fromSession;
5615
5954
  if (typeof fromWorkspace === "string" && fromWorkspace !== "") return fromWorkspace;
5955
+ if (typeof fromSession === "string" && fromSession !== "") return fromSession;
5616
5956
  return void 0;
5617
5957
  }
5618
5958
  function rootsQueryKey(state) {
5619
5959
  if (!state.open) return "closed";
5620
- if (state.workspacePath === void 0 || state.workspacePath === "") return "open:";
5621
- return `open:${state.workspacePath}\0${state.sessionId ?? ""}`;
5960
+ const workspace = state.workspacePath === void 0 || state.workspacePath === "" ? "unknown" : state.workspacePath;
5961
+ const session = state.sessionId === void 0 || state.sessionId === "" ? "unknown" : state.sessionId;
5962
+ return `open:${workspace}\0${session}`;
5622
5963
  }
5623
5964
  function SessionDirectoryContainer(props) {
5624
5965
  const { copy, io } = props;
5625
5966
  const copyRef = (0, import_react11.useRef)(copy);
5626
5967
  copyRef.current = copy;
5627
- const [controller] = (0, import_react11.useState)(() => createSessionDirectory({ io, copy: () => copyRef.current }));
5968
+ const injected = props.controller;
5969
+ const [controller] = (0, import_react11.useState)(() => injected ?? createSessionDirectory({ io, copy: () => copyRef.current }));
5628
5970
  const state = (0, import_react11.useSyncExternalStore)(controller.subscribe, controller.getState);
5629
5971
  const workspacePath = readWorkspacePath(props);
5630
5972
  const titleId = (0, import_react11.useId)();
@@ -5649,8 +5991,10 @@ function SessionDirectoryContainer(props) {
5649
5991
  useCardTabTrap(state.open, portal.container);
5650
5992
  (0, import_react11.useEffect)(() => {
5651
5993
  controller.activate();
5652
- return () => controller.dispose();
5653
- }, [controller]);
5994
+ return () => {
5995
+ if (injected === void 0) controller.dispose();
5996
+ };
5997
+ }, [controller, injected]);
5654
5998
  (0, import_react11.useLayoutEffect)(() => {
5655
5999
  if (!state.open) return;
5656
6000
  focusOrigin.current = captureFocusOrigin(browserPortalDocument());
@@ -6003,10 +6347,6 @@ function createWorkspaceDirectoryFlow(deps) {
6003
6347
  }
6004
6348
  };
6005
6349
  }
6006
- function fieldValue2(event) {
6007
- const target = event.target;
6008
- return typeof target?.value === "string" ? target.value : "";
6009
- }
6010
6350
  function WorkspaceDirectoryFlowSection({
6011
6351
  copy,
6012
6352
  state,
@@ -6149,7 +6489,7 @@ function WorkspaceDirectoryFlowSection({
6149
6489
  value: state.drafts.path,
6150
6490
  disabled: !canPickLocal2,
6151
6491
  placeholder: copy.rootsRemotePathLabel,
6152
- onChange: (event) => actions.onDraftChange({ path: fieldValue2(event) })
6492
+ onChange: (event) => actions.onDraftChange({ path: fieldValue(event) })
6153
6493
  }),
6154
6494
  Button({
6155
6495
  variant: "secondary",
@@ -6308,6 +6648,8 @@ function apply(ctx) {
6308
6648
  subsessionBadge: dict.sessionPanelSubsession,
6309
6649
  unreadableBadge: dict.sessionPanelUnreadable,
6310
6650
  confirmTitle: dict.sessionPanelConfirmTitle,
6651
+ sweepConfirmTitle: dict.sessionResultSweepConfirmTitle,
6652
+ sweepConfirmBody: dict.sessionResultSweepConfirmBody,
6311
6653
  confirmBody: (targets) => resolveMessage2(dict, { key: "sessionPanelConfirmBody", params: { targets } }),
6312
6654
  confirmPurgeBody: (targets) => resolveMessage2(dict, { key: "sessionPanelConfirmPurgeBody", params: { targets } }),
6313
6655
  confirmCancel: dict.sessionPanelConfirmCancel,
@@ -6331,7 +6673,7 @@ function apply(ctx) {
6331
6673
  };
6332
6674
  };
6333
6675
  const io = { fetch: (input, init) => globalThis.fetch(input, init) };
6334
- return ctx.slots.inject(
6676
+ const register = () => ctx.slots.inject(
6335
6677
  "settings.section",
6336
6678
  () => ctx.slots.register(
6337
6679
  {
@@ -6339,12 +6681,20 @@ function apply(ctx) {
6339
6681
  id: SESSION_SECTION_ID,
6340
6682
  order: SESSION_SECTION_ORDER,
6341
6683
  label: copy().title,
6342
- inject: () => ({ copy: copy(), io, close: () => {
6343
- } })
6684
+ inject: () => ({ copy: copy(), io })
6344
6685
  },
6345
6686
  SessionPanel
6346
6687
  )
6347
6688
  );
6689
+ let dispose = register();
6690
+ const offLocaleChange = ctx.on("locale/change", () => {
6691
+ dispose();
6692
+ dispose = register();
6693
+ });
6694
+ return () => {
6695
+ offLocaleChange();
6696
+ dispose();
6697
+ };
6348
6698
  });
6349
6699
  ctx.effect(() => {
6350
6700
  let rows;
@@ -6474,7 +6824,7 @@ function apply(ctx) {
6474
6824
  let lastOverlay = "";
6475
6825
  const overlayOf = () => {
6476
6826
  const state = rootsController.getState();
6477
- return `${state.editingId ?? ""}|${state.confirmingRemove ?? ""}|${state.pending ? "busy" : "idle"}`;
6827
+ return `${state.editingId ?? ""}|${state.confirmingRemove ?? ""}`;
6478
6828
  };
6479
6829
  lastOverlay = overlayOf();
6480
6830
  const offSnapshot = rootsController.subscribe(() => {
@@ -6505,15 +6855,18 @@ function apply(ctx) {
6505
6855
  const api = createRootsApi((input, init) => globalThis.fetch(input, init));
6506
6856
  let ids = /* @__PURE__ */ new Set();
6507
6857
  let disposed = false;
6858
+ const requests = createAbortGate();
6508
6859
  const dict = () => OVER_SSH_DICTS[ctx.locale.getLocale().active === "en" ? "en" : "zh"];
6509
6860
  const label = () => dict().rootsTitle;
6510
6861
  let loaded = false;
6511
6862
  let dispose = () => void 0;
6512
6863
  async function reload() {
6864
+ const signal = requests.begin();
6513
6865
  try {
6514
- const [roots, sessions] = await Promise.all([api.load(), loadSessionPanel(io)]);
6866
+ const [roots, sessions] = await Promise.all([api.load(signal), readSessionPanel(io, signal)]);
6515
6867
  if (disposed || !roots.ok) return;
6516
- const next = remoteSessionIds(sessions.sessions, roots.value.anchorRoot);
6868
+ if (!sessions.ok || !sessions.snapshot.available) return;
6869
+ const next = remoteSessionIds(sessions.snapshot.sessions, roots.value.anchorRoot);
6517
6870
  if (sameIds(next, ids)) return;
6518
6871
  ids = next;
6519
6872
  refresh();
@@ -6553,6 +6906,7 @@ function apply(ctx) {
6553
6906
  const offLocaleChange = ctx.on("locale/change", refresh);
6554
6907
  return () => {
6555
6908
  disposed = true;
6909
+ requests.abort();
6556
6910
  doc?.removeEventListener("visibilitychange", onVisibility);
6557
6911
  offLocaleChange();
6558
6912
  dispose();
@@ -6604,6 +6958,7 @@ function apply(ctx) {
6604
6958
  return await chooser.pickDirectory();
6605
6959
  }
6606
6960
  });
6961
+ const controller = createSessionDirectory({ io, copy: () => copy() });
6607
6962
  const registerSessionEntry = () => ctx.slots.inject(
6608
6963
  SESSION_DIRECTORY_SLOT,
6609
6964
  () => ctx.slots.register(
@@ -6611,7 +6966,7 @@ function apply(ctx) {
6611
6966
  name: SESSION_DIRECTORY_SLOT,
6612
6967
  id: SESSION_DIRECTORY_ID,
6613
6968
  order: SESSION_DIRECTORY_ORDER,
6614
- inject: () => ({ copy: copy(), io })
6969
+ inject: () => ({ copy: copy(), io, controller })
6615
6970
  },
6616
6971
  SessionDirectoryContainer
6617
6972
  )
@@ -6624,6 +6979,7 @@ function apply(ctx) {
6624
6979
  return () => {
6625
6980
  offLocaleChange();
6626
6981
  disposeEntry();
6982
+ controller.dispose();
6627
6983
  };
6628
6984
  });
6629
6985
  ctx.effect(() => {