mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
017606bd61 | ||
|
|
c8e4346193 | ||
|
|
d6e47d18a5 | ||
|
|
64c4ce9e9d | ||
|
|
69c5ccdb4b | ||
|
|
0e43e80555 | ||
|
|
d7ec12037e | ||
|
|
43a35a5e62 | ||
|
|
d78738e96d | ||
|
|
d8975c6fb2 | ||
|
|
979dccc0b3 | ||
|
|
fd1b433940 | ||
|
|
432cf8d323 | ||
|
|
4f3f72116e | ||
|
|
d6b47b5363 | ||
|
|
7acc67bef2 | ||
|
|
9bf5108f4c | ||
|
|
7683341270 | ||
|
|
758dc26641 | ||
|
|
3153158b12 | ||
|
|
89c23e4453 | ||
|
|
3a41b6f462 | ||
|
|
a6d326664e | ||
|
|
d6a553691c | ||
|
|
2783684d10 | ||
|
|
6adef4c680 | ||
|
|
3820635809 | ||
|
|
f99b150381 | ||
|
|
882852ac73 | ||
|
|
21c6d59b2f | ||
|
|
5d3abd14d8 | ||
|
|
400e7ed81d | ||
|
|
0a083fe8b0 | ||
|
|
3579a5c3d4 | ||
|
|
452c2df34c | ||
|
|
b0c272a207 | ||
|
|
a60f75731b | ||
|
|
f2b4632931 | ||
|
|
c9d22b3b4d | ||
|
|
2dea078c6d | ||
|
|
446e82180b | ||
|
|
2eaddf0baf | ||
|
|
7b964ead21 | ||
|
|
0ea8fa4943 | ||
|
|
7228af73bf | ||
|
|
3626601eef | ||
|
|
81ec019892 | ||
|
|
1151b31a42 | ||
|
|
20b12a861c | ||
|
|
f70f6e4a5f | ||
|
|
a789ec1b9d | ||
|
|
2d9890a92e | ||
|
|
1ef7b026a6 | ||
|
|
f9eded887f | ||
|
|
701e35dac0 | ||
|
|
09cb03c88c | ||
|
|
5c5fa4f27e | ||
|
|
c91a57f315 | ||
|
|
81c3feba0b | ||
|
|
1be02e5c01 | ||
|
|
41a35824aa | ||
|
|
438a5f0652 | ||
|
|
16b2a20220 | ||
|
|
2f4726c8ce | ||
|
|
c9b40d98c2 | ||
|
|
9fca48a927 | ||
|
|
3411d7f08b | ||
|
|
82e62c8c1d | ||
|
|
48780fcd09 | ||
|
|
116e70a206 | ||
|
|
56e593898c | ||
|
|
636f6c29ab | ||
|
|
d2e584f00d | ||
|
|
0b8df00e31 | ||
|
|
dd9577ce75 | ||
|
|
8becc81e7a | ||
|
|
6b6b37c7a1 | ||
|
|
d62dfac977 | ||
|
|
cfbda2a23f | ||
|
|
d2ed328907 | ||
|
|
52ec1edab8 | ||
|
|
f3f89edad5 | ||
|
|
3e03f439ec | ||
|
|
5a05da0b4a | ||
|
|
efaaaebd29 | ||
|
|
969da6aa88 | ||
|
|
8bf63ed89e | ||
|
|
2308d133c2 | ||
|
|
d607c81b5b | ||
|
|
4fc97ce96e | ||
|
|
f828e0d479 | ||
|
|
3162c2c375 | ||
|
|
e7b59ad768 | ||
|
|
1de1221707 | ||
|
|
b285200798 | ||
|
|
02f5729ab7 | ||
|
|
74c1cd063b | ||
|
|
044a95b831 | ||
|
|
8e05923ca2 | ||
|
|
73ed3e02ed | ||
|
|
c0a46cc926 | ||
|
|
b2c72d607b | ||
|
|
b83f3ee17a | ||
|
|
689fcfd4e4 | ||
|
|
0fb15b99c5 | ||
|
|
c6c769acc3 | ||
|
|
0b8ba35da7 | ||
|
|
47bef3c9d9 | ||
|
|
2faa30d79b | ||
|
|
d19ddce4ff | ||
|
|
ae26219fdd | ||
|
|
35076fea5d | ||
|
|
192c7043c1 | ||
|
|
2ff47eafce | ||
|
|
2538da9891 | ||
|
|
7440e66ea8 | ||
|
|
e84ef25720 | ||
|
|
56deb86151 | ||
|
|
2189f6c820 | ||
|
|
7e3960f561 | ||
|
|
acb29e0791 | ||
|
|
a6683e23ab | ||
|
|
d350f2746a | ||
|
|
89dab1394d | ||
|
|
0326881d85 | ||
|
|
309fe73492 | ||
|
|
cc0fc9ee19 | ||
|
|
c628cb233c | ||
|
|
5f3a1eaed6 | ||
|
|
5d10f09a0a | ||
|
|
4abbdd9e48 | ||
|
|
6ff65334d4 | ||
|
|
2c23f445ac | ||
|
|
e3f56204b2 | ||
|
|
dcc3e0718f | ||
|
|
3936d8b7f3 | ||
|
|
b0481e7da0 | ||
|
|
e911792287 | ||
|
|
ceb89d65aa | ||
|
|
1025d93ae3 | ||
|
|
3b6f185866 | ||
|
|
8d4fde0340 | ||
|
|
9c61e46635 | ||
|
|
f4987550f1 | ||
|
|
6ad422b1ea | ||
|
|
24b8d8e05d | ||
|
|
2487e954ee | ||
|
|
f66c098c9b | ||
|
|
8332aa2dbd | ||
|
|
7451fed016 | ||
|
|
908cc687cb | ||
|
|
47667e80f5 | ||
|
|
da8b13471f | ||
|
|
2d6183c440 | ||
|
|
a352ca3836 | ||
|
|
4fad9b8dcf | ||
|
|
2569c6c3c4 | ||
|
|
0ecd06b2f6 | ||
|
|
1f52f6f399 | ||
|
|
606c53d7fa | ||
|
|
f2ec2a4842 | ||
|
|
0556e493f8 | ||
|
|
4435f763bd | ||
|
|
5ae1df350d | ||
|
|
74b77ddec1 | ||
|
|
2084ede4db | ||
|
|
ae59c32888 | ||
|
|
1f75c20446 | ||
|
|
7062dcb4a4 | ||
|
|
0a3d001f47 | ||
|
|
26b9027d41 | ||
|
|
25b79a6947 | ||
|
|
a1fce3ad7c | ||
|
|
7db5a21e3e | ||
|
|
10f1c02c5c | ||
|
|
ffb4d94aec | ||
|
|
b818db5f0c | ||
|
|
ce1cdab559 | ||
|
|
d79643d31c | ||
|
|
4bb8a224b6 | ||
|
|
8788803fb5 | ||
|
|
a7342ff7b8 | ||
|
|
e5f2519433 | ||
|
|
6e56fe5a8d | ||
|
|
2c35040e79 | ||
|
|
55ceaa3290 | ||
|
|
74abfe28ef | ||
|
|
d92406a9fd | ||
|
|
ccacb69bb9 | ||
|
|
7c419f41de | ||
|
|
151364ce4b | ||
|
|
e6df190b98 | ||
|
|
e5c750398d | ||
|
|
d4bfe02dc4 | ||
|
|
ee47c9e15c | ||
|
|
0068180204 | ||
|
|
513b2d66af | ||
|
|
715487b2a0 | ||
|
|
8f34d2a31f | ||
|
|
fb034f08bb | ||
|
|
39d7c38a6a | ||
|
|
e8c291373b | ||
|
|
162b028d39 | ||
|
|
2d494e794e | ||
|
|
8f2c51cbcd | ||
|
|
b71c75ed07 | ||
|
|
03a8c4921b | ||
|
|
8df0965066 | ||
|
|
75eefa50c3 | ||
|
|
e052f22148 | ||
|
|
46aa537c0e | ||
|
|
ccb63165c9 | ||
|
|
611f9cca3e | ||
|
|
3adf86bcd9 | ||
|
|
a20065a6a8 | ||
|
|
76fa1c7a2d | ||
|
|
b11dc42a10 | ||
|
|
13dedcf17a | ||
|
|
4d685557b1 | ||
|
|
f11378afbc | ||
|
|
5b0f949101 | ||
|
|
9de90a6a02 | ||
|
|
8ce19700fa | ||
|
|
ae2014ddf5 | ||
|
|
be63f6de88 | ||
|
|
23d66e5d43 | ||
|
|
35d0b16e21 | ||
|
|
cf84e61451 | ||
|
|
5d2e15eab6 | ||
|
|
8ecced6d32 | ||
|
|
7e51b94686 | ||
|
|
a5e61ca1d3 | ||
|
|
467e548ad8 | ||
|
|
c3bc173aa5 | ||
|
|
cc2bdb5b7e | ||
|
|
0c9a22bed5 | ||
|
|
d1136a4987 |
No files matched your search
@@ -40,6 +40,10 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/build.yml"
|
||||
- "components/AppLockGate.tsx"
|
||||
- "components/TrayPanel.tsx"
|
||||
- "index.tsx"
|
||||
- "index.css"
|
||||
- "build/**"
|
||||
- "electron-builder.config.cjs"
|
||||
- "electron/**"
|
||||
@@ -76,6 +80,7 @@ on:
|
||||
- "scripts/rebuildPatchedNodePty.cjs"
|
||||
- "scripts/resolve-et-bin-release.cjs"
|
||||
- "scripts/resolve-mosh-bin-release.cjs"
|
||||
- "scripts/tray-panel-layout.live.test.cjs"
|
||||
- "scripts/verify-linux-*.sh"
|
||||
- "scripts/xterm-macos-column-selection.live.test.cjs"
|
||||
- "skills/**"
|
||||
@@ -318,6 +323,15 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Test tray panel layout
|
||||
run: npm run test:tray-panel-layout
|
||||
|
||||
- name: Test tray panel layout at 200% scale on Windows
|
||||
if: matrix.name == 'windows'
|
||||
env:
|
||||
NETCATTY_TRAY_LAYOUT_SCALE: "2"
|
||||
run: npm run test:tray-panel-layout
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -681,6 +695,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Security CDN endpoints can lack files advertised in their indexes.
|
||||
# Fall back per file, with the final Bullseye security snapshot for packages only.
|
||||
# Live signed indexes still select versions and verify every download.
|
||||
printf 'http://deb.debian.org/debian-security\tpriority:1\nhttp://security.debian.org/debian-security\tpriority:2\nhttp://snapshot.debian.org/archive/debian-security/20260901T000000Z/\tpriority:3 type:deb\n' > /etc/apt/security-mirrors.txt
|
||||
sed -i 's|http://deb.debian.org/debian-security|mirror+file:/etc/apt/security-mirrors.txt|g' /etc/apt/sources.list
|
||||
apt-get -o Acquire::Retries=4 update
|
||||
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
|
||||
@@ -52,6 +52,9 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test terminal background rendering
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
|
||||
|
||||
- name: Test terminal keyword highlight performance
|
||||
env:
|
||||
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
|
||||
|
||||
@@ -315,6 +315,30 @@ test('pane zoom hotkey delegates to the active in-app magnification surface', ()
|
||||
assert.equal(toggles, 1);
|
||||
});
|
||||
|
||||
test('broadcast hotkey toggles global mode for an active orphan tab', () => {
|
||||
let globalToggles = 0;
|
||||
let workspaceToggles = 0;
|
||||
|
||||
executeHotkeyActionImpl(() => ({
|
||||
activeTabStore: { getActiveTabId: () => 'orphan-1' },
|
||||
editorTabs: [],
|
||||
orderedTabs: ['orphan-1', 'orphan-2'],
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
toEditorTabId: (id: string) => id,
|
||||
sessions: [
|
||||
{ id: 'orphan-1' },
|
||||
{ id: 'orphan-2' },
|
||||
],
|
||||
workspaces: [],
|
||||
canUseGlobalBroadcast: true,
|
||||
toggleBroadcast: () => { workspaceToggles += 1; },
|
||||
toggleGlobalBroadcast: () => { globalToggles += 1; },
|
||||
}), 'broadcast', {} as KeyboardEvent);
|
||||
|
||||
assert.equal(globalToggles, 1);
|
||||
assert.equal(workspaceToggles, 0);
|
||||
});
|
||||
|
||||
test('move-focus shortcut cannot send input behind a magnified pane', () => {
|
||||
let moveCalls = 0;
|
||||
executeHotkeyActionImpl(() => ({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, splitSessionWithCurrentShellImpl } from "./AppHandlers";
|
||||
import { copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, splitSessionWithCurrentShellImpl } from "./AppHandlers";
|
||||
import { createCopiedTerminalSessionClone } from "../state/terminalConnectionReuse";
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
type CloneOpts = { localShellType?: string; inheritedCwd?: string };
|
||||
type CloneOpts = { localShellType?: string; inheritedCwd?: string; reuseConnection?: boolean };
|
||||
type Calls = {
|
||||
copy?: { id: string; opts: CloneOpts };
|
||||
split?: { id: string; dir: string; opts: CloneOpts };
|
||||
@@ -55,6 +57,47 @@ test("live tracked cwd is preferred over the probe", async () => {
|
||||
assert.equal(calls.probed, false, "must not probe when live cwd is known");
|
||||
});
|
||||
|
||||
for (const protocol of ["ssh", undefined] as const) {
|
||||
for (const liveCwd of ["/srv/old-target", undefined]) {
|
||||
test(`duplicate SSH session does not capture or inject the old target directory (${protocol}, ${liveCwd})`, async () => {
|
||||
const source: TerminalSession = {
|
||||
id: "src", hostId: "bastion", hostLabel: "Bastion", hostname: "bastion.test",
|
||||
username: "alice", protocol, status: "connected", lastCwd: "/saved/old-target",
|
||||
};
|
||||
let cwdReads = 0;
|
||||
let bridgeReads = 0;
|
||||
const { getCtx, calls } = ctxFactory({
|
||||
sessions: [source],
|
||||
getSessionRestoreCwd: () => { cwdReads += 1; return liveCwd; },
|
||||
netcattyBridge: { get: () => { bridgeReads += 1; return {}; } },
|
||||
});
|
||||
await duplicateSessionWithCurrentShellImpl(getCtx, "src");
|
||||
assert.equal(calls.copy?.id, "src");
|
||||
assert.equal(calls.copy?.opts.reuseConnection, false);
|
||||
assert.equal(calls.copy?.opts.inheritedCwd, undefined);
|
||||
assert.equal(cwdReads, 0, "fresh remote login must not read the previous target's directory");
|
||||
assert.equal(bridgeReads, 0, "fresh remote login must not probe the previous target");
|
||||
const clone = createCopiedTerminalSessionClone(source, {
|
||||
id: "duplicate",
|
||||
inheritedCwd: calls.copy?.opts.inheritedCwd,
|
||||
reuseConnection: calls.copy?.opts.reuseConnection,
|
||||
});
|
||||
assert.equal(clone.requireFreshConnection, true);
|
||||
assert.equal(clone.pendingInitialCwd, undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("duplicate local session retains the current working directory", async () => {
|
||||
const { getCtx, calls } = ctxFactory({
|
||||
sessions: [{ id: "src", protocol: "local", status: "connected", localStartDir: "/home/alice" }],
|
||||
getSessionRestoreCwd: () => "/home/alice/project",
|
||||
});
|
||||
await duplicateSessionWithCurrentShellImpl(getCtx, "src");
|
||||
assert.equal(calls.copy?.opts.inheritedCwd, "/home/alice/project");
|
||||
assert.equal(calls.probed, false);
|
||||
});
|
||||
|
||||
test("network device (by deviceType) is never probed", async () => {
|
||||
const { getCtx, calls } = ctxFactory({
|
||||
hostById: new Map([["h1", { id: "h1", deviceType: "network" }]]),
|
||||
|
||||
@@ -551,17 +551,37 @@ export async function splitSessionWithCurrentShellImpl(getCtx: AppContextGetter,
|
||||
});
|
||||
}
|
||||
|
||||
export async function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
|
||||
export async function copySessionWithCurrentShellImpl(
|
||||
getCtx: AppContextGetter,
|
||||
sessionId: string,
|
||||
options?: { reuseConnection?: boolean },
|
||||
) {
|
||||
const { classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, sessions, terminalSettings } = getCtx();
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const inheritedCwd = await captureCtxInheritedCwd(getCtx, sessionId);
|
||||
// A fresh remote login may stop at a bastion's host-selection prompt. Do
|
||||
// not probe the old target or send its directory command to the new login.
|
||||
// Local duplicates still open in the source terminal's current directory.
|
||||
const inheritCwd = options?.reuseConnection !== false
|
||||
|| sessions?.find((session: { id: string }) => session.id === sessionId)?.protocol === "local";
|
||||
const inheritedCwd = inheritCwd ? await captureCtxInheritedCwd(getCtx, sessionId) : undefined;
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
return copySession(sessionId, {
|
||||
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
|
||||
inheritedCwd,
|
||||
reuseConnection: options?.reuseConnection,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "Duplicate session": clone the tab but open a brand-new connection instead of
|
||||
* multiplexing over the source's established SSH channel (fresh auth; with a
|
||||
* bastion/jump host this restarts the bastion login so the user can pick a new
|
||||
* target host).
|
||||
*/
|
||||
export async function duplicateSessionWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
|
||||
return copySessionWithCurrentShellImpl(getCtx, sessionId, { reuseConnection: false });
|
||||
}
|
||||
|
||||
export async function copyWorkspaceWithCurrentShellImpl(getCtx: AppContextGetter, workspaceId: string) {
|
||||
const { classifyLocalShellType, collectSessionIds, copyWorkspace, discoveredShells, resolveShellSetting, terminalSettings, workspaces } = getCtx();
|
||||
const workspace = workspaces.find((w: { id: string }) => w.id === workspaceId);
|
||||
@@ -702,7 +722,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -896,11 +916,16 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
case 'broadcast': {
|
||||
// Toggle broadcast mode for the active workspace
|
||||
// Workspace tabs keep their local broadcast state; orphan tabs share
|
||||
// the global state when at least two visible orphan tabs exist.
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
const activeWs = workspaces.find(w => w.id === currentId);
|
||||
if (activeWs) {
|
||||
toggleBroadcast(activeWs.id);
|
||||
} else if (sessions.some((session: TerminalSession) => (
|
||||
session.id === currentId && !session.workspaceId
|
||||
)) && canUseGlobalBroadcast) {
|
||||
toggleGlobalBroadcast();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
|
||||
type OpenSessionInNewWindowPayload = {
|
||||
title?: string;
|
||||
@@ -503,12 +503,13 @@ export function AppSideEffects() {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
}),
|
||||
}, { currentHosts: hosts }),
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
|
||||
}),
|
||||
[
|
||||
buildCurrentSyncPayload,
|
||||
hosts,
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
settings.rehydrateAllFromStorage,
|
||||
@@ -526,7 +527,7 @@ export function AppSideEffects() {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
});
|
||||
}, { currentHosts: hosts });
|
||||
await commitReplica();
|
||||
},
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
@@ -534,6 +535,7 @@ export function AppSideEffects() {
|
||||
}),
|
||||
[
|
||||
buildCurrentSyncPayload,
|
||||
hosts,
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
settings.rehydrateAllFromStorage,
|
||||
@@ -957,6 +959,8 @@ export function AppSideEffects() {
|
||||
|
||||
const copySessionWithCurrentShell = useCallback((sessionId: string) => { return copySessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, getSessionRestoreCwd, hostById, terminalHosts, netcattyBridge, resolveShellSetting, sessionId, sessions, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells, sessions, getSessionRestoreCwd, hostById, terminalHosts]);
|
||||
|
||||
const duplicateSessionWithCurrentShell = useCallback((sessionId: string) => { return duplicateSessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, getSessionRestoreCwd, hostById, terminalHosts, netcattyBridge, resolveShellSetting, sessionId, sessions, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells, sessions, getSessionRestoreCwd, hostById, terminalHosts]);
|
||||
|
||||
const copyWorkspaceWithCurrentShell = useCallback((workspaceId: string) => { return copyWorkspaceWithCurrentShellImpl(() => ({ classifyLocalShellType, collectSessionIds, copyWorkspace, discoveredShells, getSessionRestoreCwd, hostById, terminalHosts, netcattyBridge, resolveShellSetting, sessions, terminalSettings, workspaces }), workspaceId); }, [copyWorkspace, terminalSettings, discoveredShells, sessions, workspaces, getSessionRestoreCwd, hostById, terminalHosts]);
|
||||
|
||||
const copySessionToNewWindowWithCurrentShell = useCallback((sessionId: string) => { return copySessionToNewWindowWithCurrentShellImpl(() => ({ classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast }), sessionId); }, [sessions, terminalSettings, discoveredShells, t]);
|
||||
@@ -1869,6 +1873,7 @@ export function AppSideEffects() {
|
||||
// Terminal glue
|
||||
closeTabsBatch,
|
||||
copySessionWithCurrentShell,
|
||||
duplicateSessionWithCurrentShell,
|
||||
copyWorkspaceWithCurrentShell,
|
||||
copySessionToNewWindowWithCurrentShell,
|
||||
createWorkspaceFromTargets: createWorkspaceFromEffectiveTargets,
|
||||
@@ -1944,6 +1949,7 @@ export function AppSideEffects() {
|
||||
unmanageSource,
|
||||
closeTabsBatch,
|
||||
copySessionWithCurrentShell,
|
||||
duplicateSessionWithCurrentShell,
|
||||
copyWorkspaceWithCurrentShell,
|
||||
copySessionToNewWindowWithCurrentShell,
|
||||
createWorkspaceFromEffectiveTargets,
|
||||
|
||||
@@ -260,7 +260,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
|
||||
const {
|
||||
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
followAppTerminalTheme,
|
||||
@@ -268,7 +268,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
@@ -503,6 +503,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onDuplicateSession={duplicateSessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onEditHost={handleEditHostFromOverlay}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
@@ -724,6 +725,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onReorderWorkspaceSessions={reorderWorkspaceSessions}
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onCopySession={copySessionWithCurrentShell}
|
||||
onDuplicateSession={duplicateSessionWithCurrentShell}
|
||||
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
|
||||
onSplitSession={splitSessionWithCurrentShell}
|
||||
onConnectToHost={handleConnectToHost}
|
||||
@@ -735,6 +737,9 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
onToggleBroadcast={toggleBroadcast}
|
||||
isGlobalBroadcastEnabled={isGlobalBroadcastEnabled}
|
||||
onToggleGlobalBroadcast={onToggleGlobalBroadcast}
|
||||
canUseGlobalBroadcast={canUseGlobalBroadcast}
|
||||
updateHosts={updateTerminalHosts}
|
||||
updateSnippets={updateSnippets}
|
||||
updateSnippetPackages={updateSnippetPackages}
|
||||
|
||||
@@ -180,6 +180,7 @@ export function TerminalHost() {
|
||||
copySessionWithCurrentShell: handlers.copySessionWithCurrentShell,
|
||||
copyWorkspaceWithCurrentShell: handlers.copyWorkspaceWithCurrentShell,
|
||||
copySessionToNewWindowWithCurrentShell: handlers.copySessionToNewWindowWithCurrentShell,
|
||||
duplicateSessionWithCurrentShell: handlers.duplicateSessionWithCurrentShell,
|
||||
closeWorkspace: sessionActions?.closeWorkspace,
|
||||
createWorkspaceFromSessions: sessionActions?.createWorkspaceFromSessions,
|
||||
createWorkspaceFromTargets: handlers.createWorkspaceFromTargets,
|
||||
@@ -206,6 +207,8 @@ export function TerminalHost() {
|
||||
updateTerminalHosts: handlers.updateTerminalHosts,
|
||||
hotkeyScheme: terminalSettings.hotkeyScheme,
|
||||
isBroadcastEnabled: sessionActions?.isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled: sessionActions?.isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast: sessionActions?.canUseGlobalBroadcast,
|
||||
keyBindings: terminalSettings.keyBindings,
|
||||
openNoteRequest: local.openNoteRequest,
|
||||
portForwardingRules: local.portForwardingRules,
|
||||
@@ -237,6 +240,8 @@ export function TerminalHost() {
|
||||
terminalSettings: terminalSettings.terminalSettings,
|
||||
terminalThemeId: terminalSettings.terminalThemeId,
|
||||
toggleBroadcast: sessionActions?.toggleBroadcast,
|
||||
toggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
|
||||
onToggleGlobalBroadcast: sessionActions?.toggleGlobalBroadcast,
|
||||
toggleScriptsSidePanelRef: handlers.toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef: handlers.toggleSidePanelRef,
|
||||
terminalPaneMagnificationRef: handlers.terminalPaneMagnificationRef,
|
||||
|
||||
@@ -70,6 +70,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
@@ -147,6 +150,9 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
reorderTabs,
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
canUseGlobalBroadcast,
|
||||
copySession,
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
@@ -175,8 +181,10 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
getOrderedWorkTabs,
|
||||
getSessionRestoreCwd,
|
||||
isBroadcastEnabled,
|
||||
isGlobalBroadcastEnabled,
|
||||
moveFocusInWorkspace,
|
||||
openLogView,
|
||||
canUseGlobalBroadcast,
|
||||
removeSessionFromWorkspace,
|
||||
renameSessionInline,
|
||||
reorderTabs,
|
||||
@@ -193,6 +201,7 @@ export function SessionPublisher({ persistSessionRestore, children }: SessionPub
|
||||
submitSessionRename,
|
||||
submitWorkspaceRename,
|
||||
toggleBroadcast,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
updateSessionCodingCliProvider,
|
||||
updateSessionDynamicTitle,
|
||||
|
||||
@@ -497,6 +497,13 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Files',
|
||||
'ai.chat.menuImage': 'Image',
|
||||
'ai.chat.menuMentionHost': 'Mention Host',
|
||||
'ai.chat.menuMentionNote': 'Mention Note',
|
||||
'ai.chat.mentionNoteSearch': 'Search notes…',
|
||||
'ai.chat.mentionNoteEmpty': 'No matching notes',
|
||||
'ai.chat.mentionNoteUnavailable': 'This agent cannot read Vault notes in the current connection mode.',
|
||||
'ai.chat.mentionNoteTooMany': 'These notes cannot all be referenced together. Please select fewer notes.',
|
||||
'ai.chat.mentionNoteInvalid': '"{{title}}" could not be attached: the note has an invalid identifier.',
|
||||
'ai.chat.untitledNote': 'Untitled note',
|
||||
'ai.chat.menuUserSkills': 'User Skills',
|
||||
'ai.chat.menuSlashCommands': 'Slash Commands',
|
||||
'ai.chat.slashCommands': 'Slash commands',
|
||||
|
||||
@@ -197,6 +197,12 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': 'The new passwords do not match.',
|
||||
'settings.appLock.validation.incorrect': 'The current lock password is incorrect.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Launch at Login',
|
||||
'settings.autoLaunch.enabled': 'Launch at system startup',
|
||||
'settings.autoLaunch.enabledDesc': 'Automatically start Netcatty when you log in, hidden in the system tray.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'Not available on this platform or in a development build.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Network Proxy',
|
||||
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
|
||||
@@ -1076,6 +1082,7 @@ Highlight the focused split pane:
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Find a host or ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
'vault.hosts.connect': 'Connect',
|
||||
'vault.hosts.openSftp': 'Open SFTP',
|
||||
'vault.view.grid': 'Grid',
|
||||
'vault.view.list': 'List',
|
||||
'vault.view.tree': 'Tree',
|
||||
|
||||
@@ -117,6 +117,9 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
'terminal.dragDrop.destinationUnknown': 'Could not determine the current terminal folder. Enable directory tracking, or open SFTP and choose an upload folder first.',
|
||||
'terminal.dragDrop.uploadCancelled': 'The upload was cancelled because the terminal connection changed or could not be reused. Drop the files again after reconnecting.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'This folder is not writable as the login user. Enable Sudo elevation in host settings, or go back to your user directory and drop again.',
|
||||
'terminal.search.placeholder': 'Search...',
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
@@ -630,6 +633,7 @@ export const enTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.duplicateSession': 'Duplicate Session',
|
||||
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
|
||||
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
|
||||
@@ -148,6 +148,10 @@ export const enVaultMessages: Messages = {
|
||||
'vault.import.mobaxterm.utf8Desc': 'Use for files saved or converted as UTF-8.',
|
||||
'vault.import.mobaxterm.gb18030': 'Chinese Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': 'Use for legacy files exported on Chinese Windows.',
|
||||
'vault.import.mobaxterm.masterPassword': 'Master password (optional)',
|
||||
'vault.import.mobaxterm.masterPasswordPlaceholder': 'Enter the MobaXterm master password',
|
||||
'vault.import.mobaxterm.masterPasswordHint':
|
||||
'Needed to import saved passwords from .mobaconf or MobaXterm.ini. Leave blank to import sessions only.',
|
||||
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
|
||||
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
|
||||
'vault.import.sshConfig.importOnly': 'Import Only',
|
||||
@@ -269,6 +273,12 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
'sftp.bookmark.removeConfirm': 'Remove bookmark {path}?',
|
||||
'sftp.bookmark.manage': 'Manage',
|
||||
'sftp.bookmark.rename': 'Rename bookmark',
|
||||
'sftp.bookmark.done': 'Done',
|
||||
'sftp.bookmark.moveUp': 'Move {label} up',
|
||||
'sftp.bookmark.moveDown': 'Move {label} down',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
@@ -309,6 +319,10 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.viewMode.tree': 'Tree view',
|
||||
'sftp.viewMode.switchToList': 'Switch to list view',
|
||||
'sftp.viewMode.switchToTree': 'Switch to tree view',
|
||||
'sftp.listDensity.compact': 'Compact list',
|
||||
'sftp.listDensity.comfortable': 'Comfortable list',
|
||||
'sftp.listDensity.switchToCompact': 'Switch to compact list',
|
||||
'sftp.listDensity.switchToComfortable': 'Switch to comfortable list',
|
||||
'sftp.tree.loadError': 'Failed to load directory',
|
||||
'sftp.tree.loading': 'Loading...',
|
||||
'sftp.kind.folder': 'Folder',
|
||||
@@ -404,6 +418,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': 'Go to home',
|
||||
'sftp.goRoot': 'Go to root',
|
||||
'sftp.folderName': 'Folder name',
|
||||
'sftp.folderName.placeholder': 'Enter folder name',
|
||||
'sftp.fileName': 'File name',
|
||||
@@ -695,6 +710,11 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Address',
|
||||
'hostDetails.hostname.placeholder': 'IP or Hostname',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.os.title': 'Operating System',
|
||||
'hostDetails.os.desc': 'Used for environment reporting and shell-aware behavior.',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
'hostDetails.section.sftp': 'SFTP Settings',
|
||||
'hostDetails.sftp.fileProtocol': 'File Protocol',
|
||||
'hostDetails.sftp.fileProtocol.desc': 'Auto tries SFTP, then falls back to SCP mode when the SFTP subsystem is unavailable (e.g. some NAS). Force SCP for hosts that only support SCP.',
|
||||
|
||||
@@ -497,6 +497,13 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Archivos',
|
||||
'ai.chat.menuImage': 'Imagen',
|
||||
'ai.chat.menuMentionHost': 'Mencionar host',
|
||||
'ai.chat.menuMentionNote': 'Mencionar nota',
|
||||
'ai.chat.mentionNoteSearch': 'Buscar notas…',
|
||||
'ai.chat.mentionNoteEmpty': 'Sin notas coincidentes',
|
||||
'ai.chat.mentionNoteUnavailable': 'Este agente no puede leer notas de la bóveda en el modo de conexión actual.',
|
||||
'ai.chat.mentionNoteTooMany': 'No se pueden referenciar todas estas notas juntas. Selecciona menos notas.',
|
||||
'ai.chat.mentionNoteInvalid': 'No se pudo adjuntar «{{title}}»: la nota tiene un identificador no válido.',
|
||||
'ai.chat.untitledNote': 'Nota sin título',
|
||||
'ai.chat.menuUserSkills': 'Habilidades de usuario',
|
||||
'ai.chat.menuSlashCommands': 'Comandos de barra',
|
||||
'ai.chat.slashCommands': 'Comandos de barra',
|
||||
|
||||
@@ -1040,6 +1040,7 @@ Resalta el panel dividido enfocado:
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Busca un host o escribe ssh usuario@hostname / ssh -p 2222 usuario@hostname...',
|
||||
'vault.hosts.connect': 'Conectar',
|
||||
'vault.hosts.openSftp': 'Abrir SFTP',
|
||||
'vault.view.grid': 'Cuadrícula',
|
||||
'vault.view.list': 'Lista',
|
||||
'vault.view.tree': 'Árbol',
|
||||
|
||||
@@ -117,6 +117,9 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.notConnected': 'No se pueden soltar archivos: la terminal no está conectada',
|
||||
'terminal.dragDrop.errorTitle': 'Error al soltar',
|
||||
'terminal.dragDrop.errorMessage': 'No se pudieron procesar los archivos soltados',
|
||||
'terminal.dragDrop.destinationUnknown': 'No se pudo determinar la carpeta actual del terminal. Active el seguimiento de directorios o abra SFTP y elija primero una carpeta de carga.',
|
||||
'terminal.dragDrop.uploadCancelled': 'La carga se canceló porque la conexión del terminal cambió o no se pudo reutilizar. Vuelva a soltar los archivos después de reconectar.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'Esta carpeta no es escribible con el usuario de inicio de sesión. Active la elevación Sudo en la configuración del host, o vuelva al directorio de usuario y suelte de nuevo.',
|
||||
'terminal.search.placeholder': 'Buscar...',
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
@@ -630,6 +633,7 @@ export const esTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Registro:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copiar pestaña',
|
||||
'tabs.duplicateSession': 'Duplicar sesión',
|
||||
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
|
||||
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
|
||||
'tabs.closeOthers': 'Cerrar las demás',
|
||||
|
||||
@@ -148,6 +148,10 @@ export const esVaultMessages: Messages = {
|
||||
'vault.import.mobaxterm.utf8Desc': 'Úsalo para archivos guardados o convertidos como UTF-8.',
|
||||
'vault.import.mobaxterm.gb18030': 'Windows en chino',
|
||||
'vault.import.mobaxterm.gb18030Desc': 'Úsalo para archivos antiguos exportados en Windows en chino.',
|
||||
'vault.import.mobaxterm.masterPassword': 'Contraseña maestra (opcional)',
|
||||
'vault.import.mobaxterm.masterPasswordPlaceholder': 'Introduce la contraseña maestra de MobaXterm',
|
||||
'vault.import.mobaxterm.masterPasswordHint':
|
||||
'Hace falta para importar las contraseñas guardadas de .mobaconf o MobaXterm.ini. Déjalo vacío para importar solo las sesiones.',
|
||||
'vault.import.sshConfig.chooseMode': 'Elige cómo importar tu archivo de configuración SSH.',
|
||||
'vault.import.sshConfig.modeQuestion': '¿Cómo te gustaría importar?',
|
||||
'vault.import.sshConfig.importOnly': 'Solo importar',
|
||||
@@ -269,6 +273,12 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.bookmark.addGlobal': '+Global',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Guardar como marcador global (compartido entre todos los hosts)',
|
||||
'sftp.bookmark.empty': 'Aún no hay marcadores',
|
||||
'sftp.bookmark.removeConfirm': '¿Quitar el marcador {path}?',
|
||||
'sftp.bookmark.manage': 'Administrar',
|
||||
'sftp.bookmark.rename': 'Renombrar marcador',
|
||||
'sftp.bookmark.done': 'Listo',
|
||||
'sftp.bookmark.moveUp': 'Mover {label} hacia arriba',
|
||||
'sftp.bookmark.moveDown': 'Mover {label} hacia abajo',
|
||||
'sftp.columns.name': 'Nombre',
|
||||
'sftp.columns.modified': 'Modificado',
|
||||
'sftp.columns.size': 'Tamaño',
|
||||
@@ -309,6 +319,10 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.viewMode.tree': 'Vista de árbol',
|
||||
'sftp.viewMode.switchToList': 'Cambiar a vista de lista',
|
||||
'sftp.viewMode.switchToTree': 'Cambiar a vista de árbol',
|
||||
'sftp.listDensity.compact': 'Lista compacta',
|
||||
'sftp.listDensity.comfortable': 'Lista cómoda',
|
||||
'sftp.listDensity.switchToCompact': 'Cambiar a lista compacta',
|
||||
'sftp.listDensity.switchToComfortable': 'Cambiar a lista cómoda',
|
||||
'sftp.tree.loadError': 'No se pudo cargar el directorio',
|
||||
'sftp.tree.loading': 'Cargando...',
|
||||
'sftp.kind.folder': 'Carpeta',
|
||||
@@ -404,6 +418,7 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': 'Ir al inicio',
|
||||
'sftp.goRoot': 'Ir a la raíz',
|
||||
'sftp.folderName': 'Nombre de la carpeta',
|
||||
'sftp.folderName.placeholder': 'Ingresa el nombre de la carpeta',
|
||||
'sftp.fileName': 'Nombre del archivo',
|
||||
@@ -695,6 +710,11 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Dirección',
|
||||
'hostDetails.hostname.placeholder': 'IP o nombre de host',
|
||||
'hostDetails.section.general': 'General',
|
||||
'hostDetails.os.title': 'Sistema operativo',
|
||||
'hostDetails.os.desc': 'Se usa para informar del entorno y adaptar el comportamiento del shell.',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
'hostDetails.section.sftp': 'Configuración de SFTP',
|
||||
'hostDetails.sftp.fileProtocol': 'Protocolo de archivos',
|
||||
'hostDetails.sftp.fileProtocol.desc': 'Auto intenta SFTP y luego recurre al modo SCP cuando el subsistema SFTP no está disponible (p. ej., algunos NAS). Fuerza SCP para hosts que solo admiten SCP.',
|
||||
|
||||
@@ -449,6 +449,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': 'Файлы',
|
||||
'ai.chat.menuImage': 'Изображение',
|
||||
'ai.chat.menuMentionHost': 'Упомянуть хост',
|
||||
'ai.chat.menuMentionNote': 'Упомянуть заметку',
|
||||
'ai.chat.mentionNoteSearch': 'Поиск заметок…',
|
||||
'ai.chat.mentionNoteEmpty': 'Нет подходящих заметок',
|
||||
'ai.chat.mentionNoteUnavailable': 'Этот агент не может читать заметки хранилища в текущем режиме подключения.',
|
||||
'ai.chat.mentionNoteTooMany': 'Невозможно сослаться на все эти заметки одновременно. Выберите меньше заметок.',
|
||||
'ai.chat.mentionNoteInvalid': '«{{title}}» не удалось прикрепить: у заметки недопустимый идентификатор.',
|
||||
'ai.chat.untitledNote': 'Заметка без названия',
|
||||
'ai.chat.menuUserSkills': 'Пользовательские навыки',
|
||||
'ai.chat.menuSlashCommands': 'Команды /',
|
||||
'ai.chat.slashCommands': 'Команды /',
|
||||
|
||||
@@ -196,6 +196,12 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': 'Новые пароли не совпадают.',
|
||||
'settings.appLock.validation.incorrect': 'Текущий пароль блокировки неверен.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Запуск при входе в систему',
|
||||
'settings.autoLaunch.enabled': 'Запускать при старте системы',
|
||||
'settings.autoLaunch.enabledDesc': 'Автоматически запускать Netcatty при входе в систему, свернутым в системный трей.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'Недоступно на этой платформе или в сборке для разработки.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Сетевой прокси',
|
||||
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
|
||||
|
||||
@@ -138,6 +138,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
|
||||
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
|
||||
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
|
||||
'terminal.dragDrop.destinationUnknown': 'Не удалось определить текущую папку терминала. Включите отслеживание каталогов или сначала откройте SFTP и выберите папку загрузки.',
|
||||
'terminal.dragDrop.uploadCancelled': 'Загрузка отменена: соединение терминала изменилось или его не удалось повторно использовать. После переподключения перетащите файлы снова.',
|
||||
'terminal.dragDrop.needsSudoElevation': 'Эта папка недоступна для записи пользователю входа. Включите повышение прав Sudo в настройках хоста или вернитесь в домашний каталог и перетащите файлы снова.',
|
||||
'terminal.search.placeholder': 'Поиск...',
|
||||
'terminal.search.noResults': 'Ничего не найдено',
|
||||
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
|
||||
@@ -645,6 +648,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': 'Журнал:',
|
||||
'tabs.logLocal': 'Локальный',
|
||||
'tabs.copyTab': 'Копировать вкладку',
|
||||
'tabs.duplicateSession': 'Дублировать сессию',
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
|
||||
@@ -95,6 +95,7 @@ export const ruVaultMessages: Messages = {
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': 'Найти хост или ssh user@hostname / ssh -p 2222 user@hostname...',
|
||||
'vault.hosts.connect': 'Подключиться',
|
||||
'vault.hosts.openSftp': 'Открыть SFTP',
|
||||
'vault.view.grid': 'Сетка',
|
||||
'vault.view.list': 'Список',
|
||||
'vault.view.tree': 'Дерево',
|
||||
@@ -185,6 +186,10 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.import.mobaxterm.utf8Desc': 'Для файлов, сохранённых или преобразованных в UTF-8.',
|
||||
'vault.import.mobaxterm.gb18030': 'Китайская Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': 'Для старых файлов, экспортированных в китайской Windows.',
|
||||
'vault.import.mobaxterm.masterPassword': 'Мастер-пароль (необязательно)',
|
||||
'vault.import.mobaxterm.masterPasswordPlaceholder': 'Введите мастер-пароль MobaXterm',
|
||||
'vault.import.mobaxterm.masterPasswordHint':
|
||||
'Нужен, чтобы импортировать сохранённые пароли из .mobaconf или MobaXterm.ini. Оставьте пустым, чтобы импортировать только сеансы.',
|
||||
'vault.import.sshConfig.chooseMode': 'Выберите, как импортировать ваш файл SSH-конфига.',
|
||||
'vault.import.sshConfig.modeQuestion': 'Как вы хотите выполнить импорт?',
|
||||
'vault.import.sshConfig.importOnly': 'Только импорт',
|
||||
@@ -306,6 +311,12 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.bookmark.addGlobal': '+Глобальная',
|
||||
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
|
||||
'sftp.bookmark.empty': 'Пока нет закладок',
|
||||
'sftp.bookmark.removeConfirm': 'Удалить закладку {path}?',
|
||||
'sftp.bookmark.manage': 'Управление',
|
||||
'sftp.bookmark.rename': 'Переименовать закладку',
|
||||
'sftp.bookmark.done': 'Готово',
|
||||
'sftp.bookmark.moveUp': 'Переместить {label} вверх',
|
||||
'sftp.bookmark.moveDown': 'Переместить {label} вниз',
|
||||
'sftp.columns.name': 'Имя',
|
||||
'sftp.columns.modified': 'Изменён',
|
||||
'sftp.columns.size': 'Размер',
|
||||
@@ -346,6 +357,10 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.viewMode.tree': 'Дерево',
|
||||
'sftp.viewMode.switchToList': 'Переключиться на список',
|
||||
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
|
||||
'sftp.listDensity.compact': 'Компактный список',
|
||||
'sftp.listDensity.comfortable': 'Обычный список',
|
||||
'sftp.listDensity.switchToCompact': 'Переключиться на компактный список',
|
||||
'sftp.listDensity.switchToComfortable': 'Переключиться на обычный список',
|
||||
'sftp.tree.loadError': 'Не удалось загрузить каталог',
|
||||
'sftp.tree.loading': 'Загрузка...',
|
||||
'sftp.kind.folder': 'Папка',
|
||||
@@ -440,6 +455,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': 'Перейти в домашний каталог',
|
||||
'sftp.goRoot': 'Перейти в корень',
|
||||
'sftp.folderName': 'Имя папки',
|
||||
'sftp.folderName.placeholder': 'Введите имя папки',
|
||||
'sftp.fileName': 'Имя файла',
|
||||
@@ -731,6 +747,11 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.section.address': 'Адрес',
|
||||
'hostDetails.hostname.placeholder': 'IP или имя хоста',
|
||||
'hostDetails.section.general': 'Общие',
|
||||
'hostDetails.os.title': 'Операционная система',
|
||||
'hostDetails.os.desc': 'Используется для сведений о среде и адаптации командной оболочки.',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
'hostDetails.section.sftp': 'Настройки SFTP',
|
||||
'hostDetails.sftp.fileProtocol': 'Протокол файлов',
|
||||
'hostDetails.sftp.fileProtocol.desc': 'Авто сначала пробует SFTP, затем SCP-режим, если подсистема SFTP недоступна. Принудительный SCP — для хостов только с SCP.',
|
||||
|
||||
@@ -24,6 +24,9 @@ const KEYS = [
|
||||
"vault.import.progress.persistFailed",
|
||||
"vault.import.progress.rollbackFailed",
|
||||
"vault.import.sshConfig.managedDestinationHint",
|
||||
"vault.import.mobaxterm.masterPassword",
|
||||
"vault.import.mobaxterm.masterPasswordPlaceholder",
|
||||
"vault.import.mobaxterm.masterPasswordHint",
|
||||
] as const;
|
||||
|
||||
test("bulk vault import and group-selection copy exists in every locale", () => {
|
||||
|
||||
@@ -497,6 +497,13 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': '文件',
|
||||
'ai.chat.menuImage': '图片',
|
||||
'ai.chat.menuMentionHost': '提及主机',
|
||||
'ai.chat.menuMentionNote': '提及笔记',
|
||||
'ai.chat.mentionNoteSearch': '搜索笔记…',
|
||||
'ai.chat.mentionNoteEmpty': '没有匹配的笔记',
|
||||
'ai.chat.mentionNoteUnavailable': '当前连接方式下,此 AI 无法读取保险箱笔记。',
|
||||
'ai.chat.mentionNoteTooMany': '这些笔记无法一起引用,请减少选择。',
|
||||
'ai.chat.mentionNoteInvalid': '无法附加「{{title}}」:笔记的标识符无效。',
|
||||
'ai.chat.untitledNote': '未命名笔记',
|
||||
'ai.chat.menuUserSkills': '用户 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷命令',
|
||||
'ai.chat.slashCommands': '快捷命令',
|
||||
|
||||
@@ -194,6 +194,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': '两次输入的新密码不一致。',
|
||||
'settings.appLock.validation.incorrect': '当前锁定密码不正确。',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': '开机自启动',
|
||||
'settings.autoLaunch.enabled': '开机自动启动',
|
||||
'settings.autoLaunch.enabledDesc': '系统启动时自动运行 Netcatty,并隐藏到系统托盘。',
|
||||
'settings.autoLaunch.unsupportedDesc': '当前平台不支持此功能,或处于开发环境。',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': '网络代理',
|
||||
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
|
||||
@@ -712,6 +718,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': '查找主机或 ssh user@hostname / ssh -p 2222 user@hostname…',
|
||||
'vault.hosts.connect': '连接',
|
||||
'vault.hosts.openSftp': '打开 SFTP',
|
||||
'vault.view.grid': '网格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '树形',
|
||||
@@ -804,6 +811,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.import.mobaxterm.utf8Desc': '适用于已保存或转换为 UTF-8 的文件。',
|
||||
'vault.import.mobaxterm.gb18030': '中文 Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': '适用于中文 Windows 上导出的旧版文件。',
|
||||
'vault.import.mobaxterm.masterPassword': '主密码(可选)',
|
||||
'vault.import.mobaxterm.masterPasswordPlaceholder': '输入 MobaXterm 主密码',
|
||||
'vault.import.mobaxterm.masterPasswordHint':
|
||||
'导入 .mobaconf 或 MobaXterm.ini 中保存的密码时需要填写。留空则只导入会话。',
|
||||
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
|
||||
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
|
||||
'vault.import.sshConfig.importOnly': '仅导入',
|
||||
@@ -893,6 +904,12 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.bookmark.addGlobal': '+全局',
|
||||
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
'sftp.bookmark.removeConfirm': '删除收藏 {path}?',
|
||||
'sftp.bookmark.manage': '管理',
|
||||
'sftp.bookmark.rename': '重命名收藏',
|
||||
'sftp.bookmark.done': '完成',
|
||||
'sftp.bookmark.moveUp': '上移 {label}',
|
||||
'sftp.bookmark.moveDown': '下移 {label}',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.configure': '选择显示的列',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
@@ -933,6 +950,10 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.viewMode.tree': '树形视图',
|
||||
'sftp.viewMode.switchToList': '切换到列表视图',
|
||||
'sftp.viewMode.switchToTree': '切换到树形视图',
|
||||
'sftp.listDensity.compact': '紧凑列表',
|
||||
'sftp.listDensity.comfortable': '宽松列表',
|
||||
'sftp.listDensity.switchToCompact': '切换到紧凑列表',
|
||||
'sftp.listDensity.switchToComfortable': '切换到宽松列表',
|
||||
'sftp.tree.loadError': '加载目录失败',
|
||||
'sftp.tree.loading': '加载中...',
|
||||
'sftp.kind.folder': '文件夹',
|
||||
@@ -1027,6 +1048,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': '返回主目录',
|
||||
'sftp.goRoot': '回到根目录',
|
||||
'sftp.folderName': '文件夹名称',
|
||||
'sftp.folderName.placeholder': '输入文件夹名称',
|
||||
'sftp.fileName': '文件名称',
|
||||
|
||||
@@ -718,6 +718,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.duplicateSession': '复制会话',
|
||||
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
|
||||
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
|
||||
@@ -133,6 +133,11 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.section.address': '地址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主机名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.os.title': '操作系统',
|
||||
'hostDetails.os.desc': '用于环境信息和适配对应的命令行行为。',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
'hostDetails.section.sftp': 'SFTP 设置',
|
||||
'hostDetails.sftp.fileProtocol': '文件协议',
|
||||
'hostDetails.sftp.fileProtocol.desc': '自动优先 SFTP;当服务器无 SFTP 子系统时回退到 SCP 模式(如部分 NAS)。仅支持 SCP 的主机可强制 SCP。',
|
||||
@@ -519,6 +524,9 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
|
||||
'terminal.dragDrop.errorTitle': '拖放错误',
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
'terminal.dragDrop.destinationUnknown': '无法确定当前终端目录。请启用目录跟踪,或先打开 SFTP 选择上传目录。',
|
||||
'terminal.dragDrop.uploadCancelled': '终端连接已变化或无法继续复用,本次上传已取消。请重新连接后再次拖放文件。',
|
||||
'terminal.dragDrop.needsSudoElevation': '当前目录登录用户无法写入。请在主机设置中开启 Sudo 提权,或回到用户目录后再拖放。',
|
||||
'terminal.search.placeholder': '搜索…',
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
|
||||
@@ -496,6 +496,13 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.menuFiles': '檔案',
|
||||
'ai.chat.menuImage': '圖片',
|
||||
'ai.chat.menuMentionHost': '提及主機',
|
||||
'ai.chat.menuMentionNote': '提及筆記',
|
||||
'ai.chat.mentionNoteSearch': '搜尋筆記…',
|
||||
'ai.chat.mentionNoteEmpty': '沒有符合的筆記',
|
||||
'ai.chat.mentionNoteUnavailable': '目前連線方式下,此 AI 無法讀取保險箱筆記。',
|
||||
'ai.chat.mentionNoteTooMany': '這些筆記無法一起引用,請減少選擇。',
|
||||
'ai.chat.mentionNoteInvalid': '無法附加「{{title}}」:筆記的識別碼無效。',
|
||||
'ai.chat.untitledNote': '未命名筆記',
|
||||
'ai.chat.menuUserSkills': '使用者 Skills',
|
||||
'ai.chat.menuSlashCommands': '快捷指令',
|
||||
'ai.chat.slashCommands': '快捷指令',
|
||||
|
||||
@@ -197,6 +197,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appLock.validation.mismatch': '兩次輸入的新密碼不一致。',
|
||||
'settings.appLock.validation.incorrect': '目前的鎖定密碼不正確。',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': '開機自動啟動',
|
||||
'settings.autoLaunch.enabled': '開機時自動啟動',
|
||||
'settings.autoLaunch.enabledDesc': '系統啟動時自動執行 Netcatty,並隱藏到系統匣。',
|
||||
'settings.autoLaunch.unsupportedDesc': '目前平台不支援此功能,或處於開發環境。',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': '網路代理',
|
||||
'settings.system.networkProxy.description': '用於雲端同步與 AI 服務商的 HTTP(S) 代理。與 SSH 的 ProxyJump / ProxyCommand 設定無關。',
|
||||
@@ -715,6 +721,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
// Vault hosts header/actions
|
||||
'vault.hosts.search.placeholder': '查詢主機或 ssh user@hostname / ssh -p 2222 user@hostname…',
|
||||
'vault.hosts.connect': '連線',
|
||||
'vault.hosts.openSftp': '開啟 SFTP',
|
||||
'vault.view.grid': '網格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '樹形',
|
||||
@@ -807,6 +814,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.import.mobaxterm.utf8Desc': '適用於已儲存或轉換為 UTF-8 的檔案。',
|
||||
'vault.import.mobaxterm.gb18030': '中文 Windows',
|
||||
'vault.import.mobaxterm.gb18030Desc': '適用於中文 Windows 上匯出的舊版檔案。',
|
||||
'vault.import.mobaxterm.masterPassword': '主密碼(選填)',
|
||||
'vault.import.mobaxterm.masterPasswordPlaceholder': '輸入 MobaXterm 主密碼',
|
||||
'vault.import.mobaxterm.masterPasswordHint':
|
||||
'匯入 .mobaconf 或 MobaXterm.ini 中已儲存的密碼時需要填寫。留空則只匯入工作階段。',
|
||||
'vault.import.sshConfig.chooseMode': '選擇如何匯入你的 SSH config 檔案。',
|
||||
'vault.import.sshConfig.modeQuestion': '你希望如何匯入?',
|
||||
'vault.import.sshConfig.importOnly': '僅匯入',
|
||||
@@ -896,6 +907,12 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.bookmark.addGlobal': '+全域',
|
||||
'sftp.bookmark.addGlobalTooltip': '儲存為全域收藏(所有主機共享)',
|
||||
'sftp.bookmark.empty': '暫無收藏路徑',
|
||||
'sftp.bookmark.removeConfirm': '刪除收藏 {path}?',
|
||||
'sftp.bookmark.manage': '管理',
|
||||
'sftp.bookmark.rename': '重新命名收藏',
|
||||
'sftp.bookmark.done': '完成',
|
||||
'sftp.bookmark.moveUp': '上移 {label}',
|
||||
'sftp.bookmark.moveDown': '下移 {label}',
|
||||
'sftp.columns.name': '名稱',
|
||||
'sftp.columns.configure': '選擇顯示的欄位',
|
||||
'sftp.columns.modified': '修改時間',
|
||||
@@ -936,6 +953,10 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.viewMode.tree': '樹形檢視',
|
||||
'sftp.viewMode.switchToList': '切換到列表檢視',
|
||||
'sftp.viewMode.switchToTree': '切換到樹形檢視',
|
||||
'sftp.listDensity.compact': '緊湊列表',
|
||||
'sftp.listDensity.comfortable': '寬鬆列表',
|
||||
'sftp.listDensity.switchToCompact': '切換到緊湊列表',
|
||||
'sftp.listDensity.switchToComfortable': '切換到寬鬆列表',
|
||||
'sftp.tree.loadError': '載入目錄失敗',
|
||||
'sftp.tree.loading': '載入中...',
|
||||
'sftp.kind.folder': '資料夾',
|
||||
@@ -1030,6 +1051,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.encoding.utf8': 'UTF-8',
|
||||
'sftp.encoding.gb18030': 'GB18030',
|
||||
'sftp.goHome': '返回主目錄',
|
||||
'sftp.goRoot': '回到根目錄',
|
||||
'sftp.folderName': '資料夾名稱',
|
||||
'sftp.folderName.placeholder': '輸入資料夾名稱',
|
||||
'sftp.fileName': '檔案名稱',
|
||||
|
||||
@@ -718,6 +718,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'tabs.logPrefix': '日誌:',
|
||||
'tabs.logLocal': '本機',
|
||||
'tabs.copyTab': '複製分頁',
|
||||
'tabs.duplicateSession': '複製工作階段',
|
||||
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
|
||||
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
|
||||
'tabs.closeOthers': '關閉其他分頁',
|
||||
|
||||
@@ -132,6 +132,11 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.section.address': '位址',
|
||||
'hostDetails.hostname.placeholder': 'IP 或 主機名',
|
||||
'hostDetails.section.general': '通用',
|
||||
'hostDetails.os.title': '作業系統',
|
||||
'hostDetails.os.desc': '用於環境資訊和配合對應的命令列行為。',
|
||||
'hostDetails.os.linux': 'Linux',
|
||||
'hostDetails.os.windows': 'Windows',
|
||||
'hostDetails.os.macos': 'macOS',
|
||||
'hostDetails.section.sftp': 'SFTP 設定',
|
||||
'hostDetails.sftp.fileProtocol': '檔案協定',
|
||||
'hostDetails.sftp.fileProtocol.desc': '自動優先 SFTP;當伺服器無 SFTP 子系統時改用 SCP 模式(如部分 NAS)。僅支援 SCP 的主機可強制 SCP。',
|
||||
@@ -519,6 +524,9 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.dragDrop.notConnected': '無法拖放檔案 - 終端未連線',
|
||||
'terminal.dragDrop.errorTitle': '拖放錯誤',
|
||||
'terminal.dragDrop.errorMessage': '處理拖放檔案失敗',
|
||||
'terminal.dragDrop.destinationUnknown': '無法確定目前終端目錄。請啟用目錄追蹤,或先開啟 SFTP 選擇上傳目錄。',
|
||||
'terminal.dragDrop.uploadCancelled': '終端連線已變更或無法繼續重用,本次上傳已取消。請重新連線後再次拖放檔案。',
|
||||
'terminal.dragDrop.needsSudoElevation': '目前目錄登入使用者無法寫入。請在主機設定中開啟 Sudo 提權,或回到使用者目錄後再拖放。',
|
||||
'terminal.search.placeholder': '搜尋…',
|
||||
'terminal.search.noResults': '無結果',
|
||||
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
@@ -47,3 +48,466 @@ test('orphan cleanup keeps durable Catty output while explicit deletion removes
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function makeSession(id: string, updatedAt: number, messages: unknown[]): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'agent',
|
||||
scope: { type: 'terminal', targetId: 't' },
|
||||
messages: messages as never,
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
const messages = () => [{
|
||||
id: 'm',
|
||||
role: 'assistant' as const,
|
||||
content: 'hello',
|
||||
timestamp: 0,
|
||||
providerContinuation: {
|
||||
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
||||
},
|
||||
}];
|
||||
const sessions = [
|
||||
makeSession('newest', 3, messages()),
|
||||
makeSession('older', 2, messages()),
|
||||
makeSession('oldest', 1, messages()),
|
||||
];
|
||||
|
||||
const hasCiphertext = (result: { sessions: AISession[] }) =>
|
||||
result.sessions.some(s => s.messages.some(m =>
|
||||
m.providerContinuation?.reasoningParts?.some(p => typeof p.providerOptions?.openai?.reasoningEncryptedContent === 'string')));
|
||||
|
||||
// Removing replay-only ciphertext from the oldest session fits the budget,
|
||||
// so every visible chat remains available after restart.
|
||||
const withOldestCiphertextStripped = serializeSessionsForStorage(sessions, 600 * 1024);
|
||||
assert.deepEqual(
|
||||
withOldestCiphertextStripped.sessions.map(s => s.id),
|
||||
['newest', 'older', 'oldest'],
|
||||
);
|
||||
assert.equal(hasCiphertext(withOldestCiphertextStripped), true);
|
||||
assert.ok(withOldestCiphertextStripped.json.length <= 600 * 1024);
|
||||
|
||||
// Tight budget that even a single session exceeds: ciphertext stripped but
|
||||
// the visible conversation content survives.
|
||||
const withCiphertextStripped = serializeSessionsForStorage(sessions, 220 * 1024);
|
||||
assert.ok(withCiphertextStripped.json.length <= 220 * 1024);
|
||||
assert.equal(hasCiphertext(withCiphertextStripped), false);
|
||||
assert.deepEqual(withCiphertextStripped.sessions.map(s => s.id), ['newest', 'older', 'oldest']);
|
||||
assert.equal(withCiphertextStripped.sessions[0].messages[0].content, 'hello');
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage keeps new ciphertext when an oversized old chat must be dropped', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
|
||||
const newest = makeSession('newest', 2, [{
|
||||
id: 'new-message',
|
||||
role: 'assistant',
|
||||
content: 'new visible chat',
|
||||
timestamp: 2,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
|
||||
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
||||
},
|
||||
}]);
|
||||
const oversizedOld = makeSession('oldest', 1, [{
|
||||
id: 'old-message',
|
||||
role: 'user',
|
||||
content: 'x'.repeat(300 * 1024),
|
||||
timestamp: 1,
|
||||
}]);
|
||||
|
||||
const result = serializeSessionsForStorage([oversizedOld, newest], 200 * 1024);
|
||||
|
||||
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
|
||||
assert.equal(
|
||||
result.sessions[0].messages[0].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
ciphertext,
|
||||
);
|
||||
assert.ok(result.json.length <= 200 * 1024);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage prioritizes a usable newest chat over older visible history', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB
|
||||
const newest = makeSession('newest', 2, [{
|
||||
id: 'new-message',
|
||||
role: 'assistant',
|
||||
content: 'new visible chat',
|
||||
timestamp: 2,
|
||||
providerContinuation: {
|
||||
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
|
||||
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
||||
},
|
||||
}]);
|
||||
const olderVisible = makeSession('oldest', 1, [{
|
||||
id: 'old-message',
|
||||
role: 'user',
|
||||
content: 'x'.repeat(100 * 1024),
|
||||
timestamp: 1,
|
||||
}]);
|
||||
|
||||
const result = serializeSessionsForStorage([olderVisible, newest], 150 * 1024);
|
||||
|
||||
assert.deepEqual(result.sessions.map(session => session.id), ['newest']);
|
||||
assert.equal(
|
||||
result.sessions[0].messages[0].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
ciphertext,
|
||||
);
|
||||
assert.ok(result.json.length <= 150 * 1024);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage keeps the newest replayable turn in one oversized chat', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
|
||||
const reasoning = (itemId: string) => ({
|
||||
source: { providerConfigId: 'p', providerType: 'openai', modelId: 'm' },
|
||||
reasoningParts: [{
|
||||
text: '',
|
||||
providerOptions: { openai: { itemId, reasoningEncryptedContent: ciphertext } },
|
||||
}],
|
||||
});
|
||||
const session = makeSession('active', 1, [
|
||||
{
|
||||
id: 'assistant-old',
|
||||
role: 'assistant',
|
||||
content: 'old turn',
|
||||
timestamp: 1,
|
||||
providerContinuation: reasoning('rs_old'),
|
||||
toolCalls: [{ id: 'call-old', name: 'terminal_execute', arguments: { command: 'pwd' } }],
|
||||
},
|
||||
{
|
||||
id: 'tool-old',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 2,
|
||||
toolResults: [{ toolCallId: 'call-old', content: '/tmp' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-new',
|
||||
role: 'assistant',
|
||||
content: 'new turn',
|
||||
timestamp: 3,
|
||||
providerContinuation: reasoning('rs_new'),
|
||||
toolCalls: [{ id: 'call-new', name: 'terminal_execute', arguments: { command: 'ls' } }],
|
||||
},
|
||||
{
|
||||
id: 'tool-new',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 4,
|
||||
toolResults: [{ toolCallId: 'call-new', content: 'file.txt' }],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = serializeSessionsForStorage([session], 150 * 1024);
|
||||
|
||||
assert.ok(result.json.length <= 150 * 1024);
|
||||
assert.deepEqual(result.sessions[0].messages.map(message => message.id), [
|
||||
'assistant-old',
|
||||
'tool-old',
|
||||
'assistant-new',
|
||||
'tool-new',
|
||||
]);
|
||||
assert.equal(
|
||||
result.sessions[0].messages[0].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
result.sessions[0].messages[2].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
ciphertext,
|
||||
);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage drops compacted ciphertext before protecting the newest chat', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(20 * 1024); // ~100 KB per reasoning turn
|
||||
const compactedMessages = Array.from({ length: 17 }, (_, index) => ({
|
||||
id: `compacted-${index}`,
|
||||
role: 'assistant' as const,
|
||||
content: `compacted turn ${index}`,
|
||||
timestamp: index,
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: '',
|
||||
providerOptions: {
|
||||
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
|
||||
},
|
||||
}],
|
||||
},
|
||||
}));
|
||||
const newest = {
|
||||
...makeSession('newest', 2, [
|
||||
...compactedMessages,
|
||||
{
|
||||
id: 'recent',
|
||||
role: 'assistant',
|
||||
content: 'recent turn',
|
||||
timestamp: 18,
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: '',
|
||||
providerOptions: {
|
||||
openai: { itemId: 'rs_recent', reasoningEncryptedContent: ciphertext },
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
]),
|
||||
contextCompaction: {
|
||||
summary: 'The first 17 turns were summarized.',
|
||||
compactedMessageCount: 17,
|
||||
},
|
||||
};
|
||||
const olderVisible = makeSession('older', 1, [{
|
||||
id: 'older-visible',
|
||||
role: 'user',
|
||||
content: 'x'.repeat(600 * 1024),
|
||||
timestamp: 1,
|
||||
}]);
|
||||
|
||||
const result = serializeSessionsForStorage([olderVisible, newest]);
|
||||
|
||||
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'older']);
|
||||
assert.ok(result.json.length <= 2 * 1024 * 1024);
|
||||
const persistedNewest = result.sessions[0];
|
||||
for (const message of persistedNewest.messages.slice(0, 17)) {
|
||||
assert.equal(
|
||||
message.providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
persistedNewest.messages[17].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
ciphertext,
|
||||
);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage shifts the compaction boundary when trimming old messages', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'enc'.repeat(100);
|
||||
const messages = Array.from({ length: 250 }, (_, index) => ({
|
||||
id: `message-${index}`,
|
||||
role: 'assistant' as const,
|
||||
content: `turn ${index}`,
|
||||
timestamp: index,
|
||||
providerContinuation: {
|
||||
reasoningParts: [{
|
||||
text: '',
|
||||
providerOptions: {
|
||||
openai: { itemId: `rs_${index}`, reasoningEncryptedContent: ciphertext },
|
||||
},
|
||||
}],
|
||||
},
|
||||
}));
|
||||
const session = {
|
||||
...makeSession('trimmed', 1, messages),
|
||||
contextCompaction: {
|
||||
summary: 'The first 100 messages were summarized.',
|
||||
compactedMessageCount: 100,
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSessionsForStorage([session]);
|
||||
const persisted = result.sessions[0];
|
||||
|
||||
assert.equal(persisted.messages.length, 200);
|
||||
assert.equal(persisted.messages[0].id, 'message-50');
|
||||
assert.equal(persisted.contextCompaction?.compactedMessageCount, 50);
|
||||
assert.equal(
|
||||
persisted.messages[49].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
persisted.messages[50].providerContinuation
|
||||
?.reasoningParts?.[0].providerOptions?.openai?.reasoningEncryptedContent,
|
||||
ciphertext,
|
||||
);
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage does not trim between a tool call and its result', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const messages = Array.from({ length: 250 }, (_, index) => ({
|
||||
id: `message-${index}`,
|
||||
role: 'user' as const,
|
||||
content: `turn ${index}`,
|
||||
timestamp: index,
|
||||
}));
|
||||
messages[49] = {
|
||||
id: 'assistant-call',
|
||||
role: 'assistant',
|
||||
content: 'Running it.',
|
||||
timestamp: 49,
|
||||
toolCalls: [{ id: 'call-1', name: 'terminal_execute', arguments: { command: 'pwd' } }],
|
||||
} as never;
|
||||
messages[50] = {
|
||||
id: 'tool-result',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 50,
|
||||
toolResults: [{ toolCallId: 'call-1', content: '/tmp' }],
|
||||
} as never;
|
||||
const session = {
|
||||
...makeSession('tool-boundary', 1, messages),
|
||||
contextCompaction: {
|
||||
summary: 'The first 10 messages were summarized.',
|
||||
compactedMessageCount: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSessionsForStorage([session]);
|
||||
const persisted = result.sessions[0];
|
||||
|
||||
assert.equal(persisted.messages.length, 201);
|
||||
assert.equal(persisted.messages[0].id, 'assistant-call');
|
||||
assert.equal(persisted.messages[1].id, 'tool-result');
|
||||
assert.equal(persisted.contextCompaction?.compactedMessageCount, 0);
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage retries below nominal budgets after a quota failure', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const writes: string[] = [];
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value);
|
||||
if (value.length > 350 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
try {
|
||||
// The retry loop must attempt progressively smaller payloads (here the
|
||||
// ciphertext stripping at the tighter budget) before reporting failure.
|
||||
const huge = 'x'.repeat(260 * 1024);
|
||||
const ciphertext = 'gAAAA'.repeat(30 * 1024); // ~150 KB
|
||||
const sessions = [makeSession('s', 1, [{
|
||||
id: 'm',
|
||||
role: 'user' as const,
|
||||
content: huge,
|
||||
timestamp: 0,
|
||||
providerContinuation: {
|
||||
reasoningParts: [{ text: '', providerOptions: { openai: { reasoningEncryptedContent: ciphertext } } }],
|
||||
},
|
||||
}])];
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal(writes.length, 2);
|
||||
assert.equal(stored, writes[1]);
|
||||
assert.ok(writes[writes.length - 1].length < writes[0].length);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB quota', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const writes: string[] = [];
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value);
|
||||
if (value.length > 300 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
10 - index,
|
||||
[{
|
||||
id: `message-${index}`,
|
||||
role: 'user' as const,
|
||||
content: 'x'.repeat(40 * 1024),
|
||||
timestamp: index,
|
||||
}],
|
||||
));
|
||||
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(writes[0].length > 300 * 1024);
|
||||
assert.ok(stored);
|
||||
assert.ok(stored.length <= 300 * 1024);
|
||||
assert.ok((JSON.parse(stored) as AISession[]).length < sessions.length);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const writes: string[] = [];
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value);
|
||||
if (value.length > 50 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
10 - index,
|
||||
[{
|
||||
id: `message-${index}`,
|
||||
role: 'user' as const,
|
||||
content: 'x'.repeat(40 * 1024),
|
||||
timestamp: index,
|
||||
}],
|
||||
));
|
||||
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.equal(writes.length, 6);
|
||||
assert.ok(stored);
|
||||
const persisted = JSON.parse(stored) as AISession[];
|
||||
assert.deepEqual(persisted.map(session => session.id), ['session-0']);
|
||||
assert.ok(stored.length <= 50 * 1024);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
|
||||
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
@@ -112,10 +114,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
pruneSessionsForStorage(nextSessionCleanup.sessions),
|
||||
);
|
||||
writeSessionsForStorage(nextSessionCleanup.sessions);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
@@ -181,6 +180,91 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
/**
|
||||
* Byte budget for the serialized sessions JSON. The localStorage quota is
|
||||
* ~5-10 MB across all keys, and Responses reasoning ciphertext can add tens
|
||||
* of KB per turn, so keep the sessions blob well under the quota with
|
||||
* headroom for the rest of the app's storage keys.
|
||||
*/
|
||||
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
|
||||
/** Retry budgets used when the primary budget still fails to persist. */
|
||||
const RETRY_SESSIONS_JSON_BYTES = [
|
||||
1024 * 1024,
|
||||
512 * 1024,
|
||||
256 * 1024,
|
||||
128 * 1024,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
|
||||
* continuation. The ciphertext exists so stateless Responses turns can replay
|
||||
* prior reasoning items; it is also by far the largest per-message payload.
|
||||
* When storage pressure forces it, dropping the ciphertext keeps the visible
|
||||
* conversation intact at the cost of reasoning replay for affected messages.
|
||||
*/
|
||||
function stripReasoningEncryptedContent(
|
||||
options: ProviderContinuationOptions,
|
||||
): ProviderContinuationOptions | undefined {
|
||||
const hasCiphertext = Object.values(options).some(
|
||||
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
|
||||
);
|
||||
if (!hasCiphertext) return options;
|
||||
const stripped: ProviderContinuationOptions = {};
|
||||
for (const [provider, providerOptions] of Object.entries(options)) {
|
||||
const rest = { ...providerOptions };
|
||||
delete rest.reasoningEncryptedContent;
|
||||
if (Object.keys(rest).length) stripped[provider] = rest;
|
||||
}
|
||||
return Object.keys(stripped).length ? stripped : undefined;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
const continuation = message.providerContinuation;
|
||||
if (!continuation?.reasoningParts) return message;
|
||||
let changed = false;
|
||||
const parts = continuation.reasoningParts.map(part => {
|
||||
if (!part.providerOptions) return part;
|
||||
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
|
||||
if (providerOptions === part.providerOptions) return part;
|
||||
changed = true;
|
||||
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
|
||||
});
|
||||
if (!changed) return message;
|
||||
return {
|
||||
...message,
|
||||
providerContinuation: {
|
||||
...continuation,
|
||||
reasoningParts: parts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
let changed = false;
|
||||
const messages = session.messages.map(message => {
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
return changed ? { ...session, messages } : session;
|
||||
}
|
||||
|
||||
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
const compactedMessageCount = Math.min(
|
||||
session.messages.length,
|
||||
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
|
||||
);
|
||||
if (compactedMessageCount === 0) return session;
|
||||
|
||||
let changed = false;
|
||||
const messages = session.messages.map((message, index) => {
|
||||
if (index >= compactedMessageCount) return message;
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
return changed ? { ...session, messages } : session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to localStorage to prevent hitting the
|
||||
@@ -196,12 +280,179 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||
return limited.map(s => {
|
||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
||||
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
|
||||
// Do not start the retained tail with a tool result whose assistant call
|
||||
// was just trimmed. Reuse the same tool-safe boundary logic as context
|
||||
// compaction, even if that retains a few more than the nominal cap.
|
||||
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
|
||||
s.messages,
|
||||
MAX_SESSION_MESSAGES,
|
||||
);
|
||||
const contextCompaction = s.contextCompaction
|
||||
? {
|
||||
...s.contextCompaction,
|
||||
compactedMessageCount: Math.max(
|
||||
0,
|
||||
s.contextCompaction.compactedMessageCount - removedMessageCount,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
...s,
|
||||
messages: s.messages.slice(removedMessageCount),
|
||||
...(contextCompaction ? { contextCompaction } : {}),
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
* sessions that JSON represents.
|
||||
*/
|
||||
export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const serialized = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
// These messages are replaced by the durable summary before every later
|
||||
// request, so their replay ciphertext can never be used again. Remove it
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
const ciphertextMessages = session.messages.flatMap((message, index) => {
|
||||
const strippedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (strippedMessage === message) return [];
|
||||
return [{
|
||||
index,
|
||||
strippedMessage,
|
||||
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
const json = JSON.stringify(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
strippedSession,
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
ciphertextMessages,
|
||||
};
|
||||
});
|
||||
|
||||
// Preserve the newest session's full continuation whenever it can fit by
|
||||
// itself. That session is the one the user is most likely continuing now;
|
||||
// older visible history must not make its next tool turn unreplayable.
|
||||
const protectNewestContinuation = serialized.length > 0
|
||||
&& serialized[0].json.length + 2 <= budgetBytes;
|
||||
|
||||
// Determine how many sessions can fit using the smallest representation for
|
||||
// older sessions while reserving the newest session's full representation
|
||||
// when possible. This also prevents an old, oversized visible chat that must
|
||||
// be dropped anyway from causing newer replay ciphertext to be stripped.
|
||||
let minimalLength = 2
|
||||
+ serialized.reduce((total, entry, index) => (
|
||||
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
|
||||
), 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
while (minimalLength > budgetBytes && serialized.length > 1) {
|
||||
const removed = serialized.pop();
|
||||
if (!removed) break;
|
||||
minimalLength -= removed.strippedJson.length + 1;
|
||||
}
|
||||
|
||||
// Then keep full continuation data for the retained sessions and remove
|
||||
// replay-only ciphertext from the oldest retained sessions only as needed,
|
||||
// never touching the protected newest session. Within one session, remove
|
||||
// it one message at a time from oldest to newest so a long active chat can
|
||||
// retain its most recent replayable tool exchange when that still fits.
|
||||
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
|
||||
for (
|
||||
let index = serialized.length - 1;
|
||||
index >= firstStrippableIndex && jsonLength > budgetBytes;
|
||||
index -= 1
|
||||
) {
|
||||
const current = serialized[index];
|
||||
let strippedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
|
||||
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
|
||||
strippedMessageCount += 1;
|
||||
}
|
||||
if (strippedMessageCount > 0) {
|
||||
const strippedMessagesByIndex = new Map(
|
||||
current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
.map(entry => [entry.index, entry.strippedMessage] as const),
|
||||
);
|
||||
const nextSession = {
|
||||
...current.session,
|
||||
messages: current.session.messages.map((message, messageIndex) => (
|
||||
strippedMessagesByIndex.get(messageIndex) ?? message
|
||||
)),
|
||||
};
|
||||
const nextJson = JSON.stringify(nextSession);
|
||||
const projectedJsonLength = current.json.length
|
||||
+ current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
||||
jsonLength += nextJson.length - projectedJsonLength;
|
||||
serialized[index] = {
|
||||
...current,
|
||||
session: nextSession,
|
||||
json: nextJson,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
json: `[${serialized.map(entry => entry.json).join(',')}]`,
|
||||
sessions: serialized.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist sessions to localStorage with byte-budgeted pruning and retries.
|
||||
* Returns true when the write succeeded; a false result means the payload
|
||||
* could not be persisted even after escalation (it stays memory-only).
|
||||
*/
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
// A real quota failure means the next attempt must be smaller even when
|
||||
// the failed payload was already below the nominal retry budget. Reduce
|
||||
// materially on each bounded retry so several small sessions can be
|
||||
// removed before the retry sequence is exhausted.
|
||||
const reducedBudget = Number.isFinite(previousLength)
|
||||
? Math.floor(previousLength * 0.75)
|
||||
: configuredBudget;
|
||||
const budget = Math.min(configuredBudget, reducedBudget);
|
||||
if (budget < 2) break;
|
||||
const candidate = serializeSessionsForStorage(sessions, budget);
|
||||
if (candidate.json.length >= previousLength) continue;
|
||||
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
|
||||
previousLength = candidate.json.length;
|
||||
}
|
||||
// Last resort: attempt the smallest representation this serializer can
|
||||
// produce (the newest session with replay-only ciphertext removed). A very
|
||||
// small amount of shared quota may still be enough to preserve that chat.
|
||||
const minimalCandidate = serializeSessionsForStorage(sessions, 0);
|
||||
if (
|
||||
minimalCandidate.json.length < previousLength
|
||||
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
console.warn(
|
||||
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
export let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
export let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { migrateLegacyCommandBlocklist } from '../../domain/commandBlocklist';
|
||||
import commandBlocklistTable from '../../lib/commandBlocklist.json';
|
||||
|
||||
const legacyDefaults = [
|
||||
...commandBlocklistTable.common,
|
||||
...commandBlocklistTable.posixNative,
|
||||
...commandBlocklistTable.posix,
|
||||
];
|
||||
|
||||
test('untouched legacy defaults gain the PowerShell group once', () => {
|
||||
assert.deepEqual(
|
||||
migrateLegacyCommandBlocklist(legacyDefaults),
|
||||
[...legacyDefaults, ...commandBlocklistTable.powershell],
|
||||
);
|
||||
});
|
||||
|
||||
test('customized legacy settings preserve removed defaults', () => {
|
||||
const customized = legacyDefaults.slice(1);
|
||||
assert.deepEqual(migrateLegacyCommandBlocklist(customized), customized);
|
||||
assert.deepEqual(migrateLegacyCommandBlocklist([]), []);
|
||||
});
|
||||
|
||||
test('legacy settings keep user additions while gaining new defaults', () => {
|
||||
const customized = [...legacyDefaults, 'company-forbidden-command'];
|
||||
assert.deepEqual(
|
||||
migrateLegacyCommandBlocklist(customized),
|
||||
[...customized, ...commandBlocklistTable.powershell],
|
||||
);
|
||||
});
|
||||
|
||||
test('a list that already contains a PowerShell default is left unchanged', () => {
|
||||
const alreadyUpgraded = [...legacyDefaults, commandBlocklistTable.powershell[0]];
|
||||
assert.deepEqual(migrateLegacyCommandBlocklist(alreadyUpgraded), alreadyUpgraded);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { migrateLegacyCommandBlocklist } from '../../domain/commandBlocklist';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import { STORAGE_KEY_AI_COMMAND_BLOCKLIST } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export function persistCommandBlocklistSetting(blocklist: string[]): boolean {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, blocklist);
|
||||
}
|
||||
|
||||
export function readCommandBlocklistSetting(): string[] {
|
||||
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (stored != null && !Array.isArray(stored)) {
|
||||
return [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
}
|
||||
|
||||
const current = stored ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
const migrated = stored == null ? current : migrateLegacyCommandBlocklist(current);
|
||||
if (
|
||||
stored == null
|
||||
|| migrated.length !== current.length
|
||||
|| migrated.some((pattern, index) => pattern !== current[index])
|
||||
) {
|
||||
persistCommandBlocklistSetting(migrated);
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
@@ -68,6 +68,9 @@ export type SessionSnapshotActions = Pick<
|
||||
| 'reorderTabs'
|
||||
| 'toggleBroadcast'
|
||||
| 'isBroadcastEnabled'
|
||||
| 'toggleGlobalBroadcast'
|
||||
| 'isGlobalBroadcastEnabled'
|
||||
| 'canUseGlobalBroadcast'
|
||||
| 'copySession'
|
||||
| 'copyWorkspace'
|
||||
| 'createSessionFromCloneSource'
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
/**
|
||||
* useSettingsIpcSync is a React hook (useEffect-driven); without
|
||||
* react-test-renderer available in this environment we can't mount it and
|
||||
* assert on live cross-window broadcast behavior. These structural checks
|
||||
* instead verify the source wiring end-to-end: every other window's
|
||||
* useSettingsState instance (mounted via AppLockGate, even though the
|
||||
* auto-launch toggle only ever renders in the Settings window) must consume
|
||||
* the STORAGE_KEY_AUTO_LAUNCH_ENABLED broadcast, matching the established
|
||||
* pattern used by every other synchronized boolean setting in this file.
|
||||
*/
|
||||
|
||||
test('settingsIpcSync handles the auto-launch broadcast key like other synced booleans', () => {
|
||||
const source = fs.readFileSync(new URL('./settingsIpcSync.ts', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/STORAGE_KEY_AUTO_LAUNCH_ENABLED/,
|
||||
'must import the storage key',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(key === STORAGE_KEY_AUTO_LAUNCH_ENABLED && typeof value === 'boolean'\) \{\s*[\s\S]*?setAutoLaunchEnabled\(/,
|
||||
'must apply the broadcast value to local state, same as e.g. STORAGE_KEY_GLOBAL_HOTKEY_ENABLED',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>/,
|
||||
'the setter prop must be declared on the params interface',
|
||||
);
|
||||
|
||||
// The dependency array of the effect must list the setter, or the
|
||||
// listener could close over a stale setter reference after a re-render.
|
||||
const effectStart = source.indexOf('useEffect(() => {');
|
||||
const depsStart = source.indexOf('}, [', effectStart);
|
||||
assert.ok(effectStart > -1 && depsStart > effectStart, 'expected to find the sync effect and its deps array');
|
||||
const depsSlice = source.slice(depsStart, source.indexOf(']);', depsStart));
|
||||
assert.match(depsSlice, /setAutoLaunchEnabled/, 'setAutoLaunchEnabled must be listed in the effect deps array');
|
||||
});
|
||||
|
||||
test('useSettingsState wires the real autoLaunchEnabled setter into useSettingsIpcSync (not a no-op)', () => {
|
||||
const source = fs.readFileSync(new URL('./useSettingsState.ts', import.meta.url), 'utf8');
|
||||
|
||||
const hookCallStart = source.indexOf('useSettingsIpcSync({');
|
||||
assert.notEqual(hookCallStart, -1, 'expected to find the useSettingsIpcSync(...) call');
|
||||
const hookCallEnd = source.indexOf('});', hookCallStart);
|
||||
const callSlice = source.slice(hookCallStart, hookCallEnd);
|
||||
|
||||
assert.match(
|
||||
callSlice,
|
||||
/setAutoLaunchEnabled,/,
|
||||
'useSettingsIpcSync must receive the real setAutoLaunchEnabled state setter, ' +
|
||||
'or a broadcast from another window silently does nothing in this one',
|
||||
);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindi
|
||||
import { resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import {
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
@@ -93,6 +94,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -138,6 +140,7 @@ export function useSettingsIpcSync({
|
||||
setWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
@@ -270,6 +273,13 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_LAUNCH_ENABLED && typeof value === 'boolean') {
|
||||
// Peer windows (main, tray panel, terminal popups) all mount
|
||||
// useSettingsState via AppLockGate even though the toggle is only
|
||||
// ever rendered in the Settings window — keep their in-memory copy
|
||||
// in sync so it's not stale if anything reads it later.
|
||||
setAutoLaunchEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HTTP_NETWORK_PROXY) {
|
||||
const next = normalizeHttpNetworkProxySettings(value);
|
||||
setHttpNetworkProxy((prev) => (
|
||||
@@ -330,6 +340,7 @@ export function useSettingsIpcSync({
|
||||
applyIncomingCustomKeyBindings,
|
||||
mergeIncomingTerminalSettings,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createSftpBookmark,
|
||||
moveSftpBookmark,
|
||||
renameSftpBookmark,
|
||||
} from "./bookmarkHelpers.ts";
|
||||
|
||||
test("moveSftpBookmark reorders by id and ignores unknown ids", () => {
|
||||
const bookmarks = [
|
||||
{ id: "a", path: "/a" },
|
||||
{ id: "b", path: "/b" },
|
||||
{ id: "c", path: "/c" },
|
||||
];
|
||||
assert.deepEqual(
|
||||
moveSftpBookmark(bookmarks, "c", "a").map((item) => item.id),
|
||||
["c", "a", "b"],
|
||||
);
|
||||
assert.equal(moveSftpBookmark(bookmarks, "missing", "a"), bookmarks);
|
||||
assert.equal(moveSftpBookmark(bookmarks, "a", "a"), bookmarks);
|
||||
});
|
||||
|
||||
test("renameSftpBookmark updates a trimmed label", () => {
|
||||
const bookmarks = [
|
||||
createSftpBookmark("/var/www"),
|
||||
createSftpBookmark("/etc"),
|
||||
];
|
||||
const renamed = renameSftpBookmark(bookmarks, bookmarks[0].id, " Web ");
|
||||
assert.equal(renamed[0].label, "Web");
|
||||
assert.equal(renamed[1].label, bookmarks[1].label);
|
||||
assert.equal(renameSftpBookmark(bookmarks, bookmarks[0].id, " "), bookmarks);
|
||||
});
|
||||
@@ -21,3 +21,29 @@ export function createSftpBookmark(
|
||||
...(global ? { global: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function moveSftpBookmark<T extends { id: string }>(
|
||||
bookmarks: T[],
|
||||
fromId: string,
|
||||
toId: string,
|
||||
): T[] {
|
||||
const from = bookmarks.findIndex((bookmark) => bookmark.id === fromId);
|
||||
const to = bookmarks.findIndex((bookmark) => bookmark.id === toId);
|
||||
if (from < 0 || to < 0 || from === to) return bookmarks;
|
||||
const next = bookmarks.slice();
|
||||
const [item] = next.splice(from, 1);
|
||||
next.splice(to, 0, item);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function renameSftpBookmark<T extends { id: string; label: string }>(
|
||||
bookmarks: T[],
|
||||
id: string,
|
||||
label: string,
|
||||
): T[] {
|
||||
const nextLabel = label.trim();
|
||||
if (!nextLabel) return bookmarks;
|
||||
return bookmarks.map((bookmark) => (
|
||||
bookmark.id === id ? { ...bookmark, label: nextLabel } : bookmark
|
||||
));
|
||||
}
|
||||
@@ -39,6 +39,108 @@ const host = (id: string, label: string, hostname = label): Host => ({
|
||||
protocol: "ssh",
|
||||
} as Host);
|
||||
|
||||
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
|
||||
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
|
||||
const batchExistingIdentity = retainedStatus !== undefined;
|
||||
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
const originalGet = netcattyBridge.get;
|
||||
let abort = false;
|
||||
let running: Promise<unknown> | undefined;
|
||||
let childId: string | undefined;
|
||||
const parent: TransferTask = {
|
||||
id: "superseded-compacted-folder", fileName: "folder",
|
||||
sourcePath: "/local/folder", targetPath: "/remote/folder",
|
||||
sourceConnectionId: "local", targetConnectionId: "expired-sftp",
|
||||
targetHostId: "h1", direction: "upload", status: "transferring",
|
||||
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: Date.now(),
|
||||
isDirectory: true, progressMode: "files", reconnectRequired: true,
|
||||
};
|
||||
t.after(async () => {
|
||||
abort = true;
|
||||
await running;
|
||||
netcattyBridge.get = originalGet;
|
||||
sftpTransferCenterStore.patchTask(parent.id, { status: "completed" });
|
||||
sftpTransferCenterStore.dismiss(parent.id);
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
});
|
||||
const persisted: TransferTask = {
|
||||
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
|
||||
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
|
||||
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
|
||||
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
|
||||
};
|
||||
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
|
||||
// Exercise the real large-history batching branch with one relevant retained row.
|
||||
const childBatcher = createDedicatedResumeChildUpdateBatcher({
|
||||
getTaskCount: () => 4096,
|
||||
hasTask: (id) => id === persisted.id,
|
||||
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
|
||||
});
|
||||
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
|
||||
listLocalTree: async () => [{
|
||||
localPath: "/local/folder/a.bin", relativePath: "a.bin", type: "file", size: 10, lastModified: 1,
|
||||
}],
|
||||
mkdirSftp: async () => {},
|
||||
statLocal: async () => {
|
||||
if (newerPause) sftpTransferCenterStore.patchTask(persisted.id, { status: "paused", lifecycleEpoch: 9 });
|
||||
return { size: 10, lastModified: 1 };
|
||||
},
|
||||
startStreamTransfer: async (options: {
|
||||
transferId: string; parentTaskId?: string; directoryEntryIndex?: number; directoryEntryIdentity?: string;
|
||||
}) => {
|
||||
childId = options.transferId;
|
||||
// Main-process lifecycle events carry the stream's metadata, not queued UI updates.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "started", transferId: childId, lifecycleEpoch: 0,
|
||||
parentTaskId: options.parentTaskId,
|
||||
directoryEntryIndex: options.directoryEntryIndex,
|
||||
directoryEntryIdentity: options.directoryEntryIdentity,
|
||||
});
|
||||
// A newer same-id owner completes before this older invocation rejoins.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
|
||||
});
|
||||
return { superseded: true };
|
||||
},
|
||||
});
|
||||
running = resumeTransferWithDedicatedSession(parent, {
|
||||
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
|
||||
}, undefined, {
|
||||
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
|
||||
onChildUpdate: (child) => batchExistingIdentity
|
||||
? childBatcher.push(child)
|
||||
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
|
||||
});
|
||||
const result = await Promise.race([
|
||||
running,
|
||||
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
|
||||
]);
|
||||
if (newerPause) {
|
||||
assert.equal(result, "still-waiting", "a later pause must still hold fresh recovery");
|
||||
assert.equal(childId, undefined, "newer paused child must not start");
|
||||
assert.equal(sftpTransferCenterStore.getTask(persisted.id)?.lifecycleEpoch, 9);
|
||||
return;
|
||||
}
|
||||
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
|
||||
assert.ok(childId);
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
|
||||
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
|
||||
assert.equal((result as { success: boolean }).success, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
|
||||
assert.equal(
|
||||
resolveDirectoryResumeTargetRoot({
|
||||
@@ -97,10 +199,11 @@ test("persisted resume child lookup handles 50,000 retained rows in linear time"
|
||||
assert.ok(Date.now() - startedAt < 2_000, "50k retained-child lookup should stay linear");
|
||||
});
|
||||
|
||||
test("resolveHostForTransferEndpoint prefers id then label", () => {
|
||||
test("resolveHostForTransferEndpoint requires recorded id and supports unique legacy names", () => {
|
||||
const hosts = [host("id-1", "CI-Build-01", "ci-01.example")];
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "id-1", "other")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "CI-Build-01"), null);
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "CI-Build-01")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, undefined, "ci-01.example")?.id, "id-1");
|
||||
assert.equal(resolveHostForTransferEndpoint(hosts, "missing", "gone"), null);
|
||||
});
|
||||
@@ -1998,3 +2101,33 @@ test("corrupted replace-stage history cannot delete an unrelated directory", asy
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
|
||||
test(`restart upload does not select a different server by ${scenario}`, async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; });
|
||||
const opened: string[] = [];
|
||||
let uploads = 0;
|
||||
(netcattyBridge as { get: () => unknown }).get = () => ({
|
||||
openSftp: async (options: { hostname: string }) => { opened.push(options.hostname); return "new-channel"; },
|
||||
closeSftp: async () => {},
|
||||
statLocal: async () => ({ size: 100, lastModified: 123 }),
|
||||
startStreamTransfer: async () => { uploads++; return { transferId: "wrong-host-resume" }; },
|
||||
});
|
||||
const hosts = scenario === "deleted-id"
|
||||
? [host("different-id", "production", "different.example")]
|
||||
: [host("first-id", "production", "first.example"), host("second-id", "production", "second.example")];
|
||||
const result = await resumeTransferWithDedicatedSession({
|
||||
id: "wrong-host-resume", fileName: "deployment.zip",
|
||||
sourcePath: "/fixture/deployment.zip", targetPath: "/srv/deployment.zip",
|
||||
sourceConnectionId: "local", targetConnectionId: "right-expired",
|
||||
targetHostId: scenario === "deleted-id" ? "deleted-original-id" : undefined,
|
||||
targetHostLabel: "production", direction: "upload", status: "interrupted",
|
||||
totalBytes: 100, transferredBytes: 20, checkpointBytes: 20,
|
||||
speed: 0, startTime: 1, isDirectory: false, reconnectRequired: true,
|
||||
}, {hosts, keys: [], identities: []});
|
||||
assert.equal(result.success, false, "resume must stop instead of selecting another endpoint by display name");
|
||||
assert.deepEqual(opened, [], "must not authenticate to an unproven replacement host");
|
||||
assert.equal(uploads, 0);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
|
||||
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
|
||||
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
|
||||
@@ -95,8 +96,8 @@ export function resetDedicatedSessionOpenGateForTests(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a vault host for a transfer endpoint. Prefer stable id; fall back to
|
||||
* label/hostname when the id is stale after vault edits or older task records.
|
||||
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
|
||||
* without an id may use label/hostname only when exactly one host matches.
|
||||
*/
|
||||
export function resolveHostForTransferEndpoint(
|
||||
hosts: readonly Host[],
|
||||
@@ -104,16 +105,16 @@ export function resolveHostForTransferEndpoint(
|
||||
hostLabel?: string,
|
||||
): Host | null {
|
||||
if (hostId) {
|
||||
const byId = hosts.find((host) => host.id === hostId);
|
||||
if (byId) return byId;
|
||||
return hosts.find((host) => host.id === hostId) ?? null;
|
||||
}
|
||||
const needle = (hostLabel || "").trim().toLowerCase();
|
||||
if (!needle) return null;
|
||||
return hosts.find((host) => {
|
||||
const matches = hosts.filter((host) => {
|
||||
const label = (host.label || "").trim().toLowerCase();
|
||||
const hostname = (host.hostname || "").trim().toLowerCase();
|
||||
return label === needle || hostname === needle;
|
||||
}) ?? null;
|
||||
});
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
export type OpenTransferSftpSessionOptions = {
|
||||
@@ -1000,6 +1001,11 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
onProgress?: (progress: DedicatedResumeProgress) => void,
|
||||
options?: DedicatedResumeOptions,
|
||||
): Promise<DedicatedResumeResult> {
|
||||
// Fresh recovery authorizes only pauses already present at its entry. Keep
|
||||
// row references so a newer cross-window pause invalidates that permission.
|
||||
const pausedAtResume = new Map(sftpTransferCenterStore.getSnapshot().tasks
|
||||
.filter((child) => child.parentTaskId === parent.id && child.status === "paused")
|
||||
.map((child) => [child.id, child]));
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startStreamTransfer) {
|
||||
return { success: false, error: "Transfer bridge unavailable" };
|
||||
@@ -1247,8 +1253,13 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
options?.onChildUpdate?.(childBase);
|
||||
|
||||
const streamResult = await bridge.startStreamTransfer!({
|
||||
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
|
||||
transferId: childId,
|
||||
// Lifecycle events must carry the current identity even while
|
||||
// large-history renderer child updates remain batched.
|
||||
parentTaskId: childBase.parentTaskId,
|
||||
directoryEntryIndex: childBase.directoryEntryIndex,
|
||||
directoryEntryIdentity: childBase.directoryEntryIdentity,
|
||||
sourcePath: file.sourcePath,
|
||||
targetPath: file.targetPath,
|
||||
sourceType,
|
||||
@@ -1267,19 +1278,9 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
|
||||
sourceFingerprint: childBase.sourceFingerprint,
|
||||
skipAdmission: true,
|
||||
});
|
||||
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
|
||||
|
||||
if (streamResult?.superseded === true) {
|
||||
for (;;) {
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
const latest = sftpTransferCenterStore.getTask(childBase.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
|
||||
if (status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (streamResult?.error || streamResult?.cancelled) {
|
||||
if (streamResult?.error || streamResult?.cancelled) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,46 @@ function createHost(initial: TransferTask[], bridge?: TransferControlHost["getBr
|
||||
};
|
||||
}
|
||||
|
||||
test("an older pause response must not resume a newer pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finishFirstPause!: (value: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
let calls = 0;
|
||||
let backendPaused = true;
|
||||
const { host, getTasks } = createHost([makeTask("overlapping-pause")], () => ({
|
||||
pauseTransfer: async () => {
|
||||
backendPaused = true;
|
||||
if (++calls === 1) return new Promise((resolve) => { finishFirstPause = resolve; });
|
||||
return { success: true, lifecycleEpoch: 2 };
|
||||
},
|
||||
resumeTransfer: async () => {
|
||||
backendPaused = false;
|
||||
return { success: true, lifecycleEpoch: 3 };
|
||||
},
|
||||
}));
|
||||
const first = softPauseTransfer(host, "overlapping-pause");
|
||||
await softPauseTransfer(host, "overlapping-pause");
|
||||
finishFirstPause({ success: true, lifecycleEpoch: 1 });
|
||||
await first;
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
assert.equal(backendPaused, true, "late pause acknowledgement must not restart file writes");
|
||||
});
|
||||
|
||||
test("a delayed resume response must not repaint a newer pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finishResume!: (value: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
const { host, getTasks } = createHost([makeTask("resume-then-pause", "paused")], () => ({
|
||||
resumeTransfer: () => new Promise((resolve) => { finishResume = resolve; }),
|
||||
pauseTransfer: async () => ({ success: true, lifecycleEpoch: 3 }),
|
||||
}));
|
||||
const resume = softResumeTransfer(host, "resume-then-pause");
|
||||
await softPauseTransfer(host, "resume-then-pause");
|
||||
finishResume({ success: true, lifecycleEpoch: 2 });
|
||||
await resume;
|
||||
assert.equal(isTransferPauseLatched("resume-then-pause"), true);
|
||||
assert.equal(getTasks()[0].status, "paused", "latest user intent must win over an older response");
|
||||
assert.equal(getTasks()[0].lifecycleEpoch, 3);
|
||||
});
|
||||
|
||||
test("softPauseTransfer latches and paints paused for a live directory walk without a panel", async () => {
|
||||
resetTransferPauseLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
@@ -245,3 +285,218 @@ test("directory softResume stamps bridge epoch only on successIds; queued siblin
|
||||
resetTransferPauseLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
});
|
||||
|
||||
for (const isDirectory of [false, true]) {
|
||||
for (const newerLocalPause of [false, true]) {
|
||||
test(`cross-window resume releases ${isDirectory ? "folder" : "file"} latches unless local pause is newer: ${newerLocalPause}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finish!: (result: { success: boolean; superseded: true; supersededBy: "resume" }) => void;
|
||||
let pauses = 0;
|
||||
const id = `remote-resume-${isDirectory}-${newerLocalPause}`;
|
||||
const initial: TransferTask[] = [{ ...makeTask(id), isDirectory }];
|
||||
if (isDirectory) initial.push({ ...makeTask(`${id}-child`), parentTaskId: id });
|
||||
const { host, getTasks } = createHost(initial, () => ({
|
||||
pauseTransfer: () => ++pauses === 1
|
||||
? new Promise((resolve) => { finish = resolve; })
|
||||
: Promise.resolve({ success: true, lifecycleEpoch: 9 }),
|
||||
}));
|
||||
const pending = softPauseTransfer(host, id);
|
||||
host.setTasks(getTasks().map(task => isDirectory && task.id === id ? task : ({ ...task, status: "transferring", lifecycleEpoch: 8 })));
|
||||
if (newerLocalPause) await softPauseTransfer(host, id);
|
||||
finish({ success: false, superseded: true, supersededBy: "resume" });
|
||||
await pending;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched(id), newerLocalPause, "authoritative resume must release the old root pause barrier");
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, newerLocalPause ? "paused" : "transferring");
|
||||
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), newerLocalPause);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of ["pause", "resume"] as const) {
|
||||
test(`superseded cross-window ${action} preserves authoritative paused state`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
let finish!: (result: { success: boolean; superseded: boolean }) => void;
|
||||
const deferred = () => new Promise<{ success: boolean; superseded: boolean }>((resolve) => { finish = resolve; });
|
||||
const { host, getTasks } = createHost([makeTask(`cross-window-${action}`, action === "pause" ? "transferring" : "paused")], () => ({ pauseTransfer: deferred, resumeTransfer: deferred }));
|
||||
const id = getTasks()[0].id;
|
||||
const operation = action === "pause" ? softPauseTransfer(host, id) : softResumeTransfer(host, id);
|
||||
// A global event from another window changes lifecycle, not this window's local control epoch.
|
||||
host.setTasks(getTasks().map(task => ({ ...task, status: "paused", lifecycleEpoch: 8 })));
|
||||
finish({ success: false, superseded: true });
|
||||
const result = await operation;
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
assert.equal(getTasks()[0].lifecycleEpoch, 8);
|
||||
if (action === "pause") assert.equal(isTransferPauseLatched(id), true);
|
||||
else assert.deepEqual(result, { handled: true }, "obsolete response must not trigger dedicated recovery");
|
||||
});
|
||||
}
|
||||
|
||||
for (const isDirectory of [false, true]) {
|
||||
test(`remote pause restores released ${isDirectory ? "folder" : "file"} barriers after stale resume`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = `remote-pause-${isDirectory}`;
|
||||
const tasks: TransferTask[] = [{ ...makeTask(id, "paused"), isDirectory }];
|
||||
if (isDirectory) tasks.push({ ...makeTask(`${id}-child`, "paused"), parentTaskId: id });
|
||||
const { host, getTasks } = createHost(tasks, () => ({
|
||||
resumeTransfer: async () => ({ success: false, superseded: true, supersededBy: "pause" }),
|
||||
}));
|
||||
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
|
||||
assert.equal(isTransferPauseLatched(id), true);
|
||||
if (isDirectory) assert.equal(isTransferPauseLatched(`${id}-child`), true);
|
||||
assert.equal(getTasks()[0].status, "paused");
|
||||
});
|
||||
}
|
||||
|
||||
test("a child-only remote resume does not release the folder pause", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const { host } = createHost([
|
||||
{ ...makeTask("mixed-root"), isDirectory: true },
|
||||
{ ...makeTask("mixed-one"), parentTaskId: "mixed-root" },
|
||||
{ ...makeTask("mixed-two"), parentTaskId: "mixed-root" },
|
||||
], () => ({ pauseTransfer: async id => id === "mixed-one"
|
||||
? { success: false, superseded: true, supersededBy: "resume" }
|
||||
: { success: true } }));
|
||||
await softPauseTransfer(host, "mixed-root");
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched("mixed-root"), true);
|
||||
assert.equal(isTransferPauseLatched("mixed-two"), true);
|
||||
});
|
||||
|
||||
test("directory resume joins successful and remotely resumed children", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = "mixed-success-resume";
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-one`, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: async childId => childId === `${id}-one`
|
||||
? { success: true, lifecycleEpoch: 3 }
|
||||
: { success: false, superseded: true, supersededBy: "resume" } }));
|
||||
assert.deepEqual(await softResumeTransfer(host, id), { handled: true });
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, "transferring");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
});
|
||||
|
||||
for (const newerPause of [false, true]) {
|
||||
test(`directory live resume rejection is ignored only when superseded: ${newerPause}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const id = `rejected-folder-resume-${newerPause}`;
|
||||
registerTransferWalk(id);
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let backendPaused = true;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
|
||||
], () => ({
|
||||
resumeTransfer: () => new Promise((_, reject) => { rejectResume = reject; }),
|
||||
pauseTransfer: async () => { backendPaused = true; return { success: true, lifecycleEpoch: 4 }; },
|
||||
}));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerPause) await softPauseTransfer(host, id);
|
||||
rejectResume(new Error("resume transport disconnected"));
|
||||
const result = await running;
|
||||
assert.equal(result.handled, newerPause);
|
||||
if (!newerPause) assert.match(result.reason || "", /resume transport disconnected/);
|
||||
assert.equal(backendPaused, true);
|
||||
assert.equal(getTasks().find(task => task.id === id)?.status, "paused");
|
||||
if (newerPause) assert.equal(isTransferPauseLatched(id), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const newerResume of [false, true]) {
|
||||
test(`partial folder resume rejection reports running and paused children unless superseded: ${newerResume}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = `partial-reject-${newerResume}`;
|
||||
const successfulId = `${id}-one`;
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let successfulBackendPaused = true;
|
||||
let round = 0;
|
||||
let rollbackCalls = 0;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(successfulId, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(`${id}-two`, "paused"), parentTaskId: id },
|
||||
], () => ({
|
||||
resumeTransfer: async childId => {
|
||||
if (childId === successfulId) { successfulBackendPaused = false; return { success: true }; }
|
||||
if (round > 0) return { success: true };
|
||||
return new Promise((_, reject) => { rejectResume = reject; });
|
||||
},
|
||||
pauseTransfer: async childId => {
|
||||
rollbackCalls++;
|
||||
if (childId === successfulId) successfulBackendPaused = true;
|
||||
return { success: true };
|
||||
},
|
||||
}));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerResume) { round++; await softResumeTransfer(host, id); }
|
||||
rejectResume(new Error("second child IPC rejected"));
|
||||
const result = await running;
|
||||
assert.equal(result.handled, true);
|
||||
assert.equal(successfulBackendPaused, false, "successful child keeps running after partial resume");
|
||||
assert.equal(rollbackCalls, 0, "partial reporting must not introduce compensating controls");
|
||||
assert.equal(getTasks()[0].status, "transferring", "root must report the successful child still running");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
const rejected = getTasks().find(task => task.id === `${id}-two`);
|
||||
assert.equal(rejected?.status, newerResume ? "transferring" : "paused");
|
||||
assert.equal(isTransferPauseLatched(`${id}-two`), !newerResume);
|
||||
if (!newerResume) assert.match(rejected?.error || "", /second child IPC rejected/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const resolvedFailure of [false, true]) {
|
||||
test(`remote-resumed child remains visibly running when sibling resume fails: resolved=${resolvedFailure}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
const id = "remote-partial-reject";
|
||||
const runningId = `${id}-one`;
|
||||
const rejectedId = `${id}-two`;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(runningId, "paused"), parentTaskId: id },
|
||||
{ ...makeTask(rejectedId, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: async childId => {
|
||||
if (childId === rejectedId) {
|
||||
if (resolvedFailure) return { success: false, reason: "sibling resume rejected" };
|
||||
throw new Error("sibling resume rejected");
|
||||
}
|
||||
host.setTasks(getTasks().map(task => task.id === runningId ? { ...task, status: "transferring", lifecycleEpoch: 8 } : task));
|
||||
return { success: false, superseded: true, supersededBy: "resume" };
|
||||
} }));
|
||||
assert.equal((await softResumeTransfer(host, id)).handled, true);
|
||||
assert.equal(getTasks()[0].status, "transferring");
|
||||
assert.equal(isTransferPauseLatched(id), false);
|
||||
assert.equal(getTasks().find(task => task.id === runningId)?.status, "transferring");
|
||||
assert.equal(getTasks().find(task => task.id === runningId)?.lifecycleEpoch, 8);
|
||||
assert.equal(getTasks().find(task => task.id === rejectedId)?.status, "paused");
|
||||
assert.equal(isTransferPauseLatched(rejectedId), true);
|
||||
assert.match(getTasks().find(task => task.id === rejectedId)?.error || "", /sibling resume rejected/);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
for (const newerResume of [false, true]) {
|
||||
test(`resolved verification failure holds live folder unless newer resume won: ${newerResume}`, async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const id = `resolved-verification-${newerResume}`;
|
||||
registerTransferWalk(id);
|
||||
let finish!: (value: { success: boolean; reason: string }) => void;
|
||||
let calls = 0;
|
||||
const { host, getTasks } = createHost([
|
||||
{ ...makeTask(id, "paused"), isDirectory: true },
|
||||
{ ...makeTask(`${id}-child`, "paused"), parentTaskId: id },
|
||||
], () => ({ resumeTransfer: () => ++calls === 1
|
||||
? new Promise(resolve => { finish = resolve; }) : Promise.resolve({ success: true }) }));
|
||||
const running = softResumeTransfer(host, id);
|
||||
if (newerResume) await softResumeTransfer(host, id);
|
||||
finish({ success: false, reason: "Could not verify the source file for resume" });
|
||||
const result = await running;
|
||||
assert.equal(result.handled, newerResume);
|
||||
if (!newerResume) assert.match(result.reason || "", /verify the source file/);
|
||||
assert.equal(getTasks()[0].status, newerResume ? "transferring" : "paused");
|
||||
assert.equal(isTransferPauseLatched(id), !newerResume);
|
||||
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
isTransferControlEpochCurrent,
|
||||
} from "./transferControlEpoch";
|
||||
import {
|
||||
isTransferOrRootPauseLatched,
|
||||
latchTransferPauseTree,
|
||||
releaseTransferPauseTree,
|
||||
} from "./transferPauseLatch";
|
||||
@@ -28,6 +29,9 @@ import { isTransferWalkInFlight } from "./transferWalkRegistry";
|
||||
export type TransferControlBridge = {
|
||||
pauseTransfer?: (id: string) => Promise<{
|
||||
success: boolean;
|
||||
/** A newer control in another window owns the authoritative state. */
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
reason?: string;
|
||||
checkpointBytes?: number;
|
||||
resumeStage?: TransferTask["resumeStage"];
|
||||
@@ -39,12 +43,67 @@ export type TransferControlBridge = {
|
||||
}>;
|
||||
resumeTransfer?: (id: string) => Promise<{
|
||||
success: boolean;
|
||||
/** A newer control in another window owns the authoritative state. */
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
reason?: string;
|
||||
lifecycleEpoch?: number;
|
||||
}>;
|
||||
cancelTransfer?: (id: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
function wasSuperseded(result: { success?: boolean; superseded?: boolean } | undefined | null): boolean {
|
||||
return result?.superseded === true;
|
||||
}
|
||||
|
||||
type SupersededControlResult = {
|
||||
success?: boolean;
|
||||
superseded?: boolean;
|
||||
supersededBy?: "pause" | "resume" | "cancel";
|
||||
};
|
||||
|
||||
export function reconcileSupersededControls(
|
||||
host: TransferControlHost,
|
||||
taskId: string,
|
||||
childIds: string[],
|
||||
backendIds: string[],
|
||||
results: ReadonlyArray<SupersededControlResult | undefined>,
|
||||
epoch: number,
|
||||
requestedAction: "pause" | "resume",
|
||||
controlTaskId = taskId,
|
||||
): void {
|
||||
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
|
||||
const task = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
|
||||
const apply = (id: string, descendants: string[], action: "pause" | "resume" | "cancel") => {
|
||||
if (action === "resume") releaseTransferPauseTree(id, descendants);
|
||||
else latchTransferPauseTree(id, descendants);
|
||||
for (const affectedId of [id, ...descendants]) {
|
||||
try {
|
||||
if (action === "resume") globalSftpTransferScheduler.resume(affectedId);
|
||||
else globalSftpTransferScheduler.pause(affectedId);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
};
|
||||
const decisions = results.map((result) => result?.superseded
|
||||
? result.supersededBy
|
||||
: result?.success ? requestedAction : undefined);
|
||||
// A child-only resume does not establish a folder-wide decision. Require all
|
||||
// relevant children to agree, or an already-authoritative resumed root row.
|
||||
if (decisions.length > 0 && decisions.every((action) => action === "resume")) {
|
||||
apply(taskId, childIds, "resume");
|
||||
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "transferring" } : row));
|
||||
} else if (decisions.length > 0 && decisions.every((action) => action === "pause")) {
|
||||
apply(taskId, childIds, "pause");
|
||||
host.setTasks(host.getTasks().map((row) => row.id === taskId ? { ...row, status: "paused" } : row));
|
||||
} else if (task.status === "transferring" && decisions.includes("resume")) {
|
||||
apply(taskId, [], "resume");
|
||||
}
|
||||
decisions.forEach((action, index) => {
|
||||
if (action) apply(backendIds[index], [], action);
|
||||
});
|
||||
}
|
||||
|
||||
/** Prefer the highest bridge lifecycleEpoch from successful pause/resume results. */
|
||||
function maxBridgeLifecycleEpoch(
|
||||
results: ReadonlyArray<{ success?: boolean; lifecycleEpoch?: number } | undefined | null>,
|
||||
@@ -170,14 +229,23 @@ export async function softPauseTransfer(
|
||||
}
|
||||
|
||||
const backendIds = treeIds.length > 0 ? treeIds : [taskId];
|
||||
// An obsolete pause can be superseded by another pause or cancellation,
|
||||
// not only by resume. Compensate only while the tree still wants to run.
|
||||
const undoObsoletePause = async (id: string) => {
|
||||
const live = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!live || ["completed", "cancelled", "failed", "interrupted"].includes(live.status)) return;
|
||||
if (isTransferOrRootPauseLatched(taskId, id)) return;
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
};
|
||||
const pauseOne = async (id: string) => {
|
||||
let result = await bridge!.pauseTransfer?.(id)
|
||||
?? { success: false, reason: "Pause unavailable" };
|
||||
const maxAttempts = task.isDirectory ? 4 : 16;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
if (wasSuperseded(result)) return result;
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
if (result.success) {
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
return { success: false, reason: "Pause superseded by resume" };
|
||||
}
|
||||
@@ -186,6 +254,9 @@ export async function softPauseTransfer(
|
||||
return result;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
return { success: false, reason: "Pause superseded by newer control" };
|
||||
}
|
||||
result = await bridge!.pauseTransfer?.(id)
|
||||
?? { success: false, reason: "Pause unavailable" };
|
||||
}
|
||||
@@ -200,17 +271,22 @@ export async function softPauseTransfer(
|
||||
return { id, result: { success: false, reason: "Pause superseded by resume" } };
|
||||
}
|
||||
const result = await pauseOne(id);
|
||||
if (wasSuperseded(result)) return { id, result };
|
||||
const live = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
const userResumed = !pauseStillCurrent()
|
||||
|| !live
|
||||
|| (live.status !== "paused" && live.status !== "pausing");
|
||||
if (userResumed) {
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
return { id, result: { success: false, reason: "Pause superseded by resume" } };
|
||||
}
|
||||
return { id, result };
|
||||
}));
|
||||
if (!pauseStillCurrent()) return;
|
||||
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
|
||||
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
|
||||
return;
|
||||
}
|
||||
const after = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (!after || after.status === "cancelled") return;
|
||||
if (after.status !== "paused" && after.status !== "pausing") return;
|
||||
@@ -259,6 +335,10 @@ export async function softPauseTransfer(
|
||||
id,
|
||||
result: await pauseOne(id),
|
||||
})));
|
||||
if (pauseResults.some(({ result }) => wasSuperseded(result))) {
|
||||
reconcileSupersededControls(host, taskId, childIds, backendIds, pauseResults.map(({ result }) => result), pauseEpoch, "pause");
|
||||
return "noop";
|
||||
}
|
||||
const afterLivePause = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (afterLivePause?.status === "cancelled") {
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
@@ -271,7 +351,7 @@ export async function softPauseTransfer(
|
||||
if (userAlreadyResumed) {
|
||||
for (const { id, result } of pauseResults) {
|
||||
if (result?.success) {
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
}
|
||||
return "noop";
|
||||
@@ -284,7 +364,7 @@ export async function softPauseTransfer(
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) {
|
||||
for (const { id, result } of pauseResults) {
|
||||
if (result?.success) {
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
}
|
||||
return "noop";
|
||||
@@ -367,8 +447,9 @@ export async function softPauseTransfer(
|
||||
bridgeResults: pauseResults.map((row) => row.result),
|
||||
});
|
||||
for (const id of rollback.bridgeIdsToResume) {
|
||||
try { await bridge.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
await undoObsoletePause(id);
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, pauseEpoch)) return "noop";
|
||||
const hard = pauseResults.find(({ result }) =>
|
||||
result && !result.success && !isBenignPauseMiss(result.reason),
|
||||
)?.result;
|
||||
@@ -428,7 +509,7 @@ export async function softResumeTransfer(
|
||||
: [taskId, ...childIds.filter((id) => id !== taskId)];
|
||||
|
||||
// Supersede in-flight soft-drain / pauseWatch only — not a bridge lifecycle stamp.
|
||||
bumpTransferControlEpoch(taskId);
|
||||
const resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, releaseIds);
|
||||
for (const id of [taskId, ...releaseIds]) {
|
||||
try { globalSftpTransferScheduler.resume(id); } catch { /* best-effort */ }
|
||||
@@ -438,17 +519,50 @@ export async function softResumeTransfer(
|
||||
try { bridge = host.getBridge(); } catch { bridge = undefined; }
|
||||
|
||||
const resumeIds = treeIds.length > 0 ? treeIds : [taskId];
|
||||
const results = await Promise.all(resumeIds.map(async (id) =>
|
||||
bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" },
|
||||
));
|
||||
const failedIds = new Set<string>();
|
||||
const results = await Promise.all(resumeIds.map(async (id) => {
|
||||
try {
|
||||
return await bridge?.resumeTransfer?.(id) ?? { success: false, reason: "Resume unavailable" };
|
||||
} catch (error) {
|
||||
// Rejections must pass the same stale-control check as ordinary failures.
|
||||
failedIds.add(id);
|
||||
return { success: false, reason: error instanceof Error && error.message ? error.message : "Resume request failed" };
|
||||
}
|
||||
}));
|
||||
const after = host.getTasks().find((candidate) => candidate.id === taskId);
|
||||
if (after?.status === "cancelled") return { handled: true };
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return { handled: true };
|
||||
if (!after || ["completed", "cancelled", "failed"].includes(after.status)) return { handled: true };
|
||||
|
||||
results.forEach((result, index) => {
|
||||
// A missing stream can belong to queued directory work. Verification and
|
||||
// other explicit failures still own a paused stream and must remain held.
|
||||
const benignMiss = /^(?:Transfer is no longer active|not active|Resume unavailable)$/i.test(result.reason || "");
|
||||
if (!result.success && !result.superseded && !benignMiss) failedIds.add(resumeIds[index]);
|
||||
});
|
||||
if (results.some(wasSuperseded)) {
|
||||
reconcileSupersededControls(host, taskId, releaseIds, resumeIds, results, resumeEpoch, "resume");
|
||||
}
|
||||
const successIds = resumeIds.filter((_, index) => results[index]?.success);
|
||||
const effectiveRunning = results.some((result) => result?.success || (result?.superseded && result.supersededBy === "resume"));
|
||||
const failedIndex = resumeIds.findIndex((id) => failedIds.has(id));
|
||||
const failureReason = failedIndex >= 0 ? results[failedIndex]?.reason || "Resume request failed" : undefined;
|
||||
if (failedIds.size > 0) {
|
||||
// A partial resume stays visibly active; hold only failed children. Do not
|
||||
// hide successfully running streams behind a paused root barrier.
|
||||
const heldIds = effectiveRunning ? [...failedIds] : [taskId, ...failedIds];
|
||||
for (const id of heldIds) {
|
||||
latchTransferPauseTree(id, []);
|
||||
try { globalSftpTransferScheduler.pause(id); } catch { /* best-effort */ }
|
||||
}
|
||||
if (!effectiveRunning) return { handled: false, reason: failureReason };
|
||||
}
|
||||
if (results.some(wasSuperseded) && failedIds.size === 0) {
|
||||
return { handled: true };
|
||||
}
|
||||
const walkAlive = isTransferWalkInFlight(taskId);
|
||||
// Directory walk can continue after unlatch without bridge resume on every child.
|
||||
// Single-file must not claim success when every bridge resume fails (stuck bar).
|
||||
if (successIds.length === 0) {
|
||||
if (!effectiveRunning) {
|
||||
if (task.isDirectory && walkAlive) {
|
||||
host.setTasks(paintTreeStatus(
|
||||
host.getTasks(),
|
||||
@@ -491,6 +605,12 @@ export async function softResumeTransfer(
|
||||
return candidate;
|
||||
}
|
||||
|
||||
if (failedIds.has(candidate.id)) {
|
||||
return { ...candidate, status: "paused" as const, speed: 0, error: failureReason };
|
||||
}
|
||||
const candidateResult = results[resumeIds.indexOf(candidate.id)];
|
||||
if (candidate.id !== taskId && candidateResult?.superseded) return candidate;
|
||||
|
||||
// Parent: transferring. Prefer bridge epoch; never wipe to undefined or a
|
||||
// late pause fanout re-applies "paused" (acceptsLifecycle treats missing as any).
|
||||
if (candidate.id === taskId) {
|
||||
@@ -502,7 +622,7 @@ export async function softResumeTransfer(
|
||||
return {
|
||||
...candidate,
|
||||
status: "transferring" as const,
|
||||
error: undefined,
|
||||
error: failureReason,
|
||||
reconnectRequired: false,
|
||||
pauseUnavailableReason: undefined,
|
||||
phase: undefined,
|
||||
|
||||
@@ -4,6 +4,11 @@ import test from "node:test";
|
||||
import {
|
||||
createGlobalSftpTransferScheduler,
|
||||
} from "./globalTransferScheduler";
|
||||
import { resolveProgressiveFolderUploadConcurrency } from "../../../lib/progressiveFolderUpload";
|
||||
import {
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
resolveSftpTransferConcurrency,
|
||||
} from "./transferConcurrency";
|
||||
|
||||
test("scheduler limits each remote host independently", async () => {
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
@@ -28,6 +33,40 @@ test("scheduler limits each remote host independently", async () => {
|
||||
assert.equal(maxActive, 2);
|
||||
});
|
||||
|
||||
test("progressive folder uploads use six slots by default and preserve a saved limit", async () => {
|
||||
const displayedDefault = resolveSftpTransferConcurrency(() => null);
|
||||
const effectiveDefault = resolveProgressiveFolderUploadConcurrency(null);
|
||||
assert.equal(displayedDefault, DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(effectiveDefault, displayedDefault);
|
||||
assert.equal(effectiveDefault, 6);
|
||||
assert.equal(resolveProgressiveFolderUploadConcurrency(3), 3);
|
||||
assert.equal(resolveProgressiveFolderUploadConcurrency(99), 6);
|
||||
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
const releases: Array<() => void> = [];
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
const jobs = Array.from({ length: 8 }, (_, index) => scheduler.run(
|
||||
"progressive",
|
||||
`file-${index}`,
|
||||
["host:one"],
|
||||
() => null,
|
||||
async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise<void>((resolve) => releases.push(resolve));
|
||||
active -= 1;
|
||||
},
|
||||
));
|
||||
|
||||
while (releases.length < 6) await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(maxActive, 6);
|
||||
releases.splice(0).forEach((release) => release());
|
||||
while (releases.length < 2) await new Promise((resolve) => setImmediate(resolve));
|
||||
releases.splice(0).forEach((release) => release());
|
||||
await Promise.all(jobs);
|
||||
});
|
||||
|
||||
test("scheduler alternates owners when both have queued work", async () => {
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
const order: string[] = [];
|
||||
@@ -84,3 +123,49 @@ test("queued work stays paused until resumed and can be cancelled", async () =>
|
||||
await paused;
|
||||
assert.deepEqual(order, ["b1"]);
|
||||
});
|
||||
|
||||
test("enqueuing a large blocked batch does not repeatedly inspect the existing queue", async () => {
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
let release: (() => void) | undefined;
|
||||
let limitReads = 0;
|
||||
const readLimit = () => { limitReads += 1; return 1; };
|
||||
const blocker = scheduler.run("panel", "active", ["host"], readLimit, () => (
|
||||
new Promise<void>((resolve) => { release = resolve; })
|
||||
));
|
||||
const count = 2_000;
|
||||
const completed: number[] = [];
|
||||
const jobs = Array.from({ length: count }, (_, index) => scheduler.run(
|
||||
"panel", `queued-${index}`, ["host"], readLimit,
|
||||
async () => { completed.push(index); },
|
||||
));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const readsWhileBlocked = limitReads;
|
||||
release?.();
|
||||
await Promise.all([blocker, ...jobs]);
|
||||
|
||||
assert.deepEqual(completed, Array.from({ length: count }, (_, index) => index));
|
||||
assert.ok(readsWhileBlocked <= count * 3,
|
||||
`a blocked batch should be inspected linearly, got ${readsWhileBlocked} limit reads for ${count} files`);
|
||||
});
|
||||
|
||||
test("large batches of immediately completed files yield to user input", async () => {
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
let completed = 0;
|
||||
const count = 1_000;
|
||||
const inputTurn = new Promise<number>((resolve) => setTimeout(() => resolve(completed), 0));
|
||||
const jobs = Array.from({ length: count }, (_, index) => scheduler.run(
|
||||
"panel", `tiny-${index}`, ["host"], () => 2, async () => { completed += 1; },
|
||||
));
|
||||
const completedAtInput = await inputTurn;
|
||||
await Promise.all(jobs);
|
||||
assert.ok(completedAtInput < count, "input must run before the entire batch drains");
|
||||
assert.equal(completed, count);
|
||||
});
|
||||
|
||||
test("a synchronous job failure releases its slot for queued work", async () => {
|
||||
const scheduler = createGlobalSftpTransferScheduler();
|
||||
const failed = scheduler.run("panel", "failed", ["host"], () => 1, () => { throw new Error("read failed"); });
|
||||
const next = scheduler.run("panel", "next", ["host"], () => 1, async () => "completed");
|
||||
await assert.rejects(failed, /read failed/);
|
||||
assert.equal(await next, "completed");
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
|
||||
|
||||
type LimitReader = () => number | null | undefined;
|
||||
|
||||
interface ScheduledJob<T> {
|
||||
@@ -20,9 +22,7 @@ export interface GlobalSftpTransferScheduler {
|
||||
}
|
||||
|
||||
function normalizeLimit(value: number | null | undefined): number {
|
||||
return Number.isInteger(value) && value !== undefined && value !== null && value >= 1 && value <= 16
|
||||
? value
|
||||
: 2;
|
||||
return resolveSftpTransferConcurrency(() => value);
|
||||
}
|
||||
|
||||
export function getSftpTransferResourceKeys(input: {
|
||||
@@ -44,6 +44,25 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
let lastOwnerId: string | null = null;
|
||||
let prioritySequence = 0;
|
||||
const pausedJobs = new Map<string, ScheduledJob<unknown>>();
|
||||
let pumpScheduled = false;
|
||||
let startsSinceYield = 0;
|
||||
|
||||
const schedulePump = () => {
|
||||
if (pumpScheduled || queue.length === 0) return;
|
||||
pumpScheduled = true;
|
||||
const run = () => {
|
||||
pumpScheduled = false;
|
||||
pump();
|
||||
};
|
||||
// Coalesce a discovery/enqueue burst into one scan. Periodically yield to
|
||||
// input/paint as well, including when many tiny jobs resolve immediately.
|
||||
if (startsSinceYield >= 64) {
|
||||
startsSinceYield = 0;
|
||||
setTimeout(run, 0);
|
||||
} else {
|
||||
queueMicrotask(run);
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeResourceKeys = (keys: readonly string[]) => [...new Set(keys.length > 0 ? keys : ["local"])];
|
||||
const canRun = (job: ScheduledJob<unknown>) => {
|
||||
@@ -60,24 +79,31 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
|
||||
const pump = () => {
|
||||
while (queue.length > 0) {
|
||||
const runnable = queue.map((job, index) => ({ job, index })).filter(({ job }) => canRun(job));
|
||||
if (runnable.length === 0) return;
|
||||
const highestPriority = runnable.reduce((max, { job }) => Math.max(max, job.priority), 0);
|
||||
const prioritizedIndexes = queue
|
||||
.map((job, index) => ({ job, index }))
|
||||
.filter(({ job }) => job.priority === highestPriority && canRun(job));
|
||||
const alternate = lastOwnerId === null
|
||||
? undefined
|
||||
: prioritizedIndexes.find(({ job }) => job.ownerId !== lastOwnerId);
|
||||
const index = alternate?.index ?? prioritizedIndexes[0]?.index ?? 0;
|
||||
let index = -1;
|
||||
for (let candidateIndex = 0; candidateIndex < queue.length; candidateIndex += 1) {
|
||||
const candidate = queue[candidateIndex];
|
||||
if (!canRun(candidate)) continue;
|
||||
const selected = queue[index];
|
||||
if (!selected || candidate.priority > selected.priority || (
|
||||
candidate.priority === selected.priority
|
||||
&& selected.ownerId === lastOwnerId
|
||||
&& candidate.ownerId !== lastOwnerId
|
||||
)) index = candidateIndex;
|
||||
}
|
||||
if (index < 0) return;
|
||||
const [job] = queue.splice(index, 1);
|
||||
if (!job) return;
|
||||
adjustActive(job, 1);
|
||||
lastOwnerId = job.ownerId;
|
||||
void job.work().then(job.resolve, job.reject).finally(() => {
|
||||
startsSinceYield += 1;
|
||||
void (async () => job.work())().then(job.resolve, job.reject).finally(() => {
|
||||
adjustActive(job, -1);
|
||||
pump();
|
||||
schedulePump();
|
||||
});
|
||||
if (startsSinceYield >= 64) {
|
||||
schedulePump();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,7 +111,8 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
run<T>(ownerId: string, taskId: string, resourceKeys: readonly string[], readLimit: LimitReader, work: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
queue.push({ ownerId, taskId, resourceKeys: normalizeResourceKeys(resourceKeys), priority: 0, readLimit, work, resolve, reject } as ScheduledJob<unknown>);
|
||||
pump();
|
||||
if (queue.length === 1 && !pumpScheduled && startsSinceYield < 64) pump();
|
||||
else schedulePump();
|
||||
});
|
||||
},
|
||||
prioritize(taskId: string) {
|
||||
@@ -93,7 +120,7 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
if (!job) return;
|
||||
prioritySequence += 1;
|
||||
job.priority = prioritySequence;
|
||||
pump();
|
||||
schedulePump();
|
||||
},
|
||||
pause(taskId: string) {
|
||||
const index = queue.findIndex((job) => job.taskId === taskId);
|
||||
@@ -108,7 +135,7 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
if (!job) return false;
|
||||
pausedJobs.delete(taskId);
|
||||
queue.push(job);
|
||||
pump();
|
||||
schedulePump();
|
||||
return true;
|
||||
},
|
||||
cancel(taskId: string) {
|
||||
@@ -117,7 +144,7 @@ export function createGlobalSftpTransferScheduler(): GlobalSftpTransferScheduler
|
||||
if (!job) return false;
|
||||
pausedJobs.delete(taskId);
|
||||
job.reject(new Error("Transfer cancelled"));
|
||||
pump();
|
||||
schedulePump();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
import { createSftpBookmark } from "./bookmarkHelpers";
|
||||
import { createSftpBookmark, moveSftpBookmark, renameSftpBookmark } from "./bookmarkHelpers";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
@@ -74,10 +74,20 @@ export const useLocalSftpBookmarks = ({
|
||||
setLocalSftpBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
const reorderBookmark = useCallback((fromId: string, toId: string) => {
|
||||
setLocalSftpBookmarks((prev) => moveSftpBookmark(prev, fromId, toId));
|
||||
}, []);
|
||||
|
||||
const renameBookmark = useCallback((id: string, label: string) => {
|
||||
setLocalSftpBookmarks((prev) => renameSftpBookmark(prev, id, label));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
reorderBookmark,
|
||||
renameBookmark,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
consumePendingDualPaneSftpRequest,
|
||||
requestOpenDualPaneSftp,
|
||||
resetDualPaneSftpOpenStore,
|
||||
subscribeDualPaneSftpOpen,
|
||||
} from "./sftpDualPaneOpenStore.ts";
|
||||
|
||||
test("requestOpenDualPaneSftp stores a pending request when nothing is listening", () => {
|
||||
resetDualPaneSftpOpenStore();
|
||||
requestOpenDualPaneSftp("host-1");
|
||||
assert.deepEqual(consumePendingDualPaneSftpRequest(), { hostId: "host-1", seq: 1 });
|
||||
assert.equal(consumePendingDualPaneSftpRequest(), null);
|
||||
});
|
||||
|
||||
test("requestOpenDualPaneSftp delivers live to subscribers instead of queueing", () => {
|
||||
resetDualPaneSftpOpenStore();
|
||||
const received: string[] = [];
|
||||
const unsubscribe = subscribeDualPaneSftpOpen((request) => {
|
||||
received.push(request.hostId);
|
||||
});
|
||||
requestOpenDualPaneSftp("host-2");
|
||||
assert.deepEqual(received, ["host-2"]);
|
||||
assert.equal(consumePendingDualPaneSftpRequest(), null);
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { activeTabStore } from "../activeTabStore";
|
||||
|
||||
export type DualPaneSftpRequest = {
|
||||
hostId: string;
|
||||
seq: number;
|
||||
};
|
||||
|
||||
type Listener = (request: DualPaneSftpRequest) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
let pending: DualPaneSftpRequest | null = null;
|
||||
let seq = 0;
|
||||
|
||||
export function requestOpenDualPaneSftp(hostId: string): DualPaneSftpRequest {
|
||||
const request: DualPaneSftpRequest = { hostId, seq: ++seq };
|
||||
if (listeners.size > 0) {
|
||||
pending = null;
|
||||
for (const listener of listeners) listener(request);
|
||||
} else {
|
||||
pending = request;
|
||||
}
|
||||
if (typeof globalThis.window !== "undefined") {
|
||||
activeTabStore.setActiveTabId("sftp");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
export function consumePendingDualPaneSftpRequest(): DualPaneSftpRequest | null {
|
||||
const current = pending;
|
||||
pending = null;
|
||||
return current;
|
||||
}
|
||||
|
||||
export function subscribeDualPaneSftpOpen(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function resetDualPaneSftpOpenStore() {
|
||||
pending = null;
|
||||
listeners.clear();
|
||||
seq = 0;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import {
|
||||
DEFAULT_SFTP_LIST_DENSITY,
|
||||
getNextSftpListDensity,
|
||||
parseSftpListDensity,
|
||||
type SftpListDensity,
|
||||
} from "../../../domain/sftpListDensity";
|
||||
import { STORAGE_KEY_SFTP_LIST_DENSITY } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpListDensity = parseSftpListDensity(
|
||||
localStorageAdapter.readString(STORAGE_KEY_SFTP_LIST_DENSITY),
|
||||
);
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function subscribeSftpListDensity(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getSftpListDensitySnapshot(): SftpListDensity {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function setSftpListDensity(next: SftpListDensity) {
|
||||
const density = parseSftpListDensity(next);
|
||||
if (density === snapshot) return;
|
||||
snapshot = density;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_LIST_DENSITY, density);
|
||||
emit();
|
||||
}
|
||||
|
||||
export function toggleSftpListDensity() {
|
||||
setSftpListDensity(getNextSftpListDensity(snapshot));
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key !== STORAGE_KEY_SFTP_LIST_DENSITY) return;
|
||||
snapshot = parseSftpListDensity(event.newValue);
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
export function useSftpListDensity() {
|
||||
const density = useSyncExternalStore(
|
||||
subscribeSftpListDensity,
|
||||
getSftpListDensitySnapshot,
|
||||
() => DEFAULT_SFTP_LIST_DENSITY,
|
||||
);
|
||||
const setDensity = useCallback((next: SftpListDensity) => {
|
||||
setSftpListDensity(next);
|
||||
}, []);
|
||||
const toggleDensity = useCallback(() => {
|
||||
toggleSftpListDensity();
|
||||
}, []);
|
||||
return { density, setDensity, toggleDensity };
|
||||
}
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
|
||||
test("defaults folder file transfers to two concurrent files", () => {
|
||||
test("defaults folder file transfers to six concurrent files", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => null), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 2);
|
||||
assert.equal(DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY, 6);
|
||||
});
|
||||
|
||||
test("defaults directory listing fanout to four concurrent readdirs", () => {
|
||||
@@ -34,6 +34,7 @@ test("defaults skip-unchanged to enabled", () => {
|
||||
test("keeps explicit folder transfer concurrency within the supported range", () => {
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 1), 1);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 16), 16);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 1.5), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 0), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
assert.equal(resolveSftpTransferConcurrency(() => 17), DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY);
|
||||
});
|
||||
@@ -51,18 +52,18 @@ test("keeps directory listing concurrency within the supported range", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("limits default multi-file transfer scheduling to two concurrent workers", async () => {
|
||||
test("limits default multi-file transfer scheduling to six concurrent workers", async () => {
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
|
||||
await runSftpTransferWorkers([1, 2, 3, 4], () => null, async () => {
|
||||
await runSftpTransferWorkers([1, 2, 3, 4, 5, 6, 7], () => null, async () => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
active -= 1;
|
||||
});
|
||||
|
||||
assert.equal(maxActive, 2);
|
||||
assert.equal(maxActive, 6);
|
||||
});
|
||||
|
||||
test("runBoundedConcurrency respects an explicit limit", async () => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export const DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY = 2;
|
||||
export const MIN_SFTP_FILE_TRANSFER_CONCURRENCY = 1;
|
||||
export const MAX_SFTP_FILE_TRANSFER_CONCURRENCY = 16;
|
||||
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
|
||||
|
||||
export {
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
MAX_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
MIN_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
resolveSftpTransferConcurrency,
|
||||
} from "../../../domain/sftpTransferConcurrency";
|
||||
|
||||
/**
|
||||
* Bounded parallel directory listings while walking a folder tree.
|
||||
@@ -16,15 +21,6 @@ export const MAX_SFTP_DIRECTORY_LISTING_CONCURRENCY = 8;
|
||||
/** Default on: skip size+mtime matches like rsync's generator. */
|
||||
export const DEFAULT_SFTP_SKIP_UNCHANGED = true;
|
||||
|
||||
export function resolveSftpTransferConcurrency(readStoredValue: () => number | null | undefined): number {
|
||||
const stored = readStoredValue();
|
||||
return stored != null &&
|
||||
stored >= MIN_SFTP_FILE_TRANSFER_CONCURRENCY &&
|
||||
stored <= MAX_SFTP_FILE_TRANSFER_CONCURRENCY
|
||||
? stored
|
||||
: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY;
|
||||
}
|
||||
|
||||
export function resolveSftpDirectoryListingConcurrency(
|
||||
readStoredValue?: () => number | null | undefined,
|
||||
): number {
|
||||
|
||||
@@ -41,6 +41,106 @@ const rootTask = (): TransferTask => ({
|
||||
progressMode: "files",
|
||||
});
|
||||
|
||||
for (const newestAction of ["pause", "cancel", "resume", "remote-resume", "remote-child-resume"] as const) {
|
||||
test(`directory pause watcher respects a newer ${newestAction}`, async () => {
|
||||
const { bumpTransferControlEpoch, resetTransferControlEpochsForTests } = await import("./transferControlEpoch");
|
||||
const { latchTransferPauseTree, isTransferPauseLatched, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const root = { ...rootTask(), id: `watch-root-${newestAction}` };
|
||||
let tasks: TransferTask[] = [root];
|
||||
const transfersRef = { current: tasks };
|
||||
const cancelledTasksRef = { current: new Set<string>() };
|
||||
const pausedTasksRef = { current: new Set<string>() };
|
||||
let pauseCalls = 0;
|
||||
let finishPause!: (value: { success: boolean; superseded?: boolean; supersededBy?: "resume" }) => void;
|
||||
let finishTransfer!: (value: { error?: string }) => void;
|
||||
let pauseStarted!: () => void;
|
||||
const pauseGate = new Promise<void>((resolve) => { pauseStarted = resolve; });
|
||||
const transferGate = new Promise<{ error?: string }>((resolve) => { finishTransfer = resolve; });
|
||||
let resumeCalls = 0;
|
||||
let childId = "";
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => ({ type: "directory" }),
|
||||
startStreamTransfer: (options: { transferId: string }) => {
|
||||
childId = options.transferId;
|
||||
// Reproduce a stream arming during the parent's initial pause round.
|
||||
bumpTransferControlEpoch(root.id);
|
||||
latchTransferPauseTree(root.id, [childId]);
|
||||
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") { pausedTasksRef.current.add(root.id); pausedTasksRef.current.add(childId); }
|
||||
return transferGate;
|
||||
},
|
||||
pauseTransfer: () => {
|
||||
pauseCalls++;
|
||||
pauseStarted();
|
||||
return new Promise<{ success: boolean; superseded?: boolean; supersededBy?: "resume" }>((resolve) => { finishPause = resolve; });
|
||||
},
|
||||
resumeTransfer: async () => { resumeCalls++; return { success: true }; },
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
|
||||
};
|
||||
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let running: Promise<unknown> | undefined;
|
||||
const Probe = () => {
|
||||
operations = useSftpDirectoryTransferOps({
|
||||
ownerId: `watch-owner-${newestAction}`, cancelledTasksRef,
|
||||
pausedTasksRef,
|
||||
waitUntilTransferResumed: async () => undefined,
|
||||
activeChildIdsRef: { current: new Map() }, transfersRef,
|
||||
setTransfers: (update) => {
|
||||
tasks = typeof update === "function" ? update(tasks) : update;
|
||||
transfersRef.current = tasks;
|
||||
},
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
|
||||
});
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(operations);
|
||||
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
|
||||
await pauseGate;
|
||||
if (newestAction === "remote-resume" || newestAction === "remote-child-resume") {
|
||||
// Another window's resumed event updates rows without bumping this local epoch.
|
||||
transfersRef.current = tasks = tasks.map(task => ({ ...task, status: task.id === root.id && newestAction === "remote-child-resume" ? "paused" : "transferring", lifecycleEpoch: 8 }));
|
||||
finishPause({ success: false, superseded: true, supersededBy: "resume" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 110));
|
||||
assert.equal(pauseCalls, 1, "superseded watcher must not re-pause the resumed backend");
|
||||
assert.equal(isTransferPauseLatched(root.id), newestAction === "remote-child-resume");
|
||||
assert.equal(isTransferPauseLatched(childId), false);
|
||||
assert.equal(pausedTasksRef.current.has(root.id), newestAction === "remote-child-resume");
|
||||
assert.equal(pausedTasksRef.current.has(childId), false);
|
||||
return;
|
||||
}
|
||||
bumpTransferControlEpoch(root.id);
|
||||
if (newestAction === "pause") latchTransferPauseTree(root.id, [childId]);
|
||||
else {
|
||||
if (newestAction === "cancel") cancelledTasksRef.current.add(root.id);
|
||||
resetTransferPauseLatchesForTests();
|
||||
}
|
||||
finishPause({ success: true });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(resumeCalls, newestAction === "resume" ? 1 : 0, "compensation must follow the latest decision");
|
||||
} finally {
|
||||
pausedTasksRef.current.clear();
|
||||
resetTransferPauseLatchesForTests();
|
||||
finishPause?.({ success: true });
|
||||
finishTransfer({});
|
||||
await running?.catch(() => {});
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
resetTransferControlEpochsForTests();
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("directory transfer discovers each directory once with bounded listing concurrency", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
@@ -233,3 +333,74 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
test("live folder settles a superseded child whose completed row was compacted", async () => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
const root = { ...rootTask(), id: "live-compacted-root", startTime: Date.now() };
|
||||
let tasks: TransferTask[] = [root];
|
||||
const transfersRef = { current: tasks };
|
||||
const cancelledTasksRef = { current: new Set<string>() };
|
||||
let childId: string | undefined;
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => ({ type: "directory" }),
|
||||
startStreamTransfer: async (options: { transferId: string }) => {
|
||||
childId = options.transferId;
|
||||
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
|
||||
// The winning invocation has already completed; history compaction drops its row.
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
|
||||
});
|
||||
return { superseded: true };
|
||||
},
|
||||
} };
|
||||
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let running: Promise<number> | undefined;
|
||||
const Probe = () => {
|
||||
operations = useSftpDirectoryTransferOps({
|
||||
ownerId: "live-compacted-owner", cancelledTasksRef,
|
||||
pausedTasksRef: { current: new Set() }, waitUntilTransferResumed: async () => undefined,
|
||||
activeChildIdsRef: { current: new Map() }, transfersRef,
|
||||
setTransfers: (update) => {
|
||||
tasks = typeof update === "function" ? update(tasks) : update;
|
||||
transfersRef.current = tasks;
|
||||
},
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [fileEntry("file.txt")],
|
||||
});
|
||||
return null;
|
||||
};
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(operations);
|
||||
running = operations.transferDirectory(root, "source-sftp", null, false, true, "auto", "auto", root.id);
|
||||
const result = await Promise.race([
|
||||
running,
|
||||
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
|
||||
]);
|
||||
assert.ok(childId);
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
|
||||
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
|
||||
assert.equal(result, 0);
|
||||
} finally {
|
||||
cancelledTasksRef.current.add(root.id);
|
||||
await running?.catch(() => {});
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
sftpTransferCenterStore.patchTask(root.id, { status: "completed" });
|
||||
sftpTransferCenterStore.dismiss(root.id);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reconcileSupersededControls } from "./globalSftpTransferControl";
|
||||
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
|
||||
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import {
|
||||
@@ -412,7 +414,11 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
// Await the invoke result — cancel resolves with { error } and may
|
||||
// not fire onComplete/onError after preload clears listeners.
|
||||
const transferPromise = netcattyBridge.require().startStreamTransfer!(options);
|
||||
const transferPromise = runTransferAndWaitForOwner(
|
||||
task,
|
||||
() => netcattyBridge.require().startStreamTransfer!(options),
|
||||
() => isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id),
|
||||
);
|
||||
// Streams that arm after the parent pause round never receive the
|
||||
// initial pauseTransfer. Keep pausing while the folder is latched.
|
||||
// Capture epoch per attempt so Resume (epoch bump) undoes a late pause.
|
||||
@@ -427,12 +433,33 @@ export function useSftpDirectoryTransferOps({
|
||||
)
|
||||
) {
|
||||
const epochAtAttempt = getTransferControlEpoch(rootTaskId);
|
||||
const childEpochAtAttempt = getTransferControlEpoch(task.id);
|
||||
try {
|
||||
const result = await netcattyBridge.get()?.pauseTransfer?.(task.id);
|
||||
// Resume won while we were awaiting pause — undo.
|
||||
if (
|
||||
result?.superseded && result.supersededBy === "resume"
|
||||
&& isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
|
||||
&& getTransferControlEpoch(task.id) === childEpochAtAttempt
|
||||
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
|
||||
) {
|
||||
const rootAlreadyRunning = transfersRef.current.find((row) => row.id === rootTaskId)?.status === "transferring";
|
||||
reconcileSupersededControls({
|
||||
getTasks: () => transfersRef.current,
|
||||
setTasks: (next) => setTransfers(next),
|
||||
getBridge: () => netcattyBridge.get(),
|
||||
}, rootAlreadyRunning ? rootTaskId : task.id, rootAlreadyRunning ? [task.id] : [], [task.id], [result],
|
||||
epochAtAttempt, "pause", rootTaskId);
|
||||
if (rootAlreadyRunning) pausedTasksRef.current.delete(rootTaskId);
|
||||
pausedTasksRef.current.delete(task.id);
|
||||
break;
|
||||
}
|
||||
// Compensate only if the newest decision still wants this file running.
|
||||
// A new pause or cancellation also changes the epoch.
|
||||
if (
|
||||
result?.success
|
||||
&& !isTransferControlEpochCurrent(rootTaskId, epochAtAttempt)
|
||||
&& !isPauseLatched(rootTaskId, task.id)
|
||||
&& !isCancelledLocalOrGlobal(cancelledTasksRef, rootTaskId, task.id)
|
||||
) {
|
||||
try {
|
||||
await netcattyBridge.get()?.resumeTransfer?.(task.id);
|
||||
@@ -450,33 +477,6 @@ export function useSftpDirectoryTransferOps({
|
||||
watchPaused = false;
|
||||
await pauseWatch.catch(() => {});
|
||||
}
|
||||
// Same-id retry stole ownership while OPEN was pending. The live
|
||||
// owner still drives progress/completion events; this invoke must
|
||||
// not mark the child completed or failed (Codex P2 on b17f64e9).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for the live same-id owner only — no fixed deadline so
|
||||
// long transfers are not failed while still running (Codex P2).
|
||||
for (;;) {
|
||||
if (
|
||||
cancelledTasksRef.current.has(task.id)
|
||||
|| cancelledTasksRef.current.has(rootTaskId)
|
||||
) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
const latest = sftpTransferCenterStore.getTask(task.id)
|
||||
?? transfersRef.current.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (result?.error || result?.cancelled) {
|
||||
throw new Error(result.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
function child(): TransferTask {
|
||||
return {
|
||||
id: "child", parentTaskId: "root", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
|
||||
sourcePath: "/source/a", targetPath: "/target/a", fileName: "a",
|
||||
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
|
||||
totalBytes: 1, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: false, status: "transferring",
|
||||
};
|
||||
}
|
||||
|
||||
for (const status of ["completed", "failed", "cancelled"] as const) {
|
||||
test(`settlement observation retains exact ${status} before history pruning`, () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true }, task]);
|
||||
const observation = store.observeTaskSettlement(task);
|
||||
store.patchTask(task.id, { status, error: status === "failed" ? "write failed" : undefined });
|
||||
assert.equal(observation.read()?.status, status);
|
||||
if (status === "completed") assert.equal(store.getTask(task.id), undefined);
|
||||
observation.dispose();
|
||||
assert.equal(observation.read(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test("another file reusing an id and index is not completion evidence", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([task]);
|
||||
const observation = store.observeTaskSettlement(task);
|
||||
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: "completed" }]);
|
||||
assert.equal(observation.read(), undefined);
|
||||
observation.dispose();
|
||||
});
|
||||
|
||||
test("a disposed observer receives no later completion", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([task]);
|
||||
const observation = store.observeTaskSettlement(task);
|
||||
observation.dispose();
|
||||
store.patchTask(task.id, { status: "completed" });
|
||||
assert.equal(observation.read(), undefined);
|
||||
});
|
||||
|
||||
test("explicit dispatch refreshes failed identity without overwriting checkpoints", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = { ...child(), status: "failed" as const, checkpointBytes: 7 };
|
||||
store.upsertTasks([task]);
|
||||
const next = { ...task, directoryEntryIdentity: "b".repeat(64), checkpointBytes: 0 };
|
||||
assert.equal(store.admitTaskRun(next), "ready");
|
||||
assert.equal(store.getTask(task.id)?.status, "transferring");
|
||||
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, next.directoryEntryIdentity);
|
||||
assert.equal(store.getTask(task.id)?.checkpointBytes, 7);
|
||||
});
|
||||
|
||||
for (const status of ["cancelled", "completed", "paused", "pausing"] as const) {
|
||||
test(`dispatch cannot revive a retained ${status} row`, () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([
|
||||
{ ...task, id: "root", parentTaskId: undefined },
|
||||
{ ...task, status },
|
||||
]);
|
||||
assert.equal(store.admitTaskRun(task), status === "pausing" ? "paused" : status);
|
||||
assert.equal(store.getTask(task.id)?.status, status);
|
||||
});
|
||||
}
|
||||
|
||||
test("dispatch preserves active lifecycle guards and avoids replacing unchanged rows", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = { ...child(), lifecycleEpoch: 9 };
|
||||
store.upsertTasks([task]);
|
||||
const before = store.getTask(task.id);
|
||||
assert.equal(store.admitTaskRun(task), "ready");
|
||||
assert.equal(store.getTask(task.id), before);
|
||||
assert.equal(store.admitTaskRun({ ...task, directoryEntryIdentity: "b".repeat(64) }), "ready");
|
||||
assert.equal(store.getTask(task.id)?.lifecycleEpoch, 9);
|
||||
});
|
||||
|
||||
test("dispatch rejects a later pause or cancellation before changing identity", async (t) => {
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = { ...child(), status: "failed" as const };
|
||||
store.upsertTasks([task]);
|
||||
t.after(() => { resetTransferPauseLatchesForTests(); settleTransferCancelTree("root", [task.id]); });
|
||||
latchTransferPause("root");
|
||||
assert.equal(store.admitTaskRun(task), "paused");
|
||||
resetTransferPauseLatchesForTests();
|
||||
markTransferCancelledTree("root", [task.id]);
|
||||
assert.equal(store.admitTaskRun(task), "cancelled");
|
||||
assert.equal(store.getTask(task.id)?.status, "failed");
|
||||
});
|
||||
|
||||
for (const nextStatus of ["transferring", "completed", "cancelled"] as const) {
|
||||
test(`dispatch waits through a later pause until ${nextStatus}`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: `paused-admission-${nextStatus}`, parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "paused" }]);
|
||||
let starts = 0;
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
|
||||
// Attach rejection handling before the cancellation event is delivered.
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => { abort = true; await settled; store.dismiss(task.id); });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(store.getTask(task.id)?.status, "paused");
|
||||
if (nextStatus !== "cancelled") store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
|
||||
if (nextStatus === "cancelled") {
|
||||
const { markTransferCancelledTree, settleTransferCancelTree } = await import("./transferCancelLatch");
|
||||
markTransferCancelledTree(task.id, []);
|
||||
t.after(() => settleTransferCancelTree(task.id, []));
|
||||
} else store.patchTask(task.id, { status: nextStatus });
|
||||
const outcome = await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]);
|
||||
assert.equal(outcome, nextStatus === "cancelled" ? "Transfer cancelled" : "completed");
|
||||
assert.equal(starts, nextStatus === "transferring" ? 1 : 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("completed dispatch consumes only exact identity and never restarts", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const task = { ...child(), id: "completed-admission", parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "completed" }]);
|
||||
t.after(() => store.dismiss(task.id));
|
||||
const start = async () => { assert.fail("completed transfer must not restart"); };
|
||||
await runTransferAndWaitForOwner(task, start, () => false);
|
||||
await assert.rejects(runTransferAndWaitForOwner({ ...task, directoryEntryIdentity: "b".repeat(64) }, start, () => false), /identity changed/);
|
||||
assert.equal(store.getTask(task.id)?.status, "completed");
|
||||
});
|
||||
|
||||
test("a resumed retry does not inherit failed settlement captured while admission was paused", async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
|
||||
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
|
||||
const task = { ...child(), id: "failed-paused-admission", parentTaskId: undefined };
|
||||
store.upsertTasks([{ ...task, status: "failed", error: "previous attempt failed" }]);
|
||||
latchTransferPause(task.id);
|
||||
let abort = false;
|
||||
const running = runTransferAndWaitForOwner(task, async () => {
|
||||
store.patchTask(task.id, { status: "completed" });
|
||||
return { superseded: true };
|
||||
}, () => abort);
|
||||
const settled = running.then(() => "completed", (error: Error) => error.message);
|
||||
t.after(async () => { abort = true; resetTransferPauseLatchesForTests(); await settled; store.dismiss(task.id); });
|
||||
// An unrelated lifecycle publication captures the previous failed row while waiting.
|
||||
store.upsertTasks([{ ...child(), id: "unrelated-admission", parentTaskId: undefined }]);
|
||||
t.after(() => store.dismiss("unrelated-admission"));
|
||||
resetTransferPauseLatchesForTests();
|
||||
assert.equal(await Promise.race([settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 1000))]), "completed");
|
||||
});
|
||||
|
||||
test("fresh directory recovery authorizes only an unchanged retained pause under an active parent", () => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const task = child();
|
||||
store.upsertTasks([{ ...task, id: "root", parentTaskId: undefined, isDirectory: true, status: "pending" }, { ...task, status: "paused", lifecycleEpoch: 4 }]);
|
||||
const paused = store.getTask(task.id)!;
|
||||
assert.equal(store.admitTaskRun(task), "paused", "ordinary live dispatch must respect cross-window pause");
|
||||
assert.equal(store.admitTaskRun(task, paused), "ready");
|
||||
store.patchTask(task.id, { status: "paused", lifecycleEpoch: 5 });
|
||||
assert.equal(store.admitTaskRun(task, paused), "paused", "newer child pause invalidates fresh recovery permission");
|
||||
const currentPause = store.getTask(task.id)!;
|
||||
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
|
||||
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
|
||||
});
|
||||
@@ -102,6 +102,49 @@ test("assertUploadEndpointUnchanged allows same-host reconnect with new connecti
|
||||
));
|
||||
});
|
||||
|
||||
test("a strict upload pin rejects a same-endpoint connection replacement", () => {
|
||||
const map = new Map<string, string>([
|
||||
["conn-old", "key-a"],
|
||||
["conn-new", "key-a"],
|
||||
]);
|
||||
const expected = captureUploadEndpoint(
|
||||
pane({ id: "t", connectionId: "conn-old", hostId: "host-a" }).connection!,
|
||||
map,
|
||||
{ pinConnectionId: true },
|
||||
);
|
||||
assert.throws(() => assertUploadEndpointUnchanged(
|
||||
pane({ id: "t", connectionId: "conn-new", hostId: "host-a" }).connection!,
|
||||
expected,
|
||||
map,
|
||||
), /Upload target changed/);
|
||||
});
|
||||
|
||||
test("a slow strict upload probe cannot continue after its tab is rebound", async () => {
|
||||
const map = new Map<string, string>([
|
||||
["conn-old", "key-a"],
|
||||
["conn-new", "key-a"],
|
||||
]);
|
||||
let livePane = pane({ id: "t", connectionId: "conn-old", hostId: "host-a" });
|
||||
const expected = captureUploadEndpoint(livePane.connection!, map, {
|
||||
pinConnectionId: true,
|
||||
});
|
||||
let resolveProbe!: () => void;
|
||||
const probe = new Promise<void>((resolve) => { resolveProbe = resolve; });
|
||||
let uploadCreated = false;
|
||||
const start = async () => {
|
||||
await probe;
|
||||
assertUploadEndpointUnchanged(livePane.connection!, expected, map);
|
||||
uploadCreated = true;
|
||||
};
|
||||
|
||||
const pending = start();
|
||||
livePane = pane({ id: "t", connectionId: "conn-new", hostId: "host-a" });
|
||||
resolveProbe();
|
||||
|
||||
await assert.rejects(pending, /Upload target changed/);
|
||||
assert.equal(uploadCreated, false);
|
||||
});
|
||||
|
||||
test("carried endpoint pin rejects a retargeted tab mid multi-folder upload", () => {
|
||||
// Simulate paste-time pin for host-a; later folder call resolves tab on host-b.
|
||||
const mapAtPaste = new Map<string, string>([["conn-1", "key-a"]]);
|
||||
|
||||
@@ -4,11 +4,14 @@ export type UploadEndpointPin = {
|
||||
isLocal: boolean;
|
||||
hostId: string | null;
|
||||
cacheKey: string | null;
|
||||
/** When present, reconnecting the same tab is still a target change. */
|
||||
connectionId?: string;
|
||||
};
|
||||
|
||||
export function captureUploadEndpoint(
|
||||
connection: NonNullable<SftpPane["connection"]>,
|
||||
connectionCacheKeyMap: Map<string, string>,
|
||||
options?: { pinConnectionId?: boolean },
|
||||
): UploadEndpointPin {
|
||||
return {
|
||||
isLocal: connection.isLocal,
|
||||
@@ -16,6 +19,7 @@ export function captureUploadEndpoint(
|
||||
cacheKey: connection.isLocal
|
||||
? "local"
|
||||
: (connectionCacheKeyMap.get(connection.id) ?? null),
|
||||
...(options?.pinConnectionId ? { connectionId: connection.id } : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +28,9 @@ export function assertUploadEndpointUnchanged(
|
||||
expected: UploadEndpointPin,
|
||||
connectionCacheKeyMap: Map<string, string>,
|
||||
): void {
|
||||
if (expected.connectionId && connection.id !== expected.connectionId) {
|
||||
throw new Error("Upload target changed before the transfer started");
|
||||
}
|
||||
if (connection.isLocal !== expected.isLocal) {
|
||||
throw new Error("Upload target changed before the transfer started");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { usePendingSftpUploadRebind } from "./usePendingSftpUploadRebind";
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
|
||||
|
||||
test("a repeated drop can share a slow strict connect without losing completion", async () => {
|
||||
let resolveConnect!: () => void;
|
||||
const sharedConnect = new Promise<void>((resolve) => {
|
||||
resolveConnect = resolve;
|
||||
});
|
||||
let latest: ReturnType<typeof usePendingSftpUploadRebind> | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
latest = usePendingSftpUploadRebind();
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
latest!.start({
|
||||
requestId: "drop-1",
|
||||
previousConnectionId: "old-connection",
|
||||
connect: () => sharedConnect,
|
||||
});
|
||||
latest!.start({
|
||||
requestId: "drop-2",
|
||||
previousConnectionId: "connecting-connection",
|
||||
connect: () => sharedConnect,
|
||||
});
|
||||
latest!.bindTarget("drop-2", {
|
||||
tabId: "new-tab",
|
||||
connectionId: "new-connection",
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(latest!.startedRequestIdRef.current, "drop-2");
|
||||
assert.equal(latest!.settledRequestId, null);
|
||||
assert.deepEqual(latest!.barrierRef.current, {
|
||||
requestId: "drop-2",
|
||||
previousConnectionId: "connecting-connection",
|
||||
targetTabId: "new-tab",
|
||||
targetConnectionId: "new-connection",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveConnect();
|
||||
await sharedConnect;
|
||||
});
|
||||
|
||||
assert.equal(latest!.settledRequestId, "drop-2");
|
||||
act(() => renderer!.unmount());
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
export interface PendingSftpUploadRebindBarrier {
|
||||
requestId: string;
|
||||
previousConnectionId: string | null;
|
||||
targetTabId?: string;
|
||||
targetConnectionId?: string;
|
||||
}
|
||||
|
||||
export interface PendingSftpUploadRebindTarget {
|
||||
tabId: string;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
interface StartPendingSftpUploadRebindParams {
|
||||
requestId: string;
|
||||
previousConnectionId: string | null;
|
||||
connect: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the strict SFTP connect attempt owned by the latest terminal drop.
|
||||
* A later drop may share the same in-flight connect promise, so completion is
|
||||
* tracked by request generation instead of relying only on connection-id churn.
|
||||
*/
|
||||
export function usePendingSftpUploadRebind() {
|
||||
const startedRequestIdRef = useRef<string | null>(null);
|
||||
const barrierRef = useRef<PendingSftpUploadRebindBarrier | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
const [settledRequestId, setSettledRequestId] = useState<string | null>(null);
|
||||
|
||||
const start = useCallback((params: StartPendingSftpUploadRebindParams) => {
|
||||
const generation = generationRef.current + 1;
|
||||
generationRef.current = generation;
|
||||
startedRequestIdRef.current = params.requestId;
|
||||
barrierRef.current = {
|
||||
requestId: params.requestId,
|
||||
previousConnectionId: params.previousConnectionId,
|
||||
};
|
||||
setSettledRequestId(null);
|
||||
|
||||
const settle = () => {
|
||||
if (
|
||||
generationRef.current === generation
|
||||
&& startedRequestIdRef.current === params.requestId
|
||||
) {
|
||||
setSettledRequestId(params.requestId);
|
||||
}
|
||||
};
|
||||
void params.connect().then(settle, settle);
|
||||
}, []);
|
||||
|
||||
const bindTarget = useCallback((
|
||||
requestId: string,
|
||||
target: PendingSftpUploadRebindTarget,
|
||||
) => {
|
||||
const barrier = barrierRef.current;
|
||||
if (
|
||||
!barrier
|
||||
|| barrier.requestId !== requestId
|
||||
|| startedRequestIdRef.current !== requestId
|
||||
) return;
|
||||
barrierRef.current = {
|
||||
...barrier,
|
||||
targetTabId: target.tabId,
|
||||
targetConnectionId: target.connectionId,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearBarrier = useCallback((requestId?: string) => {
|
||||
if (requestId && barrierRef.current?.requestId !== requestId) return;
|
||||
barrierRef.current = null;
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
generationRef.current += 1;
|
||||
startedRequestIdRef.current = null;
|
||||
barrierRef.current = null;
|
||||
setSettledRequestId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
barrierRef,
|
||||
bindTarget,
|
||||
clearBarrier,
|
||||
reset,
|
||||
settledRequestId,
|
||||
start,
|
||||
startedRequestIdRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import React, { Suspense } from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSftpBrowseConnectionLifecycle } from "./useSftpBrowseConnectionLifecycle";
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
|
||||
|
||||
test("an uncommitted hidden render cannot invalidate live browse connections", async () => {
|
||||
const never = new Promise<void>(() => {});
|
||||
let lifecycleRef: ReturnType<typeof useSftpBrowseConnectionLifecycle> | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe({ interactive }: { interactive: boolean }) {
|
||||
const nextRef = useSftpBrowseConnectionLifecycle(interactive);
|
||||
if (!interactive) throw never;
|
||||
lifecycleRef = nextRef;
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(
|
||||
Suspense,
|
||||
{ fallback: null },
|
||||
React.createElement(Probe, { interactive: true }),
|
||||
));
|
||||
});
|
||||
assert.deepEqual(lifecycleRef!.current, { generation: 0, interactive: true });
|
||||
|
||||
await act(async () => {
|
||||
renderer!.update(React.createElement(
|
||||
Suspense,
|
||||
{ fallback: null },
|
||||
React.createElement(Probe, { interactive: false }),
|
||||
));
|
||||
});
|
||||
|
||||
assert.deepEqual(lifecycleRef!.current, { generation: 0, interactive: true });
|
||||
act(() => renderer!.unmount());
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
|
||||
export interface SftpBrowseConnectionLifecycle {
|
||||
generation: number;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
export function useSftpBrowseConnectionLifecycle(
|
||||
interactive: boolean,
|
||||
): MutableRefObject<SftpBrowseConnectionLifecycle> {
|
||||
const lifecycleRef = useRef<SftpBrowseConnectionLifecycle>({
|
||||
generation: 0,
|
||||
interactive,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (lifecycleRef.current.interactive === interactive) return;
|
||||
lifecycleRef.current = {
|
||||
generation: lifecycleRef.current.generation + 1,
|
||||
interactive,
|
||||
};
|
||||
}, [interactive]);
|
||||
|
||||
return lifecycleRef;
|
||||
}
|
||||
@@ -3,20 +3,37 @@
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { Host } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
import {
|
||||
applyToLiveSftpTabSide,
|
||||
beginSftpTabConnectRequest,
|
||||
buildSftpConnectInFlightKey,
|
||||
buildSftpHomeDirCandidates,
|
||||
clearSftpConnectInFlightForTab,
|
||||
closeSftpTabLifecycle,
|
||||
runSftpConnectOnceByKey,
|
||||
createSftpConnectionId,
|
||||
createPinnedReconnectSideResolver,
|
||||
finishSftpTabConnectRequest,
|
||||
invalidateSftpTabConnectRequest,
|
||||
isSftpHostKeySessionCurrent,
|
||||
isSftpTabConnectRequestCurrent,
|
||||
openSftpConnectionOnce,
|
||||
openSftpWithSessionPreference,
|
||||
rejectHostKeyVerificationRequest,
|
||||
registerOpenedSftpSession,
|
||||
resolveSftpPaneEndpointKey,
|
||||
resolveSftpReconnectAttempt,
|
||||
takeSftpConnectionMetadataForClose,
|
||||
releaseSftpConnectionMetadata,
|
||||
resolvePinnedReconnectSide,
|
||||
resolveSftpReconnectOptions,
|
||||
resolveSftpReconnectSchedule,
|
||||
resolveSftpReconnectHost,
|
||||
runSftpTabDisconnectIfLatest,
|
||||
settleFailedSftpConnectIfCurrent,
|
||||
} from "./useSftpConnections.ts";
|
||||
|
||||
test("home dir candidates prefer user home then root", () => {
|
||||
@@ -54,6 +71,18 @@ test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and e
|
||||
assert.equal(inFlight.size, 0);
|
||||
});
|
||||
|
||||
test("disconnect detaches only that tab's in-flight connects", () => {
|
||||
const pending = Promise.resolve();
|
||||
const inFlight = new Map<string, Promise<void>>([
|
||||
[`tab-a\u0000host-a\u0000`, pending],
|
||||
[`tab-b\u0000host-a\u0000`, pending],
|
||||
]);
|
||||
|
||||
clearSftpConnectInFlightForTab(inFlight, "tab-a");
|
||||
|
||||
assert.deepEqual([...inFlight.keys()], ["tab-b\u0000host-a\u0000"]);
|
||||
});
|
||||
|
||||
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
|
||||
const first = buildSftpConnectInFlightKey({
|
||||
side: "left",
|
||||
@@ -75,6 +104,260 @@ test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs"
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test("moving an in-flight reconnect does not create a second connection", () => {
|
||||
const base = {
|
||||
tabId: "moving-tab",
|
||||
targetConnectionKey: "host-key",
|
||||
sourceSessionId: "ssh-session-1",
|
||||
initialPath: "/home",
|
||||
};
|
||||
assert.equal(
|
||||
buildSftpConnectInFlightKey({ ...base, side: "left" }),
|
||||
buildSftpConnectInFlightKey({ ...base, side: "right" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("the newest request for a moved tab wins after an older close finishes", () => {
|
||||
const requests = new Map<string, symbol>();
|
||||
const oldRequest = beginSftpTabConnectRequest(requests, "tab-moving");
|
||||
const newRequest = beginSftpTabConnectRequest(requests, "tab-moving");
|
||||
|
||||
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", oldRequest), false);
|
||||
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), true);
|
||||
|
||||
finishSftpTabConnectRequest(requests, "tab-moving", oldRequest);
|
||||
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), true);
|
||||
|
||||
invalidateSftpTabConnectRequest(requests, "tab-moving");
|
||||
assert.equal(isSftpTabConnectRequestCurrent(requests, "tab-moving", newRequest), false);
|
||||
});
|
||||
|
||||
test("a slow disconnect cannot clear a newer connection on the same tab", async () => {
|
||||
const requests = new Map<string, symbol>();
|
||||
let releaseClose: (() => void) | undefined;
|
||||
let clears = 0;
|
||||
const disconnect = runSftpTabDisconnectIfLatest({
|
||||
requests,
|
||||
tabId: "tab-a",
|
||||
disconnect: async () => new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
}),
|
||||
clear: () => {
|
||||
clears += 1;
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
const newerConnect = beginSftpTabConnectRequest(requests, "tab-a");
|
||||
finishSftpTabConnectRequest(requests, "tab-a", newerConnect);
|
||||
releaseClose?.();
|
||||
|
||||
assert.equal(await disconnect, false);
|
||||
assert.equal(clears, 0);
|
||||
});
|
||||
|
||||
test("a slow disconnect clears a tab after it moves to the other pane", async () => {
|
||||
const requests = new Map<string, symbol>();
|
||||
let leftTabs: ReadonlyArray<{ id: string }> = [{ id: "tab-moving" }];
|
||||
let rightTabs: ReadonlyArray<{ id: string }> = [];
|
||||
let releaseClose: (() => void) | undefined;
|
||||
let clearedSide: "left" | "right" | null = null;
|
||||
const disconnect = runSftpTabDisconnectIfLatest({
|
||||
requests,
|
||||
tabId: "tab-moving",
|
||||
disconnect: async () => new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
}),
|
||||
clear: () => {
|
||||
applyToLiveSftpTabSide({
|
||||
requestedSide: "left",
|
||||
tabId: "tab-moving",
|
||||
leftTabs,
|
||||
rightTabs,
|
||||
apply: (side) => {
|
||||
clearedSide = side;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
leftTabs = [];
|
||||
rightTabs = [{ id: "tab-moving" }];
|
||||
releaseClose?.();
|
||||
|
||||
assert.equal(await disconnect, true);
|
||||
assert.equal(clearedSide, "right");
|
||||
});
|
||||
|
||||
test("closing a tab removes it before a slow connection release", async () => {
|
||||
const requests = new Map<string, symbol>();
|
||||
beginSftpTabConnectRequest(requests, "tab-a");
|
||||
const pending = Promise.resolve();
|
||||
const inFlight = new Map<string, Promise<void>>([
|
||||
["tab-a\u0000host-a", pending],
|
||||
["tab-b\u0000host-b", pending],
|
||||
]);
|
||||
const connectedHosts = new Map<string, Host | "local">([["tab-a", "local"]]);
|
||||
let releaseClose: (() => void) | undefined;
|
||||
let closedSide: "left" | "right" | null = null;
|
||||
const closing = closeSftpTabLifecycle({
|
||||
requestedSide: "left",
|
||||
tabId: "tab-a",
|
||||
leftTabs: [{ id: "tab-a" }],
|
||||
rightTabs: [],
|
||||
connectRequests: requests,
|
||||
connectInFlight: inFlight,
|
||||
connectedHosts,
|
||||
closeTab: (side) => {
|
||||
closedSide = side;
|
||||
},
|
||||
releaseConnection: async () => new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(closedSide, "left");
|
||||
assert.equal(requests.has("tab-a"), false);
|
||||
assert.deepEqual([...inFlight.keys()], ["tab-b\u0000host-b"]);
|
||||
assert.equal(connectedHosts.has("tab-a"), false);
|
||||
releaseClose?.();
|
||||
await closing;
|
||||
});
|
||||
|
||||
test("a slow failed-request cleanup cannot overwrite a newer successful connection", async () => {
|
||||
let current = true;
|
||||
let releaseClose: (() => void) | undefined;
|
||||
let failuresWritten = 0;
|
||||
const settlement = settleFailedSftpConnectIfCurrent({
|
||||
isCurrent: () => current,
|
||||
close: async () => new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
}),
|
||||
updateFailure: () => {
|
||||
failuresWritten += 1;
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
current = false;
|
||||
releaseClose?.();
|
||||
|
||||
assert.equal(await settlement, false);
|
||||
assert.equal(failuresWritten, 0);
|
||||
});
|
||||
|
||||
test("stale host-key prompts do not belong to a replacement connection", () => {
|
||||
const requests = new Map<string, symbol>();
|
||||
const oldToken = beginSftpTabConnectRequest(requests, "tab-a");
|
||||
const oldOwner = { tabId: "tab-a", connectRequestToken: oldToken };
|
||||
assert.equal(isSftpHostKeySessionCurrent(requests, oldOwner), true);
|
||||
|
||||
beginSftpTabConnectRequest(requests, "tab-a");
|
||||
assert.equal(isSftpHostKeySessionCurrent(requests, oldOwner), false);
|
||||
});
|
||||
|
||||
test("reconnect state only follows the endpoint that originally failed", () => {
|
||||
assert.equal(resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect: false,
|
||||
previousPaneReconnecting: true,
|
||||
previousConnectionKey: "host-a",
|
||||
targetConnectionKey: "host-a",
|
||||
}), true);
|
||||
assert.equal(resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect: false,
|
||||
previousPaneReconnecting: true,
|
||||
previousConnectionKey: "host-a",
|
||||
targetConnectionKey: "host-b",
|
||||
}), false);
|
||||
assert.equal(resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect: true,
|
||||
initialPath: "/srv/app",
|
||||
previousPaneReconnecting: false,
|
||||
previousConnectionKey: null,
|
||||
targetConnectionKey: "host-a",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("reconnect keeps its endpoint identity after released connection metadata is gone", () => {
|
||||
const hostA = {
|
||||
id: "host-a",
|
||||
label: "A",
|
||||
hostname: "a.example",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
const hostB = {
|
||||
id: "host-b",
|
||||
label: "B",
|
||||
hostname: "b.example",
|
||||
port: 22,
|
||||
username: "bob",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
const pane = {
|
||||
id: "tab-a",
|
||||
reconnecting: true,
|
||||
connection: {
|
||||
id: "released-connection-a",
|
||||
hostId: hostA.id,
|
||||
hostLabel: hostA.label,
|
||||
isLocal: false,
|
||||
status: "disconnected",
|
||||
currentPath: "/srv/app",
|
||||
},
|
||||
} as SftpPane;
|
||||
|
||||
const previousConnectionKey = resolveSftpPaneEndpointKey({
|
||||
connection: pane.connection,
|
||||
cachedConnectionKey: null,
|
||||
connectedHost: hostA,
|
||||
});
|
||||
const hostAKey = resolveSftpPaneEndpointKey({
|
||||
connection: pane.connection,
|
||||
cachedConnectionKey: null,
|
||||
connectedHost: hostA,
|
||||
});
|
||||
const hostBKey = resolveSftpPaneEndpointKey({
|
||||
connection: {
|
||||
...pane.connection!,
|
||||
hostId: hostB.id,
|
||||
},
|
||||
cachedConnectionKey: null,
|
||||
connectedHost: hostB,
|
||||
});
|
||||
|
||||
assert.equal(resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect: false,
|
||||
previousPaneReconnecting: pane.reconnecting,
|
||||
previousConnectionKey,
|
||||
targetConnectionKey: hostAKey!,
|
||||
}), true);
|
||||
assert.equal(resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect: false,
|
||||
previousPaneReconnecting: pane.reconnecting,
|
||||
previousConnectionKey,
|
||||
targetConnectionKey: hostBKey!,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("forced terminal rebinds never merge into an older in-flight route", () => {
|
||||
const base = {
|
||||
side: "left" as const,
|
||||
tabId: "tab-1",
|
||||
targetConnectionKey: "host-key",
|
||||
initialPath: "/srv/app",
|
||||
};
|
||||
const oldRoute = buildSftpConnectInFlightKey(base);
|
||||
const newRouteDrop = buildSftpConnectInFlightKey({
|
||||
...base,
|
||||
connectRequestKey: "drop-route-b",
|
||||
});
|
||||
|
||||
assert.notEqual(oldRoute, newRouteDrop);
|
||||
});
|
||||
|
||||
const openOptions = {
|
||||
sessionId: "sftp-request-1",
|
||||
hostname: "192.168.9.138",
|
||||
@@ -127,6 +410,33 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
|
||||
});
|
||||
|
||||
test("strict source-session reuse never dials a different route after reuse fails", async () => {
|
||||
const calls: string[] = [];
|
||||
let receivedOptions: NetcattySSHOptions | undefined;
|
||||
await assert.rejects(
|
||||
openSftpWithSessionPreference({
|
||||
bridge: {
|
||||
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
|
||||
calls.push(`openForSession:${sessionId}`);
|
||||
receivedOptions = options;
|
||||
throw new Error("channel unavailable");
|
||||
},
|
||||
openSftp: async () => {
|
||||
calls.push("openSftp");
|
||||
return "fresh-sftp";
|
||||
},
|
||||
},
|
||||
sourceSessionId: "ssh-session-1",
|
||||
requireSourceSessionReuse: true,
|
||||
openOptions,
|
||||
}),
|
||||
/channel unavailable/,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
|
||||
assert.equal(receivedOptions?.requireExactSourceSession, true);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
|
||||
const calls: string[] = [];
|
||||
let passedOptions: NetcattySSHOptions | undefined;
|
||||
@@ -156,14 +466,6 @@ test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fre
|
||||
assert.equal(passedOptions?.password, "sudo-pass");
|
||||
});
|
||||
|
||||
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
|
||||
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
|
||||
);
|
||||
});
|
||||
|
||||
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
|
||||
const calls: string[] = [];
|
||||
const sftpId = await openSftpWithSessionPreference({
|
||||
@@ -248,6 +550,51 @@ test("release metadata closes the backend before a failed connection is retained
|
||||
assert.equal(cacheKeys.size, 0);
|
||||
});
|
||||
|
||||
test("a connection that finishes after its owner unmounts is closed instead of registered", async () => {
|
||||
const sftpSessions = new Map<string, string>();
|
||||
const closed: string[] = [];
|
||||
const notified: string[] = [];
|
||||
const disposedRef = { current: true };
|
||||
|
||||
const registered = await registerOpenedSftpSession({
|
||||
disposedRef,
|
||||
connectionId: "connection-late",
|
||||
sftpId: "sftp-late",
|
||||
sftpSessions,
|
||||
closeSftp: async (sftpId) => { closed.push(sftpId); },
|
||||
onRemoteSessionClosed: (sftpId) => { notified.push(sftpId); },
|
||||
});
|
||||
|
||||
assert.equal(registered, false);
|
||||
assert.equal(sftpSessions.size, 0);
|
||||
assert.deepEqual(closed, ["sftp-late"]);
|
||||
assert.deepEqual(notified, ["sftp-late"]);
|
||||
});
|
||||
|
||||
test("a connection that finishes after browse parking is closed instead of registered", async () => {
|
||||
const sftpSessions = new Map<string, string>();
|
||||
const closed: string[] = [];
|
||||
const lifecycle = { generation: 1, interactive: true };
|
||||
const openedGeneration = lifecycle.generation;
|
||||
lifecycle.generation += 1;
|
||||
lifecycle.interactive = false;
|
||||
|
||||
const registered = await registerOpenedSftpSession({
|
||||
disposedRef: { current: false },
|
||||
canRegister: () => (
|
||||
lifecycle.interactive && lifecycle.generation === openedGeneration
|
||||
),
|
||||
connectionId: "connection-parked",
|
||||
sftpId: "sftp-parked",
|
||||
sftpSessions,
|
||||
closeSftp: async (sftpId) => { closed.push(sftpId); },
|
||||
});
|
||||
|
||||
assert.equal(registered, false);
|
||||
assert.equal(sftpSessions.size, 0);
|
||||
assert.deepEqual(closed, ["sftp-parked"]);
|
||||
});
|
||||
|
||||
test("releaseSftpConnectionMetadata notifies after a remote session closes", async () => {
|
||||
const sessions = new Map([["connection-1", "sftp-1"]]);
|
||||
const cacheKeys = new Map([["connection-1", "cache-1"]]);
|
||||
@@ -301,7 +648,7 @@ test("rejectHostKeyVerificationRequest rejects an orphaned verification", () =>
|
||||
assert.deepEqual(responses, [["hostkey-1", false, false]]);
|
||||
});
|
||||
|
||||
test("pinned reconnect side resolver follows moves across async boundaries", async () => {
|
||||
test("a newly allocated forced-connect tab follows moves across async boundaries", async () => {
|
||||
let leftTabs: ReadonlyArray<{ id: string }> = [{ id: "tab-1" }];
|
||||
let rightTabs: ReadonlyArray<{ id: string }> = [];
|
||||
const resolveSide = createPinnedReconnectSideResolver(
|
||||
@@ -322,3 +669,104 @@ test("pinned reconnect side resolver follows moves across async boundaries", asy
|
||||
rightTabs = [];
|
||||
assert.equal(resolveSide(), "right");
|
||||
});
|
||||
|
||||
test("a just-allocated connect tab uses its known side until React state commits", () => {
|
||||
let leftTabs: ReadonlyArray<{ id: string }> = [];
|
||||
let rightTabs: ReadonlyArray<{ id: string }> = [];
|
||||
const resolveSide = createPinnedReconnectSideResolver(
|
||||
"left",
|
||||
"new-tab",
|
||||
() => leftTabs,
|
||||
() => rightTabs,
|
||||
);
|
||||
|
||||
assert.equal(resolveSide(), "left");
|
||||
leftTabs = [{ id: "new-tab" }];
|
||||
assert.equal(resolveSide(), "left");
|
||||
leftTabs = [];
|
||||
rightTabs = [{ id: "new-tab" }];
|
||||
assert.equal(resolveSide(), "right");
|
||||
});
|
||||
|
||||
test("reconnect uses the active tab host instead of the side's previous host", () => {
|
||||
const hostA = {
|
||||
id: "host-a",
|
||||
label: "A",
|
||||
hostname: "a.example",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
const hostB = {
|
||||
id: "host-b",
|
||||
label: "B",
|
||||
hostname: "b.example",
|
||||
port: 22,
|
||||
username: "bob",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
const activePane = {
|
||||
id: "tab-a-moved-right",
|
||||
connection: {
|
||||
id: "connection-a",
|
||||
hostId: "host-a",
|
||||
hostLabel: "A",
|
||||
isLocal: false,
|
||||
status: "disconnected",
|
||||
currentPath: "/home/alice",
|
||||
},
|
||||
} as SftpPane;
|
||||
|
||||
assert.equal(resolveSftpReconnectHost({
|
||||
pane: activePane,
|
||||
lastHost: hostB,
|
||||
connectedHostByTabId: new Map([[activePane.id, hostA]]),
|
||||
hosts: [hostA, hostB],
|
||||
}), hostA);
|
||||
});
|
||||
|
||||
test("a reconnecting tab moved to the other pane still schedules recovery", () => {
|
||||
const movedPane = {
|
||||
id: "tab-a",
|
||||
reconnecting: true,
|
||||
connection: {
|
||||
id: "connection-a",
|
||||
hostId: "host-a",
|
||||
hostLabel: "A",
|
||||
isLocal: false,
|
||||
status: "disconnected",
|
||||
currentPath: "/home/alice",
|
||||
},
|
||||
} as SftpPane;
|
||||
|
||||
assert.deepEqual(resolveSftpReconnectSchedule({
|
||||
requestedSide: "left",
|
||||
pane: movedPane,
|
||||
leftTabs: [],
|
||||
rightTabs: [movedPane],
|
||||
}), { side: "right", tabId: "tab-a" });
|
||||
});
|
||||
|
||||
test("automatic reconnect retries the source session then allows a fresh open", () => {
|
||||
const pane = {
|
||||
id: "tab-a",
|
||||
connection: {
|
||||
id: "connection-a",
|
||||
hostId: "host-a",
|
||||
hostLabel: "A",
|
||||
isLocal: false,
|
||||
status: "disconnected",
|
||||
currentPath: "/home/alice",
|
||||
sourceSessionId: "ssh-a",
|
||||
},
|
||||
} as SftpPane;
|
||||
|
||||
assert.deepEqual(resolveSftpReconnectOptions(pane), {
|
||||
tabId: "tab-a",
|
||||
sourceSessionId: "ssh-a",
|
||||
});
|
||||
assert.equal(
|
||||
"requireSourceSessionReuse" in resolveSftpReconnectOptions(pane),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -34,10 +34,16 @@ interface UseSftpConnectionsParams {
|
||||
navSeqRef: MutableRefObject<{ left: number; right: number }>;
|
||||
dirCacheRef: MutableRefObject<DirectoryListingCache>;
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>;
|
||||
disposedRef: MutableRefObject<boolean>;
|
||||
browseConnectionLifecycleRef: MutableRefObject<{
|
||||
generation: number;
|
||||
interactive: boolean;
|
||||
}>;
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
|
||||
connectedHostByTabIdRef: MutableRefObject<Map<string, Host | "local">>;
|
||||
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
connectInFlightRef: MutableRefObject<Map<string, Promise<void>>>;
|
||||
connectRequestByTabIdRef: MutableRefObject<Map<string, symbol>>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
|
||||
@@ -54,6 +60,12 @@ export interface SftpConnectOptions {
|
||||
tabId?: string;
|
||||
onTabCreated?: (tabId: string) => void;
|
||||
sourceSessionId?: string;
|
||||
/** Fail instead of opening a new route when the requested terminal transport cannot be reused. */
|
||||
requireSourceSessionReuse?: boolean;
|
||||
/** Prevent a forced terminal-drop route rebind from sharing an older open. */
|
||||
connectRequestKey?: string;
|
||||
/** Identifies the exact tab and connection created for a route-bound request. */
|
||||
onConnectionCreated?: (target: { tabId: string; connectionId: string }) => void;
|
||||
}
|
||||
|
||||
type SftpOpenBridge = Pick<NetcattyBridge, "openSftp"> &
|
||||
@@ -62,6 +74,7 @@ type SftpOpenBridge = Pick<NetcattyBridge, "openSftp"> &
|
||||
interface OpenSftpWithSessionPreferenceParams {
|
||||
bridge: SftpOpenBridge | null | undefined;
|
||||
sourceSessionId?: string;
|
||||
requireSourceSessionReuse?: boolean;
|
||||
openOptions: NetcattySSHOptions;
|
||||
}
|
||||
|
||||
@@ -97,6 +110,30 @@ export async function releaseSftpConnectionMetadata(params: {
|
||||
params.onRemoteSessionClosed?.(sftpId);
|
||||
}
|
||||
|
||||
/** Register a completed open, or close it immediately if its owner unmounted. */
|
||||
export async function registerOpenedSftpSession(params: {
|
||||
disposedRef: MutableRefObject<boolean>;
|
||||
canRegister?: () => boolean;
|
||||
connectionId: string;
|
||||
sftpId: string;
|
||||
sftpSessions: Map<string, string>;
|
||||
closeSftp: (sftpId: string) => Promise<unknown>;
|
||||
onRemoteSessionClosed?: (sftpId: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (!params.disposedRef.current && (params.canRegister?.() ?? true)) {
|
||||
params.sftpSessions.set(params.connectionId, params.sftpId);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await params.closeSftp(params.sftpId);
|
||||
} catch {
|
||||
// Best-effort: backend owner cleanup remains the final safety net.
|
||||
}
|
||||
params.onRemoteSessionClosed?.(params.sftpId);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
|
||||
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
|
||||
if (username === "root") return ["/root"];
|
||||
@@ -115,13 +152,20 @@ export function createSftpConnectionId(
|
||||
export async function openSftpWithSessionPreference({
|
||||
bridge,
|
||||
sourceSessionId,
|
||||
requireSourceSessionReuse = false,
|
||||
openOptions,
|
||||
}: OpenSftpWithSessionPreferenceParams): Promise<string> {
|
||||
if (!bridge?.openSftp) throw new Error("SFTP bridge unavailable");
|
||||
if (requireSourceSessionReuse && (!sourceSessionId || !bridge.openSftpForSession)) {
|
||||
throw new Error("The requested terminal connection is no longer available");
|
||||
}
|
||||
if (sourceSessionId && bridge.openSftpForSession) {
|
||||
try {
|
||||
return await bridge.openSftpForSession(sourceSessionId, openOptions);
|
||||
} catch {
|
||||
return await bridge.openSftpForSession(sourceSessionId, requireSourceSessionReuse
|
||||
? { ...openOptions, requireExactSourceSession: true }
|
||||
: openOptions);
|
||||
} catch (error) {
|
||||
if (requireSourceSessionReuse) throw error;
|
||||
// Fall through to the existing SFTP open path so users still get a usable
|
||||
// file browser when the live SSH transport cannot provide an SFTP channel.
|
||||
}
|
||||
@@ -161,12 +205,18 @@ export function createPinnedReconnectSideResolver(
|
||||
getLeftTabs: () => ReadonlyArray<{ id: string }>,
|
||||
getRightTabs: () => ReadonlyArray<{ id: string }>,
|
||||
): () => "left" | "right" {
|
||||
let lastResolvedSide = resolvePinnedReconnectSide(
|
||||
requestedSide,
|
||||
tabId,
|
||||
getLeftTabs(),
|
||||
getRightTabs(),
|
||||
);
|
||||
let lastResolvedSide = requestedSide;
|
||||
try {
|
||||
lastResolvedSide = resolvePinnedReconnectSide(
|
||||
requestedSide,
|
||||
tabId,
|
||||
getLeftTabs(),
|
||||
getRightTabs(),
|
||||
);
|
||||
} catch {
|
||||
// A newly allocated tab is not visible in React refs until the state update
|
||||
// commits. Its requested side is authoritative during that brief window.
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
lastResolvedSide = resolvePinnedReconnectSide(
|
||||
@@ -183,6 +233,202 @@ export function createPinnedReconnectSideResolver(
|
||||
};
|
||||
}
|
||||
|
||||
export function applyToLiveSftpTabSide(params: {
|
||||
requestedSide: "left" | "right";
|
||||
tabId: string;
|
||||
leftTabs: ReadonlyArray<{ id: string }>;
|
||||
rightTabs: ReadonlyArray<{ id: string }>;
|
||||
apply: (side: "left" | "right") => void;
|
||||
}): boolean {
|
||||
try {
|
||||
const liveSide = resolvePinnedReconnectSide(
|
||||
params.requestedSide,
|
||||
params.tabId,
|
||||
params.leftTabs,
|
||||
params.rightTabs,
|
||||
);
|
||||
params.apply(liveSide);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSftpReconnectSchedule(params: {
|
||||
requestedSide: "left" | "right";
|
||||
pane: SftpPane | null | undefined;
|
||||
leftTabs: ReadonlyArray<{ id: string }>;
|
||||
rightTabs: ReadonlyArray<{ id: string }>;
|
||||
}): { side: "left" | "right"; tabId: string } | null {
|
||||
if (!params.pane?.reconnecting) return null;
|
||||
try {
|
||||
return {
|
||||
side: resolvePinnedReconnectSide(
|
||||
params.requestedSide,
|
||||
params.pane.id,
|
||||
params.leftTabs,
|
||||
params.rightTabs,
|
||||
),
|
||||
tabId: params.pane.id,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry the last terminal transport, then fall through to a fresh/sudo open. */
|
||||
export function resolveSftpReconnectOptions(
|
||||
pane: SftpPane,
|
||||
): SftpConnectOptions {
|
||||
const sourceSessionId = pane.connection?.sourceSessionId;
|
||||
return {
|
||||
tabId: pane.id,
|
||||
...(sourceSessionId ? { sourceSessionId } : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSftpReconnectAttempt(params: {
|
||||
isPinnedBackgroundReconnect: boolean;
|
||||
initialPath?: string;
|
||||
previousPaneReconnecting?: boolean;
|
||||
previousConnectionKey: string | null;
|
||||
targetConnectionKey: string;
|
||||
}): boolean {
|
||||
if (params.isPinnedBackgroundReconnect) return !!params.initialPath;
|
||||
return Boolean(
|
||||
params.previousPaneReconnecting
|
||||
&& params.previousConnectionKey === params.targetConnectionKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSftpPaneEndpointKey(params: {
|
||||
connection: SftpConnection | null | undefined;
|
||||
cachedConnectionKey?: string | null;
|
||||
connectedHost?: Host | "local" | null;
|
||||
}): string | null {
|
||||
const { connection } = params;
|
||||
if (!connection) return null;
|
||||
if (connection.isLocal) return "local";
|
||||
if (params.cachedConnectionKey) return params.cachedConnectionKey;
|
||||
|
||||
const connectedHost = params.connectedHost;
|
||||
if (!connectedHost || connectedHost === "local" || connectedHost.id !== connection.hostId) {
|
||||
return null;
|
||||
}
|
||||
return buildCacheKey(
|
||||
connectedHost.id,
|
||||
connectedHost.hostname,
|
||||
connectedHost.port,
|
||||
connectedHost.protocol,
|
||||
connectedHost.sftpSudo,
|
||||
connectedHost.username,
|
||||
connectedHost.sftpFileProtocol,
|
||||
);
|
||||
}
|
||||
|
||||
export function beginSftpTabConnectRequest(
|
||||
requests: Map<string, symbol>,
|
||||
tabId: string,
|
||||
): symbol {
|
||||
const token = Symbol(tabId);
|
||||
requests.set(tabId, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
export function isSftpTabConnectRequestCurrent(
|
||||
requests: ReadonlyMap<string, symbol>,
|
||||
tabId: string,
|
||||
token: symbol,
|
||||
): boolean {
|
||||
return requests.get(tabId) === token;
|
||||
}
|
||||
|
||||
export function finishSftpTabConnectRequest(
|
||||
requests: Map<string, symbol>,
|
||||
tabId: string,
|
||||
token: symbol,
|
||||
): void {
|
||||
if (isSftpTabConnectRequestCurrent(requests, tabId, token)) {
|
||||
requests.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateSftpTabConnectRequest(
|
||||
requests: Map<string, symbol>,
|
||||
tabId: string,
|
||||
): void {
|
||||
requests.delete(tabId);
|
||||
}
|
||||
|
||||
export function isSftpHostKeySessionCurrent(
|
||||
requests: ReadonlyMap<string, symbol>,
|
||||
owner: { tabId: string; connectRequestToken: symbol },
|
||||
): boolean {
|
||||
return isSftpTabConnectRequestCurrent(
|
||||
requests,
|
||||
owner.tabId,
|
||||
owner.connectRequestToken,
|
||||
);
|
||||
}
|
||||
|
||||
export async function settleFailedSftpConnectIfCurrent(params: {
|
||||
isCurrent: () => boolean;
|
||||
close: () => Promise<void>;
|
||||
updateFailure: () => void;
|
||||
}): Promise<boolean> {
|
||||
if (!params.isCurrent()) {
|
||||
await params.close();
|
||||
return false;
|
||||
}
|
||||
await params.close();
|
||||
if (!params.isCurrent()) return false;
|
||||
params.updateFailure();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runSftpTabDisconnectIfLatest(params: {
|
||||
requests: Map<string, symbol>;
|
||||
tabId: string;
|
||||
disconnect: () => Promise<void>;
|
||||
clear: () => void;
|
||||
}): Promise<boolean> {
|
||||
const token = beginSftpTabConnectRequest(params.requests, params.tabId);
|
||||
try {
|
||||
await params.disconnect();
|
||||
if (!isSftpTabConnectRequestCurrent(params.requests, params.tabId, token)) {
|
||||
return false;
|
||||
}
|
||||
params.clear();
|
||||
return true;
|
||||
} finally {
|
||||
finishSftpTabConnectRequest(params.requests, params.tabId, token);
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeSftpTabLifecycle(params: {
|
||||
requestedSide: "left" | "right";
|
||||
tabId: string;
|
||||
leftTabs: ReadonlyArray<{ id: string }>;
|
||||
rightTabs: ReadonlyArray<{ id: string }>;
|
||||
connectRequests: Map<string, symbol>;
|
||||
connectInFlight: Map<string, Promise<void>>;
|
||||
connectedHosts: { delete: (tabId: string) => boolean };
|
||||
closeTab: (side: "left" | "right") => void;
|
||||
releaseConnection: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
invalidateSftpTabConnectRequest(params.connectRequests, params.tabId);
|
||||
clearSftpConnectInFlightForTab(params.connectInFlight, params.tabId);
|
||||
params.connectedHosts.delete(params.tabId);
|
||||
applyToLiveSftpTabSide({
|
||||
requestedSide: params.requestedSide,
|
||||
tabId: params.tabId,
|
||||
leftTabs: params.leftTabs,
|
||||
rightTabs: params.rightTabs,
|
||||
apply: params.closeTab,
|
||||
});
|
||||
await params.releaseConnection();
|
||||
}
|
||||
|
||||
export function runSftpConnectOnceByKey(
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
key: string,
|
||||
@@ -200,24 +446,68 @@ export function runSftpConnectOnceByKey(
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function clearSftpConnectInFlightForTab(
|
||||
inFlight: Map<string, Promise<void>>,
|
||||
tabId: string,
|
||||
): void {
|
||||
const prefix = `${tabId}\u0000`;
|
||||
for (const key of inFlight.keys()) {
|
||||
if (key.startsWith(prefix)) inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSftpConnectInFlightKey(params: {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
targetConnectionKey: string;
|
||||
sourceSessionId?: string;
|
||||
requireSourceSessionReuse?: boolean;
|
||||
initialPath?: string;
|
||||
forceNewTab?: boolean;
|
||||
browseLifecycleGeneration?: number;
|
||||
connectRequestKey?: string;
|
||||
}): string {
|
||||
return [
|
||||
params.side,
|
||||
params.tabId,
|
||||
params.targetConnectionKey,
|
||||
params.sourceSessionId ?? "",
|
||||
params.requireSourceSessionReuse ? "strict-source" : "",
|
||||
params.initialPath ?? "",
|
||||
params.forceNewTab ? "force-new-tab" : "",
|
||||
params.browseLifecycleGeneration ?? 0,
|
||||
params.connectRequestKey ?? "",
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
/** Resolve reconnect identity from the active tab before side-wide history. */
|
||||
export function resolveSftpReconnectHost(params: {
|
||||
pane: SftpPane | null | undefined;
|
||||
lastHost: Host | "local" | null;
|
||||
connectedHostByTabId: ReadonlyMap<string, Host | "local">;
|
||||
hosts: ReadonlyArray<Host>;
|
||||
}): Host | "local" | null {
|
||||
const connection = params.pane?.connection;
|
||||
if (!connection) return params.lastHost;
|
||||
|
||||
const tabHost = params.pane
|
||||
? params.connectedHostByTabId.get(params.pane.id) ?? null
|
||||
: null;
|
||||
if (connection.isLocal) {
|
||||
return tabHost === "local" ? tabHost : "local";
|
||||
}
|
||||
if (tabHost && tabHost !== "local" && tabHost.id === connection.hostId) {
|
||||
return tabHost;
|
||||
}
|
||||
const vaultHost = params.hosts.find((candidate) => candidate.id === connection.hostId) ?? null;
|
||||
if (vaultHost) return vaultHost;
|
||||
if (
|
||||
params.lastHost
|
||||
&& params.lastHost !== "local"
|
||||
&& params.lastHost.id === connection.hostId
|
||||
) return params.lastHost;
|
||||
return null;
|
||||
}
|
||||
|
||||
interface UseSftpConnectionsResult {
|
||||
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
|
||||
disconnect: (side: "left" | "right") => Promise<void>;
|
||||
@@ -234,6 +524,19 @@ type HostKeyVerificationRequest = SftpHostKeyInfo & {
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
type ActiveHostKeySession = {
|
||||
side: "left" | "right";
|
||||
tabId: string;
|
||||
connectRequestToken: symbol;
|
||||
};
|
||||
|
||||
type PendingHostKeyVerification = SftpHostKeyVerificationState & {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
tabId: string;
|
||||
connectRequestToken: symbol;
|
||||
};
|
||||
|
||||
const toSftpHostKeyInfo = (request: HostKeyVerificationRequest): SftpHostKeyInfo => ({
|
||||
hostname: request.hostname,
|
||||
port: request.port || 22,
|
||||
@@ -271,10 +574,13 @@ export const useSftpConnections = ({
|
||||
navSeqRef,
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
disposedRef,
|
||||
browseConnectionLifecycleRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
connectedHostByTabIdRef,
|
||||
reconnectingRef,
|
||||
connectInFlightRef,
|
||||
connectRequestByTabIdRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane,
|
||||
@@ -289,12 +595,11 @@ export const useSftpConnections = ({
|
||||
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
|
||||
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
|
||||
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
const hostKeyVerificationRef = useRef<PendingHostKeyVerification | null>(null);
|
||||
const activeHostKeySessionsRef = useRef<Map<string, ActiveHostKeySession>>(new Map());
|
||||
|
||||
const setPendingHostKeyVerification = useCallback((
|
||||
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
|
||||
next: PendingHostKeyVerification | null,
|
||||
) => {
|
||||
hostKeyVerificationRef.current = next;
|
||||
setHostKeyVerification(next ? {
|
||||
@@ -309,6 +614,10 @@ export const useSftpConnections = ({
|
||||
if (!sessionId) return;
|
||||
const activeSession = activeHostKeySessionsRef.current.get(sessionId);
|
||||
if (!activeSession) return;
|
||||
if (!isSftpHostKeySessionCurrent(connectRequestByTabIdRef.current, activeSession)) {
|
||||
rejectHostKeyVerificationRequest(netcattyBridge.get(), request.requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const hostKeyInfo = toSftpHostKeyInfo(request);
|
||||
const logLine = request.status === "changed"
|
||||
@@ -334,6 +643,8 @@ export const useSftpConnections = ({
|
||||
setPendingHostKeyVerification({
|
||||
requestId: request.requestId,
|
||||
sessionId,
|
||||
tabId: activeSession.tabId,
|
||||
connectRequestToken: activeSession.connectRequestToken,
|
||||
hostKeyInfo,
|
||||
progressLogs: [logLine],
|
||||
});
|
||||
@@ -342,11 +653,17 @@ export const useSftpConnections = ({
|
||||
return () => {
|
||||
dispose?.();
|
||||
};
|
||||
}, [leftTabsRef, rightTabsRef, setPendingHostKeyVerification, updateTab]);
|
||||
}, [connectRequestByTabIdRef, leftTabsRef, rightTabsRef, setPendingHostKeyVerification, updateTab]);
|
||||
|
||||
const respondToHostKeyVerification = useCallback((accept: boolean, addToKnownHosts = false) => {
|
||||
const pending = hostKeyVerificationRef.current;
|
||||
if (!pending) return;
|
||||
const owner = activeHostKeySessionsRef.current.get(pending.sessionId);
|
||||
if (!owner || !isSftpHostKeySessionCurrent(connectRequestByTabIdRef.current, owner)) {
|
||||
rejectHostKeyVerificationRequest(netcattyBridge.get(), pending.requestId);
|
||||
setPendingHostKeyVerification(null);
|
||||
return;
|
||||
}
|
||||
if (accept && addToKnownHosts) {
|
||||
onAddKnownHost?.(createKnownHostFromSftpHostKeyInfo(pending.hostKeyInfo));
|
||||
}
|
||||
@@ -356,7 +673,7 @@ export const useSftpConnections = ({
|
||||
addToKnownHosts,
|
||||
);
|
||||
setPendingHostKeyVerification(null);
|
||||
}, [onAddKnownHost, setPendingHostKeyVerification]);
|
||||
}, [connectRequestByTabIdRef, onAddKnownHost, setPendingHostKeyVerification]);
|
||||
|
||||
const rejectHostKeyVerification = useCallback(() => {
|
||||
respondToHostKeyVerification(false);
|
||||
@@ -373,13 +690,13 @@ export const useSftpConnections = ({
|
||||
const connect = useCallback(
|
||||
async (requestedSide: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => {
|
||||
// Follow pinned tabs that were dragged to the other side mid-reconnect.
|
||||
const resolveTargetSide = createPinnedReconnectSideResolver(
|
||||
const resolveRequestedSide = createPinnedReconnectSideResolver(
|
||||
requestedSide,
|
||||
options?.tabId,
|
||||
() => leftTabsRef.current.tabs,
|
||||
() => rightTabsRef.current.tabs,
|
||||
);
|
||||
const side = resolveTargetSide();
|
||||
const side = resolveRequestedSide();
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
@@ -401,18 +718,21 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!activeTabId) return;
|
||||
|
||||
// Once a tab has been selected or allocated, follow that exact tab if it
|
||||
// is dragged across panes while the asynchronous connect is in flight.
|
||||
const resolveTargetSide = createPinnedReconnectSideResolver(
|
||||
side,
|
||||
activeTabId,
|
||||
() => leftTabsRef.current.tabs,
|
||||
() => rightTabsRef.current.tabs,
|
||||
);
|
||||
|
||||
// Pinned reconnect of a non-active tab must not clobber the active tab's
|
||||
// lastConnectedHost / reconnecting recovery state on this side.
|
||||
const isPinnedBackgroundReconnect =
|
||||
!!options?.tabId
|
||||
&& !!sideTabs.activeTabId
|
||||
&& options.tabId !== sideTabs.activeTabId;
|
||||
const clearSideReconnecting = () => {
|
||||
if (!isPinnedBackgroundReconnect) {
|
||||
reconnectingRef.current[side] = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getTargetPaneEarly = () => {
|
||||
const targetSide = resolveTargetSide();
|
||||
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
|
||||
@@ -426,13 +746,16 @@ export const useSftpConnections = ({
|
||||
// auto-reconnect can land back where the user was browsing instead of home.
|
||||
// Do not inherit path across endpoints (including same hostId with different
|
||||
// hostname/port/user) if a reconnect flag is still set while switching.
|
||||
const previousConnection = getTargetPaneEarly()?.connection;
|
||||
const previousPane = getTargetPaneEarly();
|
||||
const previousConnection = previousPane?.connection;
|
||||
const previousPath = previousConnection?.currentPath;
|
||||
const previousConnectionKey = !previousConnection
|
||||
? null
|
||||
: previousConnection.isLocal
|
||||
? "local"
|
||||
: (connectionCacheKeyMapRef.current.get(previousConnection.id) ?? null);
|
||||
const previousConnectionKey = resolveSftpPaneEndpointKey({
|
||||
connection: previousConnection,
|
||||
cachedConnectionKey: previousConnection
|
||||
? connectionCacheKeyMapRef.current.get(previousConnection.id)
|
||||
: null,
|
||||
connectedHost: connectedHostByTabIdRef.current.get(activeTabId) ?? null,
|
||||
});
|
||||
const targetConnectionKey = host === "local"
|
||||
? "local"
|
||||
: buildCacheKey(
|
||||
@@ -444,18 +767,14 @@ export const useSftpConnections = ({
|
||||
host.username,
|
||||
host.sftpFileProtocol,
|
||||
);
|
||||
if (
|
||||
!isPinnedBackgroundReconnect
|
||||
&& reconnectingRef.current[side]
|
||||
&& previousConnectionKey
|
||||
&& previousConnectionKey !== targetConnectionKey
|
||||
) {
|
||||
clearSideReconnecting();
|
||||
}
|
||||
// Background pin reconnects resume via options.initialPath, not the side-wide flag.
|
||||
const isReconnectAttempt = isPinnedBackgroundReconnect
|
||||
? !!options?.initialPath
|
||||
: reconnectingRef.current[side];
|
||||
// Reconnect state belongs to the exact tab, not the side it occupies.
|
||||
const isReconnectAttempt = resolveSftpReconnectAttempt({
|
||||
isPinnedBackgroundReconnect,
|
||||
initialPath: options?.initialPath,
|
||||
previousPaneReconnecting: previousPane?.reconnecting,
|
||||
previousConnectionKey,
|
||||
targetConnectionKey,
|
||||
});
|
||||
const sameEndpointReconnect =
|
||||
isReconnectAttempt
|
||||
&& !!previousPath
|
||||
@@ -474,12 +793,35 @@ export const useSftpConnections = ({
|
||||
tabId: activeTabId,
|
||||
targetConnectionKey,
|
||||
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
|
||||
requireSourceSessionReuse: host === "local" ? false : options?.requireSourceSessionReuse,
|
||||
initialPath: effectiveInitialPath,
|
||||
forceNewTab: options?.forceNewTab,
|
||||
browseLifecycleGeneration: browseConnectionLifecycleRef.current.generation,
|
||||
connectRequestKey: options?.connectRequestKey,
|
||||
});
|
||||
|
||||
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
|
||||
const connectRequestToken = beginSftpTabConnectRequest(
|
||||
connectRequestByTabIdRef.current,
|
||||
activeTabId,
|
||||
);
|
||||
const isConnectRequestCurrent = () => isSftpTabConnectRequestCurrent(
|
||||
connectRequestByTabIdRef.current,
|
||||
activeTabId,
|
||||
connectRequestToken,
|
||||
);
|
||||
const pendingHostKey = hostKeyVerificationRef.current;
|
||||
if (
|
||||
pendingHostKey?.tabId === activeTabId
|
||||
&& pendingHostKey.connectRequestToken !== connectRequestToken
|
||||
) {
|
||||
rejectHostKeyVerificationRequest(netcattyBridge.get(), pendingHostKey.requestId);
|
||||
setPendingHostKeyVerification(null);
|
||||
}
|
||||
try {
|
||||
const browseLifecycleGeneration = browseConnectionLifecycleRef.current.generation;
|
||||
const connectionId = createSftpConnectionId(side);
|
||||
options?.onConnectionCreated?.({ tabId: activeTabId, connectionId });
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
const connectRequestId = navSeqRef.current[side];
|
||||
@@ -489,12 +831,14 @@ export const useSftpConnections = ({
|
||||
return tabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
};
|
||||
const isTargetConnectionCurrent = () => {
|
||||
if (!isConnectRequestCurrent()) return false;
|
||||
const pane = getTargetPane();
|
||||
if (!pane) return false;
|
||||
if (pane.connection?.id === connectionId) return true;
|
||||
return !pane.connection && navSeqRef.current[side] === connectRequestId;
|
||||
};
|
||||
const isTargetConnectionAtPath = (path: string) => {
|
||||
if (!isConnectRequestCurrent()) return false;
|
||||
const connection = getTargetPane()?.connection;
|
||||
if (!connection) return navSeqRef.current[side] === connectRequestId;
|
||||
return connection?.id === connectionId && connection.currentPath === path;
|
||||
@@ -553,8 +897,17 @@ export const useSftpConnections = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!isConnectRequestCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (host === "local") {
|
||||
let homeDir = await netcattyBridge.get()?.getHomeDir?.();
|
||||
if (!isConnectRequestCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
if (!homeDir) {
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
|
||||
@@ -592,7 +945,6 @@ export const useSftpConnections = ({
|
||||
files,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearSideReconnecting();
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
files,
|
||||
@@ -601,7 +953,6 @@ export const useSftpConnections = ({
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
clearSideReconnecting();
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
@@ -627,6 +978,7 @@ export const useSftpConnections = ({
|
||||
);
|
||||
|
||||
const sourceSessionId = options?.sourceSessionId;
|
||||
const requireSourceSessionReuse = options?.requireSourceSessionReuse === true;
|
||||
|
||||
const connection: SftpConnection = {
|
||||
id: connectionId,
|
||||
@@ -640,6 +992,7 @@ export const useSftpConnections = ({
|
||||
// non-interactive (loading=true) with stale cached files visible —
|
||||
// no worse than the previous UX of always showing a spinner.
|
||||
reusedConnection: !!sourceSessionId,
|
||||
sourceSessionId: requireSourceSessionReuse ? sourceSessionId : undefined,
|
||||
fileProtocol: host.sftpFileProtocol ?? 'auto',
|
||||
};
|
||||
|
||||
@@ -650,16 +1003,20 @@ export const useSftpConnections = ({
|
||||
// The cached file list is shown as a preview, but the pane stays
|
||||
// non-interactive until the SFTP session is actually established.
|
||||
loading: true,
|
||||
reconnecting: prev.reconnecting,
|
||||
reconnecting: isReconnectAttempt,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
|
||||
files: isReconnectAttempt ? prev.files : (sharedHostCache?.files ?? []),
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
// Subscribe to SFTP connection progress events for auth logging
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
|
||||
activeHostKeySessionsRef.current.set(sftpSessionId, {
|
||||
side,
|
||||
tabId: activeTabId,
|
||||
connectRequestToken,
|
||||
});
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
@@ -713,6 +1070,7 @@ export const useSftpConnections = ({
|
||||
const sftpId = await openSftpConnectionOnce({
|
||||
bridge,
|
||||
sourceSessionId,
|
||||
requireSourceSessionReuse,
|
||||
openOptions: {
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
@@ -721,7 +1079,19 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!sftpId) throw new Error("Failed to open SFTP session");
|
||||
|
||||
sftpSessionsRef.current.set(connectionId, sftpId);
|
||||
const registered = await registerOpenedSftpSession({
|
||||
disposedRef,
|
||||
canRegister: () => (
|
||||
browseConnectionLifecycleRef.current.interactive
|
||||
&& browseConnectionLifecycleRef.current.generation === browseLifecycleGeneration
|
||||
),
|
||||
connectionId,
|
||||
sftpId,
|
||||
sftpSessions: sftpSessionsRef.current,
|
||||
closeSftp: async (openedSftpId) => bridge.closeSftp(openedSftpId),
|
||||
onRemoteSessionClosed: notifyRemoteSessionClosed,
|
||||
});
|
||||
if (!registered) return;
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
@@ -863,8 +1233,6 @@ export const useSftpConnections = ({
|
||||
filenameEncoding,
|
||||
});
|
||||
|
||||
clearSideReconnecting();
|
||||
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
@@ -882,31 +1250,29 @@ export const useSftpConnections = ({
|
||||
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
|
||||
}));
|
||||
} catch (err) {
|
||||
if (!isTargetConnectionCurrent()) {
|
||||
await closeSftpSessionForConnection();
|
||||
return;
|
||||
}
|
||||
// A backend may already be open when initial directory discovery
|
||||
// fails. Never leave that handle mapped behind an error pane.
|
||||
await closeSftpSessionForConnection();
|
||||
clearSideReconnecting();
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
await settleFailedSftpConnectIfCurrent({
|
||||
isCurrent: isTargetConnectionCurrent,
|
||||
close: closeSftpSessionForConnection,
|
||||
updateFailure: () => {
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
connection: prev.connection
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
activeHostKeySessionsRef.current.delete(sftpSessionId);
|
||||
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
|
||||
@@ -915,6 +1281,13 @@ export const useSftpConnections = ({
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
finishSftpTabConnectRequest(
|
||||
connectRequestByTabIdRef.current,
|
||||
activeTabId,
|
||||
connectRequestToken,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -928,6 +1301,7 @@ export const useSftpConnections = ({
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
setPendingHostKeyVerification,
|
||||
browseConnectionLifecycleRef,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -950,56 +1324,72 @@ export const useSftpConnections = ({
|
||||
useEffect(() => {
|
||||
const reconnectTimers: number[] = [];
|
||||
|
||||
/** Prefer lastConnectedHostRef; fall back to vault host via connection.hostId. */
|
||||
const resolveReconnectHost = (side: "left" | "right"): Host | "local" | null => {
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost) return lastHost;
|
||||
|
||||
const connection = getActivePane(side)?.connection;
|
||||
if (!connection) return null;
|
||||
if (connection.isLocal) {
|
||||
lastConnectedHostRef.current[side] = "local";
|
||||
return "local";
|
||||
}
|
||||
if (!connection.hostId) return null;
|
||||
const host = hosts.find((candidate) => candidate.id === connection.hostId) ?? null;
|
||||
const resolveReconnectHost = (
|
||||
side: "left" | "right",
|
||||
pane: SftpPane | null = getActivePane(side),
|
||||
): Host | "local" | null => {
|
||||
const host = resolveSftpReconnectHost({
|
||||
pane,
|
||||
lastHost: lastConnectedHostRef.current[side],
|
||||
connectedHostByTabId: connectedHostByTabIdRef.current,
|
||||
hosts,
|
||||
});
|
||||
if (host) {
|
||||
// Seed the ref so later refresh/session-error paths do not depend on tab races.
|
||||
// Keep legacy side-wide recovery state aligned with the active tab.
|
||||
lastConnectedHostRef.current[side] = host;
|
||||
}
|
||||
return host;
|
||||
};
|
||||
|
||||
const scheduleReconnect = (side: "left" | "right") => {
|
||||
if (!reconnectingRef.current[side]) return;
|
||||
const host = resolveReconnectHost(side);
|
||||
if (!host) return;
|
||||
const scheduleReconnect = (requestedSide: "left" | "right", pane: SftpPane | null) => {
|
||||
const schedule = resolveSftpReconnectSchedule({
|
||||
requestedSide,
|
||||
pane,
|
||||
leftTabs: leftTabsRef.current.tabs,
|
||||
rightTabs: rightTabsRef.current.tabs,
|
||||
});
|
||||
if (!schedule) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!reconnectingRef.current[side]) return;
|
||||
void connect(side, host);
|
||||
const livePane = [
|
||||
...leftTabsRef.current.tabs,
|
||||
...rightTabsRef.current.tabs,
|
||||
].find((candidate) => candidate.id === schedule.tabId) ?? null;
|
||||
const liveSchedule = resolveSftpReconnectSchedule({
|
||||
requestedSide: schedule.side,
|
||||
pane: livePane,
|
||||
leftTabs: leftTabsRef.current.tabs,
|
||||
rightTabs: rightTabsRef.current.tabs,
|
||||
});
|
||||
if (!liveSchedule) return;
|
||||
const host = resolveReconnectHost(liveSchedule.side, livePane);
|
||||
if (!host) return;
|
||||
if (!livePane) return;
|
||||
void connect(
|
||||
liveSchedule.side,
|
||||
host,
|
||||
resolveSftpReconnectOptions(livePane),
|
||||
);
|
||||
}, 1000);
|
||||
reconnectTimers.push(timer);
|
||||
};
|
||||
|
||||
if (leftPane.reconnecting && reconnectingRef.current.left) {
|
||||
scheduleReconnect("left");
|
||||
}
|
||||
if (rightPane.reconnecting && reconnectingRef.current.right) {
|
||||
scheduleReconnect("right");
|
||||
}
|
||||
scheduleReconnect("left", leftPane);
|
||||
scheduleReconnect("right", rightPane);
|
||||
|
||||
return () => {
|
||||
reconnectTimers.forEach((timer) => window.clearTimeout(timer));
|
||||
};
|
||||
}, [
|
||||
leftPane.reconnecting,
|
||||
rightPane.reconnecting,
|
||||
leftPane,
|
||||
rightPane,
|
||||
leftTabsRef,
|
||||
rightTabsRef,
|
||||
connect,
|
||||
connectedHostByTabIdRef,
|
||||
getActivePane,
|
||||
hosts,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
]);
|
||||
|
||||
const disconnect = useCallback(
|
||||
@@ -1010,33 +1400,51 @@ export const useSftpConnections = ({
|
||||
|
||||
if (!pane || !activeTabId) return;
|
||||
|
||||
navSeqRef.current[side] += 1;
|
||||
|
||||
const sftpId = pane.connection
|
||||
? takeSftpConnectionMetadataForClose({
|
||||
connectionId: pane.connection.id,
|
||||
sftpSessions: sftpSessionsRef.current,
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
reconnectingRef.current[side] = false;
|
||||
lastConnectedHostRef.current[side] = null;
|
||||
connectedHostByTabIdRef.current.delete(activeTabId);
|
||||
|
||||
if (pane.connection && !pane.connection.isLocal) {
|
||||
if (sftpId) {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP session during disconnect
|
||||
await runSftpTabDisconnectIfLatest({
|
||||
requests: connectRequestByTabIdRef.current,
|
||||
tabId: activeTabId,
|
||||
disconnect: async () => {
|
||||
clearSftpConnectInFlightForTab(connectInFlightRef.current, activeTabId);
|
||||
const pendingHostKey = hostKeyVerificationRef.current;
|
||||
if (pendingHostKey?.tabId === activeTabId) {
|
||||
rejectHostKeyVerificationRequest(netcattyBridge.get(), pendingHostKey.requestId);
|
||||
setPendingHostKeyVerification(null);
|
||||
}
|
||||
notifyRemoteSessionClosed(sftpId);
|
||||
}
|
||||
}
|
||||
navSeqRef.current[side] += 1;
|
||||
|
||||
updateTab(side, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
|
||||
const sftpId = pane.connection
|
||||
? takeSftpConnectionMetadataForClose({
|
||||
connectionId: pane.connection.id,
|
||||
sftpSessions: sftpSessionsRef.current,
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
lastConnectedHostRef.current[side] = null;
|
||||
connectedHostByTabIdRef.current.delete(activeTabId);
|
||||
|
||||
if (pane.connection && !pane.connection.isLocal && sftpId) {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP session during disconnect
|
||||
}
|
||||
notifyRemoteSessionClosed(sftpId);
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
applyToLiveSftpTabSide({
|
||||
requestedSide: side,
|
||||
tabId: activeTabId,
|
||||
leftTabs: leftTabsRef.current.tabs,
|
||||
rightTabs: rightTabsRef.current.tabs,
|
||||
apply: (liveSide) => {
|
||||
updateTab(liveSide, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, clearCacheForConnection, updateTab],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FileConflict, FileConflictAction, Host, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
|
||||
import { resolveSftpTransferConcurrency } from "../../../domain/sftpTransferConcurrency";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { notify } from "../../notification";
|
||||
@@ -105,6 +106,11 @@ import {
|
||||
waitWhileTransferOrRootPaused,
|
||||
} from "./transferPauseLatch";
|
||||
|
||||
const readSftpFileTransferConcurrency = (): number =>
|
||||
resolveSftpTransferConcurrency(
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
);
|
||||
|
||||
type UploadConflictResolver = {
|
||||
resolve: (action: FileConflictAction) => void;
|
||||
setDefault: (action: FileConflictAction) => void;
|
||||
@@ -833,7 +839,10 @@ export const useSftpExternalOperations = (
|
||||
// Create upload bridge that wraps netcattyBridge.
|
||||
// Pass connect-time Host so pooled stream uploads open the pinned endpoint
|
||||
// (session hostname/port/user overrides), not the vault entry by hostId alone.
|
||||
const createUploadBridge = useCallback((connectHost?: Host): UploadBridge => {
|
||||
const createUploadBridge = useCallback((
|
||||
connectHost?: Host,
|
||||
readTransferConcurrency = () => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
): UploadBridge => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return {
|
||||
managesTransferLifecycle: Boolean(
|
||||
@@ -896,7 +905,7 @@ export const useSftpExternalOperations = (
|
||||
ownerId,
|
||||
options.transferId,
|
||||
getSftpTransferResourceKeys(options),
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
readTransferConcurrency,
|
||||
async () => {
|
||||
let lease: { sftpId: string; release: () => void; discard: () => void } | null = null;
|
||||
try {
|
||||
@@ -1031,7 +1040,10 @@ export const useSftpExternalOperations = (
|
||||
const uploadPaneId = livePane.id;
|
||||
const liveTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(connectHost);
|
||||
const uploadBridge = createUploadBridge(
|
||||
connectHost,
|
||||
readSftpFileTransferConcurrency,
|
||||
);
|
||||
const liveCallbacks = livePane.connection.id === pane.connection.id
|
||||
&& liveTargetPath === uploadTargetPath
|
||||
? callbacks
|
||||
@@ -1068,6 +1080,7 @@ export const useSftpExternalOperations = (
|
||||
targetPath: liveTargetPath,
|
||||
sftpId,
|
||||
targetHostId: livePane.connection.isLocal ? undefined : livePane.connection.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1246,6 +1259,7 @@ export const useSftpExternalOperations = (
|
||||
targetPath: liveTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1375,6 +1389,7 @@ export const useSftpExternalOperations = (
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1595,6 +1610,7 @@ export const useSftpExternalOperations = (
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: uploadBridge,
|
||||
joinPath,
|
||||
@@ -1682,6 +1698,7 @@ export const useSftpExternalOperations = (
|
||||
connectionId?: string;
|
||||
tabId?: string;
|
||||
endpointPin?: UploadEndpointPin;
|
||||
strictConnectionPin?: boolean;
|
||||
},
|
||||
): Promise<UploadResult[]> => {
|
||||
// Pin before any await so tab switches cannot retarget the upload.
|
||||
@@ -1697,6 +1714,7 @@ export const useSftpExternalOperations = (
|
||||
const originatingEndpoint = options?.endpointPin ?? captureUploadEndpoint(
|
||||
originatingPane.connection,
|
||||
connectionCacheKeyMapRef.current,
|
||||
{ pinConnectionId: options?.strictConnectionPin },
|
||||
);
|
||||
assertUploadEndpointUnchanged(
|
||||
originatingPane.connection,
|
||||
@@ -1778,6 +1796,7 @@ export const useSftpExternalOperations = (
|
||||
targetPath: uploadTargetPath,
|
||||
sftpId: uploadSftpId,
|
||||
targetHostId: livePane.connection!.isLocal ? undefined : livePane.connection!.hostId,
|
||||
fileTransferConcurrency: readSftpFileTransferConcurrency(),
|
||||
isLocal: livePane.connection!.isLocal,
|
||||
bridge: directUploadBridge,
|
||||
joinPath,
|
||||
|
||||
@@ -92,7 +92,14 @@ export interface SftpExternalOperationsResult {
|
||||
uploadExternalEntries: (
|
||||
side: "left" | "right",
|
||||
entries: DropEntry[],
|
||||
options?: { targetPath?: string; connectionId?: string; tabId?: string; endpointPin?: UploadEndpointPin },
|
||||
options?: {
|
||||
targetPath?: string;
|
||||
connectionId?: string;
|
||||
tabId?: string;
|
||||
endpointPin?: UploadEndpointPin;
|
||||
/** Fail closed if the selected tab is rebound before upload work starts. */
|
||||
strictConnectionPin?: boolean;
|
||||
},
|
||||
) => Promise<UploadResult[]>;
|
||||
cancelExternalUpload: (taskId?: string) => Promise<void>;
|
||||
selectApplication: () => Promise<{ path: string; name: string } | null>;
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import type { Host } from "../../../domain/models";
|
||||
import { useSftpFollowTerminalCwd } from "./useSftpFollowTerminalCwd";
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
|
||||
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
port: 22,
|
||||
username: "alice",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
|
||||
test("first-open follow uses a fresh terminal cwd instead of the cached home path", async () => {
|
||||
const requestedOptions: unknown[] = [];
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
|
||||
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "/home/alice",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: async (options) => {
|
||||
requestedOptions.push(options);
|
||||
return "/root/releases";
|
||||
},
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(requestedOptions, [{
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
}]);
|
||||
assert.deepEqual(navigatedPaths, ["/root/releases"]);
|
||||
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("first-open follow does not use a stale cached cwd while the fresh probe is pending", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/srv/manual",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "/home/alice",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/releases");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.deepEqual(navigatedPaths, ["/root/releases"]);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight first-open probe cannot navigate after Mosh focus changes", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
|
||||
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
|
||||
navigatedPaths.push(path);
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let focusedSessionId: string | null = "mosh-a";
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onGetTerminalCwd = () => new Promise<string>((resolve) => { resolveCwd = resolve; });
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: null,
|
||||
focusedSessionId,
|
||||
activeTerminalCwd: "/home/alice",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd,
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
|
||||
focusedSessionId = "mosh-b";
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/from-mosh-a");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight first-open probe cannot move a pane after a hidden tab is restored", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let visible = true;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "/home/alice",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: visible,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
|
||||
visible = false;
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
visible = true;
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/releases");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight first-open probe cannot overwrite a manually browsed path", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd: "/home/alice",
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
|
||||
connection.currentPath = "/srv/manual";
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/releases");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("manual go-to-terminal-cwd cannot move a replacement connection", async () => {
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
const navigatedPaths: string[] = [];
|
||||
let activeSessionId = "session-1";
|
||||
let connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
|
||||
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
|
||||
navigatedPaths.push(path);
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let actions: ReturnType<typeof useSftpFollowTerminalCwd> | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
actions = useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
activeTerminalCwd: null,
|
||||
canFollowTerminalCwd: false,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: false,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: () => new Promise<string>((resolve) => { resolveCwd = resolve; }),
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(actions);
|
||||
let navigationPromise: Promise<void> | undefined;
|
||||
await act(async () => {
|
||||
navigationPromise = actions?.handleGoToTerminalCwd();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
|
||||
activeSessionId = "session-2";
|
||||
connection = { ...connection, id: "conn-2" };
|
||||
sftpRef.current.leftPane.connection = connection;
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/releases");
|
||||
await navigationPromise;
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("only trusted live terminal cwd recovery resumes follow after initial probes are exhausted", async () => {
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/srv/manual",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string) => {
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let activeTerminalCwd = "/home/alice";
|
||||
let activeTerminalCwdTrusted = false;
|
||||
let probeCalls = 0;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId: "session-1",
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd: async () => {
|
||||
probeCalls += 1;
|
||||
return null;
|
||||
},
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
for (let attempt = 1; attempt < 3; attempt += 1) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
}
|
||||
assert.equal(probeCalls, 3);
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
|
||||
activeTerminalCwd = null;
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
activeTerminalCwd = "/home/alice";
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
assert.deepEqual(navigatedPaths, [], "an untrusted restored snapshot must stay blocked");
|
||||
|
||||
activeTerminalCwd = "/srv/new";
|
||||
activeTerminalCwdTrusted = true;
|
||||
await act(async () => {
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, ["/srv/new"]);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("an in-flight follow probe cannot navigate after the focused session changes", async () => {
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/home/alice",
|
||||
status: "connected",
|
||||
isLocal: false,
|
||||
};
|
||||
const sftpRef = {
|
||||
current: {
|
||||
leftPane: { connection, loading: false },
|
||||
navigateTo: async (_side: "left", path: string, options?: { shouldApply?: () => boolean }) => {
|
||||
if (options?.shouldApply && !options.shouldApply()) return "aborted" as const;
|
||||
navigatedPaths.push(path);
|
||||
connection.currentPath = path;
|
||||
return "reached" as const;
|
||||
},
|
||||
},
|
||||
};
|
||||
let activeSessionId = "session-a";
|
||||
let activeTerminalCwd: string | null = "/home/alice";
|
||||
let resolveCwd: ((cwd: string) => void) | null = null;
|
||||
let deferProbe = false;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const onGetTerminalCwd = () => {
|
||||
if (!deferProbe) return Promise.resolve("/root/session-a");
|
||||
return new Promise<string>((resolve) => { resolveCwd = resolve; });
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
canFollowTerminalCwd: true,
|
||||
connectionId: connection.id,
|
||||
connectionIsLocal: connection.isLocal,
|
||||
connectionLoading: false,
|
||||
connectionPath: connection.currentPath,
|
||||
connectionStatus: connection.status,
|
||||
effectiveFollowTerminalCwd: true,
|
||||
followTerminalCwdHost: host,
|
||||
hasActiveWork: false,
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
onGetTerminalCwd,
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
|
||||
deferProbe = true;
|
||||
activeTerminalCwd = null;
|
||||
await act(async () => {
|
||||
renderer?.update(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.ok(resolveCwd);
|
||||
|
||||
activeSessionId = "session-b";
|
||||
await act(async () => renderer?.update(React.createElement(Probe)));
|
||||
await act(async () => {
|
||||
resolveCwd?.("/root/from-session-a");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
@@ -0,0 +1,565 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
isFollowOriginStillCurrent,
|
||||
shouldApplyFollowTerminalCwdSyncResult,
|
||||
shouldClearBlockedFollowOnReach,
|
||||
shouldFollowTerminalCwdNavigate,
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange,
|
||||
shouldLatchInitialFollowInterruption,
|
||||
shouldReleaseInitialFollowSyncAttempt,
|
||||
shouldResetInitialFollowTerminalCwdSync,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
} from "../../../domain/sftpFollowTerminalCwd";
|
||||
import type { Host } from "../../../types";
|
||||
import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActions";
|
||||
|
||||
type FollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
|
||||
type SftpFollowApi = {
|
||||
leftPane: {
|
||||
connection?: FollowConnection | null;
|
||||
loading: boolean;
|
||||
};
|
||||
navigateTo: (
|
||||
side: "left" | "right",
|
||||
path: string,
|
||||
options?: SftpNavigateOptions,
|
||||
) => Promise<SftpNavigateResult>;
|
||||
};
|
||||
|
||||
type GetTerminalCwd = (options?: {
|
||||
preferFreshBackend?: boolean;
|
||||
allowRendererFallback?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}) => Promise<string | null>;
|
||||
|
||||
type InitialFollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
|
||||
type InitialFollowSyncOptions = {
|
||||
expectedConnectionId: string;
|
||||
staleTerminalCwd?: string | null;
|
||||
getFreshTerminalCwd: () => Promise<string | null | undefined>;
|
||||
isEligible: () => boolean;
|
||||
getConnection: () => InitialFollowConnection | null | undefined;
|
||||
navigate: (
|
||||
cwd: string,
|
||||
shouldApply: () => boolean,
|
||||
) => Promise<"reached" | "failed" | "aborted" | "superseded">;
|
||||
setHandled: (value: SftpFollowTerminalCwdBlock) => void;
|
||||
setBlocked: (value: SftpFollowTerminalCwdBlock | null) => void;
|
||||
};
|
||||
|
||||
/** Run one guarded first-open sync. False means the caller may retry. */
|
||||
const runInitialFollowTerminalCwdSync = async ({
|
||||
expectedConnectionId,
|
||||
staleTerminalCwd,
|
||||
getFreshTerminalCwd,
|
||||
isEligible,
|
||||
getConnection,
|
||||
navigate,
|
||||
setHandled,
|
||||
setBlocked,
|
||||
}: InitialFollowSyncOptions): Promise<boolean> => {
|
||||
const cwd = await getFreshTerminalCwd();
|
||||
if (!cwd || !isEligible()) return false;
|
||||
|
||||
const live = getConnection();
|
||||
if (!live || live.id !== expectedConnectionId || live.status !== "connected" || live.isLocal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setHandled({
|
||||
connectionId: expectedConnectionId,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== cwd ? staleTerminalCwd : cwd,
|
||||
});
|
||||
if (live.currentPath === cwd) return true;
|
||||
|
||||
const navigateResult = await navigate(cwd, isEligible);
|
||||
if (!isEligible()) return false;
|
||||
const current = getConnection();
|
||||
if (!current || current.id !== expectedConnectionId || current.status !== "connected") {
|
||||
return false;
|
||||
}
|
||||
if (navigateResult === "failed") {
|
||||
setBlocked({ connectionId: expectedConnectionId, terminalCwd: cwd });
|
||||
return true;
|
||||
}
|
||||
if (navigateResult === "reached") {
|
||||
setBlocked(null);
|
||||
return true;
|
||||
}
|
||||
return navigateResult === "superseded";
|
||||
};
|
||||
|
||||
type UseSftpFollowTerminalCwdOptions = {
|
||||
activeSessionId?: string | null;
|
||||
/** Focused terminal including Mosh/ET, used when SSH reuse id is absent. */
|
||||
focusedSessionId?: string | null;
|
||||
activeTerminalCwd: string | null;
|
||||
activeTerminalCwdTrusted?: boolean;
|
||||
canFollowTerminalCwd: boolean;
|
||||
connectionId: string | null;
|
||||
connectionIsLocal?: boolean;
|
||||
connectionLoading: boolean;
|
||||
connectionPath: string | null;
|
||||
connectionStatus?: string;
|
||||
effectiveFollowTerminalCwd: boolean;
|
||||
followTerminalCwdHost: Host | null;
|
||||
hasActiveWork: boolean;
|
||||
isVisible: boolean;
|
||||
ownerPanelOpen: boolean;
|
||||
onGetTerminalCwd?: GetTerminalCwd;
|
||||
onPendingFollowOverride: (override: { hostId: string; value: boolean }) => void;
|
||||
onSftpFollowTerminalCwdChange?: (enabled: boolean, host?: Host | null) => void;
|
||||
sftpRef: MutableRefObject<SftpFollowApi>;
|
||||
};
|
||||
|
||||
export type SftpFollowTerminalCwdActions = {
|
||||
handleGoToTerminalCwd: () => Promise<void>;
|
||||
handleToggleFollowTerminalCwd: () => void;
|
||||
};
|
||||
|
||||
const MAX_INITIAL_FOLLOW_ATTEMPTS = 3;
|
||||
const INITIAL_FOLLOW_RETRY_DELAY_MS = 250;
|
||||
|
||||
/** Owns terminal-cwd follow state so the SFTP view remains presentation-only. */
|
||||
export function useSftpFollowTerminalCwd({
|
||||
activeSessionId,
|
||||
focusedSessionId = null,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted = false,
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
connectionIsLocal,
|
||||
connectionLoading,
|
||||
connectionPath,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
followTerminalCwdHost,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
onGetTerminalCwd,
|
||||
onPendingFollowOverride,
|
||||
onSftpFollowTerminalCwdChange,
|
||||
sftpRef,
|
||||
}: UseSftpFollowTerminalCwdOptions): SftpFollowTerminalCwdActions {
|
||||
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
||||
const followSyncGenerationRef = useRef(0);
|
||||
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
|
||||
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
|
||||
const activeTerminalCwdRef = useRef(activeTerminalCwd);
|
||||
const activeSessionIdRef = useRef(activeSessionId);
|
||||
const focusedSessionIdRef = useRef(focusedSessionId);
|
||||
const connectionIdRef = useRef(connectionId);
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
const ownerPanelOpenRef = useRef(ownerPanelOpen);
|
||||
const hasActiveWorkRef = useRef(hasActiveWork);
|
||||
const initialFollowReadyConnectionRef = useRef<string | null>(null);
|
||||
|
||||
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
||||
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
||||
activeTerminalCwdRef.current = activeTerminalCwd;
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
focusedSessionIdRef.current = focusedSessionId;
|
||||
connectionIdRef.current = connectionId;
|
||||
isVisibleRef.current = isVisible;
|
||||
ownerPanelOpenRef.current = ownerPanelOpen;
|
||||
hasActiveWorkRef.current = hasActiveWork;
|
||||
|
||||
const invalidateInFlightFollowSync = useCallback(() => {
|
||||
followSyncGenerationRef.current += 1;
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
invalidateInFlightFollowSync();
|
||||
}, [connectionId, followTerminalCwdHost?.id, invalidateInFlightFollowSync]);
|
||||
|
||||
const lastLiveTerminalCwdRef = useRef<string | null>(activeTerminalCwd);
|
||||
const lastLiveTerminalCwdTrustedRef = useRef(activeTerminalCwdTrusted);
|
||||
useEffect(() => {
|
||||
const cwdInvalidates = shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: activeTerminalCwd,
|
||||
lastCwd: lastLiveTerminalCwdRef.current,
|
||||
isVisible,
|
||||
});
|
||||
const trustInvalidates = activeTerminalCwd !== null
|
||||
&& activeTerminalCwdTrusted !== lastLiveTerminalCwdTrustedRef.current;
|
||||
if (!cwdInvalidates && !trustInvalidates) return;
|
||||
lastLiveTerminalCwdRef.current = activeTerminalCwd;
|
||||
lastLiveTerminalCwdTrustedRef.current = activeTerminalCwdTrusted;
|
||||
if (activeTerminalCwd && activeTerminalCwdTrusted && connectionIdRef.current) {
|
||||
// A concrete live cwd is stronger evidence than the exhausted initial
|
||||
// backend probes. Let the ordinary follow path resume immediately.
|
||||
initialFollowReadyConnectionRef.current = connectionIdRef.current;
|
||||
}
|
||||
invalidateInFlightFollowSync();
|
||||
}, [activeTerminalCwd, activeTerminalCwdTrusted, invalidateInFlightFollowSync, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveFollowTerminalCwd) return;
|
||||
invalidateInFlightFollowSync();
|
||||
}, [effectiveFollowTerminalCwd, invalidateInFlightFollowSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const blockedFollow = blockedFollowRef.current;
|
||||
if (shouldClearBlockedFollowOnReach(
|
||||
blockedFollow,
|
||||
connectionId,
|
||||
connectionPath,
|
||||
connectionLoading,
|
||||
)) {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = blockedFollow;
|
||||
}
|
||||
}, [connectionId, connectionLoading, connectionPath]);
|
||||
|
||||
const handleGoToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd) return;
|
||||
const expectedConnectionId = sftpRef.current.leftPane.connection?.id ?? null;
|
||||
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
if (!expectedConnectionId) return;
|
||||
const shouldApply = () => (
|
||||
syncGeneration === followSyncGenerationRef.current
|
||||
&& isFollowOriginStillCurrent({
|
||||
expectedOriginId: expectedSessionId,
|
||||
liveOriginId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
|
||||
})
|
||||
&& connectionIdRef.current === expectedConnectionId
|
||||
&& sftpRef.current.leftPane.connection?.id === expectedConnectionId
|
||||
&& sftpRef.current.leftPane.connection?.status === "connected"
|
||||
);
|
||||
const cwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
if (!cwd) return;
|
||||
if (!shouldApply()) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
if (navigateResult !== "reached" || !shouldApply()) return;
|
||||
blockedFollowRef.current = null;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (connection?.id) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
|
||||
}
|
||||
}, [onGetTerminalCwd, sftpRef]);
|
||||
|
||||
const syncFollowToTerminalCwd = useCallback(async () => {
|
||||
if (!onGetTerminalCwd || !effectiveFollowTerminalCwd || !canFollowTerminalCwd) return;
|
||||
|
||||
const liveConnectionId = sftpRef.current.leftPane.connection?.id ?? null;
|
||||
if (!liveConnectionId || initialFollowReadyConnectionRef.current !== liveConnectionId) return;
|
||||
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
|
||||
const expectedConnectionIdAtStart = connectionIdRef.current ?? liveConnectionId;
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
|
||||
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
|
||||
if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
}
|
||||
if (!terminalCwd) return;
|
||||
if (!shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
expectedSessionId,
|
||||
liveSessionId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
|
||||
expectedConnectionId: expectedConnectionIdAtStart,
|
||||
liveConnectionId: connectionIdRef.current,
|
||||
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
|
||||
})) return;
|
||||
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (!shouldFollowTerminalCwdNavigate({
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
isVisible,
|
||||
terminalCwd,
|
||||
currentPath: connection?.currentPath,
|
||||
connectionId: connection?.id,
|
||||
hasActiveWork,
|
||||
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
|
||||
blockedFollow: blockedFollowRef.current,
|
||||
handledFollow: handledFollowRef.current,
|
||||
})) {
|
||||
if (
|
||||
connection?.id
|
||||
&& !connection.isLocal
|
||||
&& connection.status === "connected"
|
||||
&& connection.currentPath === terminalCwd
|
||||
) {
|
||||
handledFollowRef.current = { connectionId: connection.id, terminalCwd };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedConnectionId = connection?.id ?? null;
|
||||
const shouldApplyCurrentFollowSync = () => shouldApplyFollowTerminalCwdSyncResult({
|
||||
syncGeneration,
|
||||
currentGeneration: followSyncGenerationRef.current,
|
||||
followEnabled: effectiveFollowTerminalCwdRef.current,
|
||||
canFollow: canFollowTerminalCwdRef.current,
|
||||
expectedSessionId,
|
||||
liveSessionId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
|
||||
expectedConnectionId,
|
||||
liveConnectionId: connectionIdRef.current,
|
||||
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
|
||||
expectedTerminalCwd: terminalCwd,
|
||||
liveTerminalCwd: activeTerminalCwdRef.current,
|
||||
requireLiveTerminalCwd: usesLiveTerminalCwd,
|
||||
});
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
|
||||
shouldApply: shouldApplyCurrentFollowSync,
|
||||
});
|
||||
if (!shouldApplyCurrentFollowSync()) return;
|
||||
|
||||
const currentConnection = sftpRef.current.leftPane.connection;
|
||||
if (!currentConnection || currentConnection.id !== connection?.id) return;
|
||||
if (navigateResult === "failed") {
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
} else if (navigateResult === "superseded") {
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
} else if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
}
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
sftpRef,
|
||||
]);
|
||||
|
||||
const handleToggleFollowTerminalCwd = useCallback(() => {
|
||||
const nextEnabled = !effectiveFollowTerminalCwd;
|
||||
invalidateInFlightFollowSync();
|
||||
if (followTerminalCwdHost?.id) {
|
||||
onPendingFollowOverride({ hostId: followTerminalCwdHost.id, value: nextEnabled });
|
||||
}
|
||||
onSftpFollowTerminalCwdChange?.(nextEnabled, followTerminalCwdHost);
|
||||
}, [
|
||||
effectiveFollowTerminalCwd,
|
||||
followTerminalCwdHost,
|
||||
invalidateInFlightFollowSync,
|
||||
onPendingFollowOverride,
|
||||
onSftpFollowTerminalCwdChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
void syncFollowToTerminalCwd();
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
connectionIsLocal,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
syncFollowToTerminalCwd,
|
||||
]);
|
||||
|
||||
const initialFollowSyncedConnRef = useRef<string | null>(null);
|
||||
const initialFollowRetryRef = useRef<{ connectionId: string | null; attempts: number }>({
|
||||
connectionId: null,
|
||||
attempts: 0,
|
||||
});
|
||||
const initialFollowRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initialFollowInterruptedRef = useRef(false);
|
||||
const initialFollowProbeSeqRef = useRef(0);
|
||||
const initialFollowProbeAttemptRef = useRef<number | null>(null);
|
||||
const initialFollowMountedRef = useRef(true);
|
||||
const [initialFollowRetryNonce, setInitialFollowRetryNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
initialFollowMountedRef.current = true;
|
||||
return () => {
|
||||
initialFollowMountedRef.current = false;
|
||||
if (initialFollowRetryTimerRef.current) clearTimeout(initialFollowRetryTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFollowProbeAttemptRef.current === null) return;
|
||||
if (!shouldLatchInitialFollowInterruption({ isVisible, ownerPanelOpen })) return;
|
||||
initialFollowInterruptedRef.current = true;
|
||||
}, [isVisible, ownerPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible || !ownerPanelOpen || !initialFollowRetryTimerRef.current) return;
|
||||
clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
}, [isVisible, ownerPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
connectionId,
|
||||
trackedConnectionId: initialFollowRetryRef.current.connectionId,
|
||||
})) return;
|
||||
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
initialFollowReadyConnectionRef.current = null;
|
||||
initialFollowInterruptedRef.current = false;
|
||||
initialFollowRetryRef.current = { connectionId, attempts: 0 };
|
||||
followSyncGenerationRef.current += 1;
|
||||
initialFollowProbeAttemptRef.current = null;
|
||||
if (initialFollowRetryTimerRef.current) {
|
||||
clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
}
|
||||
}, [connectionId, isVisible, ownerPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
if (!connection || connection.isLocal || connection.status !== "connected" || !connection.id) return;
|
||||
if (initialFollowSyncedConnRef.current === connection.id) return;
|
||||
if (initialFollowRetryRef.current.connectionId !== connection.id) {
|
||||
initialFollowRetryRef.current = { connectionId: connection.id, attempts: 0 };
|
||||
}
|
||||
if (initialFollowRetryRef.current.attempts >= MAX_INITIAL_FOLLOW_ATTEMPTS) return;
|
||||
initialFollowRetryRef.current.attempts += 1;
|
||||
initialFollowSyncedConnRef.current = connection.id;
|
||||
|
||||
const expectedConnectionId = connection.id;
|
||||
const expectedPanePath = connection.currentPath ?? null;
|
||||
const expectedSessionId = focusedSessionId ?? activeSessionId ?? null;
|
||||
const staleTerminalCwd = activeTerminalCwdRef.current;
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
let navigationStarted = false;
|
||||
const followCurrentlyEligible = () => (
|
||||
initialFollowMountedRef.current
|
||||
&& effectiveFollowTerminalCwdRef.current
|
||||
&& canFollowTerminalCwdRef.current
|
||||
&& isVisibleRef.current
|
||||
&& !hasActiveWorkRef.current
|
||||
&& isFollowOriginStillCurrent({
|
||||
expectedOriginId: expectedSessionId,
|
||||
liveOriginId: focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null,
|
||||
})
|
||||
&& sftpRef.current.leftPane.connection?.id === expectedConnectionId
|
||||
&& (
|
||||
navigationStarted
|
||||
|| (sftpRef.current.leftPane.connection?.currentPath ?? null) === expectedPanePath
|
||||
)
|
||||
&& !sftpRef.current.leftPane.connection?.isLocal
|
||||
&& sftpRef.current.leftPane.connection?.status === "connected"
|
||||
);
|
||||
const followStillEligible = () => (
|
||||
syncGeneration === followSyncGenerationRef.current
|
||||
&& !initialFollowInterruptedRef.current
|
||||
&& followCurrentlyEligible()
|
||||
);
|
||||
const clearAttemptAndRetry = () => {
|
||||
if (initialFollowInterruptedRef.current) return;
|
||||
if (
|
||||
initialFollowSyncedConnRef.current === expectedConnectionId
|
||||
&& !shouldReleaseInitialFollowSyncAttempt({
|
||||
isVisible: isVisibleRef.current,
|
||||
ownerPanelOpen: ownerPanelOpenRef.current,
|
||||
})
|
||||
) return;
|
||||
if (
|
||||
!initialFollowMountedRef.current
|
||||
|| !followCurrentlyEligible()
|
||||
|| initialFollowRetryRef.current.attempts >= MAX_INITIAL_FOLLOW_ATTEMPTS
|
||||
) {
|
||||
if (initialFollowSyncedConnRef.current === expectedConnectionId) {
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initialFollowRetryTimerRef.current) clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = setTimeout(() => {
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
if (!shouldReleaseInitialFollowSyncAttempt({
|
||||
isVisible: isVisibleRef.current,
|
||||
ownerPanelOpen: ownerPanelOpenRef.current,
|
||||
})) return;
|
||||
if (initialFollowSyncedConnRef.current === expectedConnectionId) {
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
}
|
||||
setInitialFollowRetryNonce((value) => value + 1);
|
||||
}, INITIAL_FOLLOW_RETRY_DELAY_MS);
|
||||
};
|
||||
|
||||
initialFollowProbeSeqRef.current += 1;
|
||||
const probeAttempt = initialFollowProbeSeqRef.current;
|
||||
initialFollowProbeAttemptRef.current = probeAttempt;
|
||||
void runInitialFollowTerminalCwdSync({
|
||||
expectedConnectionId,
|
||||
staleTerminalCwd,
|
||||
getFreshTerminalCwd: () => onGetTerminalCwd?.({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
requireActiveShellCwd: true,
|
||||
}),
|
||||
isEligible: followStillEligible,
|
||||
getConnection: () => sftpRef.current.leftPane.connection,
|
||||
navigate: (cwd, shouldApply) => {
|
||||
navigationStarted = true;
|
||||
return sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
},
|
||||
setHandled: (value) => { handledFollowRef.current = value; },
|
||||
setBlocked: (value) => { blockedFollowRef.current = value; },
|
||||
}).then((completed) => {
|
||||
const isCurrentAttempt = initialFollowProbeAttemptRef.current === probeAttempt;
|
||||
if (isCurrentAttempt) initialFollowProbeAttemptRef.current = null;
|
||||
if (completed && isCurrentAttempt) {
|
||||
initialFollowReadyConnectionRef.current = expectedConnectionId;
|
||||
}
|
||||
if (!completed && isCurrentAttempt) clearAttemptAndRetry();
|
||||
});
|
||||
}, [
|
||||
activeSessionId,
|
||||
focusedSessionId,
|
||||
activeTerminalCwd,
|
||||
canFollowTerminalCwd,
|
||||
connectionId,
|
||||
connectionIsLocal,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
initialFollowRetryNonce,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
sftpRef,
|
||||
]);
|
||||
|
||||
return { handleGoToTerminalCwd, handleToggleFollowTerminalCwd };
|
||||
}
|
||||
@@ -39,7 +39,6 @@ interface UseSftpPaneActionsParams {
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||||
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
clearCacheForConnection: (connectionId: string) => void;
|
||||
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||||
@@ -98,7 +97,6 @@ export const useSftpPaneActions = ({
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
listLocalFiles,
|
||||
@@ -464,8 +462,7 @@ export const useSftpPaneActions = ({
|
||||
|| pane.connection.isLocal
|
||||
|| (!pane.connection.isLocal && pane.connection.hostId)
|
||||
);
|
||||
if (canReconnect && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
if (canReconnect) {
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
@@ -486,8 +483,7 @@ export const useSftpPaneActions = ({
|
||||
// when they switch back to that tab.
|
||||
if (options?.tabId) return;
|
||||
const lastHost = lastConnectedHostRef.current[side];
|
||||
if (lastHost && !reconnectingRef.current[side]) {
|
||||
reconnectingRef.current[side] = true;
|
||||
if (lastHost) {
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
@@ -501,7 +497,7 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef, sftpSessionsRef],
|
||||
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, sftpSessionsRef],
|
||||
);
|
||||
|
||||
const navigateUp = useCallback(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after } from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSftpSessionCleanup } from "./useSftpSessionCleanup";
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; });
|
||||
|
||||
test("cleanup marks the SFTP owner disposed before a late open can register", async () => {
|
||||
const sessionsRef = { current: new Map<string, string>() };
|
||||
let disposedRef: { current: boolean } | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
|
||||
function Probe() {
|
||||
disposedRef = useSftpSessionCleanup(sessionsRef);
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
assert.equal(disposedRef?.current, false);
|
||||
|
||||
await act(async () => renderer?.unmount());
|
||||
assert.equal(disposedRef?.current, true);
|
||||
});
|
||||
@@ -1,19 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useSftpSessionCleanup = (sftpSessionsRef: MutableRefObject<Map<string, string>>) => {
|
||||
export const useSftpSessionCleanup = (
|
||||
sftpSessionsRef: MutableRefObject<Map<string, string>>,
|
||||
): MutableRefObject<boolean> => {
|
||||
const disposedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
disposedRef.current = false;
|
||||
const sessionsRef = sftpSessionsRef.current;
|
||||
|
||||
return () => {
|
||||
sessionsRef.forEach(async (sftpId) => {
|
||||
try {
|
||||
await netcattyBridge.get()?.closeSftp(sftpId);
|
||||
} catch {
|
||||
// Ignore errors when closing SFTP sessions during cleanup
|
||||
}
|
||||
});
|
||||
disposedRef.current = true;
|
||||
const sftpIds = [...sessionsRef.values()];
|
||||
sessionsRef.clear();
|
||||
for (const sftpId of sftpIds) {
|
||||
void netcattyBridge.get()?.closeSftp(sftpId).catch(() => {
|
||||
// Ignore errors when closing SFTP sessions during cleanup.
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [sftpSessionsRef]);
|
||||
|
||||
return disposedRef;
|
||||
};
|
||||
@@ -13,7 +13,6 @@ interface UseSftpSessionErrorsParams {
|
||||
) => void;
|
||||
navSeqRef: MutableRefObject<{ left: number; right: number }>;
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
|
||||
releaseConnection: (connectionId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -42,7 +41,6 @@ export const useSftpSessionErrors = ({
|
||||
updateActiveTab,
|
||||
navSeqRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
releaseConnection,
|
||||
}: UseSftpSessionErrorsParams) =>
|
||||
useCallback(
|
||||
@@ -64,22 +62,10 @@ export const useSftpSessionErrors = ({
|
||||
connection: pane.connection,
|
||||
});
|
||||
|
||||
if (canReconnect && !reconnectingRef.current[side]) {
|
||||
if (canReconnect) {
|
||||
// Keep the connection object (host identity + path) so the UI does not
|
||||
// collapse into the empty host picker when listing root (or any path)
|
||||
// fails with a transient session error on some servers.
|
||||
reconnectingRef.current[side] = true;
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
loading: false,
|
||||
error: "sftp.error.connectionLostReconnecting",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (canReconnect && reconnectingRef.current[side]) {
|
||||
// Already reconnecting — keep connection, avoid blank host picker.
|
||||
updateActiveTab(side, (prev) => ({
|
||||
...prev,
|
||||
reconnecting: true,
|
||||
@@ -108,7 +94,6 @@ export const useSftpSessionErrors = ({
|
||||
updateActiveTab,
|
||||
navSeqRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
releaseConnection,
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createEmptyPane } from "./types.ts";
|
||||
import {
|
||||
closeSftpTabStateImmediately,
|
||||
removeSftpTabFromState,
|
||||
} from "./useSftpTabsState.ts";
|
||||
|
||||
test("closing the active SFTP tab selects its nearest remaining neighbor", () => {
|
||||
const left = createEmptyPane("left");
|
||||
const middle = createEmptyPane("middle");
|
||||
const right = createEmptyPane("right");
|
||||
|
||||
assert.deepEqual(removeSftpTabFromState({
|
||||
tabs: [left, middle, right],
|
||||
activeTabId: middle.id,
|
||||
}, middle.id), {
|
||||
tabs: [left, right],
|
||||
activeTabId: right.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("closing a background SFTP tab preserves the active tab", () => {
|
||||
const active = createEmptyPane("active");
|
||||
const background = createEmptyPane("background");
|
||||
|
||||
assert.deepEqual(removeSftpTabFromState({
|
||||
tabs: [active, background],
|
||||
activeTabId: active.id,
|
||||
}, background.id), {
|
||||
tabs: [active],
|
||||
activeTabId: active.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("closing a tab updates the live ref before React commits the state update", () => {
|
||||
const closing = createEmptyPane("closing");
|
||||
const remaining = createEmptyPane("remaining");
|
||||
const initial = {
|
||||
tabs: [closing, remaining],
|
||||
activeTabId: closing.id,
|
||||
};
|
||||
const tabsRef = { current: initial };
|
||||
let queuedUpdate: ((prev: typeof initial) => typeof initial) | undefined;
|
||||
|
||||
closeSftpTabStateImmediately({
|
||||
tabsRef,
|
||||
tabId: closing.id,
|
||||
setTabs: (update) => {
|
||||
queuedUpdate = update;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(tabsRef.current.tabs, [remaining]);
|
||||
assert.equal(tabsRef.current.activeTabId, remaining.id);
|
||||
assert.deepEqual(queuedUpdate?.(initial), tabsRef.current);
|
||||
});
|
||||
@@ -37,6 +37,39 @@ interface SftpTabsState {
|
||||
|
||||
const EMPTY_SELECTION = new Set<string>();
|
||||
|
||||
export function removeSftpTabFromState(prev: SftpSideTabs, tabId: string): SftpSideTabs {
|
||||
const tabIndex = prev.tabs.findIndex((tab) => tab.id === tabId);
|
||||
if (tabIndex === -1) return prev;
|
||||
|
||||
let activeTabId: string | null = null;
|
||||
if (prev.tabs.length > 1) {
|
||||
if (prev.activeTabId === tabId) {
|
||||
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
|
||||
activeTabId = prev.tabs[nextIndex]?.id ?? null;
|
||||
} else {
|
||||
activeTabId = prev.activeTabId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: prev.tabs.filter((tab) => tab.id !== tabId),
|
||||
activeTabId,
|
||||
};
|
||||
}
|
||||
|
||||
export function closeSftpTabStateImmediately(params: {
|
||||
tabsRef: { current: SftpSideTabs };
|
||||
tabId: string;
|
||||
setTabs: (updater: (prev: SftpSideTabs) => SftpSideTabs) => void;
|
||||
}): void {
|
||||
params.tabsRef.current = removeSftpTabFromState(params.tabsRef.current, params.tabId);
|
||||
params.setTabs((prev) => {
|
||||
const next = removeSftpTabFromState(prev, params.tabId);
|
||||
params.tabsRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export const useSftpTabsState = ({
|
||||
defaultShowHiddenFiles = false,
|
||||
}: {
|
||||
@@ -154,24 +187,14 @@ export const useSftpTabsState = ({
|
||||
const closeTab = useCallback(
|
||||
(side: "left" | "right", tabId: string) => {
|
||||
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
|
||||
setTabs((prev) => {
|
||||
const tabIndex = prev.tabs.findIndex((t) => t.id === tabId);
|
||||
if (tabIndex === -1) return prev;
|
||||
|
||||
let newActiveTabId: string | null = null;
|
||||
if (prev.tabs.length > 1) {
|
||||
if (prev.activeTabId === tabId) {
|
||||
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
|
||||
newActiveTabId = prev.tabs[nextIndex]?.id || null;
|
||||
} else {
|
||||
newActiveTabId = prev.activeTabId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tabs: prev.tabs.filter((t) => t.id !== tabId),
|
||||
activeTabId: newActiveTabId,
|
||||
};
|
||||
const tabsRef = side === "left" ? leftTabsRef : rightTabsRef;
|
||||
// Make the removal visible to in-flight SFTP work immediately, before
|
||||
// React commits the state update, so a late connection cannot register
|
||||
// itself to a tab the user has already closed.
|
||||
closeSftpTabStateImmediately({
|
||||
tabsRef,
|
||||
tabId,
|
||||
setTabs,
|
||||
});
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getSftpBreadcrumbSegments,
|
||||
getSftpFilterAfterPathChange,
|
||||
getSftpFilterAfterPathChangeError,
|
||||
getSftpPathRoot,
|
||||
isConcreteTransferTargetPath,
|
||||
isSftpDescendantPath,
|
||||
isWindowsRoot,
|
||||
@@ -323,3 +324,18 @@ test("SFTP filter restores when changed-directory navigation fails", () => {
|
||||
test("SFTP filter preserves in-flight edits when same-directory refresh fails", () => {
|
||||
assert.equal(getSftpFilterAfterPathChangeError(false, "log", "typed-while-loading"), "typed-while-loading");
|
||||
});
|
||||
|
||||
test("getSftpPathRoot resolves the filesystem root for breadcrumb navigation", () => {
|
||||
assert.equal(getSftpPathRoot("/var/www"), "/");
|
||||
assert.equal(getSftpPathRoot("/"), "/");
|
||||
assert.equal(getSftpPathRoot("//srv/share/logs"), "/");
|
||||
assert.equal(getSftpPathRoot("C:\\Users\\alice"), "C:\\");
|
||||
assert.equal(getSftpPathRoot("D:/"), "D:\\");
|
||||
assert.equal(getSftpPathRoot("\\\\server\\share\\folder"), "\\\\server\\share");
|
||||
// Forward-slash //host/share stays POSIX unless UNC is explicitly accepted.
|
||||
assert.equal(getSftpPathRoot("//srv/share/logs"), "/");
|
||||
assert.equal(
|
||||
getSftpPathRoot("//srv/share/logs", { acceptForwardSlashUnc: true }),
|
||||
"\\\\srv\\share",
|
||||
);
|
||||
});
|
||||
@@ -212,6 +212,23 @@ const normalizeWindowsRoot = (path: string): string => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filesystem root for the given path, used by the breadcrumb "go to root" affordance:
|
||||
* "/" on POSIX panes, the drive root (C:\) or UNC share root on Windows panes.
|
||||
* Returns null when no root can be derived (e.g. a relative Windows path).
|
||||
*/
|
||||
export const getSftpPathRoot = (
|
||||
path: string,
|
||||
options?: SftpWindowsPathOptions,
|
||||
): string | null => {
|
||||
if (!isWindowsPath(path, options)) return "/";
|
||||
const normalized = path.replace(/\//g, "\\");
|
||||
const uncRoot = getWindowsUncRoot(normalized, options);
|
||||
if (uncRoot) return uncRoot;
|
||||
const drive = normalized.match(/^[A-Za-z]:/);
|
||||
return drive ? `${drive[0]}\\` : null;
|
||||
};
|
||||
|
||||
export const isWindowsRoot = (
|
||||
path: string,
|
||||
options?: SftpWindowsPathOptions,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
|
||||
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
|
||||
export async function runTransferAndWaitForOwner(
|
||||
task: TransferTask,
|
||||
start: () => Promise<StreamResult>,
|
||||
shouldAbort: () => boolean,
|
||||
pausedAtResume?: TransferTask,
|
||||
): Promise<StreamResult> {
|
||||
// Register before admission/start: an owner may finish while dispatch waits for resume.
|
||||
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
|
||||
try {
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
|
||||
if (admission === "cancelled") throw new Error("Transfer cancelled");
|
||||
if (admission === "completed" || observation.read()?.status === "completed") return {};
|
||||
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
|
||||
if (admission === "ready") break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
// Admission begins a new attempt; discard any previous failed settlement.
|
||||
observation.dispose();
|
||||
observation = sftpTransferCenterStore.observeTaskSettlement(task);
|
||||
const result = await start();
|
||||
if (!result?.superseded) return result;
|
||||
for (;;) {
|
||||
if (shouldAbort()) throw new Error("Transfer cancelled");
|
||||
const latest = observation.read();
|
||||
if (latest?.status === "completed") return { ...result, superseded: false };
|
||||
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
|
||||
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} finally {
|
||||
observation.dispose();
|
||||
}
|
||||
}
|
||||
@@ -3762,3 +3762,108 @@ test("hard directory resume failure falls back to bounded fresh retry history",
|
||||
assert.equal(retainedParent?.checkpointBytes, 0);
|
||||
assert.ok(snapshot.length <= 200, `failed directory history must stay bounded, got ${snapshot.length}`);
|
||||
});
|
||||
|
||||
|
||||
test("held dedicated file resume cannot overwrite a subsequent pause", async (t) => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
let releaseRun!: () => void;
|
||||
let started!: () => void;
|
||||
let releaseResume!: (result: { success: boolean; lifecycleEpoch: number }) => void;
|
||||
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
|
||||
const startGate = new Promise<void>((resolve) => { started = resolve; });
|
||||
const resumeGate = new Promise<{ success: boolean; lifecycleEpoch: number }>((resolve) => { releaseResume = resolve; });
|
||||
t.after(async () => {
|
||||
releaseRun();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
|
||||
resetTransferPauseLatchesForTests();
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
|
||||
resumeTransfer: () => resumeGate,
|
||||
} } });
|
||||
store.publishOwner("dedicated-resume", [{
|
||||
...makeTask("held-file", "interrupted"),
|
||||
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
|
||||
}]);
|
||||
store.setDedicatedResumeHandler(async () => {
|
||||
store.patchTask("held-file", { status: "transferring", reconnectRequired: false });
|
||||
started();
|
||||
await runGate;
|
||||
return { success: true };
|
||||
});
|
||||
const first = store.resume("held-file");
|
||||
await startGate;
|
||||
await store.pause("held-file");
|
||||
const second = store.resume("held-file");
|
||||
await store.pause("held-file");
|
||||
releaseResume({ success: true, lifecycleEpoch: 2 });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const row = store.getSnapshot().tasks.find((task) => task.id === "held-file");
|
||||
releaseRun();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(row?.status, "paused", "held transfer must respect the latest pause");
|
||||
assert.equal(row?.lifecycleEpoch, 3);
|
||||
});
|
||||
|
||||
for (const outcome of ["rejected", "stream-gone"] as const) {
|
||||
test(`held file ${outcome} resume cannot restart after a newer pause during wind-down`, async (t) => {
|
||||
const store = createSftpTransferCenterStore();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
let releaseRun!: () => void;
|
||||
let started!: () => void;
|
||||
let resolveResume!: (result: { success: boolean; reason: string }) => void;
|
||||
let rejectResume!: (error: Error) => void;
|
||||
let resumeCalls = 0;
|
||||
const runGate = new Promise<void>((resolve) => { releaseRun = resolve; });
|
||||
const startGate = new Promise<void>((resolve) => { started = resolve; });
|
||||
const resumeGate = new Promise<{ success: boolean; reason: string }>((resolve, reject) => {
|
||||
resolveResume = resolve;
|
||||
rejectResume = reject;
|
||||
});
|
||||
t.after(async () => {
|
||||
releaseRun();
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
const { resetTransferPauseLatchesForTests } = await import("./sftp/transferPauseLatch");
|
||||
resetTransferPauseLatchesForTests();
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 2, lifecycleEpoch: 3 }),
|
||||
resumeTransfer: () => ++resumeCalls === 1
|
||||
? resumeGate
|
||||
: Promise.resolve({ success: true, lifecycleEpoch: 4 }),
|
||||
} } });
|
||||
const id = `held-file-${outcome}`;
|
||||
store.publishOwner("dedicated-resume", [{
|
||||
...makeTask(id, "interrupted"),
|
||||
ownerId: "dedicated-resume", targetHostId: "host-a", reconnectRequired: true,
|
||||
}]);
|
||||
store.setDedicatedResumeHandler(async () => {
|
||||
store.patchTask(id, { status: "transferring", reconnectRequired: false });
|
||||
started();
|
||||
await runGate;
|
||||
return { success: false, error: "Transfer cancelled" };
|
||||
});
|
||||
const first = store.resume(id);
|
||||
await startGate;
|
||||
await store.pause(id);
|
||||
const second = store.resume(id);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
if (outcome === "stream-gone") {
|
||||
resolveResume({ success: false, reason: "Transfer is no longer active" });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
await store.pause(id);
|
||||
if (outcome === "rejected") {
|
||||
rejectResume(new Error("worker channel unavailable"));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
releaseRun();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(store.getSnapshot().tasks.find((task) => task.id === id)?.status, "paused");
|
||||
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./sftp/transferControlEpoch";
|
||||
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
|
||||
import {
|
||||
isTransferOrRootCancelled,
|
||||
markTransferCancelledTree,
|
||||
settleTransferCancelTree,
|
||||
} from "./sftp/transferCancelLatch";
|
||||
@@ -96,6 +97,10 @@ export interface SftpTransferCenterStore {
|
||||
getBadgeSnapshot(): { count: number; hasAttention: boolean };
|
||||
/** Single task row — same object identity until that row is patched. */
|
||||
getTask(taskId: string): TransferTask | undefined;
|
||||
/** Observe exact task settlement before completed child rows are compacted. */
|
||||
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
|
||||
/** Publish an explicit dispatch identity without flushing large child-history batches. */
|
||||
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
|
||||
getOwnerTasks(ownerId: string): TransferTask[];
|
||||
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
|
||||
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
|
||||
@@ -398,6 +403,15 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
let snapshotDirty = false;
|
||||
const listeners = new Set<Listener>();
|
||||
const progressListeners = new Set<Listener>();
|
||||
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
|
||||
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
|
||||
expected.id === candidate.id
|
||||
&& expected.sourcePath === candidate.sourcePath
|
||||
&& expected.targetPath === candidate.targetPath
|
||||
&& expected.parentTaskId === candidate.parentTaskId
|
||||
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
|
||||
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
|
||||
);
|
||||
const refreshBadgeSnapshot = () => {
|
||||
const next = buildBadgeSnapshot(tasks);
|
||||
if (
|
||||
@@ -609,6 +623,14 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// live renderer walk keeps its controls until TransferRuntime.runWalk
|
||||
// settles; everything else can release its whole tree here.
|
||||
settleFinishedTransferControlState(tasks, tasks);
|
||||
if (settlementObservers.size > 0) {
|
||||
for (const task of tasks) {
|
||||
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
|
||||
for (const observer of settlementObservers.get(task.id) ?? []) {
|
||||
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
|
||||
}
|
||||
}
|
||||
}
|
||||
const beforePrune = tasks;
|
||||
tasks = pruneSftpTransferHistory(tasks);
|
||||
const retainedIds = new Set(tasks.map((task) => task.id));
|
||||
@@ -1307,6 +1329,70 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
},
|
||||
getSnapshot: () => ensureSnapshot(),
|
||||
getBadgeSnapshot: () => badgeSnapshot,
|
||||
admitTaskRun(incoming, pausedAtResume) {
|
||||
const rootId = incoming.parentTaskId ?? incoming.id;
|
||||
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
|
||||
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
|
||||
if (parent?.status === "cancelled") return "cancelled";
|
||||
const index = tasks.findIndex((task) => task.id === incoming.id);
|
||||
const existing = index < 0 ? undefined : tasks[index];
|
||||
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|
||||
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
|
||||
if (existing?.status === "cancelled") return "cancelled";
|
||||
if (existing?.status === "completed") {
|
||||
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
|
||||
}
|
||||
const resumesUnchangedPause = existing === pausedAtResume
|
||||
&& existing?.status === "paused"
|
||||
&& (parent?.status === "pending" || parent?.status === "transferring");
|
||||
if (isTransferOrRootPauseLatched(rootId, incoming.id)
|
||||
|| parent?.status === "paused" || parent?.status === "pausing"
|
||||
|| (existing?.status === "paused" && !resumesUnchangedPause) || existing?.status === "pausing") return "paused";
|
||||
if (parent?.status === "completed") return "conflict";
|
||||
if (!existing) return "ready";
|
||||
if (existing.status === "transferring"
|
||||
&& existing.directoryEntryIndex === incoming.directoryEntryIndex
|
||||
&& existing.directoryEntryIdentity === incoming.directoryEntryIdentity) return "ready";
|
||||
// Explicit retry may leave failed/interrupted and replace its manifest identity.
|
||||
// Do not overwrite byte checkpoints with a delayed renderer snapshot.
|
||||
const next = tasks.slice();
|
||||
next[index] = {
|
||||
...existing,
|
||||
directoryEntryIndex: incoming.directoryEntryIndex,
|
||||
directoryEntryIdentity: incoming.directoryEntryIdentity,
|
||||
...(existing.status === "transferring" ? {} : {
|
||||
status: "transferring" as const,
|
||||
lifecycleEpoch: undefined,
|
||||
error: undefined,
|
||||
endTime: undefined,
|
||||
reconnectRequired: false,
|
||||
}),
|
||||
};
|
||||
tasks = next;
|
||||
emitProgress(false);
|
||||
return "ready";
|
||||
},
|
||||
observeTaskSettlement(expected) {
|
||||
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
|
||||
const observers = settlementObservers.get(expected.id) ?? new Set();
|
||||
observers.add(observer);
|
||||
settlementObservers.set(expected.id, observers);
|
||||
let disposed = false;
|
||||
return {
|
||||
read() {
|
||||
if (disposed) return undefined;
|
||||
if (observer.settled) return observer.settled;
|
||||
const current = tasks.find((task) => task.id === expected.id);
|
||||
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
observer.settled = undefined;
|
||||
observers.delete(observer);
|
||||
if (observers.size === 0) settlementObservers.delete(expected.id);
|
||||
},
|
||||
};
|
||||
},
|
||||
getTask(taskId) {
|
||||
return tasks.find((task) => task.id === taskId);
|
||||
},
|
||||
@@ -1671,7 +1757,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// Always clear process-global latches + control epoch so walks wake and
|
||||
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
|
||||
// control epoch as task.lifecycleEpoch (bridge-aligned only).
|
||||
bumpTransferControlEpoch(taskId);
|
||||
let resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
|
||||
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
|
||||
@@ -1693,64 +1779,53 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
try {
|
||||
const resumeIds = [taskId, ...childIds.filter((id) => id !== taskId)];
|
||||
const results = await Promise.all(resumeIds.map(async (id) =>
|
||||
netcattyBridge.get()?.resumeTransfer?.(id) ?? { success: false },
|
||||
));
|
||||
const after = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (after?.status === "cancelled") return existing;
|
||||
// Only rejoin when at least one backend stream actually resumed.
|
||||
// Empty/all-fail must not paint transferring over a dead held run.
|
||||
const successIds = resumeIds.filter((_, index) => results[index]?.success);
|
||||
if (successIds.length > 0) {
|
||||
const resumed = new Set(successIds);
|
||||
// Align with softResumeTransfer: prefer bridge lifecycleEpoch; clear
|
||||
// if omitted so main-process progress is not stale-dropped.
|
||||
let bridgeEpoch: number | undefined;
|
||||
for (let index = 0; index < results.length; index += 1) {
|
||||
if (!results[index]?.success) continue;
|
||||
const epoch = (results[index] as { lifecycleEpoch?: number } | undefined)?.lifecycleEpoch;
|
||||
if (!Number.isFinite(epoch)) continue;
|
||||
bridgeEpoch = bridgeEpoch === undefined
|
||||
? (epoch as number)
|
||||
: Math.max(bridgeEpoch, epoch as number);
|
||||
}
|
||||
tasks = tasks.map((candidate) => {
|
||||
if (candidate.id === taskId || resumed.has(candidate.id)) {
|
||||
return {
|
||||
...candidate,
|
||||
status: "transferring" as const,
|
||||
error: undefined,
|
||||
reconnectRequired: false,
|
||||
pauseUnavailableReason: undefined,
|
||||
phase: undefined,
|
||||
lifecycleEpoch: bridgeEpoch,
|
||||
};
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
// Reuse the live control path: held dedicated transfers must obey
|
||||
// the same ordering and per-child lifecycle rules as panel jobs.
|
||||
const softOperation = softResumeTransfer({
|
||||
getTasks: () => tasks,
|
||||
setTasks: (next) => { tasks = next; emit(); },
|
||||
getBridge: defaultTransferControlBridge,
|
||||
}, taskId);
|
||||
resumeEpoch = getTransferControlEpoch(taskId);
|
||||
const soft = await softOperation;
|
||||
if (soft.handled) return existing;
|
||||
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
|
||||
.test(soft.reason ?? "");
|
||||
if (!streamGone) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "paused" as const,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
error: soft.reason,
|
||||
} : candidate);
|
||||
emit();
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to await + restart.
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
// After demotion to interrupted/attention/failed while work unwinds:
|
||||
// wait then re-invoke (do not rejoin a dying canceling promise).
|
||||
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
|
||||
const resumeEpoch = getTransferControlEpoch(taskId);
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
return existing;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type SidePanelLiveSnapshot = {
|
||||
sftpActiveHost: Host | null;
|
||||
activeTerminalSessionIdForSftp: string | null;
|
||||
activeTerminalCwd: string | null;
|
||||
activeTerminalCwdTrusted: boolean;
|
||||
activeWorkspace: Workspace | undefined;
|
||||
activeTerminalSessionForSystem: TerminalSession | null;
|
||||
activeSystemSessionHost: Host | null;
|
||||
@@ -25,6 +26,7 @@ const EMPTY_SNAPSHOT: SidePanelLiveSnapshot = {
|
||||
sftpActiveHost: null,
|
||||
activeTerminalSessionIdForSftp: null,
|
||||
activeTerminalCwd: null,
|
||||
activeTerminalCwdTrusted: false,
|
||||
activeWorkspace: undefined,
|
||||
activeTerminalSessionForSystem: null,
|
||||
activeSystemSessionHost: null,
|
||||
@@ -50,6 +52,7 @@ function liveSnapshotEqual(a: SidePanelLiveSnapshot, b: SidePanelLiveSnapshot):
|
||||
return a.sftpActiveHost === b.sftpActiveHost
|
||||
&& a.activeTerminalSessionIdForSftp === b.activeTerminalSessionIdForSftp
|
||||
&& a.activeTerminalCwd === b.activeTerminalCwd
|
||||
&& a.activeTerminalCwdTrusted === b.activeTerminalCwdTrusted
|
||||
&& a.activeWorkspace === b.activeWorkspace
|
||||
&& a.activeTerminalSessionForSystem === b.activeTerminalSessionForSystem
|
||||
&& a.activeSystemSessionHost === b.activeSystemSessionHost
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { isAutoLaunchResultTrustworthy } from './systemSettingsEffects.ts';
|
||||
|
||||
test('isAutoLaunchResultTrustworthy trusts a successful read/write result', () => {
|
||||
assert.equal(isAutoLaunchResultTrustworthy({ success: true }), true);
|
||||
});
|
||||
|
||||
test('isAutoLaunchResultTrustworthy rejects a transient failure result', () => {
|
||||
assert.equal(
|
||||
isAutoLaunchResultTrustworthy({ success: false }),
|
||||
false,
|
||||
'success:false means the OS state is unknown, not confirmed disabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('isAutoLaunchResultTrustworthy defaults to trusting a result with no success field', () => {
|
||||
// Defensive default for any future/alternate caller that omits the field
|
||||
// entirely — only an explicit false should withhold trust.
|
||||
assert.equal(isAutoLaunchResultTrustworthy({}), true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Minimal model of the hydration effect's decision: given a getAutoLaunch()
|
||||
* result, should it overwrite the cached autoLaunchEnabled value? This is
|
||||
* the exact regression scenario from the review — a transient read failure
|
||||
* must not overwrite a cached `true` with a "successful-looking" `false`,
|
||||
* which would otherwise cascade into an unwanted disable write via the
|
||||
* adjacent push effect.
|
||||
*/
|
||||
function simulateHydration(cachedEnabled: boolean, result: { success: boolean; enabled: boolean }): boolean {
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return cachedEnabled;
|
||||
return result.enabled;
|
||||
}
|
||||
|
||||
test('a transient read failure during hydration keeps the cached enabled value', () => {
|
||||
const next = simulateHydration(true, { success: false, enabled: false });
|
||||
assert.equal(next, true, 'must not silently flip a cached true to false on an unrelated read failure');
|
||||
});
|
||||
|
||||
test('a successful read during hydration applies the real OS state, even when it flips the cache', () => {
|
||||
const next = simulateHydration(true, { success: true, enabled: false });
|
||||
assert.equal(next, false, 'a confirmed read must still win over a stale cache');
|
||||
});
|
||||
|
||||
/**
|
||||
* Model of the actual hook: a mount-time hydration request and a
|
||||
* user-triggered write can race. This mirrors the real ordering the review
|
||||
* flagged — hydration starts, the user toggles before it resolves (which
|
||||
* starts a real write), then the stale hydration response arrives.
|
||||
*/
|
||||
function simulateMountToggleRace(
|
||||
initialCachedEnabled: boolean,
|
||||
userToggleTo: boolean,
|
||||
staleHydrationResult: { success: boolean; enabled: boolean },
|
||||
): boolean {
|
||||
let state = initialCachedEnabled;
|
||||
let writeStarted = false;
|
||||
|
||||
// Hydration request is issued here (pending) — resolution happens later.
|
||||
|
||||
// The user toggles before hydration resolves; this is what the real push
|
||||
// effect does: apply optimistically and flag that a real write started.
|
||||
state = userToggleTo;
|
||||
writeStarted = true;
|
||||
|
||||
// The stale hydration response now arrives.
|
||||
if (!writeStarted && isAutoLaunchResultTrustworthy(staleHydrationResult)) {
|
||||
state = staleHydrationResult.enabled;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
test('a user toggle during in-flight hydration is not clobbered by the stale hydration response', () => {
|
||||
const result = simulateMountToggleRace(false, true, { success: true, enabled: false });
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
true,
|
||||
'the stale response (enabled:false, read before the user toggled) must not overwrite the user\'s fresh true — ' +
|
||||
'otherwise the adjacent push effect reacts to the overwrite and disables an item the user just enabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('a user toggle to false during in-flight hydration is not clobbered by a stale enabled:true response', () => {
|
||||
const result = simulateMountToggleRace(true, false, { success: true, enabled: true });
|
||||
|
||||
assert.equal(result, false, 'symmetric case: disabling must also win over a stale hydration response');
|
||||
});
|
||||
|
||||
test('hydration still applies normally when it resolves before any write starts', () => {
|
||||
// No race: writeStarted stays false throughout, so trustworthy hydration
|
||||
// results must still be applied — the guard must not disable hydration
|
||||
// unconditionally, only once a real write has actually begun.
|
||||
let state = false;
|
||||
let writeStarted = false;
|
||||
const result = { success: true, enabled: true };
|
||||
|
||||
if (!writeStarted && isAutoLaunchResultTrustworthy(result)) {
|
||||
state = result.enabled;
|
||||
}
|
||||
|
||||
assert.equal(state, true);
|
||||
void writeStarted;
|
||||
});
|
||||
|
||||
/**
|
||||
* Model of the push effect's own overlap guard: rapid double (or triple)
|
||||
* toggles start multiple bridge.setAutoLaunch(...) calls that can resolve
|
||||
* out of order. Each response is only reconciled into state if no newer
|
||||
* request has started since — this is the exact scenario the review
|
||||
* flagged: "each callback closes over its render's autoLaunchEnabled value
|
||||
* ... without checking whether a newer request exists".
|
||||
*/
|
||||
class AutoLaunchWriteSimulator {
|
||||
state: boolean;
|
||||
private generation = 0;
|
||||
|
||||
constructor(initial: boolean) {
|
||||
this.state = initial;
|
||||
}
|
||||
|
||||
/** Simulates the user toggling the switch, starting a new write. */
|
||||
startWrite(requestedValue: boolean): { requestGeneration: number; requestedValue: boolean } {
|
||||
this.state = requestedValue;
|
||||
this.generation += 1;
|
||||
return { requestGeneration: this.generation, requestedValue };
|
||||
}
|
||||
|
||||
/** Simulates that write's bridge.setAutoLaunch(...) response arriving. */
|
||||
resolveWrite(request: { requestGeneration: number; requestedValue: boolean }, result: { success: boolean; enabled: boolean }) {
|
||||
if (this.generation !== request.requestGeneration) return; // superseded — ignore
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return;
|
||||
if (result.enabled !== request.requestedValue) this.state = result.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
test('a stale write response is ignored once a newer request has started', () => {
|
||||
const sim = new AutoLaunchWriteSimulator(false);
|
||||
|
||||
const first = sim.startWrite(true); // user enables
|
||||
const second = sim.startWrite(false); // user immediately disables again, before `first` resolves
|
||||
assert.equal(sim.state, false, 'the latest toggle is reflected optimistically right away');
|
||||
|
||||
// `first`'s response arrives late, reporting a mismatch against ITS OWN
|
||||
// request (e.g. macOS pending approval reported enabled:false for a
|
||||
// requested true) — without the generation guard this would incorrectly
|
||||
// "correct" state to false right as `second` is still in flight, and
|
||||
// with a mismatch reported the OTHER way it could just as easily stomp
|
||||
// a subsequent true.
|
||||
sim.resolveWrite(first, { success: true, enabled: false });
|
||||
assert.equal(sim.state, false, 'the stale response for the superseded first request must not touch state');
|
||||
|
||||
// `second`'s own (current) response arrives and is applied normally.
|
||||
sim.resolveWrite(second, { success: true, enabled: false });
|
||||
assert.equal(sim.state, false);
|
||||
});
|
||||
|
||||
test('a stale write response would have incorrectly overridden a later successful write, without the guard', () => {
|
||||
const sim = new AutoLaunchWriteSimulator(false);
|
||||
|
||||
const first = sim.startWrite(true); // request enable — this one will report a mismatch
|
||||
const second = sim.startWrite(true); // user toggles off and back on again quickly; also requests true
|
||||
assert.equal(sim.state, true);
|
||||
|
||||
// second's response arrives first (network/OS timing is not ordered) and
|
||||
// confirms the real, current state.
|
||||
sim.resolveWrite(second, { success: true, enabled: true });
|
||||
assert.equal(sim.state, true);
|
||||
|
||||
// first's stale response finally arrives, reporting a mismatch (its own
|
||||
// request was true but the OS said false at the time) — must not undo
|
||||
// the now-confirmed true state from the newer request.
|
||||
sim.resolveWrite(first, { success: true, enabled: false });
|
||||
assert.equal(
|
||||
sim.state,
|
||||
true,
|
||||
'a delayed response from a superseded request must not overwrite the newer, already-confirmed state',
|
||||
);
|
||||
});
|
||||
|
||||
test('the only in-flight write still reconciles a genuine mismatch normally', () => {
|
||||
const sim = new AutoLaunchWriteSimulator(false);
|
||||
|
||||
const only = sim.startWrite(true);
|
||||
sim.resolveWrite(only, { success: true, enabled: false }); // e.g. blocked by Windows Startup Apps
|
||||
|
||||
assert.equal(sim.state, false, 'with no overlap, a real mismatch must still correct the optimistic toggle');
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
@@ -24,11 +25,24 @@ import {
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
|
||||
/**
|
||||
* A getAutoLaunch/setAutoLaunch IPC result is only safe to apply to state
|
||||
* when the main process could actually determine the OS's login-item state.
|
||||
* success:false means "unknown" (a transient read/write failure), not a
|
||||
* confirmed disabled state — applying enabled:false in that case would
|
||||
* overwrite a valid cached value and, via the push effect, cascade into an
|
||||
* unwanted disable write that could remove a working login item.
|
||||
*/
|
||||
export function isAutoLaunchResultTrustworthy(result: { success?: boolean }): boolean {
|
||||
return result.success !== false;
|
||||
}
|
||||
|
||||
interface UseSystemSettingsEffectsParams {
|
||||
enabled?: boolean;
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
autoLaunchEnabled: boolean;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
appIconVariant: AppIconVariant;
|
||||
@@ -38,6 +52,8 @@ interface UseSystemSettingsEffectsParams {
|
||||
setHotkeyRegistrationError: (error: string | null) => void;
|
||||
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setAppIconVariant: (variant: AppIconVariant | ((prev: AppIconVariant) => AppIconVariant)) => void;
|
||||
setAutoLaunchEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
|
||||
setAutoLaunchSupported: (supported: boolean) => void;
|
||||
notifySettingsChanged: (key: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -46,6 +62,7 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
appIconVariant,
|
||||
@@ -55,9 +72,26 @@ export function useSystemSettingsEffects({
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
setAppIconVariant,
|
||||
setAutoLaunchEnabled,
|
||||
setAutoLaunchSupported,
|
||||
notifySettingsChanged,
|
||||
}: UseSystemSettingsEffectsParams) {
|
||||
const appIconApplyRequestIdRef = useRef(0);
|
||||
// True once the push effect has issued a real OS write (i.e. after mount,
|
||||
// when persistMountedRef is set — see below). If the user toggles
|
||||
// auto-launch while the mount-time hydration read is still in flight, the
|
||||
// hydration effect must not let its now-stale response overwrite that
|
||||
// choice, or the adjacent push effect reacts to the overwrite and issues
|
||||
// the opposite OS write right after the user's own request lands.
|
||||
const autoLaunchWriteStartedRef = useRef(false);
|
||||
// Incremented on every push-effect invocation. If the user toggles again
|
||||
// before an in-flight bridge.setAutoLaunch(...) resolves, that older
|
||||
// call's response is stale by the time it arrives — comparing its own
|
||||
// request against its own result is self-consistent but says nothing
|
||||
// about whether a newer request has since superseded it, so a delayed
|
||||
// response could otherwise "correct" state the user has already moved
|
||||
// past (undoing a toggle they made after this specific request started).
|
||||
const autoLaunchWriteGenerationRef = useRef(0);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
@@ -139,6 +173,61 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-launch from the main process on mount — the OS login item
|
||||
// is the real source of truth (the user may have toggled it outside the
|
||||
// app), localStorage is only an optimistic cache for first paint.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getAutoLaunch) return;
|
||||
let cancelled = false;
|
||||
bridge.getAutoLaunch().then((result) => {
|
||||
if (cancelled) return;
|
||||
setAutoLaunchSupported(result.supported);
|
||||
// The user (or an already-issued write) may have moved state on while
|
||||
// this request was in flight — a stale hydration response must not
|
||||
// clobber it, or the push effect below reacts to the clobber and
|
||||
// fires an unwanted OS write right after the user's own request.
|
||||
if (autoLaunchWriteStartedRef.current) return;
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return;
|
||||
setAutoLaunchEnabled(result.enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_LAUNCH_ENABLED, result.enabled ? 'true' : 'false');
|
||||
}).catch((err) => {
|
||||
console.warn('[AutoLaunch] Failed to read login item state:', err);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Runs once per mount — this is a one-shot hydration, not a sync loop.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled]);
|
||||
|
||||
// Push auto-launch changes to the main process and cache the result.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!persistMountedRef.current) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setAutoLaunch) return;
|
||||
// A real write is now in flight — permanently disqualify the mount-time
|
||||
// hydration effect above from applying a (now possibly stale) response.
|
||||
autoLaunchWriteStartedRef.current = true;
|
||||
const requestGeneration = ++autoLaunchWriteGenerationRef.current;
|
||||
bridge.setAutoLaunch(autoLaunchEnabled).then((result) => {
|
||||
setAutoLaunchSupported(result.supported);
|
||||
// The user toggled again before this request resolved — a newer
|
||||
// request (and its own response, once it arrives) is authoritative;
|
||||
// reconciling this stale one could undo a choice made after it fired.
|
||||
if (autoLaunchWriteGenerationRef.current !== requestGeneration) return;
|
||||
if (!isAutoLaunchResultTrustworthy(result)) return;
|
||||
if (result.enabled !== autoLaunchEnabled) setAutoLaunchEnabled(result.enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_LAUNCH_ENABLED, result.enabled ? 'true' : 'false');
|
||||
}).catch((err) => {
|
||||
console.warn('[AutoLaunch] Failed to update login item state:', err);
|
||||
});
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_LAUNCH_ENABLED, autoLaunchEnabled);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, autoLaunchEnabled]);
|
||||
|
||||
// Persist and apply app-level HTTP(S) network proxy (cloud sync / AI)
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createCopiedTerminalSessionClone,
|
||||
createSplitTerminalSessionClone,
|
||||
isTerminalSessionEligibleForSftpReuse,
|
||||
resolveSftpReuseSourceSessionId,
|
||||
} from "./terminalConnectionReuse";
|
||||
|
||||
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
|
||||
@@ -33,6 +34,18 @@ test("SSH sessions stay SFTP-linkable while reconnecting", () => {
|
||||
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ etEnabled: true })), false);
|
||||
});
|
||||
|
||||
test("Mosh and ET drops keep their origin but do not request SSH session reuse", () => {
|
||||
assert.equal(resolveSftpReuseSourceSessionId(session(), "session-1"), "session-1");
|
||||
assert.equal(
|
||||
resolveSftpReuseSourceSessionId(session({ moshEnabled: true }), "session-1"),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
resolveSftpReuseSourceSessionId(session({ etEnabled: true }), "session-1"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("non-SSH or unavailable sessions do not reuse a connection", () => {
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "connecting" })), false);
|
||||
assert.equal(canReuseTerminalConnection(session({ status: "disconnected" })), false);
|
||||
@@ -85,6 +98,50 @@ test("copy session clones reuse SSH sources and preserve serial config", () => {
|
||||
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
|
||||
});
|
||||
|
||||
test("copy session clones open a fresh connection when reuseConnection is false", () => {
|
||||
const fresh = createCopiedTerminalSessionClone(session(), { id: "copy-fresh", reuseConnection: false });
|
||||
assert.equal(fresh.reuseConnectionFromSessionId, undefined);
|
||||
// The explicit fresh flag must survive: without it the bridge treats the
|
||||
// clone as eligible for general endpoint reuse and can still borrow the
|
||||
// source's live authenticated transport.
|
||||
assert.equal(fresh.requireFreshConnection, true);
|
||||
// Local sources never had reuse to drop — behavior is unchanged.
|
||||
assert.equal(
|
||||
createCopiedTerminalSessionClone(session({ protocol: "local" }), { id: "copy-local-fresh", reuseConnection: false }).reuseConnectionFromSessionId,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("copy and split clones default to no fresh-connection requirement", () => {
|
||||
assert.equal(createCopiedTerminalSessionClone(session(), { id: "copy-default" }).requireFreshConnection, undefined);
|
||||
assert.equal(createSplitTerminalSessionClone(session(), { id: "split-default" }).requireFreshConnection, undefined);
|
||||
assert.equal(
|
||||
createCopiedTerminalSessionClone(session(), { id: "copy-reuse", reuseConnection: true }).requireFreshConnection,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("fresh SSH clones discard inherited directories even when a caller supplies one", () => {
|
||||
for (const protocol of ["ssh", undefined] as const) {
|
||||
const clone = createCopiedTerminalSessionClone(session({ protocol }), {
|
||||
id: "fresh-no-cwd", reuseConnection: false, inheritedCwd: "/srv/old-target",
|
||||
});
|
||||
assert.equal(clone.pendingInitialCwd, undefined);
|
||||
assert.equal(clone.requireFreshConnection, true);
|
||||
}
|
||||
const localClone = createCopiedTerminalSessionClone(session({ protocol: "local" }), {
|
||||
id: "local-cwd", reuseConnection: false, inheritedCwd: "/home/alice/project",
|
||||
});
|
||||
assert.equal(localClone.localStartDir, "/home/alice/project");
|
||||
});
|
||||
|
||||
test("copy session clones default to connection reuse when reuseConnection is unset", () => {
|
||||
assert.equal(
|
||||
createCopiedTerminalSessionClone(session(), { id: "copy-default" }).reuseConnectionFromSessionId,
|
||||
"session-1",
|
||||
);
|
||||
});
|
||||
|
||||
test("split and copy session clones preserve local start directory", () => {
|
||||
const source = session({
|
||||
protocol: "local",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
|
||||
type SftpReuseCandidate = Pick<
|
||||
TerminalSession,
|
||||
"protocol" | "moshEnabled" | "etEnabled"
|
||||
>;
|
||||
|
||||
/** SSH (non-mosh/et) sessions that can back SFTP once the transport is up. */
|
||||
export function isTerminalSessionEligibleForSftpReuse(session: TerminalSession): boolean {
|
||||
export function isTerminalSessionEligibleForSftpReuse(session: SftpReuseCandidate): boolean {
|
||||
return (
|
||||
(session.protocol === "ssh" || session.protocol === undefined)
|
||||
&& !session.moshEnabled
|
||||
@@ -9,6 +14,15 @@ export function isTerminalSessionEligibleForSftpReuse(session: TerminalSession):
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSftpReuseSourceSessionId(
|
||||
session: SftpReuseCandidate,
|
||||
sessionId: string,
|
||||
): string | undefined {
|
||||
return isTerminalSessionEligibleForSftpReuse(session)
|
||||
? sessionId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function canReuseTerminalConnection(session: TerminalSession): boolean {
|
||||
return isTerminalSessionEligibleForSftpReuse(session) && session.status === "connected";
|
||||
}
|
||||
@@ -18,6 +32,12 @@ type CloneSessionOptions = {
|
||||
localShellType?: TerminalSession["shellType"];
|
||||
workspaceId?: string;
|
||||
inheritedCwd?: string;
|
||||
/**
|
||||
* When false, the clone never reuses the source's established SSH channel and
|
||||
* instead opens a brand-new connection (fresh auth; e.g. a new bastion login).
|
||||
* Defaults to true (reuse when possible).
|
||||
*/
|
||||
reuseConnection?: boolean;
|
||||
};
|
||||
|
||||
function getClonedShellType(
|
||||
@@ -36,7 +56,8 @@ function createTerminalSessionClone(
|
||||
// pendingInitialCwd for telnet/serial/mosh/et would be dead, never-cleared
|
||||
// state (those protocols don't track cwd, so it's never consumed or cleared).
|
||||
const injectsInheritedCwd =
|
||||
(session.protocol === "ssh" || session.protocol === undefined)
|
||||
options.reuseConnection !== false
|
||||
&& (session.protocol === "ssh" || session.protocol === undefined)
|
||||
&& !session.moshEnabled
|
||||
&& !session.etEnabled;
|
||||
const clonedSession: TerminalSession = {
|
||||
@@ -64,7 +85,15 @@ function createTerminalSessionClone(
|
||||
fontSizeOverride: session.fontSizeOverride,
|
||||
...(session.ephemeralHost ? { ephemeralHost: true } : {}),
|
||||
...(injectsInheritedCwd && options.inheritedCwd ? { pendingInitialCwd: options.inheritedCwd } : {}),
|
||||
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
|
||||
// Clearing `reuseConnectionFromSessionId` only disables source-specific
|
||||
// reuse; without an explicit fresh-connection flag the bridge would still
|
||||
// be eligible for general endpoint/idle transport reuse (e.g. borrowing
|
||||
// the source's live transport). Flag the clone so the starter sends
|
||||
// `reuseTransport: false` and the session truly dials fresh.
|
||||
...(options.reuseConnection === false ? { requireFreshConnection: true } : {}),
|
||||
reuseConnectionFromSessionId: options.reuseConnection === false
|
||||
? undefined
|
||||
: (canReuseTerminalConnection(session) ? session.id : undefined),
|
||||
};
|
||||
|
||||
if (options.workspaceId) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
|
||||
import { terminalCwdStore } from './terminalCwdStore.ts';
|
||||
|
||||
test('terminalCwdStore bumps version only when cwd changes', () => {
|
||||
test('terminalCwdStore bumps version only when cwd or provenance changes', () => {
|
||||
const versions: number[] = [];
|
||||
const unsubscribe = terminalCwdStore.subscribe(() => {
|
||||
versions.push(terminalCwdStore.getVersion());
|
||||
@@ -20,6 +20,10 @@ test('terminalCwdStore bumps version only when cwd changes', () => {
|
||||
assert.equal(terminalCwdStore.setCwd('s1', '/var'), true);
|
||||
assert.equal(terminalCwdStore.getCwd('s1'), '/var');
|
||||
|
||||
assert.equal(terminalCwdStore.setCwd('s1', '/var', 'backend-strict'), true);
|
||||
assert.equal(terminalCwdStore.getSource('s1'), 'backend-strict');
|
||||
assert.equal(terminalCwdStore.setCwd('s1', '/var', 'backend-strict'), false);
|
||||
|
||||
terminalCwdStore.prune(new Set());
|
||||
assert.equal(terminalCwdStore.getCwd('s1'), null);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
type Listener = () => void;
|
||||
|
||||
export type TerminalCwdSource = "osc7" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
|
||||
|
||||
/**
|
||||
* Live terminal CWD map + version token.
|
||||
* OSC 7 / cwd probes update this store without setState on TerminalLayer,
|
||||
@@ -7,6 +9,7 @@ type Listener = () => void;
|
||||
*/
|
||||
class TerminalCwdStore {
|
||||
private cwdBySession = new Map<string, string>();
|
||||
private sourceBySession = new Map<string, TerminalCwdSource>();
|
||||
private version = 0;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
@@ -17,6 +20,11 @@ class TerminalCwdStore {
|
||||
return this.cwdBySession.get(sessionId) ?? null;
|
||||
};
|
||||
|
||||
getSource = (sessionId: string | null | undefined): TerminalCwdSource | undefined => {
|
||||
if (!sessionId) return undefined;
|
||||
return this.sourceBySession.get(sessionId);
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
@@ -24,15 +32,21 @@ class TerminalCwdStore {
|
||||
};
|
||||
};
|
||||
|
||||
setCwd(sessionId: string, cwd: string | null): boolean {
|
||||
setCwd(sessionId: string, cwd: string | null, source?: TerminalCwdSource): boolean {
|
||||
const current = this.cwdBySession.get(sessionId) ?? null;
|
||||
const currentSource = this.sourceBySession.get(sessionId);
|
||||
const next = cwd && cwd.trim().length > 0 ? cwd : null;
|
||||
if (current === next) return false;
|
||||
const nextSource = next
|
||||
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
|
||||
: undefined;
|
||||
if (current === next && currentSource === nextSource) return false;
|
||||
|
||||
if (next) {
|
||||
this.cwdBySession.set(sessionId, next);
|
||||
this.sourceBySession.set(sessionId, nextSource!);
|
||||
} else {
|
||||
this.cwdBySession.delete(sessionId);
|
||||
this.sourceBySession.delete(sessionId);
|
||||
}
|
||||
this.version += 1;
|
||||
for (const listener of this.listeners) {
|
||||
@@ -46,6 +60,7 @@ class TerminalCwdStore {
|
||||
for (const sessionId of this.cwdBySession.keys()) {
|
||||
if (!validSessionIds.has(sessionId)) {
|
||||
this.cwdBySession.delete(sessionId);
|
||||
this.sourceBySession.delete(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createTerminalSelectionAttachment,
|
||||
decodeTerminalSelectionAttachment,
|
||||
} from "./terminalSelectionAttachment.ts";
|
||||
import { createVaultNoteAttachment, formatVaultNoteReferences } from "./vaultNoteAttachment.ts";
|
||||
|
||||
test("createTerminalSelectionAttachment returns null for blank selections", () => {
|
||||
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
|
||||
@@ -50,3 +51,13 @@ test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts
|
||||
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildPromptWithTerminalSelectionAttachments keeps empty Vault note blocks", () => {
|
||||
const attachment = createVaultNoteAttachment({ id: "note-1", title: "Runbook" });
|
||||
|
||||
assert.ok(attachment);
|
||||
assert.equal(
|
||||
buildPromptWithTerminalSelectionAttachments("add deployment steps to this note", [attachment]),
|
||||
`add deployment steps to this note\n\n${formatVaultNoteReferences([attachment])}`,
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
|
||||
import {
|
||||
formatVaultNoteReferences,
|
||||
isVaultNoteAttachment,
|
||||
} from "./vaultNoteAttachment";
|
||||
|
||||
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
|
||||
|
||||
@@ -81,6 +85,16 @@ export function isTerminalSelectionAttachment(
|
||||
return attachment.terminalSelection === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments whose text is inlined into the prompt instead of being sent as
|
||||
* file parts: terminal selections and Vault note mentions.
|
||||
*/
|
||||
export function isInlineTextAttachment(
|
||||
attachment: Pick<ChatMessageAttachment | UploadedFile, "terminalSelection" | "vaultNoteId">,
|
||||
): boolean {
|
||||
return isTerminalSelectionAttachment(attachment) || isVaultNoteAttachment(attachment);
|
||||
}
|
||||
|
||||
export function buildPromptWithTerminalSelectionAttachments(
|
||||
prompt: string,
|
||||
attachments: Array<ChatMessageAttachment | UploadedFile>,
|
||||
@@ -95,7 +109,11 @@ export function buildPromptWithTerminalSelectionAttachments(
|
||||
})
|
||||
.filter((block): block is string => block !== null);
|
||||
|
||||
if (terminalBlocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
|
||||
return `${prompt}${terminalBlocks.join("")}`;
|
||||
const notes = attachments.filter(isVaultNoteAttachment);
|
||||
const noteBlocks = notes.length ? [`\n\n${formatVaultNoteReferences(notes)}`] : [];
|
||||
|
||||
const blocks = [...terminalBlocks, ...noteBlocks];
|
||||
if (blocks.length === 0) return prompt;
|
||||
if (!prompt.trim()) return blocks.join("").trimStart();
|
||||
return `${prompt}${blocks.join("")}`;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
uploadFromDataTransfer,
|
||||
uploadFromFileList,
|
||||
} from "../../lib/uploadService.ts";
|
||||
import { DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY } from "../../domain/sftpTransferConcurrency.ts";
|
||||
|
||||
function createDataTransfer(files: File[]): DataTransfer {
|
||||
return {
|
||||
@@ -197,6 +198,50 @@ test("uploads picked folder files with their relative directory structure", asyn
|
||||
]);
|
||||
});
|
||||
|
||||
test("ordinary multi-file uploads use the shared transfer concurrency", async () => {
|
||||
const countInitiallyStarted = async (fileTransferConcurrency?: number) => {
|
||||
const started: string[] = [];
|
||||
let releaseAll!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { releaseAll = resolve; });
|
||||
const files = Array.from({ length: DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY + 2 }, (_, index) => {
|
||||
const file = new File([`file-${index}`], `file-${index}.txt`);
|
||||
Object.defineProperty(file, "path", { value: `/local/file-${index}.txt` });
|
||||
return file;
|
||||
});
|
||||
|
||||
const uploading = uploadFromFileList(
|
||||
files,
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
fileTransferConcurrency,
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
startStreamTransfer: async ({ targetPath }) => {
|
||||
started.push(targetPath);
|
||||
await gate;
|
||||
return { transferId: targetPath };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const initiallyStarted = started.length;
|
||||
releaseAll();
|
||||
await uploading;
|
||||
return initiallyStarted;
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await countInitiallyStarted(),
|
||||
DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY,
|
||||
);
|
||||
assert.equal(await countInitiallyStarted(3), 3);
|
||||
});
|
||||
|
||||
test("compression remains enabled for DataTransfer folder uploads that have a conflict resolver", async (t) => {
|
||||
const starts: string[] = [];
|
||||
installCompressedUploadBridge(t, {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
|
||||
import { STORAGE_KEY_AI_COMMAND_BLOCKLIST } from '../../infrastructure/config/storageKeys.ts';
|
||||
import { useAISettingsState } from './useAISettingsState.ts';
|
||||
|
||||
test('command blocklist storage events refresh the mounted AI settings state', async () => {
|
||||
const store = new Map<string, string>();
|
||||
const fakeStorage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, String(value)),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
};
|
||||
const fakeWindow = new EventTarget() as EventTarget & { netcatty?: unknown };
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
localStorage?: typeof fakeStorage;
|
||||
window?: typeof fakeWindow;
|
||||
};
|
||||
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: fakeStorage, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', { value: fakeWindow, configurable: true });
|
||||
|
||||
let currentBlocklist: string[] = [];
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const Probe = () => {
|
||||
currentBlocklist = useAISettingsState().commandBlocklist;
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
fakeStorage.setItem(STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(['company-blocked-command']));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_COMMAND_BLOCKLIST });
|
||||
await act(async () => {
|
||||
fakeWindow.dispatchEvent(event);
|
||||
});
|
||||
assert.deepEqual(currentBlocklist, ['company-blocked-command']);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
|
||||
else delete globals.localStorage;
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else delete globals.window;
|
||||
}
|
||||
});
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
@@ -38,6 +37,10 @@ import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAIBridge } from './aiStateSnapshots';
|
||||
import { useStoredBoolean } from './useStoredBoolean';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
readCommandBlocklistSetting,
|
||||
} from './commandBlocklistSettings';
|
||||
|
||||
function readPermissionMode(): AIPermissionMode {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
|
||||
@@ -69,9 +72,7 @@ export function useAISettingsState() {
|
||||
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
|
||||
);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(readCommandBlocklistSetting);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
@@ -173,7 +174,7 @@ export function useAISettingsState() {
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
persistCommandBlocklistSetting(value);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(value);
|
||||
}, []);
|
||||
|
||||
@@ -249,9 +250,7 @@ export function useAISettingsState() {
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (list != null && !Array.isArray(list)) break;
|
||||
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
const blocklist = readCommandBlocklistSetting();
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
|
||||
@@ -37,7 +37,6 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import {
|
||||
DEFAULT_COMMAND_BLOCKLIST,
|
||||
DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
DEFAULT_RESPONSE_IDLE_TIMEOUT_SECONDS,
|
||||
normalizeCommandTimeoutSeconds,
|
||||
@@ -69,7 +68,7 @@ import {
|
||||
latestAIDraftsByScopeSnapshot,
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
pruneSessionsForStorage,
|
||||
writeSessionsForStorage,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
setLatestAIPanelViewByScopeSnapshot,
|
||||
@@ -78,6 +77,10 @@ import {
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
readCommandBlocklistSetting,
|
||||
} from './commandBlocklistSettings';
|
||||
import {
|
||||
handoffDissolvedWorkspaceAIScope,
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
@@ -140,9 +143,7 @@ export function useAIState() {
|
||||
);
|
||||
|
||||
// ── Safety Settings ──
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
|
||||
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
|
||||
);
|
||||
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(readCommandBlocklistSetting);
|
||||
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
|
||||
normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
@@ -423,7 +424,7 @@ export function useAIState() {
|
||||
|
||||
const setCommandBlocklist = useCallback((value: string[]) => {
|
||||
setCommandBlocklistRaw(value);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
|
||||
persistCommandBlocklistSetting(value);
|
||||
// Sync to MCP Server bridge so SDK agents also respect the blocklist
|
||||
const bridge = getAIBridge();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(value);
|
||||
@@ -503,12 +504,7 @@ export function useAIState() {
|
||||
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
|
||||
break;
|
||||
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
|
||||
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
|
||||
if (list != null && !Array.isArray(list)) {
|
||||
console.warn('[useAIState] Cross-window sync: AI_COMMAND_BLOCKLIST is not an array, skipping');
|
||||
break;
|
||||
}
|
||||
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
const blocklist = readCommandBlocklistSetting();
|
||||
setCommandBlocklistRaw(blocklist);
|
||||
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
|
||||
break;
|
||||
@@ -627,7 +623,7 @@ export function useAIState() {
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
useEffect(() => {
|
||||
const bridge = getAIBridge();
|
||||
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
|
||||
const initialBlocklist = readCommandBlocklistSetting();
|
||||
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
|
||||
const initialTimeout = normalizeCommandTimeoutSeconds(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? DEFAULT_COMMAND_TIMEOUT_SECONDS,
|
||||
@@ -650,7 +646,7 @@ export function useAIState() {
|
||||
|
||||
// ── Session CRUD ──
|
||||
const persistSessions = useCallback((next: AISession[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
|
||||
writeSessionsForStorage(next);
|
||||
}, []);
|
||||
|
||||
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
|
||||
@@ -661,7 +657,7 @@ export function useAIState() {
|
||||
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = setTimeout(() => {
|
||||
if (!mountedRef.current) return; // Skip writes after unmount
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
|
||||
writeSessionsForStorage(sessionsRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
sanitizeHostsForSync,
|
||||
sanitizePortForwardingRulesForSync,
|
||||
shouldPromptCloudVaultRecovery,
|
||||
} from '../syncPayload';
|
||||
@@ -145,7 +146,7 @@ const isRestoreInProgress = (): boolean => {
|
||||
|
||||
const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
return JSON.stringify({
|
||||
hosts: payload.hosts,
|
||||
hosts: sanitizeHostsForSync(payload.hosts),
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
proxyProfiles: payload.proxyProfiles,
|
||||
@@ -264,7 +265,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
return {
|
||||
hosts: config.hosts,
|
||||
hosts: sanitizeHostsForSync(config.hosts),
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
proxyProfiles: config.proxyProfiles,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import type { Host } from "../../domain/models.ts";
|
||||
import { haveSameManagedSshAgentFields } from "./useManagedSourceSync.ts";
|
||||
import { STORAGE_KEY_MANAGED_SOURCES } from "../../infrastructure/config/storageKeys.ts";
|
||||
import { isVaultImportLockHeld } from "./vaultManagedImportLock.ts";
|
||||
import { haveSameManagedSshAgentFields, useManagedSourceSync } from "./useManagedSourceSync.ts";
|
||||
|
||||
const host: Host = {
|
||||
id: "host-1",
|
||||
@@ -54,3 +59,145 @@ test("managed SSH source comparison tracks identity file paths", () => {
|
||||
identityFilePaths: [...withPaths.identityFilePaths].reverse(),
|
||||
}), false);
|
||||
});
|
||||
|
||||
type TestHost = Host & { managedSourceId?: string };
|
||||
|
||||
const managedHost = (label: string): TestHost => ({
|
||||
...host,
|
||||
label,
|
||||
managedSourceId: "source-1",
|
||||
});
|
||||
|
||||
const managedSource = {
|
||||
id: "source-1",
|
||||
type: "ssh_config" as const,
|
||||
filePath: "/home/tester/.ssh/config",
|
||||
groupName: "Managed",
|
||||
lastSyncedAt: 0,
|
||||
};
|
||||
|
||||
function createFakeLockManager() {
|
||||
const tails = new Map<string, Promise<unknown>>();
|
||||
return {
|
||||
request<T>(name: string, callback: () => Promise<T>): Promise<T> {
|
||||
const previous = (tails.get(name) ?? Promise.resolve()) as Promise<unknown>;
|
||||
const run = previous.then(callback, callback) as Promise<T>;
|
||||
tails.set(name, run.then(() => undefined, () => undefined));
|
||||
return run;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("managed sync writes the latest persisted hosts, not the pre-edit snapshot", async () => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
const globalAny = globalThis as Record<string, unknown>;
|
||||
const saved = {
|
||||
window: globalAny.window,
|
||||
document: globalAny.document,
|
||||
localStorage: globalAny.localStorage,
|
||||
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
|
||||
actEnv: (globalAny as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT,
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: { locks: createFakeLockManager() },
|
||||
});
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
|
||||
const container = dom.window.document.createElement("div");
|
||||
dom.window.document.body.appendChild(container);
|
||||
|
||||
try {
|
||||
dom.window.localStorage.setItem(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
JSON.stringify([managedSource]),
|
||||
);
|
||||
|
||||
// Simulate updateHosts: the React state already holds the edited alias,
|
||||
// but the encrypted disk write is still queued and only lands later.
|
||||
const hostBeforeEdit = managedHost("aaaaaaaa");
|
||||
const hostAfterEdit = managedHost("bbbbbbbb");
|
||||
let persistedHosts: TestHost[] = [hostBeforeEdit];
|
||||
let resolveCommit!: () => void;
|
||||
const commit = new Promise<void>((resolve) => { resolveCommit = resolve; });
|
||||
setTimeout(() => {
|
||||
persistedHosts = [hostAfterEdit];
|
||||
resolveCommit();
|
||||
}, 20);
|
||||
|
||||
const onReadPersistedHosts = async (): Promise<Host[]> => {
|
||||
// Mirrors the real readPersistedHosts semantics: while the vault lock is
|
||||
// held, only the encrypt phase is drained (the disk write queued behind
|
||||
// that lock is not visible yet); outside the lock it also waits for the
|
||||
// queued disk write before reading.
|
||||
if (isVaultImportLockHeld("vault")) return persistedHosts;
|
||||
await commit;
|
||||
return persistedHosts;
|
||||
};
|
||||
|
||||
let written = "";
|
||||
(dom.window as unknown as Record<string, unknown>).netcatty = {
|
||||
readLocalFile: async () => new TextEncoder().encode(
|
||||
"# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK\n"
|
||||
+ "Host aaaaaaaa\n HostName managed.example.com\n"
|
||||
+ "# END NETCATTY MANAGED\n",
|
||||
).buffer,
|
||||
writeLocalFile: async (_filePath: string, buffer: ArrayBuffer) => {
|
||||
written = new TextDecoder().decode(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
function Harness() {
|
||||
useManagedSourceSync({
|
||||
hosts: [hostAfterEdit],
|
||||
managedSources: [managedSource],
|
||||
onUpdateManagedSources: () => {},
|
||||
onReadPersistedHosts,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
|
||||
for (let i = 0; i < 250 && !written; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
await act(async () => root.unmount());
|
||||
assert.ok(written, "managed sync never wrote the ssh_config file");
|
||||
assert.ok(written.includes("Host bbbbbbbb"), "sync wrote a stale alias");
|
||||
assert.ok(!written.includes("Host aaaaaaaa"), "sync wrote the pre-edit alias");
|
||||
} finally {
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
for (const key of ["window", "document", "localStorage"] as const) {
|
||||
if (key in globalAny) {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: saved[key],
|
||||
});
|
||||
} else {
|
||||
delete globalAny[key];
|
||||
}
|
||||
}
|
||||
if (saved.navigator) {
|
||||
Object.defineProperty(globalThis, "navigator", saved.navigator);
|
||||
} else {
|
||||
delete (globalThis as { navigator?: unknown }).navigator;
|
||||
}
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
configurable: true,
|
||||
value: saved.actEnv,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -156,6 +156,14 @@ export const useManagedSourceSync = ({
|
||||
|
||||
const syncManagedSource = useCallback(
|
||||
async (source: ManagedSource): Promise<{ sourceId: string; success: boolean }> => {
|
||||
// Drain any in-flight Vault host write BEFORE taking the vault lock.
|
||||
// updateHosts commits its encrypted write under the vault lock, so if this
|
||||
// sync acquires the lock first, readPersistedHosts runs while the lock is
|
||||
// held and only waits for the encrypt phase — returning the previous host
|
||||
// snapshot and writing the stale alias back to the file (lagging one edit
|
||||
// behind, see issue #3259). Waiting outside the lock also waits for the
|
||||
// queued disk write, so the read below sees the just-edited hosts.
|
||||
await onReadPersistedHosts();
|
||||
return withVaultImportLock("vault", async () => {
|
||||
const persistedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
|
||||
@@ -385,7 +385,10 @@ export const usePortForwardingAutoStart = ({
|
||||
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
));
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("global broadcast availability follows visible orphan session count", async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
assert.equal(current().orphanSessions.length, 0);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
|
||||
await act(async () => {
|
||||
current().createLocalTerminal({ shellName: "01" });
|
||||
});
|
||||
assert.equal(current().orphanSessions.length, 1);
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
|
||||
await act(async () => {
|
||||
current().createLocalTerminal({ shellName: "02" });
|
||||
});
|
||||
assert.equal(current().orphanSessions.length, 2);
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
|
||||
await act(async () => {
|
||||
current().toggleGlobalBroadcast();
|
||||
});
|
||||
assert.equal(current().isGlobalBroadcastEnabled, true);
|
||||
|
||||
await act(async () => {
|
||||
current().toggleGlobalBroadcast();
|
||||
});
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
const closedId = current().orphanSessions[1].id;
|
||||
await act(async () => { current().closeSession(closedId); });
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => { current().createLocalTerminal({ shellName: "03" }); });
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => { current().toggleGlobalBroadcast(); });
|
||||
const [first, second] = current().orphanSessions;
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(first.id, second.id, "right");
|
||||
});
|
||||
assert.equal(current().canUseGlobalBroadcast, false);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
await act(async () => {
|
||||
current().removeSessionFromWorkspace(second.id);
|
||||
});
|
||||
assert.equal(current().canUseGlobalBroadcast, true);
|
||||
assert.equal(current().isGlobalBroadcastEnabled, false);
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
}
|
||||
});
|
||||
@@ -59,7 +59,12 @@ import type { SessionRestorePayload } from '../../domain/sessionRestore';
|
||||
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
|
||||
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
|
||||
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
|
||||
import { DEFAULT_WORKSPACE_TITLE } from '../../domain/sessionTabTitle';
|
||||
import {
|
||||
DEFAULT_WORKSPACE_TITLE,
|
||||
buildMergedWorkspaceTitle,
|
||||
buildMergedWorkspaceTitleFromSessions,
|
||||
getSessionConnectionLabel,
|
||||
} from '../../domain/sessionTabTitle';
|
||||
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
|
||||
|
||||
export function addWorkspaceIfMissing(
|
||||
@@ -228,6 +233,8 @@ export const useSessionState = ({
|
||||
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
|
||||
// Broadcast mode: stores workspace IDs that have broadcast enabled
|
||||
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
|
||||
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
|
||||
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
|
||||
// Log views: stores open log replay tabs
|
||||
const [logViews, setLogViews] = useState<LogView[]>([]);
|
||||
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
|
||||
@@ -260,6 +267,41 @@ export const useSessionState = ({
|
||||
}
|
||||
}, [initialRestoreState.activeTabId]);
|
||||
|
||||
// Keep generated merged-workspace titles (e.g. "01/02" after dragging tab
|
||||
// "02" onto "01") synchronized with the workspace's current membership and
|
||||
// each pane's connection label. `autoTitle: false` makes
|
||||
// resolveWorkspaceTabLabel treat the composed title as a user-chosen name,
|
||||
// so without this pass a pane rename or a pane being added/removed would
|
||||
// leave the label stale. Runs off session changes and only touches
|
||||
// workspaces still flagged `generatedTitle` (a user rename clears it).
|
||||
useEffect(() => {
|
||||
setWorkspaces(prev => {
|
||||
let changed = false;
|
||||
const next = prev.map(ws => {
|
||||
if (!ws.generatedTitle) return ws;
|
||||
// Order members by the workspace's pane tree (base/join or split
|
||||
// layout), not by global session creation order — merging a newer tab
|
||||
// onto an older one must keep the merged title as created (e.g.
|
||||
// "02/01"), while `sessions.filter` would rewrite it to "01/02".
|
||||
const rootIds = collectSessionIds(ws.root);
|
||||
const members = [
|
||||
...rootIds
|
||||
.map(id => sessions.find(s => s.id === id))
|
||||
.filter((s): s is TerminalSession => Boolean(s)),
|
||||
// Safety net for transient membership: any session assigned to this
|
||||
// workspace whose pane has not landed in `root` yet keeps its
|
||||
// previous (creation-order) position after the pane-tree members.
|
||||
...sessions.filter(s => s.workspaceId === ws.id && !rootIds.includes(s.id)),
|
||||
];
|
||||
const recomputed = buildMergedWorkspaceTitleFromSessions(members) ?? DEFAULT_WORKSPACE_TITLE;
|
||||
if (recomputed === ws.title) return ws;
|
||||
changed = true;
|
||||
return { ...ws, title: recomputed };
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRestorePreviousSessionChanged = (key?: string) => {
|
||||
if (key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION) return;
|
||||
@@ -622,11 +664,16 @@ export const useSessionState = ({
|
||||
}, []);
|
||||
|
||||
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
|
||||
// A user rename takes ownership of the title: clear `generatedTitle` so a
|
||||
// previously synthesized merged title ("01/02") stops being auto-updated.
|
||||
const applyRename = (w: Workspace, trimmed: string): Workspace => (
|
||||
{ ...w, title: trimmed, autoTitle: false, generatedTitle: false }
|
||||
);
|
||||
if (workspaceId !== undefined && name !== undefined) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
setWorkspaces(prev => prev.map(w => (
|
||||
w.id === workspaceId ? { ...w, title: trimmed, autoTitle: false } : w
|
||||
w.id === workspaceId ? applyRename(w, trimmed) : w
|
||||
)));
|
||||
return;
|
||||
}
|
||||
@@ -637,7 +684,7 @@ export const useSessionState = ({
|
||||
|
||||
setWorkspaceRenameTarget(prevTarget => {
|
||||
if (!prevTarget) return prevTarget;
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: trimmed, autoTitle: false } : w));
|
||||
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? applyRename(w, trimmed) : w));
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -729,7 +776,29 @@ export const useSessionState = ({
|
||||
hint: SplitHint
|
||||
) => {
|
||||
if (!hint || baseSessionId === joiningSessionId) return;
|
||||
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
||||
// Name the merged workspace after both tabs' connection labels (e.g.
|
||||
// "01/02") so the window title and the rename dialog show the merged
|
||||
// identity instead of the generic "Workspace". Falls back to the default
|
||||
// auto title when neither tab has a label. The explicit `autoTitle: false`
|
||||
// makes tab labels use this composed name too; `generatedTitle: true`
|
||||
// marks it as synthesized so it keeps tracking membership/renames (see the
|
||||
// generated-title sync effect below) instead of going stale.
|
||||
// Compose the title from the created pane-tree order (not base/join
|
||||
// order): for left/top merges `createWorkspaceEntity` places the joining
|
||||
// pane before the base pane in `root`, and the generated-title sync
|
||||
// effect above recomputes from `root` order — building the initial title
|
||||
// in tree order keeps it stable instead of renaming itself right after
|
||||
// creation.
|
||||
const created = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
|
||||
const treeLabels = collectSessionIds(created.root).map((id) =>
|
||||
getSessionConnectionLabel(
|
||||
sessionsRef.current.find(s => s.id === id) ?? { customName: '', hostLabel: '' },
|
||||
),
|
||||
);
|
||||
const mergedTitle = buildMergedWorkspaceTitle(treeLabels[0] ?? '', treeLabels[1] ?? '');
|
||||
const newWorkspace = mergedTitle
|
||||
? { ...created, title: mergedTitle, autoTitle: false, generatedTitle: true }
|
||||
: created;
|
||||
|
||||
setSessions((prevSessions) => {
|
||||
const base = prevSessions.find((s) => s.id === baseSessionId);
|
||||
@@ -1054,6 +1123,12 @@ export const useSessionState = ({
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
|
||||
const canUseGlobalBroadcast = orphanSessions.length >= 2;
|
||||
|
||||
// Losing the group must disarm broadcast before another tab can join it.
|
||||
if (!canUseGlobalBroadcast && globalBroadcastEnabled) {
|
||||
setGlobalBroadcastEnabled(false);
|
||||
}
|
||||
|
||||
const openLogView = useCallback((log: ConnectionLog) => {
|
||||
const tabId = getLogViewTabId(log);
|
||||
@@ -1075,6 +1150,8 @@ export const useSessionState = ({
|
||||
const copySession = useCallback((sessionId: string, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
inheritedCwd?: string;
|
||||
/** When false, open a brand-new connection instead of reusing the source's SSH channel. */
|
||||
reuseConnection?: boolean;
|
||||
}) => {
|
||||
// Pre-allocate the new id outside the updater so StrictMode's
|
||||
// double-invocation of the functional updater doesn't mint two ids.
|
||||
@@ -1090,6 +1167,7 @@ export const useSessionState = ({
|
||||
id: newSessionId,
|
||||
localShellType: options?.localShellType,
|
||||
inheritedCwd: options?.inheritedCwd,
|
||||
reuseConnection: options?.reuseConnection,
|
||||
});
|
||||
|
||||
// Schedule the activeTab + tabOrder updates only when creation
|
||||
@@ -1202,6 +1280,14 @@ export const useSessionState = ({
|
||||
return broadcastWorkspaceIds.has(workspaceId);
|
||||
}, [broadcastWorkspaceIds]);
|
||||
|
||||
// Toggle global broadcast mode for orphan sessions
|
||||
const toggleGlobalBroadcast = useCallback(() => {
|
||||
setGlobalBroadcastEnabled(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// Global broadcast enabled state - direct boolean value
|
||||
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
|
||||
|
||||
const baseWorkTabIds = useMemo(() => [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
@@ -1325,9 +1411,12 @@ export const useSessionState = ({
|
||||
moveFocusInWorkspace,
|
||||
runSnippet,
|
||||
orphanSessions,
|
||||
canUseGlobalBroadcast,
|
||||
// Broadcast mode
|
||||
toggleBroadcast,
|
||||
isBroadcastEnabled,
|
||||
toggleGlobalBroadcast,
|
||||
isGlobalBroadcastEnabled,
|
||||
orderedTabs,
|
||||
getOrderedWorkTabs,
|
||||
reorderTabs,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
test("merged tab titles follow the workspace lifecycle until the user renames it", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, {
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
t.after(() => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
t.after(() => {
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
});
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
const workspace = (workspaceId: string) => {
|
||||
const found = current().workspaces.find((candidate) => candidate.id === workspaceId);
|
||||
assert.ok(found);
|
||||
return found;
|
||||
};
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
let firstSessionId = "";
|
||||
let secondSessionId = "";
|
||||
await act(async () => {
|
||||
firstSessionId = current().createLocalTerminal({ shellName: "01" });
|
||||
secondSessionId = current().createLocalTerminal({ shellName: "02" });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(firstSessionId, secondSessionId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: firstSessionId,
|
||||
});
|
||||
});
|
||||
|
||||
const workspaceId = current().workspaces[0]?.id;
|
||||
assert.ok(workspaceId);
|
||||
assert.equal(workspace(workspaceId).title, "01/02");
|
||||
assert.equal(workspace(workspaceId).autoTitle, false);
|
||||
assert.equal(workspace(workspaceId).generatedTitle, true);
|
||||
|
||||
await act(async () => {
|
||||
current().startWorkspaceRename(workspaceId);
|
||||
});
|
||||
assert.equal(current().workspaceRenameValue, "01/02");
|
||||
|
||||
await act(async () => {
|
||||
current().resetWorkspaceRename();
|
||||
current().renameSessionInline(secondSessionId, "prod");
|
||||
});
|
||||
assert.equal(workspace(workspaceId).title, "01/prod");
|
||||
|
||||
let thirdSessionId = "";
|
||||
await act(async () => {
|
||||
thirdSessionId = current().createLocalTerminal({ shellName: "03" });
|
||||
});
|
||||
await act(async () => {
|
||||
current().addSessionToWorkspace(workspaceId, thirdSessionId, {
|
||||
direction: "vertical",
|
||||
position: "right",
|
||||
targetSessionId: secondSessionId,
|
||||
});
|
||||
});
|
||||
assert.equal(workspace(workspaceId).title, "01/prod/03");
|
||||
|
||||
await act(async () => {
|
||||
current().removeSessionFromWorkspace(thirdSessionId);
|
||||
});
|
||||
assert.equal(workspace(workspaceId).title, "01/prod");
|
||||
|
||||
const workspaceIdsBeforeCopy = new Set(current().workspaces.map((candidate) => candidate.id));
|
||||
await act(async () => {
|
||||
current().copyWorkspace(workspaceId);
|
||||
});
|
||||
const copiedWorkspace = current().workspaces.find(
|
||||
(candidate) => !workspaceIdsBeforeCopy.has(candidate.id),
|
||||
);
|
||||
assert.ok(copiedWorkspace);
|
||||
assert.equal(copiedWorkspace.title, "01/prod");
|
||||
assert.equal(copiedWorkspace.autoTitle, false);
|
||||
assert.equal(copiedWorkspace.generatedTitle, true);
|
||||
|
||||
await act(async () => {
|
||||
current().submitWorkspaceRename(workspaceId, "Pinned");
|
||||
current().renameSessionInline(firstSessionId, "ops");
|
||||
});
|
||||
assert.equal(workspace(workspaceId).title, "Pinned");
|
||||
assert.equal(workspace(workspaceId).generatedTitle, false);
|
||||
|
||||
let leftBaseSessionId = "";
|
||||
let leftJoiningSessionId = "";
|
||||
await act(async () => {
|
||||
leftBaseSessionId = current().createLocalTerminal({ shellName: "04" });
|
||||
leftJoiningSessionId = current().createLocalTerminal({ shellName: "05" });
|
||||
});
|
||||
await act(async () => {
|
||||
current().createWorkspaceFromSessions(leftBaseSessionId, leftJoiningSessionId, {
|
||||
direction: "vertical",
|
||||
position: "left",
|
||||
targetSessionId: leftBaseSessionId,
|
||||
});
|
||||
});
|
||||
const leftWorkspace = current().workspaces.find((candidate) => candidate.title === "05/04");
|
||||
assert.ok(leftWorkspace);
|
||||
assert.equal(leftWorkspace.generatedTitle, true);
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
@@ -539,6 +540,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
// Optimistic cache for first paint; useSystemSettingsEffects hydrates this
|
||||
// from app.getLoginItemSettings() (the real source of truth) on mount.
|
||||
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_AUTO_LAUNCH_ENABLED);
|
||||
return stored === 'true';
|
||||
});
|
||||
const [autoLaunchSupported, setAutoLaunchSupported] = useState<boolean>(true);
|
||||
const [httpNetworkProxy, setHttpNetworkProxyState] = useState<HttpNetworkProxySettings>(() => {
|
||||
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
|
||||
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
|
||||
@@ -1184,6 +1192,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setWindowOpacity: applyIncomingWindowOpacity,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
@@ -1747,6 +1756,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
appIconVariant,
|
||||
@@ -1756,6 +1766,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setHotkeyRegistrationError,
|
||||
setAutoUpdateEnabled,
|
||||
setAppIconVariant,
|
||||
setAutoLaunchEnabled,
|
||||
setAutoLaunchSupported,
|
||||
notifySettingsChanged,
|
||||
});
|
||||
|
||||
@@ -2101,6 +2113,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
autoLaunchEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
autoLaunchSupported,
|
||||
httpNetworkProxy,
|
||||
setHttpNetworkProxy,
|
||||
autoUpdateEnabled,
|
||||
|
||||
@@ -18,11 +18,13 @@ import {
|
||||
joinPath,
|
||||
} from "./sftp/utils";
|
||||
import { useSftpTabsState } from "./sftp/useSftpTabsState";
|
||||
import { useSftpBrowseConnectionLifecycle } from "./sftp/useSftpBrowseConnectionLifecycle";
|
||||
import { isSessionError } from "./sftp/errors";
|
||||
import { useSftpExternalOperations } from "./sftp/useSftpExternalOperations";
|
||||
import { useSftpTransfers } from "./sftp/useSftpTransfers";
|
||||
import { useSftpPaneActions } from "./sftp/useSftpPaneActions";
|
||||
import {
|
||||
closeSftpTabLifecycle,
|
||||
releaseSftpConnectionMetadata,
|
||||
useSftpConnections,
|
||||
} from "./sftp/useSftpConnections";
|
||||
@@ -181,12 +183,6 @@ export const useSftpState = (
|
||||
return null;
|
||||
}, [leftTabsRef, rightTabsRef]);
|
||||
|
||||
// Ref to track pending reconnections to avoid multiple reconnect attempts
|
||||
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
|
||||
left: false,
|
||||
right: false,
|
||||
});
|
||||
|
||||
// Map connectionId → cache key, set at connect time so each tab's
|
||||
// navigateTo can use the correct cache key even when multiple tabs
|
||||
// share the same hostId with different session-time overrides.
|
||||
@@ -195,6 +191,8 @@ export const useSftpState = (
|
||||
// Full Host used when each tab connected (includes session-time overrides).
|
||||
// Tab ids are stable across reconnect; connection ids are not.
|
||||
const connectedHostByTabIdRef = useRef<Map<string, Host | "local">>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
const connectRequestByTabIdRef = useRef<Map<string, symbol>>(new Map());
|
||||
|
||||
// Full endpoint key captured at connect time (hostId:hostname:port:…).
|
||||
const getConnectionCacheKey = useCallback((connectionId: string) => {
|
||||
@@ -234,20 +232,29 @@ export const useSftpState = (
|
||||
|
||||
const closeTabAndClearHost = useCallback(async (side: "left" | "right", tabId: string) => {
|
||||
const pane = getPaneByTabId(tabId);
|
||||
connectedHostByTabIdRef.current.delete(tabId);
|
||||
if (pane?.connection) {
|
||||
await releaseSftpTabConnection({
|
||||
connectionId: pane.connection.id,
|
||||
isLocal: pane.connection.isLocal,
|
||||
sftpSessions: sftpSessionsRef.current,
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
}
|
||||
closeTab(side, tabId);
|
||||
}, [clearCacheForConnection, closeTab, getPaneByTabId]);
|
||||
await closeSftpTabLifecycle({
|
||||
requestedSide: side,
|
||||
tabId,
|
||||
leftTabs: leftTabsRef.current.tabs,
|
||||
rightTabs: rightTabsRef.current.tabs,
|
||||
connectRequests: connectRequestByTabIdRef.current,
|
||||
connectInFlight: connectInFlightRef.current,
|
||||
connectedHosts: connectedHostByTabIdRef.current,
|
||||
closeTab: (liveSide) => closeTab(liveSide, tabId),
|
||||
releaseConnection: async () => {
|
||||
if (!pane?.connection) return;
|
||||
await releaseSftpTabConnection({
|
||||
connectionId: pane.connection.id,
|
||||
isLocal: pane.connection.isLocal,
|
||||
sftpSessions: sftpSessionsRef.current,
|
||||
connectionCacheKeys: connectionCacheKeyMapRef.current,
|
||||
clearCacheForConnection,
|
||||
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
|
||||
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [clearCacheForConnection, closeTab, getPaneByTabId, leftTabsRef, rightTabsRef]);
|
||||
|
||||
const releaseConnection = useCallback(async (connectionId: string) => {
|
||||
await releaseSftpConnectionMetadata({
|
||||
@@ -267,11 +274,10 @@ export const useSftpState = (
|
||||
updateActiveTab,
|
||||
navSeqRef,
|
||||
lastConnectedHostRef,
|
||||
reconnectingRef,
|
||||
releaseConnection,
|
||||
});
|
||||
|
||||
useSftpSessionCleanup(sftpSessionsRef);
|
||||
const sftpDisposedRef = useSftpSessionCleanup(sftpSessionsRef);
|
||||
useSftpFileWatch(options);
|
||||
|
||||
// Transfer channel pool (max concurrent sftpIds per host, short idle reuse).
|
||||
@@ -398,6 +404,8 @@ export const useSftpState = (
|
||||
);
|
||||
|
||||
/** True after browse channels were soft-closed while this owner stayed mounted. */
|
||||
const interactive = options?.interactive !== false;
|
||||
const browseConnectionLifecycleRef = useSftpBrowseConnectionLifecycle(interactive);
|
||||
const browseParkedRef = useRef(false);
|
||||
const browseLifecycleGenRef = useRef(0);
|
||||
|
||||
@@ -430,10 +438,13 @@ export const useSftpState = (
|
||||
navSeqRef,
|
||||
dirCacheRef,
|
||||
sftpSessionsRef,
|
||||
disposedRef: sftpDisposedRef,
|
||||
browseConnectionLifecycleRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
connectedHostByTabIdRef,
|
||||
reconnectingRef,
|
||||
connectInFlightRef,
|
||||
connectRequestByTabIdRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
createEmptyPane: createPane,
|
||||
@@ -474,7 +485,6 @@ export const useSftpState = (
|
||||
sftpSessionsRef,
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
reconnectingRef,
|
||||
makeCacheKey,
|
||||
clearCacheForConnection,
|
||||
listLocalFiles,
|
||||
@@ -637,7 +647,6 @@ export const useSftpState = (
|
||||
// has unfinished transfers so pre-lease prep (conflict/stat) cannot race a
|
||||
// hard-close of the browse id. In-flight streams also soft-close via leases;
|
||||
// pool handles bulk I/O.
|
||||
const interactive = options?.interactive !== false;
|
||||
useEffect(() => {
|
||||
const gen = ++browseLifecycleGenRef.current;
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { useStoredBoolean } from "./useStoredBoolean.ts";
|
||||
|
||||
const STORAGE_KEY = "netcatty:test:stored-boolean";
|
||||
|
||||
test("functional updates stay pure when another consumer shares the storage key", async () => {
|
||||
const values = new Map<string, string>([[STORAGE_KEY, "false"]]);
|
||||
const fakeStorage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
};
|
||||
const fakeWindow = new EventTarget();
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
localStorage?: typeof fakeStorage;
|
||||
window?: typeof fakeWindow;
|
||||
};
|
||||
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const previousDispatchEvent = Object.getOwnPropertyDescriptor(globalThis, "dispatchEvent");
|
||||
const previousCustomEvent = Object.getOwnPropertyDescriptor(globalThis, "CustomEvent");
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let toggle: (() => void) | null = null;
|
||||
let parentValue = false;
|
||||
let childValue = false;
|
||||
const errors: string[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", { value: fakeStorage, configurable: true });
|
||||
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
|
||||
Object.defineProperty(globalThis, "dispatchEvent", {
|
||||
value: fakeWindow.dispatchEvent.bind(fakeWindow),
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "CustomEvent", {
|
||||
value: class TestCustomEvent<T> extends Event implements CustomEvent<T> {
|
||||
readonly detail: T;
|
||||
|
||||
constructor(type: string, init?: CustomEventInit<T>) {
|
||||
super(type);
|
||||
this.detail = init?.detail as T;
|
||||
}
|
||||
|
||||
initCustomEvent(): void {
|
||||
// Deprecated browser API required by the CustomEvent interface.
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
const Parent = () => {
|
||||
[parentValue] = useStoredBoolean(STORAGE_KEY);
|
||||
return React.createElement(Child);
|
||||
};
|
||||
const Child = () => {
|
||||
const [value, setValue] = useStoredBoolean(STORAGE_KEY);
|
||||
childValue = value;
|
||||
toggle = () => setValue((previous) => !previous);
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(React.StrictMode, null, React.createElement(Parent)));
|
||||
});
|
||||
await act(async () => {
|
||||
toggle?.();
|
||||
});
|
||||
|
||||
assert.equal(parentValue, true);
|
||||
assert.equal(childValue, true);
|
||||
assert.equal(values.get(STORAGE_KEY), "true");
|
||||
assert.equal(
|
||||
errors.some((message) => message.includes("Cannot update a component")
|
||||
&& message.includes("while rendering a different component")),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
|
||||
else delete globals.localStorage;
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else delete globals.window;
|
||||
if (previousDispatchEvent) Object.defineProperty(globalThis, "dispatchEvent", previousDispatchEvent);
|
||||
else delete (globalThis as typeof globalThis & { dispatchEvent?: unknown }).dispatchEvent;
|
||||
if (previousCustomEvent) Object.defineProperty(globalThis, "CustomEvent", previousCustomEvent);
|
||||
else delete (globalThis as typeof globalThis & { CustomEvent?: unknown }).CustomEvent;
|
||||
}
|
||||
});
|
||||
Loaded 100 of 387 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user