dsh-over-ssh 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -29,6 +29,25 @@ __export(index_exports, {
29
29
  module.exports = __toCommonJS(index_exports);
30
30
  var import_cordis = require("@deepseek-ai/cordis");
31
31
 
32
+ // src/client/core/abort.ts
33
+ function createAbortGate() {
34
+ let active;
35
+ return {
36
+ begin: () => {
37
+ active?.abort();
38
+ active = new AbortController();
39
+ return active.signal;
40
+ },
41
+ abort: () => {
42
+ active?.abort();
43
+ active = void 0;
44
+ },
45
+ get live() {
46
+ return active !== void 0 && !active.signal.aborted;
47
+ }
48
+ };
49
+ }
50
+
32
51
  // src/client/core/locale.ts
33
52
  var OVER_SSH_NS = "over-ssh";
34
53
  var OVER_SSH_DICTS = {
@@ -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) {
@@ -1945,6 +1983,10 @@ function withoutError(state) {
1945
1983
  const { error: _error, ...rest } = state;
1946
1984
  return rest;
1947
1985
  }
1986
+ function withoutAdding(state) {
1987
+ const { addingConnection: _adding, ...rest } = state;
1988
+ return rest;
1989
+ }
1948
1990
  function withoutEditing(state) {
1949
1991
  const { editingId: _editing, ...rest } = state;
1950
1992
  return rest;
@@ -1983,7 +2025,7 @@ function rootsPanelReducer(state, action) {
1983
2025
  case "succeeded": {
1984
2026
  const saved = action.scope === void 0 ? void 0 : SAVED_MESSAGE[action.scope];
1985
2027
  return {
1986
- ...withoutError(withoutFormNotice(withoutEditing(withoutConfirming(state)))),
2028
+ ...withoutError(withoutFormNotice(withoutAdding(withoutEditing(withoutConfirming(state))))),
1987
2029
  pending: false,
1988
2030
  loaded: true,
1989
2031
  // The edit dialog is closed by `withoutEditing` whether the request added or removed something, so its
@@ -2002,11 +2044,16 @@ function rootsPanelReducer(state, action) {
2002
2044
  if (!canEdit(state)) return state;
2003
2045
  return { ...withoutFormNotice(state), pending: true };
2004
2046
  }
2047
+ case "add-connection-opened":
2048
+ return { ...withoutEditing(withoutFormNotice(state)), addingConnection: true };
2049
+ case "add-connection-closed":
2050
+ if (state.addingConnection !== true) return state;
2051
+ return withoutAdding(withoutFormNotice(state));
2005
2052
  case "edit-started": {
2006
2053
  const row = state.snapshot?.connections.find((candidate) => candidate.id === action.id);
2007
2054
  if (row === void 0) return state;
2008
2055
  return {
2009
- ...withoutFormNotice(state),
2056
+ ...withoutFormNotice(withoutAdding(state)),
2010
2057
  editingId: row.id,
2011
2058
  editDrafts: {
2012
2059
  id: row.id,
@@ -2529,6 +2576,7 @@ function createRootsPanelController(options) {
2529
2576
  const listeners = /* @__PURE__ */ new Set();
2530
2577
  let token = 0;
2531
2578
  let disposed = false;
2579
+ const requests = createAbortGate();
2532
2580
  const dispatch = (action) => {
2533
2581
  const next = rootsPanelReducer(state, action);
2534
2582
  if (next === state) return;
@@ -2538,8 +2586,9 @@ function createRootsPanelController(options) {
2538
2586
  const latest = (mine) => !disposed && mine === token;
2539
2587
  const reload = async () => {
2540
2588
  const mine = ++token;
2589
+ const signal = requests.begin();
2541
2590
  dispatch({ type: "load" });
2542
- const result = await options.api.load();
2591
+ const result = await options.api.load(signal);
2543
2592
  if (!latest(mine)) return;
2544
2593
  if (result.ok) dispatch({ type: "loaded", snapshot: result.value });
2545
2594
  else dispatch({ type: "failed", error: result.error });
@@ -2569,6 +2618,7 @@ function createRootsPanelController(options) {
2569
2618
  };
2570
2619
  const submitConnection = async () => {
2571
2620
  if (state.editingId !== void 0) return;
2621
+ if (state.addingConnection !== true) return;
2572
2622
  if (!canEdit(state)) return;
2573
2623
  const { drafts } = state;
2574
2624
  const portText = drafts.newPort.trim();
@@ -2607,6 +2657,12 @@ function createRootsPanelController(options) {
2607
2657
  const cancelEdit = () => {
2608
2658
  dispatch({ type: "edit-ended" });
2609
2659
  };
2660
+ const openAddConnection = () => {
2661
+ dispatch({ type: "add-connection-opened" });
2662
+ };
2663
+ const closeAddConnection = () => {
2664
+ dispatch({ type: "add-connection-closed" });
2665
+ };
2610
2666
  const defer = options.defer ?? ((run) => {
2611
2667
  const handle = setTimeout(run, 0);
2612
2668
  return () => clearTimeout(handle);
@@ -2719,6 +2775,8 @@ function createRootsPanelController(options) {
2719
2775
  changeEditDraft: (patch) => dispatch({ type: "edit-draft-changed", patch }),
2720
2776
  submitRoot,
2721
2777
  submitConnection,
2778
+ openAddConnection,
2779
+ closeAddConnection,
2722
2780
  remove,
2723
2781
  startEdit,
2724
2782
  cancelEdit,
@@ -2737,6 +2795,7 @@ function createRootsPanelController(options) {
2737
2795
  submitImport,
2738
2796
  dispose: () => {
2739
2797
  disposed = true;
2798
+ requests.abort();
2740
2799
  listeners.clear();
2741
2800
  surfaceAttached();
2742
2801
  }
@@ -2748,6 +2807,8 @@ function rootsPanelSectionActions(controller) {
2748
2807
  onEditDraftChange: controller.changeEditDraft,
2749
2808
  onSubmitRoot: (location) => void controller.submitRoot(location),
2750
2809
  onSubmitConnection: () => void controller.submitConnection(),
2810
+ onOpenAddConnection: controller.openAddConnection,
2811
+ onCloseAddConnection: controller.closeAddConnection,
2751
2812
  onRemove: (key) => void controller.remove(key),
2752
2813
  onSubmitEdit: () => void controller.submitEdit(),
2753
2814
  onCancelEdit: controller.cancelEdit,
@@ -2760,9 +2821,43 @@ function rootsPanelSectionActions(controller) {
2760
2821
  onImportSubmit: () => void controller.submitImport()
2761
2822
  };
2762
2823
  }
2763
- function fieldValue(event) {
2764
- const target = event.target;
2765
- return typeof target?.value === "string" ? target.value : "";
2824
+ function selectRootsViewForRender(state) {
2825
+ const cached = rootsViewCache;
2826
+ if (cached !== void 0 && cached.snapshot === state.snapshot && cached.error === state.error && cached.pending === state.pending) {
2827
+ return cached.view;
2828
+ }
2829
+ const view = selectRootsView(state);
2830
+ rootsViewCache = { snapshot: state.snapshot, error: state.error, pending: state.pending, view };
2831
+ return view;
2832
+ }
2833
+ var rootsViewCache;
2834
+ var rootsRowElementsCache = /* @__PURE__ */ new WeakMap();
2835
+ function rootsPanelRowElements(view, copy, editable, onRemove) {
2836
+ const cached = rootsRowElementsCache.get(view);
2837
+ if (cached !== void 0 && cached.copy === copy && cached.editable === editable && cached.onRemove === onRemove) {
2838
+ return cached.elements;
2839
+ }
2840
+ const elements = view.rows.map((row) => renderRow(row, copy, editable, onRemove));
2841
+ rootsRowElementsCache.set(view, { copy, editable, onRemove, elements });
2842
+ return elements;
2843
+ }
2844
+ function openRootsDialog(portal, doc) {
2845
+ const origin = captureFocusOrigin(doc);
2846
+ portal.mount();
2847
+ portal.focusCard();
2848
+ return () => {
2849
+ portal.unmount();
2850
+ restoreFocusOrigin(origin);
2851
+ };
2852
+ }
2853
+ function installRootsDialogEscape(doc, close) {
2854
+ if (doc?.addEventListener === void 0) return () => void 0;
2855
+ const onKeyDown = (event) => {
2856
+ if (event.key !== "Escape") return;
2857
+ close();
2858
+ };
2859
+ doc.addEventListener("keydown", onKeyDown);
2860
+ return () => doc.removeEventListener?.("keydown", onKeyDown);
2766
2861
  }
2767
2862
  function renderFormNotice(copy, state, scope) {
2768
2863
  const notice = state.formNotice;
@@ -2791,9 +2886,15 @@ function renderRow(row, copy, editable, onRemove) {
2791
2886
  ) : null
2792
2887
  );
2793
2888
  }
2794
- function SshConfigImportDialog({ copy, state, actions, idPrefix = DEFAULT_ROOTS_ID_PREFIX }) {
2889
+ function SshConfigImportDialog({
2890
+ copy,
2891
+ state,
2892
+ actions,
2893
+ idPrefix = DEFAULT_ROOTS_ID_PREFIX,
2894
+ editable: editableOverride
2895
+ }) {
2795
2896
  if (state.importOpen !== true) return null;
2796
- const editable = canEdit(state);
2897
+ const editable = editableOverride ?? canEdit(state);
2797
2898
  const listing = state.importListing;
2798
2899
  const selected = new Set(state.importSelected ?? []);
2799
2900
  const busy = state.importBusy === true;
@@ -2805,10 +2906,12 @@ function SshConfigImportDialog({ copy, state, actions, idPrefix = DEFAULT_ROOTS_
2805
2906
  "section",
2806
2907
  {
2807
2908
  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).
2909
+ // A named **region**, not a dialog. It is rendered inline inside the settings page's own scroll flow (it is
2910
+ // not portalled), the page behind it stays interactive, and it has no Tab trap — a `role="dialog"` would
2911
+ // promise modal behaviour the surface does not implement, which is worse than not claiming it. The other
2912
+ // two forms really are portalled modals (`aria-modal`, focus trap), so the two roles stay honest
2913
+ // (audit 2026-11-01b ui P2-2; 2026-11-05 P2-3).
2914
+ role: "region",
2812
2915
  "aria-labelledby": `${idPrefix}-import-title`,
2813
2916
  // Esc closes it from anywhere inside, like the other five dialogs. It is inline, so this handler is the
2814
2917
  // only one that will see the key while the settings page scrolls the caret inside it.
@@ -2931,10 +3034,154 @@ function SshConfigImportDialog({ copy, state, actions, idPrefix = DEFAULT_ROOTS_
2931
3034
  )
2932
3035
  );
2933
3036
  }
2934
- function ConnectionEditForm({ copy, state, actions, idPrefix = DEFAULT_ROOTS_ID_PREFIX }) {
3037
+ function ConnectionAddForm({
3038
+ copy,
3039
+ state,
3040
+ actions,
3041
+ idPrefix = DEFAULT_ROOTS_ID_PREFIX,
3042
+ editable: editableOverride
3043
+ }) {
3044
+ if (state.addingConnection !== true) return null;
3045
+ const editable = editableOverride ?? canEdit(state);
3046
+ const drafts = state.drafts;
3047
+ return (0, import_react5.createElement)(
3048
+ "form",
3049
+ {
3050
+ onSubmit: (event) => {
3051
+ event.preventDefault();
3052
+ actions.onSubmitConnection();
3053
+ },
3054
+ className: CLASS.dialog,
3055
+ role: "dialog",
3056
+ "aria-modal": "true",
3057
+ "aria-label": copy.rootsAddConnection,
3058
+ onKeyDown: (event) => {
3059
+ if (!isEscape(event)) return;
3060
+ event.stopPropagation?.();
3061
+ actions.onCloseAddConnection();
3062
+ }
3063
+ },
3064
+ (0, import_react5.createElement)(
3065
+ "div",
3066
+ { className: CLASS.dialogHeader },
3067
+ (0, import_react5.createElement)("h3", { className: CLASS.sectionTitle }, copy.rootsAddConnection)
3068
+ ),
3069
+ (0, import_react5.createElement)(
3070
+ "div",
3071
+ { className: CLASS.dialogBody },
3072
+ (0, import_react5.createElement)(
3073
+ "div",
3074
+ { className: CLASS.form },
3075
+ Field({
3076
+ htmlFor: `${idPrefix}-connection-id`,
3077
+ label: copy.rootsIdLabel,
3078
+ children: TextInput({
3079
+ id: `${idPrefix}-connection-id`,
3080
+ name: "roots-connection-id",
3081
+ value: drafts.newId,
3082
+ disabled: !editable,
3083
+ onChange: (event) => actions.onDraftChange({ newId: fieldValue(event) })
3084
+ })
3085
+ }),
3086
+ Field({
3087
+ htmlFor: `${idPrefix}-connection-host`,
3088
+ label: copy.rootsHostLabel,
3089
+ children: TextInput({
3090
+ id: `${idPrefix}-connection-host`,
3091
+ name: "roots-connection-host",
3092
+ value: drafts.newHost,
3093
+ disabled: !editable,
3094
+ onChange: (event) => actions.onDraftChange({ newHost: fieldValue(event) })
3095
+ })
3096
+ }),
3097
+ Field({
3098
+ htmlFor: `${idPrefix}-connection-user`,
3099
+ label: copy.rootsUserLabel,
3100
+ children: TextInput({
3101
+ id: `${idPrefix}-connection-user`,
3102
+ name: "roots-connection-user",
3103
+ value: drafts.newUser,
3104
+ disabled: !editable,
3105
+ onChange: (event) => actions.onDraftChange({ newUser: fieldValue(event) })
3106
+ })
3107
+ }),
3108
+ Field({
3109
+ htmlFor: `${idPrefix}-connection-auth`,
3110
+ label: copy.rootsAuthLabel,
3111
+ children: Select({
3112
+ id: `${idPrefix}-connection-auth`,
3113
+ name: "roots-connection-auth",
3114
+ value: drafts.newAuthKind,
3115
+ disabled: !editable,
3116
+ onChange: (value) => actions.onDraftChange({ newAuthKind: value === "password" ? "password" : "key" }),
3117
+ options: [
3118
+ { value: "key", label: copy.rootsAuthKey },
3119
+ { value: "password", label: copy.rootsAuthPassword }
3120
+ ]
3121
+ })
3122
+ }),
3123
+ // Only meaningful for key authentication; shown on demand rather than as a dead field.
3124
+ drafts.newAuthKind === "key" ? Field({
3125
+ htmlFor: `${idPrefix}-connection-key-path`,
3126
+ label: copy.rootsKeyPathLabel,
3127
+ children: TextInput({
3128
+ id: `${idPrefix}-connection-key-path`,
3129
+ name: "roots-connection-key-path",
3130
+ value: drafts.newKeyPath,
3131
+ disabled: !editable,
3132
+ onChange: (event) => actions.onDraftChange({ newKeyPath: fieldValue(event) })
3133
+ })
3134
+ }) : null,
3135
+ // Only meaningful for password authentication. The value never comes back from the host, so this field is
3136
+ // always empty on render: the panel can say a password is stored, never show one.
3137
+ drafts.newAuthKind === "password" ? Field({
3138
+ htmlFor: `${idPrefix}-connection-password`,
3139
+ label: copy.rootsPasswordLabel,
3140
+ children: TextInput({
3141
+ id: `${idPrefix}-connection-password`,
3142
+ name: "roots-connection-password",
3143
+ type: "password",
3144
+ autoComplete: "new-password",
3145
+ value: drafts.newPassword,
3146
+ disabled: !editable,
3147
+ placeholder: copy.rootsPasswordLabel,
3148
+ onChange: (event) => actions.onDraftChange({ newPassword: fieldValue(event) })
3149
+ })
3150
+ }) : null,
3151
+ Field({
3152
+ htmlFor: `${idPrefix}-connection-port`,
3153
+ label: copy.rootsPortLabel,
3154
+ children: TextInput({
3155
+ id: `${idPrefix}-connection-port`,
3156
+ name: "roots-connection-port",
3157
+ value: drafts.newPort,
3158
+ disabled: !editable,
3159
+ inputMode: "numeric",
3160
+ size: "compact",
3161
+ onChange: (event) => actions.onDraftChange({ newPort: fieldValue(event) })
3162
+ })
3163
+ })
3164
+ ),
3165
+ renderFormNotice(copy, state, "connection")
3166
+ ),
3167
+ (0, import_react5.createElement)(
3168
+ "div",
3169
+ { className: CLASS.actions },
3170
+ Button({ variant: "primary", type: "submit", disabled: !editable, children: copy.rootsSave }),
3171
+ Button({ variant: "secondary", type: "button", disabled: !editable, onClick: actions.onCloseAddConnection, children: copy.rootsEditCancel })
3172
+ )
3173
+ );
3174
+ }
3175
+ function ConnectionEditForm({
3176
+ copy,
3177
+ state,
3178
+ actions,
3179
+ idPrefix = DEFAULT_ROOTS_ID_PREFIX,
3180
+ editable: editableOverride
3181
+ }) {
2935
3182
  const editingId = state.editingId;
2936
3183
  if (editingId === void 0) return null;
2937
- const editable = canEdit(state);
3184
+ const editable = editableOverride ?? canEdit(state);
2938
3185
  const drafts = state.editDrafts;
2939
3186
  const renamed = drafts.id.trim() !== editingId;
2940
3187
  return (0, import_react5.createElement)(
@@ -3098,12 +3345,16 @@ function RootsPanelSection({
3098
3345
  actions,
3099
3346
  transfers,
3100
3347
  portal,
3101
- idPrefix = DEFAULT_ROOTS_ID_PREFIX
3348
+ idPrefix = DEFAULT_ROOTS_ID_PREFIX,
3349
+ view: precomputedView,
3350
+ rows: precomputedRows,
3351
+ editable: editableOverride
3102
3352
  }) {
3103
- const view = selectRootsView(state);
3104
- const editForm = ConnectionEditForm({ copy, state, actions, idPrefix });
3105
- const editable = view.canEdit;
3106
- const { drafts } = state;
3353
+ const view = precomputedView ?? selectRootsViewForRender(state);
3354
+ const editable = editableOverride ?? view.canEdit;
3355
+ const editForm = ConnectionEditForm({ copy, state, actions, idPrefix, editable });
3356
+ const addForm = ConnectionAddForm({ copy, state, actions, idPrefix, editable });
3357
+ const rowElements = precomputedRows ?? rootsPanelRowElements(view, copy, editable, actions.onRemove);
3107
3358
  const notice = resolveMessage2(copy, view.notice);
3108
3359
  return (0, import_react5.createElement)(
3109
3360
  "section",
@@ -3119,113 +3370,15 @@ function RootsPanelSection({
3119
3370
  // collapsed diagnostic block at the bottom of this section (see below): it is still the only place a
3120
3371
  // root can be removed from, so it is demoted, never dropped.
3121
3372
  // -- add a connection ----------------------------------------------------
3373
+ // 2026-11-05(用户反馈):原先是常驻的页内表单,六个字段把页面占满;现在是一个按钮,点开才出现对话框,
3374
+ // 与「编辑」同一种卡片形态(同样的 `CLASS.dialog`、Esc 关闭、字段布局)。
3122
3375
  (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
- )
3376
+ "div",
3377
+ { className: CLASS.block },
3378
+ Button({ variant: "primary", disabled: !editable, onClick: () => actions.onOpenAddConnection(), children: copy.rootsAddConnection })
3226
3379
  ),
3380
+ // 成功行留在页面上:保存成功后对话框会关闭,提示必须仍在用户眼前。
3227
3381
  renderFormSuccess(copy, state, "connection"),
3228
- renderFormNotice(copy, state, "connection"),
3229
3382
  // -- import from ~/.ssh/config (FR-2.1.4) --------------------------------
3230
3383
  // The button sits next to the add form because it is an alternative way to *create* a connection, and it
3231
3384
  // is disabled for the same reason every other control is (`canEdit`).
@@ -3234,7 +3387,7 @@ function RootsPanelSection({
3234
3387
  { className: CLASS.block },
3235
3388
  Button({ variant: "secondary", disabled: !editable, onClick: () => actions.onImportOpen(), children: copy.rootsImportOpen })
3236
3389
  ),
3237
- SshConfigImportDialog({ copy, state, actions, idPrefix }),
3390
+ SshConfigImportDialog({ copy, state, actions, idPrefix, editable }),
3238
3391
  // -- no file tree, no entry point (audit 0085) ---------------------------
3239
3392
  // Settings used to carry a sentence pointing at the conversation. It was a signpost for a capability
3240
3393
  // that deliberately has no UI; the decision still stands (remote files are the agent's), but the
@@ -3243,6 +3396,7 @@ function RootsPanelSection({
3243
3396
  transfers ?? null,
3244
3397
  // -- edit one saved connection (a dialog, not a block in this page) -------
3245
3398
  editForm === null ? null : portal === void 0 ? editForm : portal.render(editForm, portal.container),
3399
+ addForm === null ? null : portal === void 0 ? addForm : portal.render(addForm, portal.container),
3246
3400
  // -- the advanced root table (diagnostic) --------------------------------
3247
3401
  // This block used to be the section's headline content, which made it read as "the remote workspaces
3248
3402
  // list". It is not one: each row is a *root route* - a remote directory plus the local anchor the
@@ -3262,7 +3416,7 @@ function RootsPanelSection({
3262
3416
  void 0,
3263
3417
  (0, import_react5.createElement)("summary", { className: CLASS.blockTitle }, copy.rootsAdvancedSummary),
3264
3418
  (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)))
3419
+ view.rows.length === 0 ? (0, import_react5.createElement)("p", { className: CLASS.empty }, copy.rootsEmpty) : (0, import_react5.createElement)("ul", { className: CLASS.list }, ...rowElements)
3266
3420
  )
3267
3421
  )
3268
3422
  );
@@ -3271,6 +3425,16 @@ function RootsPanelContainer({ copy, controller }) {
3271
3425
  const state = (0, import_react5.useSyncExternalStore)(controller.subscribe, controller.getState);
3272
3426
  const uid = (0, import_react5.useId)();
3273
3427
  const idPrefix = `dsh-rw-${uid.replace(/[^A-Za-z0-9_-]/g, "")}`;
3428
+ const view = (0, import_react5.useMemo)(
3429
+ () => selectRootsViewForRender(state),
3430
+ [state.snapshot, state.error, state.pending]
3431
+ );
3432
+ const editable = view.canEdit;
3433
+ const sectionActions = (0, import_react5.useMemo)(() => rootsPanelSectionActions(controller), [controller]);
3434
+ const rowElements = (0, import_react5.useMemo)(
3435
+ () => rootsPanelRowElements(view, copy, editable, sectionActions.onRemove),
3436
+ [view, copy, editable, sectionActions.onRemove]
3437
+ );
3274
3438
  const [portal] = (0, import_react5.useState)(
3275
3439
  () => createCardPortal({
3276
3440
  document: browserPortalDocument(),
@@ -3287,23 +3451,27 @@ function RootsPanelContainer({ copy, controller }) {
3287
3451
  controller.surfaceAttached();
3288
3452
  return controller.surfaceDetached;
3289
3453
  }, [controller]);
3290
- useCardTabTrap(state.editingId !== void 0, portal.container);
3454
+ const cardOpen = state.editingId !== void 0 || state.addingConnection === true;
3455
+ useCardTabTrap(cardOpen, portal.container);
3291
3456
  (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]);
3457
+ if (!cardOpen) return;
3458
+ return openRootsDialog(portal, browserPortalDocument());
3459
+ }, [portal, cardOpen]);
3460
+ const closeOpenDialog = state.editingId !== void 0 ? sectionActions.onCancelEdit : state.addingConnection === true ? sectionActions.onCloseAddConnection : void 0;
3461
+ (0, import_react5.useEffect)(() => {
3462
+ if (!cardOpen || closeOpenDialog === void 0) return;
3463
+ return installRootsDialogEscape(browserPortalDocument(), closeOpenDialog);
3464
+ }, [cardOpen, closeOpenDialog]);
3300
3465
  return RootsPanelSection({
3301
3466
  copy,
3302
3467
  state,
3303
3468
  idPrefix,
3469
+ view,
3470
+ rows: rowElements,
3471
+ editable,
3304
3472
  // No `actions`: the transfer panel owns its own polling and its own buttons (the prop that used to be passed
3305
3473
  // here was typed `unknown` and never read — audit 2026-10-31 client.md §2.4).
3306
- transfers: (0, import_react5.createElement)(TransferProgressSection, { copy, state }),
3474
+ transfers: (0, import_react5.createElement)(TransferProgressSection, { copy }),
3307
3475
  // The panel needs the connection list, which this section already read; it does not re-fetch it.
3308
3476
  actions: sectionActions,
3309
3477
  portal
@@ -3329,14 +3497,14 @@ function renderConnectionRow(row, actions) {
3329
3497
  "span",
3330
3498
  { className: CLASS.rowActions },
3331
3499
  row.test === void 0 || onTest === void 0 ? null : Button({
3332
- variant: "ghost",
3500
+ variant: "secondary",
3333
3501
  size: "compact",
3334
3502
  // Disabled while its own probe runs: one honest click, and the label says what is happening.
3335
3503
  disabled: row.test.status === "running" || disabled === true,
3336
3504
  onClick: () => onTest(row.id),
3337
3505
  children: row.test.label
3338
3506
  }),
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 }),
3507
+ row.actions === void 0 || onEdit === void 0 ? null : Button({ variant: "secondary", size: "compact", disabled: disabled === true, onClick: () => onEdit(row.id), children: row.actions.editLabel }),
3340
3508
  row.actions === void 0 || onRemove === void 0 ? null : Button({ variant: "ghost", size: "compact", disabled: disabled === true, onClick: () => onRemove(row.id), children: row.actions.removeLabel })
3341
3509
  )
3342
3510
  );
@@ -3448,22 +3616,18 @@ function buildPanelModel(copy, options = {}) {
3448
3616
  acceptLabel: copy.rootsConfirmRemoveAccept,
3449
3617
  // The count is the reason this confirmation exists: deleting a connection silently takes its
3450
3618
  // 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 })
3619
+ body: (row, remoteRoots) => resolveTemplate(copy.rootsConfirmRemoveBody, { id: row.id, count: remoteRoots })
3452
3620
  }
3453
3621
  }
3454
3622
  };
3455
3623
  }
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
3624
 
3463
- // src/client/core/remote.ts
3625
+ // src/shared/remote-identity.ts
3464
3626
  var SSH_DEV_REMOTE_NAMESPACE = "sshdev";
3465
- var SSH_DEV_REMOTE_METHOD = "connections";
3466
3627
  var SSH_DEV_REMOTE_SERVICE = "sshDevRemote";
3628
+
3629
+ // src/client/core/remote.ts
3630
+ var SSH_DEV_REMOTE_METHOD = "connections";
3467
3631
  var SSH_DEV_REMOTE_PACKAGE = "dsh-over-ssh";
3468
3632
  var SSH_DEV_CONNECTIONS_DESCRIPTOR = {
3469
3633
  id: `${SSH_DEV_REMOTE_PACKAGE}#${SSH_DEV_REMOTE_NAMESPACE}/${SSH_DEV_REMOTE_METHOD}`,
@@ -3517,6 +3681,7 @@ function createConnectionTester(options) {
3517
3681
  const listeners = /* @__PURE__ */ new Set();
3518
3682
  let state = /* @__PURE__ */ new Map();
3519
3683
  let disposed = false;
3684
+ const controllers = /* @__PURE__ */ new Map();
3520
3685
  const set = (id, next) => {
3521
3686
  if (disposed) return;
3522
3687
  const copy = new Map(state);
@@ -3548,15 +3713,21 @@ function createConnectionTester(options) {
3548
3713
  };
3549
3714
  const test = async (id) => {
3550
3715
  if (disposed) return;
3716
+ controllers.get(id)?.abort();
3717
+ const controller = new AbortController();
3718
+ controllers.set(id, controller);
3551
3719
  set(id, { status: "running" });
3552
- const signal = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(timeoutMs) : void 0;
3720
+ const timeoutSignal = typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(timeoutMs) : void 0;
3721
+ const signal = timeoutSignal === void 0 ? controller.signal : typeof AbortSignal.any === "function" ? AbortSignal.any([controller.signal, timeoutSignal]) : timeoutSignal;
3722
+ const current = () => !disposed && controllers.get(id) === controller;
3553
3723
  try {
3554
3724
  const response = await options.fetchImpl(path, {
3555
3725
  method: "POST",
3556
3726
  headers: { "content-type": "application/json" },
3557
3727
  body: JSON.stringify({ action: "test-connection", id }),
3558
- ...signal === void 0 ? {} : { signal }
3728
+ signal
3559
3729
  });
3730
+ if (!current()) return;
3560
3731
  if (!response.ok) {
3561
3732
  set(id, {
3562
3733
  status: "failed",
@@ -3567,6 +3738,7 @@ function createConnectionTester(options) {
3567
3738
  }
3568
3739
  set(id, parse(await response.json()));
3569
3740
  } catch (error) {
3741
+ if (!current()) return;
3570
3742
  if (isTimeout(error)) {
3571
3743
  set(id, {
3572
3744
  status: "failed",
@@ -3576,6 +3748,8 @@ function createConnectionTester(options) {
3576
3748
  return;
3577
3749
  }
3578
3750
  set(id, { status: "failed", code: "UNREACHABLE", message: error instanceof Error ? error.message : String(error) });
3751
+ } finally {
3752
+ if (controllers.get(id) === controller) controllers.delete(id);
3579
3753
  }
3580
3754
  };
3581
3755
  return {
@@ -3589,6 +3763,8 @@ function createConnectionTester(options) {
3589
3763
  },
3590
3764
  dispose: () => {
3591
3765
  disposed = true;
3766
+ for (const controller of controllers.values()) controller.abort();
3767
+ controllers.clear();
3592
3768
  listeners.clear();
3593
3769
  }
3594
3770
  };
@@ -3611,7 +3787,21 @@ function createLatestOnly() {
3611
3787
  };
3612
3788
  }
3613
3789
 
3790
+ // src/shared/path-fold.ts
3791
+ function foldPathKey(path) {
3792
+ return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
3793
+ }
3794
+
3614
3795
  // src/client/session/session-panel.ts
3796
+ function unblockOrphanSubsessions(rows) {
3797
+ const byId = new Map(rows.map((row) => [row.id, row]));
3798
+ return rows.map((row) => {
3799
+ if (row.classification !== "subsession" || row.blockedReason !== "subsession" || row.running) return row;
3800
+ const parent = row.parentSessionId === void 0 ? void 0 : byId.get(row.parentSessionId);
3801
+ if (parent !== void 0 && parent.classification !== "unreadable") return row;
3802
+ return { ...row, deletable: true, blockedReason: void 0 };
3803
+ });
3804
+ }
3615
3805
  function toSessionRowView(raw) {
3616
3806
  const id = raw["id"];
3617
3807
  if (typeof id !== "string" || id === "") return void 0;
@@ -3677,20 +3867,18 @@ function workspaceLabel(row) {
3677
3867
  return "";
3678
3868
  }
3679
3869
  function visibleSessions(rows) {
3680
- const present = new Set(rows.map((row) => row.id));
3870
+ const byId = new Map(rows.map((row) => [row.id, row]));
3681
3871
  return rows.filter((row) => {
3682
3872
  if (row.classification !== "subsession") return true;
3683
3873
  if (row.parentSessionId === void 0) return true;
3684
- return !present.has(row.parentSessionId);
3874
+ const parent = byId.get(row.parentSessionId);
3875
+ return parent === void 0 || parent.classification === "unreadable";
3685
3876
  });
3686
3877
  }
3687
- function hiddenSubsessionCount(rows) {
3688
- return rows.length - visibleSessions(rows).length;
3689
- }
3690
3878
  function groupByWorkspace(rows) {
3691
3879
  const groups = /* @__PURE__ */ new Map();
3692
3880
  for (const row of rows) {
3693
- const key = row.cwd === void 0 || row.cwd === "" ? "" : row.cwd;
3881
+ const key = row.cwd === void 0 || row.cwd === "" ? "" : foldPathKey(row.cwd);
3694
3882
  const existing = groups.get(key);
3695
3883
  if (existing === void 0) {
3696
3884
  groups.set(key, { label: workspaceLabel(row), cwd: row.cwd, sessions: [row] });
@@ -3728,26 +3916,31 @@ function parseSessionSnapshot(value) {
3728
3916
  if (body["ok"] !== true) return emptySnapshot();
3729
3917
  const available = body["available"] === true;
3730
3918
  const sessions = Array.isArray(body["sessions"]) ? body["sessions"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toSessionRowView(raw)].filter((row) => row !== void 0) : []) : [];
3919
+ const unblocked = unblockOrphanSubsessions(sessions);
3731
3920
  const bin = Array.isArray(body["bin"]) ? body["bin"].flatMap((raw) => typeof raw === "object" && raw !== null ? [toBinRowView(raw)].filter((row) => row !== void 0) : []) : [];
3732
3921
  const rawPolicy = typeof body["policy"] === "object" && body["policy"] !== null ? body["policy"] : {};
3733
3922
  const numberOr = (key) => typeof rawPolicy[key] === "number" && Number.isFinite(rawPolicy[key]) ? rawPolicy[key] : 0;
3734
3923
  return {
3735
3924
  available,
3736
- sessions,
3925
+ sessions: unblocked,
3737
3926
  bin,
3738
3927
  policy: { maxEntries: numberOr("maxEntries"), maxBytes: numberOr("maxBytes"), maxAgeMs: numberOr("maxAgeMs") },
3739
3928
  totals: {
3740
- sessions: sessions.length,
3741
- bytes: sessions.reduce((sum, row) => sum + row.bytes, 0),
3742
- subsessions: sessions.filter((row) => row.classification === "subsession").length,
3929
+ sessions: unblocked.length,
3930
+ bytes: unblocked.reduce((sum, row) => sum + row.bytes, 0),
3931
+ subsessions: unblocked.filter((row) => row.classification === "subsession").length,
3743
3932
  live: sessions.filter((row) => row.live).length,
3744
3933
  running: sessions.filter((row) => row.running).length
3745
3934
  }
3746
3935
  };
3747
3936
  }
3748
- async function readSessionPanel(io) {
3937
+ async function readSessionPanel(io, signal) {
3749
3938
  try {
3750
- const response = await io.fetch(SESSION_API_PATH, { method: "GET", headers: { accept: "application/json" } });
3939
+ const response = await io.fetch(SESSION_API_PATH, {
3940
+ method: "GET",
3941
+ headers: { accept: "application/json" },
3942
+ ...signal === void 0 ? {} : { signal }
3943
+ });
3751
3944
  if (!response.ok) return { ok: false, error: `http-${response.status}` };
3752
3945
  return { ok: true, snapshot: parseSessionSnapshot(await response.json()) };
3753
3946
  } catch (error) {
@@ -3779,10 +3972,16 @@ async function deleteSessions(io, ids, options = {}) {
3779
3972
  if (options.permanent === true) body["confirm"] = true;
3780
3973
  return postAction(io, body, (value) => {
3781
3974
  const recoverable = value["recoverable"] === true;
3975
+ const refused = Array.isArray(value["refusals"]) ? value["refusals"].length : 0;
3782
3976
  const size = formatBytes(Number(value["bytes"] ?? 0));
3783
- if (options.permanent === true) return { key: "sessionResultDeletedPermanent", params: { size } };
3977
+ if (refused > 0 && Number(value["bytes"] ?? 0) === 0) {
3978
+ return { key: "sessionResultDeleteNone" };
3979
+ }
3980
+ if (options.permanent === true) {
3981
+ return refused === 0 ? { key: "sessionResultDeletedPermanent", params: { size } } : { key: "sessionResultDeletedPermanentPartial", params: { size, refused } };
3982
+ }
3784
3983
  if (!recoverable) return { key: "sessionResultDeleteNone" };
3785
- return { key: "sessionResultRecycled", params: { size } };
3984
+ return refused === 0 ? { key: "sessionResultRecycled", params: { size } } : { key: "sessionResultRecycledPartial", params: { size, refused } };
3786
3985
  });
3787
3986
  }
3788
3987
  async function deleteWorkspaceSessions(io, cwd, options = {}) {
@@ -3794,10 +3993,12 @@ async function deleteWorkspaceSessions(io, cwd, options = {}) {
3794
3993
  };
3795
3994
  if (options.permanent === true) body["confirm"] = true;
3796
3995
  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" };
3996
+ const matched = Number(value["matched"] ?? 0);
3800
3997
  const refused = Array.isArray(value["refusals"]) ? value["refusals"].length : 0;
3998
+ const count = Math.max(0, matched - refused);
3999
+ const size = formatBytes(Number(value["bytes"] ?? 0));
4000
+ if (matched === 0) return { key: "sessionResultWorkspaceEmpty" };
4001
+ if (count === 0) return { key: "sessionResultDeleteNone" };
3801
4002
  const params = refused === 0 ? { count, size } : { count, size, refused };
3802
4003
  if (options.permanent === true) {
3803
4004
  return { key: refused === 0 ? "sessionResultWorkspacePurged" : "sessionResultWorkspacePurgedRefused", params };
@@ -3813,10 +4014,14 @@ async function purgeSession(io, entryId) {
3813
4014
  if (entryId === "") return { kind: "error", message: { key: "sessionResultMissingEntryId" } };
3814
4015
  return postAction(io, { action: "purge", entryId, confirm: true }, () => ({ key: "sessionResultPurged" }));
3815
4016
  }
3816
- async function sweepSessions(io) {
3817
- return postAction(io, { action: "sweep" }, (value) => {
4017
+ async function sweepSessions(io, options = {}) {
4018
+ const body = { action: "sweep" };
4019
+ if (options.confirm === true) body["confirm"] = true;
4020
+ return postAction(io, body, (value) => {
3818
4021
  const purged = Array.isArray(value["purged"]) ? value["purged"].length : 0;
3819
- return purged === 0 ? { key: "sessionResultSweepNone" } : { key: "sessionResultSweepDone", params: { count: purged } };
4022
+ const failed = Array.isArray(value["failed"]) ? value["failed"].length : 0;
4023
+ if (purged === 0 && failed === 0) return { key: "sessionResultSweepNone" };
4024
+ return failed === 0 ? { key: "sessionResultSweepDone", params: { count: purged } } : { key: "sessionResultSweepPartial", params: { count: purged, failed } };
3820
4025
  });
3821
4026
  }
3822
4027
  async function postAction(io, body, describe) {
@@ -3834,7 +4039,16 @@ async function postAction(io, body, describe) {
3834
4039
  };
3835
4040
  }
3836
4041
  if (!response.ok) return { kind: "error", message: await errorMessage(response), status: response.status };
3837
- const value = await response.json();
4042
+ let value;
4043
+ try {
4044
+ const decoded = await response.json();
4045
+ if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
4046
+ return { kind: "error", message: { key: "sessionResultActionFailed" }, status: response.status };
4047
+ }
4048
+ value = decoded;
4049
+ } catch {
4050
+ return { kind: "error", message: { key: "sessionResultActionFailed" }, status: response.status };
4051
+ }
3838
4052
  if (value["ok"] !== true) {
3839
4053
  return {
3840
4054
  kind: "error",
@@ -3909,7 +4123,10 @@ function renderBinRow(props) {
3909
4123
  (0, import_react7.createElement)(
3910
4124
  "div",
3911
4125
  { className: CLASS.row },
3912
- (0, import_react7.createElement)("code", { className: CLASS.badge }, row.sessionId),
4126
+ // 回收站条目以前只画完整 uuid,用户没法判断该还原/清除哪一条;宿主本来就带 title 与删除时间。
4127
+ (0, import_react7.createElement)("code", { className: CLASS.badge }, shortId(row.sessionId)),
4128
+ row.title === void 0 || row.title === "" ? null : (0, import_react7.createElement)("span", { className: CLASS.rowTitle, title: `movedBy: ${row.movedBy}` }, row.title),
4129
+ (0, import_react7.createElement)("span", { className: CLASS.rowMeta }, formatStamp(row.deletedAtMs)),
3913
4130
  (0, import_react7.createElement)("span", { className: CLASS.rowMeta }, formatBytes(row.bytes)),
3914
4131
  (0, import_react7.createElement)(
3915
4132
  "span",
@@ -3993,54 +4210,84 @@ function SessionPanel({ copy, io, close }) {
3993
4210
  const [selected, setSelected] = (0, import_react7.useState)([]);
3994
4211
  const [outcome, setOutcome] = (0, import_react7.useState)(void 0);
3995
4212
  const [busy, setBusy] = (0, import_react7.useState)(false);
4213
+ const busyRef = (0, import_react7.useRef)(false);
4214
+ const markBusy = (0, import_react7.useCallback)((value) => {
4215
+ busyRef.current = value;
4216
+ setBusy(value);
4217
+ }, []);
3996
4218
  const [pending, setPending] = (0, import_react7.useState)(void 0);
3997
4219
  const snapshot = phase.kind === "ready" ? phase.snapshot : EMPTY_SNAPSHOT;
3998
4220
  const [reads] = (0, import_react7.useState)(() => createLatestOnly());
4221
+ const [actions] = (0, import_react7.useState)(() => createLatestOnly());
4222
+ const [requests] = (0, import_react7.useState)(() => createAbortGate());
3999
4223
  const alive = (0, import_react7.useRef)(true);
4000
4224
  (0, import_react7.useEffect)(() => {
4001
4225
  alive.current = true;
4002
4226
  return () => {
4003
4227
  alive.current = false;
4228
+ requests.abort();
4004
4229
  };
4005
- }, []);
4230
+ }, [requests]);
4006
4231
  const refresh = (0, import_react7.useCallback)(async () => {
4232
+ if (busyRef.current) return;
4007
4233
  const mine = reads.begin();
4008
- setBusy(true);
4234
+ const signal = requests.begin();
4235
+ markBusy(true);
4009
4236
  try {
4010
- const result = await readSessionPanel(io);
4237
+ const result = await readSessionPanel(io, signal);
4011
4238
  if (!alive.current || !reads.isCurrent(mine)) return;
4012
4239
  setPhase(result.ok ? { kind: "ready", snapshot: result.snapshot } : { kind: "failed", error: result.error });
4240
+ if (result.ok) {
4241
+ const stillVisible = new Set(visibleSessions(result.snapshot.sessions).map((row) => row.id));
4242
+ setSelected((previous) => previous.filter((id) => stillVisible.has(id)));
4243
+ }
4013
4244
  } finally {
4014
- if (alive.current && reads.isCurrent(mine)) setBusy(false);
4245
+ if (alive.current && reads.isCurrent(mine)) markBusy(false);
4015
4246
  }
4016
- }, [io, reads]);
4247
+ }, [io, markBusy, reads, requests]);
4248
+ const refreshRef = (0, import_react7.useRef)(refresh);
4017
4249
  (0, import_react7.useEffect)(() => {
4018
- void refresh();
4250
+ refreshRef.current = refresh;
4019
4251
  }, [refresh]);
4252
+ const mounted = (0, import_react7.useRef)(false);
4253
+ (0, import_react7.useEffect)(() => {
4254
+ if (mounted.current) return;
4255
+ mounted.current = true;
4256
+ void refreshRef.current();
4257
+ }, []);
4020
4258
  const run = (0, import_react7.useCallback)(
4021
4259
  async (action) => {
4260
+ if (busyRef.current) return;
4022
4261
  const mine = reads.begin();
4023
- setBusy(true);
4262
+ const actionId = actions.begin();
4263
+ const signal = requests.begin();
4264
+ markBusy(true);
4024
4265
  try {
4025
- const result = await action();
4026
- if (!alive.current || !reads.isCurrent(mine)) return;
4027
- setOutcome(result);
4266
+ let result;
4267
+ try {
4268
+ result = await action();
4269
+ } catch {
4270
+ result = { kind: "error", message: { key: "sessionResultActionFailed" } };
4271
+ }
4272
+ if (!alive.current) return;
4273
+ if (actions.isCurrent(actionId)) setOutcome(result);
4028
4274
  setSelected([]);
4029
4275
  setPending(void 0);
4030
- const read = await readSessionPanel(io);
4276
+ const read = await readSessionPanel(io, signal);
4031
4277
  if (!alive.current || !reads.isCurrent(mine)) return;
4032
4278
  setPhase(read.ok ? { kind: "ready", snapshot: read.snapshot } : { kind: "failed", error: read.error });
4033
4279
  } finally {
4034
- if (alive.current && reads.isCurrent(mine)) setBusy(false);
4280
+ if (alive.current && actions.isCurrent(actionId)) markBusy(false);
4035
4281
  }
4036
4282
  },
4037
- [io, reads]
4283
+ [actions, io, markBusy, reads, requests]
4038
4284
  );
4039
4285
  const visible = (0, import_react7.useMemo)(() => visibleSessions(snapshot.sessions), [snapshot.sessions]);
4040
- const hidden = hiddenSubsessionCount(snapshot.sessions);
4286
+ const selectedSet = (0, import_react7.useMemo)(() => new Set(selected), [selected]);
4287
+ const hidden = snapshot.sessions.length - visible.length;
4041
4288
  const deletable = (0, import_react7.useMemo)(
4042
- () => visible.filter((row) => row.deletable && selected.includes(row.id)),
4043
- [visible, selected]
4289
+ () => visible.filter((row) => row.deletable && selectedSet.has(row.id)),
4290
+ [visible, selectedSet]
4044
4291
  );
4045
4292
  const groups = (0, import_react7.useMemo)(() => groupByWorkspace(visible), [visible]);
4046
4293
  const toggle = (0, import_react7.useCallback)((id) => {
@@ -4051,6 +4298,7 @@ function SessionPanel({ copy, io, close }) {
4051
4298
  if (current === void 0) return;
4052
4299
  setPending(void 0);
4053
4300
  void run(() => {
4301
+ if (current.kind === "sweep") return sweepSessions(io, { confirm: true });
4054
4302
  if (current.kind === "purge") return purgeSession(io, current.ids[0]);
4055
4303
  if (current.kind === "delete-workspace") return deleteWorkspaceSessions(io, current.cwd, { permanent: true });
4056
4304
  return deleteSessions(io, current.ids, { permanent: true });
@@ -4103,7 +4351,7 @@ function SessionPanel({ copy, io, close }) {
4103
4351
  (0, import_react7.createElement)(
4104
4352
  "div",
4105
4353
  { className: CLASS.headerActions },
4106
- Button({ variant: "secondary", size: "compact", onClick: () => void refresh(), children: copy.refresh }),
4354
+ Button({ variant: "secondary", size: "compact", disabled: busy, onClick: () => void refresh(), children: copy.refresh }),
4107
4355
  Button({ variant: "secondary", size: "compact", onClick: close, children: copy.close })
4108
4356
  )
4109
4357
  ),
@@ -4194,7 +4442,7 @@ function SessionPanel({ copy, io, close }) {
4194
4442
  variant: "secondary",
4195
4443
  size: "compact",
4196
4444
  disabled: busy,
4197
- onClick: () => void run(() => sweepSessions(io)),
4445
+ onClick: () => setPending({ kind: "sweep" }),
4198
4446
  children: copy.sweep
4199
4447
  })
4200
4448
  ),
@@ -4216,7 +4464,11 @@ function renderConfirmationDialog(pending, copy, handlers, titleId, loadedIdle =
4216
4464
  handlers.cancel();
4217
4465
  }
4218
4466
  },
4219
- (0, import_react7.createElement)("h3", { id: titleId, className: CLASS.dialogHeader }, copy.confirmTitle),
4467
+ (0, import_react7.createElement)(
4468
+ "h3",
4469
+ { id: titleId, className: CLASS.dialogHeader },
4470
+ pending.kind === "sweep" ? copy.sweepConfirmTitle : copy.confirmTitle
4471
+ ),
4220
4472
  (0, import_react7.createElement)("p", { className: CLASS.dialogBody }, confirmationBody(pending, copy, loadedIdle)),
4221
4473
  (0, import_react7.createElement)(
4222
4474
  "div",
@@ -4231,6 +4483,7 @@ function workspaceDeleteAction(group) {
4231
4483
  return { kind: "delete-workspace", cwd: group.cwd };
4232
4484
  }
4233
4485
  function confirmationBody(pending, copy, loadedIdle = 0) {
4486
+ if (pending.kind === "sweep") return copy.sweepConfirmBody;
4234
4487
  if (pending.kind === "delete-workspace") return copy.confirmWorkspaceBody(pending.cwd);
4235
4488
  const targets = pending.ids.join(", ");
4236
4489
  const sentence = pending.kind === "purge" ? copy.confirmPurgeBody(targets) : copy.confirmBody(targets);
@@ -4304,7 +4557,12 @@ function createSessionRowActionStore(deps) {
4304
4557
  return current !== void 0 && current.kind === request.kind && current.sessionId === request.sessionId;
4305
4558
  };
4306
4559
  dispatch({ ...state, busy: true, outcome: void 0 });
4307
- const result = await deleteSessions(deps.io, [request.sessionId], request.kind === "delete" ? { permanent: true } : {});
4560
+ let result;
4561
+ try {
4562
+ result = await deleteSessions(deps.io, [request.sessionId], request.kind === "delete" ? { permanent: true } : {});
4563
+ } catch {
4564
+ result = { kind: "error", message: { key: "sessionResultActionFailed" } };
4565
+ }
4308
4566
  if (disposed || !stillPending()) return;
4309
4567
  dispatch({ ...state, busy: false, outcome: result });
4310
4568
  if (result.kind === "ok") {
@@ -4571,7 +4829,6 @@ var import_react11 = require("react");
4571
4829
 
4572
4830
  // src/client/flows/directory-browser.ts
4573
4831
  var import_react10 = require("react");
4574
- var GIT_STATUS_API_PATH = "/api/over-ssh/git-status";
4575
4832
  var GIT_STATUS_BADGE = {
4576
4833
  modified: "M",
4577
4834
  added: "A",
@@ -4898,17 +5155,24 @@ function createDirectoryBrowser(deps) {
4898
5155
  dispatch({ type: "remote-loaded", path, listing: result.value });
4899
5156
  void loadGit(connectionId, result.value.path, signal);
4900
5157
  };
5158
+ let connectionsInFlight;
4901
5159
  const ensureConnections = async () => {
4902
5160
  if (state.connections.length > 0) return true;
4903
- if (state.connectionsPending) return false;
5161
+ if (connectionsInFlight !== void 0) return connectionsInFlight;
4904
5162
  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;
5163
+ const attempt = (async () => {
5164
+ const result = await deps.io.loadConnections();
5165
+ connectionsInFlight = void 0;
5166
+ if (disposed) return false;
5167
+ if (!result.ok) {
5168
+ dispatch({ type: "connections-failed", error: result.error });
5169
+ return false;
5170
+ }
5171
+ dispatch({ type: "connections-loaded", connections: result.value });
5172
+ return true;
5173
+ })();
5174
+ connectionsInFlight = attempt;
5175
+ return attempt;
4912
5176
  };
4913
5177
  const refresh = async (path) => {
4914
5178
  if (disposed) return;
@@ -5370,15 +5634,26 @@ function selectSessionDirectoryView(state, copy) {
5370
5634
  workspaceLine: state.workspacePath === void 0 ? copy["sessionAddWorkspaceUnknown"] : resolveMessage2(copy, { key: "sessionAddWorkspace", params: { path: state.workspacePath } }),
5371
5635
  ...state.workspacePath === void 0 ? {} : { workspacePath: state.workspacePath },
5372
5636
  ...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
- })),
5637
+ // 工作区永远排在第一行:它是默认边界,`workspace-write` 下与已添加目录一样可写(ADR-0016)。
5638
+ roots: [
5639
+ ...state.workspacePath === void 0 ? [] : [{
5640
+ key: `workspace:${state.workspacePath}`,
5641
+ title: state.workspacePath,
5642
+ subtitle: copy["sessionRootsWorkspaceNote"],
5643
+ badge: copy["sessionRootsWorkspaceBadge"],
5644
+ removable: false,
5645
+ workspace: true
5646
+ }],
5647
+ ...state.roots.map((row) => ({
5648
+ key: row.key,
5649
+ // A label is what the user called it; without one the path is the only honest title. A remote path alone
5650
+ // would hide which host it is on, so the connection name is part of the title there.
5651
+ title: row.label ?? (row.location === "remote" ? `${row.connectionId ?? ""}:${row.path}` : row.path),
5652
+ subtitle: row.label === void 0 ? "" : row.path,
5653
+ badge: row.location === "remote" ? copy["rootsBadgeRemote"] : copy["rootsBadgeLocal"],
5654
+ removable: row.inUserFile
5655
+ }))
5656
+ ],
5382
5657
  rootsPending: state.rootsPending,
5383
5658
  rootsReading: state.rootsReading,
5384
5659
  browsing: state.browsing
@@ -5544,7 +5819,7 @@ function SessionDirectorySection({
5544
5819
  { className: CLASS.block },
5545
5820
  (0, import_react11.createElement)("h4", { className: CLASS.blockTitle }, copy.sessionRootsTitle),
5546
5821
  (0, import_react11.createElement)("p", { className: CLASS.hint }, copy.sessionRootsHint),
5547
- view.roots.length === 0 ? view.rootsReading ? Notice({ role: "status", tone: "info", children: copy.sessionRootsLoading }) : (
5822
+ view.roots.filter((row) => row.workspace !== true).length === 0 ? view.rootsReading ? Notice({ role: "status", tone: "info", children: copy.sessionRootsLoading }) : (
5548
5823
  // A failed read is not an empty list: the error notice below is the whole truth there, and
5549
5824
  // 「还没有添加目录」 next to it would contradict it (same rule as the import dialog, ui P2-2/P2-3).
5550
5825
  view.notice === "" ? (0, import_react11.createElement)("p", { className: CLASS.empty }, copy.sessionRootsEmpty) : null
@@ -6003,10 +6278,6 @@ function createWorkspaceDirectoryFlow(deps) {
6003
6278
  }
6004
6279
  };
6005
6280
  }
6006
- function fieldValue2(event) {
6007
- const target = event.target;
6008
- return typeof target?.value === "string" ? target.value : "";
6009
- }
6010
6281
  function WorkspaceDirectoryFlowSection({
6011
6282
  copy,
6012
6283
  state,
@@ -6149,7 +6420,7 @@ function WorkspaceDirectoryFlowSection({
6149
6420
  value: state.drafts.path,
6150
6421
  disabled: !canPickLocal2,
6151
6422
  placeholder: copy.rootsRemotePathLabel,
6152
- onChange: (event) => actions.onDraftChange({ path: fieldValue2(event) })
6423
+ onChange: (event) => actions.onDraftChange({ path: fieldValue(event) })
6153
6424
  }),
6154
6425
  Button({
6155
6426
  variant: "secondary",
@@ -6308,6 +6579,8 @@ function apply(ctx) {
6308
6579
  subsessionBadge: dict.sessionPanelSubsession,
6309
6580
  unreadableBadge: dict.sessionPanelUnreadable,
6310
6581
  confirmTitle: dict.sessionPanelConfirmTitle,
6582
+ sweepConfirmTitle: dict.sessionResultSweepConfirmTitle,
6583
+ sweepConfirmBody: dict.sessionResultSweepConfirmBody,
6311
6584
  confirmBody: (targets) => resolveMessage2(dict, { key: "sessionPanelConfirmBody", params: { targets } }),
6312
6585
  confirmPurgeBody: (targets) => resolveMessage2(dict, { key: "sessionPanelConfirmPurgeBody", params: { targets } }),
6313
6586
  confirmCancel: dict.sessionPanelConfirmCancel,
@@ -6331,7 +6604,7 @@ function apply(ctx) {
6331
6604
  };
6332
6605
  };
6333
6606
  const io = { fetch: (input, init) => globalThis.fetch(input, init) };
6334
- return ctx.slots.inject(
6607
+ const register = () => ctx.slots.inject(
6335
6608
  "settings.section",
6336
6609
  () => ctx.slots.register(
6337
6610
  {
@@ -6339,12 +6612,20 @@ function apply(ctx) {
6339
6612
  id: SESSION_SECTION_ID,
6340
6613
  order: SESSION_SECTION_ORDER,
6341
6614
  label: copy().title,
6342
- inject: () => ({ copy: copy(), io, close: () => {
6343
- } })
6615
+ inject: () => ({ copy: copy(), io })
6344
6616
  },
6345
6617
  SessionPanel
6346
6618
  )
6347
6619
  );
6620
+ let dispose = register();
6621
+ const offLocaleChange = ctx.on("locale/change", () => {
6622
+ dispose();
6623
+ dispose = register();
6624
+ });
6625
+ return () => {
6626
+ offLocaleChange();
6627
+ dispose();
6628
+ };
6348
6629
  });
6349
6630
  ctx.effect(() => {
6350
6631
  let rows;
@@ -6474,7 +6755,7 @@ function apply(ctx) {
6474
6755
  let lastOverlay = "";
6475
6756
  const overlayOf = () => {
6476
6757
  const state = rootsController.getState();
6477
- return `${state.editingId ?? ""}|${state.confirmingRemove ?? ""}|${state.pending ? "busy" : "idle"}`;
6758
+ return `${state.editingId ?? ""}|${state.confirmingRemove ?? ""}`;
6478
6759
  };
6479
6760
  lastOverlay = overlayOf();
6480
6761
  const offSnapshot = rootsController.subscribe(() => {
@@ -6505,15 +6786,18 @@ function apply(ctx) {
6505
6786
  const api = createRootsApi((input, init) => globalThis.fetch(input, init));
6506
6787
  let ids = /* @__PURE__ */ new Set();
6507
6788
  let disposed = false;
6789
+ const requests = createAbortGate();
6508
6790
  const dict = () => OVER_SSH_DICTS[ctx.locale.getLocale().active === "en" ? "en" : "zh"];
6509
6791
  const label = () => dict().rootsTitle;
6510
6792
  let loaded = false;
6511
6793
  let dispose = () => void 0;
6512
6794
  async function reload() {
6795
+ const signal = requests.begin();
6513
6796
  try {
6514
- const [roots, sessions] = await Promise.all([api.load(), loadSessionPanel(io)]);
6797
+ const [roots, sessions] = await Promise.all([api.load(signal), readSessionPanel(io, signal)]);
6515
6798
  if (disposed || !roots.ok) return;
6516
- const next = remoteSessionIds(sessions.sessions, roots.value.anchorRoot);
6799
+ if (!sessions.ok || !sessions.snapshot.available) return;
6800
+ const next = remoteSessionIds(sessions.snapshot.sessions, roots.value.anchorRoot);
6517
6801
  if (sameIds(next, ids)) return;
6518
6802
  ids = next;
6519
6803
  refresh();
@@ -6553,6 +6837,7 @@ function apply(ctx) {
6553
6837
  const offLocaleChange = ctx.on("locale/change", refresh);
6554
6838
  return () => {
6555
6839
  disposed = true;
6840
+ requests.abort();
6556
6841
  doc?.removeEventListener("visibilitychange", onVisibility);
6557
6842
  offLocaleChange();
6558
6843
  dispose();