mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 10:22:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd15958256 | ||
|
|
0b8b8a5623 |
No files matched your search
@@ -7,7 +7,6 @@ import {
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
@@ -399,69 +398,3 @@ test('keyboard-interactive submit keeps the prompt and password unchanged when d
|
||||
assert.equal(hosts[0].password, 'old-password');
|
||||
assert.deepEqual(errors, ['Request not found']);
|
||||
});
|
||||
|
||||
test('local host without explicit shell resolves the configured Settings shell', () => {
|
||||
const connectedHosts: Host[] = [];
|
||||
const localHost: Host = { ...baseHost, id: 'local-1', hostname: 'localhost', protocol: 'local' };
|
||||
const discoveredShells = [
|
||||
{ id: 'wsl-ubuntu', name: 'Ubuntu (WSL)', command: 'wsl.exe', args: ['-d', 'Ubuntu'] },
|
||||
];
|
||||
|
||||
handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: () => {},
|
||||
connectToHost: (host: Host) => {
|
||||
connectedHosts.push(host);
|
||||
return 'local-session';
|
||||
},
|
||||
discoveredShells,
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: '' }),
|
||||
resolveShellSetting,
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
terminalSettings: { localShell: 'wsl-ubuntu' },
|
||||
}) as never,
|
||||
localHost,
|
||||
);
|
||||
|
||||
assert.equal(connectedHosts.length, 1);
|
||||
assert.equal(connectedHosts[0].localShell, 'wsl.exe');
|
||||
assert.deepEqual(connectedHosts[0].localShellArgs, ['-d', 'Ubuntu']);
|
||||
assert.equal(connectedHosts[0].localShellName, 'Ubuntu (WSL)');
|
||||
});
|
||||
|
||||
test('local host with an explicit shell keeps it (no Settings fallback)', () => {
|
||||
const connectedHosts: Host[] = [];
|
||||
const localHost: Host = {
|
||||
...baseHost,
|
||||
id: 'local-2',
|
||||
hostname: 'localhost',
|
||||
protocol: 'local',
|
||||
localShell: 'pwsh',
|
||||
};
|
||||
|
||||
handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: () => {},
|
||||
connectToHost: (host: Host) => {
|
||||
connectedHosts.push(host);
|
||||
return 'local-session-2';
|
||||
},
|
||||
discoveredShells: [],
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: '' }),
|
||||
resolveShellSetting,
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
terminalSettings: { localShell: 'wsl-ubuntu' },
|
||||
}) as never,
|
||||
localHost,
|
||||
);
|
||||
|
||||
assert.equal(connectedHosts.length, 1);
|
||||
assert.equal(connectedHosts[0].localShell, 'pwsh');
|
||||
assert.equal(connectedHosts[0].localShellName, undefined);
|
||||
});
|
||||
@@ -699,89 +699,6 @@ test('connection log host snapshot includes custom host icon fields', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('toggleOpacity hotkey invokes toggleWindowOpacity', () => {
|
||||
let called = false;
|
||||
const noop = () => {};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => ({
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => 'vault' },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: [],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: noop,
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWindowOpacity: () => { called = true; },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
}),
|
||||
'toggleOpacity',
|
||||
{ key: 'u', code: 'KeyU', ctrlKey: true, shiftKey: true } as KeyboardEvent,
|
||||
);
|
||||
|
||||
assert.equal(called, true);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'IME process key with its original physical code', key: 'Process', keyCode: 229, isComposing: false, expected: false },
|
||||
{ name: 'key during composition', key: 'U', keyCode: 85, isComposing: true, expected: false },
|
||||
{ name: 'ordinary shortcut', key: 'U', keyCode: 85, isComposing: false, expected: true },
|
||||
]) {
|
||||
test(`transparency shortcut respects ${scenario.name}`, () => {
|
||||
const target = new FakeHTMLElement();
|
||||
const actions: string[] = [];
|
||||
let prevented = false;
|
||||
const event = {
|
||||
...scenario,
|
||||
code: 'KeyU',
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
target,
|
||||
composedPath: () => [target],
|
||||
preventDefault: () => { prevented = true; },
|
||||
stopPropagation: () => {},
|
||||
} as unknown as KeyboardEvent;
|
||||
handleGlobalHotkeyKeyDownImpl(() => ({
|
||||
HOTKEY_DEBUG: false,
|
||||
closeTabKeyStr: 'Ctrl + W',
|
||||
executeHotkeyAction: (action: string) => actions.push(action),
|
||||
hotkeyScheme: 'pc',
|
||||
keyBindings: DEFAULT_KEY_BINDINGS,
|
||||
matchesKeyBinding,
|
||||
}), event);
|
||||
assert.deepEqual(actions, scenario.expected ? ['toggleOpacity'] : []);
|
||||
assert.equal(prevented, scenario.expected);
|
||||
});
|
||||
}
|
||||
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' },
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Host, HostProtocol, TerminalSession } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
@@ -205,10 +204,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
// Local-shell vault hosts carry no SSH credentials, so log the system
|
||||
// user like local terminals do (see handleConnectToHostImpl) instead of
|
||||
// 'root' or an inherited group SSH username.
|
||||
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -306,11 +302,6 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
for (const binding of keyBindings) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
|
||||
// IME-filtered keys retain their physical code (e.g. Linux Unicode entry).
|
||||
// Let composition handle them instead of treating them as opacity toggles.
|
||||
if (binding.action === 'toggleOpacity' && (e.isComposing || e.keyCode === 229 || e.key === 'Process')) {
|
||||
return;
|
||||
}
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
@@ -786,7 +777,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, 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, toggleWindowOpacity, workspaces } = getCtx();
|
||||
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, canUseGlobalBroadcast, closePluginViewTab, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isPluginViewTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, sftpPaneMagnificationRef, splitSessionWithCurrentShell, systemInfoRef, terminalPaneMagnificationRef, toEditorTabId, toggleBroadcast, toggleGlobalBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
|
||||
{
|
||||
const shortcutTabs = buildNumberShortcutTabTargets({
|
||||
showSftpTab: settings.showSftpTab ?? true,
|
||||
@@ -976,9 +967,6 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
setNavigateToSection('snippets');
|
||||
}
|
||||
break;
|
||||
case 'toggleOpacity':
|
||||
toggleWindowOpacity();
|
||||
break;
|
||||
case 'toggleSidePanel':
|
||||
toggleSidePanelRef.current?.();
|
||||
break;
|
||||
@@ -1116,15 +1104,11 @@ export function handleCreateLocalTerminalImpl(
|
||||
}
|
||||
|
||||
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hidden = false) {
|
||||
const { addConnectionLog, connectToHost, discoveredShells, identities, keys, resolveEffectiveHost, resolveHostAuth, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
|
||||
{
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
const effectiveHost = resolveHostDefaultLocalShell(resolveEffectiveHost(host), {
|
||||
discoveredShells,
|
||||
resolveShellSetting,
|
||||
terminalSettings,
|
||||
});
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
@@ -1154,10 +1138,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
// Local-shell vault hosts carry no SSH credentials, so log the system
|
||||
// user like local terminals do (see handleCreateLocalTerminalImpl)
|
||||
// instead of 'root' or an inherited group SSH username.
|
||||
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -1205,13 +1186,8 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
|
||||
// Gates the protocol picker (legacy name kept for its existing wiring).
|
||||
// Only prompt when Telnet is available but isn't the host's default protocol;
|
||||
// SSH-only, SSH+Mosh and Telnet-default all connect directly.
|
||||
// SSH-only guard, matching VaultView's handleHostConnect: local-shell and
|
||||
// serial hosts must always launch their own transport, never offer
|
||||
// SSH/Telnet pickers driven by an inherited group Telnet flag. Hosts
|
||||
// without an explicit protocol are legacy SSH entries.
|
||||
const effective = resolveEffectiveHost(host);
|
||||
const effectiveProtocol = effective.protocol ?? 'ssh';
|
||||
return effectiveProtocol === 'ssh' && Boolean(effective.telnetEnabled);
|
||||
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,17 +34,6 @@ test('serial targets use the serial editor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('local targets use the local shell editor', () => {
|
||||
assert.equal(
|
||||
resolveWorkSurfaceHostEditorKind({
|
||||
mode: 'edit',
|
||||
openedHost: host({ protocol: 'local' }),
|
||||
requestId: 3,
|
||||
}),
|
||||
'local',
|
||||
);
|
||||
});
|
||||
|
||||
test('new and ssh targets use the standard editor', () => {
|
||||
assert.equal(
|
||||
resolveWorkSurfaceHostEditorKind({ mode: 'new', defaultGroup: null, requestId: 1 }),
|
||||
@@ -82,8 +71,8 @@ test('editor overlay leaves the work surface interactive outside the panel', ()
|
||||
|
||||
assert.match(source, /pointer-events-none absolute inset-0 z-40/);
|
||||
assert.match(source, /\[&>\*\]:pointer-events-auto/);
|
||||
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 3);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 3);
|
||||
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 2);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test('editor host panels share vault resize width persistence', () => {
|
||||
@@ -92,7 +81,7 @@ test('editor host panels share vault resize width persistence', () => {
|
||||
assert.match(source, /STORAGE_KEY_VAULT_HOST_PANEL_WIDTH/);
|
||||
assert.match(source, /resizable:\s*true/);
|
||||
assert.match(source, /\{\.\.\.hostPanelResizeProps\}/);
|
||||
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 3);
|
||||
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test('editor stays mounted while another app surface is active', () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import HostDetailsPanel from '../../components/HostDetailsPanel';
|
||||
import LocalShellHostDetailsPanel from '../../components/LocalShellHostDetailsPanel';
|
||||
import SerialHostDetailsPanel from '../../components/SerialHostDetailsPanel';
|
||||
import { PortalContainerProvider } from '../../components/ui/portal-container';
|
||||
import { resolveGroupDefaults } from '../../domain/groupConfig';
|
||||
@@ -23,16 +22,14 @@ import type {
|
||||
} from '../../types';
|
||||
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
|
||||
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial' | 'local';
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
|
||||
|
||||
export function resolveWorkSurfaceHostEditorKind(
|
||||
target: WorkSurfaceHostEditorTarget,
|
||||
): WorkSurfaceHostEditorKind {
|
||||
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
|
||||
? 'serial'
|
||||
: target.mode === 'edit' && target.openedHost.protocol === 'local'
|
||||
? 'local'
|
||||
: 'standard';
|
||||
: 'standard';
|
||||
}
|
||||
|
||||
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
|
||||
@@ -163,18 +160,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
style={getAppHostEditorLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<PortalContainerProvider container={portalContainer}>
|
||||
{target.mode === 'edit' && target.openedHost.protocol === 'local' ? (
|
||||
<LocalShellHostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.openedHost}
|
||||
groups={groups}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
layout="overlay"
|
||||
className="pointer-events-auto"
|
||||
{...hostPanelResizeProps}
|
||||
/>
|
||||
) : target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
||||
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
||||
<SerialHostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.openedHost}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { createHostTerminalSession } from '../state/sessionFactories';
|
||||
|
||||
// Exercise the production application callback, including its async command
|
||||
// resolution boundary, before the state hook snapshots the shell type.
|
||||
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const callback = source.match(/const handleRunSnippet = useCallback\(([\s\S]*?)\n {2}\);/)?.[1];
|
||||
assert.ok(callback);
|
||||
const compiled = ts.transpile(`const run = useCallback(${callback});`, { target: ts.ScriptTarget.ES2022 });
|
||||
|
||||
for (const script of [false, true]) {
|
||||
test(`saved default local shell is resolved before ${script ? 'script' : 'snippet'} sessions are created`, async () => {
|
||||
let session: ReturnType<typeof createHostTerminalSession> | undefined;
|
||||
const run = new Function('useCallback', 'isScriptSnippet', 'runSnippet', 'resolveSnippetCommand', 'resolveEffectiveHost', `${compiled} return run;`)(
|
||||
(value: unknown) => value,
|
||||
() => script,
|
||||
(_snippet: unknown, hosts: ReturnType<typeof createLocalShellHost>[]) => {
|
||||
session = createHostTerminalSession('snippet-local', hosts[0]);
|
||||
},
|
||||
async () => 'echo hello',
|
||||
(host: ReturnType<typeof createLocalShellHost>) => resolveHostDefaultLocalShell(host, {
|
||||
terminalSettings: { localShell: 'cmd' },
|
||||
resolveShellSetting: () => ({ command: 'cmd.exe' }),
|
||||
}),
|
||||
);
|
||||
await run({ id: 'snippet-1' }, [createLocalShellHost({ os: 'windows' })]);
|
||||
assert.equal(session?.protocol, 'local');
|
||||
assert.equal(session?.localShell, 'cmd.exe');
|
||||
assert.equal(session?.shellType, 'cmd');
|
||||
});
|
||||
}
|
||||
@@ -38,11 +38,9 @@ import {
|
||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from '../../domain/models';
|
||||
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../../domain/groupConfig';
|
||||
import { upsertKnownHost } from '../../domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
|
||||
import { useVaultHostDeletion } from '../state/useVaultHostDeletion';
|
||||
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from '../../domain/sshDeepLink';
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
@@ -55,7 +53,6 @@ import {
|
||||
} from '../../domain/terminalAppearance';
|
||||
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
|
||||
import { collectSessionIds } from '../../domain/workspace';
|
||||
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
|
||||
import type { PaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { resolveCloseIntent } from '../state/resolveCloseIntent';
|
||||
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
|
||||
@@ -152,7 +149,6 @@ export function AppSideEffects() {
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
toggleWindowOpacity,
|
||||
} = settings;
|
||||
|
||||
// Always publish terminal keyboard focus to the main process. When font zoom
|
||||
@@ -273,6 +269,22 @@ export function AppSideEffects() {
|
||||
renameSessionInline,
|
||||
} = sessionState;
|
||||
|
||||
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
|
||||
// AppSideEffects only needs host/session maps for glue handlers and effects.
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
if (targetHosts.length === 0) return;
|
||||
if (isScriptSnippet(snippet)) {
|
||||
runSnippet(snippet, targetHosts);
|
||||
return;
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
runSnippet(snippet, targetHosts, command);
|
||||
},
|
||||
[runSnippet],
|
||||
);
|
||||
|
||||
// Presence-only list: keystrokes must not rebuild App domains / TopTabs structure.
|
||||
const editorTabs = useEditorTabChromeList();
|
||||
|
||||
@@ -1081,7 +1093,6 @@ export function AppSideEffects() {
|
||||
canUseGlobalBroadcast: canUseGlobalBroadcastRef.current,
|
||||
toggleScriptsSidePanelRef,
|
||||
toggleSidePanelRef,
|
||||
toggleWindowOpacity,
|
||||
toggleWorkspaceViewMode,
|
||||
workspaces: workspacesRefForHotkeys.current,
|
||||
}), action, e);
|
||||
@@ -1094,7 +1105,6 @@ export function AppSideEffects() {
|
||||
splitSessionWithCurrentShell,
|
||||
moveFocusInWorkspace,
|
||||
toggleBroadcast,
|
||||
toggleWindowOpacity,
|
||||
toggleGlobalBroadcast,
|
||||
toggleWorkspaceViewMode,
|
||||
confirmIfBusyLocalTerminal,
|
||||
@@ -1200,16 +1210,11 @@ export function AppSideEffects() {
|
||||
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
|
||||
}, [hosts]);
|
||||
|
||||
const deleteVaultHosts = useVaultHostDeletion(commitVaultGroupMutation);
|
||||
const handleConfirmDeleteHost = useCallback(async () => {
|
||||
const handleConfirmDeleteHost = useCallback(() => {
|
||||
if (!deleteHostConfirm) return;
|
||||
try {
|
||||
await deleteVaultHosts([deleteHostConfirm.hostId]);
|
||||
setDeleteHostConfirm(null);
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}, [deleteHostConfirm, deleteVaultHosts, t]);
|
||||
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
|
||||
setDeleteHostConfirm(null);
|
||||
}, [deleteHostConfirm, hosts, updateHosts]);
|
||||
|
||||
const handleCancelDeleteHost = useCallback(() => {
|
||||
setDeleteHostConfirm(null);
|
||||
@@ -1361,32 +1366,8 @@ export function AppSideEffects() {
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
const effectiveHost = materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
// Local-shell vault hosts saved with "System default shell" carry no
|
||||
// localShell; resolve the Settings → Terminal shell here at the shared
|
||||
// effective-host boundary so every launch path (connect, tray/dock open,
|
||||
// workspace creation/append, tunnels) uses the configured shell instead
|
||||
// of silently falling back to the backend OS default shell.
|
||||
return resolveHostDefaultLocalShell(effectiveHost, {
|
||||
discoveredShells,
|
||||
resolveShellSetting,
|
||||
terminalSettings,
|
||||
});
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles, discoveredShells, terminalSettings]);
|
||||
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
if (targetHosts.length === 0) return;
|
||||
if (isScriptSnippet(snippet)) {
|
||||
runSnippet(snippet, targetHosts.map(resolveEffectiveHost));
|
||||
return;
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
runSnippet(snippet, targetHosts.map(resolveEffectiveHost), command);
|
||||
},
|
||||
[runSnippet, resolveEffectiveHost],
|
||||
);
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
|
||||
const createWorkspaceWithEffectiveHosts = useCallback((name: string, selectedHosts: Host[]) => {
|
||||
createWorkspaceWithHosts(name, selectedHosts.map(resolveEffectiveHost));
|
||||
@@ -1417,17 +1398,14 @@ export function AppSideEffects() {
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
@@ -1454,10 +1432,6 @@ export function AppSideEffects() {
|
||||
return { ok: true as const, sessionId, host };
|
||||
}, [handleConnectToHost]);
|
||||
|
||||
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
|
||||
sessions, hosts, keys, identities, resolveEffectiveHost,
|
||||
});
|
||||
|
||||
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
|
||||
if (!sessions.some((session) => session.id === sessionId)) {
|
||||
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
|
||||
@@ -1500,7 +1474,6 @@ export function AppSideEffects() {
|
||||
stopTunnel,
|
||||
stopRuleTunnels,
|
||||
openHost: openHostForVaultAgent,
|
||||
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
||||
closeSession: closeSessionForVaultAgent,
|
||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||
});
|
||||
|
||||
@@ -37,7 +37,6 @@ import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||
import { useDefaultLocalShellContext } from '../../lib/useDiscoveredShells';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
@@ -261,7 +260,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
|
||||
|
||||
const {
|
||||
addShellHistoryEntry, clearAllCommandHistory, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
|
||||
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, duplicateSessionWithCurrentShell, copyWorkspaceWithCurrentShell,
|
||||
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
|
||||
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
|
||||
@@ -355,16 +354,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const defaultLocalShell = useDefaultLocalShellContext(terminalSettings);
|
||||
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
|
||||
resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds,
|
||||
defaultLocalShell,
|
||||
})
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds, defaultLocalShell]);
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
@@ -749,7 +746,6 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleTerminalCommandExecuted}
|
||||
onDeleteShellHistoryEntry={removeShellHistoryEntry}
|
||||
onClearCommandHistory={clearAllCommandHistory}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
|
||||
@@ -36,7 +36,6 @@ export function VaultHost() {
|
||||
return {
|
||||
addShellHistoryEntry: actions?.addShellHistoryEntry,
|
||||
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
|
||||
clearAllCommandHistory: actions?.clearAllCommandHistory,
|
||||
commitPluginImporterData: actions?.commitPluginImporterData,
|
||||
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
|
||||
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
|
||||
|
||||
@@ -72,7 +72,6 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
clearAllCommandHistory,
|
||||
} = vault;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -145,14 +144,12 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
|
||||
updateHostLastConnected,
|
||||
addShellHistoryEntry,
|
||||
removeShellHistoryEntry,
|
||||
clearAllCommandHistory,
|
||||
});
|
||||
return () => {
|
||||
registerVaultSnapshotActions(null);
|
||||
};
|
||||
}, [
|
||||
addShellHistoryEntry,
|
||||
clearAllCommandHistory,
|
||||
commitPluginImporterData,
|
||||
commitVaultImportTransaction,
|
||||
commitVaultGroupMutation,
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type ConvergentMigrationProviderInput,
|
||||
} from '../domain/convergentSync';
|
||||
import { isProviderReadyForSync } from '../domain/sync';
|
||||
import { isPreserveDeletionsStrategy } from '../domain/syncStrategy';
|
||||
import { getCloudSyncManager, type CloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { markConvergentSyncInitialized } from '../infrastructure/services/convergentSyncConfig';
|
||||
import { applyProtectedSyncPayload } from './localVaultBackups';
|
||||
@@ -114,7 +113,6 @@ export async function prepareConvergentSyncMigration(
|
||||
providers: inputs,
|
||||
deviceId: manager.getState().deviceId,
|
||||
now,
|
||||
preserveDeletions: isPreserveDeletionsStrategy(manager.getState().syncStrategy),
|
||||
});
|
||||
if (plan.state) {
|
||||
for (const seed of legacyBaselineSeeds) {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import en from '../locales/en.ts';
|
||||
import ru from '../locales/ru.ts';
|
||||
import es from '../locales/es.ts';
|
||||
import zhCN from '../locales/zh-CN.ts';
|
||||
import zhTW from '../locales/zh-TW.ts';
|
||||
|
||||
const keys = [
|
||||
'cloudSync.provider.disconnect',
|
||||
'cloudSync.provider.disconnect.confirmTitle',
|
||||
'cloudSync.provider.disconnect.confirmMessage',
|
||||
'cloudSync.provider.disconnect.confirmAction',
|
||||
'cloudSync.provider.disconnect.toastTitle',
|
||||
'cloudSync.provider.disconnect.cloudRemains',
|
||||
'cloudSync.provider.disconnect.failed',
|
||||
] as const;
|
||||
|
||||
test('disconnect copy exists in every bundled locale', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
for (const key of keys) {
|
||||
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
|
||||
assert.notEqual(messages[key], '', `${locale} has empty ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect confirm copy states that saved provider credentials are removed', () => {
|
||||
// The confirm dialog must explain the data consequence of disconnecting:
|
||||
// locally saved endpoint + credentials removal, not just "stops syncing".
|
||||
const expected = [
|
||||
[en, 'will be removed'],
|
||||
[es, 'eliminarán'],
|
||||
[ru, 'будут удалены'],
|
||||
[zhCN, '删除'],
|
||||
[zhTW, '刪除'],
|
||||
] as const;
|
||||
for (const [messages, fragment] of expected) {
|
||||
assert.ok(
|
||||
(messages['cloudSync.provider.disconnect.confirmMessage'] as string).includes(fragment),
|
||||
`confirmMessage should mention credential removal: ${fragment}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect toast copy explains the cloud copy is retained', () => {
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
assert.ok(
|
||||
(messages['cloudSync.provider.disconnect.cloudRemains'] as string).includes(
|
||||
'{name}',
|
||||
),
|
||||
`${locale} cloudRemains hint should reference the provider name`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect copy distinguishes login cleanup from retained local data', () => {
|
||||
assert.match(en['cloudSync.provider.disconnect.confirmMessage'], /backup history and sync encryption password stay here/);
|
||||
assert.doesNotMatch(en['cloudSync.provider.disconnect.cloudRemains'], /change.*password/);
|
||||
for (const messages of [en, ru, es, zhCN, zhTW]) {
|
||||
assert.ok(messages['cloudSync.provider.disconnect.failed'].includes('{name}'));
|
||||
}
|
||||
});
|
||||
@@ -11,16 +11,10 @@ const strategyKeys = [
|
||||
"cloudSync.strategy.desc",
|
||||
"cloudSync.strategy.smartMerge",
|
||||
"cloudSync.strategy.smartMergeDesc",
|
||||
"cloudSync.strategy.smartMergePreserve",
|
||||
"cloudSync.strategy.smartMergePreserveDesc",
|
||||
"cloudSync.strategy.preferCloud",
|
||||
"cloudSync.strategy.preferCloudDesc",
|
||||
"cloudSync.strategy.preferLocal",
|
||||
"cloudSync.strategy.preferLocalDesc",
|
||||
"cloudSync.aiSessions.title",
|
||||
"cloudSync.aiSessions.desc",
|
||||
"cloudSync.aiSessions.compatibility",
|
||||
"cloudSync.aiSessions.enableFailed",
|
||||
] as const;
|
||||
|
||||
test("cloud sync strategy copy exists in every bundled locale", () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': 'Content not available on this device',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent Settings',
|
||||
'ai.chat.preparing': 'Preparing…',
|
||||
@@ -514,10 +513,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.pinModel': 'Pin model',
|
||||
'ai.chat.unpinModel': 'Unpin model',
|
||||
'ai.chat.loadingModels': 'Loading models...',
|
||||
'ai.chat.modelCatalogSourceLive': 'Live catalog',
|
||||
'ai.chat.modelCatalogSourceCached': 'Cached · last success {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Cached',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Built-in default list',
|
||||
'ai.chat.noMatchingModels': 'No matching models',
|
||||
'ai.chat.recent': 'Recent',
|
||||
'ai.chat.viewAll': 'View All',
|
||||
@@ -527,8 +522,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
'ai.chat.daysAgo': '{n}d ago',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.undoLastTurn': 'Undo last turn',
|
||||
'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.",
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.jumpNav': 'Jump to message',
|
||||
@@ -540,8 +533,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
|
||||
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.forkFromHere': 'Fork from here',
|
||||
'ai.chat.forkCreated': 'Branched into a new chat — the original stays in history.',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
|
||||
@@ -1123,7 +1123,6 @@ Highlight the focused split pane:
|
||||
'vault.tree.expandAll': 'Expand All',
|
||||
'vault.tree.collapseAll': 'Collapse All',
|
||||
'vault.hosts.newHost': 'New Host',
|
||||
'vault.hosts.newLocalShell': 'Local Shell (CMD)',
|
||||
'vault.hosts.newGroup': 'New Group',
|
||||
'vault.hosts.import': 'Import',
|
||||
'vault.hosts.export': 'Export',
|
||||
@@ -1149,9 +1148,6 @@ Highlight the focused split pane:
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Connect ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -44,10 +44,6 @@ export const enTerminalMessages: Messages = {
|
||||
'history.action.run': 'Run in terminal',
|
||||
'history.action.saveAsSnippet': 'Save as snippet',
|
||||
'history.action.delete': 'Delete from history',
|
||||
'history.action.clearAll': 'Clear all history',
|
||||
'history.clearAll.dialog.title': 'Clear all command history?',
|
||||
'history.clearAll.dialog.desc': 'This permanently deletes all locally recorded command history and autocomplete suggestions. This cannot be undone.',
|
||||
'history.clearAll.dialog.confirm': 'Clear all',
|
||||
'terminal.toolbar.library': 'Library',
|
||||
'terminal.toolbar.noSnippets': 'No snippets available',
|
||||
'terminal.toolbar.terminalSettings': 'Terminal settings',
|
||||
@@ -314,11 +310,8 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.provider.connecting': 'Connecting...',
|
||||
'cloudSync.provider.disconnect': 'Disconnect',
|
||||
'cloudSync.provider.disconnect.confirmTitle': 'Disconnect "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Its saved address and login credentials will be removed. Your local vault, backup history and sync encryption password stay here. If system credential storage is unavailable, plugin credentials may need manual removal.',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'This device will stop syncing with {name}. Your local vault stays on this computer.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Disconnect',
|
||||
'cloudSync.provider.disconnect.toastTitle': 'Disconnected',
|
||||
'cloudSync.provider.disconnect.cloudRemains': 'Encrypted sync copies may still exist in your {name} storage. To remove them, delete the sync files there. Disconnecting does not clear this device for handover.',
|
||||
'cloudSync.provider.disconnect.failed': 'Could not finish disconnecting from {name}. Saved connection details may remain. Please try again.',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Connect to a self-hosted WebDAV endpoint',
|
||||
'cloudSync.provider.s3': 'S3 Compatible',
|
||||
@@ -388,12 +381,6 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.title': 'Sync strategy',
|
||||
'cloudSync.strategy.desc': 'Choose what happens when local and cloud data both changed.',
|
||||
'cloudSync.strategy.smartMerge': 'Smart merge (recommended)',
|
||||
'cloudSync.aiSessions.title': 'Sync AI chat history',
|
||||
'cloudSync.aiSessions.desc': 'Include AI chat sessions in cloud sync. Messages merge append-only across devices; attachment contents stay on this device.',
|
||||
'cloudSync.aiSessions.compatibility': 'Older versions may overwrite chat history. Use an updated Netcatty on every synced device.',
|
||||
'cloudSync.aiSessions.enableFailed': 'Could not update chat sync. Check the cloud connection and available local storage, then try again.',
|
||||
'cloudSync.strategy.smartMergePreserve': 'Smart merge (never delete)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': 'Combine changes from both sides and never propagate a one-sided deletion — an entry survives until it is removed on every device. Recommended when you restore backups or keep several machines in sync.',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combine changes from both sides when possible; if Netcatty cannot decide safely, ask you to choose.',
|
||||
'cloudSync.strategy.preferCloud': 'Cloud wins',
|
||||
'cloudSync.strategy.preferCloudDesc': 'When both sides changed, download the cloud version and replace local changes.',
|
||||
|
||||
@@ -1018,21 +1018,6 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Add a tag...',
|
||||
'hostDetails.createTag': 'Create tag',
|
||||
'hostDetails.createGroup': 'Create group',
|
||||
// Local shell (CMD) host entries
|
||||
'localShell.subtitle': 'Local Shell',
|
||||
'localShell.panel.title': 'New Local Shell',
|
||||
'localShell.panel.title.edit': 'Edit Local Shell',
|
||||
'localShell.field.label': 'Name',
|
||||
'localShell.field.labelPlaceholder': 'e.g. Project A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': 'System default shell',
|
||||
'localShell.field.shellDefaultDesc': 'Leave empty to use the default local shell configured in Settings → Terminal.',
|
||||
'localShell.field.shellEmpty': 'No shells found',
|
||||
'localShell.field.shellResolved': 'Runs {command}',
|
||||
'localShell.field.startDir': 'Start directory',
|
||||
'localShell.field.startDirPlaceholder': 'Inherit global start directory (home if unset)',
|
||||
'localShell.saveAndConnect': 'Save & Connect',
|
||||
|
||||
'hostDetails.plugin.title': 'Plugin connection',
|
||||
'hostDetails.plugin.provider.placeholder': 'Select a connection provider',
|
||||
'hostDetails.plugin.provider.empty': 'No connection providers are installed',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': 'Contenido no disponible en este dispositivo',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Configuración del agente',
|
||||
'ai.chat.preparing': 'Preparando…',
|
||||
@@ -505,10 +504,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.pinModel': 'Fijar modelo',
|
||||
'ai.chat.unpinModel': 'Quitar modelo fijado',
|
||||
'ai.chat.loadingModels': 'Cargando modelos...',
|
||||
'ai.chat.modelCatalogSourceLive': 'Catálogo en vivo',
|
||||
'ai.chat.modelCatalogSourceCached': 'En caché · último éxito {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'En caché',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Lista predeterminada integrada',
|
||||
'ai.chat.noMatchingModels': 'No hay modelos coincidentes',
|
||||
'ai.chat.recent': 'Recientes',
|
||||
'ai.chat.viewAll': 'Ver todo',
|
||||
@@ -518,8 +513,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': 'hace {n} h',
|
||||
'ai.chat.daysAgo': 'hace {n} d',
|
||||
'ai.chat.newChat': 'Nuevo chat',
|
||||
'ai.chat.undoLastTurn': 'Deshacer el último turno',
|
||||
'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.",
|
||||
'ai.chat.allSessions': 'Todas las sesiones',
|
||||
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
|
||||
'ai.chat.jumpNav': 'Ir al mensaje',
|
||||
@@ -531,8 +524,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
|
||||
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
|
||||
'ai.chat.noSessions': 'No hay sesiones anteriores',
|
||||
'ai.chat.forkFromHere': 'Bifurcar desde aquí',
|
||||
'ai.chat.forkCreated': 'Conversación bifurcada en un chat nuevo; el original permanece en el historial.',
|
||||
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
|
||||
@@ -883,7 +883,6 @@ Resalta el panel dividido enfocado:
|
||||
'settings.shortcuts.binding.new-workspace': 'Nuevo espacio de trabajo',
|
||||
'settings.shortcuts.binding.snippets': 'Abrir fragmentos',
|
||||
'settings.shortcuts.binding.broadcast': 'Alternar modo de transmisión',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Alternar la transparencia de la ventana',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Alternar panel lateral',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copiar archivos',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cortar archivos',
|
||||
@@ -1082,7 +1081,6 @@ Resalta el panel dividido enfocado:
|
||||
'vault.tree.expandAll': 'Expandir todo',
|
||||
'vault.tree.collapseAll': 'Contraer todo',
|
||||
'vault.hosts.newHost': 'Nuevo host',
|
||||
'vault.hosts.newLocalShell': 'Shell local (CMD)',
|
||||
'vault.hosts.newGroup': 'Nuevo grupo',
|
||||
'vault.hosts.import': 'Importar',
|
||||
'vault.hosts.export': 'Exportar',
|
||||
@@ -1108,9 +1106,6 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.deleteMultiple.success': 'Se eliminaron {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Conectar ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Conectando {count} hosts',
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -44,10 +44,6 @@ export const esTerminalMessages: Messages = {
|
||||
'history.action.run': 'Ejecutar en la terminal',
|
||||
'history.action.saveAsSnippet': 'Guardar como snippet',
|
||||
'history.action.delete': 'Eliminar del historial',
|
||||
'history.action.clearAll': 'Borrar todo el historial',
|
||||
'history.clearAll.dialog.title': '¿Borrar todo el historial de comandos?',
|
||||
'history.clearAll.dialog.desc': 'Esto elimina de forma permanente todo el historial de comandos local y las sugerencias de autocompletado. Esta acción no se puede deshacer.',
|
||||
'history.clearAll.dialog.confirm': 'Borrar todo',
|
||||
'terminal.toolbar.library': 'Biblioteca',
|
||||
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
|
||||
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
|
||||
@@ -313,11 +309,8 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.provider.connecting': 'Conectando...',
|
||||
'cloudSync.provider.disconnect': 'Desconectar',
|
||||
'cloudSync.provider.disconnect.confirmTitle': '¿Desconectar "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. Se eliminarán su dirección y credenciales de acceso guardadas. La bóveda local, las copias de seguridad y la contraseña de cifrado de sincronización permanecerán aquí. Si el almacén de credenciales del sistema no está disponible, puede ser necesario eliminar manualmente las credenciales de los complementos.',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Este dispositivo dejará de sincronizar con {name}. La bóveda local permanecerá en este equipo.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Desconectar',
|
||||
'cloudSync.provider.disconnect.toastTitle': 'Desconectado',
|
||||
'cloudSync.provider.disconnect.cloudRemains': 'Las copias de sincronización cifradas pueden seguir en tu almacenamiento de {name}. Para eliminarlas, borra los archivos de sincronización allí. Desconectar no prepara este dispositivo para entregarlo a otra persona.',
|
||||
'cloudSync.provider.disconnect.failed': 'No se pudo completar la desconexión de {name}. Los datos de conexión guardados podrían permanecer. Inténtalo de nuevo.',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
|
||||
'cloudSync.provider.s3': 'Compatible con S3',
|
||||
@@ -387,12 +380,6 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.title': 'Estrategia de sincronización',
|
||||
'cloudSync.strategy.desc': 'Elige qué sucede cuando cambian tanto los datos locales como los de la nube.',
|
||||
'cloudSync.strategy.smartMerge': 'Fusión inteligente (recomendada)',
|
||||
'cloudSync.aiSessions.title': 'Sincronizar el historial de chats de IA',
|
||||
'cloudSync.aiSessions.desc': 'Incluye las sesiones de chat de IA en la sincronización en la nube. Los mensajes se combinan de forma incremental entre dispositivos; el contenido de los adjuntos permanece en este dispositivo.',
|
||||
'cloudSync.aiSessions.compatibility': 'Las versiones antiguas pueden sobrescribir el historial. Actualiza Netcatty en todos los dispositivos sincronizados.',
|
||||
'cloudSync.aiSessions.enableFailed': 'No se pudo actualizar la sincronización del chat. Comprueba la conexión con la nube y el espacio local disponible e inténtalo de nuevo.',
|
||||
'cloudSync.strategy.smartMergePreserve': 'Fusión inteligente (sin borrar)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': 'Combina los cambios de ambos lados y nunca propaga un borrado de un solo lado: una entrada se conserva hasta que se elimina en todos los dispositivos. Recomendado al restaurar copias de seguridad o al usar varios equipos.',
|
||||
'cloudSync.strategy.smartMergeDesc': 'Combina los cambios de ambos lados cuando sea posible; si Netcatty no puede decidir con seguridad, te pedirá que elijas.',
|
||||
'cloudSync.strategy.preferCloud': 'Gana la nube',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Cuando ambos lados cambiaron, descarga la versión de la nube y reemplaza los cambios locales.',
|
||||
|
||||
@@ -1008,21 +1008,6 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Agregar una etiqueta...',
|
||||
'hostDetails.createTag': 'Crear etiqueta',
|
||||
'hostDetails.createGroup': 'Crear grupo',
|
||||
// Entradas de shell local (CMD) en la bóveda
|
||||
'localShell.subtitle': 'Shell local',
|
||||
'localShell.panel.title': 'Nueva shell local',
|
||||
'localShell.panel.title.edit': 'Editar shell local',
|
||||
'localShell.field.label': 'Nombre',
|
||||
'localShell.field.labelPlaceholder': 'p. ej. Proyecto A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': 'Shell predeterminada del sistema',
|
||||
'localShell.field.shellDefaultDesc': 'Si se deja vacío, se usa la shell local predeterminada configurada en Ajustes → Terminal.',
|
||||
'localShell.field.shellEmpty': 'No se encontraron shells',
|
||||
'localShell.field.shellResolved': 'Ejecuta {command}',
|
||||
'localShell.field.startDir': 'Directorio de inicio',
|
||||
'localShell.field.startDirPlaceholder': 'Heredar directorio inicial global (personal si no se configura)',
|
||||
'localShell.saveAndConnect': 'Guardar y conectar',
|
||||
|
||||
'hostDetails.plugin.title': 'Conexión de plugin',
|
||||
'hostDetails.plugin.provider.placeholder': 'Selecciona un proveedor de conexión',
|
||||
'hostDetails.plugin.provider.empty': 'No hay proveedores de conexión instalados',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': 'Содержимое недоступно на этом устройстве',
|
||||
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Настройки агента',
|
||||
@@ -461,10 +460,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.pinModel': 'Закрепить модель',
|
||||
'ai.chat.unpinModel': 'Открепить модель',
|
||||
'ai.chat.loadingModels': 'Загрузка моделей...',
|
||||
'ai.chat.modelCatalogSourceLive': 'Актуальный список моделей',
|
||||
'ai.chat.modelCatalogSourceCached': 'Кэш · обновлён {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': 'Кэш',
|
||||
'ai.chat.modelCatalogSourcePresets': 'Встроенный список моделей',
|
||||
'ai.chat.noMatchingModels': 'Нет подходящих моделей',
|
||||
'ai.chat.recent': 'Недавние',
|
||||
'ai.chat.viewAll': 'Показать всё',
|
||||
@@ -474,8 +469,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}ч назад',
|
||||
'ai.chat.daysAgo': '{n}д назад',
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.undoLastTurn': 'Отменить последний ход',
|
||||
'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.",
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.jumpNav': 'Перейти к сообщению',
|
||||
@@ -487,8 +480,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
|
||||
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.forkFromHere': 'Ветвление отсюда',
|
||||
'ai.chat.forkCreated': 'Разговор перенесён в новый чат; оригинал остаётся в истории.',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.menuHosts': 'Хосты',
|
||||
|
||||
@@ -908,7 +908,6 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': 'Новая рабочая область',
|
||||
'settings.shortcuts.binding.snippets': 'Открыть сниппеты',
|
||||
'settings.shortcuts.binding.broadcast': 'Переключить режим трансляции',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Переключить прозрачность окна',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Переключить боковую панель',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Копировать файл',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Вырезать файл',
|
||||
|
||||
@@ -65,10 +65,6 @@ export const ruTerminalMessages: Messages = {
|
||||
'history.action.run': 'Выполнить в терминале',
|
||||
'history.action.saveAsSnippet': 'Сохранить как сниппет',
|
||||
'history.action.delete': 'Удалить из истории',
|
||||
'history.action.clearAll': 'Очистить всю историю',
|
||||
'history.clearAll.dialog.title': 'Очистить всю историю команд?',
|
||||
'history.clearAll.dialog.desc': 'Будет безвозвратно удалена вся локальная история команд и подсказки автодополнения. Это действие нельзя отменить.',
|
||||
'history.clearAll.dialog.confirm': 'Очистить всё',
|
||||
'terminal.toolbar.library': 'Библиотека',
|
||||
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
|
||||
'terminal.toolbar.terminalSettings': 'Настройки терминала',
|
||||
@@ -332,11 +328,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.provider.connecting': 'Подключение...',
|
||||
'cloudSync.provider.disconnect': 'Отключить',
|
||||
'cloudSync.provider.disconnect.confirmTitle': 'Отключить "{name}"?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Сохранённый адрес и данные для входа будут удалены. Локальное хранилище, резервные копии и пароль шифрования синхронизации останутся здесь. Если системное хранилище учётных данных недоступно, данные для входа плагинов может потребоваться удалить вручную.',
|
||||
'cloudSync.provider.disconnect.confirmMessage': 'Это устройство перестанет синхронизироваться с {name}. Локальное хранилище останется на этом компьютере.',
|
||||
'cloudSync.provider.disconnect.confirmAction': 'Отключить',
|
||||
'cloudSync.provider.disconnect.toastTitle': 'Отключено',
|
||||
'cloudSync.provider.disconnect.cloudRemains': 'Зашифрованные копии синхронизации могут остаться в хранилище {name}. Чтобы удалить их, удалите там файлы синхронизации. Отключение не очищает устройство для передачи другому человеку.',
|
||||
'cloudSync.provider.disconnect.failed': 'Не удалось завершить отключение от {name}. Сохранённые данные подключения могут остаться. Повторите попытку.',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': 'Подключение к самостоятельно размещённому WebDAV endpoint',
|
||||
'cloudSync.provider.s3': 'Совместимое с S3',
|
||||
@@ -406,12 +399,6 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.strategy.title': 'Стратегия синхронизации',
|
||||
'cloudSync.strategy.desc': 'Выберите, что делать, когда изменились и локальные, и облачные данные.',
|
||||
'cloudSync.strategy.smartMerge': 'Умное объединение (рекомендуется)',
|
||||
'cloudSync.aiSessions.title': 'Синхронизировать историю ИИ-чатов',
|
||||
'cloudSync.aiSessions.desc': 'Включать сессии ИИ-чатов в облачную синхронизацию. Сообщения объединяются дополнением между устройствами; содержимое вложений остаётся на этом устройстве.',
|
||||
'cloudSync.aiSessions.compatibility': 'Старые версии могут перезаписать историю. Обновите Netcatty на всех синхронизируемых устройствах.',
|
||||
'cloudSync.aiSessions.enableFailed': 'Не удалось изменить синхронизацию чатов. Проверьте подключение к облаку и свободное место на устройстве, затем повторите попытку.',
|
||||
'cloudSync.strategy.smartMergePreserve': 'Умное слияние (без удалений)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': 'Объединяет изменения с обеих сторон и никогда не передаёт удаление с одной стороны: запись сохраняется, пока её не убрали на всех устройствах. Рекомендуется при восстановлении из резервных копий или при работе с несколькими машинами.',
|
||||
'cloudSync.strategy.smartMergeDesc': 'По возможности объединять изменения с обеих сторон; если Netcatty не сможет безопасно выбрать, он попросит вас решить вручную.',
|
||||
'cloudSync.strategy.preferCloud': 'Приоритет облака',
|
||||
'cloudSync.strategy.preferCloudDesc': 'Когда изменились обе стороны, скачать облачную версию и заменить локальные изменения.',
|
||||
|
||||
@@ -102,7 +102,6 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.tree.expandAll': 'Развернуть все',
|
||||
'vault.tree.collapseAll': 'Свернуть все',
|
||||
'vault.hosts.newHost': 'Новый хост',
|
||||
'vault.hosts.newLocalShell': 'Локальная оболочка (CMD)',
|
||||
'vault.hosts.newGroup': 'Новая группа',
|
||||
'vault.hosts.import': 'Импорт',
|
||||
'vault.hosts.export': 'Экспорт',
|
||||
@@ -128,9 +127,6 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
|
||||
'vault.hosts.connectSelected': 'Подключить ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
@@ -1055,21 +1051,6 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Добавить тег...',
|
||||
'hostDetails.createTag': 'Создать тег',
|
||||
'hostDetails.createGroup': 'Создать группу',
|
||||
// Записи локальной оболочки (CMD) в хранилище
|
||||
'localShell.subtitle': 'Локальная оболочка',
|
||||
'localShell.panel.title': 'Новая локальная оболочка',
|
||||
'localShell.panel.title.edit': 'Изменить локальную оболочку',
|
||||
'localShell.field.label': 'Название',
|
||||
'localShell.field.labelPlaceholder': 'напр. Проект A · CMD',
|
||||
'localShell.field.shell': 'Оболочка',
|
||||
'localShell.field.shellDefault': 'Системная оболочка по умолчанию',
|
||||
'localShell.field.shellDefaultDesc': 'Если оставить пустым, используется локальная оболочка по умолчанию из Настройки → Терминал.',
|
||||
'localShell.field.shellEmpty': 'Оболочки не найдены',
|
||||
'localShell.field.shellResolved': 'Запускает {command}',
|
||||
'localShell.field.startDir': 'Начальный каталог',
|
||||
'localShell.field.startDirPlaceholder': 'Использовать общий начальный каталог (иначе домашний)',
|
||||
'localShell.saveAndConnect': 'Сохранить и открыть',
|
||||
|
||||
'hostDetails.plugin.title': 'Подключение плагина',
|
||||
'hostDetails.plugin.provider.placeholder': 'Выберите провайдер подключения',
|
||||
'hostDetails.plugin.provider.empty': 'Провайдеры подключения не установлены',
|
||||
|
||||
@@ -1,20 +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 UNDO_KEYS = [
|
||||
'ai.chat.undoLastTurn',
|
||||
'ai.chat.undoLastTurnNotice',
|
||||
] as const;
|
||||
|
||||
test('Undo-last-turn UI is localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
const missing = UNDO_KEYS.filter(key => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': '附件内容未保留在此设备',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 设置',
|
||||
'ai.chat.preparing': '准备中…',
|
||||
@@ -514,10 +513,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.pinModel': '固定模型',
|
||||
'ai.chat.unpinModel': '取消固定',
|
||||
'ai.chat.loadingModels': '正在加载模型...',
|
||||
'ai.chat.modelCatalogSourceLive': '实时目录',
|
||||
'ai.chat.modelCatalogSourceCached': '缓存 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '缓存',
|
||||
'ai.chat.modelCatalogSourcePresets': '内置默认列表',
|
||||
'ai.chat.noMatchingModels': '没有匹配的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '查看全部',
|
||||
@@ -527,8 +522,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.undoLastTurn': '撤销上一轮',
|
||||
'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。",
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.jumpNav': '跳转到消息',
|
||||
@@ -540,8 +533,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
|
||||
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.forkFromHere': '从此处分叉',
|
||||
'ai.chat.forkCreated': '已分叉到新对话,原对话保留在历史中。',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
'ai.chat.menuHosts': '主机',
|
||||
|
||||
@@ -740,7 +740,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.tree.expandAll': '展开全部',
|
||||
'vault.tree.collapseAll': '折叠全部',
|
||||
'vault.hosts.newHost': '新建主机',
|
||||
'vault.hosts.newLocalShell': '本地终端 (CMD)',
|
||||
'vault.hosts.newGroup': '新建分组',
|
||||
'vault.hosts.import': '导入',
|
||||
'vault.hosts.export': '导出',
|
||||
@@ -766,9 +765,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.connectSelected': '连接 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
|
||||
@@ -63,10 +63,6 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'history.action.run': '在终端执行',
|
||||
'history.action.saveAsSnippet': '保存为代码片段',
|
||||
'history.action.delete': '从历史记录中删除',
|
||||
'history.action.clearAll': '清空全部历史',
|
||||
'history.clearAll.dialog.title': '清空全部命令历史?',
|
||||
'history.clearAll.dialog.desc': '这将永久删除所有本地记录的命令历史及自动补全建议,且无法撤销。',
|
||||
'history.clearAll.dialog.confirm': '全部清空',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '复制文件路径',
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
@@ -598,7 +594,6 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': '新建工作区',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切换窗口透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
|
||||
@@ -390,21 +390,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.addTag': '添加标签...',
|
||||
'hostDetails.createTag': '创建标签',
|
||||
'hostDetails.createGroup': '创建分组',
|
||||
// 本地终端(CMD)主机条目
|
||||
'localShell.subtitle': '本地终端',
|
||||
'localShell.panel.title': '新建本地终端',
|
||||
'localShell.panel.title.edit': '编辑本地终端',
|
||||
'localShell.field.label': '名称',
|
||||
'localShell.field.labelPlaceholder': '例如:项目 A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': '系统默认 Shell',
|
||||
'localShell.field.shellDefaultDesc': '留空则使用设置 → 终端中配置的默认本地 Shell。',
|
||||
'localShell.field.shellEmpty': '未发现可用 Shell',
|
||||
'localShell.field.shellResolved': '启动 {command}',
|
||||
'localShell.field.startDir': '启动目录',
|
||||
'localShell.field.startDirPlaceholder': '继承全局起始目录(未设置时为主目录)',
|
||||
'localShell.saveAndConnect': '保存并打开',
|
||||
|
||||
'hostDetails.plugin.title': '插件连接',
|
||||
'hostDetails.plugin.provider.placeholder': '选择连接提供程序',
|
||||
'hostDetails.plugin.provider.empty': '未安装连接提供程序',
|
||||
@@ -717,11 +702,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.provider.connecting': '连接中...',
|
||||
'cloudSync.provider.disconnect': '断开连接',
|
||||
'cloudSync.provider.disconnect.confirmTitle': '断开 “{name}”?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步,并删除本机保存的该服务商地址与登录凭据。本地主机库、备份历史和同步加密密码仍会保留。若系统凭据存储不可用,插件凭据可能需要手动删除。',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此设备将停止与 {name} 同步。本地保险库仍保留在这台电脑上。',
|
||||
'cloudSync.provider.disconnect.confirmAction': '断开连接',
|
||||
'cloudSync.provider.disconnect.toastTitle': '已断开连接',
|
||||
'cloudSync.provider.disconnect.cloudRemains': '云端的加密同步副本可能仍保存在 {name} 存储中。如需清除,请到该服务商删除同步文件。断开连接不代表设备已完成交接清理。',
|
||||
'cloudSync.provider.disconnect.failed': '未能完成与 {name} 的断开连接,保存的连接资料可能仍在,请重试。',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': '连接到自建 WebDAV 端点',
|
||||
'cloudSync.provider.s3': 'S3 兼容存储',
|
||||
@@ -791,12 +773,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.strategy.title': '同步策略',
|
||||
'cloudSync.strategy.desc': '当本地和云端都发生变化时,选择如何处理。',
|
||||
'cloudSync.strategy.smartMerge': '智能合并(推荐)',
|
||||
'cloudSync.aiSessions.title': '同步 AI 对话记录',
|
||||
'cloudSync.aiSessions.desc': '把 AI 对话记录纳入云同步。多设备的对话会按追加方式合并,附件内容仍保留在本机。',
|
||||
'cloudSync.aiSessions.compatibility': '旧版本可能覆盖对话记录,请先更新所有参与同步的设备。',
|
||||
'cloudSync.aiSessions.enableFailed': '无法更改聊天同步设置,请检查云端连接和本地可用空间,然后重试。',
|
||||
'cloudSync.strategy.smartMergePreserve': '智能合并(不传播删除)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': '合并两边的变化,且单侧删除不会同步到其它设备——只有所有设备都删掉才会真正消失。恢复备份或多台机器同步时推荐使用。',
|
||||
'cloudSync.strategy.smartMergeDesc': '尽量保留两边的变化;如果无法安全判断,会再让你手动选择。',
|
||||
'cloudSync.strategy.preferCloud': '云端优先',
|
||||
'cloudSync.strategy.preferCloudDesc': '两边都有变化时,下载云端版本,并替换本地变化。',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.attachmentContentUnavailable': '附件內容未保留在此裝置',
|
||||
// AI Settings
|
||||
'ai.agentSettings': 'Agent 設定',
|
||||
'ai.chat.preparing': '準備中…',
|
||||
@@ -504,10 +503,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.pinModel': '釘選模型',
|
||||
'ai.chat.unpinModel': '取消釘選',
|
||||
'ai.chat.loadingModels': '正在載入模型...',
|
||||
'ai.chat.modelCatalogSourceLive': '即時目錄',
|
||||
'ai.chat.modelCatalogSourceCached': '快取 · 上次成功 {time}',
|
||||
'ai.chat.modelCatalogSourceCachedNoTime': '快取',
|
||||
'ai.chat.modelCatalogSourcePresets': '內建預設清單',
|
||||
'ai.chat.noMatchingModels': '沒有符合的模型',
|
||||
'ai.chat.recent': '最近',
|
||||
'ai.chat.viewAll': '檢視全部',
|
||||
@@ -517,8 +512,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}小時前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新對話',
|
||||
'ai.chat.undoLastTurn': '復原上一輪',
|
||||
'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。",
|
||||
'ai.chat.allSessions': '所有工作階段',
|
||||
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
|
||||
'ai.chat.jumpNav': '跳轉到訊息',
|
||||
@@ -530,8 +523,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
|
||||
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
'ai.chat.forkFromHere': '從此處分叉',
|
||||
'ai.chat.forkCreated': '已分叉到新對話,原對話保留在歷史中。',
|
||||
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
|
||||
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
|
||||
'ai.chat.menuHosts': '主機',
|
||||
|
||||
@@ -742,7 +742,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.tree.expandAll': '展開全部',
|
||||
'vault.tree.collapseAll': '摺疊全部',
|
||||
'vault.hosts.newHost': '新增主機',
|
||||
'vault.hosts.newLocalShell': '本地終端 (CMD)',
|
||||
'vault.hosts.newGroup': '新增群組',
|
||||
'vault.hosts.import': '匯入',
|
||||
'vault.hosts.export': '匯出',
|
||||
@@ -768,9 +767,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
|
||||
'vault.hosts.connectSelected': '連線 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
|
||||
@@ -63,10 +63,6 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'history.action.run': '在終端執行',
|
||||
'history.action.saveAsSnippet': '儲存為程式碼片段',
|
||||
'history.action.delete': '從歷史記錄中刪除',
|
||||
'history.action.clearAll': '清空全部歷史',
|
||||
'history.clearAll.dialog.title': '清空全部指令歷史?',
|
||||
'history.clearAll.dialog.desc': '這將永久刪除所有本機記錄的指令歷史及自動補全建議,且無法復原。',
|
||||
'history.clearAll.dialog.confirm': '全部清空',
|
||||
// SFTP File Opener
|
||||
'sftp.context.copyPath': '複製檔案路徑',
|
||||
'sftp.context.openWith': '開啟方式...',
|
||||
@@ -598,7 +594,6 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.shortcuts.binding.new-workspace': '新增工作區',
|
||||
'settings.shortcuts.binding.snippets': '開啟程式碼片段',
|
||||
'settings.shortcuts.binding.broadcast': '切換廣播模式',
|
||||
'settings.shortcuts.binding.toggle-opacity': '切換視窗透明度',
|
||||
'settings.shortcuts.binding.toggle-side-panel': '切換側邊欄',
|
||||
'settings.shortcuts.binding.sftp-copy': '複製檔案',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪下檔案',
|
||||
|
||||
@@ -390,21 +390,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.addTag': '新增標籤...',
|
||||
'hostDetails.createTag': '建立標籤',
|
||||
'hostDetails.createGroup': '建立群組',
|
||||
// 本地終端(CMD)主機項目
|
||||
'localShell.subtitle': '本地終端',
|
||||
'localShell.panel.title': '新增本地終端',
|
||||
'localShell.panel.title.edit': '編輯本地終端',
|
||||
'localShell.field.label': '名稱',
|
||||
'localShell.field.labelPlaceholder': '例如:專案 A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': '系統預設 Shell',
|
||||
'localShell.field.shellDefaultDesc': '留空則使用設定 → 終端中設定的預設本地 Shell。',
|
||||
'localShell.field.shellEmpty': '未找到可用的 Shell',
|
||||
'localShell.field.shellResolved': '啟動 {command}',
|
||||
'localShell.field.startDir': '啟動目錄',
|
||||
'localShell.field.startDirPlaceholder': '繼承全域起始目錄(未設定時為主目錄)',
|
||||
'localShell.saveAndConnect': '儲存並開啟',
|
||||
|
||||
'hostDetails.plugin.title': '外掛程式連線',
|
||||
'hostDetails.plugin.provider.placeholder': '選擇連線提供者',
|
||||
'hostDetails.plugin.provider.empty': '尚未安裝連線提供者',
|
||||
@@ -717,11 +702,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.provider.connecting': '連線中...',
|
||||
'cloudSync.provider.disconnect': '中斷連線',
|
||||
'cloudSync.provider.disconnect.confirmTitle': '中斷「{name}」?',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步,並刪除本機保存的該服務商位址與登入憑證。本機主機庫、備份歷史和同步加密密碼仍會保留。若系統憑證儲存無法使用,外掛憑證可能需要手動刪除。',
|
||||
'cloudSync.provider.disconnect.confirmMessage': '此裝置將停止與 {name} 同步。本機保險庫仍保留在這台電腦上。',
|
||||
'cloudSync.provider.disconnect.confirmAction': '中斷連線',
|
||||
'cloudSync.provider.disconnect.toastTitle': '已中斷連線',
|
||||
'cloudSync.provider.disconnect.cloudRemains': '雲端的加密同步副本可能仍保存在 {name} 儲存空間中。如需清除,請到該服務商刪除同步檔案。中斷連線不代表裝置已完成交接清理。',
|
||||
'cloudSync.provider.disconnect.failed': '未能完成與 {name} 的中斷連線,保存的連線資料可能仍在,請重試。',
|
||||
'cloudSync.provider.webdav': 'WebDAV',
|
||||
'cloudSync.provider.webdav.desc': '連線到自建 WebDAV 端點',
|
||||
'cloudSync.provider.s3': 'S3 相容儲存',
|
||||
@@ -791,12 +773,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.strategy.title': '同步策略',
|
||||
'cloudSync.strategy.desc': '當本機和雲端都發生變化時,選擇如何處理。',
|
||||
'cloudSync.strategy.smartMerge': '智慧合併(推薦)',
|
||||
'cloudSync.aiSessions.title': '同步 AI 對話記錄',
|
||||
'cloudSync.aiSessions.desc': '把 AI 對話記錄納入雲同步。多裝置的對話會按追加方式合併,附件內容仍保留在本機。',
|
||||
'cloudSync.aiSessions.compatibility': '舊版本可能覆寫對話記錄,請先更新所有參與同步的裝置。',
|
||||
'cloudSync.aiSessions.enableFailed': '無法變更聊天同步設定,請檢查雲端連線與本機可用空間,然後重試。',
|
||||
'cloudSync.strategy.smartMergePreserve': '智慧合併(不傳播刪除)',
|
||||
'cloudSync.strategy.smartMergePreserveDesc': '合併兩邊的變化,且單側刪除不會同步到其它裝置——只有所有裝置都刪掉才會真正消失。還原備份或多台機器同步時建議使用。',
|
||||
'cloudSync.strategy.smartMergeDesc': '儘量保留兩邊的變化;如果無法安全判斷,會再讓你手動選擇。',
|
||||
'cloudSync.strategy.preferCloud': '雲端優先',
|
||||
'cloudSync.strategy.preferCloudDesc': '兩邊都有變化時,下載雲端版本,並替換本機變化。',
|
||||
|
||||
@@ -2,7 +2,6 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AIDraft,
|
||||
@@ -48,10 +47,12 @@ export const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-s
|
||||
export type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
export type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<void> {
|
||||
export function cleanupSdkAgentSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (sessionIds.length === 0) return;
|
||||
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
|
||||
for (const sessionId of sessionIds) {
|
||||
void bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
@@ -470,11 +471,6 @@ export function serializeSessionsForStorage(
|
||||
* could not be persisted even after escalation (it stays memory-only).
|
||||
*/
|
||||
export function writeSessionsForStorage(sessions: AISession[]): boolean {
|
||||
// Synced records are authoritative after restart: silently evicting a
|
||||
// session or turn here would turn local retention into cloud deletion.
|
||||
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
|
||||
}
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
// A real quota failure means the next attempt must be smaller even when
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
STORAGE_KEY_COMMAND_HISTORY,
|
||||
STORAGE_KEY_COMMAND_HISTORY_CLEARED,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
|
||||
type StorageEventLike = { key: string | null; newValue: string | null; oldValue: string | null };
|
||||
type StorageListener = (event: StorageEventLike) => void;
|
||||
|
||||
type DeferredRead = { resolve: (value: string) => void };
|
||||
|
||||
const COMMAND_HISTORY_STORAGE_KEY = STORAGE_KEY_COMMAND_HISTORY;
|
||||
|
||||
function installLocalStorage(): Map<string, string> {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* The store registers its cross-window `storage` listener at module load, so
|
||||
* the DOM globals must exist before the dynamic imports below.
|
||||
*/
|
||||
function installWindow(): (key: string, newValue: string | null, oldValue?: string | null) => void {
|
||||
const listeners = new Set<StorageListener>();
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
addEventListener: (type: string, listener: StorageListener) => {
|
||||
if (type === "storage") listeners.add(listener);
|
||||
},
|
||||
removeEventListener: (type: string, listener: StorageListener) => {
|
||||
if (type === "storage") listeners.delete(listener);
|
||||
},
|
||||
netcatty: undefined as unknown,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
return (key: string, newValue: string | null, oldValue: string | null = null) => {
|
||||
for (const listener of listeners) listener({ key, newValue, oldValue });
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
const storage = installLocalStorage();
|
||||
const dispatchStorageEvent = installWindow();
|
||||
|
||||
const {
|
||||
clearHistory,
|
||||
flushCommandHistoryStore,
|
||||
getCommandHistoryClearGeneration,
|
||||
hasCommandHistoryBeenCleared,
|
||||
markCommandHistoryCleared,
|
||||
queryHistory,
|
||||
recordCommand,
|
||||
subscribeCommandHistoryCleared,
|
||||
} = await import("./commandHistoryStore.ts");
|
||||
const { seedLocalShellHistoryFromHistfiles } = await import(
|
||||
"../../components/terminal/autocomplete/localShellHistorySeed.ts"
|
||||
);
|
||||
|
||||
function readPersistedCommands(): string[] {
|
||||
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as { entries: Array<{ command: string }> };
|
||||
return parsed.entries.map((entry) => entry.command);
|
||||
}
|
||||
|
||||
function readPersistedGeneration(): number {
|
||||
const raw = storage.get(COMMAND_HISTORY_STORAGE_KEY);
|
||||
if (!raw) return 0;
|
||||
const parsed = JSON.parse(raw) as { clearGeneration?: number };
|
||||
return typeof parsed.clearGeneration === "number" ? parsed.clearGeneration : 0;
|
||||
}
|
||||
|
||||
test("a fresh popup keeps compose recall on an ordinary peer write after an earlier clear", async () => {
|
||||
const baseline = JSON.stringify({ entries: [], version: 1, clearGeneration: 3 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, baseline);
|
||||
const composeBar = await import("./composeBarHistoryStore.ts");
|
||||
composeBar.recordComposeBarHistory("fresh-popup", "echo popup-current-command");
|
||||
|
||||
// No autocomplete query has loaded this renderer's command-history store.
|
||||
const recorded = JSON.stringify({
|
||||
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
|
||||
version: 1, clearGeneration: 3,
|
||||
});
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded, baseline);
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), ["echo popup-current-command"]);
|
||||
|
||||
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 4 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared, recorded);
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("fresh-popup"), []);
|
||||
});
|
||||
|
||||
test("a stale pending write cannot overwrite a peer clear before its storage event arrives", () => {
|
||||
clearHistory();
|
||||
recordCommand("old-command", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
recordCommand("pending-before-clear", "host-1");
|
||||
|
||||
// localStorage is already updated in the peer, but this renderer has not yet
|
||||
// processed its queued storage event when the pending save runs.
|
||||
const generation = readPersistedGeneration() + 1;
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, JSON.stringify({
|
||||
entries: [], version: 1, clearGeneration: generation,
|
||||
}));
|
||||
flushCommandHistoryStore();
|
||||
assert.deepEqual(readPersistedCommands(), []);
|
||||
assert.equal(readPersistedGeneration(), generation);
|
||||
assert.deepEqual(queryHistory("old", { hostId: "host-1" }), []);
|
||||
|
||||
recordCommand("new-after-clear", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
assert.deepEqual(readPersistedCommands(), ["new-after-clear"]);
|
||||
});
|
||||
|
||||
test("an ordinary peer write does not cancel a locally pending command", async () => {
|
||||
clearHistory();
|
||||
recordCommand("local-pending-command", "host-1");
|
||||
const before = storage.get(COMMAND_HISTORY_STORAGE_KEY)!;
|
||||
const ordinary = JSON.stringify({
|
||||
entries: [{ command: "peer-command", hostId: "host-2", os: "linux", frequency: 1, createdAt: 1, lastUsedAt: 1 }],
|
||||
version: 1, clearGeneration: getCommandHistoryClearGeneration(),
|
||||
});
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, ordinary);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, ordinary, before);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
assert.ok(readPersistedCommands().includes("local-pending-command"));
|
||||
});
|
||||
|
||||
test("an external clear invalidates the cached history and cannot be resurrected", () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
recordCommand("stale-command --flag", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
assert.ok(queryHistory("stale", { hostId: "host-1" }).length > 0);
|
||||
|
||||
// Simulate another renderer (main window or a detached terminal popup)
|
||||
// clearing the shared localStorage entry.
|
||||
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: 1 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
|
||||
// The stale module-local cache must be dropped immediately.
|
||||
assert.equal(queryHistory("stale", { hostId: "host-1" }).length, 0);
|
||||
|
||||
// A later recordCommand must persist only the new command, never the stale
|
||||
// snapshot it still held in memory.
|
||||
recordCommand("fresh-command", "host-1");
|
||||
flushCommandHistoryStore();
|
||||
assert.deepEqual(readPersistedCommands(), ["fresh-command"]);
|
||||
});
|
||||
|
||||
test("clearHistory persists synchronously and cancels a pending debounced write", async () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
recordCommand("pending-command", "host-1");
|
||||
// No flush: the debounced write is still scheduled.
|
||||
clearHistory();
|
||||
assert.deepEqual(readPersistedCommands(), []);
|
||||
|
||||
// Wait past the debounce window to prove the cancelled write cannot restore
|
||||
// the entries.
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
assert.deepEqual(readPersistedCommands(), []);
|
||||
});
|
||||
|
||||
test("a local clear bumps the generation and notifies subscribers", () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
const before = getCommandHistoryClearGeneration();
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeCommandHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
recordCommand("notify-local", "host-1");
|
||||
clearHistory();
|
||||
|
||||
assert.equal(getCommandHistoryClearGeneration(), before + 1);
|
||||
assert.equal(readPersistedGeneration(), before + 1);
|
||||
assert.equal(notified, 1);
|
||||
|
||||
unsubscribe();
|
||||
clearHistory();
|
||||
assert.equal(notified, 1);
|
||||
});
|
||||
|
||||
test("an external clear notifies subscribers, while an external record does not", () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
const baseline = getCommandHistoryClearGeneration();
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeCommandHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
// A normal record from another renderer keeps the generation: no clear event.
|
||||
const recorded = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, recorded);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, recorded);
|
||||
assert.equal(notified, 0);
|
||||
|
||||
// A clear from another renderer bumps the generation: subscribers fire.
|
||||
const cleared = JSON.stringify({ entries: [], version: 1, clearGeneration: baseline + 1 });
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
assert.equal(notified, 1);
|
||||
assert.equal(getCommandHistoryClearGeneration(), baseline + 1);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("a histfile import that started before a clear is discarded", async () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
|
||||
const hostId = `local-seed-clear-${Date.now()}`;
|
||||
const deferredReads: DeferredRead[] = [];
|
||||
(window as unknown as { netcatty?: unknown }).netcatty = {
|
||||
getHomeDir: async () => "/home/demo",
|
||||
readLocalFile: () =>
|
||||
new Promise<string>((resolve) => {
|
||||
deferredReads.push({ resolve });
|
||||
}),
|
||||
};
|
||||
|
||||
const seedPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
// Let the seeder resolve getHomeDir and reach the histfile reads.
|
||||
await waitFor(() => deferredReads.length === 4);
|
||||
|
||||
// The user clears history while the bridge reads are still pending.
|
||||
clearHistory();
|
||||
|
||||
for (const deferred of deferredReads) {
|
||||
deferred.resolve(": 1700000000:0;echo seeded\n");
|
||||
}
|
||||
|
||||
assert.equal(await seedPromise, 0);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
|
||||
|
||||
// The host is marked seeded so a later open cannot re-import the deleted
|
||||
// commands.
|
||||
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
|
||||
});
|
||||
|
||||
test("markCommandHistoryCleared persists a tombstone for histfile seeding", () => {
|
||||
storage.clear();
|
||||
assert.equal(hasCommandHistoryBeenCleared(), false);
|
||||
|
||||
markCommandHistoryCleared();
|
||||
|
||||
assert.equal(hasCommandHistoryBeenCleared(), true);
|
||||
assert.equal(storage.get(STORAGE_KEY_COMMAND_HISTORY_CLEARED), "true");
|
||||
});
|
||||
|
||||
test("a first-time histfile import is skipped once history has been cleared", async () => {
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
markCommandHistoryCleared();
|
||||
|
||||
const hostId = `local-seed-tombstone-${Date.now()}`;
|
||||
(window as unknown as { netcatty?: unknown }).netcatty = {
|
||||
getHomeDir: async () => "/home/demo",
|
||||
readLocalFile: async () => ": 1700000000:0;echo resurrected\n",
|
||||
};
|
||||
|
||||
assert.equal(await seedLocalShellHistoryFromHistfiles(hostId, "macos"), 0);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 0);
|
||||
});
|
||||
|
||||
test("an external clear also drops in-memory compose-bar recall", async () => {
|
||||
const composeBar = await import("./composeBarHistoryStore.ts");
|
||||
storage.clear();
|
||||
clearHistory();
|
||||
composeBar.pruneComposeBarHistory([]);
|
||||
composeBar.recordComposeBarHistory("session-1", "echo compose");
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), ["echo compose"]);
|
||||
|
||||
let notified = 0;
|
||||
const unsubscribe = composeBar.subscribeComposeBarHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
// Simulate another renderer clearing command history. The compose-bar store
|
||||
// follows the same clear event, so a detached popup drops its recall too.
|
||||
const cleared = JSON.stringify({
|
||||
entries: [],
|
||||
version: 1,
|
||||
clearGeneration: getCommandHistoryClearGeneration() + 1,
|
||||
});
|
||||
storage.set(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
dispatchStorageEvent(COMMAND_HISTORY_STORAGE_KEY, cleared);
|
||||
|
||||
assert.deepEqual(composeBar.getComposeBarHistory("session-1"), []);
|
||||
assert.equal(notified, 1);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory, clearComposeBarHistory, subscribeComposeBarHistoryCleared } from './composeBarHistoryStore';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
|
||||
|
||||
test('compose history is isolated per terminal and removed when its session closes', () => {
|
||||
pruneComposeBarHistory([]);
|
||||
@@ -40,24 +40,3 @@ test('failed submissions unblock later successes in submission order', async ()
|
||||
assert.deepEqual(getComposeBarHistory('a'), ['accepted']);
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('clearComposeBarHistory drops every session and notifies live compose bars', () => {
|
||||
pruneComposeBarHistory([]);
|
||||
recordComposeBarHistory('a', 'echo a');
|
||||
recordComposeBarHistory('b', 'echo b');
|
||||
|
||||
let notified = 0;
|
||||
const unsubscribe = subscribeComposeBarHistoryCleared(() => {
|
||||
notified += 1;
|
||||
});
|
||||
|
||||
clearComposeBarHistory();
|
||||
|
||||
assert.deepEqual(getComposeBarHistory('a'), []);
|
||||
assert.deepEqual(getComposeBarHistory('b'), []);
|
||||
assert.equal(notified, 1);
|
||||
|
||||
unsubscribe();
|
||||
clearComposeBarHistory();
|
||||
assert.equal(notified, 1);
|
||||
});
|
||||
@@ -1,14 +1,10 @@
|
||||
import { appendComposeBarHistory } from '../../domain/composeBarHistory';
|
||||
import { subscribeCommandHistoryCleared } from './commandHistoryStore';
|
||||
|
||||
// Session-only memory: survives closing the bar and moving a terminal into a
|
||||
// workspace, but is discarded when that terminal session is removed.
|
||||
type PendingSend = { settled: boolean; command?: string };
|
||||
const histories = new Map<string, { entries: readonly string[]; pending: PendingSend[] }>();
|
||||
|
||||
type Listener = () => void;
|
||||
const clearedListeners = new Set<Listener>();
|
||||
|
||||
export function getComposeBarHistory(sessionId: string): readonly string[] {
|
||||
return histories.get(sessionId)?.entries ?? [];
|
||||
}
|
||||
@@ -48,36 +44,3 @@ export function pruneComposeBarHistory(sessionIds: readonly string[]): void {
|
||||
if (!active.has(id)) histories.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to full compose-bar history clears. A live compose bar uses this to
|
||||
* drop the history walk it cached, so ArrowUp cannot recall deleted commands.
|
||||
*/
|
||||
export function subscribeComposeBarHistoryCleared(listener: Listener): () => void {
|
||||
clearedListeners.add(listener);
|
||||
return () => {
|
||||
clearedListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the in-memory compose-bar history for every session.
|
||||
*
|
||||
* This store is session-only (never persisted), so the "clear all command
|
||||
* history" action must clear it explicitly. Otherwise a command sent through
|
||||
* the compose bar stays recallable, and resending it would re-record it into
|
||||
* shell history and autocomplete after the user deleted it.
|
||||
*/
|
||||
export function clearComposeBarHistory(): void {
|
||||
histories.clear();
|
||||
// Always notify: a live compose bar can still hold a cached walk even when
|
||||
// the map is already empty (for example after its session was pruned).
|
||||
for (const listener of [...clearedListeners]) listener();
|
||||
}
|
||||
|
||||
// The compose bar holds the most recent commands sent through it, so clearing
|
||||
// command history must drop it too. Subscribing to the command-history clear
|
||||
// event covers every renderer: a detached terminal popup renders its own
|
||||
// TerminalComposeBar and learns about a main-window clear through the shared
|
||||
// clear generation, while the main window is notified synchronously.
|
||||
subscribeCommandHistoryCleared(clearComposeBarHistory);
|
||||
@@ -1,244 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createHostTerminalSession } from "./sessionFactories.ts";
|
||||
import { createLocalTerminalSession } from "./sessionFactories.ts";
|
||||
import { resolveHostDefaultLocalShell } from "../../domain/localShellHost";
|
||||
import { resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
|
||||
import type { Host } from "../../domain/models";
|
||||
|
||||
const localVaultHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "local-vault-1",
|
||||
label: "Project A CMD",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
group: "Projects/A",
|
||||
tags: ["local"],
|
||||
os: "linux",
|
||||
protocol: "local",
|
||||
createdAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("createHostTerminalSession copies local shell fields for local vault hosts", () => {
|
||||
const host = localVaultHost({
|
||||
localShell: "wsl-ubuntu",
|
||||
localShellArgs: ["--cd", "/work"],
|
||||
localShellName: "Ubuntu (WSL)",
|
||||
localShellIcon: "ubuntu",
|
||||
localStartDir: "/work",
|
||||
});
|
||||
const session = createHostTerminalSession("session-1", host);
|
||||
|
||||
assert.equal(session.protocol, "local");
|
||||
assert.equal(session.hostId, "local-vault-1");
|
||||
assert.equal(session.localShell, "wsl-ubuntu");
|
||||
assert.deepEqual(session.localShellArgs, ["--cd", "/work"]);
|
||||
assert.equal(session.localShellName, "Ubuntu (WSL)");
|
||||
assert.equal(session.localShellIcon, "ubuntu");
|
||||
assert.equal(session.localStartDir, "/work");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession does not add local shell fields to SSH hosts", () => {
|
||||
const host: Host = {
|
||||
...localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 }),
|
||||
} as Host;
|
||||
const session = createHostTerminalSession("session-2", host);
|
||||
|
||||
assert.equal(session.protocol, "ssh");
|
||||
assert.equal(session.localShell, undefined);
|
||||
assert.equal(session.localShellName, undefined);
|
||||
assert.equal(session.shellType, undefined);
|
||||
});
|
||||
|
||||
test("createHostTerminalSession classifies the shell for CMD/PowerShell local vault hosts", () => {
|
||||
const cmd = createHostTerminalSession("session-cmd", localVaultHost({ localShell: "cmd", os: "windows" }));
|
||||
assert.equal(cmd.shellType, "cmd");
|
||||
|
||||
const pwsh = createHostTerminalSession("session-pwsh", localVaultHost({ localShell: "pwsh", os: "windows" }));
|
||||
assert.equal(pwsh.shellType, "powershell");
|
||||
|
||||
// Discovered shell IDs persisted by the local-shell editor are not
|
||||
// executable basenames, but they still resolve to POSIX shells at spawn
|
||||
// time — WSL distros, Git Bash, and Cygwin all run a POSIX shell.
|
||||
const wslId = createHostTerminalSession("session-wsl-id", localVaultHost({ localShell: "wsl-ubuntu", os: "windows" }));
|
||||
assert.equal(wslId.shellType, "posix");
|
||||
|
||||
const gitBash = createHostTerminalSession("session-git-bash", localVaultHost({ localShell: "git-bash", os: "windows" }));
|
||||
assert.equal(gitBash.shellType, "posix");
|
||||
|
||||
const cygwin = createHostTerminalSession("session-cygwin", localVaultHost({ localShell: "cygwin", os: "windows" }));
|
||||
assert.equal(cygwin.shellType, "posix");
|
||||
|
||||
// Unix discovery emits disambiguated path-slug IDs when multiple
|
||||
// installations share a basename (e.g. /bin/bash vs
|
||||
// /opt/homebrew/bin/bash); the backend launches the resolved command, so
|
||||
// the session must classify as that command's shell type, not "unknown".
|
||||
const slugBash = createHostTerminalSession("session-slug-bash", localVaultHost({ localShell: "bin-bash", os: "macos" }));
|
||||
assert.equal(slugBash.shellType, "posix");
|
||||
|
||||
const slugHomebrewBash = createHostTerminalSession(
|
||||
"session-slug-homebrew",
|
||||
localVaultHost({ localShell: "opt-homebrew-bin-bash", os: "macos" }),
|
||||
);
|
||||
assert.equal(slugHomebrewBash.shellType, "posix");
|
||||
|
||||
const customPowershellPath = createHostTerminalSession(
|
||||
"session-pwsh-path",
|
||||
localVaultHost({ localShell: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", os: "windows" }),
|
||||
);
|
||||
assert.equal(customPowershellPath.shellType, "powershell");
|
||||
|
||||
// Default-shell hosts (empty localShell) launch the machine's OS default
|
||||
// shell (the backend's own detection), so classification must use the
|
||||
// runtime platform — not the saved host.os, which may describe a machine
|
||||
// the host was synced/imported from. Node 22's navigator.userAgent is
|
||||
// always "Node.js/<version>", so this test environment always classifies
|
||||
// as POSIX regardless of the CI runner's real OS.
|
||||
const windowsSyncedDefault = createHostTerminalSession("session-win-default", localVaultHost({ os: "windows" }));
|
||||
assert.equal(windowsSyncedDefault.shellType, "posix");
|
||||
|
||||
const posixDefault = createHostTerminalSession("session-posix-default", localVaultHost({ os: "linux" }));
|
||||
assert.equal(posixDefault.shellType, "posix");
|
||||
|
||||
const unknownCustom = createHostTerminalSession(
|
||||
"session-unknown",
|
||||
localVaultHost({ localShell: "/usr/local/bin/exotic-shell", os: "linux" }),
|
||||
);
|
||||
assert.equal(unknownCustom.shellType, "unknown");
|
||||
});
|
||||
|
||||
test("createLocalTerminalSession is unaffected by vault local hosts", () => {
|
||||
const session = createLocalTerminalSession("session-3", { shellName: "CMD" });
|
||||
assert.equal(session.hostId, "local-terminal");
|
||||
assert.equal(session.localShellName, "CMD");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an explicit no-args local shell as an empty array", () => {
|
||||
// Legacy vault hosts can predate localShellArgs, so the field may be
|
||||
// absent on the Host; the session snapshot must still distinguish
|
||||
// "launched with no args" ([]) from "no metadata" (undefined).
|
||||
const host = localVaultHost({
|
||||
localShell: "cmd",
|
||||
localShellName: "Command Prompt",
|
||||
os: "windows",
|
||||
});
|
||||
const session = createHostTerminalSession("session-noargs", host);
|
||||
|
||||
assert.ok(Array.isArray(session.localShellArgs), "args snapshot must be present");
|
||||
assert.deepEqual(session.localShellArgs, []);
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost does not merge edited host args into a no-args local snapshot", () => {
|
||||
const launchTimeHost = localVaultHost({
|
||||
localShell: "cmd",
|
||||
localShellName: "Command Prompt",
|
||||
os: "windows",
|
||||
});
|
||||
const session = createHostTerminalSession("session-1", launchTimeHost);
|
||||
|
||||
// The saved host was later edited to a shell with args; the snapshot taken
|
||||
// at launch time must win so the clone relaunches CMD, not WSL.
|
||||
const editedHost: Host = {
|
||||
...launchTimeHost,
|
||||
label: "Edited WSL",
|
||||
localShell: "wsl-ubuntu",
|
||||
localShellArgs: ["--cd", "/elsewhere"],
|
||||
localShellName: "Ubuntu (WSL)",
|
||||
localStartDir: "/elsewhere",
|
||||
};
|
||||
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [editedHost],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "windows",
|
||||
});
|
||||
|
||||
assert.equal(resolved.localShell, "cmd");
|
||||
assert.deepEqual(resolved.localShellArgs, []);
|
||||
assert.equal(resolved.localShellName, "Command Prompt");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an inherited start directory for Copy Tab", () => {
|
||||
// The saved host has no directory of its own, so the starter launches in
|
||||
// Settings > Terminal's localStartDir. That effective directory has to be
|
||||
// recorded now; overlay falls back to the host or the current setting when
|
||||
// the session field is missing.
|
||||
const saved = localVaultHost({
|
||||
localShell: "bash",
|
||||
localShellName: "Bash",
|
||||
localStartDir: "",
|
||||
});
|
||||
const effective = resolveHostDefaultLocalShell(saved, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
const session = createHostTerminalSession("session-inherit", effective);
|
||||
assert.equal(session.localStartDir, "/home/alice/global");
|
||||
|
||||
const changedSettings = {
|
||||
terminalSettings: { localShell: "fish", localStartDir: "/home/alice/changed" },
|
||||
};
|
||||
const afterSettingsChange = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [saved],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "linux",
|
||||
defaultLocalShell: changedSettings,
|
||||
});
|
||||
assert.equal(afterSettingsChange.localStartDir, "/home/alice/global");
|
||||
|
||||
const afterHostEdit = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [{ ...saved, localStartDir: "/home/alice/edited" }],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "linux",
|
||||
defaultLocalShell: changedSettings,
|
||||
});
|
||||
assert.equal(afterHostEdit.localStartDir, "/home/alice/global");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an inherited start directory for a default shell host", () => {
|
||||
const saved = localVaultHost();
|
||||
const effective = resolveHostDefaultLocalShell(saved, {
|
||||
terminalSettings: { localShell: "bash", localStartDir: "/home/alice/global" },
|
||||
resolveShellSetting: () => ({ command: "/bin/bash", args: ["-l"] }),
|
||||
});
|
||||
const session = createHostTerminalSession("session-default-shell", effective);
|
||||
|
||||
assert.equal(session.localShell, "/bin/bash");
|
||||
assert.deepEqual(session.localShellArgs, ["-l"]);
|
||||
assert.equal(session.localStartDir, "/home/alice/global");
|
||||
});
|
||||
|
||||
test("resolveHostDefaultLocalShell keeps an explicit start directory and ignores non-local hosts", () => {
|
||||
const explicit = localVaultHost({ localShell: "bash", localStartDir: "/work" });
|
||||
const resolvedExplicit = resolveHostDefaultLocalShell(explicit, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
assert.equal(resolvedExplicit.localStartDir, "/work");
|
||||
assert.equal(createHostTerminalSession("session-explicit", resolvedExplicit).localStartDir, "/work");
|
||||
|
||||
const ssh = localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 });
|
||||
const resolvedSsh = resolveHostDefaultLocalShell(ssh, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
assert.equal(resolvedSsh.localStartDir, undefined);
|
||||
assert.equal(resolvedSsh, ssh);
|
||||
});
|
||||
|
||||
test("saved local sessions use the ordinary local username marker without changing vault or SSH credentials", () => {
|
||||
const saved = localVaultHost({ username: "" });
|
||||
const ordinary = createLocalTerminalSession("ordinary-local");
|
||||
const opened = createHostTerminalSession("saved-local", saved);
|
||||
assert.equal(opened.username, ordinary.username);
|
||||
assert.equal(opened.username, "local");
|
||||
assert.equal(saved.username, "");
|
||||
const remote = { ...saved, protocol: "ssh" as const, username: "remote-user" };
|
||||
assert.equal(createHostTerminalSession("ssh-session", remote).username, "remote-user");
|
||||
const resolved = resolveTerminalSessionHost({ session: opened, hosts: [saved], groupConfigs: [], proxyProfiles: [], localOs: "linux" });
|
||||
assert.equal(resolved.username, ordinary.username, "terminal-run scripts receive the same local marker");
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
|
||||
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||
import {
|
||||
createHostTerminalSession,
|
||||
createSerialTerminalSession,
|
||||
@@ -171,16 +171,3 @@ test("host session factories snapshot plugin connection configuration", () => {
|
||||
assert.deepEqual(workspace.pluginConnection, pluginConnection);
|
||||
assert.notEqual(workspace.pluginConnection, pluginConnection);
|
||||
});
|
||||
|
||||
test("local sessions preserve intentionally absent display metadata when the saved shell changes", () => {
|
||||
const original = host({ protocol: "local", hostname: "localhost", localShell: "/bin/bash" });
|
||||
const source = createHostTerminalSession("original-bash", original);
|
||||
const edited = { ...original, localShell: "/bin/zsh", localShellName: "Zsh", localShellIcon: "zsh" };
|
||||
const copied = resolveTerminalSessionHost({ session: source, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: "macos" });
|
||||
assert.equal(copied.localShell, "/bin/bash");
|
||||
assert.equal(copied.localShellName, "");
|
||||
assert.equal(copied.localShellIcon, "");
|
||||
const named = createHostTerminalSession("named-zsh", edited);
|
||||
assert.equal(named.localShellName, "Zsh");
|
||||
assert.equal(named.localShellIcon, "zsh");
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
export interface LocalTerminalOptions {
|
||||
shellType?: TerminalSession["shellType"];
|
||||
@@ -19,28 +18,6 @@ export interface LocalTerminalOptions {
|
||||
*/
|
||||
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
|
||||
|
||||
/**
|
||||
* [] captures the absence of custom arguments instead of inheriting them
|
||||
* from a later host edit. The starter still applies backend defaults to []
|
||||
* and undefined alike. Share the array to preserve pane equality checks.
|
||||
*/
|
||||
const EMPTY_LOCAL_SHELL_ARGS: string[] = [];
|
||||
|
||||
/**
|
||||
* Platform string describing the machine that is launching the session.
|
||||
*
|
||||
* Vault local hosts can be synced/imported from another OS, so their saved
|
||||
* `host.os` describes the source machine. When the host has no explicit
|
||||
* shell (`localShell` empty), `startLocalSession` spawns the current
|
||||
* machine's OS default shell, so classification must fall back to the
|
||||
* runtime platform — not the persisted host OS — or a Windows host opened
|
||||
* on Linux would be tagged "powershell" while Bash actually runs.
|
||||
*/
|
||||
const runtimePlatformLike = (): string | undefined => {
|
||||
if (typeof navigator === "undefined") return undefined;
|
||||
return navigator.userAgent || navigator.platform || undefined;
|
||||
};
|
||||
|
||||
export const createLocalTerminalSession = (
|
||||
sessionId: string,
|
||||
options?: LocalTerminalOptions,
|
||||
@@ -136,30 +113,6 @@ export const createHostTerminalSession = (
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
// Local-shell vault hosts carry the per-host shell selection into the
|
||||
// session (the bridge resolves discovered shell ids at spawn time).
|
||||
// shellType matches ordinary local-terminal creation so captured-CWD
|
||||
// restore can reject POSIX injection into CMD/PowerShell sessions.
|
||||
...(host.protocol === "local" ? {
|
||||
username: "local",
|
||||
// With an explicit shell the basename classifies it on any OS, so the
|
||||
// saved host.os stays the fallback; a default-shell host launches the
|
||||
// current machine's OS default, so classify against the runtime.
|
||||
shellType: classifyLocalShellType(
|
||||
host.localShell,
|
||||
host.localShell?.trim() ? host.os : runtimePlatformLike(),
|
||||
),
|
||||
localShell: host.localShell ?? "",
|
||||
// Preserve field presence: an edited host must not contribute its args
|
||||
// to a session that was launched with an explicit shell and no args.
|
||||
localShellArgs: host.localShellArgs ?? EMPTY_LOCAL_SHELL_ARGS,
|
||||
localShellName: host.localShellName ?? "",
|
||||
localShellIcon: host.localShellIcon ?? "",
|
||||
// Effective directory from resolveHostDefaultLocalShell, including a
|
||||
// Settings directory the saved host inherits. Copy Tab overlays this
|
||||
// snapshot, so a later settings or vault edit cannot move the relaunch.
|
||||
localStartDir: host.localStartDir || "~",
|
||||
} : {}),
|
||||
...(host.ephemeral ? { ephemeralHost: true } : {}),
|
||||
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
|
||||
};
|
||||
|
||||
@@ -148,8 +148,7 @@ test("session peer windows do not run main-window startup effects", () => {
|
||||
assert.match(settingsStateSource, /const enableSettingsSync = options\.enableSettingsSync !== false/);
|
||||
assert.match(settingsStateSource, /useSettingsIpcSync\(\{[^}]*enabled: enableSettingsSync/s);
|
||||
assert.match(settingsStateSource, /useSettingsStorageSync\(\{[^}]*enabled: enableSettingsSync/s);
|
||||
// Detached windows share opacity, while general settings broadcasts stay off.
|
||||
assert.match(settingsStateSource, /if \(!enableSettingsSync && key !== STORAGE_KEY_WINDOW_OPACITY\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
|
||||
assert.match(settingsStateSource, /if \(!enableSettingsSync\) return;\s*try \{\s*netcattyBridge\.get\(\)\?\.notifySettingsChanged/s);
|
||||
assert.match(settingsStateSource, /useSystemSettingsEffects\(\{[^}]*enabled: enableSystemEffects/s);
|
||||
assert.match(settingsIpcSyncSource, /enabled\?: boolean/);
|
||||
assert.match(settingsIpcSyncSource, /if \(!enabled\) return;/);
|
||||
|
||||
@@ -133,11 +133,6 @@ export type OpenTransferSftpSessionOptions = {
|
||||
* credentials. When false, try sourceSessionId first (shared transport).
|
||||
*/
|
||||
dedicated?: boolean;
|
||||
/**
|
||||
* Connect-time jump-host snapshots. When set, this session dials through the
|
||||
* pinned chain the pane connected with, not the live (possibly edited) chain.
|
||||
*/
|
||||
jumpHosts?: Host[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -164,7 +159,6 @@ export async function openTransferSftpSession(
|
||||
identities: [...deps.identities],
|
||||
knownHosts: deps.knownHosts ? [...deps.knownHosts] : undefined,
|
||||
terminalSettings: deps.terminalSettings,
|
||||
jumpHostsOverride: options?.jumpHosts,
|
||||
});
|
||||
// Shared transport path: open an SFTP channel on the already-authenticated
|
||||
// terminal connection. The transport registry lease keeps the conn alive
|
||||
|
||||
@@ -137,26 +137,11 @@ async function tryStatTransferTarget(
|
||||
return tryStatTransferPath(targetPath, targetIsLocal, targetSftpId, targetEncoding);
|
||||
}
|
||||
|
||||
export interface TransferConnectHosts {
|
||||
source?: Host;
|
||||
target?: Host;
|
||||
/**
|
||||
* Connect-time jump-host chain snapshots. Pinned together with the top-level
|
||||
* host so leases opened after a bastion edit still dial through the chain the
|
||||
* pane's session connected with (private hostnames can resolve differently
|
||||
* per bastion). Present only when the matching endpoint is pinned.
|
||||
*/
|
||||
sourceChain?: Host[];
|
||||
targetChain?: Host[];
|
||||
}
|
||||
|
||||
export type AcquireTransferSessionFn = (
|
||||
hostId: string,
|
||||
transferId: string,
|
||||
/** Connect-time host (session hostname/port/user overrides). Prefer over vault. */
|
||||
connectHost?: Host,
|
||||
/** Connect-time jump-host snapshots; undefined re-resolves the live chain. */
|
||||
jumpHosts?: Host[],
|
||||
) => Promise<TransferConnectionLease>;
|
||||
|
||||
interface UseSftpDirectoryTransferOpsParams {
|
||||
@@ -283,7 +268,6 @@ export function useSftpDirectoryTransferOps({
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string, // The original top-level task ID for cancellation checking
|
||||
sameHost?: boolean,
|
||||
connectHosts?: TransferConnectHosts,
|
||||
): Promise<void> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -331,14 +315,10 @@ export function useSftpDirectoryTransferOps({
|
||||
// Dedicated pool only for remote ends — panel/browse sessions die when
|
||||
// the SFTP tab is closed, which freezes global transfer center rows.
|
||||
if (acquireTransferSession && !sourceIsLocal && task.sourceHostId) {
|
||||
sourceLease = await acquireTransferSession(
|
||||
task.sourceHostId, task.id, connectHosts?.source, connectHosts?.sourceChain,
|
||||
);
|
||||
sourceLease = await acquireTransferSession(task.sourceHostId, task.id);
|
||||
}
|
||||
if (acquireTransferSession && !targetIsLocal && task.targetHostId) {
|
||||
targetLease = await acquireTransferSession(
|
||||
task.targetHostId, task.id, connectHosts?.target, connectHosts?.targetChain,
|
||||
);
|
||||
targetLease = await acquireTransferSession(task.targetHostId, task.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -561,7 +541,6 @@ export function useSftpDirectoryTransferOps({
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
discoveryProgress?: DirectoryDiscoveryProgress,
|
||||
traversalBudget?: SftpDirectoryTraversalBudget,
|
||||
connectHosts?: TransferConnectHosts,
|
||||
) => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
@@ -719,7 +698,6 @@ export function useSftpDirectoryTransferOps({
|
||||
followSymlinks,
|
||||
progress,
|
||||
traversal,
|
||||
connectHosts,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -947,7 +925,6 @@ export function useSftpDirectoryTransferOps({
|
||||
targetEncoding,
|
||||
rootTaskId,
|
||||
sameHost,
|
||||
connectHosts,
|
||||
);
|
||||
|
||||
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
|
||||
|
||||
@@ -42,12 +42,6 @@ interface UseSftpConnectionsParams {
|
||||
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||||
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
|
||||
connectedHostByTabIdRef: MutableRefObject<Map<string, Host | "local">>;
|
||||
/**
|
||||
* Jump-host chain snapshots captured when each tab connected, keyed by tab
|
||||
* id. Same lifecycle as connectedHostByTabIdRef; used to keep transfer
|
||||
* session opens on the pane's connect-time bastion chain after chain edits.
|
||||
*/
|
||||
connectedHostChainByTabIdRef?: MutableRefObject<Map<string, Host[]>>;
|
||||
connectInFlightRef: MutableRefObject<Map<string, Promise<void>>>;
|
||||
connectRequestByTabIdRef: MutableRefObject<Map<string, symbol>>;
|
||||
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||||
@@ -421,15 +415,12 @@ export async function closeSftpTabLifecycle(params: {
|
||||
connectRequests: Map<string, symbol>;
|
||||
connectInFlight: Map<string, Promise<void>>;
|
||||
connectedHosts: { delete: (tabId: string) => boolean };
|
||||
/** Connect-time jump-host chain snapshots, same tab-id lifecycle. */
|
||||
connectedHostChains?: { delete: (tabId: string) => boolean };
|
||||
closeTab: (side: "left" | "right") => void;
|
||||
releaseConnection: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
invalidateSftpTabConnectRequest(params.connectRequests, params.tabId);
|
||||
clearSftpConnectInFlightForTab(params.connectInFlight, params.tabId);
|
||||
params.connectedHosts.delete(params.tabId);
|
||||
params.connectedHostChains?.delete(params.tabId);
|
||||
applyToLiveSftpTabSide({
|
||||
requestedSide: params.requestedSide,
|
||||
tabId: params.tabId,
|
||||
@@ -590,7 +581,6 @@ export const useSftpConnections = ({
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
connectedHostByTabIdRef,
|
||||
connectedHostChainByTabIdRef,
|
||||
connectInFlightRef,
|
||||
connectRequestByTabIdRef,
|
||||
makeCacheKey,
|
||||
@@ -873,24 +863,6 @@ export const useSftpConnections = ({
|
||||
// Always remember the full connect-time Host for this tab so background
|
||||
// upload reconnects can restore session-time overrides (not just vault hostId).
|
||||
connectedHostByTabIdRef.current.set(activeTabId, host);
|
||||
// Snapshot the resolved jump-host chain so retained transfer leases keep
|
||||
// dialing the chain this tab connected through, even after one of the
|
||||
// bastion hosts is edited (private hostnames can resolve differently
|
||||
// per bastion — the live chain must not silently replace this one).
|
||||
if (connectedHostChainByTabIdRef) {
|
||||
if (host !== "local" && host.hostChain?.hostIds?.length) {
|
||||
const chain = host.hostChain.hostIds
|
||||
.map((chainId) => hosts.find((candidate) => candidate.id === chainId))
|
||||
.filter((candidate): candidate is Host => !!candidate);
|
||||
if (chain.length === host.hostChain.hostIds.length) {
|
||||
connectedHostChainByTabIdRef.current.set(activeTabId, chain);
|
||||
} else {
|
||||
connectedHostChainByTabIdRef.current.delete(activeTabId);
|
||||
}
|
||||
} else {
|
||||
connectedHostChainByTabIdRef.current.delete(activeTabId);
|
||||
}
|
||||
}
|
||||
// Store the cache key for this connection so pane actions can look it up
|
||||
// by connectionId instead of relying on the per-side lastConnectedHostRef.
|
||||
if (host !== "local") {
|
||||
@@ -1454,7 +1426,6 @@ export const useSftpConnections = ({
|
||||
|
||||
lastConnectedHostRef.current[side] = null;
|
||||
connectedHostByTabIdRef.current.delete(activeTabId);
|
||||
connectedHostChainByTabIdRef?.current.delete(activeTabId);
|
||||
|
||||
if (pane.connection && !pane.connection.isLocal && sftpId) {
|
||||
try {
|
||||
|
||||
@@ -148,7 +148,6 @@ export const useSftpExternalOperations = (
|
||||
connectionCacheKeyMapRef,
|
||||
ensureRemoteSftpId,
|
||||
resolveConnectedHost,
|
||||
resolveConnectedChain,
|
||||
acquireTransferSession,
|
||||
clearDirCacheEntry,
|
||||
useCompressedUpload = false,
|
||||
@@ -166,15 +165,6 @@ export const useSftpExternalOperations = (
|
||||
[resolveConnectedHost],
|
||||
);
|
||||
|
||||
/** Jump-host chain the tab dialed at connect time, when still meaningful. */
|
||||
const resolveUploadConnectChain = useCallback(
|
||||
(tabId: string, isLocal: boolean): Host[] | undefined => {
|
||||
if (isLocal || !resolveConnectedChain) return undefined;
|
||||
return resolveConnectedChain(tabId) ?? undefined;
|
||||
},
|
||||
[resolveConnectedChain],
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve an SFTP id for upload prep (mkdir/stat/conflict checks).
|
||||
* File bytes use dedicated pool connections inside startStreamTransfer
|
||||
@@ -853,7 +843,6 @@ export const useSftpExternalOperations = (
|
||||
// (session hostname/port/user overrides), not the vault entry by hostId alone.
|
||||
const createUploadBridge = useCallback((
|
||||
connectHost?: Host,
|
||||
connectChain?: Host[],
|
||||
readTransferConcurrency = () => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
): UploadBridge => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -931,7 +920,6 @@ export const useSftpExternalOperations = (
|
||||
options.targetHostId,
|
||||
options.transferId,
|
||||
connectHost,
|
||||
connectChain,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1054,10 +1042,8 @@ export const useSftpExternalOperations = (
|
||||
const uploadPaneId = livePane.id;
|
||||
const liveTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(
|
||||
connectHost,
|
||||
connectChain,
|
||||
readSftpFileTransferConcurrency,
|
||||
);
|
||||
const liveCallbacks = livePane.connection.id === pane.connection.id
|
||||
@@ -1223,8 +1209,7 @@ export const useSftpExternalOperations = (
|
||||
const uploadPaneId = livePane.id;
|
||||
const liveTargetPath = targetPath || livePane.connection.currentPath;
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(connectHost, connectChain);
|
||||
const uploadBridge = createUploadBridge(connectHost);
|
||||
|
||||
// Retarget callbacks if the pane path/id changed after reconnect.
|
||||
const liveCallbacks = livePane.connection.id === pane.connection.id
|
||||
@@ -1267,7 +1252,7 @@ export const useSftpExternalOperations = (
|
||||
jobId: `compressed-upload-${crypto.randomUUID()}`,
|
||||
prepSftpId: sftpId,
|
||||
acquire: acquireTransferSession
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
|
||||
: undefined,
|
||||
shouldDiscard: isSessionError,
|
||||
run: async (uploadSftpId) => uploadEntriesDirect(
|
||||
@@ -1346,7 +1331,6 @@ export const useSftpExternalOperations = (
|
||||
refresh,
|
||||
resolveRemoteSftpId,
|
||||
resolveUploadConnectHost,
|
||||
resolveUploadConnectChain,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
@@ -1383,8 +1367,7 @@ export const useSftpExternalOperations = (
|
||||
),
|
||||
);
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
|
||||
const uploadBridge = createUploadBridge(connectHost, connectChain);
|
||||
const uploadBridge = createUploadBridge(connectHost);
|
||||
|
||||
try {
|
||||
const files = Array.from(fileList);
|
||||
@@ -1399,7 +1382,7 @@ export const useSftpExternalOperations = (
|
||||
jobId: `compressed-upload-${crypto.randomUUID()}`,
|
||||
prepSftpId: sftpId,
|
||||
acquire: acquireTransferSession
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
|
||||
: undefined,
|
||||
shouldDiscard: isSessionError,
|
||||
run: async (uploadSftpId) => uploadFromFileList(
|
||||
@@ -1458,7 +1441,6 @@ export const useSftpExternalOperations = (
|
||||
refresh,
|
||||
resolveRemoteSftpId,
|
||||
resolveUploadConnectHost,
|
||||
resolveUploadConnectChain,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
@@ -1575,11 +1557,7 @@ export const useSftpExternalOperations = (
|
||||
uploadPaneId,
|
||||
livePane.connection.isLocal,
|
||||
);
|
||||
const uploadConnectChain = resolveUploadConnectChain(
|
||||
uploadPaneId,
|
||||
livePane.connection.isLocal,
|
||||
);
|
||||
const uploadBridge = createUploadBridge(uploadConnectHost, uploadConnectChain);
|
||||
const uploadBridge = createUploadBridge(uploadConnectHost);
|
||||
const liveCallbacks = bindUploadControllerCallbacks(
|
||||
controller,
|
||||
createUploadCallbacks(
|
||||
@@ -1625,7 +1603,7 @@ export const useSftpExternalOperations = (
|
||||
jobId: `compressed-upload-${crypto.randomUUID()}`,
|
||||
prepSftpId: sftpId,
|
||||
acquire: acquireTransferSession
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, uploadConnectHost, uploadConnectChain)
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, uploadConnectHost)
|
||||
: undefined,
|
||||
shouldDiscard: isSessionError,
|
||||
run: async (uploadSftpId) => uploadEntriesDirect(
|
||||
@@ -1709,7 +1687,6 @@ export const useSftpExternalOperations = (
|
||||
refresh,
|
||||
resolveRemoteSftpId,
|
||||
resolveUploadConnectHost,
|
||||
resolveUploadConnectChain,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
@@ -1798,8 +1775,7 @@ export const useSftpExternalOperations = (
|
||||
),
|
||||
);
|
||||
const connectHost = resolveUploadConnectHost(uploadPaneId, livePane.connection.isLocal);
|
||||
const connectChain = resolveUploadConnectChain(uploadPaneId, livePane.connection.isLocal);
|
||||
const directUploadBridge = createUploadBridge(connectHost, connectChain);
|
||||
const directUploadBridge = createUploadBridge(connectHost);
|
||||
|
||||
try {
|
||||
const hasDirectory = entries.some((entry) => (
|
||||
@@ -1813,7 +1789,7 @@ export const useSftpExternalOperations = (
|
||||
jobId: `compressed-upload-${crypto.randomUUID()}`,
|
||||
prepSftpId: sftpId,
|
||||
acquire: acquireTransferSession
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost, connectChain)
|
||||
? (hostId, jobId) => acquireTransferSession(hostId, jobId, connectHost)
|
||||
: undefined,
|
||||
shouldDiscard: isSessionError,
|
||||
run: async (uploadSftpId) => uploadEntriesDirect(
|
||||
@@ -1880,7 +1856,6 @@ export const useSftpExternalOperations = (
|
||||
refresh,
|
||||
resolveRemoteSftpId,
|
||||
resolveUploadConnectHost,
|
||||
resolveUploadConnectChain,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -32,12 +32,6 @@ export interface UseSftpExternalOperationsParams {
|
||||
* Used so pooled stream uploads open the pinned browse endpoint.
|
||||
*/
|
||||
resolveConnectedHost?: (tabId: string) => Host | "local" | null | undefined;
|
||||
/**
|
||||
* Jump-host chain snapshots captured when the tab connected, keyed by tab id.
|
||||
* Lets pooled uploads keep dialing the pane's connect-time bastion chain
|
||||
* after a chain host is edited.
|
||||
*/
|
||||
resolveConnectedChain?: (tabId: string) => Host[] | null | undefined;
|
||||
/**
|
||||
* FileZilla-style dedicated transfer sessions for bulk uploads.
|
||||
* When set, remote stream uploads prefer pool connections (1–2/host)
|
||||
@@ -47,7 +41,6 @@ export interface UseSftpExternalOperationsParams {
|
||||
hostId: string,
|
||||
transferId: string,
|
||||
connectHost?: Host,
|
||||
connectChain?: Host[],
|
||||
) => Promise<{ sftpId: string; release: () => void; discard: () => void }>;
|
||||
clearDirCacheEntry?: (connectionId: string, path: string) => void;
|
||||
useCompressedUpload?: boolean;
|
||||
|
||||
@@ -707,30 +707,3 @@ test("imported IdentityFile paths remain usable when agent login is disabled", (
|
||||
assert.equal(credentials.useSshAgent, false);
|
||||
assert.deepEqual(credentials.identityFilePaths, ["~/.ssh/id_work"]);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials pins the captured jump chain over edited vault hosts", () => {
|
||||
const oldBastion = host({ id: "jump-1", hostname: "bastion-internal.old" });
|
||||
const editedBastion = host({ id: "jump-1", hostname: "bastion-internal.edited" });
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [editedBastion],
|
||||
keys: [],
|
||||
identities: [],
|
||||
jumpHostsOverride: [oldBastion],
|
||||
});
|
||||
|
||||
assert.equal(credentials.jumpHosts?.[0]?.hostname, "bastion-internal.old");
|
||||
assert.equal(credentials.jumpHosts?.[0]?.hostId, "jump-1");
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials re-resolves the live chain without a pinned snapshot", () => {
|
||||
const editedBastion = host({ id: "jump-1", hostname: "bastion-internal.edited" });
|
||||
const credentials = buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [editedBastion],
|
||||
keys: [],
|
||||
identities: [],
|
||||
});
|
||||
|
||||
assert.equal(credentials.jumpHosts?.[0]?.hostname, "bastion-internal.edited");
|
||||
});
|
||||
@@ -38,17 +38,7 @@ export const buildSftpHostCredentials = ({
|
||||
identities,
|
||||
knownHosts,
|
||||
terminalSettings,
|
||||
jumpHostsOverride,
|
||||
}: UseSftpHostCredentialsParams & {
|
||||
host: Host;
|
||||
/**
|
||||
* Connect-time jump-host snapshots. When set, the chain resolves from these
|
||||
* pinned Host objects instead of the live `hosts` array, so leases opened
|
||||
* after a bastion edit keep connecting through the chain the pane's session
|
||||
* dialed — private hostnames can resolve differently per bastion.
|
||||
*/
|
||||
jumpHostsOverride?: Host[];
|
||||
}): NetcattySSHOptions => {
|
||||
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
|
||||
const globalTerminalSettings = { ...FALLBACK_TERMINAL_SETTINGS, ...(terminalSettings ?? {}) };
|
||||
if (host.proxyProfileId && !host.proxyConfig) {
|
||||
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
@@ -67,21 +57,12 @@ export const buildSftpHostCredentials = ({
|
||||
? resolveProxyConfigAuth(host.proxyConfig, identities)
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
const resolveChainHost = (hostId: string): Host => {
|
||||
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!jumpHost) {
|
||||
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
|
||||
}
|
||||
return jumpHost;
|
||||
};
|
||||
// A non-empty pinned chain snapshot wins over live hostChain resolution.
|
||||
const chainHosts: Host[] | null = jumpHostsOverride && jumpHostsOverride.length > 0
|
||||
? jumpHostsOverride
|
||||
: host.hostChain?.hostIds && host.hostChain.hostIds.length > 0
|
||||
? host.hostChain.hostIds.map(resolveChainHost)
|
||||
: null;
|
||||
if (chainHosts) {
|
||||
jumpHosts = chainHosts.map((jumpHost, index) => {
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds.map((hostId) => {
|
||||
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!jumpHost) {
|
||||
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
|
||||
}
|
||||
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
|
||||
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
@@ -91,6 +72,8 @@ export const buildSftpHostCredentials = ({
|
||||
if (findIncompleteProxyIdentityId(jumpHost.proxyConfig, identities)) {
|
||||
throw new Error(formatIncompleteProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
|
||||
}
|
||||
return jumpHost;
|
||||
}).map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
|
||||
@@ -5,7 +5,6 @@ import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import type { Host } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
|
||||
type StartOptions = {
|
||||
@@ -84,160 +83,6 @@ function installGlobals(netty: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
for (const pinned of [false, true]) {
|
||||
for (const direction of ["download", "upload"] as const) {
|
||||
test(`${pinned ? "existing" : "new"} dual-pane ${direction} preserves its endpoint after host edits`, async () => {
|
||||
const acquired: string[] = [];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => direction === "download"
|
||||
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
|
||||
: null,
|
||||
statLocal: async () => direction === "upload"
|
||||
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0, totalBytes: options.totalBytes ?? 0, lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
});
|
||||
// These pane callbacks stay stable in the app while the saved hosts change.
|
||||
const getActivePane = (side: "left" | "right") => makePane(side);
|
||||
const getPaneByConnectionId = () => null;
|
||||
const getTabByConnectionId = () => null;
|
||||
const updateTab = () => undefined;
|
||||
const sftpSessionsRef = { current: new Map([["remote-conn", "browse-session"]]) };
|
||||
const pinnedHost = { id: "host-1", hostname: "old-endpoint" } as Host;
|
||||
const pinnedChain = [{ id: "jump-1", hostname: "old-bastion" } as Host];
|
||||
const acquiredChains: string[] = [];
|
||||
const acquire = (savedEndpoint: string) => async (
|
||||
_hostId: string, _jobId: string, connectHost?: Host, connectChain?: Host[],
|
||||
) => {
|
||||
const endpoint = connectHost?.hostname ?? savedEndpoint;
|
||||
acquired.push(endpoint);
|
||||
acquiredChains.push(connectChain?.map((hop) => hop.hostname).join(" -> ") ?? "unpinned");
|
||||
return { sftpId: endpoint, poolKey: endpoint, release: () => undefined, discard: () => undefined };
|
||||
};
|
||||
const original = acquire("old-endpoint");
|
||||
const refreshed = acquire("new-endpoint");
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe({ acquireSession }: { acquireSession: typeof original }) {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: `refresh-host-${direction}`,
|
||||
getActivePane, getPaneByConnectionId, getTabByConnectionId, updateTab,
|
||||
refresh: async () => undefined, clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined, sftpSessionsRef,
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [],
|
||||
acquireTransferSession: acquireSession,
|
||||
resolveConnectedHost: () => pinned ? pinnedHost : null,
|
||||
resolveConnectedChain: pinned ? () => pinnedChain : undefined,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe, { acquireSession: original })); });
|
||||
await act(async () => { renderer!.update(React.createElement(Probe, { acquireSession: refreshed })); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
direction === "download" ? "left" : "right", direction === "download" ? "right" : "left");
|
||||
});
|
||||
assert.equal(acquired.length, 2, "both the work lease and file lease must use the pane endpoint");
|
||||
assert.ok(acquired.every((endpoint) => endpoint === (pinned ? "old-endpoint" : "new-endpoint")), JSON.stringify(acquired));
|
||||
if (pinned) {
|
||||
assert.ok(
|
||||
acquiredChains.every((chain) => chain === "old-bastion"),
|
||||
`the pinned jump chain must ride along with the pinned endpoint: ${JSON.stringify(acquiredChains)}`,
|
||||
);
|
||||
} else {
|
||||
assert.ok(
|
||||
acquiredChains.every((chain) => chain === "unpinned"),
|
||||
`no chain may attach when the connect host is unresolved: ${JSON.stringify(acquiredChains)}`,
|
||||
);
|
||||
}
|
||||
assert.equal(started.length, 1);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
for (const direction of ["download", "upload"] as const) {
|
||||
test(`dual-pane ${direction} started during a reconnect falls back to the pane endpoint, not the incoming tab host`, async () => {
|
||||
const acquired: string[] = [];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statSftp: async () => direction === "download"
|
||||
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
|
||||
: null,
|
||||
statLocal: async () => direction === "upload"
|
||||
? { name: "aops_dns_view_demo.sh", type: "file", size: 4, sizeKnown: true, lastModified: 1 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed", transferId: options.transferId,
|
||||
transferred: options.totalBytes ?? 0, totalBytes: options.totalBytes ?? 0, lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
});
|
||||
const getActivePane = (side: "left" | "right") => makePane(side);
|
||||
const getPaneByConnectionId = () => null;
|
||||
const getTabByConnectionId = () => null;
|
||||
const updateTab = () => undefined;
|
||||
const sftpSessionsRef = { current: new Map([["remote-conn", "browse-session"]]) };
|
||||
// connect() has already recorded the incoming endpoint for this tab id
|
||||
// while the pane still shows the old connection (reconnect in flight).
|
||||
const incomingHost = { id: "host-2", hostname: "reconnect-endpoint" } as Host;
|
||||
const acquire = (savedEndpoint: string) => async (_hostId: string, _jobId: string, connectHost?: Host) => {
|
||||
const endpoint = connectHost?.hostname ?? savedEndpoint;
|
||||
acquired.push(endpoint);
|
||||
return { sftpId: endpoint, poolKey: endpoint, release: () => undefined, discard: () => undefined };
|
||||
};
|
||||
const acquireSession = acquire("saved-endpoint");
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: `reconnect-host-${direction}`,
|
||||
getActivePane, getPaneByConnectionId, getTabByConnectionId, updateTab,
|
||||
refresh: async () => undefined, clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined, sftpSessionsRef,
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => [],
|
||||
acquireTransferSession: acquireSession,
|
||||
resolveConnectedHost: () => incomingHost,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "aops_dns_view_demo.sh", isDirectory: false }],
|
||||
direction === "download" ? "left" : "right", direction === "download" ? "right" : "left");
|
||||
});
|
||||
assert.equal(acquired.length, 2, "both the work lease and file lease must be acquired");
|
||||
assert.ok(
|
||||
acquired.every((endpoint) => endpoint === "saved-endpoint"),
|
||||
`the incoming endpoint must not capture the transfer: ${JSON.stringify(acquired)}`,
|
||||
);
|
||||
assert.equal(started.length, 1);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const liveStatAvailable of [true, false]) {
|
||||
test(
|
||||
liveStatAvailable
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction
|
||||
import {
|
||||
FileConflict,
|
||||
FileConflictAction,
|
||||
Host,
|
||||
SftpFilenameEncoding,
|
||||
TransferDirection,
|
||||
TransferStatus,
|
||||
@@ -26,10 +25,7 @@ import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import {
|
||||
useSftpDirectoryTransferOps,
|
||||
type TransferConnectHosts,
|
||||
} from "./transferDirectoryOps";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
import { globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
@@ -82,78 +78,6 @@ const STAT_SFTP_TIMEOUT_MS = 10_000;
|
||||
export const PREFLIGHT_STAT_CONCURRENCY = 8;
|
||||
export const PREFLIGHT_STAT_MAX_FILES = 128;
|
||||
|
||||
// A "local" or missing resolved host means the dedicated pool should fall
|
||||
// back to the saved vault host; only a concrete Host pins the endpoint.
|
||||
const toConnectHost = (host: Host | "local" | null | undefined): Host | undefined =>
|
||||
host && host !== "local" ? host : undefined;
|
||||
|
||||
// Stable identity of a pinned proxy transport. Two endpoints sharing
|
||||
// id/hostname/port/username can still dial through different proxies after an
|
||||
// edit; remote DNS behind each proxy may resolve the same hostname to a
|
||||
// different server, so a same-host `cp` pinned to the source route could write
|
||||
// on the wrong one.
|
||||
const proxyIdentity = (p: Host["proxyConfig"]): string =>
|
||||
p
|
||||
? JSON.stringify([p.type, p.host, p.port, p.command ?? "", p.identityId ?? "", p.username ?? "", p.password ?? ""])
|
||||
: "";
|
||||
|
||||
// Stable identity of a pinned jump chain. buildCacheKey omits the chain, so
|
||||
// two panes on the same saved host can share a cache key while pinning
|
||||
// different bastion routes (e.g. one connected before a jump-host edit, one
|
||||
// after) — those routes may reach different servers. Compare identities, not
|
||||
// just cache keys, before allowing any same-host optimization. proxyConfig
|
||||
// must be part of the identity: buildSftpHostCredentials forwards it as the
|
||||
// hop's proxy transport, so two hops sharing id/hostname/port/username can
|
||||
// still dial through different proxies after an edit.
|
||||
const chainIdentity = (chain: Host[] | undefined): string =>
|
||||
(chain ?? []).map(h => `${h.id}@${h.hostname}:${h.port ?? ""}:${h.username}:${proxyIdentity(h.proxyConfig)}`).join("→");
|
||||
|
||||
// Only pin a concrete endpoint while the pane's live connection still belongs
|
||||
// to it. connect() records the incoming tab host before it awaits closure of
|
||||
// the old session and replaces the pane's connection, so during that window a
|
||||
// transfer must not capture endpoint B while its pane snapshot, task paths,
|
||||
// and plan still belong to endpoint A; the pool then falls back to the saved
|
||||
// vault host for the pane's own hostId (mirrors resolveSftpReconnectHost's
|
||||
// tab-host-vs-connection guard).
|
||||
const toConnectHostForPane = (
|
||||
pane: SftpPane,
|
||||
host: Host | "local" | null | undefined,
|
||||
): Host | undefined => {
|
||||
const connection = pane.connection;
|
||||
if (!connection || connection.isLocal) return undefined;
|
||||
return host && host !== "local" && host.id === connection.hostId ? host : undefined;
|
||||
};
|
||||
|
||||
// Snapshot the panes' connect-time endpoints. Called before any preflight
|
||||
// await: tab ids survive reconnect, so resolving this map later can return a
|
||||
// different endpoint than the one the pane snapshot, task paths, and transfer
|
||||
// plan belong to.
|
||||
const captureConnectHosts = (
|
||||
resolveConnectedHost: UseSftpTransfersParams["resolveConnectedHost"],
|
||||
resolveConnectedChain: UseSftpTransfersParams["resolveConnectedChain"],
|
||||
...panes: SftpPane[]
|
||||
): TransferConnectHosts => {
|
||||
const captureEndpoint = (
|
||||
pane: SftpPane | undefined,
|
||||
): { host?: Host; chain?: Host[] } => {
|
||||
if (!pane) return {};
|
||||
const host = toConnectHostForPane(pane, resolveConnectedHost?.(pane.id));
|
||||
if (!host) return {};
|
||||
// Pin only the chain captured with this tab's connection; a later edit of
|
||||
// its bastion hosts must not redirect retained transfers (private
|
||||
// hostnames can resolve differently behind each bastion).
|
||||
return { host, chain: resolveConnectedChain?.(pane.id) ?? undefined };
|
||||
};
|
||||
const source = captureEndpoint(panes[0]);
|
||||
const target = captureEndpoint(panes[1]);
|
||||
return {
|
||||
source: source.host,
|
||||
sourceChain: source.chain,
|
||||
target: target.host,
|
||||
targetChain: target.chain,
|
||||
};
|
||||
};
|
||||
|
||||
/** Keep the MutableRefObject mirror in sync with the process-global latch set. */
|
||||
function syncPausedTasksRef(ref: { current: Set<string> }, taskId: string, latched: boolean) {
|
||||
if (latched) ref.current.add(taskId);
|
||||
@@ -263,8 +187,6 @@ export const useSftpTransfers = ({
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
acquireTransferSession,
|
||||
resolveConnectedHost,
|
||||
resolveConnectedChain,
|
||||
}: UseSftpTransfersParams): UseSftpTransfersResult => {
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
@@ -554,8 +476,6 @@ export const useSftpTransfers = ({
|
||||
sourcePane: SftpPane,
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
/** Connect-time endpoint snapshot captured before any preflight await. */
|
||||
connectHostsOverride?: TransferConnectHosts,
|
||||
): Promise<TransferStatus> => {
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
return "cancelled";
|
||||
@@ -614,7 +534,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
let walkStatus: TransferStatus = "transferring";
|
||||
await transferRuntime.runWalk(task.id, async () => {
|
||||
walkStatus = await processTransferBody(task, sourcePane, targetPane, targetSide, updateTask, connectHostsOverride);
|
||||
walkStatus = await processTransferBody(task, sourcePane, targetPane, targetSide, updateTask);
|
||||
});
|
||||
return walkStatus;
|
||||
});
|
||||
@@ -626,8 +546,6 @@ export const useSftpTransfers = ({
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
updateTask: (updates: Partial<TransferTask>) => void,
|
||||
/** Connect-time endpoint snapshot. Undefined re-resolves the live map. */
|
||||
connectHostsOverride?: TransferConnectHosts,
|
||||
): Promise<TransferStatus> => {
|
||||
|
||||
// Initialize encoding early to avoid temporal dead zone issues
|
||||
@@ -679,29 +597,13 @@ export const useSftpTransfers = ({
|
||||
&& sourceCacheKey === targetCacheKey
|
||||
);
|
||||
|
||||
// Keep the pane's connect-time endpoint even when its saved host is edited.
|
||||
// startTransfer snapshots both hosts before its preflight await so a
|
||||
// reconnect (same tab id, same host id, different endpoint) pending when
|
||||
// processing starts cannot redirect the transfer away from the endpoint
|
||||
// its panes, paths, and plan were captured against.
|
||||
const resolvedConnectHosts = connectHostsOverride
|
||||
?? captureConnectHosts(resolveConnectedHost, resolveConnectedChain, sourcePane, targetPane);
|
||||
const connectHosts: TransferConnectHosts = {
|
||||
source: toConnectHost(resolvedConnectHosts.source),
|
||||
sourceChain: resolvedConnectHosts.sourceChain,
|
||||
target: toConnectHost(resolvedConnectHosts.target),
|
||||
targetChain: resolvedConnectHosts.targetChain,
|
||||
};
|
||||
|
||||
try {
|
||||
// Prefer dedicated work leases for remote ends so folder walks/listings
|
||||
// survive browse park and tab hide (pool holders, not panel map ids).
|
||||
if (!sourcePane.connection?.isLocal) {
|
||||
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
||||
if (acquireTransferSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
sourceHostId, `${task.id}:work-source`, connectHosts.source, connectHosts.sourceChain,
|
||||
);
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
||||
sourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
if (!sourceSftpId) {
|
||||
@@ -714,9 +616,7 @@ export const useSftpTransfers = ({
|
||||
if (!targetPane.connection?.isLocal) {
|
||||
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
|
||||
if (acquireTransferSession && targetHostId) {
|
||||
targetWorkLease = await acquireTransferSession(
|
||||
targetHostId, `${task.id}:work-target`, connectHosts.target, connectHosts.targetChain,
|
||||
);
|
||||
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
|
||||
targetSftpId = targetWorkLease.sftpId;
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
@@ -725,20 +625,7 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}
|
||||
|
||||
// The cache-key equality above omits the jump chain. When the two pinned
|
||||
// chains differ (a bastion edit between the panes' connect times), the
|
||||
// "same" saved host may resolve to different servers behind each bastion;
|
||||
// a same-host `cp` would then write on the source-side server. Treat the
|
||||
// transfer as cross-host unless the pinned routes match (both undefined =
|
||||
// both direct, which is equal).
|
||||
const sameHostPinnedChains = chainIdentity(connectHosts.sourceChain) === chainIdentity(connectHosts.targetChain);
|
||||
// buildCacheKey also omits the endpoint's own top-level proxyConfig, and a
|
||||
// proxy edit between the panes' connect times changes the transport (and
|
||||
// possibly the resolved server) without moving the cache key. Compare the
|
||||
// pinned top-level proxy identities too; undefined on both sides means
|
||||
// both dial directly, which is equal.
|
||||
const sameHostPinnedProxies = proxyIdentity(connectHosts.source?.proxyConfig) === proxyIdentity(connectHosts.target?.proxyConfig);
|
||||
const sameHost = sameHostEndpoints && sameHostPinnedChains && sameHostPinnedProxies && !!sourceSftpId && !!targetSftpId;
|
||||
const sameHost = sameHostEndpoints && !!sourceSftpId && !!targetSftpId;
|
||||
|
||||
// Returns the discovered stat so callers that need the size (e.g. the
|
||||
// overwrite conflict dialog) can await this instead of reading stale
|
||||
@@ -1009,7 +896,7 @@ export const useSftpTransfers = ({
|
||||
: t,
|
||||
),
|
||||
);
|
||||
return processTransfer(updatedTask, sourcePane, targetPane, targetSide, connectHostsOverride);
|
||||
return processTransfer(updatedTask, sourcePane, targetPane, targetSide);
|
||||
}
|
||||
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
@@ -1091,7 +978,6 @@ export const useSftpTransfers = ({
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
sameHost,
|
||||
0, false, undefined, undefined, connectHosts,
|
||||
);
|
||||
|
||||
if (dirErrors > 0) {
|
||||
@@ -1139,7 +1025,6 @@ export const useSftpTransfers = ({
|
||||
targetEncoding,
|
||||
task.id, // rootTaskId - this is the top-level task
|
||||
sameHost,
|
||||
connectHosts,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1281,12 +1166,6 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return [];
|
||||
|
||||
// Capture the connect-time endpoints before the preflight stat await
|
||||
// below: reconnecting either pane to a different endpoint under the same
|
||||
// host id while stats are pending must not redirect the transfer — the
|
||||
// pane snapshot, task paths, and plan all belong to this endpoint.
|
||||
const connectHosts = captureConnectHosts(resolveConnectedHost, resolveConnectedChain, sourcePane, targetPane);
|
||||
|
||||
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
|
||||
const targetPath = options?.targetPath ?? targetPane.connection.currentPath;
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
@@ -1514,7 +1393,7 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
const results = await Promise.all(runnableTasks.map(async (task): Promise<TransferResult> => {
|
||||
const status = await processTransfer(task, sourcePane, targetPane, targetSide, connectHosts);
|
||||
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
return {
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
@@ -1525,10 +1404,8 @@ export const useSftpTransfers = ({
|
||||
|
||||
return [...skippedResults, ...results];
|
||||
},
|
||||
// Saved host edits replace acquisition; new transfers must not retain the old endpoint.
|
||||
// Already-running attempts keep the leases they acquired.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, getPaneByConnectionId, getTabByConnectionId, sftpSessionsRef, updateTab, acquireTransferSession, resolveConnectedHost],
|
||||
[getActivePane, getPaneByConnectionId, getTabByConnectionId, sftpSessionsRef, updateTab],
|
||||
);
|
||||
|
||||
const cancelTransfer = useCallback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Host, FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import type { FileConflict, FileConflictAction, SftpFileEntry, SftpFilenameEncoding, TransferStatus, TransferTask } from "../../../domain/models";
|
||||
import type { SftpPane } from "./types";
|
||||
import type { AcquireTransferSessionFn } from "./transferDirectoryOps";
|
||||
|
||||
@@ -21,14 +21,6 @@ export interface UseSftpTransfersParams {
|
||||
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||||
/** FileZilla-style dedicated transfer sessions (1–2 per host). */
|
||||
acquireTransferSession?: AcquireTransferSessionFn;
|
||||
resolveConnectedHost?: (tabId: string) => Host | "local" | null;
|
||||
/**
|
||||
* Jump-host chain snapshots captured when the tab connected. Lets retained
|
||||
* transfers keep dialing the connect-time bastion chain after a chain host
|
||||
* is edited (private hostnames can resolve differently per bastion);
|
||||
* undefined = chain must be re-resolved from the live vault.
|
||||
*/
|
||||
resolveConnectedChain?: (tabId: string) => Host[] | null;
|
||||
}
|
||||
|
||||
export interface UseSftpTransfersResult {
|
||||
|
||||
@@ -266,9 +266,9 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_HTTP_NETWORK_PROXY, normalized);
|
||||
}, [enabled, httpNetworkProxy, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Window chrome also applies in detached sessions, independently of the
|
||||
// OS-level effects disabled there (tray, global shortcuts, autolaunch, ...).
|
||||
// Persist and sync window opacity
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setWindowOpacity?.(windowOpacityRecord.opacity).catch((err) => {
|
||||
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
|
||||
@@ -294,6 +294,7 @@ export function useSystemSettingsEffects({
|
||||
if (!decision.shouldBroadcast) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacityRecord);
|
||||
}, [
|
||||
enabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
notifySettingsChanged,
|
||||
|
||||
@@ -224,31 +224,3 @@ test("split and copy session clones preserve isolated plugin connection snapshot
|
||||
assert.equal(split.reuseConnectionFromSessionId, undefined);
|
||||
assert.equal(copied.reuseConnectionFromSessionId, undefined);
|
||||
});
|
||||
|
||||
for (const clone of [createSplitTerminalSessionClone, createCopiedTerminalSessionClone]) {
|
||||
test(`${clone.name} keeps the type of an explicit local shell despite global settings`, () => {
|
||||
for (const [localShell, shellType] of [["cmd.exe", "cmd"], ["pwsh.exe", "powershell"]] as const) {
|
||||
const source = session({ protocol: "local", localShell, shellType });
|
||||
const copied = clone(source, { id: "clone", localShellType: "posix" });
|
||||
assert.equal(copied.localShell, localShell);
|
||||
assert.equal(copied.shellType, shellType);
|
||||
assert.equal(clone({ ...source, shellType: undefined },
|
||||
{ id: "legacy", localShellType: "posix" }).shellType, shellType);
|
||||
}
|
||||
const implicit = clone(session({ protocol: "local", shellType: "cmd" }),
|
||||
{ id: "clone-default", localShellType: "posix" });
|
||||
assert.equal(implicit.shellType, "posix", "implicit shells still follow global settings");
|
||||
});
|
||||
}
|
||||
|
||||
test("copied backend-default local snapshot retains its shell semantics after global settings change", () => {
|
||||
const source = session({ protocol: "local", localShell: "", localShellArgs: [], shellType: "powershell", localStartDir: "~" });
|
||||
for (const clone of [
|
||||
createCopiedTerminalSessionClone(source, { id: "copy-default-snapshot", localShellType: "posix" }),
|
||||
createSplitTerminalSessionClone(source, { id: "split-default-snapshot", localShellType: "posix" }),
|
||||
]) {
|
||||
assert.equal(clone.localShell, "");
|
||||
assert.equal(clone.shellType, "powershell");
|
||||
assert.equal(clone.localStartDir, "~");
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
type SftpReuseCandidate = Pick<
|
||||
TerminalSession,
|
||||
@@ -45,12 +44,7 @@ function getClonedShellType(
|
||||
session: TerminalSession,
|
||||
localShellType?: TerminalSession["shellType"],
|
||||
): TerminalSession["shellType"] {
|
||||
if (session.protocol !== "local") return session.shellType;
|
||||
// Clones retain an explicit per-session executable, so its shell type must
|
||||
// survive a different global default (e.g. saved CMD with WSL in Settings).
|
||||
return session.localShell !== undefined
|
||||
? (session.shellType ?? classifyLocalShellType(session.localShell))
|
||||
: localShellType;
|
||||
return session.protocol === "local" ? localShellType : session.shellType;
|
||||
}
|
||||
|
||||
function createTerminalSessionClone(
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ExecutorContext } from '../../infrastructure/ai/cattyAgent/executor';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { stopAgentTurn } from '../../infrastructure/ai/harness/agentStop';
|
||||
import type {
|
||||
TurnSteerInput,
|
||||
TurnSteerResult,
|
||||
@@ -47,16 +46,6 @@ export function isAIChatSessionStreaming(sessionId: string | null | undefined):
|
||||
return !!sessionId && sharedStreamingSessionIds.has(sessionId);
|
||||
}
|
||||
|
||||
/** Use the same stop entry and controllers as the UI before replacing history. */
|
||||
export async function stopAIChatSessionBeforeReplacement(sessionId: string): Promise<void> {
|
||||
const controller = sharedAbortControllers.get(sessionId);
|
||||
if (controller || sharedStreamingSessionIds.has(sessionId)) {
|
||||
await stopAgentTurn({ chatSessionId: sessionId, abortController: controller, bridge: getNetcattyBridge() });
|
||||
}
|
||||
await getAgentRuntime().waitForActiveTurn(sessionId);
|
||||
if (controller && sharedAbortControllers.get(sessionId) === controller) sharedAbortControllers.delete(sessionId);
|
||||
}
|
||||
|
||||
function emitStreamingStoreChange(): void {
|
||||
streamingSubscribers.forEach(listener => {
|
||||
try {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import type { AISession } from '../../infrastructure/ai/types.ts';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SOURCE_SESSION: AISession = {
|
||||
id: 'chat-source',
|
||||
title: 'Fork source',
|
||||
agentId: 'catty',
|
||||
scope: { type: 'terminal', targetId: 'terminal-a' },
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
|
||||
{
|
||||
id: 'user-2',
|
||||
role: 'user',
|
||||
content: 'bad last prompt',
|
||||
timestamp: 3,
|
||||
attachments: [
|
||||
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
|
||||
],
|
||||
},
|
||||
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 4,
|
||||
};
|
||||
|
||||
type HookCapture = {
|
||||
ai: ReturnType<typeof useAIState> | null;
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
};
|
||||
|
||||
function ForkHarness({ capture }: { capture: HookCapture }) {
|
||||
capture.ai = useAIState();
|
||||
const store = useAISessionsStore();
|
||||
capture.sessions = store.sessions as AISession[];
|
||||
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function setupAiState(sessions: AISession[]) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
|
||||
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
|
||||
await act(async () => {
|
||||
root.render(React.createElement(ForkHarness, { capture }));
|
||||
});
|
||||
return { dom, container, root, capture };
|
||||
}
|
||||
|
||||
test('fork retains the completed prefix, replay metadata, and source-owned outputs', async () => {
|
||||
const runtime = getAgentRuntime();
|
||||
const outputStore = runtime.getToolOutputStore('chat-source');
|
||||
const handle = outputStore.store({ chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'saved output' });
|
||||
runtime.getSessionStateStore().updateFromToolResult('chat-source', 'terminal_start',
|
||||
{ sessionId: 'terminal-a', command: 'running-command' },
|
||||
JSON.stringify({ jobId: 'source-job', status: 'running', nextOffset: 7 }), false);
|
||||
const source = structuredClone(SOURCE_SESSION);
|
||||
source.externalSessionId = 'original-provider-thread';
|
||||
source.messages[1].content = `Preview handleId=${handle.id}`;
|
||||
source.messages[1].providerContinuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
let branch: AISession | null = null;
|
||||
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1', { type: 'terminal', targetId: 'reconnected' }); });
|
||||
assert.ok(branch);
|
||||
assert.notEqual(branch!.id, source.id);
|
||||
assert.equal(branch!.externalSessionId, undefined);
|
||||
assert.deepEqual(branch!.messages[1].providerContinuation, source.messages[1].providerContinuation);
|
||||
assert.match(branch!.messages[1].content, /Preview/);
|
||||
assert.doesNotMatch(branch!.messages[1].content, /handleId=/);
|
||||
assert.deepEqual(branch!.scope, { type: 'terminal', targetId: 'reconnected', hostIds: undefined });
|
||||
assert.equal(capture.activeSessionIdMap['terminal:reconnected'], branch!.id);
|
||||
assert.deepEqual({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }, source);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, branch!.id), null);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'saved output');
|
||||
assert.deepEqual(runtime.getSessionStateStore().get(branch!.id).activeJobs, {});
|
||||
assert.ok(runtime.getSessionStateStore().get(source.id).activeJobs['source-job']);
|
||||
runtime.getSessionStateStore().clear(source.id);
|
||||
outputStore.prune(source.id);
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
});
|
||||
|
||||
test('fork rejects an incomplete boundary without adding a chat', async () => {
|
||||
const source = structuredClone(SOURCE_SESSION);
|
||||
source.messages[1].executionStatus = 'failed';
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
let result: unknown;
|
||||
await act(async () => { result = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1'); });
|
||||
assert.equal(result, null);
|
||||
assert.equal(capture.sessions.length, 1);
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
});
|
||||
|
||||
for (const newerCount of [49, 50]) {
|
||||
test(`fork preserves the original on reload with ${newerCount} newer chats`, async () => {
|
||||
const newer = Array.from({ length: newerCount }, (_, index) => ({ ...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index }));
|
||||
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
|
||||
let branch: AISession | null = null;
|
||||
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
|
||||
assert.ok(branch);
|
||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
||||
assert.equal(saved.length, 50);
|
||||
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
const reloaded = await setupAiState(saved);
|
||||
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
|
||||
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
|
||||
await act(async () => reloaded.root.unmount()); reloaded.container.remove(); reloaded.dom.window.close();
|
||||
});
|
||||
}
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
import { convertFilesToUploads } from './useFileUpload';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { publishAISessionsSnapshot } from './aiSessionsStore';
|
||||
import { planSessionFork } from '../../domain/aiSessionFork';
|
||||
import {
|
||||
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
|
||||
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
|
||||
@@ -87,11 +86,6 @@ import {
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
seedWorkspaceAIActiveSessionFromMembers,
|
||||
} from '../../domain/workspaceAiScopeHandoff';
|
||||
import {
|
||||
buildUndoLastTurnBranch,
|
||||
type UndoLastTurnRestoredDraft,
|
||||
} from '../../domain/aiUndoLastTurn';
|
||||
|
||||
|
||||
function providerPatchIsNoop(
|
||||
current: ProviderConfig,
|
||||
@@ -681,57 +675,6 @@ export function useAIState() {
|
||||
};
|
||||
}, [persistSessions]);
|
||||
|
||||
// "Fork from here": copy the conversation up to a completed assistant
|
||||
// response into a new session and make it the scope's active chat. The
|
||||
// original session stays untouched in history. Pure boundary validation and
|
||||
// plan building live in domain/aiSessionFork; the fork has no original
|
||||
// external session id or pending-turn state. Per-message replay metadata is
|
||||
// preserved; output handles and running jobs remain owned by the source.
|
||||
// Null is returned when the boundary is not forkable.
|
||||
// `viewingScope` is the panel scope the fork was requested from; the branch
|
||||
// is created there so the fork is active and visible in the very scope that
|
||||
// is viewing it, even when the source session carries a stale scope (e.g. a
|
||||
// terminal chat resumed in a reconnected terminal or a merged workspace).
|
||||
const forkSessionFromMessage = useCallback(async (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
): Promise<AISession | null> => {
|
||||
const source = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!source) return null;
|
||||
const plan = planSessionFork(source, messageId);
|
||||
if (!plan.ok) return null;
|
||||
const now = Date.now();
|
||||
const fork: AISession = {
|
||||
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: plan.title,
|
||||
agentId: source.agentId,
|
||||
scope: {
|
||||
...source.scope,
|
||||
// Host membership stays with the source (the messages were produced
|
||||
// there); the scope the fork belongs to is the viewing scope.
|
||||
...(viewingScope ? { type: viewingScope.type, targetId: viewingScope.targetId } : {}),
|
||||
hostIds: source.scope.hostIds ? [...source.scope.hostIds] : undefined,
|
||||
},
|
||||
messages: plan.messages,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (plan.contextCompaction) {
|
||||
fork.contextCompaction = plan.contextCompaction;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
// Keep the source alongside its branch within the existing history limit.
|
||||
const next = [fork, { ...source, updatedAt: now }, ...prev.filter(session => session.id !== source.id)];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${fork.scope.type}:${fork.scope.targetId ?? ''}`;
|
||||
setActiveSessionId(scopeKey, fork.id);
|
||||
return fork;
|
||||
}, [persistSessions, setActiveSessionId]);
|
||||
|
||||
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
|
||||
const now = Date.now();
|
||||
const session: AISession = {
|
||||
@@ -754,40 +697,6 @@ export function useAIState() {
|
||||
return session;
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
// Non-destructive "undo last turn": branch the session at the boundary
|
||||
// before the latest user message and keep the original in history intact.
|
||||
// Returns the new branch id plus the undone user message for the composer.
|
||||
const undoLastTurnInSession = useCallback(async (
|
||||
sessionId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
): Promise<{
|
||||
sessionId: string;
|
||||
restored: UndoLastTurnRestoredDraft;
|
||||
} | null> => {
|
||||
const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const source = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!source) return null;
|
||||
const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() });
|
||||
if (!result) return null;
|
||||
|
||||
const branched = result.session;
|
||||
// Like Fork, Undo belongs to the panel viewing the source, which may have
|
||||
// resumed a chat from a closed terminal or inherited it after a merge.
|
||||
if (viewingScope) {
|
||||
branched.scope = { ...branched.scope, ...viewingScope };
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
// Keep both recently used chats within the existing history limit.
|
||||
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`;
|
||||
setActiveSessionId(scopeKey, branched.id);
|
||||
return { sessionId: branched.id, restored: result.restored };
|
||||
}, [persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
cleanupDeletedAIChatSessions([sessionId]);
|
||||
if (persistTimerRef.current) {
|
||||
@@ -1366,7 +1275,6 @@ export function useAIState() {
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
@@ -1379,7 +1287,6 @@ export function useAIState() {
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
forkSessionFromMessage,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
@@ -1428,7 +1335,6 @@ export function useAIState() {
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
@@ -1441,6 +1347,5 @@ export function useAIState() {
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
forkSessionFromMessage,
|
||||
]);
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SCOPE_KEY = 'terminal:terminal-a';
|
||||
|
||||
const SOURCE_SESSION: AISession = {
|
||||
id: 'chat-source',
|
||||
title: 'Undo source',
|
||||
agentId: 'catty',
|
||||
scope: { type: 'terminal', targetId: 'terminal-a' },
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
|
||||
{
|
||||
id: 'user-2',
|
||||
role: 'user',
|
||||
content: 'bad last prompt',
|
||||
timestamp: 3,
|
||||
attachments: [
|
||||
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
|
||||
],
|
||||
},
|
||||
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 4,
|
||||
};
|
||||
|
||||
type HookCapture = {
|
||||
ai: ReturnType<typeof useAIState> | null;
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
};
|
||||
|
||||
function UndoHarness({ capture }: { capture: HookCapture }) {
|
||||
capture.ai = useAIState();
|
||||
const store = useAISessionsStore();
|
||||
capture.sessions = store.sessions as AISession[];
|
||||
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function setupAiState(sessions: AISession[]) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
|
||||
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
|
||||
await act(async () => {
|
||||
root.render(React.createElement(UndoHarness, { capture }));
|
||||
});
|
||||
return { dom, container, root, capture };
|
||||
}
|
||||
|
||||
test('undoLastTurnInSession branches and restores the last user turn', async () => {
|
||||
const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]);
|
||||
assert.ok(capture.ai);
|
||||
|
||||
let result: Awaited<ReturnType<typeof runUndo>> | null = null;
|
||||
async function runUndo() {
|
||||
return capture.ai!.undoLastTurnInSession('chat-source');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
result = await runUndo();
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.notEqual(result!.sessionId, 'chat-source');
|
||||
|
||||
const sessions = capture.sessions;
|
||||
const branch = sessions.find((session) => session.id === result!.sessionId);
|
||||
assert.ok(branch);
|
||||
// Branch keeps the conversation up to the boundary before the last turn.
|
||||
assert.deepEqual(
|
||||
branch!.messages.map((message) => message.content),
|
||||
['first prompt', 'first answer'],
|
||||
);
|
||||
// Original session stays intact (non-destructive).
|
||||
const original = sessions.find((session) => session.id === 'chat-source');
|
||||
assert.equal(original?.messages.length, 4);
|
||||
// Branch becomes the active session for the scope.
|
||||
assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId);
|
||||
|
||||
const attachments = result!.restored.attachments as UploadedFile[];
|
||||
assert.equal(result!.restored.text, 'bad last prompt');
|
||||
assert.equal(attachments.length, 1);
|
||||
assert.equal(attachments[0].filename, 'note.txt');
|
||||
assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD');
|
||||
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('undoLastTurnInSession returns null when there is nothing to undo', async () => {
|
||||
const emptySession: AISession = {
|
||||
...SOURCE_SESSION,
|
||||
id: 'chat-empty',
|
||||
messages: [],
|
||||
};
|
||||
const { dom, container, root, capture } = await setupAiState([emptySession]);
|
||||
assert.ok(capture.ai);
|
||||
|
||||
let result: unknown;
|
||||
await act(async () => {
|
||||
result = await capture.ai!.undoLastTurnInSession('chat-empty');
|
||||
});
|
||||
assert.equal(result, null);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('undo copies chat only, preserving original outputs and job ownership', async () => {
|
||||
const runtime = getAgentRuntime();
|
||||
const outputStore = runtime.getToolOutputStore('chat-source');
|
||||
const handle = outputStore.store({
|
||||
chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output',
|
||||
});
|
||||
const stateStore = runtime.getSessionStateStore();
|
||||
stateStore.updateFromToolResult('chat-source', 'terminal_start',
|
||||
{ sessionId: 'terminal-a', command: 'long-running-command' },
|
||||
JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false);
|
||||
const source = structuredClone(SOURCE_SESSION);
|
||||
source.messages[1].content = `Preview with handleId=${handle.id}`;
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
const original = JSON.stringify(source);
|
||||
let result: { sessionId: string } | null = null;
|
||||
await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); });
|
||||
assert.ok(result);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
|
||||
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
|
||||
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
|
||||
assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original);
|
||||
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
|
||||
stateStore.clear(source.id);
|
||||
outputStore.prune(source.id);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
});
|
||||
|
||||
for (const newerCount of [49, 50]) {
|
||||
test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => {
|
||||
const newer = Array.from({ length: newerCount }, (_, index) => ({
|
||||
...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index,
|
||||
}));
|
||||
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
|
||||
let branchId: string | undefined;
|
||||
await act(async () => {
|
||||
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
|
||||
});
|
||||
assert.ok(branchId);
|
||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
||||
assert.equal(saved.length, 50);
|
||||
const original = saved.find(item => item.id === SOURCE_SESSION.id);
|
||||
assert.ok(original, 'the original must survive updatedAt-based history pruning');
|
||||
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
|
||||
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
|
||||
// Remount the actual hook from the persisted records, as on app reload.
|
||||
const reloaded = await setupAiState(saved);
|
||||
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
await act(async () => reloaded.root.unmount());
|
||||
reloaded.container.remove(); reloaded.dom.window.close();
|
||||
});
|
||||
}
|
||||
|
||||
for (const viewingScope of [
|
||||
{ type: 'terminal' as const, targetId: 'terminal-b' },
|
||||
{ type: 'workspace' as const, targetId: 'workspace-b' },
|
||||
]) {
|
||||
test(`undo stays visible when a ${viewingScope.type} views a chat from a closed terminal`, async () => {
|
||||
const source = {
|
||||
...structuredClone(SOURCE_SESSION),
|
||||
scope: { type: 'terminal' as const, targetId: 'terminal-closed', hostIds: ['host-a'] },
|
||||
};
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
const sourceBefore = structuredClone(source);
|
||||
let branchId: string | undefined;
|
||||
await act(async () => {
|
||||
capture.ai!.setActiveSessionId('terminal:terminal-b', source.id);
|
||||
branchId = (await capture.ai!.undoLastTurnInSession(source.id, viewingScope))?.sessionId;
|
||||
});
|
||||
assert.ok(branchId);
|
||||
const branch = capture.sessions.find(item => item.id === branchId)!;
|
||||
assert.deepEqual(branch.messages, source.messages.slice(0, 2));
|
||||
assert.deepEqual(branch.scope, { ...source.scope, ...viewingScope });
|
||||
assert.equal(capture.activeSessionIdMap[`${viewingScope.type}:${viewingScope.targetId}`], branch.id);
|
||||
// The real history predicate must still include the selected branch after
|
||||
// the source terminal no longer belongs to the current workspace.
|
||||
assert.equal(getSessionScopeMatchRank(
|
||||
branch, viewingScope.type, viewingScope.targetId, ['host-a'],
|
||||
new Set([branch.id]), new Set(['terminal-b']), new Set([source.id]),
|
||||
), 3);
|
||||
const original = capture.sessions.find(item => item.id === source.id)!;
|
||||
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
|
||||
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
});
|
||||
}
|
||||
@@ -21,14 +21,12 @@ import { isProviderReadyForSync, SYNC_STORAGE_KEYS, type CloudProvider, type Syn
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import { materializeSyncPayloadFromConvergentState } from '../../domain/convergentSync';
|
||||
import {
|
||||
isPreserveDeletionsStrategy,
|
||||
resolveCloudSyncConflictAction,
|
||||
type CloudSyncConflictAction,
|
||||
} from '../../domain/syncStrategy';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectCloudSyncableSettings,
|
||||
collectAiSessionsForSync,
|
||||
getEffectivePortForwardingRulesForSync,
|
||||
hasCloudSyncEntityData,
|
||||
hasMeaningfulCloudSyncData,
|
||||
@@ -40,8 +38,6 @@ import { commitPluginSidecarsAfterSuccessfulSync } from '../pluginSyncSidecarBri
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
|
||||
@@ -163,7 +159,6 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
|
||||
groupConfigs: payload.groupConfigs,
|
||||
settings: payload.settings,
|
||||
pluginSidecars: payload.pluginSidecars,
|
||||
aiSessions: payload.aiSessions,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -248,11 +243,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
const bumpIfSyncableSetting = (key: string | null | undefined) => {
|
||||
if (!key) return;
|
||||
const chatChanged = key === STORAGE_KEY_AI_SESSIONS
|
||||
&& localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||
if (!SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)
|
||||
&& key !== STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS && !chatChanged) return;
|
||||
if (!key || !SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)) return;
|
||||
setSyncableSettingsStorageVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
@@ -308,10 +299,8 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const buildPayload = useCallback(async (): Promise<SyncPayload> => {
|
||||
const { withPluginSyncSidecars } = await import('../syncPayload');
|
||||
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
|
||||
const aiSessions = collectAiSessionsForSync();
|
||||
const base: SyncPayload = {
|
||||
...getSyncSnapshot(),
|
||||
...(aiSessions !== undefined ? { aiSessions } : {}),
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -792,7 +781,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
const conflictAction = resolveCloudSyncConflictAction(state.syncStrategy, {
|
||||
hasConflict: inspection.remoteChanged || isPreserveDeletionsStrategy(state.syncStrategy),
|
||||
hasConflict: inspection.remoteChanged,
|
||||
hasRemoteFile: Boolean(inspection.remoteFile),
|
||||
});
|
||||
|
||||
@@ -861,9 +850,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// upload and wipe usable cloud passwords. Same for remote-side poison.
|
||||
const localHealed = healPoisonedSecretsForMerge(localPayload, remoteRaw, base);
|
||||
const remoteHealed = healPoisonedSecretsForMerge(remoteRaw, localPayload, base);
|
||||
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed, {
|
||||
preserveDeletions: isPreserveDeletionsStrategy(state.syncStrategy),
|
||||
});
|
||||
const mergeResult = mergeSyncPayloads(base, localHealed, remoteHealed);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
// throws, the next startup will re-run the merge with fresh data.
|
||||
|
||||
@@ -28,8 +28,6 @@ import {
|
||||
isProviderReadyForSync,
|
||||
} from '../../domain/sync';
|
||||
import type { CloudSyncStrategy } from '../../domain/syncStrategy';
|
||||
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
|
||||
import type { CloudSyncConflictAction } from '../../domain/syncStrategy';
|
||||
import {
|
||||
getCloudSyncManager,
|
||||
@@ -175,9 +173,6 @@ export interface CloudSyncHook {
|
||||
setAutoSync: (enabled: boolean, intervalMinutes?: number) => void;
|
||||
setDeviceName: (name: string) => void;
|
||||
setSyncStrategy: (strategy: CloudSyncStrategy) => void;
|
||||
/** Whether AI chat sessions are included in cloud sync payloads (opt-in). */
|
||||
aiSessionsSyncEnabled: boolean;
|
||||
setAiSessionsSyncEnabled: (enabled: boolean) => Promise<void>;
|
||||
setConvergentSyncEnabled: (enabled: boolean) => ConvergentSyncLocalConfig;
|
||||
refreshConvergentSyncConfig: () => ConvergentSyncLocalConfig;
|
||||
|
||||
@@ -254,25 +249,6 @@ const getSnapshot = (): SyncManagerState => {
|
||||
return manager.getState();
|
||||
};
|
||||
|
||||
function readAiSessionsSyncEnabled(): boolean {
|
||||
return localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||
}
|
||||
|
||||
function subscribeAiSessionsSyncEnabled(listener: () => void): () => void {
|
||||
const storage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS || event.key === null) listener();
|
||||
};
|
||||
const local = (event: Event) => {
|
||||
if ((event as CustomEvent<{ key?: string }>).detail?.key === STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS) listener();
|
||||
};
|
||||
window.addEventListener('storage', storage);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, local);
|
||||
return () => {
|
||||
window.removeEventListener('storage', storage);
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, local);
|
||||
};
|
||||
}
|
||||
|
||||
export const useCloudSync = (): CloudSyncHook => {
|
||||
// Use useSyncExternalStore for real-time state sync across all components
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
@@ -748,19 +724,6 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
manager.setDeviceName(name);
|
||||
}, []);
|
||||
|
||||
const aiSessionsSyncEnabled = useSyncExternalStore(
|
||||
subscribeAiSessionsSyncEnabled, readAiSessionsSyncEnabled, readAiSessionsSyncEnabled,
|
||||
);
|
||||
const setAiSessionsSyncEnabled = useCallback(async (enabled: boolean) => {
|
||||
const { setAiSessionsSyncPreference } = await import('../syncPayload');
|
||||
await setAiSessionsSyncPreference(enabled, async () => {
|
||||
const providers = Object.entries(manager.getAllProviders())
|
||||
.filter(([, connection]) => isProviderReadyForSync(connection));
|
||||
const remotes = await Promise.all(providers.map(([provider]) => manager.downloadFromProvider(provider as CloudProvider)));
|
||||
return remotes.filter((remote) => remote !== null).map((remote) => remote.payload);
|
||||
}, manager.getState().syncStrategy === 'smartMergePreserve');
|
||||
}, []);
|
||||
|
||||
const setSyncStrategy = useCallback((strategy: CloudSyncStrategy) => {
|
||||
manager.setSyncStrategy(strategy);
|
||||
}, []);
|
||||
@@ -901,7 +864,6 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
}
|
||||
return manager.resolveConvergentConflict(addressKey, candidateDot, applyPayload, {
|
||||
pluginSidecars,
|
||||
aiSessions: (await import('../syncPayload')).collectAiSessionsForSync(),
|
||||
});
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
@@ -994,8 +956,6 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
setAutoSync,
|
||||
setDeviceName,
|
||||
setSyncStrategy,
|
||||
aiSessionsSyncEnabled,
|
||||
setAiSessionsSyncEnabled,
|
||||
setConvergentSyncEnabled,
|
||||
refreshConvergentSyncConfig,
|
||||
|
||||
|
||||
@@ -3,11 +3,7 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useComposeBarHistory } from './useComposeBarHistory';
|
||||
import {
|
||||
clearComposeBarHistory,
|
||||
pruneComposeBarHistory,
|
||||
recordComposeBarHistory,
|
||||
} from './composeBarHistoryStore';
|
||||
import { pruneComposeBarHistory, recordComposeBarHistory } from './composeBarHistoryStore';
|
||||
|
||||
test('changing workspace focus resets navigation without replacing the draft', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
@@ -51,31 +47,3 @@ test('an in-progress history walk stays stable when a late send evicts the oldes
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
|
||||
test('clearing history restores the pre-walk draft instead of leaving the recalled command', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
pruneComposeBarHistory([]);
|
||||
recordComposeBarHistory('a', 'deleted-command');
|
||||
const restoredDrafts: string[] = [];
|
||||
let history!: ReturnType<typeof useComposeBarHistory>;
|
||||
function Harness() {
|
||||
history = useComposeBarHistory('a', (draft) => { restoredDrafts.push(draft); });
|
||||
return null;
|
||||
}
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
|
||||
// Enter a history walk: the textarea now shows the recalled command while the
|
||||
// hook holds the pre-walk draft.
|
||||
assert.equal(history.navigate('my unsent draft', 'up'), 'deleted-command');
|
||||
|
||||
clearComposeBarHistory();
|
||||
|
||||
// The pre-walk draft is put back, so the deleted command is no longer in the
|
||||
// textarea ready for Enter.
|
||||
assert.deepEqual(restoredDrafts, ['my unsent draft']);
|
||||
assert.equal(history.navigate('my unsent draft', 'up'), undefined);
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
pruneComposeBarHistory([]);
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import { navigateComposeBarHistory, type ComposeBarHistoryDirection } from '../../domain/composeBarHistory';
|
||||
import {
|
||||
getComposeBarHistory,
|
||||
createComposeBarHistoryRecorder,
|
||||
subscribeComposeBarHistoryCleared,
|
||||
} from './composeBarHistoryStore';
|
||||
import { getComposeBarHistory, createComposeBarHistoryRecorder } from './composeBarHistoryStore';
|
||||
|
||||
export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: string) => void) {
|
||||
const cursor = useRef<{ index: number; draft: string; entries?: readonly string[] }>({ index: Infinity, draft: '' });
|
||||
@@ -20,24 +16,6 @@ export function useComposeBarHistory(sessionId: string, restoreDraft?: (draft: s
|
||||
reset();
|
||||
}, [reset, restoreDraft, sessionId]);
|
||||
|
||||
/**
|
||||
* Restore the pre-walk draft and drop the cached history walk.
|
||||
*
|
||||
* While the user is in an ArrowUp walk the textarea shows the recalled
|
||||
* command, so resetting the cursor alone would leave a deleted command ready
|
||||
* for Enter. Put the saved draft back first, exactly like the session-change
|
||||
* path above.
|
||||
*/
|
||||
const handleHistoryCleared = useCallback(() => {
|
||||
if (cursor.current.entries) restoreDraft?.(cursor.current.draft);
|
||||
reset();
|
||||
}, [reset, restoreDraft]);
|
||||
|
||||
useEffect(
|
||||
() => subscribeComposeBarHistoryCleared(handleHistoryCleared),
|
||||
[handleHistoryCleared],
|
||||
);
|
||||
|
||||
const prepareRecord = useCallback(() => createComposeBarHistoryRecorder(sessionId), [sessionId]);
|
||||
|
||||
const navigate = useCallback((currentValue: string, direction: ComposeBarHistoryDirection) => {
|
||||
|
||||
@@ -39,7 +39,6 @@ export const getAppLevelActions = (): Set<string> => {
|
||||
'togglePaneZoom',
|
||||
'openLocal',
|
||||
'openSettings',
|
||||
'toggleOpacity',
|
||||
]);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,383 +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 type { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
|
||||
import { updateRestoredSessionStatusState } from './sessionRestoreState';
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
|
||||
import { createHostTerminalSession } from './sessionFactories';
|
||||
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
|
||||
import { resolveShellSetting } from '../../lib/useDiscoveredShells';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const effective = (host: Host) => host;
|
||||
const host = (extra: Partial<Host> = {}): Host => ({
|
||||
id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra,
|
||||
});
|
||||
|
||||
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[]; resolveEffectiveHost?: (host: Host) => Host } = {}) {
|
||||
let hosts = [initial, ...(extra.hosts ?? [])];
|
||||
let keys = extra.keys ?? [];
|
||||
let identities = extra.identities ?? [];
|
||||
let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }];
|
||||
let find: ReturnType<typeof useHostOpenSessionReuse>;
|
||||
function Harness() {
|
||||
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: extra.resolveEffectiveHost ?? effective });
|
||||
return null;
|
||||
}
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
return {
|
||||
find: () => find(initial.id),
|
||||
async update(input: { host?: Host; hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] }) {
|
||||
hosts = input.hosts ?? (input.host ? [input.host, ...hosts.slice(1)] : hosts);
|
||||
keys = input.keys ?? keys;
|
||||
identities = input.identities ?? identities;
|
||||
sessions = input.sessions ?? sessions;
|
||||
await act(async () => renderer.update(React.createElement(Harness)));
|
||||
},
|
||||
close: () => act(async () => renderer.unmount()),
|
||||
};
|
||||
}
|
||||
|
||||
for (const [field, value] of [
|
||||
['hostname', 'replacement.invalid'], ['username', 'other-user'], ['port', 2222],
|
||||
['protocol', 'telnet'], ['password', 'new-password'], ['authMethod', 'key'],
|
||||
['hostChain', { hostIds: ['jump'] }],
|
||||
['proxyConfig', { type: 'socks5', host: 'proxy.invalid', port: 1080 }],
|
||||
] as const) {
|
||||
test(`a saved ${field} edit cannot reuse the old tab`, async (t) => {
|
||||
const original = host({ password: 'old-password', authMethod: 'password' });
|
||||
const api = await harness(original, { hosts: [host({ id: 'jump', hostname: 'jump.invalid' })] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, [field]: value } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('renaming the target and jump host keeps the same tab eligible', async (t) => {
|
||||
const original = host({ hostChain: { hostIds: ['jump'] } });
|
||||
const jump = host({ id: 'jump', hostname: 'jump.invalid' });
|
||||
const api = await harness(original, { hosts: [jump] });
|
||||
t.after(api.close);
|
||||
await api.update({ hosts: [{ ...original, label: 'Renamed', fontSize: 16, notes: 'Updated note' }, { ...jump, label: 'Renamed jump' }] });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
});
|
||||
|
||||
test('changing a jump endpoint or its password rejects the original connection', async (t) => {
|
||||
const original = host({ hostChain: { hostIds: ['jump'] } });
|
||||
const jump = host({ id: 'jump', hostname: 'jump.invalid', password: 'old' });
|
||||
const api = await harness(original, { hosts: [jump] });
|
||||
t.after(api.close);
|
||||
for (const changed of [{ ...jump, hostname: 'new-jump.invalid' }, { ...jump, password: 'new' }]) {
|
||||
await api.update({ hosts: [original, changed] });
|
||||
assert.equal(await api.find(), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('rotating a referenced identity or key keeps stable IDs from masking auth changes', async (t) => {
|
||||
const identity: Identity = { id: 'identity', label: 'Login', username: 'user', password: 'old', authMethod: 'password', created: 0 };
|
||||
const original = host({ identityId: identity.id });
|
||||
const api = await harness(original, { identities: [identity] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ identities: [{ ...identity, password: 'rotated' }] });
|
||||
assert.equal(await api.find(), undefined);
|
||||
const key: SSHKey = { id: 'key', label: 'Key', type: 'ED25519', privateKey: 'old-key', source: 'generated', category: 'key', created: 0 };
|
||||
const keyHost = host({ identityFileId: key.id, authMethod: 'key' });
|
||||
const keyApi = await harness(keyHost, { keys: [key] });
|
||||
t.after(keyApi.close);
|
||||
assert.equal((await keyApi.find())?.sessionId, 'tab');
|
||||
await keyApi.update({ keys: [{ ...key, privateKey: 'rotated-key' }] });
|
||||
assert.equal(await keyApi.find(), undefined);
|
||||
});
|
||||
|
||||
for (const [field, value] of [
|
||||
['baudRate', 19200], ['dataBits', 7], ['stopBits', 2], ['parity', 'even'], ['flowControl', 'rts/cts'],
|
||||
] as const) {
|
||||
test(`a serial ${field} edit must not reuse the opening parameters`, async (t) => {
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' } });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, serialConfig: { ...original.serialConfig!, [field]: value } } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('unchanged legacy serial defaults still match their opening snapshot', async (t) => {
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', port: 9600 });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
});
|
||||
|
||||
test('the captured serial settings come from the opening session even if the vault was already edited', async (t) => {
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600 } });
|
||||
const session: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'connected' };
|
||||
const api = await harness({ ...original, serialConfig: { ...original.serialConfig!, baudRate: 19200 } }, { sessions: [session] });
|
||||
t.after(api.close);
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
|
||||
for (const changedField of ['configuration', 'credentialId'] as const) {
|
||||
test(`a restored plugin tab keeps its opening ${changedField} after manual reconnect`, async (t) => {
|
||||
const providerId = 'com.example.transport.connection';
|
||||
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' }, credentialId: 'original-credential-reference' } });
|
||||
const edited = { ...original, pluginConnection: { ...original.pluginConnection!, [changedField]: changedField === 'configuration' ? { endpoint: 'replacement.invalid' } : 'replacement-credential-reference' } };
|
||||
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
|
||||
const api = await harness(edited, { sessions: [restored] });
|
||||
t.after(api.close);
|
||||
assert.equal(await api.find(), undefined);
|
||||
const reconnecting = updateRestoredSessionStatusState([restored], restored.id, 'connecting');
|
||||
const actual = resolveTerminalSessionHost({ session: reconnecting[0], hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux' });
|
||||
assert.deepEqual(actual.pluginConnection, original.pluginConnection, 'the real terminal resolver uses the restored opening configuration');
|
||||
await api.update({ sessions: reconnecting });
|
||||
assert.equal(await api.find(), undefined, 'host_open must not call that old transport the edited vault connection');
|
||||
});
|
||||
}
|
||||
|
||||
test('an unchanged restored plugin configuration can be reused after manual reconnect', async (t) => {
|
||||
const providerId = 'com.example.transport.connection';
|
||||
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' } } });
|
||||
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
|
||||
const api = await harness({ ...original, label: 'Renamed' }, { sessions: [restored] });
|
||||
t.after(api.close);
|
||||
await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
});
|
||||
|
||||
for (const [field, value] of [
|
||||
['telnetPort', 2324], ['telnetUsername', 'other-telnet-user'], ['telnetPassword', 'rotated-telnet-password'],
|
||||
] as const) {
|
||||
test(`a Telnet ${field} edit cannot reuse the old transport`, async (t) => {
|
||||
const original = host({ protocol: 'telnet', telnetPort: 2323, telnetUsername: 'telnet-user', telnetPassword: 'telnet-password' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, [field]: value } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('Telnet identity credential rotation and missing identity reject reuse', async (t) => {
|
||||
const identity: Identity = { id: 'telnet-login', label: 'Telnet login', username: 'telnet-user', password: 'original', authMethod: 'password', created: 0 };
|
||||
const original = host({ protocol: 'telnet', telnetIdentityId: identity.id });
|
||||
const api = await harness(original, { identities: [identity] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
for (const identities of [[{ ...identity, username: 'other-user' }], [{ ...identity, password: 'rotated' }], []]) {
|
||||
await api.update({ identities });
|
||||
assert.equal(await api.find(), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('Telnet explicit empty login does not fall back to SSH login fields', async (t) => {
|
||||
const original = host({ protocol: 'telnet', telnetUsername: '', telnetPassword: '', password: 'ssh-only' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, label: 'Renamed', username: 'unrelated-ssh-user', password: 'unrelated-ssh-password', identityId: 'missing-ssh-identity', telnetPort: 23 } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, telnetUsername: undefined, telnetPassword: undefined } });
|
||||
assert.equal(await api.find(), undefined, 'removing explicit empty overrides enables the actual fallback login');
|
||||
});
|
||||
|
||||
for (const transport of [{ protocol: 'et' as const }, { protocol: 'ssh' as const, etEnabled: true }]) {
|
||||
test(`changing a jump ET server port rejects the old ${JSON.stringify(transport)} transport`, async (t) => {
|
||||
const original = host({ ...transport, hostChain: { hostIds: ['jump'] } });
|
||||
const jump = host({ id: 'jump', hostname: 'jump.invalid', etPort: 2022 });
|
||||
const api = await harness(original, { hosts: [jump] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ hosts: [original, { ...jump, etPort: 3022 }] });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('serial direct auto-login password is not masked by an unrelated SSH identity', async (t) => {
|
||||
const identity: Identity = { id: 'ssh-login', label: 'SSH login', username: 'ssh-user', password: 'ssh-password', authMethod: 'password', created: 0 };
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', password: 'serial-password', identityId: identity.id });
|
||||
const api = await harness(original, { identities: [identity] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, password: 'rotated-serial-password' } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
|
||||
test('unchanged serial direct username matches the actual auto-login input', async (t) => {
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: 'serial-user', password: 'serial-password' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, username: 'other-serial-user' } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
|
||||
for (const [field, value] of [
|
||||
['localShell', '/bin/bash'], ['localShellArgs', ['--norc']], ['localStartDir', '/replacement'],
|
||||
] as const) {
|
||||
test(`a saved local ${field} edit cannot reuse the old process`, async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/original' });
|
||||
const session = { ...createHostTerminalSession('tab', original), status: 'connected', localShell: original.localShell, localShellArgs: original.localShellArgs, localStartDir: original.localStartDir } as TerminalSession;
|
||||
const api = await harness(original, { sessions: [session] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, [field]: value } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
const alreadyEdited = await harness({ ...original, [field]: value }, { sessions: [session] });
|
||||
t.after(alreadyEdited.close);
|
||||
assert.equal(await alreadyEdited.find(), undefined, 'opening fields come from the actual local session');
|
||||
});
|
||||
}
|
||||
|
||||
test('local default shell, cwd and empty args match the renderer launch contract', async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
await api.update({ host: { ...original, localShell: '', localStartDir: '', label: 'Renamed' } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, localShellArgs: [] } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab', 'the renderer omits empty args so backend login defaults still apply');
|
||||
});
|
||||
|
||||
for (const [label, proxy] of [
|
||||
['a missing saved profile', { proxyProfileId: 'missing-profile' }],
|
||||
['a usable proxy', { proxyConfig: { type: 'socks5', host: 'proxy.invalid', port: 1080 } }],
|
||||
['a missing proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'missing-identity' } }],
|
||||
['an incomplete proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'incomplete-identity' } }],
|
||||
] as const) {
|
||||
test(`Telnet adding ${label} cannot bypass actual starter rejection through reuse`, async (t) => {
|
||||
const original = host({ protocol: 'telnet' });
|
||||
const api = await harness(original, { identities: [{ id: 'incomplete-identity', label: 'Incomplete', username: 'proxy-user', authMethod: 'password', created: 0 }] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, ...proxy } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
await api.update({ host: { ...original, label: 'Renamed without a proxy' } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
});
|
||||
}
|
||||
|
||||
test('Telnet inheriting a proxy profile cannot reuse its previous direct session', async (t) => {
|
||||
const original = host({ protocol: 'telnet', group: 'prod/web' });
|
||||
const groupConfigs: GroupConfig[] = [];
|
||||
const proxyProfiles = [{ id: 'group-proxy', label: 'Proxy', createdAt: 0, config: { type: 'socks5' as const, host: 'proxy.invalid', port: 1080 } }];
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveEffectiveTerminalHost({ host: candidate, groupConfigs, proxyProfiles });
|
||||
const api = await harness(original, { resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
groupConfigs.push({ path: 'prod', proxyProfileId: 'group-proxy' });
|
||||
assert.equal(resolveEffectiveHost(original).proxyConfig?.host, 'proxy.invalid');
|
||||
await api.update({ host: { ...original } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
|
||||
for (const owner of ['saved', 'global'] as const) {
|
||||
for (const [field, changed] of [
|
||||
['localShell', '/bin/zsh'], ['localShellArgs', ['--norc']], ['localStartDir', '/project-b'],
|
||||
] as const) {
|
||||
test(`local launch snapshot survives ${owner} ${field} edits and unchanged defaults reuse`, async (t) => {
|
||||
const settings = { localShell: '/bin/bash', localShellArgs: undefined as string[] | undefined, localStartDir: '/project-a' };
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
|
||||
const context = {
|
||||
terminalSettings: settings,
|
||||
resolveShellSetting: (command: string, _shells: DiscoveredShell[], args?: string[]) => ({ command, args }),
|
||||
};
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('launched', resolveEffectiveHost(original)), status: 'connected' as const };
|
||||
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id, 'undefined settings args and [] snapshot launch identically');
|
||||
const edited = owner === 'saved' ? { ...original, [field]: changed } : original;
|
||||
if (owner === 'global') Object.assign(settings, { [field]: changed });
|
||||
await api.update({ host: edited });
|
||||
assert.equal(await api.find(), undefined, 'an edited launch must not return the old process');
|
||||
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
|
||||
assert.equal(copy.localShell, '/bin/bash');
|
||||
assert.deepEqual(copy.localShellArgs, []);
|
||||
assert.equal(copy.localStartDir, '/project-a');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('home and backend-default snapshots do not inherit later global or saved settings', async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost' });
|
||||
const settings: { localShell?: string; localStartDir?: string } = {};
|
||||
const context = { terminalSettings: settings, resolveShellSetting: (command: string) => command ? { command } : null };
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('home-default', resolveEffectiveHost(original)), status: 'connected' as const };
|
||||
assert.equal(launched.localShell, '');
|
||||
assert.equal(launched.localStartDir, '~');
|
||||
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id);
|
||||
Object.assign(settings, { localShell: '/bin/bash', localStartDir: '/new-global' });
|
||||
const edited = { ...original, localShell: '/bin/zsh', localStartDir: '/new-saved' };
|
||||
await api.update({ host: edited });
|
||||
assert.equal(await api.find(), undefined);
|
||||
const copied = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
|
||||
assert.equal(copied.localShell, '', 'empty captured executable still requests backend default');
|
||||
assert.equal(copied.localStartDir, '~', 'home remains home after a new global directory');
|
||||
});
|
||||
|
||||
test('saved discovery IDs resolve before snapshot while backend default stays independent of discovery', async (t) => {
|
||||
const saved = host({ protocol: 'local', hostname: 'localhost', localShell: 'bash' });
|
||||
const context = {
|
||||
discoveredShells: [{ id: 'bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash', isDefault: true }],
|
||||
resolveShellSetting: (command: string) => command === 'bash' ? { command: '/bin/bash', args: ['-l'] } : null,
|
||||
};
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('discovered', resolveEffectiveHost(saved)), status: 'connected' as const };
|
||||
assert.equal(launched.localShell, '/bin/bash');
|
||||
const api = await harness(saved, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id);
|
||||
const defaultLaunch = resolveEffectiveHost({ ...saved, localShell: undefined });
|
||||
assert.equal(defaultLaunch.localShell, '');
|
||||
assert.equal(defaultLaunch.localShellArgs, undefined);
|
||||
});
|
||||
|
||||
for (const discovered of [
|
||||
{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' },
|
||||
{ id: 'wsl-ubuntu', name: 'Ubuntu', command: 'C:\\Windows\\System32\\wsl.exe', args: ['-d', 'Ubuntu'], icon: 'ubuntu' },
|
||||
]) {
|
||||
for (const owner of ['saved', 'global'] as const) {
|
||||
test(`cold ${owner} ${discovered.id} reuses after discovery without masking edits`, async (t) => {
|
||||
const settings = { localShell: discovered.id, localStartDir: '/project-a' };
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
|
||||
const context = { terminalSettings: settings, discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('cold', resolveEffectiveHost(original)), status: 'connected' as const };
|
||||
assert.equal(launched.localShell, discovered.id);
|
||||
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id);
|
||||
context.discoveredShells = [discovered];
|
||||
await api.update({ host: { ...original } });
|
||||
assert.equal((await api.find())?.sessionId, launched.id, 'discovery resolves the same executable and default arguments');
|
||||
const edited = { ...original, localShell: discovered.command, localShellArgs: ['--edited'], localStartDir: '/project-b' };
|
||||
await api.update({ host: edited });
|
||||
assert.equal(await api.find(), undefined, 'a real path/argument/directory edit still opens a new session');
|
||||
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
|
||||
assert.equal(copy.localShell, discovered.id);
|
||||
assert.deepEqual(copy.localShellArgs, []);
|
||||
assert.equal(copy.localStartDir, '/project-a');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('discovery does not replace an explicit executable or its custom arguments with an alias', async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/bash', localShellArgs: ['--norc'] });
|
||||
const context = { discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const api = await harness(original, { resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
context.discoveredShells = [{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' }];
|
||||
await api.update({ host: { ...original } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, localShell: 'bin-bash', localShellArgs: undefined } });
|
||||
assert.equal(await api.find(), undefined, 'selecting discovered login defaults changes the actual launch');
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
|
||||
import { findReusableHostSession } from '../../domain/hostOpenSession';
|
||||
import { resolveTelnetPassword, resolveTelnetPort, resolveTelnetUsername } from '../../domain/host';
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { findIncompleteProxyIdentityId, findMissingProxyIdentityId, hasUsableProxyConfig } from '../../domain/proxyProfiles';
|
||||
import { buildSftpHostCredentials } from './sftp/useSftpHostCredentials';
|
||||
import { buildTransferPoolKey } from './sftp/transferConnectionPool';
|
||||
import { createHostTerminalSession } from './sessionFactories';
|
||||
|
||||
type HostOpenSessionReuseInput = {
|
||||
sessions: TerminalSession[];
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
resolveEffectiveHost: (host: Host) => Host;
|
||||
};
|
||||
|
||||
type OpeningConnection = { localHost: Host } | { key: Promise<string | undefined> };
|
||||
|
||||
/** Retain the opening connection identity, never credentials in session storage. */
|
||||
export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost }: HostOpenSessionReuseInput) {
|
||||
const openingKeys = useRef(new Map<string, OpeningConnection>());
|
||||
const connectionKey = useCallback((host: Host) => {
|
||||
const protocol = resolveEffectiveTerminalProtocol(host);
|
||||
if (protocol === 'local') {
|
||||
const options = {
|
||||
hostname: host.hostname,
|
||||
username: '',
|
||||
localShell: host.localShell || undefined,
|
||||
// The renderer starter omits both absent and empty args so the
|
||||
// backend chooses its discovered/login defaults for either form.
|
||||
localShellArgs: host.localShellArgs?.length ? host.localShellArgs : undefined,
|
||||
localStartDir: host.localStartDir || '~',
|
||||
};
|
||||
return buildTransferPoolKey({ hostname: host.hostname, hostId: host.id, protocol, connectionOptions: options });
|
||||
}
|
||||
if (protocol === 'telnet') {
|
||||
// The actual Telnet starter rejects these settings before connecting.
|
||||
if ((host.proxyProfileId && !host.proxyConfig)
|
||||
|| findMissingProxyIdentityId(host.proxyConfig, identities)
|
||||
|| findIncompleteProxyIdentityId(host.proxyConfig, identities)
|
||||
|| hasUsableProxyConfig(host.proxyConfig)) {
|
||||
throw new Error('Telnet proxy configuration cannot be used');
|
||||
}
|
||||
// Match the actual Telnet starter, including explicit empty overrides.
|
||||
const identity = host.telnetIdentityId ? identities.find((item) => item.id === host.telnetIdentityId) : undefined;
|
||||
if (host.telnetIdentityId && (!identity?.username?.trim() || identity.password === undefined)) {
|
||||
throw new Error('Telnet identity is missing or incomplete');
|
||||
}
|
||||
const rawPassword = identity ? identity.password : resolveTelnetPassword(host);
|
||||
if (isEncryptedCredentialPlaceholder(rawPassword)) throw new Error('Telnet credentials are unavailable');
|
||||
const options = {
|
||||
hostname: host.hostname,
|
||||
port: resolveTelnetPort(host),
|
||||
username: identity ? identity.username?.trim() : resolveTelnetUsername(host),
|
||||
password: sanitizeCredentialValue(rawPassword),
|
||||
};
|
||||
return buildTransferPoolKey({ ...options, hostId: host.id, protocol, connectionOptions: options });
|
||||
}
|
||||
// Reuse the existing credential/chain expansion and transport identity
|
||||
// comparison. Presentation names and SFTP-only choices do not change a
|
||||
// terminal's destination or login.
|
||||
if (protocol === 'serial' && isEncryptedCredentialPlaceholder(host.password)) throw new Error('Serial credentials are unavailable');
|
||||
const options = buildSftpHostCredentials({
|
||||
host, hosts: hosts.map(resolveEffectiveHost), keys, identities,
|
||||
});
|
||||
delete options.sudo;
|
||||
delete options.fileProtocol;
|
||||
for (const hop of options.jumpHosts ?? []) delete hop.label;
|
||||
const connectionOptions = {
|
||||
...options,
|
||||
protocol,
|
||||
moshEnabled: host.moshEnabled,
|
||||
moshServerPath: host.moshServerPath,
|
||||
etEnabled: host.etEnabled,
|
||||
etPort: host.etPort,
|
||||
etJumpPorts: protocol === 'et'
|
||||
? host.hostChain?.hostIds.map((id) => hosts.find((item) => item.id === id)).map((hop) => hop && (resolveEffectiveHost(hop).etPort ?? 2022))
|
||||
: undefined,
|
||||
pluginConnection: host.pluginConnection,
|
||||
serialConfig: protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
|
||||
serialLogin: protocol === 'serial' ? { username: host.username?.trim(), password: sanitizeCredentialValue(host.password) } : undefined,
|
||||
agentForwarding: host.agentForwarding,
|
||||
x11Forwarding: host.x11Forwarding,
|
||||
};
|
||||
return buildTransferPoolKey({ ...options, protocol: connectionOptions.protocol, connectionOptions });
|
||||
}, [hosts, keys, identities, resolveEffectiveHost]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const liveIds = new Set(sessions.map((session) => session.id));
|
||||
for (const id of openingKeys.current.keys()) {
|
||||
if (!liveIds.has(id)) openingKeys.current.delete(id);
|
||||
}
|
||||
for (const session of sessions) {
|
||||
if (openingKeys.current.has(session.id) || session.restoreState) continue;
|
||||
const host = hosts.find((candidate) => candidate.id === session.hostId);
|
||||
if (!host) continue;
|
||||
try {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
const openingHost = {
|
||||
...effectiveHost,
|
||||
hostname: session.hostname,
|
||||
username: session.protocol === 'serial' ? resolveEffectiveHost(host).username : session.username,
|
||||
port: session.port ?? host.port,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: session.etEnabled ?? host.etEnabled,
|
||||
serialConfig: session.serialConfig ?? host.serialConfig,
|
||||
pluginConnection: session.pluginConnection ?? host.pluginConnection,
|
||||
localShell: session.localShell ?? effectiveHost.localShell,
|
||||
localShellArgs: session.localShellArgs ?? effectiveHost.localShellArgs,
|
||||
localStartDir: session.localStartDir ?? effectiveHost.localStartDir,
|
||||
};
|
||||
// Local IDs may only resolve after renderer discovery completes. Keep
|
||||
// the opening settings so that later discovery can identify the same
|
||||
// executable without replacing them with edited vault/global settings.
|
||||
openingKeys.current.set(session.id, resolveEffectiveTerminalProtocol(openingHost) === 'local'
|
||||
? { localHost: openingHost }
|
||||
: { key: connectionKey(openingHost).catch(() => undefined) });
|
||||
} catch {
|
||||
openingKeys.current.set(session.id, { key: Promise.resolve(undefined) });
|
||||
}
|
||||
}
|
||||
}, [sessions, hosts, connectionKey, resolveEffectiveHost]);
|
||||
|
||||
return useCallback(async (hostId: string) => {
|
||||
const host = hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!host) return undefined;
|
||||
try {
|
||||
const requestedKey = await connectionKey(resolveEffectiveHost(host));
|
||||
const matches = await Promise.all(sessions.map(async (session) => {
|
||||
if (session.hostId !== hostId) return undefined;
|
||||
const opening = openingKeys.current.get(session.id);
|
||||
const openingKey = opening && ('localHost' in opening
|
||||
// An explicit empty selector means backend default, not today's
|
||||
// global setting. Nonempty captured IDs can safely resolve now.
|
||||
? connectionKey(opening.localHost.localShell ? resolveEffectiveHost(opening.localHost) : opening.localHost)
|
||||
: opening.key);
|
||||
return await openingKey === requestedKey ? session : undefined;
|
||||
}));
|
||||
return findReusableHostSession(matches.filter((session) => session !== undefined), hostId);
|
||||
} catch {
|
||||
// An unresolved/changed credential must never select an old transport.
|
||||
return undefined;
|
||||
}
|
||||
}, [hosts, sessions, connectionKey, resolveEffectiveHost]);
|
||||
}
|
||||
@@ -1098,12 +1098,17 @@ export const useSessionState = ({
|
||||
if (targetHosts.length === 0) return;
|
||||
const resolvedCommand = commandOverride ?? snippet.command;
|
||||
|
||||
// Create sessions for each target host. createHostTerminalSession copies
|
||||
// protocol, shellType, and local-shell metadata, so local vault hosts keep
|
||||
// their transport info (session consumers treat a missing protocol as SSH).
|
||||
const newSessions: TerminalSession[] = targetHosts.map(host =>
|
||||
createHostTerminalSession(crypto.randomUUID(), host),
|
||||
);
|
||||
// Create sessions for each target host
|
||||
const newSessions: TerminalSession[] = targetHosts.map(host => ({
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting' as const,
|
||||
charset: host.charset,
|
||||
// workspaceId will be set after workspace is created
|
||||
}));
|
||||
|
||||
const sessionIds = newSessions.map(s => s.id);
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ import {
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_WINDOW_OPACITY,
|
||||
clampWindowOpacity,
|
||||
applyThemeTokens,
|
||||
areTerminalSettingsEqual,
|
||||
createCustomKeyBindingsSyncOrigin,
|
||||
@@ -179,8 +180,6 @@ import {
|
||||
import {
|
||||
parseWindowOpacityRecord,
|
||||
shouldApplyWindowOpacityRecord,
|
||||
updateWindowOpacityRecord,
|
||||
toggleWindowOpacityRecord,
|
||||
type WindowOpacityMutationSource,
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync';
|
||||
@@ -609,7 +608,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: number) => number)(prev.opacity)
|
||||
: nextValue;
|
||||
return updateWindowOpacityRecord(prev, candidate);
|
||||
const nextOpacity = clampWindowOpacity(candidate);
|
||||
if (nextOpacity === prev.opacity) return prev;
|
||||
return { opacity: nextOpacity, version: prev.version + 1 };
|
||||
});
|
||||
}, []);
|
||||
const applyIncomingWindowOpacity = useCallback((raw: unknown) => {
|
||||
@@ -624,10 +625,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
return incoming;
|
||||
});
|
||||
}, []);
|
||||
const toggleWindowOpacity = useCallback(() => {
|
||||
windowOpacityMutationSourceRef.current = 'local';
|
||||
setWindowOpacityRecord(toggleWindowOpacityRecord);
|
||||
}, []);
|
||||
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT);
|
||||
return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT);
|
||||
@@ -788,7 +785,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
if (!enableSettingsSync && key !== STORAGE_KEY_WINDOW_OPACITY) return;
|
||||
if (!enableSettingsSync) return;
|
||||
try {
|
||||
netcattyBridge.get()?.notifySettingsChanged?.({ key, value });
|
||||
} catch {
|
||||
@@ -1244,38 +1241,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSshTransportIdleTtlMsState,
|
||||
});
|
||||
|
||||
// Detached sessions share opacity and the shortcut configuration needed to
|
||||
// operate it. Keep general settings listeners and OS effects disabled there.
|
||||
useEffect(() => {
|
||||
if (enableSettingsSync) return;
|
||||
const applySharedWindowSetting = (key: string, value: unknown) => {
|
||||
if (key === STORAGE_KEY_WINDOW_OPACITY) applyIncomingWindowOpacity(value);
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_KEY_BINDINGS) {
|
||||
const incoming = parseCustomKeyBindingsStorageRecord(value);
|
||||
if (incoming) applyIncomingCustomKeyBindings(incoming);
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
|
||||
setIsHotkeyRecordingState(value);
|
||||
}
|
||||
};
|
||||
const unsubscribe = netcattyBridge.get()?.onSettingsChanged?.(({ key, value }) => {
|
||||
applySharedWindowSetting(key, value);
|
||||
});
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== null && event.newValue !== null) {
|
||||
applySharedWindowSetting(event.key, event.newValue);
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}, [enableSettingsSync, applyIncomingWindowOpacity, applyIncomingCustomKeyBindings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableSettingsSync) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -2218,7 +2183,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setGlobalHotkeyEnabled,
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
toggleWindowOpacity,
|
||||
appIconVariant,
|
||||
setAppIconVariant,
|
||||
rehydrateAllFromStorage,
|
||||
|
||||
@@ -191,10 +191,6 @@ export const useSftpState = (
|
||||
// Full Host used when each tab connected (includes session-time overrides).
|
||||
// Tab ids are stable across reconnect; connection ids are not.
|
||||
const connectedHostByTabIdRef = useRef<Map<string, Host | "local">>(new Map());
|
||||
// Resolved jump-host chain captured at connect time, keyed by tab id.
|
||||
// Retained transfers dial through this pinned chain even when one of the
|
||||
// chain's bastion hosts is edited later.
|
||||
const connectedHostChainByTabIdRef = useRef<Map<string, Host[]>>(new Map());
|
||||
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
|
||||
const connectRequestByTabIdRef = useRef<Map<string, symbol>>(new Map());
|
||||
|
||||
@@ -231,7 +227,6 @@ export const useSftpState = (
|
||||
connectedHostByTabIdRef.current.set(resolvedTabId, host);
|
||||
} else {
|
||||
connectedHostByTabIdRef.current.delete(resolvedTabId);
|
||||
connectedHostChainByTabIdRef.current.delete(resolvedTabId);
|
||||
}
|
||||
}, [leftTabsRef, rightTabsRef]);
|
||||
|
||||
@@ -245,7 +240,6 @@ export const useSftpState = (
|
||||
connectRequests: connectRequestByTabIdRef.current,
|
||||
connectInFlight: connectInFlightRef.current,
|
||||
connectedHosts: connectedHostByTabIdRef.current,
|
||||
connectedHostChains: connectedHostChainByTabIdRef.current,
|
||||
closeTab: (liveSide) => closeTab(liveSide, tabId),
|
||||
releaseConnection: async () => {
|
||||
if (!pane?.connection) return;
|
||||
@@ -324,7 +318,7 @@ export const useSftpState = (
|
||||
const resolveBrowseSourceSessionId = options?.resolveBrowseSourceSessionId;
|
||||
|
||||
const openPoolSftpSession = useCallback(
|
||||
async (host: Host, jumpHosts?: Host[]) => {
|
||||
async (host: Host) => {
|
||||
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
|
||||
// not repeated. Transport leases keep the shared conn alive after the
|
||||
// terminal tab closes until the transfer SFTP lease is returned.
|
||||
@@ -343,7 +337,7 @@ export const useSftpState = (
|
||||
knownHosts: transferKnownHosts,
|
||||
terminalSettings: transferTerminalSettings,
|
||||
},
|
||||
{ dedicated: false, sourceSessionId, jumpHosts },
|
||||
{ dedicated: false, sourceSessionId },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
@@ -367,14 +361,14 @@ export const useSftpState = (
|
||||
knownHosts: transferKnownHosts,
|
||||
terminalSettings: transferTerminalSettings,
|
||||
},
|
||||
{ dedicated: false, jumpHosts },
|
||||
{ dedicated: false },
|
||||
);
|
||||
},
|
||||
[hosts, identities, keys, resolveTransferSourceSessionId, transferKnownHosts, transferTerminalSettings],
|
||||
);
|
||||
|
||||
const acquireTransferSession = useCallback(
|
||||
async (hostId: string, transferId: string, connectHost?: Host, jumpHosts?: Host[]) => {
|
||||
async (hostId: string, transferId: string, connectHost?: Host) => {
|
||||
// Prefer the connect-time Host (terminal session overrides) so pooled
|
||||
// uploads open the same endpoint as the browse tab, not the vault entry.
|
||||
const host = connectHost && connectHost.id === hostId
|
||||
@@ -388,11 +382,7 @@ export const useSftpState = (
|
||||
`Transfer connect host id mismatch: expected ${hostId}, got ${connectHost.id}`,
|
||||
);
|
||||
}
|
||||
// Chain-pinned acquisitions key the pool-key cache by the chain snapshot
|
||||
// (stable array captured at connect) so they cannot collide with the
|
||||
// vault-resolved entry for the same host object in the WeakMap.
|
||||
const cacheKey = jumpHosts?.length ? jumpHosts : host;
|
||||
const poolKey = await transferPoolKeyCache.get(cacheKey, () => ({
|
||||
const poolKey = await transferPoolKeyCache.get(host, () => ({
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
@@ -406,10 +396,9 @@ export const useSftpState = (
|
||||
identities,
|
||||
knownHosts: transferKnownHosts,
|
||||
terminalSettings: transferTerminalSettings,
|
||||
jumpHostsOverride: jumpHosts,
|
||||
}),
|
||||
}));
|
||||
return transferPoolRef.current.acquire(poolKey, transferId, () => openPoolSftpSession(host, jumpHosts));
|
||||
return transferPoolRef.current.acquire(poolKey, transferId, () => openPoolSftpSession(host));
|
||||
},
|
||||
[hosts, identities, keys, openPoolSftpSession, transferKnownHosts, transferPoolKeyCache, transferTerminalSettings],
|
||||
);
|
||||
@@ -454,7 +443,6 @@ export const useSftpState = (
|
||||
lastConnectedHostRef,
|
||||
connectionCacheKeyMapRef,
|
||||
connectedHostByTabIdRef,
|
||||
connectedHostChainByTabIdRef,
|
||||
connectInFlightRef,
|
||||
connectRequestByTabIdRef,
|
||||
makeCacheKey,
|
||||
@@ -569,8 +557,6 @@ export const useSftpState = (
|
||||
listRemoteFiles,
|
||||
handleSessionError,
|
||||
acquireTransferSession,
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
resolveConnectedChain: (tabId) => connectedHostChainByTabIdRef.current.get(tabId) ?? null,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -635,7 +621,6 @@ export const useSftpState = (
|
||||
});
|
||||
},
|
||||
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
|
||||
resolveConnectedChain: (tabId) => connectedHostChainByTabIdRef.current.get(tabId) ?? null,
|
||||
acquireTransferSession,
|
||||
clearDirCacheEntry,
|
||||
useCompressedUpload: options?.useCompressedUpload,
|
||||
|
||||
@@ -53,7 +53,6 @@ export interface UseVaultAgentBridgeInput {
|
||||
stopTunnel: VaultAgentApiDeps['stopTunnel'];
|
||||
stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels'];
|
||||
openHost?: VaultAgentApiDeps['openHost'];
|
||||
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
|
||||
closeSession?: VaultAgentApiDeps['closeSession'];
|
||||
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
|
||||
}
|
||||
@@ -272,7 +271,6 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
|
||||
openHost: current.openHost
|
||||
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
|
||||
: undefined,
|
||||
findExistingSessionForHost: current.findExistingSessionForHost,
|
||||
closeSession: current.closeSession
|
||||
? (sessionId) => current.closeSession!(sessionId)
|
||||
: undefined,
|
||||
|
||||
@@ -483,73 +483,3 @@ for (const phase of ["snapshot", "commit"] as const) {
|
||||
assert.equal(commits, phase === "snapshot" ? 0 : 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const mixedSelection of [false, true]) {
|
||||
test(`group deletion prunes host and inherited chains, mixed selection: ${mixedSelection}`, async () => {
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const storedHosts = [
|
||||
{ id: "jump", group: "A", label: "jump", hostname: "j.example.com", port: 22, username: "root", protocol: "ssh" as const, tags: [], os: "linux" as const },
|
||||
{ id: "survivor", group: "B", label: "survivor", hostname: "s.example.com", port: 22, username: "root", protocol: "ssh" as const, tags: [], os: "linux" as const, hostChain: { hostIds: ["jump"] } },
|
||||
{ id: "extra", group: "B", label: "extra", hostname: "e.example.com", username: "root", tags: [], os: "linux" as const },
|
||||
{ id: "kept", group: "B", label: "kept", hostname: "k.example.com", username: "root", tags: [], os: "linux" as const },
|
||||
];
|
||||
const configs: GroupConfig[] = [{ path: "B", hostChain: { hostIds: ["jump", "extra", "kept"] } }];
|
||||
let deleteGroups: ReturnType<typeof useVaultGroupDeletion> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
let committedHosts: Array<{ id: string; hostChain?: { hostIds: string[] } }> = [];
|
||||
let committedConfigs: GroupConfig[] = [];
|
||||
|
||||
const Probe = () => {
|
||||
deleteGroups = useVaultGroupDeletion({
|
||||
customGroups: ["A", "B"],
|
||||
hosts: storedHosts,
|
||||
groupConfigs: configs,
|
||||
managedSources: [],
|
||||
onReadPersistedHosts: async () => storedHosts,
|
||||
onReadPersistedManagedSources: () => [],
|
||||
onCommitVaultGroupMutation: async (mutate) => {
|
||||
const result = mutate({
|
||||
groups: ["A", "B"],
|
||||
configs,
|
||||
hosts: storedHosts,
|
||||
managedSources: [],
|
||||
snippets: [],
|
||||
});
|
||||
if (result.ok) {
|
||||
committedHosts = result.state.hosts;
|
||||
committedConfigs = result.state.configs;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
await deleteGroups?.(["A"], true, new Set(mixedSelection ? ["extra"] : []));
|
||||
});
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
|
||||
assert.deepEqual(committedHosts.map(({ id, hostChain }) => ({ id, hostChain })), [
|
||||
{ id: "survivor", hostChain: { hostIds: [] } },
|
||||
...(!mixedSelection ? [{ id: "extra", hostChain: undefined }] : []),
|
||||
{ id: "kept", hostChain: undefined },
|
||||
]);
|
||||
assert.deepEqual(committedConfigs[0].hostChain,
|
||||
{ hostIds: mixedSelection ? ["kept"] : ["extra", "kept"] });
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { useCallback, useRef } from "react";
|
||||
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
|
||||
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
|
||||
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
|
||||
import { pruneVaultJumpHostReferences } from "../../domain/vaultJumpGraph";
|
||||
import type {
|
||||
VaultGroupMutationResult,
|
||||
VaultGroupMutationState,
|
||||
@@ -156,30 +155,14 @@ export function useVaultGroupDeletion({
|
||||
)) {
|
||||
throw RETRY_VAULT_GROUP_DELETION;
|
||||
}
|
||||
const nextTransactionHosts = currentDeletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
);
|
||||
// Hosts removed by this transaction must stop being referenced
|
||||
// from other hosts' jump chains (and group config chains), or
|
||||
// those targets keep failing with "A configured jump host is
|
||||
// missing".
|
||||
const retainedHostIds = new Set(
|
||||
nextTransactionHosts.map((host) => host.id),
|
||||
);
|
||||
const removedHostIds = current.hosts
|
||||
.filter((host) => !retainedHostIds.has(host.id))
|
||||
.map((host) => host.id);
|
||||
const prunedReferences = pruneVaultJumpHostReferences({
|
||||
hosts: nextTransactionHosts,
|
||||
groupConfigs: currentDeletion.groupConfigs,
|
||||
removedHostIds,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
state: {
|
||||
groups: currentDeletion.customGroups,
|
||||
hosts: prunedReferences.hosts,
|
||||
configs: prunedReferences.groupConfigs,
|
||||
hosts: currentDeletion.hosts.filter(
|
||||
(host) => !additionallyDeletedHostIds.has(host.id),
|
||||
),
|
||||
configs: currentDeletion.groupConfigs,
|
||||
managedSources: current.managedSources.filter(
|
||||
(source) => !currentDeletion.sourcesToRemove.some(
|
||||
(removed) => removed.id === source.id,
|
||||
|
||||
@@ -1,103 +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 type { Host } from '../../domain/models';
|
||||
import type { VaultGroupMutationState } from '../../domain/vaultGroupMutation';
|
||||
import { useVaultHostDeletion } from './useVaultHostDeletion';
|
||||
import { commitVaultGroupMutationPersistence } from './vaultGroupMutationPersistence';
|
||||
import {
|
||||
STORAGE_KEY_GROUP_CONFIGS,
|
||||
STORAGE_KEY_HOSTS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
|
||||
const host = (id: string, hostIds?: string[]): Host => ({
|
||||
id, label: id, hostname: `${id}.test`, username: 'root', tags: [], os: 'linux',
|
||||
...(hostIds ? { hostChain: { hostIds } } : {}),
|
||||
});
|
||||
|
||||
async function withDeletion(
|
||||
commit: Parameters<typeof useVaultHostDeletion>[0],
|
||||
run: (remove: ReturnType<typeof useVaultHostDeletion>) => 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 remove!: ReturnType<typeof useVaultHostDeletion>;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
remove = useVaultHostDeletion(commit);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await run(remove);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
}
|
||||
|
||||
for (const removed of [['jump'], ['jump', 'extra']]) {
|
||||
test(`host deletion cleans latest host and group references together: ${removed.join(',')}`, async () => {
|
||||
const latest: VaultGroupMutationState = {
|
||||
hosts: [host('jump'), host('extra'), host('kept'), host('target', ['jump', 'kept'])],
|
||||
configs: [{ path: 'prod', username: 'latest-user', hostChain: { hostIds: ['jump'] } }],
|
||||
groups: ['prod'], managedSources: [], snippets: [],
|
||||
};
|
||||
let committed: VaultGroupMutationState | undefined;
|
||||
await withDeletion(async (mutate) => {
|
||||
const result = mutate(latest);
|
||||
if (result.ok) committed = result.state;
|
||||
return result;
|
||||
}, async (remove) => { await act(async () => { await remove(removed); }); });
|
||||
assert.ok(committed);
|
||||
assert.deepEqual(committed.hosts.map((item) => item.id),
|
||||
removed.length === 1 ? ['extra', 'kept', 'target'] : ['kept', 'target']);
|
||||
assert.deepEqual(committed.hosts.at(-1)?.hostChain, { hostIds: ['kept'] });
|
||||
assert.deepEqual(committed.configs[0], {
|
||||
path: 'prod', username: 'latest-user', hostChain: { hostIds: [] },
|
||||
});
|
||||
assert.equal(committed.snippets, latest.snippets);
|
||||
assert.deepEqual(latest.hosts.at(-1)?.hostChain, { hostIds: ['jump', 'kept'] });
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed group-config write rolls host deletion back and reports failure', async () => {
|
||||
const original = {
|
||||
[STORAGE_KEY_HOSTS]: [host('jump'), host('target', ['jump'])],
|
||||
[STORAGE_KEY_GROUP_CONFIGS]: [{ path: 'prod', hostChain: { hostIds: ['jump'] } }],
|
||||
};
|
||||
const values = new Map(Object.entries(original).map(([key, value]) => [key, JSON.stringify(value)]));
|
||||
const storage = {
|
||||
read: <T,>(key: string): T | null => JSON.parse(values.get(key) ?? 'null'),
|
||||
readString: (key: string) => values.get(key) ?? null,
|
||||
write: <T,>(key: string, value: T) => {
|
||||
if (key === STORAGE_KEY_GROUP_CONFIGS) return false;
|
||||
values.set(key, JSON.stringify(value));
|
||||
return true;
|
||||
},
|
||||
writeString: (key: string, value: string) => { values.set(key, value); return true; },
|
||||
remove: (key: string) => { values.delete(key); },
|
||||
};
|
||||
const identity = async <T,>(value: T) => value;
|
||||
await withDeletion((mutate) => commitVaultGroupMutationPersistence({
|
||||
storage, mutate, prepareState: (value) => value,
|
||||
decryptHosts: identity, decryptConfigs: identity,
|
||||
encryptHosts: identity, encryptConfigs: identity, isCurrent: () => true,
|
||||
}), async (remove) => {
|
||||
await assert.rejects(remove(['jump']), /rejected importer transaction/);
|
||||
});
|
||||
for (const [key, value] of Object.entries(original)) {
|
||||
assert.deepEqual(storage.read(key), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('host deletion reports a rejected or superseded transaction', async () => {
|
||||
for (const failure of [{ ok: false, error: 'unavailable' }, { ok: false, superseded: true }] as const) {
|
||||
await withDeletion(async () => failure, async (remove) => {
|
||||
await assert.rejects(remove(['jump']), /unavailable|superseded/);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { VaultGroupMutationResult, VaultGroupMutationState } from '../../domain/vaultGroupMutation';
|
||||
import { pruneVaultJumpHostReferences } from '../../domain/vaultJumpGraph';
|
||||
|
||||
export function useVaultHostDeletion(
|
||||
commit: (
|
||||
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
||||
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>,
|
||||
) {
|
||||
return useCallback(async (hostIds: Iterable<string>) => {
|
||||
const removedHostIds = new Set(hostIds);
|
||||
if (removedHostIds.size === 0) return;
|
||||
const result = await commit((current) => {
|
||||
const pruned = pruneVaultJumpHostReferences({
|
||||
hosts: current.hosts.filter((host) => !removedHostIds.has(host.id)),
|
||||
groupConfigs: current.configs,
|
||||
removedHostIds,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
state: { ...current, hosts: pruned.hosts, configs: pruned.groupConfigs },
|
||||
};
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error('error' in result ? result.error : 'Host deletion was superseded. Try again.');
|
||||
}
|
||||
}, [commit]);
|
||||
}
|
||||
@@ -82,10 +82,6 @@ import {
|
||||
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
|
||||
import { rebasePendingVaultNotes } from "../../domain/vaultNotesReconciliation";
|
||||
import { publishShellHistorySnapshot } from "./shellHistoryStore";
|
||||
import {
|
||||
clearHistory as clearAutocompleteHistory,
|
||||
markCommandHistoryCleared,
|
||||
} from "./commandHistoryStore";
|
||||
import { setVaultInitialized } from "./vaultInitStore";
|
||||
import { notify } from "../notification";
|
||||
import {
|
||||
@@ -1254,23 +1250,6 @@ export const useVaultState = () => {
|
||||
publishShellHistorySnapshot([]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear every locally recorded command history: the global shell history, the
|
||||
* terminal autocomplete history, and the in-memory compose-bar recall.
|
||||
*
|
||||
* The autocomplete store persists the clear synchronously and fans it out to
|
||||
* other renderers through its own `storage` listener. The compose-bar store
|
||||
* subscribes to that same clear event, so detached terminal popups drop their
|
||||
* recall too without a separate broadcast.
|
||||
*/
|
||||
const clearAllCommandHistory = useCallback(() => {
|
||||
clearShellHistory();
|
||||
clearAutocompleteHistory();
|
||||
// Tombstone so a later first-time histfile import cannot restore the
|
||||
// commands the user just deleted.
|
||||
markCommandHistoryCleared();
|
||||
}, [clearShellHistory]);
|
||||
|
||||
const removeShellHistoryEntry = useCallback((entryId: string) => {
|
||||
setShellHistory((prev) => {
|
||||
const updated = removeGlobalHistoryEntry(prev, entryId);
|
||||
@@ -2222,7 +2201,6 @@ export const useVaultState = () => {
|
||||
updateGroupConfigs,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
clearAllCommandHistory,
|
||||
removeShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
|
||||
@@ -86,7 +86,6 @@ test('vault actions register and unregister', () => {
|
||||
updateHostLastConnected: noop,
|
||||
addShellHistoryEntry: noop,
|
||||
removeShellHistoryEntry: noop,
|
||||
clearAllCommandHistory: noop,
|
||||
});
|
||||
getVaultSnapshotActions()?.updateHosts([]);
|
||||
assert.deepEqual(calls, ['hosts']);
|
||||
|
||||
@@ -63,7 +63,6 @@ export type VaultSnapshotActions = Pick<
|
||||
| 'updateHostLastConnected'
|
||||
| 'addShellHistoryEntry'
|
||||
| 'removeShellHistoryEntry'
|
||||
| 'clearAllCommandHistory'
|
||||
>;
|
||||
|
||||
const EMPTY_HOSTS: readonly Host[] = Object.freeze([]);
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
serializeWindowOpacityRecord,
|
||||
shouldApplyWindowOpacityRecord,
|
||||
shouldBroadcastWindowOpacityChange,
|
||||
updateWindowOpacityRecord,
|
||||
toggleWindowOpacityRecord,
|
||||
type WindowOpacityMutationSource,
|
||||
type WindowOpacityRecord,
|
||||
} from './windowOpacitySync.ts';
|
||||
@@ -199,23 +197,3 @@ test('serializeWindowOpacityRecord round-trips through parse', () => {
|
||||
const raw = serializeWindowOpacityRecord({ opacity: 0.55, version: 9 });
|
||||
assert.deepEqual(parseWindowOpacityRecord(JSON.parse(raw)), { opacity: 0.55, version: 9 });
|
||||
});
|
||||
|
||||
test('toggling off and restarting preserves a legacy selected opacity', () => {
|
||||
const selected = parseWindowOpacityRecord('0.6');
|
||||
const opaque = toggleWindowOpacityRecord(selected);
|
||||
assert.equal(opaque.opacity, 1);
|
||||
const restarted = parseWindowOpacityRecord(serializeWindowOpacityRecord(opaque));
|
||||
assert.equal(toggleWindowOpacityRecord(restarted).opacity, 0.6);
|
||||
assert.equal(toggleWindowOpacityRecord(restarted).version, selected.version + 2);
|
||||
});
|
||||
|
||||
test('slider changes replace the preferred opacity and toggles use the latest record', () => {
|
||||
const opaque = toggleWindowOpacityRecord({ opacity: 0.6, version: 2 });
|
||||
const slider = updateWindowOpacityRecord(opaque, 0.7);
|
||||
const next = toggleWindowOpacityRecord(toggleWindowOpacityRecord(slider));
|
||||
assert.equal(next.opacity, 0.7);
|
||||
assert.equal(next.preferredOpacity, 0.7);
|
||||
assert.equal(next.version, 6);
|
||||
assert.equal(updateWindowOpacityRecord(next, 0.7), next);
|
||||
assert.equal(toggleWindowOpacityRecord({ opacity: 1, version: 0 }).opacity, 0.85);
|
||||
});
|
||||
@@ -10,7 +10,6 @@ export type WindowOpacityMutationSource = 'local' | 'incoming';
|
||||
export type WindowOpacityRecord = {
|
||||
opacity: number;
|
||||
version: number;
|
||||
preferredOpacity?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -35,16 +34,12 @@ export function parseWindowOpacityRecord(raw: unknown): WindowOpacityRecord {
|
||||
}
|
||||
|
||||
if (raw && typeof raw === 'object') {
|
||||
const record = raw as { opacity?: unknown; version?: unknown; preferredOpacity?: unknown };
|
||||
const record = raw as { opacity?: unknown; version?: unknown };
|
||||
const opacity = clampWindowOpacity(record.opacity);
|
||||
const version = Number(record.version);
|
||||
const preferred = Number(record.preferredOpacity);
|
||||
return {
|
||||
opacity,
|
||||
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
|
||||
...(Number.isFinite(preferred) && preferred < 1
|
||||
? { preferredOpacity: clampWindowOpacity(preferred) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,30 +50,9 @@ export function serializeWindowOpacityRecord(record: WindowOpacityRecord): strin
|
||||
return JSON.stringify({
|
||||
opacity: clampWindowOpacity(record.opacity),
|
||||
version: Math.max(0, Math.floor(record.version) || 0),
|
||||
...(record.preferredOpacity !== undefined ? { preferredOpacity: record.preferredOpacity } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Slider changes and temporary toggles share the existing versioned setting. */
|
||||
export function updateWindowOpacityRecord(
|
||||
current: WindowOpacityRecord,
|
||||
nextValue: number,
|
||||
): WindowOpacityRecord {
|
||||
const opacity = clampWindowOpacity(nextValue);
|
||||
if (opacity === current.opacity) return current;
|
||||
const preferredOpacity = opacity < 1 ? opacity
|
||||
: current.opacity < 1 ? current.opacity : current.preferredOpacity;
|
||||
return {
|
||||
opacity,
|
||||
version: current.version + 1,
|
||||
...(preferredOpacity !== undefined ? { preferredOpacity } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleWindowOpacityRecord(current: WindowOpacityRecord): WindowOpacityRecord {
|
||||
return updateWindowOpacityRecord(current, current.opacity < 1 ? 1 : (current.preferredOpacity ?? 0.85));
|
||||
}
|
||||
|
||||
/**
|
||||
* Incoming peer updates must not clobber a newer local/drag revision.
|
||||
* Equal versions are treated as already-applied (no state thrash).
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { AISession } from '../infrastructure/ai/types';
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import { withSyncReliabilityMeta } from '../domain/syncReliability';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIChatStreaming, type UseAIChatStreamingReturn } from './state/useAIChatStreaming';
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
removeItem: (key: string) => storage.delete(key),
|
||||
} });
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: events });
|
||||
const keys = await import('../infrastructure/config/storageKeys');
|
||||
const snapshots = await import('./state/aiStateSnapshots');
|
||||
const { getAISessionsSnapshot } = await import('./state/aiSessionsStore');
|
||||
const {
|
||||
buildCloudSyncPayload, buildLocalVaultPayload, collectAiSessionsForSync,
|
||||
applySyncPayload, applyLocalVaultPayload, hasMeaningfulSyncData,
|
||||
setAiSessionsSyncPreference,
|
||||
hasCloudSyncEntityData, hasMeaningfulCloudSyncData, shouldPromptCloudVaultRecovery,
|
||||
} = await import('./syncPayload');
|
||||
|
||||
const vault = { hosts: [], keys: [], identities: [], snippets: [], customGroups: [], knownHosts: [] };
|
||||
const remotePayload = (aiSessions: AISession[]): SyncPayload => ({ ...vault, aiSessions, syncedAt: 1 });
|
||||
const session = (): AISession => ({
|
||||
id: 'chat', title: 'Chat', agentId: 'catty', scope: { type: 'global' }, createdAt: 1, updatedAt: 2,
|
||||
messages: [{ id: 'message', role: 'user', content: 'Inspect', timestamp: 1, attachments: [{
|
||||
filename: 'terminal.log', mediaType: 'text/plain', terminalSelection: true,
|
||||
base64Data: 'cHJpdmF0ZQ==', previewText: 'private', filePath: '/private/device/file', lineCount: 1,
|
||||
}] }],
|
||||
});
|
||||
function save(sessions: AISession[], enabled = true) {
|
||||
snapshots.setLatestAISessionsSnapshot(sessions);
|
||||
storage.set(keys.STORAGE_KEY_AI_SESSIONS, JSON.stringify(sessions));
|
||||
storage.set(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
|
||||
}
|
||||
test.beforeEach(() => { storage.clear(); save([], false); });
|
||||
|
||||
test('actual manual upload and auto collector honor consent and explicit empty', async () => {
|
||||
save([session()], false);
|
||||
const options = { collectPluginSidecars: () => undefined };
|
||||
assert.equal((await buildCloudSyncPayload(vault, [], options)).aiSessions, undefined);
|
||||
assert.equal(collectAiSessionsForSync(), undefined);
|
||||
save([], true);
|
||||
assert.deepEqual((await buildCloudSyncPayload(vault, [], options)).aiSessions, []);
|
||||
assert.deepEqual(collectAiSessionsForSync(), []);
|
||||
save([session()], true);
|
||||
const cloud = await buildCloudSyncPayload(vault, [], options);
|
||||
assert.equal(cloud.aiSessions?.length, 1);
|
||||
assert.deepEqual(cloud.aiSessions, collectAiSessionsForSync());
|
||||
assert.equal(JSON.stringify(cloud).includes('cHJpdmF0ZQ=='), false);
|
||||
assert.equal(JSON.stringify(cloud).includes('/private/device/file'), false);
|
||||
assert.equal(JSON.stringify(cloud.aiSessions?.[0].messages[0].attachments).includes('private'), false);
|
||||
});
|
||||
|
||||
test('sync persistence survives restart beyond the local-only history limits', () => {
|
||||
const many = Array.from({ length: 51 }, (_, index) => ({ ...session(), id: `chat-${index}`, updatedAt: index,
|
||||
messages: Array.from({ length: 201 }, (_, turn) => ({ id: `m-${turn}`, role: 'user' as const, content: 'History', timestamp: turn })) }));
|
||||
save(many);
|
||||
assert.equal(snapshots.writeSessionsForStorage(many), true);
|
||||
assert.equal(collectAiSessionsForSync()?.length, 51);
|
||||
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
const afterRestart = collectAiSessionsForSync()!;
|
||||
assert.equal(afterRestart.length, 51);
|
||||
assert.equal(afterRestart[0].messages.length, 201);
|
||||
save(many, false);
|
||||
assert.equal(snapshots.writeSessionsForStorage(many), true);
|
||||
const localOnly = JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!);
|
||||
assert.equal(localOnly.length, 50);
|
||||
assert.equal(localOnly[0].messages.length, 200);
|
||||
});
|
||||
|
||||
test('failed history persistence refuses upload without pruning the cloud representation', async () => {
|
||||
save([session()]);
|
||||
const original = globalThis.localStorage.setItem;
|
||||
globalThis.localStorage.setItem = (key, value) => {
|
||||
if (key === keys.STORAGE_KEY_AI_SESSIONS) throw new DOMException('Full', 'QuotaExceededError');
|
||||
original(key, value);
|
||||
};
|
||||
snapshots.setLatestAISessionsSnapshot([{ ...session(), title: 'Unsaved change' }]);
|
||||
try {
|
||||
await assert.rejects(buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined }), /persist|save/i);
|
||||
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].title, 'Chat');
|
||||
const backup = buildLocalVaultPayload(vault);
|
||||
assert.equal(backup.aiSessions?.[0].title, 'Unsaved change');
|
||||
assert.equal(backup.aiSessions?.[0].messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
|
||||
} finally { globalThis.localStorage.setItem = original; }
|
||||
});
|
||||
|
||||
test('failed opt-in preference commit leaves local chats and native threads untouched', async () => {
|
||||
const local = { ...session(), externalSessionId: 'native-thread' };
|
||||
save([local], false);
|
||||
const original = globalThis.localStorage.setItem;
|
||||
const cleanups: string[] = [];
|
||||
Object.assign(window, { netcatty: { aiSdkAgentCleanup: async (id: string) => { cleanups.push(id); return { ok: true }; } } });
|
||||
globalThis.localStorage.setItem = (key, value) => {
|
||||
if (key === keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS) throw new DOMException('Full', 'QuotaExceededError');
|
||||
original(key, value);
|
||||
};
|
||||
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
|
||||
{ id: 'remote', role: 'assistant' as const, content: 'Remote turn', timestamp: 3 }] };
|
||||
try {
|
||||
await assert.rejects(setAiSessionsSyncPreference(true, async () => [remotePayload([incoming])]), /preference|enable/i);
|
||||
assert.deepEqual(snapshots.latestAISessionsSnapshot, [local]);
|
||||
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), [local]);
|
||||
assert.deepEqual(cleanups, []);
|
||||
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)!).enabled, false);
|
||||
} finally { globalThis.localStorage.setItem = original; delete (window as unknown as { netcatty?: unknown }).netcatty; }
|
||||
});
|
||||
|
||||
test('local backup retains chat and attachment bytes with cloud consent off', async () => {
|
||||
const local = session();
|
||||
save([local], false);
|
||||
const backup = buildLocalVaultPayload(vault);
|
||||
assert.deepEqual(backup.aiSessions, [local]);
|
||||
assert.equal(hasMeaningfulSyncData({ ...vault, aiSessions: [local], syncedAt: 1 }), true);
|
||||
save([], false);
|
||||
await applyLocalVaultPayload(backup, { importVaultData: () => undefined }, {
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
assert.equal(getAISessionsSnapshot().sessions[0].messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
|
||||
});
|
||||
|
||||
test('chat-only data is syncable but does not suppress empty-vault recovery', () => {
|
||||
const local = { ...vault, aiSessions: [session()], syncedAt: 1 };
|
||||
assert.equal(hasMeaningfulCloudSyncData(local), true);
|
||||
assert.equal(hasCloudSyncEntityData(local), false);
|
||||
const host = { id: 'remote', label: 'Remote', hostname: 'remote.example', username: 'test', port: 22, protocol: 'ssh' as const, tags: [], os: 'linux' as const };
|
||||
for (const hosts of [[host], [host, { ...host, id: 'second' }]]) {
|
||||
assert.equal(shouldPromptCloudVaultRecovery(local, { ...vault, hosts, syncedAt: 2 }), true);
|
||||
}
|
||||
assert.equal(shouldPromptCloudVaultRecovery({ ...vault, syncedAt: 1 }, local), false);
|
||||
});
|
||||
|
||||
test('download publishes immediately and keeps local attachment bytes on a newer message', async () => {
|
||||
save([session()]);
|
||||
const incoming = session();
|
||||
incoming.updatedAt = 3;
|
||||
incoming.messages[0].content = 'Updated';
|
||||
incoming.messages[0].attachments = [{ base64Data: '', mediaType: 'text/plain', filename: 'terminal.log', terminalSelection: true, contentUnavailable: true }];
|
||||
const payload: SyncPayload = { ...vault, aiSessions: [incoming], syncedAt: 2 };
|
||||
await applySyncPayload(payload, { importVaultData: () => undefined });
|
||||
const current = getAISessionsSnapshot().sessions[0];
|
||||
assert.equal(current.messages[0].content, 'Updated');
|
||||
assert.equal(current.messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
|
||||
assert.equal(snapshots.latestAISessionsSnapshot?.[0], current);
|
||||
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].messages[0].content, 'Updated');
|
||||
});
|
||||
|
||||
test('restoring an old backup without chat leaves current chats alone', async () => {
|
||||
const local = session();
|
||||
save([local], false);
|
||||
await applyLocalVaultPayload({ ...vault, syncedAt: 1 }, { importVaultData: () => undefined }, {
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
assert.deepEqual(snapshots.latestAISessionsSnapshot, [local]);
|
||||
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), [local]);
|
||||
});
|
||||
|
||||
test('authoritative cloud session removal stays removed in the next upload', async () => {
|
||||
save([session()]);
|
||||
await applySyncPayload({ ...vault, aiSessions: [], syncedAt: 3 }, { importVaultData: () => undefined });
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||
assert.deepEqual(collectAiSessionsForSync(), []);
|
||||
assert.deepEqual((await buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined })).aiSessions, []);
|
||||
});
|
||||
|
||||
test('replacement stops active turns and waits for finalizers before persisting history', async (context) => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
let hook: UseAIChatStreamingReturn;
|
||||
function Harness() {
|
||||
hook = useAIChatStreaming({ maxIterations: 1, addMessageToSession: () => {}, updateLastMessage: () => {}, updateMessageById: () => {} });
|
||||
return null;
|
||||
}
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
const original = globalThis.localStorage.setItem;
|
||||
try {
|
||||
for (const remove of [false, true]) {
|
||||
const order: string[] = [];
|
||||
const controller = new AbortController();
|
||||
const local = { ...session(), messages: [...session().messages,
|
||||
{ id: 'reply', role: 'assistant' as const, content: 'Partial', timestamp: 2, executionStatus: 'running' as const }] };
|
||||
save([local]);
|
||||
hook!.abortControllersRef.current.set('chat', controller);
|
||||
await act(async () => { hook!.setStreamingForScope('chat', true); });
|
||||
Object.assign(window, { netcatty: {
|
||||
aiCattyCancelExec: async () => { order.push('cancel'); },
|
||||
aiSdkAgentCancel: async () => ({ ok: true }),
|
||||
aiSdkAgentCleanup: async () => { order.push('cleanup'); return { ok: true }; },
|
||||
} });
|
||||
const wait = context.mock.method(getAgentRuntime(), 'waitForActiveTurn', async () => {
|
||||
assert.equal(controller.signal.aborted, true);
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
snapshots.setLatestAISessionsSnapshot([{ ...local, messages: [local.messages[0],
|
||||
{ ...local.messages[1], content: 'Settled', executionStatus: 'cancelled' }] }]);
|
||||
order.push('settled');
|
||||
});
|
||||
globalThis.localStorage.setItem = (key, value) => { if (key === keys.STORAGE_KEY_AI_SESSIONS) order.push('persist'); original(key, value); };
|
||||
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
|
||||
{ id: 'remote', role: 'user' as const, content: 'Remote', timestamp: 3 }] };
|
||||
await applySyncPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined });
|
||||
assert.ok(order.indexOf('cancel') < order.indexOf('settled'));
|
||||
assert.ok(order.indexOf('settled') < order.indexOf('persist'));
|
||||
assert.ok(order.indexOf('persist') < order.indexOf('cleanup'));
|
||||
assert.equal(hook!.abortControllersRef.current.has('chat'), false);
|
||||
if (remove) assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||
else assert.ok(getAISessionsSnapshot().sessions[0].messages.some(message => message.content === 'Settled'));
|
||||
wait.mock.restore();
|
||||
globalThis.localStorage.setItem = original;
|
||||
await act(async () => { hook!.setStreamingForScope('chat', false); });
|
||||
}
|
||||
} finally {
|
||||
context.mock.restoreAll(); globalThis.localStorage.setItem = original;
|
||||
delete (window as unknown as { netcatty?: unknown }).netcatty;
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
}
|
||||
});
|
||||
|
||||
test('local restore replaces later sessions and later turns with the backup snapshot', async () => {
|
||||
const backedUp = session();
|
||||
const later = { ...session(), updatedAt: 3, messages: [...session().messages,
|
||||
{ id: 'later', role: 'assistant' as const, content: 'After backup', timestamp: 3 }] };
|
||||
save([later, { ...session(), id: 'created-after-backup' }]);
|
||||
await applyLocalVaultPayload({ ...vault, aiSessions: [backedUp], syncedAt: 1 }, { importVaultData: () => undefined }, {
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, [backedUp]);
|
||||
await applyLocalVaultPayload({ ...vault, aiSessions: [], syncedAt: 1 }, { importVaultData: () => undefined }, {
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||
});
|
||||
|
||||
test('cloud downloads respect local chat opt-in while still applying vault data', async () => {
|
||||
save([], false);
|
||||
let vaultImports = 0;
|
||||
const downloaded = { ...vault, aiSessions: [session()], syncedAt: 2 };
|
||||
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
|
||||
assert.equal(vaultImports, 1);
|
||||
assert.deepEqual(snapshots.latestAISessionsSnapshot, []);
|
||||
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), []);
|
||||
save([], true);
|
||||
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
|
||||
assert.equal(vaultImports, 2);
|
||||
assert.equal(snapshots.latestAISessionsSnapshot?.[0].id, 'chat');
|
||||
});
|
||||
|
||||
test('enabling sync imports existing cloud chats before an empty local list can be uploaded', async () => {
|
||||
save([], false);
|
||||
let loads = 0;
|
||||
await setAiSessionsSyncPreference(true, async () => {
|
||||
loads++;
|
||||
assert.equal(collectAiSessionsForSync(), undefined);
|
||||
return [remotePayload([session()])];
|
||||
});
|
||||
assert.equal(loads, 1);
|
||||
assert.equal(collectAiSessionsForSync()?.[0].id, 'chat');
|
||||
assert.equal(getAISessionsSnapshot().sessions[0].id, 'chat');
|
||||
await setAiSessionsSyncPreference(false, async () => { throw new Error('Disable must not load'); });
|
||||
assert.equal(collectAiSessionsForSync(), undefined);
|
||||
});
|
||||
|
||||
test('an unsuccessful initial cloud read leaves chat sync disabled and local history unchanged', async () => {
|
||||
save([], false);
|
||||
await assert.rejects(setAiSessionsSyncPreference(true, async () => { throw new Error('offline'); }), /offline/);
|
||||
assert.equal(collectAiSessionsForSync(), undefined);
|
||||
assert.deepEqual(snapshots.latestAISessionsSnapshot, []);
|
||||
});
|
||||
|
||||
test('enabling from multiple mirrored providers retains the newer turns from either copy', async () => {
|
||||
save([], false);
|
||||
const newer = session();
|
||||
newer.updatedAt = 3;
|
||||
newer.messages.push({ id: 'new-turn', role: 'assistant', content: 'Newer provider turn', timestamp: 3 });
|
||||
await setAiSessionsSyncPreference(true, async () => [remotePayload([newer]), remotePayload([session()])]);
|
||||
assert.deepEqual(collectAiSessionsForSync()?.[0].messages.map((message) => message.id), ['message', 'new-turn']);
|
||||
});
|
||||
|
||||
test('initial enable retains local-only chats but respects remote deletions and preservation', async () => {
|
||||
const localOnly = { ...session(), id: 'local-only' };
|
||||
save([localOnly], false);
|
||||
await setAiSessionsSyncPreference(true, async () => [remotePayload([session()])]);
|
||||
assert.deepEqual(collectAiSessionsForSync()?.map(item => item.id).sort(), ['chat', 'local-only']);
|
||||
const removed = withSyncReliabilityMeta(remotePayload([]), remotePayload([session()]), { now: 3 });
|
||||
for (const preserve of [false, true]) {
|
||||
save([session(), localOnly], false);
|
||||
await setAiSessionsSyncPreference(true, async () => [removed, remotePayload([session()])], preserve);
|
||||
assert.deepEqual(collectAiSessionsForSync()?.map(item => item.id).sort(), preserve ? ['chat', 'local-only'] : ['local-only']);
|
||||
}
|
||||
});
|
||||
|
||||
test('backup rollback resets the native thread that still contains later turns', async () => {
|
||||
const cleanups: string[] = [];
|
||||
Object.assign(window, { netcatty: { aiSdkAgentCleanup: async (id: string) => { cleanups.push(id); return { ok: true }; } } });
|
||||
const backedUp = { ...session(), agentId: 'codex', externalSessionId: 'native-thread' };
|
||||
save([{ ...backedUp, updatedAt: 4, messages: [...backedUp.messages,
|
||||
{ id: 'later', role: 'assistant', content: 'After backup', timestamp: 4 }] }]);
|
||||
await applyLocalVaultPayload(remotePayload([backedUp]), { importVaultData: () => undefined }, {
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
const restored = getAISessionsSnapshot().sessions[0];
|
||||
assert.equal(restored.externalSessionId, undefined);
|
||||
assert.deepEqual(restored.messages, backedUp.messages);
|
||||
assert.deepEqual(cleanups, ['chat']);
|
||||
delete (window as unknown as { netcatty?: unknown }).netcatty;
|
||||
});
|
||||
|
||||
test('cloud restore never resumes a source device SDK thread, and preserves matching local threads', async () => {
|
||||
const local = { ...session(), externalSessionId: 'local-native-thread' };
|
||||
save([local]);
|
||||
const incoming = { ...session(), updatedAt: 3, externalSessionId: 'remote-native-thread' };
|
||||
const fresh = { ...incoming, id: 'new-chat' };
|
||||
await applySyncPayload({ ...vault, aiSessions: [incoming, fresh], syncedAt: 2 }, { importVaultData: () => undefined });
|
||||
const current = getAISessionsSnapshot().sessions;
|
||||
assert.equal(current.find((s) => s.id === 'chat')?.externalSessionId, 'local-native-thread');
|
||||
assert.equal(current.find((s) => s.id === 'new-chat')?.externalSessionId, undefined);
|
||||
});
|
||||
|
||||
test('changed remote transcript resets the local native SDK thread before continuing', async () => {
|
||||
const cleanups: string[] = [];
|
||||
Object.assign(window, { netcatty: { aiSdkAgentCleanup: async (id: string) => { cleanups.push(id); return { ok: true }; } } });
|
||||
save([{ ...session(), agentId: 'codex', externalSessionId: 'local-native-thread' }]);
|
||||
const incoming = { ...session(), agentId: 'codex', updatedAt: 3, externalSessionId: 'remote-native-thread' };
|
||||
incoming.messages.push({ id: 'remote-turn', role: 'user', content: 'Added on another device', timestamp: 3 });
|
||||
await applySyncPayload({ ...vault, aiSessions: [incoming], syncedAt: 2 }, { importVaultData: () => undefined });
|
||||
assert.deepEqual(cleanups, ['chat']);
|
||||
assert.equal(getAISessionsSnapshot().sessions[0].externalSessionId, undefined);
|
||||
assert.equal(getAISessionsSnapshot().sessions[0].messages.at(-1)?.content, 'Added on another device');
|
||||
delete (window as unknown as { netcatty?: unknown }).netcatty;
|
||||
});
|
||||
+15
-149
@@ -46,14 +46,6 @@ import { localStorageAdapter } from '../infrastructure/persistence/localStorageA
|
||||
import { decryptField, encryptField } from '../infrastructure/persistence/secureFieldAdapter';
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import {
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
writeSessionsForStorage,
|
||||
cleanupSdkAgentSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
} from './state/aiStateSnapshots';
|
||||
import { getAISessionsSnapshot, publishAISessionsSnapshot } from './state/aiSessionsStore';
|
||||
import {
|
||||
persistCommandBlocklistSetting,
|
||||
readCommandBlocklistSetting,
|
||||
@@ -72,7 +64,6 @@ import {
|
||||
import { isValidHslToken } from './state/settingsStateDefaults';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
@@ -134,10 +125,7 @@ import {
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_FAMILY,
|
||||
STORAGE_KEY_VAULT_NOTES_FONT_SIZE,
|
||||
STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { mergeAiSessionLists, mergeSyncPayloads, stripAiSessionDeviceState, stripAiSessionPayloadForSync } from '../domain/syncMerge';
|
||||
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
|
||||
import { prepareRestoredPayloadConvergentWrites } from './convergentSyncReplica';
|
||||
|
||||
@@ -183,7 +171,6 @@ function hasMeaningfulPluginSidecars(payload: SyncPayload): boolean {
|
||||
*/
|
||||
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
|
||||
if (Array.isArray(payload.aiSessions) && payload.aiSessions.length > 0) return true;
|
||||
|
||||
if (
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined)
|
||||
@@ -213,11 +200,11 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
|
||||
/**
|
||||
* Returns true only when the payload contains synced vault entities.
|
||||
* Settings and chat history are ignored: neither proves that the vault's
|
||||
* hosts and keys survived local data loss.
|
||||
* Settings are intentionally ignored so default settings written on first
|
||||
* launch do not make a new device look non-empty during cloud restore checks.
|
||||
*/
|
||||
export function hasCloudSyncEntityData(payload: SyncPayload): boolean {
|
||||
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS.filter((key) => key !== 'aiSessions'));
|
||||
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS);
|
||||
}
|
||||
|
||||
export function shouldPromptCloudVaultRecovery(
|
||||
@@ -1042,40 +1029,15 @@ function pruneOrphanPerAgentBindings(): void {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* AI chat sessions only leave the device when the user opted in.
|
||||
* Attachments are stripped — see {@link stripAiSessionPayloadForSync}.
|
||||
* Build a complete `SyncPayload` from local data.
|
||||
*
|
||||
* Port-forwarding rules are optional because they are managed by a separate
|
||||
* state hook (`usePortForwardingState`). Callers should strip transient
|
||||
* runtime fields (status, error, lastUsedAt) before passing them in.
|
||||
*/
|
||||
export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
|
||||
const enabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
|
||||
if (enabled?.enabled !== true) return undefined;
|
||||
const sessions = collectLocalAiSessions();
|
||||
if (!writeSessionsForStorage(sessions)) {
|
||||
throw new Error('Unable to persist chat history for cloud sync');
|
||||
}
|
||||
return stripAiSessionPayloadForSync(sessions);
|
||||
}
|
||||
|
||||
function collectLocalAiSessions(): NonNullable<SyncPayload['aiSessions']> {
|
||||
return latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<SyncPayload['aiSessions']>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
}
|
||||
|
||||
/** Build the portable payload; device-local data is added by the backup builder. */
|
||||
export function buildSyncPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
const aiSessions = collectAiSessionsForSync();
|
||||
return {
|
||||
...buildVaultSyncPayload(vault, portForwardingRules),
|
||||
...(aiSessions !== undefined ? { aiSessions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVaultSyncPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
return {
|
||||
hosts: sanitizeHostsForSync(vault.hosts),
|
||||
@@ -1162,7 +1124,6 @@ export async function buildCloudSyncPayload(
|
||||
collectPluginSidecars?: PluginSyncSidecarCollector;
|
||||
},
|
||||
): Promise<SyncPayload> {
|
||||
const aiSessions = collectAiSessionsForSync();
|
||||
const base: SyncPayload = {
|
||||
hosts: sanitizeHostsForSync(vault.hosts),
|
||||
keys: vault.keys,
|
||||
@@ -1175,7 +1136,6 @@ export async function buildCloudSyncPayload(
|
||||
noteGroups: vault.noteGroups,
|
||||
groupConfigs: vault.groupConfigs,
|
||||
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
|
||||
...(aiSessions !== undefined ? { aiSessions } : {}),
|
||||
settings: await collectCloudSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -1190,13 +1150,11 @@ export function buildLocalVaultPayload(
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
const base: SyncPayload = {
|
||||
...buildVaultSyncPayload(vault, portForwardingRules),
|
||||
...buildSyncPayload(vault, portForwardingRules),
|
||||
// Keep device-local recent-connection timestamps in protective backups.
|
||||
hosts: vault.hosts,
|
||||
settings: collectLocalBackupSettings(),
|
||||
knownHosts: vault.knownHosts,
|
||||
// Cloud consent and cloud attachment stripping do not apply to local backups.
|
||||
aiSessions: collectLocalAiSessions(),
|
||||
};
|
||||
// Protective backups must capture plugin sidecars so restore can recover
|
||||
// plugin settings/baselines. Prefer the last successful host collect cache
|
||||
@@ -1249,96 +1207,12 @@ export async function buildLocalVaultPayloadAsync(
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Merge downloaded chat data through the existing state and persistence boundary. */
|
||||
async function applyAiSessionData(sessions: NonNullable<SyncPayload['aiSessions']>, includeLocalOnlyData: boolean): Promise<void> {
|
||||
const incoming = includeLocalOnlyData
|
||||
? sessions
|
||||
: sessions.map(stripAiSessionDeviceState);
|
||||
const incomingIds = new Set(incoming.map((session) => session.id));
|
||||
const incomingById = new Map(incoming.map((session) => [session.id, session]));
|
||||
const portableMessages = (value: typeof incoming[number]) => stripAiSessionPayloadForSync([value])[0].messages;
|
||||
const affectedIds = collectLocalAiSessions().filter((local) => {
|
||||
const next = incomingById.get(local.id);
|
||||
if (!next) return true;
|
||||
const candidate = includeLocalOnlyData ? next : mergeAiSessionLists([local], [next])[0];
|
||||
return JSON.stringify(portableMessages(local)) !== JSON.stringify(portableMessages(candidate));
|
||||
}).map((session) => session.id);
|
||||
if (affectedIds.length > 0) {
|
||||
const { stopAIChatSessionBeforeReplacement } = await import('./state/useAIChatStreaming');
|
||||
await Promise.all(affectedIds.map(stopAIChatSessionBeforeReplacement));
|
||||
}
|
||||
// Stop finalizers can finish the last local message. Read it again before
|
||||
// merging so the stopped turn cannot write into a replaced/deleted session.
|
||||
const localSessions = collectLocalAiSessions();
|
||||
const localById = new Map(localSessions.map((session) => [session.id, session]));
|
||||
const removedIds = localSessions.filter((session) => !incomingIds.has(session.id)).map((session) => session.id);
|
||||
const resetSdkSessions: string[] = [];
|
||||
// The sync merge has already decided which session IDs survive. Backups
|
||||
// restore their exact transcript; cloud survivors still retain local turns.
|
||||
const candidates = includeLocalOnlyData ? incoming
|
||||
: mergeAiSessionLists(localSessions.filter((session) => incomingIds.has(session.id)), incoming);
|
||||
const mergedSessions = candidates.map((session) => {
|
||||
// Native SDK threads belong to this device. Reuse the local thread
|
||||
// only while it already contains the entire merged transcript.
|
||||
const local = localById.get(session.id);
|
||||
const transcriptChanged = local
|
||||
&& JSON.stringify(portableMessages(local)) !== JSON.stringify(portableMessages(session));
|
||||
if (transcriptChanged) resetSdkSessions.push(session.id);
|
||||
const { externalSessionId: _remoteThread, ...portable } = session;
|
||||
return local?.externalSessionId && !transcriptChanged
|
||||
? { ...portable, externalSessionId: local.externalSessionId }
|
||||
: portable;
|
||||
});
|
||||
if (!writeSessionsForStorage(mergedSessions)) {
|
||||
throw new Error('Unable to save downloaded AI chat history');
|
||||
}
|
||||
cleanupDeletedAIChatSessions(removedIds);
|
||||
await cleanupSdkAgentSessions(resetSdkSessions);
|
||||
setLatestAISessionsSnapshot(mergedSessions);
|
||||
publishAISessionsSnapshot({ ...getAISessionsSnapshot(), sessions: mergedSessions });
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
/** Read existing cloud chats before an enabled empty local list can overwrite them. */
|
||||
export async function setAiSessionsSyncPreference(
|
||||
enabled: boolean,
|
||||
loadRemotePayloads: () => Promise<SyncPayload[]>,
|
||||
preserveDeletions = false,
|
||||
): Promise<void> {
|
||||
const wasEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||
if (enabled && !wasEnabled) {
|
||||
const remotes = await loadRemotePayloads();
|
||||
// Reuse the ordinary no-base deletion rules, including cloud tombstones,
|
||||
// while retaining local-only chats when this is a first-time opt-in.
|
||||
const local: SyncPayload = { hosts: [], keys: [], identities: [], snippets: [], customGroups: [],
|
||||
aiSessions: collectLocalAiSessions(), syncedAt: 0 };
|
||||
const merged = remotes.reduce((current, remote) =>
|
||||
mergeSyncPayloads(null, current, remote, { preserveDeletions }).payload, local);
|
||||
const previous = localStorageAdapter.readString(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
|
||||
const { withRestoreBarrier } = await import('./localVaultBackups');
|
||||
await withRestoreBarrier(async () => {
|
||||
if ((localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL) ?? 0) <= Date.now()) {
|
||||
throw new Error('Unable to pause sync while enabling chat history');
|
||||
}
|
||||
if (!localStorageAdapter.write(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, { enabled })) {
|
||||
throw new Error('Unable to save the chat sync preference');
|
||||
}
|
||||
try {
|
||||
await applyAiSessionData(merged.aiSessions ?? [], false);
|
||||
} catch (error) {
|
||||
if (previous === null) localStorageAdapter.remove(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
|
||||
else localStorageAdapter.writeString(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, previous);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!localStorageAdapter.write(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, { enabled })) {
|
||||
throw new Error('Unable to save the chat sync preference');
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply downloaded vault data and supported settings through their state boundaries. */
|
||||
/**
|
||||
* Apply a downloaded `SyncPayload` to local state via the provided importers.
|
||||
*
|
||||
* This ensures both vault data and port-forwarding rules are imported
|
||||
* consistently across windows.
|
||||
*/
|
||||
async function preparePayloadApply(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
@@ -1401,14 +1275,6 @@ async function preparePayloadApply(
|
||||
importers.importPortForwardingRules(sanitizedPayload.portForwardingRules);
|
||||
}
|
||||
|
||||
// AI chat sessions merge append-only: a cloud snapshot may be missing
|
||||
// turns that only exist on this device, so union instead of replace.
|
||||
const applyAiSessions = options.includeLocalOnlyData
|
||||
|| localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||
if (applyAiSessions && Array.isArray(sanitizedPayload.aiSessions)) {
|
||||
await applyAiSessionData(sanitizedPayload.aiSessions, options.includeLocalOnlyData);
|
||||
}
|
||||
|
||||
// Apply synced settings
|
||||
if (sanitizedPayload.settings) {
|
||||
await applySyncableSettings(sanitizedPayload.settings, preparedProviders);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { type Dispatch, type SetStateAction } from 'react';
|
||||
import { History, Plus, Undo2 } from 'lucide-react';
|
||||
import { History, Plus } from 'lucide-react';
|
||||
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
|
||||
import type { Host, VaultNote } from '../types';
|
||||
import type { UserSkillOption } from './ai/userSkillsState';
|
||||
@@ -47,8 +47,6 @@ interface AIChatPanelContentProps {
|
||||
activeSessionId: string | null;
|
||||
handleSelectSession: (sessionId: string) => void;
|
||||
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
|
||||
/** Fork the conversation up to a completed assistant response into a new session. */
|
||||
onForkFromMessage?: (messageId: string) => void;
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
|
||||
@@ -57,9 +55,6 @@ interface AIChatPanelContentProps {
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
handleCompact: () => void;
|
||||
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
|
||||
canUndoLastTurn?: boolean;
|
||||
handleUndoLastTurn?: () => void;
|
||||
canCompact?: boolean;
|
||||
handleSteer: () => void;
|
||||
handleStop: () => void;
|
||||
@@ -73,10 +68,6 @@ interface AIChatPanelContentProps {
|
||||
modelCatalogWarning?: string;
|
||||
/** Live SDK model catalog is loading (no built-in presets to show yet). */
|
||||
modelCatalogLoading?: boolean;
|
||||
/** Provenance of the active model list, rendered as the picker status chip. */
|
||||
modelCatalogSource?: 'live' | 'last-known-good' | 'presets';
|
||||
/** ISO timestamp of the successful fetch backing `modelCatalogSource`. */
|
||||
modelCatalogFetchedAt?: string;
|
||||
/** Retry a failed SDK model catalog load from the warning banner. */
|
||||
handleModelCatalogRetry?: () => void;
|
||||
agentModelPresets: AgentModelPreset[];
|
||||
@@ -133,7 +124,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
activeSessionId,
|
||||
handleSelectSession,
|
||||
handleDeleteSession,
|
||||
onForkFromMessage,
|
||||
messages,
|
||||
isStreaming,
|
||||
activeCompaction = null,
|
||||
@@ -142,8 +132,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
setInputValue,
|
||||
handleSend,
|
||||
handleCompact,
|
||||
canUndoLastTurn = false,
|
||||
handleUndoLastTurn,
|
||||
canCompact = false,
|
||||
handleSteer,
|
||||
handleStop,
|
||||
@@ -157,8 +145,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
modelDisplayName,
|
||||
modelCatalogWarning,
|
||||
modelCatalogLoading = false,
|
||||
modelCatalogSource,
|
||||
modelCatalogFetchedAt,
|
||||
agentModelPresets,
|
||||
allowCustomModelEntry = true,
|
||||
selectedAgentModel,
|
||||
@@ -246,23 +232,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{handleUndoLastTurn && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
aria-label={t('ai.chat.undoLastTurn')}
|
||||
disabled={sending || isStreaming || !canUndoLastTurn}
|
||||
onClick={handleUndoLastTurn}
|
||||
>
|
||||
<Undo2 size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.undoLastTurn')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
@@ -322,8 +291,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
isStreaming={isStreaming}
|
||||
activeSessionId={activeSessionId}
|
||||
activeCompaction={activeCompaction}
|
||||
contextCompaction={activeSession?.contextCompaction}
|
||||
onForkFromMessage={onForkFromMessage}
|
||||
notes={notes}
|
||||
hosts={hosts}
|
||||
onOpenVaultNote={onOpenVaultNote}
|
||||
@@ -409,8 +376,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
||||
modelPresets={agentModelPresets}
|
||||
modelCatalogLoading={modelCatalogLoading}
|
||||
modelCatalogSource={modelCatalogSource}
|
||||
modelCatalogFetchedAt={modelCatalogFetchedAt}
|
||||
allowCustomModelEntry={allowCustomModelEntry}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
|
||||
@@ -120,9 +120,7 @@ test('send preflight aborts after the panel unmounts or the scope changes', () =
|
||||
test('send preflight locks header agent and new-chat controls', () => {
|
||||
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
|
||||
// `sending` also covers an in-flight undo-last-turn so the header controls
|
||||
// (and a quick send) stay locked while the branch materializes.
|
||||
assert.match(panel, /sending=\{isSending \|\| isUndoingLastTurn\}/);
|
||||
assert.match(panel, /sending=\{isSending\}/);
|
||||
assert.match(content, /disabled=\{sending\}/);
|
||||
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
|
||||
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
|
||||
@@ -165,18 +163,6 @@ test('hidden empty AI side panel can release its subtree', () => {
|
||||
assert.equal(shouldKeepAIChatSidePanelMounted(props), false);
|
||||
});
|
||||
|
||||
test('Catty without an SDK catalog request does not claim models are loading', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(I18nProvider, {
|
||||
locale: 'en',
|
||||
children: React.createElement(TooltipProvider, {
|
||||
children: React.createElement(AIChatSidePanel, baseProps({ isVisible: true })),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.doesNotMatch(html, /Loading models|ai\.chat\.loadingModels/);
|
||||
});
|
||||
|
||||
test('hidden AI side panel is retained when it has draft text', () => {
|
||||
const props = baseProps({
|
||||
draftsByScope: {
|
||||
|
||||
+71
-276
@@ -65,7 +65,6 @@ import {
|
||||
} from '../application/state/useAIChatStreaming';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
|
||||
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
|
||||
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
@@ -80,21 +79,15 @@ import {
|
||||
buildCursorListModelsAgentEnv,
|
||||
buildSdkRuntimeModelCacheKey,
|
||||
sdkRuntimeModelCache,
|
||||
reconcileRuntimeModelWarning,
|
||||
retainStoredModelPresetForCatalog,
|
||||
toSdkRuntimeModelCatalog,
|
||||
generateId,
|
||||
agentModelPresetsShallowEqual,
|
||||
mergeFallbackThinkingLevels,
|
||||
normalizeStoredAgentModelSelection,
|
||||
normalizeSdkRuntimeModelPresets,
|
||||
resolveSdkRuntimeCatalogDefaultModelId,
|
||||
shouldAdoptSdkCurrentModel,
|
||||
appendRetainedStoredModelPreset,
|
||||
shouldLoadSdkRuntimeModels,
|
||||
shouldUseStoredAgentModel,
|
||||
type SdkRuntimeModelCatalog,
|
||||
type SdkRuntimeModelCatalogSource,
|
||||
} from './AIChatSidePanelHelpers';
|
||||
import { AIChatPanelContent } from './AIChatPanelContent';
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
|
||||
@@ -276,7 +269,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
@@ -284,7 +276,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
forkSessionFromMessage,
|
||||
providers,
|
||||
activeProviderId,
|
||||
activeModelId,
|
||||
@@ -328,25 +319,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
// Undo-last-turn is still
|
||||
// running. Unlike the send flow this is not reflected in `isSending`, so it
|
||||
// is tracked separately to block sends until the branch settles: a turn
|
||||
// submitted meanwhile would run against the (soon hidden) source session.
|
||||
const [isUndoingLastTurn, setIsUndoingLastTurn] = useState(false);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[]; catalogUnavailable: boolean }>>({});
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
|
||||
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
|
||||
// Tracks SDK runtime model catalogs currently loading (per agent scope);
|
||||
// the composer shows the loading row alongside the build-time presets and
|
||||
// the live catalog replaces them when it arrives.
|
||||
// Tracks SDK runtime model catalogs currently loading (per agent scope):
|
||||
// while an OpenCode catalog is pending we must not flash the built-in
|
||||
// OPENCODE_MODEL_PRESETS for providers the user never configured (#3584).
|
||||
const [runtimeModelLoading, setRuntimeModelLoading] = useState<Record<string, { cacheKey: string }>>({});
|
||||
// Provenance of the last applied SDK runtime catalog, per agent scope. The
|
||||
// picker renders a uniform status chip from it; when no runtime catalog is
|
||||
// active the composer falls back to build-time presets ('presets').
|
||||
const [runtimeModelCatalogMeta, setRuntimeModelCatalogMeta] = useState<Record<string, {
|
||||
cacheKey: string;
|
||||
source: SdkRuntimeModelCatalogSource;
|
||||
fetchedAt?: string;
|
||||
}>>({});
|
||||
const [steerWarnings, setSteerWarnings] = useState<Record<string, SteerWarning>>({});
|
||||
const [steeringSessionId, setSteeringSessionId] = useState<string | null>(null);
|
||||
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
|
||||
@@ -843,83 +821,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
&& Boolean(effectiveActiveProvider)
|
||||
&& Boolean(effectiveActiveModelId.trim());
|
||||
|
||||
// Undo last turn: offered only at a safe boundary (never inside a compacted
|
||||
// prefix, never between an assistant tool call and its result).
|
||||
const canUndoLastTurn = useMemo(() => Boolean(
|
||||
activeSession
|
||||
&& !isStreaming
|
||||
&& resolveUndoLastTurnBoundary(
|
||||
activeSession.messages,
|
||||
activeSession.contextCompaction?.compactedMessageCount ?? 0,
|
||||
) != null,
|
||||
), [activeSession, isStreaming]);
|
||||
|
||||
const undoInFlightRef = useRef(false);
|
||||
const handleUndoLastTurn = useCallback(async () => {
|
||||
const sessionId = activeSessionRef.current?.id;
|
||||
if (!sessionId || !undoLastTurnInSession) return;
|
||||
if (isStreaming || isAIChatSessionStreaming(sessionId)) return;
|
||||
// Serialize: while a branch is still being created, `canUndoLastTurn` stays stale, so a quick second
|
||||
// click must not start a parallel undo of the same session — that would
|
||||
// publish duplicate branches and restore the same prompt twice.
|
||||
if (undoInFlightRef.current) return;
|
||||
undoInFlightRef.current = true;
|
||||
setIsUndoingLastTurn(true);
|
||||
try {
|
||||
const result = await undoLastTurnInSession(sessionId, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
});
|
||||
if (!result) return;
|
||||
|
||||
ensureScopeDraft(currentAgentId);
|
||||
// Text typed since the last flush lives only in the live composer buffer
|
||||
// (pendingComposerTextRef/currentDraftRef), not in the persisted draft;
|
||||
// merge from the live buffer so it is not lost, then drop the pending
|
||||
// buffer so the later flushDraftText() cannot overwrite the merged text.
|
||||
const liveDraft = currentDraftRef.current;
|
||||
updateScopeDraft(currentAgentId, (draft) => {
|
||||
const liveText = liveDraft?.text ?? draft.text;
|
||||
const liveSkillSlugs = liveDraft?.selectedUserSkillSlugs ?? draft.selectedUserSkillSlugs;
|
||||
return {
|
||||
...draft,
|
||||
// Keep anything the user is still typing; the undone prompt goes first.
|
||||
text: liveText.trim()
|
||||
? `${result.restored.text}\n\n${liveText}`
|
||||
: result.restored.text,
|
||||
attachments: [
|
||||
...result.restored.attachments,
|
||||
...(liveDraft?.attachments ?? draft.attachments),
|
||||
],
|
||||
// The undone turn was selected with these pills; keep any pills the
|
||||
// user picked since the send on top of them.
|
||||
selectedUserSkillSlugs: [
|
||||
...result.restored.selectedUserSkillSlugs,
|
||||
...liveSkillSlugs.filter((slug) => !result.restored.selectedUserSkillSlugs.includes(slug)),
|
||||
],
|
||||
};
|
||||
});
|
||||
discardPendingComposerText();
|
||||
showScopeSessionView(result.sessionId);
|
||||
// Tool side effects live outside the conversation and cannot be rolled back.
|
||||
toast.info(t('ai.chat.undoLastTurnNotice'));
|
||||
} finally {
|
||||
undoInFlightRef.current = false;
|
||||
setIsUndoingLastTurn(false);
|
||||
}
|
||||
}, [
|
||||
discardPendingComposerText,
|
||||
ensureScopeDraft,
|
||||
isStreaming,
|
||||
showScopeSessionView,
|
||||
t,
|
||||
undoLastTurnInSession,
|
||||
updateScopeDraft,
|
||||
currentAgentId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
]);
|
||||
|
||||
const providersRef = useRef(providers);
|
||||
providersRef.current = providers;
|
||||
|
||||
@@ -1013,37 +914,29 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
|
||||
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return;
|
||||
const agentId = target.agentId;
|
||||
// Record the catalog provenance for the picker's status chip. An absent
|
||||
// source (older main without the marker) displays as a live catalog.
|
||||
setRuntimeModelCatalogMeta((prev) => ({
|
||||
...prev,
|
||||
[agentId]: {
|
||||
cacheKey: target.cacheKey,
|
||||
source: catalog.source ?? 'live',
|
||||
...(catalog.fetchedAt ? { fetchedAt: catalog.fetchedAt } : {}),
|
||||
},
|
||||
}));
|
||||
const catalogPresets = mergeFallbackThinkingLevels(
|
||||
normalizeSdkRuntimeModelPresets(catalog.models, catalog.currentModelId),
|
||||
getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent)),
|
||||
);
|
||||
const runtimePresets = retainStoredModelPresetForCatalog(
|
||||
const runtimePresets = appendComposerCustomModelPresets(
|
||||
catalogPresets,
|
||||
appendComposerCustomModelPresets(
|
||||
catalogPresets,
|
||||
collectCustomModelIds(agentId, catalogPresets),
|
||||
),
|
||||
agentModelMapRef.current[agentId],
|
||||
agent,
|
||||
Boolean(catalog.warning),
|
||||
collectCustomModelIds(agentId, catalogPresets),
|
||||
);
|
||||
setRuntimeAgentModelPresets((prev) => (
|
||||
prev[agentId]?.cacheKey === target.cacheKey
|
||||
&& prev[agentId].catalogUnavailable === Boolean(catalog.warning)
|
||||
&& agentModelPresetsShallowEqual(prev[agentId].models, catalogPresets)
|
||||
? prev
|
||||
: { ...prev, [agentId]: { cacheKey: target.cacheKey, models: catalogPresets, catalogUnavailable: Boolean(catalog.warning) } }
|
||||
));
|
||||
const storedModelId = agentModelMapRef.current[agentId];
|
||||
if (runtimePresets.length === 0) {
|
||||
setRuntimeAgentModelPresets((prev) => {
|
||||
if (!(agentId in prev)) return prev;
|
||||
const { [agentId]: _removed, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
} else {
|
||||
setRuntimeAgentModelPresets((prev) => (
|
||||
prev[agentId]?.cacheKey === target.cacheKey
|
||||
&& agentModelPresetsShallowEqual(prev[agentId].models, runtimePresets)
|
||||
? prev
|
||||
: { ...prev, [agentId]: { cacheKey: target.cacheKey, models: runtimePresets } }
|
||||
));
|
||||
}
|
||||
// Another path (e.g. send preflight) applied the catalog before the
|
||||
// deferred idle callback scheduled in the visibility effect: clear its
|
||||
// stale pending flag so the composer stops claiming models are loading.
|
||||
@@ -1054,21 +947,20 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
});
|
||||
|
||||
const normalizedStoredModelId = normalizeStoredAgentModelSelection(
|
||||
agentModelMapRef.current[agentId],
|
||||
storedModelId,
|
||||
runtimePresets,
|
||||
);
|
||||
if (normalizedStoredModelId && normalizedStoredModelId !== agentModelMapRef.current[agentId]) {
|
||||
if (normalizedStoredModelId && normalizedStoredModelId !== storedModelId) {
|
||||
setAgentModel(agentId, normalizedStoredModelId);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultModelId = resolveSdkRuntimeCatalogDefaultModelId(catalog, agent);
|
||||
if (
|
||||
options.adoptCurrentModel
|
||||
&& defaultModelId
|
||||
&& shouldAdoptSdkCurrentModel(defaultModelId, agentModelMapRef.current[agentId], runtimePresets, agent)
|
||||
&& catalog.currentModelId
|
||||
&& shouldAdoptSdkCurrentModel(catalog.currentModelId, storedModelId, runtimePresets)
|
||||
) {
|
||||
setAgentModel(agentId, defaultModelId);
|
||||
setAgentModel(agentId, catalog.currentModelId);
|
||||
}
|
||||
}, [setAgentModel, setRuntimeModelLoading, buildExternalAgentRuntimeModelTarget, collectCustomModelIds]);
|
||||
|
||||
@@ -1109,25 +1001,13 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return toSdkRuntimeModelCatalog(result);
|
||||
return {
|
||||
currentModelId: result.currentModelId ?? null,
|
||||
models: result.models,
|
||||
};
|
||||
},
|
||||
{ force: options.force },
|
||||
).then((catalog) => {
|
||||
// A refresh that joined another panel's in-flight load never ran the
|
||||
// loader above, so sync this panel's banner from the shared result.
|
||||
setRuntimeModelWarnings((current) => {
|
||||
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
|
||||
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
|
||||
return reconcileRuntimeModelWarning(
|
||||
current,
|
||||
target.agentId,
|
||||
target.cacheKey,
|
||||
Boolean(catalog.warning),
|
||||
t('ai.chat.modelCatalogWarning'),
|
||||
);
|
||||
});
|
||||
return catalog;
|
||||
}).catch((err) => {
|
||||
).catch((err) => {
|
||||
setRuntimeModelWarnings((current) => {
|
||||
const agent = externalAgentsRef.current.find((item) => item.id === target.agentId);
|
||||
if (buildExternalAgentRuntimeModelTarget(agent)?.cacheKey !== target.cacheKey) return current;
|
||||
@@ -1154,16 +1034,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const cached = sdkRuntimeModelCache.read(target.cacheKey);
|
||||
if (cached) {
|
||||
applySdkRuntimeModelCatalog(target, cached);
|
||||
// The cache stores warned (last-known-good) responses too: sync the
|
||||
// banner so another panel or a remount within the TTL does not present
|
||||
// degraded models as live.
|
||||
setRuntimeModelWarnings((current) => reconcileRuntimeModelWarning(
|
||||
current,
|
||||
target.agentId,
|
||||
target.cacheKey,
|
||||
Boolean(cached.warning),
|
||||
t('ai.chat.modelCatalogWarning'),
|
||||
));
|
||||
}
|
||||
|
||||
// Respect renderer TTL / in-flight coalescing for all SDK agents including
|
||||
@@ -1180,10 +1050,10 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
};
|
||||
let cancelled = false;
|
||||
let idleFired = false;
|
||||
// Mark the catalog pending as soon as the refresh is scheduled so the
|
||||
// composer can show its loading affordance for the whole idle delay (and
|
||||
// longer while the composer stays focused) instead of silently keeping
|
||||
// only the build-time presets (#3584). Only do this on a cache miss:
|
||||
// Mark the catalog pending as soon as the refresh is scheduled: otherwise
|
||||
// `runtimeModelLoading` stays false for the whole idle delay (and longer
|
||||
// while the composer stays focused), so the built-in OPENCODE_MODEL_PRESETS
|
||||
// remain selectable the entire time (#3584). Only do this on a cache miss:
|
||||
// when a cached catalog was already applied above, the runtime presets are
|
||||
// available, so a pending flag would keep the composer banner/picker in the
|
||||
// "loading" state indefinitely while the idle callback is deferred.
|
||||
@@ -1219,34 +1089,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
buildExternalAgentRuntimeModelTarget,
|
||||
loadSdkRuntimeModelCatalog,
|
||||
applySdkRuntimeModelCatalog,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Uniform status chip inputs: when a runtime catalog is active for the
|
||||
// current agent its recorded provenance wins; otherwise the composer is
|
||||
// showing build-time presets (the renderer-side preset fallback derives
|
||||
// 'presets' here). While a load is pending the picker replaces the chip
|
||||
// with the spinner row.
|
||||
const modelCatalogSource = useMemo((): SdkRuntimeModelCatalogSource | undefined => {
|
||||
// A config-locked Codex picker shows only that configured model, not the
|
||||
// discovered catalog whose provenance is tracked below.
|
||||
if (isCodexManagedAgent && codexConfigModel) return undefined;
|
||||
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
||||
const meta = runtimeModelCatalogMeta[currentAgentId];
|
||||
const runtimeEntry = runtimeAgentModelPresets[currentAgentId];
|
||||
if (meta && target && meta.cacheKey === target.cacheKey
|
||||
&& runtimeEntry?.cacheKey === target.cacheKey && runtimeEntry.models.length > 0) {
|
||||
return meta.source;
|
||||
}
|
||||
return 'presets';
|
||||
}, [currentAgentConfig, currentAgentId, runtimeModelCatalogMeta, runtimeAgentModelPresets, buildExternalAgentRuntimeModelTarget, isCodexManagedAgent, codexConfigModel]);
|
||||
const modelCatalogFetchedAt = useMemo((): string | undefined => {
|
||||
const target = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
||||
const meta = runtimeModelCatalogMeta[currentAgentId];
|
||||
if (meta && target && meta.cacheKey === target.cacheKey) return meta.fetchedAt;
|
||||
return undefined;
|
||||
}, [currentAgentConfig, currentAgentId, runtimeModelCatalogMeta, buildExternalAgentRuntimeModelTarget]);
|
||||
|
||||
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
|
||||
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
|
||||
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
|
||||
@@ -1266,15 +1110,26 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (hasCodexCustomConfig && codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
if (runtimePresets?.length) {
|
||||
return appendRetainedStoredModelPreset(
|
||||
appendComposerCustomModelPresets(
|
||||
runtimePresets,
|
||||
collectCustomModelIds(currentAgentId, runtimePresets),
|
||||
),
|
||||
agentModelMap[currentAgentId],
|
||||
currentAgentConfig,
|
||||
Boolean(runtimeEntry?.catalogUnavailable),
|
||||
if (runtimePresets) {
|
||||
return appendComposerCustomModelPresets(
|
||||
runtimePresets,
|
||||
collectCustomModelIds(currentAgentId, runtimePresets),
|
||||
);
|
||||
}
|
||||
// While an OpenCode catalog load is pending, do not flash the built-in
|
||||
// OPENCODE_MODEL_PRESETS: they enumerate well-known gateway providers the
|
||||
// user may never have configured, so for a few seconds every switch looks
|
||||
// like a broken default model (#3584). Show only the user's own custom
|
||||
// model ids until the live catalog resolves (or a retryable warning
|
||||
// appears in the composer banner).
|
||||
if (
|
||||
target
|
||||
&& getExternalAgentSdkBackend(currentAgentConfig) === 'opencode'
|
||||
&& runtimeModelLoading[currentAgentId]?.cacheKey === target.cacheKey
|
||||
) {
|
||||
return appendComposerCustomModelPresets(
|
||||
[],
|
||||
collectCustomModelIds(currentAgentId, []),
|
||||
);
|
||||
}
|
||||
const presets = getAgentModelPresets(
|
||||
@@ -1286,22 +1141,15 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
const filteredPresets = filterAgentModelPresetsForCliVersion(presets, cliVersion);
|
||||
// Manual model ids typed in the composer (#3534) stay selectable even when
|
||||
// the CLI catalog does not list them yet.
|
||||
return appendRetainedStoredModelPreset(
|
||||
appendComposerCustomModelPresets(
|
||||
filteredPresets,
|
||||
collectCustomModelIds(currentAgentId, filteredPresets),
|
||||
),
|
||||
agentModelMap[currentAgentId],
|
||||
currentAgentConfig,
|
||||
// Until the initial catalog settles, show the choice a failed
|
||||
// preflight would actually send instead of flashing Auto.
|
||||
!runtimeEntry || !target || runtimeEntry.cacheKey !== target.cacheKey || runtimeEntry.catalogUnavailable,
|
||||
return appendComposerCustomModelPresets(
|
||||
filteredPresets,
|
||||
collectCustomModelIds(currentAgentId, filteredPresets),
|
||||
);
|
||||
}, [
|
||||
currentAgentConfig,
|
||||
currentAgentId,
|
||||
runtimeAgentModelPresets,
|
||||
agentModelMap,
|
||||
runtimeModelLoading,
|
||||
buildExternalAgentRuntimeModelTarget,
|
||||
hasCodexCustomConfig,
|
||||
codexConfigModel,
|
||||
@@ -1329,8 +1177,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
|
||||
const canSendCurrentAgent = useMemo(
|
||||
() => !isSending && !isUndoingLastTurn && canSendWithAgent(inputAgentId, externalAgents),
|
||||
[inputAgentId, externalAgents, isSending, isUndoingLastTurn],
|
||||
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
|
||||
[inputAgentId, externalAgents, isSending],
|
||||
);
|
||||
|
||||
const handleAgentModelSelect = useCallback((modelId: string) => {
|
||||
@@ -1466,10 +1314,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}));
|
||||
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
|
||||
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
|
||||
// While undo-last-turn is creating the branch, the target is still the source; a
|
||||
// turn submitted now would run in the soon-hidden source session, so
|
||||
// block it until the branch settles.
|
||||
if (isUndoingLastTurn) return;
|
||||
// Note-only sends (empty text + a mentioned note) still need a usable
|
||||
// session title: fall back to the first mentioned note's title so these
|
||||
// conversations don't all show up as "Untitled" in history.
|
||||
@@ -1526,24 +1370,17 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
getExternalAgentSdkBackend(currentAgentConfig),
|
||||
),
|
||||
);
|
||||
const runtimePresets = retainStoredModelPresetForCatalog(
|
||||
const runtimePresets = appendComposerCustomModelPresets(
|
||||
catalogPresets,
|
||||
appendComposerCustomModelPresets(
|
||||
catalogPresets,
|
||||
collectCustomModelIds(sendAgentId, catalogPresets),
|
||||
),
|
||||
agentModelMapRef.current[sendAgentId],
|
||||
currentAgentConfig,
|
||||
Boolean(catalog.warning),
|
||||
collectCustomModelIds(sendAgentId, catalogPresets),
|
||||
);
|
||||
const storedModelId = agentModelMapRef.current[sendAgentId];
|
||||
const defaultModelId = resolveSdkRuntimeCatalogDefaultModelId(catalog, currentAgentConfig);
|
||||
if (
|
||||
defaultModelId
|
||||
&& shouldAdoptSdkCurrentModel(defaultModelId, storedModelId, runtimePresets, currentAgentConfig)
|
||||
catalog.currentModelId
|
||||
&& shouldAdoptSdkCurrentModel(catalog.currentModelId, storedModelId, runtimePresets)
|
||||
) {
|
||||
sendSelectedAgentModel = defaultModelId;
|
||||
} else if (shouldUseStoredAgentModel(storedModelId, runtimePresets, currentAgentConfig)) {
|
||||
sendSelectedAgentModel = catalog.currentModelId;
|
||||
} else if (shouldUseStoredAgentModel(storedModelId, runtimePresets)) {
|
||||
sendSelectedAgentModel = normalizeStoredAgentModelSelection(
|
||||
storedModelId,
|
||||
runtimePresets,
|
||||
@@ -1601,7 +1438,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
@@ -1616,7 +1452,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
@@ -1630,8 +1465,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
// Kept on the user message so undo last turn can restore the pills.
|
||||
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
@@ -1713,7 +1546,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [
|
||||
validateNoteMentions, loadCodexConfigModel,
|
||||
isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope,
|
||||
@@ -1934,36 +1767,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
[setActiveSessionId, showScopeSessionView],
|
||||
);
|
||||
|
||||
// "Fork from here": copy the viewed conversation up to a completed assistant
|
||||
// response into a new session, switch to it, and confirm via toast. The
|
||||
// original stays in history. Boundary validation is re-run by the state
|
||||
// hook; a null result means the fork was refused (already hidden in the UI).
|
||||
// This panel's scope is passed so the branch is created in the scope viewing
|
||||
// it, even when the source session's own scope went stale (e.g. a terminal
|
||||
// chat resumed after reconnecting under a new terminal id).
|
||||
const handleForkFromMessage = useCallback(async (messageId: string) => {
|
||||
const sourceSessionId = activeSessionRef.current?.id ?? activeSessionId;
|
||||
if (!sourceSessionId) return;
|
||||
// Reject a double-click while the new session is being selected so one
|
||||
// interaction cannot create two forks.
|
||||
if (!tryBeginSendForKey(`fork:${sourceSessionId}`)) return;
|
||||
try {
|
||||
const fork = await forkSessionFromMessage?.(sourceSessionId, messageId, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
});
|
||||
if (!fork) return;
|
||||
applyHistorySessionSelection(fork.id, {
|
||||
showSessionView: showScopeSessionView,
|
||||
setActiveSessionId,
|
||||
closeHistory: () => setShowHistory(false),
|
||||
});
|
||||
toast.success(t('ai.chat.forkCreated'));
|
||||
} finally {
|
||||
endSendForKey(`fork:${sourceSessionId}`);
|
||||
}
|
||||
}, [activeSessionId, forkSessionFromMessage, scopeTargetId, scopeType, setActiveSessionId, showScopeSessionView, t]);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
async (e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
@@ -2031,7 +1834,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
>
|
||||
<AIChatPanelContent
|
||||
parked={!isVisible}
|
||||
sending={isSending || isUndoingLastTurn}
|
||||
sending={isSending}
|
||||
t={t}
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
@@ -2050,7 +1853,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
activeSessionId={activeSessionId}
|
||||
handleSelectSession={handleSelectSession}
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
onForkFromMessage={handleForkFromMessage}
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeCompaction={
|
||||
@@ -2062,8 +1864,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleCompact={handleCompact}
|
||||
canUndoLastTurn={canUndoLastTurn}
|
||||
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
|
||||
handleSteer={handleSteer}
|
||||
handleStop={handleStop}
|
||||
canSteer={canSteerCurrentTurn}
|
||||
@@ -2073,18 +1873,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
canSendCurrentAgent={canSendCurrentAgent}
|
||||
providerDisplayName={providerDisplayName}
|
||||
modelDisplayName={modelDisplayName}
|
||||
modelCatalogWarning={!hasCodexCustomConfig
|
||||
&& runtimeModelWarnings[currentAgentId]
|
||||
modelCatalogWarning={runtimeModelWarnings[currentAgentId]
|
||||
&& runtimeModelWarnings[currentAgentId].cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
|
||||
? runtimeModelWarnings[currentAgentId].message
|
||||
: undefined}
|
||||
modelCatalogLoading={
|
||||
!hasCodexCustomConfig
|
||||
&& Boolean(runtimeModelLoading[currentAgentId])
|
||||
getExternalAgentSdkBackend(currentAgentConfig) === 'opencode'
|
||||
&& runtimeModelLoading[currentAgentId]?.cacheKey === buildExternalAgentRuntimeModelTarget(currentAgentConfig)?.cacheKey
|
||||
}
|
||||
modelCatalogSource={modelCatalogSource}
|
||||
modelCatalogFetchedAt={modelCatalogFetchedAt}
|
||||
handleModelCatalogRetry={handleRetrySdkModelCatalog}
|
||||
agentModelPresets={agentModelPresets}
|
||||
// A managed Codex config's `model` field overrides every selection on
|
||||
@@ -2145,7 +1941,6 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'updateLastMessage',
|
||||
'updateMessageById',
|
||||
'persistContextCompaction',
|
||||
'forkSessionFromMessage',
|
||||
'providers',
|
||||
'activeProviderId',
|
||||
'activeModelId',
|
||||
|
||||
@@ -37,11 +37,6 @@ export interface AIChatSidePanelProps {
|
||||
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
|
||||
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
|
||||
undoLastTurnInSession?: (
|
||||
sessionId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
@@ -59,11 +54,6 @@ export interface AIChatSidePanelProps {
|
||||
sessionId: string,
|
||||
compaction: import('../infrastructure/ai/types').AISessionContextCompaction,
|
||||
) => void;
|
||||
forkSessionFromMessage?: (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
) => Promise<AISession | null>;
|
||||
// Provider config
|
||||
providers: ProviderConfig[];
|
||||
activeProviderId: string;
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import {
|
||||
appendRetainedStoredModelPreset,
|
||||
retainStoredModelPresetForCatalog,
|
||||
buildCursorListModelsAgentEnv,
|
||||
buildSdkRuntimeModelCacheKey,
|
||||
canonicalizeEffortEncodedModelId,
|
||||
createSdkRuntimeModelCache,
|
||||
agentModelPresetsShallowEqual,
|
||||
mergeFallbackThinkingLevels,
|
||||
MODEL_CACHE_ENV_HINTS,
|
||||
modelPresetsContainId,
|
||||
normalizeStoredAgentModelSelection,
|
||||
normalizeSdkRuntimeModelPresets,
|
||||
resolveSdkRuntimeCatalogDefaultModelId,
|
||||
reconcileRuntimeModelWarning,
|
||||
toSdkRuntimeModelCatalog,
|
||||
shouldAdoptSdkCurrentModel,
|
||||
shouldLoadSdkRuntimeModels,
|
||||
shouldUseStoredAgentModel,
|
||||
} from './AIChatSidePanelHelpers';
|
||||
import { createRequire } from 'node:module';
|
||||
import type { AgentModelPreset, ExternalAgentConfig } from '../infrastructure/ai/types';
|
||||
|
||||
test('buildCursorListModelsAgentEnv injects cli-login auth mode for list-models', () => {
|
||||
@@ -306,157 +298,6 @@ test('shouldUseStoredAgentModel trusts SDK defaults when no runtime list is retu
|
||||
);
|
||||
});
|
||||
|
||||
test('stored claude alias selections keep resolving against a full-id catalog', () => {
|
||||
const claudeAgent: ExternalAgentConfig = {
|
||||
id: 'managed_claude',
|
||||
name: 'Claude Code',
|
||||
command: 'claude',
|
||||
enabled: true,
|
||||
sdkBackend: 'claude',
|
||||
};
|
||||
const fullIdPresets: AgentModelPreset[] = [
|
||||
{ id: 'claude-sonnet-5-5', name: 'Sonnet 5.5' },
|
||||
{ id: 'claude-haiku-5-5', name: 'Haiku 5.5' },
|
||||
];
|
||||
|
||||
// Legacy persisted alias ids (pre-2026-10 preset refresh) still resolve for
|
||||
// the claude backend, with or without an effort suffix.
|
||||
assert.equal(shouldUseStoredAgentModel('sonnet', fullIdPresets, claudeAgent), true);
|
||||
assert.equal(shouldUseStoredAgentModel('sonnet/high', fullIdPresets, claudeAgent), true);
|
||||
// ...and are never silently replaced by the catalog's current model.
|
||||
assert.equal(shouldAdoptSdkCurrentModel('claude-sonnet-5-5', 'sonnet', fullIdPresets, claudeAgent), false);
|
||||
assert.equal(shouldAdoptSdkCurrentModel('claude-sonnet-5-5', 'sonnet/high', fullIdPresets, claudeAgent), false);
|
||||
|
||||
// Without agent context the previous behavior is unchanged.
|
||||
assert.equal(shouldUseStoredAgentModel('sonnet', fullIdPresets), false);
|
||||
assert.equal(shouldAdoptSdkCurrentModel('claude-sonnet-5-5', 'sonnet', fullIdPresets), true);
|
||||
|
||||
// Non-claude backends and non-alias stored ids are untouched.
|
||||
const opencodeAgent: ExternalAgentConfig = {
|
||||
id: 'managed_opencode',
|
||||
name: 'OpenCode',
|
||||
command: 'opencode',
|
||||
enabled: true,
|
||||
sdkBackend: 'opencode',
|
||||
};
|
||||
assert.equal(shouldUseStoredAgentModel('sonnet', fullIdPresets, opencodeAgent), false);
|
||||
assert.equal(shouldAdoptSdkCurrentModel('claude-sonnet-5-5', 'gpt-5.5', fullIdPresets, claudeAgent), true);
|
||||
});
|
||||
|
||||
test('a saved Copilot model survives the auto-only degraded fallback', () => {
|
||||
const copilotAgent: ExternalAgentConfig = {
|
||||
id: 'managed_copilot',
|
||||
name: 'GitHub Copilot',
|
||||
command: 'copilot',
|
||||
enabled: true,
|
||||
sdkBackend: 'copilot',
|
||||
};
|
||||
const autoOnlyFallback: AgentModelPreset[] = [{ id: 'auto', name: 'Auto' }];
|
||||
|
||||
// Live discovery failed (no last-known store yet): the saved explicit model
|
||||
// must keep resolving instead of silently switching to `auto`.
|
||||
assert.equal(shouldUseStoredAgentModel('claude-sonnet-4.5', autoOnlyFallback, copilotAgent, true), true);
|
||||
assert.equal(shouldUseStoredAgentModel('claude-sonnet-4.5/high', autoOnlyFallback, copilotAgent, true), true);
|
||||
// Other backends with the same shape are unaffected.
|
||||
const opencodeAgent: ExternalAgentConfig = {
|
||||
id: 'managed_opencode',
|
||||
name: 'OpenCode',
|
||||
command: 'opencode',
|
||||
enabled: true,
|
||||
sdkBackend: 'opencode',
|
||||
};
|
||||
assert.equal(shouldUseStoredAgentModel('claude-sonnet-4.5', autoOnlyFallback, opencodeAgent), false);
|
||||
// Composer custom ids are mixed into the same picker list; the degraded
|
||||
// fallback must still be detected with extra rows present.
|
||||
const mixedFallback: AgentModelPreset[] = [
|
||||
{ id: 'auto', name: 'Auto', description: 'Automatic model selection' },
|
||||
{ id: 'gpt-custom', name: 'gpt-custom' },
|
||||
];
|
||||
assert.equal(shouldUseStoredAgentModel('claude-sonnet-4.5', mixedFallback, copilotAgent, true), true);
|
||||
assert.equal(modelPresetsContainId(
|
||||
appendRetainedStoredModelPreset(mixedFallback, 'claude-sonnet-4.5', copilotAgent, true),
|
||||
'claude-sonnet-4.5',
|
||||
), true);
|
||||
// A real Copilot catalog (multiple entries with named rows) does not
|
||||
// trigger the fallback, mixed or not.
|
||||
assert.equal(
|
||||
shouldUseStoredAgentModel('claude-sonnet-4.5', [
|
||||
{ id: 'auto', name: 'Auto' },
|
||||
{ id: 'gpt-5', name: 'GPT-5' },
|
||||
], copilotAgent),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseStoredAgentModel('claude-sonnet-4.5', [
|
||||
{ id: 'auto', name: 'Auto' },
|
||||
{ id: 'gpt-5', name: 'GPT-5' },
|
||||
{ id: 'gpt-custom', name: 'gpt-custom' },
|
||||
], copilotAgent),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('appendRetainedStoredModelPreset makes preserved saved models visible', () => {
|
||||
const claudeAgent: ExternalAgentConfig = {
|
||||
id: 'managed_claude',
|
||||
name: 'Claude Code',
|
||||
command: 'claude',
|
||||
enabled: true,
|
||||
sdkBackend: 'claude',
|
||||
};
|
||||
const fullIdPresets: AgentModelPreset[] = [{ id: 'claude-sonnet-5-5', name: 'Sonnet 5.5' }];
|
||||
|
||||
// A stored claude alias absent from a full-id catalog gains a preset row
|
||||
// (reusing the build-time preset so thinking levels come along).
|
||||
const withAlias = appendRetainedStoredModelPreset(fullIdPresets, 'sonnet/high', claudeAgent);
|
||||
assert.equal(withAlias.length, 2);
|
||||
assert.equal(withAlias[1].id, 'sonnet');
|
||||
assert.equal(modelPresetsContainId(withAlias, 'sonnet/high'), true);
|
||||
|
||||
// The copilot auto-only degraded fallback keeps a saved explicit model
|
||||
// selectable as its own row.
|
||||
const copilotAgent: ExternalAgentConfig = {
|
||||
id: 'managed_copilot',
|
||||
name: 'GitHub Copilot',
|
||||
command: 'copilot',
|
||||
enabled: true,
|
||||
sdkBackend: 'copilot',
|
||||
};
|
||||
const withCopilot = appendRetainedStoredModelPreset(
|
||||
[{ id: 'auto', name: 'Auto' }],
|
||||
'claude-sonnet-4.5',
|
||||
copilotAgent,
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(withCopilot[1], { id: 'claude-sonnet-4.5', name: 'claude-sonnet-4.5' });
|
||||
|
||||
// An effort-suffixed saved id keeps its full spelling so the appended row
|
||||
// matches the selection the send preflight passes.
|
||||
const withCopilotSuffix = appendRetainedStoredModelPreset(
|
||||
[{ id: 'auto', name: 'Auto' }],
|
||||
'claude-sonnet-4.5/high',
|
||||
copilotAgent,
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(withCopilotSuffix[1], { id: 'claude-sonnet-4.5/high', name: 'claude-sonnet-4.5/high' });
|
||||
assert.equal(modelPresetsContainId(withCopilotSuffix, 'claude-sonnet-4.5/high'), true);
|
||||
|
||||
// A base-only custom row (no thinking levels) does not cover the stored
|
||||
// suffixed id, so the exact retained id is appended beside it.
|
||||
const baseOnly: AgentModelPreset[] = [
|
||||
{ id: 'auto', name: 'Auto' },
|
||||
{ id: 'claude-sonnet-4.5', name: 'claude-sonnet-4.5' },
|
||||
];
|
||||
const besideBase = appendRetainedStoredModelPreset(baseOnly, 'claude-sonnet-4.5/high', copilotAgent, true);
|
||||
assert.equal(besideBase.length, 3);
|
||||
assert.equal(modelPresetsContainId(besideBase, 'claude-sonnet-4.5/high'), true);
|
||||
|
||||
// Listed or non-retained selections are untouched.
|
||||
assert.equal(appendRetainedStoredModelPreset(fullIdPresets, 'claude-sonnet-5-5', claudeAgent), fullIdPresets);
|
||||
assert.equal(appendRetainedStoredModelPreset(fullIdPresets, 'gpt-5.5', claudeAgent), fullIdPresets);
|
||||
assert.equal(appendRetainedStoredModelPreset(fullIdPresets, undefined, claudeAgent), fullIdPresets);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache coalesces concurrent refreshes', async () => {
|
||||
const cache = createSdkRuntimeModelCache();
|
||||
let calls = 0;
|
||||
@@ -599,213 +440,3 @@ test('SDK runtime model cache stays bounded under agent configuration churn', as
|
||||
assert.equal(cache.read('agent:0'), null);
|
||||
assert.equal(cache.read('agent:19')?.currentModelId, 'model-19');
|
||||
});
|
||||
|
||||
test('SDK runtime model cache preserves degraded warnings until a successful retry', async () => {
|
||||
const cache = createSdkRuntimeModelCache();
|
||||
const degraded = {
|
||||
currentModelId: 'auto',
|
||||
models: [{ id: 'auto', name: 'Auto' }],
|
||||
warning: 'Live discovery failed; serving last-known-good models',
|
||||
};
|
||||
assert.equal((await cache.refresh('copilot', async () => degraded)).warning, degraded.warning);
|
||||
assert.equal(cache.read('copilot')?.warning, degraded.warning);
|
||||
assert.equal((await cache.refresh('copilot', async () => {
|
||||
assert.fail('A second panel should use the cached catalog within the TTL');
|
||||
})).warning, degraded.warning);
|
||||
|
||||
await cache.refresh('copilot', async () => ({
|
||||
currentModelId: 'auto',
|
||||
models: [{ id: 'auto', name: 'Auto' }],
|
||||
}), { force: true });
|
||||
assert.equal(cache.read('copilot')?.warning, undefined);
|
||||
});
|
||||
|
||||
// #3584 alignment: OpenCode must render build-time presets while its live
|
||||
// catalog load is pending, exactly like every other backend. The old host
|
||||
// code suppressed presets for opencode while `runtimeModelLoading` was set,
|
||||
// leaving the picker empty until the pull resolved. Guard against it coming
|
||||
// back: the preset fallback in the panel must not gate on opencode loading.
|
||||
test('host preset fallback does not suppress presets for a loading opencode catalog', () => {
|
||||
const source = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /getExternalAgentSdkBackend\(currentAgentConfig\) === 'opencode'[\s\S]*?runtimeModelLoading/);
|
||||
// Build-time presets remain the fallback whenever no runtime catalog is
|
||||
// active for the agent (loading included).
|
||||
assert.match(source, /const presets = getAgentModelPresets\(/);
|
||||
});
|
||||
|
||||
test('reconcileRuntimeModelWarning adds the banner for a warned catalog and keeps unrelated agents', () => {
|
||||
const current = { other: { cacheKey: 'k-other', message: 'x' } };
|
||||
const next = reconcileRuntimeModelWarning(current, 'copilot', 'k1', true, 'warn');
|
||||
assert.deepEqual(next, {
|
||||
other: { cacheKey: 'k-other', message: 'x' },
|
||||
copilot: { cacheKey: 'k1', message: 'warn' },
|
||||
});
|
||||
});
|
||||
|
||||
test('reconcileRuntimeModelWarning returns the same object when nothing changes', () => {
|
||||
const current = { copilot: { cacheKey: 'k1', message: 'warn' } };
|
||||
assert.equal(reconcileRuntimeModelWarning(current, 'copilot', 'k1', true, 'warn'), current);
|
||||
assert.equal(reconcileRuntimeModelWarning({}, 'copilot', 'k1', false, 'warn').copilot, undefined);
|
||||
});
|
||||
|
||||
test('reconcileRuntimeModelWarning clears only the banner that belongs to this cache key', () => {
|
||||
const current = { copilot: { cacheKey: 'k1', message: 'warn' } };
|
||||
assert.deepEqual(reconcileRuntimeModelWarning(current, 'copilot', 'k1', false, 'warn'), {});
|
||||
// A banner that belongs to another cache key (another profile) is not ours to clear.
|
||||
assert.equal(reconcileRuntimeModelWarning(current, 'copilot', 'k2', false, 'warn'), current);
|
||||
});
|
||||
|
||||
test('renderer model cache env hints cover every key the main-process catalog cache fingerprints', () => {
|
||||
// Both caches must split on the same profile-affecting environment: a key that
|
||||
// only the main process knows lets the renderer serve one profile's catalog to another.
|
||||
const require = createRequire(import.meta.url);
|
||||
const { SDK_MODEL_CACHE_ENV_KEYS } = require('../electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs') as {
|
||||
SDK_MODEL_CACHE_ENV_KEYS: string[];
|
||||
};
|
||||
const hints = new Set<string>(MODEL_CACHE_ENV_HINTS);
|
||||
const missing = SDK_MODEL_CACHE_ENV_KEYS.filter((key) => !hints.has(key));
|
||||
assert.deepEqual(missing, []);
|
||||
});
|
||||
|
||||
test('toSdkRuntimeModelCatalog keeps the provenance main reported for a last-known-good serve', () => {
|
||||
const catalog = toSdkRuntimeModelCatalog({
|
||||
ok: true,
|
||||
currentModelId: 'm-1',
|
||||
models: [{ id: 'm-1', name: 'M1' }],
|
||||
warning: 'live fetch failed',
|
||||
source: 'last-known-good',
|
||||
fetchedAt: '2026-10-10T08:00:00.000Z',
|
||||
});
|
||||
assert.deepEqual(catalog, {
|
||||
currentModelId: 'm-1',
|
||||
models: [{ id: 'm-1', name: 'M1' }],
|
||||
warning: 'live fetch failed',
|
||||
source: 'last-known-good',
|
||||
fetchedAt: '2026-10-10T08:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('toSdkRuntimeModelCatalog tolerates an older main without provenance or with unknown values', () => {
|
||||
assert.deepEqual(
|
||||
toSdkRuntimeModelCatalog({ ok: true, models: [{ id: 'a', name: 'A' }] }),
|
||||
{ currentModelId: null, models: [{ id: 'a', name: 'A' }], warning: null },
|
||||
);
|
||||
const odd = toSdkRuntimeModelCatalog({
|
||||
ok: true,
|
||||
models: [],
|
||||
source: 'something-else' as never,
|
||||
fetchedAt: 42 as never,
|
||||
});
|
||||
assert.equal(odd.source, undefined);
|
||||
assert.equal(odd.fetchedAt, undefined);
|
||||
});
|
||||
|
||||
test('retainStoredModelPresetForCatalog leaves the built-in fallback list alone when no catalog arrived', () => {
|
||||
const claudeAgent = { id: 'claude', command: 'claude', sdkBackend: 'claude' } as unknown as ExternalAgentConfig;
|
||||
// The first Claude catalog request failed: nothing to extend. Appending only
|
||||
// the saved alias would make a one-row list authoritative and hide the
|
||||
// built-in presets, so the empty list must stay empty.
|
||||
assert.deepEqual(retainStoredModelPresetForCatalog([], [], 'sonnet', claudeAgent), []);
|
||||
});
|
||||
|
||||
test('retainStoredModelPresetForCatalog drops composer custom rows too when no catalog arrived', () => {
|
||||
const claudeAgent = { id: 'claude', command: 'claude', sdkBackend: 'claude' } as unknown as ExternalAgentConfig;
|
||||
// The callers wrap the empty catalog with the user's custom composer models, so
|
||||
// the list is non-empty even though no catalog arrived. Stored as the runtime
|
||||
// list it would still hide the built-in presets; the panel appends custom
|
||||
// models on top of the fallback list itself.
|
||||
const withCustom = [{ id: 'my-custom-model', name: 'my-custom-model' }];
|
||||
assert.deepEqual(retainStoredModelPresetForCatalog([], withCustom, 'sonnet', claudeAgent), []);
|
||||
});
|
||||
|
||||
test('retainStoredModelPresetForCatalog still retains a saved selection on top of a real catalog', () => {
|
||||
const claudeAgent = { id: 'claude', command: 'claude', sdkBackend: 'claude' } as unknown as ExternalAgentConfig;
|
||||
const catalog = [{ id: 'claude-opus-5-5', name: 'Opus 5.5' }];
|
||||
const retained = retainStoredModelPresetForCatalog(catalog, catalog, 'sonnet', claudeAgent);
|
||||
assert.deepEqual(retained, appendRetainedStoredModelPreset(catalog, 'sonnet', claudeAgent));
|
||||
});
|
||||
|
||||
test('catalog source adaptation preserves explicit Copilot failure retention', () => {
|
||||
const agent = { id: 'copilot', command: 'copilot', sdkBackend: 'copilot' } as ExternalAgentConfig;
|
||||
const catalog = toSdkRuntimeModelCatalog({
|
||||
ok: true,
|
||||
models: [{ id: 'auto', name: 'Auto' }],
|
||||
warning: 'live discovery unavailable',
|
||||
source: 'last-known-good',
|
||||
fetchedAt: '2026-10-15T09:00:00.000Z',
|
||||
});
|
||||
const presets = retainStoredModelPresetForCatalog(
|
||||
catalog.models, catalog.models, 'saved-model', agent, Boolean(catalog.warning),
|
||||
);
|
||||
assert.deepEqual(presets.map(model => model.id), ['auto', 'saved-model']);
|
||||
assert.deepEqual(retainStoredModelPresetForCatalog(
|
||||
catalog.models, catalog.models, 'saved-model', agent, false,
|
||||
).map(model => model.id), ['auto']);
|
||||
});
|
||||
|
||||
test('SDK runtime model cache keeps the retry warning when a degraded refresh retains the cached catalog', async () => {
|
||||
let now = 1_000;
|
||||
const cache = createSdkRuntimeModelCache({ now: () => now });
|
||||
const key = 'copilot:/opt/bin/copilot';
|
||||
|
||||
await cache.refresh(key, async () => ({
|
||||
currentModelId: 'gpt-5',
|
||||
models: [{ id: 'gpt-5', name: 'GPT-5' }],
|
||||
source: 'live',
|
||||
fetchedAt: '2026-10-15T09:00:00.000Z',
|
||||
}));
|
||||
|
||||
now = 2_000;
|
||||
const retried = await cache.refresh(
|
||||
key,
|
||||
async () => ({ currentModelId: null, models: [], warning: 'catalog unavailable' }),
|
||||
{ force: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(retried.models, [{ id: 'gpt-5', name: 'GPT-5' }]);
|
||||
assert.equal(retried.warning, 'catalog unavailable');
|
||||
assert.equal(retried.source, 'last-known-good');
|
||||
assert.equal(retried.fetchedAt, '2026-10-15T09:00:00.000Z');
|
||||
// Later reads retain the warning without extending the good catalog's age.
|
||||
assert.equal(cache.read(key)?.warning, 'catalog unavailable');
|
||||
assert.equal(cache.read(key)?.updatedAt, 1_000);
|
||||
const nextRead = await cache.refresh(key, async () => { throw new Error('fresh cache should be reused'); });
|
||||
assert.equal(nextRead.warning, 'catalog unavailable');
|
||||
assert.equal(nextRead.source, 'last-known-good');
|
||||
});
|
||||
|
||||
|
||||
test('degraded Copilot selection survives custom rows while live auto-only remains authoritative', () => {
|
||||
const agent: ExternalAgentConfig = { id: 'copilot', name: 'Copilot', command: 'copilot', enabled: true, sdkBackend: 'copilot' };
|
||||
const customRows = [{ id: 'manual-model', name: 'manual-model' }];
|
||||
const retained = appendRetainedStoredModelPreset(customRows, 'saved-model', agent, true);
|
||||
assert.equal(modelPresetsContainId(retained, 'saved-model'), true);
|
||||
assert.equal(shouldUseStoredAgentModel('saved-model', retained, agent, true), true);
|
||||
assert.equal(shouldAdoptSdkCurrentModel('auto', 'saved-model', retained, agent), false);
|
||||
const live = [{ id: 'auto', name: 'auto' }];
|
||||
assert.equal(appendRetainedStoredModelPreset(live, 'saved-model', agent, false), live);
|
||||
assert.equal(shouldUseStoredAgentModel('saved-model', live, agent, false), false);
|
||||
assert.equal(shouldAdoptSdkCurrentModel('auto', 'saved-model', live, agent), true);
|
||||
});
|
||||
|
||||
test('renderer catalogs separate supported credential and profile overrides', () => {
|
||||
const agent = { id: 'agent', command: '/same/cli', sdkBackend: 'copilot' };
|
||||
for (const name of ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN', 'GH_CONFIG_DIR', 'CODEBUDDY_API_KEY', 'CODEBUDDY_AUTH_TOKEN', 'CODEBUDDY_INTERNET_ENVIRONMENT', 'XAI_API_KEY', 'GROK_CONFIG_DIR']) {
|
||||
const key = buildSdkRuntimeModelCacheKey({ ...agent, env: { [name]: 'fixture-profile-a' } });
|
||||
assert.notEqual(key, buildSdkRuntimeModelCacheKey({ ...agent, env: { [name]: 'fixture-profile-b' } }), name);
|
||||
assert.doesNotMatch(key, /fixture-profile-a/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('Copilot successful catalog without current id replaces an unlisted saved choice before failure', () => {
|
||||
const agent: ExternalAgentConfig = { id: 'copilot', name: 'Copilot', command: 'copilot', enabled: true, sdkBackend: 'copilot' };
|
||||
// Copilot returns an array, so IPC has no currentModelId even on success.
|
||||
const live = { models: [{ id: 'auto', name: 'auto' }], currentModelId: null };
|
||||
const next = resolveSdkRuntimeCatalogDefaultModelId(live, agent);
|
||||
assert.equal(next, 'auto');
|
||||
assert.equal(shouldAdoptSdkCurrentModel(next, 'saved-model', live.models, agent), true);
|
||||
const laterFailure = appendRetainedStoredModelPreset(live.models, next, agent, true);
|
||||
assert.deepEqual(laterFailure, live.models);
|
||||
assert.equal(resolveSdkRuntimeCatalogDefaultModelId({ ...live, warning: 'unavailable' }, agent), null);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
getAgentModelPresets,
|
||||
resolveAgentModelSelection,
|
||||
type AgentModelPreset,
|
||||
type ExternalAgentConfig,
|
||||
@@ -19,22 +18,9 @@ export {
|
||||
modelPresetsContainId,
|
||||
};
|
||||
|
||||
export type SdkRuntimeModelCatalogSource = 'live' | 'last-known-good' | 'presets';
|
||||
|
||||
export type SdkRuntimeModelCatalog = {
|
||||
currentModelId: string | null;
|
||||
models: AgentModelPreset[];
|
||||
// Degraded loads (e.g. last-known-good serve) carry their warning so a
|
||||
// cached hit in another panel or after a remount still shows the retry
|
||||
// banner instead of presenting stale models as live.
|
||||
warning?: string | null;
|
||||
// Provenance of the models list: 'live' when the SDK fetch succeeded,
|
||||
// 'last-known-good' when main served the persisted previous success.
|
||||
// Absent (older main) behaves like a live fetch for display purposes.
|
||||
source?: Exclude<SdkRuntimeModelCatalogSource, 'presets'>;
|
||||
// ISO timestamp of the successful fetch backing this catalog (live fetch
|
||||
// time, or the persisted last-known-good fetchedAt).
|
||||
fetchedAt?: string;
|
||||
};
|
||||
|
||||
export type SdkRuntimeModelCacheEntry = SdkRuntimeModelCatalog & {
|
||||
@@ -55,7 +41,7 @@ const SDK_RUNTIME_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const SDK_RUNTIME_MODEL_CACHE_MAX_ENTRIES = 64;
|
||||
// Keep in sync with main-process SDK_MODEL_CACHE_ENV_KEYS: profile-affecting
|
||||
// env must bust the renderer cache so we re-query after OpenCode config switches.
|
||||
export const MODEL_CACHE_ENV_HINTS = [
|
||||
const MODEL_CACHE_ENV_HINTS = [
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
'XDG_CONFIG_HOME',
|
||||
@@ -63,7 +49,6 @@ export const MODEL_CACHE_ENV_HINTS = [
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'OPENCODE_BIN',
|
||||
'OPENCODE_CONFIG',
|
||||
@@ -79,41 +64,15 @@ export const MODEL_CACHE_ENV_HINTS = [
|
||||
'XDG_CACHE_HOME',
|
||||
'CLAUDE_CODE_EXECUTABLE',
|
||||
'CODEBUDDY_CODE_PATH',
|
||||
'CODEBUDDY_API_KEY',
|
||||
'CODEBUDDY_AUTH_TOKEN',
|
||||
'CODEBUDDY_INTERNET_ENVIRONMENT',
|
||||
'XAI_API_KEY',
|
||||
'GROK_CONFIG_DIR',
|
||||
'COPILOT_GITHUB_TOKEN',
|
||||
'GH_TOKEN',
|
||||
'GITHUB_TOKEN',
|
||||
'GH_CONFIG_DIR',
|
||||
'CURSOR_API_KEY',
|
||||
'NETCATTY_CURSOR_AUTH_MODE',
|
||||
'NETCATTY_CURSOR_CLI_BIN',
|
||||
] as const;
|
||||
|
||||
const SDK_RUNTIME_MODEL_CATALOG_SOURCES = new Set(['live', 'last-known-good']);
|
||||
|
||||
function normalizeCatalogSource(
|
||||
source: unknown,
|
||||
): SdkRuntimeModelCatalog['source'] {
|
||||
return typeof source === 'string' && SDK_RUNTIME_MODEL_CATALOG_SOURCES.has(source)
|
||||
? source as SdkRuntimeModelCatalog['source']
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeCatalogFetchedAt(fetchedAt: unknown): string | undefined {
|
||||
return typeof fetchedAt === 'string' && fetchedAt ? fetchedAt : undefined;
|
||||
}
|
||||
|
||||
function cloneCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
|
||||
return {
|
||||
currentModelId: catalog.currentModelId ?? null,
|
||||
models: [...catalog.models],
|
||||
...(catalog.warning ? { warning: catalog.warning } : {}),
|
||||
...(catalog.source ? { source: catalog.source } : {}),
|
||||
...(catalog.fetchedAt ? { fetchedAt: catalog.fetchedAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,36 +82,6 @@ function normalizeSdkRuntimeModelCatalog(catalog: SdkRuntimeModelCatalog): SdkRu
|
||||
models: Array.isArray(catalog.models)
|
||||
? catalog.models.filter((model): model is AgentModelPreset => Boolean(model?.id))
|
||||
: [],
|
||||
...(catalog.warning ? { warning: catalog.warning } : {}),
|
||||
...(normalizeCatalogSource(catalog.source) ? { source: normalizeCatalogSource(catalog.source) } : {}),
|
||||
...(normalizeCatalogFetchedAt(catalog.fetchedAt) ? { fetchedAt: normalizeCatalogFetchedAt(catalog.fetchedAt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The list-models IPC result as the renderer receives it. */
|
||||
export type SdkListModelsResult = {
|
||||
ok: boolean;
|
||||
models?: AgentModelPreset[];
|
||||
currentModelId?: string | null;
|
||||
warning?: string;
|
||||
error?: string;
|
||||
// Provenance reported by main (absent on an older main).
|
||||
source?: Exclude<SdkRuntimeModelCatalogSource, 'presets'>;
|
||||
fetchedAt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adapt a successful list-models result to a catalog while keeping the
|
||||
* provenance main reported. Dropping source/fetchedAt would make the picker
|
||||
* label a persisted last-known-good list as a live catalog.
|
||||
*/
|
||||
export function toSdkRuntimeModelCatalog(result: SdkListModelsResult): SdkRuntimeModelCatalog {
|
||||
return {
|
||||
currentModelId: result.currentModelId ?? null,
|
||||
models: result.models ?? [],
|
||||
warning: result.warning ?? null,
|
||||
...(normalizeCatalogSource(result.source) ? { source: normalizeCatalogSource(result.source) } : {}),
|
||||
...(normalizeCatalogFetchedAt(result.fetchedAt) ? { fetchedAt: normalizeCatalogFetchedAt(result.fetchedAt) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,16 +187,7 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
|
||||
.then((catalog) => {
|
||||
const normalized = normalizeSdkRuntimeModelCatalog(catalog);
|
||||
if (normalized.models.length === 0 && !normalized.currentModelId) {
|
||||
// A degraded retry keeps the older catalog usable but must still
|
||||
// report its own warning, or the banner and Retry would vanish.
|
||||
if (!cached) return cloneCatalog(normalized);
|
||||
const retained = cloneCatalog(cached);
|
||||
if (normalized.warning) {
|
||||
retained.warning = normalized.warning;
|
||||
retained.source = 'last-known-good';
|
||||
touchEntry(key, { ...retained, updatedAt: cached.updatedAt });
|
||||
}
|
||||
return retained;
|
||||
return cached ? cloneCatalog(cached) : cloneCatalog(normalized);
|
||||
}
|
||||
touchEntry(key, { ...cloneCatalog(normalized), updatedAt: now() });
|
||||
pruneEntries();
|
||||
@@ -287,32 +207,6 @@ export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions
|
||||
|
||||
export const sdkRuntimeModelCache = createSdkRuntimeModelCache();
|
||||
|
||||
export type RuntimeModelWarnings = Record<string, { cacheKey: string; message: string }>;
|
||||
|
||||
/**
|
||||
* Make the per-agent catalog warning banner match a catalog result. Used both
|
||||
* for cache reads and after a refresh settles: a refresh that coalesced onto
|
||||
* another panel's in-flight load never runs its own loader callback, so
|
||||
* the caller must reconcile the banner from the shared result itself.
|
||||
* Returns the same object when nothing changes (no extra render). A banner
|
||||
* that belongs to a different cache key is never cleared.
|
||||
*/
|
||||
export function reconcileRuntimeModelWarning(
|
||||
current: RuntimeModelWarnings,
|
||||
agentId: string,
|
||||
cacheKey: string,
|
||||
hasWarning: boolean,
|
||||
message: string,
|
||||
): RuntimeModelWarnings {
|
||||
const existing = current[agentId];
|
||||
if (hasWarning) {
|
||||
return existing?.cacheKey === cacheKey ? current : { ...current, [agentId]: { cacheKey, message } };
|
||||
}
|
||||
if (existing?.cacheKey !== cacheKey) return current;
|
||||
const { [agentId]: _removed, ...rest } = current;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function mergeFallbackThinkingLevels(
|
||||
runtime: AgentModelPreset[],
|
||||
fallbacks: AgentModelPreset[],
|
||||
@@ -384,71 +278,12 @@ export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean
|
||||
|| sdkBackend === 'mimo';
|
||||
}
|
||||
|
||||
// Alias ids the claude CLI itself still resolves: its alias table maps
|
||||
// opus/sonnet/haiku -> claude-*-5-5 and fable -> claude-fable-5-1. Preset
|
||||
// lists before the 2026-10 catalog refresh persisted `sonnet`/`haiku` as
|
||||
// preset ids, so a stored alias must keep resolving instead of being
|
||||
// silently replaced by the live catalog's full id.
|
||||
const CLAUDE_CLI_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku', 'fable']);
|
||||
|
||||
function isStillValidClaudeAlias(
|
||||
storedModelId: string | null | undefined,
|
||||
agent?: ExternalAgentConfig,
|
||||
): boolean {
|
||||
if (!storedModelId) return false;
|
||||
if (getExternalAgentSdkBackend(agent) !== 'claude') return false;
|
||||
const baseId = canonicalizeEffortEncodedModelId(storedModelId).split('/')[0];
|
||||
return CLAUDE_CLI_MODEL_ALIASES.has(baseId);
|
||||
}
|
||||
|
||||
// Retain unlisted Copilot choices while discovery is pending or failed.
|
||||
// A successful auto-only catalog is authoritative, regardless of row names.
|
||||
function shouldRetainUnlistedCopilotModel(
|
||||
agent: ExternalAgentConfig | undefined,
|
||||
retainUnlistedModel: boolean,
|
||||
): boolean {
|
||||
return retainUnlistedModel && getExternalAgentSdkBackend(agent) === 'copilot';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a stored selection still resolves even though it is absent from
|
||||
* the given presets: either the presets list it under an effort-suffixed
|
||||
* spelling, or it is a claude CLI alias the backend accepts verbatim.
|
||||
*/
|
||||
export function storedModelStillResolvesForAgent(
|
||||
storedModelId: string | null | undefined,
|
||||
presets: AgentModelPreset[],
|
||||
agent?: ExternalAgentConfig,
|
||||
): boolean {
|
||||
if (!storedModelId) return false;
|
||||
return modelPresetsContainId(presets, storedModelId)
|
||||
|| isStillValidClaudeAlias(storedModelId, agent);
|
||||
}
|
||||
|
||||
export function resolveSdkRuntimeCatalogDefaultModelId(
|
||||
catalog: SdkRuntimeModelCatalog,
|
||||
agent?: ExternalAgentConfig,
|
||||
): string | null {
|
||||
if (catalog.currentModelId) return catalog.currentModelId;
|
||||
// Copilot lists models without a current id. Commit the same visible
|
||||
// default on live success, so a later failed refresh cannot revive an
|
||||
// unlisted saved choice that the live catalog has already replaced.
|
||||
if (!catalog.warning && getExternalAgentSdkBackend(agent) === 'copilot' && catalog.models[0]) {
|
||||
return resolveAgentModelSelection(catalog.models[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldAdoptSdkCurrentModel(
|
||||
currentModelId: string | null | undefined,
|
||||
storedModelId: string | null | undefined,
|
||||
runtimePresets: AgentModelPreset[],
|
||||
agent?: ExternalAgentConfig,
|
||||
): boolean {
|
||||
if (!currentModelId) return false;
|
||||
// A stored claude alias the CLI still resolves is a user choice: never
|
||||
// overwrite it with the catalog's current model.
|
||||
if (isStillValidClaudeAlias(storedModelId, agent)) return false;
|
||||
return !storedModelId
|
||||
|| runtimePresets.length === 0
|
||||
|| !modelPresetsContainId(runtimePresets, storedModelId);
|
||||
@@ -467,63 +302,10 @@ export function shouldUseStoredAgentModel(
|
||||
storedModelId: string | null | undefined,
|
||||
presets: AgentModelPreset[],
|
||||
agent?: ExternalAgentConfig,
|
||||
retainUnlistedModel = false,
|
||||
): boolean {
|
||||
if (!storedModelId) return false;
|
||||
return storedModelStillResolvesForAgent(storedModelId, presets, agent)
|
||||
|| (presets.length === 0 && shouldLoadSdkRuntimeModels(agent))
|
||||
|| shouldRetainUnlistedCopilotModel(agent, retainUnlistedModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored selection preserved through the claude-alias or copilot
|
||||
* degraded fallback has no preset row of its own; append one (reusing the
|
||||
* build-time preset when it defines the id) so the picker can render and
|
||||
* check the selection while the send preflight passes the same value.
|
||||
*/
|
||||
export function appendRetainedStoredModelPreset(
|
||||
presets: AgentModelPreset[],
|
||||
storedModelId: string | null | undefined,
|
||||
agent?: ExternalAgentConfig,
|
||||
retainUnlistedModel = false,
|
||||
): AgentModelPreset[] {
|
||||
if (!storedModelId || modelPresetsContainId(presets, storedModelId)) return presets;
|
||||
if (!isStillValidClaudeAlias(storedModelId, agent)
|
||||
&& !shouldRetainUnlistedCopilotModel(agent, retainUnlistedModel)) {
|
||||
return presets;
|
||||
}
|
||||
const canonical = canonicalizeEffortEncodedModelId(storedModelId);
|
||||
const baseId = canonical.split('/')[0];
|
||||
if (presets.some((preset) => preset.id === baseId)) {
|
||||
// The base row did not cover the stored id (checked above), so a suffixed
|
||||
// selection needs its own exact row; a bare id is already present.
|
||||
return canonical === baseId ? presets : [...presets, { id: canonical, name: canonical }];
|
||||
}
|
||||
const fallback = getAgentModelPresets(agent?.command, getExternalAgentSdkBackend(agent))
|
||||
.find((preset) => preset.id === baseId);
|
||||
// Keep the exact stored id (including any effort suffix) so the appended
|
||||
// row still matches the selection the send preflight passes.
|
||||
return [...presets, fallback ?? { id: canonical, name: canonical }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain the saved selection on top of the presets built for a catalog, but
|
||||
* only when a catalog actually arrived. When none did (the request failed
|
||||
* before any last-known-good entry existed) the result is empty so the agent
|
||||
* keeps its whole built-in fallback list: a lone retained row, or only the
|
||||
* user's custom composer models, would be stored as the authoritative runtime
|
||||
* list and hide every built-in preset. The panel appends custom models on top
|
||||
* of the fallback list itself.
|
||||
*/
|
||||
export function retainStoredModelPresetForCatalog(
|
||||
catalogPresets: AgentModelPreset[],
|
||||
presets: AgentModelPreset[],
|
||||
storedModelId: string | null | undefined,
|
||||
agent?: ExternalAgentConfig,
|
||||
retainUnlistedModel = false,
|
||||
): AgentModelPreset[] {
|
||||
if (catalogPresets.length === 0) return [];
|
||||
return appendRetainedStoredModelPreset(presets, storedModelId, agent, retainUnlistedModel);
|
||||
return modelPresetsContainId(presets, storedModelId)
|
||||
|| (presets.length === 0 && shouldLoadSdkRuntimeModels(agent));
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
|
||||
|
||||
test('provider disconnect waits for completion, reports failure and permits retry', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const globals = {
|
||||
localStorage: dom.window.localStorage,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
HTMLInputElement: dom.window.HTMLInputElement,
|
||||
};
|
||||
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
const { ProviderCard } = await import('./cloud-sync/CloudSyncControls');
|
||||
const { toast } = await import('./ui/toast');
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const notices: string[] = [];
|
||||
const info = toast.info;
|
||||
const error = toast.error;
|
||||
toast.info = (message) => { notices.push(`info:${message}`); };
|
||||
toast.error = (message) => { notices.push(`error:${message}`); };
|
||||
let finish: (() => void) | undefined;
|
||||
let fail = true;
|
||||
let calls = 0;
|
||||
try {
|
||||
await renderer.render(
|
||||
<I18nProvider locale="en">
|
||||
<ProviderCard provider="webdav" name="WebDAV" icon={null} isConnected isSyncing={false}
|
||||
onConnect={() => {}} onSync={() => {}}
|
||||
onDisconnect={() => {
|
||||
calls += 1;
|
||||
if (fail) throw new Error('fixture failure');
|
||||
return new Promise<void>((resolve) => { finish = resolve; });
|
||||
}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const click = async (node: Element) => {
|
||||
await dispatchDomEvent(node, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
await flushEffects();
|
||||
};
|
||||
const button = dom.document.querySelector('button[aria-label="Disconnect"]');
|
||||
assert.ok(button);
|
||||
assert.equal(button.textContent, 'Disconnect');
|
||||
await click(button);
|
||||
let dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
assert.match(dialog.textContent ?? '', /backup history and sync encryption password stay here/);
|
||||
const confirm = () => {
|
||||
const node = [...dom.document.querySelectorAll('[role="dialog"] button')].find((el) => el.textContent === 'Disconnect');
|
||||
assert.ok(node);
|
||||
return node as HTMLButtonElement;
|
||||
};
|
||||
await click(confirm());
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(notices.length, 1);
|
||||
assert.match(notices[0], /^error:Could not finish disconnecting from WebDAV/);
|
||||
assert.ok(dom.document.querySelector('[role="dialog"]'));
|
||||
assert.equal(confirm().disabled, false);
|
||||
fail = false;
|
||||
await click(confirm());
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(confirm().disabled, true);
|
||||
assert.equal(notices.length, 1);
|
||||
await runWithAct(async () => { finish?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(notices.length, 2);
|
||||
assert.match(notices[1], /^info:Encrypted sync copies may still exist in your WebDAV storage/);
|
||||
assert.doesNotMatch(notices[1], /\{name\}/);
|
||||
// Cancelling a new confirmation never calls the disconnect callback.
|
||||
await click(button);
|
||||
dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
const cancel = [...dialog.querySelectorAll('button')].find((el) => el.textContent === 'Cancel');
|
||||
assert.ok(cancel);
|
||||
await click(cancel);
|
||||
assert.equal(calls, 2);
|
||||
} finally {
|
||||
toast.info = info;
|
||||
toast.error = error;
|
||||
await renderer.unmount();
|
||||
// Radix restores focus on a timer after unmount. Keep the DOM globals
|
||||
// installed until that cleanup has dispatched its final event.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
formatProxyConfigType,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
|
||||
import {
|
||||
EnvVar,
|
||||
GroupConfig,
|
||||
@@ -334,12 +333,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const availableHostsForChain = useMemo(() => {
|
||||
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
||||
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
|
||||
// entries (e.g. saved local shells) as jump hosts.
|
||||
return allHosts.filter((h) =>
|
||||
!chainedIds.has(h.id)
|
||||
&& canServeAsSshJumpHost(h),
|
||||
);
|
||||
return allHosts.filter((h) => !chainedIds.has(h.id));
|
||||
}, [allHosts, form.hostChain?.hostIds]);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
|
||||
@@ -27,9 +27,8 @@ import {
|
||||
toGlobalHistoryDisplayEntries,
|
||||
} from '../domain/globalHistory';
|
||||
import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models';
|
||||
import { removeCommandHistoryEntry } from '../application/state/commandHistoryStore';
|
||||
import { removeCommandHistoryEntry } from './terminal/autocomplete/commandHistoryStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ConfirmDialog } from './ui/confirm-dialog';
|
||||
import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState';
|
||||
import {
|
||||
VariableSizeVirtualList,
|
||||
@@ -47,7 +46,6 @@ export interface HistorySidePanelProps {
|
||||
globalEntries: ShellHistoryEntry[];
|
||||
onFetch: (sessionId: string, hostId: string) => void;
|
||||
onDeleteGlobalEntry?: (entryId: string) => void;
|
||||
onClearGlobalHistory?: () => void;
|
||||
/** Paste into the terminal without executing (no trailing Enter). */
|
||||
onPasteToTerminal: (command: string) => void;
|
||||
/** Write to the terminal and execute (append Enter). */
|
||||
@@ -117,7 +115,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
globalEntries,
|
||||
onFetch,
|
||||
onDeleteGlobalEntry,
|
||||
onClearGlobalHistory,
|
||||
onPasteToTerminal,
|
||||
onRunInTerminal,
|
||||
isVisible = true,
|
||||
@@ -126,7 +123,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
const [scope, setScope] = useState<HistoryPanelScope>('host');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||
const [clearAllOpen, setClearAllOpen] = useState(false);
|
||||
const listRef = useRef<VariableSizeVirtualListHandle>(null);
|
||||
|
||||
const protocol = focusedHost?.protocol;
|
||||
@@ -212,14 +208,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
setSelectedEntryId(null);
|
||||
}, [globalEntries, onDeleteGlobalEntry, scope, sourceEntries]);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
// The application-state action clears both the global shell history and the
|
||||
// autocomplete history, and fans the clear out to every renderer.
|
||||
onClearGlobalHistory?.();
|
||||
setClearAllOpen(false);
|
||||
setSelectedEntryId(null);
|
||||
}, [onClearGlobalHistory]);
|
||||
|
||||
const handleRowClick = useCallback((entryId: string) => {
|
||||
setSelectedEntryId((current) => {
|
||||
const next = current === entryId ? null : entryId;
|
||||
@@ -306,18 +294,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
<RefreshCw size={14} className={cn(state.loading && 'animate-spin')} />
|
||||
</button>
|
||||
)}
|
||||
{scope === 'global' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClearAllOpen(true)}
|
||||
disabled={!onClearGlobalHistory}
|
||||
title={t('history.action.clearAll')}
|
||||
aria-label={t('history.action.clearAll')}
|
||||
className="shrink-0 h-6 w-6 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-40 disabled:hover:text-muted-foreground disabled:hover:bg-transparent"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/30 min-h-[28px]">
|
||||
@@ -435,16 +411,6 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={clearAllOpen}
|
||||
onOpenChange={setClearAllOpen}
|
||||
title={t('history.clearAll.dialog.title')}
|
||||
message={t('history.clearAll.dialog.desc')}
|
||||
confirmLabel={t('history.clearAll.dialog.confirm')}
|
||||
destructive
|
||||
onConfirm={handleClearAll}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { hasRequiredHostAuthCredential, resolveHostAuth, resolveHostAuthMethodForPersistence } from "../domain/sshAuth";
|
||||
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
hasHostFontSizeOverride,
|
||||
@@ -750,13 +749,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const availableHostsForChain = useMemo(() => {
|
||||
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
||||
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
|
||||
// entries (e.g. saved local shells) as jump hosts.
|
||||
return allHosts.filter((h) =>
|
||||
h.id !== form.id
|
||||
&& !chainedIds.has(h.id)
|
||||
&& canServeAsSshJumpHost(h),
|
||||
);
|
||||
return allHosts.filter((h) => h.id !== form.id && !chainedIds.has(h.id));
|
||||
}, [allHosts, form.id, form.hostChain?.hostIds]);
|
||||
|
||||
const chainedHosts = useMemo(() => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type HostClickBehavior,
|
||||
} from '../domain/hostClickBehavior';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
@@ -727,7 +726,6 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
initialTreeItemKey,
|
||||
onActiveTreeItemChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const displayDetails = useMemo(
|
||||
@@ -845,9 +843,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<HostNotesIndicator notes={host.notes} />
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{displayProtocol === 'local'
|
||||
? getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })
|
||||
: `${displayUsername}@${host.hostname}:${displayPort}`}
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { LocalShellHostDetailsPanel } from "./LocalShellHostDetailsPanel.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
|
||||
const panelSource = readFileSync(
|
||||
new URL("./LocalShellHostDetailsPanel.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const existingLocalHost = {
|
||||
id: "local-existing",
|
||||
label: "Project A CMD",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
tags: ["local"],
|
||||
group: "Projects/A",
|
||||
protocol: "local",
|
||||
os: "linux",
|
||||
createdAt: 0,
|
||||
localShell: "powershell",
|
||||
localShellName: "Windows PowerShell",
|
||||
localShellIcon: "powershell",
|
||||
} as unknown as Host;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
test("renderToStaticMarkup: create mode shows title, default-shell option and Save & Connect", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<LocalShellHostDetailsPanel
|
||||
defaultGroup="Projects/A"
|
||||
groups={["Projects/A", "Infra"]}
|
||||
onSave={noop}
|
||||
onSaveAndConnect={noop}
|
||||
onCancel={noop}
|
||||
layout="inline"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /localShell\.panel\.title/);
|
||||
assert.match(markup, /localShell\.field\.shellDefault/);
|
||||
assert.match(markup, /localShell\.saveAndConnect/);
|
||||
assert.match(markup, /localShell\.saveAndConnect|common\.save/);
|
||||
});
|
||||
|
||||
test("renderToStaticMarkup: edit mode hides Save & Connect and shows existing shell", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<LocalShellHostDetailsPanel
|
||||
initialData={existingLocalHost}
|
||||
groups={["Projects/A"]}
|
||||
onSave={noop}
|
||||
onCancel={noop}
|
||||
layout="inline"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /localShell\.panel\.title\.edit/);
|
||||
assert.doesNotMatch(markup, /localShell\.saveAndConnect/);
|
||||
// Existing label is carried into the form
|
||||
assert.match(markup, /Project A CMD/);
|
||||
assert.match(markup, /local-shell-start-dir/);
|
||||
});
|
||||
|
||||
test("buildHost preserves the saved OS on edits and detects it only for new entries", () => {
|
||||
// Editing must retain initialData.os: entries imported/synced on one
|
||||
// platform must not be re-classified as the editor machine's OS on save.
|
||||
assert.match(
|
||||
panelSource,
|
||||
/os:\s*initialData\?\.os \?\? detectLocalOs\(navigator\.userAgent \|\| navigator\.platform\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("unchanged shell selection keeps stored metadata over discovery", () => {
|
||||
// A host with custom localShellArgs for a shell id that discovery also
|
||||
// matches must keep its stored args/name/icon when an unrelated edit
|
||||
// (label, tag, group) is saved; discovered metadata applies only after
|
||||
// the user changes the shell selection.
|
||||
const unchanged = panelSource.match(
|
||||
/const shellMeta =\s*\n[\s\S]*?shell === initialData\?\.localShell\s*\n\s*\?\s*\{[\s\S]*?\}\s*\n\s*: matchedShell;/,
|
||||
);
|
||||
assert.ok(unchanged, "shellMeta must branch on an unchanged shell selection");
|
||||
// Inside the unchanged branch, stored metadata wins per field and
|
||||
// matchedShell is only a fallback (never the primary source).
|
||||
assert.match(
|
||||
unchanged[0],
|
||||
/args:\s*initialData\?\.localShellArgs \?\? matchedShell\?\.args/,
|
||||
);
|
||||
assert.match(
|
||||
unchanged[0],
|
||||
/name:\s*initialData\?\.localShellName \?\? matchedShell\?\.name/,
|
||||
);
|
||||
assert.match(
|
||||
unchanged[0],
|
||||
/icon:\s*initialData\?\.localShellIcon \?\? matchedShell\?\.icon/,
|
||||
);
|
||||
assert.doesNotMatch(unchanged[0], /matchedShell \?\?/);
|
||||
});
|
||||
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* Local Shell Host Details Panel
|
||||
* A dedicated editor for local shell / CMD vault hosts (protocol 'local'),
|
||||
* distinct from the SSH HostDetailsPanel. Lets users save named local shells
|
||||
* into vault groups next to their SSH hosts (issue #3615).
|
||||
*/
|
||||
import { Save, TerminalSquare, X } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useDiscoveredShells } from '../lib/useDiscoveredShells';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import type { Host } from '../domain/models';
|
||||
import { createLocalShellHost } from '../domain/localShellHost';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
type AsidePanelResizeProps,
|
||||
} from './ui/aside-panel';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface LocalShellHostDetailsPanelProps {
|
||||
/** Existing host when editing; null/undefined when creating a new entry. */
|
||||
initialData?: Host | null;
|
||||
defaultGroup?: string;
|
||||
groups?: string[];
|
||||
onSave: (host: Host) => void;
|
||||
/** Saves the host and immediately opens a terminal for it. */
|
||||
onSaveAndConnect?: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type LocalShellHostDetailsPanelPropsWithResize =
|
||||
LocalShellHostDetailsPanelProps & AsidePanelResizeProps;
|
||||
|
||||
export const LocalShellHostDetailsPanel: React.FC<LocalShellHostDetailsPanelPropsWithResize> = ({
|
||||
initialData,
|
||||
defaultGroup,
|
||||
groups = [],
|
||||
onSave,
|
||||
onSaveAndConnect,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
className,
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
const [label, setLabel] = useState(initialData?.label ?? '');
|
||||
const [shell, setShell] = useState(initialData?.localShell ?? '');
|
||||
const [startDir, setStartDir] = useState(initialData?.localStartDir ?? '');
|
||||
// On creation, initialize from defaultGroup so the field reflects what a
|
||||
// plain save would persist; clearing it then intentionally saves at the
|
||||
// vault root. Edits keep the host's own group (possibly empty).
|
||||
const [group, setGroup] = useState(
|
||||
initialData ? initialData.group ?? '' : defaultGroup ?? '',
|
||||
);
|
||||
|
||||
const matchedShell = useMemo(
|
||||
() => discoveredShells.find((candidate) => candidate.id === shell),
|
||||
[discoveredShells, shell],
|
||||
);
|
||||
|
||||
// While the shell selection is unchanged from the saved host, prefer the
|
||||
// host's stored metadata: an imported/synced host may carry custom startup
|
||||
// arguments for a shell id that also exists in discovery, and saving an
|
||||
// unrelated edit (label, tag, group) must not overwrite them with the
|
||||
// current discovery result. Discovered metadata applies only after the user
|
||||
// changes the selection — or per-field when the host stores none.
|
||||
const shellMeta =
|
||||
shell && shell === initialData?.localShell
|
||||
? {
|
||||
args: initialData?.localShellArgs ?? matchedShell?.args,
|
||||
name: initialData?.localShellName ?? matchedShell?.name,
|
||||
icon: initialData?.localShellIcon ?? matchedShell?.icon,
|
||||
}
|
||||
: matchedShell;
|
||||
|
||||
const shellOptions: ComboboxOption[] = useMemo(() => {
|
||||
const options: ComboboxOption[] = [
|
||||
{ value: '', label: t('localShell.field.shellDefault') },
|
||||
];
|
||||
for (const candidate of discoveredShells) {
|
||||
options.push({
|
||||
value: candidate.id,
|
||||
label: candidate.name,
|
||||
sublabel: candidate.command,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [discoveredShells, t]);
|
||||
|
||||
const groupOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allGroups = new Set(groups);
|
||||
if (group && !allGroups.has(group)) {
|
||||
allGroups.add(group);
|
||||
}
|
||||
return Array.from(allGroups).map((g) => ({
|
||||
value: g,
|
||||
label: g,
|
||||
}));
|
||||
}, [groups, group]);
|
||||
|
||||
const buildHost = (): Host => {
|
||||
const host = createLocalShellHost({
|
||||
id: initialData?.id,
|
||||
// Keep the entry's saved OS on edits: importing/syncing on one platform
|
||||
// then editing elsewhere must not silently re-classify the host (shell
|
||||
// defaults, CSV round trips, dedup) as the editor machine's OS.
|
||||
os: initialData?.os ?? detectLocalOs(navigator.userAgent || navigator.platform),
|
||||
label,
|
||||
shell,
|
||||
shellArgs: shellMeta?.args,
|
||||
shellName: shellMeta?.name,
|
||||
shellIcon: shellMeta?.icon,
|
||||
startDir,
|
||||
// Save the controlled value directly: '' means the user explicitly
|
||||
// cleared the group, which persists at the vault root.
|
||||
group: group,
|
||||
tags: initialData?.tags,
|
||||
notes: initialData?.notes,
|
||||
});
|
||||
// On edits, merge over the existing host so fields this form does not
|
||||
// expose (pinned, lastConnectedAt, order, ...) and the original
|
||||
// creation timestamp survive a save.
|
||||
return initialData
|
||||
? { ...initialData, ...host, createdAt: initialData.createdAt ?? host.createdAt }
|
||||
: host;
|
||||
};
|
||||
|
||||
const handleSave = () => onSave(buildHost());
|
||||
const handleSaveAndConnect = () => {
|
||||
if (!onSaveAndConnect) return;
|
||||
onSaveAndConnect(buildHost());
|
||||
};
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={initialData ? t('localShell.panel.title.edit') : t('localShell.panel.title')}
|
||||
subtitle={initialData?.label}
|
||||
className={cn('z-40', className)}
|
||||
layout={layout}
|
||||
dataSection="local-shell-host-details-panel"
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
resizeAriaLabel={resizeAriaLabel}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Label */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="local-shell-label">{t('localShell.field.label')}</Label>
|
||||
<Input
|
||||
id="local-shell-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('localShell.field.labelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Shell */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('localShell.field.shell')}</Label>
|
||||
<Combobox
|
||||
options={shellOptions}
|
||||
value={shell}
|
||||
onValueChange={setShell}
|
||||
placeholder={t('localShell.field.shellDefault')}
|
||||
emptyText={t('localShell.field.shellEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{matchedShell
|
||||
? t('localShell.field.shellResolved', { command: matchedShell.command })
|
||||
: t('localShell.field.shellDefaultDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Group */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('hostDetails.group')}</Label>
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={group}
|
||||
onValueChange={setGroup}
|
||||
placeholder={t('hostDetails.selectGroup')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createGroup')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start directory */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="local-shell-start-dir">{t('localShell.field.startDir')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="local-shell-start-dir"
|
||||
value={startDir}
|
||||
onChange={(e) => setStartDir(e.target.value)}
|
||||
placeholder={t('localShell.field.startDirPlaceholder')}
|
||||
className="pr-8"
|
||||
/>
|
||||
{startDir && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStartDir('')}
|
||||
aria-label={t('common.reset')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AsidePanelContent>
|
||||
|
||||
<AsidePanelFooter>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onCancel} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{onSaveAndConnect && !initialData && (
|
||||
<Button
|
||||
onClick={handleSaveAndConnect}
|
||||
className="flex-1"
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" />
|
||||
{t('localShell.saveAndConnect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSave} className="flex-1">
|
||||
<Save size={14} className="mr-2" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalShellHostDetailsPanel;
|
||||
@@ -30,8 +30,6 @@ interface SelectHostPanelProps {
|
||||
resizable?: boolean;
|
||||
persistWidthStorageKey?: string;
|
||||
resizeAriaLabel?: string;
|
||||
/** Allow saved local-shell (CMD/PowerShell/...) hosts as targets, e.g. snippet/script runners. */
|
||||
allowLocalHosts?: boolean;
|
||||
}
|
||||
|
||||
const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
@@ -58,7 +56,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
allowLocalHosts = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [newHostPanelOpen, setNewHostPanelOpen] = useState(false);
|
||||
@@ -106,7 +103,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onNewHostPanelOpenChange={setNewHostPanelOpen}
|
||||
allowLocalHosts={allowLocalHosts}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</AsidePanel>
|
||||
|
||||
@@ -58,8 +58,6 @@ export interface SelectHostPanelContentProps {
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
onNewHostPanelOpenChange?: (open: boolean) => void;
|
||||
/** Allow saved local-shell (CMD/PowerShell/...) hosts as targets, e.g. snippet/script runners. */
|
||||
allowLocalHosts?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -87,7 +85,6 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
onNewHostPanelOpenChange,
|
||||
allowLocalHosts = false,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -109,14 +106,9 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
onNewHostPanelOpenChange?.(showNewHostPanel);
|
||||
}, [onNewHostPanelOpenChange, showNewHostPanel]);
|
||||
|
||||
// This panel picks hosts for SSH-backed capabilities (tunnels, keychain
|
||||
// key export targets); serial hosts have no SSH session to target and, by
|
||||
// default, neither do local-shell hosts. Snippet/script target pickers pass
|
||||
// `allowLocalHosts` because their domain treats every non-serial host
|
||||
// (including saved CMD/PowerShell shells) as a runnable target.
|
||||
const selectableHosts = useMemo(
|
||||
() => hosts.filter((host) => host.protocol !== 'serial' && (allowLocalHosts || host.protocol !== 'local')),
|
||||
[hosts, allowLocalHosts],
|
||||
() => hosts.filter((host) => host.protocol !== 'serial'),
|
||||
[hosts],
|
||||
);
|
||||
const selectedHostIdSet = useMemo(
|
||||
() => new Set(selectedHostIds),
|
||||
|
||||
@@ -132,7 +132,6 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
customGroups={customGroups}
|
||||
selectedHostIds={targetSelection}
|
||||
multiSelect={true}
|
||||
allowLocalHosts={true}
|
||||
onSelect={handleTargetSelect}
|
||||
onSelectionChange={handleTargetSelectionChange}
|
||||
onBack={handleTargetPickerBack}
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
resolveTerminalChainHosts,
|
||||
resolveTerminalSessionHost,
|
||||
} from '../domain/terminalHostResolution';
|
||||
import { useDefaultLocalShellContext } from '../lib/useDiscoveredShells';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { toast } from './ui/toast';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
@@ -183,7 +182,7 @@ function buildScriptSessionMeta(
|
||||
connected: session.status === 'connected',
|
||||
name: session.customName || session.hostLabel || host?.label,
|
||||
hostname: host?.hostname ?? session.hostname,
|
||||
username: session.protocol === 'local' ? session.username : host?.username ?? session.username,
|
||||
username: host?.username ?? session.username,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,7 +238,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onAddKnownHost,
|
||||
onCommandExecuted,
|
||||
onDeleteShellHistoryEntry,
|
||||
onClearCommandHistory,
|
||||
onTerminalDataCapture,
|
||||
onCreateWorkspaceFromSessions,
|
||||
onAddSessionToWorkspace,
|
||||
@@ -1197,16 +1195,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
const defaultLocalShell = useDefaultLocalShellContext(terminalSettings);
|
||||
const effectiveHosts = useMemo(
|
||||
() => hosts.map((host) => resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds: proxyProfileIdSet,
|
||||
defaultLocalShell,
|
||||
})),
|
||||
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles, defaultLocalShell],
|
||||
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles],
|
||||
);
|
||||
|
||||
// Pre-compute fallback hosts to avoid creating new objects on every render
|
||||
@@ -1219,12 +1215,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
localOs: detectLocalOs(navigator.userAgent || navigator.platform),
|
||||
defaultLocalShell,
|
||||
});
|
||||
map.set(session.id, applySessionFontSizeToHost(hostForSession, session));
|
||||
}
|
||||
return map;
|
||||
}, [sessions, hosts, groupConfigs, proxyProfiles, defaultLocalShell]);
|
||||
}, [sessions, hosts, groupConfigs, proxyProfiles]);
|
||||
const resolvedSessionHostIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
@@ -2534,7 +2529,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleCloseSidePanel,
|
||||
handleCommandExecuted,
|
||||
handleHistoryDelete: onDeleteShellHistoryEntry,
|
||||
handleHistoryClear: onClearCommandHistory,
|
||||
handleCommandSubmitted,
|
||||
handleComposeSend,
|
||||
handleHistoryPaste,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { readFileSync } from 'node:fs';
|
||||
import type { Host, TerminalSession } from '../types';
|
||||
import type { TerminalPopupPayload } from '../domain/systemManager/types';
|
||||
import { resolveTerminalPopupHost, resolveTerminalPopupReuseId } from './TerminalPopupPage';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells';
|
||||
|
||||
const source = readFileSync(new URL('./TerminalPopupPage.tsx', import.meta.url), 'utf8');
|
||||
const terminalSource = readFileSync(new URL('./Terminal.tsx', import.meta.url), 'utf8');
|
||||
@@ -54,36 +53,6 @@ test('resolveTerminalPopupHost honors source session transport over saved Mosh h
|
||||
assert.equal(host.etEnabled, false);
|
||||
});
|
||||
|
||||
test('resolveTerminalPopupHost resolves the settings shell for saved local hosts with a system default shell', () => {
|
||||
const localHost = vaultHost({
|
||||
protocol: 'local',
|
||||
hostname: 'localhost',
|
||||
username: '',
|
||||
port: undefined,
|
||||
localShell: undefined,
|
||||
});
|
||||
const defaultLocalShell = {
|
||||
discoveredShells: [],
|
||||
resolveShellSetting,
|
||||
terminalSettings: { localShell: 'fish', localShellArgs: [] },
|
||||
};
|
||||
|
||||
const withContext = resolveTerminalPopupHost(
|
||||
popupPayload(sourceSession({ protocol: 'local', port: undefined })),
|
||||
[localHost],
|
||||
{ defaultLocalShell },
|
||||
);
|
||||
assert.equal(withContext.localShell, 'fish');
|
||||
|
||||
// Without the context (e.g. callers that cannot resolve it) the vault host
|
||||
// keeps its saved (empty) shell — the caller decides how to handle it.
|
||||
const withoutContext = resolveTerminalPopupHost(
|
||||
popupPayload(sourceSession({ protocol: 'local', port: undefined })),
|
||||
[localHost],
|
||||
);
|
||||
assert.equal(withoutContext.localShell, undefined);
|
||||
});
|
||||
|
||||
test('resolveTerminalPopupHost still falls back to source session details when the host is missing', () => {
|
||||
const host = resolveTerminalPopupHost(popupPayload(sourceSession({ hostId: 'missing' })), []);
|
||||
|
||||
|
||||
@@ -12,14 +12,12 @@ import {
|
||||
} from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import { upsertKnownHost } from '../domain/knownHosts';
|
||||
import { isPluginHostProtocol, sanitizePluginConnection } from '../domain/pluginConnection';
|
||||
import type { ResolveDefaultLocalShellContext } from '../domain/localShellHost';
|
||||
import { resolveTerminalChainHosts, resolveTerminalSessionHost } from '../domain/terminalHostResolution';
|
||||
import type { TerminalPopupPayload } from '../domain/systemManager/types';
|
||||
import type { GroupConfig, Host, ProxyProfile, TerminalTheme } from '../domain/models';
|
||||
import type { KnownHost } from '../types';
|
||||
import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import { useDefaultLocalShellContext } from '../lib/useDiscoveredShells';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { AppLockGateRenderContext } from './AppLockGate';
|
||||
import { PluginAuthenticationHost } from './plugins/PluginAuthenticationHost';
|
||||
@@ -235,7 +233,6 @@ export function resolveTerminalPopupHost(
|
||||
groupConfigs?: GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
localOs?: Host['os'];
|
||||
defaultLocalShell?: ResolveDefaultLocalShellContext;
|
||||
} = {},
|
||||
): Host {
|
||||
const resolvedHost = resolveTerminalSessionHost({
|
||||
@@ -244,7 +241,6 @@ export function resolveTerminalPopupHost(
|
||||
groupConfigs: options.groupConfigs ?? [],
|
||||
proxyProfiles: options.proxyProfiles ?? [],
|
||||
localOs: options.localOs ?? 'linux',
|
||||
defaultLocalShell: options.defaultLocalShell,
|
||||
});
|
||||
return applySourceSessionConnectionOverrides(
|
||||
resolvedHost,
|
||||
@@ -370,17 +366,14 @@ function TerminalPopupPageInner({
|
||||
});
|
||||
}, [attachAuthorization, attachSessionId, handleClose, onPrepareClose]);
|
||||
|
||||
const defaultLocalShell = useDefaultLocalShellContext(settings.terminalSettings);
|
||||
|
||||
const host = useMemo(() => {
|
||||
if (!config) return null;
|
||||
return resolveTerminalPopupHost(config, hosts, {
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
localOs: detectLocalOs(navigator.userAgent || navigator.platform),
|
||||
defaultLocalShell,
|
||||
});
|
||||
}, [config, groupConfigs, hosts, proxyProfiles, defaultLocalShell]);
|
||||
}, [config, groupConfigs, hosts, proxyProfiles]);
|
||||
|
||||
const chainHosts = useMemo(() => resolveTerminalChainHosts({
|
||||
host,
|
||||
|
||||
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user