mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5974d2e946 | ||
|
|
1dfbd411ae | ||
|
|
108b545bd6 | ||
|
|
5487d4522b | ||
|
|
620c9c88c4 | ||
|
|
a2fe1cd99f | ||
|
|
83dab66c3c | ||
|
|
ffe57f9672 | ||
|
|
89de0f8755 | ||
|
|
a6edbbc650 | ||
|
|
34343da81f | ||
|
|
85cfc7c4ba | ||
|
|
270e1dacf4 | ||
|
|
34f999d3b7 | ||
|
|
01a16ece0e | ||
|
|
0d3238db88 | ||
|
|
4f8a1983c9 | ||
|
|
b26b14d009 | ||
|
|
64ee11b017 | ||
|
|
dc741f5a19 | ||
|
|
a4d3b3d90a | ||
|
|
d497dc050d | ||
|
|
20996e4a4a | ||
|
|
b192089679 | ||
|
|
d558893c23 | ||
|
|
9588012d12 | ||
|
|
2170aed553 | ||
|
|
b2ed83ae51 | ||
|
|
f91b2cd04e | ||
|
|
1d10d1679e | ||
|
|
8d2058267a | ||
|
|
8020eaa427 | ||
|
|
3298a70f32 | ||
|
|
e7202f392a | ||
|
|
9423a1d1cd | ||
|
|
50ca182e1e | ||
|
|
04a63c653a | ||
|
|
e2f499b0f9 | ||
|
|
5805d15746 | ||
|
|
bd8476fb34 | ||
|
|
c74daf0102 | ||
|
|
f1425c8d5a | ||
|
|
fc55e9ac17 | ||
|
|
025ce741c2 | ||
|
|
323ff0b16a | ||
|
|
6ece7e8222 | ||
|
|
e3894eb850 | ||
|
|
38c7376515 | ||
|
|
1b8228b23d | ||
|
|
b2c518a6f1 | ||
|
|
ea36f46d1d | ||
|
|
6d0d39e678 | ||
|
|
08089323a9 | ||
|
|
91db655a1a | ||
|
|
d51cd2714d | ||
|
|
3fd954d2bf | ||
|
|
ff28c9b412 | ||
|
|
7006fa214e | ||
|
|
459f3a5278 | ||
|
|
6c8edddfa8 | ||
|
|
5af2c86f8f | ||
|
|
7b1151f976 | ||
|
|
d21016b91a | ||
|
|
b62a0b3a8f | ||
|
|
3560a04fa6 | ||
|
|
a373f57905 | ||
|
|
e376190a70 | ||
|
|
037a2f651b | ||
|
|
1a4a022a8e | ||
|
|
fd177e8453 | ||
|
|
dee468dcfa | ||
|
|
c666fdb156 | ||
|
|
b4111cf970 | ||
|
|
69497ccc6a | ||
|
|
078353dc87 | ||
|
|
fb7adc3bfc | ||
|
|
8d6ef72006 | ||
|
|
85683757e5 | ||
|
|
598f5cf796 | ||
|
|
ed92944af0 | ||
|
|
e2b3daac9a | ||
|
|
105b7f16bc | ||
|
|
ec11989d52 | ||
|
|
fd3e82386a | ||
|
|
2b120c0d41 | ||
|
|
318577cd72 | ||
|
|
6398360854 | ||
|
|
e3b2e82f88 | ||
|
|
9e07e9e7b9 |
No files matched your search
@@ -70,3 +70,26 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
windows-broadcast-ssh:
|
||||
name: windows-broadcast-ssh
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Exercise two SSH password prompts in the desktop app
|
||||
run: node scripts/terminal-broadcast-ssh.live.cjs
|
||||
@@ -3,7 +3,7 @@ import type React from 'react';
|
||||
import type { Host, HostProtocol, TerminalSession } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
@@ -535,7 +535,11 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
if (!liveCwd && isConnectedSsh && !isNetworkDevice) {
|
||||
try {
|
||||
const info = await bridge?.getSessionRemoteInfo?.(sessionId);
|
||||
allowSshProbe = shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: info?.remoteSshVersion });
|
||||
allowSshProbe = shouldProbeSessionCwd({
|
||||
isNetworkDevice: false,
|
||||
remoteSshVersion: info?.remoteSshVersion,
|
||||
restrictExtraSshChannels: hostRestrictsExtraSshChannels(host),
|
||||
});
|
||||
} catch {
|
||||
allowSshProbe = false;
|
||||
}
|
||||
@@ -776,9 +780,7 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
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({
|
||||
// Match the TopTabs badge map: when the sidebar hosts SFTP, the top tab
|
||||
// is hidden and work tabs shift up in the Cmd/Ctrl+[1...9] numbering.
|
||||
showSftpTab: (settings.showSftpTab ?? true) && !settings.sftpInSidebar,
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
|
||||
orderedTabs,
|
||||
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
|
||||
@@ -925,9 +927,7 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
setActiveTabId('vault');
|
||||
break;
|
||||
case 'openSftp':
|
||||
// Effective availability: the SFTP tab is reachable either as a top
|
||||
// tab (showSftpTab) or inside the vault sidebar (sftpInSidebar).
|
||||
if ((settings.showSftpTab ?? true) || settings.sftpInSidebar) {
|
||||
if (settings.showSftpTab) {
|
||||
setActiveTabId('sftp');
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
|
||||
import { useSettingsChromeStore } from '../state/settingsChromeStore';
|
||||
import { computeSftpViewRailOffset, useVaultSidebarLayoutWidth } from '../state/vaultSidebarLayoutStore';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
|
||||
import { isTerminalContentTabSurface } from './workTabSurface';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -16,20 +14,13 @@ export const VaultViewContainer: React.FC<{
|
||||
children: React.ReactNode;
|
||||
appThemeStyle?: React.CSSProperties;
|
||||
}> = ({ children, appThemeStyle }) => {
|
||||
const isVaultActive = useIsVaultActive();
|
||||
const { sftpInSidebar } = useSettingsChromeStore();
|
||||
const isSftpActive = useIsSftpActive();
|
||||
const vaultRailWidth = useVaultSidebarLayoutWidth();
|
||||
// With the "SFTP in sidebar" setting, the vault sidebar rail stays visible
|
||||
// next to the root SFTP view while the SFTP tab is active.
|
||||
const sftpRailMode = !!sftpInSidebar && isSftpActive;
|
||||
const isActive = isVaultActive || sftpRailMode;
|
||||
const isActive = useIsVaultActive();
|
||||
const wasActiveRef = useRef(isActive);
|
||||
const [suppressActiveTransition, setSuppressActiveTransition] = useState(false);
|
||||
const isActivating = isActive && !wasActiveRef.current;
|
||||
const shouldSuppressTransition = isActivating || suppressActiveTransition;
|
||||
const containerStyle: React.CSSProperties = isActive
|
||||
? (sftpRailMode ? { left: 0, top: 0, bottom: 0, width: vaultRailWidth } : {})
|
||||
? {}
|
||||
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -155,8 +146,6 @@ export function shouldRenderTerminalLayerMount(
|
||||
|
||||
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
const isActive = useIsSftpActive();
|
||||
const { sftpInSidebar } = useSettingsChromeStore();
|
||||
const vaultRailWidth = useVaultSidebarLayoutWidth();
|
||||
const [shouldMount, setShouldMount] = useState(isActive);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -165,25 +154,12 @@ export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
|
||||
|
||||
if (!shouldMount) return null;
|
||||
|
||||
const railOffset = computeSftpViewRailOffset({
|
||||
sftpInSidebar: !!sftpInSidebar,
|
||||
isSftpActive: isActive,
|
||||
vaultRailWidth,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
// The inactive terminal layer remains painted at z=0. Place SFTP above
|
||||
// it, while the vault rail at z=20 keeps its resize handle interactive.
|
||||
style={{ left: railOffset, zIndex: railOffset > 0 ? 10 : undefined }}
|
||||
>
|
||||
<LazyLoadBoundary name="SFTP" resetKey={isActive ? "active" : "idle"}>
|
||||
<Suspense fallback={<SftpViewFallback visible={isActive} />}>
|
||||
<LazySftpView {...props} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
</div>
|
||||
<LazyLoadBoundary name="SFTP" resetKey={isActive ? "active" : "idle"}>
|
||||
<Suspense fallback={<SftpViewFallback visible={isActive} />}>
|
||||
<LazySftpView {...props} />
|
||||
</Suspense>
|
||||
</LazyLoadBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("deep-link tab names are applied to terminal sessions", () => {
|
||||
assert.match(source, /type DeepLinkPayload = \{[\s\S]*tabName\?: string/);
|
||||
assert.match(source, /const handleConnectToHostWithTabName = useCallback/);
|
||||
assert.match(source, /renameSessionInline\(sessionId, tabName\)/);
|
||||
assert.match(source, /handleConnectToHostWithTabName\(ephemeralHost, tabName\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/handleConnectToHostWithTabName\(buildSshDeepLinkConnectionHost\(originalHost\), tabName\)/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/handleConnectToHostWithTabName\(buildTelnetDeepLinkConnectionHost\(matchedEffectiveHost\), tabName\)/,
|
||||
);
|
||||
assert.match(source, /label: tabName/);
|
||||
});
|
||||
@@ -98,6 +98,11 @@ type OpenSessionInNewWindowPayload = {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
};
|
||||
|
||||
type DeepLinkPayload = {
|
||||
url?: string;
|
||||
tabName?: string;
|
||||
};
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
IS_DEV && localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === '1';
|
||||
@@ -141,7 +146,6 @@ export function AppSideEffects() {
|
||||
keyBindings,
|
||||
isHotkeyRecording,
|
||||
showSftpTab,
|
||||
sftpInSidebar,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
@@ -261,6 +265,7 @@ export function AppSideEffects() {
|
||||
copyWorkspace,
|
||||
createSessionFromCloneSource,
|
||||
getSessionRestoreCwd,
|
||||
renameSessionInline,
|
||||
} = sessionState;
|
||||
|
||||
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
|
||||
@@ -930,8 +935,6 @@ export function AppSideEffects() {
|
||||
workspacesRefForHotkeys.current = workspaces;
|
||||
const showSftpTabRef = useRef(showSftpTab);
|
||||
showSftpTabRef.current = showSftpTab;
|
||||
const sftpInSidebarRef = useRef(sftpInSidebar);
|
||||
sftpInSidebarRef.current = sftpInSidebar;
|
||||
const shellOnlyTabNumberShortcutsRef = useRef(shellOnlyTabNumberShortcuts);
|
||||
shellOnlyTabNumberShortcutsRef.current = shellOnlyTabNumberShortcuts;
|
||||
const isQuickSwitcherOpenRef = useRef(isQuickSwitcherOpen);
|
||||
@@ -1075,7 +1078,6 @@ export function AppSideEffects() {
|
||||
setNavigateToSection,
|
||||
settings: {
|
||||
showSftpTab: showSftpTabRef.current,
|
||||
sftpInSidebar: sftpInSidebarRef.current,
|
||||
shellOnlyTabNumberShortcuts: shellOnlyTabNumberShortcutsRef.current,
|
||||
},
|
||||
sftpPaneMagnificationRef,
|
||||
@@ -1402,6 +1404,14 @@ export function AppSideEffects() {
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
if (sessionId && tabName) {
|
||||
renameSessionInline(sessionId, tabName);
|
||||
}
|
||||
return sessionId;
|
||||
}, [handleConnectToHost, renameSessionInline]);
|
||||
|
||||
const openHostForVaultAgent = useCallback((host: Host, isExternalMcpCall: boolean) => {
|
||||
// Silent sessions only apply to actual external MCP clients (chatSessionId
|
||||
// equals the reserved external-MCP scope). The in-app Catty AI chat's
|
||||
@@ -1469,15 +1479,16 @@ export function AppSideEffects() {
|
||||
// deep links until unlock so saved-credential connects cannot start behind
|
||||
// the lock screen.
|
||||
const pendingDeepLinksWhileLockedRef = useRef<Array<
|
||||
| { kind: 'ssh'; payload: { url?: string } }
|
||||
| { kind: 'telnet'; payload: { url?: string } }
|
||||
| { kind: 'ssh'; payload: DeepLinkPayload }
|
||||
| { kind: 'telnet'; payload: DeepLinkPayload }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const tabName = payload?.tabName?.trim();
|
||||
const target = parseSshDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.ssh.invalid'));
|
||||
@@ -1506,25 +1517,26 @@ export function AppSideEffects() {
|
||||
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
|
||||
: buildSshDeepLinkEphemeralHost(target, draftOptions);
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchedEffectiveHost) {
|
||||
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
|
||||
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
|
||||
handleConnectToHostWithTabName(buildSshDeepLinkConnectionHost(originalHost), tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeepLinkHostDraft(buildSshDeepLinkHostDraft(target, {
|
||||
const hostDraft = buildSshDeepLinkHostDraft(target, {
|
||||
id: crypto.randomUUID(),
|
||||
now: Date.now(),
|
||||
}));
|
||||
});
|
||||
setDeepLinkHostDraft(tabName ? { ...hostDraft, label: tabName } : hostDraft);
|
||||
setNavigateToSection('hosts');
|
||||
setActiveTabId('vault');
|
||||
});
|
||||
|
||||
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _handleSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'ssh', payload: payload || {} });
|
||||
return;
|
||||
@@ -1541,9 +1553,10 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _processTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const rawUrl = payload?.url || '';
|
||||
const tabName = payload?.tabName?.trim();
|
||||
const target = parseTelnetDeepLink(rawUrl);
|
||||
if (!target) {
|
||||
toast.warning(t('deepLink.telnet.invalid'));
|
||||
@@ -1564,10 +1577,10 @@ export function AppSideEffects() {
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
return;
|
||||
}
|
||||
handleConnectToHost(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost));
|
||||
handleConnectToHostWithTabName(buildTelnetDeepLinkConnectionHost(matchedEffectiveHost), tabName);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1576,10 +1589,10 @@ export function AppSideEffects() {
|
||||
now: Date.now(),
|
||||
});
|
||||
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
|
||||
handleConnectToHost(ephemeralHost);
|
||||
handleConnectToHostWithTabName(ephemeralHost, tabName);
|
||||
});
|
||||
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
|
||||
const _handleTelnetDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({ kind: 'telnet', payload: payload || {} });
|
||||
return;
|
||||
|
||||
@@ -291,7 +291,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
resolvedTheme,
|
||||
windowOpacity,
|
||||
showSftpTab,
|
||||
sftpInSidebar,
|
||||
showHostTreeSidebar,
|
||||
showRecentHosts,
|
||||
hostClickBehavior,
|
||||
@@ -531,7 +530,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onReorderTabs={reorderWorkTabs}
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
onAppendHostToWorkspace={handleAppendHostToWorkspace}
|
||||
showSftpTab={showSftpTab && !sftpInSidebar}
|
||||
showSftpTab={showSftpTab}
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
@@ -892,7 +891,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
results={quickResults}
|
||||
sessions={sessions}
|
||||
workspaces={workspaces}
|
||||
showSftpTab={showSftpTab || sftpInSidebar}
|
||||
showSftpTab={showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onEditHost={(host) => {
|
||||
|
||||
@@ -171,10 +171,7 @@ export function ChromeHost() {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
// The SFTP-tab guard only redirects when the SFTP surface is truly
|
||||
// unreachable; sidebar placement (sftpInSidebar) keeps it reachable
|
||||
// even when the top tab flag is off.
|
||||
showSftpTab: settingsChrome.showSftpTab || settingsChrome.sftpInSidebar,
|
||||
showSftpTab: settingsChrome.showSftpTab,
|
||||
setActiveTabId,
|
||||
applyAppTheme,
|
||||
hostById,
|
||||
@@ -197,7 +194,6 @@ export function ChromeHost() {
|
||||
sessionById,
|
||||
setActiveTabId,
|
||||
settingsChrome.showSftpTab,
|
||||
settingsChrome.sftpInSidebar,
|
||||
t,
|
||||
terminalSettings.followAppTerminalTheme,
|
||||
themeById,
|
||||
|
||||
@@ -28,35 +28,3 @@ test("top tabs chrome theme keeps accent foreground in sync", () => {
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
|
||||
});
|
||||
|
||||
test("bottom title bar receives and clears the same active theme as the tabs", async () => {
|
||||
const { applyTopTabsChromeThemeVars, clearTopTabsChromeThemeVars } = await import('./topTabsChromeTheme.ts');
|
||||
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes.ts');
|
||||
const makeRoot = () => {
|
||||
const values = new Map<string, string>();
|
||||
return { style: {
|
||||
getPropertyValue: (key: string) => values.get(key) ?? '',
|
||||
setProperty: (key: string, value: string) => values.set(key, value),
|
||||
removeProperty: (key: string) => values.delete(key),
|
||||
}};
|
||||
};
|
||||
const tabs = makeRoot();
|
||||
const titlebar = makeRoot();
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: {
|
||||
querySelector: (selector: string) => selector === '[data-top-tabs-root]' ? tabs : titlebar,
|
||||
}});
|
||||
try {
|
||||
applyTopTabsChromeThemeVars(TERMINAL_THEMES[0]);
|
||||
for (const property of ['--secondary', '--foreground', '--top-tabs-bg']) {
|
||||
assert.notEqual(tabs.style.getPropertyValue(property), '');
|
||||
assert.equal(titlebar.style.getPropertyValue(property), tabs.style.getPropertyValue(property));
|
||||
}
|
||||
clearTopTabsChromeThemeVars();
|
||||
assert.equal(tabs.style.getPropertyValue('--secondary'), '');
|
||||
assert.equal(titlebar.style.getPropertyValue('--secondary'), '');
|
||||
} finally {
|
||||
if (original) Object.defineProperty(globalThis, 'document', original);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
});
|
||||
@@ -70,31 +70,23 @@ const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
export function getTopTabsChromeRoots(): HTMLElement[] {
|
||||
if (typeof document === 'undefined') return [];
|
||||
return [
|
||||
document.querySelector<HTMLElement>('[data-top-tabs-root]'),
|
||||
document.querySelector<HTMLElement>('[data-tab-window-titlebar]'),
|
||||
].filter((root): root is HTMLElement => root !== null);
|
||||
}
|
||||
|
||||
let topTabsChromeThemeVarsApplied = false;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!topTabsChromeThemeVarsApplied) return;
|
||||
for (const root of getTopTabsChromeRoots()) {
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(root, property);
|
||||
}
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = false;
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const roots = getTopTabsChromeRoots();
|
||||
if (roots.length === 0) return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
@@ -105,21 +97,19 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
for (const tabsRoot of roots) {
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
}
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
topTabsChromeThemeVarsApplied = true;
|
||||
}
|
||||
@@ -288,6 +288,18 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
|
||||
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': "Xiaomi's MiMo Code CLI, an OpenCode fork. Install it and configure providers in its own config, then select it as an external coding agent.",
|
||||
'ai.mimo.detecting': 'Detecting...',
|
||||
'ai.mimo.detected': 'Detected',
|
||||
'ai.mimo.notFound': 'Not found',
|
||||
'ai.mimo.path': 'Path:',
|
||||
'ai.mimo.notFoundHint': 'Could not find mimo in PATH. Install it or specify the executable path below.',
|
||||
'ai.mimo.customPathPlaceholder': 'e.g. /usr/local/bin/mimo',
|
||||
'ai.mimo.check': 'Check',
|
||||
'ai.mimo.resetPath': 'Reset',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "xAI's Grok Build coding agent CLI. Install the Grok CLI, sign in with `grok login` or set XAI_API_KEY, then select it as an external agent.",
|
||||
@@ -324,6 +336,13 @@ export const enAiMessages: Messages = {
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'External MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Always',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Always allow this action on this server.',
|
||||
'ai.externalMcp.allowAll': 'Allow all actions',
|
||||
'ai.externalMcp.allowSession': 'This connection',
|
||||
'ai.externalMcp.allowSessionHint': 'Allow all actions from this client until it disconnects.',
|
||||
'ai.externalMcp.allowHost': 'This server',
|
||||
'ai.externalMcp.allowHostHint': 'Always allow actions on this server.',
|
||||
'ai.externalMcp.description': 'Expose Netcatty as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep Netcatty running while clients are connected.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
|
||||
'ai.externalMcp.mode': 'Availability mode',
|
||||
@@ -602,6 +621,10 @@ export const enAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': 'Move panel to left',
|
||||
'terminal.layer.movePanelRight': 'Move panel to right',
|
||||
'terminal.layer.movePanelBottom': 'Move panel to bottom',
|
||||
'terminal.layer.choosePanelPosition': 'Change position',
|
||||
'terminal.layer.dockLeft': 'Left',
|
||||
'terminal.layer.dockRight': 'Right',
|
||||
'terminal.layer.dockBottom': 'Bottom',
|
||||
'terminal.layer.closePanel': 'Close panel',
|
||||
'terminal.layer.closePane': 'Close split',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Save layout as default',
|
||||
|
||||
@@ -312,8 +312,11 @@ export const enCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
|
||||
'settings.globalHotkey.notSet': 'Not set',
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.closeToTray': 'Keep Running When Window Closes',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window hides it while the app keeps running. Tray icon visibility is set separately.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Show System Tray Icon',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'When disabled, the tray icon is hidden but the app keeps running in the background.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Tray icon hidden. Launch the app again or use the global toggle hotkey to bring the window back, then re-enable this setting to restore the icon.',
|
||||
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
|
||||
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
@@ -359,13 +362,8 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Click once to select a host (accent highlight in grid view), click again to connect. Groups work the same way. Default remains single-click connect.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.appearance.tabBarPosition': 'Tab bar position',
|
||||
'settings.appearance.tabBarPosition.top': 'Top',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Bottom',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.sftpInSidebar': 'Keep SFTP in the sidebar',
|
||||
'settings.vault.sftpInSidebarDesc': 'Hide the top SFTP tab and show SFTP in the vault sidebar instead, so the sidebar stays visible while SFTP is open.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Auto-import system known_hosts',
|
||||
@@ -543,7 +541,7 @@ Highlight the focused split pane:
|
||||
'settings.appearance.windowOpacity': 'Window Opacity',
|
||||
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
|
||||
'settings.appearance.appIcon': 'App Icon',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
'settings.appearance.appIcon.bright': 'Bright',
|
||||
'settings.appearance.appIcon.dark': 'Dark',
|
||||
@@ -619,6 +617,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Font smoothing',
|
||||
'settings.terminal.font.smoothing.desc': 'Use native macOS font anti-aliasing',
|
||||
'settings.terminal.font.ligatures': 'Font ligatures',
|
||||
'settings.terminal.font.ligatures.desc': 'Render ligatures such as != when the terminal font includes them',
|
||||
'settings.terminal.font.linePadding': 'Line padding',
|
||||
'settings.terminal.font.linePadding.desc': 'Additional space between lines (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Terminal emulation type',
|
||||
@@ -626,6 +626,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.cursor.style.block': 'Block',
|
||||
'settings.terminal.cursor.style.bar': 'Bar',
|
||||
'settings.terminal.cursor.style.underline': 'Underline',
|
||||
'settings.terminal.cursor.barWidth': 'Bar cursor width',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Width of bar cursors used by applications such as vi (1-4 px)',
|
||||
'settings.terminal.cursor.blink': 'Cursor blink',
|
||||
'settings.terminal.cursor.highlightLine': 'Highlight current line',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -710,7 +712,10 @@ Highlight the focused split pane:
|
||||
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Use \\n for newline, \\t for tab, and \\\\ for backslash.',
|
||||
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
|
||||
@@ -1029,7 +1034,6 @@ Highlight the focused split pane:
|
||||
'vault.nav.snippets': 'Scripts',
|
||||
'vault.nav.notes': 'Notes',
|
||||
'vault.nav.knownHosts': 'Known Hosts',
|
||||
'vault.nav.sftp': 'SFTP',
|
||||
'vault.nav.logs': 'Logs',
|
||||
|
||||
'proxyProfiles.action.add': 'Add Proxy',
|
||||
|
||||
@@ -27,6 +27,9 @@ export const enScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
|
||||
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
|
||||
'scripts.actions.runOnAllTabs': 'Run on all tabs',
|
||||
'scripts.actions.copyCommand': 'Copy command',
|
||||
'scripts.actions.copyCommand.copied': 'Command copied',
|
||||
'scripts.actions.copyCommand.failed': 'Could not copy command',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} tab(s) still connecting and were skipped',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} tab(s) skipped (password/sensitive input)',
|
||||
'scripts.actions.noRunnableHosts': 'No connectable hosts match this script\'s targets',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
'systemManager.errors.loadServices': 'Failed to load systemd services',
|
||||
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
|
||||
'systemManager.unsupportedRemote': 'This host does not allow extra SSH exec channels, so System Manager is unavailable. Opening this panel will no longer disconnect the terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'No system overview data yet.',
|
||||
'systemManager.overview.loading': 'Loading system overview…',
|
||||
|
||||
@@ -61,6 +61,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Broadcast without password protection',
|
||||
'terminal.composeBar.resize': 'Resize compose bar height',
|
||||
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
|
||||
'terminal.composeBar.searchSnippets': 'Search snippets...',
|
||||
@@ -134,6 +135,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.cut': 'Cut',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.uploadClipboardImage': 'Upload clipboard image',
|
||||
@@ -188,6 +190,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
|
||||
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Use saved identity',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
|
||||
@@ -407,6 +407,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': 'Transfer next',
|
||||
'sftp.transferCenter.pauseAll': 'Pause all',
|
||||
'sftp.transferCenter.resumeAll': 'Resume all',
|
||||
'sftp.transferCenter.cancelAll': 'Cancel all',
|
||||
'sftp.transferCenter.empty': 'No transfers in this category',
|
||||
'sftp.transferCenter.showBackground': 'Show {count} background transfers',
|
||||
'sftp.transferCenter.hideBackground': 'Hide background transfers',
|
||||
@@ -417,7 +418,6 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.goUp': 'Go up',
|
||||
'sftp.goToTerminalCwd': 'Go to terminal directory',
|
||||
'sftp.locatePathInTerminal': 'Open path in terminal',
|
||||
'sftp.refreshAfterCommand': 'Refresh after terminal commands',
|
||||
'sftp.followTerminalCwd': 'Follow terminal directory',
|
||||
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
|
||||
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
|
||||
|
||||
@@ -279,6 +279,18 @@ export const esAiMessages: Messages = {
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
|
||||
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': 'CLI de MiMo Code de Xiaomi, un fork de OpenCode. Instálalo y configura los proveedores en su propia configuración, luego selecciónalo como agente de codificación externo.',
|
||||
'ai.mimo.detecting': 'Detectando...',
|
||||
'ai.mimo.detected': 'Detectado',
|
||||
'ai.mimo.notFound': 'No encontrado',
|
||||
'ai.mimo.path': 'Ruta:',
|
||||
'ai.mimo.notFoundHint': 'No se pudo encontrar mimo en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
|
||||
'ai.mimo.customPathPlaceholder': 'p. ej. /usr/local/bin/mimo',
|
||||
'ai.mimo.check': 'Verificar',
|
||||
'ai.mimo.resetPath': 'Restablecer',
|
||||
|
||||
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': "La CLI de agente de codificación Grok Build de xAI. Instala la CLI de Grok, inicia sesión con `grok login` o define XAI_API_KEY, y luego selecciónala como agente externo.",
|
||||
@@ -315,6 +327,13 @@ export const esAiMessages: Messages = {
|
||||
|
||||
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
|
||||
'ai.externalMcp.title': 'MCP externo',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Siempre',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Permitir siempre esta acción en este servidor.',
|
||||
'ai.externalMcp.allowAll': 'Permitir todas las acciones',
|
||||
'ai.externalMcp.allowSession': 'Esta conexión',
|
||||
'ai.externalMcp.allowSessionHint': 'Permitir todas las acciones de este cliente hasta que se desconecte.',
|
||||
'ai.externalMcp.allowHost': 'Este servidor',
|
||||
'ai.externalMcp.allowHostHint': 'Permitir siempre acciones en este servidor.',
|
||||
'ai.externalMcp.description': 'Expón Netcatty como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén Netcatty en ejecución mientras los clientes estén conectados.',
|
||||
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
|
||||
'ai.externalMcp.mode': 'Modo de disponibilidad',
|
||||
@@ -593,6 +612,10 @@ export const esAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
|
||||
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
|
||||
'terminal.layer.movePanelBottom': 'Mover panel a la parte inferior',
|
||||
'terminal.layer.choosePanelPosition': 'Cambiar posición',
|
||||
'terminal.layer.dockLeft': 'Izquierda',
|
||||
'terminal.layer.dockRight': 'Derecha',
|
||||
'terminal.layer.dockBottom': 'Abajo',
|
||||
'terminal.layer.closePanel': 'Cerrar panel',
|
||||
'terminal.layer.closePane': 'Cerrar división',
|
||||
'terminal.layer.resizeSplit': 'Redimensionar división',
|
||||
|
||||
@@ -227,8 +227,11 @@ export const esCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Presiona una combinación de teclas para definir un atajo global que muestre u oculte la ventana.',
|
||||
'settings.globalHotkey.notSet': 'No configurada',
|
||||
'settings.globalHotkey.reset': 'Restablecer al valor predeterminado',
|
||||
'settings.globalHotkey.closeToTray': 'Cerrar a la bandeja del sistema',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Cuando está activado, cerrar la ventana la minimiza a la bandeja del sistema en lugar de salir.',
|
||||
'settings.globalHotkey.closeToTray': 'Seguir en segundo plano al cerrar la ventana',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Al cerrar la ventana, se oculta y la aplicación sigue activa. La visibilidad del icono de la bandeja se configura aparte.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Mostrar icono de la bandeja del sistema',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'Cuando está desactivado, el icono de la bandeja se oculta, pero la aplicación sigue ejecutándose en segundo plano.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Icono de la bandeja oculto. Vuelve a iniciar la aplicación o usa el atajo global para recuperar la ventana y vuelve a activar este ajuste para restaurar el icono.',
|
||||
'settings.globalHotkey.enabled': 'Activar tecla de acceso global',
|
||||
'settings.globalHotkey.enabledDesc': 'Registra atajos de teclado a nivel de todo el sistema. Cuando está desactivado, todas las teclas de acceso global se desregistran.',
|
||||
'settings.globalHotkey.hint': 'La tecla de acceso global funciona en todo el sistema para mostrar u ocultar la ventana rápidamente (terminal estilo Quake).',
|
||||
@@ -274,13 +277,8 @@ export const esCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Haz clic una vez para seleccionar un host (resaltado con el color de acento en la vista de cuadrícula), haz clic de nuevo para conectarte. Los grupos funcionan igual. El valor predeterminado sigue siendo conectarse con un solo clic.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Mostrar solo hosts sin grupo en la raíz',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Cuando está activado, la lista de hosts de la raíz solo muestra hosts sin grupo. Abre un grupo desde la barra lateral para ver los hosts agrupados.',
|
||||
'settings.appearance.tabBarPosition': 'Posición de la barra de pestañas',
|
||||
'settings.appearance.tabBarPosition.top': 'Arriba',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Abajo',
|
||||
'settings.vault.showSftpTab': 'Mostrar pestaña SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Muestra la vista SFTP independiente en la barra de pestañas superior. Cuando está oculta, usa el panel lateral SFTP dentro de la sesión.',
|
||||
'settings.vault.sftpInSidebar': 'Mantener SFTP en la barra lateral',
|
||||
'settings.vault.sftpInSidebarDesc': 'Oculta la pestaña SFTP superior y muestra SFTP en la barra lateral del depósito, de modo que la barra lateral siga visible mientras SFTP está abierto.',
|
||||
'settings.vault.showHostTreeSidebar': 'Mostrar barra lateral de lista de hosts',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Muestra la barra lateral de lista de hosts y su alternador en la barra superior en las pestañas de terminal y editor.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Importar automáticamente known_hosts del sistema',
|
||||
@@ -458,7 +456,7 @@ Resalta el panel dividido enfocado:
|
||||
'settings.appearance.windowOpacity': 'Opacidad de la ventana',
|
||||
'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.',
|
||||
'settings.appearance.appIcon': 'Ícono de la aplicación',
|
||||
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||
'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.',
|
||||
'settings.appearance.appIcon.original': 'Original',
|
||||
'settings.appearance.appIcon.bright': 'Brillante',
|
||||
'settings.appearance.appIcon.dark': 'Oscuro',
|
||||
@@ -534,6 +532,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.font.weightBold.desc': 'Peso del texto en negrita (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Suavizado de fuente',
|
||||
'settings.terminal.font.smoothing.desc': 'Usar el anti-aliasing nativo de fuentes de macOS',
|
||||
'settings.terminal.font.ligatures': 'Ligaduras de fuente',
|
||||
'settings.terminal.font.ligatures.desc': 'Mostrar ligaduras como != cuando la fuente del terminal las incluye',
|
||||
'settings.terminal.font.linePadding': 'Espaciado de líneas',
|
||||
'settings.terminal.font.linePadding.desc': 'Espacio adicional entre líneas (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Tipo de emulación de terminal',
|
||||
@@ -541,6 +541,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.cursor.style.block': 'Bloque',
|
||||
'settings.terminal.cursor.style.bar': 'Barra',
|
||||
'settings.terminal.cursor.style.underline': 'Subrayado',
|
||||
'settings.terminal.cursor.barWidth': 'Ancho del cursor de barra',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Ancho de los cursores de barra utilizados por aplicaciones como vi (1-4 px)',
|
||||
'settings.terminal.cursor.blink': 'Parpadeo del cursor',
|
||||
'settings.terminal.cursor.highlightLine': 'Resaltar línea actual',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -625,7 +627,10 @@ Resalta el panel dividido enfocado:
|
||||
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Usa \\n para nueva línea, \\t para tabulación y \\\\ para barra invertida.',
|
||||
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` borra el historial de desplazamiento',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Haz que `clear` también borre el buffer del historial de desplazamiento (predeterminado de POSIX). Desactívalo para mantener visible el historial después de `clear`.',
|
||||
@@ -987,7 +992,6 @@ Resalta el panel dividido enfocado:
|
||||
'vault.nav.snippets': 'Scripts',
|
||||
'vault.nav.notes': 'Notas',
|
||||
'vault.nav.knownHosts': 'Hosts conocidos',
|
||||
'vault.nav.sftp': 'SFTP',
|
||||
'vault.nav.logs': 'Registros',
|
||||
|
||||
'proxyProfiles.action.add': 'Agregar proxy',
|
||||
|
||||
@@ -27,6 +27,9 @@ export const esScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Ejecutar en todas las pestañas (en paralelo)',
|
||||
'scripts.actions.runSequential': 'Ejecutar en todas las pestañas (en secuencia)',
|
||||
'scripts.actions.runOnAllTabs': 'Ejecutar en todas las pestañas',
|
||||
'scripts.actions.copyCommand': 'Copiar comando',
|
||||
'scripts.actions.copyCommand.copied': 'Comando copiado',
|
||||
'scripts.actions.copyCommand.failed': 'No se pudo copiar el comando',
|
||||
'scripts.actions.skippedConnectingSessions': '{count} pestaña(s) aún conectándose y fueron omitidas',
|
||||
'scripts.actions.skippedSensitiveSessions': '{count} pestaña(s) omitidas (entrada de contraseña/datos sensibles)',
|
||||
'scripts.actions.noRunnableHosts': 'Ningún host conectable coincide con los destinos de este script',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
'systemManager.overview.loading': 'Cargando resumen del sistema…',
|
||||
|
||||
@@ -61,6 +61,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Enviar',
|
||||
'terminal.composeBar.close': 'Cerrar barra de redacción',
|
||||
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Difundir sin protección de contraseña',
|
||||
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
|
||||
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
|
||||
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
|
||||
@@ -134,6 +135,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'Sin resultados',
|
||||
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
|
||||
'terminal.menu.cut': 'Cortar',
|
||||
'terminal.menu.copy': 'Copiar',
|
||||
'terminal.menu.paste': 'Pegar',
|
||||
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
|
||||
@@ -188,6 +190,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
|
||||
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
|
||||
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
|
||||
'terminal.auth.selectIdentity': 'Usar identidad guardada',
|
||||
'terminal.auth.continueSave': 'Continuar y guardar',
|
||||
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
|
||||
|
||||
@@ -407,6 +407,7 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': 'Transferir a continuación',
|
||||
'sftp.transferCenter.pauseAll': 'Pausar todas',
|
||||
'sftp.transferCenter.resumeAll': 'Reanudar todas',
|
||||
'sftp.transferCenter.cancelAll': 'Cancelar todas',
|
||||
'sftp.transferCenter.empty': 'No hay transferencias en esta categoría',
|
||||
'sftp.transferCenter.showBackground': 'Mostrar {count} transferencias en segundo plano',
|
||||
'sftp.transferCenter.hideBackground': 'Ocultar transferencias en segundo plano',
|
||||
|
||||
@@ -283,6 +283,13 @@ export const ruAiMessages: Messages = {
|
||||
'ai.toolAccess.skills.unavailable': 'Путь к skill-файлу недоступен',
|
||||
|
||||
'ai.externalMcp.title': 'Внешний MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': 'Всегда',
|
||||
'ai.externalMcp.allowCommandOnHostHint': 'Всегда разрешать это действие на этом сервере.',
|
||||
'ai.externalMcp.allowAll': 'Разрешить все действия',
|
||||
'ai.externalMcp.allowSession': 'Это подключение',
|
||||
'ai.externalMcp.allowSessionHint': 'Разрешать все действия клиента до отключения.',
|
||||
'ai.externalMcp.allowHost': 'Этот сервер',
|
||||
'ai.externalMcp.allowHostHint': 'Всегда разрешать действия на этом сервере.',
|
||||
'ai.externalMcp.description': 'Откройте Netcatty как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите Netcatty запущенным, пока клиенты подключены.',
|
||||
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
|
||||
'ai.externalMcp.mode': 'Режим доступности',
|
||||
@@ -561,6 +568,10 @@ export const ruAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': 'Переместить панель влево',
|
||||
'terminal.layer.movePanelRight': 'Переместить панель вправо',
|
||||
'terminal.layer.movePanelBottom': 'Переместить панель вниз',
|
||||
'terminal.layer.choosePanelPosition': 'Изменить положение',
|
||||
'terminal.layer.dockLeft': 'Слева',
|
||||
'terminal.layer.dockRight': 'Справа',
|
||||
'terminal.layer.dockBottom': 'Снизу',
|
||||
'terminal.layer.closePanel': 'Закрыть панель',
|
||||
'terminal.layer.closePane': 'Закрыть область',
|
||||
'terminal.layer.resizeSplit': 'Изменить размер области',
|
||||
|
||||
@@ -296,8 +296,11 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Нажмите сочетание клавиш, чтобы задать глобальную горячую клавишу для показа или скрытия окна.',
|
||||
'settings.globalHotkey.notSet': 'Не задано',
|
||||
'settings.globalHotkey.reset': 'Сбросить по умолчанию',
|
||||
'settings.globalHotkey.closeToTray': 'Сворачивать в системный трей',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'Если включено, при закрытии окно будет сворачиваться в системный трей вместо выхода из приложения.',
|
||||
'settings.globalHotkey.closeToTray': 'Оставлять приложение в фоне при закрытии окна',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'При закрытии окно скрывается, а приложение продолжает работать. Отображение значка в трее настраивается отдельно.',
|
||||
'settings.globalHotkey.showTrayIcon': 'Показывать значок в системном трее',
|
||||
'settings.globalHotkey.showTrayIconDesc': 'Если отключено, значок в трее скрыт, но приложение продолжает работать в фоне.',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': 'Значок в трее скрыт. Запустите приложение снова или используйте глобальную горячую клавишу, чтобы вернуть окно, затем снова включите этот параметр, чтобы восстановить значок.',
|
||||
'settings.globalHotkey.enabled': 'Включить глобальную горячую клавишу',
|
||||
'settings.globalHotkey.enabledDesc': 'Регистрировать системные сочетания клавиш. Когда отключено, все глобальные горячие клавиши снимаются с регистрации.',
|
||||
'settings.globalHotkey.hint': 'Глобальная горячая клавиша работает на уровне всей системы и позволяет быстро показывать или скрывать окно (терминал в стиле Quake).',
|
||||
@@ -343,13 +346,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Один клик выбирает хост (акцентная рамка в сетке), повторный клик подключает. Группы работают так же. По умолчанию — подключение одним кликом.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.appearance.tabBarPosition': 'Расположение панели вкладок',
|
||||
'settings.appearance.tabBarPosition.top': 'Сверху',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Снизу',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.sftpInSidebar': 'Держать SFTP в боковой панели',
|
||||
'settings.vault.sftpInSidebarDesc': 'Скрывает верхнюю вкладку SFTP и показывает SFTP в боковой панели хранилища, чтобы панель оставалась видимой, пока открыт SFTP.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
|
||||
'settings.vault.autoImportSystemKnownHosts': 'Автоимпорт системного known_hosts',
|
||||
@@ -512,7 +510,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
'settings.appearance.appIcon': 'Значок приложения',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.',
|
||||
'settings.appearance.appIcon.original': 'Оригинал',
|
||||
'settings.appearance.appIcon.bright': 'Яркий',
|
||||
'settings.appearance.appIcon.dark': 'Тёмный',
|
||||
@@ -587,6 +585,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
|
||||
'settings.terminal.font.smoothing': 'Сглаживание шрифта',
|
||||
'settings.terminal.font.smoothing.desc': 'Использовать родное сглаживание шрифтов macOS',
|
||||
'settings.terminal.font.ligatures': 'Лигатуры шрифта',
|
||||
'settings.terminal.font.ligatures.desc': 'Показывать лигатуры вроде !=, если шрифт терминала их содержит',
|
||||
'settings.terminal.font.linePadding': 'Межстрочный отступ',
|
||||
'settings.terminal.font.linePadding.desc': 'Дополнительное пространство между строками (0-10)',
|
||||
'settings.terminal.font.emulationType': 'Тип эмуляции терминала',
|
||||
@@ -594,6 +594,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': 'Блок',
|
||||
'settings.terminal.cursor.style.bar': 'Полоса',
|
||||
'settings.terminal.cursor.style.underline': 'Подчёркивание',
|
||||
'settings.terminal.cursor.barWidth': 'Толщина курсора-полосы',
|
||||
'settings.terminal.cursor.barWidth.desc': 'Ширина курсоров-полос, используемых приложениями вроде vi (1-4 пикс.)',
|
||||
'settings.terminal.cursor.blink': 'Мигание курсора',
|
||||
'settings.terminal.cursor.highlightLine': 'Подсветка текущей строки',
|
||||
'settings.terminal.cursor.highlightLine.desc':
|
||||
@@ -661,7 +663,10 @@ export const ruCoreMessages: Messages = {
|
||||
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'Используйте \\n для перевода строки, \\t для табуляции и \\\\ для обратной косой черты.',
|
||||
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
|
||||
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` очищает буфер прокрутки',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Команда `clear` также будет очищать буфер прокрутки (поведение POSIX по умолчанию). Отключите, чтобы история оставалась видимой после `clear`.',
|
||||
@@ -1012,7 +1017,6 @@ export const ruCoreMessages: Messages = {
|
||||
'vault.nav.snippets': 'Сниппеты',
|
||||
'vault.nav.notes': 'Заметки',
|
||||
'vault.nav.knownHosts': 'Известные хосты',
|
||||
'vault.nav.sftp': 'SFTP',
|
||||
'vault.nav.logs': 'Журналы',
|
||||
|
||||
'proxyProfiles.action.add': 'Добавить прокси',
|
||||
|
||||
@@ -27,6 +27,9 @@ export const ruScriptsMessages = {
|
||||
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
|
||||
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
|
||||
'scripts.actions.runOnAllTabs': 'Запустить на всех вкладках',
|
||||
'scripts.actions.copyCommand': 'Копировать команду',
|
||||
'scripts.actions.copyCommand.copied': 'Команда скопирована',
|
||||
'scripts.actions.copyCommand.failed': 'Не удалось скопировать команду',
|
||||
'scripts.actions.skippedConnectingSessions': 'Пропущено вкладок, которые ещё подключаются: {count}',
|
||||
'scripts.actions.skippedSensitiveSessions': 'Пропущено вкладок с паролем/чувствительным вводом: {count}',
|
||||
'scripts.actions.noRunnableHosts': 'Нет доступных для подключения хостов, соответствующих целям этого скрипта',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
'systemManager.errors.loadServices': 'Не удалось загрузить службы systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
|
||||
'systemManager.unsupportedRemote': 'Этот хост не разрешает дополнительные SSH exec-каналы, поэтому системная панель недоступна. Открытие панели больше не разрывает терминал.',
|
||||
|
||||
'systemManager.overview.empty': 'Данных обзора пока нет.',
|
||||
'systemManager.overview.loading': 'Загрузка обзора системы…',
|
||||
|
||||
@@ -82,6 +82,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.composeBar.send': 'Отправить',
|
||||
'terminal.composeBar.close': 'Закрыть строку ввода',
|
||||
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
|
||||
'terminal.composeBar.broadcastAllowPassword': 'Транслировать без защиты пароля',
|
||||
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
|
||||
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
|
||||
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
|
||||
@@ -155,6 +156,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.search.noResults': 'Ничего не найдено',
|
||||
'terminal.search.prevMatch': 'Предыдущее совпадение (Shift+Enter)',
|
||||
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
|
||||
'terminal.menu.cut': 'Вырезать',
|
||||
'terminal.menu.copy': 'Копировать',
|
||||
'terminal.menu.paste': 'Вставить',
|
||||
'terminal.menu.uploadClipboardImage': 'Загрузить изображение из буфера',
|
||||
@@ -209,6 +211,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
|
||||
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
|
||||
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
|
||||
'terminal.auth.selectIdentity': 'Использовать сохранённый профиль',
|
||||
'terminal.auth.continueSave': 'Продолжить и сохранить',
|
||||
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'У jump-хоста сохранены учётные данные, которые нельзя расшифровать на этом устройстве. Откройте настройки хоста и введите их заново.',
|
||||
|
||||
@@ -445,6 +445,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': 'Передать следующей',
|
||||
'sftp.transferCenter.pauseAll': 'Приостановить все',
|
||||
'sftp.transferCenter.resumeAll': 'Продолжить все',
|
||||
'sftp.transferCenter.cancelAll': 'Отменить все',
|
||||
'sftp.transferCenter.empty': 'В этой категории нет передач',
|
||||
'sftp.transferCenter.showBackground': 'Показать фоновые: {count}',
|
||||
'sftp.transferCenter.hideBackground': 'Скрыть фоновые',
|
||||
|
||||
@@ -7,9 +7,11 @@ import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
const LOCALIZED_SETTINGS_LOCALES = [
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
@@ -83,6 +85,21 @@ test("localized settings include OSC desktop notification labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include bar cursor width labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.cursor.barWidth",
|
||||
"settings.terminal.cursor.barWidth.desc",
|
||||
];
|
||||
|
||||
for (const locale of [
|
||||
{ name: "en", messages: en },
|
||||
...LOCALIZED_SETTINGS_LOCALES,
|
||||
]) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing bar cursor width labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include terminal font weight option labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.font.weight.thin",
|
||||
|
||||
@@ -288,6 +288,18 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': '小米 MiMo Code CLI(OpenCode 分支)。安装后在它自己的配置里配置 provider 和密钥,检测到后即可作为外部编程 Agent 使用。',
|
||||
'ai.mimo.detecting': '检测中...',
|
||||
'ai.mimo.detected': '已检测到',
|
||||
'ai.mimo.notFound': '未找到',
|
||||
'ai.mimo.path': '路径:',
|
||||
'ai.mimo.notFoundHint': '在 PATH 中未找到 mimo。请安装或在下方指定可执行文件路径。',
|
||||
'ai.mimo.customPathPlaceholder': '例如 /usr/local/bin/mimo',
|
||||
'ai.mimo.check': '检查',
|
||||
'ai.mimo.resetPath': '重置',
|
||||
|
||||
// AI Grok Build(应用内托管 Agent,与 External MCP「添加到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 编程 Agent CLI。安装 Grok CLI,使用 `grok login` 登录或设置 XAI_API_KEY 后,即可作为外部 Agent 选择。',
|
||||
@@ -324,6 +336,13 @@ export const zhCNAiMessages: Messages = {
|
||||
|
||||
// External MCP
|
||||
'ai.externalMcp.title': '对外 MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': '始终',
|
||||
'ai.externalMcp.allowCommandOnHostHint': '始终允许在此服务器执行同类操作。',
|
||||
'ai.externalMcp.allowAll': '允许所有操作',
|
||||
'ai.externalMcp.allowSession': '此次连接',
|
||||
'ai.externalMcp.allowSessionHint': '在客户端断开前,允许此次连接的所有操作。',
|
||||
'ai.externalMcp.allowHost': '此服务器',
|
||||
'ai.externalMcp.allowHostHint': '始终允许操作此服务器。',
|
||||
'ai.externalMcp.description': '把 Netcatty 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 Netcatty 运行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
@@ -602,6 +621,10 @@ export const zhCNAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': '面板移至左侧',
|
||||
'terminal.layer.movePanelRight': '面板移至右侧',
|
||||
'terminal.layer.movePanelBottom': '面板移至底部',
|
||||
'terminal.layer.choosePanelPosition': '改变位置',
|
||||
'terminal.layer.dockLeft': '左侧',
|
||||
'terminal.layer.dockRight': '右侧',
|
||||
'terminal.layer.dockBottom': '底部',
|
||||
'terminal.layer.closePanel': '关闭面板',
|
||||
'terminal.layer.closePane': '关闭分屏',
|
||||
'terminal.layer.saveLayoutAsDefault': '将布局保存为默认',
|
||||
|
||||
@@ -316,8 +316,11 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
|
||||
'settings.globalHotkey.notSet': '未设置',
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.closeToTray': '关闭窗口后在后台运行',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口会隐藏窗口,应用继续在后台运行。托盘图标可单独开关。',
|
||||
'settings.globalHotkey.showTrayIcon': '显示系统托盘图标',
|
||||
'settings.globalHotkey.showTrayIconDesc': '关闭后,托盘图标将被隐藏,但应用仍会在后台正常运行。',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': '托盘图标已隐藏。再次启动应用或使用全局显示/隐藏快捷键即可找回窗口,然后重新打开此设置即可恢复图标。',
|
||||
'settings.globalHotkey.enabled': '启用全局快捷键',
|
||||
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
@@ -363,13 +366,8 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '单击选中主机(网格模式显示强调色边框),再次单击连接;分组同理。默认仍为单击直接连接。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.appearance.tabBarPosition': '标签栏位置',
|
||||
'settings.appearance.tabBarPosition.top': '顶部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.sftpInSidebar': '将 SFTP 放入侧边栏',
|
||||
'settings.vault.sftpInSidebarDesc': '隐藏顶部的 SFTP 标签页,改为在侧边栏显示 SFTP,打开 SFTP 时侧边栏保持可见,可直接切换到其他功能页面。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
|
||||
'settings.vault.autoImportSystemKnownHosts': '自动导入系统 known_hosts',
|
||||
@@ -547,7 +545,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
'settings.appearance.appIcon': '应用图标',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
@@ -653,7 +651,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.nav.snippets': '脚本',
|
||||
'vault.nav.notes': '笔记',
|
||||
'vault.nav.knownHosts': '已知主机',
|
||||
'vault.nav.sftp': 'SFTP',
|
||||
'vault.nav.logs': '日志',
|
||||
|
||||
'proxyProfiles.action.add': '添加代理',
|
||||
@@ -1054,6 +1051,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': '优先传输',
|
||||
'sftp.transferCenter.pauseAll': '全部暂停',
|
||||
'sftp.transferCenter.resumeAll': '全部继续',
|
||||
'sftp.transferCenter.cancelAll': '全部取消',
|
||||
'sftp.transferCenter.empty': '当前分类没有传输任务',
|
||||
'sftp.transferCenter.showBackground': '显示 {count} 个后台任务',
|
||||
'sftp.transferCenter.hideBackground': '隐藏后台任务',
|
||||
@@ -1064,7 +1062,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.locatePathInTerminal': '定位路径到终端',
|
||||
'sftp.refreshAfterCommand': '终端命令完成后刷新',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
|
||||
@@ -27,6 +27,9 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.actions.runParallel': '在所有标签页并行运行',
|
||||
'scripts.actions.runSequential': '在所有标签页顺序运行',
|
||||
'scripts.actions.runOnAllTabs': '在所有标签页运行',
|
||||
'scripts.actions.copyCommand': '复制命令',
|
||||
'scripts.actions.copyCommand.copied': '命令已复制',
|
||||
'scripts.actions.copyCommand.failed': '复制命令失败',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 个标签仍在连接中,已跳过',
|
||||
'scripts.actions.skippedSensitiveSessions': '有 {count} 个标签处于密码/敏感输入,已跳过',
|
||||
'scripts.actions.noRunnableHosts': '没有与脚本目标匹配的可连接主机',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
'systemManager.errors.loadServices': '加载 systemd 服务失败',
|
||||
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
|
||||
'systemManager.unsupportedRemote': '该主机不允许额外的 SSH 执行通道,系统面板不可用。打开此面板不会再断开当前终端。',
|
||||
|
||||
'systemManager.overview.empty': '暂无系统概览数据。',
|
||||
'systemManager.overview.loading': '正在加载系统概览…',
|
||||
|
||||
@@ -278,6 +278,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
|
||||
'settings.terminal.font.smoothing': '字体平滑',
|
||||
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字体抗锯齿',
|
||||
'settings.terminal.font.ligatures': '字体连字',
|
||||
'settings.terminal.font.ligatures.desc': '支持连字的字体把 != 这类字符显示成一个符号',
|
||||
'settings.terminal.font.linePadding': '行间距',
|
||||
'settings.terminal.font.linePadding.desc': '行之间的额外间距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '终端仿真类型',
|
||||
@@ -285,6 +287,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': '块',
|
||||
'settings.terminal.cursor.style.bar': '竖线',
|
||||
'settings.terminal.cursor.style.underline': '下划线',
|
||||
'settings.terminal.cursor.barWidth': '竖线光标粗细',
|
||||
'settings.terminal.cursor.barWidth.desc': 'vi 等应用使用竖线光标时的宽度(1-4 像素)',
|
||||
'settings.terminal.cursor.blink': '光标闪烁',
|
||||
'settings.terminal.cursor.highlightLine': '高亮当前行',
|
||||
'settings.terminal.cursor.highlightLine.desc': '为光标所在行加上淡色背景,方便定位当前输入位置',
|
||||
@@ -344,7 +348,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\\\ 表示反斜杠。',
|
||||
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
|
||||
@@ -466,6 +466,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.composeBar.broadcastAllowPassword': '广播时不对密码进行保护',
|
||||
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
|
||||
'terminal.composeBar.searchSnippets': '搜索代码片段...',
|
||||
@@ -537,6 +538,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.cut': '剪切',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.addSelectionToAI': '添加到对话',
|
||||
@@ -579,6 +581,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
|
||||
'terminal.auth.retryLog': '身份验证失败,请重试。',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.selectIdentity': '使用已保存的身份',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
|
||||
@@ -279,6 +279,18 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
'ai.mimo.description': '小米 MiMo Code CLI(OpenCode 分支)。安裝後在它自己的設定裡設定 provider 和金鑰,偵測到後即可作為外部程式設計 Agent 使用。',
|
||||
'ai.mimo.detecting': '偵測中...',
|
||||
'ai.mimo.detected': '已偵測到',
|
||||
'ai.mimo.notFound': '未找到',
|
||||
'ai.mimo.path': '路徑:',
|
||||
'ai.mimo.notFoundHint': '在 PATH 中未找到 mimo。請安裝或在下方指定執行檔路徑。',
|
||||
'ai.mimo.customPathPlaceholder': '例如 /usr/local/bin/mimo',
|
||||
'ai.mimo.check': '檢查',
|
||||
'ai.mimo.resetPath': '重設',
|
||||
|
||||
// AI Grok Build(應用內託管 Agent,與 External MCP「新增到 Grok」不同)
|
||||
'ai.grok.title': 'Grok Build',
|
||||
'ai.grok.description': 'xAI 的 Grok Build 程式設計 Agent CLI。安裝 Grok CLI,使用 `grok login` 登入或設定 XAI_API_KEY 後,即可作為外部 Agent 選擇。',
|
||||
@@ -314,6 +326,13 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.toolAccess.skills.unavailable': '暫時無法取得 Skill 檔案路徑',
|
||||
|
||||
'ai.externalMcp.title': '對外 MCP',
|
||||
'ai.externalMcp.allowCommandOnHost': '始終',
|
||||
'ai.externalMcp.allowCommandOnHostHint': '始終允許在此伺服器執行同類操作。',
|
||||
'ai.externalMcp.allowAll': '允許所有操作',
|
||||
'ai.externalMcp.allowSession': '此次連線',
|
||||
'ai.externalMcp.allowSessionHint': '在用戶端中斷前,允許此次連線的所有操作。',
|
||||
'ai.externalMcp.allowHost': '此伺服器',
|
||||
'ai.externalMcp.allowHostHint': '始終允許操作此伺服器。',
|
||||
'ai.externalMcp.description': '將 Netcatty 作為 MCP 伺服器暴露給 Codex、Claude Code、Cursor、Grok 等外部用戶端。工具面與應用內 Agent 相同。用戶端連線期間請保持 Netcatty 執行。',
|
||||
'ai.externalMcp.sessionsExposed': '作用域內工作階段:{count}',
|
||||
'ai.externalMcp.mode': '可用模式',
|
||||
@@ -592,6 +611,10 @@ export const zhTWAiMessages: Messages = {
|
||||
'terminal.layer.movePanelLeft': '面板移至左側',
|
||||
'terminal.layer.movePanelRight': '面板移至右側',
|
||||
'terminal.layer.movePanelBottom': '面板移至底部',
|
||||
'terminal.layer.choosePanelPosition': '變更位置',
|
||||
'terminal.layer.dockLeft': '左側',
|
||||
'terminal.layer.dockRight': '右側',
|
||||
'terminal.layer.dockBottom': '底部',
|
||||
'terminal.layer.closePanel': '關閉面板',
|
||||
'terminal.layer.closePane': '關閉分割',
|
||||
'terminal.layer.saveLayoutAsDefault': '將佈局儲存為預設',
|
||||
|
||||
@@ -318,8 +318,11 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下組合鍵以設定顯示/隱藏視窗的全域快速鍵。',
|
||||
'settings.globalHotkey.notSet': '未設定',
|
||||
'settings.globalHotkey.reset': '恢復預設',
|
||||
'settings.globalHotkey.closeToTray': '關閉時最小化到系統匣',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗將最小化到系統匣而不是結束程式。',
|
||||
'settings.globalHotkey.closeToTray': '關閉視窗後在背景執行',
|
||||
'settings.globalHotkey.closeToTrayDesc': '啟用後,關閉視窗會隱藏視窗,應用程式繼續在背景執行。系統匣圖示可另外開關。',
|
||||
'settings.globalHotkey.showTrayIcon': '顯示系統匣圖示',
|
||||
'settings.globalHotkey.showTrayIconDesc': '關閉後,系統匣圖示將被隱藏,但應用程式仍會在背景正常執行。',
|
||||
'settings.globalHotkey.showTrayIconHiddenHint': '系統匣圖示已隱藏。再次啟動應用程式或使用全域顯示/隱藏快捷鍵即可找回視窗,然後重新開啟此設定即可恢復圖示。',
|
||||
'settings.globalHotkey.enabled': '啟用全域快速鍵',
|
||||
'settings.globalHotkey.enabledDesc': '註冊系統級鍵盤快速鍵。停用後將取消所有全域快速鍵註冊。',
|
||||
'settings.globalHotkey.hint': '全域快速鍵在系統範圍內工作,可快速顯示或隱藏視窗(下拉式終端風格)。',
|
||||
@@ -365,13 +368,8 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '點一下選取主機(網格模式會顯示強調色邊框),再點一下才連線;群組同理。預設仍是點一下直接連線。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未歸類主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有群組的主機,已歸類的主機請從左側群組進入檢視。',
|
||||
'settings.appearance.tabBarPosition': '分頁列位置',
|
||||
'settings.appearance.tabBarPosition.top': '頂部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
|
||||
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
|
||||
'settings.vault.sftpInSidebar': '將 SFTP 放入側邊欄',
|
||||
'settings.vault.sftpInSidebarDesc': '隱藏頂部的 SFTP 分頁,改為在側邊欄顯示 SFTP,開啟 SFTP 時側邊欄保持可見,可直接切換到其他功能頁面。',
|
||||
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
|
||||
'settings.vault.showHostTreeSidebarDesc': '在終端和編輯器分頁顯示主機列表側欄及頂部開關。',
|
||||
'settings.vault.autoImportSystemKnownHosts': '自動匯入系統 known_hosts',
|
||||
@@ -549,7 +547,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.appearance.windowOpacity': '視窗透明度',
|
||||
'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。',
|
||||
'settings.appearance.appIcon': '應用程式圖示',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。',
|
||||
'settings.appearance.appIcon.original': '原始',
|
||||
'settings.appearance.appIcon.bright': '明亮',
|
||||
'settings.appearance.appIcon.dark': '深色',
|
||||
@@ -655,7 +653,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.nav.snippets': '程式碼片段',
|
||||
'vault.nav.notes': '筆記',
|
||||
'vault.nav.knownHosts': '已知主機',
|
||||
'vault.nav.sftp': 'SFTP',
|
||||
'vault.nav.logs': '日誌',
|
||||
|
||||
'proxyProfiles.action.add': '新增代理',
|
||||
@@ -1056,6 +1053,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transferCenter.prioritize': '優先傳輸',
|
||||
'sftp.transferCenter.pauseAll': '全部暫停',
|
||||
'sftp.transferCenter.resumeAll': '全部繼續',
|
||||
'sftp.transferCenter.cancelAll': '全部取消',
|
||||
'sftp.transferCenter.empty': '目前分類沒有傳輸工作',
|
||||
'sftp.transferCenter.showBackground': '顯示 {count} 個背景工作',
|
||||
'sftp.transferCenter.hideBackground': '隱藏背景工作',
|
||||
@@ -1066,7 +1064,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.goUp': '上一級',
|
||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||
'sftp.locatePathInTerminal': '定位路徑到終端',
|
||||
'sftp.refreshAfterCommand': '終端命令完成後重新整理',
|
||||
'sftp.followTerminalCwd': '追隨終端目錄',
|
||||
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
||||
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
||||
|
||||
@@ -27,6 +27,9 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.actions.runParallel': '在所有分頁並行執行',
|
||||
'scripts.actions.runSequential': '在所有分頁依序執行',
|
||||
'scripts.actions.runOnAllTabs': '在所有分頁執行',
|
||||
'scripts.actions.copyCommand': '複製指令',
|
||||
'scripts.actions.copyCommand.copied': '指令已複製',
|
||||
'scripts.actions.copyCommand.failed': '複製指令失敗',
|
||||
'scripts.actions.skippedConnectingSessions': '有 {count} 個分頁仍在連線中,已跳過',
|
||||
'scripts.actions.skippedSensitiveSessions': '有 {count} 個分頁處於密碼/敏感輸入,已跳過',
|
||||
'scripts.actions.noRunnableHosts': '沒有與指令碼目標相符的可連線主機',
|
||||
|
||||
@@ -31,6 +31,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
'systemManager.errors.loadServices': '載入 systemd 服務失敗',
|
||||
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
|
||||
'systemManager.unsupportedRemote': '此主機不允許額外的 SSH 執行通道,系統面板無法使用。開啟此面板不會再中斷目前終端。',
|
||||
|
||||
'systemManager.overview.empty': '暫無系統概覽資料。',
|
||||
'systemManager.overview.loading': '正在載入系統概覽…',
|
||||
|
||||
@@ -278,6 +278,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.font.weightBold.desc': '粗體文字字重 (100-900)',
|
||||
'settings.terminal.font.smoothing': '字型平滑',
|
||||
'settings.terminal.font.smoothing.desc': '使用 macOS 原生字型抗鋸齒',
|
||||
'settings.terminal.font.ligatures': '字型連字',
|
||||
'settings.terminal.font.ligatures.desc': '支援連字的字型把 != 這類字元顯示成一個符號',
|
||||
'settings.terminal.font.linePadding': '行間距',
|
||||
'settings.terminal.font.linePadding.desc': '行之間的額外間距 (0-10)',
|
||||
'settings.terminal.font.emulationType': '終端模擬型別',
|
||||
@@ -285,6 +287,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.cursor.style.block': '塊',
|
||||
'settings.terminal.cursor.style.bar': '豎線',
|
||||
'settings.terminal.cursor.style.underline': '下劃線',
|
||||
'settings.terminal.cursor.barWidth': '豎線游標粗細',
|
||||
'settings.terminal.cursor.barWidth.desc': 'vi 等應用程式使用豎線游標時的寬度(1-4 像素)',
|
||||
'settings.terminal.cursor.blink': '游標閃爍',
|
||||
'settings.terminal.cursor.highlightLine': '高亮目前行',
|
||||
'settings.terminal.cursor.highlightLine.desc': '為游標所在行加上淡色背景,方便定位目前輸入位置',
|
||||
@@ -344,7 +348,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
|
||||
'settings.terminal.behavior.shiftEnterNewlineText.desc':
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\\\ 表示反斜線。',
|
||||
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
|
||||
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
|
||||
'settings.terminal.behavior.shiftEnterForceText.desc':
|
||||
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同時清空捲動緩衝',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 指令同時清空捲動緩衝(POSIX 預設行為)。關閉則保留歷史。',
|
||||
|
||||
@@ -466,6 +466,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.composeBar.send': '傳送',
|
||||
'terminal.composeBar.close': '關閉撰寫欄',
|
||||
'terminal.composeBar.broadcasting': '正在廣播到所有工作階段',
|
||||
'terminal.composeBar.broadcastAllowPassword': '廣播時不對密碼進行保護',
|
||||
'terminal.composeBar.resize': '拖曳調整撰寫欄高度',
|
||||
'terminal.composeBar.manageSnippets': '管理快捷程式碼片段',
|
||||
'terminal.composeBar.searchSnippets': '搜尋程式碼片段...',
|
||||
@@ -537,6 +538,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.search.noResults': '無結果',
|
||||
'terminal.search.prevMatch': '上一個相符 (Shift+Enter)',
|
||||
'terminal.search.nextMatch': '下一個相符 (Enter)',
|
||||
'terminal.menu.cut': '剪下',
|
||||
'terminal.menu.copy': '複製',
|
||||
'terminal.menu.paste': '貼上',
|
||||
'terminal.menu.addSelectionToAI': '新增到對話',
|
||||
@@ -579,6 +581,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
|
||||
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
|
||||
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
|
||||
'terminal.auth.selectIdentity': '使用已儲存的身分',
|
||||
'terminal.auth.continueSave': '繼續並儲存',
|
||||
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某個跳板機的已儲存憑證無法在目前裝置解密,請到主機設定中重新填寫。',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
runAutomationScript,
|
||||
runConnectScriptsSequential,
|
||||
selectScriptOverlayRun,
|
||||
setScriptRuns,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from './scriptAutomationCoordinator.ts';
|
||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import type { Snippet } from '@/domain/models';
|
||||
import { STORAGE_KEY_AI_PERMISSION_MODE } from '@/infrastructure/config/storageKeys.ts';
|
||||
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
|
||||
|
||||
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
|
||||
@@ -246,3 +248,127 @@ test('runConnectScriptsSequential does not treat a script error named Aborted as
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
function installPermissionMode(mode: string) {
|
||||
const storage = new Map<string, string>([[STORAGE_KEY_AI_PERMISSION_MODE, mode]]);
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
|
||||
removeItem: (key: string) => { storage.delete(key); },
|
||||
clear: () => { storage.clear(); },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const writeScript: Snippet = {
|
||||
id: 'write-script',
|
||||
label: 'Write',
|
||||
command: "await nct.screen.sendLine('echo hi');",
|
||||
kind: 'script',
|
||||
};
|
||||
|
||||
test('runAutomationScript executes a user write script while AI permission mode is observer', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let seenMode: string | undefined;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
seenMode = params.permissionMode;
|
||||
return { runId: 'user-run', runIds: ['user-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
const result = await runAutomationScript({
|
||||
snippet: writeScript,
|
||||
sessionId: 'sess-user',
|
||||
initiatedBy: 'user',
|
||||
});
|
||||
assert.equal(result.runId, 'user-run');
|
||||
assert.equal(seenMode, 'auto');
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript blocks automatic write scripts in observer mode', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let called = false;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async () => {
|
||||
called = true;
|
||||
return { runId: 'automatic-run', runIds: ['automatic-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runAutomationScript({ snippet: writeScript, sessionId: 'sess-automatic' }),
|
||||
/Observer mode blocks scripts that write to the terminal/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript blocks an AI write script in observer mode', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let called = false;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async () => {
|
||||
called = true;
|
||||
return { runId: 'ai-run', runIds: ['ai-run'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => runAutomationScript({
|
||||
snippet: writeScript,
|
||||
sessionId: 'sess-ai',
|
||||
initiatedBy: 'ai',
|
||||
}),
|
||||
/Observer mode blocks scripts that write to the terminal/,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
|
||||
test('runAutomationScript keeps observer on an AI read-only script so later writes stay blocked', async () => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
installPermissionMode('observer');
|
||||
let seenMode: string | undefined;
|
||||
netcattyBridge.get = () => ({
|
||||
scriptRun: async (params) => {
|
||||
seenMode = params.permissionMode;
|
||||
return { runId: 'ai-read', runIds: ['ai-read'] };
|
||||
},
|
||||
}) as ReturnType<typeof netcattyBridge.get>;
|
||||
|
||||
try {
|
||||
await runAutomationScript({
|
||||
snippet: {
|
||||
id: 'read-script',
|
||||
label: 'Read',
|
||||
command: "await nct.screen.waitForPrompt(1000);",
|
||||
kind: 'script',
|
||||
},
|
||||
sessionId: 'sess-ai-read',
|
||||
initiatedBy: 'ai',
|
||||
});
|
||||
assert.equal(seenMode, 'observer');
|
||||
} finally {
|
||||
netcattyBridge.get = originalGet;
|
||||
Reflect.deleteProperty(globalThis, 'localStorage');
|
||||
}
|
||||
});
|
||||
@@ -95,8 +95,10 @@ export async function runAutomationScript(params: {
|
||||
hostname?: string;
|
||||
username?: string;
|
||||
};
|
||||
/** Only a direct user action bypasses Observer; AI and automatic runs honor it. */
|
||||
initiatedBy?: 'user' | 'ai';
|
||||
}): Promise<{ runId: string; runIds: string[] }> {
|
||||
const permissionMode = readPermissionMode();
|
||||
const permissionMode = params.initiatedBy === 'user' ? 'auto' : readPermissionMode();
|
||||
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
|
||||
throw new Error('Observer mode blocks scripts that write to the terminal.');
|
||||
}
|
||||
|
||||
@@ -23,9 +23,7 @@ export type SettingsChromeSnapshot = {
|
||||
darkUiThemeId: string;
|
||||
uiLanguage: UILanguage;
|
||||
windowOpacity: number;
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
showSftpTab: boolean;
|
||||
sftpInSidebar: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
showRecentHosts: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
@@ -59,9 +57,7 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
darkUiThemeId: 'default',
|
||||
uiLanguage: 'en',
|
||||
windowOpacity: 1,
|
||||
tabBarPosition: 'top',
|
||||
showSftpTab: true,
|
||||
sftpInSidebar: false,
|
||||
showHostTreeSidebar: true,
|
||||
showRecentHosts: true,
|
||||
hostClickBehavior: 'connect',
|
||||
@@ -88,9 +84,7 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& a.darkUiThemeId === b.darkUiThemeId
|
||||
&& a.uiLanguage === b.uiLanguage
|
||||
&& a.windowOpacity === b.windowOpacity
|
||||
&& a.tabBarPosition === b.tabBarPosition
|
||||
&& a.showSftpTab === b.showSftpTab
|
||||
&& a.sftpInSidebar === b.sftpInSidebar
|
||||
&& a.showHostTreeSidebar === b.showHostTreeSidebar
|
||||
&& a.showRecentHosts === b.showRecentHosts
|
||||
&& a.hostClickBehavior === b.hostClickBehavior
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -45,7 +44,6 @@ import {
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
|
||||
@@ -104,7 +102,6 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
@@ -153,7 +150,6 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -310,9 +306,6 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -379,7 +372,6 @@ export function useSettingsIpcSync({
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
|
||||
@@ -80,7 +80,6 @@ export { DEFAULT_HOST_CLICK_BEHAVIOR } from '../../domain/hostClickBehavior';
|
||||
export type { HostClickBehavior } from '../../domain/hostClickBehavior';
|
||||
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
|
||||
export const DEFAULT_SHOW_SFTP_TAB = true;
|
||||
export const DEFAULT_SFTP_IN_SIDEBAR = false;
|
||||
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
|
||||
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
|
||||
export const DEFAULT_SHOW_TAB_NUMBER_BADGES = true;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -35,8 +34,6 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SFTP_IN_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -95,9 +92,7 @@ interface UseSettingsStorageSyncParams {
|
||||
showRecentHosts: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
tabBarPosition: TabBarPosition;
|
||||
showSftpTab: boolean;
|
||||
sftpInSidebar: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
|
||||
@@ -148,9 +143,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setHostClickBehaviorState: Dispatch<SetStateAction<HostClickBehavior>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpInSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
@@ -189,7 +182,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -198,7 +191,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setSftpInSidebarState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -213,7 +206,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -223,7 +216,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -444,21 +437,12 @@ export function useSettingsStorageSync({
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(e.newValue));
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
setShowSftpTabState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SFTP_IN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.sftpInSidebar) {
|
||||
setSftpInSidebarState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showHostTreeSidebar) {
|
||||
@@ -610,7 +594,7 @@ export function useSettingsStorageSync({
|
||||
setLocalShellSidePanelAutoOpenState,
|
||||
setLocalShellSidePanelAutoOpenTabState,
|
||||
setShowRecentHostsState,
|
||||
setTabBarPositionState, setShowSftpTabState, setSftpInSidebarState,
|
||||
setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
|
||||
test("isMissingStatError accepts only true path absence codes", () => {
|
||||
for (const code of [2, "ENOENT", "NO_SUCH_FILE", "SSH_FX_NO_SUCH_FILE"] as const) {
|
||||
@@ -64,3 +64,15 @@ test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("unwrapSftpIpcError strips the Electron IPC wrapper", () => {
|
||||
assert.equal(
|
||||
unwrapSftpIpcError(
|
||||
new Error("Error invoking remote method 'netcatty:sftp:delete': Error: Permission denied"),
|
||||
),
|
||||
"Permission denied",
|
||||
);
|
||||
assert.equal(unwrapSftpIpcError(new Error("Permission denied")), "Permission denied");
|
||||
assert.equal(unwrapSftpIpcError("channel closed"), "channel closed");
|
||||
});
|
||||
@@ -18,4 +18,13 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const SFTP_IPC_ERROR_PREFIX = /^Error invoking remote method 'netcatty:[^']+':\s*/i;
|
||||
|
||||
/** Drop Electron IPC wrapper so dialogs can show the server error. */
|
||||
export function unwrapSftpIpcError(err: unknown): string {
|
||||
const raw = err instanceof Error ? err.message : String(err ?? "");
|
||||
const unwrapped = raw.replace(SFTP_IPC_ERROR_PREFIX, "").replace(/^Error:\s*/, "").trim();
|
||||
return unwrapped || raw;
|
||||
}
|
||||
|
||||
export { isMissingStatError } from "../../../domain/sftpStatError";
|
||||
@@ -520,3 +520,24 @@ test("folder resume releases a completed child compacted while pause was drainin
|
||||
assert.equal(resumed.handled, true);
|
||||
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
|
||||
});
|
||||
|
||||
|
||||
test("late pause acknowledgement cannot restart a cancelling stream before settlement", async (t) => {
|
||||
const { markTransferCancelledTree, resetTransferCancelLatchesForTests } = await import("./transferCancelLatch");
|
||||
const { bumpTransferControlEpoch } = await import("./transferControlEpoch");
|
||||
const { releaseTransferPauseTree } = await import("./transferPauseLatch");
|
||||
t.after(() => { resetTransferCancelLatchesForTests(); resetTransferPauseLatchesForTests(); });
|
||||
let finishPause!: (value: { success: boolean }) => void;
|
||||
let resumes = 0;
|
||||
const { host } = createHost([makeTask("cancel-during-pause")], () => ({
|
||||
pauseTransfer: () => new Promise((resolve) => { finishPause = resolve; }),
|
||||
resumeTransfer: async () => { resumes += 1; return { success: true }; },
|
||||
}));
|
||||
const pause = softPauseTransfer(host, "cancel-during-pause");
|
||||
markTransferCancelledTree("cancel-during-pause");
|
||||
bumpTransferControlEpoch("cancel-during-pause");
|
||||
releaseTransferPauseTree("cancel-during-pause");
|
||||
finishPause({ success: true });
|
||||
await pause;
|
||||
assert.equal(resumes, 0);
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
latchTransferPauseTree,
|
||||
releaseTransferPauseTree,
|
||||
} from "./transferPauseLatch";
|
||||
import { isTransferOrRootCancelled } from "./transferCancelLatch";
|
||||
import { isTransferWalkInFlight } from "./transferWalkRegistry";
|
||||
|
||||
export type TransferControlBridge = {
|
||||
@@ -72,7 +73,7 @@ export function reconcileSupersededControls(
|
||||
requestedAction: "pause" | "resume",
|
||||
controlTaskId = taskId,
|
||||
): void {
|
||||
if (!isTransferControlEpochCurrent(controlTaskId, epoch)) return;
|
||||
if (!isTransferControlEpochCurrent(controlTaskId, epoch) || isTransferOrRootCancelled(taskId)) 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") => {
|
||||
@@ -234,7 +235,7 @@ export async function softPauseTransfer(
|
||||
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;
|
||||
if (isTransferOrRootPauseLatched(taskId, id) || isTransferOrRootCancelled(taskId, id)) return;
|
||||
try { await bridge!.resumeTransfer?.(id); } catch { /* best-effort */ }
|
||||
};
|
||||
const pauseOne = async (id: string) => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { isValidDirectoryResumeCheckpoint } from "../../../domain/sftpDirectoryCheckpoint";
|
||||
|
||||
/** Apply only after backend cancellation AND the old walk have settled. */
|
||||
export function settleCancelledTransferTree(
|
||||
tasks: readonly TransferTask[],
|
||||
rootId: string,
|
||||
cancelledIds: ReadonlySet<string>,
|
||||
failedIds: ReadonlySet<string>,
|
||||
retainedTasks: readonly TransferTask[] = [],
|
||||
): TransferTask[] {
|
||||
const existingTasks = new Map(tasks.map((task) => [task.id, task]));
|
||||
// Use observed terminal outcomes rather than stale pre-cancel snapshots.
|
||||
// Missing completed children already covered by the parent stay compacted;
|
||||
// cancelled/failed exceptions still need their rows for the recovery walk.
|
||||
const settledTasks = failedIds.size > 0
|
||||
? [...tasks, ...retainedTasks.filter((task) => {
|
||||
if (existingTasks.has(task.id)) return false;
|
||||
const checkpoint = task.parentTaskId
|
||||
? existingTasks.get(task.parentTaskId)?.directoryResumeCheckpoint
|
||||
: undefined;
|
||||
return !(task.status === "completed"
|
||||
&& isValidDirectoryResumeCheckpoint(checkpoint)
|
||||
&& Number.isSafeInteger(task.directoryEntryIndex)
|
||||
&& (task.directoryEntryIndex ?? -1) >= 0
|
||||
&& task.directoryEntryIndex! < checkpoint.coveredEntries);
|
||||
})]
|
||||
: tasks;
|
||||
return settledTasks.map((task) => {
|
||||
// Natural completion/failure wins a race with Cancel. Rows cancelled by
|
||||
// this attempt can be re-admitted only when the whole tree needs recovery.
|
||||
if (!cancelledIds.has(task.id) || task.status === "completed" || task.status === "failed") return task;
|
||||
const failed = failedIds.has(task.id) || (task.id === rootId && failedIds.size > 0);
|
||||
const recoverable = failedIds.size > 0;
|
||||
return {
|
||||
...task,
|
||||
status: failed ? "attention" : recoverable ? "interrupted" : "cancelled",
|
||||
error: failed ? "Could not cancel transfer. Please try again." : undefined,
|
||||
reconnectRequired: recoverable,
|
||||
endTime: recoverable ? undefined : Date.now(),
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
conflict: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { createSftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./transferCancelLatch";
|
||||
import { createTransferRuntime, resetTransferRuntimeRunsForTests } from "./transferRuntime";
|
||||
import { resetTransferWalkRegistryForTests } from "./transferWalkRegistry";
|
||||
|
||||
const task = (id: string, extra: Partial<TransferTask> = {}): TransferTask => ({
|
||||
id, fileName: id, sourcePath: `/source/${id}`, targetPath: `/target/${id}`,
|
||||
sourceConnectionId: "local", targetConnectionId: "fixture", direction: "upload",
|
||||
status: "transferring", totalBytes: 8, transferredBytes: 0, speed: 0,
|
||||
startTime: 1, isDirectory: false, resumable: true, ...extra,
|
||||
});
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
test("failed cancellation waits for the old walk, then Resume restores scheduler-rejected file bytes", async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "netcatty-cancel-"));
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(async () => {
|
||||
netcattyBridge.get = originalGet;
|
||||
resetTransferCancelLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
resetTransferRuntimeRunsForTests();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const store = createSftpTransferCenterStore();
|
||||
const runtime = createTransferRuntime(store);
|
||||
const ids = ["active", "queued-1", "queued-2"];
|
||||
const expected = ids.map((id) => Buffer.from(`bytes-of-${id}`));
|
||||
const children = ids.map((id) => task(id, { parentTaskId: "folder", targetPath: join(root, id) }));
|
||||
store.publishOwner("closed-panel", [task("folder", { isDirectory: true, targetHostId: "fixture-host" }), ...children]);
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
t.after(() => release());
|
||||
let softResumeCalls = 0;
|
||||
let pauseCalls = 0;
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => ({ success: id !== "active" }),
|
||||
pauseTransfer: async () => { pauseCalls += 1; return { success: true }; },
|
||||
clearPendingTransferCancel: async () => undefined,
|
||||
resumeTransfer: async () => { softResumeCalls += 1; return { success: true }; },
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
let starts = 0;
|
||||
const walking = runtime.runWalk("folder", async () => {
|
||||
await Promise.all(children.map((child, index) => globalSftpTransferScheduler.run(
|
||||
"closed-panel", child.id, ["cancel-fixture"], () => 1, async () => {
|
||||
starts += 1;
|
||||
await held;
|
||||
await writeFile(child.targetPath, expected[index]);
|
||||
store.patchTask(child.id, { status: "completed" });
|
||||
},
|
||||
).catch(() => store.patchTask(child.id, { status: "cancelled" }))));
|
||||
store.patchTask("folder", { status: "cancelled" });
|
||||
});
|
||||
await tick();
|
||||
assert.equal(starts, 1);
|
||||
let cancelSettled = false;
|
||||
const cancelling = store.cancel("folder").then(() => { cancelSettled = true; });
|
||||
const resuming = store.resume("folder");
|
||||
await tick();
|
||||
assert.equal(cancelSettled, true, "failed cancel must return promptly so the user can retry");
|
||||
assert.equal(store.getTask("folder")?.status, "attention");
|
||||
assert.equal(isTransferOrRootCancelled("folder"), true);
|
||||
assert.equal(softResumeCalls, 0, "must not unlatch the old walk");
|
||||
await store.pause("folder");
|
||||
assert.equal(pauseCalls, 0, "a late row-level Pause must not park the cancelling walk");
|
||||
let freshWalks = 0;
|
||||
store.setDedicatedResumeHandler(async () => {
|
||||
freshWalks += 1;
|
||||
assert.equal(runtime.isWalkInFlight("folder"), false);
|
||||
for (let index = 0; index < children.length; index += 1) {
|
||||
const child = children[index];
|
||||
if (store.getTask(child.id)?.status === "completed") continue;
|
||||
assert.equal(store.admitTaskRun(child), "ready");
|
||||
await writeFile(child.targetPath, expected[index]);
|
||||
store.patchTask(child.id, { status: "completed" });
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
release();
|
||||
await Promise.all([walking, cancelling, resuming]);
|
||||
assert.equal(freshWalks, 1);
|
||||
assert.equal(starts, 1, "cancelled queue callbacks cannot silently count as transferred files");
|
||||
assert.equal(store.getTask("folder")?.status, "completed");
|
||||
for (let index = 0; index < children.length; index += 1) {
|
||||
assert.deepEqual(await readFile(children[index].targetPath), expected[index]);
|
||||
}
|
||||
});
|
||||
|
||||
test("failed ownerless compression cancellation retries the same backend", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
|
||||
const store = createSftpTransferCenterStore();
|
||||
const calls: string[] = [];
|
||||
netcattyBridge.get = () => ({
|
||||
cancelCompressedUpload: async (id: string) => {
|
||||
calls.push(`compressed:${id}`);
|
||||
return { success: calls.length > 1 };
|
||||
},
|
||||
cancelTransfer: async (id: string) => { calls.push(`stream:${id}`); return { success: true }; },
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
store.publishOwner("closed-panel", [task("archive", { controlKind: "compressed-upload" })]);
|
||||
await store.cancel("archive");
|
||||
assert.equal(store.getTask("archive")?.status, "attention");
|
||||
await store.cancel("archive");
|
||||
assert.deepEqual(calls, ["compressed:archive", "compressed:archive"]);
|
||||
assert.equal(store.getTask("archive")?.status, "cancelled");
|
||||
});
|
||||
|
||||
test("late completion survives cancellation, while an unrelated paused batch peer stays paused", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
|
||||
const store = createSftpTransferCenterStore();
|
||||
const calls: string[] = [];
|
||||
store.publishOwner("gone", [task("selected", { batchId: "batch" }), task("peer", { batchId: "batch", status: "paused" })]);
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => {
|
||||
calls.push(id);
|
||||
store.patchTask(id, { status: "completed" });
|
||||
return { success: true };
|
||||
},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
await store.cancel("selected");
|
||||
assert.deepEqual(calls, ["selected"]);
|
||||
assert.equal(store.getTask("selected")?.status, "completed");
|
||||
assert.equal(store.getTask("peer")?.status, "paused");
|
||||
});
|
||||
|
||||
|
||||
test("Resume cannot revive a tree pre-latched by an upcoming Cancel all batch", async (t) => {
|
||||
const { markTransferCancelledTree } = await import("./transferCancelLatch");
|
||||
t.after(resetTransferCancelLatchesForTests);
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.publishOwner("gone", [task("waiting-cancel-batch", { status: "paused", isDirectory: true })]);
|
||||
markTransferCancelledTree("waiting-cancel-batch");
|
||||
await store.resume("waiting-cancel-batch");
|
||||
assert.equal(store.getTask("waiting-cancel-batch")?.status, "paused");
|
||||
assert.equal(isTransferOrRootCancelled("waiting-cancel-batch"), true);
|
||||
});
|
||||
|
||||
|
||||
test("failed cancel can be retried while its old walk is still running", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
|
||||
const store = createSftpTransferCenterStore();
|
||||
const runtime = createTransferRuntime(store);
|
||||
store.publishOwner("gone", [task("retry-live-cancel", { isDirectory: true })]);
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
t.after(() => release());
|
||||
const walking = runtime.runWalk("retry-live-cancel", async () => { await held; });
|
||||
let attempts = 0;
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return { success: false };
|
||||
release();
|
||||
return { success: true };
|
||||
},
|
||||
cleanupTransferArtifacts: async () => undefined,
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
await store.cancel("retry-live-cancel");
|
||||
assert.equal(runtime.isWalkInFlight("retry-live-cancel"), true);
|
||||
assert.equal(store.getTask("retry-live-cancel")?.status, "attention");
|
||||
const resume = store.resume("retry-live-cancel");
|
||||
await store.cancel("retry-live-cancel");
|
||||
await Promise.all([walking, resume]);
|
||||
assert.equal(attempts, 2, "second cancel must reach the backend before natural completion");
|
||||
assert.equal(store.getTask("retry-live-cancel")?.status, "cancelled", "new successful cancel owns final outcome");
|
||||
});
|
||||
|
||||
|
||||
test("large orphan recovery clears pending cancellations in bounded concurrent batches", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.publishOwner("gone", [task("cleanup-root", { isDirectory: true }),
|
||||
...Array.from({ length: 2200 }, (_, index) => task(`cleanup-${index}`, { parentTaskId: "cleanup-root" }))]);
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
let cleared = 0;
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => ({ success: id !== "cleanup-0" }),
|
||||
clearPendingTransferCancel: async () => {
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
active -= 1;
|
||||
cleared += 1;
|
||||
},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
await store.cancel("cleanup-root");
|
||||
assert.equal(cleared, 2201);
|
||||
assert.ok(peak > 1, "recovery must not serialize one IPC per row");
|
||||
assert.ok(peak <= 32, "recovery uses the cancellation batch bound");
|
||||
});
|
||||
|
||||
for (const completeDuring of ["backend-cancel", "walk-settlement"] as const) {
|
||||
test(`failed cancellation preserves compacted completion during ${completeDuring} on dedicated resume`, async (t) => {
|
||||
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
|
||||
const { createDirectoryEntryIdentity } = await import("../../../domain/sftpDirectoryCheckpoint");
|
||||
const { resumeTransferWithDedicatedSession, resetDedicatedSessionOpenGateForTests } =
|
||||
await import("./dedicatedTransferResume");
|
||||
const originalGet = netcattyBridge.get;
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
});
|
||||
const parent = task(`compacted-cancel-${completeDuring}`, {
|
||||
sourcePath: "/source/folder", targetPath: "/target/folder", targetHostId: "fixture-host",
|
||||
isDirectory: true, progressMode: "files", totalBytes: 3,
|
||||
});
|
||||
const children = ["completed", "cancelled", "cancel-failed"].map((name, index) => {
|
||||
const child = task(`${parent.id}-${name}`, {
|
||||
parentTaskId: parent.id, fileName: name,
|
||||
sourcePath: `${parent.sourcePath}/${name}`, targetPath: `${parent.targetPath}/${name}`,
|
||||
sourceLastModified: 1, directoryEntryIndex: index,
|
||||
});
|
||||
return { ...child, directoryEntryIdentity: createDirectoryEntryIdentity({
|
||||
sourcePath: child.sourcePath, targetPath: child.targetPath, size: child.totalBytes, lastModified: 1,
|
||||
}) };
|
||||
});
|
||||
// Match the sorted traversal order so dedicated resume validates the actual checkpoint.
|
||||
children.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
|
||||
children.forEach((child, index) => { child.directoryEntryIndex = index; });
|
||||
const completed = children.find((child) => child.fileName === "completed")!;
|
||||
const cancelled = children.find((child) => child.fileName === "cancelled")!;
|
||||
const failed = children.find((child) => child.fileName === "cancel-failed")!;
|
||||
const runtime = createTransferRuntime(store);
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
let walking: Promise<unknown> | undefined;
|
||||
t.after(async () => {
|
||||
release();
|
||||
await walking;
|
||||
store.setDedicatedResumeHandler(null);
|
||||
store.patchTask(parent.id, { status: "completed" });
|
||||
store.dismiss(parent.id);
|
||||
netcattyBridge.get = originalGet;
|
||||
resetTransferCancelLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
resetTransferRuntimeRunsForTests();
|
||||
resetDedicatedSessionOpenGateForTests();
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
const uploads: string[] = [];
|
||||
const progress: number[] = [];
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => {
|
||||
if (completeDuring === "backend-cancel" && id === completed.id) {
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: id, transferred: 8, totalBytes: 8 });
|
||||
}
|
||||
return { success: id !== failed.id };
|
||||
},
|
||||
clearPendingTransferCancel: async () => undefined,
|
||||
openSftp: async () => "reconnected-sftp", closeSftp: async () => {},
|
||||
listLocalTree: async () => children.map((child) => ({
|
||||
localPath: child.sourcePath, relativePath: child.fileName,
|
||||
type: "file", size: 8, lastModified: 1,
|
||||
})),
|
||||
mkdirSftp: async () => {},
|
||||
statLocal: async () => ({ size: 8, lastModified: 1 }),
|
||||
startStreamTransfer: async (options: { sourcePath: string; checkpointBytes: number }) => {
|
||||
if (options.sourcePath === cancelled.sourcePath) assert.equal(options.checkpointBytes, 4);
|
||||
uploads.push(options.sourcePath);
|
||||
return {};
|
||||
},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
store.publishOwner("closed-panel", [parent, ...children]);
|
||||
walking = runtime.runWalk(parent.id, async () => {
|
||||
await held;
|
||||
if (completeDuring === "walk-settlement") {
|
||||
store.ingestBackgroundEvent({ type: "completed", transferId: completed.id, transferred: 8, totalBytes: 8 });
|
||||
}
|
||||
store.patchTask(cancelled.id, { status: "cancelled", checkpointBytes: 4 });
|
||||
store.patchTask(parent.id, { status: "cancelled" });
|
||||
});
|
||||
await store.cancel(parent.id);
|
||||
assert.equal(runtime.isWalkInFlight(parent.id), true);
|
||||
store.setDedicatedResumeHandler(async (root) => {
|
||||
assert.equal(root.directoryResumeCheckpoint?.completedEntries, 1);
|
||||
assert.equal(store.getTask(completed.id), undefined, "the finished child stays compacted");
|
||||
assert.equal(store.getTask(cancelled.id)?.status, "interrupted");
|
||||
assert.equal(store.getTask(failed.id)?.status, "attention");
|
||||
return resumeTransferWithDedicatedSession(root, {
|
||||
hosts: [{ id: "fixture-host", label: "fixture", hostname: "fixture", port: 22,
|
||||
username: "test", authMethod: "password", protocol: "ssh", tags: [], os: "linux" }],
|
||||
keys: [], identities: [],
|
||||
}, (value) => { progress.push(value.transferred); }, {
|
||||
children: store.getSnapshot().tasks.filter((child) => child.parentTaskId === parent.id),
|
||||
onChildUpdate: (child) => { store.upsertTasks([child]); },
|
||||
onDirectoryCheckpointUpdate: (checkpoint) => store.patchTask(parent.id, { directoryResumeCheckpoint: checkpoint }),
|
||||
});
|
||||
});
|
||||
const resuming = store.resume(parent.id);
|
||||
release();
|
||||
await Promise.all([walking, resuming]);
|
||||
assert.deepEqual(uploads.sort(), [cancelled.sourcePath, failed.sourcePath].sort(),
|
||||
"only cancelled and failed-to-cancel children should upload again");
|
||||
assert.equal(store.getTask(parent.id)?.status, "completed");
|
||||
assert.ok(progress.every((count) => count <= 3), "compacted completion must count exactly once");
|
||||
assert.equal(progress.at(-1), 3);
|
||||
});
|
||||
}
|
||||
|
||||
for (const rejects of [false, true]) {
|
||||
test(`failed live compression cancellation resumes the same compression job: rejects=${rejects}`, async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; resetTransferCancelLatchesForTests(); });
|
||||
const store = createSftpTransferCenterStore();
|
||||
const calls: string[] = [];
|
||||
netcattyBridge.get = () => ({
|
||||
cancelCompressedUpload: async () => {
|
||||
if (rejects) throw new Error("Cancel IPC failed");
|
||||
return { success: false };
|
||||
},
|
||||
resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; },
|
||||
resumeTransfer: async () => { calls.push("generic"); return { success: false, reason: "not active" }; },
|
||||
clearPendingTransferCancel: async () => {},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
store.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; });
|
||||
store.publishOwner("closed-panel", [task("live-compressed", {
|
||||
isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false,
|
||||
})]);
|
||||
await store.cancel("live-compressed");
|
||||
assert.equal(store.getTask("live-compressed")?.status, "attention");
|
||||
await store.resume("live-compressed");
|
||||
assert.deepEqual(calls, ["compressed"], "failed cancellation must not start a second destination writer");
|
||||
assert.equal(store.getTask("live-compressed")?.status, "transferring");
|
||||
});
|
||||
}
|
||||
|
||||
for (const hasConflict of [false, true]) {
|
||||
test(`restored compressed attention uses reconnect and preserves conflict gating: conflict=${hasConflict}`, async (t) => {
|
||||
const { serializeSftpTransferCenter } = await import("../../../domain/sftpTransferCenter");
|
||||
const { listGloballyResumableTransferIds } = await import("../../../domain/sftpTransferActions");
|
||||
const { restoreSftpTransferHistoryCooperatively } = await import("./transferHistoryRestoreMigration");
|
||||
const originalGet = netcattyBridge.get;
|
||||
t.after(() => { netcattyBridge.get = originalGet; });
|
||||
const saved = task("restored-compressed", {
|
||||
status: "attention", isDirectory: true, controlKind: "compressed-upload", reconnectRequired: false,
|
||||
error: "Could not cancel the compressed upload.",
|
||||
...(hasConflict ? { conflict: {
|
||||
transferId: "restored-compressed", fileName: "folder", sourcePath: "/source/folder", targetPath: "/target/folder",
|
||||
isDirectory: true, existingSize: 8, newSize: 8, existingModified: 1, newModified: 1,
|
||||
} } : {}),
|
||||
});
|
||||
const raw = serializeSftpTransferCenter([saved]);
|
||||
const restored = createSftpTransferCenterStore({ read: () => raw, write: () => {} });
|
||||
const cooperative = await restoreSftpTransferHistoryCooperatively(JSON.stringify({ version: 1, tasks: [saved] }));
|
||||
assert.equal(restored.getTask(saved.id)?.reconnectRequired, true);
|
||||
assert.equal(cooperative.tasks[0]?.reconnectRequired, true);
|
||||
assert.equal(restored.getTask(saved.id)?.status, "attention");
|
||||
assert.deepEqual(restored.getTask(saved.id)?.conflict, saved.conflict);
|
||||
assert.equal(saved.reconnectRequired, false, "saving must not reclassify the current live row");
|
||||
const calls: string[] = [];
|
||||
netcattyBridge.get = () => ({
|
||||
resumeCompressedUpload: async () => { calls.push("compressed"); return { success: true }; },
|
||||
clearPendingTransferCancel: async () => {},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
restored.setDedicatedResumeHandler(async () => { calls.push("dedicated"); return { success: true }; });
|
||||
const resumeIds = listGloballyResumableTransferIds(restored.getSnapshot().tasks);
|
||||
assert.deepEqual(resumeIds, hasConflict ? [] : [saved.id]);
|
||||
for (const id of resumeIds) await restored.resume(id);
|
||||
assert.deepEqual(calls, hasConflict ? [] : ["dedicated"]);
|
||||
});
|
||||
}
|
||||
@@ -972,13 +972,10 @@ export function useSftpDirectoryTransferOps({
|
||||
if (isTransferCancelledError(err)) {
|
||||
// Keep cancelled status; do not rethrow — other workers must finish
|
||||
// and the parent should not become a clean completed tree.
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === fileId
|
||||
? { ...t, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
|
||||
: t,
|
||||
),
|
||||
);
|
||||
setTransfers((prev) => prev.map((task) =>
|
||||
task.id === fileId
|
||||
? { ...task, status: "cancelled" as TransferStatus, error: undefined, endTime: Date.now() }
|
||||
: task));
|
||||
errors.push(err instanceof Error ? err : new Error(message));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface TransferRuntime {
|
||||
* walk is dead / reconnectRequired. Single external operation.
|
||||
*/
|
||||
resume(taskId: string): Promise<void>;
|
||||
/** Wait for backend cancellation requests; Resume remains fenced on walk settlement. */
|
||||
cancel(taskId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,21 +71,24 @@ export function useSftpTransferTaskOps({
|
||||
const bridge = netcattyBridge.get();
|
||||
const cancelTransferAtBackend = bridge?.cancelTransfer;
|
||||
const cancelCompressedUpload = bridge?.cancelCompressedUpload;
|
||||
if (!cancelTransferAtBackend && !cancelCompressedUpload) return;
|
||||
if (!cancelTransferAtBackend && !cancelCompressedUpload) return [];
|
||||
|
||||
await Promise.all(
|
||||
Array.from(idsToCancel).map(async (id) => {
|
||||
const failed: string[] = [];
|
||||
const ids = [...idsToCancel];
|
||||
for (let offset = 0; offset < ids.length; offset += 32) {
|
||||
await Promise.all(ids.slice(offset, offset + 32).map(async (id) => {
|
||||
const candidate = currentTransfers.find((task) => task.id === id);
|
||||
const compressed = candidate?.controlKind === "compressed-upload";
|
||||
const operation = compressed
|
||||
? cancelCompressedUpload?.(id)
|
||||
: cancelTransferAtBackend?.(id);
|
||||
const results = operation ? await Promise.allSettled([operation]) : [];
|
||||
if (results.some((result) => result.status === "rejected")) {
|
||||
logger.warn("Failed to cancel one or more transfer backends");
|
||||
try {
|
||||
const result = await (compressed ? cancelCompressedUpload?.(id) : cancelTransferAtBackend?.(id));
|
||||
if (result && result.success === false) failed.push(id);
|
||||
} catch {
|
||||
failed.push(id);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}));
|
||||
}
|
||||
if (failed.length > 0) logger.warn("Failed to cancel one or more transfer backends");
|
||||
return failed;
|
||||
}, [activeChildIdsRef, cancelledTasksRef, transfersRef]);
|
||||
|
||||
const markBatchStopped = useCallback(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
const inFlightRootIds = new Set<string>();
|
||||
const settlementWaiters = new Map<string, Set<() => void>>();
|
||||
|
||||
export function registerTransferWalk(rootTaskId: string): void {
|
||||
inFlightRootIds.add(rootTaskId);
|
||||
@@ -13,15 +14,28 @@ export function registerTransferWalk(rootTaskId: string): void {
|
||||
|
||||
export function unregisterTransferWalk(rootTaskId: string): void {
|
||||
inFlightRootIds.delete(rootTaskId);
|
||||
const waiters = settlementWaiters.get(rootTaskId);
|
||||
settlementWaiters.delete(rootTaskId);
|
||||
for (const resolve of waiters ?? []) resolve();
|
||||
}
|
||||
|
||||
export function isTransferWalkInFlight(rootTaskId: string): boolean {
|
||||
return inFlightRootIds.has(rootTaskId);
|
||||
}
|
||||
|
||||
/** Wait for the current walk to finish; cancellation must not resume a dying walk. */
|
||||
export function waitForTransferWalkSettled(rootTaskId: string): Promise<void> {
|
||||
if (!inFlightRootIds.has(rootTaskId)) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const waiters = settlementWaiters.get(rootTaskId) ?? new Set();
|
||||
waiters.add(resolve);
|
||||
settlementWaiters.set(rootTaskId, waiters);
|
||||
});
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetTransferWalkRegistryForTests(): void {
|
||||
inFlightRootIds.clear();
|
||||
for (const id of inFlightRootIds) unregisterTransferWalk(id);
|
||||
}
|
||||
|
||||
export function listTransferWalksForTests(): string[] {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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 { publishTerminalCommandCompletion } from '../terminalCommandCompletion';
|
||||
import { useSftpCommandRefresh } from './useSftpCommandRefresh';
|
||||
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
||||
|
||||
test('completion refresh is coalesced, visible, idle and bound to its original pane', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let count = 0;
|
||||
let options = {
|
||||
sessionId: 'terminal-1', enabled: true, visible: true, busy: false,
|
||||
matchesTerminal: true, paneId: 'pane-1', connectionId: 'connection-1',
|
||||
path: '/browsed', connected: true, refresh: async () => { count += 1; },
|
||||
};
|
||||
function Probe() { useSftpCommandRefresh(options); return null; }
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const update = async (patch: Partial<typeof options>) => {
|
||||
options = { ...options, ...patch };
|
||||
await act(async () => { renderer.update(React.createElement(Probe)); });
|
||||
};
|
||||
const finish = async () => { await act(async () => { t.mock.timers.tick(250); }); };
|
||||
publishTerminalCommandCompletion('another-terminal');
|
||||
await finish();
|
||||
assert.equal(count, 0);
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
for (const patch of [{ enabled: false }, { visible: false }, { connected: false }, { matchesTerminal: false }]) {
|
||||
await update(patch);
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
await update({ enabled: true, visible: true, busy: false, connected: true, matchesTerminal: true });
|
||||
}
|
||||
await update({ busy: true });
|
||||
publishTerminalCommandCompletion('terminal-1');
|
||||
await finish();
|
||||
assert.equal(count, 1);
|
||||
await update({ busy: false });
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
for (const patch of [{ path: '/new' }, { paneId: 'pane-2' }, { connectionId: 'connection-2' }, { sessionId: 'terminal-2' }]) {
|
||||
publishTerminalCommandCompletion(options.sessionId);
|
||||
await update(patch);
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
}
|
||||
publishTerminalCommandCompletion(options.sessionId);
|
||||
await act(async () => { renderer.unmount(); });
|
||||
await finish();
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { subscribeTerminalCommandCompletion } from '../terminalCommandCompletion';
|
||||
|
||||
interface Options {
|
||||
sessionId: string | null;
|
||||
enabled: boolean;
|
||||
visible: boolean;
|
||||
busy: boolean;
|
||||
matchesTerminal: boolean;
|
||||
paneId: string;
|
||||
connectionId: string | null;
|
||||
path: string | null;
|
||||
connected: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Coalesce completion bursts; never poll or carry refreshes across pane changes. */
|
||||
export function useSftpCommandRefresh(options: Options): void {
|
||||
const latest = useRef(options);
|
||||
latest.current = options;
|
||||
const drain = useRef(() => {});
|
||||
useEffect(() => {
|
||||
if (!options.enabled || !options.visible || !options.sessionId) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let pending = false;
|
||||
let refreshing = false;
|
||||
let disposed = false;
|
||||
const eligible = () => {
|
||||
const live = latest.current;
|
||||
return !disposed && live.enabled && live.visible && live.connected && live.matchesTerminal
|
||||
&& live.sessionId === options.sessionId && live.paneId === options.paneId
|
||||
&& live.connectionId === options.connectionId && live.path === options.path;
|
||||
};
|
||||
const schedule = () => {
|
||||
if (!pending || refreshing || latest.current.busy || !eligible()) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (!eligible() || latest.current.busy) return;
|
||||
pending = false;
|
||||
refreshing = true;
|
||||
void latest.current.refresh().catch(() => {}).finally(() => {
|
||||
refreshing = false;
|
||||
schedule();
|
||||
});
|
||||
}, 250);
|
||||
};
|
||||
drain.current = schedule;
|
||||
const unsubscribe = subscribeTerminalCommandCompletion((sessionId) => {
|
||||
if (sessionId !== options.sessionId || !eligible()) return;
|
||||
pending = true;
|
||||
schedule();
|
||||
});
|
||||
return () => { disposed = true; clearTimeout(timer); unsubscribe(); drain.current = () => {}; };
|
||||
}, [options.enabled, options.visible, options.sessionId, options.paneId, options.connectionId, options.path]);
|
||||
useEffect(() => { drain.current(); }, [options.busy]);
|
||||
}
|
||||
@@ -62,6 +62,8 @@ export interface SftpConnectOptions {
|
||||
sourceSessionId?: string;
|
||||
/** Fail instead of opening a new route when the requested terminal transport cannot be reused. */
|
||||
requireSourceSessionReuse?: boolean;
|
||||
/** When false, never attach SFTP to a live terminal SSH transport. */
|
||||
reuseTransport?: 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. */
|
||||
@@ -1074,6 +1076,7 @@ export const useSftpConnections = ({
|
||||
openOptions: {
|
||||
sessionId: sftpSessionId,
|
||||
...credentials,
|
||||
...(options?.reuseTransport === false ? { reuseTransport: false } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,60 @@ test("first-open follow uses a fresh terminal cwd instead of the cached home pat
|
||||
await act(async () => renderer?.unmount());
|
||||
});
|
||||
|
||||
test("first-open follow stays put when the terminal cwd is still an unresolved ~", async () => {
|
||||
const navigatedPaths: string[] = [];
|
||||
const connection = {
|
||||
id: "conn-1",
|
||||
hostId: "host-1",
|
||||
currentPath: "/root",
|
||||
homeDir: "/root",
|
||||
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: "~",
|
||||
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 () => "~",
|
||||
onPendingFollowOverride: () => {},
|
||||
sftpRef,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
assert.deepEqual(navigatedPaths, []);
|
||||
assert.equal(connection.currentPath, "/root");
|
||||
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[] = [];
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
shouldReleaseInitialFollowSyncAttempt,
|
||||
shouldResetInitialFollowTerminalCwdSync,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
resolveTerminalCwdForSftp,
|
||||
isSftpFollowTargetPath,
|
||||
fallbackFollowPathForUntrackedSession,
|
||||
} from "../../../domain/sftpFollowTerminalCwd";
|
||||
import type { Host } from "../../../types";
|
||||
import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActions";
|
||||
@@ -23,6 +26,7 @@ import type { SftpNavigateOptions, SftpNavigateResult } from "./useSftpPaneActio
|
||||
type FollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
homeDir?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
@@ -48,6 +52,7 @@ type GetTerminalCwd = (options?: {
|
||||
type InitialFollowConnection = {
|
||||
id: string;
|
||||
currentPath?: string | null;
|
||||
homeDir?: string | null;
|
||||
status: string;
|
||||
isLocal?: boolean;
|
||||
};
|
||||
@@ -85,13 +90,21 @@ const runInitialFollowTerminalCwdSync = async ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetPath = resolveTerminalCwdForSftp(cwd, live.homeDir, live.currentPath);
|
||||
if (!isSftpFollowTargetPath(targetPath)) {
|
||||
// Prompt-only shortcuts such as `~` are not a real SFTP path yet. Stay on
|
||||
// the connection's landing directory instead of listing `/~`.
|
||||
setHandled({ connectionId: expectedConnectionId, terminalCwd: cwd });
|
||||
return true;
|
||||
}
|
||||
|
||||
setHandled({
|
||||
connectionId: expectedConnectionId,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== cwd ? staleTerminalCwd : cwd,
|
||||
terminalCwd: staleTerminalCwd && staleTerminalCwd !== targetPath ? staleTerminalCwd : targetPath,
|
||||
});
|
||||
if (live.currentPath === cwd) return true;
|
||||
if (live.currentPath === targetPath) return true;
|
||||
|
||||
const navigateResult = await navigate(cwd, isEligible);
|
||||
const navigateResult = await navigate(targetPath, isEligible);
|
||||
if (!isEligible()) return false;
|
||||
const current = getConnection();
|
||||
if (!current || current.id !== expectedConnectionId || current.status !== "connected") {
|
||||
@@ -174,6 +187,7 @@ export function useSftpFollowTerminalCwd({
|
||||
const ownerPanelOpenRef = useRef(ownerPanelOpen);
|
||||
const hasActiveWorkRef = useRef(hasActiveWork);
|
||||
const initialFollowReadyConnectionRef = useRef<string | null>(null);
|
||||
const lastFollowOriginIdRef = useRef<string | null>(null);
|
||||
|
||||
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
||||
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
||||
@@ -257,10 +271,16 @@ export function useSftpFollowTerminalCwd({
|
||||
});
|
||||
if (!cwd) return;
|
||||
if (!shouldApply()) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
const targetPath = resolveTerminalCwdForSftp(
|
||||
cwd,
|
||||
connection?.homeDir,
|
||||
connection?.currentPath,
|
||||
);
|
||||
if (!isSftpFollowTargetPath(targetPath)) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", targetPath, { 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 };
|
||||
}
|
||||
@@ -275,9 +295,23 @@ export function useSftpFollowTerminalCwd({
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
const expectedSessionId = focusedSessionIdRef.current ?? activeSessionIdRef.current ?? null;
|
||||
const expectedConnectionIdAtStart = connectionIdRef.current ?? liveConnectionId;
|
||||
const previousOriginId = lastFollowOriginIdRef.current;
|
||||
const originChanged = Boolean(
|
||||
previousOriginId
|
||||
&& expectedSessionId
|
||||
&& previousOriginId !== expectedSessionId,
|
||||
);
|
||||
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
|
||||
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
|
||||
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
|
||||
if (!terminalCwd) {
|
||||
if (!terminalCwd && originChanged) {
|
||||
// Do not reuse an in-flight probe from the pane we just left.
|
||||
terminalCwd = fallbackFollowPathForUntrackedSession({
|
||||
originChanged,
|
||||
homeDir: sftpRef.current.leftPane.connection?.homeDir,
|
||||
currentPath: sftpRef.current.leftPane.connection?.currentPath,
|
||||
});
|
||||
} else if (!terminalCwd) {
|
||||
terminalCwd = await onGetTerminalCwd({
|
||||
preferFreshBackend: true,
|
||||
allowRendererFallback: false,
|
||||
@@ -335,26 +369,36 @@ export function useSftpFollowTerminalCwd({
|
||||
liveTerminalCwd: activeTerminalCwdRef.current,
|
||||
requireLiveTerminalCwd: usesLiveTerminalCwd,
|
||||
});
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
|
||||
if (!connection) return;
|
||||
const targetPath = resolveTerminalCwdForSftp(
|
||||
terminalCwd,
|
||||
connection.homeDir,
|
||||
connection.currentPath,
|
||||
);
|
||||
if (!isSftpFollowTargetPath(targetPath)) return;
|
||||
const navigateResult = await sftpRef.current.navigateTo("left", targetPath, {
|
||||
shouldApply: shouldApplyCurrentFollowSync,
|
||||
quiet: true,
|
||||
});
|
||||
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 };
|
||||
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
} else if (navigateResult === "superseded") {
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
} else if (navigateResult === "reached") {
|
||||
blockedFollowRef.current = null;
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
||||
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd: targetPath };
|
||||
}
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
onGetTerminalCwd,
|
||||
@@ -380,6 +424,7 @@ export function useSftpFollowTerminalCwd({
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
void syncFollowToTerminalCwd();
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeTerminalCwd,
|
||||
activeTerminalCwdTrusted,
|
||||
canFollowTerminalCwd,
|
||||
@@ -387,6 +432,7 @@ export function useSftpFollowTerminalCwd({
|
||||
connectionIsLocal,
|
||||
connectionStatus,
|
||||
effectiveFollowTerminalCwd,
|
||||
focusedSessionId,
|
||||
hasActiveWork,
|
||||
isVisible,
|
||||
syncFollowToTerminalCwd,
|
||||
@@ -533,7 +579,7 @@ export function useSftpFollowTerminalCwd({
|
||||
getConnection: () => sftpRef.current.leftPane.connection,
|
||||
navigate: (cwd, shouldApply) => {
|
||||
navigationStarted = true;
|
||||
return sftpRef.current.navigateTo("left", cwd, { shouldApply });
|
||||
return sftpRef.current.navigateTo("left", cwd, { shouldApply, quiet: true });
|
||||
},
|
||||
setHandled: (value) => { handledFollowRef.current = value; },
|
||||
setBlocked: (value) => { blockedFollowRef.current = value; },
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildSftpHostCredentials,
|
||||
} from "./useSftpHostCredentials.ts";
|
||||
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
|
||||
import { resolvePasswordAuthSftpHost } from "../../../domain/authIdentityPicker.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
@@ -16,6 +17,22 @@ const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("SFTP credentials follow the temporary deploy identity on a root profile", () => {
|
||||
const identities = [
|
||||
{ id: "root-id", label: "root", username: "root", authMethod: "password", password: "bad-root", created: 0 },
|
||||
{ id: "deploy-id", label: "deploy", username: "deploy", authMethod: "password", password: "good-deploy", created: 0 },
|
||||
] as Identity[];
|
||||
const savedHost = host({ identityId: "root-id" });
|
||||
const sftpHost = resolvePasswordAuthSftpHost(savedHost, identities, {
|
||||
authMethod: "password", username: "deploy", password: "good-deploy", savedToHost: false,
|
||||
});
|
||||
const credentials = buildSftpHostCredentials({ host: sftpHost, hosts: [savedHost], keys: [], identities });
|
||||
assert.equal(credentials.username, "deploy");
|
||||
assert.equal(credentials.password, "good-deploy");
|
||||
assert.equal(credentials.hostId, savedHost.id);
|
||||
assert.equal(savedHost.identityId, "root-id");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials forwards system agent settings for target and jump hosts", () => {
|
||||
const jump = host({
|
||||
id: "jump-1",
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
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 { useSftpPaneActions } from './useSftpPaneActions';
|
||||
import { createEmptyPane, type SftpPane } from './types';
|
||||
import type { SftpFileEntry } from '../../../types';
|
||||
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
|
||||
|
||||
test('command refresh keeps current directory/filter/selection and drops deleted selections', async () => {
|
||||
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '1970-01-01' });
|
||||
let result = [file('kept'), file('new')];
|
||||
const selection = new Set(['kept']);
|
||||
const pane: SftpPane = { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', status: 'connected', currentPath: '/browsed', homeDir: '/', isLocal: true }, files: [file('kept')], selectedFiles: selection, filter: 'ke' };
|
||||
const left = { current: { tabs: [pane], activeTabId: pane.id } };
|
||||
const calls: string[] = [];
|
||||
let actions: ReturnType<typeof useSftpPaneActions>;
|
||||
const update: Parameters<typeof useSftpPaneActions>[0]['updateTab'] = (_side, id, updater) => {
|
||||
left.current.tabs = left.current.tabs.map((p) => p.id === id ? updater(p) : p);
|
||||
};
|
||||
function Probe() {
|
||||
actions = useSftpPaneActions({
|
||||
hosts: [], getActivePane: () => left.current.tabs[0], updateTab: update,
|
||||
updateActiveTab: (side, updater) => update(side, pane.id, updater),
|
||||
leftTabsRef: left, rightTabsRef: { current: { tabs: [], activeTabId: null } },
|
||||
navSeqRef: { current: { left: 0, right: 0 } }, dirCacheRef: { current: new Map() },
|
||||
sftpSessionsRef: { current: new Map() }, lastConnectedHostRef: { current: { left: null, right: null } },
|
||||
connectionCacheKeyMapRef: { current: new Map() }, makeCacheKey: (id, path) => id + path,
|
||||
clearCacheForConnection() {}, listLocalFiles: async (path) => { calls.push(path); return result; },
|
||||
listRemoteFiles: async () => [], handleSessionError() {}, releaseConnection: async () => {},
|
||||
isSessionError: () => false, clearSelectionsExcept() {}, dirCacheTtlMs: 1000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await actions!.refresh('left', { tabId: pane.id, preserveSelection: true });
|
||||
assert.deepEqual(calls, ['/browsed']);
|
||||
assert.equal(left.current.tabs[0].selectedFiles, selection);
|
||||
assert.equal(left.current.tabs[0].filter, 'ke');
|
||||
assert.deepEqual(left.current.tabs[0].files.map((f) => f.name), ['kept', 'new']);
|
||||
result = [file('new')];
|
||||
await actions!.refresh('left', { preserveSelection: true });
|
||||
assert.equal(left.current.tabs[0].selectedFiles.size, 0);
|
||||
assert.equal(left.current.tabs[0].connection?.currentPath, '/browsed');
|
||||
await act(async () => { renderer.unmount(); });
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useRef } from "react";
|
||||
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { SftpPane } from "./types";
|
||||
import {
|
||||
getFileName,
|
||||
@@ -27,13 +28,6 @@ import {
|
||||
/** Shared empty set for navigation resets — never mutate this. */
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
/** Retain selection identity when no selected entry disappeared. */
|
||||
export function retainSftpSelection(selected: Set<string>, files: SftpFileEntry[]): Set<string> {
|
||||
const names = new Set(files.map((file) => file.name));
|
||||
const retained = new Set([...selected].filter((name) => names.has(name)));
|
||||
return retained.size === selected.size ? selected : retained;
|
||||
}
|
||||
|
||||
interface UseSftpPaneActionsParams {
|
||||
hosts: Host[];
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
@@ -60,14 +54,15 @@ interface UseSftpPaneActionsParams {
|
||||
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
|
||||
export type SftpNavigateOptions = {
|
||||
force?: boolean;
|
||||
preserveSelection?: boolean;
|
||||
tabId?: string;
|
||||
shouldApply?: () => boolean;
|
||||
/** Restore the previous listing without showing a pane error. */
|
||||
quiet?: boolean;
|
||||
};
|
||||
|
||||
interface UseSftpPaneActionsResult {
|
||||
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => Promise<void>;
|
||||
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||||
navigateUp: (side: "left" | "right") => Promise<void>;
|
||||
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||||
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
|
||||
@@ -187,7 +182,6 @@ export const useSftpPaneActions = ({
|
||||
pane.connection.homeDir,
|
||||
);
|
||||
|
||||
const preserveSelection = options?.preserveSelection && normalizedPath === pane.connection.currentPath;
|
||||
const connectionId = pane.connection.id;
|
||||
const requestId = ++navSeqRef.current[side];
|
||||
const cacheKey = makeCacheKey(connectionId, normalizedPath, pane.filenameEncoding);
|
||||
@@ -290,7 +284,7 @@ export const useSftpPaneActions = ({
|
||||
connection: prev.connection
|
||||
? { ...prev.connection, currentPath: normalizedPath }
|
||||
: null,
|
||||
selectedFiles: preserveSelection ? prev.selectedFiles : EMPTY_SET,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
loading: true,
|
||||
error: null,
|
||||
@@ -378,7 +372,7 @@ export const useSftpPaneActions = ({
|
||||
connectionId,
|
||||
path: normalizedPath,
|
||||
files,
|
||||
selectedFiles: preserveSelection ? retainSftpSelection(getTargetPane()?.selectedFiles ?? EMPTY_SET, files) : EMPTY_SET,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: nextConfirmedFilter,
|
||||
});
|
||||
|
||||
@@ -389,7 +383,7 @@ export const useSftpPaneActions = ({
|
||||
: null,
|
||||
files,
|
||||
loading: false,
|
||||
selectedFiles: preserveSelection ? retainSftpSelection(prev.selectedFiles, files) : EMPTY_SET,
|
||||
selectedFiles: EMPTY_SET,
|
||||
filter: clearFilterForPathChange ? "" : prev.filter,
|
||||
}));
|
||||
if (!pane.connection.isLocal) {
|
||||
@@ -425,8 +419,9 @@ export const useSftpPaneActions = ({
|
||||
files: previousFiles,
|
||||
selectedFiles: previousSelection,
|
||||
filter: getSftpFilterAfterPathChangeError(clearFilterForPathChange, previousFilter, prev.filter),
|
||||
error:
|
||||
err instanceof Error ? err.message : "Failed to list directory",
|
||||
error: options?.quiet
|
||||
? previousError
|
||||
: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
@@ -454,7 +449,7 @@ export const useSftpPaneActions = ({
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => {
|
||||
async (side: "left" | "right", options?: { tabId?: string }) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const pane = options?.tabId
|
||||
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||||
@@ -485,7 +480,7 @@ export const useSftpPaneActions = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId, preserveSelection: options?.preserveSelection });
|
||||
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
|
||||
} else if (!pane?.connection && pane?.error) {
|
||||
// For background tabs, don't trigger reconnection (it operates on
|
||||
// the active tab). Just leave the error state for the user to see
|
||||
@@ -1039,6 +1034,30 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const removeListedNames = useCallback((
|
||||
side: "left" | "right",
|
||||
parentPath: string,
|
||||
names: string[],
|
||||
) => {
|
||||
if (names.length === 0) return;
|
||||
const removeSet = new Set(names);
|
||||
updateActiveTab(side, (prev) => {
|
||||
if (!prev.connection || prev.connection.currentPath !== parentPath) return prev;
|
||||
const nextSelection = new Set(prev.selectedFiles);
|
||||
for (const name of names) nextSelection.delete(name);
|
||||
return {
|
||||
...prev,
|
||||
files: prev.files.filter((file) => !removeSet.has(file.name)),
|
||||
selectedFiles: nextSelection,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
const pane = getActivePane(side);
|
||||
if (pane?.connection && !pane.connection.isLocal) {
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
}
|
||||
}, [clearCacheForConnection, getActivePane, updateActiveTab]);
|
||||
|
||||
return {
|
||||
navigateTo,
|
||||
refresh,
|
||||
@@ -1056,6 +1075,7 @@ export const useSftpPaneActions = ({
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
removeListedNames,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
/** @test issue #3559 — dual-pane transfers must re-stat remote sources before planning */
|
||||
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 { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
sourceType: string;
|
||||
targetType: string;
|
||||
totalBytes?: number;
|
||||
sourceSftpId?: string;
|
||||
};
|
||||
|
||||
/** Remote pane whose listing was captured before the file grew to 3626 bytes. */
|
||||
function makePane(side: "left" | "right"): SftpPane {
|
||||
const isLocal = side === "right";
|
||||
return {
|
||||
id: side,
|
||||
connection: {
|
||||
id: isLocal ? "local-conn" : "remote-conn",
|
||||
hostId: isLocal ? undefined : "host-1",
|
||||
hostLabel: isLocal ? "Local" : "SSH host",
|
||||
isLocal,
|
||||
status: "connected",
|
||||
currentPath: isLocal ? "/Users/reporter/Downloads" : "/opt",
|
||||
},
|
||||
files: isLocal
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 3622,
|
||||
sizeFormatted: "3.5 KB",
|
||||
lastModified: 1758000000000,
|
||||
lastModifiedFormatted: "",
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set<string>(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
showHiddenFiles: true,
|
||||
transferMutationToken: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function installGlobals(netty: Record<string, unknown>) {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousActFlag = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const previousStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousRaf = (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame;
|
||||
const previousCancelRaf = (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame;
|
||||
// Progress patches are rAF-coalesced; provide a microtask-ish shim.
|
||||
const raf = (cb: (time: number) => void) => setTimeout(() => cb(Date.now()), 0);
|
||||
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = raf;
|
||||
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = (id: unknown) => clearTimeout(id as number);
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: netty,
|
||||
requestAnimationFrame: raf,
|
||||
cancelAnimationFrame: (id: unknown) => clearTimeout(id as number),
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
return () => {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActFlag;
|
||||
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = previousRaf;
|
||||
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = previousCancelRaf;
|
||||
};
|
||||
}
|
||||
|
||||
for (const liveStatAvailable of [true, false]) {
|
||||
test(
|
||||
liveStatAvailable
|
||||
? "dual-pane download plan uses the live source size, not the stale listing (#3559)"
|
||||
: "dual-pane download plan falls back to live size measurement when stat is unavailable (#3559)",
|
||||
async () => {
|
||||
const statCalls: Array<{ sftpId: string; path: string }> = [];
|
||||
const startedOptions: StartOptions[] = [];
|
||||
let resolveTransfer!: (options: StartOptions) => void;
|
||||
const transferStarted = new Promise<StartOptions>((resolve) => { resolveTransfer = resolve; });
|
||||
|
||||
const restore = installGlobals({
|
||||
statSftp: async (sftpId: string, target: string) => {
|
||||
if (!liveStatAvailable) return undefined;
|
||||
statCalls.push({ sftpId, path: target });
|
||||
return {
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 3626,
|
||||
sizeKnown: true,
|
||||
lastModified: 1758000001000,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
startedOptions.push(options);
|
||||
resolveTransfer(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
const running = ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
const options = await transferStarted;
|
||||
startedOptions.length = 0;
|
||||
startedOptions.push(options);
|
||||
await running;
|
||||
});
|
||||
|
||||
// Every fresh remote plan must come from a live re-stat of the source.
|
||||
if (liveStatAvailable) {
|
||||
assert.equal(statCalls.length, 1);
|
||||
assert.equal(statCalls[0].sftpId, "sftp-remote");
|
||||
assert.equal(statCalls[0].path, "/opt/aops_dns_view_demo.sh");
|
||||
}
|
||||
const planBytes = startedOptions[0]?.totalBytes;
|
||||
if (liveStatAvailable) {
|
||||
assert.equal(planBytes, 3626, "plan must use the live remote size, not the stale listing 3622");
|
||||
} else {
|
||||
// No live stat possible: plan 0 must be omitted so the transfer
|
||||
// bridge measures the live size instead of truncating.
|
||||
assert.equal(planBytes, undefined);
|
||||
}
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("stat-less remote source keeps the listing size in the overwrite dialog and leaves the plan unknown", async () => {
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => ({
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 0,
|
||||
sizeKnown: false,
|
||||
lastModified: 0,
|
||||
}),
|
||||
statLocal: async () => ({
|
||||
name: "aops_dns_view_demo.sh",
|
||||
type: "file" as const,
|
||||
size: 100,
|
||||
lastModified: 1,
|
||||
}),
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-statless",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(started.length, 0, "an unresolved conflict must not start the transfer");
|
||||
assert.equal(ops!.conflicts.length, 1);
|
||||
assert.equal(ops!.conflicts[0].newSize, 3622);
|
||||
assert.equal(ops!.conflicts[0].newModified, 1758000000000);
|
||||
const task = ops!.transfers.find((row) => row.fileName === "aops_dns_view_demo.sh");
|
||||
assert.equal(task?.totalBytes, 0, "listing size must not become the transfer plan");
|
||||
assert.equal(task?.status, "attention");
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("remote transfer preflight stats stay within the concurrency cap and file bound", async () => {
|
||||
const limit = PREFLIGHT_STAT_CONCURRENCY;
|
||||
const fileCount = limit + 4;
|
||||
let inflight = 0;
|
||||
let maxInflight = 0;
|
||||
let releaseAll = () => {};
|
||||
const gate = new Promise<void>((resolve) => { releaseAll = resolve; });
|
||||
let reachedCap = () => {};
|
||||
const hitCap = new Promise<void>((resolve) => { reachedCap = resolve; });
|
||||
const names = Array.from({ length: fileCount }, (_, index) => `file-${index}.bin`);
|
||||
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
inflight += 1;
|
||||
maxInflight = Math.max(maxInflight, inflight);
|
||||
if (inflight >= limit) reachedCap();
|
||||
try {
|
||||
await gate;
|
||||
} finally {
|
||||
inflight -= 1;
|
||||
}
|
||||
return {
|
||||
name: "file",
|
||||
type: "file" as const,
|
||||
size: 10,
|
||||
sizeKnown: true,
|
||||
lastModified: 1,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-concurrency",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
let running: Promise<unknown> = Promise.resolve();
|
||||
await act(async () => {
|
||||
running = ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
const capTimeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("preflight did not reach the concurrency cap")), 2000);
|
||||
});
|
||||
await Promise.race([hitCap, capTimeout]);
|
||||
});
|
||||
assert.equal(maxInflight, limit);
|
||||
assert.ok(inflight <= limit);
|
||||
releaseAll();
|
||||
await act(async () => { await running; });
|
||||
assert.equal(maxInflight, limit);
|
||||
} finally {
|
||||
releaseAll();
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preflight timeout stops the rest of the batch", async () => {
|
||||
const extra = 4;
|
||||
const names = Array.from(
|
||||
{ length: PREFLIGHT_STAT_CONCURRENCY + extra },
|
||||
(_, index) => `hung-${index}.bin`,
|
||||
);
|
||||
let statCalls = 0;
|
||||
const restore = installGlobals({
|
||||
statSftp: () => {
|
||||
statCalls += 1;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-timeout",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
// One preflight wave, then stop. Timed-out files are not statted again.
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_CONCURRENCY);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-less preflight results are not statted again before transfer", async () => {
|
||||
const names = Array.from(
|
||||
{ length: PREFLIGHT_STAT_CONCURRENCY + 2 },
|
||||
(_, index) => `legacy-${index}.bin`,
|
||||
);
|
||||
let statCalls = 0;
|
||||
let inflight = 0;
|
||||
let maxInflight = 0;
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
statCalls += 1;
|
||||
inflight += 1;
|
||||
maxInflight = Math.max(maxInflight, inflight);
|
||||
await Promise.resolve();
|
||||
inflight -= 1;
|
||||
return {
|
||||
name: "legacy",
|
||||
type: "file" as const,
|
||||
size: 0,
|
||||
sizeKnown: false,
|
||||
lastModified: 0,
|
||||
};
|
||||
},
|
||||
statLocal: async () => null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-statless-batch",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(statCalls, names.length);
|
||||
assert.ok(maxInflight <= PREFLIGHT_STAT_CONCURRENCY);
|
||||
assert.equal(started.length, names.length);
|
||||
assert.equal(started.filter((options) => options.totalBytes === undefined).length, names.length);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("files past the preflight bound stay un-statted after conflict resolution", async () => {
|
||||
const extra = 2;
|
||||
const names = Array.from({ length: PREFLIGHT_STAT_MAX_FILES + extra }, (_, index) => `bulk-${index}.bin`);
|
||||
let statCalls = 0;
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => {
|
||||
statCalls += 1;
|
||||
return {
|
||||
name: "bulk",
|
||||
type: "file" as const,
|
||||
size: 20,
|
||||
sizeKnown: true,
|
||||
lastModified: 5,
|
||||
};
|
||||
},
|
||||
statLocal: async (target: string) => names.slice(-extra).some((name) => target.endsWith(`/${name}`))
|
||||
? { name: target.split("/").pop() || "", type: "file" as const, size: 1, lastModified: 1 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0,
|
||||
totalBytes: options.totalBytes ?? 0,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "plan-owner-bound",
|
||||
getActivePane: (side) => (side === "left" ? makePane("left") : makePane("right")),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
names.map((name) => ({ name, isDirectory: false })),
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES);
|
||||
assert.equal(started.length, names.length - extra);
|
||||
assert.equal(ops!.conflicts.length, extra);
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(ops!.conflicts.length, extra, "conflicts must survive panel remount");
|
||||
await act(async () => {
|
||||
await ops!.resolveConflict(ops!.conflicts[0].transferId, "replace", true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
assert.equal(started.length, names.length);
|
||||
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES, "conflict resolution must keep the preflight cap");
|
||||
const omitted = started.filter((options) => options.totalBytes === undefined);
|
||||
assert.equal(omitted.length, extra);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("owned cancelled directory settles recoverable children without dropping completed bytes", async () => {
|
||||
const { writeFile, readFile, mkdtemp, rm } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { transferRuntime } = await import("./transferRuntime");
|
||||
const root = await mkdtemp(join(tmpdir(), "netcatty-owner-cancel-"));
|
||||
const completedPath = join(root, "completed.txt");
|
||||
await writeFile(completedPath, "completed bytes must remain");
|
||||
const ownerId = "cancel-owner-large";
|
||||
const rootId = "cancel-root-large";
|
||||
const base = {
|
||||
id: rootId, fileName: "folder", sourcePath: "/opt/folder", targetPath: root,
|
||||
sourceConnectionId: "remote-conn", targetConnectionId: "local-conn",
|
||||
sourceHostId: "host-1", direction: "download" as const, status: "transferring" as const,
|
||||
totalBytes: 2201, transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true,
|
||||
};
|
||||
const children = Array.from({ length: 2200 }, (_, index) => ({
|
||||
...base, id: `cancel-child-${index}`, parentTaskId: rootId, isDirectory: false,
|
||||
sourcePath: `/opt/folder/${index}`, targetPath: join(root, String(index)),
|
||||
totalBytes: 10, transferredBytes: 4, status: "queued" as const,
|
||||
}));
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [base, ...children, {
|
||||
...base, id: "cancel-completed", parentTaskId: rootId, isDirectory: false,
|
||||
sourcePath: "/opt/folder/completed.txt", targetPath: completedPath, status: "completed",
|
||||
}]);
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
let clearing = 0;
|
||||
let clearPeak = 0;
|
||||
let clearCount = 0;
|
||||
const cleaned: string[] = [];
|
||||
const restore = installGlobals({
|
||||
cancelTransfer: async (id: string) => {
|
||||
assert.notEqual(id, "cancel-completed");
|
||||
active += 1;
|
||||
peak = Math.max(peak, active);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
active -= 1;
|
||||
return { success: id !== "cancel-child-0" };
|
||||
},
|
||||
clearPendingTransferCancel: async () => {
|
||||
clearing += 1;
|
||||
clearPeak = Math.max(clearPeak, clearing);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
clearing -= 1;
|
||||
clearCount += 1;
|
||||
},
|
||||
cleanupTransferArtifacts: async ({ transferId }: { transferId: string }) => { cleaned.push(transferId); },
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side),
|
||||
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined, refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-remote"]]) },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const walking = transferRuntime.runWalk(rootId, async () => {
|
||||
await held;
|
||||
// Old scheduler callbacks settle as cancelled before the outcome is applied.
|
||||
sftpTransferCenterStore.publishOwner(ownerId, sftpTransferCenterStore.getOwnerTasks(ownerId).map((task) =>
|
||||
task.status === "completed" ? task : {
|
||||
...task, status: "cancelled",
|
||||
checkpointBytes: task.id === "cancel-child-1" ? 6 : task.checkpointBytes,
|
||||
}));
|
||||
});
|
||||
await act(async () => {
|
||||
const cancellation = ops!.cancelTransfer(rootId);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
|
||||
release();
|
||||
await Promise.all([walking, cancellation]);
|
||||
});
|
||||
assert.ok(peak <= 32, `bounded cancellation IPC, got ${peak}`);
|
||||
assert.equal(clearCount, 2201);
|
||||
assert.ok(clearPeak > 1 && clearPeak <= 32, `bounded concurrent cleanup, got ${clearPeak}`);
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "attention");
|
||||
assert.equal(sftpTransferCenterStore.getTask("cancel-child-0")?.status, "attention");
|
||||
const remaining = sftpTransferCenterStore.getTask("cancel-child-1");
|
||||
assert.equal(remaining?.status, "interrupted", "evicted cancelled children remain recoverable");
|
||||
assert.equal(remaining?.checkpointBytes, 6, "retain the terminal checkpoint, not the pre-cancel snapshot");
|
||||
assert.deepEqual(cleaned, [], "no stage cleanup during partial cancellation recovery");
|
||||
assert.equal(await readFile(completedPath, "utf8"), "completed bytes must remain");
|
||||
} finally {
|
||||
release();
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -63,6 +63,21 @@ import {
|
||||
retryExternalDragDropFileUpload,
|
||||
} from "./externalDragDropRetry";
|
||||
|
||||
// The bridge's statSftp awaits an unbounded SFTP callback, so a half-open
|
||||
// session can leave a stat pending forever. Every stat issued outside the
|
||||
// bridge is bounded with this timeout; on timeout the caller falls back to
|
||||
// stale metadata / a 0 plan instead of one hung stat blocking a batch.
|
||||
const STAT_SFTP_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Preflight re-stats share the browse SFTP session and the renderer IPC
|
||||
// queue. Launching one stat per file for a huge multi-file selection would
|
||||
// flood both before any transfer task reaches the UI, so cap the number of
|
||||
// in-flight preflight stats and skip the re-stat entirely once the selection
|
||||
// grows past a bound (files beyond the cap stay on the safe 0 plan, which
|
||||
// makes the bridge measure the live size anyway).
|
||||
export const PREFLIGHT_STAT_CONCURRENCY = 8;
|
||||
export const PREFLIGHT_STAT_MAX_FILES = 128;
|
||||
|
||||
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
|
||||
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
|
||||
if (latched) ref.current.add(taskId);
|
||||
@@ -207,7 +222,6 @@ export const useSftpTransfers = ({
|
||||
const transfersRef = useRef(transfers);
|
||||
const conflictsRef = useRef(conflicts);
|
||||
conflictsRef.current = conflicts;
|
||||
|
||||
// When the retained panel is re-opened, catch up React state from the ref that
|
||||
// kept receiving progress while the surface was hidden.
|
||||
useEffect(() => {
|
||||
@@ -613,9 +627,30 @@ export const useSftpTransfers = ({
|
||||
|
||||
const sameHost = sameHostEndpoints && !!sourceSftpId && !!targetSftpId;
|
||||
|
||||
const discoverTransferSize = async () => {
|
||||
// Returns the discovered stat so callers that need the size (e.g. the
|
||||
// overwrite conflict dialog) can await this instead of reading stale
|
||||
// metadata from the local task copy.
|
||||
const discoverTransferSize = async (): Promise<{
|
||||
size: number;
|
||||
lastModified?: number;
|
||||
/** False for pane-listing display data. That size must not become the plan. */
|
||||
plan: boolean;
|
||||
} | null> => {
|
||||
// When the endpoint cannot stat the source (legacy SCP returns the
|
||||
// sizeKnown: false placeholder) or the stat fails, the transfer plan
|
||||
// stays unknown (0 bytes, so the bridge measures the live size). The
|
||||
// overwrite dialog still needs display metadata, so fall back to the
|
||||
// retained cached listing entry instead of reporting 0 bytes — but only
|
||||
// for the dialog; the plan itself must never take the listing's
|
||||
// possibly stale size back.
|
||||
const cachedListingStatForDialog = (): { size: number; lastModified?: number; plan: false } | null => {
|
||||
if (getParentPath(task.sourcePath) !== sourcePane.connection?.currentPath) return null;
|
||||
const entry = sourcePane.files.find((candidate) => candidate.name === task.fileName);
|
||||
if (!entry || !(entry.size > 0)) return null;
|
||||
return { size: entry.size, lastModified: entry.lastModified, plan: false };
|
||||
};
|
||||
try {
|
||||
if (task.totalBytes > 0 || !!task.sourceLastModified) return;
|
||||
if (task.totalBytes > 0 || !!task.sourceLastModified) return null;
|
||||
|
||||
if (sourcePane.connection?.isLocal) {
|
||||
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
|
||||
@@ -628,17 +663,41 @@ export const useSftpTransfers = ({
|
||||
totalBytes: stat.size,
|
||||
});
|
||||
}
|
||||
return { size: stat.size, lastModified: stat.lastModified, plan: true };
|
||||
}
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sourceSftpId) {
|
||||
const stat = await netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
);
|
||||
if (stat) {
|
||||
if (task.preflightStatSkipped) {
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
// This stat runs deferred (started before the conflict check) and
|
||||
// can be awaited by the conflict path, so it must not hang on a
|
||||
// half-open session. Bound it like the preflight stat and settle
|
||||
// early once the task is cancelled, since a marked task cannot
|
||||
// settle an in-flight SFTP callback itself.
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let cancelTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const stat = await Promise.race([
|
||||
Promise.resolve(netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpId,
|
||||
task.sourcePath,
|
||||
sourceEncoding,
|
||||
)),
|
||||
new Promise<undefined>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => resolve(undefined), STAT_SFTP_TIMEOUT_MS);
|
||||
}),
|
||||
new Promise<undefined>((resolve) => {
|
||||
cancelTimer = setInterval(() => {
|
||||
if (cancelledTasksRef.current.has(task.id)) resolve(undefined);
|
||||
}, 250);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
if (cancelTimer) clearInterval(cancelTimer);
|
||||
});
|
||||
if (stat && stat.sizeKnown !== false) {
|
||||
if (!task.sourceLastModified && stat.lastModified) {
|
||||
task.sourceLastModified = stat.lastModified;
|
||||
}
|
||||
@@ -647,12 +706,18 @@ export const useSftpTransfers = ({
|
||||
totalBytes: stat.size,
|
||||
});
|
||||
}
|
||||
return { size: stat.size, lastModified: stat.lastModified, plan: true };
|
||||
}
|
||||
// Unknown size (e.g. legacy SCP stat): keep the transfer plan
|
||||
// unknown so a 0-byte placeholder never replaces trusted metadata,
|
||||
// but give the conflict dialog the retained cached listing entry.
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isTransferCancelledError(err)) {
|
||||
logger.debug?.("[SFTP] Deferred transfer size discovery failed", err);
|
||||
}
|
||||
return cachedListingStatForDialog();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -746,16 +811,35 @@ export const useSftpTransfers = ({
|
||||
return null;
|
||||
})();
|
||||
|
||||
// For single files: fire-and-forget size discovery
|
||||
if (!task.isDirectory) {
|
||||
void discoverTransferSize();
|
||||
}
|
||||
// For single files: start size discovery without blocking the conflict
|
||||
// check, but keep the promise so an actual conflict can await fresh
|
||||
// size metadata before the dialog is shown.
|
||||
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
|
||||
|
||||
// Only await conflict check (fast single stat call)
|
||||
const conflict = await conflictCheckPromise;
|
||||
// Cancel/Stop may have won while conflict stats were in flight.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
|
||||
// null means the plan size is still unknown. A verified 0-byte stat is 0,
|
||||
// not null, so an empty file does not pick up a stale listing size.
|
||||
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
|
||||
if (conflict && sizeDiscoveryPromise) {
|
||||
// The task's totalBytes may still be 0 here because discovery ran
|
||||
// deferred and updateTask never mutates the local task copy. Await
|
||||
// discovery (bounded per-task; the task is already visible/cancellable)
|
||||
// so the overwrite dialog shows the real source size, not 0 (#3559).
|
||||
const discovered = await sizeDiscoveryPromise;
|
||||
// Cancel during this stat must win before an apply-to-all Replace
|
||||
// deletes an existing symlink.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
if (discovered && discovered.size >= 0) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
}
|
||||
}
|
||||
|
||||
if (conflict) {
|
||||
const defaultAction = conflictDefaultsRef.current
|
||||
.get(task.batchId ?? "global")
|
||||
@@ -819,7 +903,9 @@ export const useSftpTransfers = ({
|
||||
setConflicts((prev) => [...prev, conflict]);
|
||||
updateTask({
|
||||
status: "attention",
|
||||
totalBytes: conflict.newSize || task.totalBytes || 0,
|
||||
// Dialog newSize may be the listing fallback (plan: false). Only a
|
||||
// verified stat may become the plan; 0 stays omitted at startStreamTransfer.
|
||||
totalBytes: verifiedPlanBytes ?? 0,
|
||||
conflict,
|
||||
});
|
||||
return "attention";
|
||||
@@ -1094,6 +1180,84 @@ export const useSftpTransfers = ({
|
||||
? new Map(sourcePane.files.map(f => [f.name, f]))
|
||||
: null;
|
||||
|
||||
// Pane listings can be stale when the file changed in a terminal or
|
||||
// another session after the pane was listed (#3559). A stale undersized
|
||||
// size becomes the transfer plan and silently truncates the download, so
|
||||
// remote single files are always re-statted before the plan is built.
|
||||
// When no live stat is possible the plan stays 0, which makes the bridge
|
||||
// measure the live size instead of trusting the stale listing.
|
||||
const freshRemoteMetadata = new Map<string, { size: number; lastModified: number }>();
|
||||
const sourceSftpIdForPlan = sourcePane.connection.isLocal
|
||||
? null
|
||||
: (sftpSessionsRef.current.get(sourceConnectionId) ?? null);
|
||||
const remoteSingleFiles = sourcePane.connection.isLocal
|
||||
? []
|
||||
: sourceFiles.filter((file) => !file.isDirectory);
|
||||
const preflightFiles = remoteSingleFiles.slice(0, PREFLIGHT_STAT_MAX_FILES);
|
||||
// Remote files without a verified live stat are not statted again in
|
||||
// discoverTransferSize. That covers the tail past the cap, a missing
|
||||
// browse session, a timeout that stops the pool, and a stat that
|
||||
// returns no usable size. The plan stays unknown so the bridge measures
|
||||
// the live size; the overwrite dialog still uses the pane listing.
|
||||
const preflightSkippedNames = new Set<string>();
|
||||
if (remoteSingleFiles.length > 0 && sourceSftpIdForPlan) {
|
||||
const sourceEncoding = sourcePane.filenameEncoding || "auto";
|
||||
// The bridge's statSftp awaits an unbounded SFTP callback, so a
|
||||
// half-open session can leave a stat pending forever. Bound each
|
||||
// preflight stat: on timeout the file keeps its 0 plan and the bridge
|
||||
// measures the live size during the transfer instead of one hung stat
|
||||
// blocking the entire selected batch.
|
||||
const PREFLIGHT_STAT_TIMEOUT_MS = STAT_SFTP_TIMEOUT_MS;
|
||||
// These stats share the browse session. Keep a few in flight, and
|
||||
// after the first timeout stop claiming more files so a half-open
|
||||
// session cannot run the whole selection out to 10s each.
|
||||
let preflightStopped = false;
|
||||
const statOneRemoteFile = async (file: { name: string; isDirectory: boolean }) => {
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let timedOut = false;
|
||||
try {
|
||||
const stat = await Promise.race([
|
||||
Promise.resolve(netcattyBridge.get()?.statSftp?.(
|
||||
sourceSftpIdForPlan,
|
||||
joinPath(sourcePath, file.name),
|
||||
sourceEncoding,
|
||||
)),
|
||||
new Promise<undefined>((resolve) => {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
resolve(undefined);
|
||||
}, PREFLIGHT_STAT_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
if (timedOut) {
|
||||
preflightStopped = true;
|
||||
return;
|
||||
}
|
||||
if (stat && stat.type !== "symlink" && stat.sizeKnown !== false && stat.size >= 0) {
|
||||
freshRemoteMetadata.set(file.name, { size: stat.size, lastModified: stat.lastModified });
|
||||
}
|
||||
} catch (err) {
|
||||
// Fall through to a 0 plan so the bridge measures the live size.
|
||||
logger.debug?.("[SFTP] Transfer plan re-stat failed; falling back to live size", err);
|
||||
} finally {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
}
|
||||
};
|
||||
const workers = Math.min(PREFLIGHT_STAT_CONCURRENCY, preflightFiles.length);
|
||||
let preflightCursor = 0;
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: workers }, async () => {
|
||||
while (!preflightStopped && preflightCursor < preflightFiles.length) {
|
||||
const file = preflightFiles[preflightCursor++];
|
||||
await statOneRemoteFile(file);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const file of remoteSingleFiles) {
|
||||
if (!freshRemoteMetadata.has(file.name)) preflightSkippedNames.add(file.name);
|
||||
}
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const direction: TransferDirection =
|
||||
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
|
||||
@@ -1102,12 +1266,30 @@ export const useSftpTransfers = ({
|
||||
? "download"
|
||||
: "remote-to-remote";
|
||||
|
||||
// Use cached metadata from the source pane's file list to avoid
|
||||
// redundant stat calls over the network, but only when the transfer
|
||||
// source matches the pane's currently listed directory.
|
||||
// Prefer the fresh re-stat for remote files: a cached listing size can
|
||||
// be stale and undersized, which would silently truncate downloads
|
||||
// (#3559). Remote files without a live stat use plan 0 so the bridge
|
||||
// measures the live size; local sources keep the pane metadata.
|
||||
const fileEntry = fileEntryMap?.get(file.name);
|
||||
const fileSize = file.isDirectory ? 0 : (fileEntry?.size ?? 0);
|
||||
const sourceLastModified = fileEntry?.lastModified ?? 0;
|
||||
const freshMetadata = freshRemoteMetadata.get(file.name);
|
||||
const fileSize = file.isDirectory
|
||||
? 0
|
||||
: freshMetadata
|
||||
? freshMetadata.size
|
||||
: sourcePane.connection!.isLocal
|
||||
? (fileEntry?.size ?? 0)
|
||||
: 0;
|
||||
// Only keep an mtime that is paired with trusted metadata (live re-stat,
|
||||
// local source size, or directory totals). A remote file whose re-stat
|
||||
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
|
||||
// discoverTransferSize and the conflict check both treat a nonzero
|
||||
// sourceLastModified as proof the size is already known, so a stale
|
||||
// mtime would suppress the deferred source re-stat. The overwrite
|
||||
// dialog still gets the pane listing's cached entry via
|
||||
// discoverTransferSize's cachedListingStatForDialog fallback.
|
||||
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
|
||||
? 0
|
||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||
|
||||
const nextSourcePath = joinPath(sourcePath, file.name);
|
||||
const nextTargetPath = joinPath(targetPath, file.name);
|
||||
@@ -1167,8 +1349,9 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const taskId = crypto.randomUUID();
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
id: taskId,
|
||||
batchId,
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
@@ -1189,6 +1372,7 @@ export const useSftpTransfers = ({
|
||||
isDirectory: file.isDirectory,
|
||||
progressMode: file.isDirectory ? "files" : "bytes",
|
||||
sourceLastModified,
|
||||
preflightStatSkipped: preflightSkippedNames.has(file.name),
|
||||
origin: "manual",
|
||||
resumable: !usesLegacyScp,
|
||||
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
|
||||
@@ -1234,77 +1418,66 @@ export const useSftpTransfers = ({
|
||||
releasePausedTransfer(transferId);
|
||||
globalSftpTransferScheduler.cancel(transferId);
|
||||
|
||||
// Cancel parent + remove child tasks
|
||||
const childIdsToCancel = new Set<string>();
|
||||
const childrenToCleanup: TransferTask[] = [];
|
||||
for (const t of transfersRef.current) {
|
||||
if (t.parentTaskId === transferId && !["completed", "cancelled", "failed"].includes(t.status)) {
|
||||
childIdsToCancel.add(t.id);
|
||||
childrenToCleanup.push(t);
|
||||
}
|
||||
// Keep child rows and checkpoints until the old walk has stopped. A
|
||||
// partial backend failure needs a fresh re-walk, including queued jobs
|
||||
// the scheduler has already rejected and will never run again.
|
||||
const children = transfersRef.current.filter((task) =>
|
||||
task.parentTaskId === transferId
|
||||
&& !["completed", "cancelled", "failed"].includes(task.status));
|
||||
const childIds = new Set([
|
||||
...children.map((task) => task.id),
|
||||
...(activeChildIdsRef.current.get(transferId) ?? []),
|
||||
]);
|
||||
for (const id of childIds) {
|
||||
cancelledTasksRef.current.add(id);
|
||||
releasePausedTransfer(id);
|
||||
globalSftpTransferScheduler.cancel(id);
|
||||
}
|
||||
for (const cid of activeChildIdsRef.current.get(transferId) ?? []) {
|
||||
if (!childIdsToCancel.has(cid)) {
|
||||
childIdsToCancel.add(cid);
|
||||
if (taskToCancel) {
|
||||
childrenToCleanup.push({
|
||||
...taskToCancel,
|
||||
id: cid,
|
||||
parentTaskId: transferId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const cid of childIdsToCancel) {
|
||||
cancelledTasksRef.current.add(cid);
|
||||
markTransferCancelled(cid);
|
||||
globalSftpTransferScheduler.cancel(cid);
|
||||
}
|
||||
markTransferCancelledTree(transferId, [...childIdsToCancel]);
|
||||
// Keep refs in sync immediately so same-turn resolveConflict/resume sees cancel.
|
||||
const nextTransfers = transfersRef.current
|
||||
.filter((t) => t.parentTaskId !== transferId)
|
||||
.map((t) =>
|
||||
t.id === transferId
|
||||
? { ...t, status: "cancelled" as TransferStatus, endTime: Date.now(), conflict: undefined }
|
||||
: t,
|
||||
);
|
||||
setTransfers(nextTransfers);
|
||||
conflictsRef.current = conflictsRef.current.filter((c) => c.transferId !== transferId && !childIdsToCancel.has(c.transferId));
|
||||
markTransferCancelledTree(transferId, [...childIds]);
|
||||
conflictsRef.current = conflictsRef.current.filter((conflict) =>
|
||||
conflict.transferId !== transferId && !childIds.has(conflict.transferId));
|
||||
setConflicts(conflictsRef.current);
|
||||
|
||||
await cancelBackendTransfers([transferId, ...childIdsToCancel]);
|
||||
if (taskToCancel) await cleanupTaskArtifacts(taskToCancel);
|
||||
// Child stages are keyed by per-file transferId — clean each known child.
|
||||
for (const child of childrenToCleanup) {
|
||||
try {
|
||||
await cleanupTaskArtifacts(child);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
const ids = new Set([transferId, ...childIds]);
|
||||
const retainedTasks = (taskToCancel ? [taskToCancel, ...children] : children)
|
||||
.map((task) => ({ task, ...sftpTransferCenterStore.observeTaskSettlement(task) }));
|
||||
const failedIds = new Set(await cancelBackendTransfers([...ids]));
|
||||
await sftpTransferCenterStore.settleCancellation(
|
||||
transferId, ids, failedIds, retainedTasks,
|
||||
async () => {
|
||||
setTransfers(sftpTransferCenterStore.getOwnerTasks(ownerId));
|
||||
for (const id of ids) clearCancelledTask(id);
|
||||
clearTransferCancelledTree(transferId, [...childIds]);
|
||||
if (failedIds.size > 0) {
|
||||
// Preserve stages while any writer could not be cancelled. The
|
||||
// fresh recovery walk reuses ids only after this settlement.
|
||||
const recoveryIds = [...ids];
|
||||
for (let offset = 0; offset < recoveryIds.length; offset += 32) {
|
||||
await Promise.all(recoveryIds.slice(offset, offset + 32).map(async (id) => {
|
||||
try { await netcattyBridge.get()?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (taskToCancel && sftpTransferCenterStore.getTask(transferId)?.status === "cancelled") {
|
||||
try { await cleanupTaskArtifacts(taskToCancel); } catch { /* best-effort */ }
|
||||
}
|
||||
for (const child of children) {
|
||||
if (sftpTransferCenterStore.getTask(child.id)?.status === "completed") continue;
|
||||
try { await cleanupTaskArtifacts(child); } catch { /* best-effort */ }
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
},
|
||||
[cancelBackendTransfers, cleanupTaskArtifacts, releasePausedTransfer, setTransfers],
|
||||
[cancelBackendTransfers, clearCancelledTask, cleanupTaskArtifacts, ownerId, releasePausedTransfer, setTransfers],
|
||||
);
|
||||
|
||||
// Soft pause/resume: single TransferRuntime entry (store soft-control +
|
||||
// dedicated hard reconnect). No panel-local soft-control dual path.
|
||||
// All controls go through the process-global runtime, including cancellation
|
||||
// settlement. A panel must never clear cancellation latches on Resume.
|
||||
const resumeTransfer = useCallback(async (transferId: string) => {
|
||||
// Clear sticky child cancel latches so re-walk can retry same child ids.
|
||||
clearCancelledTask(transferId);
|
||||
for (const child of transfersRef.current) {
|
||||
if (child.parentTaskId === transferId) clearCancelledTask(child.id);
|
||||
}
|
||||
for (const childId of activeChildIdsRef.current.get(transferId) ?? []) {
|
||||
clearCancelledTask(childId);
|
||||
}
|
||||
clearTransferCancelledTree(
|
||||
transferId,
|
||||
[...(activeChildIdsRef.current.get(transferId) ?? [])],
|
||||
);
|
||||
await transferRuntime.resume(transferId);
|
||||
}, [clearCancelledTask]);
|
||||
}, []);
|
||||
|
||||
const prioritizeTransfer = useCallback((transferId: string) => {
|
||||
globalSftpTransferScheduler.prioritize(transferId);
|
||||
@@ -2081,7 +2254,6 @@ export const useSftpTransfers = ({
|
||||
await transferRuntime.pause(taskId);
|
||||
},
|
||||
resume: async (taskId: string) => {
|
||||
// Use resumeTransfer so cancel latches are cleared before soft/hard resume.
|
||||
await resumeTransfer(taskId);
|
||||
},
|
||||
cancel: cancelTransfer,
|
||||
@@ -2106,7 +2278,7 @@ export const useSftpTransfers = ({
|
||||
activeTransfersCount,
|
||||
startTransfer,
|
||||
downloadToLocal,
|
||||
cancelTransfer,
|
||||
cancelTransfer: transferRuntime.cancel,
|
||||
// Single process-level control surface (TransferRuntime).
|
||||
pauseTransfer: async (transferId: string) => {
|
||||
await transferRuntime.pause(transferId);
|
||||
|
||||
@@ -1622,6 +1622,46 @@ test("orphan compressed upload resume and cancel keep using the compression job"
|
||||
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
|
||||
});
|
||||
|
||||
test("compressed upload completing during cancel IPC keeps its completed paint", async (t) => {
|
||||
const calls: string[] = [];
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
t.after(() => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
|
||||
let storeRef: ReturnType<typeof createSftpTransferCenterStore> | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
netcatty: {
|
||||
// Inactive-compression responses report success even after the upload
|
||||
// already finished; the completion repaint must survive the cancel.
|
||||
cancelCompressedUpload: async (id: string) => {
|
||||
calls.push(`cancel:${id}`);
|
||||
storeRef?.ingestBackgroundEvent({ type: "completed", transferId: id });
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const store = createSftpTransferCenterStore();
|
||||
storeRef = store;
|
||||
store.publishOwner("closed-panel", [{
|
||||
...makeTask("compressed-race", "transferring"),
|
||||
fileName: "done (compressed)",
|
||||
isDirectory: true,
|
||||
phase: "compressing",
|
||||
controlKind: "compressed-upload",
|
||||
} as TransferTask]);
|
||||
|
||||
await store.cancel("compressed-race");
|
||||
|
||||
assert.deepEqual(calls, ["cancel:compressed-race"]);
|
||||
assert.equal(store.getSnapshot().tasks[0]?.status, "completed");
|
||||
});
|
||||
|
||||
test("orphan cancel marks process-global cancel so surviving walks stop", async (t) => {
|
||||
const {
|
||||
isTransferCancelledFlag,
|
||||
@@ -1650,6 +1690,10 @@ test("orphan cancel marks process-global cancel so surviving walks stop", async
|
||||
}]);
|
||||
await store.cancel("walk-1");
|
||||
assert.equal(isTransferCancelledFlag("walk-1"), true);
|
||||
const resuming = store.resume("walk-1");
|
||||
unregisterTransferWalk("walk-1");
|
||||
await resuming;
|
||||
assert.equal(isTransferCancelledFlag("walk-1"), false);
|
||||
assert.equal(store.getSnapshot().tasks.find((row) => row.id === "walk-1")?.status, "cancelled");
|
||||
});
|
||||
|
||||
@@ -2706,6 +2750,39 @@ test("an interrupted task without its old controller can still be cancelled", as
|
||||
assert.equal(store.getSnapshot().tasks[0]?.status, "cancelled");
|
||||
});
|
||||
|
||||
test("orphan cancel keeps failed work visible and leaves completed children alone", async (t) => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
t.after(() => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
const cancelledIds: string[] = [];
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
cancelTransfer: async (id: string) => {
|
||||
cancelledIds.push(id);
|
||||
return { success: id !== "unfinished" };
|
||||
},
|
||||
} },
|
||||
});
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.publishOwner("closed-panel", [
|
||||
{ ...makeTask("directory", "paused"), isDirectory: true },
|
||||
{ ...makeTask("finished", "completed"), parentTaskId: "directory" },
|
||||
{ ...makeTask("unfinished", "queued"), parentTaskId: "directory" },
|
||||
]);
|
||||
const finishedBeforeCancel = store.getTask("finished");
|
||||
|
||||
await store.cancel("directory");
|
||||
|
||||
assert.deepEqual(cancelledIds, ["directory", "unfinished"]);
|
||||
assert.equal(store.getTask("finished")?.status, finishedBeforeCancel?.status);
|
||||
assert.equal(store.getTask("unfinished")?.status, "attention");
|
||||
assert.equal(store.getTask("directory")?.status, "attention");
|
||||
assert.match(store.getTask("directory")?.error ?? "", /Could not cancel/);
|
||||
});
|
||||
|
||||
test("concurrent resume clicks adopt a task only once", async () => {
|
||||
let adoptCount = 0;
|
||||
const store = createSftpTransferCenterStore();
|
||||
@@ -3867,3 +3944,66 @@ for (const outcome of ["rejected", "stream-gone"] as const) {
|
||||
assert.equal(resumeCalls, 1, "obsolete resume must not issue another resume after the held run ends");
|
||||
});
|
||||
}
|
||||
|
||||
test("partial cancel failure resets recovered folder children so the re-walk can re-admit them", async (t) => {
|
||||
const { netcattyBridge } = await import("../../infrastructure/services/netcattyBridge");
|
||||
const { resetTransferCancelLatchesForTests, isTransferOrRootCancelled } =
|
||||
await import("./sftp/transferCancelLatch");
|
||||
const { resetTransferWalkRegistryForTests } = await import("./sftp/transferWalkRegistry");
|
||||
const originalGet = netcattyBridge.get;
|
||||
resetTransferCancelLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
t.after(() => {
|
||||
netcattyBridge.get = originalGet;
|
||||
resetTransferCancelLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
});
|
||||
// Only child2 fails to cancel; the folder must stay recoverable via Resume.
|
||||
let failChild2 = true;
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => ({ success: id !== "dir-child-2" || !failChild2 }),
|
||||
cleanupTransferArtifacts: async () => ({}),
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
const store = createSftpTransferCenterStore();
|
||||
const child = (id: string): TransferTask => ({
|
||||
...makeTask(id),
|
||||
parentTaskId: "dir-root",
|
||||
isDirectory: false,
|
||||
transferredBytes: 4,
|
||||
directoryEntryIndex: 0,
|
||||
directoryEntryIdentity: "a".repeat(64),
|
||||
});
|
||||
store.publishOwner("gone-panel", [
|
||||
{ ...makeTask("dir-root"), isDirectory: true, transferredBytes: 10, totalBytes: 20, directoryEntryIndex: 0 },
|
||||
child("dir-child-1"),
|
||||
child("dir-child-2"),
|
||||
]);
|
||||
await store.cancel("dir-root");
|
||||
|
||||
const rows = store.getSnapshot().tasks;
|
||||
const parent = rows.find((row) => row.id === "dir-root");
|
||||
const child1 = rows.find((row) => row.id === "dir-child-1");
|
||||
const child2 = rows.find((row) => row.id === "dir-child-2");
|
||||
assert.equal(parent?.status, "attention");
|
||||
assert.equal(parent?.error, "Could not cancel transfer. Please try again.");
|
||||
// A failed backend cancellation keeps the folder recoverable: successfully
|
||||
// cancelled siblings must not settle as terminal "cancelled", or admitTaskRun
|
||||
// would reject the recovery re-walk and leave the destination incomplete.
|
||||
assert.equal(child1?.status, "interrupted");
|
||||
assert.equal(child1?.error, undefined);
|
||||
assert.equal(child2?.status, "attention");
|
||||
assert.equal(isTransferOrRootCancelled("dir-root"), false, "recovery latch must be cleared");
|
||||
|
||||
// The directory re-walk (Resume) can re-admit the reset sibling.
|
||||
const incoming = { ...child("dir-child-1"), directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64) };
|
||||
assert.equal(store.admitTaskRun(incoming), "ready");
|
||||
assert.equal(store.getTask("dir-child-1")?.status, "transferring");
|
||||
|
||||
// Without any cancellation failure the tree still settles fully cancelled.
|
||||
failChild2 = false;
|
||||
await store.cancel("dir-root");
|
||||
const afterFullCancel = store.getSnapshot().tasks;
|
||||
for (const id of ["dir-root", "dir-child-1", "dir-child-2"]) {
|
||||
assert.equal(afterFullCancel.find((row) => row.id === id)?.status, "cancelled");
|
||||
}
|
||||
});
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
isTransferControlEpochCurrent,
|
||||
settleTransferControlEpochTree,
|
||||
} from "./sftp/transferControlEpoch";
|
||||
import { isTransferWalkInFlight } from "./sftp/transferWalkRegistry";
|
||||
import { isTransferWalkInFlight, waitForTransferWalkSettled } from "./sftp/transferWalkRegistry";
|
||||
import { settleCancelledTransferTree } from "./sftp/transferCancelOutcome";
|
||||
import {
|
||||
clearTransferCancelledTree,
|
||||
isTransferOrRootCancelled,
|
||||
markTransferCancelledTree,
|
||||
settleTransferCancelTree,
|
||||
@@ -93,6 +95,12 @@ export type DedicatedTransferResumeHandler = (task: TransferTask) => Promise<{
|
||||
resetCheckpoint?: boolean;
|
||||
}>;
|
||||
|
||||
interface RetainedCancellationTask {
|
||||
task: TransferTask;
|
||||
read(): TransferTask | undefined;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface SftpTransferCenterStore {
|
||||
subscribe(listener: Listener): () => void;
|
||||
/**
|
||||
@@ -121,7 +129,10 @@ export interface SftpTransferCenterStore {
|
||||
isResuming(taskId: string): boolean;
|
||||
pause(taskId: string): Promise<void>;
|
||||
resume(taskId: string): Promise<void>;
|
||||
/** Wait for backend cancellation requests; live walks settle separately. */
|
||||
cancel(taskId: string): Promise<void>;
|
||||
/** Publish one cancellation outcome after the old execution has settled. */
|
||||
settleCancellation(taskId: string, taskIds: ReadonlySet<string>, failedIds: ReadonlySet<string>, retainedTasks: readonly RetainedCancellationTask[], onSettled: () => Promise<void>): Promise<void>;
|
||||
retry(taskId: string): Promise<void>;
|
||||
prioritize(taskId: string): Promise<void>;
|
||||
dismiss(taskId: string): void;
|
||||
@@ -464,6 +475,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
let persistenceDirty = false;
|
||||
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const resumeInvocations = new Map<string, Promise<void>>();
|
||||
const cancelInvocations = new Map<string, Promise<void>>();
|
||||
const cancelSettlements = new Map<string, Promise<void>>();
|
||||
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
|
||||
const resumeRequests = new Map<string, symbol>();
|
||||
const resumeListeners = new Set<Listener>();
|
||||
@@ -818,8 +831,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
// Compressed soft-control is process-global: do not gate on a live panel owner.
|
||||
const liveCompressedJob = task?.controlKind === "compressed-upload"
|
||||
&& task.reconnectRequired !== true
|
||||
&& task.status !== "interrupted"
|
||||
&& task.status !== "attention";
|
||||
&& task.status !== "interrupted";
|
||||
if (liveCompressedJob && (action === "pause" || action === "resume" || action === "cancel")) {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (action === "pause") {
|
||||
@@ -879,8 +891,17 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
if (!result.success && result.reason) notify.warning(result.reason, "SFTP");
|
||||
return;
|
||||
}
|
||||
const result = await (bridge?.cancelCompressedUpload?.(taskId)
|
||||
?? { success: false });
|
||||
const result = await Promise.resolve(bridge?.cancelCompressedUpload?.(taskId)
|
||||
?? { success: false }).catch(() => ({ success: false }));
|
||||
// The upload may finish while this cancel is still awaiting IPC: its
|
||||
// completion event has already repainted the row terminal. The new
|
||||
// inactive-compression response still reports success, so recheck the
|
||||
// latest row before applying the cancellation result — otherwise Cancel
|
||||
// all misreports a successfully completed upload as cancelled.
|
||||
const latestAfterCancelIpc = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (latestAfterCancelIpc && ["completed", "failed", "cancelled"].includes(latestAfterCancelIpc.status)) {
|
||||
return;
|
||||
}
|
||||
if (!result.success) {
|
||||
const reason = "Could not cancel the compressed upload.";
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
@@ -889,6 +910,13 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
error: reason,
|
||||
speed: 0,
|
||||
} : candidate);
|
||||
if (isTransferOrRootCancelled(taskId)) {
|
||||
// The row is kept in attention for recovery instead of settling as
|
||||
// cancelled. Drop the pre-installed cancellation latch now, or a
|
||||
// later Resume is rejected outright by admitTaskRun's cancelled-root
|
||||
// check (compressed uploads have no runWalk settlement to clear it).
|
||||
clearTransferCancelledTree(taskId);
|
||||
}
|
||||
emit();
|
||||
notify.warning(reason, "SFTP");
|
||||
return;
|
||||
@@ -1301,9 +1329,9 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
emit();
|
||||
}
|
||||
}
|
||||
if (!controller && action === "cancel" && task && ["paused", "interrupted", "attention", "pending", "queued", "transferring", "pausing"].includes(task.status)) {
|
||||
if (!controller && action === "cancel" && task && !["completed", "cancelled"].includes(task.status)) {
|
||||
const childIds = tasks
|
||||
.filter((candidate) => candidate.parentTaskId === taskId)
|
||||
.filter((candidate) => candidate.parentTaskId === taskId && !["completed", "cancelled", "failed"].includes(candidate.status))
|
||||
.map((candidate) => candidate.id);
|
||||
// Stop surviving processTransfer walks immediately (panel-local
|
||||
// cancelledTasksRef is gone after unmount).
|
||||
@@ -1315,33 +1343,43 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
await netcattyBridge.get()?.cancelTransfer?.(taskId);
|
||||
for (const childId of childIds) {
|
||||
try { await netcattyBridge.get()?.cancelTransfer?.(childId); } catch { /* best-effort */ }
|
||||
const failedIds = new Set<string>();
|
||||
const cancelIds = [taskId, ...childIds];
|
||||
const cancelledIdSet = new Set(cancelIds);
|
||||
const retainedTasks = tasks.filter((candidate) => cancelledIdSet.has(candidate.id))
|
||||
.map((task) => ({ task, ...store.observeTaskSettlement(task) }));
|
||||
for (let offset = 0; offset < cancelIds.length; offset += 32) {
|
||||
await Promise.all(cancelIds.slice(offset, offset + 32).map(async (id) => {
|
||||
try {
|
||||
const result: unknown = await netcattyBridge.get()?.cancelTransfer?.(id);
|
||||
if (result && typeof result === "object" && "success" in result && result.success === false) {
|
||||
failedIds.add(id);
|
||||
}
|
||||
} catch {
|
||||
failedIds.add(id);
|
||||
}
|
||||
}));
|
||||
}
|
||||
await store.settleCancellation(taskId, cancelledIdSet, failedIds, retainedTasks, async () => {
|
||||
clearTransferCancelledTree(taskId, childIds);
|
||||
if (failedIds.size > 0) {
|
||||
const recoveryIds = cancelIds;
|
||||
for (let offset = 0; offset < recoveryIds.length; offset += 32) {
|
||||
await Promise.all(recoveryIds.slice(offset, offset + 32).map(async (id) => {
|
||||
try { await netcattyBridge.get()?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}));
|
||||
}
|
||||
} else if (tasks.find((candidate) => candidate.id === taskId)?.status === "cancelled") {
|
||||
try {
|
||||
await netcattyBridge.get()?.cleanupTransferArtifacts?.({
|
||||
transferId: taskId,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
stagedTargetPath: task.stagedTargetPath,
|
||||
});
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
} catch {
|
||||
// Best-effort backend cancel when the owning panel is gone / no window.
|
||||
}
|
||||
try {
|
||||
await netcattyBridge.get()?.cleanupTransferArtifacts?.({
|
||||
transferId: taskId,
|
||||
sourcePath: task.sourcePath,
|
||||
targetPath: task.targetPath,
|
||||
stagedTargetPath: task.stagedTargetPath,
|
||||
});
|
||||
} catch {
|
||||
// best-effort temp/.part cleanup
|
||||
}
|
||||
const cancelIds = new Set([taskId, ...childIds]);
|
||||
tasks = tasks.map((candidate) => cancelIds.has(candidate.id) ? {
|
||||
...candidate,
|
||||
status: "cancelled",
|
||||
error: undefined,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
conflict: undefined,
|
||||
} : candidate);
|
||||
});
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
@@ -1794,10 +1832,28 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
|
||||
},
|
||||
pause(taskId) {
|
||||
if (cancelInvocations.has(taskId) || isTransferOrRootCancelled(taskId)) return Promise.resolve();
|
||||
clearResumeRequest(taskId);
|
||||
return invoke(taskId, "pause");
|
||||
},
|
||||
async resume(taskId) {
|
||||
// A cancelled scheduler job cannot be soft-resumed. Wait for the cancel
|
||||
// outcome and fresh re-walk rather than unlatching the old walk.
|
||||
const cancelling = cancelInvocations.get(taskId);
|
||||
if (cancelling) {
|
||||
await cancelling;
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task || ["completed", "cancelled", "failed"].includes(task.status)) return;
|
||||
}
|
||||
// Failed cancellation reports immediately, but its old walk must still
|
||||
// settle before Resume can start a fresh scan. A retried Cancel may have
|
||||
// replaced the awaited outcome, so drain the latest settlement as well.
|
||||
while (cancelSettlements.has(taskId)) await cancelSettlements.get(taskId);
|
||||
const afterCancellation = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!afterCancellation || ["completed", "cancelled"].includes(afterCancellation.status)) return;
|
||||
// Cancel all latches every selected tree before its bounded IPC batches.
|
||||
// A row-level Resume must not revive a tree whose batch has not run yet.
|
||||
if (isTransferOrRootCancelled(taskId)) return;
|
||||
const request = Symbol("resume");
|
||||
resumeRequests.set(taskId, request);
|
||||
notifyResume();
|
||||
@@ -1913,9 +1969,52 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
|
||||
}
|
||||
},
|
||||
async settleCancellation(taskId, taskIds, failedIds, retainedTasks, onSettled) {
|
||||
const walkStillRunning = isTransferWalkInFlight(taskId);
|
||||
const settlement = (async () => {
|
||||
await waitForTransferWalkSettled(taskId);
|
||||
// A later cancel attempt owns the outcome, even if this older attempt
|
||||
// failed. Never repaint a successful retry as a failed cancellation.
|
||||
if (cancelSettlements.get(taskId) !== settlement) return;
|
||||
tasks = settleCancelledTransferTree(
|
||||
tasks, taskId, taskIds, failedIds,
|
||||
retainedTasks.map((retained) => retained.read() ?? retained.task),
|
||||
);
|
||||
await onSettled();
|
||||
emit();
|
||||
})().finally(() => {
|
||||
for (const retained of retainedTasks) retained.dispose();
|
||||
if (cancelSettlements.get(taskId) === settlement) cancelSettlements.delete(taskId);
|
||||
});
|
||||
cancelSettlements.set(taskId, settlement);
|
||||
if (walkStillRunning) {
|
||||
if (failedIds.size > 0) {
|
||||
tasks = tasks.map((task) => task.id === taskId
|
||||
&& !["completed", "failed"].includes(task.status)
|
||||
? { ...task, status: "attention", error: "Could not cancel transfer. Please try again.", speed: 0 }
|
||||
: task);
|
||||
emit();
|
||||
}
|
||||
// Backend requests are done: let Cancel all dispatch its next batch.
|
||||
// Resume, final status, and cleanup still wait on this settlement.
|
||||
void settlement.catch(() => {});
|
||||
return;
|
||||
}
|
||||
await settlement;
|
||||
},
|
||||
cancel(taskId) {
|
||||
clearResumeRequest(taskId);
|
||||
return invoke(taskId, "cancel");
|
||||
const existing = cancelInvocations.get(taskId);
|
||||
if (existing) return existing;
|
||||
bumpTransferControlEpoch(taskId);
|
||||
for (const task of tasks) {
|
||||
if (task.parentTaskId === taskId) bumpTransferControlEpoch(task.id);
|
||||
}
|
||||
const running = invoke(taskId, "cancel").finally(() => {
|
||||
if (cancelInvocations.get(taskId) === running) cancelInvocations.delete(taskId);
|
||||
});
|
||||
cancelInvocations.set(taskId, running);
|
||||
return running;
|
||||
},
|
||||
async retry(taskId) {
|
||||
const ownerId = findOwner(taskId);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import {
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_SHOW_TRAY_ICON,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
@@ -42,6 +43,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
showTrayIcon: boolean;
|
||||
autoLaunchEnabled: boolean;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
@@ -62,6 +64,7 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
showTrayIcon,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
@@ -173,6 +176,24 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync the show-tray-icon setting. Runs after the close-to-tray
|
||||
// effect above (declaration order) so a hidden icon destroys the tray that
|
||||
// close-to-tray's mount-time push may have just created.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
// Update main process tray visibility (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setShowTrayIcon) {
|
||||
bridge.setShowTrayIcon(showTrayIcon).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set show-tray-icon:', err);
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SHOW_TRAY_ICON, showTrayIcon ? 'true' : 'false');
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_TRAY_ICON, showTrayIcon);
|
||||
}, [enabled, showTrayIcon, 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.
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
type Listener = (sessionId: string) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
/** Renderer-local fan-out of the terminal's existing completion detection. */
|
||||
export function publishTerminalCommandCompletion(sessionId: string): void {
|
||||
for (const listener of listeners) listener(sessionId);
|
||||
}
|
||||
|
||||
export function subscribeTerminalCommandCompletion(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
type Listener = () => void;
|
||||
|
||||
export type TerminalCwdSource = "osc7" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
|
||||
export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backend" | "snapshot" | "stale" | "unknown";
|
||||
|
||||
/**
|
||||
* Live terminal CWD map + version token.
|
||||
|
||||
@@ -174,10 +174,13 @@ export function useAgentDiscovery(
|
||||
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: matchPath }
|
||||
: match.command === 'opencode'
|
||||
? { ...(ea.env ?? {}), OPENCODE_BIN: matchPath }
|
||||
: ea.env;
|
||||
: match.command === 'mimo'
|
||||
? { ...(ea.env ?? {}), MIMOCODE_BIN: matchPath }
|
||||
: ea.env;
|
||||
const envChanged =
|
||||
(match.command === 'claude' && ea.env?.CLAUDE_CODE_EXECUTABLE !== matchPath)
|
||||
|| (match.command === 'opencode' && ea.env?.OPENCODE_BIN !== matchPath);
|
||||
|| (match.command === 'opencode' && ea.env?.OPENCODE_BIN !== matchPath)
|
||||
|| (match.command === 'mimo' && ea.env?.MIMOCODE_BIN !== matchPath);
|
||||
const versionChanged = Boolean(match.version) && ea.cliVersion !== match.version;
|
||||
if (currentArgs !== newArgs || backendChanged || envChanged || versionChanged) {
|
||||
changed = true;
|
||||
@@ -220,7 +223,9 @@ export function useAgentDiscovery(
|
||||
? { env: { CLAUDE_CODE_EXECUTABLE: agent.binPath || agent.path || '' } }
|
||||
: agent.command === 'opencode'
|
||||
? { env: { OPENCODE_BIN: agent.binPath || agent.path || '' } }
|
||||
: {}),
|
||||
: agent.command === 'mimo'
|
||||
? { env: { MIMOCODE_BIN: agent.binPath || agent.path || '' } }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
[],
|
||||
|
||||
@@ -10,5 +10,11 @@ export const useClipboardBackend = () => {
|
||||
return typeof text === "string" ? text : "";
|
||||
}, []);
|
||||
|
||||
return { readClipboardText };
|
||||
const writeClipboardText = useCallback(async (text: string): Promise<boolean> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeClipboardText) return false;
|
||||
return await bridge.writeClipboardText(text) === true;
|
||||
}, []);
|
||||
|
||||
return { readClipboardText, writeClipboardText };
|
||||
};
|
||||
@@ -29,6 +29,9 @@ for (const codexRuntime of ['sdk', 'app-server'] as const) {
|
||||
const config = { ...agent, codexRuntime };
|
||||
await act(async () => { root = TestRenderer.create(React.createElement(Harness, { config })); });
|
||||
assert.equal(result.model, null);
|
||||
// The unresolved probe must be distinguishable from "no locked model"
|
||||
// so consumers can hide manual entry until it resolves (#3535).
|
||||
assert.equal(result.isPending, true);
|
||||
let sent = false;
|
||||
const sending = result.loadModel().then(model => { sent = true; return model; });
|
||||
await Promise.resolve();
|
||||
@@ -38,9 +41,11 @@ for (const codexRuntime of ['sdk', 'app-server'] as const) {
|
||||
await act(async () => { resolve({ customConfig: { model: 'glm-5' } }); await sending; });
|
||||
assert.equal(await sending, 'glm-5');
|
||||
assert.equal(result.model, 'glm-5');
|
||||
assert.equal(result.isPending, false);
|
||||
// A different agent must not inherit the previous home/model or promise.
|
||||
await act(async () => { root.update(React.createElement(Harness, { config: { ...config, env: { CODEX_HOME: '/fixture/other' } } })); });
|
||||
assert.equal(result.model, null);
|
||||
assert.equal(result.isPending, true);
|
||||
assert.equal(calls.length, 2);
|
||||
await act(async () => { resolve({ customConfig: null }); await result.loadModel(); });
|
||||
assert.equal(result.model, null);
|
||||
|
||||
@@ -29,5 +29,11 @@ export function useCodexConfigModel(agent: ExternalAgentConfig | undefined, isVi
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isVisible, loadModel]);
|
||||
return { model: result?.loadModel === loadModel ? result.model : null, loadModel };
|
||||
return {
|
||||
model: result?.loadModel === loadModel ? result.model : null,
|
||||
loadModel,
|
||||
// `model` is null both while the probe is pending and when no locked model
|
||||
// exists; this flag lets consumers distinguish the unresolved state.
|
||||
isPending: result?.loadModel !== loadModel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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 type { TransferTask } from "../../domain/models";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { sftpTransferCenterStore as store } from "./sftpTransferCenterStore";
|
||||
import { useGlobalSftpTransferActions } from "./useGlobalSftpTransferActions";
|
||||
import { isTransferOrRootCancelled, resetTransferCancelLatchesForTests } from "./sftp/transferCancelLatch";
|
||||
import { resetTransferRuntimeRunsForTests, transferRuntime } from "./sftp/transferRuntime";
|
||||
import { resetTransferWalkRegistryForTests } from "./sftp/transferWalkRegistry";
|
||||
|
||||
test("Cancel all releases a skipped late failure so one Retry executes while the old walk unwinds", async (t) => {
|
||||
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const originalGet = netcattyBridge.get;
|
||||
const tasks: TransferTask[] = Array.from({ length: 33 }, (_, index) => ({
|
||||
id: `late-failure-${index}`, fileName: `${index}.bin`,
|
||||
sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
|
||||
sourceConnectionId: "local", targetConnectionId: "remote", targetHostId: "host",
|
||||
direction: "upload", status: "transferring", totalBytes: 8, transferredBytes: 0,
|
||||
speed: 0, startTime: Date.now() + index, isDirectory: false, resumable: true,
|
||||
}));
|
||||
const later = tasks[32];
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let actions: ReturnType<typeof useGlobalSftpTransferActions> | undefined;
|
||||
function Probe() {
|
||||
actions = useGlobalSftpTransferActions(store.getSnapshot().tasks);
|
||||
return null;
|
||||
}
|
||||
store.publishOwner("closed-panel", tasks);
|
||||
const walking = transferRuntime.runWalk(later.id, async () => { await held; });
|
||||
t.after(async () => {
|
||||
release();
|
||||
await walking;
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
store.setDedicatedResumeHandler(null);
|
||||
for (const task of tasks) store.dismiss(task.id);
|
||||
netcattyBridge.get = originalGet;
|
||||
resetTransferCancelLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
resetTransferRuntimeRunsForTests();
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: previousAct });
|
||||
});
|
||||
const cancellations: string[] = [];
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => {
|
||||
cancellations.push(id);
|
||||
if (id === tasks[0].id) store.patchTask(later.id, { status: "failed", error: "Connection lost" });
|
||||
return { success: true };
|
||||
},
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
clearPendingTransferCancel: async () => {}, cleanupTransferArtifacts: async () => {},
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
let retries = 0;
|
||||
store.setDedicatedResumeHandler(async () => { retries += 1; return { success: true }; });
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => { await actions!.cancelAll(); });
|
||||
assert.equal(cancellations.length, 32, "the late failure must be skipped by the second batch");
|
||||
assert.equal(store.getTask(later.id)?.status, "failed");
|
||||
assert.equal(store.getTask(later.id)?.error, "Connection lost");
|
||||
assert.equal(transferRuntime.isWalkInFlight(later.id), true);
|
||||
|
||||
await store.retry(later.id);
|
||||
|
||||
assert.equal(retries, 1, "one Retry must reach recovery instead of being swallowed by the pre-latch");
|
||||
assert.equal(store.getTask(later.id)?.status, "completed");
|
||||
assert.equal(isTransferOrRootCancelled(later.id), false);
|
||||
});
|
||||
|
||||
for (const firstCancelSucceeds of [true, false]) {
|
||||
test(`Cancel all dispatches later batches before walk settlement: success=${firstCancelSucceeds}`, async (t) => {
|
||||
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const originalGet = netcattyBridge.get;
|
||||
const tasks: TransferTask[] = Array.from({ length: 33 }, (_, index) => ({
|
||||
id: `dispatch-${firstCancelSucceeds}-${index}`, fileName: `${index}.bin`,
|
||||
sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
|
||||
sourceConnectionId: "local", targetConnectionId: "remote", targetHostId: "host",
|
||||
direction: "upload", status: "transferring", totalBytes: 8, transferredBytes: 0,
|
||||
speed: 0, startTime: Date.now() + index, isDirectory: false, resumable: true,
|
||||
}));
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => { release = resolve; });
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let actions: ReturnType<typeof useGlobalSftpTransferActions> | undefined;
|
||||
let cancelling: Promise<void> | undefined;
|
||||
function Probe() {
|
||||
actions = useGlobalSftpTransferActions(store.getSnapshot().tasks);
|
||||
return null;
|
||||
}
|
||||
store.publishOwner("closed-panel", tasks);
|
||||
const first = tasks[0].id;
|
||||
const walking = transferRuntime.runWalk(first, async () => { await held; });
|
||||
t.after(async () => {
|
||||
release();
|
||||
await walking;
|
||||
await act(async () => { await cancelling; renderer?.unmount(); });
|
||||
store.setDedicatedResumeHandler(null);
|
||||
for (const task of tasks) store.dismiss(task.id);
|
||||
netcattyBridge.get = originalGet;
|
||||
resetTransferCancelLatchesForTests();
|
||||
resetTransferWalkRegistryForTests();
|
||||
resetTransferRuntimeRunsForTests();
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: previousAct });
|
||||
});
|
||||
const calls: string[] = [];
|
||||
const cleaned: string[] = [];
|
||||
let active = 0;
|
||||
let peak = 0;
|
||||
let recoveryCalls = 0;
|
||||
netcattyBridge.get = () => ({
|
||||
cancelTransfer: async (id: string) => {
|
||||
calls.push(id);
|
||||
peak = Math.max(peak, ++active);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
active -= 1;
|
||||
return { success: id !== first || firstCancelSucceeds };
|
||||
},
|
||||
clearPendingTransferCancel: async () => {},
|
||||
cleanupTransferArtifacts: async ({ transferId }: { transferId: string }) => { cleaned.push(transferId); },
|
||||
} as unknown as ReturnType<typeof netcattyBridge.get>);
|
||||
store.setDedicatedResumeHandler(async () => { recoveryCalls += 1; return { success: true }; });
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
cancelling = actions!.cancelAll();
|
||||
for (let tick = 0; tick < 5; tick += 1) await new Promise((resolve) => setImmediate(resolve));
|
||||
});
|
||||
assert.equal(calls.length, 33, "the later active stream must receive cancellation before the first walk finishes");
|
||||
assert.ok(peak > 1 && peak <= 32, "backend cancellation remains bounded");
|
||||
assert.equal(transferRuntime.isWalkInFlight(first), true);
|
||||
assert.equal(isTransferOrRootCancelled(first), true, "dispatch completion must not clear the old walk latch");
|
||||
assert.equal(cleaned.includes(first), false, "artifact cleanup still waits for settlement");
|
||||
let resumeFinished = false;
|
||||
const resuming = store.resume(first).then(() => { resumeFinished = true; });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(resumeFinished, false, "Resume must still wait for the cancelling walk");
|
||||
assert.equal(recoveryCalls, 0);
|
||||
release();
|
||||
await Promise.all([walking, cancelling, resuming]);
|
||||
assert.equal(recoveryCalls, firstCancelSucceeds ? 0 : 1);
|
||||
assert.equal(store.getTask(first)?.status, firstCancelSucceeds ? "cancelled" : "completed");
|
||||
});
|
||||
}
|
||||
@@ -1,28 +1,95 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { TransferTask } from "../../domain/models";
|
||||
import {
|
||||
getGlobalTransferBatchEligibility,
|
||||
listGloballyCancellableTransferIds,
|
||||
listGloballyPausableTransferIds,
|
||||
listGloballyResumableTransferIds,
|
||||
} from "../../domain/sftpTransferActions";
|
||||
import { sftpTransferCenterStore } from "./sftpTransferCenterStore";
|
||||
import { globalSftpTransferScheduler } from "./sftp/globalTransferScheduler";
|
||||
import { clearTransferCancelledTree, markTransferCancelledTree } from "./sftp/transferCancelLatch";
|
||||
import { transferRuntime } from "./sftp/transferRuntime";
|
||||
|
||||
export function useGlobalSftpTransferActions(tasks: readonly TransferTask[]) {
|
||||
const cancellingRef = useRef(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const batchEligibility = useMemo(
|
||||
() => getGlobalTransferBatchEligibility(tasks),
|
||||
[tasks],
|
||||
);
|
||||
const pauseAll = useCallback(() => {
|
||||
// Ignore clicks racing a cancelAll batch: re-enabling these tasks would
|
||||
// restart work cancelAll has already pre-cancelled at the scheduler.
|
||||
if (cancellingRef.current) return;
|
||||
for (const taskId of listGloballyPausableTransferIds(tasks)) {
|
||||
void sftpTransferCenterStore.pause(taskId);
|
||||
}
|
||||
}, [tasks]);
|
||||
const resumeAll = useCallback(() => {
|
||||
if (cancellingRef.current) return;
|
||||
for (const taskId of listGloballyResumableTransferIds(tasks)) {
|
||||
void sftpTransferCenterStore.resume(taskId);
|
||||
}
|
||||
}, [tasks]);
|
||||
|
||||
return { batchEligibility, pauseAll, resumeAll };
|
||||
const cancelAll = useCallback(async () => {
|
||||
if (cancellingRef.current) return;
|
||||
cancellingRef.current = true;
|
||||
setIsCancelling(true);
|
||||
const currentTasks = transferRuntime.getSnapshot().tasks;
|
||||
const ids = listGloballyCancellableTransferIds(currentTasks);
|
||||
try {
|
||||
// Stop queued work before yielding to IPC, including children of folder
|
||||
// transfers. Otherwise later batches could start while earlier ones stop.
|
||||
const selected = new Set(ids);
|
||||
const children = new Map<string, string[]>();
|
||||
for (const task of currentTasks) {
|
||||
if (!task.parentTaskId || !selected.has(task.parentTaskId)
|
||||
|| ["completed", "failed", "cancelled"].includes(task.status)) continue;
|
||||
const idsForParent = children.get(task.parentTaskId) ?? [];
|
||||
idsForParent.push(task.id);
|
||||
children.set(task.parentTaskId, idsForParent);
|
||||
globalSftpTransferScheduler.cancel(task.id);
|
||||
}
|
||||
for (const id of ids) {
|
||||
markTransferCancelledTree(id, children.get(id));
|
||||
globalSftpTransferScheduler.cancel(id);
|
||||
}
|
||||
// Bound IPC work for large queues while active transfers stop promptly.
|
||||
for (let offset = 0; offset < ids.length; offset += 32) {
|
||||
await Promise.all(ids.slice(offset, offset + 32).map(async (taskId) => {
|
||||
const current = transferRuntime.getTask(taskId);
|
||||
// "failed" is terminal here just like the initial eligibility filter:
|
||||
// a queued task can fail naturally while its cancel batch awaits IPC,
|
||||
// and cancel's owner/orphan recovery paths would repaint the row as
|
||||
// cancelled, hiding the real failure and its diagnostic.
|
||||
if (!current || ["completed", "failed", "cancelled"].includes(current.status)) {
|
||||
// No cancel invocation will own this pre-latch. Release it here so
|
||||
// Retry is not discarded while the finished walk is unwinding.
|
||||
clearTransferCancelledTree(taskId, children.get(taskId));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await transferRuntime.cancel(taskId);
|
||||
} catch {
|
||||
// The row is kept in attention for recovery, so the pre-installed
|
||||
// cancellation latch must be dropped or a later Resume is rejected
|
||||
// outright by admitTaskRun's cancelled-root check.
|
||||
clearTransferCancelledTree(taskId, children.get(taskId));
|
||||
sftpTransferCenterStore.patchTask(taskId, {
|
||||
status: "attention",
|
||||
error: "Could not cancel transfer. Please try again.",
|
||||
});
|
||||
}
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
cancellingRef.current = false;
|
||||
setIsCancelling(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { batchEligibility, pauseAll, resumeAll, cancelAll, isCancelling };
|
||||
}
|
||||
@@ -201,3 +201,96 @@ test("managed sync writes the latest persisted hosts, not the pre-edit snapshot"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("managed source deletion preserves the file when its contents cannot be read", 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);
|
||||
const original = "# Keep this comment\nHost outside\n HostName outside.example.com\n"
|
||||
+ "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK\n"
|
||||
+ "Host managed\n HostName managed.example.com\n"
|
||||
+ "# END NETCATTY MANAGED\n";
|
||||
let fileContent = original;
|
||||
let writes = 0;
|
||||
let failRead = true;
|
||||
(dom.window as unknown as Record<string, unknown>).netcatty = {
|
||||
readLocalFile: async () => {
|
||||
if (failRead) throw new Error("EIO: simulated read failure");
|
||||
return new TextEncoder().encode(fileContent).buffer;
|
||||
},
|
||||
writeLocalFile: async (_filePath: string, buffer: ArrayBuffer) => {
|
||||
writes += 1;
|
||||
fileContent = new TextDecoder().decode(buffer);
|
||||
},
|
||||
};
|
||||
|
||||
let clearAndRemoveSource!: ReturnType<typeof useManagedSourceSync>["clearAndRemoveSource"];
|
||||
function Harness() {
|
||||
({ clearAndRemoveSource } = useManagedSourceSync({
|
||||
hosts: [],
|
||||
managedSources: [],
|
||||
onUpdateManagedSources: () => {},
|
||||
onReadPersistedHosts: async () => [],
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
|
||||
await assert.rejects(clearAndRemoveSource(managedSource), /Could not clear managed SSH config source/);
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(fileContent, original);
|
||||
|
||||
failRead = false;
|
||||
await clearAndRemoveSource(managedSource);
|
||||
assert.equal(writes, 1);
|
||||
assert.match(fileContent, /Host outside/);
|
||||
assert.doesNotMatch(fileContent, /Host managed/);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -55,26 +55,20 @@ export const useManagedSourceSync = ({
|
||||
);
|
||||
|
||||
const readExistingFileContent = useCallback(
|
||||
async (filePath: string): Promise<string | null> => {
|
||||
async (filePath: string): Promise<string> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readLocalFile) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const buffer = await bridge.readLocalFile(filePath);
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(buffer);
|
||||
} catch {
|
||||
// File might not exist yet
|
||||
return null;
|
||||
throw new Error("readLocalFile not available");
|
||||
}
|
||||
const buffer = await bridge.readLocalFile(filePath);
|
||||
return new TextDecoder().decode(buffer);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const mergeWithExistingContent = useCallback(
|
||||
(
|
||||
existingContent: string | null,
|
||||
existingContent: string,
|
||||
managedHosts: Host[],
|
||||
allHosts: Host[],
|
||||
): string => {
|
||||
@@ -82,7 +76,7 @@ export const useManagedSourceSync = ({
|
||||
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
|
||||
|
||||
if (!existingContent) {
|
||||
// No existing file, just wrap the managed content
|
||||
// The existing file is empty; wrap the managed content.
|
||||
return `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ import {
|
||||
getSessionConnectionLabel,
|
||||
} from '../../domain/sessionTabTitle';
|
||||
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
|
||||
import { syncSessionHostLabels } from '../../domain/syncSessionHostLabels';
|
||||
import { useVaultSnapshotField } from './vaultSnapshotStore';
|
||||
|
||||
export function addWorkspaceIfMissing(
|
||||
workspaces: Workspace[],
|
||||
@@ -267,6 +269,20 @@ export const useSessionState = ({
|
||||
}
|
||||
}, [initialRestoreState.activeTabId]);
|
||||
|
||||
// Keep open sessions' connection label in sync with the vault. Sessions
|
||||
// snapshot `hostLabel` at connect time, so renaming a host would otherwise
|
||||
// leave its already-open tabs (and merged-workspace titles derived from
|
||||
// those labels) showing the old name until the session is closed. The
|
||||
// generated-title sync effect below re-runs on this session change and
|
||||
// refreshes synthesized workspace titles too.
|
||||
const vaultHosts = useVaultSnapshotField('hosts');
|
||||
useEffect(() => {
|
||||
if (vaultHosts.length === 0) return;
|
||||
// The helper returns the same array reference when nothing changed, so
|
||||
// React bails out of the state update.
|
||||
setSessions(prev => syncSessionHostLabels(prev, vaultHosts));
|
||||
}, [vaultHosts]);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 type { Host } from "../../domain/models";
|
||||
import { EMPTY_VAULT_SNAPSHOT, publishVaultSnapshot } from "./vaultSnapshotStore";
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
const testHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "web-01",
|
||||
hostname: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
os: "linux",
|
||||
tags: [],
|
||||
...overrides,
|
||||
} as Host);
|
||||
|
||||
test("rewriting the host label updates already-open session tabs on that host", async (t) => {
|
||||
publishVaultSnapshot(EMPTY_VAULT_SNAPSHOT);
|
||||
t.after(() => publishVaultSnapshot(EMPTY_VAULT_SNAPSHOT));
|
||||
|
||||
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;
|
||||
};
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
let sessionId = "";
|
||||
await act(async () => {
|
||||
sessionId = current().connectToHost(testHost());
|
||||
});
|
||||
const sessionOf = () => current().sessions.find((s) => s.id === sessionId);
|
||||
assert.ok(sessionOf());
|
||||
assert.equal(sessionOf()?.hostLabel, "web-01");
|
||||
|
||||
// The host is renamed in the vault while the session stays open.
|
||||
await act(async () => {
|
||||
publishVaultSnapshot({
|
||||
...EMPTY_VAULT_SNAPSHOT,
|
||||
hosts: [testHost({ label: "web-01-renamed" })],
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(sessionOf()?.hostLabel, "web-01-renamed");
|
||||
|
||||
// Renaming the pane afterwards hands the title to the user rename.
|
||||
await act(async () => {
|
||||
current().submitSessionRename(sessionId, "my pane");
|
||||
});
|
||||
const renamed = sessionOf();
|
||||
assert.ok(renamed);
|
||||
assert.equal(renamed!.customName, "my pane");
|
||||
|
||||
// A later vault rename must not clobber the user-authored pane name.
|
||||
await act(async () => {
|
||||
publishVaultSnapshot({
|
||||
...EMPTY_VAULT_SNAPSHOT,
|
||||
hosts: [testHost({ label: "web-01-again" })],
|
||||
});
|
||||
});
|
||||
const after = sessionOf();
|
||||
assert.ok(after);
|
||||
assert.equal(after!.customName, "my pane");
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
@@ -68,6 +67,7 @@ import {
|
||||
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_SHOW_TRAY_ICON,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
@@ -79,8 +79,6 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SFTP_IN_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -132,7 +130,6 @@ import {
|
||||
DEFAULT_SHOW_RECENT_HOSTS,
|
||||
DEFAULT_HOST_CLICK_BEHAVIOR,
|
||||
DEFAULT_SHOW_SFTP_TAB,
|
||||
DEFAULT_SFTP_IN_SIDEBAR,
|
||||
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
|
||||
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
DEFAULT_SHOW_TAB_NUMBER_BADGES,
|
||||
@@ -419,17 +416,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
|
||||
});
|
||||
const [tabBarPosition, setTabBarPositionState] = useState<TabBarPosition>(() =>
|
||||
normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)),
|
||||
);
|
||||
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
});
|
||||
const [sftpInSidebar, setSftpInSidebarState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_IN_SIDEBAR);
|
||||
return stored ?? DEFAULT_SFTP_IN_SIDEBAR;
|
||||
});
|
||||
const [showHostTreeSidebar, setShowHostTreeSidebarState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
|
||||
@@ -561,6 +551,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
// Preserve the old tray visibility for existing users until they set this
|
||||
// independently. Fresh installs still default to showing the icon.
|
||||
const [showTrayIcon, setShowTrayIcon] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SHOW_TRAY_ICON);
|
||||
if (stored === null) return closeToTray;
|
||||
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>(() => {
|
||||
@@ -1033,11 +1030,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
);
|
||||
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setTabBarPositionState(normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)));
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedSftpInSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_IN_SIDEBAR);
|
||||
setSftpInSidebarState(storedSftpInSidebar ?? DEFAULT_SFTP_IN_SIDEBAR);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
|
||||
const storedTerminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
|
||||
@@ -1230,7 +1224,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -1289,7 +1282,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1299,7 +1292,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setSftpInSidebarState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1429,14 +1422,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setTabBarPosition = useCallback((value: TabBarPosition) => {
|
||||
const next = normalizeTabBarPosition(value);
|
||||
setTabBarPositionState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TAB_BAR_POSITION, next);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowSftpTab = useCallback((enabled: boolean) => {
|
||||
setShowSftpTabState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
@@ -1444,13 +1429,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setSftpInSidebar = useCallback((enabled: boolean) => {
|
||||
setSftpInSidebarState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_IN_SIDEBAR, enabled);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_IN_SIDEBAR, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowHostTreeSidebar = useCallback((enabled: boolean) => {
|
||||
setShowHostTreeSidebarState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
|
||||
@@ -1821,6 +1799,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
showTrayIcon,
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
@@ -1930,9 +1909,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
uiLanguage,
|
||||
windowOpacity,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
sftpInSidebar,
|
||||
showHostTreeSidebar,
|
||||
showRecentHosts,
|
||||
hostClickBehavior,
|
||||
@@ -1960,9 +1937,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showHostTreeSidebar,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
showRecentHosts,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
sftpInSidebar,
|
||||
showTabNumberBadges,
|
||||
terminalSettings.dynamicTabTitleMode,
|
||||
terminalSidePanelAutoOpen,
|
||||
@@ -2136,12 +2111,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setHostClickBehavior,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
setTabBarPosition,
|
||||
setShowSftpTab,
|
||||
sftpInSidebar,
|
||||
setSftpInSidebar,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
terminalSidePanelAutoOpen,
|
||||
@@ -2194,6 +2165,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
showTrayIcon,
|
||||
setShowTrayIcon,
|
||||
autoLaunchEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
autoLaunchSupported,
|
||||
@@ -2220,7 +2193,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, sftpInSidebar, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -469,6 +469,7 @@ export const useSftpState = (
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
removeListedNames,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
@@ -781,6 +782,7 @@ export const useSftpState = (
|
||||
createFileAtPath,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
removeListedNames,
|
||||
renameFile,
|
||||
renameFileAtPath,
|
||||
moveEntriesToPath,
|
||||
@@ -857,6 +859,8 @@ export const useSftpState = (
|
||||
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
|
||||
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
|
||||
methodsRef.current.deleteFilesAtPath(...args),
|
||||
removeListedNames: (...args: Parameters<typeof removeListedNames>) =>
|
||||
methodsRef.current.removeListedNames(...args),
|
||||
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
|
||||
renameFileAtPath: (...args: Parameters<typeof renameFileAtPath>) => methodsRef.current.renameFileAtPath(...args),
|
||||
moveEntriesToPath: (...args: Parameters<typeof moveEntriesToPath>) => methodsRef.current.moveEntriesToPath(...args),
|
||||
|
||||
@@ -15,6 +15,9 @@ function delay(ms: number): Promise<void> {
|
||||
function normalizePollingErrorMessage(error: unknown, t: I18nContextValue['t']): string {
|
||||
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
|
||||
const lower = message.toLowerCase();
|
||||
if (lower.includes('extra exec channels')) {
|
||||
return t('systemManager.unsupportedRemote');
|
||||
}
|
||||
if (lower.includes('channel open failure') || lower.includes('unable to exec')) {
|
||||
return t('systemManager.errors.sshChannelUnavailable');
|
||||
}
|
||||
|
||||
@@ -521,7 +521,7 @@ export const useTerminalBackend = () => {
|
||||
|
||||
const getSessionPwd = useCallback(async (
|
||||
sessionId: string,
|
||||
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean },
|
||||
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean; timeoutMs?: number },
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
applyAgentNotesUpdate,
|
||||
haveSameVaultAgentSnapshot,
|
||||
resolveVaultAgentEffectiveHost,
|
||||
resolveVaultAgentNotes,
|
||||
@@ -27,6 +28,33 @@ describe('haveSameVaultAgentSnapshot', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAgentNotesUpdate', () => {
|
||||
it('rolls back an unsaved agent note before a later successful write', () => {
|
||||
const oldNotes = [{ id: 'old', title: 'Old', content: 'old', createdAt: 1, updatedAt: 1 }];
|
||||
const noteA = { id: 'a', title: 'A', content: 'a', createdAt: 2, updatedAt: 2 };
|
||||
const noteB = { id: 'b', title: 'B', content: 'b', createdAt: 3, updatedAt: 3 };
|
||||
let inMemory = oldNotes;
|
||||
let persisted = oldNotes;
|
||||
let failNext = true;
|
||||
const updateNotes = (notes: typeof oldNotes) => {
|
||||
inMemory = notes;
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
return false;
|
||||
}
|
||||
persisted = notes;
|
||||
return true;
|
||||
};
|
||||
|
||||
assert.equal(applyAgentNotesUpdate([...oldNotes, noteA], oldNotes, updateNotes), false);
|
||||
assert.deepEqual(inMemory, oldNotes);
|
||||
assert.deepEqual(persisted, oldNotes);
|
||||
|
||||
assert.equal(applyAgentNotesUpdate([...inMemory, noteB], inMemory, updateNotes), true);
|
||||
assert.deepEqual(persisted.map((note) => note.id), ['old', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveVaultAgentEffectiveHost', () => {
|
||||
it('uses the latest snapshotted group defaults', () => {
|
||||
const host: Host = {
|
||||
|
||||
@@ -89,6 +89,20 @@ export function resolveVaultAgentNotes(
|
||||
: (getNotesSnapshot().notes as VaultNote[]);
|
||||
}
|
||||
|
||||
export function applyAgentNotesUpdate(
|
||||
notes: VaultNote[],
|
||||
previousNotes: VaultNote[],
|
||||
updateNotes: (notes: VaultNote[]) => boolean | void,
|
||||
): boolean | void {
|
||||
const saved = updateNotes(notes);
|
||||
if (saved === false) {
|
||||
// Editor autosave keeps failed writes in memory so a draft is not lost.
|
||||
// Agent writes must roll that draft back before another approved write.
|
||||
updateNotes(previousNotes);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
export const haveSameVaultAgentSnapshot = (
|
||||
left: VaultAgentSnapshot,
|
||||
right: VaultAgentSnapshot,
|
||||
@@ -145,6 +159,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
?? ((notes: VaultNote[]) => {
|
||||
console.warn('[useVaultAgentBridge] updateNotes unavailable');
|
||||
void notes;
|
||||
return false;
|
||||
});
|
||||
return handleVaultAgentOp(op, params, {
|
||||
getHosts: () => vaultSnapshotRef.current.hosts,
|
||||
@@ -239,8 +254,10 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
updateKeys: current.updateKeys,
|
||||
}),
|
||||
updateNotes: (notes) => {
|
||||
vaultSnapshotRef.current.notes = notes;
|
||||
applyUpdateNotes(notes);
|
||||
const previousNotes = vaultSnapshotRef.current.notes;
|
||||
const saved = applyAgentNotesUpdate(notes, previousNotes, applyUpdateNotes);
|
||||
vaultSnapshotRef.current.notes = saved === false ? previousNotes : notes;
|
||||
return saved;
|
||||
},
|
||||
updateSnippets: (snippetUpdate) => {
|
||||
vaultSnapshotRef.current.snippets = typeof snippetUpdate === 'function'
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
computeSftpViewRailOffset,
|
||||
vaultSidebarLayoutStore,
|
||||
} from './vaultSidebarLayoutStore.ts';
|
||||
|
||||
test('computeSftpViewRailOffset offsets only when the sidebar hosts the SFTP surface', () => {
|
||||
assert.equal(
|
||||
computeSftpViewRailOffset({
|
||||
sftpInSidebar: true,
|
||||
isSftpActive: true,
|
||||
vaultRailWidth: 208,
|
||||
}),
|
||||
208,
|
||||
);
|
||||
assert.equal(
|
||||
computeSftpViewRailOffset({
|
||||
sftpInSidebar: true,
|
||||
isSftpActive: false,
|
||||
vaultRailWidth: 208,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
computeSftpViewRailOffset({
|
||||
sftpInSidebar: false,
|
||||
isSftpActive: true,
|
||||
vaultRailWidth: 208,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('computeSftpViewRailOffset clamps negative rail widths', () => {
|
||||
assert.equal(
|
||||
computeSftpViewRailOffset({
|
||||
sftpInSidebar: true,
|
||||
isSftpActive: true,
|
||||
vaultRailWidth: -20,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('vaultSidebarLayoutStore dedupes identical width publishes', () => {
|
||||
const events: number[] = [];
|
||||
const unsubscribe = vaultSidebarLayoutStore.subscribe(() => {
|
||||
events.push(vaultSidebarLayoutStore.getLayoutWidth());
|
||||
});
|
||||
|
||||
vaultSidebarLayoutStore.setLayoutWidth(208.4);
|
||||
vaultSidebarLayoutStore.setLayoutWidth(208.2);
|
||||
vaultSidebarLayoutStore.setLayoutWidth(0);
|
||||
vaultSidebarLayoutStore.setLayoutWidth(-5);
|
||||
|
||||
assert.deepEqual(events, [208, 0]);
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/**
|
||||
* Live width of the vault sidebar rail. VaultViewLayout publishes its sidebar
|
||||
* width here so the root SFTP surface can offset itself beside the rail while
|
||||
* the vault sidebar stays visible during the SFTP tab (the "SFTP in sidebar"
|
||||
* setting). Mirrors the terminalHostTreeStore layout-width pattern.
|
||||
*/
|
||||
class VaultSidebarLayoutStore {
|
||||
private layoutWidth = 0;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getLayoutWidth = () => this.layoutWidth;
|
||||
|
||||
setLayoutWidth = (width: number) => {
|
||||
const next = Math.max(0, Math.round(width));
|
||||
if (this.layoutWidth === next) return;
|
||||
this.layoutWidth = next;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const vaultSidebarLayoutStore = new VaultSidebarLayoutStore();
|
||||
|
||||
export const useVaultSidebarLayoutWidth = () => {
|
||||
return useSyncExternalStore(
|
||||
vaultSidebarLayoutStore.subscribe,
|
||||
vaultSidebarLayoutStore.getLayoutWidth,
|
||||
vaultSidebarLayoutStore.getLayoutWidth,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Horizontal offset applied to the root SFTP view so it starts to the right
|
||||
* of the vault sidebar rail. Zero unless the SFTP-in-sidebar mode is on and
|
||||
* the SFTP tab is the active surface.
|
||||
*/
|
||||
export function computeSftpViewRailOffset(params: {
|
||||
sftpInSidebar: boolean;
|
||||
isSftpActive: boolean;
|
||||
vaultRailWidth: number;
|
||||
}): number {
|
||||
return params.sftpInSidebar && params.isSftpActive
|
||||
? Math.max(0, params.vaultRailWidth)
|
||||
: 0;
|
||||
}
|
||||
+159
-15
@@ -4,7 +4,8 @@ import assert from "node:assert/strict";
|
||||
import type { SyncPayload } from "../domain/sync.ts";
|
||||
import type { KnownHost } from "../domain/models.ts";
|
||||
import type { SyncableVaultData } from "./syncPayload.ts";
|
||||
import { parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
|
||||
import { clampTerminalFontSizeValue, parseTerminalFontSizeRecord } from "./state/terminalFontSizeSync.ts";
|
||||
import { parseCustomAccentRecord } from "./state/customAccentSync.ts";
|
||||
import commandBlocklistTable from "../lib/commandBlocklist.json";
|
||||
|
||||
type LocalStorageMock = {
|
||||
@@ -498,6 +499,134 @@ test("applySyncPayload writes a newer terminal font size that old versions can r
|
||||
assert.equal(record.origin, "sync-payload");
|
||||
});
|
||||
|
||||
test("applying identical versioned settings preserves their records, then propagates real changes", async () => {
|
||||
const scenarios: Array<{
|
||||
name: string;
|
||||
storageKey: string;
|
||||
field: keyof NonNullable<SyncPayload["settings"]>;
|
||||
initialValue: string | number | Record<string, unknown>;
|
||||
changedValue: string | number | Record<string, unknown>;
|
||||
}> = [
|
||||
{
|
||||
name: "accent",
|
||||
storageKey: storageKeys.STORAGE_KEY_COLOR,
|
||||
field: "customAccent",
|
||||
initialValue: parseCustomAccentRecord(null).color,
|
||||
changedValue: "0 84% 60%",
|
||||
},
|
||||
{
|
||||
name: "terminal font size",
|
||||
storageKey: storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
|
||||
field: "terminalFontSize",
|
||||
initialValue: parseTerminalFontSizeRecord(null).fontSize,
|
||||
changedValue: 19,
|
||||
},
|
||||
{
|
||||
name: "custom key bindings",
|
||||
storageKey: storageKeys.STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
field: "customKeyBindings",
|
||||
initialValue: {},
|
||||
changedValue: { open: { mac: "Cmd+K" } },
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
localStorage.clear();
|
||||
const writes: string[] = [];
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = (key, value) => {
|
||||
writes.push(key);
|
||||
originalSetItem(key, value);
|
||||
};
|
||||
const applySetting = (value: typeof scenario.initialValue) => applySyncPayload({
|
||||
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
||||
settings: { [scenario.field]: value },
|
||||
} as SyncPayload, { importVaultData: () => {} });
|
||||
|
||||
try {
|
||||
await applySetting(scenario.initialValue);
|
||||
const firstRecord = localStorage.getItem(scenario.storageKey);
|
||||
assert.ok(firstRecord, `${scenario.name}: an explicit first value must be saved`);
|
||||
assert.equal(writes.filter((key) => key === scenario.storageKey).length, 1);
|
||||
|
||||
writes.length = 0;
|
||||
await applySetting(scenario.initialValue);
|
||||
assert.equal(localStorage.getItem(scenario.storageKey), firstRecord);
|
||||
assert.equal(writes.filter((key) => key === scenario.storageKey).length, 0,
|
||||
`${scenario.name}: unchanged value must not write or trigger a storage change`);
|
||||
|
||||
writes.length = 0;
|
||||
await applySetting(scenario.changedValue);
|
||||
assert.equal(writes.filter((key) => key === scenario.storageKey).length, 1,
|
||||
`${scenario.name}: a real change must be saved`);
|
||||
assert.notEqual(localStorage.getItem(scenario.storageKey), firstRecord);
|
||||
assert.deepEqual(buildSyncPayload(vault([])).settings?.[scenario.field], scenario.changedValue);
|
||||
} finally {
|
||||
localStorage.setItem = originalSetItem;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("sync replaces malformed setting records and does not repeat normalized writes", async () => {
|
||||
const versionedKeys = [
|
||||
storageKeys.STORAGE_KEY_COLOR,
|
||||
storageKeys.STORAGE_KEY_TERM_FONT_SIZE,
|
||||
storageKeys.STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
];
|
||||
for (const key of versionedKeys) localStorage.setItem(key, "bad");
|
||||
const writes: string[] = [];
|
||||
const originalSetItem = localStorage.setItem;
|
||||
localStorage.setItem = (key, value) => {
|
||||
writes.push(key);
|
||||
originalSetItem(key, value);
|
||||
};
|
||||
const settings = {
|
||||
customAccent: parseCustomAccentRecord(null).color,
|
||||
terminalFontSize: parseTerminalFontSizeRecord(null).fontSize,
|
||||
customKeyBindings: {},
|
||||
};
|
||||
const applySettings = (nextSettings: NonNullable<SyncPayload["settings"]>) => applySyncPayload({
|
||||
hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
||||
settings: nextSettings,
|
||||
}, { importVaultData: () => {} });
|
||||
|
||||
try {
|
||||
await applySettings(settings);
|
||||
for (const key of versionedKeys) {
|
||||
assert.notEqual(localStorage.getItem(key), "bad", `${key}: malformed record must be repaired`);
|
||||
assert.equal(writes.filter((written) => written === key).length, 1);
|
||||
}
|
||||
|
||||
writes.length = 0;
|
||||
await applySettings(settings);
|
||||
for (const key of versionedKeys) {
|
||||
assert.equal(writes.filter((written) => written === key).length, 0,
|
||||
`${key}: repaired record must not be written again`);
|
||||
}
|
||||
|
||||
const oversizedFontSize = 100;
|
||||
writes.length = 0;
|
||||
await applySettings({ terminalFontSize: oversizedFontSize });
|
||||
assert.equal(parseTerminalFontSizeRecord(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE)).fontSize,
|
||||
clampTerminalFontSizeValue(oversizedFontSize));
|
||||
assert.equal(writes.filter((key) => key === storageKeys.STORAGE_KEY_TERM_FONT_SIZE).length, 1);
|
||||
|
||||
writes.length = 0;
|
||||
await applySettings({ terminalFontSize: oversizedFontSize });
|
||||
assert.equal(writes.filter((key) => key === storageKeys.STORAGE_KEY_TERM_FONT_SIZE).length, 0,
|
||||
"a repeated out-of-range incoming size must not bump the stored version again");
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE, "999");
|
||||
writes.length = 0;
|
||||
await applySettings({ terminalFontSize: oversizedFontSize });
|
||||
assert.equal(writes.filter((key) => key === storageKeys.STORAGE_KEY_TERM_FONT_SIZE).length, 1,
|
||||
"an out-of-range legacy record must be replaced with the normalized value");
|
||||
assert.notEqual(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_FONT_SIZE), "999");
|
||||
} finally {
|
||||
localStorage.setItem = originalSetItem;
|
||||
}
|
||||
});
|
||||
|
||||
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
|
||||
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
|
||||
@@ -1585,6 +1714,26 @@ test("applySyncPayload restores the terminal host information bar preference", a
|
||||
assert.equal(parsed.scrollback, 5000);
|
||||
});
|
||||
|
||||
test("bar cursor width survives terminal settings sync round-trip", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ cursorBarWidth: 3 }),
|
||||
);
|
||||
|
||||
const payload = buildSyncPayload(vault());
|
||||
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
|
||||
assert.equal(termSettings.cursorBarWidth, 3);
|
||||
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ cursorBarWidth: 1 }),
|
||||
);
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
|
||||
const restored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(restored.cursorBarWidth, 3);
|
||||
});
|
||||
|
||||
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
@@ -2001,22 +2150,17 @@ test("missing local shell settings and invalid tabs preserve existing settings",
|
||||
assert.equal((await buildSyncPayload(vault())).settings?.localShellSidePanelAutoOpenTab, undefined);
|
||||
});
|
||||
|
||||
test("tab bar position survives settings export and import, with a safe fallback", async () => {
|
||||
test("old bottom tab preference is ignored by settings export and import", async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
|
||||
const legacyKey = "netcatty_tab_bar_position_v1";
|
||||
localStorage.setItem(legacyKey, "bottom");
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
assert.equal(payload.settings?.tabBarPosition, "bottom");
|
||||
localStorage.removeItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION);
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
|
||||
|
||||
const invalid = { ...payload, settings: { tabBarPosition: "left" } } as unknown as SyncPayload;
|
||||
await applySyncPayload(invalid, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "top");
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
|
||||
await applySyncPayload({ ...payload, settings: {} }, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
|
||||
assert.equal(Object.hasOwn(payload.settings ?? {}, "tabBarPosition"), false);
|
||||
await applySyncPayload({ ...payload, settings: { tabBarPosition: "bottom" } } as unknown as SyncPayload, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(legacyKey), "bottom");
|
||||
localStorage.removeItem(legacyKey);
|
||||
await applySyncPayload({ ...payload, settings: { tabBarPosition: "bottom" } } as unknown as SyncPayload, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(legacyKey), null);
|
||||
});
|
||||
for (const enabled of [true, false]) {
|
||||
test(`right-click long press preference survives sync (${enabled})`, async () => {
|
||||
|
||||
+56
-46
@@ -1,4 +1,3 @@
|
||||
import { normalizeTabBarPosition } from '../domain/tabBarPosition';
|
||||
import { decryptProviderHeaders, encryptProviderHeaders } from '../infrastructure/ai/providerHeaderCredentials';
|
||||
/**
|
||||
* Sync Payload Builders - Single source of truth for constructing and applying
|
||||
@@ -52,6 +51,7 @@ import {
|
||||
} from './state/commandBlocklistSettings';
|
||||
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
|
||||
import {
|
||||
clampTerminalFontSizeValue,
|
||||
nextTerminalFontSizeSyncVersion,
|
||||
parseTerminalFontSizeRecord,
|
||||
serializeTerminalFontSizeRecord,
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
parseCustomAccentRecord,
|
||||
serializeCustomAccentRecord,
|
||||
} from './state/customAccentSync';
|
||||
import { isValidHslToken } from './state/settingsStateDefaults';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
@@ -96,8 +97,6 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SFTP_IN_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
@@ -260,8 +259,9 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'startupCommandDelayMs',
|
||||
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
|
||||
'fontLigatures', 'fontSmoothing', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'highlightCursorLine', 'minimumContrastRatio',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'cursorBarWidth', 'highlightCursorLine', 'minimumContrastRatio',
|
||||
'altAsMeta', 'optionArrowWordJump', 'shiftEnterNewlineEnabled', 'shiftEnterNewlineText',
|
||||
'shiftEnterForceText',
|
||||
'kittyKeyboardProtocolEnabled',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
@@ -324,8 +324,6 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SFTP_IN_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
@@ -559,12 +557,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
}
|
||||
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const tabBarPosition = localStorageAdapter.readString(STORAGE_KEY_TAB_BAR_POSITION);
|
||||
if (tabBarPosition != null) settings.tabBarPosition = normalizeTabBarPosition(tabBarPosition);
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const sftpInSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SFTP_IN_SIDEBAR);
|
||||
if (sftpInSidebar != null) settings.sftpInSidebar = sftpInSidebar;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
|
||||
const showTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
|
||||
@@ -698,15 +692,25 @@ async function applySyncableSettings(
|
||||
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
|
||||
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
|
||||
if (settings.customAccent != null) {
|
||||
const existing = parseCustomAccentRecord(localStorageAdapter.readString(STORAGE_KEY_COLOR));
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_COLOR,
|
||||
serializeCustomAccentRecord({
|
||||
color: parseCustomAccentRecord(settings.customAccent).color,
|
||||
// Bump so peer windows' version gates accept the synced value.
|
||||
version: Math.max(existing.version, 0) + 1,
|
||||
}),
|
||||
const storedColor = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
||||
const existing = parseCustomAccentRecord(storedColor);
|
||||
const incomingColor = parseCustomAccentRecord(settings.customAccent).color;
|
||||
// A missing or malformed record parses to the fallback color, but still
|
||||
// needs to be replaced by an explicit incoming value.
|
||||
const storedColorIsValid = storedColor !== null && (
|
||||
isValidHslToken(storedColor.trim())
|
||||
|| storedColor === serializeCustomAccentRecord(existing)
|
||||
);
|
||||
if (!storedColorIsValid || incomingColor !== existing.color) {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_COLOR,
|
||||
serializeCustomAccentRecord({
|
||||
color: incomingColor,
|
||||
// Bump so peer windows' version gates accept the synced value.
|
||||
version: Math.max(existing.version, 0) + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
||||
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
||||
@@ -728,18 +732,26 @@ async function applySyncableSettings(
|
||||
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) {
|
||||
const existing = parseTerminalFontSizeRecord(
|
||||
localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE),
|
||||
);
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
serializeTerminalFontSizeRecord({
|
||||
fontSize: settings.terminalFontSize,
|
||||
// Bump so peer windows' version gates accept the synced value.
|
||||
version: nextTerminalFontSizeSyncVersion(existing.version, existing.version),
|
||||
origin: 'sync-payload',
|
||||
}),
|
||||
const storedFontSize = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
const existing = parseTerminalFontSizeRecord(storedFontSize);
|
||||
const incomingFontSize = clampTerminalFontSizeValue(settings.terminalFontSize);
|
||||
// A missing or malformed record parses to the default font size. A
|
||||
// supported legacy number remains valid without forcing migration.
|
||||
const storedFontSizeIsValid = storedFontSize !== null && (
|
||||
storedFontSize.trim() === String(existing.fontSize)
|
||||
|| storedFontSize === serializeTerminalFontSizeRecord(existing)
|
||||
);
|
||||
if (!storedFontSizeIsValid || incomingFontSize !== existing.fontSize) {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
serializeTerminalFontSizeRecord({
|
||||
fontSize: incomingFontSize,
|
||||
// Bump so peer windows' version gates accept the synced value.
|
||||
version: nextTerminalFontSizeSyncVersion(existing.version, existing.version),
|
||||
origin: 'sync-payload',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (settings.terminalSidePanelAutoOpen != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, settings.terminalSidePanelAutoOpen);
|
||||
@@ -793,17 +805,21 @@ async function applySyncableSettings(
|
||||
|
||||
// Keyboard
|
||||
if (settings.customKeyBindings != null) {
|
||||
const previous = parseCustomKeyBindingsStorageRecord(
|
||||
localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS),
|
||||
);
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
serializeCustomKeyBindingsStorageRecord({
|
||||
version: nextCustomKeyBindingsSyncVersion(previous?.version || 0),
|
||||
origin: CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN,
|
||||
bindings: settings.customKeyBindings,
|
||||
}),
|
||||
);
|
||||
const storedBindings = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
const previous = parseCustomKeyBindingsStorageRecord(storedBindings);
|
||||
// Keep an explicit empty binding set when this device has no record.
|
||||
const incomingBindings = JSON.stringify(settings.customKeyBindings);
|
||||
const existingBindings = JSON.stringify(previous?.bindings ?? []);
|
||||
if (previous === null || incomingBindings !== existingBindings) {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
serializeCustomKeyBindingsStorageRecord({
|
||||
version: nextCustomKeyBindingsSyncVersion(previous?.version || 0),
|
||||
origin: CUSTOM_KEY_BINDINGS_SYNC_PAYLOAD_ORIGIN,
|
||||
bindings: settings.customKeyBindings,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Editor
|
||||
@@ -834,15 +850,9 @@ async function applySyncableSettings(
|
||||
settings.showOnlyUngroupedHostsInRoot,
|
||||
);
|
||||
}
|
||||
if (settings.tabBarPosition != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, normalizeTabBarPosition(settings.tabBarPosition));
|
||||
}
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
if (settings.sftpInSidebar != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SFTP_IN_SIDEBAR, settings.sftpInSidebar);
|
||||
}
|
||||
if (settings.shellOnlyTabNumberShortcuts != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ interface AIChatPanelContentProps {
|
||||
modelDisplayName?: string;
|
||||
modelCatalogWarning?: string;
|
||||
agentModelPresets: AgentModelPreset[];
|
||||
/** Suppress the composer picker's "use custom model" action (locked model). */
|
||||
allowCustomModelEntry?: boolean;
|
||||
selectedAgentModel: string;
|
||||
handleAgentModelSelect: (modelId: string) => void;
|
||||
cattyConfiguredProviders: ProviderConfig[];
|
||||
@@ -138,6 +140,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
modelDisplayName,
|
||||
modelCatalogWarning,
|
||||
agentModelPresets,
|
||||
allowCustomModelEntry = true,
|
||||
selectedAgentModel,
|
||||
handleAgentModelSelect,
|
||||
cattyConfiguredProviders,
|
||||
@@ -354,6 +357,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
modelName={modelDisplayName}
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
allowCustomModelEntry={allowCustomModelEntry}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
providerSwitcher={providerSwitcher}
|
||||
|
||||
@@ -22,6 +22,14 @@ import {
|
||||
resolveAgentModelSelection,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend, getManualAgentCommand, matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import {
|
||||
appendComposerCustomModelPresets,
|
||||
resolveComposerCustomModelIds,
|
||||
} from '../infrastructure/ai/composerPicker';
|
||||
import {
|
||||
readComposerModelPrefs,
|
||||
subscribeComposerModelPrefs,
|
||||
} from '../infrastructure/ai/composerModelPrefs';
|
||||
import { toast } from './ui/toast';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import {
|
||||
@@ -843,13 +851,34 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
const { model: codexConfigModel, loadModel: loadCodexConfigModel } = useCodexConfigModel(
|
||||
const {
|
||||
model: codexConfigModel,
|
||||
loadModel: loadCodexConfigModel,
|
||||
isPending: isCodexConfigModelPending,
|
||||
} = useCodexConfigModel(
|
||||
currentAgentConfig, isVisible,
|
||||
);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
|
||||
// Manual model ids typed in the composer picker are recorded in composer
|
||||
// prefs (per agent scope). Re-render when they change so custom picks stay
|
||||
// visible and accepted as stored selections (#3534).
|
||||
const [composerCustomModelsVersion, setComposerCustomModelsVersion] = useState(0);
|
||||
useEffect(() => subscribeComposerModelPrefs(() => {
|
||||
setComposerCustomModelsVersion((version) => version + 1);
|
||||
}), []);
|
||||
const collectCustomModelIds = useCallback((agentId: string, presets: AgentModelPreset[]) => {
|
||||
// Touch the version so pref writes recreate this callback and invalidate
|
||||
// memos that read custom ids.
|
||||
void composerCustomModelsVersion;
|
||||
return resolveComposerCustomModelIds({
|
||||
prefs: readComposerModelPrefs(agentId),
|
||||
presets,
|
||||
});
|
||||
}, [composerCustomModelsVersion]);
|
||||
|
||||
const buildExternalAgentRuntimeModelTarget = useCallback((agent: ExternalAgentConfig | undefined): SdkRuntimeModelTarget | null => {
|
||||
if (!agent) return null;
|
||||
const sdkBackend = getExternalAgentSdkBackend(agent);
|
||||
@@ -881,10 +910,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
|
||||
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return;
|
||||
const agentId = target.agentId;
|
||||
const runtimePresets = mergeFallbackThinkingLevels(
|
||||
const catalogPresets = mergeFallbackThinkingLevels(
|
||||
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
|
||||
getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent)),
|
||||
);
|
||||
const runtimePresets = appendComposerCustomModelPresets(
|
||||
catalogPresets,
|
||||
collectCustomModelIds(agentId, catalogPresets),
|
||||
);
|
||||
const storedModelId = agentModelMapRef.current[agentId];
|
||||
if (runtimePresets.length === 0) {
|
||||
setRuntimeAgentModelPresets((prev) => {
|
||||
@@ -917,7 +950,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
) {
|
||||
setAgentModel(agentId, catalog.currentModelId);
|
||||
}
|
||||
}, [setAgentModel, buildExternalAgentRuntimeModelTarget]);
|
||||
}, [setAgentModel, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
|
||||
|
||||
const loadSdkRuntimeModelCatalog = useCallback((
|
||||
target: SdkRuntimeModelTarget,
|
||||
@@ -1020,6 +1053,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
|
||||
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
|
||||
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
|
||||
// `codexConfigModel` is null both while the config probe is pending and when
|
||||
// the managed Codex config has no locked model. Keep the manual-entry action
|
||||
// hidden until the probe resolves so a pick made in the interim can't be
|
||||
// silently overridden by the config model on send (#3535).
|
||||
const allowCustomModelEntry = !isCodexManagedAgent
|
||||
|| (!isCodexConfigModelPending && !hasCodexCustomConfig);
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
||||
@@ -1030,14 +1069,25 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (hasCodexCustomConfig && codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
if (runtimePresets) return runtimePresets;
|
||||
if (runtimePresets) {
|
||||
return appendComposerCustomModelPresets(
|
||||
runtimePresets,
|
||||
collectCustomModelIds(currentAgentId, runtimePresets),
|
||||
);
|
||||
}
|
||||
const presets = getAgentModelPresets(
|
||||
currentAgentConfig?.command,
|
||||
getExternalAgentSdkBackend(currentAgentConfig),
|
||||
);
|
||||
// BYO Codex CLI: hide GPT-5.6 when CLI < 0.144.0 (stored probe or discovery).
|
||||
const cliVersion = resolveAgentCliVersion(currentAgentConfig, discoveredAgents);
|
||||
return filterAgentModelPresetsForCliVersion(presets, cliVersion);
|
||||
const filteredPresets = filterAgentModelPresetsForCliVersion(presets, cliVersion);
|
||||
// Manual model ids typed in the composer (#3534) stay selectable even when
|
||||
// the CLI catalog does not list them yet.
|
||||
return appendComposerCustomModelPresets(
|
||||
filteredPresets,
|
||||
collectCustomModelIds(currentAgentId, filteredPresets),
|
||||
);
|
||||
}, [
|
||||
currentAgentConfig,
|
||||
currentAgentId,
|
||||
@@ -1046,6 +1096,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
hasCodexCustomConfig,
|
||||
codexConfigModel,
|
||||
discoveredAgents,
|
||||
collectCustomModelIds,
|
||||
]);
|
||||
|
||||
const selectedAgentModel = useMemo(() => {
|
||||
@@ -1229,13 +1280,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
|
||||
if (catalog) {
|
||||
applySdkRuntimeModelCatalog(runtimeTarget, catalog, { adoptCurrentModel: true });
|
||||
const runtimePresets = mergeFallbackThinkingLevels(
|
||||
const catalogPresets = mergeFallbackThinkingLevels(
|
||||
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
|
||||
getAgentModelPresets(
|
||||
currentAgentConfig.command,
|
||||
getExternalAgentSdkBackend(currentAgentConfig),
|
||||
),
|
||||
);
|
||||
const runtimePresets = appendComposerCustomModelPresets(
|
||||
catalogPresets,
|
||||
collectCustomModelIds(sendAgentId, catalogPresets),
|
||||
);
|
||||
const storedModelId = agentModelMapRef.current[sendAgentId];
|
||||
if (
|
||||
catalog.currentModelId
|
||||
@@ -1418,7 +1473,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
toolIntegrationMode,
|
||||
clearScopeDraft, showScopeSessionView, setActiveSessionId,
|
||||
flushDraftText, currentAgentConfig, buildExternalAgentRuntimeModelTarget,
|
||||
loadSdkRuntimeModelCatalog, applySdkRuntimeModelCatalog,
|
||||
loadSdkRuntimeModelCatalog, applySdkRuntimeModelCatalog, collectCustomModelIds,
|
||||
]);
|
||||
|
||||
const handleCompact = useCallback(async () => {
|
||||
@@ -1740,6 +1795,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
? runtimeModelWarnings[currentAgentId].message
|
||||
: undefined}
|
||||
agentModelPresets={agentModelPresets}
|
||||
// A managed Codex config's `model` field overrides every selection on
|
||||
// send, so the manual-entry action would be a silent no-op.
|
||||
allowCustomModelEntry={allowCustomModelEntry}
|
||||
selectedAgentModel={selectedAgentModel}
|
||||
handleAgentModelSelect={handleAgentModelSelect}
|
||||
cattyConfiguredProviders={cattyConfiguredProviders}
|
||||
|
||||
@@ -175,6 +175,22 @@ test('Claude model cache keys isolate configuration directories under one home',
|
||||
);
|
||||
});
|
||||
|
||||
test('MiMo model cache reloads after its config directory changes', async () => {
|
||||
const agent = { id: 'discovered_mimo', sdkBackend: 'mimo', command: '/bin/mimo' };
|
||||
const firstKey = buildSdkRuntimeModelCacheKey({ ...agent, env: { MIMOCODE_HOME: '/profiles/a' } });
|
||||
const secondKey = buildSdkRuntimeModelCacheKey({ ...agent, env: { MIMOCODE_HOME: '/profiles/b' } });
|
||||
const cache = createSdkRuntimeModelCache();
|
||||
let loads = 0;
|
||||
const load = async () => ({ currentModelId: String(++loads), models: [] });
|
||||
assert.deepEqual(await cache.refresh(firstKey, load), { currentModelId: '1', models: [] });
|
||||
assert.deepEqual(await cache.refresh(secondKey, load), { currentModelId: '2', models: [] });
|
||||
assert.equal(loads, 2);
|
||||
assert.notEqual(
|
||||
buildSdkRuntimeModelCacheKey({ ...agent, env: { MIMOCODE_CONFIG: '/profiles/a/mimocode.json' } }),
|
||||
buildSdkRuntimeModelCacheKey({ ...agent, env: { MIMOCODE_CONFIG: '/profiles/b/mimocode.json' } }),
|
||||
);
|
||||
});
|
||||
|
||||
test('Claude model cache keys isolate credentials without exposing them', () => {
|
||||
const agent = { id: 'discovered_claude', sdkBackend: 'claude', command: '/bin/claude' };
|
||||
for (const name of ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN']) {
|
||||
|
||||
@@ -4,11 +4,19 @@ import {
|
||||
type ExternalAgentConfig,
|
||||
} from '../infrastructure/ai/types';
|
||||
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
|
||||
import { canonicalizeEffortEncodedModelId } from '../infrastructure/ai/composerPicker';
|
||||
import {
|
||||
canonicalizeEffortEncodedModelId,
|
||||
modelPresetMatchesId,
|
||||
modelPresetsContainId,
|
||||
} from '../infrastructure/ai/composerPicker';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { bytesToHex } from '@noble/hashes/utils.js';
|
||||
|
||||
export { canonicalizeEffortEncodedModelId };
|
||||
export {
|
||||
canonicalizeEffortEncodedModelId,
|
||||
modelPresetMatchesId,
|
||||
modelPresetsContainId,
|
||||
};
|
||||
|
||||
export type SdkRuntimeModelCatalog = {
|
||||
currentModelId: string | null;
|
||||
@@ -46,6 +54,14 @@ const MODEL_CACHE_ENV_HINTS = [
|
||||
'OPENCODE_CONFIG',
|
||||
'OPENCODE_CONFIG_DIR',
|
||||
'OPENCODE_CONFIG_CONTENT',
|
||||
'MIMOCODE_HOME',
|
||||
'MIMOCODE_BIN',
|
||||
'MIMOCODE_BIN_PATH',
|
||||
'MIMOCODE_CONFIG',
|
||||
'MIMOCODE_CONFIG_DIR',
|
||||
'MIMOCODE_MIMO_ONLY',
|
||||
'XDG_DATA_HOME',
|
||||
'XDG_CACHE_HOME',
|
||||
'CLAUDE_CODE_EXECUTABLE',
|
||||
'CODEBUDDY_CODE_PATH',
|
||||
'CURSOR_API_KEY',
|
||||
@@ -234,19 +250,6 @@ export function agentModelPresetsShallowEqual(
|
||||
});
|
||||
}
|
||||
|
||||
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
|
||||
const canonical = canonicalizeEffortEncodedModelId(modelId);
|
||||
if (preset.thinkingLevels?.length) {
|
||||
return preset.id === canonical
|
||||
|| preset.thinkingLevels.some((level) => `${preset.id}/${level}` === canonical);
|
||||
}
|
||||
return preset.id === canonical;
|
||||
}
|
||||
|
||||
export function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
|
||||
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
|
||||
}
|
||||
|
||||
export function normalizeStoredAgentModelSelection(
|
||||
storedModelId: string | null | undefined,
|
||||
presets: AgentModelPreset[],
|
||||
@@ -271,7 +274,8 @@ export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean
|
||||
|| sdkBackend === 'cursor'
|
||||
|| sdkBackend === 'codebuddy'
|
||||
|| sdkBackend === 'opencode'
|
||||
|| sdkBackend === 'grok';
|
||||
|| sdkBackend === 'grok'
|
||||
|| sdkBackend === 'mimo';
|
||||
}
|
||||
|
||||
export function shouldAdoptSdkCurrentModel(
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { TransferTask } from "../domain/models";
|
||||
import { listGloballyCancellableTransferIds } from "../domain/sftpTransferActions";
|
||||
import {
|
||||
buildGlobalTransferProgressDisplay,
|
||||
getGlobalConflictActionPresentation,
|
||||
@@ -65,14 +66,28 @@ test("batch actions are disabled when no task can accept them", () => {
|
||||
assert.deepEqual(getGlobalTransferBatchEligibility([
|
||||
task("done", "completed"),
|
||||
task("cancelled", "cancelled"),
|
||||
]), { pausableCount: 0, resumableCount: 0 });
|
||||
]), { cancellableCount: 0, pausableCount: 0, resumableCount: 0 });
|
||||
|
||||
assert.deepEqual(getGlobalTransferBatchEligibility([
|
||||
task("running", "transferring"),
|
||||
task("waiting", "queued"),
|
||||
task("paused", "paused"),
|
||||
{ ...task("failed", "failed"), checkpointBytes: 4 },
|
||||
]), { pausableCount: 2, resumableCount: 2 });
|
||||
]), { cancellableCount: 3, pausableCount: 2, resumableCount: 2 });
|
||||
});
|
||||
|
||||
test("cancel all selects unfinished top-level tasks without touching completed files", () => {
|
||||
const statuses: TransferTask["status"][] = [
|
||||
"pending", "queued", "transferring", "pausing", "paused", "interrupted", "attention",
|
||||
];
|
||||
const tasks = [
|
||||
...statuses.map((status) => task(status, status)),
|
||||
task("completed", "completed"),
|
||||
task("failed", "failed"),
|
||||
task("cancelled", "cancelled"),
|
||||
{ ...task("child", "queued"), parentTaskId: "pending" },
|
||||
];
|
||||
assert.deepEqual(listGloballyCancellableTransferIds(tasks), statuses);
|
||||
});
|
||||
|
||||
test("paused transfer errors remain visible instead of looking safely paused", () => {
|
||||
|
||||
@@ -837,7 +837,7 @@ export function GlobalSftpTransferCenter() {
|
||||
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || b.startTime - a.startTime), [bucket, snapshot.tasks]);
|
||||
const { visible, collapsed } = splitBackgroundTransfers(bucketTasks);
|
||||
const displayed = showBackground ? [...visible, ...collapsed] : visible;
|
||||
const { batchEligibility, pauseAll, resumeAll } = useGlobalSftpTransferActions(snapshot.tasks);
|
||||
const { batchEligibility, pauseAll, resumeAll, cancelAll, isCancelling } = useGlobalSftpTransferActions(snapshot.tasks);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -877,13 +877,16 @@ export function GlobalSftpTransferCenter() {
|
||||
<div className="min-w-0 pr-2 text-sm font-semibold">
|
||||
{t("sftp.transferCenter.title")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={batchEligibility.pausableCount === 0}>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={pauseAll} disabled={isCancelling || batchEligibility.pausableCount === 0}>
|
||||
<Pause size={12} className="mr-1" />{t("sftp.transferCenter.pauseAll")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={batchEligibility.resumableCount === 0}>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={resumeAll} disabled={isCancelling || batchEligibility.resumableCount === 0}>
|
||||
<Play size={12} className="mr-1" />{t("sftp.transferCenter.resumeAll")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-destructive" onClick={() => { void cancelAll(); }} disabled={isCancelling || batchEligibility.cancellableCount === 0}>
|
||||
<X size={12} className="mr-1" />{t("sftp.transferCenter.cancelAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -53,6 +53,19 @@ test("scripts side panel offers run-on-all-tabs for every snippet, not only auto
|
||||
assert.match(source, /openPackageDialog/);
|
||||
});
|
||||
|
||||
test("scripts side panel snippet context menu offers copy command without running", () => {
|
||||
// Copy must sit between the run actions and Edit, write snippet.command to
|
||||
// the clipboard, and confirm with a toast — it never executes the command.
|
||||
assert.match(source, /onCopyCommand=\{\(\) => handleCopySnippetCommand\(item\.snippet\)\}/);
|
||||
assert.match(source, /onCopyCommand=\{\(\) => handleCopySnippetCommand\(item\.row\.snippet\)\}/);
|
||||
assert.match(source, /scripts\.actions\.copyCommand'/);
|
||||
const menu = source.match(/\{onRunParallel \? \([\s\S]*?<\/ContextMenu>/);
|
||||
assert.ok(menu, "snippet row context menu should exist");
|
||||
assert.match(menu[0], /onCopyCommand[\s\S]*?onClick=\{onEdit\}/);
|
||||
assert.match(menu[0], /<Copy className="[\s\S]*?\{copyCommandLabel\}/);
|
||||
assert.match(source, /handleCopySnippetCommand[\s\S]*?snippet\.command[\s\S]*?toast\.success/);
|
||||
});
|
||||
|
||||
test("scripts side panel exposes create actions as inline toolbar icons", () => {
|
||||
assert.match(source, /snippets\.action\.newSnippet/);
|
||||
assert.match(source, /snippets\.action\.newPackage/);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import {
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Edit2,
|
||||
Expand,
|
||||
FolderPlus,
|
||||
@@ -54,6 +55,7 @@ import { SnippetCommandTooltipContent } from './snippets/SnippetCommandTooltipCo
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
|
||||
import { isNonPrimaryPointer, primaryOnlyDragHandlers } from './ui/primaryOnlyDrag';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import { toast } from './ui/toast';
|
||||
|
||||
const toolbarIconButtonClass =
|
||||
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
|
||||
@@ -889,6 +891,20 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleCopySnippetCommand = useCallback((snippet: Snippet) => {
|
||||
if (!snippet.command) return;
|
||||
navigator.clipboard.writeText(snippet.command).then(
|
||||
() => {
|
||||
toast.success(t('scripts.actions.copyCommand.copied'), {
|
||||
title: snippet.label,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
toast.error(t('scripts.actions.copyCommand.failed'));
|
||||
},
|
||||
);
|
||||
}, [t]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const hasAnyContent = snippets.length > 0 || packages.length > 0;
|
||||
@@ -1129,6 +1145,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
onClick={() => handleSnippetClick(item.snippet)}
|
||||
onEdit={() => handleEditSnippet(item.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(item.snippet.id)}
|
||||
onCopyCommand={() => handleCopySnippetCommand(item.snippet)}
|
||||
copyCommandLabel={t('scripts.actions.copyCommand')}
|
||||
onRunParallel={onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(item.snippet, 'parallel')
|
||||
: undefined}
|
||||
@@ -1182,6 +1200,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
onClick={() => handleSnippetClick(item.row.snippet)}
|
||||
onEdit={() => handleEditSnippet(item.row.snippet)}
|
||||
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
|
||||
onCopyCommand={() => handleCopySnippetCommand(item.row.snippet)}
|
||||
copyCommandLabel={t('scripts.actions.copyCommand')}
|
||||
onRunParallel={onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(item.row.snippet, 'parallel')
|
||||
: undefined}
|
||||
@@ -1505,6 +1525,8 @@ interface SnippetRowProps {
|
||||
onDelete: () => void;
|
||||
onRunParallel?: () => void;
|
||||
onRunSequential?: () => void;
|
||||
onCopyCommand?: () => void;
|
||||
copyCommandLabel?: string;
|
||||
runParallelLabel?: string;
|
||||
runSequentialLabel?: string;
|
||||
editLabel: string;
|
||||
@@ -1527,6 +1549,8 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
onDelete,
|
||||
onRunParallel,
|
||||
onRunSequential,
|
||||
onCopyCommand,
|
||||
copyCommandLabel,
|
||||
runParallelLabel,
|
||||
runSequentialLabel,
|
||||
editLabel,
|
||||
@@ -1604,6 +1628,11 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onCopyCommand ? (
|
||||
<ContextMenuItem onClick={onCopyCommand}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -557,12 +557,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setHostClickBehavior={settings.setHostClickBehavior}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
tabBarPosition={settings.tabBarPosition}
|
||||
setTabBarPosition={settings.setTabBarPosition}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
sftpInSidebar={settings.sftpInSidebar}
|
||||
setSftpInSidebar={settings.setSftpInSidebar}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
@@ -681,6 +677,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
showTrayIcon={settings.showTrayIcon}
|
||||
setShowTrayIcon={settings.setShowTrayIcon}
|
||||
autoLaunchEnabled={settings.autoLaunchEnabled}
|
||||
setAutoLaunchEnabled={settings.setAutoLaunchEnabled}
|
||||
autoLaunchSupported={settings.autoLaunchSupported}
|
||||
|
||||
Loaded 100 of 375 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user