Compare commits

..
337 changed files with 861 additions and 18700 deletions

No files matched your search

-2
View File
@@ -11,7 +11,6 @@ import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
@@ -82,7 +81,6 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<MultilinePasteConfirmHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
@@ -698,51 +698,3 @@ test('connection log host snapshot includes custom host icon fields', () => {
},
);
});
for (const scenario of [
{ name: 'single orphan', active: 'one', sessions: [{ id: 'one' }], available: false, expected: null },
{ name: 'two orphans', active: 'one', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: 'global' },
{ name: 'hidden active session', active: 'hidden', sessions: [{ id: 'one' }, { id: 'two' }, { id: 'hidden', hiddenFromTabs: true }], available: true, expected: null },
{ name: 'non-terminal tab', active: 'vault', sessions: [{ id: 'one' }, { id: 'two' }], available: true, expected: null },
{ name: 'workspace', active: 'workspace', sessions: [{ id: 'one', workspaceId: 'workspace' }, { id: 'two', workspaceId: 'workspace' }], available: false, expected: 'workspace' },
]) {
for (const scheme of ['pc', 'mac'] as const) {
test(`broadcast shortcut ${scheme}: ${scenario.name} only consumes an executed toggle`, () => {
const target = new FakeHTMLElement();
let prevented = 0;
let stopped = 0;
let globalEnabled = false;
let workspaceEnabled = false;
const toggles: string[] = [];
const event = {
key: 'b', code: 'KeyB', ctrlKey: scheme === 'pc', metaKey: scheme === 'mac',
altKey: false, shiftKey: false, target, composedPath: () => [target],
preventDefault: () => { prevented += 1; },
stopPropagation: () => { stopped += 1; },
} as unknown as KeyboardEvent;
const actionCtx = {
activeTabStore: { getActiveTabId: () => scenario.active },
editorTabs: [], orderedTabs: [], settings: {}, toEditorTabId: (id: string) => id,
sessions: scenario.sessions,
workspaces: scenario.active === 'workspace' ? [{ id: 'workspace' }] : [],
canUseGlobalBroadcast: scenario.available,
toggleGlobalBroadcast: () => { globalEnabled = !globalEnabled; toggles.push('global'); },
toggleBroadcast: (id: string) => { assert.equal(id, 'workspace'); workspaceEnabled = !workspaceEnabled; toggles.push('workspace'); },
};
const invoke = () => handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false, closeTabKeyStr: '', hotkeyScheme: scheme,
keyBindings: DEFAULT_KEY_BINDINGS, matchesKeyBinding,
executeHotkeyAction: (action: string, e: KeyboardEvent) => executeHotkeyActionImpl(() => actionCtx, action, e),
}), event);
invoke();
assert.equal(globalEnabled, scenario.expected === 'global');
assert.equal(workspaceEnabled, scenario.expected === 'workspace');
invoke();
assert.equal(globalEnabled, false);
assert.equal(workspaceEnabled, false);
assert.deepEqual(toggles, scenario.expected ? [scenario.expected, scenario.expected] : []);
assert.equal(prevented, scenario.expected ? 2 : 0);
assert.equal(stopped, scenario.expected ? 2 : 0);
});
}
}
-11
View File
@@ -8,7 +8,6 @@ import {
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { useAppearanceChromeStore } from '../state/appearanceChromeStore';
import { focusEditorInWindow } from '../state/editorWindowClient';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type { TerminalAppearanceHostScope, ResolvedAppearance } from '../../domain/terminalAppearanceRuntime';
@@ -72,16 +71,6 @@ export function AppActiveTabChrome({
}
}, [showSftpTab, activeTabId, setActiveTabId]);
useEffect(() => {
if (!isEditorTabId(activeTabId)) return;
const editorId = fromEditorTabId(activeTabId);
if (!editorId) return;
const tab = editorTabs.find((entry) => entry.id === editorId);
if (tab?.placement !== 'window') return;
void focusEditorInWindow(editorId);
setActiveTabId('vault');
}, [activeTabId, editorTabs, setActiveTabId]);
const chromeThemeDeps = useMemo(() => ({
accentMode,
applyAppTheme,
+4 -20
View File
@@ -313,16 +313,6 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
continue;
}
// An unavailable broadcast action must leave the chord to the terminal
// (notably tmux's Ctrl+B prefix). Dispatch once before consuming it.
if (binding.action === 'broadcast') {
if (executeHotkeyAction(binding.action, e) === true) {
e.preventDefault();
e.stopPropagation();
}
return;
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
@@ -776,9 +766,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 +913,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;
@@ -977,14 +963,12 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleBroadcast(activeWs.id);
return true;
} else if (sessions.some((session: TerminalSession) => (
session.id === currentId && !session.workspaceId && !session.hiddenFromTabs
session.id === currentId && !session.workspaceId
)) && canUseGlobalBroadcast) {
toggleGlobalBroadcast();
return true;
}
return false;
break;
}
case 'openSettings':
handleOpenSettingsRef.current();
+7 -31
View File
@@ -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>
);
};
-14
View File
@@ -22,7 +22,6 @@ import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
import { installEditorWindowSourceListeners } from '../state/editorWindowClient';
import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry';
import {
isPluginViewTabId,
@@ -142,7 +141,6 @@ export function AppSideEffects() {
keyBindings,
isHotkeyRecording,
showSftpTab,
sftpInSidebar,
shellOnlyTabNumberShortcuts,
workspaceFocusStyle,
} = settings;
@@ -254,8 +252,6 @@ export function AppSideEffects() {
runSnippet,
getOrderedWorkTabs,
toggleBroadcast,
toggleGlobalBroadcast,
canUseGlobalBroadcast,
logViews,
closeLogView,
copySession,
@@ -637,8 +633,6 @@ export function AppSideEffects() {
workspaces,
});
useEffect(() => installEditorWindowSourceListeners(), []);
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
@@ -912,8 +906,6 @@ export function AppSideEffects() {
// array thrash does not rebuild appTerminalDomain (see domain isolation).
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const canUseGlobalBroadcastRef = useRef(canUseGlobalBroadcast);
canUseGlobalBroadcastRef.current = canUseGlobalBroadcast;
// Logs live in connectionLogsStore — keep a ref fresh without re-rendering App.
const connectionLogsRef = useRef(getConnectionLogsSnapshot().connectionLogs);
useEffect(() => {
@@ -933,8 +925,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);
@@ -1078,7 +1068,6 @@ export function AppSideEffects() {
setNavigateToSection,
settings: {
showSftpTab: showSftpTabRef.current,
sftpInSidebar: sftpInSidebarRef.current,
shellOnlyTabNumberShortcuts: shellOnlyTabNumberShortcutsRef.current,
},
sftpPaneMagnificationRef,
@@ -1087,8 +1076,6 @@ export function AppSideEffects() {
terminalPaneMagnificationRef,
toEditorTabId,
toggleBroadcast,
toggleGlobalBroadcast,
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
toggleWorkspaceViewMode,
@@ -1103,7 +1090,6 @@ export function AppSideEffects() {
splitSessionWithCurrentShell,
moveFocusInWorkspace,
toggleBroadcast,
toggleGlobalBroadcast,
toggleWorkspaceViewMode,
confirmIfBusyLocalTerminal,
]);
+5 -41
View File
@@ -2,9 +2,8 @@
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId, useIsEditorTabActive } from '../state/activeTabStore';
import { editorTabStore, tabIsDirty } from '../state/editorTabStore';
import { editorTabStore } from '../state/editorTabStore';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
import { focusEditorInWindow, popOutEditorTab } from '../state/editorWindowClient';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { TopTabs } from '../../components/TopTabs';
import { VaultView } from '../../components/VaultView';
@@ -292,7 +291,6 @@ function AppViewInner({ domains }: AppViewProps) {
resolvedTheme,
windowOpacity,
showSftpTab,
sftpInSidebar,
showHostTreeSidebar,
showRecentHosts,
hostClickBehavior,
@@ -302,8 +300,6 @@ function AppViewInner({ domains }: AppViewProps) {
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
localShellSidePanelAutoOpen,
localShellSidePanelAutoOpenTab,
} = useSettingsChromeStore();
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
@@ -422,23 +418,6 @@ function AppViewInner({ domains }: AppViewProps) {
const tab = editorTabStore.getTab(id);
if (!tab) return false;
if (tab.placement === "window") {
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({ editorIds: [id] });
const ok = Boolean(result?.success) && result?.cancelled !== true;
if (ok) {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditorsRef.current;
const idx = list.indexOf(closingTabId);
releaseEditorTabSaveCoordinator(id);
editorTabStore.close(id);
if (activeTabStore.getActiveTabId() === closingTabId) {
const next = list[idx - 1] ?? list[idx + 1] ?? 'vault';
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
}
}
return ok;
}
const closeEditorAndActivateNeighbor = () => {
const closingTabId = toEditorTabId(id);
const list = orderedTabsWithEditorsRef.current;
@@ -450,7 +429,7 @@ function AppViewInner({ domains }: AppViewProps) {
activeTabStore.setActiveTabId(next === closingTabId ? 'vault' : next);
};
const dirty = tabIsDirty(tab);
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
closeEditorAndActivateNeighbor();
return true;
@@ -481,16 +460,6 @@ function AppViewInner({ domains }: AppViewProps) {
// App.tsx stub `() => false` between commit and useEffect.
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
const handlePopOutEditorTab = useCallback(async (id: string) => {
const tab = editorTabStore.getTab(id);
if (!tab || tab.placement === "window") {
if (tab) void focusEditorInWindow(id);
return;
}
const host = hostById.get(tab.hostId);
await popOutEditorTab(id, host?.label);
}, [hostById]);
const handleSaveSessionRename = useCallback((name: string) => {
if (!sessionRenameTarget) return;
if (!name.trim()) return;
@@ -559,7 +528,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}
@@ -567,8 +536,6 @@ function AppViewInner({ domains }: AppViewProps) {
pluginViewTabs={pluginViewTabs}
onClosePluginViewTab={closePluginViewTab}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
onPopOutEditorTab={handlePopOutEditorTab}
onFocusEditorWindowTab={(id) => { void focusEditorInWindow(id); }}
hostById={hostById}
/>
@@ -785,8 +752,6 @@ function AppViewInner({ domains }: AppViewProps) {
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
localShellSidePanelAutoOpen={localShellSidePanelAutoOpen}
localShellSidePanelAutoOpenTab={localShellSidePanelAutoOpenTab}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
@@ -819,7 +784,7 @@ function AppViewInner({ domains }: AppViewProps) {
))}
{/* Editor Tabs — kept mounted for Monaco instance persistence; visibility toggled via CSS */}
{editorTabs.filter((tab) => tab.placement !== "window").map((tab) => (
{editorTabs.map((tab) => (
<LazyLoadBoundary key={tab.id} name="Editor" resetKey={tab.id}>
<Suspense fallback={<TextEditorTabFallback tabId={tab.id} />}>
<LazyTextEditorTabView
@@ -828,7 +793,6 @@ function AppViewInner({ domains }: AppViewProps) {
keyBindings={keyBindings}
hostById={hostById}
onRequestClose={(id) => handleRequestCloseEditorTabRef.current(id)}
onPopOut={(id) => { void handlePopOutEditorTab(id); }}
/>
</Suspense>
</LazyLoadBoundary>
@@ -923,7 +887,7 @@ function AppViewInner({ domains }: AppViewProps) {
results={quickResults}
sessions={sessions}
workspaces={workspaces}
showSftpTab={showSftpTab || sftpInSidebar}
showSftpTab={showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onEditHost={(host) => {
+6 -22
View File
@@ -1,12 +1,10 @@
import type { TransferOwnerObservation } from "../state/sftp/waitForTransferOwner";
import type { TransferStatus, TransferTask } from "../../domain/models";
export const DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD = 4_096;
export const DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE = 512;
export interface DedicatedResumeChildUpdateBatcher {
push(task: TransferTask, observation?: TransferOwnerObservation): void;
discard(taskId: string): void;
push(task: TransferTask): void;
flush(): void;
}
@@ -26,39 +24,25 @@ export function createDedicatedResumeChildUpdateBatcher(deps: {
hasTask: (taskId: string) => boolean;
upsertTasks: (tasks: readonly TransferTask[]) => void;
}): DedicatedResumeChildUpdateBatcher {
const pending = new Map<string, { task: TransferTask; observation?: TransferOwnerObservation }>();
const pending = new Map<string, TransferTask>();
const flush = () => {
if (pending.size === 0) return;
const batch = [...pending.values()];
pending.clear();
try {
const updates = batch.filter(({ observation }) => !observation?.hasIdentityConflict()).map(({ task }) => task);
if (updates.length) deps.upsertTasks(updates);
} finally {
for (const { observation } of batch) observation?.dispose();
}
deps.upsertTasks(batch);
};
return {
push(task, observation) {
if (observation?.hasIdentityConflict()) {
pending.get(task.id)?.observation?.dispose();
pending.delete(task.id);
observation.dispose();
return;
}
push(task) {
const shouldBatch = !!task.parentTaskId
&& deps.getTaskCount() >= DEDICATED_RESUME_LARGE_HISTORY_THRESHOLD
&& deps.hasTask(task.id);
if (!shouldBatch) {
try { deps.upsertTasks([task]); } finally { observation?.dispose(); }
deps.upsertTasks([task]);
return;
}
const previous = pending.get(task.id);
if (previous?.observation && previous.observation !== observation) previous.observation.dispose();
pending.set(task.id, { task, observation });
pending.set(task.id, task);
if (pending.size >= DEDICATED_RESUME_CHILD_UPDATE_BATCH_SIZE) flush();
},
discard: (taskId) => { pending.get(taskId)?.observation?.dispose(); pending.delete(taskId); },
flush,
};
}
+1 -5
View File
@@ -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,
@@ -29,7 +29,7 @@ test('every renderer root mounts under StrictMode', () => {
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
const renderCalls = indexSource.match(/root\.render\(/g) ?? [];
assert.equal(renderCalls.length, 5, 'main, settings, tray, terminal-popup and editor-window roots');
assert.equal(renderCalls.length, 4, 'main, settings, tray and terminal-popup roots');
let cursor = 0;
for (let index = 0; index < renderCalls.length; index += 1) {
@@ -1,47 +0,0 @@
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 { useAppStartupEffects } from "./useAppStartupEffects.ts";
import { editorTabStore } from "../state/editorTabStore.ts";
import { popOutEditorTab } from "../state/editorWindowClient.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { toast } from "../../components/ui/toast.tsx";
test("app Quit sees the source's unsaved contents until the detached receiver accepts them", async (t) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
let renderer: ReactTestRenderer | undefined;
const editorId = "quit-during-editor-handoff";
t.after(async () => {
await act(async () => renderer?.unmount());
editorTabStore.close(editorId);
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
});
let query!: () => Promise<void>;
let accept!: (result: { success: boolean }) => void;
const receipt = new Promise<{ success: boolean }>((resolve) => { accept = resolve; });
const reports: boolean[] = [];
t.mock.method(toast, "warning", () => {});
t.mock.method(netcattyBridge, "get", () => ({
openEditorWindow: () => receipt,
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
reportDirtyEditorsResult: (dirty: boolean) => reports.push(dirty),
}));
function SourceWindow() {
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
return null;
}
await act(async () => { renderer = create(React.createElement(SourceWindow)); });
editorTabStore.upsertFromSnapshot({ editorId, sessionId: "source", sftpTabId: "sftp", hostId: "fixture", remotePath: "/alpha.txt", fileName: "alpha.txt", languageId: "plaintext", content: "unsaved", baselineContent: "original", wordWrap: false, viewState: null });
const opening = popOutEditorTab(editorId);
assert.equal(editorTabStore.getTab(editorId)?.placement, "window");
await query();
assert.deepEqual(reports, [true], "a cold receiver must not make the only owned unsaved copy invisible to Quit");
accept({ success: true });
assert.equal(await opening, true);
assert.equal(editorTabStore.getTab(editorId)?.content, "");
await query();
assert.deepEqual(reports, [true, false], "after receipt the destination owns dirty reporting; the source copy is released");
});
+2 -6
View File
@@ -181,14 +181,11 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
},
{
children,
onChildUpdate: (child, observation) => {
onChildUpdate: (child) => {
if (acceptsResumeCallbacks) {
childUpdateBatcher.push({ ...child, ownerId: "dedicated-resume" }, observation);
return true;
childUpdateBatcher.push({ ...child, ownerId: "dedicated-resume" });
}
},
onChildSuperseded: (taskId) => childUpdateBatcher.discard(taskId),
flushChildUpdates: () => childUpdateBatcher.flush(),
onDirectoryCheckpointUpdate: (checkpoint) => {
if (acceptsResumeCallbacks) {
sftpTransferCenterStore.patchTask(task.id, {
@@ -357,7 +354,6 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
// exactly the same outcome.
let hasDirty = false;
try {
// Pending handoffs retain source contents; accepted handoffs clear them.
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
if (!hasDirty) {
+5 -13
View File
@@ -4,7 +4,6 @@ export const enAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
'ai.chat.modelCatalogWarning': 'Could not refresh the live model list. Showing the last available or built-in models.',
'ai.chat.compactingContext': 'Compacting earlier context…',
'ai.chat.compactingStep': 'Trimming context for next step…',
'ai.chat.compactionRetry': 'Request was too large. Compacting context and retrying…',
@@ -365,24 +364,21 @@ export const enAiMessages: Messages = {
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.client.other': 'Other',
'ai.externalMcp.cliCommand': 'CLI command',
'ai.externalMcp.configSnippet': 'Config snippet',
'ai.externalMcp.universalSetupPrompt': 'Universal setup prompt',
'ai.externalMcp.addToCodex': 'Add to Codex',
'ai.externalMcp.addToClaude': 'Add to Claude Code',
'ai.externalMcp.addToGrok': 'Add to Grok',
'ai.externalMcp.codexAdded': 'Codex MCP entry and Netcatty skill added. Restart Codex or open a new Codex session.',
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry and Netcatty skill added. Restart Claude Code or open a new Claude Code session.',
'ai.externalMcp.grokAdded': 'Grok MCP entry and Netcatty skill added. Restart Grok or open a new Grok session.',
'ai.externalMcp.codexAdded': 'Codex MCP entry added. Restart Codex or open a new Codex session.',
'ai.externalMcp.claudeAdded': 'Claude Code MCP entry added. Restart Claude Code or open a new Claude Code session.',
'ai.externalMcp.grokAdded': 'Grok MCP entry added. Restart Grok or open a new Grok session.',
'ai.externalMcp.installCodex': 'Install Codex separately, then click Refresh.',
'ai.externalMcp.installClaude': 'Install Claude Code separately, then click Refresh.',
'ai.externalMcp.installGrok': 'Install the Grok CLI separately, then click Refresh.',
'ai.externalMcp.conflict.description': 'A netcatty-external entry already exists and points elsewhere. Remove or edit it manually.',
'ai.externalMcp.enableForLauncher': 'Enable External MCP to get a usable launcher path.',
'ai.externalMcp.cursor.title': 'Cursor',
'ai.externalMcp.cursor.description': 'Merge this into your Cursor MCP configuration without replacing existing servers.',
'ai.externalMcp.other.description': 'Copy the setup prompt into another terminal agent, or merge the MCP snippet into its client configuration.',
'ai.externalMcp.cursor.title': 'Cursor / other clients',
'ai.externalMcp.cursor.description': 'Merge this into your MCP config (for example ~/.cursor/mcp.json). Do not replace the whole file if you already have other servers.',
'ai.externalMcp.status.unavailable': 'Unavailable',
'ai.externalMcp.status.disabled': 'Disabled',
'ai.externalMcp.status.running': 'Running',
@@ -601,12 +597,8 @@ export const enAiMessages: Messages = {
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.movePanelBottom': 'Move panel to bottom',
'terminal.layer.closePanel': 'Close panel',
'terminal.layer.closePane': 'Close split',
'terminal.layer.saveLayoutAsDefault': 'Save layout as default',
'terminal.layer.layoutSavedAsDefault': 'Layout saved as default',
'terminal.layer.layoutSaveFailed': 'Could not save layout',
'terminal.layer.resizeSplit': 'Resize split',
'terminal.layer.splitHorizontal': 'Split top and bottom',
'terminal.layer.splitVertical': 'Split left and right',
-20
View File
@@ -364,8 +364,6 @@ export const enCoreMessages: Messages = {
'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',
@@ -651,12 +649,6 @@ Highlight the focused split pane:
'settings.terminal.sidePanel.pane.system': 'System',
'settings.terminal.sidePanel.pane.notes': 'Notes',
'settings.terminal.sidePanel.pane.ai': 'AI',
'settings.terminal.section.localShellSidePanel': 'Local Shell Side Panel',
'settings.terminal.localShellSidePanel.autoOpen': 'Open side panel with local shell',
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Automatically open a side panel when opening a local shell.',
'settings.terminal.localShellSidePanel.autoOpenPane': 'Default side panel',
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Choose which panel opens first.',
'settings.terminal.localShellSidePanel.pane.files': 'Files',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
'settings.terminal.behavior.rightClick': 'Right-click behavior',
@@ -699,12 +691,6 @@ Highlight the focused split pane:
'settings.terminal.behavior.autoUploadClipboardImage': 'Auto-upload clipboard images on paste',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'When the clipboard holds an image, pasting in a remote session uploads it via SFTP to .netcatty-paste-images/ under the remote working directory and inserts the remote path instead of pasting text.',
'settings.terminal.behavior.confirmMultilinePaste': 'Ask before pasting multiple lines',
'settings.terminal.behavior.confirmMultilinePaste.desc':
'Show a confirmation dialog before pasting multiple lines, with the line/character count, an editable preview, and Send / Send line by line / Cancel actions. Protects switch and firewall CLIs (Cisco IOS, Huawei VRP, H3C Comware) that do not support bracketed paste.',
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Confirmation threshold (lines)',
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
'Ask for confirmation when a paste contains at least this many lines.',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
@@ -1029,7 +1015,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',
@@ -1063,10 +1048,6 @@ Highlight the focused split pane:
'vault.groups.deleteDialogTitle': 'Delete Group',
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
'vault.groups.deleteDialog.mixedDesc': 'This group contains hosts imported from a linked SSH config file. Every host under its managed subgroup will be deleted, even hosts not managed by that file. All other hosts will move to the root level unless you also delete them below.',
'vault.groups.deleteDialog.managedWarning': 'Deleting this group will also clear its entries from the linked SSH config file. Entries that came from that file may be lost for other apps that use it. Back up the file before continuing.',
'vault.groups.deleteDialog.sourcesChanged': 'The managed files affected by this deletion have changed. Review the files and confirm again.',
'vault.groups.deleteDialog.managedFile': 'Linked file: {file}',
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
'vault.groups.deleteDialog.bulkTitle': 'Delete Selected Groups',
'vault.groups.deleteDialog.bulkDesc': 'This will permanently delete the selected groups and move their hosts to the root level.',
@@ -1125,7 +1106,6 @@ Highlight the focused split pane:
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
'vault.hosts.export.toast.passphrasesSkipped': '{count} saved key passphrases could not be read and were left blank',
'vault.hosts.export.toast.proxyCredentialsSkipped': '{count} saved proxy passwords could not be read and were left blank',
'vault.hosts.export.toast.noHosts': 'No hosts to export',
'vault.hosts.allHosts': 'All hosts',
'vault.hosts.pinned': 'Pinned',
-19
View File
@@ -3,13 +3,6 @@ import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.menu.saveScreen': 'Save current screen',
'terminal.saveScreen.failed': 'Failed to save terminal screen.',
// Multi-line paste confirmation dialog (#3398)
'terminal.pasteConfirm.title': 'Paste multiple lines?',
'terminal.pasteConfirm.summary': '{lines} lines ({chars} characters) will be sent to the session.',
'terminal.pasteConfirm.preview': 'Preview (editable)',
'terminal.pasteConfirm.send': 'Send',
'terminal.pasteConfirm.sendLineByLine': 'Send line by line',
'terminal.pasteConfirm.cancel': 'Cancel',
'terminal.sudoHint.pressEnter': 'Press Enter to paste saved password',
'terminal.passwordPicker.title': 'Saved passwords',
'terminal.passwordPicker.empty': 'No saved passwords',
@@ -100,7 +93,6 @@ export const enTerminalMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': 'Reconnect this session',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.gpu': 'GPU Usage',
'terminal.serverStats.memory': 'Memory Usage',
'terminal.serverStats.memoryDetails': 'Memory Details',
'terminal.serverStats.memUsed': 'Used',
@@ -835,10 +827,6 @@ export const enTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'e.g. Arduino Uno',
'serial.connectAndSave': 'Connect & Save',
'serial.edit.title': 'Serial Port Settings',
'serial.field.username': 'Username',
'serial.field.password': 'Password',
'serial.field.autoLoginDesc': 'When set, Login/Password prompts on the serial console are answered automatically with these saved credentials.',
'serial.field.clearPassword': 'Clear saved password',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Authentication Required',
@@ -865,13 +853,6 @@ export const enTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
'sftp.editor.maximize': 'Maximize',
'sftp.editor.popOut': 'Open in editor window',
'sftp.editor.dock': 'Dock to main window',
'sftp.editor.focusWindow': 'Focus editor window',
'sftp.editor.close': 'Close',
'sftp.editor.windowTitle': 'Editor',
'sftp.editor.windowEmpty': 'No files open',
'sftp.editor.dockFailed': 'Could not dock this file back to the main window',
'sftp.editor.unsavedTitle': 'Unsaved changes',
'sftp.editor.unsavedMessage': '{fileName} has unsaved changes. Save before closing?',
'sftp.editor.discardChanges': 'Discard',
+5 -10
View File
@@ -4,7 +4,6 @@ export const esAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Configuración del agente',
'ai.chat.preparing': 'Preparando…',
'ai.chat.modelCatalogWarning': 'No se pudo actualizar la lista de modelos. Se muestran los últimos modelos disponibles o los integrados.',
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
@@ -356,24 +355,21 @@ export const esAiMessages: Messages = {
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.client.other': 'Otros',
'ai.externalMcp.cliCommand': 'Comando CLI',
'ai.externalMcp.configSnippet': 'Fragmento de configuración',
'ai.externalMcp.universalSetupPrompt': 'Prompt de instalación universal',
'ai.externalMcp.addToCodex': 'Agregar a Codex',
'ai.externalMcp.addToClaude': 'Agregar a Claude Code',
'ai.externalMcp.addToGrok': 'Agregar a Grok',
'ai.externalMcp.codexAdded': 'Entrada MCP de Codex y skill de Netcatty agregados. Reinicia Codex o abre una nueva sesión de Codex.',
'ai.externalMcp.claudeAdded': 'Entrada MCP de Claude Code y skill de Netcatty agregados. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
'ai.externalMcp.grokAdded': 'Entrada MCP de Grok y skill de Netcatty agregados. Reinicia Grok o abre una nueva sesión de Grok.',
'ai.externalMcp.codexAdded': 'Entrada de MCP de Codex agregada. Reinicia Codex o abre una nueva sesión de Codex.',
'ai.externalMcp.claudeAdded': 'Entrada de MCP de Claude Code agregada. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
'ai.externalMcp.grokAdded': 'Entrada de MCP de Grok agregada. Reinicia Grok o abre una nueva sesión de Grok.',
'ai.externalMcp.installCodex': 'Instala Codex por separado y luego haz clic en Actualizar.',
'ai.externalMcp.installClaude': 'Instala Claude Code por separado y luego haz clic en Actualizar.',
'ai.externalMcp.installGrok': 'Instala la CLI de Grok por separado y luego haz clic en Actualizar.',
'ai.externalMcp.conflict.description': 'Ya existe una entrada netcatty-external que apunta a otro lugar. Quítala o edítala manualmente.',
'ai.externalMcp.enableForLauncher': 'Habilita el MCP externo para obtener una ruta de launcher utilizable.',
'ai.externalMcp.cursor.title': 'Cursor',
'ai.externalMcp.cursor.description': 'Combina esto con la configuración MCP de Cursor sin reemplazar los servidores existentes.',
'ai.externalMcp.other.description': 'Copia el prompt de instalación en otro agente de terminal o combina el fragmento MCP con la configuración de su cliente.',
'ai.externalMcp.cursor.title': 'Cursor / otros clientes',
'ai.externalMcp.cursor.description': 'Combina esto en tu configuración de MCP (por ejemplo, ~/.cursor/mcp.json). No reemplaces todo el archivo si ya tienes otros servidores.',
'ai.externalMcp.status.unavailable': 'No disponible',
'ai.externalMcp.status.disabled': 'Desactivado',
'ai.externalMcp.status.running': 'En ejecución',
@@ -592,7 +588,6 @@ export const esAiMessages: Messages = {
'terminal.layer.aiChat': 'Chat de IA',
'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.closePanel': 'Cerrar panel',
'terminal.layer.closePane': 'Cerrar división',
'terminal.layer.resizeSplit': 'Redimensionar división',
-20
View File
@@ -279,8 +279,6 @@ export const esCoreMessages: Messages = {
'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',
@@ -566,12 +564,6 @@ Resalta el panel dividido enfocado:
'settings.terminal.sidePanel.pane.system': 'Sistema',
'settings.terminal.sidePanel.pane.notes': 'Notas',
'settings.terminal.sidePanel.pane.ai': 'IA',
'settings.terminal.section.localShellSidePanel': 'Panel lateral de Shell local',
'settings.terminal.localShellSidePanel.autoOpen': 'Expandir panel lateral al abrir Shell local',
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Abre automáticamente un panel lateral después de abrir el Shell local.',
'settings.terminal.localShellSidePanel.autoOpenPane': 'Panel lateral predeterminado',
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Elige qué panel se abre primero.',
'settings.terminal.localShellSidePanel.pane.files': 'Archivos',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Ajusta los colores para cumplir con los requisitos de contraste (1 = desactivado, 21 = máximo)',
'settings.terminal.behavior.rightClick': 'Comportamiento del clic derecho',
@@ -614,12 +606,6 @@ Resalta el panel dividido enfocado:
'settings.terminal.behavior.autoUploadClipboardImage': 'Subir automáticamente imágenes del portapapeles al pegar',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'Cuando el portapapeles contiene una imagen, pegarla en una sesión remota la sube por SFTP a .netcatty-paste-images/ dentro del directorio de trabajo remoto e inserta la ruta remota en lugar de pegar texto.',
'settings.terminal.behavior.confirmMultilinePaste': 'Preguntar antes de pegar varias líneas',
'settings.terminal.behavior.confirmMultilinePaste.desc':
'Muestra un diálogo de confirmación antes de pegar varias líneas, con el número de líneas y caracteres, una vista previa editable y las acciones Enviar / Enviar línea por línea / Cancelar. Protege las CLI de dispositivos de red (Cisco IOS, Huawei VRP, H3C Comware) que no admiten bracketed paste.',
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Umbral de confirmación (líneas)',
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
'Pedir confirmación cuando el texto pegado contenga al menos esta cantidad de líneas.',
'settings.terminal.behavior.shiftEnterNewline': 'Texto de Shift+Enter',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
@@ -987,7 +973,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',
@@ -1021,10 +1006,6 @@ Resalta el panel dividido enfocado:
'vault.groups.deleteDialogTitle': 'Eliminar grupo',
'vault.groups.deleteDialog.desc': 'Esto eliminará permanentemente el grupo y moverá todos los hosts al nivel raíz.',
'vault.groups.deleteDialog.managedDesc': 'Este es un grupo administrado de configuración SSH. Eliminarlo también eliminará todos los hosts y lo desvinculará del archivo de origen.',
'vault.groups.deleteDialog.mixedDesc': 'Este grupo contiene hosts importados de un archivo de configuración SSH vinculado. Todos los hosts bajo su subgrupo administrado se eliminarán, incluso los hosts no administrados por ese archivo. Los demás hosts se moverán al nivel raíz a menos que también los elimine abajo.',
'vault.groups.deleteDialog.managedWarning': 'Al eliminar este grupo también se borrarán sus entradas del archivo de configuración SSH vinculado. Las entradas provenientes de ese archivo pueden perderse y también afectará a otras aplicaciones que lo usen. Haz una copia de seguridad del archivo antes de continuar.',
'vault.groups.deleteDialog.sourcesChanged': 'Los archivos administrados afectados por esta eliminación han cambiado. Revisa los archivos y vuelve a confirmar.',
'vault.groups.deleteDialog.managedFile': 'Archivo vinculado: {file}',
'vault.groups.deleteDialog.deleteHosts': 'También eliminar todos los hosts de este grupo',
'vault.groups.deleteDialog.bulkTitle': 'Eliminar grupos seleccionados',
'vault.groups.deleteDialog.bulkDesc': 'Esto eliminará permanentemente los grupos seleccionados y moverá sus hosts al nivel raíz.',
@@ -1083,7 +1064,6 @@ Resalta el panel dividido enfocado:
'vault.hosts.export.toast.success': 'Se exportaron {count} hosts a CSV',
'vault.hosts.export.toast.successWithSkipped': 'Se exportaron {count} hosts a CSV (se omitieron {skipped} hosts no compatibles)',
'vault.hosts.export.toast.passphrasesSkipped': 'No se pudieron leer {count} frases de contraseña de claves guardadas y se dejaron en blanco',
'vault.hosts.export.toast.proxyCredentialsSkipped': 'No se pudieron leer {count} contraseñas de proxy guardadas y se dejaron en blanco',
'vault.hosts.export.toast.noHosts': 'No hay hosts para exportar',
'vault.hosts.allHosts': 'Todos los hosts',
'vault.hosts.pinned': 'Fijados',
-19
View File
@@ -3,13 +3,6 @@ import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.menu.saveScreen': 'Guardar pantalla actual',
'terminal.saveScreen.failed': 'No se pudo guardar la pantalla del terminal.',
// Diálogo de confirmación de pegado multilínea (#3398)
'terminal.pasteConfirm.title': '¿Pegar varias líneas?',
'terminal.pasteConfirm.summary': 'Se enviarán {lines} líneas ({chars} caracteres) a la sesión.',
'terminal.pasteConfirm.preview': 'Vista previa (editable)',
'terminal.pasteConfirm.send': 'Enviar',
'terminal.pasteConfirm.sendLineByLine': 'Enviar línea por línea',
'terminal.pasteConfirm.cancel': 'Cancelar',
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
'terminal.passwordPicker.title': 'Contraseñas guardadas',
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
@@ -100,7 +93,6 @@ export const esTerminalMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': 'Reconectar esta sesión',
'terminal.serverStats.cpu': 'Uso de CPU',
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
'terminal.serverStats.gpu': 'Uso de GPU',
'terminal.serverStats.memory': 'Uso de memoria',
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
'terminal.serverStats.memUsed': 'Usada',
@@ -835,10 +827,6 @@ export const esTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
'serial.connectAndSave': 'Conectar y guardar',
'serial.edit.title': 'Configuración del puerto serial',
'serial.field.username': 'Usuario',
'serial.field.password': 'Contraseña',
'serial.field.autoLoginDesc': 'Si se establecen, los mensajes Login/Password de la consola serial se responden automáticamente con estas credenciales guardadas.',
'serial.field.clearPassword': 'Borrar la contraseña guardada',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Se requiere autenticación',
@@ -865,13 +853,6 @@ export const esTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Ajuste de línea',
'sftp.editor.maximize': 'Maximizar',
'sftp.editor.popOut': 'Abrir en ventana de editor',
'sftp.editor.dock': 'Anclar a la ventana principal',
'sftp.editor.focusWindow': 'Enfocar ventana de editor',
'sftp.editor.close': 'Cerrar',
'sftp.editor.windowTitle': 'Editor',
'sftp.editor.windowEmpty': 'No hay archivos abiertos',
'sftp.editor.dockFailed': 'No se pudo anclar este archivo a la ventana principal',
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
'sftp.editor.discardChanges': 'Descartar',
+5 -10
View File
@@ -5,7 +5,6 @@ export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.chat.modelCatalogWarning': 'Не удалось обновить список моделей. Показаны последние доступные или встроенные модели.',
'ai.chat.contextCompacted': 'Контекст сжат',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
@@ -324,24 +323,21 @@ export const ruAiMessages: Messages = {
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.client.other': 'Другие',
'ai.externalMcp.cliCommand': 'CLI-команда',
'ai.externalMcp.configSnippet': 'Фрагмент конфига',
'ai.externalMcp.universalSetupPrompt': 'Универсальный запрос установки',
'ai.externalMcp.addToCodex': 'Добавить в Codex',
'ai.externalMcp.addToClaude': 'Добавить в Claude Code',
'ai.externalMcp.addToGrok': 'Добавить в Grok',
'ai.externalMcp.codexAdded': 'Запись MCP для Codex и навык Netcatty добавлены. Перезапустите Codex или откройте новую сессию.',
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code и навык Netcatty добавлены. Перезапустите Claude Code или откройте новую сессию.',
'ai.externalMcp.grokAdded': 'Запись MCP для Grok и навык Netcatty добавлены. Перезапустите Grok или откройте новую сессию.',
'ai.externalMcp.codexAdded': 'Запись MCP для Codex добавлена. Перезапустите Codex или откройте новую сессию.',
'ai.externalMcp.claudeAdded': 'Запись MCP для Claude Code добавлена. Перезапустите Claude Code или откройте новую сессию.',
'ai.externalMcp.grokAdded': 'Запись MCP для Grok добавлена. Перезапустите Grok или откройте новую сессию.',
'ai.externalMcp.installCodex': 'Сначала установите Codex, затем нажмите Обновить.',
'ai.externalMcp.installClaude': 'Сначала установите Claude Code, затем нажмите Обновить.',
'ai.externalMcp.installGrok': 'Сначала установите Grok CLI, затем нажмите Обновить.',
'ai.externalMcp.conflict.description': 'Запись netcatty-external уже существует и указывает в другое место. Удалите или измените её вручную.',
'ai.externalMcp.enableForLauncher': 'Включите External MCP, чтобы получить путь launcher.',
'ai.externalMcp.cursor.title': 'Cursor',
'ai.externalMcp.cursor.description': 'Добавьте этот фрагмент в конфигурацию MCP Cursor, сохранив существующие серверы.',
'ai.externalMcp.other.description': 'Скопируйте запрос установки в другой терминальный агент или добавьте фрагмент MCP в конфигурацию его клиента.',
'ai.externalMcp.cursor.title': 'Cursor / другие клиенты',
'ai.externalMcp.cursor.description': 'Объедините с MCP-конфигом (например ~/.cursor/mcp.json). Не заменяйте весь файл, если там уже есть другие серверы.',
'ai.externalMcp.status.unavailable': 'Недоступно',
'ai.externalMcp.status.disabled': 'Выключено',
'ai.externalMcp.status.running': 'Работает',
@@ -560,7 +556,6 @@ export const ruAiMessages: Messages = {
'terminal.layer.aiChat': 'AI-чат',
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
'terminal.layer.movePanelBottom': 'Переместить панель вниз',
'terminal.layer.closePanel': 'Закрыть панель',
'terminal.layer.closePane': 'Закрыть область',
'terminal.layer.resizeSplit': 'Изменить размер области',
-19
View File
@@ -348,8 +348,6 @@ export const ruCoreMessages: Messages = {
'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',
@@ -650,12 +648,6 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.autoUploadClipboardImage': 'Авто-загрузка изображений буфера обмена при вставке',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'Когда в буфере обмена есть изображение, вставка в удалённой сессии загружает его по SFTP в каталог .netcatty-paste-images/ в текущем удалённом каталоге и вводит удалённый путь вместо вставки текста.',
'settings.terminal.behavior.confirmMultilinePaste': 'Спрашивать перед вставкой нескольких строк',
'settings.terminal.behavior.confirmMultilinePaste.desc':
'Перед вставкой нескольких строк показывать диалог подтверждения с количеством строк и символов, редактируемым предпросмотром и действиями «Отправить» / «Отправить построчно» / «Отмена». Защищает CLI сетевых устройств (Cisco IOS, Huawei VRP, H3C Comware), не поддерживающих bracketed paste.',
'settings.terminal.behavior.confirmMultilinePasteMinLines': 'Порог подтверждения (строк)',
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
'Запрашивать подтверждение, если вставка содержит не меньше этого числа строк.',
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
@@ -758,12 +750,6 @@ export const ruCoreMessages: Messages = {
'settings.terminal.localShell.startDir.placeholder': 'Домашний каталог',
'settings.terminal.localShell.startDir.notFound': 'Каталог не найден',
'settings.terminal.localShell.startDir.isFile': 'Путь указывает на файл, а не на каталог',
'settings.terminal.section.localShellSidePanel': 'Боковая панель локальной оболочки',
'settings.terminal.localShellSidePanel.autoOpen': 'Развернуть боковую панель при открытии локальной оболочки',
'settings.terminal.localShellSidePanel.autoOpen.desc': 'Автоматически открывать боковую панель после открытия локальной оболочки.',
'settings.terminal.localShellSidePanel.autoOpenPane': 'Боковая панель по умолчанию',
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
'settings.terminal.section.connection': 'Подключение',
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
@@ -1012,7 +998,6 @@ export const ruCoreMessages: Messages = {
'vault.nav.snippets': 'Сниппеты',
'vault.nav.notes': 'Заметки',
'vault.nav.knownHosts': 'Известные хосты',
'vault.nav.sftp': 'SFTP',
'vault.nav.logs': 'Журналы',
'proxyProfiles.action.add': 'Добавить прокси',
@@ -1045,10 +1030,6 @@ export const ruCoreMessages: Messages = {
'vault.groups.deleteDialogTitle': 'Удалить группу',
'vault.groups.deleteDialog.desc': 'Группа будет безвозвратно удалена, а все хосты будут перемещены в корень.',
'vault.groups.deleteDialog.managedDesc': 'Это управляемая группа SSH-конфига. При её удалении также будут удалены все хосты и снята связь с исходным файлом.',
'vault.groups.deleteDialog.mixedDesc': 'Эта группа содержит хосты, импортированные из связанного SSH-конфига. Все хосты под управляемой подгруппой будут удалены, включая хосты, не управляемые этим файлом. Остальные хосты будут перемещены в корень, если вы также не удалите их ниже.',
'vault.groups.deleteDialog.managedWarning': 'При удалении этой группы её записи также будут удалены из связанного файла конфигурации SSH. Записи, импортированные из этого файла, могут быть утеряны, это затронет и другие приложения, использующие файл. Сделайте резервную копию файла перед продолжением.',
'vault.groups.deleteDialog.sourcesChanged': 'Управляемые файлы, затрагиваемые этим удалением, изменились. Проверьте файлы и подтвердите удаление снова.',
'vault.groups.deleteDialog.managedFile': 'Связанный файл: {file}',
'vault.groups.deleteDialog.deleteHosts': 'Также удалить все хосты в этой группе',
'vault.groups.deleteDialog.bulkTitle': 'Удалить выбранные группы',
'vault.groups.deleteDialog.bulkDesc': 'Выбранные группы будут безвозвратно удалены, а их хосты перемещены в корень.',
-19
View File
@@ -3,13 +3,6 @@ import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.menu.saveScreen': 'Сохранить текущий экран',
'terminal.saveScreen.failed': 'Не удалось сохранить экран терминала.',
// Диалог подтверждения многострочной вставки (#3398)
'terminal.pasteConfirm.title': 'Вставить несколько строк?',
'terminal.pasteConfirm.summary': 'В сессию будет отправлено строк: {lines} (символов: {chars}).',
'terminal.pasteConfirm.preview': 'Предпросмотр (можно изменить)',
'terminal.pasteConfirm.send': 'Отправить',
'terminal.pasteConfirm.sendLineByLine': 'Отправить построчно',
'terminal.pasteConfirm.cancel': 'Отмена',
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить сохранённый пароль',
'terminal.passwordPicker.title': 'Сохранённые пароли',
'terminal.passwordPicker.empty': 'Нет сохранённых паролей',
@@ -121,7 +114,6 @@ export const ruTerminalMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': 'Переподключить эту сессию',
'terminal.serverStats.cpu': 'Использование CPU',
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
'terminal.serverStats.gpu': 'Использование GPU',
'terminal.serverStats.memory': 'Использование памяти',
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
'terminal.serverStats.memUsed': 'Использовано',
@@ -842,10 +834,6 @@ export const ruTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': 'например, Arduino Uno',
'serial.connectAndSave': 'Подключить и сохранить',
'serial.edit.title': 'Настройки последовательного порта',
'serial.field.username': 'Имя пользователя',
'serial.field.password': 'Пароль',
'serial.field.autoLoginDesc': 'Если задано, запросы Login/Password в последовательной консоли автоматически заполняются этими сохранёнными учётными данными.',
'serial.field.clearPassword': 'Очистить сохранённый пароль',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Требуется аутентификация',
@@ -872,13 +860,6 @@ export const ruTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Перенос строк',
'sftp.editor.maximize': 'Развернуть',
'sftp.editor.popOut': 'Открыть в окне редактора',
'sftp.editor.dock': 'Вернуть в главное окно',
'sftp.editor.focusWindow': 'Перейти к окну редактора',
'sftp.editor.close': 'Закрыть',
'sftp.editor.windowTitle': 'Редактор',
'sftp.editor.windowEmpty': 'Нет открытых файлов',
'sftp.editor.dockFailed': 'Не удалось вернуть файл в главное окно',
'sftp.editor.unsavedTitle': 'Несохранённые изменения',
'sftp.editor.unsavedMessage': 'В файле {fileName} есть несохранённые изменения. Сохранить перед закрытием?',
'sftp.editor.discardChanges': 'Отбросить',
-1
View File
@@ -108,7 +108,6 @@ export const ruVaultMessages: Messages = {
'vault.hosts.export.toast.success': 'Экспортировано {count} хостов в CSV',
'vault.hosts.export.toast.successWithSkipped': 'Экспортировано {count} хостов в CSV ({skipped} неподдерживаемых хостов пропущено)',
'vault.hosts.export.toast.passphrasesSkipped': 'Не удалось прочитать {count} сохранённых парольных фраз ключей; они оставлены пустыми',
'vault.hosts.export.toast.proxyCredentialsSkipped': 'Не удалось прочитать {count} сохранённых паролей прокси; они оставлены пустыми',
'vault.hosts.export.toast.noHosts': 'Нет хостов для экспорта',
'vault.hosts.allHosts': 'Все хосты',
'vault.hosts.pinned': 'Закреплённые',
@@ -1,48 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const KEYS = [
'vault.groups.deleteDialog.managedDesc',
'vault.groups.deleteDialog.mixedDesc',
'vault.groups.deleteDialog.managedWarning',
'vault.groups.deleteDialog.managedFile',
] as const;
test('managed group delete warning copy exists in every locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${locale} is missing managed group delete copy`);
}
});
test('managed group delete warning mentions data loss in every locale', () => {
const MARKERS = ['备份', '備份', 'copia de seguridad', 'резервную копию', 'Back up'];
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const text = messages['vault.groups.deleteDialog.managedWarning'];
assert.match(
text ?? '',
/SSH/,
`${locale} warning should reference the SSH config file`,
);
assert.ok(
MARKERS.some((marker) => text?.includes(marker)),
`${locale} warning should tell the user to back up the file`,
);
}
});
test('linked file line interpolates the file path', () => {
for (const messages of [en, ru, es, zhCN, zhTW]) {
assert.match(
messages['vault.groups.deleteDialog.managedFile'] ?? '',
/\{file\}/,
'managedFile copy should include the {file} placeholder',
);
}
});
+5 -13
View File
@@ -4,7 +4,6 @@ export const zhCNAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
'ai.chat.modelCatalogWarning': '暂时无法更新实时模型列表,当前显示上次可用或内置的模型。',
'ai.chat.compactingContext': '正在压缩较早的上下文…',
'ai.chat.compactingStep': '正在为下一步整理上下文…',
'ai.chat.compactionRetry': '请求过大,正在压缩上下文并重试…',
@@ -365,24 +364,21 @@ export const zhCNAiMessages: Messages = {
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.client.other': '其他',
'ai.externalMcp.cliCommand': 'CLI 命令',
'ai.externalMcp.configSnippet': '配置片段',
'ai.externalMcp.universalSetupPrompt': '通用安装提示词',
'ai.externalMcp.addToCodex': '添加到 Codex',
'ai.externalMcp.addToClaude': '添加到 Claude Code',
'ai.externalMcp.addToGrok': '添加到 Grok',
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目和 Netcatty Skill。请重启 Codex 或打开新会话。',
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目和 Netcatty Skill。请重启 Claude Code 或打开新会话。',
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目和 Netcatty Skill。请重启 Grok 或打开新会话。',
'ai.externalMcp.codexAdded': '已添加 Codex MCP 条目。请重启 Codex 或打开新会话。',
'ai.externalMcp.claudeAdded': '已添加 Claude Code MCP 条目。请重启 Claude Code 或打开新会话。',
'ai.externalMcp.grokAdded': '已添加 Grok MCP 条目。请重启 Grok 或打开新会话。',
'ai.externalMcp.installCodex': '请先单独安装 Codex,然后点击刷新。',
'ai.externalMcp.installClaude': '请先单独安装 Claude Code,然后点击刷新。',
'ai.externalMcp.installGrok': '请先单独安装 Grok CLI,然后点击刷新。',
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 条目,请手动删除或修改。',
'ai.externalMcp.enableForLauncher': '请先启用对外 MCP,以获取可用的 launcher 路径。',
'ai.externalMcp.cursor.title': 'Cursor',
'ai.externalMcp.cursor.description': '将片段合并到 Cursor MCP 配置中,并保留已有服务器。',
'ai.externalMcp.other.description': '将安装提示词复制到其他终端 Agent,或把 MCP 片段合并到对应客户端配置。',
'ai.externalMcp.cursor.title': 'Cursor / 其他客户端',
'ai.externalMcp.cursor.description': '合并到 MCP 配置文件(例如 ~/.cursor/mcp.json)。若已有其他服务器,请勿整文件覆盖。',
'ai.externalMcp.status.unavailable': '不可用',
'ai.externalMcp.status.disabled': '已关闭',
'ai.externalMcp.status.running': '运行中',
@@ -601,12 +597,8 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.movePanelBottom': '面板移至底部',
'terminal.layer.closePanel': '关闭面板',
'terminal.layer.closePane': '关闭分屏',
'terminal.layer.saveLayoutAsDefault': '将布局保存为默认',
'terminal.layer.layoutSavedAsDefault': '布局已保存为默认',
'terminal.layer.layoutSaveFailed': '布局保存失败',
'terminal.layer.resizeSplit': '调整分屏大小',
'terminal.layer.splitHorizontal': '上下分屏',
'terminal.layer.splitVertical': '左右分屏',
-14
View File
@@ -120,12 +120,6 @@ export const zhCNCoreMessages: Messages = {
'settings.terminal.sidePanel.pane.system': '系统',
'settings.terminal.sidePanel.pane.notes': '笔记',
'settings.terminal.sidePanel.pane.ai': 'AI',
'settings.terminal.section.localShellSidePanel': '本地 Shell 侧边栏',
'settings.terminal.localShellSidePanel.autoOpen': '打开本地 Shell 时展开侧边栏',
'settings.terminal.localShellSidePanel.autoOpen.desc': '打开本地 Shell 后自动打开一个侧边栏。',
'settings.terminal.localShellSidePanel.autoOpenPane': '默认侧边栏',
'settings.terminal.localShellSidePanel.autoOpenPane.desc': '选择优先打开哪个面板。',
'settings.terminal.localShellSidePanel.pane.files': '文件',
// Settings > System
'settings.system.title': '系统',
@@ -368,8 +362,6 @@ export const zhCNCoreMessages: Messages = {
'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',
@@ -653,7 +645,6 @@ export const zhCNCoreMessages: Messages = {
'vault.nav.snippets': '脚本',
'vault.nav.notes': '笔记',
'vault.nav.knownHosts': '已知主机',
'vault.nav.sftp': 'SFTP',
'vault.nav.logs': '日志',
'proxyProfiles.action.add': '添加代理',
@@ -687,10 +678,6 @@ export const zhCNCoreMessages: Messages = {
'vault.groups.deleteDialogTitle': '删除分组',
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
'vault.groups.deleteDialog.mixedDesc': '该分组包含从关联 SSH config 文件导入的主机。其托管子分组下的所有主机都将被删除,包括不受该文件管理的主机;其余主机将移动到根级别,除非您在下方选择同时删除。',
'vault.groups.deleteDialog.managedWarning': '删除该分组会同时清空关联 SSH config 文件中的对应条目,从该文件导入的配置可能会丢失,其他使用该文件的应用也会受到影响。请先备份文件再继续。',
'vault.groups.deleteDialog.sourcesChanged': '此次删除涉及的托管文件已发生变化。请检查文件并重新确认。',
'vault.groups.deleteDialog.managedFile': '关联文件:{file}',
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
'vault.groups.deleteDialog.bulkTitle': '删除所选分组',
'vault.groups.deleteDialog.bulkDesc': '这将永久删除所选分组,并把其中的主机移动到根级别。',
@@ -749,7 +736,6 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV(跳过 {skipped} 个不支持的主机)',
'vault.hosts.export.toast.passphrasesSkipped': '有 {count} 个已保存的密钥口令无法读取,导出时已留空',
'vault.hosts.export.toast.proxyCredentialsSkipped': '有 {count} 个已保存的代理密码无法读取,导出时已留空',
'vault.hosts.export.toast.noHosts': '没有主机可导出',
'vault.hosts.allHosts': '全部主机',
'vault.hosts.pinned': '已置顶',
@@ -3,13 +3,6 @@ import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.menu.saveScreen': '保存当前屏幕',
'terminal.saveScreen.failed': '保存终端屏幕失败。',
// 多行粘贴确认对话框(#3398)
'terminal.pasteConfirm.title': '要粘贴多行文本吗?',
'terminal.pasteConfirm.summary': '将向会话发送 {lines} 行({chars} 个字符)。',
'terminal.pasteConfirm.preview': '预览(可编辑)',
'terminal.pasteConfirm.send': '发送',
'terminal.pasteConfirm.sendLineByLine': '逐行发送',
'terminal.pasteConfirm.cancel': '取消',
'terminal.sudoHint.pressEnter': '按 Enter 粘贴已保存的密码',
'terminal.passwordPicker.title': '已保存的密码',
'terminal.passwordPicker.empty': '没有已保存的密码',
@@ -333,12 +326,6 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.autoUploadClipboardImage': '粘贴时自动上传剪贴板图片',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'当剪贴板包含图片时,在远程会话中粘贴会通过 SFTP 将图片上传到远端当前目录的 .netcatty-paste-images/ 文件夹,并在终端输入远端路径,而不是粘贴文本。',
'settings.terminal.behavior.confirmMultilinePaste': '粘贴多行前先询问',
'settings.terminal.behavior.confirmMultilinePaste.desc':
'粘贴多行文本时先弹出确认对话框,显示行数和字符数、可编辑的预览,并提供发送 / 逐行发送 / 取消操作。可保护不支持括号粘贴的网络设备 CLI(Cisco IOS、华为 VRP、H3C Comware)。',
'settings.terminal.behavior.confirmMultilinePasteMinLines': '确认阈值(行数)',
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
'粘贴内容达到该行数时才弹出确认对话框。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
@@ -931,10 +918,6 @@ export const zhCNTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '连接并保存',
'serial.edit.title': '串口设置',
'serial.field.username': '用户名',
'serial.field.password': '密码',
'serial.field.autoLoginDesc': '设置后,串口终端出现 Login/Password 登录提示时,会自动填充此处保存的用户名和密码。',
'serial.field.clearPassword': '清除已保存的密码',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要验证',
@@ -961,13 +944,6 @@ export const zhCNTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': '自动换行',
'sftp.editor.maximize': '最大化',
'sftp.editor.popOut': '在编辑器窗口打开',
'sftp.editor.dock': '停靠回主窗口',
'sftp.editor.focusWindow': '聚焦编辑器窗口',
'sftp.editor.close': '关闭',
'sftp.editor.windowTitle': '编辑器',
'sftp.editor.windowEmpty': '没有打开的文件',
'sftp.editor.dockFailed': '无法把该文件停靠回主窗口',
'sftp.editor.unsavedTitle': '未保存的修改',
'sftp.editor.unsavedMessage': '{fileName} 有未保存的修改,是否保存后关闭?',
'sftp.editor.discardChanges': '不保存',
-1
View File
@@ -503,7 +503,6 @@ export const zhCNVaultMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': '重新连接当前会话',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.gpu': 'GPU 使用率',
'terminal.serverStats.memory': '内存使用',
'terminal.serverStats.memoryDetails': '内存详情',
'terminal.serverStats.memUsed': '已用',
+5 -13
View File
@@ -4,7 +4,6 @@ export const zhTWAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 設定',
'ai.chat.preparing': '準備中…',
'ai.chat.modelCatalogWarning': '暫時無法更新即時模型清單,目前顯示上次可用或內建的模型。',
'ai.chat.compactingContext': '正在壓縮較早的上下文…',
'ai.chat.compactingStep': '正在為下一步整理上下文…',
'ai.chat.compactionRetry': '請求過大,正在壓縮上下文並重試…',
@@ -355,24 +354,21 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.client.other': '其他',
'ai.externalMcp.cliCommand': 'CLI 命令',
'ai.externalMcp.configSnippet': '設定片段',
'ai.externalMcp.universalSetupPrompt': '通用安裝提示詞',
'ai.externalMcp.addToCodex': '新增到 Codex',
'ai.externalMcp.addToClaude': '新增到 Claude Code',
'ai.externalMcp.addToGrok': '新增到 Grok',
'ai.externalMcp.codexAdded': '已新增 Codex MCP 項目和 Netcatty Skill。請重新啟動 Codex 或開啟新工作階段。',
'ai.externalMcp.claudeAdded': '已新增 Claude Code MCP 項目和 Netcatty Skill。請重新啟動 Claude Code 或開啟新工作階段。',
'ai.externalMcp.grokAdded': '已新增 Grok MCP 項目和 Netcatty Skill。請重新啟動 Grok 或開啟新工作階段。',
'ai.externalMcp.codexAdded': '已新增 Codex MCP 項目。請重新啟動 Codex 或開啟新工作階段。',
'ai.externalMcp.claudeAdded': '已新增 Claude Code MCP 項目。請重新啟動 Claude Code 或開啟新工作階段。',
'ai.externalMcp.grokAdded': '已新增 Grok MCP 項目。請重新啟動 Grok 或開啟新工作階段。',
'ai.externalMcp.installCodex': '請先另外安裝 Codex,然後按重新整理。',
'ai.externalMcp.installClaude': '請先另外安裝 Claude Code,然後按重新整理。',
'ai.externalMcp.installGrok': '請先另外安裝 Grok CLI,然後按重新整理。',
'ai.externalMcp.conflict.description': '已存在指向其他位置的 netcatty-external 項目,請手動刪除或修改。',
'ai.externalMcp.enableForLauncher': '請先啟用對外 MCP,以取得可用的 launcher 路徑。',
'ai.externalMcp.cursor.title': 'Cursor',
'ai.externalMcp.cursor.description': '將片段合併到 Cursor MCP 設定中,並保留已有伺服器。',
'ai.externalMcp.other.description': '將安裝提示詞複製到其他終端 Agent,或把 MCP 片段合併到對應用戶端設定。',
'ai.externalMcp.cursor.title': 'Cursor / 其他用戶端',
'ai.externalMcp.cursor.description': '合併到 MCP 設定檔(例如 ~/.cursor/mcp.json)。若已有其他伺服器,請勿整檔覆蓋。',
'ai.externalMcp.status.unavailable': '無法使用',
'ai.externalMcp.status.disabled': '已關閉',
'ai.externalMcp.status.running': '執行中',
@@ -591,12 +587,8 @@ export const zhTWAiMessages: Messages = {
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左側',
'terminal.layer.movePanelRight': '面板移至右側',
'terminal.layer.movePanelBottom': '面板移至底部',
'terminal.layer.closePanel': '關閉面板',
'terminal.layer.closePane': '關閉分割',
'terminal.layer.saveLayoutAsDefault': '將佈局儲存為預設',
'terminal.layer.layoutSavedAsDefault': '佈局已儲存為預設',
'terminal.layer.layoutSaveFailed': '佈局儲存失敗',
'terminal.layer.resizeSplit': '調整分割大小',
'terminal.layer.splitHorizontal': '上下分割',
'terminal.layer.splitVertical': '左右分割',
-14
View File
@@ -122,12 +122,6 @@ export const zhTWCoreMessages: Messages = {
'settings.terminal.sidePanel.pane.system': '系統',
'settings.terminal.sidePanel.pane.notes': '筆記',
'settings.terminal.sidePanel.pane.ai': 'AI',
'settings.terminal.section.localShellSidePanel': '本機 Shell 側邊欄',
'settings.terminal.localShellSidePanel.autoOpen': '開啟本機 Shell 時展開側邊欄',
'settings.terminal.localShellSidePanel.autoOpen.desc': '開啟本機 Shell 後自動開啟一個側邊欄。',
'settings.terminal.localShellSidePanel.autoOpenPane': '預設側邊欄',
'settings.terminal.localShellSidePanel.autoOpenPane.desc': '選擇優先開啟哪個面板。',
'settings.terminal.localShellSidePanel.pane.files': '檔案',
// Settings > System
'settings.system.title': '系統',
@@ -370,8 +364,6 @@ export const zhTWCoreMessages: Messages = {
'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',
@@ -655,7 +647,6 @@ export const zhTWCoreMessages: Messages = {
'vault.nav.snippets': '程式碼片段',
'vault.nav.notes': '筆記',
'vault.nav.knownHosts': '已知主機',
'vault.nav.sftp': 'SFTP',
'vault.nav.logs': '日誌',
'proxyProfiles.action.add': '新增代理',
@@ -689,10 +680,6 @@ export const zhTWCoreMessages: Messages = {
'vault.groups.deleteDialogTitle': '刪除群組',
'vault.groups.deleteDialog.desc': '這將永久刪除該群組並將所有主機移動到根層級。',
'vault.groups.deleteDialog.managedDesc': '這是一個託管的 SSH config 群組。刪除後將同時刪除所有主機並斷開與原始檔的連線。',
'vault.groups.deleteDialog.mixedDesc': '該群組包含從關聯 SSH config 檔案匯入的主機。其受管理子群組下的所有主機都將被刪除,包括不受該檔案管理的主機;其餘主機將移動到根層級,除非您在下方選擇同時刪除。',
'vault.groups.deleteDialog.managedWarning': '刪除該群組會同時清空關聯 SSH config 檔案中的對應條目,從該檔案匯入的設定可能會遺失,其他使用該檔案的應用也會受到影響。請先備份檔案再繼續。',
'vault.groups.deleteDialog.sourcesChanged': '此次刪除涉及的受管理檔案已發生變化。請檢查檔案並重新確認。',
'vault.groups.deleteDialog.managedFile': '關聯檔案:{file}',
'vault.groups.deleteDialog.deleteHosts': '同時刪除該群組下的所有主機',
'vault.groups.deleteDialog.bulkTitle': '刪除所選群組',
'vault.groups.deleteDialog.bulkDesc': '這將永久刪除所選群組,並把其中的主機移動到根層級。',
@@ -751,7 +738,6 @@ export const zhTWCoreMessages: Messages = {
'vault.hosts.export.toast.success': '已匯出 {count} 個主機到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已匯出 {count} 個主機到 CSV(跳過 {skipped} 個不支援的主機)',
'vault.hosts.export.toast.passphrasesSkipped': '有 {count} 個已儲存的金鑰密碼無法讀取,匯出時已留空',
'vault.hosts.export.toast.proxyCredentialsSkipped': '有 {count} 個已儲存的代理密碼無法讀取,匯出時已留空',
'vault.hosts.export.toast.noHosts': '沒有主機可匯出',
'vault.hosts.allHosts': '全部主機',
'vault.hosts.pinned': '已置頂',
@@ -3,13 +3,6 @@ import type { Messages } from '../types';
export const zhTWTerminalMessages: Messages = {
'terminal.menu.saveScreen': '儲存目前畫面',
'terminal.saveScreen.failed': '儲存終端畫面失敗。',
// 多行貼上確認對話框(#3398)
'terminal.pasteConfirm.title': '要貼上多行文字嗎?',
'terminal.pasteConfirm.summary': '將向工作階段傳送 {lines} 行({chars} 個字元)。',
'terminal.pasteConfirm.preview': '預覽(可編輯)',
'terminal.pasteConfirm.send': '傳送',
'terminal.pasteConfirm.sendLineByLine': '逐行傳送',
'terminal.pasteConfirm.cancel': '取消',
'terminal.sudoHint.pressEnter': '按 Enter 貼上已儲存的密碼',
'terminal.passwordPicker.title': '已儲存的密碼',
'terminal.passwordPicker.empty': '沒有已儲存的密碼',
@@ -333,12 +326,6 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.autoUploadClipboardImage': '貼上時自動上傳剪貼簿圖片',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
'settings.terminal.behavior.confirmMultilinePaste': '貼上多行前先詢問',
'settings.terminal.behavior.confirmMultilinePaste.desc':
'貼上多行文字時先彈出確認對話框,顯示行數和字元數、可編輯的預覽,並提供傳送 / 逐行傳送 / 取消操作。可保護不支援括號貼上的網路設備 CLI(Cisco IOS、華為 VRP、H3C Comware)。',
'settings.terminal.behavior.confirmMultilinePasteMinLines': '確認門檻(行數)',
'settings.terminal.behavior.confirmMultilinePasteMinLines.desc':
'貼上內容達到該行數時才彈出確認對話框。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
@@ -931,10 +918,6 @@ export const zhTWTerminalMessages: Messages = {
'serial.field.configLabelPlaceholder': '例如 Arduino Uno',
'serial.connectAndSave': '連線並儲存',
'serial.edit.title': '序列埠設定',
'serial.field.username': '使用者名稱',
'serial.field.password': '密碼',
'serial.field.autoLoginDesc': '設定後,序列埠終端出現 Login/Password 登入提示時,會自動填入此處儲存的使用者名稱和密碼。',
'serial.field.clearPassword': '清除已儲存的密碼',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': '需要驗證',
@@ -961,13 +944,6 @@ export const zhTWTerminalMessages: Messages = {
// Text Editor
'sftp.editor.wordWrap': '自動換行',
'sftp.editor.maximize': '最大化',
'sftp.editor.popOut': '在編輯器視窗開啟',
'sftp.editor.dock': '停靠回主視窗',
'sftp.editor.focusWindow': '聚焦編輯器視窗',
'sftp.editor.close': '關閉',
'sftp.editor.windowTitle': '編輯器',
'sftp.editor.windowEmpty': '沒有開啟的檔案',
'sftp.editor.dockFailed': '無法把此檔案停靠回主視窗',
'sftp.editor.unsavedTitle': '未儲存的修改',
'sftp.editor.unsavedMessage': '{fileName} 有未儲存的修改,是否儲存後關閉?',
'sftp.editor.discardChanges': '不儲存',
-1
View File
@@ -503,7 +503,6 @@ export const zhTWVaultMessages: Messages = {
'terminal.statusbar.reconnect.tooltip': '重新連線目前工作階段',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.gpu': 'GPU 使用率',
'terminal.serverStats.memory': '記憶體使用',
'terminal.serverStats.memoryDetails': '記憶體詳情',
'terminal.serverStats.memUsed': '已用',
-109
View File
@@ -1,109 +0,0 @@
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 { editorTabStore } from "../../application/state/editorTabStore.ts";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { toast } from "../../components/ui/toast.tsx";
import { useSftpViewFileOps } from "../../components/sftp/hooks/useSftpViewFileOps.ts";
import type { UseSftpViewFileOpsParams, UseSftpViewFileOpsResult } from "../../components/sftp/hooks/useSftpViewFileOps.types.ts";
for (const action of ["onPopOut", "onPromoteToTab"] as const) {
test(`${action} preserves a modified modal when the file is already detached`, async (t) => {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
let ops!: UseSftpViewFileOpsResult;
const params = {
sftpRef: { current: {
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
readTextFile: async () => "original",
} },
behaviorRef: { current: "" }, autoSyncRef: { current: false },
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
t: (key: string) => key,
} as unknown as UseSftpViewFileOpsParams;
const original = {
editorId: "modal-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
content: "window edits", baselineContent: "original", wordWrap: false, viewState: null,
};
editorTabStore.upsertFromSnapshot(original, "window");
const before = editorTabStore.getTab(original.editorId);
t.after(() => editorTabStore.close(original.editorId));
let focusCount = 0;
t.mock.method(netcattyBridge, "get", () => ({
focusEditorWindow: async () => { focusCount++; return { success: true }; },
}));
const errors = t.mock.method(toast, "error", () => {});
function Harness() { ops = useSftpViewFileOps(params); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
t.after(async () => {
await act(async () => renderer.unmount());
environment.IS_REACT_ACT_ENVIRONMENT = previous;
});
await act(async () => {
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/./file.txt");
});
const modal = { ...original, content: "modal edits" };
await act(async () => { ops[action](modal); });
assert.equal(ops.showTextEditor, true);
assert.equal(ops.textEditorTarget?.fullPath, "/./file.txt");
assert.equal(ops.textEditorContent, "original");
assert.equal(modal.content, "modal edits");
assert.equal(editorTabStore.getTab(original.editorId), before);
assert.equal(focusCount, 0);
assert.equal(errors.mock.callCount(), 1);
});
}
for (const focusSucceeds of [true, false]) {
test(`Maximize dismisses a clean duplicate modal only after detached focus succeeds: ${focusSucceeds}`, async (t) => {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
let ops!: UseSftpViewFileOpsResult;
const params = {
sftpRef: { current: {
leftPane: { id: "pane", connection: { id: "conn", hostId: "host" } },
readTextFile: async () => "original",
} },
behaviorRef: { current: "" }, autoSyncRef: { current: false },
getOpenerForFileRef: { current: () => null }, setOpenerForExtension: () => {},
t: (key: string) => key,
} as unknown as UseSftpViewFileOpsParams;
const detached = {
editorId: "clean-duplicate", sessionId: "conn", sftpTabId: "pane", hostId: "host",
remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext",
content: "detached edits", baselineContent: "original", wordWrap: false, viewState: null,
};
editorTabStore.upsertFromSnapshot(detached, "window");
const before = editorTabStore.getTab(detached.editorId);
t.after(() => editorTabStore.close(detached.editorId));
let finishFocus!: (result: { success: boolean }) => void;
const focusing = new Promise<{ success: boolean }>((resolve) => { finishFocus = resolve; });
let focusCount = 0;
t.mock.method(netcattyBridge, "get", () => ({
focusEditorWindow: () => { focusCount++; return focusing; },
}));
function Harness() { ops = useSftpViewFileOps(params); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
t.after(async () => {
await act(async () => renderer.unmount());
environment.IS_REACT_ACT_ENVIRONMENT = previous;
});
await act(async () => {
await ops.onEditFileLeft({ name: "file.txt", size: 8 } as Parameters<typeof ops.onEditFileLeft>[0], "/file.txt");
});
await act(async () => { ops.onPromoteToTab({ ...detached, content: "original" }); });
assert.equal(focusCount, 1);
assert.equal(ops.showTextEditor, true, "keep the modal until focus is acknowledged");
await act(async () => { finishFocus({ success: focusSucceeds }); });
assert.equal(ops.showTextEditor, !focusSucceeds);
assert.equal(ops.textEditorTarget?.fullPath ?? null, focusSucceeds ? null : "/file.txt");
assert.equal(ops.textEditorContent, focusSucceeds ? "" : "original");
assert.equal(editorTabStore.getTab(detached.editorId), before, "focusing must preserve existing detached edits");
});
}
@@ -1,16 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
test("independent renderers creating editors in the same millisecond get distinct identities", async (t) => {
t.mock.method(Date, "now", () => 1234567890);
const [firstRenderer, secondRenderer] = await Promise.all([
import(new URL("./editorTabStore.ts?renderer=first", import.meta.url).href),
import(new URL("./editorTabStore.ts?renderer=second", import.meta.url).href),
]);
assert.notEqual(firstRenderer.EditorTabStore, secondRenderer.EditorTabStore, "use independent renderer module state");
const snapshot = { sessionId: "session", sftpTabId: "pane", hostId: "host", remotePath: "/file.txt", fileName: "file.txt", languageId: "plaintext", content: "edits", baselineContent: "original", wordWrap: false, viewState: null };
const first = new firstRenderer.EditorTabStore().promoteFromModal(snapshot);
const second = new secondRenderer.EditorTabStore().promoteFromModal(snapshot);
assert.notEqual(first, second, "process-wide ownership must not collide across renderer-local counters");
assert.match(first, /^edt_[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i);
});
-14
View File
@@ -28,20 +28,6 @@ const makeTab = (overrides: Partial<EditorTab> = {}): EditorTab => ({
...overrides,
});
test("clean modal reuses a detached tab without changing its dirty state or content", () => {
const store = new EditorTabStore();
const tab = makeTab({ placement: "window", windowDirty: true, content: "", baselineContent: "" });
store._debugInsert(tab);
const id = store.promoteFromModal({
...makeTab(),
remotePath: "/etc//nginx/./nginx.conf",
});
assert.equal(id, tab.id);
assert.equal(store.getTab(id), tab);
assert.equal(store.isDirty(id), true);
assert.equal(store.getTabs().length, 1);
});
test("updateContent stores content and viewState; dirty flag derives from baseline", () => {
const store = new EditorTabStore();
store._debugInsert(makeTab());
+5 -107
View File
@@ -2,7 +2,6 @@ import { useCallback, useMemo, useSyncExternalStore } from "react";
import type * as Monaco from "monaco-editor";
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
import type { EditorTabPlacement, EditorWindowTabSnapshot } from "./editorWindowTypes";
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
// may contain semantic ".." segments we don't want to resolve client-side).
@@ -24,7 +23,6 @@ export interface EditorTab {
sftpTabId: string;
/** Stable endpoint id; used to verify the session is still the one we opened against. */
hostId: string;
hostLabel?: string;
remotePath: string;
fileName: string;
languageId: string;
@@ -34,16 +32,12 @@ export interface EditorTab {
viewState: Monaco.editor.ICodeEditorViewState | null;
savingState: EditorSavingState;
saveError: string | null;
placement?: EditorTabPlacement;
windowDirty?: boolean;
}
type Listener = () => void;
const genId = (): EditorTabId => `edt_${crypto.randomUUID()}`;
export const tabIsDirty = (tab: EditorTab): boolean =>
tab.placement === "window" ? tab.windowDirty === true : tab.content !== tab.baselineContent;
let idCounter = 0;
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
export class EditorTabStore {
private tabs: EditorTab[] = [];
@@ -85,82 +79,7 @@ export class EditorTabStore {
};
isDirty = (id: EditorTabId): boolean => {
const t = this.getTab(id);
return !!t && tabIsDirty(t);
};
listByOwner = (owner: { sessionId?: string; sftpTabId?: string }): EditorTab[] =>
this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
markDetached = (id: EditorTabId, dirty: boolean, releaseContent = true): void => {
this.patch(id, {
placement: "window",
windowDirty: dirty,
...(releaseContent ? { content: "", baselineContent: "", viewState: null } : {}),
});
this.notifyStructural();
};
setWindowDirty = (id: EditorTabId, dirty: boolean): void => {
const tab = this.getTab(id);
if (!tab || tab.placement !== "window" || tab.windowDirty === dirty) return;
this.patch(id, { windowDirty: dirty });
};
upsertFromSnapshot = (
snapshot: EditorWindowTabSnapshot,
placement: EditorTabPlacement = "tab",
): EditorTabId => {
const existingById = snapshot.editorId ? this.getTab(snapshot.editorId) : undefined;
const normalized = normalizePath(snapshot.remotePath);
const existing = existingById ?? this.tabs.find(
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
);
const next: Partial<EditorTab> = {
sessionId: snapshot.sessionId,
sftpTabId: snapshot.sftpTabId,
hostId: snapshot.hostId,
hostLabel: snapshot.hostLabel,
remotePath: snapshot.remotePath,
fileName: snapshot.fileName,
languageId: snapshot.languageId,
content: placement === "window" ? "" : snapshot.content,
baselineContent: placement === "window" ? "" : snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: placement === "window" ? null : snapshot.viewState as EditorTab["viewState"],
placement,
windowDirty: placement === "window"
? snapshot.content !== snapshot.baselineContent
: false,
savingState: "idle",
saveError: null,
};
if (existing) {
this.patch(existing.id, next);
if (existing.placement !== placement) this.notifyStructural();
return existing.id;
}
const tab: EditorTab = {
id: snapshot.editorId || this.makeId(),
kind: "editor",
sessionId: snapshot.sessionId,
sftpTabId: snapshot.sftpTabId,
hostId: snapshot.hostId,
hostLabel: snapshot.hostLabel,
remotePath: snapshot.remotePath,
fileName: snapshot.fileName,
languageId: snapshot.languageId,
content: next.content ?? "",
baselineContent: next.baselineContent ?? "",
wordWrap: snapshot.wordWrap,
viewState: (next.viewState ?? null) as EditorTab["viewState"],
savingState: "idle",
saveError: null,
placement,
windowDirty: next.windowDirty === true,
};
this.tabs = [...this.tabs, tab];
this.notifyStructural();
return tab.id;
return !!t && t.content !== t.baselineContent;
};
updateContent = (
@@ -172,12 +91,6 @@ export class EditorTabStore {
};
markSaved = (id: EditorTabId, newBaseline: string) => {
const tab = this.getTab(id);
if (tab?.placement === "window") {
// Only the detached renderer has the current content. Wait for its dirty report.
this.patch(id, { savingState: "idle", saveError: null });
return;
}
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
};
@@ -261,26 +174,17 @@ export class EditorTabStore {
baselineContent: string;
wordWrap: boolean;
viewState: Monaco.editor.ICodeEditorViewState | null;
placement?: EditorTabPlacement;
}): EditorTabId => {
const normalized = normalizePath(snapshot.remotePath);
const existing = this.tabs.find(
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
);
if (existing) {
if (existing.placement === "window") {
if (snapshot.content !== snapshot.baselineContent) {
throw new Error("This file is already open in the editor window. Your changes are still in this dialog. Dock or close the existing editor before transferring these changes.");
}
return existing.id;
}
this.patch(existing.id, {
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
placement: snapshot.placement ?? existing.placement ?? "tab",
windowDirty: false,
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
});
return existing.id;
@@ -300,8 +204,6 @@ export class EditorTabStore {
viewState: snapshot.viewState,
savingState: "idle",
saveError: null,
placement: snapshot.placement ?? "tab",
windowDirty: false,
};
this.tabs = [...this.tabs, tab];
this.notifyStructural();
@@ -319,9 +221,9 @@ export class EditorTabStore {
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> => {
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner) && t.placement !== "window");
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
for (const tab of matching) {
const dirty = tabIsDirty(tab);
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
onCloseTab?.(tab.id);
this.close(tab.id);
@@ -430,11 +332,9 @@ export type EditorTabChrome = Pick<
| 'sessionId'
| 'sftpTabId'
| 'hostId'
| 'hostLabel'
| 'remotePath'
| 'fileName'
| 'languageId'
| 'placement'
>;
const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
@@ -443,11 +343,9 @@ const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
sessionId: tab.sessionId,
sftpTabId: tab.sftpTabId,
hostId: tab.hostId,
hostLabel: tab.hostLabel,
remotePath: tab.remotePath,
fileName: tab.fileName,
languageId: tab.languageId,
placement: tab.placement ?? "tab",
});
export const useHasEditorTabForSessions = (
@@ -1,147 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
import { editorTabStore } from "./editorTabStore.ts";
import { dockDetachedEditorTab, installEditorWindowSourceListeners, popOutEditorTab, saveDetachedEditorTab } from "./editorWindowClient.ts";
import { registerEditorSftpWriterScoped } from "./editorSftpBridge.ts";
import { releaseEditorTabSaveCoordinator } from "./editorTabSave.ts";
import type { EditorWindowSaveRequest, EditorWindowSaveResult } from "./editorWindowTypes.ts";
const snapshot = {
editorId: "save-test", sessionId: "conn", sftpTabId: "pane", hostId: "host",
remotePath: "/file", fileName: "file", languageId: "plaintext",
content: "v2", baselineContent: "v1", wordWrap: false, viewState: null,
};
for (const latestContent of ["v3", "v2", "v1"]) {
test(`detached save reports current dirty state after editing to ${latestContent}`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
let finish!: (result: { ok: boolean }) => void;
const pending = new Promise<{ ok: boolean }>((resolve) => { finish = resolve; });
const reports: boolean[] = [];
t.mock.method(netcattyBridge, "get", () => ({
saveEditorWindowTab: (request: { content: string }) => {
assert.equal(request.content, "v2");
return pending;
},
reportEditorWindowDirty: ({ dirty }: { dirty: boolean }) => reports.push(dirty),
}));
const saving = saveDetachedEditorTab(snapshot.editorId);
editorTabStore.updateContent(snapshot.editorId, latestContent, null);
finish({ ok: true });
assert.deepEqual(await saving, { ok: true });
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v2");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, latestContent);
assert.deepEqual(reports, [latestContent !== "v2"]);
});
}
test("source save receipt preserves dirty until the detached renderer reports it", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot, "window");
t.after(() => {
editorTabStore.close(snapshot.editorId);
releaseEditorTabSaveCoordinator(snapshot.editorId);
});
let finish!: () => void;
const pending = new Promise<void>((resolve) => { finish = resolve; });
t.after(registerEditorSftpWriterScoped(async () => { await pending; return "conn"; }));
let onSave!: (request: EditorWindowSaveRequest) => Promise<void>;
let onDirty!: (payload: { editorId: string; dirty: boolean }) => void;
const receipts: EditorWindowSaveResult[] = [];
t.mock.method(netcattyBridge, "get", () => ({
onEditorWindowSaveRequest: (cb: typeof onSave) => { onSave = cb; return () => {}; },
onEditorWindowDirtyChanged: (cb: typeof onDirty) => { onDirty = cb; return () => {}; },
reportEditorWindowSaveResult: (result: EditorWindowSaveResult) => receipts.push(result),
}));
t.after(installEditorWindowSourceListeners());
const saving = onSave({ ...snapshot, requestId: "request" });
onDirty({ editorId: snapshot.editorId, dirty: true });
finish();
await saving;
assert.equal(receipts[0]?.ok, true);
assert.equal(editorTabStore.isDirty(snapshot.editorId), true);
onDirty({ editorId: snapshot.editorId, dirty: false });
assert.equal(editorTabStore.isDirty(snapshot.editorId), false);
});
for (const outcome of ["accepted", "rejected", "failed"] as const) {
test(`popout retains source contents until the receiver ${outcome}`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
let resolve!: (result: { success: boolean }) => void;
let reject!: (error: Error) => void;
const receipt = new Promise<{ success: boolean }>((yes, no) => { resolve = yes; reject = no; });
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: () => receipt }));
const opening = popOutEditorTab(snapshot.editorId);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.placement, "window");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v2");
assert.equal(editorTabStore.getTab(snapshot.editorId)?.baselineContent, "v1");
if (outcome === "failed") reject(new Error("IPC disconnected"));
else resolve({ success: outcome === "accepted" });
assert.equal(await opening, outcome === "accepted");
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.placement, outcome === "accepted" ? "window" : "tab");
assert.equal(tab.content, outcome === "accepted" ? "" : "v2");
assert.equal(tab.baselineContent, outcome === "accepted" ? "" : "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
}
for (const outcome of ["accepted", "rejected", "failed"] as const) {
test(`dock freezes the current copy until ${outcome} and refuses a second transfer`, async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
editorTabStore.setSavingState(snapshot.editorId, "error", "previous save failed");
t.after(() => editorTabStore.close(snapshot.editorId));
let resolve!: (result: { success: boolean; error?: string }) => void;
let reject!: (error: Error) => void;
const receipt = new Promise<{ success: boolean; error?: string }>((yes, no) => { resolve = yes; reject = no; });
let calls = 0;
t.mock.method(netcattyBridge, "get", () => ({
dockEditorWindowTab: (sent: typeof snapshot) => {
calls++;
assert.equal(sent.content, "v2");
return receipt;
},
}));
const docking = dockDetachedEditorTab(snapshot.editorId);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving", "existing pane busy state makes Monaco read-only during transfer");
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
assert.equal(calls, 1);
if (outcome === "failed") reject(new Error("IPC disconnected"));
else resolve({ success: outcome === "accepted", error: "Source unavailable" });
assert.equal((await docking).success, outcome === "accepted");
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.content, "v2");
assert.equal(tab.baselineContent, "v1");
assert.equal(tab.savingState, outcome === "accepted" ? "saving" : "error", "success stays frozen until the caller closes; failure restores interaction");
if (outcome !== "accepted") {
assert.equal(tab.saveError, "previous save failed");
editorTabStore.updateContent(snapshot.editorId, "v3", null);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.content, "v3");
}
});
}
test("dock does not take over an in-progress save", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
editorTabStore.setSavingState(snapshot.editorId, "saving");
t.after(() => editorTabStore.close(snapshot.editorId));
let calls = 0;
t.mock.method(netcattyBridge, "get", () => ({ dockEditorWindowTab: async () => { calls++; return { success: true }; } }));
assert.equal((await dockDetachedEditorTab(snapshot.editorId)).success, false);
assert.equal(calls, 0);
assert.equal(editorTabStore.getTab(snapshot.editorId)?.savingState, "saving");
});
test("a closing destination rejects a popout without releasing source content", async (t) => {
editorTabStore.upsertFromSnapshot(snapshot);
t.after(() => editorTabStore.close(snapshot.editorId));
t.mock.method(netcattyBridge, "get", () => ({ openEditorWindow: async () => ({ success: false, error: "Editor window is closing" }) }));
assert.equal(await popOutEditorTab(snapshot.editorId), false);
const tab = editorTabStore.getTab(snapshot.editorId)!;
assert.equal(tab.placement, "tab");
assert.equal(tab.content, "v2");
assert.equal(tab.baselineContent, "v1");
assert.equal(editorTabStore.isDirty(tab.id), true);
});
-283
View File
@@ -1,283 +0,0 @@
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { saveEditorTab } from "./editorTabSave";
import {
editorTabStore,
tabIsDirty,
type EditorTab,
type EditorTabId,
} from "./editorTabStore";
import type { EditorWindowTabSnapshot } from "./editorWindowTypes";
import { activeTabStore, toEditorTabId } from "./activeTabStore";
export function toEditorWindowSnapshot(
tab: EditorTab,
hostLabel?: string,
): EditorWindowTabSnapshot {
return {
editorId: tab.id,
sessionId: tab.sessionId,
sftpTabId: tab.sftpTabId,
hostId: tab.hostId,
hostLabel: hostLabel || tab.hostLabel,
remotePath: tab.remotePath,
fileName: tab.fileName,
languageId: tab.languageId,
content: tab.content,
baselineContent: tab.baselineContent,
wordWrap: tab.wordWrap,
viewState: tab.viewState,
};
}
export async function openEditorInWindow(snapshot: EditorWindowTabSnapshot): Promise<boolean> {
const bridge = netcattyBridge.get();
if (!bridge?.openEditorWindow) return false;
const result = await bridge.openEditorWindow(snapshot);
return result?.success === true;
}
export async function focusEditorInWindow(editorId: EditorTabId): Promise<boolean> {
const result = await netcattyBridge.get()?.focusEditorWindow?.(editorId);
return result?.success === true;
}
export async function popOutEditorTab(
tabId: EditorTabId,
hostLabel?: string,
): Promise<boolean> {
const tab = editorTabStore.getTab(tabId);
if (!tab) return false;
if (tab.placement === "window") {
return focusEditorInWindow(tabId);
}
const dirty = tabIsDirty(tab);
const snapshot = toEditorWindowSnapshot(tab, hostLabel || tab.hostLabel);
// Hide the source editor during transfer, but retain its only owned copy
// until the destination has acknowledged installing the tab.
editorTabStore.markDetached(tabId, dirty, false);
const ok = await openEditorInWindow(snapshot).catch(() => false);
if (!ok) {
if (editorTabStore.getTab(tabId)) editorTabStore.upsertFromSnapshot(snapshot, "tab");
return false;
}
editorTabStore.markDetached(tabId, dirty);
const activeId = activeTabStore.getActiveTabId();
if (activeId === toEditorTabId(tabId)) {
activeTabStore.setActiveTabId("vault");
}
return true;
}
export async function closeOwnedEditorWindowTabs(
owner: { sessionId?: string; sftpTabId?: string },
options?: { force?: boolean },
): Promise<{ cancelled: boolean; closedIds: string[] }> {
const matching = editorTabStore.listByOwner(owner).filter((tab) => tab.placement === "window");
if (matching.length === 0) return { cancelled: false, closedIds: [] };
const result = await netcattyBridge.get()?.closeEditorWindowTabs?.({
editorIds: matching.map((tab) => tab.id),
force: options?.force === true,
});
const closedIds = result?.closedIds ?? [];
for (const id of closedIds) editorTabStore.close(id);
if (!result || result.success === false) {
return { cancelled: options?.force !== true, closedIds };
}
if (result.cancelled !== true) {
for (const tab of matching) editorTabStore.close(tab.id);
}
return {
cancelled: result.cancelled === true,
closedIds,
};
}
export async function confirmCloseOwnedEditors(
owner: { sessionId?: string; sftpTabId?: string },
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> {
const windowClose = await closeOwnedEditorWindowTabs(owner, { force: false });
if (windowClose.cancelled) return false;
return editorTabStore.confirmCloseByOwner(owner, promptChoice, saveTab, onCloseTab);
}
export function forceCloseOwnedEditors(owners: {
sessionIds?: readonly string[];
sftpTabIds?: readonly string[];
}): EditorTabId[] {
const matching = editorTabStore.getTabs().filter((tab) => {
if (tab.placement !== "window") return false;
if (owners.sessionIds?.includes(tab.sessionId)) return true;
if (owners.sftpTabIds?.includes(tab.sftpTabId)) return true;
return false;
});
if (matching.length > 0) {
void netcattyBridge.get()?.closeEditorWindowTabs?.({
editorIds: matching.map((tab) => tab.id),
force: true,
});
}
return editorTabStore.forceCloseByOwners(owners);
}
export function notifyEditorWindowSessionRemap(fromSessionId: string, toSessionId: string): void {
if (!fromSessionId || !toSessionId || fromSessionId === toSessionId) return;
netcattyBridge.get()?.remapEditorWindowSession?.({ fromSessionId, toSessionId });
}
export async function saveDetachedEditorTab(tabId: EditorTabId): Promise<{ ok: boolean; error?: string }> {
const current = editorTabStore.getTab(tabId);
if (!current) return { ok: false, error: "Editor tab closed before save completed" };
editorTabStore.setSavingState(tabId, "saving");
const result = await netcattyBridge.get()?.saveEditorWindowTab?.({
editorId: current.id,
sessionId: current.sessionId,
sftpTabId: current.sftpTabId,
hostId: current.hostId,
remotePath: current.remotePath,
content: current.content,
});
if (result?.ok) {
editorTabStore.markSaved(tabId, current.content);
netcattyBridge.get()?.reportEditorWindowDirty?.({
editorId: tabId,
dirty: editorTabStore.isDirty(tabId),
});
return { ok: true };
}
const error = result?.error || "Save failed";
editorTabStore.setSavingState(tabId, "error", error);
return { ok: false, error };
}
export async function dockDetachedEditorTab(tabId: EditorTabId): Promise<{ success: boolean; error?: string }> {
const tab = editorTabStore.getTab(tabId);
if (!tab) return { success: false, error: "Editor tab closed" };
if (tab.savingState === "saving") return { success: false, error: "Editor tab is busy" };
// Keep the snapshot's owner read-only until receipt. Success stays busy
// until the caller removes this copy; failure restores its previous state.
editorTabStore.setSavingState(tabId, "saving");
let accepted = false;
try {
const result = await netcattyBridge.get()?.dockEditorWindowTab?.(
toEditorWindowSnapshot(tab, tab.hostLabel),
);
if (result?.success) {
accepted = true;
return { success: true };
}
return { success: false, error: result?.error || "Failed to dock editor tab" };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : "Failed to dock editor tab" };
} finally {
if (!accepted) editorTabStore.setSavingState(tabId, tab.savingState, tab.saveError);
}
}
export function reportDetachedEditorDirty(tabId: EditorTabId, dirty: boolean): void {
netcattyBridge.get()?.reportEditorWindowDirty?.({ editorId: tabId, dirty });
}
export function reportDetachedEditorTabsClosed(editorIds: string[]): void {
if (editorIds.length === 0) return;
netcattyBridge.get()?.reportEditorWindowTabsClosed?.({ editorIds });
}
export function subscribeEditorWindowCloseTabs(
cb: (payload: import("./editorWindowTypes").EditorWindowCloseTabsRequest) => void | Promise<void>,
): () => void {
return netcattyBridge.get()?.onEditorWindowCloseTabs?.(cb) ?? (() => {});
}
export function reportEditorWindowCloseTabsResult(
payload: import("./editorWindowTypes").EditorWindowCloseTabsResult,
): void {
netcattyBridge.get()?.reportEditorWindowCloseTabsResult?.(payload);
}
export function subscribeEditorWindowDirtyCheck(cb: () => boolean): () => void {
const bridge = netcattyBridge.get();
if (!bridge?.onCheckDirtyEditors) return () => {};
return bridge.onCheckDirtyEditors(() => {
let hasDirty = false;
try {
hasDirty = cb() === true;
} catch {
hasDirty = false;
}
try {
bridge.reportDirtyEditorsResult?.(hasDirty);
} catch {
// ignore
}
});
}
export function installEditorWindowSourceListeners(): () => void {
const bridge = netcattyBridge.get();
if (!bridge) return () => {};
const unsubSave = bridge.onEditorWindowSaveRequest?.(async (payload) => {
try {
const tab = editorTabStore.getTab(payload.editorId);
if (!tab) {
bridge.reportEditorWindowSaveResult?.({
requestId: payload.requestId,
ok: false,
error: "Editor tab closed before save completed",
});
return;
}
if (tab.sessionId !== payload.sessionId) {
editorTabStore.remapSessionId(tab.sessionId, payload.sessionId);
}
const ok = await saveEditorTab(payload.editorId, payload.content);
const latest = editorTabStore.getTab(payload.editorId);
bridge.reportEditorWindowSaveResult?.({
requestId: payload.requestId,
ok,
liveConnectionId: latest?.sessionId,
error: ok ? undefined : (latest?.saveError || "Save failed"),
});
} catch (err) {
bridge.reportEditorWindowSaveResult?.({
requestId: payload.requestId,
ok: false,
error: err instanceof Error ? err.message : "Save failed",
});
}
}) ?? (() => {});
const unsubDock = bridge.onEditorWindowDockRequest?.((payload) => {
try {
const id = editorTabStore.upsertFromSnapshot(payload, "tab");
activeTabStore.setActiveTabId(toEditorTabId(id));
bridge.reportEditorWindowDockResult?.({ requestId: payload.requestId, ok: true });
} catch (err) {
bridge.reportEditorWindowDockResult?.({
requestId: payload.requestId,
ok: false,
error: err instanceof Error ? err.message : "Failed to dock",
});
}
}) ?? (() => {});
const unsubDirty = bridge.onEditorWindowDirtyChanged?.((payload) => {
editorTabStore.setWindowDirty(payload.editorId, payload.dirty === true);
}) ?? (() => {});
const unsubClosed = bridge.onEditorWindowTabsClosed?.((payload) => {
for (const editorId of payload.editorIds ?? []) {
editorTabStore.close(editorId);
}
}) ?? (() => {});
return () => {
unsubSave();
unsubDock();
unsubDirty();
unsubClosed();
};
}
-45
View File
@@ -1,45 +0,0 @@
export type EditorTabPlacement = "tab" | "window";
export interface EditorWindowTabSnapshot {
editorId: string;
sessionId: string;
sftpTabId: string;
hostId: string;
hostLabel?: string;
remotePath: string;
fileName: string;
languageId: string;
content: string;
baselineContent: string;
wordWrap: boolean;
viewState: unknown;
}
export interface EditorWindowSaveRequest {
requestId: string;
editorId: string;
sessionId: string;
sftpTabId: string;
hostId: string;
remotePath: string;
content: string;
}
export interface EditorWindowSaveResult {
requestId: string;
ok: boolean;
liveConnectionId?: string;
error?: string;
}
export interface EditorWindowCloseTabsRequest {
requestId: string;
editorIds: string[];
force?: boolean;
}
export interface EditorWindowCloseTabsResult {
requestId: string;
cancelled: boolean;
closedIds: string[];
}
@@ -1,62 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
getPendingMultilinePasteConfirm,
requestMultilinePasteConfirm,
respondMultilinePasteConfirm,
subscribeMultilinePasteConfirm,
} from "./multilinePasteConfirmStore";
test("request stores the pending paste and respond resolves it", async () => {
const pending = requestMultilinePasteConfirm({
text: "line1\nline2",
lineCount: 2,
charCount: 12,
});
assert.equal(getPendingMultilinePasteConfirm()?.lineCount, 2);
respondMultilinePasteConfirm("line-by-line", "line1\nline2\nline3");
const response = await pending;
assert.deepEqual(response, { action: "line-by-line", text: "line1\nline2\nline3" });
assert.equal(getPendingMultilinePasteConfirm(), null);
});
test("respond without text falls back to the original request text", async () => {
const pending = requestMultilinePasteConfirm({
text: "line1\nline2",
lineCount: 2,
charCount: 12,
});
respondMultilinePasteConfirm("send");
const response = await pending;
assert.deepEqual(response, { action: "send", text: "line1\nline2" });
});
test("a newer paste request supersedes the pending one as cancelled", async () => {
const first = requestMultilinePasteConfirm({ text: "a\nb", lineCount: 2, charCount: 3 });
const second = requestMultilinePasteConfirm({ text: "c\nd", lineCount: 2, charCount: 3 });
assert.equal(getPendingMultilinePasteConfirm()?.text, "c\nd");
respondMultilinePasteConfirm("send", "c\nd");
assert.deepEqual(await first, { action: "cancel", text: "a\nb" });
assert.deepEqual(await second, { action: "send", text: "c\nd" });
});
test("respond without a pending request is a no-op and listeners are notified", async () => {
respondMultilinePasteConfirm("send");
assert.equal(getPendingMultilinePasteConfirm(), null);
const events: Array<string | null> = [];
const unsubscribe = subscribeMultilinePasteConfirm(() => {
events.push(getPendingMultilinePasteConfirm()?.id ?? "cleared");
});
const pending = requestMultilinePasteConfirm({ text: "x", lineCount: 1, charCount: 1 });
const pendingId = getPendingMultilinePasteConfirm()?.id ?? null;
respondMultilinePasteConfirm("cancel", "x");
await pending;
unsubscribe();
assert.ok(pendingId);
assert.deepEqual(events, [pendingId, "cleared"]);
});
@@ -1,81 +0,0 @@
/**
* App-level store for the "paste multiple lines" confirmation dialog (#3398).
*
* Terminal paste handlers call `requestMultilinePasteConfirm` from anywhere in
* the renderer and await the user's decision; `MultilinePasteConfirmHost`
* (mounted once in App) renders the dialog and resolves the pending request.
*/
export type MultilinePasteConfirmAction = "send" | "line-by-line" | "cancel";
export interface MultilinePasteConfirmRequest {
id: string;
/** Clipboard text the dialog previews (editable by the user). */
text: string;
lineCount: number;
charCount: number;
/** Restore the originating terminal's focus after the modal releases it. */
onClose?: () => void;
}
export interface MultilinePasteConfirmResponse {
action: MultilinePasteConfirmAction;
/** Text to send: the preview content, possibly edited by the user. */
text: string;
}
interface PendingRequest {
request: MultilinePasteConfirmRequest;
resolve: (response: MultilinePasteConfirmResponse) => void;
}
let pending: PendingRequest | null = null;
let nextRequestId = 0;
const listeners = new Set<() => void>();
const emit = (): void => {
for (const listener of listeners) listener();
};
export function requestMultilinePasteConfirm(
request: Omit<MultilinePasteConfirmRequest, "id">,
): Promise<MultilinePasteConfirmResponse> {
// Only one confirmation dialog can be pending; a newer paste supersedes the
// older one (the stale promise resolves as "cancel" so its paste is dropped).
if (pending) {
const stale = pending;
pending = null;
stale.resolve({ action: "cancel", text: stale.request.text });
}
nextRequestId += 1;
const requestWithId: MultilinePasteConfirmRequest = {
...request,
id: `multiline-paste-${nextRequestId}`,
};
return new Promise((resolve) => {
pending = { request: requestWithId, resolve };
emit();
});
}
export function respondMultilinePasteConfirm(
action: MultilinePasteConfirmAction,
text?: string,
): void {
const current = pending;
if (!current) return;
pending = null;
current.resolve({ action, text: text ?? current.request.text });
emit();
}
/** Snapshot for `useSyncExternalStore`; stable reference while pending. */
export const getPendingMultilinePasteConfirm =
(): MultilinePasteConfirmRequest | null => pending?.request ?? null;
export function subscribeMultilinePasteConfirm(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
@@ -209,13 +209,3 @@ test("restore terminal cwd setting participates in cross-window settings sync",
assert.match(ipcSyncSource, /setRestoreTerminalCwdState/);
assert.match(ipcSyncSource, /key === STORAGE_KEY_RESTORE_TERMINAL_CWD/);
});
test("editor route keeps IPC/storage synchronization while disabling system effects", () => {
const source = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
const expression = source.match(/const settingsOptions =\s*([^;]+);/s)?.[1];
assert.ok(expression, "renderer settings options must be explicit");
const resolveOptions = new Function("isPeerSessionWindow", "isEditorWindow", `return (${expression});`);
assert.deepEqual(resolveOptions(false, true), { enableSystemEffects: false });
assert.deepEqual(resolveOptions(true, false), { enableSettingsSync: false, enableSystemEffects: false });
assert.equal(resolveOptions(false, false), undefined);
});
+1 -10
View File
@@ -25,7 +25,6 @@ export type SettingsChromeSnapshot = {
windowOpacity: number;
tabBarPosition: 'top' | 'bottom';
showSftpTab: boolean;
sftpInSidebar: boolean;
showHostTreeSidebar: boolean;
showRecentHosts: boolean;
hostClickBehavior: HostClickBehavior;
@@ -38,8 +37,6 @@ export type SettingsChromeSnapshot = {
restoreTerminalCwd: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
localShellSidePanelAutoOpen: boolean;
localShellSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
};
/**
@@ -61,7 +58,6 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
windowOpacity: 1,
tabBarPosition: 'top',
showSftpTab: true,
sftpInSidebar: false,
showHostTreeSidebar: true,
showRecentHosts: true,
hostClickBehavior: 'connect',
@@ -74,8 +70,6 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
restoreTerminalCwd: true,
terminalSidePanelAutoOpen: false,
terminalSidePanelAutoOpenTab: 'ai',
localShellSidePanelAutoOpen: false,
localShellSidePanelAutoOpenTab: 'scripts',
} satisfies SettingsChromeSnapshot);
export function settingsChromeSnapshotsEqual(
@@ -90,7 +84,6 @@ export function settingsChromeSnapshotsEqual(
&& 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
@@ -102,9 +95,7 @@ export function settingsChromeSnapshotsEqual(
&& a.showTabNumberBadges === b.showTabNumberBadges
&& a.restoreTerminalCwd === b.restoreTerminalCwd
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab
&& a.localShellSidePanelAutoOpen === b.localShellSidePanelAutoOpen
&& a.localShellSidePanelAutoOpenTab === b.localShellSidePanelAutoOpenTab;
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
}
class SettingsChromeStore {
@@ -1,79 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { useState } from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useSettingsIpcSync } from './settingsIpcSync.ts';
import { useSettingsStorageSync } from './settingsStorageSync.ts';
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen.ts';
import {
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN as enabledKey,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB as tabKey,
} from '../../infrastructure/config/storageKeys.ts';
for (const pathway of ['ipc', 'storage'] as const) {
test(`${pathway} receives local shell settings, rejects invalid tabs and cleans up`, async (t) => {
let receive: ((payload: { key: string; value: unknown }) => void) | undefined;
let unsubscribed = false;
const fakeWindow = Object.assign(new EventTarget(), {
netcatty: {
onSettingsChanged(callback: typeof receive) {
receive = callback;
return () => { unsubscribed = true; };
},
},
});
for (const [key, value] of Object.entries({ window: fakeWindow, IS_REACT_ACT_ENVIRONMENT: true })) {
const previous = Object.getOwnPropertyDescriptor(globalThis, key);
Object.defineProperty(globalThis, key, { value, configurable: true });
t.after(() => {
if (previous) Object.defineProperty(globalThis, key, previous);
else Reflect.deleteProperty(globalThis, key);
});
}
let current = { enabled: false, tab: 'scripts' };
let renderer: ReactTestRenderer | undefined;
function Harness() {
const [enabled, setEnabled] = useState(false);
const [tab, setTab] = useState<TerminalSidePanelAutoOpenTab>('scripts');
current = { enabled, tab };
const params = {
localShellSidePanelAutoOpen: enabled,
localShellSidePanelAutoOpenTab: tab,
setLocalShellSidePanelAutoOpenState: setEnabled,
setLocalShellSidePanelAutoOpenTabState: setTab,
};
// Only local-shell events are dispatched; unrelated settings are not used.
useSettingsIpcSync({ ...params, enabled: pathway === 'ipc' } as unknown as Parameters<typeof useSettingsIpcSync>[0]);
useSettingsStorageSync({ ...params, enabled: pathway === 'storage' } as Parameters<typeof useSettingsStorageSync>[0]);
return null;
}
async function send(key: string, value: unknown) {
await act(async () => {
if (pathway === 'ipc') receive!({ key, value });
else fakeWindow.dispatchEvent(Object.assign(new Event('storage'), { key, newValue: value === null ? null : String(value) }));
});
}
try {
await act(async () => { renderer = create(React.createElement(Harness)); });
await send(enabledKey, true);
await send(tabKey, 'notes');
assert.deepEqual(current, { enabled: true, tab: 'notes' });
await send(tabKey, 'invalid');
await send(tabKey, null);
await send(enabledKey, null);
assert.deepEqual(current, { enabled: true, tab: 'notes' });
if (pathway === 'ipc') {
await send(enabledKey, 'false');
assert.equal(current.enabled, true);
}
await send(enabledKey, false);
await send(tabKey, 'history');
assert.deepEqual(current, { enabled: false, tab: 'history' });
await act(async () => { renderer!.unmount(); });
renderer = undefined;
if (pathway === 'ipc') assert.equal(unsubscribed, true);
} finally {
await act(async () => { renderer?.unmount(); });
}
});
}
-14
View File
@@ -48,8 +48,6 @@ import {
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,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
@@ -108,8 +106,6 @@ interface UseSettingsIpcSyncParams {
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setLocalShellSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setLocalShellSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
@@ -157,8 +153,6 @@ export function useSettingsIpcSync({
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setLocalShellSidePanelAutoOpenState,
setLocalShellSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -322,12 +316,6 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
setTerminalSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN && typeof value === 'boolean') {
setLocalShellSidePanelAutoOpenState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
setLocalShellSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
}
@@ -383,8 +371,6 @@ export function useSettingsIpcSync({
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setLocalShellSidePanelAutoOpenState,
setLocalShellSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -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;
+5 -33
View File
@@ -35,13 +35,10 @@ 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,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
@@ -97,12 +94,9 @@ interface UseSettingsStorageSyncParams {
showOnlyUngroupedHostsInRoot: boolean;
tabBarPosition: TabBarPosition;
showSftpTab: boolean;
sftpInSidebar: boolean;
showHostTreeSidebar: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
localShellSidePanelAutoOpen: boolean;
localShellSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
shellOnlyTabNumberShortcuts: boolean;
showTabNumberBadges: boolean;
disableTerminalFontZoom: boolean;
@@ -150,12 +144,9 @@ interface UseSettingsStorageSyncParams {
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>>;
setLocalShellSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setLocalShellSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setShowTabNumberBadgesState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
@@ -189,7 +180,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, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, 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 +189,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, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -213,7 +204,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, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
@@ -223,7 +214,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, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -453,12 +444,6 @@ export function useSettingsStorageSync({
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) {
@@ -476,17 +461,6 @@ export function useSettingsStorageSync({
setTerminalSidePanelAutoOpenTabState(e.newValue);
}
}
if (e.key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.localShellSidePanelAutoOpen) {
setLocalShellSidePanelAutoOpenState(newValue);
}
}
if (e.key === STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB && e.newValue !== null) {
if (isTerminalSidePanelAutoOpenTab(e.newValue) && e.newValue !== s.localShellSidePanelAutoOpenTab) {
setLocalShellSidePanelAutoOpenTabState(e.newValue);
}
}
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.shellOnlyTabNumberShortcuts) {
@@ -607,10 +581,8 @@ export function useSettingsStorageSync({
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setLocalShellSidePanelAutoOpenState,
setLocalShellSidePanelAutoOpenTabState,
setShowRecentHostsState,
setTabBarPositionState, setShowSftpTabState, setSftpInSidebarState,
setTabBarPositionState, setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setShowTabNumberBadgesState,
setDisableTerminalFontZoomState,
@@ -41,12 +41,8 @@ const host = (id: string, label: string, hostname = label): Host => ({
for (const retainedStatus of [undefined, "interrupted", "failed", "paused"] as const) {
for (const newerPause of retainedStatus === "paused" ? [false, true] : [false]) {
for (const ownerChange of retainedStatus === "interrupted" || retainedStatus === undefined ? ["same", "active", "completed"] : ["same"]) {
for (const completedBeforeReplacement of ownerChange === "active" ? [false, true] : [false]) {
for (const flushBeforeReply of ownerChange === "same" || retainedStatus === undefined ? [false] : [false, true]) {
for (const replyRejects of ownerChange !== "same" && !completedBeforeReplacement && !flushBeforeReply ? [false, true] : [false]) {
const batchExistingIdentity = retainedStatus !== undefined;
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}, owner=${ownerChange}, flush=${flushBeforeReply}, priorComplete=${completedBeforeReplacement}, reject=${replyRejects}`, async (t) => {
test(`superseded folder child settles when its completion was compacted into the parent: retained=${retainedStatus ?? "none"}, newerPause=${newerPause}`, async (t) => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
@@ -79,19 +75,14 @@ test(`superseded folder child settles when its completion was compacted into the
...parent, id: "retained-child", parentTaskId: parent.id, isDirectory: false,
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin",
totalBytes: 10, sourceLastModified: 1, status: retainedStatus ?? "interrupted",
directoryEntryIndex: 0,
directoryEntryIdentity: newerPause ? createDirectoryEntryIdentity({
sourcePath: "/local/folder/a.bin", targetPath: "/remote/folder/a.bin", size: 10, lastModified: 1,
}) : "a".repeat(64),
directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64),
};
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
// Exercise the real large-history batching branch with one relevant retained row.
const childBatcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096,
hasTask: (id) => id === persisted.id || id.startsWith("flush-peer-"),
// Peer rows only drive the real 512-entry flush; keep the relevant store
// fixture small so unrelated history maintenance cannot mask ownership.
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates.filter((task) => !task.id.startsWith("flush-peer-"))),
hasTask: (id) => id === persisted.id,
upsertTasks: (updates) => sftpTransferCenterStore.upsertTasks(updates),
});
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, ...(batchExistingIdentity ? [persisted] : [])]);
(netcattyBridge as { get: () => unknown }).get = () => ({
@@ -115,50 +106,20 @@ test(`superseded folder child settles when its completion was compacted into the
directoryEntryIndex: options.directoryEntryIndex,
directoryEntryIdentity: options.directoryEntryIdentity,
});
if (ownerChange !== "same") {
const current = sftpTransferCenterStore.getTask(childId)!;
if (completedBeforeReplacement) {
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
});
}
sftpTransferCenterStore.upsertTasks([{
...current, status: "transferring", directoryEntryIdentity: "e".repeat(64), lifecycleEpoch: 1,
}]);
}
if (ownerChange !== "active") {
// A newer same-id owner completes before this older invocation rejoins.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: ownerChange === "same" ? 0 : 1,
});
}
if (flushBeforeReply) {
for (let index = 0; index < 512; index += 1) {
childBatcher.push({ ...persisted, id: `flush-peer-${index}`, status: "transferring" });
}
if (ownerChange === "active") {
assert.equal(sftpTransferCenterStore.getTask(childId)?.directoryEntryIdentity, "e".repeat(64),
"automatic flush before the displaced reply must preserve the new owner");
} else assert.equal(sftpTransferCenterStore.getTask(childId), undefined,
"automatic flush must not recreate the compacted new owner");
}
if (replyRejects) throw new Error("old invocation transport failed");
return completedBeforeReplacement ? {} : { superseded: true };
// A newer same-id owner completes before this older invocation rejoins.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 10, totalBytes: 10, lifecycleEpoch: 0,
});
return { superseded: true };
},
});
running = resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box", "1.2.3.4")], keys: [], identities: [],
}, undefined, {
children: batchExistingIdentity ? [persisted] : [], shouldAbort: () => abort,
onChildSuperseded: (id) => childBatcher.discard(id),
flushChildUpdates: () => childBatcher.flush(),
onChildUpdate: (child, observation) => {
if (batchExistingIdentity) {
childBatcher.push(child, observation);
return true;
}
sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]);
},
onChildUpdate: (child) => batchExistingIdentity
? childBatcher.push(child)
: sftpTransferCenterStore.publishOwner("compacted-owner", [parent, child]),
});
const result = await Promise.race([
running,
@@ -172,15 +133,6 @@ test(`superseded folder child settles when its completion was compacted into the
}
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
assert.ok(childId);
if (ownerChange !== "same") {
childBatcher.flush();
assert.equal((result as { success: boolean }).success, false, "the displaced walk must not report success");
if (ownerChange === "active") {
assert.equal(sftpTransferCenterStore.getTask(childId)?.status, "transferring");
assert.equal(sftpTransferCenterStore.getTask(childId)?.directoryEntryIdentity, "e".repeat(64));
} else assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "old waiter must not recreate a failed row");
return;
}
assert.equal(sftpTransferCenterStore.getTask(childId), undefined, "completed row was compacted");
assert.equal(sftpTransferCenterStore.getTask(parent.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "completed compacted child must not leave its folder waiting forever");
@@ -188,10 +140,6 @@ test(`superseded folder child settles when its completion was compacted into the
});
}
}
}
}
}
}
test("resolveDirectoryResumeTargetRoot prefers staged replace path", () => {
assert.equal(
@@ -748,67 +696,6 @@ test("single-file restart resume continues from checkpoint without page callback
}
});
test("stat-less SCP download resume keeps saved progress instead of restarting", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
t.after(() => {
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
});
const originalGet = netcattyBridge.get;
let startOptions: Record<string, unknown> | undefined;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp",
closeSftp: async () => {},
// Stat-less SCP host: size cannot be measured, so the bridge reports the
// placeholder 0 with sizeKnown false.
statSftp: async () => ({ type: "file", size: 0, lastModified: 5, sizeKnown: false }),
startStreamTransfer: async (options: Record<string, unknown>) => {
startOptions = options;
return { transferId: "scp-download-resume" };
},
});
try {
const result = await resumeTransferWithDedicatedSession({
id: "scp-download-resume",
fileName: "file.bin",
sourcePath: "/remote/file.bin",
targetPath: "/local/file.bin",
sourceConnectionId: "old-sftp",
sourceHostId: "h1",
sourceHostLabel: "box",
targetConnectionId: "local",
direction: "download",
status: "interrupted",
totalBytes: 100,
transferredBytes: 20,
checkpointBytes: 20,
sourceLastModified: 5,
speed: 0,
startTime: 1,
isDirectory: false,
reconnectRequired: true,
}, {
hosts: [host("h1", "box", "1.2.3.4")],
keys: [],
identities: [],
});
assert.equal(result.success, true, result.error);
// Unknown size must not misread the saved checkpoint as a shrunk source;
// the download resumes from byte 20 instead of silently restarting.
assert.equal(startOptions?.checkpointBytes, 20);
assert.equal(startOptions?.totalBytes, 100);
} finally {
(netcattyBridge as { get: typeof originalGet }).get = originalGet;
resetDedicatedSessionOpenGateForTests();
}
});
test("folder restart resume reports file-count progress without child page callbacks", async (t) => {
resetDedicatedSessionOpenGateForTests();
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
@@ -2244,202 +2131,3 @@ for (const scenario of ["deleted-id", "ambiguous-legacy-label"] as const) {
assert.equal(uploads, 0);
});
}
for (const completeDuringSetup of [false, true]) {
test(`large-history replace recovery retransfers retained completed children into its empty stage: late=${completeDuringSetup}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
const originalGet = netcattyBridge.get;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true, value: { getItem: () => "2", setItem: () => {}, removeItem: () => {} },
});
const { createRequire } = await import("node:module");
const tempDirBridge = createRequire(import.meta.url)("../../../electron/bridges/tempDirBridge.cjs");
const root = await fs.promises.mkdtemp(`${tempDirBridge.getTempFilePath("folder-rebuild-test")}-`);
const finalPath = path.join(root, "final");
const stage = `${finalPath}.netcatty-retained-replace.part`;
await fs.promises.mkdir(stage);
await fs.promises.writeFile(path.join(stage, "b.txt"), "old contents");
const parent: TransferTask = {
id: "retained-replace", fileName: "folder", sourcePath: "/remote/folder", targetPath: finalPath,
stagedTargetPath: stage, replaceExistingTarget: true, sourceConnectionId: "old-sftp", targetConnectionId: "local",
sourceHostId: "h1", direction: "download", status: "transferring", totalBytes: 2,
transferredBytes: 1, speed: 0, startTime: 1, isDirectory: true, progressMode: "files", reconnectRequired: true,
};
const retained: TransferTask = {
...parent, id: "retained-replace-b", parentTaskId: parent.id, isDirectory: false,
sourcePath: "/remote/folder/b.txt", targetPath: `${stage}/b.txt`, fileName: "b.txt",
status: completeDuringSetup ? "interrupted" : "completed", totalBytes: 10, transferredBytes: 10, checkpointBytes: 10,
sourceLastModified: 2, directoryEntryIndex: 1,
directoryEntryIdentity: createDirectoryEntryIdentity({
sourcePath: "/remote/folder/b.txt", targetPath: `${stage}/b.txt`, size: 10, lastModified: 2,
}),
};
store.upsertTasks([parent, retained]);
assert.ok(store.getTask(retained.id), "missing earlier manifest index retains the completed row");
const batcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096,
hasTask: (id) => !!store.getTask(id),
upsertTasks: (updates) => store.upsertTasks(updates),
});
let releaseFirst!: () => void;
const secondDispatched = new Promise<void>((resolve) => { releaseFirst = resolve; });
t.after(async () => {
await fs.promises.rm(root, { recursive: true, force: true });
netcattyBridge.get = originalGet;
store.patchTask(parent.id, { status: "completed" });
store.dismiss(parent.id);
if (previousLocalStorage) Object.defineProperty(globalThis, "localStorage", previousLocalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
resetDedicatedSessionOpenGateForTests();
});
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated-sftp", closeSftp: async () => {},
listSftp: async (_id: string, remotePath: string) => {
if (completeDuringSetup) store.patchTask(retained.id, { status: "completed", ownerId: "dedicated-resume" });
return remotePath === parent.sourcePath
? ["a.txt", "b.txt"].map((name) => ({ name, type: "file", size: 10, lastModified: 2 })) : [];
},
mkdirLocal: async (target: string) => { await fs.promises.mkdir(target, { recursive: true }); },
statLocal: async (target: string) => {
try {
const stat = await fs.promises.stat(target);
return { type: stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
},
statSftp: async (_id: string, source: string) => {
if (source.endsWith("a.txt")) await secondDispatched;
return { type: "file", size: 10, lastModified: 2 };
},
deleteLocalFile: async (target: string) => { await fs.promises.rm(target, { recursive: true }); },
renameLocalFile: async (source: string, target: string) => { await fs.promises.rename(source, target); },
startStreamTransfer: async (options: { targetPath: string; checkpointBytes: number }) => {
assert.equal(options.checkpointBytes, 0, "an empty replacement stage cannot reuse byte checkpoints");
await fs.promises.writeFile(options.targetPath, `new ${path.basename(options.targetPath)}`);
return {};
},
});
const result = await resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box")], keys: [], identities: [],
}, undefined, {
children: [retained],
flushChildUpdates: () => batcher.flush(),
onChildUpdate: (task, observation) => {
batcher.push(task, observation);
if (task.id === retained.id && task.status === "completed") releaseFirst();
return true;
},
onDirectoryCheckpointUpdate: (value) => store.patchTask(parent.id, { directoryResumeCheckpoint: value }),
});
batcher.flush();
assert.equal(result.success, true, result.error);
assert.deepEqual((await fs.promises.readdir(finalPath)).sort(), ["a.txt", "b.txt"],
"published replacement must include the previously completed file");
assert.equal(await fs.promises.readFile(path.join(finalPath, "b.txt"), "utf8"), "new b.txt");
});
}
for (const boundary of ["before-admission", "source-missing", "source-modified", "after-completion", "after-commit"] as const) {
test(`dedicated recovery preserves replacement ownership ${boundary}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
const originalGet = netcattyBridge.get;
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: {
getItem: () => "2", setItem: () => {}, removeItem: () => {},
} });
const parent: TransferTask = {
id: `ownership-${boundary}`, fileName: "folder", sourcePath: "/local/folder", targetPath: "/remote/folder",
sourceConnectionId: "local", targetConnectionId: "old", targetHostId: "h1", direction: "upload",
status: "transferring", totalBytes: 2, transferredBytes: 0, speed: 0, startTime: 1,
isDirectory: true, progressMode: "files", reconnectRequired: true,
};
const child: TransferTask = {
...parent, id: `${parent.id}-a`, parentTaskId: parent.id, isDirectory: false,
sourcePath: "/local/folder/a", targetPath: "/remote/folder/a", fileName: "a", totalBytes: 1,
status: "interrupted", directoryEntryIndex: 0, directoryEntryIdentity: "a".repeat(64), sourceLastModified: 1,
};
store.upsertTasks([parent, boundary === "before-admission"
? { ...child, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }
: child]);
const batch = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096, hasTask: (id) => id === child.id || id.startsWith("peer-"),
upsertTasks: (rows) => store.upsertTasks(rows.filter((row) => !row.id.startsWith("peer-"))),
});
if (boundary === "before-admission") {
for (let n = 0; n < 511; n++) batch.push({ ...child, id: `peer-${n}` });
}
let releaseB!: () => void;
const bGate = new Promise<void>((resolve) => { releaseB = resolve; });
let timedOut = false;
const watchdog = setTimeout(() => { timedOut = true; releaseB(); }, 1000);
t.after(() => {
clearTimeout(watchdog);
releaseB();
batch.flush();
store.patchTask(parent.id, { status: "completed" });
store.dismiss(parent.id);
netcattyBridge.get = originalGet;
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
resetDedicatedSessionOpenGateForTests();
});
let starts = 0;
let displaced = 0;
let replaced = false;
(netcattyBridge as { get: () => unknown }).get = () => ({
openSftp: async () => "dedicated", closeSftp: async () => {
if (boundary === "after-commit") {
assert.equal(store.getTask(child.id), undefined, "completion must be committed before async cleanup");
store.upsertTasks([{ ...child, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
}
},
listLocalTree: async () => (boundary === "after-completion" ? ["a", "b"] : ["a"]).map((name) => ({
localPath: `/local/folder/${name}`, relativePath: name, type: "file", size: 1, lastModified: 1,
})),
mkdirSftp: async () => {}, statLocal: async () => {
if (boundary === "source-missing" || boundary === "source-modified") {
store.upsertTasks([{ ...child, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
if (boundary === "source-missing") return null;
return { type: "file", size: 1, lastModified: 2 };
}
return { type: "file", size: 1, lastModified: 1 };
},
startStreamTransfer: async (options: { sourcePath: string }) => {
starts++;
if (options.sourcePath.endsWith("/b")) await bGate;
return {};
},
});
const result = await resumeTransferWithDedicatedSession(parent, {
hosts: [host("h1", "box")], keys: [], identities: [],
}, undefined, {
children: [child],
onChildSuperseded: (id) => { displaced++; batch.discard(id); },
flushChildUpdates: () => batch.flush(),
onChildUpdate: (task, observation) => {
batch.push(task, observation);
if (boundary === "after-completion" && task.id === child.id && task.status === "completed" && !replaced) {
replaced = true;
queueMicrotask(() => {
try {
store.upsertTasks([{ ...task, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
} finally { releaseB(); }
});
}
return true;
},
});
batch.flush();
assert.equal(timedOut, false);
assert.equal(result.success, boundary === "after-commit", "only a committed success may precede a legitimate replacement");
assert.equal(store.getTask(child.id)?.directoryEntryIdentity, "b".repeat(64));
assert.equal(store.getTask(child.id)?.status, "transferring");
if (boundary !== "after-completion" && boundary !== "after-commit") assert.equal(starts, 0, "source validation and a full batch must not bypass ownership");
else assert.equal(displaced, boundary === "after-commit" ? 0 : 1);
});
}
@@ -1,4 +1,4 @@
import { runTransferAndWaitForOwner, TransferOwnerChangedError, type TransferOwnerObservation } from "./waitForTransferOwner";
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
@@ -62,10 +62,7 @@ export type DedicatedResumeResult = {
export type DedicatedResumeOptions = {
children?: readonly TransferTask[];
/** Return true only when the consumer takes responsibility for disposing the observation. */
onChildUpdate?: (child: TransferTask, observation?: TransferOwnerObservation) => unknown;
onChildSuperseded?: (taskId: string) => void;
flushChildUpdates?: () => void;
onChildUpdate?: (child: TransferTask) => void;
onDirectoryCheckpointUpdate?: (checkpoint: TransferTask["directoryResumeCheckpoint"]) => void;
shouldAbort?: () => boolean;
};
@@ -674,7 +671,6 @@ async function resumeSingleFileWithDedicatedSession(
const validationError = validateTransferResumeSource(task, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
sizeKnown: sourceStat.sizeKnown,
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "modified") {
@@ -1094,12 +1090,6 @@ async function resumeDirectoryWithDedicatedSession(
// the source since the interrupted attempt, so rebuild it from empty.
await resetDirectoryReplaceStage(parent, endpoints, targetSftpId);
}
// Session setup and traversal may finish more children. Capture only
// after resetting the stage/checkpoint: all these completions refer to
// the discarded destination, while later completions remain valid.
const completedAtRestart = new Map(sftpTransferCenterStore.getSnapshot().tasks
.filter((child) => child.parentTaskId === parent.id && child.status === "completed")
.map((child) => [child.id, child]));
const destRoot = resolveDirectoryResumeTargetRoot(parent);
if (endpoints.isDownload) await ensureLocalDir(destRoot);
if (targetSftpId) await ensureRemoteDir(targetSftpId, destRoot);
@@ -1198,25 +1188,8 @@ async function resumeDirectoryWithDedicatedSession(
}
if (targetSftpId) await ensureRemoteDir(targetSftpId, getParentPath(file.targetPath));
let countedCompleted = false;
const onOwnerChanged = () => {
if (countedCompleted) {
countedCompleted = false;
completedCount -= 1;
failedCount += 1;
}
options?.onChildSuperseded?.(childId);
};
let childObservation: TransferOwnerObservation | undefined = sftpTransferCenterStore.observeTaskSettlement(
childBase, resetPersistedCheckpoint ? completedAtRestart.get(childId) : undefined, onOwnerChanged,
);
const publishChild = (child: TransferTask) => {
if (childObservation?.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before child update");
if (options?.onChildUpdate?.(child, childObservation) === true) childObservation = undefined;
};
try {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
if (childObservation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before source validation");
const sourceType = endpoints.isUpload ? "local" as const : "sftp" as const;
const targetType = endpoints.isDownload ? "local" as const : "sftp" as const;
@@ -1233,7 +1206,6 @@ async function resumeDirectoryWithDedicatedSession(
const validationError = validateTransferResumeSource(childBase, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
sizeKnown: sourceStat.sizeKnown,
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "restart") {
@@ -1245,7 +1217,8 @@ async function resumeDirectoryWithDedicatedSession(
sourceLastModified: sourceStat.lastModified,
};
} else if (classified.kind === "modified") {
publishChild({
attentionCount += 1;
options?.onChildUpdate?.({
...childBase,
status: "attention",
error: classified.message || validationError || "Source was modified",
@@ -1254,7 +1227,6 @@ async function resumeDirectoryWithDedicatedSession(
phase: undefined,
retryable: true,
});
attentionCount += 1;
return;
} else if (classified.kind === "fatal") {
throw new Error(classified.message || validationError || "Resume validation failed");
@@ -1264,71 +1236,56 @@ async function resumeDirectoryWithDedicatedSession(
// zero-byte plans (`||` would incorrectly promote to grown size).
const plannedBytes = Number(childBase.totalBytes);
const hasPlannedBytes = Number.isFinite(plannedBytes) && plannedBytes >= 0;
// Stat-less SCP sources report size as a placeholder 0; keep the
// planned size rather than re-planning to a fake zero.
const sourceSizeKnown = sourceStat.sizeKnown !== false && Number.isFinite(sourceStat.size);
childBase = {
...childBase,
totalBytes: !sourceSizeKnown
? childBase.totalBytes
: allowSourceGrowth
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
: (sourceStat.size || childBase.totalBytes),
sourceLastModified: !sourceSizeKnown
? (sourceStat.lastModified ?? childBase.sourceLastModified)
: allowSourceGrowth
&& hasPlannedBytes
&& sourceStat.size > plannedBytes
? (childBase.sourceLastModified ?? sourceStat.lastModified)
: (sourceStat.lastModified ?? childBase.sourceLastModified),
totalBytes: allowSourceGrowth
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
: (sourceStat.size || childBase.totalBytes),
sourceLastModified: allowSourceGrowth
&& hasPlannedBytes
&& sourceStat.size > plannedBytes
? (childBase.sourceLastModified ?? sourceStat.lastModified)
: (sourceStat.lastModified ?? childBase.sourceLastModified),
};
}
// Re-check abort after async stat before inserting a transferring child.
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const streamResult = await runTransferAndWaitForOwner(childBase, () => {
// Publish only after admission, while identity changes are observed.
options?.onChildUpdate?.(childBase);
return bridge.startStreamTransfer!({
transferId: childId,
// Lifecycle events must carry the current identity even while
// large-history renderer child updates remain batched.
parentTaskId: childBase.parentTaskId,
directoryEntryIndex: childBase.directoryEntryIndex,
directoryEntryIdentity: childBase.directoryEntryIdentity,
sourcePath: file.sourcePath,
targetPath: file.targetPath,
sourceType,
targetType,
sourceSftpId,
targetSftpId,
sourceHostId: endpoints.sourceHost?.id,
targetHostId: endpoints.targetHost?.id,
totalBytes: Number.isFinite(childBase.totalBytes)
? childBase.totalBytes
: (file.size || undefined),
resumable: parent.resumable !== false,
checkpointBytes: childBase.checkpointBytes ?? 0,
resumeStage: childBase.resumeStage,
downloadCheckpointBytes: childBase.downloadCheckpointBytes,
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
sourceFingerprint: childBase.sourceFingerprint,
skipAdmission: true,
});
}, () => options?.shouldAbort?.() === true, pausedAtResume.get(childId),
resetPersistedCheckpoint ? completedAtRestart.get(childId) : undefined,
// Drop queued UI updates at the ownership change itself: waiting
// for this invocation's reply may allow an intervening batch flush.
onOwnerChanged,
(observation) => { childObservation = observation; return true; }, childObservation);
options?.onChildUpdate?.(childBase);
if (childObservation?.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before child update");
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
transferId: childId,
// Lifecycle events must carry the current identity even while
// large-history renderer child updates remain batched.
parentTaskId: childBase.parentTaskId,
directoryEntryIndex: childBase.directoryEntryIndex,
directoryEntryIdentity: childBase.directoryEntryIdentity,
sourcePath: file.sourcePath,
targetPath: file.targetPath,
sourceType,
targetType,
sourceSftpId,
targetSftpId,
sourceHostId: endpoints.sourceHost?.id,
targetHostId: endpoints.targetHost?.id,
totalBytes: Number.isFinite(childBase.totalBytes)
? childBase.totalBytes
: (file.size || undefined),
resumable: parent.resumable !== false,
checkpointBytes: childBase.checkpointBytes ?? 0,
resumeStage: childBase.resumeStage,
downloadCheckpointBytes: childBase.downloadCheckpointBytes,
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
sourceFingerprint: childBase.sourceFingerprint,
skipAdmission: true,
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
publishChild({
completedCount += 1;
options?.onChildUpdate?.({
...childBase,
status: "completed",
transferredBytes: Number.isFinite(childBase.totalBytes)
@@ -1340,21 +1297,13 @@ async function resumeDirectoryWithDedicatedSession(
reconnectRequired: false,
phase: undefined,
});
countedCompleted = true;
completedCount += 1;
bumpParentProgress(0);
} catch (error) {
if (options?.shouldAbort?.() || /cancelled|canceled/i.test(error instanceof Error ? error.message : String(error))) {
throw error instanceof Error ? error : new Error(String(error));
}
failedCount += 1;
// Another invocation owns this ID now. Report this walk's failure
// without replacing the winner or recreating its compacted row.
if (error instanceof TransferOwnerChangedError || childObservation?.hasIdentityConflict()) {
options?.onChildSuperseded?.(childId);
return;
}
publishChild({
options?.onChildUpdate?.({
...childBase,
status: "failed",
error: error instanceof Error ? error.message : String(error),
@@ -1363,8 +1312,6 @@ async function resumeDirectoryWithDedicatedSession(
reconnectRequired: false,
phase: undefined,
});
} finally {
childObservation?.dispose();
}
},
);
@@ -1375,10 +1322,6 @@ async function resumeDirectoryWithDedicatedSession(
if (result?.error) throw new Error(result.error);
// Commit deferred child outcomes before deciding success or promoting a stage.
// Async connection cleanup must not outlive provisional completion evidence.
options?.flushChildUpdates?.();
if (attentionCount > 0 && failedCount === 0 && completedCount + attentionCount >= totalFiles) {
return {
success: false,
@@ -1,108 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveDownloadSourceSnapshot } from "./downloadSourceSnapshot.ts";
const stat = (overrides: Partial<SftpStatResult> = {}): SftpStatResult => ({
name: "f",
size: 50_000,
type: "file",
lastModified: 0,
sizeKnown: true,
...overrides,
});
for (const listedSize of [25_000, 100_000]) {
test(`recreated file uses the current 50k size rather than listed ${listedSize}`, async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async (_id, path, encoding) => {
assert.equal(path, "/tmp/f");
assert.equal(encoding, "auto");
return stat();
},
"sftp-1", "/tmp/f", "auto",
);
assert.deepEqual(snapshot, { size: 50_000, isDirectory: false });
assert.notEqual(snapshot.size, listedSize);
});
}
test("a stale file entry replaced by a directory uses directory routing", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: 0, type: "directory" }),
"sftp-1", "/tmp/x", "auto",
);
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
});
test("a stale directory entry replaced by a file uses file routing", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: 12, type: "file" }),
"sftp-1", "/tmp/x", "auto",
);
assert.deepEqual(snapshot, { size: 12, isDirectory: false });
});
test("missing, failed and unsupported stats reject instead of using a stale listing", async () => {
const rejected = [
undefined,
async () => null as unknown as SftpStatResult,
async () => { throw new Error("File not found"); },
async () => stat({ type: "symlink" }),
async () => stat({ size: Number.NaN }),
async () => stat({ size: -1 }),
];
for (const candidate of rejected) {
await assert.rejects(
resolveDownloadSourceSnapshot(candidate, "sftp-1", "/tmp/f", "auto"),
/Cannot verify the current remote source before download/,
);
}
});
test("stat-less SCP file leaves sizing to the transfer instead of using listed bytes", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: 0, sizeKnown: false }),
"scp-1", "/tmp/f", "auto",
);
assert.deepEqual(snapshot, { size: undefined, isDirectory: false });
});
test("SCP links retain their resolved file or directory routing without using link size", async () => {
for (const target of ["file", "directory"] as const) {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ type: "symlink", size: 8 }),
"scp-1", "/tmp/link", "auto",
async (_id, parent, encoding) => {
assert.equal(parent, "/tmp");
assert.equal(encoding, "auto");
return [{ name: "link", type: "symlink", linkTarget: target, size: "8 bytes", lastModified: "" }];
},
);
assert.deepEqual(snapshot, { size: undefined, isDirectory: target === "directory" });
}
});
test("SCP link whose current target cannot be confirmed stops safely", async () => {
for (const entries of [
[],
[{ name: "link", type: "symlink" as const, linkTarget: null, size: "8 bytes", lastModified: "" }],
[{ name: "link", type: "file" as const, size: "8 bytes", lastModified: "" }],
]) {
await assert.rejects(
resolveDownloadSourceSnapshot(
async () => stat({ type: "symlink" }),
"scp-1", "/tmp/link", "auto", async () => entries,
),
/Cannot verify the current remote source before download/,
);
}
});
test("directory routing does not require a file byte count", async () => {
const snapshot = await resolveDownloadSourceSnapshot(
async () => stat({ size: Number.NaN, sizeKnown: false, type: "directory" }),
"sftp-1", "/tmp/d", "auto",
);
assert.deepEqual(snapshot, { size: 0, isDirectory: true });
});
@@ -1,67 +0,0 @@
import type { SftpFilenameEncoding } from "../../../domain/models/sftp";
import type { RemoteFile } from "../../../domain/models/workspace";
import { getFileName, getParentPath } from "./utils";
export type DownloadSourceSnapshot = {
/** Unknown for stat-less SCP files and links; the transfer reads its wire size. */
size: number | undefined;
isDirectory: boolean;
};
type StatSftp = (
sftpId: string,
path: string,
encoding?: SftpFilenameEncoding,
) => Promise<SftpStatResult>;
type ListSftp = (
sftpId: string,
path: string,
encoding?: SftpFilenameEncoding,
) => Promise<RemoteFile[]>;
const sourceUnavailable = () => new Error("Cannot verify the current remote source before download");
/** A stale listing must never supply the size or type of a new download. */
export const resolveDownloadSourceSnapshot = async (
statSftp: StatSftp | undefined,
sftpId: string,
sourcePath: string,
encoding: SftpFilenameEncoding | undefined,
listSftp?: ListSftp,
): Promise<DownloadSourceSnapshot> => {
if (!statSftp) throw sourceUnavailable();
let stat: SftpStatResult | null;
try {
stat = await statSftp(sftpId, sourcePath, encoding);
} catch (cause) {
throw new Error("Cannot verify the current remote source before download", { cause });
}
if (!stat) {
throw sourceUnavailable();
}
if (stat.type === "directory") return { size: 0, isDirectory: true };
if (stat.type === "symlink") {
// SFTP STAT follows links; legacy SCP describes the link node. Re-list its
// parent to resolve the current target kind rather than trusting the pane.
if (!listSftp) throw sourceUnavailable();
let entries: RemoteFile[];
try {
entries = await listSftp(sftpId, getParentPath(sourcePath), encoding);
} catch (cause) {
throw new Error("Cannot verify the current remote source before download", { cause });
}
const current = entries.find((entry) => entry.name === getFileName(sourcePath));
if (current?.type !== "symlink"
|| (current.linkTarget !== "file" && current.linkTarget !== "directory")) {
throw sourceUnavailable();
}
// The link's own size is never a valid file plan; SCP's wire header is.
return { size: undefined, isDirectory: current.linkTarget === "directory" };
}
if (stat.type !== "file") throw sourceUnavailable();
if (stat.sizeKnown === false) return { size: undefined, isDirectory: false };
if (!Number.isSafeInteger(stat.size) || stat.size < 0) throw sourceUnavailable();
return { size: stat.size, isDirectory: false };
};
@@ -1,174 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { JSDOM } from 'jsdom';
import {
startSftpFileDrag, takeLocalFileDragSources, getLocalFileDragSources,
clearLocalFileDrag, clearLocalFileDragForPane, localDragPathKey, localFileDragMoveEffect,
} from './localFileDrag';
function setup(t: test.TestContext) {
const dom = new JSDOM('<!doctype html><html><body></body></html>');
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { value: dom.window, configurable: true });
const calls: { requestId: string; paths: string[] }[] = [];
const cancels: string[] = [];
const errors: string[] = [];
const remote: unknown[] = [];
const bridge = {
startLocalFileDrag: async (payload) => { calls.push(payload); return { started: true }; },
cancelLocalFileDrag: (id) => cancels.push(id),
getPathForFile: (file) => (file as unknown as { path: string }).path,
} as unknown as NetcattyBridge;
Object.defineProperty(window, "netcatty", { value: bridge, configurable: true });
t.after(() => {
clearLocalFileDrag();
dom.window.close();
if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window');
});
const transfer = (paths: string[] = []) => ({
types: ['Files'], files: paths.map((path) => ({ path })),
effectAllowed: '', setData: (...args: string[]) => remote.push(args),
}) as unknown as DataTransfer;
const sources = [
{ name: 'certificate.txt', sourcePath: '/local', sourceConnectionId: 'local', isDirectory: false },
{ name: 'folder', sourcePath: '/another path', sourceConnectionId: 'local', isDirectory: true },
];
let prevented = 0;
const start = (isLocal: boolean | undefined = true, selection = sources) => startSftpFileDrag({
event: { dataTransfer: transfer(), preventDefault: () => { prevented++; } },
paneId: 'pane', connection: { id: 'local', isLocal }, sources: selection, side: 'left',
onRemoteDrag: (...args) => remote.push(args), onError: (error) => errors.push(error),
});
return { calls, cancels, errors, remote, transfer, sources, start, prevented: () => prevented, dom };
}
test('local multi-selection retains individual tree paths and never exports plain text', async (t) => {
const h = setup(t);
h.start();
assert.equal(h.prevented(), 1);
assert.equal(h.remote.length, 0);
assert.deepEqual(h.calls[0].paths, ['/local/certificate.txt', '/another path/folder']);
const payload = h.transfer([...h.calls[0].paths].reverse());
assert.equal(getLocalFileDragSources(payload)?.length, 2);
assert.deepEqual(takeLocalFileDragSources(payload), h.sources.map((source) => ({ ...source, side: 'left' })));
assert.equal(takeLocalFileDragSources(payload), null, 'a native gesture is consumed exactly once');
assert.equal(getLocalFileDragSources(payload), null);
});
test('native paths retain symlink-parent segments and match the returning internal drop', (t) => {
const h = setup(t);
const sources = [{ ...h.sources[0], sourcePath: '/local/link/.././folder' }];
h.start(true, sources);
assert.deepEqual(h.calls[0].paths, ['/local/link/.././folder/certificate.txt']);
const payload = h.transfer(h.calls[0].paths);
assert.deepEqual(takeLocalFileDragSources(payload), sources.map(source => ({ ...source, side: 'left' })));
assert.equal(takeLocalFileDragSources(payload), null);
});
test('remote origin preserves HTML payload and callbacks, including absent isLocal', (t) => {
const h = setup(t);
h.start(false);
assert.equal(h.prevented(), 0);
assert.equal(h.calls.length, 0);
assert.deepEqual(h.remote, [['text/plain', 'certificate.txt\nfolder'], [h.sources, 'left']]);
startSftpFileDrag({ event: { dataTransfer: h.transfer(), preventDefault: () => assert.fail('remote drag canceled') },
paneId: 'remote', connection: { id: 'remote' }, sources: h.sources, side: 'right',
onRemoteDrag: () => {}, onError: assert.fail });
assert.equal(h.calls.length, 0);
});
test('unrelated external files cannot reuse a previous local gesture', (t) => {
const h = setup(t);
h.start();
assert.equal(takeLocalFileDragSources(h.transfer(['/different/certificate.txt'])), null);
assert.equal(getLocalFileDragSources(h.transfer()), null);
h.start();
assert.equal(takeLocalFileDragSources(h.transfer(['/local/certificate.txt'])), null, 'partial selection is not an internal transfer');
});
test('local failure cancels without text fallback and stale async errors cannot clear a newer gesture', async (t) => {
const h = setup(t);
let reject!: (error: Error) => void;
netcattyBridge.require().startLocalFileDrag = () => new Promise((_resolve, rejectPromise) => { reject = rejectPromise; });
h.start();
const oldReject = reject;
h.start();
oldReject(new Error('obsolete'));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(h.errors.length, 0);
assert.ok(getLocalFileDragSources(h.transfer()));
reject(new Error('file disappeared'));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(h.errors, ['file disappeared']);
assert.equal(getLocalFileDragSources(h.transfer()), null);
assert.equal(h.remote.length, 0);
});
test('native state is cleared on physical cancellation, external completion, connection disposal and new gestures', async (t) => {
const h = setup(t);
for (const event of [new h.dom.window.KeyboardEvent('keydown', { key: 'Escape' }),
new h.dom.window.MouseEvent('mousemove', { buttons: 0 }), new h.dom.window.MouseEvent('mousedown'),
new h.dom.window.Event('blur'), new h.dom.window.Event('pagehide')]) {
h.start();
await Promise.resolve();
window.dispatchEvent(event);
assert.equal(getLocalFileDragSources(h.transfer()), null, event.type);
}
h.start();
clearLocalFileDragForPane('another-pane');
assert.ok(getLocalFileDragSources(h.transfer()));
clearLocalFileDragForPane('pane');
assert.equal(getLocalFileDragSources(h.transfer()), null);
h.start();
h.start(false);
assert.equal(getLocalFileDragSources(h.transfer()), null);
});
test('invalid local selections and missing bridge are rejected; parent entry is excluded', (t) => {
const h = setup(t);
h.start(true, [{ ...h.sources[0], name: '..' }]);
assert.equal(h.calls.length, 0);
h.start(true, [{ ...h.sources[0], sourceConnectionId: 'remote' }]);
assert.equal(h.calls.length, 0);
h.start(true, [h.sources[0], { ...h.sources[0], name: '..' }]);
assert.deepEqual(h.calls[0].paths, ['/local/certificate.txt']);
netcattyBridge.require().startLocalFileDrag = undefined;
h.start();
assert.equal(h.errors.length, 3);
assert.equal(h.remote.length, 0);
});
test('path matching preserves POSIX whitespace, Unicode and backslashes, and accepts Windows separators', () => {
assert.equal(localDragPathKey('C:\\Users\\david\\certificate.pem'), 'C:/Users/david/certificate.pem');
assert.equal(localDragPathKey('\\\\server\\share\\folder'), '//server/share/folder');
assert.equal(localDragPathKey('/local/é \\name '), '/local/é \\name ');
assert.notEqual(localDragPathKey('/local/A'), localDragPathKey('/local/a'));
});
test('local move accepts the copy-only native transport on Windows/Linux without changing the operation', () => {
assert.equal(localFileDragMoveEffect({ effectAllowed: 'copyLink' }), 'copy');
assert.equal(localFileDragMoveEffect({ effectAllowed: 'copy' }), 'copy');
assert.equal(localFileDragMoveEffect({ effectAllowed: 'all' }), 'move');
assert.equal(localFileDragMoveEffect({ effectAllowed: 'copyMove' }), 'move');
});
test('native drop cleanup survives the browser microtask checkpoint before React receives the drop', async (t) => {
const h = setup(t);
h.start();
window.dispatchEvent(new h.dom.window.Event('drop'));
// Unlike dispatchEvent in jsdom, native browser events can drain microtasks
// after the window capture callback, before the React root bubble callback.
await Promise.resolve();
assert.ok(getLocalFileDragSources(h.transfer()), 'capture must not erase native identity before React handles drop');
assert.deepEqual(takeLocalFileDragSources(h.transfer(h.calls[0].paths)), h.sources.map(source => ({ ...source, side: 'left' })));
await new Promise(resolve => setTimeout(resolve, 5));
assert.equal(getLocalFileDragSources(h.transfer()), null);
h.start();
window.dispatchEvent(new h.dom.window.Event('drop'));
await new Promise(resolve => setTimeout(resolve, 5));
assert.equal(getLocalFileDragSources(h.transfer()), null, 'unhandled drops also release the gesture');
});
-177
View File
@@ -1,177 +0,0 @@
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { joinPath } from "./utils";
export interface LocalDragSource {
name: string;
isDirectory: boolean;
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
side: "left" | "right";
}
interface DragSession {
requestId: string;
paneId: string;
connectionId: string;
paths: string[];
sources: LocalDragSource[];
started?: boolean;
}
// Windows separators can differ between the SFTP UI and Electron webUtils.
// POSIX backslashes, whitespace, and case must remain significant.
export function localDragPathKey(value: string): string {
if (/^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value)) {
return value.replace(/\\/g, "/").replace(/\/$/, "");
}
return value.length > 1 ? value.replace(/\/$/, "") : value;
}
class LocalFileDragState {
session: DragSession | null = null;
clear(requestId?: string) {
if (!requestId || this.session?.requestId === requestId) this.session = null;
}
take(paths: string[]): LocalDragSource[] | null {
const session = this.session;
this.clear();
if (!session || paths.length === 0 || paths.some((path) => !path)) return null;
const expected = new Set(session.paths.map(localDragPathKey));
const actual = new Set(paths.map(localDragPathKey));
if (expected.size !== actual.size || [...expected].some((path) => !actual.has(path))) return null;
return session.sources;
}
}
const state = new LocalFileDragState();
let detach: (() => void) | undefined;
export function clearLocalFileDrag(requestId?: string) {
if (requestId && state.session?.requestId !== requestId) return;
const previous = state.session;
state.clear();
detach?.();
detach = undefined;
if (previous) netcattyBridge.get()?.cancelLocalFileDrag?.(previous.requestId);
}
export function clearLocalFileDragForPane(paneId: string) {
if (state.session?.paneId === paneId) clearLocalFileDrag();
}
function observeGestureEnd() {
let dropCleanupTimer: ReturnType<typeof setTimeout> | undefined;
// Electron has no cross-platform native-drag completion callback. A canceled
// HTML drag's dragend is not a native completion signal. Clear on drop and
// on the next physical input/focus transition, including Escape and returning
// after a drop outside the window. Never retain state into a new gesture.
const clear = () => clearLocalFileDrag();
const key = (event: KeyboardEvent) => { if (event.key === "Escape") clear(); };
const move = (event: MouseEvent) => { if (state.session?.started && event.buttons === 0) clear(); };
const drop = () => {
const requestId = state.session?.requestId;
// Native event dispatch can run microtasks BETWEEN capture and React's
// bubble listener. Use a macrotask so the row/pane can consume the gesture.
dropCleanupTimer = setTimeout(() => { if (requestId) clearLocalFileDrag(requestId); }, 0);
};
// preventDefault() on dragstart produces a synthetic mouseup in Chromium.
// It must not cancel the native handoff. Observe later input instead.
window.addEventListener("mousedown", clear, true);
window.addEventListener("mousemove", move, true);
window.addEventListener("keydown", key, true);
window.addEventListener("blur", clear);
window.addEventListener("pagehide", clear);
window.addEventListener("drop", drop, true);
detach = () => {
if (dropCleanupTimer !== undefined) clearTimeout(dropCleanupTimer);
window.removeEventListener("mousedown", clear, true);
window.removeEventListener("mousemove", move, true);
window.removeEventListener("keydown", key, true);
window.removeEventListener("blur", clear);
window.removeEventListener("pagehide", clear);
window.removeEventListener("drop", drop, true);
};
}
type DragEventLike = Pick<DragEvent, "dataTransfer" | "preventDefault">;
export function startSftpFileDrag({ event, paneId, connection, sources, side, onRemoteDrag, onError }: {
event: DragEventLike;
paneId: string;
connection?: { id: string; isLocal?: boolean } | null;
sources: Omit<LocalDragSource, "side">[];
side: "left" | "right";
onRemoteDrag: (sources: Omit<LocalDragSource, "side">[], side: "left" | "right") => void;
onError: (message: string) => void;
}) {
clearLocalFileDrag();
if (connection?.isLocal !== true) {
event.dataTransfer!.effectAllowed = "copyMove";
event.dataTransfer!.setData("text/plain", sources.map((file) => file.name).join("\n"));
onRemoteDrag(sources, side);
return;
}
event.preventDefault();
const bridge = netcattyBridge.get();
if (!bridge?.startLocalFileDrag) {
onError("Native file drag is unavailable. Restart Netcatty after updating.");
return;
}
const files = sources.filter((source) => source.name !== "..");
if (!files.length || files.some((file) => !file.sourcePath || file.sourceConnectionId !== connection.id)) {
onError("The local file selection is no longer available.");
return;
}
const requestId = crypto.randomUUID();
const paths = [...new Set(files.map((file) => joinPath(file.sourcePath!, file.name)))];
state.session = { requestId, paneId, connectionId: connection.id, paths, sources: files.map((file) => ({ ...file, side })) };
observeGestureEnd();
// Send during dragstart, without awaiting in the renderer. Do not interpret
// the reply as a completed transfer or use it to clear an active gesture.
try {
void bridge.startLocalFileDrag({ requestId, paths }).then((result) => {
if (result.started && state.session?.requestId === requestId) state.session.started = true;
if (!result.started && state.session?.requestId === requestId) {
clearLocalFileDrag(requestId);
if (result.error) onError(result.error);
}
}).catch((error: unknown) => {
if (state.session?.requestId !== requestId) return;
clearLocalFileDrag(requestId);
onError(error instanceof Error ? error.message : "Unable to drag local files");
});
} catch (error) {
clearLocalFileDrag(requestId);
onError(error instanceof Error ? error.message : "Unable to drag local files");
}
}
export function getLocalFileDragSources(dataTransfer: DataTransfer): LocalDragSource[] | null {
return dataTransfer.types.includes("Files") ? state.session?.sources ?? null : null;
}
export function localFileDragMoveEffect(dataTransfer: Pick<DataTransfer, "effectAllowed">): "copy" | "move" {
// Electron's Windows/Linux native source advertises COPY | LINK, not MOVE.
// Negotiate an allowed transport effect; our existing same-pane callback is
// responsible for the actual local move (the OS must not delete originals).
return ["all", "copyMove", "linkMove", "move"].includes(dataTransfer.effectAllowed) ? "move" : "copy";
}
export function takeLocalFileDragSources(dataTransfer: DataTransfer): LocalDragSource[] | null {
if (!state.session) return null;
const bridge = netcattyBridge.get();
const session = state.session;
try {
return state.take(Array.from(dataTransfer.files).map((file) => bridge?.getPathForFile?.(file) ?? ""));
} catch {
state.clear();
return null;
} finally {
detach?.();
detach = undefined;
bridge?.cancelLocalFileDrag?.(session.requestId);
}
}
@@ -334,8 +334,7 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
}
});
for (const ownerChange of ["same", "changed-active", "changed-completed"] as const) {
test(`live folder settles superseded ownership without clobbering its replacement: ${ownerChange}`, async () => {
test("live folder settles a superseded child whose completed row was compacted", async () => {
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
const previousWindow = (globalThis as { window?: unknown }).window;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
@@ -356,18 +355,10 @@ test(`live folder settles superseded ownership without clobbering its replacemen
startStreamTransfer: async (options: { transferId: string }) => {
childId = options.transferId;
sftpTransferCenterStore.publishOwner("live-compacted-owner", tasks);
if (ownerChange !== "same") {
const current = sftpTransferCenterStore.getTask(childId)!;
sftpTransferCenterStore.admitTaskRun({ ...current, directoryEntryIdentity: "b".repeat(64) });
}
if (ownerChange !== "changed-active") {
// The winning invocation completed; history compaction drops its row.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
});
}
tasks = [...sftpTransferCenterStore.getSnapshot().tasks];
transfersRef.current = tasks;
// The winning invocation has already completed; history compaction drops its row.
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: childId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0,
});
return { superseded: true };
},
} };
@@ -396,15 +387,10 @@ test(`live folder settles superseded ownership without clobbering its replacemen
new Promise<"still-waiting">((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
]);
assert.ok(childId);
if (ownerChange === "changed-active") {
assert.equal(tasks.find((task) => task.id === childId)?.status, "transferring");
assert.equal(tasks.find((task) => task.id === childId)?.directoryEntryIdentity, "b".repeat(64));
} else {
assert.equal(tasks.find((task) => task.id === childId), undefined, "old waiter must not reinsert a compacted child");
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
}
assert.equal(sftpTransferCenterStore.getTask(childId), undefined);
assert.equal(sftpTransferCenterStore.getTask(root.id)?.directoryResumeCheckpoint?.completedEntries, 1);
assert.notEqual(result, "still-waiting", "compacted completion must settle the live folder");
assert.equal(result, ownerChange === "same" ? 0 : 1);
assert.equal(result, 0);
} finally {
cancelledTasksRef.current.add(root.id);
await running?.catch(() => {});
@@ -418,4 +404,3 @@ test(`live folder settles superseded ownership without clobbering its replacemen
}
});
}
@@ -1,5 +1,5 @@
import { reconcileSupersededControls } from "./globalSftpTransferControl";
import { runTransferAndWaitForOwner, TransferOwnerChangedError } from "./waitForTransferOwner";
import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
import {
@@ -118,10 +118,6 @@ async function tryStatTransferPath(
if (!sftpId) return null;
const stat = await netcattyBridge.get()?.statSftp?.(sftpId, filePath, encoding);
if (!stat || stat.type === "directory") return null;
// Stat-less SCP endpoints report size as a placeholder 0 (sizeKnown false).
// Treat that as missing metadata so skip-unchanged is disabled rather than
// comparing a fake zero against the other endpoint (Codex P1).
if (stat.sizeKnown === false) return null;
return { size: Number(stat.size) || 0, lastModified: Number(stat.lastModified) || 0, type: stat.type };
} catch {
return null;
@@ -982,10 +978,6 @@ export function useSftpDirectoryTransferOps({
errors.push(err instanceof Error ? err : new Error(message));
return;
}
if (err instanceof TransferOwnerChangedError) {
errors.push(err);
return;
}
// Mark child as failed
setTransfers((prev) =>
prev.map((t) =>
@@ -169,284 +169,3 @@ test("fresh directory recovery authorizes only an unchanged retained pause under
store.patchTask("root", { status: "paused", lifecycleEpoch: 6 });
assert.equal(store.admitTaskRun(task, currentPause), "paused", "parent pause still wins");
});
for (const compactNewOwner of [false, true]) {
test(`superseded waiter rejects a changed identity even after new owner compaction: ${compactNewOwner}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: `changed-owner-${compactNewOwner}`, parentTaskId: `changed-root-${compactNewOwner}` };
store.upsertTasks([{ ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true }, task]);
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => {
const changed = { ...task, directoryEntryIdentity: "b".repeat(64) };
assert.equal(store.admitTaskRun(changed), "ready");
if (compactNewOwner) {
store.patchTask(changed.id, { status: "completed" });
assert.equal(store.getTask(changed.id), undefined);
}
return { superseded: true };
}, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
await settled;
store.patchTask(task.parentTaskId, { status: "completed" });
store.dismiss(task.parentTaskId);
});
assert.match(String(await Promise.race([
settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
])), /identity changed/i);
});
}
test("destination rebuild restarts only the captured completion and preserves newer controls", async (t) => {
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const store = createSftpTransferCenterStore();
const task = { ...child(), parentTaskId: undefined, status: "completed" as const, checkpointBytes: 10 };
store.upsertTasks([task]);
const captured = store.getTask(task.id)!;
const restart = { ...task, status: "transferring" as const, checkpointBytes: 0, transferredBytes: 0 };
latchTransferPause(task.id);
t.after(resetTransferPauseLatchesForTests);
assert.equal(store.admitTaskRun(restart, undefined, captured), "paused");
resetTransferPauseLatchesForTests();
assert.equal(store.admitTaskRun(restart, undefined, captured), "ready");
assert.equal(store.getTask(task.id)?.checkpointBytes, 0);
store.patchTask(task.id, { status: "completed" });
assert.equal(store.admitTaskRun(restart, undefined, captured), "completed", "new completion must not be restarted");
store.patchTask(task.id, { status: "cancelled" });
assert.equal(store.admitTaskRun(restart, undefined, captured), "cancelled");
});
test("destination rebuild waiting on pause reuses a newer compacted completion", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "restart-wait-child", parentTaskId: "restart-wait-root", directoryEntryIndex: 1 };
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
store.upsertTasks([root, { ...task, status: "completed" }]);
const captured = store.getTask(task.id)!;
latchTransferPause(task.id);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort, undefined, captured);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
resetTransferPauseLatchesForTests();
await settled;
store.patchTask(root.id, { status: "completed" });
store.dismiss(root.id);
});
// An unrelated lifecycle emit must not pin the old completion in the observation.
store.patchTask(root.id, { reconnectRequired: true });
assert.equal(await Promise.race([
settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 230)),
]), "still-waiting", "old completion cannot finish recovery while a new transfer is still needed");
assert.equal(starts, 0);
resetTransferPauseLatchesForTests();
store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
store.upsertTasks([{ ...task, id: "restart-wait-first", directoryEntryIndex: 0, status: "completed" }]);
store.patchTask(task.id, { status: "completed", lifecycleEpoch: 2 });
assert.equal(store.getTask(task.id), undefined);
assert.equal(await settled, "completed");
assert.equal(starts, 0, "new completion after stage rebuild must be reused even when its row was compacted");
});
test("paused admission cannot reclaim an identity replaced while it waited", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "paused-displaced", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "paused" }]);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
await settled;
store.patchTask(task.id, { status: "completed" });
store.dismiss(task.id);
});
store.upsertTasks([{ ...task, status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
assert.match(await settled, /identity changed/i);
assert.equal(starts, 0);
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
});
for (const identityChangesDuringWait of [false, true]) {
test(`paused recovery distinguishes initial stale identity from later ownership: changed=${identityChangesDuringWait}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: `paused-origin-${identityChangesDuringWait}`, parentTaskId: `paused-origin-root-${identityChangesDuringWait}` };
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
store.upsertTasks([root, {
...task, status: identityChangesDuringWait ? "failed" : "paused",
directoryEntryIdentity: identityChangesDuringWait ? task.directoryEntryIdentity : "b".repeat(64),
}]);
const initialPause = store.getTask(task.id)!;
latchTransferPause(task.id);
let starts = 0;
let abort = false;
let conflicts = 0;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort,
identityChangesDuringWait ? undefined : initialPause, undefined, () => { conflicts += 1; });
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
resetTransferPauseLatchesForTests();
await settled;
store.patchTask(root.id, { status: "completed" });
store.dismiss(root.id);
});
// This captures the prior failed result, or republishes the unchanged
// initial old identity. Neither is evidence of a replacement by itself.
store.patchTask(root.id, { reconnectRequired: true });
resetTransferPauseLatchesForTests();
if (identityChangesDuringWait) {
store.patchTask(task.id, { status: "transferring", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 });
}
const outcome = await settled;
if (identityChangesDuringWait) {
assert.match(outcome, /identity changed/i);
assert.equal(starts, 0);
assert.equal(conflicts, 1);
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
} else {
assert.equal(outcome, "completed");
assert.equal(starts, 1);
assert.equal(conflicts, 0);
}
});
}
test("paused retry observes newer same-identity completion after an old failure", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "failed-then-completed", parentTaskId: "failed-then-completed-root" };
const root = { ...task, id: task.parentTaskId, parentTaskId: undefined, isDirectory: true };
store.upsertTasks([root, { ...task, status: "failed" }]);
latchTransferPause(task.id);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
resetTransferPauseLatchesForTests();
await settled;
store.patchTask(root.id, { status: "completed" });
store.dismiss(root.id);
});
store.patchTask(root.id, { reconnectRequired: true });
resetTransferPauseLatchesForTests();
store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 1 });
store.patchTask(task.id, { status: "completed", lifecycleEpoch: 2 });
assert.equal(store.getTask(task.id), undefined);
assert.equal(await settled, "completed");
assert.equal(starts, 0, "a newer completed attempt must replace the captured old failure");
});
test("an unchanged owner retains its original transport rejection", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "unchanged-rejected-owner", parentTaskId: undefined };
store.upsertTasks([task]);
t.after(() => { store.patchTask(task.id, { status: "completed" }); store.dismiss(task.id); });
const error = new Error("original transport failure");
await assert.rejects(runTransferAndWaitForOwner(task, async () => { throw error; }, () => false),
(actual) => actual === error);
});
for (const activation of ["patch", "progress", "owner"] as const) {
test(`initial stale identity exemption ends when another owner activates that row: ${activation}`, async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { latchTransferPause, resetTransferPauseLatchesForTests } = await import("./transferPauseLatch");
const task = { ...child(), id: "activated-initial-identity", parentTaskId: undefined };
store.upsertTasks([{ ...task, status: "paused", directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
const initial = store.getTask(task.id)!;
latchTransferPause(task.id);
let starts = 0;
let abort = false;
const running = runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => abort, initial);
const settled = running.then(() => "completed", (error: Error) => error.message);
t.after(async () => {
abort = true;
resetTransferPauseLatchesForTests();
await settled;
store.patchTask(task.id, { status: "completed" });
store.dismiss(task.id);
});
resetTransferPauseLatchesForTests();
if (activation === "progress") {
store.ingestBackgroundEvent({ type: "progress", transferId: task.id,
transferred: 1, lifecycleEpoch: 2, lifecycleState: "transferring" });
} else if (activation === "owner") store.patchTask(task.id, { ownerId: "replacement-owner" });
else store.patchTask(task.id, { status: "transferring", lifecycleEpoch: 2 });
assert.match(String(await Promise.race([
settled, new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 450)),
])), /identity changed/i);
assert.equal(starts, 0);
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
});
}
test("already active different identity is rejected before first admission", async (t) => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const task = { ...child(), id: "already-active-identity", parentTaskId: undefined };
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), lifecycleEpoch: 1 }]);
t.after(() => { store.patchTask(task.id, { status: "completed" }); store.dismiss(task.id); });
let starts = 0;
let conflicts = 0;
await assert.rejects(runTransferAndWaitForOwner(task, async () => { starts += 1; return {}; }, () => false,
undefined, undefined, () => { conflicts += 1; }), /identity changed/i);
assert.equal(starts, 0);
assert.equal(conflicts, 1);
assert.equal(store.getTask(task.id)?.directoryEntryIdentity, "b".repeat(64));
});
for (const replacementStatus of ["transferring", "completed"] as const) {
for (const oldStatus of ["completed", "failed"] as const) {
test(`deferred ${oldStatus} cannot overwrite a ${replacementStatus} replacement after waiter exit`, async () => {
const { sftpTransferCenterStore: store } = await import("../sftpTransferCenterStore");
const { runTransferAndWaitForOwner } = await import("./waitForTransferOwner");
const { createDedicatedResumeChildUpdateBatcher } = await import("../../app/dedicatedResumeProgress");
const task = child();
const root = { ...task, id: task.parentTaskId!, parentTaskId: undefined, isDirectory: true };
const batcher = createDedicatedResumeChildUpdateBatcher({
getTaskCount: () => 4096, hasTask: () => true,
upsertTasks: (updates) => store.upsertTasks(updates),
});
store.upsertTasks([root, task]);
let observation: ReturnType<typeof store.observeTaskSettlement> | undefined;
try {
await runTransferAndWaitForOwner(task, async () => {
store.patchTask(task.id, { status: oldStatus });
return {};
}, () => false, undefined, undefined, () => batcher.discard(task.id), (value) => {
observation = value;
return true;
});
assert.ok(observation);
batcher.push({ ...task, status: oldStatus }, observation);
store.upsertTasks([{ ...task, directoryEntryIdentity: "b".repeat(64), status: replacementStatus }]);
const replacement = store.getTask(task.id);
batcher.flush();
assert.equal(store.getTask(task.id), replacement);
if (replacementStatus === "transferring") assert.equal(replacement?.directoryEntryIdentity, "b".repeat(64));
else assert.equal(replacement, undefined, "the replacement remains compacted");
assert.equal(observation.read(), undefined, "the retained observation is disposed");
} finally {
batcher.flush();
observation?.dispose();
store.patchTask(root.id, { status: "completed" });
store.dismiss(root.id);
}
});
}
}
@@ -79,7 +79,6 @@ import { localStorageAdapter } from "../../../infrastructure/persistence/localSt
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { editorTabStore } from "../editorTabStore";
import { notifyEditorWindowSessionRemap } from "../editorWindowClient";
import {
resolveUploadStreamTargetSftpId,
} from "../../../domain/sftpDedicatedStreamPolicy";
@@ -444,7 +443,6 @@ export const useSftpExternalOperations = (
if (liveConnectionId !== connectionId) {
editorTabStore.remapSessionId(connectionId, liveConnectionId);
notifyEditorWindowSessionRemap(connectionId, liveConnectionId);
}
const bridge = netcattyBridge.get();
+5 -24
View File
@@ -1,11 +1,6 @@
import type { TransferTask } from "../../../domain/models";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
/** The displaced invocation may fail its walk, but no longer owns the child row. */
export class TransferOwnerChangedError extends Error {}
export type TransferOwnerObservation = ReturnType<typeof sftpTransferCenterStore.observeTaskSettlement>;
type StreamResult = { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
/** A live waiter owns bounded settlement evidence; persisted history stays compact. */
@@ -14,47 +9,33 @@ export async function runTransferAndWaitForOwner(
start: () => Promise<StreamResult>,
shouldAbort: () => boolean,
pausedAtResume?: TransferTask,
completedAtRestart?: TransferTask,
onOwnerChanged?: () => void,
retainObservation?: (observation: TransferOwnerObservation) => boolean,
existingObservation?: TransferOwnerObservation,
): Promise<StreamResult> {
// Register before admission/start: an owner may finish while dispatch waits for resume.
let observation = existingObservation ?? sftpTransferCenterStore.observeTaskSettlement(task, completedAtRestart, onOwnerChanged);
let observation = sftpTransferCenterStore.observeTaskSettlement(task);
try {
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed before dispatch");
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume, completedAtRestart);
const admission = sftpTransferCenterStore.admitTaskRun(task, pausedAtResume);
if (admission === "cancelled") throw new Error("Transfer cancelled");
if (admission === "completed" || observation.read()?.status === "completed") return {};
if (admission === "conflict") throw new TransferOwnerChangedError("Transfer identity changed before dispatch");
if (admission === "conflict") throw new Error("Transfer identity changed before dispatch");
if (admission === "ready") break;
await new Promise((resolve) => setTimeout(resolve, 200));
}
// Admission begins a new attempt; discard any previous failed settlement.
observation.dispose();
observation = sftpTransferCenterStore.observeTaskSettlement(task, undefined, onOwnerChanged);
observation = sftpTransferCenterStore.observeTaskSettlement(task);
const result = await start();
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
if (!result?.superseded) return result;
for (;;) {
if (shouldAbort()) throw new Error("Transfer cancelled");
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
const latest = observation.read();
if (latest?.status === "completed") return { ...result, superseded: false };
if (latest?.status === "failed") throw new Error(latest.error || "Transfer failed");
if (latest?.status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} catch (error) {
// A displaced invocation can reject instead of returning superseded. Its
// transport error must not regain permission to write the winning row.
if (observation.hasIdentityConflict()) throw new TransferOwnerChangedError("Transfer identity changed while waiting for its owner");
throw error;
} finally {
// A deferred child update may outlive the invocation. Its caller must either
// retain this evidence through the write or release it after handling exit.
if (!retainObservation?.(observation)) observation.dispose();
observation.dispose();
}
}
+16 -71
View File
@@ -42,14 +42,6 @@ import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryR
import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime";
type Listener = () => void;
type TaskSettlementObserver = {
expected: TransferTask;
initialInactiveRow?: TransferTask;
settled?: TransferTask;
conflicted?: boolean;
ignoredCompletion?: TransferTask;
onIdentityConflict?: () => void;
};
// Ordinary bounded history restores synchronously so existing callers receive
// it immediately. Legacy directory snapshots can contain tens of thousands of
@@ -106,9 +98,9 @@ export interface SftpTransferCenterStore {
/** Single task row — same object identity until that row is patched. */
getTask(taskId: string): TransferTask | undefined;
/** Observe exact task settlement before completed child rows are compacted. */
observeTaskSettlement(task: TransferTask, ignoredCompletion?: TransferTask, onIdentityConflict?: () => void): { read(): TransferTask | undefined; hasIdentityConflict(): boolean; dispose(): void };
observeTaskSettlement(task: TransferTask): { read(): TransferTask | undefined; dispose(): void };
/** Publish an explicit dispatch identity without flushing large child-history batches. */
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask, completedAtRestart?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
admitTaskRun(task: TransferTask, pausedAtResume?: TransferTask): "ready" | "paused" | "completed" | "cancelled" | "conflict";
getOwnerTasks(ownerId: string): TransferTask[];
publishOwner(ownerId: string, tasks: readonly TransferTask[]): void;
registerOwner(ownerId: string, controls: SftpTransferOwnerControls): () => void;
@@ -413,7 +405,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
let snapshotDirty = false;
const listeners = new Set<Listener>();
const progressListeners = new Set<Listener>();
const settlementObservers = new Map<string, Set<TaskSettlementObserver>>();
const settlementObservers = new Map<string, Set<{ expected: TransferTask; settled?: TransferTask }>>();
const matchesObservedTask = (expected: TransferTask, candidate: TransferTask) => (
expected.id === candidate.id
&& expected.sourcePath === candidate.sourcePath
@@ -422,29 +414,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
&& expected.directoryEntryIndex === candidate.directoryEntryIndex
&& expected.directoryEntryIdentity === candidate.directoryEntryIdentity
);
const captureObservedTask = (task: TransferTask) => {
for (const observer of settlementObservers.get(task.id) ?? []) {
if (observer.conflicted || task === observer.ignoredCompletion) continue;
const matchesExpected = matchesObservedTask(observer.expected, task);
// A retry may plan a new source identity while its initial stale row is
// still paused. Only the unchanged initial attempt is exempt: activating
// it or replacing its owner/epoch must not let the stale plan reclaim it.
if (!matchesExpected && observer.initialInactiveRow
&& matchesObservedTask(observer.initialInactiveRow, task)
&& observer.initialInactiveRow.status === task.status
&& observer.initialInactiveRow.lifecycleEpoch === task.lifecycleEpoch
&& observer.initialInactiveRow.ownerId === task.ownerId) continue;
observer.initialInactiveRow = undefined;
if (!matchesExpected) {
observer.conflicted = true;
observer.onIdentityConflict?.();
} else if (observer.settled?.status !== "completed") {
// Failed/cancelled evidence belongs to the latest observed attempt. A
// resumed owner can supersede it; exact successful completion stays final.
observer.settled = TERMINAL_OWNER_STATUSES.has(task.status) ? task : undefined;
}
}
};
const refreshBadgeSnapshot = () => {
const next = buildBadgeSnapshot(tasks);
if (
@@ -664,7 +633,12 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// settles; everything else can release its whole tree here.
settleFinishedTransferControlState(tasks, tasks);
if (settlementObservers.size > 0) {
for (const task of tasks) captureObservedTask(task);
for (const task of tasks) {
if (!TERMINAL_OWNER_STATUSES.has(task.status)) continue;
for (const observer of settlementObservers.get(task.id) ?? []) {
if (!observer.settled && matchesObservedTask(observer.expected, task)) observer.settled = task;
}
}
}
const beforePrune = tasks;
tasks = pruneSftpTransferHistory(tasks);
@@ -1364,7 +1338,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
},
getSnapshot: () => ensureSnapshot(),
getBadgeSnapshot: () => badgeSnapshot,
admitTaskRun(incoming, pausedAtResume, completedAtRestart) {
admitTaskRun(incoming, pausedAtResume) {
const rootId = incoming.parentTaskId ?? incoming.id;
if (isTransferOrRootCancelled(rootId, incoming.id)) return "cancelled";
const parent = incoming.parentTaskId ? tasks.find((task) => task.id === rootId) : undefined;
@@ -1374,10 +1348,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (existing && (existing.sourcePath !== incoming.sourcePath || existing.targetPath !== incoming.targetPath
|| existing.parentTaskId !== incoming.parentTaskId)) return "conflict";
if (existing?.status === "cancelled") return "cancelled";
// Only the exact completion captured at the destination reset is
// stale. A newer completion or user control must keep its authority.
const restartsCompletion = existing?.status === "completed" && existing === completedAtRestart;
if (existing?.status === "completed" && !restartsCompletion) {
if (existing?.status === "completed") {
return matchesObservedTask(incoming, existing) ? "completed" : "conflict";
}
const resumesUnchangedPause = existing === pausedAtResume
@@ -1398,14 +1369,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
...existing,
directoryEntryIndex: incoming.directoryEntryIndex,
directoryEntryIdentity: incoming.directoryEntryIdentity,
...(restartsCompletion ? {
checkpointBytes: incoming.checkpointBytes,
transferredBytes: incoming.transferredBytes,
resumeStage: incoming.resumeStage,
downloadCheckpointBytes: incoming.downloadCheckpointBytes,
uploadCheckpointBytes: incoming.uploadCheckpointBytes,
sourceFingerprint: incoming.sourceFingerprint,
} : {}),
...(existing.status === "transferring" ? {} : {
status: "transferring" as const,
lifecycleEpoch: undefined,
@@ -1415,36 +1378,25 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
}),
};
tasks = next;
captureObservedTask(next[index]);
emitProgress(false);
return "ready";
},
observeTaskSettlement(expected, ignoredCompletion, onIdentityConflict) {
const initial = tasks.find((task) => task.id === expected.id);
const observer: TaskSettlementObserver = {
expected: { ...expected }, ignoredCompletion, onIdentityConflict,
initialInactiveRow: initial && ["paused", "failed", "interrupted", "attention"].includes(initial.status)
&& !matchesObservedTask(expected, initial) ? { ...initial } : undefined,
};
observeTaskSettlement(expected) {
const observer: { expected: TransferTask; settled?: TransferTask } = { expected: { ...expected } };
const observers = settlementObservers.get(expected.id) ?? new Set();
observers.add(observer);
settlementObservers.set(expected.id, observers);
// A different active owner may predate registration. Detect it now,
// before admission can publish the stale caller's identity over it.
if (initial) captureObservedTask(initial);
let disposed = false;
return {
read() {
if (disposed) return undefined;
if (observer.settled) return observer.settled;
const current = tasks.find((task) => task.id === expected.id);
return current && current !== ignoredCompletion && matchesObservedTask(observer.expected, current) ? current : undefined;
return current && matchesObservedTask(observer.expected, current) ? current : undefined;
},
hasIdentityConflict: () => !disposed && observer.conflicted === true,
dispose() {
disposed = true;
observer.settled = undefined;
observer.conflicted = undefined;
observers.delete(observer);
if (observers.size === 0) settlementObservers.delete(expected.id);
},
@@ -1710,7 +1662,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
const next = tasks.slice();
next[index] = nextTask;
tasks = next;
captureObservedTask(nextTask);
if (changed) {
const lifecycleChanged = (
(updates.status !== undefined && updates.status !== task.status)
@@ -2072,8 +2023,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
backgroundEventsDuringRestore.set(event.transferId, pending);
return;
}
let existingIndex = tasks.findIndex((task) => task.id === event.transferId);
let existing = tasks[existingIndex];
let existing = tasks.find((task) => task.id === event.transferId);
const eventParent = event.parentTaskId
? tasks.find((task) => task.id === event.parentTaskId)
: undefined;
@@ -2089,8 +2039,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
directoryEntryIdentity: event.directoryEntryIdentity ?? task.directoryEntryIdentity,
background: false,
} : task);
existing = tasks[existingIndex];
captureObservedTask(existing);
existing = tasks.find((task) => task.id === event.transferId);
}
// A delayed child progress event can arrive after the renderer compacted
// that completed child into its parent checkpoint. Never resurrect it as
@@ -2170,7 +2119,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if ((event.type === "queued" || event.type === "started" || event.type === "progress") && !existing) {
const sourcePath = event.sourcePath ?? "";
const targetPath = event.targetPath ?? "";
existingIndex = tasks.length;
tasks.push({
id: event.transferId,
ownerId: eventParent?.ownerId
@@ -2404,9 +2352,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
phase: undefined,
} : task);
}
// Ownership changes must be observed synchronously even when a progress
// event takes the lightweight UI path. Reuse the located row, not a scan.
if (existingIndex >= 0) captureObservedTask(tasks[existingIndex]);
// Progress-only ticks skip prune + coalesce listener paints; lifecycle
// events still take the full emit path so UI never lags on pause/cancel.
if (event.type === "progress" && !persistImmediately) {
@@ -2,15 +2,10 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
clampTerminalSidePanelHeight,
clampTerminalSidePanelWidth,
getTerminalSidePanelAvailableWidth,
getTerminalSidePanelMaxShownTools,
getTerminalSidePanelMaxWidth,
getTerminalSidePanelAvailableHeight,
getTerminalSidePanelMaxHeight,
TERMINAL_SIDE_PANEL_MAX_HEIGHT,
TERMINAL_SIDE_PANEL_MIN_HEIGHT,
TERMINAL_SIDE_PANEL_MAX_WIDTH,
TERMINAL_SIDE_PANEL_MIN_WIDTH,
} from './terminalSidePanelWidth.ts';
@@ -39,17 +34,3 @@ test('terminal side panel keeps usable terminal space in smaller windows', () =>
assert.equal(clampTerminalSidePanelWidth(280, 1000, 401), 401);
assert.equal(clampTerminalSidePanelWidth(280, 600, 401), TERMINAL_SIDE_PANEL_MIN_WIDTH);
});
test('terminal side panel bottom dock clamps height against surface height', () => {
assert.equal(getTerminalSidePanelAvailableHeight(1000, 200), 800);
assert.equal(getTerminalSidePanelMaxHeight(1000), 760);
assert.equal(clampTerminalSidePanelHeight(900, 1000), 760);
assert.equal(clampTerminalSidePanelHeight(100, 1000), TERMINAL_SIDE_PANEL_MIN_HEIGHT);
assert.equal(clampTerminalSidePanelHeight(300, 1000, 500), 500);
assert.equal(clampTerminalSidePanelHeight(5000, 4000), TERMINAL_SIDE_PANEL_MAX_HEIGHT);
// A tall compose bar in the smallest window leaves less than the nominal
// panel minimum; the terminal's 240px reserve must still win.
assert.equal(getTerminalSidePanelMaxHeight(244), 4);
assert.equal(clampTerminalSidePanelHeight(560, 244), 4);
assert.equal(clampTerminalSidePanelHeight(560, 220), 0);
});
@@ -41,37 +41,3 @@ export function getTerminalSidePanelMaxShownTools(panelWidth: number): number {
),
);
}
export const TERMINAL_SIDE_PANEL_MIN_HEIGHT = 200;
export const TERMINAL_SIDE_PANEL_MAX_HEIGHT = 1200;
export const TERMINAL_SIDE_PANEL_MIN_TERMINAL_HEIGHT = 240;
// Shared tab toolbar rendered above the pane tree (`h-9`).
export const TERMINAL_SIDE_PANEL_TOOLBAR_HEIGHT = 36;
export function getTerminalSidePanelAvailableHeight(
terminalLayerHeight: number,
occupiedSiblingHeight: number,
): number {
return Math.max(0, terminalLayerHeight - occupiedSiblingHeight);
}
export function getTerminalSidePanelMaxHeight(availableSurfaceHeight: number): number {
const availableHeight = availableSurfaceHeight - TERMINAL_SIDE_PANEL_MIN_TERMINAL_HEIGHT;
return Math.max(
0,
Math.min(TERMINAL_SIDE_PANEL_MAX_HEIGHT, availableHeight),
);
}
export function clampTerminalSidePanelHeight(
height: number,
availableSurfaceHeight: number,
contentMinimumHeight = TERMINAL_SIDE_PANEL_MIN_HEIGHT,
): number {
const maximumHeight = getTerminalSidePanelMaxHeight(availableSurfaceHeight);
const minimumHeight = Math.min(
maximumHeight,
Math.max(TERMINAL_SIDE_PANEL_MIN_HEIGHT, contentMinimumHeight),
);
return Math.max(minimumHeight, Math.min(maximumHeight, height));
}
-74
View File
@@ -1,74 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { editorTabStore } from "./editorTabStore";
import type { EditorWindowTabSnapshot } from "./editorWindowTypes";
import type { EditorTabId } from "./editorTabStore";
export function useEditorWindowRuntime() {
const [activeTabId, setActiveTabId] = useState<EditorTabId | null>(
() => editorTabStore.getTabs()[editorTabStore.getTabs().length - 1]?.id ?? null,
);
const closeWindow = useCallback(async () => {
await netcattyBridge.get()?.windowClose?.();
}, []);
const setWindowTitle = useCallback(async (title: string) => {
await netcattyBridge.get()?.setWindowTitle?.(title);
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onEditorWindowOpenTab) return () => {};
return bridge.onEditorWindowOpenTab((snapshot: EditorWindowTabSnapshot) => {
const existing = editorTabStore.getTab(snapshot.editorId)
?? editorTabStore.getTabs().find((tab) => (
tab.sessionId === snapshot.sessionId && tab.remotePath === snapshot.remotePath
));
if (existing) {
if (existing.sessionId !== snapshot.sessionId) {
editorTabStore.remapSessionId(existing.sessionId, snapshot.sessionId);
}
if (existing.sftpTabId !== snapshot.sftpTabId || existing.hostLabel !== snapshot.hostLabel) {
editorTabStore.upsertFromSnapshot({
...snapshot,
content: existing.content,
baselineContent: existing.baselineContent,
viewState: existing.viewState,
wordWrap: existing.wordWrap,
languageId: existing.languageId,
}, "tab");
}
setActiveTabId(existing.id);
return;
}
const id = editorTabStore.upsertFromSnapshot(snapshot, "tab");
setActiveTabId(id);
});
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onEditorWindowActivateTab) return () => {};
return bridge.onEditorWindowActivateTab((payload) => {
if (payload?.editorId && editorTabStore.getTab(payload.editorId)) {
setActiveTabId(payload.editorId);
}
});
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onEditorWindowRemapSession) return () => {};
return bridge.onEditorWindowRemapSession((payload) => {
editorTabStore.remapSessionId(payload.fromSessionId, payload.toSessionId);
});
}, []);
return {
activeTabId,
setActiveTabId,
closeWindow,
setWindowTitle,
};
}
-155
View File
@@ -352,158 +352,3 @@ test("manual stop still returns its recording and permits a clean restart", asyn
assert.equal(recorder!.isRecording, true);
await act(async () => renderer!.unmount());
});
test("confirmed line-by-line recording awaits each send and prompt step in order", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
let appendGate: Promise<void> | undefined;
let stopCalls = 0;
const steps: Array<{ type: string; value?: unknown; sensitive?: boolean }> = [];
const eventTarget = new EventTarget();
Object.assign(eventTarget, {
netcatty: {
scriptRecordingStart: async () => ({ ok: true }),
scriptRecordingStop: async () => { stopCalls++; return { steps: [...steps], code: "" }; },
scriptRecordingAppendStep: async (_id: string, step: typeof steps[number]) => {
await appendGate;
await new Promise(resolve => setImmediate(resolve));
steps.push(step);
return { stopped: false };
},
},
setInterval, clearInterval,
});
Object.defineProperty(globalThis, "window", { configurable: true, value: eventTarget });
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
let recorder!: Recorder;
let renderer!: ReactTestRenderer;
function Probe() { recorder = useScriptRecorder("session-1"); return null; }
await act(async () => { renderer = create(React.createElement(Probe)); });
try {
await act(async () => { await recorder.startRecording(); });
await act(async () => {
recorder.recordInput("echo ");
const recordLine = recorder.captureSubmittedLineRecorder()!;
recorder.recordInput("new input");
await Promise.all([recordLine("echo first"), recordLine("cd /tmp")]);
});
assert.deepEqual(steps.map(step => [step.type, step.value]), [
["send", "echo first"], ["waitForPrompt", undefined],
["send", "cd /tmp"], ["waitForPrompt", undefined],
]);
steps.length = 0;
await act(async () => {
await recorder.recordEnter();
assert.equal(steps[0].value, "new input");
steps.length = 0;
const recordLine = recorder.captureSubmittedLineRecorder()!;
await Promise.all([recordLine("secret", { sensitive: true }), recordLine("second secret", { sensitive: true })]);
});
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.sensitive), [true, true]);
const staleRecorder = recorder.captureSubmittedLineRecorder()!;
await act(async () => { await recorder.stopRecording(); await recorder.startRecording(); });
steps.length = 0;
await act(async () => { await staleRecorder("never sent in this recording"); });
assert.deepEqual(steps, []);
const pauseGate = deferred<void>();
appendGate = pauseGate.promise;
const beforePause = recorder.captureSubmittedLineRecorder()!;
const pausedFirst = beforePause("sent before pause 1");
const pausedSecond = beforePause("sent before pause 2");
await flushReact();
act(() => recorder.pauseRecording());
await beforePause("ignored while paused");
pauseGate.resolve();
await act(async () => { await Promise.all([pausedFirst, pausedSecond]); });
assert.deepEqual(steps.map(step => [step.type, step.value]), [
["send", "sent before pause 1"], ["waitForPrompt", undefined],
["send", "sent before pause 2"], ["waitForPrompt", undefined],
]);
act(() => recorder.resumeRecording());
steps.length = 0;
const gate = deferred<void>();
appendGate = gate.promise;
const recordLine = recorder.captureSubmittedLineRecorder()!;
const first = recordLine("sent first");
const second = recordLine("sent second");
await flushReact();
let stopped!: ReturnType<Recorder["stopRecording"]>;
act(() => { stopped = recorder.stopRecording(); });
assert.equal(stopCalls, 1);
await recordLine("arrived after stop");
gate.resolve();
await act(async () => { await Promise.all([first, second, stopped]); });
assert.equal(stopCalls, 2);
assert.deepEqual(steps.map(step => [step.type, step.value]), [
["send", "sent first"], ["waitForPrompt", undefined],
["send", "sent second"], ["waitForPrompt", undefined],
]);
} finally {
await act(async () => renderer.unmount());
}
});
test("paced recording uses its captured prefix and never imports text typed while paused", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const steps: Array<{ type: string; value?: unknown }> = [];
const target = new EventTarget();
Object.assign(target, {
netcatty: {
scriptRecordingStart: async () => ({ ok: true }),
scriptRecordingAppendStep: async (_id: string, step: typeof steps[number]) => {
steps.push(step);
return { stopped: false };
},
}, setInterval, clearInterval,
});
Object.defineProperty(globalThis, "window", { configurable: true, value: target });
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
let recorder!: Recorder;
let renderer!: ReactTestRenderer;
function Probe() { recorder = useScriptRecorder("s"); return null; }
await act(async () => { renderer = create(React.createElement(Probe)); });
try {
await act(async () => {
await recorder.startRecording();
recorder.recordInput("echo ");
recorder.pauseRecording();
recorder.recordInput("PAUSED_");
recorder.resumeRecording();
const lineRecorder = recorder.captureSubmittedLineRecorder()!;
recorder.recordInput("next input");
await lineRecorder("first", { includePendingInput: true });
await lineRecorder("second");
await recorder.recordEnter();
});
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.value), ["echo first", "second", "next input"]);
steps.length = 0;
await act(async () => {
recorder.recordInput("preserved ");
recorder.captureSubmittedLineRecorder(); // No write receipt: rejected batch.
recorder.recordInput("draft");
await recorder.recordEnter();
assert.equal(steps.find(step => step.type === "send")?.value, "preserved draft");
steps.length = 0;
recorder.recordInput("old ");
const lateReceipt = recorder.captureSubmittedLineRecorder()!;
recorder.recordClearLine();
recorder.recordInput("old new draft");
await lateReceipt("sent", { includePendingInput: true });
await recorder.recordEnter();
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.value), ["old sent", "old new draft"]);
steps.length = 0;
recorder.recordInput("prefix for a dropped first line");
const lineRecorder = recorder.captureSubmittedLineRecorder()!;
await lineRecorder("second was sent", { consumePendingInput: false });
await recorder.recordEnter();
});
assert.deepEqual(steps.filter(step => step.type === "send").map(step => step.value), ["second was sent", "prefix for a dropped first line"]);
} finally {
await act(async () => renderer.unmount());
}
});
+20 -67
View File
@@ -27,16 +27,12 @@ export function useScriptRecorder(sessionId: string | undefined) {
const [elapsedMs, setElapsedMs] = useState(0);
const startedAtRef = useRef<number | null>(null);
const inputBufferRef = useRef('');
const inputRevisionRef = useRef(0);
const lastStepAtRef = useRef<number>(Date.now());
const isRecordingRef = useRef(false);
const isPausedRef = useRef(false);
const isStoppingRef = useRef(false);
const stopPromiseRef = useRef<Promise<ScriptRecordingResult> | null>(null);
const sessionIdRef = useRef(sessionId);
const recordingGenerationRef = useRef(0);
const pendingRecordStepsRef = useRef(Promise.resolve());
const isDrainingRecordingRef = useRef(false);
sessionIdRef.current = sessionId;
@@ -64,10 +60,8 @@ export function useScriptRecorder(sessionId: string | undefined) {
const bridge = netcattyBridge.get();
if (!sid || !bridge?.scriptRecordingStart) return;
await bridge.scriptRecordingStart(sid);
recordingGenerationRef.current += 1;
startedAtRef.current = Date.now();
lastStepAtRef.current = Date.now();
inputRevisionRef.current += 1;
inputBufferRef.current = '';
setElapsedMs(0);
setIsPaused(false);
@@ -82,26 +76,27 @@ export function useScriptRecorder(sessionId: string | undefined) {
const sid = sessionIdRef.current;
const bridge = netcattyBridge.get();
isStoppingRef.current = true;
isDrainingRecordingRef.current = true;
isRecordingRef.current = false;
isPausedRef.current = false;
setIsRecording(false);
setIsPaused(false);
inputRevisionRef.current += 1;
inputBufferRef.current = '';
startedAtRef.current = null;
if (!sid || !bridge?.scriptRecordingStop) {
isStoppingRef.current = false;
isDrainingRecordingRef.current = false;
return emptyScriptRecordingResult();
}
const stopRequest = pendingRecordStepsRef.current.catch(() => {}).then(() => bridge.scriptRecordingStop!(sid));
let stopRequest: Promise<ScriptRecordingResult>;
try {
stopRequest = Promise.resolve(bridge.scriptRecordingStop(sid));
} catch (error) {
stopRequest = Promise.reject(error);
}
let stopPromise: Promise<ScriptRecordingResult>;
stopPromise = stopRequest.finally(() => {
if (stopPromiseRef.current === stopPromise) {
stopPromiseRef.current = null;
isStoppingRef.current = false;
isDrainingRecordingRef.current = false;
}
});
stopPromiseRef.current = stopPromise;
@@ -109,10 +104,8 @@ export function useScriptRecorder(sessionId: string | undefined) {
}, []);
const finishAutomaticStop = useCallback((detail: ScriptRecordingLimitDetail) => {
isDrainingRecordingRef.current = false;
isRecordingRef.current = false;
isPausedRef.current = false;
inputRevisionRef.current += 1;
inputBufferRef.current = '';
startedAtRef.current = null;
setIsRecording(false);
@@ -133,13 +126,10 @@ export function useScriptRecorder(sessionId: string | undefined) {
setIsPaused(false);
}, []);
const appendStep = useCallback(async (step: ScriptRecordingStep, accepted = false) => {
const appendStep = useCallback(async (step: ScriptRecordingStep) => {
const sid = sessionIdRef.current;
if (!sid || (!(accepted && isDrainingRecordingRef.current)
&& (!isRecordingRef.current || (!accepted && isPausedRef.current) || isStoppingRef.current))) return;
const generation = recordingGenerationRef.current;
if (!sid || !isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
const result = await netcattyBridge.get()?.scriptRecordingAppendStep?.(sid, step);
if (generation !== recordingGenerationRef.current || sid !== sessionIdRef.current) return;
if (result?.stopped) {
finishAutomaticStop({
sessionId: sid,
@@ -164,7 +154,6 @@ export function useScriptRecorder(sessionId: string | undefined) {
isStoppingRef.current = true;
isRecordingRef.current = false;
isPausedRef.current = false;
inputRevisionRef.current += 1;
inputBufferRef.current = '';
startedAtRef.current = null;
setIsRecording(false);
@@ -199,68 +188,33 @@ export function useScriptRecorder(sessionId: string | undefined) {
const recordBackspace = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
inputRevisionRef.current += 1;
inputBufferRef.current = inputBufferRef.current.slice(0, -1);
}, []);
const recordClearLine = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
inputRevisionRef.current += 1;
inputBufferRef.current = '';
}, []);
const recordEnter = useCallback(async (options?: { sensitive?: boolean; submittedLine?: string }) => {
const recordEnter = useCallback(async (options?: { sensitive?: boolean }) => {
const sid = sessionIdRef.current;
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current || !sid) return;
const line = options?.submittedLine ?? inputBufferRef.current;
if (options?.submittedLine === undefined) {
inputRevisionRef.current += 1;
inputBufferRef.current = '';
}
const line = inputBufferRef.current;
inputBufferRef.current = '';
const now = Date.now();
const gap = now - lastStepAtRef.current;
lastStepAtRef.current = now;
const generation = recordingGenerationRef.current;
const pending = pendingRecordStepsRef.current.catch(() => {}).then(async () => {
const isCurrent = () => generation === recordingGenerationRef.current && sid === sessionIdRef.current
&& (isRecordingRef.current || isDrainingRecordingRef.current);
if (!isCurrent()) return;
if (gap > 1000) await appendStep({ type: 'sleep', value: gap }, true);
if (!isCurrent()) return;
await appendStep({ type: 'send', value: line, sensitive: options?.sensitive }, true);
if (!isCurrent()) return;
await appendStep({ type: 'waitForPrompt', timeoutMs: DEFAULT_RECORDING_PROMPT_TIMEOUT_MS }, true);
if (gap > 1000) {
await appendStep({ type: 'sleep', value: gap });
}
await appendStep({
type: 'send',
value: line,
sensitive: options?.sensitive,
});
pendingRecordStepsRef.current = pending;
await pending;
await appendStep({ type: 'waitForPrompt', timeoutMs: DEFAULT_RECORDING_PROMPT_TIMEOUT_MS });
lastStepAtRef.current = now;
}, [appendStep]);
// Delayed paste receipts must not consume newly typed input or leak into a
// recording started after the paste. Each receipt records one actual write.
const captureSubmittedLineRecorder = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return undefined;
const generation = recordingGenerationRef.current;
const sid = sessionIdRef.current;
const pendingInput = inputBufferRef.current;
const revision = inputRevisionRef.current;
let consumed = false;
const isCurrent = () => generation === recordingGenerationRef.current
&& sid === sessionIdRef.current && isRecordingRef.current
&& !isPausedRef.current && !isStoppingRef.current;
return (line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }): Promise<void> => {
// Capturing a batch is not a write. Retain its prefix if no receipt
// arrives; only its prefixed first line can consume the still-owned draft.
if (options?.consumePendingInput !== false && !consumed && generation === recordingGenerationRef.current && sid === sessionIdRef.current
&& revision === inputRevisionRef.current && inputBufferRef.current.startsWith(pendingInput)) {
inputBufferRef.current = inputBufferRef.current.slice(pendingInput.length);
inputRevisionRef.current += 1;
}
if (options?.consumePendingInput !== false) consumed = true;
if (!isCurrent()) return Promise.resolve();
return recordEnter({ sensitive: options?.sensitive, submittedLine: options?.includePendingInput ? `${pendingInput}${line}` : line });
};
}, [recordEnter]);
return {
isRecording,
isPaused,
@@ -273,6 +227,5 @@ export function useScriptRecorder(sessionId: string | undefined) {
recordBackspace,
recordClearLine,
recordEnter,
captureSubmittedLineRecorder,
};
}
-12
View File
@@ -33,10 +33,6 @@ export interface ServerStats {
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
gpu: number | null; // NVIDIA GPU utilization percentage (0-100), null when no GPU data
gpuName: string | null; // First GPU name (tooltip)
gpuMemUsed: number | null; // Summed VRAM used in MB
gpuMemTotal: number | null; // Summed VRAM total in MB
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB
@@ -111,10 +107,6 @@ function createEmptyServerStats(): ServerStats {
cpu: null,
cpuCores: null,
cpuPerCore: [],
gpu: null,
gpuName: null,
gpuMemUsed: null,
gpuMemTotal: null,
memTotal: null,
memUsed: null,
memFree: null,
@@ -208,10 +200,6 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
cpu: stats.cpu ?? null,
cpuCores: stats.cpuCores ?? null,
cpuPerCore: stats.cpuPerCore || [],
gpu: stats.gpu ?? null,
gpuName: stats.gpuName ?? null,
gpuMemUsed: stats.gpuMemUsed ?? null,
gpuMemTotal: stats.gpuMemTotal ?? null,
memTotal: stats.memTotal ?? null,
memUsed: stats.memUsed ?? null,
memFree: stats.memFree ?? null,
+3 -65
View File
@@ -79,13 +79,10 @@ 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,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
@@ -132,7 +129,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,
@@ -426,10 +422,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
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;
@@ -442,14 +434,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
return isTerminalSidePanelAutoOpenTab(stored) ? stored : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
});
const [localShellSidePanelAutoOpen, setLocalShellSidePanelAutoOpenState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN);
return stored ?? false;
});
const [localShellSidePanelAutoOpenTab, setLocalShellSidePanelAutoOpenTabState] = useState<TerminalSidePanelAutoOpenTab>(() => {
const stored = readStoredString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB);
return isTerminalSidePanelAutoOpenTab(stored) ? stored : 'scripts';
});
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
@@ -1036,8 +1020,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
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);
@@ -1048,14 +1030,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
? storedTerminalSidePanelAutoOpenTab
: DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
);
const storedLocalShellSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN);
setLocalShellSidePanelAutoOpenState(storedLocalShellSidePanelAutoOpen ?? false);
const storedLocalShellSidePanelAutoOpenTab = readStoredString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB);
setLocalShellSidePanelAutoOpenTabState(
isTerminalSidePanelAutoOpenTab(storedLocalShellSidePanelAutoOpenTab)
? storedLocalShellSidePanelAutoOpenTab
: 'scripts',
);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedShowTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
@@ -1234,8 +1208,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setLocalShellSidePanelAutoOpenState,
setLocalShellSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -1289,7 +1261,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, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, 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 +1271,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, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, 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,
@@ -1444,13 +1416,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);
@@ -1473,21 +1438,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
}, [notifySettingsChanged]);
const setLocalShellSidePanelAutoOpen = useCallback((enabled: boolean) => {
setLocalShellSidePanelAutoOpenState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, enabled);
}, [notifySettingsChanged]);
const setLocalShellSidePanelAutoOpenTab = useCallback((tab: TerminalSidePanelAutoOpenTab) => {
const next = isTerminalSidePanelAutoOpenTab(tab) ? tab : 'scripts';
setLocalShellSidePanelAutoOpenTabState(next);
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, next);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, next);
}, [notifySettingsChanged]);
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
setShellOnlyTabNumberShortcutsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
@@ -1932,7 +1882,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
windowOpacity,
tabBarPosition,
showSftpTab,
sftpInSidebar,
showHostTreeSidebar,
showRecentHosts,
hostClickBehavior,
@@ -1945,8 +1894,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
localShellSidePanelAutoOpen,
localShellSidePanelAutoOpenTab,
});
}, [
darkUiThemeId,
@@ -1962,7 +1909,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
showRecentHosts,
tabBarPosition,
showSftpTab,
sftpInSidebar,
showTabNumberBadges,
terminalSettings.dynamicTabTitleMode,
terminalSidePanelAutoOpen,
@@ -1970,8 +1916,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
theme,
uiLanguage,
windowOpacity,
localShellSidePanelAutoOpen,
localShellSidePanelAutoOpenTab,
]);
useLayoutEffect(() => {
@@ -2140,18 +2084,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
showSftpTab,
setTabBarPosition,
setShowSftpTab,
sftpInSidebar,
setSftpInSidebar,
showHostTreeSidebar,
setShowHostTreeSidebar,
terminalSidePanelAutoOpen,
setTerminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
setTerminalSidePanelAutoOpenTab,
localShellSidePanelAutoOpen,
setLocalShellSidePanelAutoOpen,
localShellSidePanelAutoOpenTab,
setLocalShellSidePanelAutoOpenTab,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
showTabNumberBadges,
@@ -2220,7 +2158,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, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
};
+3 -11
View File
@@ -202,19 +202,13 @@ export const useTerminalBackend = () => {
bridge?.writeToSession?.(sessionId, data, options);
}, []);
const notifyUserInput = useCallback((sessionId: string) => {
const bridge = netcattyBridge.get();
bridge?.notifySessionUserInput?.(sessionId);
}, []);
const interruptSession = useCallback((...args: Parameters<NonNullable<NetcattyBridge["interruptSession"]>>) => {
const [sessionId, , options] = args;
const interruptSession = useCallback((sessionId: string, trace?: NetcattyTerminalInterruptTrace) => {
const bridge = netcattyBridge.get();
if (bridge?.interruptSession) {
bridge.interruptSession(...args);
bridge.interruptSession(sessionId, trace);
return;
}
if (!options?.cancelPendingWritesOnly) bridge?.writeToSession?.(sessionId, "\x03");
bridge?.writeToSession?.(sessionId, "\x03");
}, []);
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
@@ -599,7 +593,6 @@ export const useTerminalBackend = () => {
getSessionDistroInfo,
getServerStats,
writeToSession,
notifyUserInput,
interruptSession,
resizeSession,
clearSessionPtyBuffer,
@@ -682,7 +675,6 @@ export const useTerminalBackend = () => {
getSessionDistroInfo,
getServerStats,
writeToSession,
notifyUserInput,
interruptSession,
resizeSession,
clearSessionPtyBuffer,
@@ -4,7 +4,7 @@ import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import type { GroupConfig, ManagedSource } from "../../domain/models.ts";
import { useVaultGroupDeletion, VaultGroupDeletionConfirmationChangedError } from "./useVaultGroupDeletion.ts";
import { useVaultGroupDeletion } from "./useVaultGroupDeletion.ts";
test("group deletion removes saved settings in the same Vault transaction", async () => {
const actEnvironment = globalThis as typeof globalThis & {
@@ -132,7 +132,7 @@ test("group deletion restores managed files when the persisted Vault cannot be r
assert.equal(commitCount, 0);
});
test("group deletion rejects retry when another managed source enters the group", async () => {
test("group deletion retries file clearing when another managed source enters the group", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
@@ -198,7 +198,7 @@ test("group deletion rejects retry when another managed source enters the group"
renderer = create(React.createElement(Probe));
});
await act(async () => {
await assert.rejects(deleteGroups!(["Production"]), VaultGroupDeletionConfirmationChangedError);
await deleteGroups?.(["Production"]);
});
} finally {
await act(async () => {
@@ -209,9 +209,10 @@ test("group deletion rejects retry when another managed source enters the group"
assert.deepEqual(clearedSourceIds, [
["managed-first"],
["managed-concurrent", "managed-first"],
]);
assert.equal(restoreCount, 1);
assert.equal(commitCount, 0);
assert.equal(commitCount, 1);
});
test("group deletion restores managed files and does not report success when commit fails", async () => {
@@ -277,209 +278,3 @@ test("group deletion restores managed files and does not report success when com
assert.equal(restoreCount, 1);
assert.equal(reportedCount, 0);
});
const confirmedSource: ManagedSource = {
id: "managed-production",
type: "ssh_config",
filePath: "/tmp/managed-production.conf",
groupName: "Production",
lastSyncedAt: 1,
};
async function withDeletionProbe(
options: Partial<Parameters<typeof useVaultGroupDeletion>[0]>,
run: (getDelete: () => ReturnType<typeof useVaultGroupDeletion>, rerender: (sources: ManagedSource[]) => Promise<void>) => Promise<void>,
) {
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer | undefined;
let deleteGroups!: ReturnType<typeof useVaultGroupDeletion>;
const defaults: Parameters<typeof useVaultGroupDeletion>[0] = {
customGroups: ["Production"], hosts: [], groupConfigs: [],
managedSources: [confirmedSource],
onReadPersistedHosts: async () => [],
onReadPersistedManagedSources: () => [confirmedSource],
onCommitVaultGroupMutation: async (mutate) => mutate({
groups: ["Production"], configs: [], hosts: [],
managedSources: [confirmedSource], snippets: [],
}),
...options,
};
const Probe = ({ sources }: { sources: ManagedSource[] }) => {
deleteGroups = useVaultGroupDeletion({ ...defaults, managedSources: sources });
return null;
};
try {
await act(async () => {
renderer = create(<Probe sources={defaults.managedSources} />);
});
await run(() => deleteGroups, async (sources) => {
await act(async () => { renderer!.update(<Probe sources={sources} />); });
});
} finally {
await act(async () => { renderer?.unmount(); });
environment.IS_REACT_ACT_ENVIRONMENT = previous;
}
}
for (const [name, rendered, persisted] of [
["changed file path", [confirmedSource], [{ ...confirmedSource, filePath: "/tmp/other.conf" }]],
["changed source id", [confirmedSource], [{ ...confirmedSource, id: "replacement" }]],
["added source", [confirmedSource], [confirmedSource, { ...confirmedSource, id: "added", filePath: "/tmp/added.conf" }]],
["first source", [], [confirmedSource]],
["source moved up within selection", [{ ...confirmedSource, groupName: "Production/Sub" }], [confirmedSource]],
["source moved into selection", [{ ...confirmedSource, groupName: "Staging" }], [confirmedSource]],
] as const) {
test(`group deletion rejects initially persisted ${name} before clearing files`, async () => {
let clearCount = 0;
let commitCount = 0;
let reportedCount = 0;
await withDeletionProbe({
managedSources: [...rendered],
onReadPersistedManagedSources: () => [...persisted],
onClearAndRemoveManagedSources: async () => {
clearCount += 1;
return async () => {};
},
onCommitVaultGroupMutation: async () => {
commitCount += 1;
throw new Error("unexpected commit");
},
onDeletedPaths: () => { reportedCount += 1; },
}, async (getDelete) => {
// A failed attempt refreshes latestRef without refreshing the warning.
for (let attempt = 0; attempt < 2; attempt += 1) {
await assert.rejects(getDelete()(["Production"]), VaultGroupDeletionConfirmationChangedError);
}
});
assert.equal(clearCount, 0);
assert.equal(commitCount, 0);
assert.equal(reportedCount, 0);
});
}
test("group deletion keeps old callbacks bound to their rendered confirmation", async () => {
const changed = { ...confirmedSource, filePath: "/tmp/changed.conf" };
const cleared: string[] = [];
await withDeletionProbe({
onReadPersistedManagedSources: () => [changed],
onClearAndRemoveManagedSources: async (sources) => {
cleared.push(...sources.map((source) => source.filePath));
return async () => {};
},
onCommitVaultGroupMutation: async (mutate) => mutate({
groups: ["Production"], configs: [], hosts: [], managedSources: [changed], snippets: [],
}),
}, async (getDelete, rerender) => {
const staleDelete = getDelete();
await rerender([changed]);
assert.notEqual(getDelete(), staleDelete);
await assert.rejects(staleDelete(["Production"]), VaultGroupDeletionConfirmationChangedError);
assert.deepEqual(cleared, []);
await getDelete()(["Production"]);
});
assert.deepEqual(cleared, [changed.filePath]);
});
for (const retry of ["snapshot", "commit", "superseded"] as const) {
test(`group deletion retains safe ${retry} retries for the same confirmed file`, async () => {
const updated = { ...confirmedSource, lastSyncedAt: 2 };
let reads = 0;
let commits = 0;
let restores = 0;
let reported = 0;
const cleared: string[] = [];
await withDeletionProbe({
onReadPersistedManagedSources: () => {
reads += 1;
return [retry === "snapshot" && reads > 1 ? updated : confirmedSource];
},
onClearAndRemoveManagedSources: async (sources) => {
cleared.push(...sources.map((source) => source.filePath));
return async () => { restores += 1; };
},
onCommitVaultGroupMutation: async (mutate) => {
commits += 1;
if (retry === "superseded" && commits === 1) return { ok: false, superseded: true };
return mutate({
groups: ["Production"], configs: [], hosts: [], snippets: [],
managedSources: [retry === "snapshot" || (retry === "commit" && commits === 1) ? updated : confirmedSource],
});
},
onDeletedPaths: () => { reported += 1; },
}, async (getDelete) => { await getDelete()(["Production"]); });
assert.deepEqual(cleared, [confirmedSource.filePath, confirmedSource.filePath]);
assert.equal(restores, 1);
assert.equal(commits, retry === "snapshot" ? 1 : 2);
assert.equal(reported, 1);
});
}
for (const retry of ["commit", "superseded"] as const) {
test(`group deletion rejects new files after a ${retry} retry`, async () => {
const added = { ...confirmedSource, id: "added", filePath: "/tmp/added.conf" };
let persisted = [confirmedSource];
let commits = 0;
let restores = 0;
let reported = 0;
const cleared: string[] = [];
await withDeletionProbe({
onReadPersistedManagedSources: () => persisted,
onClearAndRemoveManagedSources: async (sources) => {
cleared.push(...sources.map((source) => source.filePath));
return async () => { restores += 1; };
},
onCommitVaultGroupMutation: async (mutate) => {
commits += 1;
persisted = [confirmedSource, added];
if (retry === "superseded") return { ok: false, superseded: true };
return mutate({
groups: ["Production"], configs: [], hosts: [], snippets: [], managedSources: persisted,
});
},
onDeletedPaths: () => { reported += 1; },
}, async (getDelete) => {
await assert.rejects(getDelete()(["Production"]), VaultGroupDeletionConfirmationChangedError);
});
assert.deepEqual(cleared, [confirmedSource.filePath]);
assert.equal(restores, 1);
assert.equal(commits, 1);
assert.equal(reported, 0);
});
}
for (const phase of ["snapshot", "commit"] as const) {
test(`group deletion rejects a managed subgroup moved to its parent during ${phase}`, async () => {
const subgroup = { ...confirmedSource, groupName: "Production/Sub" };
let persisted = [subgroup];
let reads = 0;
let restores = 0;
let commits = 0;
const cleared: string[] = [];
await withDeletionProbe({
managedSources: [subgroup],
onReadPersistedManagedSources: () => {
if (phase === "snapshot" && ++reads > 1) persisted = [confirmedSource];
return persisted;
},
onClearAndRemoveManagedSources: async (sources) => {
cleared.push(...sources.map((source) => source.groupName));
return async () => { restores += 1; };
},
onCommitVaultGroupMutation: async (mutate) => {
commits += 1;
persisted = [confirmedSource];
return mutate({
groups: ["Production", "Production/Sub"], configs: [], hosts: [],
managedSources: persisted, snippets: [],
});
},
}, async (getDelete) => {
await assert.rejects(getDelete()(["Production"]), VaultGroupDeletionConfirmationChangedError);
});
assert.deepEqual(cleared, ["Production/Sub"]);
assert.equal(restores, 1);
assert.equal(commits, phase === "snapshot" ? 0 : 1);
});
}
@@ -14,17 +14,6 @@ import {
const RETRY_VAULT_GROUP_DELETION = Symbol("retry-vault-group-deletion");
export class VaultGroupDeletionConfirmationChangedError extends Error {
constructor() {
super("The managed files affected by this deletion have changed. Review the files and confirm again.");
this.name = "VaultGroupDeletionConfirmationChangedError";
}
}
const managedSourceDeletionIdentity = (source: ManagedSource): string => (
JSON.stringify([source.id, source.type, source.filePath, source.groupName])
);
const managedSourceSnapshotsMatch = (
left: ManagedSource[],
right: ManagedSource[],
@@ -70,14 +59,6 @@ export function useVaultGroupDeletion({
additionallyDeletedHostIds: ReadonlySet<string> = new Set(),
) => {
const selectedPaths = [...paths];
// Bind confirmation to the sources rendered with this callback, not the
// latest persisted snapshot (which reads and retries update independently).
const confirmedFiles = new Set(buildVaultGroupDeletion({
...latestRef.current,
selectedPaths,
deleteHosts,
managedSources,
}).sourcesToRemove.map(managedSourceDeletionIdentity));
let deletedRoots: string[] = [];
while (true) {
let restoreManagedFiles: (() => Promise<void>) | undefined;
@@ -101,12 +82,6 @@ export function useVaultGroupDeletion({
return { status: "empty" as const };
}
if (deletion.sourcesToRemove.some((source) => (
!confirmedFiles.has(managedSourceDeletionIdentity(source))
))) {
throw new VaultGroupDeletionConfirmationChangedError();
}
if (deletion.sourcesToRemove.length > 0) {
if (onClearAndRemoveManagedSources) {
restoreManagedFiles = await onClearAndRemoveManagedSources(deletion.sourcesToRemove);
@@ -219,7 +194,6 @@ export function useVaultGroupDeletion({
}
onDeletedPaths?.(deletedRoots);
}, [
managedSources,
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onCommitVaultGroupMutation,
-1
View File
@@ -186,7 +186,6 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
id,
label,
type: key.type || "ED25519",
keySize: key.keySize,
privateKey: key.privateKey || "",
publicKey: key.publicKey,
certificate: key.certificate,
@@ -1,145 +0,0 @@
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 {
collectSidePanelPanes,
createSidePanelLayout,
splitSidePanelPane,
type SidePanelLayout,
} from '../../domain/sidePanelLayout.ts';
import { STORAGE_KEY_WORKSPACE_LAYOUT_PRESET } from '../../infrastructure/config/storageKeys.ts';
import { useWorkspaceLayoutPresetState } from './useWorkspaceLayoutPresetState.ts';
function buildSplitLayout(): SidePanelLayout {
let layout = createSidePanelLayout('scripts', 'pane-scripts');
layout = splitSidePanelPane(layout, 'pane-scripts', 'sftp', 'horizontal', {
paneId: 'pane-sftp',
splitId: 'split-root',
}, 400);
return layout;
}
test('saved default resolves per session with fresh ids and protocol-aware pruning', async () => {
const values = new Map<string, string>();
const fakeStorage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value); },
removeItem: (key: string) => { values.delete(key); },
};
const globals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
localStorage?: typeof fakeStorage;
};
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
globals.IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, 'localStorage', {
value: fakeStorage,
configurable: true,
});
let state: ReturnType<typeof useWorkspaceLayoutPresetState> | null = null;
const Probe = () => {
state = useWorkspaceLayoutPresetState();
return null;
};
try {
await act(async () => {
create(React.createElement(Probe));
});
assert.ok(state);
// Nothing saved yet: no preset to apply.
assert.equal(state.resolveDefaultLayoutForSession({ protocol: 'ssh' }), null);
let saved = false;
await act(async () => {
saved = state?.saveWorkspaceLayoutAsDefault(buildSplitLayout()) ?? false;
});
assert.equal(saved, true);
assert.ok(values.has(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET));
// SSH sessions keep their SFTP pane; ids are rekeyed per application.
const sshResolved = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
assert.ok(sshResolved);
assert.equal(sshResolved.protocol, 'ssh');
assert.deepEqual(
collectSidePanelPanes(sshResolved.layout.root).map((pane) => pane.tool),
['scripts', 'sftp'],
);
assert.notEqual(sshResolved.layout.root.id, buildSplitLayout().root.id);
assert.equal(sshResolved.focusedTool, 'sftp');
// Protocols that cannot serve SFTP lose that pane.
const telnetResolved = state.resolveDefaultLayoutForSession({ protocol: 'telnet' });
assert.ok(telnetResolved);
assert.deepEqual(
collectSidePanelPanes(telnetResolved.layout.root).map((pane) => pane.tool),
['scripts'],
);
assert.equal(telnetResolved.focusedTool, 'scripts');
// A second resolve rekeys again so parallel workspaces share no node ids.
const again = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
assert.ok(again);
assert.notEqual(again.layout.root.id, sshResolved.layout.root.id);
} finally {
if (previousLocalStorage) {
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('saving falls back to false when persistence rejects the write', async () => {
const fakeStorage = {
getItem: () => null,
setItem: () => {
const quotaError = new DOMException('full', 'QuotaExceededError');
throw quotaError;
},
removeItem: () => undefined,
};
const globals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
localStorage?: typeof fakeStorage;
};
const previousActEnvironment = globals.IS_REACT_ACT_ENVIRONMENT;
const previousLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
globals.IS_REACT_ACT_ENVIRONMENT = true;
Object.defineProperty(globalThis, 'localStorage', {
value: fakeStorage,
configurable: true,
});
let state: ReturnType<typeof useWorkspaceLayoutPresetState> | null = null;
const Probe = () => {
state = useWorkspaceLayoutPresetState();
return null;
};
try {
await act(async () => {
create(React.createElement(Probe));
});
assert.ok(state);
let saved = true;
await act(async () => {
saved = state?.saveWorkspaceLayoutAsDefault(buildSplitLayout()) ?? true;
});
assert.equal(saved, false);
} finally {
if (previousLocalStorage) {
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
globals.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
@@ -1,69 +0,0 @@
import { useCallback } from 'react';
import {
getFocusedSidePanelPane,
type SidePanelLayout,
type SidePanelTool,
} from '../../domain/sidePanelLayout';
import {
createWorkspaceLayoutPreset,
rekeySidePanelLayout,
withoutUnavailableSidePanelTools,
} from '../../domain/workspaceLayoutPreset';
import {
readWorkspaceLayoutPreset,
writeWorkspaceLayoutPreset,
} from './workspaceLayoutPresetStore';
/** Protocols whose sessions can actually serve an SFTP pane. */
function canServeSftp(protocol: string): boolean {
return protocol === 'local' || protocol === 'ssh' || protocol === 'mosh';
}
export type ResolvedWorkspaceLayoutPreset = {
layout: SidePanelLayout;
focusedTool: SidePanelTool;
/** Connection protocol used for availability filtering (`session.protocol ?? 'ssh'`). */
protocol: string;
};
/**
* Application boundary for the saved default workspace side panel layout.
* Owns the preset persistence lifecycle (read, validate, write) so UI
* components consume handlers instead of touching the store directly.
*/
export function useWorkspaceLayoutPresetState() {
/**
* Resolve the saved default preset into a fresh layout for a newly
* connected session. Panes the session cannot serve (SFTP on non-file
* protocols) are pruned, and ids are rekeyed so several workspaces can
* run the same preset in parallel. Returns null when there is no preset
* or nothing survives availability filtering.
*/
const resolveDefaultLayoutForSession = useCallback(
(session: { protocol?: string | null }): ResolvedWorkspaceLayoutPreset | null => {
const preset = readWorkspaceLayoutPreset();
if (!preset) return null;
const proto = session.protocol ?? 'ssh';
const usable = withoutUnavailableSidePanelTools(
preset.layout,
(tool) => tool !== 'sftp' || canServeSftp(proto),
);
if (!usable) return null;
const layout = rekeySidePanelLayout(usable, () => crypto.randomUUID());
return { layout, focusedTool: getFocusedSidePanelPane(layout).tool, protocol: proto };
},
[],
);
/** Validate and persist a live layout as the default. False when invalid or the write failed. */
const saveWorkspaceLayoutAsDefault = useCallback((layout: SidePanelLayout): boolean => {
const preset = createWorkspaceLayoutPreset(layout);
if (!preset) return false;
return writeWorkspaceLayoutPreset(preset);
}, []);
return { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault };
}
-45
View File
@@ -48,51 +48,6 @@ test("MobaXterm import keeps valid UTF-8 labels when GB18030 would produce Chine
assert.equal(result.hosts[0]?.label, "¡prod");
});
test("CSV import decodes legacy GB18030 Chinese group names", async () => {
const header = new TextEncoder().encode("Groups,Label,Hostname/IP,Port,Username\n");
const encodedGroup = new Uint8Array([
0xd6, 0xd0, 0xce, 0xc4, 0xd7, 0xe9, // 中文组 in GB18030
]);
const row = new TextEncoder().encode(",web,192.168.1.10,22,root\n");
const bytes = new Uint8Array(header.length + encodedGroup.length + row.length);
bytes.set(header);
bytes.set(encodedGroup, header.length);
bytes.set(row, header.length + encodedGroup.length);
const text = await readVaultImportFile(
"csv",
new File([bytes], "hosts.csv", { type: "text/csv" }),
);
const result = importVaultHostsFromText("csv", text);
assert.equal(result.hosts[0]?.group, "中文组");
});
test("CSV import keeps valid UTF-8 Chinese group names", async () => {
const text = "Groups,Label,Hostname/IP,Port,Username\n中文组,web,192.168.1.10,22,root\n";
const decoded = await readVaultImportFile(
"csv",
new File([text], "hosts.csv", { type: "text/csv" }),
);
const result = importVaultHostsFromText("csv", decoded);
assert.equal(result.hosts[0]?.group, "中文组");
});
test("ssh_config import keeps UTF-8 host names when a comment has invalid bytes", async () => {
const file = new File([
new TextEncoder().encode("# legacy comment "),
new Uint8Array([0xff]),
new TextEncoder().encode("\nHost 中文组\n HostName example.com\n"),
], "config", { type: "text/plain" });
const text = await readVaultImportFile("ssh_config", file);
const result = importVaultHostsFromText("ssh_config", text);
assert.equal(result.hosts[0]?.label, "中文组");
assert.equal(result.hosts[0]?.hostname, "example.com");
});
test("MobaXterm import can force GB18030 for ambiguous legacy Chinese text", async () => {
const prefix = new TextEncoder().encode("[Bookmarks]\nSubRep=\nImgNum=42\n");
const suffix = new TextEncoder().encode(`prod=${sessionValue}`);
@@ -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;
}
@@ -1,18 +0,0 @@
import { STORAGE_KEY_WORKSPACE_LAYOUT_PRESET } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
sanitizeWorkspaceLayoutPreset,
type WorkspaceLayoutPreset,
} from '../../domain/workspaceLayoutPreset';
/**
* Saved default workspace side panel layout. Device-local only: it is not
* part of the sync payload because pane arrangements are client-side chrome.
*/
export function readWorkspaceLayoutPreset(): WorkspaceLayoutPreset | null {
return sanitizeWorkspaceLayoutPreset(localStorageAdapter.read(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET));
}
export function writeWorkspaceLayoutPreset(preset: WorkspaceLayoutPreset): boolean {
return localStorageAdapter.write(STORAGE_KEY_WORKSPACE_LAYOUT_PRESET, preset);
}
-37
View File
@@ -1964,43 +1964,6 @@ test("applySyncPayload applies pluginSidecars through the production applier hoo
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
});
test("local shell side panel settings round-trip independently and trigger auto-sync", async () => {
for (const key of [storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB]) {
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(key));
}
for (const enabled of [true, false]) {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, String(enabled));
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "notes");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, String(!enabled));
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
const payload = await buildSyncPayload(vault());
assert.equal(payload.settings?.localShellSidePanelAutoOpen, enabled);
assert.equal(payload.settings?.localShellSidePanelAutoOpenTab, "notes");
localStorage.clear();
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN), String(enabled));
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), String(!enabled));
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
}
});
test("missing local shell settings and invalid tabs preserve existing settings", async () => {
const payload = await buildSyncPayload(vault());
assert.equal(payload.settings?.localShellSidePanelAutoOpen, undefined);
assert.equal(payload.settings?.localShellSidePanelAutoOpenTab, undefined);
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, "true");
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "notes");
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN), "true");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
await applySyncPayload({ ...payload, settings: { localShellSidePanelAutoOpenTab: "invalid" } } as unknown as SyncPayload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB), "notes");
localStorage.setItem(storageKeys.STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, "invalid");
assert.equal((await buildSyncPayload(vault())).settings?.localShellSidePanelAutoOpenTab, undefined);
});
test("tab bar position survives settings export and import, with a safe fallback", async () => {
localStorage.clear();
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
-24
View File
@@ -78,8 +78,6 @@ import {
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
@@ -96,7 +94,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,
@@ -269,7 +266,6 @@ const SYNCABLE_TERMINAL_KEYS = [
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'autoUploadClipboardImageOnPaste',
'confirmBeforeMultilinePaste', 'multilinePasteConfirmMinLines',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'oscNotifications', 'dynamicTabTitleMode', 'tabDoubleClickBehavior',
'autoCloseOnExit', 'disconnectedNoticeMode',
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
@@ -306,8 +302,6 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
@@ -324,7 +318,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,
@@ -488,12 +481,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (isTerminalSidePanelAutoOpenTab(terminalSidePanelAutoOpenTab)) {
settings.terminalSidePanelAutoOpenTab = terminalSidePanelAutoOpenTab;
}
const localShellSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN);
if (localShellSidePanelAutoOpen != null) settings.localShellSidePanelAutoOpen = localShellSidePanelAutoOpen;
const localShellSidePanelAutoOpenTab = localStorageAdapter.readString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB);
if (isTerminalSidePanelAutoOpenTab(localShellSidePanelAutoOpenTab)) {
settings.localShellSidePanelAutoOpenTab = localShellSidePanelAutoOpenTab;
}
// Terminal settings (syncable subset only)
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
@@ -563,8 +550,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
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);
@@ -747,12 +732,6 @@ async function applySyncableSettings(
if (isTerminalSidePanelAutoOpenTab(settings.terminalSidePanelAutoOpenTab)) {
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
}
if (settings.localShellSidePanelAutoOpen != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, settings.localShellSidePanelAutoOpen);
}
if (isTerminalSidePanelAutoOpenTab(settings.localShellSidePanelAutoOpenTab)) {
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, settings.localShellSidePanelAutoOpenTab);
}
// Terminal settings - merge with existing to preserve platform-specific keys
if (settings.terminalSettings) {
@@ -840,9 +819,6 @@ async function applySyncableSettings(
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);
}
+21 -39
View File
@@ -311,8 +311,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [isSending, setIsSending] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, string>>({});
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
@@ -874,13 +874,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
externalAgentsRef.current = externalAgents;
const applySdkRuntimeModelCatalog = useCallback((
target: SdkRuntimeModelTarget,
agentId: string,
catalog: SdkRuntimeModelCatalog,
options: { adoptCurrentModel?: boolean } = {},
) => {
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return;
const agentId = target.agentId;
const agent = externalAgentsRef.current.find((item) => item.id === agentId);
const runtimePresets = mergeFallbackThinkingLevels(
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent)),
@@ -894,10 +892,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
});
} else {
setRuntimeAgentModelPresets((prev) => (
prev[agentId]?.cacheKey === target.cacheKey
&& agentModelPresetsShallowEqual(prev[agentId].models, runtimePresets)
agentModelPresetsShallowEqual(prev[agentId], runtimePresets)
? prev
: { ...prev, [agentId]: { cacheKey: target.cacheKey, models: runtimePresets } }
: { ...prev, [agentId]: runtimePresets }
));
}
@@ -917,7 +914,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
) {
setAgentModel(agentId, catalog.currentModelId);
}
}, [setAgentModel, buildExternalAgentRuntimeModelTarget]);
}, [setAgentModel]);
const loadSdkRuntimeModelCatalog = useCallback((
target: SdkRuntimeModelTarget,
@@ -942,15 +939,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
throw new Error(result?.error || 'Failed to load SDK agent models');
}
setRuntimeModelWarnings((current) => {
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
const next = { ...current };
if (result.warning) {
next[target.agentId] = {
cacheKey: target.cacheKey,
message: t('ai.chat.modelCatalogWarning'),
};
console.warn('[AIChatSidePanel] SDK model catalog unavailable:', result.warning);
if (result.warning && target.codexRuntime === 'app-server') {
next[target.agentId] = t('ai.codex.appServer.modelCatalogWarning');
console.warn('[AIChatSidePanel] Codex App Server model catalog unavailable:', result.warning);
} else {
delete next[target.agentId];
}
@@ -963,20 +955,18 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
},
{ force: options.force },
).catch((err) => {
setRuntimeModelWarnings((current) => {
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
return {
if (target.codexRuntime === 'app-server') {
setRuntimeModelWarnings((current) => ({
...current,
[target.agentId]: { cacheKey: target.cacheKey, message: t('ai.chat.modelCatalogWarning') },
};
});
[target.agentId]: t('ai.codex.appServer.modelCatalogWarning'),
}));
}
if (options.logErrors !== false) {
console.warn('[AIChatSidePanel] Failed to load SDK agent models:', err);
}
return null;
});
}, [t, buildExternalAgentRuntimeModelTarget]);
}, [t]);
useEffect(() => {
if (!isVisible) return;
@@ -988,7 +978,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const cached = sdkRuntimeModelCache.read(target.cacheKey);
if (cached) {
applySdkRuntimeModelCatalog(target, cached);
applySdkRuntimeModelCatalog(target.agentId, cached);
}
// Respect renderer TTL / in-flight coalescing for all SDK agents including
@@ -1000,7 +990,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const cancelIdle = scheduleWhenAiComposerIdle(() => {
void loadSdkRuntimeModelCatalog(target).then((catalog) => {
if (cancelled || !catalog) return;
applySdkRuntimeModelCatalog(target, catalog, { adoptCurrentModel: true });
applySdkRuntimeModelCatalog(target.agentId, catalog, { adoptCurrentModel: true });
});
});
@@ -1022,11 +1012,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
const agentModelPresets = useMemo(() => {
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
const runtimeEntry = runtimeAgentModelPresets[currentAgentId];
const runtimePresets = runtimeEntry && target && runtimeEntry.cacheKey === target.cacheKey
? runtimeEntry.models
: undefined;
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
if (hasCodexCustomConfig && codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
@@ -1042,7 +1028,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
currentAgentConfig,
currentAgentId,
runtimeAgentModelPresets,
buildExternalAgentRuntimeModelTarget,
hasCodexCustomConfig,
codexConfigModel,
discoveredAgents,
@@ -1228,7 +1213,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (runtimeTarget) {
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
if (catalog) {
applySdkRuntimeModelCatalog(runtimeTarget, catalog, { adoptCurrentModel: true });
applySdkRuntimeModelCatalog(runtimeTarget.agentId, catalog, { adoptCurrentModel: true });
const runtimePresets = mergeFallbackThinkingLevels(
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
getAgentModelPresets(
@@ -1735,10 +1720,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
canSendCurrentAgent={canSendCurrentAgent}
providerDisplayName={providerDisplayName}
modelDisplayName={modelDisplayName}
modelCatalogWarning={runtimeModelWarnings[currentAgentId]
&& runtimeModelWarnings[currentAgentId].cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
? runtimeModelWarnings[currentAgentId].message
: undefined}
modelCatalogWarning={runtimeModelWarnings[currentAgentId]}
agentModelPresets={agentModelPresets}
selectedAgentModel={selectedAgentModel}
handleAgentModelSelect={handleAgentModelSelect}
+1 -28
View File
@@ -89,11 +89,6 @@ test('mergeFallbackThinkingLevels fills missing runtime effort catalogs', () =>
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'medium', 'high'], defaultThinkingLevel: 'medium' }],
);
assert.deepEqual(partial[0]?.thinkingLevels, ['low']);
const unsupported = mergeFallbackThinkingLevels(
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: [] }],
[{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low', 'high'] }],
);
assert.deepEqual(unsupported[0]?.thinkingLevels, []);
assert.deepEqual(mergeFallbackThinkingLevels([], [{ id: 'gpt-5.5', name: 'GPT-5.5' }]), []);
const alreadyFilled = [{ id: 'gpt-5.5', name: 'GPT-5.5', thinkingLevels: ['low'] }];
assert.equal(
@@ -142,7 +137,7 @@ test('shouldLoadSdkRuntimeModels includes SDK agents with model catalogs', () =>
assert.equal(shouldLoadSdkRuntimeModels(agent('codebuddy')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('opencode')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('grok')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), true);
assert.equal(shouldLoadSdkRuntimeModels(agent('codex')), false);
assert.equal(shouldLoadSdkRuntimeModels({ ...agent('codex'), codexRuntime: 'app-server' }), true);
assert.equal(shouldLoadSdkRuntimeModels(undefined), false);
});
@@ -161,28 +156,6 @@ test('Codex App Server model discovery uses a separate cache identity', () => {
codexRuntime: 'app-server',
});
assert.notEqual(sdk, appServer);
assert.notEqual(
buildSdkRuntimeModelCacheKey({ id: 'discovered_codex', sdkBackend: 'codex', command: '/bin/codex', env: { HOME: '/shared', CODEX_HOME: '/profiles/a' } }),
buildSdkRuntimeModelCacheKey({ id: 'discovered_codex', sdkBackend: 'codex', command: '/bin/codex', env: { HOME: '/shared', CODEX_HOME: '/profiles/b' } }),
);
});
test('Claude model cache keys isolate configuration directories under one home', () => {
const agent = { id: 'discovered_claude', sdkBackend: 'claude', command: '/bin/claude' };
assert.notEqual(
buildSdkRuntimeModelCacheKey({ ...agent, env: { HOME: '/shared', CLAUDE_CONFIG_DIR: '/profiles/a' } }),
buildSdkRuntimeModelCacheKey({ ...agent, env: { HOME: '/shared', CLAUDE_CONFIG_DIR: '/profiles/b' } }),
);
});
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']) {
const first = buildSdkRuntimeModelCacheKey({ ...agent, env: { [name]: 'secret-one' } });
const second = buildSdkRuntimeModelCacheKey({ ...agent, env: { [name]: 'secret-two' } });
assert.notEqual(first, second, `${name} must separate cache entries`);
assert.doesNotMatch(first, /secret-one/);
}
});
test('shouldAdoptSdkCurrentModel keeps SDK defaults when no runtime list is returned', () => {
+3 -15
View File
@@ -5,8 +5,6 @@ import {
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
import { canonicalizeEffortEncodedModelId } from '../infrastructure/ai/composerPicker';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex } from '@noble/hashes/utils.js';
export { canonicalizeEffortEncodedModelId };
@@ -37,11 +35,6 @@ const MODEL_CACHE_ENV_HINTS = [
'HOME',
'USERPROFILE',
'XDG_CONFIG_HOME',
'CODEX_HOME',
'CLAUDE_CONFIG_DIR',
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'OPENCODE_BIN',
'OPENCODE_CONFIG',
'OPENCODE_CONFIG_DIR',
@@ -110,9 +103,7 @@ export function buildSdkRuntimeModelCacheKey(agent: {
const grokRuntime = sdkBackend === 'grok'
? (agent.grokRuntime === 'streaming-json' ? 'streaming-json' : 'acp')
: '';
// Keep authentication values out of the in-memory cache key itself.
const envHash = bytesToHex(sha256(new TextEncoder().encode(envHints.join('\u0000'))));
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, envHash].join('\u0000');
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, ...envHints].join('\u0000');
}
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
@@ -199,7 +190,7 @@ export function mergeFallbackThinkingLevels(
const byId = new Map(fallbacks.map((preset) => [preset.id, preset]));
let changed = false;
const next = runtime.map((preset) => {
if (preset.thinkingLevels !== undefined) return preset;
if (preset.thinkingLevels?.length) return preset;
const fallback = byId.get(preset.id);
if (!fallback?.thinkingLevels?.length) return preset;
changed = true;
@@ -262,10 +253,7 @@ export function normalizeStoredAgentModelSelection(
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
// Codex on the default `sdk` runtime also serves a live catalog: its driver
// falls back to the App Server runtime's model/list in main (#3496), so
// quick sends must await it instead of running with build-time presets.
return sdkBackend === 'codex'
return (sdkBackend === 'codex' && agent?.codexRuntime === 'app-server')
|| sdkBackend === 'claude'
|| sdkBackend === 'copilot'
|| sdkBackend === 'cursor'
+13 -10
View File
@@ -17,7 +17,6 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import type { GroupConfig } from "../domain/models";
import { detectSshKeyType } from "../domain/keyTypeDetect";
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
@@ -423,16 +422,16 @@ echo $3 >> "$FILE"`);
}
// Detect key type from private key content
const detected = detectSshKeyType(
draftKey.privateKey,
draftKey.publicKey,
);
let detectedType: KeyType = "ED25519";
const pk = draftKey.privateKey.toLowerCase();
if (pk.includes("rsa")) detectedType = "RSA";
else if (pk.includes("ecdsa") || pk.includes("ec ")) detectedType = "ECDSA";
else if (pk.includes("ed25519")) detectedType = "ED25519";
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draftKey.label.trim(),
type: detected.type,
keySize: detected.keySize,
type: (draftKey.type as KeyType) || detectedType,
privateKey: draftKey.privateKey.trim(),
publicKey: draftKey.publicKey?.trim() || undefined,
certificate: draftKey.certificate?.trim() || undefined,
@@ -542,7 +541,12 @@ echo $3 >> "$FILE"`);
const content = e.target?.result as string;
if (content) {
// Try to detect key type from content
const detected = detectSshKeyType(content);
let detectedType: KeyType = "ED25519";
const lc = content.toLowerCase();
if (lc.includes("rsa")) detectedType = "RSA";
else if (lc.includes("ecdsa") || lc.includes("ec private"))
detectedType = "ECDSA";
else if (lc.includes("ed25519")) detectedType = "ED25519";
// Extract label from filename (remove extension)
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, "");
@@ -551,8 +555,7 @@ echo $3 >> "$FILE"`);
...prev,
privateKey: content,
label: prev.label || label,
type: detected.type,
keySize: detected.keySize,
type: detectedType,
}));
}
};
+4 -5
View File
@@ -16,7 +16,6 @@ import { formatHostPort } from "../domain/host";
import {
buildQuickConnectHost,
getQuickConnectDefaultPort,
isQuickConnectIdentityCompatible,
isQuickConnectIdentityUsable,
type QuickConnectAuthMethod,
type QuickConnectProtocol,
@@ -105,13 +104,13 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
[keys, protocol, selectedIdentity],
);
const identityOptions = useMemo(
() => identities.filter((identity) => isQuickConnectIdentityCompatible(identity, target)).map((identity) => ({
() => identities.map((identity) => ({
value: identity.id,
label: identity.label,
sublabel: identity.username,
icon: <User size={14} />,
})),
[identities, target],
[identities],
);
const detachSelectedIdentity = () => {
@@ -132,7 +131,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
return;
}
const identity = identities.find((candidate) => candidate.id === identityId);
if (!identity || !isQuickConnectIdentityCompatible(identity, target)) return;
if (!identity) return;
setSelectedIdentityId(identity.id);
setUsername(identity.username);
setAuthMethod(identity.authMethod);
@@ -432,7 +431,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
</div>
</div>
{protocol !== "telnet" && identityOptions.length > 0 && (
{protocol !== "telnet" && identities.length > 0 && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
<Label className="text-sm font-medium">{t("quickConnect.identity.label")}</Label>
+1 -80
View File
@@ -2,7 +2,7 @@
* Serial Host Details Panel
* A dedicated editor for serial port hosts (distinct from SSH HostDetailsPanel)
*/
import { ChevronDown, ChevronUp, Eye, EyeOff, Save, Tag, Usb, X } from 'lucide-react';
import { ChevronDown, ChevronUp, Save, Tag, Usb } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
@@ -75,22 +75,9 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
const [ports, setPorts] = useState<SerialPort[]>([]);
const [isLoadingPorts, setIsLoadingPorts] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Form state
const [label, setLabel] = useState(initialData.label);
const [username, setUsername] = useState(initialData.username || '');
const [password, setPassword] = useState(initialData.password || '');
// Tracks whether the user edited the password field this session. An empty
// password is a meaningful serial credential (the auto-login detector
// submits a blank line at a Password prompt), so an explicit edit to blank
// must be saved as '' while an untouched field preserves the stored value.
const [passwordChanged, setPasswordChanged] = useState(false);
// Explicit "remove the saved credential" flag. An empty password field is a
// meaningful serial credential (a present blank password), so blanking the
// input alone cannot restore `password` to undefined — the user needs a
// dedicated control to delete the credential entirely.
const [passwordCleared, setPasswordCleared] = useState(false);
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
@@ -153,16 +140,6 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort,
port: baudRate,
username: username.trim() || undefined,
// An empty password is meaningful for serial auto-login: the detector
// distinguishes an absent password from a present empty string and
// submits the required blank line at a Password prompt. An explicit edit
// saves exactly what the user typed (including a blank line); the
// dedicated clear control removes the credential entirely (undefined);
// an untouched field preserves the stored credential as-is.
password: passwordChanged
? (passwordCleared ? undefined : password)
: (initialData.password ?? undefined),
tags,
group,
charset,
@@ -299,62 +276,6 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
)}
</div>
{/* Login credentials (auto-login) */}
<div className="space-y-2">
<Label htmlFor="serial-username">{t('serial.field.username')}</Label>
<Input
id="serial-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t('serial.field.username')}
autoComplete="off"
/>
<div className="relative">
<Input
id="serial-password"
value={password}
type={showPassword ? 'text' : 'password'}
onChange={(e) => {
setPassword(e.target.value);
setPasswordChanged(true);
if (e.target.value !== '') {
setPasswordCleared(false);
}
}}
placeholder={t('serial.field.password')}
autoComplete="off"
className="pr-16"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5">
{(initialData.password !== undefined || password !== '') && (
<button
type="button"
onClick={() => {
setPassword('');
setPasswordChanged(true);
setPasswordCleared(true);
}}
title={t('serial.field.clearPassword')}
aria-label={t('serial.field.clearPassword')}
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<X size={14} />
</button>
)}
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('serial.field.autoLoginDesc')}
</p>
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
-18
View File
@@ -135,10 +135,6 @@ type TerminalTabSettingsProps = Pick<
| 'setTerminalSidePanelAutoOpen'
| 'terminalSidePanelAutoOpenTab'
| 'setTerminalSidePanelAutoOpenTab'
| 'localShellSidePanelAutoOpen'
| 'setLocalShellSidePanelAutoOpen'
| 'localShellSidePanelAutoOpenTab'
| 'setLocalShellSidePanelAutoOpenTab'
| 'workspaceFocusStyle'
| 'setWorkspaceFocusStyle'
>;
@@ -165,10 +161,6 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
setTerminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
setTerminalSidePanelAutoOpenTab,
localShellSidePanelAutoOpen,
setLocalShellSidePanelAutoOpen,
localShellSidePanelAutoOpenTab,
setLocalShellSidePanelAutoOpenTab,
workspaceFocusStyle,
setWorkspaceFocusStyle,
}) {
@@ -197,10 +189,6 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
setTerminalSidePanelAutoOpen={setTerminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
setTerminalSidePanelAutoOpenTab={setTerminalSidePanelAutoOpenTab}
localShellSidePanelAutoOpen={localShellSidePanelAutoOpen}
setLocalShellSidePanelAutoOpen={setLocalShellSidePanelAutoOpen}
localShellSidePanelAutoOpenTab={localShellSidePanelAutoOpenTab}
setLocalShellSidePanelAutoOpenTab={setLocalShellSidePanelAutoOpenTab}
availableFonts={availableFonts}
workspaceFocusStyle={workspaceFocusStyle}
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
@@ -561,8 +549,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setTabBarPosition={settings.setTabBarPosition}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
sftpInSidebar={settings.sftpInSidebar}
setSftpInSidebar={settings.setSftpInSidebar}
showHostTreeSidebar={settings.showHostTreeSidebar}
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
windowOpacity={settings.windowOpacity}
@@ -597,10 +583,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setTerminalSidePanelAutoOpen={settings.setTerminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
setTerminalSidePanelAutoOpenTab={settings.setTerminalSidePanelAutoOpenTab}
localShellSidePanelAutoOpen={settings.localShellSidePanelAutoOpen}
setLocalShellSidePanelAutoOpen={settings.setLocalShellSidePanelAutoOpen}
localShellSidePanelAutoOpenTab={settings.localShellSidePanelAutoOpenTab}
setLocalShellSidePanelAutoOpenTab={settings.setLocalShellSidePanelAutoOpenTab}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
/>
+1 -9
View File
@@ -37,7 +37,6 @@ import {
editorTabStore,
useEditorTabPresenceRevision,
} from "../application/state/editorTabStore";
import { forceCloseOwnedEditors } from "../application/state/editorWindowClient";
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
@@ -348,7 +347,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
listLocalDir,
listDrives,
openPath,
statSftp,
} = useSftpBackend();
const sftpRef = useRef(sftp);
@@ -533,7 +531,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (id) ownedSessionIds.push(id);
}
if (ownedSessionIds.length === 0 && ownedSftpTabIds.length === 0) return;
const closed = forceCloseOwnedEditors({
const closed = editorTabStore.forceCloseByOwners({
sessionIds: ownedSessionIds,
sftpTabIds: ownedSftpTabIds,
});
@@ -1339,7 +1337,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
selectDirectory={selectDirectory}
listLocalDir={listLocalDir}
listDrives={listDrives}
statSftp={statSftp}
openPath={openPath}
t={t}
/>
@@ -1392,7 +1389,6 @@ type SftpSidePanelInteractiveBodyProps = {
selectDirectory: ReturnType<typeof useSftpBackend>["selectDirectory"];
listLocalDir: ReturnType<typeof useSftpBackend>["listLocalDir"];
listDrives: ReturnType<typeof useSftpBackend>["listDrives"];
statSftp: ReturnType<typeof useSftpBackend>["statSftp"];
openPath: ReturnType<typeof useSftpBackend>["openPath"];
t: ReturnType<typeof useI18n>["t"];
};
@@ -1435,7 +1431,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
selectDirectory,
listLocalDir,
listDrives,
statSftp,
openPath,
t,
}) => {
@@ -1530,7 +1525,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab,
onPopOut,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
@@ -1546,7 +1540,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
showSaveDialog,
selectDirectory,
getSftpIdForConnection: sftp.getSftpIdForConnection,
statSftp,
listLocalFiles: listLocalDir,
listDrives,
});
@@ -2001,7 +1994,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
onPromoteToTab={onPromoteToTab}
onPopOut={onPopOut}
onRequestTerminalFocus={onRequestTerminalFocus}
t={t}
/>
-4
View File
@@ -193,7 +193,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
listLocalDir,
listDrives,
openPath,
statSftp,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
@@ -410,7 +409,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab,
onPopOut,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
@@ -426,7 +424,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
showSaveDialog,
selectDirectory,
getSftpIdForConnection: sftp.getSftpIdForConnection,
statSftp,
listLocalFiles: listLocalDir,
listDrives,
});
@@ -798,7 +795,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
onPromoteToTab={onPromoteToTab}
onPopOut={onPopOut}
t={t}
/>
</div>
+3 -30
View File
@@ -1,4 +1,3 @@
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { publishTerminalCommandCompletion } from "../application/state/terminalCommandCompletion";
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
@@ -53,7 +52,6 @@ import { CONNECTION_PROGRESS_START } from "./terminal/connectionProgress";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth, resolveHostAutofillPassword } from "../domain/sshAuth";
import { resolveEffectiveTerminalProtocol } from "../domain/terminalProtocol";
import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPasteConfirm";
import { isPluginHostProtocol } from "../domain/pluginConnection";
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
import {
@@ -436,7 +434,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
() => passwordPromptActiveRef.current,
), [sessionId]);
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
const sensitivePromptOutputTailRef = useRef("");
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
const dismissedScriptRunIdsRef = useRef(new Set<string>());
@@ -1001,10 +998,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
autocompleteAcceptTextRef.current = (text: string) => {
const id = sessionRef.current;
if (id && text) {
markTerminalBroadcastUserInput(sessionId);
if (["\r", "\n", "\b", "\x7f", "\x15"].some(control => text.includes(control))) {
xtermRuntimeRef.current?.invalidatePendingPasteDraft();
}
const sensitive = passwordPromptActiveRef.current;
let textToWrite = text;
let handledSubmittedInput = false;
@@ -1960,12 +1953,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sensitivePromptOutputTailRef.current,
chunk,
);
const promptSecurityOptions = {
allowHostStyleGreaterThan: isNetworkDevice,
alternateScreen: termRef.current
? isTerminalAlternateScreenActive(termRef.current)
: false,
};
const promptSecurityOptions = { allowHostStyleGreaterThan: isNetworkDevice };
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
if (meta.pluginPipelineSensitiveInput) {
@@ -3228,14 +3216,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
normalizeTextOnCopyRef.current = terminalSettings?.normalizeTextOnCopy ?? true;
const autoUploadClipboardImageOnPasteRef = useRef(terminalSettings?.autoUploadClipboardImageOnPaste ?? false);
autoUploadClipboardImageOnPasteRef.current = terminalSettings?.autoUploadClipboardImageOnPaste ?? false;
const multilinePasteConfirmRef = useRef({
enabled: terminalSettings?.confirmBeforeMultilinePaste ?? false,
minLines: terminalSettings?.multilinePasteConfirmMinLines ?? MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT,
});
multilinePasteConfirmRef.current = {
enabled: terminalSettings?.confirmBeforeMultilinePaste ?? false,
minLines: terminalSettings?.multilinePasteConfirmMinLines ?? MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT,
};
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
if (!termRef.current) return;
@@ -3260,17 +3240,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.scrollToBottom();
}, [activeScriptRun]);
const broadcastUserPasteData = useCallback((
data: string,
options?: { lineDelayMs?: number },
) => {
const broadcastUserPasteData = useCallback((data: string) => {
if (
!passwordPromptActiveRef.current
&& sessionRef.current
&& isBroadcastEnabledRef.current
&& onBroadcastInputRef.current
) {
onBroadcastInputRef.current(data, sessionId, options);
onBroadcastInputRef.current(data, sessionId);
return true;
}
return false;
@@ -3322,7 +3299,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
? AUTO_RUN_SNIPPET_LINE_DELAY_MS
: undefined;
const isMultiLine = data.includes('\n');
if (lineDelayMs) markTerminalBroadcastUserInput(sessionId);
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
// outside the paste markers — otherwise shells treat it as pasted text
// instead of a submit action.
@@ -3341,7 +3317,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const sensitive = passwordPromptActiveRef.current;
if (!sensitive && options?.broadcast !== false && isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(data, sessionId, {
automated: true,
noAutoRun,
...(lineDelayMs ? { lineDelayMs } : {}),
});
@@ -3416,7 +3391,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isLocalConnection,
supportsRemoteImagePaste,
autoUploadClipboardImageOnPasteRef,
multilinePasteConfirmRef,
terminalBackend,
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
scrollToBottomAfterProgrammaticInput,
@@ -3998,7 +3972,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
containerRef,
autoUploadClipboardImage:
supportsRemoteImagePaste && terminalSettings?.autoUploadClipboardImageOnPaste === true,
multilinePasteConfirmRef,
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
onClipboardImageUploadResult: handleClipboardImageUploadResult,
});
-5
View File
@@ -306,8 +306,3 @@ test("TerminalLayer re-renders when terminalFontFamilyId changes", () => {
false,
);
});
test("local shell auto-open preferences invalidate terminal layer memo", () => {
assert.equal(terminalLayerAreEqual(baseProps as never, { ...baseProps, localShellSidePanelAutoOpen: true } as never), false);
assert.equal(terminalLayerAreEqual(baseProps as never, { ...baseProps, localShellSidePanelAutoOpenTab: "sftp" } as never), false);
});
+26 -173
View File
@@ -1,4 +1,3 @@
import { captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { pruneComposeBarHistory } from '../application/state/composeBarHistoryStore';
import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -27,7 +26,6 @@ import { useStoredString } from '../application/state/useStoredString';
import { useStoredNumber } from '../application/state/useStoredNumber';
import { useStoredBoolean } from '../application/state/useStoredBoolean';
import {
STORAGE_KEY_SIDE_PANEL_HEIGHT,
STORAGE_KEY_SIDE_PANEL_WIDTH,
STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN,
} from '../infrastructure/config/storageKeys';
@@ -107,7 +105,7 @@ import {
} from './terminalLayer/terminalLayerSessionRouting';
import {
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
resolveSessionSidePanelAutoOpen,
resolveTerminalSidePanelAutoOpen,
} from '../domain/terminalSidePanelAutoOpen';
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
import {
@@ -120,10 +118,8 @@ import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
import {
collectSidePanelPanes,
sidePanelLayoutHasTool,
type SidePanelLayout,
type SidePanelSplitDirection,
} from '../domain/sidePanelLayout';
import { useWorkspaceLayoutPresetState } from '../application/state/useWorkspaceLayoutPresetState';
import {
isPaneMagnificationSelectionValid,
resolvePaneMagnificationCandidate,
@@ -132,12 +128,9 @@ import {
} from '../domain/paneMagnification';
import { useTerminalSidePanelLayoutState } from '../application/state/useTerminalSidePanelLayoutState';
import {
TERMINAL_SIDE_PANEL_MAX_HEIGHT,
TERMINAL_SIDE_PANEL_MAX_WIDTH,
TERMINAL_SIDE_PANEL_MIN_HEIGHT,
TERMINAL_SIDE_PANEL_MIN_WIDTH,
} from '../application/state/terminalSidePanelWidth';
import { isSidePanelDockPosition } from '../domain/sidePanelLayout';
import {
AIChatPanelsHost,
@@ -269,8 +262,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpAutoOpenSidebar,
terminalSidePanelAutoOpen = false,
terminalSidePanelAutoOpenTab = DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
localShellSidePanelAutoOpen = false,
localShellSidePanelAutoOpenTab = 'scripts',
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
editorWordWrap,
@@ -454,101 +445,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalSidePanelAutoOpenRef.current = terminalSidePanelAutoOpen;
const terminalSidePanelAutoOpenTabRef = useRef(terminalSidePanelAutoOpenTab);
terminalSidePanelAutoOpenTabRef.current = terminalSidePanelAutoOpenTab;
const localShellSidePanelAutoOpenRef = useRef(localShellSidePanelAutoOpen);
localShellSidePanelAutoOpenRef.current = localShellSidePanelAutoOpen;
const localShellSidePanelAutoOpenTabRef = useRef(localShellSidePanelAutoOpenTab);
localShellSidePanelAutoOpenTabRef.current = localShellSidePanelAutoOpenTab;
const sftpFollowTerminalCwdRef = useRef(sftpFollowTerminalCwd);
sftpFollowTerminalCwdRef.current = sftpFollowTerminalCwd;
// Destructure the stable hook callbacks: the hook returns a fresh object
// each render, and depending on that object would recreate the preset
// callbacks (and in turn handleStatusChange) on unrelated state updates,
// defeating the terminal pane memoization.
const { resolveDefaultLayoutForSession, saveWorkspaceLayoutAsDefault: persistWorkspaceLayoutAsDefault } =
useWorkspaceLayoutPresetState();
const applyWorkspaceLayoutPresetForSession = useCallback((session: TerminalSession, tabId: string) => {
// Preset persistence and resolution live in the application layer; the
// component only mounts the resolved panes into its UI state.
const resolved = resolveDefaultLayoutForSession(session);
if (!resolved) return false;
const { layout, focusedTool, protocol: proto } = resolved;
for (const pane of collectSidePanelPanes(layout.root)) {
if (pane.tool === 'ai') {
setAiMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'scripts') {
setScriptsMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'theme') {
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'system') {
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'notes') {
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'sftp') {
// Mirror the normal SFTP open path: a previously retained panel (kept
// mounted while transfers or external edits finished after close) must
// not keep suppressing command-triggered refreshes once the preset
// reopens SFTP for this tab. Mark the tab as opening first so an
// activity callback firing before React commits the open state still
// sees the panel as open instead of pruning the retained host state.
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
if (cleanupTimer !== undefined) {
window.clearTimeout(cleanupTimer);
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpOpeningTabIdsRef.current.add(tabId);
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
const host = hostsRef.current.find(h => h.id === session.hostId);
const hostWithOverrides: Host = host
? {
...host,
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
}
: {
id: session.hostId || session.id,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
protocol: proto,
label: session.customName || session.hostLabel || session.hostname,
} as Host;
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
return next;
});
}
}
setSidePanelLayouts((prev) => {
const next = new Map(prev);
next.set(tabId, layout);
return next;
});
lastSidePanelTabRef.current.set(tabId, focusedTool);
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, focusedTool);
return next;
});
return true;
}, [resolveDefaultLayoutForSession, setSidePanelLayouts, setSidePanelOpenTabs]);
const handleSaveWorkspaceLayoutAsDefault = useCallback((layout: SidePanelLayout): boolean => {
if (!persistWorkspaceLayoutAsDefault(layout)) {
toast.error(t('terminal.layer.layoutSaveFailed'));
return false;
}
toast.success(t('terminal.layer.layoutSavedAsDefault'));
return true;
}, [persistWorkspaceLayoutAsDefault, t]);
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
onUpdateSessionStatus(sessionId, status);
@@ -556,62 +455,41 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
const workspaceId = session.workspaceId;
const tabId = workspaceId || sessionId;
// Within a workspace every member session maps to the same tab id, so the
// preset (and any auto-open host binding) must come from the workspace's
// focused session; otherwise whichever pane connects first would decide
// SFTP pruning and host selection regardless of focus.
let presetSession = session;
if (workspaceId) {
const focusedSessionId = workspacesRef.current.find(ws => ws.id === workspaceId)?.focusedSessionId;
const focusedSession = focusedSessionId
? sessionsRef.current.find(s => s.id === focusedSessionId)
: undefined;
if (focusedSession) presetSession = focusedSession;
}
const proto = presetSession.protocol ?? 'ssh';
const proto = session.protocol;
const sftpAvailable = proto === 'ssh' || proto === 'mosh';
const tabId = session.workspaceId || sessionId;
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const targetPanel = resolveSessionSidePanelAutoOpen({
session: presetSession,
terminalEnabled: terminalSidePanelAutoOpenRef.current,
terminalTab: terminalSidePanelAutoOpenTabRef.current,
localEnabled: localShellSidePanelAutoOpenRef.current,
localTab: localShellSidePanelAutoOpenTabRef.current,
legacySftpEnabled: sftpAutoOpenSidebarRef.current,
const sessionAutoOpenTarget = session.autoOpenSidePanel === 'sftp' && sftpAvailable ? 'sftp' : null;
const autoOpenTarget = sessionAutoOpenTarget ?? resolveTerminalSidePanelAutoOpen({
enabled: terminalSidePanelAutoOpenRef.current,
selectedTab: terminalSidePanelAutoOpenTabRef.current,
sftpAvailable,
});
// Explicit SFTP (JumpServer deep link / host.autoOpenSftpPanel) wins over a
// saved default that may omit file transfer — only when SFTP is actually
// available. Ordinary auto-open still yields to the saved default.
if (targetPanel !== "sftp") {
if (applyWorkspaceLayoutPresetForSession(presetSession, tabId)) return;
if (!targetPanel) return;
}
const targetPanel = autoOpenTarget ?? (sftpAutoOpenSidebarRef.current && sftpAvailable ? 'sftp' : null);
if (!targetPanel) return;
lastSidePanelTabRef.current.set(tabId, targetPanel);
if (targetPanel === 'sftp') {
sftpPaneClosedTabIdsRef.current.delete(tabId);
const host = hostsRef.current.find(h => h.id === presetSession.hostId);
const host = hostsRef.current.find(h => h.id === session.hostId);
const hostWithOverrides: Host = host
? {
...host,
protocol: presetSession.protocol ?? host.protocol,
port: presetSession.port ?? host.port,
moshEnabled: presetSession.moshEnabled ?? host.moshEnabled,
etEnabled: presetSession.etEnabled ?? host.etEnabled,
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
}
: {
id: presetSession.hostId || presetSession.id,
hostname: presetSession.hostname,
username: presetSession.username,
port: presetSession.port ?? 22,
id: session.hostId || sessionId,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
protocol: proto,
label: presetSession.customName || presetSession.hostLabel || presetSession.hostname,
label: session.label || session.hostname,
} as Host;
setSftpHostForTab(prev => {
@@ -636,7 +514,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.set(tabId, targetPanel);
return next;
});
}, [applyWorkspaceLayoutPresetForSession, onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
}, [onUpdateSessionStatus, setSidePanelOpenTabs, sidePanelOpenTabsRef]);
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(
@@ -736,15 +614,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
420,
{ min: TERMINAL_SIDE_PANEL_MIN_WIDTH, max: TERMINAL_SIDE_PANEL_MAX_WIDTH },
);
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right' | 'bottom'>(
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
'netcatty_side_panel_position',
'left',
isSidePanelDockPosition,
);
const [sidePanelHeight, setSidePanelHeight, persistSidePanelHeight] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_HEIGHT,
360,
{ min: TERMINAL_SIDE_PANEL_MIN_HEIGHT, max: TERMINAL_SIDE_PANEL_MAX_HEIGHT },
(v): v is 'left' | 'right' => v === 'left' || v === 'right',
);
// Remember the last sub-panel shown per tab so the toggle shortcut can
// restore it after a close. Overwritten on open, never cleared on close.
@@ -1191,26 +1064,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sourceSessionId: string,
options?: TerminalBroadcastInputOptions,
) => {
const paced = options?.pacedBroadcast;
if (paced && !options?.preparePacedBroadcast) {
if (!paced.targets || isTerminalSensitiveInputActive(sourceSessionId)) return [];
paced.targets = paced.targets.filter(target => isTerminalBroadcastInputCurrent(target)
&& !isTerminalSensitiveInputActive(target.sessionId));
}
const targetSessionIds = resolveTerminalBroadcastTargetIds({
sessions: sessionsRef.current,
sourceSessionId,
globalBroadcastEnabled: isGlobalBroadcastEnabled,
directTargetSessionIds: options?.kittyKeyboardTargetSessionIds,
}).filter(id => !paced?.targets || paced.targets.some(target => target.sessionId === id));
if (options?.preparePacedBroadcast && paced) {
paced.targets = targetSessionIds.filter(id => !isTerminalSensitiveInputActive(id)).map(id => {
markTerminalBroadcastUserInput(id);
terminalBackend.interruptSession(id, undefined, { cancelPendingWritesOnly: true });
return captureTerminalBroadcastInput(id);
});
return paced.targets.map(target => target.sessionId);
}
});
if (targetSessionIds.length === 0) return [];
const targetSessionIdSet = new Set(targetSessionIds);
@@ -1219,7 +1078,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
for (const session of sessionsRef.current) {
if (!targetSessionIdSet.has(session.id)) continue;
if (!canUseDirectSessionWriteFallback(session)) continue;
if (!paced && (options?.automated !== true || options.lineDelayMs)) markTerminalBroadcastUserInput(session.id);
if (options?.kittyKeyboardInput) {
dispatchKittyKeyboardBroadcastInput(session.id, options.kittyKeyboardInput, {
@@ -1236,7 +1094,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
&& isPluginHostProtocol(session.protocol)
&& terminalBackend.signalPluginConnection) {
broadcastInterruptPrioritizersRef.current.get(session.id)?.();
terminalBackend.interruptSession(session.id, undefined, { cancelPendingWritesOnly: true });
void terminalBackend.signalPluginConnection(session.id, "interrupt").catch(() => {
terminalBackend.interruptSession(session.id);
});
@@ -1250,7 +1107,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
if (isTerminalSensitiveInputActive(session.id)) continue;
terminalBackend.writeToSession(session.id, data, {
automated: options?.automated === true,
automated: true,
sensitive: false,
...(lineDelayMs ? { lineDelayMs } : {}),
});
@@ -2427,7 +2284,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleSftpActiveExternalEditsChange,
handleSftpInitialLocationApplied,
persistSidePanelWidth,
persistSidePanelHeight,
handleSnippetClickForFocusedSession,
handleSnippetFromPanel,
handleRunScriptFromPanel,
@@ -2541,18 +2397,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSidePanelOpenTabs,
setSidePanelLayouts,
setSidePanelWidth,
setSidePanelHeight,
setSftpFollowTerminalCwd,
setSftpHostForTab,
setSftpInitialLocationForTab,
onSaveWorkspaceLayoutAsDefault: handleSaveWorkspaceLayoutAsDefault,
setSftpPendingUploadsForTab,
showHostTreeSidebar,
sidePanelOpenTabs,
sidePanelLayouts,
sidePanelPosition,
sidePanelWidth,
sidePanelHeight,
sftpAutoSync,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
+2 -2
View File
@@ -145,11 +145,11 @@ test('popup terminals use their window-local vault readiness', () => {
assert.match(source, /vaultInitializedOverride=\{vaultInitialized\}/);
});
test('popup provider tree mounts authentication and paste confirmation hosts', () => {
test('popup provider tree mounts the plugin authentication host', () => {
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
assert.match(
source,
/<I18nProvider locale=\{settings\.uiLanguage\}>\s+<TerminalPopupPageInner\s+settings=\{settings\}\s+allowTerminalStart=\{allowTerminalStart\}\s+\/>\s+<PluginAuthenticationHost \/>\s+<MultilinePasteConfirmHost \/>\s+<\/I18nProvider>/,
/<I18nProvider locale=\{settings\.uiLanguage\}>\s+<TerminalPopupPageInner\s+settings=\{settings\}\s+allowTerminalStart=\{allowTerminalStart\}\s+\/>\s+<PluginAuthenticationHost \/>\s+<\/I18nProvider>/,
);
});
-2
View File
@@ -2,7 +2,6 @@ import { setupVaultAgentBridge } from "../infrastructure/ai/vaultAgentBridgeClie
import { Copy, Minus, Square, Unplug, X } from 'lucide-react';
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { I18nProvider, useI18n } from '../application/i18n/I18nProvider';
import { MultilinePasteConfirmHost } from './terminal/MultilinePasteConfirmDialog';
import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWindow';
import { useVaultState } from '../application/state/useVaultState';
import { useWindowControls } from '../application/state/useWindowControls';
@@ -548,7 +547,6 @@ export default function TerminalPopupPage({
allowTerminalStart={allowTerminalStart}
/>
<PluginAuthenticationHost />
<MultilinePasteConfirmHost />
</I18nProvider>
);
}
-18
View File
@@ -71,8 +71,6 @@ interface TextEditorModalProps {
keyBindings: KeyBinding[];
/** If provided, a maximize button is shown in the Pane header. */
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
/** If provided, a pop-out button sends the file to the dedicated editor window. */
onPopOut?: (snapshot: TextEditorModalSnapshot) => void;
}
const TextEditorModal: React.FC<TextEditorModalProps> = ({
@@ -86,7 +84,6 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
hotkeyScheme,
keyBindings,
onPromoteToTab,
onPopOut,
}) => {
const { t } = useI18n();
@@ -232,20 +229,6 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
if (snapshot) onPromoteToTab(snapshot);
}, [onPromoteToTab, fileName, languageId, editorWordWrap]);
const handlePopOut = useCallback(() => {
if (!onPopOut) return;
const snapshot = createTextEditorModalSnapshot({
fileName,
getBaselineContent: () => baselineContentRef.current,
getContent: () => contentRef.current,
languageId,
wordWrap: editorWordWrap,
getViewState: () => viewStateRef.current,
isSaving: () => savingRef.current,
});
if (snapshot) onPopOut(snapshot);
}, [onPopOut, fileName, languageId, editorWordWrap]);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent
@@ -272,7 +255,6 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
onSave={handleSave}
onRequestClose={handleClose}
onPromoteToTab={onPromoteToTab ? handlePromote : undefined}
onPopOut={onPopOut ? handlePopOut : undefined}
/>
</DialogContent>
</Dialog>
+2 -14
View File
@@ -173,8 +173,6 @@ interface TopTabsProps {
pluginViewTabs: readonly PluginViewTab[];
onClosePluginViewTab: (tabId: string) => void;
onRequestCloseEditorTab: (editorTabId: string) => void;
onPopOutEditorTab?: (editorTabId: string) => void;
onFocusEditorWindowTab?: (editorTabId: string) => void;
hostById: Map<string, Host>;
}
@@ -225,8 +223,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
pluginViewTabs,
onClosePluginViewTab,
onRequestCloseEditorTab,
onPopOutEditorTab,
onFocusEditorWindowTab,
hostById,
}) => {
const { t } = useI18n();
@@ -836,8 +832,6 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
host={host}
suffix={suffix}
onRequestCloseEditorTab={onRequestCloseEditorTab}
onPopOutEditorTab={onPopOutEditorTab}
onFocusEditorWindowTab={onFocusEditorWindowTab}
isBeingDragged={isBeingDragged}
isDraggingForReorder={isDraggingForReorder}
shiftStyle={shiftStyle}
@@ -1208,13 +1202,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</div>
)}
{/* Keep window controls above the top drag stripe so their full height remains clickable. */}
{/* Fixed right controls — utility icons + window controls share one h-7 row */}
<div
className={cn(
"flex-shrink-0 flex items-center gap-0.5 app-drag overflow-visible",
showWindowControls ? "relative z-20 h-9" : "h-7",
tabsAtBottom ? "self-start" : "self-end",
)}
className={cn("flex-shrink-0 flex items-center gap-0.5 app-drag h-7 overflow-visible", tabsAtBottom ? "self-start" : "self-end")}
style={dragRegionStyle}
data-section="top-tabs-toolbar-actions"
>
@@ -1308,8 +1298,6 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
// Editor open/close/fileName chrome only (presence list). Dirty dots use store.
prev.editorTabs === next.editorTabs &&
prev.onRequestCloseEditorTab === next.onRequestCloseEditorTab &&
prev.onPopOutEditorTab === next.onPopOutEditorTab &&
prev.onFocusEditorWindowTab === next.onFocusEditorWindowTab &&
prev.pluginViewTabs === next.pluginViewTabs &&
prev.onClosePluginViewTab === next.onClosePluginViewTab &&
prev.draggingSessionId === next.draggingSessionId &&
+30 -36
View File
@@ -77,7 +77,6 @@ import {
remapSnippetTargetGroupPaths,
} from "../domain/hostGroupPathMutations";
import {
canReorderVaultHosts,
reorderVaultItems,
reorderVaultStrings,
type VaultOrderPosition,
@@ -698,15 +697,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
keyPassphrases,
unreadablePassphraseCount,
} = await buildVaultCsvCredentialOptions(hosts, keys);
const { csv, exportedCount, skippedCount, unreadableProxyCredentialCount } = exportHostsToCsvWithStats(
const { csv, exportedCount, skippedCount } = exportHostsToCsvWithStats(
hosts,
{
keyPassphrases,
keyPassphrasesById,
keyPathsById,
proxyProfiles,
identities,
groupConfigs,
},
);
@@ -732,13 +728,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}),
);
}
if (unreadableProxyCredentialCount > 0) {
toast.warning(
t("vault.hosts.export.toast.proxyCredentialsSkipped", {
count: unreadableProxyCredentialCount,
}),
);
}
if (skippedCount > 0) {
toast.warning(
t("vault.hosts.export.toast.successWithSkipped", {
@@ -751,7 +740,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
t("vault.hosts.export.toast.success", { count: exportedCount }),
);
}
}, [hosts, keys, proxyProfiles, identities, groupConfigs, t]);
}, [hosts, keys, t]);
// Copy hostname/IP for cross-host paste without opening the editor
const handleCopyHostname = useCallback(
@@ -1192,13 +1181,34 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const source = hostsRef.current.find((host) => host.id === sourceHostId);
const target = hostsRef.current.find((host) => host.id === targetHostId);
if (!source || !target) return;
// Card-to-card drags only reorder hosts within the same group; moving a
// host into a different group must go through the group folder drop
// (moveHostToGroup), otherwise dragging two grouped cards past each
// other silently swaps their group membership.
if (!canReorderVaultHosts(source.group, target.group)) return;
const targetGroup = target.group || "";
const targetManagedSource = managedSources
.filter(
(sourceInfo) =>
targetGroup === sourceInfo.groupName ||
targetGroup.startsWith(`${sourceInfo.groupName}/`),
)
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
const updatedHosts = hostsRef.current.map((host) =>
host.id === sourceHostId
? {
...host,
label:
targetManagedSource &&
(!host.protocol || host.protocol === "ssh")
? host.label.replace(/\s/g, "")
: host.label,
group: targetGroup,
managedSourceId:
targetManagedSource &&
(!host.protocol || host.protocol === "ssh")
? targetManagedSource.id
: undefined,
}
: host,
);
const reorderedHosts = reorderVaultItems<Host>(
hostsRef.current,
updatedHosts,
sourceHostId,
targetHostId,
position,
@@ -1207,7 +1217,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onUpdateHosts(reorderedHosts);
setSortMode("manual");
},
[onUpdateHosts, setSortMode],
[managedSources, onUpdateHosts, setSortMode],
);
const reorderGroup = useCallback(
@@ -1279,21 +1289,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
t,
});
const managedFilesByGroupPath = useMemo(() => {
const map = new Map<string, string[]>();
for (const source of managedSources) {
if (source.filePath) {
const files = map.get(source.groupName);
if (files) {
files.push(source.filePath);
} else {
map.set(source.groupName, [source.filePath]);
}
}
}
return map;
}, [managedSources]);
const {
startInlineNewGroup,
startInlineRenameGroup,
@@ -1385,7 +1380,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<>
<HostTreeGroupDeleteDialog
managedGroupPaths={managedGroupPaths}
managedFilesByGroupPath={managedFilesByGroupPath}
onConfirmDelete={deleteGroupPath}
/>
<VaultViewLayout
@@ -1,17 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("the custom window close button delegates before touching any renderer-owned tabs", async () => {
const source = readFileSync(new URL("./EditorWindowPage.tsx", import.meta.url), "utf8");
const body = source.match(/const handleWindowClose = useCallback\(async \(\) => \{([\s\S]*?)\n {2}\}, \[/)?.[1];
assert.ok(body);
let closes = 0;
const fail = () => { throw new Error("Native close must own tab confirmation and the incoming-transfer gate"); };
const close = new Function("closeWindow", "editorTabStore", "promptUnsavedChanges", "saveTab", "reportDetachedEditorTabsClosed", `return async () => {${body}}`)(
async () => { closes++; }, { getTabs: fail, close: fail }, fail, fail, fail,
);
await close();
assert.equal(closes, 1);
assert.match(source, /<EditorWindowControls onClose=\{\(\) => \{ void handleWindowClose\(\); \}\}/);
});
@@ -1,43 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("a failed detached save re-reports the tab's dirty state after the debounce clear", async () => {
const source = readFileSync(new URL("./EditorWindowPage.tsx", import.meta.url), "utf8");
const body = source.match(/const handleSave = useCallback\(async \(\) => \{([\s\S]*?)\n {2}\}, \[tabId, t\]\)/)?.[1];
assert.ok(body, "handleSave body not found");
const timers = [];
const globalWindow = {
clearTimeout: (id) => { timers.splice(timers.indexOf(id), 1); },
setTimeout: (fn, ms) => { const id = `t${timers.length}`; timers.push(id); return id; },
};
const reports = [];
const tab = { placement: "window", windowDirty: true, content: "edited", baselineContent: "original" };
const store = { getTab: (tabId) => (tabId === "edt_1" ? tab : null) };
const savedToasts = [];
const deps = {
window: globalWindow,
saveDetachedEditorTab: async () => ({ ok: false, error: "disk full" }),
saveDetachedEditorTabOk: async () => ({ ok: true }),
editorTabStore: store,
tabIsDirty: () => true,
reportDetachedEditorDirty: (tabId, dirty) => { reports.push({ tabId, dirty }); },
toast: { error: () => savedToasts.push("error"), success: () => savedToasts.push("success") },
t: (key) => key,
};
// Cancelled the pending dirty report before the save started, then the
// failure path must re-report the current dirty state.
const run = new Function(
...Object.keys(deps), "tabId", "dirtyReportTimerRef",
`return async () => {${body}}`,
)(...Object.values(deps), "edt_1", { current: timers[0] ?? "t0" });
timers.push("t0");
await run();
assert.equal(savedToasts.at(-1), "error");
assert.deepEqual(reports, [{ tabId: "edt_1", dirty: true }]);
assert.deepEqual(timers, [], "pending dirty report must be cancelled before the save");
});
-350
View File
@@ -1,350 +0,0 @@
import { Copy, Minus, Square, X } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type * as Monaco from "monaco-editor";
import { useI18n } from "../../application/i18n/I18nProvider";
import {
editorTabStore,
tabIsDirty,
useEditorTab,
useEditorTabChromeList,
useEditorTabDirty,
type EditorTabId,
} from "../../application/state/editorTabStore";
import {
dockDetachedEditorTab,
reportDetachedEditorDirty,
reportDetachedEditorTabsClosed,
reportEditorWindowCloseTabsResult,
saveDetachedEditorTab,
subscribeEditorWindowCloseTabs,
subscribeEditorWindowDirtyCheck,
} from "../../application/state/editorWindowClient";
import { useEditorWindowRuntime } from "../../application/state/useEditorWindow";
import { useWindowControls } from "../../application/state/useWindowControls";
import type { AppLockGateRenderContext } from "../AppLockGate";
import { toast } from "../ui/toast";
import { promptUnsavedChanges, UnsavedChangesProvider } from "./UnsavedChangesDialog";
import { EditorWindowTabBar } from "./EditorWindowTabBar";
import { TextEditorPane } from "./TextEditorPane";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
type SettingsState = AppLockGateRenderContext["settings"];
function EditorWindowControls({ onClose }: { onClose: () => void }) {
const { minimize, maximize, isMaximized: fetchIsMaximized } = useWindowControls();
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
useEffect(() => {
let cancelled = false;
void fetchIsMaximized().then((value) => {
if (!cancelled) setIsWindowMaximized(!!value);
});
const handleResize = () => {
void fetchIsMaximized().then((value) => setIsWindowMaximized(!!value));
};
window.addEventListener("resize", handleResize);
return () => {
cancelled = true;
window.removeEventListener("resize", handleResize);
};
}, [fetchIsMaximized]);
if (isMac) return null;
const buttonClass =
"app-no-drag flex h-10 w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted hover:text-foreground";
return (
<div className="app-no-drag ml-auto flex h-10 shrink-0 items-center">
<button type="button" onClick={() => void minimize()} className={buttonClass} aria-label="Minimize">
<Minus size={15} />
</button>
<button
type="button"
onClick={async () => {
const value = await maximize();
setIsWindowMaximized(!!value);
}}
className={buttonClass}
aria-label={isWindowMaximized ? "Restore" : "Maximize"}
>
{isWindowMaximized ? <Copy size={14} /> : <Square size={13} />}
</button>
<button
type="button"
onClick={onClose}
className="app-no-drag flex h-10 w-11 items-center justify-center text-foreground opacity-80 transition-colors hover:bg-destructive/20 hover:opacity-100"
aria-label="Close"
>
<X size={16} />
</button>
</div>
);
}
function EditorWindowPaneHost({
tabId,
isVisible,
settings,
onRequestClose,
onDock,
}: {
tabId: EditorTabId;
isVisible: boolean;
settings: SettingsState;
onRequestClose: (tabId: EditorTabId) => void;
onDock: (tabId: EditorTabId) => void;
}) {
const { t } = useI18n();
const tab = useEditorTab(tabId);
const dirtyReportTimerRef = useRef<number | null>(null);
const handleContentChange = useCallback(
(content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => {
editorTabStore.updateContent(tabId, content, viewState);
const latest = editorTabStore.getTab(tabId);
if (!latest) return;
const dirty = content !== latest.baselineContent;
if (dirtyReportTimerRef.current != null) window.clearTimeout(dirtyReportTimerRef.current);
dirtyReportTimerRef.current = window.setTimeout(() => {
dirtyReportTimerRef.current = null;
reportDetachedEditorDirty(tabId, dirty);
}, 150);
},
[tabId],
);
useEffect(() => () => {
if (dirtyReportTimerRef.current != null) window.clearTimeout(dirtyReportTimerRef.current);
}, []);
const handleSave = useCallback(async () => {
if (dirtyReportTimerRef.current != null) {
window.clearTimeout(dirtyReportTimerRef.current);
dirtyReportTimerRef.current = null;
}
const result = await saveDetachedEditorTab(tabId);
if (result.ok) {
toast.success(t("sftp.editor.saved"), "SFTP");
} else {
toast.error(result.error ?? t("sftp.editor.saveFailed"), "SFTP");
// The pre-save debounce clear removed the pending dirty report, so a
// failed save must re-report the tab's current dirty state or the
// source placeholder/top-tab indicator will stay marked clean.
const latest = editorTabStore.getTab(tabId);
if (latest) reportDetachedEditorDirty(tabId, tabIsDirty(latest));
}
}, [tabId, t]);
if (!tab) return null;
const isDirty = tabIsDirty(tab);
const hostLabel = tab.hostLabel || tab.hostId;
return (
<div
className="absolute inset-0 flex min-h-0 flex-col bg-background"
style={isVisible ? undefined : { pointerEvents: "none", visibility: "hidden" }}
>
<TextEditorPane
chrome="window"
fileName={`${tab.fileName}${isDirty ? " *" : ""}`}
subtitle={`${hostLabel}:${tab.remotePath}`}
content={tab.content}
languageId={tab.languageId}
wordWrap={tab.wordWrap}
saving={tab.savingState === "saving"}
saveError={tab.saveError}
hotkeyScheme={settings.hotkeyScheme}
keyBindings={settings.keyBindings}
onContentChange={handleContentChange}
onLanguageChange={(lang) => editorTabStore.setLanguage(tabId, lang)}
onToggleWordWrap={() => {
const current = editorTabStore.getTab(tabId);
if (current) editorTabStore.setWordWrap(tabId, !current.wordWrap);
}}
onSave={() => { void handleSave(); }}
onRequestClose={() => onRequestClose(tabId)}
onDockToMain={() => onDock(tabId)}
initialViewState={tab.viewState}
/>
</div>
);
}
function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
const { t } = useI18n();
const { activeTabId, setActiveTabId, closeWindow, setWindowTitle } = useEditorWindowRuntime();
const { onWindowCommandCloseRequested } = useWindowControls();
const tabs = useEditorTabChromeList();
const closeInFlightRef = useRef<Promise<boolean> | null>(null);
const activeTab = useEditorTab(activeTabId ?? "");
const activeDirty = useEditorTabDirty(activeTabId ?? "");
useEffect(() => {
if (activeTabId && editorTabStore.getTab(activeTabId)) return;
if (tabs.length > 0) setActiveTabId(tabs[tabs.length - 1].id);
}, [activeTabId, setActiveTabId, tabs]);
useEffect(() => {
const name = activeTab?.fileName;
if (!name) {
void setWindowTitle(t("sftp.editor.windowTitle"));
return;
}
void setWindowTitle(`${activeDirty ? "* " : ""}${name}`);
}, [activeDirty, activeTab?.fileName, setWindowTitle, t]);
const saveTab = useCallback(async (tabId: EditorTabId): Promise<boolean> => {
const result = await saveDetachedEditorTab(tabId);
if (result.ok) return true;
toast.error(result.error ?? t("sftp.editor.saveFailed"), "SFTP");
return false;
}, [t]);
const closeTab = useCallback(async (tabId: EditorTabId, force = false): Promise<boolean> => {
const tab = editorTabStore.getTab(tabId);
if (!tab) return true;
if (!force && tabIsDirty(tab)) {
const choice = await promptUnsavedChanges(tab.fileName);
if (choice === "cancel") return false;
if (choice === "save") {
const ok = await saveTab(tabId);
if (!ok) return false;
}
}
editorTabStore.close(tabId);
reportDetachedEditorTabsClosed([tabId]);
return true;
}, [saveTab]);
const closeTabAndMaybeWindow = useCallback(async (tabId: EditorTabId, force = false): Promise<boolean> => {
if (closeInFlightRef.current) return closeInFlightRef.current;
const task = (async () => {
const ok = await closeTab(tabId, force);
if (!ok) return false;
const remaining = editorTabStore.getTabs();
if (remaining.length === 0) {
await closeWindow();
return true;
}
if (activeTabId === tabId) {
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
}
return true;
})().finally(() => {
closeInFlightRef.current = null;
});
closeInFlightRef.current = task;
return task;
}, [activeTabId, closeTab, closeWindow, setActiveTabId]);
const handleDock = useCallback(async (tabId: EditorTabId) => {
const result = await dockDetachedEditorTab(tabId);
if (!result.success) {
toast.error(result.error ?? t("sftp.editor.dockFailed"), "SFTP");
return;
}
editorTabStore.close(tabId);
const remaining = editorTabStore.getTabs();
if (remaining.length === 0) {
await closeWindow();
return;
}
if (activeTabId === tabId) {
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
}
}, [activeTabId, closeWindow, setActiveTabId, t]);
const handleWindowClose = useCallback(async () => {
// Native close owns confirmation and blocks new transfers until it settles.
await closeWindow();
}, [closeWindow]);
useEffect(() => {
return onWindowCommandCloseRequested(() => {
if (activeTabId) void closeTabAndMaybeWindow(activeTabId);
else void handleWindowClose();
});
}, [activeTabId, closeTabAndMaybeWindow, handleWindowClose, onWindowCommandCloseRequested]);
useEffect(() => {
return subscribeEditorWindowDirtyCheck(() => {
const hasDirty = editorTabStore.getTabs().some((tab) => tabIsDirty(tab));
if (hasDirty) toast.warning(t("sftp.editor.quitBlockedByDirty"), "SFTP");
return hasDirty;
});
}, [t]);
useEffect(() => {
return subscribeEditorWindowCloseTabs(async (payload) => {
const closedIds: string[] = [];
let cancelled = false;
for (const editorId of payload.editorIds ?? []) {
const ok = await closeTab(editorId, payload.force === true);
if (!ok) {
cancelled = true;
break;
}
closedIds.push(editorId);
}
reportEditorWindowCloseTabsResult({
requestId: payload.requestId,
cancelled,
closedIds,
});
if (!cancelled && editorTabStore.getTabs().length === 0) {
await closeWindow();
} else if (activeTabId && !editorTabStore.getTab(activeTabId)) {
setActiveTabId(editorTabStore.getTabs()[0]?.id ?? null);
}
});
}, [activeTabId, closeTab, closeWindow, setActiveTabId]);
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<div className="flex h-10 shrink-0 items-center border-b border-border/60 app-drag">
{isMac ? <div className="w-[72px] shrink-0" /> : null}
<div className="px-3 text-xs font-semibold text-muted-foreground app-no-drag">
{t("sftp.editor.windowTitle")}
</div>
<EditorWindowTabBar
tabs={tabs}
activeTabId={activeTabId}
onSelect={setActiveTabId}
onClose={(tabId) => { void closeTabAndMaybeWindow(tabId); }}
/>
<EditorWindowControls onClose={() => { void handleWindowClose(); }} />
</div>
<div className="relative min-h-0 flex-1">
{tabs.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t("sftp.editor.windowEmpty")}
</div>
) : (
tabs.map((tab) => (
<EditorWindowPaneHost
key={tab.id}
tabId={tab.id}
isVisible={tab.id === activeTabId}
settings={settings}
onRequestClose={(id) => { void closeTabAndMaybeWindow(id); }}
onDock={(id) => { void handleDock(id); }}
/>
))
)}
</div>
</div>
);
}
export default function EditorWindowPage({
settings,
}: {
settings: SettingsState;
allowEdit?: boolean;
}) {
return (
<UnsavedChangesProvider>
{() => <EditorWindowPageInner settings={settings} />}
</UnsavedChangesProvider>
);
}
-84
View File
@@ -1,84 +0,0 @@
import { FileCode, FileText, X } from "lucide-react";
import React from "react";
import { useEditorTabDirty, type EditorTabChrome } from "../../application/state/editorTabStore";
import { cn } from "../../lib/utils";
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
export interface EditorWindowTabBarProps {
tabs: readonly EditorTabChrome[];
activeTabId: string | null;
onSelect: (tabId: string) => void;
onClose: (tabId: string) => void;
}
export const EditorWindowTabBar: React.FC<EditorWindowTabBarProps> = ({
tabs,
activeTabId,
onSelect,
onClose,
}) => {
const fileNameCounts = new Map<string, number>();
for (const tab of tabs) {
fileNameCounts.set(tab.fileName, (fileNameCounts.get(tab.fileName) ?? 0) + 1);
}
return (
<div className="flex h-8 min-w-0 flex-1 items-end gap-0.5 overflow-x-auto px-1">
{tabs.map((tab) => (
<EditorWindowTab
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
suffix={
(fileNameCounts.get(tab.fileName) ?? 0) > 1
? ` · ${tab.hostLabel || tab.hostId}`
: ""
}
onSelect={onSelect}
onClose={onClose}
/>
))}
</div>
);
};
const EditorWindowTab: React.FC<{
tab: EditorTabChrome;
isActive: boolean;
suffix: string;
onSelect: (tabId: string) => void;
onClose: (tabId: string) => void;
}> = ({ tab, isActive, suffix, onSelect, onClose }) => {
const dirty = useEditorTabDirty(tab.id);
const FileIcon = CODE_EXTENSIONS_RE.test(tab.fileName) ? FileCode : FileText;
return (
<button
type="button"
onClick={() => onSelect(tab.id)}
className={cn(
"app-no-drag group relative flex h-7 max-w-[220px] min-w-[120px] items-center gap-1.5 rounded-t-md px-2.5 text-xs font-semibold",
isActive ? "bg-background text-foreground" : "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
)}
>
<FileIcon size={13} className="shrink-0" />
<span className="truncate">
{dirty && <span className="mr-0.5 text-primary">●</span>}
{tab.fileName}
{suffix}
</span>
<span
role="button"
tabIndex={-1}
className="ml-auto rounded-full p-0.5 opacity-0 hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
onClose(tab.id);
}}
>
<X size={11} />
</span>
</button>
);
};
@@ -1,52 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { ContextKeyExpr } = require('monaco-editor/esm/vs/platform/contextkey/common/contextkey.js');
const { KeybindingResolver } = require('monaco-editor/esm/vs/platform/keybinding/common/keybindingResolver.js');
import { registerTextEditorCommand } from './TextEditorPane.tsx';
test('save, close, find and paste resolve to the focused editor when two editors share a window', () => {
const commands = new Map<string, Parameters<typeof registerTextEditorCommand>[2]>();
const bindings: Array<{ command: string; commandArgs: undefined; chords: string[]; when: ReturnType<typeof ContextKeyExpr.deserialize>; isDefault: boolean; bubble: boolean }> = [];
const calls: string[] = [];
const actions = ['save', 'close', 'find', 'paste'];
// Match the native regression: beta was created first, alpha last, then
// beta was clicked. Unscoped Monaco commands incorrectly chose alpha.
for (const editorId of ['vs.editor.ICodeEditor:beta', 'vs.editor.ICodeEditor:alpha']) {
const editor = {
getId: () => editorId,
addCommand(keybinding: number, handler: Parameters<typeof registerTextEditorCommand>[2], context?: string) {
const command = `${editorId}:${keybinding}`;
commands.set(command, handler);
bindings.push({ command, commandArgs: undefined, chords: [String(keybinding)], when: ContextKeyExpr.deserialize(context), isDefault: false, bubble: false });
return command;
},
};
actions.forEach((action, keybinding) => registerTextEditorCommand(editor, keybinding, () => { calls.push(`${editorId}:${action}`); }));
}
const resolver = new KeybindingResolver([], bindings, () => {});
function press(editorId: string | undefined, keybinding: number) {
const result = resolver.resolve({ getValue: (key: string) => key === 'editorId' ? editorId : undefined }, [], String(keybinding)) as { kind: number; commandId?: string };
if (result.kind === 2) commands.get(result.commandId!)!(null as never);
return result.kind;
}
for (const editorId of ['vs.editor.ICodeEditor:beta', 'vs.editor.ICodeEditor:alpha', 'vs.editor.ICodeEditor:beta']) {
actions.forEach((action, keybinding) => {
const before = calls.length;
assert.equal(press(editorId, keybinding), 2);
assert.deepEqual(calls.slice(before), [`${editorId}:${action}`]);
});
}
const before = calls.length;
actions.forEach((_, keybinding) => assert.equal(press(undefined, keybinding), 0));
assert.equal(calls.length, before, 'no hidden editor handles keys outside editor context');
});
test('every TextEditorPane Monaco shortcut uses the scoped registration', () => {
const source = readFileSync(new URL('./TextEditorPane.tsx', import.meta.url), 'utf8');
const mount = source.slice(source.indexOf('const handleEditorMount:'), source.indexOf('// Capture-phase close-tab'));
assert.equal(mount.match(/registerTextEditorCommand\(editor,/g)?.length, 4);
assert.doesNotMatch(mount, /editor\.addCommand\(/);
});
+6 -57
View File
@@ -7,9 +7,7 @@ import {
CloudUpload,
Loader2,
Maximize2,
PanelLeft,
Search,
SquareArrowOutUpRight,
WrapText,
X,
} from 'lucide-react';
@@ -95,8 +93,8 @@ export interface TextEditorPaneProps {
saveError: string | null;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
/** Layout mode — modal has close+maximize+popout; tab has popout; window has dock. */
chrome: 'modal' | 'tab' | 'window';
/** Layout mode — affects header chrome (modal shows close+maximize; tab-form only shows content controls since tab has its own close). */
chrome: 'modal' | 'tab';
/** Optional secondary label shown next to the filename in muted text — used by the tab form to display `host:remotePath`. */
subtitle?: string;
onContentChange: (content: string, viewState: Monaco.editor.ICodeEditorViewState | null) => void;
@@ -105,20 +103,9 @@ export interface TextEditorPaneProps {
onSave: () => void;
onRequestClose?: () => void; // modal only
onPromoteToTab?: () => void; // modal only — omit to hide the maximize button
onPopOut?: () => void; // modal/tab — open in the dedicated editor window
onDockToMain?: () => void; // window chrome — dock current tab back to the main window
initialViewState?: Monaco.editor.ICodeEditorViewState | null;
}
/** Monaco commands share a window-level resolver; bind each to its owning editor. */
export function registerTextEditorCommand(
editor: Pick<Monaco.editor.IStandaloneCodeEditor, 'addCommand' | 'getId'>,
keybinding: number,
handler: Monaco.editor.ICommandHandler,
): string | null {
return editor.addCommand(keybinding, handler, `editorId == '${editor.getId()}'`);
}
export const isTextEditorReadOnly = ({ saving }: { saving: boolean }): boolean => saving;
export const canPromoteTextEditor = ({ saving }: { saving: boolean }): boolean => !saving;
@@ -187,8 +174,6 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
onSave,
onRequestClose,
onPromoteToTab,
onPopOut,
onDockToMain,
initialViewState,
}) => {
const { t } = useI18n();
@@ -299,7 +284,7 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
if (initialViewState) editor.restoreViewState(initialViewState);
// Add save shortcut - use ref to avoid stale closure
registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
handleSaveRef.current();
});
@@ -307,20 +292,20 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
// Pane's root div also tries to handle this, but Monaco's internal
// key-event dispatcher fires first for focused editor keystrokes, so
// registering the command here is the reliable path.
registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
if (!closeTabCommandWEnabledRef.current) return;
handleCloseRef.current?.();
});
// Add find shortcut (Ctrl+F / Cmd+F)
registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF, () => {
// Trigger Monaco's built-in find widget
editor.trigger('keyboard', 'actions.find', null);
});
// Fallback paste path for Electron environments where Monaco paste can fail.
// When focus is in the find/replace widget, paste into that input instead of the body.
registerTextEditorCommand(editor, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
void pasteForMonacoEditorCommand({
activeElement: document.activeElement,
readClipboardText: () => readClipboardTextRef.current(),
@@ -454,42 +439,6 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
/>
)}
{onPopOut && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onPopOut}
disabled={saving}
aria-label={t('sftp.editor.popOut')}
>
<SquareArrowOutUpRight size={13} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.editor.popOut')}</TooltipContent>
</Tooltip>
)}
{chrome === 'window' && onDockToMain && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onDockToMain}
disabled={saving}
aria-label={t('sftp.editor.dock')}
>
<PanelLeft size={13} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.editor.dock')}</TooltipContent>
</Tooltip>
)}
{/* Close button — modal chrome only */}
{chrome === 'modal' && onRequestClose && (
<Button
Loaded 100 of 337 files, more files were not shown because too many files have changed in this diff. Show more