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/README.md +4 -4
- package/agents/agent-manifest.json +6 -6
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/lib/client.js +507 -222
- package/lib/fs.js +3 -2
- package/lib/index.js +890 -373
- package/lib/types/src/host/domain/session/session-admin.d.ts +5 -0
- package/lib/types/src/wiring/helpers.d.ts +10 -1
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -29,6 +29,25 @@ __export(index_exports, {
|
|
|
29
29
|
module.exports = __toCommonJS(index_exports);
|
|
30
30
|
var import_cordis = require("@deepseek-ai/cordis");
|
|
31
31
|
|
|
32
|
+
// src/client/core/abort.ts
|
|
33
|
+
function createAbortGate() {
|
|
34
|
+
let active;
|
|
35
|
+
return {
|
|
36
|
+
begin: () => {
|
|
37
|
+
active?.abort();
|
|
38
|
+
active = new AbortController();
|
|
39
|
+
return active.signal;
|
|
40
|
+
},
|
|
41
|
+
abort: () => {
|
|
42
|
+
active?.abort();
|
|
43
|
+
active = void 0;
|
|
44
|
+
},
|
|
45
|
+
get live() {
|
|
46
|
+
return active !== void 0 && !active.signal.aborted;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
// src/client/core/locale.ts
|
|
33
52
|
var OVER_SSH_NS = "over-ssh";
|
|
34
53
|
var OVER_SSH_DICTS = {
|
|
@@ -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.
|
|
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
|
|
2764
|
-
const
|
|
2765
|
-
|
|
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({
|
|
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
|
-
|
|
2809
|
-
//
|
|
2810
|
-
//
|
|
2811
|
-
//
|
|
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
|
|
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 =
|
|
3104
|
-
const
|
|
3105
|
-
const
|
|
3106
|
-
const {
|
|
3353
|
+
const view = precomputedView ?? selectRootsViewForRender(state);
|
|
3354
|
+
const editable = editableOverride ?? view.canEdit;
|
|
3355
|
+
const editForm = ConnectionEditForm({ copy, state, actions, idPrefix, editable });
|
|
3356
|
+
const addForm = ConnectionAddForm({ copy, state, actions, idPrefix, editable });
|
|
3357
|
+
const rowElements = precomputedRows ?? rootsPanelRowElements(view, copy, editable, actions.onRemove);
|
|
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
|
-
"
|
|
3124
|
-
{
|
|
3125
|
-
|
|
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 }, ...
|
|
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
|
-
|
|
3454
|
+
const cardOpen = state.editingId !== void 0 || state.addingConnection === true;
|
|
3455
|
+
useCardTabTrap(cardOpen, portal.container);
|
|
3291
3456
|
(0, import_react5.useLayoutEffect)(() => {
|
|
3292
|
-
if (
|
|
3293
|
-
portal
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
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
|
|
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: "
|
|
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: "
|
|
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) =>
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
3741
|
-
bytes:
|
|
3742
|
-
subsessions:
|
|
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, {
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
-
|
|
4262
|
+
const actionId = actions.begin();
|
|
4263
|
+
const signal = requests.begin();
|
|
4264
|
+
markBusy(true);
|
|
4024
4265
|
try {
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
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 &&
|
|
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
|
|
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 &&
|
|
4043
|
-
[visible,
|
|
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: () =>
|
|
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)(
|
|
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
|
-
|
|
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 (
|
|
5161
|
+
if (connectionsInFlight !== void 0) return connectionsInFlight;
|
|
4904
5162
|
dispatch({ type: "connections-loading" });
|
|
4905
|
-
const
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
return false;
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
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
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 ?? ""}
|
|
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(),
|
|
6797
|
+
const [roots, sessions] = await Promise.all([api.load(signal), readSessionPanel(io, signal)]);
|
|
6515
6798
|
if (disposed || !roots.ok) return;
|
|
6516
|
-
|
|
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();
|