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