dsh-over-ssh 0.2.1 → 0.2.3

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
@@ -82,6 +82,8 @@ var OVER_SSH_DICTS = {
82
82
  sessionPanelSessions: "\u4F1A\u8BDD",
83
83
  sessionPanelBin: "\u56DE\u6536\u7AD9",
84
84
  sessionPanelBinEmpty: "\u56DE\u6536\u7AD9\u4E3A\u7A7A\u3002",
85
+ sessionPanelBinUnreadable: "\u53E6\u6709 {count} \u6761\u56DE\u6536\u7AD9\u6761\u76EE\u65E0\u6CD5\u663E\u793A\uFF08\u7F3A\u5C11\u4F1A\u8BDD\u6807\u8BC6\uFF09\uFF1B\u53EF\u91CD\u542F\u5BBF\u4E3B\u6216\u68C0\u67E5\u56DE\u6536\u7AD9\u7D22\u5F15\u3002",
86
+ sessionPanelBinRowUnaddressable: "\u8BE5\u6761\u76EE\u7F3A\u5C11\u6807\u8BC6\uFF0C\u65E0\u6CD5\u8FD8\u539F\u6216\u6E05\u9664",
85
87
  sessionPanelSelectAll: "\u5168\u9009\u53EF\u5220\u7684",
86
88
  sessionPanelClear: "\u6E05\u7A7A\u9009\u62E9",
87
89
  sessionPanelDeleteRecycle: "\u5220\u9664\u5230\u56DE\u6536\u7AD9",
@@ -159,6 +161,7 @@ var OVER_SSH_DICTS = {
159
161
  rootsErrorIdRequired: "\u8BF7\u586B\u5199\u8FDE\u63A5 id\u3002",
160
162
  rootsErrorIdInvalid: "\u8FDE\u63A5 id \u53EA\u80FD\u5305\u542B\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u70B9\u3001\u4E0B\u5212\u7EBF\u548C\u77ED\u6A2A\u7EBF\uFF0C\u4E14\u5FC5\u987B\u4EE5\u5B57\u6BCD\u6216\u6570\u5B57\u5F00\u5934\u3002",
161
163
  rootsErrorIdDuplicate: "\u8FD9\u4E2A\u8FDE\u63A5 id \u5DF2\u7ECF\u5B58\u5728\u3002",
164
+ rootsErrorConnectionMissing: "\u8BE5\u8FDE\u63A5\u5DF2\u88AB\u5220\u9664\uFF0C\u65E0\u6CD5\u4FDD\u5B58\uFF1B\u8BF7\u5173\u95ED\u7F16\u8F91\u5E76\u5237\u65B0\u5217\u8868\u3002",
162
165
  rootsErrorHostRequired: "\u8BF7\u586B\u5199\u4E3B\u673A\u540D\u3002",
163
166
  rootsErrorUserRequired: "\u8BF7\u586B\u5199\u7528\u6237\u540D\u3002",
164
167
  rootsErrorPort: "\u7AEF\u53E3\u5FC5\u987B\u662F 1 \u5230 65535 \u4E4B\u95F4\u7684\u6574\u6570\u3002",
@@ -276,6 +279,9 @@ var OVER_SSH_DICTS = {
276
279
  sessionResultWorkspaceRecycled: "\u5DF2\u628A\u8BE5\u5DE5\u4F5C\u533A {count} \u4E2A\u4F1A\u8BDD\u79FB\u5165\u56DE\u6536\u7AD9\uFF0C{size}\uFF08\u53EF\u6062\u590D\uFF09",
277
280
  sessionResultWorkspaceRecycledRefused: "\u5DF2\u628A\u8BE5\u5DE5\u4F5C\u533A {count} \u4E2A\u4F1A\u8BDD\u79FB\u5165\u56DE\u6536\u7AD9\uFF0C{size}\uFF08\u53EF\u6062\u590D\uFF1B{refused} \u4E2A\u88AB\u62D2\u7EDD\uFF09",
278
281
  sessionResultRestored: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD",
282
+ sessionResultRestoredWithSubsessions: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD\uFF0C\u5E76\u8FDE\u5E26\u8FD8\u539F {count} \u4E2A\u5B50\u4F1A\u8BDD",
283
+ sessionResultRestoredPartial: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD\uFF0C\u53E6\u6709 {failed} \u4E2A\u5B50\u4F1A\u8BDD\u4ECD\u7559\u5728\u56DE\u6536\u7AD9",
284
+ sessionResultRestoredParentBinned: "\u5DF2\u8FD8\u539F\u8BE5\u4F1A\u8BDD\uFF08\u5176\u7236\u4F1A\u8BDD\u4ECD\u5728\u56DE\u6536\u7AD9\uFF09",
279
285
  sessionResultPurged: "\u5DF2\u6C38\u4E45\u6E05\u9664\u8BE5\u6761\u76EE",
280
286
  sessionResultSweepNone: "\u6CA1\u6709\u9700\u8981\u6E05\u7406\u7684\u6761\u76EE",
281
287
  sessionResultSweepDone: "\u5DF2\u6E05\u7406 {count} \u4E2A\u6761\u76EE",
@@ -329,6 +335,8 @@ var OVER_SSH_DICTS = {
329
335
  sessionPanelSessions: "Sessions",
330
336
  sessionPanelBin: "Recycle bin",
331
337
  sessionPanelBinEmpty: "The recycle bin is empty.",
338
+ sessionPanelBinUnreadable: "{count} more bin entr(y/ies) cannot be shown (no session id); restart the host or check the bin index.",
339
+ sessionPanelBinRowUnaddressable: "This entry has no id, so it cannot be restored or purged",
332
340
  sessionPanelSelectAll: "Select deletable",
333
341
  sessionPanelClear: "Clear selection",
334
342
  sessionPanelDeleteRecycle: "Delete to bin",
@@ -401,6 +409,7 @@ var OVER_SSH_DICTS = {
401
409
  rootsErrorIdRequired: "Enter a connection id.",
402
410
  rootsErrorIdInvalid: "A connection id may contain letters, digits, dots, underscores and dashes, and must start with a letter or a digit.",
403
411
  rootsErrorIdDuplicate: "That connection id already exists.",
412
+ rootsErrorConnectionMissing: "That connection has been deleted; close the editor and refresh.",
404
413
  rootsErrorHostRequired: "Enter a host name.",
405
414
  rootsErrorUserRequired: "Enter a user name.",
406
415
  rootsErrorPort: "The port must be an integer between 1 and 65535.",
@@ -518,6 +527,9 @@ var OVER_SSH_DICTS = {
518
527
  sessionResultWorkspaceRecycled: "Moved {count} sessions of that workspace to the recycle bin, {size} (recoverable)",
519
528
  sessionResultWorkspaceRecycledRefused: "Moved {count} sessions of that workspace to the recycle bin, {size} (recoverable; {refused} refused)",
520
529
  sessionResultRestored: "Restored that session",
530
+ sessionResultRestoredWithSubsessions: "Restored that session and {count} sub-session(s)",
531
+ sessionResultRestoredPartial: "Restored that session; {failed} sub-session(s) stayed in the bin",
532
+ sessionResultRestoredParentBinned: "Restored that session (its parent is still in the bin)",
521
533
  sessionResultPurged: "Permanently removed that entry",
522
534
  sessionResultSweepNone: "Nothing to sweep",
523
535
  sessionResultSweepDone: "Swept {count} entries",
@@ -1438,15 +1450,29 @@ function createCardPortal(options) {
1438
1450
  };
1439
1451
  }
1440
1452
  function focusFirstControl(container) {
1453
+ const all = container?.querySelectorAll?.(CARD_FOCUS_SELECTOR);
1454
+ if (all !== void 0 && all !== null) {
1455
+ for (const candidate of Array.from(all)) {
1456
+ if (!isVisibleControl(candidate)) continue;
1457
+ candidate.focus?.();
1458
+ return true;
1459
+ }
1460
+ return false;
1461
+ }
1441
1462
  const target = container?.querySelector?.(CARD_FOCUS_SELECTOR) ?? void 0;
1442
1463
  if (target === void 0 || typeof target.focus !== "function") return false;
1443
1464
  target.focus();
1444
1465
  return true;
1445
1466
  }
1467
+ function isVisibleControl(element) {
1468
+ const measurable = element.getClientRects;
1469
+ if (typeof measurable === "function") return measurable.call(element).length > 0;
1470
+ return element.hidden !== true;
1471
+ }
1446
1472
  function focusableWithin(container) {
1447
1473
  const found = container?.querySelectorAll?.(CARD_FOCUS_SELECTOR);
1448
1474
  if (found === null || found === void 0) return [];
1449
- return Array.from(found);
1475
+ return Array.from(found).filter(isVisibleControl);
1450
1476
  }
1451
1477
  function trapTab(event, container, active) {
1452
1478
  const focusables = focusableWithin(container);
@@ -1698,8 +1724,8 @@ function parseRootsSnapshot(value) {
1698
1724
  const row = toRootRow(entry, "config", false);
1699
1725
  const identity = row === void 0 ? void 0 : rootKeyIdentity(row.key);
1700
1726
  if (row !== void 0 && identity !== void 0 && !seen.has(identity)) {
1701
- const scoped = userWorkspaces.get(identity);
1702
- const scopedSessions = userSessions.get(identity);
1727
+ const scoped = row.workspaces ?? userWorkspaces.get(identity);
1728
+ const scopedSessions = row.sessions ?? userSessions.get(identity);
1703
1729
  roots.push({
1704
1730
  ...row,
1705
1731
  inUserFile: userKeys.has(identity),
@@ -1723,7 +1749,14 @@ function parseRootsSnapshot(value) {
1723
1749
  }
1724
1750
  const warnings = (Array.isArray(payload.warnings) ? payload.warnings : []).filter((item) => typeof item === "string");
1725
1751
  const anchorRoot = readString(payload, "anchorRoot");
1726
- return { roots, connections, warnings, ...anchorRoot === void 0 ? {} : { anchorRoot } };
1752
+ const scopedForSession = readString(payload, "scopedForSession");
1753
+ return {
1754
+ roots,
1755
+ connections,
1756
+ warnings,
1757
+ ...anchorRoot === void 0 ? {} : { anchorRoot },
1758
+ ...scopedForSession === void 0 ? {} : { scopedForSession }
1759
+ };
1727
1760
  }
1728
1761
  function isAbsoluteRemotePath(path) {
1729
1762
  if (!path.startsWith("/")) return false;
@@ -1912,13 +1945,27 @@ async function requestSnapshot(fetchImpl, path, init) {
1912
1945
  const reason = typeof body?.error === "string" ? body.error : transportFailureReason(response.status, path);
1913
1946
  return { ok: false, error: reason };
1914
1947
  }
1948
+ if (typeof body === "object" && body !== null && body["ok"] === false && typeof body["error"] === "string") {
1949
+ return { ok: false, error: body["error"] };
1950
+ }
1915
1951
  return { ok: true, value: parseRootsSnapshot(body) };
1916
1952
  } catch (error) {
1917
1953
  return { ok: false, error: error instanceof Error ? error.name : "network" };
1918
1954
  }
1919
1955
  }
1920
1956
  function createRootsApi(fetchImpl, path = ROOTS_API_PATH) {
1921
- const load = async (signal) => requestSnapshot(fetchImpl, path, { method: "GET", headers: { accept: "application/json" }, ...signal === void 0 ? {} : { signal } });
1957
+ const load = async (options = {}) => {
1958
+ const normalized = options instanceof AbortSignal ? { signal: options } : options;
1959
+ const query = new URLSearchParams();
1960
+ if (normalized.session !== void 0 && normalized.session !== "") query.set("session", normalized.session);
1961
+ if (normalized.workspace !== void 0 && normalized.workspace !== "") query.set("workspace", normalized.workspace);
1962
+ const suffix = query.size === 0 ? "" : `?${query.toString()}`;
1963
+ return requestSnapshot(fetchImpl, `${path}${suffix}`, {
1964
+ method: "GET",
1965
+ headers: { accept: "application/json" },
1966
+ ...normalized.signal === void 0 ? {} : { signal: normalized.signal }
1967
+ });
1968
+ };
1922
1969
  const send = async (payload) => requestSnapshot(fetchImpl, path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
1923
1970
  const sendRaw = async (payload) => {
1924
1971
  try {
@@ -2435,7 +2482,16 @@ function createTransferProgress(options) {
2435
2482
  setState(transferPanelPollStarted(state, showLoading));
2436
2483
  const options_ = mode === "park" ? { waitMs, signal: controller.signal } : { signal: controller.signal };
2437
2484
  issued += 1;
2438
- const result = await options.io.list(options_);
2485
+ let result;
2486
+ try {
2487
+ result = await options.io.list(options_);
2488
+ } catch (error) {
2489
+ if (controller.signal.aborted || disposed) {
2490
+ if (inFlight === controller) inFlight = void 0;
2491
+ continue;
2492
+ }
2493
+ result = { ok: false, error: error instanceof Error ? error.message : "list-failed" };
2494
+ }
2439
2495
  if (inFlight === controller) inFlight = void 0;
2440
2496
  if (disposed) return;
2441
2497
  if (mine !== generation) {
@@ -2476,6 +2532,7 @@ function createTransferProgress(options) {
2476
2532
  },
2477
2533
  clear: () => {
2478
2534
  if (disposed) return;
2535
+ if (state.clearing) return;
2479
2536
  setState(transferPanelClearing(state));
2480
2537
  void options.io.clear().then((result) => {
2481
2538
  if (disposed) return;
@@ -2574,7 +2631,9 @@ var ROOTS_EDIT_BACKDROP_CLASS = classes(CLASS.backdrop, CLASS.backdropSettings);
2574
2631
  function createRootsPanelController(options) {
2575
2632
  let state = initialRootsPanelState();
2576
2633
  const listeners = /* @__PURE__ */ new Set();
2577
- let token = 0;
2634
+ let readToken = 0;
2635
+ let writeToken = 0;
2636
+ let epoch = 0;
2578
2637
  let disposed = false;
2579
2638
  const requests = createAbortGate();
2580
2639
  const dispatch = (action) => {
@@ -2583,22 +2642,30 @@ function createRootsPanelController(options) {
2583
2642
  state = next;
2584
2643
  for (const listener of [...listeners]) listener();
2585
2644
  };
2586
- const latest = (mine) => !disposed && mine === token;
2645
+ const latestRead = (mine, issuedEpoch) => !disposed && mine === readToken && issuedEpoch === epoch;
2646
+ const latestWrite = (mine) => !disposed && mine === writeToken;
2587
2647
  const reload = async () => {
2588
- const mine = ++token;
2648
+ const mine = ++readToken;
2649
+ const issuedEpoch = epoch;
2589
2650
  const signal = requests.begin();
2590
2651
  dispatch({ type: "load" });
2591
2652
  const result = await options.api.load(signal);
2592
- if (!latest(mine)) return;
2653
+ if (!latestRead(mine, issuedEpoch)) return;
2654
+ if (result.ok && result.value.warnings.includes("roots-invalid-answer")) {
2655
+ dispatch({ type: "failed", error: "roots-invalid-answer" });
2656
+ return;
2657
+ }
2593
2658
  if (result.ok) dispatch({ type: "loaded", snapshot: result.value });
2594
2659
  else dispatch({ type: "failed", error: result.error });
2595
2660
  };
2596
2661
  const settle = async (mine, scope, result, resetDrafts) => {
2597
- if (!latest(mine)) return;
2662
+ if (!latestWrite(mine)) return;
2598
2663
  if (!result.ok) {
2599
2664
  dispatch({ type: "failed", error: result.error, scope });
2600
2665
  return;
2601
2666
  }
2667
+ epoch += 1;
2668
+ void result.value;
2602
2669
  dispatch({ type: "succeeded", scope, ...resetDrafts ? { resetDrafts: true } : {} });
2603
2670
  await reload();
2604
2671
  };
@@ -2611,7 +2678,7 @@ function createRootsPanelController(options) {
2611
2678
  dispatch({ type: "refused", scope: "roots", message: validated.message });
2612
2679
  return;
2613
2680
  }
2614
- const mine = ++token;
2681
+ const mine = ++writeToken;
2615
2682
  dispatch({ type: "submitted" });
2616
2683
  const result = await options.api.send({ action: "add-root", ...validated.value });
2617
2684
  await settle(mine, "roots", result, true);
@@ -2637,7 +2704,7 @@ function createRootsPanelController(options) {
2637
2704
  dispatch({ type: "refused", scope: "connection", message: validated.message });
2638
2705
  return;
2639
2706
  }
2640
- const mine = ++token;
2707
+ const mine = ++writeToken;
2641
2708
  dispatch({ type: "submitted" });
2642
2709
  const result = await options.api.send({ action: "add-connection", ...validated.value });
2643
2710
  await settle(mine, "connection", result, true);
@@ -2646,7 +2713,7 @@ function createRootsPanelController(options) {
2646
2713
  const row = selectRootsView(state).rows.find((candidate) => candidate.key === key);
2647
2714
  if (row === void 0 || !row.removable) return;
2648
2715
  if (!canEdit(state)) return;
2649
- const mine = ++token;
2716
+ const mine = ++writeToken;
2650
2717
  dispatch({ type: "remove", key });
2651
2718
  const result = await options.api.send({ action: "remove-root", key });
2652
2719
  await settle(mine, "roots", result, false);
@@ -2685,6 +2752,11 @@ function createRootsPanelController(options) {
2685
2752
  const editingId = state.editingId;
2686
2753
  if (editingId === void 0) return;
2687
2754
  if (!canEdit(state)) return;
2755
+ if (!(state.snapshot?.connections ?? []).some((row) => row.id === editingId)) {
2756
+ dispatch({ type: "edit-ended" });
2757
+ dispatch({ type: "refused", scope: "edit", message: { key: "rootsErrorConnectionMissing" } });
2758
+ return;
2759
+ }
2688
2760
  const editDrafts = state.editDrafts;
2689
2761
  const portText = editDrafts.port.trim();
2690
2762
  const port = portText === "" ? void 0 : Number(portText);
@@ -2702,7 +2774,7 @@ function createRootsPanelController(options) {
2702
2774
  dispatch({ type: "refused", scope: "edit", message: validated.message });
2703
2775
  return;
2704
2776
  }
2705
- const mine = ++token;
2777
+ const mine = ++writeToken;
2706
2778
  dispatch({ type: "submitted" });
2707
2779
  const result = await options.api.send({ action: "update-connection", id: editingId, ...validated.value });
2708
2780
  await settle(mine, "edit", result, true);
@@ -2716,16 +2788,16 @@ function createRootsPanelController(options) {
2716
2788
  const confirmRemoveConnection = async (id) => {
2717
2789
  if (state.confirmingRemove !== id) return;
2718
2790
  if (!canEdit(state)) return;
2719
- const mine = ++token;
2791
+ const mine = ++writeToken;
2720
2792
  dispatch({ type: "submitted" });
2721
2793
  const result = await options.api.send({ action: "remove-connection", id });
2722
2794
  await settle(mine, "edit", result, true);
2723
2795
  };
2724
2796
  const loadImport = async () => {
2725
- const mine = ++token;
2797
+ const mine = ++readToken;
2726
2798
  dispatch({ type: "import-busy" });
2727
2799
  const result = await options.api.sendRaw({ action: "list-ssh-config" });
2728
- if (!latest(mine)) return;
2800
+ if (disposed || mine !== readToken) return;
2729
2801
  if (!result.ok) {
2730
2802
  dispatch({ type: "import-failed", error: result.error });
2731
2803
  return;
@@ -2739,10 +2811,10 @@ function createRootsPanelController(options) {
2739
2811
  const submitImport = async () => {
2740
2812
  const selected = state.importSelected ?? [];
2741
2813
  if (selected.length === 0) return;
2742
- const mine = ++token;
2814
+ const mine = ++writeToken;
2743
2815
  dispatch({ type: "import-busy" });
2744
2816
  const result = await options.api.sendRaw({ action: "import-ssh-config", aliases: selected });
2745
- if (!latest(mine)) return;
2817
+ if (!latestWrite(mine)) return;
2746
2818
  if (!result.ok) {
2747
2819
  dispatch({ type: "import-failed", error: result.error });
2748
2820
  return;
@@ -3625,10 +3697,10 @@ function buildPanelModel(copy, options = {}) {
3625
3697
  // src/shared/remote-identity.ts
3626
3698
  var SSH_DEV_REMOTE_NAMESPACE = "sshdev";
3627
3699
  var SSH_DEV_REMOTE_SERVICE = "sshDevRemote";
3628
-
3629
- // src/client/core/remote.ts
3630
3700
  var SSH_DEV_REMOTE_METHOD = "connections";
3631
3701
  var SSH_DEV_REMOTE_PACKAGE = "dsh-over-ssh";
3702
+
3703
+ // src/client/core/remote.ts
3632
3704
  var SSH_DEV_CONNECTIONS_DESCRIPTOR = {
3633
3705
  id: `${SSH_DEV_REMOTE_PACKAGE}#${SSH_DEV_REMOTE_NAMESPACE}/${SSH_DEV_REMOTE_METHOD}`,
3634
3706
  service: SSH_DEV_REMOTE_SERVICE,
@@ -3828,10 +3900,10 @@ function toSessionRowView(raw) {
3828
3900
  };
3829
3901
  }
3830
3902
  function toBinRowView(raw) {
3831
- const entryId = raw["entryId"];
3903
+ const rawEntryId = raw["entryId"];
3832
3904
  const sessionId = raw["sessionId"];
3833
- if (typeof entryId !== "string" || entryId === "") return void 0;
3834
3905
  if (typeof sessionId !== "string" || sessionId === "") return void 0;
3906
+ const entryId = typeof rawEntryId === "string" ? rawEntryId : "";
3835
3907
  return {
3836
3908
  entryId,
3837
3909
  sessionId,
@@ -3846,6 +3918,8 @@ function emptySnapshot() {
3846
3918
  available: false,
3847
3919
  sessions: [],
3848
3920
  bin: [],
3921
+ binUnaddressable: 0,
3922
+ binUnreadable: 0,
3849
3923
  policy: { maxEntries: 0, maxBytes: 0, maxAgeMs: 0 },
3850
3924
  totals: { sessions: 0, bytes: 0, subsessions: 0, live: 0, running: 0 }
3851
3925
  };
@@ -3917,13 +3991,28 @@ function parseSessionSnapshot(value) {
3917
3991
  const available = body["available"] === true;
3918
3992
  const sessions = Array.isArray(body["sessions"]) ? body["sessions"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toSessionRowView(raw)].filter((row) => row !== void 0) : []) : [];
3919
3993
  const unblocked = unblockOrphanSubsessions(sessions);
3920
- const bin = Array.isArray(body["bin"]) ? body["bin"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toBinRowView(raw)].filter((row) => row !== void 0) : []) : [];
3994
+ let binUnreadable = 0;
3995
+ const bin = Array.isArray(body["bin"]) ? body["bin"].flatMap((raw) => {
3996
+ if (typeof raw !== "object" || raw === null) {
3997
+ binUnreadable += 1;
3998
+ return [];
3999
+ }
4000
+ const row = toBinRowView(raw);
4001
+ if (row === void 0) {
4002
+ binUnreadable += 1;
4003
+ return [];
4004
+ }
4005
+ return [row];
4006
+ }) : [];
4007
+ const binUnaddressable = bin.filter((row) => row.entryId === "").length;
3921
4008
  const rawPolicy = typeof body["policy"] === "object" && body["policy"] !== null ? body["policy"] : {};
3922
4009
  const numberOr = (key) => typeof rawPolicy[key] === "number" && Number.isFinite(rawPolicy[key]) ? rawPolicy[key] : 0;
3923
4010
  return {
3924
4011
  available,
3925
4012
  sessions: unblocked,
3926
4013
  bin,
4014
+ binUnaddressable,
4015
+ binUnreadable,
3927
4016
  policy: { maxEntries: numberOr("maxEntries"), maxBytes: numberOr("maxBytes"), maxAgeMs: numberOr("maxAgeMs") },
3928
4017
  totals: {
3929
4018
  sessions: unblocked.length,
@@ -4008,7 +4097,14 @@ async function deleteWorkspaceSessions(io, cwd, options = {}) {
4008
4097
  }
4009
4098
  async function restoreSession(io, entryId) {
4010
4099
  if (entryId === "") return { kind: "error", message: { key: "sessionResultMissingEntryId" } };
4011
- return postAction(io, { action: "restore", entryId }, () => ({ key: "sessionResultRestored" }));
4100
+ return postAction(io, { action: "restore", entryId }, (value) => {
4101
+ const failed = typeof value["failedSubsessions"] === "number" ? value["failedSubsessions"] : 0;
4102
+ const restored = typeof value["restoredSubsessions"] === "number" ? value["restoredSubsessions"] : 0;
4103
+ if (failed > 0) return { key: "sessionResultRestoredPartial", params: { failed } };
4104
+ if (restored > 0) return { key: "sessionResultRestoredWithSubsessions", params: { count: restored } };
4105
+ if (value["parentStillBinned"] === true) return { key: "sessionResultRestoredParentBinned" };
4106
+ return { key: "sessionResultRestored" };
4107
+ });
4012
4108
  }
4013
4109
  async function purgeSession(io, entryId) {
4014
4110
  if (entryId === "") return { kind: "error", message: { key: "sessionResultMissingEntryId" } };
@@ -4117,9 +4213,10 @@ var SessionRow = (0, import_react7.memo)(function SessionRow2(props) {
4117
4213
  });
4118
4214
  function renderBinRow(props) {
4119
4215
  const { row, copy, busy } = props;
4216
+ const unaddressable = row.entryId === "";
4120
4217
  return (0, import_react7.createElement)(
4121
4218
  "li",
4122
- { key: row.entryId },
4219
+ { key: unaddressable ? `unaddressable:${row.sessionId}` : row.entryId },
4123
4220
  (0, import_react7.createElement)(
4124
4221
  "div",
4125
4222
  { className: CLASS.row },
@@ -4131,20 +4228,22 @@ function renderBinRow(props) {
4131
4228
  (0, import_react7.createElement)(
4132
4229
  "span",
4133
4230
  { className: CLASS.rowActions },
4134
- Button({
4135
- variant: "secondary",
4136
- size: "compact",
4137
- disabled: busy,
4138
- onClick: () => props.onRestore(row.entryId),
4139
- children: copy.restore
4140
- }),
4141
- Button({
4142
- variant: "ghost",
4143
- size: "compact",
4144
- disabled: busy,
4145
- onClick: () => props.onPurge(row.entryId),
4146
- children: copy.purge
4147
- })
4231
+ ...unaddressable ? [(0, import_react7.createElement)("span", { className: CLASS.rowMeta, title: copy.binRowUnaddressable }, copy.binRowUnaddressable)] : [
4232
+ Button({
4233
+ variant: "secondary",
4234
+ size: "compact",
4235
+ disabled: busy,
4236
+ onClick: () => props.onRestore(row.entryId),
4237
+ children: copy.restore
4238
+ }),
4239
+ Button({
4240
+ variant: "ghost",
4241
+ size: "compact",
4242
+ disabled: busy,
4243
+ onClick: () => props.onPurge(row.entryId),
4244
+ children: copy.purge
4245
+ })
4246
+ ]
4148
4247
  )
4149
4248
  )
4150
4249
  );
@@ -4422,10 +4521,10 @@ function SessionPanel({ copy, io, close }) {
4422
4521
  (0, import_react7.createElement)("h3", { className: CLASS.blockTitle }, copy.binHeading),
4423
4522
  // Same rule as the inventory: 「回收站为空」 is a claim about an **answer**. While nothing has been read (or the
4424
4523
  // read failed) the status line above says what is happening (audit 2026-11-01b ui P1-3).
4425
- phase.kind !== "ready" ? null : snapshot.bin.length === 0 ? (0, import_react7.createElement)("p", { className: CLASS.empty }, copy.binEmpty) : (0, import_react7.createElement)(
4524
+ phase.kind !== "ready" ? null : snapshot.bin.length === 0 && snapshot.binUnreadable === 0 ? (0, import_react7.createElement)("p", { className: CLASS.empty }, copy.binEmpty) : (0, import_react7.createElement)(
4426
4525
  "div",
4427
4526
  { className: CLASS.block },
4428
- (0, import_react7.createElement)(
4527
+ snapshot.bin.length === 0 ? null : (0, import_react7.createElement)(
4429
4528
  "ul",
4430
4529
  { className: CLASS.list },
4431
4530
  ...snapshot.bin.map(
@@ -4438,6 +4537,7 @@ function SessionPanel({ copy, io, close }) {
4438
4537
  })
4439
4538
  )
4440
4539
  ),
4540
+ snapshot.binUnreadable > 0 ? (0, import_react7.createElement)("p", { className: CLASS.empty }, copy.binUnreadable(snapshot.binUnreadable)) : null,
4441
4541
  Button({
4442
4542
  variant: "secondary",
4443
4543
  size: "compact",
@@ -4526,6 +4626,7 @@ function createSessionRowActionStore(deps) {
4526
4626
  };
4527
4627
  });
4528
4628
  let cancelAutoClose;
4629
+ let postInFlight = false;
4529
4630
  const stopAutoClose = () => {
4530
4631
  cancelAutoClose?.();
4531
4632
  cancelAutoClose = void 0;
@@ -4550,18 +4651,21 @@ function createSessionRowActionStore(deps) {
4550
4651
  const confirm = async () => {
4551
4652
  const request = state.request;
4552
4653
  const row = state.row;
4553
- if (disposed || request === void 0 || state.busy) return;
4654
+ if (disposed || request === void 0 || state.busy || postInFlight) return;
4554
4655
  if (state.phase !== "ready" || row === void 0 || !row.deletable) return;
4555
4656
  const stillPending = () => {
4556
4657
  const current = state.request;
4557
4658
  return current !== void 0 && current.kind === request.kind && current.sessionId === request.sessionId;
4558
4659
  };
4660
+ postInFlight = true;
4559
4661
  dispatch({ ...state, busy: true, outcome: void 0 });
4560
4662
  let result;
4561
4663
  try {
4562
4664
  result = await deleteSessions(deps.io, [request.sessionId], request.kind === "delete" ? { permanent: true } : {});
4563
4665
  } catch {
4564
4666
  result = { kind: "error", message: { key: "sessionResultActionFailed" } };
4667
+ } finally {
4668
+ postInFlight = false;
4565
4669
  }
4566
4670
  if (disposed || !stillPending()) return;
4567
4671
  dispatch({ ...state, busy: false, outcome: result });
@@ -4584,7 +4688,7 @@ function createSessionRowActionStore(deps) {
4584
4688
  request: (request) => {
4585
4689
  if (disposed) return;
4586
4690
  stopAutoClose();
4587
- dispatch({ request, phase: "loading", row: void 0, busy: false, outcome: void 0 });
4691
+ dispatch({ request, phase: "loading", row: void 0, busy: postInFlight, outcome: void 0 });
4588
4692
  },
4589
4693
  load,
4590
4694
  confirm,
@@ -5281,7 +5385,7 @@ function DirectoryBrowserSection({
5281
5385
  listRef
5282
5386
  }) {
5283
5387
  const view = selectDirectoryBrowserView(state);
5284
- const gate = disabled;
5388
+ const gate = disabled || state.pending;
5285
5389
  const connectionFieldId = `${idPrefix}-connection`;
5286
5390
  const modeButton = (mode, label) => Button({
5287
5391
  // The active world is the brand-filled one: the switch reads without an extra attribute, and both
@@ -5503,12 +5607,18 @@ function createSessionDirectoryIo(options) {
5503
5607
  });
5504
5608
  const rootsApi = createRootsApi(options.fetchImpl, path);
5505
5609
  const loadRoots = async (workspacePath, sessionId) => {
5506
- const result = await rootsApi.load();
5610
+ const result = await rootsApi.load({
5611
+ ...sessionId === void 0 || sessionId === "" ? {} : { session: sessionId },
5612
+ ...workspacePath === void 0 || workspacePath === "" ? {} : { workspace: workspacePath }
5613
+ });
5507
5614
  if (!result.ok) return result;
5508
5615
  if (result.value.warnings.includes("roots-invalid-answer")) {
5509
5616
  return { ok: false, error: "roots-invalid-answer" };
5510
5617
  }
5511
5618
  const all = result.value.roots;
5619
+ if (sessionId !== void 0 && sessionId !== "" && result.value.scopedForSession === sessionId) {
5620
+ return { ok: true, value: all };
5621
+ }
5512
5622
  if (workspacePath === void 0 || workspacePath === "") return { ok: true, value: [] };
5513
5623
  return {
5514
5624
  ok: true,
@@ -5651,7 +5761,11 @@ function selectSessionDirectoryView(state, copy) {
5651
5761
  title: row.label ?? (row.location === "remote" ? `${row.connectionId ?? ""}:${row.path}` : row.path),
5652
5762
  subtitle: row.label === void 0 ? "" : row.path,
5653
5763
  badge: row.location === "remote" ? copy["rootsBadgeRemote"] : copy["rootsBadgeLocal"],
5654
- removable: row.inUserFile
5764
+ // fork 继承来的行没有删除按钮:它在父亲那里才是"本会话添加的",在这里点删除会把父会话的
5765
+ // 目录拿走(宿主也会拒绝)。没有 sessions 字段的历史条目按文件权限判定,保持旧行为。
5766
+ removable: row.inUserFile && // 会话型条目只有**本会话**能删;`state.sessionId` 还没到也不能放行(那会把父会话的目录
5767
+ // 交给 fork 去删,宿主会拒,用户看到的是一次"按了没反应")。历史条目(无 sessions)按文件权限。
5768
+ (row.sessions === void 0 || state.sessionId !== void 0 && row.sessions.includes(state.sessionId))
5655
5769
  }))
5656
5770
  ],
5657
5771
  rootsPending: state.rootsPending,
@@ -5706,11 +5820,14 @@ function createSessionDirectory(deps) {
5706
5820
  const commit = async (path, connectionId) => {
5707
5821
  if (disposed || state.pending) return;
5708
5822
  const location = connectionId === void 0 ? "local" : "remote";
5709
- const validated = validateRootDraft({
5710
- location,
5711
- path,
5712
- ...connectionId === void 0 ? {} : { connectionId }
5713
- });
5823
+ const validated = validateRootDraft(
5824
+ {
5825
+ location,
5826
+ path,
5827
+ ...connectionId === void 0 ? {} : { connectionId }
5828
+ },
5829
+ state.roots
5830
+ );
5714
5831
  if (!validated.ok) {
5715
5832
  dispatch({ type: "refused", message: validated.message });
5716
5833
  return;
@@ -5819,7 +5936,10 @@ function SessionDirectorySection({
5819
5936
  { className: CLASS.block },
5820
5937
  (0, import_react11.createElement)("h4", { className: CLASS.blockTitle }, copy.sessionRootsTitle),
5821
5938
  (0, import_react11.createElement)("p", { className: CLASS.hint }, copy.sessionRootsHint),
5822
- view.roots.filter((row) => row.workspace !== true).length === 0 ? view.rootsReading ? Notice({ role: "status", tone: "info", children: copy.sessionRootsLoading }) : (
5939
+ // **有行就画列表**:工作区那一行是合成行,它不属于"已添加目录",但它是这张卡片最重要的一行。
5940
+ // 旧条件按"有没有已添加目录"决定画不画整张列表,结果是新会话只看到「还没有添加目录」,连会话
5941
+ // 自己在哪个工作区都不显示,必须先去添加一个目录才冒出来(审计 2026-11-06,用户截图)。
5942
+ view.roots.length === 0 ? view.rootsReading ? Notice({ role: "status", tone: "info", children: copy.sessionRootsLoading }) : (
5823
5943
  // A failed read is not an empty list: the error notice below is the whole truth there, and
5824
5944
  // 「还没有添加目录」 next to it would contradict it (same rule as the import dialog, ui P2-2/P2-3).
5825
5945
  view.notice === "" ? (0, import_react11.createElement)("p", { className: CLASS.empty }, copy.sessionRootsEmpty) : null
@@ -5839,7 +5959,9 @@ function SessionDirectorySection({
5839
5959
  Button({
5840
5960
  variant: "ghost",
5841
5961
  size: "compact",
5842
- disabled: view.rootsPending,
5962
+ // 刷新在飞时也禁用:否则一次点击会叠加"删除 + 一次刷新"两次远端写
5963
+ // (审计 2026-11-06 客户端 P2)。
5964
+ disabled: view.rootsPending || view.rootsReading,
5843
5965
  onClick: () => actions.onRemoveRoot(row.key),
5844
5966
  children: copy.rootsRemove
5845
5967
  })
@@ -5847,6 +5969,8 @@ function SessionDirectorySection({
5847
5969
  )
5848
5970
  )
5849
5971
  ),
5972
+ // 列表里只剩工作区行时,"还没有添加目录"仍然是有用的下一步提示;但它不再替换列表。
5973
+ 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,
5850
5974
  (0, import_react11.createElement)(
5851
5975
  "div",
5852
5976
  { className: CLASS.actions },
@@ -5886,20 +6010,22 @@ function readWorkspacePath(props) {
5886
6010
  const fromWorkspace = workspaces === void 0 ? void 0 : workspaces(
5887
6011
  (snapshot) => sessionId === void 0 || sessionId === "" ? void 0 : snapshot?.items?.find((item) => item.sessionIds?.includes(sessionId) === true)?.path
5888
6012
  );
5889
- if (typeof fromSession === "string" && fromSession !== "") return fromSession;
5890
6013
  if (typeof fromWorkspace === "string" && fromWorkspace !== "") return fromWorkspace;
6014
+ if (typeof fromSession === "string" && fromSession !== "") return fromSession;
5891
6015
  return void 0;
5892
6016
  }
5893
6017
  function rootsQueryKey(state) {
5894
6018
  if (!state.open) return "closed";
5895
- if (state.workspacePath === void 0 || state.workspacePath === "") return "open:";
5896
- return `open:${state.workspacePath}\0${state.sessionId ?? ""}`;
6019
+ const workspace = state.workspacePath === void 0 || state.workspacePath === "" ? "unknown" : state.workspacePath;
6020
+ const session = state.sessionId === void 0 || state.sessionId === "" ? "unknown" : state.sessionId;
6021
+ return `open:${workspace}\0${session}`;
5897
6022
  }
5898
6023
  function SessionDirectoryContainer(props) {
5899
6024
  const { copy, io } = props;
5900
6025
  const copyRef = (0, import_react11.useRef)(copy);
5901
6026
  copyRef.current = copy;
5902
- const [controller] = (0, import_react11.useState)(() => createSessionDirectory({ io, copy: () => copyRef.current }));
6027
+ const injected = props.controller;
6028
+ const [controller] = (0, import_react11.useState)(() => injected ?? createSessionDirectory({ io, copy: () => copyRef.current }));
5903
6029
  const state = (0, import_react11.useSyncExternalStore)(controller.subscribe, controller.getState);
5904
6030
  const workspacePath = readWorkspacePath(props);
5905
6031
  const titleId = (0, import_react11.useId)();
@@ -5924,8 +6050,10 @@ function SessionDirectoryContainer(props) {
5924
6050
  useCardTabTrap(state.open, portal.container);
5925
6051
  (0, import_react11.useEffect)(() => {
5926
6052
  controller.activate();
5927
- return () => controller.dispose();
5928
- }, [controller]);
6053
+ return () => {
6054
+ if (injected === void 0) controller.dispose();
6055
+ };
6056
+ }, [controller, injected]);
5929
6057
  (0, import_react11.useLayoutEffect)(() => {
5930
6058
  if (!state.open) return;
5931
6059
  focusOrigin.current = captureFocusOrigin(browserPortalDocument());
@@ -6565,6 +6693,8 @@ function apply(ctx) {
6565
6693
  sessionsHeading: dict.sessionPanelSessions,
6566
6694
  binHeading: dict.sessionPanelBin,
6567
6695
  binEmpty: dict.sessionPanelBinEmpty,
6696
+ binUnreadable: (count) => resolveMessage2(dict, { key: "sessionPanelBinUnreadable", params: { count } }),
6697
+ binRowUnaddressable: dict.sessionPanelBinRowUnaddressable,
6568
6698
  selectAll: dict.sessionPanelSelectAll,
6569
6699
  clearSelection: dict.sessionPanelClear,
6570
6700
  deleteRecycle: dict.sessionPanelDeleteRecycle,
@@ -6889,6 +7019,7 @@ function apply(ctx) {
6889
7019
  return await chooser.pickDirectory();
6890
7020
  }
6891
7021
  });
7022
+ const controller = createSessionDirectory({ io, copy: () => copy() });
6892
7023
  const registerSessionEntry = () => ctx.slots.inject(
6893
7024
  SESSION_DIRECTORY_SLOT,
6894
7025
  () => ctx.slots.register(
@@ -6896,7 +7027,7 @@ function apply(ctx) {
6896
7027
  name: SESSION_DIRECTORY_SLOT,
6897
7028
  id: SESSION_DIRECTORY_ID,
6898
7029
  order: SESSION_DIRECTORY_ORDER,
6899
- inject: () => ({ copy: copy(), io })
7030
+ inject: () => ({ copy: copy(), io, controller })
6900
7031
  },
6901
7032
  SessionDirectoryContainer
6902
7033
  )
@@ -6909,6 +7040,7 @@ function apply(ctx) {
6909
7040
  return () => {
6910
7041
  offLocaleChange();
6911
7042
  disposeEntry();
7043
+ controller.dispose();
6912
7044
  };
6913
7045
  });
6914
7046
  ctx.effect(() => {