mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee97b508f1 | ||
|
|
e57eeebc1e | ||
|
|
6c2bf43ee5 | ||
|
|
abdf254702 | ||
|
|
35b83d4655 | ||
|
|
b6b575b045 | ||
|
|
3d5cd6b8ec | ||
|
|
71373d1ad1 | ||
|
|
b4e056dd4a | ||
|
|
d5c7d3fbf4 | ||
|
|
1ab07ffee4 | ||
|
|
4af4af274b | ||
|
|
ccfc88cd50 | ||
|
|
602a24a991 | ||
|
|
5df2181392 | ||
|
|
451205770e | ||
|
|
231982d673 | ||
|
|
8eb52853a0 | ||
|
|
11c90b45ec | ||
|
|
ac644a5e51 | ||
|
|
0f890701d4 | ||
|
|
21e6a91f36 | ||
|
|
d3c4273886 | ||
|
|
747fdeb0f3 | ||
|
|
0bb75055c4 | ||
|
|
f82355cf1b | ||
|
|
43249cd7e0 |
No files matched your search
@@ -1202,8 +1202,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Freeze trusted helper
|
||||
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
|
||||
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
|
||||
run: *freeze_ai_helper
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
run: |
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
@@ -398,3 +399,69 @@ 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);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -204,7 +205,10 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
// 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'),
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -1104,11 +1108,15 @@ export function handleCreateLocalTerminalImpl(
|
||||
}
|
||||
|
||||
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hidden = false) {
|
||||
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
|
||||
const { addConnectionLog, connectToHost, discoveredShells, identities, keys, resolveEffectiveHost, resolveHostAuth, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
const effectiveHost = resolveHostDefaultLocalShell(resolveEffectiveHost(host), {
|
||||
discoveredShells,
|
||||
resolveShellSetting,
|
||||
terminalSettings,
|
||||
});
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
@@ -1138,7 +1146,10 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
// 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'),
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -1186,8 +1197,13 @@ 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);
|
||||
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
|
||||
const effectiveProtocol = effective.protocol ?? 'ssh';
|
||||
return effectiveProtocol === 'ssh' && Boolean(effective.telnetEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,17 @@ 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 }),
|
||||
@@ -71,8 +82,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, 2);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
|
||||
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 3);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
test('editor host panels share vault resize width persistence', () => {
|
||||
@@ -81,7 +92,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, 2);
|
||||
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
test('editor stays mounted while another app surface is active', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -22,14 +23,16 @@ import type {
|
||||
} from '../../types';
|
||||
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
|
||||
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial' | 'local';
|
||||
|
||||
export function resolveWorkSurfaceHostEditorKind(
|
||||
target: WorkSurfaceHostEditorTarget,
|
||||
): WorkSurfaceHostEditorKind {
|
||||
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
|
||||
? 'serial'
|
||||
: 'standard';
|
||||
: target.mode === 'edit' && target.openedHost.protocol === 'local'
|
||||
? 'local'
|
||||
: 'standard';
|
||||
}
|
||||
|
||||
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
|
||||
@@ -160,7 +163,18 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
style={getAppHostEditorLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<PortalContainerProvider container={portalContainer}>
|
||||
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
||||
{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' ? (
|
||||
<SerialHostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.openedHost}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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,6 +38,7 @@ 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';
|
||||
@@ -53,6 +54,7 @@ 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';
|
||||
@@ -269,22 +271,6 @@ 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();
|
||||
|
||||
@@ -1366,8 +1352,32 @@ export function AppSideEffects() {
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
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],
|
||||
);
|
||||
|
||||
const createWorkspaceWithEffectiveHosts = useCallback((name: string, selectedHosts: Host[]) => {
|
||||
createWorkspaceWithHosts(name, selectedHosts.map(resolveEffectiveHost));
|
||||
@@ -1398,14 +1408,17 @@ export function AppSideEffects() {
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
@@ -1432,6 +1445,10 @@ 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.` };
|
||||
@@ -1474,6 +1491,7 @@ export function AppSideEffects() {
|
||||
stopTunnel,
|
||||
stopRuleTunnels,
|
||||
openHost: openHostForVaultAgent,
|
||||
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
||||
closeSession: closeSessionForVaultAgent,
|
||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ 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';
|
||||
@@ -354,14 +355,16 @@ 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]);
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds, defaultLocalShell]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
|
||||
@@ -522,6 +522,8 @@ 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',
|
||||
|
||||
@@ -1123,6 +1123,7 @@ 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',
|
||||
|
||||
@@ -1018,6 +1018,21 @@ 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',
|
||||
|
||||
@@ -513,6 +513,8 @@ 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',
|
||||
|
||||
@@ -1081,6 +1081,7 @@ 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',
|
||||
|
||||
@@ -1008,6 +1008,21 @@ 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',
|
||||
|
||||
@@ -469,6 +469,8 @@ 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': 'Перейти к сообщению',
|
||||
|
||||
@@ -102,6 +102,7 @@ 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': 'Экспорт',
|
||||
@@ -1051,6 +1052,21 @@ 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': 'Провайдеры подключения не установлены',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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`);
|
||||
}
|
||||
});
|
||||
@@ -522,6 +522,8 @@ 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': '跳转到消息',
|
||||
|
||||
@@ -740,6 +740,7 @@ 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': '导出',
|
||||
|
||||
@@ -390,6 +390,21 @@ 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': '未安装连接提供程序',
|
||||
|
||||
@@ -512,6 +512,8 @@ 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': '跳轉到訊息',
|
||||
|
||||
@@ -742,6 +742,7 @@ 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': '匯出',
|
||||
|
||||
@@ -390,6 +390,21 @@ 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': '尚未安裝連線提供者',
|
||||
|
||||
@@ -466,6 +466,115 @@ test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
// Just over the 8 KiB cap: the truncation marker must not make the degraded
|
||||
// copy larger than the original, or the ladder could evict a session whose
|
||||
// unmodified JSON would have fit the budget.
|
||||
for (const oversize of [8193, 8266, 20000]) {
|
||||
const session = makeSession('near-cap', 1, [{
|
||||
id: 'tool-message',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(oversize) }],
|
||||
}]);
|
||||
const fullJson = JSON.stringify([session]);
|
||||
const result = serializeSessionsForStorage([session], fullJson.length - 10);
|
||||
const content = result.sessions[0].messages[0].toolResults?.[0].content ?? '';
|
||||
assert.ok(content.length <= 8 * 1024, `cap respected for ${oversize}`);
|
||||
assert.ok(
|
||||
JSON.stringify(result.sessions).length < fullJson.length,
|
||||
`degraded copy smaller than original for ${oversize}`,
|
||||
);
|
||||
assert.ok(content.includes('truncated'));
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage sheds oversized tool output before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const newest = makeSession('newest', 2, [{
|
||||
id: 'new-message',
|
||||
role: 'assistant',
|
||||
content: 'still-open chat',
|
||||
timestamp: 2,
|
||||
}]);
|
||||
const toolBulk = makeSession('tool-bulk', 1, [{
|
||||
id: 'tool-message',
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(400 * 1024) }],
|
||||
}]);
|
||||
|
||||
const result = serializeSessionsForStorage([toolBulk, newest], 100 * 1024);
|
||||
|
||||
// The bloated tool result must not evict the other visible chat.
|
||||
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'tool-bulk']);
|
||||
assert.ok(result.json.length <= 100 * 1024);
|
||||
const persistedToolContent = result.sessions[1].messages[0].toolResults?.[0].content;
|
||||
assert.ok(persistedToolContent);
|
||||
assert.ok(persistedToolContent.length <= 9 * 1024);
|
||||
assert.ok(persistedToolContent.includes('truncated'));
|
||||
assert.ok(persistedToolContent.startsWith('zzz'));
|
||||
assert.equal(result.sessions[0].messages[0].content, 'still-open chat');
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage keeps every chat by shedding tool output under quota pressure', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
let stored: string | undefined;
|
||||
const previousLocalStorage = globalThis.localStorage;
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: () => null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
if (value.length > 300 * 1024) {
|
||||
throw new DOMException('quota exceeded', 'QuotaExceededError');
|
||||
}
|
||||
stored = value;
|
||||
},
|
||||
removeItem: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
|
||||
`session-${index}`,
|
||||
10 - index,
|
||||
[{
|
||||
id: `message-${index}`,
|
||||
role: 'tool' as const,
|
||||
content: '',
|
||||
timestamp: index,
|
||||
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(40 * 1024) }],
|
||||
}],
|
||||
));
|
||||
|
||||
assert.equal(writeSessionsForStorage(sessions), true);
|
||||
assert.ok(stored);
|
||||
assert.ok(stored.length <= 300 * 1024);
|
||||
const persisted = JSON.parse(stored) as AISession[];
|
||||
assert.deepEqual(
|
||||
persisted.map(session => session.id),
|
||||
sessions.map(session => session.id),
|
||||
);
|
||||
// Degradation sheds only as many messages as the budget demands, so at
|
||||
// least one chat keeps its full tool output while the persisted set stays
|
||||
// complete. Assert completion first; truncation second.
|
||||
const shedContents = persisted.map(
|
||||
session => session.messages[0].toolResults?.[0].content ?? '',
|
||||
);
|
||||
assert.ok(shedContents.some(content => content.includes('truncated')));
|
||||
assert.ok(shedContents.some(content => content.startsWith('zzz') && !content.includes('truncated')));
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: previousLocalStorage,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
|
||||
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const writes: string[] = [];
|
||||
|
||||
@@ -195,12 +195,40 @@ const RETRY_SESSIONS_JSON_BYTES = [
|
||||
128 * 1024,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Maximum persisted length of a single tool result payload. Terminal tool
|
||||
* output can reach hundreds of KB per command, and AI-driven terminal work
|
||||
* accumulates many of them. When storage pressure forces degradation, shedding
|
||||
* this replay bulk keeps every visible conversation persisted instead of
|
||||
* evicting whole chats that happen to carry it.
|
||||
*/
|
||||
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
|
||||
|
||||
function truncatePersistedToolResultContent(content: string): string {
|
||||
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
|
||||
// The marker consumes budget and must stay single-line: JSON-escaped
|
||||
// newlines would cost 2 characters each, letting a slightly-over-cap
|
||||
// result serialize at the same (or larger) size than the original — the
|
||||
// ladder assumes the degraded form is the smallest. Shrink the retained
|
||||
// head+tail until the whole persisted copy fits the cap.
|
||||
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
|
||||
let omitted = content.length - 2 * half;
|
||||
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
|
||||
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
|
||||
omitted = content.length - 2 * half;
|
||||
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
}
|
||||
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
|
||||
* continuation. The ciphertext exists so stateless Responses turns can replay
|
||||
* prior reasoning items; it is also by far the largest per-message payload.
|
||||
* When storage pressure forces it, dropping the ciphertext keeps the visible
|
||||
* conversation intact at the cost of reasoning replay for affected messages.
|
||||
* continuation and shed bulk tool output. The ciphertext exists so stateless
|
||||
* Responses turns can replay prior reasoning items; tool output dominates
|
||||
* persisted size in AI-driven terminal chats. When storage pressure forces
|
||||
* it, shedding this bulk keeps the visible conversation intact at the cost of
|
||||
* reasoning replay and full tool output for affected messages.
|
||||
*/
|
||||
function stripReasoningEncryptedContent(
|
||||
options: ProviderContinuationOptions,
|
||||
@@ -218,7 +246,7 @@ function stripReasoningEncryptedContent(
|
||||
return Object.keys(stripped).length ? stripped : undefined;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
|
||||
const continuation = message.providerContinuation;
|
||||
if (!continuation?.reasoningParts) return message;
|
||||
let changed = false;
|
||||
@@ -239,6 +267,22 @@ function stripEncryptedReasoningFromMessage(message: AISession['messages'][numbe
|
||||
};
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
|
||||
const toolResults = strippedContinuationMessage.toolResults;
|
||||
if (!toolResults?.length) return strippedContinuationMessage;
|
||||
let toolResultsChanged = false;
|
||||
const nextToolResults = toolResults.map((result) => {
|
||||
const content = truncatePersistedToolResultContent(result.content);
|
||||
if (content === result.content) return result;
|
||||
toolResultsChanged = true;
|
||||
return { ...result, content };
|
||||
});
|
||||
return toolResultsChanged
|
||||
? { ...strippedContinuationMessage, toolResults: nextToolResults }
|
||||
: strippedContinuationMessage;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
let changed = false;
|
||||
const messages = session.messages.map(message => {
|
||||
@@ -259,7 +303,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
|
||||
let changed = false;
|
||||
const messages = session.messages.map((message, index) => {
|
||||
if (index >= compactedMessageCount) return message;
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
const stripped = stripReasoningCiphertextFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
@@ -310,6 +354,11 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
* sessions that JSON represents.
|
||||
*
|
||||
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
|
||||
* tool output (kept at a bounded head+tail per result), and only then drop
|
||||
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
|
||||
* emptied history for users under storage pressure.
|
||||
*/
|
||||
export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
@@ -321,13 +370,13 @@ export function serializeSessionsForStorage(
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
const ciphertextMessages = session.messages.flatMap((message, index) => {
|
||||
const strippedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (strippedMessage === message) return [];
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
return [{
|
||||
index,
|
||||
strippedMessage,
|
||||
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
|
||||
degradedMessage,
|
||||
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
@@ -339,7 +388,7 @@ export function serializeSessionsForStorage(
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
ciphertextMessages,
|
||||
degradableMessages,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -364,11 +413,12 @@ export function serializeSessionsForStorage(
|
||||
minimalLength -= removed.strippedJson.length + 1;
|
||||
}
|
||||
|
||||
// Then keep full continuation data for the retained sessions and remove
|
||||
// replay-only ciphertext from the oldest retained sessions only as needed,
|
||||
// never touching the protected newest session. Within one session, remove
|
||||
// it one message at a time from oldest to newest so a long active chat can
|
||||
// retain its most recent replayable tool exchange when that still fits.
|
||||
// Then keep full continuation data for the retained sessions and shed
|
||||
// replay-only bulk (ciphertext, oversized tool output) from the oldest
|
||||
// retained sessions only as needed, never touching the protected newest
|
||||
// session. Within one session, shed it one message at a time from oldest to
|
||||
// newest so a long active chat can retain its most recent replayable tool
|
||||
// exchange when that still fits.
|
||||
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
|
||||
@@ -378,27 +428,27 @@ export function serializeSessionsForStorage(
|
||||
index -= 1
|
||||
) {
|
||||
const current = serialized[index];
|
||||
let strippedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
|
||||
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
|
||||
strippedMessageCount += 1;
|
||||
let degradedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
|
||||
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
|
||||
degradedMessageCount += 1;
|
||||
}
|
||||
if (strippedMessageCount > 0) {
|
||||
const strippedMessagesByIndex = new Map(
|
||||
current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
.map(entry => [entry.index, entry.strippedMessage] as const),
|
||||
if (degradedMessageCount > 0) {
|
||||
const degradedMessagesByIndex = new Map(
|
||||
current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.map(entry => [entry.index, entry.degradedMessage] as const),
|
||||
);
|
||||
const nextSession = {
|
||||
...current.session,
|
||||
messages: current.session.messages.map((message, messageIndex) => (
|
||||
strippedMessagesByIndex.get(messageIndex) ?? message
|
||||
degradedMessagesByIndex.get(messageIndex) ?? message
|
||||
)),
|
||||
};
|
||||
const nextJson = JSON.stringify(nextSession);
|
||||
const projectedJsonLength = current.json.length
|
||||
+ current.ciphertextMessages
|
||||
.slice(0, strippedMessageCount)
|
||||
+ current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
||||
jsonLength += nextJson.length - projectedJsonLength;
|
||||
serialized[index] = {
|
||||
|
||||
@@ -8,10 +8,20 @@ import {
|
||||
shouldRevealTerminalPopupOnExit,
|
||||
} from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
test("backend exited events close the session tab only when auto-close is enabled", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true),
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
test("omitted auto-close setting keeps the session tab on a clean exit", () => {
|
||||
// The fallback for callers that have not loaded the terminal setting yet:
|
||||
// keep the tab so a jump host / bastion reaping a session (a clean exit)
|
||||
// never silently deletes the user's scrollback.
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "closeSession" },
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,11 +67,19 @@ test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("administrative exit-signal disconnects keep the tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal popup only auto-closes after clean command exit", () => {
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
|
||||
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
|
||||
});
|
||||
|
||||
test("disabled auto-close keeps command and attached terminal popups open", () => {
|
||||
@@ -133,6 +151,17 @@ test("exit messages show the reported code for a real remote exit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
|
||||
"\r\n[session closed by remote signal]",
|
||||
);
|
||||
});
|
||||
|
||||
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
@@ -150,3 +179,25 @@ test("exit messages without a reported code stay plain instead of showing a sent
|
||||
);
|
||||
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
|
||||
});
|
||||
|
||||
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
|
||||
// A terminal-worker exit broadcast carries the worker's own exit status
|
||||
// (graceful drain = 0). It belongs to the infrastructure failure, not to
|
||||
// any remote shell, so it must never read as "session closed (code 0)".
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({
|
||||
reason: "error",
|
||||
exitCode: 0,
|
||||
error: "Terminal worker exited with code 0",
|
||||
}),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
assert.equal(
|
||||
formatSessionClosedExitMessage({ reason: "error" }),
|
||||
"\r\n[session closed unexpectedly]",
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,10 @@ export type TerminalSessionExitEvent = {
|
||||
exitCode?: number;
|
||||
signal?: number;
|
||||
error?: string;
|
||||
reason?: "exited" | "error" | "timeout" | "closed";
|
||||
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
|
||||
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
|
||||
// terminated the session process. Distinct from a transport "closed".
|
||||
exitSignal?: string;
|
||||
// True when the remote closed the channel without sending exit-status or
|
||||
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
|
||||
// a distinct message instead of a misleading "code 0".
|
||||
@@ -16,6 +19,13 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
|
||||
if (evt?.remoteClosedWithoutExitStatus) {
|
||||
return "\r\n[session closed by remote (no exit status)]";
|
||||
}
|
||||
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
|
||||
return "\r\n[session closed by remote signal]";
|
||||
}
|
||||
// Infrastructure exit codes do not describe the remote shell's status.
|
||||
if (evt?.reason === "error") {
|
||||
return "\r\n[session closed unexpectedly]";
|
||||
}
|
||||
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
|
||||
}
|
||||
|
||||
@@ -34,7 +44,11 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
evt: TerminalSessionExitEvent,
|
||||
autoCloseOnExit = true,
|
||||
// Keep-tab is the safe default: a caller that has not loaded the terminal
|
||||
// setting yet must not silently delete a session tab. Remote graceful
|
||||
// channel closes (e.g. a jump host reaping an idle session) are reported
|
||||
// as a clean exit, so an unavailable setting must not re-enable closing.
|
||||
autoCloseOnExit = false,
|
||||
): TerminalSessionExitIntent {
|
||||
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
|
||||
return { kind: "closeSession" };
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
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");
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
export interface LocalTerminalOptions {
|
||||
shellType?: TerminalSession["shellType"];
|
||||
@@ -18,6 +19,28 @@ 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,
|
||||
@@ -113,6 +136,30 @@ 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 } : {}),
|
||||
};
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
resolveReadableForegroundForHsl,
|
||||
resolveThemeAccentForeground,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
} from "./settingsStateDefaults.ts";
|
||||
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
|
||||
import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts";
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from "../../infrastructure/config/storageKeys.ts";
|
||||
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
|
||||
|
||||
function installMemoryLocalStorage(): Map<string, string> {
|
||||
@@ -140,3 +145,72 @@ test("app surface theme vars isolate non-terminal pages from active terminal chr
|
||||
"--ring": "270 70% 45%",
|
||||
});
|
||||
});
|
||||
|
||||
test("migrateMaterializedAutoCloseOnExitDefault resets the materialized default-on once", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 3000, autoCloseOnExit: true }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(migrated.autoCloseOnExit, false);
|
||||
// Other saved settings pass through untouched.
|
||||
assert.equal(migrated.scrollback, 3000);
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedAutoCloseOnExitDefault runs once and respects later explicit edits", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: true }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.equal(
|
||||
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
|
||||
false,
|
||||
);
|
||||
// The user re-enables after upgrading: the migration must not undo it again.
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: true }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.equal(
|
||||
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explicit-off alone", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
// Fresh install: no saved settings at all.
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
|
||||
|
||||
// Explicit-off (or any settings blob without the key) is never rewritten.
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const store2 = installMemoryLocalStorage();
|
||||
store2.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 500 }),
|
||||
);
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
assert.deepEqual(JSON.parse(store2.get(STORAGE_KEY_TERM_SETTINGS)!), {
|
||||
scrollback: 500,
|
||||
});
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
|
||||
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isDeprecatedPrimaryFontId,
|
||||
TERMINAL_FONT_AUTO,
|
||||
@@ -63,6 +67,30 @@ export function migrateIncomingTerminalFontId(raw: string | null | undefined): s
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
/**
|
||||
* One-time migration for the autoCloseOnExit default flip (tabs now survive
|
||||
* session exits by default; the old default-on silently deleted tabs whenever
|
||||
* a bastion or idle-harvesting server reported a clean exit). Builds before
|
||||
* the flip shipped autoCloseOnExit: true as the default and materialized it
|
||||
* into STORAGE_KEY_TERM_SETTINGS on any settings write, so most upgraders
|
||||
* carry a value they never chose. Leaving it in place would make the new
|
||||
* default unreachable for them. Reset that materialized `true` exactly once
|
||||
* and persist the marker; afterwards every stored value is treated as the
|
||||
* user's own choice and is respected. An inbound cloud or backup payload that
|
||||
* still carries the pre-migration materialized true (no
|
||||
* autoCloseOnExitDefaultMigrated flag) is rewritten to false so sync cannot
|
||||
* resurrect auto-close. The cost: a user who deliberately enabled auto-close
|
||||
* before this release must re-enable it once.
|
||||
*/
|
||||
export function migrateMaterializedAutoCloseOnExitDefault(): void {
|
||||
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) return;
|
||||
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (stored?.autoCloseOnExit === true) {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, { ...stored, autoCloseOnExit: false });
|
||||
}
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
|
||||
}
|
||||
|
||||
// Auto-detect default hotkey scheme based on platform
|
||||
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { UploadController } from "../../../lib/uploadService";
|
||||
import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService";
|
||||
import {
|
||||
cancelExternalUploadRuntime,
|
||||
getExternalUploadController,
|
||||
@@ -37,3 +37,64 @@ test("external upload controls survive their originating panel unmount", async (
|
||||
assert.equal(getExternalUploadController("folder-1"), undefined);
|
||||
assert.equal(getExternalUploadController("child-1"), undefined);
|
||||
});
|
||||
|
||||
for (const selectedFile of ["a.bin", "c.bin"]) {
|
||||
test(`cancelling independent ${selectedFile} preserves its active and queued upload siblings`, async (t) => {
|
||||
resetExternalUploadRuntimeForTests();
|
||||
t.after(resetExternalUploadRuntimeForTests);
|
||||
const controller = new UploadController();
|
||||
const cancelled: string[] = [];
|
||||
const stagedCancelled: string[] = [];
|
||||
const ids = new Map<string, string>();
|
||||
const pending = new Map<string, (cancelled?: boolean) => void>();
|
||||
const started: string[] = [];
|
||||
const upload = uploadEntriesDirect(
|
||||
["a.bin", "b.bin", "c.bin"].map((name) => ({
|
||||
file: null, relativePath: name, isDirectory: false, localPath: `/local/${name}`, size: 8,
|
||||
})),
|
||||
{
|
||||
targetPath: "/remote", sftpId: "fixture", isLocal: false,
|
||||
fileTransferConcurrency: 2, joinPath: (base, name) => `${base}/${name}`,
|
||||
callbacks: {
|
||||
onTaskCreated: (task) => {
|
||||
ids.set(task.fileName, task.id);
|
||||
registerExternalUploadController(task.id, controller, "file");
|
||||
},
|
||||
},
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
cancelStagedUploadFile: async (id) => { stagedCancelled.push(id); },
|
||||
cancelTransfer: async (id) => { cancelled.push(id); pending.get(id)?.(true); },
|
||||
startStreamTransfer: ({ transferId, sourcePath }) => new Promise((resolve) => {
|
||||
started.push(sourcePath);
|
||||
pending.set(transferId, (cancelled = false) => resolve({ transferId, cancelled }));
|
||||
}),
|
||||
},
|
||||
},
|
||||
controller,
|
||||
);
|
||||
t.after(async () => {
|
||||
await controller.cancel();
|
||||
for (const finish of pending.values()) finish(true);
|
||||
await upload;
|
||||
});
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(started, ["/local/a.bin", "/local/b.bin"]);
|
||||
const selectedId = ids.get(selectedFile)!;
|
||||
await cancelExternalUploadRuntime(selectedId);
|
||||
assert.deepEqual(cancelled, [selectedId]);
|
||||
assert.deepEqual(stagedCancelled, [selectedId]);
|
||||
assert.equal(controller.isCancelled(), false);
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(started, selectedFile === "a.bin"
|
||||
? ["/local/a.bin", "/local/b.bin", "/local/c.bin"]
|
||||
: ["/local/a.bin", "/local/b.bin"]);
|
||||
for (const [id, finish] of pending) if (id !== selectedId) finish();
|
||||
const results = (await upload).sort((a, b) => a.fileName.localeCompare(b.fileName));
|
||||
assert.deepEqual(results, ["a.bin", "b.bin", "c.bin"].map((fileName) => (
|
||||
fileName === selectedFile
|
||||
? { fileName, success: false, cancelled: true }
|
||||
: { fileName, success: true }
|
||||
)));
|
||||
});
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import type { UploadController } from "../../../lib/uploadService";
|
||||
// terminal only removes the view; the global transfer runtime can still cancel
|
||||
// the upload, and the upload's own finally block releases these entries.
|
||||
const controllersByTaskId = new Map<string, UploadController>();
|
||||
const scopesByTaskId = new Map<string, "file" | "batch">();
|
||||
const taskIdsByController = new Map<UploadController, Set<string>>();
|
||||
|
||||
export function registerExternalUploadController(
|
||||
taskId: string,
|
||||
controller: UploadController,
|
||||
scope: "file" | "batch" = "batch",
|
||||
): void {
|
||||
if (!taskId) return;
|
||||
const previous = controllersByTaskId.get(taskId);
|
||||
@@ -19,6 +21,7 @@ export function registerExternalUploadController(
|
||||
if (previousIds?.size === 0) taskIdsByController.delete(previous);
|
||||
}
|
||||
controllersByTaskId.set(taskId, controller);
|
||||
scopesByTaskId.set(taskId, scope);
|
||||
const taskIds = taskIdsByController.get(controller) ?? new Set<string>();
|
||||
taskIds.add(taskId);
|
||||
taskIdsByController.set(controller, taskIds);
|
||||
@@ -30,6 +33,7 @@ export function unregisterExternalUploadController(controller: UploadController)
|
||||
for (const taskId of taskIds) {
|
||||
if (controllersByTaskId.get(taskId) === controller) {
|
||||
controllersByTaskId.delete(taskId);
|
||||
scopesByTaskId.delete(taskId);
|
||||
}
|
||||
}
|
||||
taskIdsByController.delete(controller);
|
||||
@@ -40,6 +44,10 @@ export function getExternalUploadController(taskId: string): UploadController |
|
||||
}
|
||||
|
||||
export async function cancelExternalUploadRuntime(taskId?: string): Promise<boolean> {
|
||||
if (taskId && scopesByTaskId.get(taskId) === "file") {
|
||||
await controllersByTaskId.get(taskId)?.cancelFile(taskId);
|
||||
return true;
|
||||
}
|
||||
const controllers = taskId
|
||||
? [controllersByTaskId.get(taskId)].filter((value): value is UploadController => !!value)
|
||||
: [...taskIdsByController.keys()];
|
||||
@@ -50,5 +58,6 @@ export async function cancelExternalUploadRuntime(taskId?: string): Promise<bool
|
||||
|
||||
export function resetExternalUploadRuntimeForTests(): void {
|
||||
controllersByTaskId.clear();
|
||||
scopesByTaskId.clear();
|
||||
taskIdsByController.clear();
|
||||
}
|
||||
@@ -201,8 +201,8 @@ export const useSftpExternalOperations = (
|
||||
return { sftpId, release: () => {} };
|
||||
}, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]);
|
||||
|
||||
const registerUploadController = useCallback((taskId: string, controller: UploadController) => {
|
||||
registerExternalUploadController(taskId, controller);
|
||||
const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => {
|
||||
registerExternalUploadController(taskId, controller, scope);
|
||||
}, []);
|
||||
|
||||
const unregisterUploadController = useCallback((controller: UploadController) => {
|
||||
@@ -222,7 +222,7 @@ export const useSftpExternalOperations = (
|
||||
callbacks.onScanningProgress?.(taskId, progress);
|
||||
},
|
||||
onTaskCreated: (task) => {
|
||||
registerUploadController(task.id, controller);
|
||||
registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch");
|
||||
if (task.parentTaskId) {
|
||||
registerUploadController(task.parentTaskId, controller);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,52 @@ test("normal shell exits do not auto reconnect", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => {
|
||||
for (const evt of [
|
||||
{ reason: "timeout" as const },
|
||||
{ reason: "timeout" as const, exitCode: 0 },
|
||||
{ reason: "timeout" as const, error: "Connection timeout to example.com" },
|
||||
{ reason: "signaled" as const, exitSignal: "SIGHUP" },
|
||||
{ reason: "signaled" as const, exitSignal: "SIGTERM" },
|
||||
{ reason: "signaled" as const },
|
||||
// A path that still says "closed" but plumbed the exit-signal is the same kick.
|
||||
{ reason: "closed" as const, exitSignal: "SIGUSR2" },
|
||||
{ reason: "closed" as const, exitSignal: " SIGHUP " },
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt,
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("transport drops still auto reconnect", () => {
|
||||
for (const evt of [
|
||||
{ reason: "closed" as const },
|
||||
{ reason: "error" as const, error: "connection reset" },
|
||||
{ reason: "closed" as const, remoteClosedWithoutExitStatus: true },
|
||||
{ reason: "closed" as const, exitSignal: "" },
|
||||
{ reason: "closed" as const, exitSignal: " " },
|
||||
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "" },
|
||||
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" },
|
||||
]) {
|
||||
assert.equal(
|
||||
shouldAutoReconnectAfterExit({
|
||||
evt,
|
||||
host: sshHost,
|
||||
terminalSettings: { sshAutoReconnectEnabled: true },
|
||||
hasEverConnected: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
|
||||
const variants = [
|
||||
{ protocol: "local" as const, hostname: "localhost" },
|
||||
|
||||
@@ -40,7 +40,16 @@ export function shouldAutoReconnectAfterExit({
|
||||
if (!hasEverConnected) return false;
|
||||
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
|
||||
if (!isAutoReconnectableSshHost(host)) return false;
|
||||
return evt.reason !== "exited";
|
||||
// "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout
|
||||
// (or a connect timeout). "signaled" — or a plumbed exit-signal on an
|
||||
// otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM).
|
||||
// Reconnecting would fight that policy. Transport "error" and bare "closed"
|
||||
// still retry.
|
||||
if (evt.reason === "error") return true;
|
||||
if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") {
|
||||
return false;
|
||||
}
|
||||
return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0;
|
||||
}
|
||||
|
||||
export function shouldContinueAutoReconnectAfterFailure({
|
||||
|
||||
@@ -224,3 +224,31 @@ 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,4 +1,5 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
type SftpReuseCandidate = Pick<
|
||||
TerminalSession,
|
||||
@@ -44,7 +45,12 @@ function getClonedShellType(
|
||||
session: TerminalSession,
|
||||
localShellType?: TerminalSession["shellType"],
|
||||
): TerminalSession["shellType"] {
|
||||
return session.protocol === "local" ? localShellType : session.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;
|
||||
}
|
||||
|
||||
function createTerminalSessionClone(
|
||||
|
||||
@@ -86,6 +86,11 @@ import {
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
seedWorkspaceAIActiveSessionFromMembers,
|
||||
} from '../../domain/workspaceAiScopeHandoff';
|
||||
import {
|
||||
buildUndoLastTurnBranch,
|
||||
type UndoLastTurnRestoredDraft,
|
||||
} from '../../domain/aiUndoLastTurn';
|
||||
|
||||
|
||||
function providerPatchIsNoop(
|
||||
current: ProviderConfig,
|
||||
@@ -697,6 +702,32 @@ 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): 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;
|
||||
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) {
|
||||
@@ -1275,6 +1306,7 @@ export function useAIState() {
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
@@ -1335,6 +1367,7 @@ export function useAIState() {
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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 { 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();
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
resetExternalMcpStartupSyncOnceForTests,
|
||||
shouldStartExternalMcpOnStartup,
|
||||
shouldWaitForExternalMcpStartupReady,
|
||||
syncExternalMcpConfig,
|
||||
syncExternalMcpStartupState,
|
||||
syncExternalMcpStartupStateOnce,
|
||||
waitForExternalMcpStartupReady,
|
||||
@@ -63,10 +67,44 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
|
||||
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
|
||||
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
|
||||
});
|
||||
|
||||
for (const savedMinutes of [null, 30]) {
|
||||
it(`syncs the session idle timeout without replacing saved value ${savedMinutes}`, async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
const manager = createSessionIdleManager();
|
||||
try {
|
||||
assert.equal(manager.getTimeoutMinutes(), 8 * 60, 'main-process default');
|
||||
if (savedMinutes !== null) {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, savedMinutes);
|
||||
}
|
||||
const expected = savedMinutes ?? manager.getTimeoutMinutes();
|
||||
const received: number[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
|
||||
savedMinutes,
|
||||
'sync does not migrate or overwrite the stored preference',
|
||||
);
|
||||
} finally {
|
||||
manager.clearAll();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('only starts on launch for persistent+enabled', () => {
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
|
||||
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
|
||||
|
||||
@@ -18,7 +18,7 @@ export type ExternalMcpMode = 'temporary' | 'persistent';
|
||||
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
|
||||
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
|
||||
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
|
||||
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
|
||||
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
|
||||
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
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,17 +1098,12 @@ export const useSessionState = ({
|
||||
if (targetHosts.length === 0) return;
|
||||
const resolvedCommand = commandOverride ?? snippet.command;
|
||||
|
||||
// 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
|
||||
}));
|
||||
// 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),
|
||||
);
|
||||
|
||||
const sessionIds = newSessions.map(s => s.id);
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ import {
|
||||
isValidUiFontId,
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
readStoredString,
|
||||
serializeTerminalSettings,
|
||||
type HostClickBehavior,
|
||||
@@ -355,6 +356,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
passwordVerifier: null,
|
||||
});
|
||||
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
|
||||
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
|
||||
// it is ingested; afterwards stored values are the user's own and win.
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return normalizeTerminalSettings(stored);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ 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;
|
||||
}
|
||||
@@ -271,6 +272,7 @@ 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,
|
||||
|
||||
@@ -1667,6 +1667,50 @@ test("terminal auto-close preference survives sync round-trip", async () => {
|
||||
assert.equal(restored.autoCloseOnExit, false);
|
||||
});
|
||||
|
||||
test("inbound sync does not resurrect the materialized auto-close default after the marker", async () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, "true");
|
||||
try {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
|
||||
);
|
||||
|
||||
await applySyncPayload({
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
settings: { terminalSettings: { autoCloseOnExit: true } },
|
||||
}, { importVaultData: () => {} });
|
||||
|
||||
const resurrected = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(resurrected.autoCloseOnExit, false);
|
||||
assert.equal(resurrected.scrollback, 3000);
|
||||
|
||||
// An explicit opt-in made after the migration round-trips with a marker.
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: true, scrollback: 3000 }),
|
||||
);
|
||||
const optedIn = buildSyncPayload(vault());
|
||||
assert.equal(optedIn.settings?.autoCloseOnExitDefaultMigrated, true);
|
||||
assert.equal(optedIn.settings?.terminalSettings?.autoCloseOnExit, true);
|
||||
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
|
||||
);
|
||||
await applySyncPayload(optedIn, { importVaultData: () => {} });
|
||||
const restoredChoice = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(restoredChoice.autoCloseOnExit, true);
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
|
||||
} finally {
|
||||
localStorage.removeItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED);
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal disconnected notice preference survives sync round-trip", async () => {
|
||||
localStorage.setItem(
|
||||
storageKeys.STORAGE_KEY_TERM_SETTINGS,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
SYNC_PAYLOAD_ENTITY_KEYS,
|
||||
SYNC_STORAGE_KEYS,
|
||||
hasSyncPayloadEntityData,
|
||||
reconcileInboundAutoCloseOnExit,
|
||||
retainLocalHostLastConnectedAt,
|
||||
sanitizeHostsForSync,
|
||||
type SyncPayload,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
} from './state/customAccentSync';
|
||||
import { isValidHslToken } from './state/settingsStateDefaults';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
@@ -505,6 +507,9 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
|
||||
} catch { /* ignore corrupt data */ }
|
||||
}
|
||||
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) {
|
||||
settings.autoCloseOnExitDefaultMigrated = true;
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
|
||||
@@ -795,6 +800,23 @@ async function applySyncableSettings(
|
||||
? 'disabled'
|
||||
: 'paste';
|
||||
}
|
||||
if ('autoCloseOnExit' in settings.terminalSettings) {
|
||||
merged.autoCloseOnExit = reconcileInboundAutoCloseOnExit(
|
||||
settings.terminalSettings.autoCloseOnExit,
|
||||
settings.autoCloseOnExitDefaultMigrated,
|
||||
);
|
||||
}
|
||||
// A legacy true is the old default. A migrated peer's explicit choice must
|
||||
// stick, so record the marker before the next local migration pass.
|
||||
if (
|
||||
settings.autoCloseOnExitDefaultMigrated === true
|
||||
|| (
|
||||
settings.terminalSettings.autoCloseOnExit === true
|
||||
&& settings.autoCloseOnExitDefaultMigrated !== true
|
||||
)
|
||||
) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { type Dispatch, type SetStateAction } from 'react';
|
||||
import { History, Plus } from 'lucide-react';
|
||||
import { History, Plus, Undo2 } 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';
|
||||
@@ -55,6 +55,9 @@ 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;
|
||||
@@ -132,6 +135,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
setInputValue,
|
||||
handleSend,
|
||||
handleCompact,
|
||||
canUndoLastTurn = false,
|
||||
handleUndoLastTurn,
|
||||
canCompact = false,
|
||||
handleSteer,
|
||||
handleStop,
|
||||
@@ -232,6 +237,23 @@ 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}
|
||||
|
||||
@@ -120,7 +120,9 @@ 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');
|
||||
assert.match(panel, /sending=\{isSending\}/);
|
||||
// `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(content, /disabled=\{sending\}/);
|
||||
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
|
||||
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
|
||||
|
||||
@@ -65,6 +65,7 @@ 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';
|
||||
@@ -269,6 +270,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
@@ -319,6 +321,11 @@ 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[] }>>({});
|
||||
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
|
||||
// Tracks SDK runtime model catalogs currently loading (per agent scope):
|
||||
@@ -821,6 +828,78 @@ 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);
|
||||
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,
|
||||
]);
|
||||
|
||||
const providersRef = useRef(providers);
|
||||
providersRef.current = providers;
|
||||
|
||||
@@ -1177,8 +1256,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
|
||||
const canSendCurrentAgent = useMemo(
|
||||
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
|
||||
[inputAgentId, externalAgents, isSending],
|
||||
() => !isSending && !isUndoingLastTurn && canSendWithAgent(inputAgentId, externalAgents),
|
||||
[inputAgentId, externalAgents, isSending, isUndoingLastTurn],
|
||||
);
|
||||
|
||||
const handleAgentModelSelect = useCallback((modelId: string) => {
|
||||
@@ -1314,6 +1393,10 @@ 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.
|
||||
@@ -1438,6 +1521,7 @@ 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() });
|
||||
@@ -1452,6 +1536,7 @@ 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() });
|
||||
@@ -1465,6 +1550,8 @@ 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() });
|
||||
@@ -1546,7 +1633,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [
|
||||
validateNoteMentions, loadCodexConfigModel,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope,
|
||||
@@ -1834,7 +1921,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
>
|
||||
<AIChatPanelContent
|
||||
parked={!isVisible}
|
||||
sending={isSending}
|
||||
sending={isSending || isUndoingLastTurn}
|
||||
t={t}
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
@@ -1864,6 +1951,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleCompact={handleCompact}
|
||||
canUndoLastTurn={canUndoLastTurn}
|
||||
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
|
||||
handleSteer={handleSteer}
|
||||
handleStop={handleStop}
|
||||
canSteer={canSteerCurrentTurn}
|
||||
|
||||
@@ -37,6 +37,10 @@ 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,
|
||||
) => 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;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
formatProxyConfigType,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
|
||||
import {
|
||||
EnvVar,
|
||||
GroupConfig,
|
||||
@@ -333,7 +334,12 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const availableHostsForChain = useMemo(() => {
|
||||
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
||||
return allHosts.filter((h) => !chainedIds.has(h.id));
|
||||
// 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),
|
||||
);
|
||||
}, [allHosts, form.hostChain?.hostIds]);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ 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,
|
||||
@@ -749,7 +750,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const availableHostsForChain = useMemo(() => {
|
||||
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
||||
return allHosts.filter((h) => h.id !== form.id && !chainedIds.has(h.id));
|
||||
// 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),
|
||||
);
|
||||
}, [allHosts, form.id, form.hostChain?.hostIds]);
|
||||
|
||||
const chainedHosts = useMemo(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -726,6 +727,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
initialTreeItemKey,
|
||||
onActiveTreeItemChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const displayDetails = useMemo(
|
||||
@@ -843,7 +845,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<HostNotesIndicator notes={host.notes} />
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
{displayProtocol === 'local'
|
||||
? getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })
|
||||
: `${displayUsername}@${host.hostname}:${displayPort}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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 \?\?/);
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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,6 +30,8 @@ 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> = ({
|
||||
@@ -56,6 +58,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
allowLocalHosts = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [newHostPanelOpen, setNewHostPanelOpen] = useState(false);
|
||||
@@ -103,6 +106,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onNewHostPanelOpenChange={setNewHostPanelOpen}
|
||||
allowLocalHosts={allowLocalHosts}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</AsidePanel>
|
||||
|
||||
@@ -58,6 +58,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -85,6 +87,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
onNewHostPanelOpenChange,
|
||||
allowLocalHosts = false,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -106,9 +109,14 @@ 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'),
|
||||
[hosts],
|
||||
() => hosts.filter((host) => host.protocol !== 'serial' && (allowLocalHosts || host.protocol !== 'local')),
|
||||
[hosts, allowLocalHosts],
|
||||
);
|
||||
const selectedHostIdSet = useMemo(
|
||||
() => new Set(selectedHostIds),
|
||||
|
||||
@@ -132,6 +132,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
customGroups={customGroups}
|
||||
selectedHostIds={targetSelection}
|
||||
multiSelect={true}
|
||||
allowLocalHosts={true}
|
||||
onSelect={handleTargetSelect}
|
||||
onSelectionChange={handleTargetSelectionChange}
|
||||
onBack={handleTargetPickerBack}
|
||||
|
||||
@@ -50,6 +50,7 @@ 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';
|
||||
@@ -182,7 +183,7 @@ function buildScriptSessionMeta(
|
||||
connected: session.status === 'connected',
|
||||
name: session.customName || session.hostLabel || host?.label,
|
||||
hostname: host?.hostname ?? session.hostname,
|
||||
username: host?.username ?? session.username,
|
||||
username: session.protocol === 'local' ? session.username : host?.username ?? session.username,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -669,7 +670,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
const intent = resolveTerminalSessionExitIntent(
|
||||
evt,
|
||||
terminalSettings?.autoCloseOnExit ?? true,
|
||||
// No terminal settings loaded yet -> do not close the tab; auto-close is
|
||||
// opt-in via Settings -> Terminal -> Behavior (DEFAULT_TERMINAL_SETTINGS).
|
||||
terminalSettings?.autoCloseOnExit ?? false,
|
||||
);
|
||||
if (intent.kind === "closeSession") {
|
||||
handleCloseSession(sessionId);
|
||||
@@ -1193,14 +1196,16 @@ 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],
|
||||
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles, defaultLocalShell],
|
||||
);
|
||||
|
||||
// Pre-compute fallback hosts to avoid creating new objects on every render
|
||||
@@ -1213,11 +1218,12 @@ 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]);
|
||||
}, [sessions, hosts, groupConfigs, proxyProfiles, defaultLocalShell]);
|
||||
const resolvedSessionHostIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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');
|
||||
@@ -53,6 +54,36 @@ 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' })), []);
|
||||
|
||||
@@ -162,7 +193,7 @@ test('attach popup close preparation has a bounded timeout', () => {
|
||||
test('terminal exit auto-close setting reaches tabs, workspaces, and popups', () => {
|
||||
assert.match(
|
||||
terminalLayerSource,
|
||||
/resolveTerminalSessionExitIntent\(\s+evt,\s+terminalSettings\?\.autoCloseOnExit \?\? true,\s+\)/,
|
||||
/resolveTerminalSessionExitIntent\(\s+evt,[\s\S]*?terminalSettings\?\.autoCloseOnExit \?\? false,\s+\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
|
||||
@@ -12,12 +12,14 @@ 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';
|
||||
@@ -233,6 +235,7 @@ export function resolveTerminalPopupHost(
|
||||
groupConfigs?: GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
localOs?: Host['os'];
|
||||
defaultLocalShell?: ResolveDefaultLocalShellContext;
|
||||
} = {},
|
||||
): Host {
|
||||
const resolvedHost = resolveTerminalSessionHost({
|
||||
@@ -241,6 +244,7 @@ export function resolveTerminalPopupHost(
|
||||
groupConfigs: options.groupConfigs ?? [],
|
||||
proxyProfiles: options.proxyProfiles ?? [],
|
||||
localOs: options.localOs ?? 'linux',
|
||||
defaultLocalShell: options.defaultLocalShell,
|
||||
});
|
||||
return applySourceSessionConnectionOverrides(
|
||||
resolvedHost,
|
||||
@@ -366,14 +370,17 @@ 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]);
|
||||
}, [config, groupConfigs, hosts, proxyProfiles, defaultLocalShell]);
|
||||
|
||||
const chainHosts = useMemo(() => resolveTerminalChainHosts({
|
||||
host,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import ts from "typescript";
|
||||
import { upsertHostById } from "../domain/host";
|
||||
import { saveWorkSurfaceHostDraft } from "../application/state/useWorkSurfaceHostEditor";
|
||||
import { createLocalShellHost } from "../domain/localShellHost";
|
||||
import type { Host } from "../types";
|
||||
|
||||
// Execute the actual Vault save callback with its persistence boundary stubbed.
|
||||
const source = readFileSync(new URL("./VaultView.tsx", import.meta.url), "utf8");
|
||||
const callback = source.match(/const handleSaveLocalShellHost = useCallback\((\(host: Host, connectNow\?: boolean\) => \{[\s\S]*?)\}, \[/)?.[1];
|
||||
assert.ok(callback);
|
||||
const compiled = ts.transpile(`const save = ${callback}};`, { target: ts.ScriptTarget.ES2022 });
|
||||
|
||||
for (const editing of [false, true]) {
|
||||
for (const connectNow of [false, true]) {
|
||||
test(`local shell save: editing=${editing}, connect=${connectNow}`, () => {
|
||||
const opened = createLocalShellHost({ id: "local-1", label: "old", tags: ["old"], notes: "clear me" });
|
||||
const latest = { ...opened, showLineTimestamps: true, lastConnectedAt: 42 };
|
||||
const draft = { ...opened, tags: ["edited"], notes: undefined };
|
||||
let saved: Host[] = [];
|
||||
let connected: Host | undefined;
|
||||
// Mirror useVaultState.updateHosts: hostsRef holds the freshest host
|
||||
// array and accepts an array or functional updater argument.
|
||||
const hostsRef: { current: Host[] } = { current: editing ? [latest] : [] };
|
||||
const save = new Function(
|
||||
"hosts", "editingLocalShellHost", "onUpdateHosts", "onConnect",
|
||||
"setIsLocalShellHostPanelOpen", "setEditingLocalShellHost",
|
||||
"upsertHostById", "saveWorkSurfaceHostDraft",
|
||||
`${compiled} return save;`,
|
||||
)(
|
||||
editing ? [latest] : [], editing ? opened : null,
|
||||
(data: Host[] | ((prev: Host[]) => Host[])) => {
|
||||
const next = typeof data === "function" ? data(hostsRef.current) : data;
|
||||
if (next) hostsRef.current = next;
|
||||
saved = next ?? hostsRef.current;
|
||||
},
|
||||
(host: Host) => { connected = host; },
|
||||
() => {}, () => {}, upsertHostById, saveWorkSurfaceHostDraft,
|
||||
);
|
||||
save(draft, connectNow);
|
||||
assert.equal(saved.length, 1);
|
||||
assert.deepEqual(saved[0], editing ? { ...latest, tags: ["edited"], notes: undefined } : draft);
|
||||
assert.deepEqual(connected, connectNow ? saved[0] : undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Saving must apply the draft against the freshest host array, not the array
|
||||
// captured at render time — a concurrent vault write between render and Save
|
||||
// would otherwise be replaced wholesale by the stale snapshot.
|
||||
test("local shell save: updater merges against the latest host array", () => {
|
||||
const opened = createLocalShellHost({ id: "local-1", label: "old", tags: ["old"] });
|
||||
const draft = { ...opened, tags: ["edited"] };
|
||||
// A concurrent write lands after the render that captured `hosts`.
|
||||
const concurrent = createLocalShellHost({ id: "local-2", label: "later" });
|
||||
const renderedHosts = [opened];
|
||||
const actualHosts = [...renderedHosts, concurrent];
|
||||
// Mirror useVaultState.updateHosts: hostsRef holds the freshest host array
|
||||
// (including the concurrent write) and accepts an array or updater argument.
|
||||
let saved: Host[] = [];
|
||||
const hostsRef: { current: Host[] } = { current: actualHosts };
|
||||
const save = new Function(
|
||||
"hosts", "editingLocalShellHost", "onUpdateHosts", "onConnect",
|
||||
"setIsLocalShellHostPanelOpen", "setEditingLocalShellHost",
|
||||
"upsertHostById", "saveWorkSurfaceHostDraft",
|
||||
`${compiled} return save;`,
|
||||
)(
|
||||
renderedHosts, opened,
|
||||
(data: Host[] | ((prev: Host[]) => Host[])) => {
|
||||
const next = typeof data === "function" ? data(hostsRef.current) : data;
|
||||
if (next) hostsRef.current = next;
|
||||
saved = next ?? hostsRef.current;
|
||||
},
|
||||
() => {}, () => {}, () => {}, upsertHostById, saveWorkSurfaceHostDraft,
|
||||
);
|
||||
save(draft, false);
|
||||
assert.equal(saved.length, 2, "concurrently added host must survive the save");
|
||||
assert.ok(saved.some((host) => host.id === "local-2"));
|
||||
assert.ok(saved.some((host) => host.id === "local-1" && host.tags.includes("edited")));
|
||||
});
|
||||
|
||||
// Duplicating a local-shell host opens its copy under a newly generated ID
|
||||
// that is not in the vault yet; it must be saved as a new entry, not as an
|
||||
// edit (which would no-op and silently discard the duplicate).
|
||||
test("local shell save: duplicated host with a new id saves as a new entry", () => {
|
||||
const opened = createLocalShellHost({ label: "cmd (copy)" });
|
||||
const draft = { ...opened, notes: "tweaked" };
|
||||
let saved: Host[] = [];
|
||||
let connected: Host | undefined;
|
||||
const save = new Function(
|
||||
"hosts", "editingLocalShellHost", "onUpdateHosts", "onConnect",
|
||||
"setIsLocalShellHostPanelOpen", "setEditingLocalShellHost",
|
||||
"upsertHostById", "saveWorkSurfaceHostDraft",
|
||||
`${compiled} return save;`,
|
||||
)(
|
||||
[], opened,
|
||||
// Mirror useVaultState.updateHosts: accepts an array or a functional
|
||||
// updater applied against the freshest host array (here, the vault is
|
||||
// empty at render time and stays empty in prev too).
|
||||
(data: Host[] | ((prev: Host[]) => Host[])) => { saved = typeof data === "function" ? data(saved) : data; },
|
||||
(host: Host) => { connected = host; },
|
||||
() => {}, () => {}, upsertHostById, saveWorkSurfaceHostDraft,
|
||||
);
|
||||
save(draft, false);
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].id, draft.id);
|
||||
assert.equal(saved[0].notes, "tweaked");
|
||||
assert.equal(connected, undefined);
|
||||
});
|
||||
+101
-12
@@ -73,6 +73,7 @@ import {
|
||||
upsertHostById,
|
||||
} from "../domain/host";
|
||||
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import { saveWorkSurfaceHostDraft, type WorkSurfaceHostEditorTarget } from "../application/state/useWorkSurfaceHostEditor";
|
||||
import { upsertKnownHost } from "../domain/knownHosts";
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
@@ -238,7 +239,9 @@ interface VaultViewProps {
|
||||
onDeleteHost: (id: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenHostFromNote?: (host: Host, source?: { noteId: string }) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||
onUpdateHosts: (
|
||||
hosts: Host[] | ((prev: Host[]) => Host[]),
|
||||
) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||
onReadPersistedHosts: () => Promise<Host[]>;
|
||||
onUpdateKeys: (keys: SSHKey[]) => void;
|
||||
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
|
||||
@@ -458,6 +461,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [isHostPanelOpen, setIsHostPanelOpen] = useState(false);
|
||||
const [editingHost, setEditingHost] = useState<Host | null>(null);
|
||||
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
|
||||
// Local shell (CMD) host panel state — a vault host entry with protocol 'local'
|
||||
const [isLocalShellHostPanelOpen, setIsLocalShellHostPanelOpen] = useState(false);
|
||||
const [editingLocalShellHost, setEditingLocalShellHost] = useState<Host | null>(null);
|
||||
const [pendingOpenNoteId, setPendingOpenNoteId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -473,8 +479,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setCurrentSection("hosts");
|
||||
setSelectedGroupPath(null);
|
||||
setNewHostGroupPath(null);
|
||||
setEditingHost(deepLinkHostDraft);
|
||||
setIsHostPanelOpen(true);
|
||||
if (deepLinkHostDraft.protocol === "local") {
|
||||
setEditingHost(null);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingLocalShellHost(deepLinkHostDraft);
|
||||
setIsLocalShellHostPanelOpen(true);
|
||||
} else {
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setEditingHost(deepLinkHostDraft);
|
||||
setIsHostPanelOpen(true);
|
||||
}
|
||||
onDeepLinkHostDraftHandled?.();
|
||||
}, [deepLinkHostDraft, onDeepLinkHostDraftHandled]);
|
||||
|
||||
@@ -507,17 +522,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(hosts.map((h) => h.id));
|
||||
// Check against previous IDs before updating the ref
|
||||
if (
|
||||
editingHost &&
|
||||
knownHostIdsRef.current.has(editingHost.id) &&
|
||||
!currentIds.has(editingHost.id)
|
||||
) {
|
||||
const editingDeleted = (openHost: Host | null) =>
|
||||
Boolean(openHost && knownHostIdsRef.current.has(openHost.id) && !currentIds.has(openHost.id));
|
||||
if (editingDeleted(editingHost) || editingDeleted(editingLocalShellHost)) {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}
|
||||
knownHostIdsRef.current = currentIds;
|
||||
}, [hosts, editingHost]);
|
||||
}, [hosts, editingHost, editingLocalShellHost]);
|
||||
|
||||
// Group panel state
|
||||
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
|
||||
@@ -599,8 +614,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{},
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
);
|
||||
// Only prompt when Telnet is available but isn't the host's default protocol.
|
||||
if (effective.telnetEnabled && effective.protocol !== "telnet") {
|
||||
// Only prompt when Telnet is available but isn't the host's default
|
||||
// protocol, and only for SSH hosts: local-shell and serial hosts must
|
||||
// always launch their own transport, never an inherited group Telnet
|
||||
// flag. Hosts without an explicit protocol are legacy SSH entries.
|
||||
const effectiveProtocol = effective.protocol ?? "ssh";
|
||||
if (effectiveProtocol === "ssh" && effective.telnetEnabled) {
|
||||
setProtocolSelectHost(effective);
|
||||
} else if (effective.protocol === "telnet") {
|
||||
// Telnet-as-default wins over a stray moshEnabled flag.
|
||||
@@ -654,13 +673,67 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
setEditingHost(null);
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
setIsHostPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
// "Local shell (CMD)" entry under the New Host menu — opens the dedicated
|
||||
// local-shell host editor with the current vault group preselected.
|
||||
const handleNewLocalShellHost = useCallback(() => {
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
setEditingHost(null);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
setIsLocalShellHostPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCancelLocalShellHost = useCallback(() => {
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
}, []);
|
||||
|
||||
const handleSaveLocalShellHost = useCallback((host: Host, connectNow?: boolean) => {
|
||||
// A duplicated local host carries a freshly generated ID that is not in the
|
||||
// vault yet, so it must be saved as a new entry (edit mode would no-op).
|
||||
// (Note: the mode is derived from the rendered hosts snapshot only, so the
|
||||
// updater below can still merge against the up-to-date vault array.)
|
||||
const editingExistingHost = hosts.some((entry) => entry.id === editingLocalShellHost?.id);
|
||||
const target: WorkSurfaceHostEditorTarget = editingExistingHost && editingLocalShellHost
|
||||
? { mode: "edit", openedHost: editingLocalShellHost, requestId: 0 }
|
||||
: { mode: "new", defaultGroup: null, requestId: 0 };
|
||||
// Apply the draft against the freshest host array, not the one captured at
|
||||
// render time: another vault write (terminal metadata, sync) may have
|
||||
// updated hostsRef between render and Save, and submitting the captured
|
||||
// array would replace it wholesale.
|
||||
let savedHost: Host | null = null;
|
||||
onUpdateHosts((prevHosts) => {
|
||||
const updatedHosts = saveWorkSurfaceHostDraft(prevHosts, target, host);
|
||||
if (!updatedHosts) return prevHosts;
|
||||
savedHost = updatedHosts.find((entry) => entry.id === host.id) ?? null;
|
||||
return updatedHosts;
|
||||
});
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
if (connectNow && savedHost) onConnect(savedHost);
|
||||
}, [hosts, editingLocalShellHost, onConnect, onUpdateHosts]);
|
||||
|
||||
const handleEditHost = useCallback((host: Host) => {
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
// Local shell hosts route to their dedicated editor (no SSH auth fields).
|
||||
if (host.protocol === "local") {
|
||||
setEditingHost(null);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingLocalShellHost(host);
|
||||
setIsLocalShellHostPanelOpen(true);
|
||||
return;
|
||||
}
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setEditingHost(host);
|
||||
setIsHostPanelOpen(true);
|
||||
}, []);
|
||||
@@ -687,6 +760,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
lastConnectedAt: undefined,
|
||||
};
|
||||
// Open the edit panel with the duplicated host for modification
|
||||
if (duplicatedHost.protocol === "local") {
|
||||
setEditingHost(null);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingLocalShellHost(duplicatedHost);
|
||||
setIsLocalShellHostPanelOpen(true);
|
||||
return;
|
||||
}
|
||||
setEditingLocalShellHost(null);
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingHost(duplicatedHost);
|
||||
setIsHostPanelOpen(true);
|
||||
},
|
||||
@@ -1355,7 +1437,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const isHostsSectionActive = currentSection === "hosts";
|
||||
const hasHostsSidePanel =
|
||||
isHostsSectionActive &&
|
||||
((isGroupPanelOpen && !!editingGroupPath) || isHostPanelOpen);
|
||||
((isGroupPanelOpen && !!editingGroupPath)
|
||||
|| isHostPanelOpen
|
||||
|| isLocalShellHostPanelOpen);
|
||||
// Every host grid uses the same container-based fixed column count. This keeps
|
||||
// pinned/recent cards aligned with the virtualized main grid and prevents a
|
||||
// single card from stretching across the full row.
|
||||
@@ -1469,6 +1553,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
handleHostConnect,
|
||||
handleImportFileSelected,
|
||||
handleNewHost,
|
||||
handleNewLocalShellHost,
|
||||
handleCancelLocalShellHost,
|
||||
handleSaveLocalShellHost,
|
||||
handleProtocolSelect,
|
||||
handleQuickConnect,
|
||||
handleQuickConnectSaveHost,
|
||||
@@ -1488,6 +1575,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
isDeleteGroupOpen,
|
||||
isGroupPanelOpen,
|
||||
isHostPanelOpen,
|
||||
isLocalShellHostPanelOpen,
|
||||
editingLocalShellHost,
|
||||
isHostsSectionActive,
|
||||
isImportOpen,
|
||||
isMultiSelectMode,
|
||||
|
||||
@@ -20,8 +20,10 @@ test("vault header collapsed actions cannot retain hidden focus", () => {
|
||||
assert.match(vaultViewLayoutSource, /newHostActionsRef\.current\?\.contains\(activeElement\)/);
|
||||
assert.match(vaultViewLayoutSource, /sessionActionsRef\.current\?\.contains\(activeElement\)/);
|
||||
assert.match(vaultViewLayoutSource, /activeElement\.blur\(\)/);
|
||||
assert.match(vaultViewLayoutSource, /aria-hidden=\{isHostPanelOpen \? true : undefined\}/);
|
||||
assert.match(vaultViewLayoutSource, /inert=\{isHostPanelOpen \? true : undefined\}/);
|
||||
// Both the SSH/new-host panel and the local-shell host panel collapse the
|
||||
// header actions so hidden buttons cannot retain focus.
|
||||
assert.match(vaultViewLayoutSource, /aria-hidden=\{isHostAsideOpen \? true : undefined\}/);
|
||||
assert.match(vaultViewLayoutSource, /inert=\{isHostAsideOpen \? true : undefined\}/);
|
||||
});
|
||||
|
||||
test("vault sidebar toggle keeps an accessible action label", () => {
|
||||
|
||||
@@ -200,6 +200,7 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
customGroups={customGroups}
|
||||
selectedHostIds={selectedHostIds}
|
||||
multiSelect
|
||||
allowLocalHosts={true}
|
||||
onSelect={onSelectHost}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onConfirm={handleTargetsConfirm}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { type TestContext } from "node:test";
|
||||
import React from "react";
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "../../test-support/renderReactDom";
|
||||
|
||||
async function setup(t: TestContext, label: string) {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
DocumentFragment: env.window.DocumentFragment,
|
||||
MutationObserver: env.window.MutationObserver,
|
||||
Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
const { default: SettingsShortcutsTab } = await import("./SettingsShortcutsTab");
|
||||
const { I18nProvider } = await import("../../../application/i18n/I18nProvider");
|
||||
const { Tabs } = await import("../../ui/tabs");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
const updates: string[][] = [];
|
||||
await renderer.render(<I18nProvider locale="en"><Tabs value="shortcuts"><SettingsShortcutsTab
|
||||
hotkeyScheme="pc" setHotkeyScheme={() => {}}
|
||||
shellOnlyTabNumberShortcuts={false} setShellOnlyTabNumberShortcuts={() => {}}
|
||||
showTabNumberBadges={true} setShowTabNumberBadges={() => {}}
|
||||
disableTerminalFontZoom={false} setDisableTerminalFontZoom={() => {}}
|
||||
keyBindings={DEFAULT_KEY_BINDINGS} resetAllKeyBindings={() => {}}
|
||||
updateKeyBinding={(...args) => { updates.push(args); }}
|
||||
/></Tabs></I18nProvider>);
|
||||
const row = [...env.document.querySelectorAll("span")].find((span) => span.textContent === label)?.parentElement;
|
||||
const button = row?.querySelector("button");
|
||||
assert.ok(button);
|
||||
await dispatchDomEvent(button, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
const key = (type: "keydown" | "keyup", init: KeyboardEventInit) => dispatchDomEvent(
|
||||
env.window, new env.window.KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }),
|
||||
);
|
||||
return { updates, key, button };
|
||||
}
|
||||
|
||||
test("the tab modifier editor saves left Alt on release without requiring a digit", async (t) => {
|
||||
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
|
||||
await key("keydown", { key: "Alt", code: "AltLeft", altKey: true });
|
||||
assert.deepEqual(updates, []);
|
||||
await key("keyup", { key: "Alt", code: "AltLeft" });
|
||||
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Alt + [1...9]"]]);
|
||||
});
|
||||
|
||||
test("the modifier editor waits for the full modifier chord to be released", async (t) => {
|
||||
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
|
||||
await key("keydown", { key: "Control", ctrlKey: true });
|
||||
await key("keydown", { key: "Alt", ctrlKey: true, altKey: true });
|
||||
await key("keyup", { key: "Alt", ctrlKey: true });
|
||||
assert.deepEqual(updates, []);
|
||||
await key("keyup", { key: "Control" });
|
||||
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Ctrl + Alt + [1...9]"]]);
|
||||
});
|
||||
|
||||
test("ordinary shortcuts still require a non-modifier key", async (t) => {
|
||||
const { updates, key } = await setup(t, "Next Tab");
|
||||
await key("keydown", { key: "Alt", altKey: true });
|
||||
await key("keyup", { key: "Alt" });
|
||||
assert.deepEqual(updates, []);
|
||||
await key("keydown", { key: "ArrowRight", altKey: true });
|
||||
assert.deepEqual(updates, [["next-tab", "pc", "Alt + \u2192"]]);
|
||||
});
|
||||
|
||||
test("Escape cancels an unfinished modifier recording", async (t) => {
|
||||
const { updates, key, button } = await setup(t, "Switch to Tab [1...9]");
|
||||
await key("keydown", { key: "Alt", altKey: true });
|
||||
await key("keydown", { key: "Escape", altKey: true });
|
||||
await key("keyup", { key: "Alt" });
|
||||
assert.deepEqual(updates, []);
|
||||
assert.equal(button.textContent, "Ctrl");
|
||||
});
|
||||
@@ -64,6 +64,7 @@ export default function SettingsShortcutsTab(props: {
|
||||
if (!recordingBindingId || !recordingScheme) return;
|
||||
|
||||
const specialSuffix = getSpecialSuffix(recordingBindingId);
|
||||
let pendingModifierBinding: string | null = null;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip while app lock overlay is up so password keys never bind (Codex P2).
|
||||
@@ -77,8 +78,6 @@ export default function SettingsShortcutsTab(props: {
|
||||
}
|
||||
|
||||
if (specialSuffix) {
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (recordingScheme === "mac") {
|
||||
if (e.metaKey) parts.push("⌘");
|
||||
@@ -95,6 +94,11 @@ export default function SettingsShortcutsTab(props: {
|
||||
const modifierString = parts.length > 0 ? `${parts.join(" + ")} + ` : "";
|
||||
const fullKeyString = modifierString + specialSuffix;
|
||||
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) {
|
||||
pendingModifierBinding = fullKeyString;
|
||||
return;
|
||||
}
|
||||
|
||||
updateKeyBinding?.(recordingBindingId, recordingScheme, fullKeyString);
|
||||
cancelRecording();
|
||||
return;
|
||||
@@ -106,6 +110,17 @@ export default function SettingsShortcutsTab(props: {
|
||||
cancelRecording();
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (!specialSuffix || !pendingModifierBinding || isAppLockOverlayActive()) return;
|
||||
if (!["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Wait for release so a modifier-only editor can capture Ctrl+Alt, etc.
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
||||
updateKeyBinding?.(recordingBindingId, recordingScheme, pendingModifierBinding);
|
||||
cancelRecording();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
cancelRecording();
|
||||
};
|
||||
@@ -115,9 +130,11 @@ export default function SettingsShortcutsTab(props: {
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("keyup", handleKeyUp, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("keyup", handleKeyUp, true);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [recordingBindingId, recordingScheme, updateKeyBinding, cancelRecording, getSpecialSuffix]);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sftpSourceSessionIdForHost,
|
||||
type SftpConnectedHostEntry,
|
||||
} from '../../domain/sftpConnectedHosts';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { canOpenDualPaneSftp } from '../../domain/sftpDualPaneOpen';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { getQuickSwitcherRowStateClass, shouldUseQuickSwitcherPointerNavigation } from '../QuickSwitcher';
|
||||
@@ -95,9 +95,11 @@ const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
return hosts.filter((h) => {
|
||||
// SFTP is an SSH-specific host capability. Plugin protocols may
|
||||
// provide arbitrary transports and cannot be treated as SSH.
|
||||
if (h.protocol === "serial" || isPluginHostProtocol(h.protocol)) return false;
|
||||
// SFTP is an SSH-specific host capability. Reuse the dual-pane
|
||||
// opener's predicate so serial, local-shell (no SSH channel —
|
||||
// selecting one would SSH into localhost) and plugin protocols
|
||||
// are excluded the same way everywhere.
|
||||
if (!canOpenDualPaneSftp(h)) return false;
|
||||
// Hide a saved host only when Connected already shows the same endpoint.
|
||||
// If the vault host was edited after connect, keep both: Live (old) + Saved (new).
|
||||
const connected = connectedByHostId.get(h.id);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
attachWindowResizeFocusRestore,
|
||||
WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
|
||||
type MinimalDocument,
|
||||
} from "./restoreWindowResizeFocus";
|
||||
|
||||
type FakeWindow = {
|
||||
listeners: Map<string, Array<() => void>>;
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
emit: (type: string) => void;
|
||||
};
|
||||
|
||||
const createWindowStub = (): FakeWindow => {
|
||||
const listeners = new Map<string, Array<() => void>>();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
listeners.set(type, bucket);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
const bucket = listeners.get(type) ?? [];
|
||||
const index = bucket.indexOf(listener);
|
||||
if (index >= 0) bucket.splice(index, 1);
|
||||
},
|
||||
emit(type) {
|
||||
for (const listener of [...(listeners.get(type) ?? [])]) listener();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type SchedulerState = {
|
||||
schedule: (callback: () => void, delay: number) => number;
|
||||
cancel: (id: number) => void;
|
||||
runNext: () => void;
|
||||
pendingCount: () => number;
|
||||
};
|
||||
|
||||
const createScheduler = (): SchedulerState => {
|
||||
type Entry = { id: number; callback: () => void; cancelled: boolean };
|
||||
const queue: Entry[] = [];
|
||||
let nextId = 1;
|
||||
return {
|
||||
schedule(callback, delay) {
|
||||
assert.equal(delay, WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS);
|
||||
queue.push({ id: nextId, callback, cancelled: false });
|
||||
return nextId++;
|
||||
},
|
||||
cancel(id) {
|
||||
for (const entry of queue) {
|
||||
if (entry.id === id) entry.cancelled = true;
|
||||
}
|
||||
},
|
||||
runNext() {
|
||||
while (queue.length) {
|
||||
const entry = queue.shift()!;
|
||||
if (!entry.cancelled) {
|
||||
entry.callback();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
pendingCount: () => queue.filter((entry) => !entry.cancelled).length,
|
||||
};
|
||||
};
|
||||
|
||||
test("resize restores terminal focus once the resize settles", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
assert.equal(scheduler.pendingCount(), 1, "restore must be debounced");
|
||||
assert.equal(refocusCalls, 0);
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("a continuous frame drag only triggers one restore", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
win.emit("resize");
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1, "debounced resizes must collapse into one restore");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus when the app window is not focused", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => false } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize restore is skipped while a modal dialog is open", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: (selector: string) => (
|
||||
selector === '[role="dialog"][data-state="open"]' ? {} : null
|
||||
),
|
||||
hasFocus: () => true,
|
||||
} as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus from an active editable field", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: { tagName: "INPUT", matches: () => false },
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0, "restore must not yank focus out of an editable field");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize does not steal focus from a contenteditable field", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: { tagName: "DIV", isContentEditable: true, matches: () => false },
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("resize still restores while the xterm helper textarea is focused", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: {
|
||||
querySelector: () => null,
|
||||
hasFocus: () => true,
|
||||
activeElement: {
|
||||
tagName: "TEXTAREA",
|
||||
matches: (selector: string) => selector === "textarea.xterm-helper-textarea",
|
||||
},
|
||||
} as unknown as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 1, "terminal-owned focus must be restored after resize");
|
||||
|
||||
detach();
|
||||
});
|
||||
|
||||
test("detach removes the listener and cancels a pending restore", () => {
|
||||
const win = createWindowStub();
|
||||
const scheduler = createScheduler();
|
||||
let refocusCalls = 0;
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win,
|
||||
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
|
||||
refocus: () => { refocusCalls += 1; },
|
||||
scheduleTimeout: scheduler.schedule,
|
||||
cancelTimeout: scheduler.cancel,
|
||||
});
|
||||
|
||||
win.emit("resize");
|
||||
detach();
|
||||
|
||||
assert.equal((win.listeners.get("resize") ?? []).length, 0, "listener must be removed");
|
||||
assert.equal(scheduler.pendingCount(), 0, "pending restore must be cancelled");
|
||||
scheduler.runNext();
|
||||
assert.equal(refocusCalls, 0);
|
||||
});
|
||||
|
||||
test("a win without listener support degrades to a no-op detach", () => {
|
||||
const detach = attachWindowResizeFocusRestore({
|
||||
win: null,
|
||||
refocus: () => {},
|
||||
scheduleTimeout: () => 1,
|
||||
cancelTimeout: () => {},
|
||||
});
|
||||
assert.doesNotThrow(() => detach());
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { hasOpenAppDialog } from "./focusTerminalSession";
|
||||
|
||||
/** Debounce so a continuous frame drag (or maximize transition) triggers one restore. */
|
||||
export const WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS = 120;
|
||||
|
||||
export type MinimalWindow = {
|
||||
addEventListener?: (type: string, listener: () => void) => unknown;
|
||||
removeEventListener?: (type: string, listener: () => void) => unknown;
|
||||
};
|
||||
|
||||
export type MinimalActiveElement = {
|
||||
tagName?: string | null;
|
||||
isContentEditable?: boolean;
|
||||
matches?: (selector: string) => boolean;
|
||||
};
|
||||
|
||||
export type MinimalDocument = Parameters<typeof hasOpenAppDialog>[0] & {
|
||||
hasFocus?: () => boolean;
|
||||
activeElement?: MinimalActiveElement | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when keyboard focus sits in an editable app field (search box, chat
|
||||
* input, SFTP filter, inline rename, ...) that is not an xterm helper
|
||||
* textarea. Resize-driven restores must not yank focus away from such fields.
|
||||
* Focus parked on `body`/`html` (or an xterm helper textarea) does not count
|
||||
* as editing another field.
|
||||
*/
|
||||
export const hasActiveEditableElement = (doc: MinimalDocument | null): boolean => {
|
||||
const element = doc?.activeElement;
|
||||
if (!element) return false;
|
||||
if (typeof element.matches === "function" && element.matches("textarea.xterm-helper-textarea")) {
|
||||
return false;
|
||||
}
|
||||
const tagName = typeof element.tagName === "string" ? element.tagName.toLowerCase() : "";
|
||||
return (
|
||||
tagName === "input"
|
||||
|| tagName === "textarea"
|
||||
|| tagName === "select"
|
||||
|| Boolean(element.isContentEditable)
|
||||
);
|
||||
};
|
||||
|
||||
type AttachWindowResizeFocusRestoreOptions = {
|
||||
win?: MinimalWindow | null;
|
||||
doc?: MinimalDocument | null;
|
||||
refocus?: () => void;
|
||||
delayMs?: number;
|
||||
scheduleTimeout?: (callback: () => void, delay: number) => unknown;
|
||||
cancelTimeout?: (id: unknown) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* #3647: dragging the OS window frame (or maximizing) leaves the active xterm
|
||||
* helper textarea unfocused, so keystrokes do nothing until the user clicks
|
||||
* back into the terminal. Attach a window `resize` listener that re-runs the
|
||||
* tab-change refocus path after the resize settles.
|
||||
*
|
||||
* Restore is skipped when the app window does not hold OS input focus, when a
|
||||
* modal dialog is open, or when an editable element other than the xterm
|
||||
* helper textarea already holds focus, so the restore never steals typing
|
||||
* from elsewhere.
|
||||
*/
|
||||
export const attachWindowResizeFocusRestore = (
|
||||
{
|
||||
win = typeof window !== "undefined" ? window : null,
|
||||
doc = typeof document !== "undefined" ? document : null,
|
||||
refocus,
|
||||
delayMs = WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
|
||||
scheduleTimeout = (callback: () => void, delay: number) => setTimeout(callback, delay),
|
||||
cancelTimeout = (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
|
||||
}: AttachWindowResizeFocusRestoreOptions = {},
|
||||
): (() => void) => {
|
||||
if (!win?.addEventListener || !win.removeEventListener || typeof refocus !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let timerId: unknown = null;
|
||||
const restoreFocus = () => {
|
||||
timerId = null;
|
||||
if (doc?.hasFocus?.() === false) return;
|
||||
if (hasOpenAppDialog(doc)) return;
|
||||
// The window holds OS focus, but the user may still be typing in a
|
||||
// non-terminal field (terminal search, AI chat, SFTP filter, rename).
|
||||
// Never steal focus from an active editable element.
|
||||
if (hasActiveEditableElement(doc)) return;
|
||||
refocus();
|
||||
};
|
||||
const handleResize = () => {
|
||||
if (timerId !== null) {
|
||||
cancelTimeout(timerId);
|
||||
}
|
||||
timerId = scheduleTimeout(restoreFocus, delayMs);
|
||||
};
|
||||
|
||||
win.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
win.removeEventListener("resize", handleResize);
|
||||
if (timerId !== null) {
|
||||
cancelTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
const makeCtx = (
|
||||
host: Record<string, unknown>,
|
||||
startLocalSession: (options: Record<string, unknown>) => Promise<string>,
|
||||
) => ({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Local Shell",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
protocol: "local",
|
||||
...host,
|
||||
},
|
||||
keys: [],
|
||||
identities: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend: {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
etAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startLocalSession,
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
},
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: true },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
});
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
test("startLocal omits an empty no-args snapshot so backend defaults apply", async () => {
|
||||
// Vault local hosts snapshot absent args as [] to keep launch-time no-args
|
||||
// semantics for clone/popup relaunch, but the snapshot must not be passed to
|
||||
// the bridge: an empty array is truthy and would suppress the bridge's
|
||||
// discovered-shell args and getLocalShellArgs fallbacks (login flags,
|
||||
// PowerShell -NoLogo).
|
||||
let started: Record<string, unknown> | null = null;
|
||||
const ctx = makeCtx(
|
||||
{ localShell: "fish", localShellArgs: [] },
|
||||
async (options) => {
|
||||
started = options;
|
||||
return "local-session";
|
||||
},
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
|
||||
|
||||
assert.ok(started);
|
||||
assert.equal((started as { shell?: string }).shell, "fish");
|
||||
assert.equal((started as { shellArgs?: string[] }).shellArgs, undefined);
|
||||
});
|
||||
|
||||
test("startLocal passes explicit local shell args through", async () => {
|
||||
let started: Record<string, unknown> | null = null;
|
||||
const ctx = makeCtx(
|
||||
{ localShell: "bash", localShellArgs: ["--login", "-i"] },
|
||||
async (options) => {
|
||||
started = options;
|
||||
return "local-session";
|
||||
},
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
|
||||
|
||||
assert.ok(started);
|
||||
assert.equal((started as { shell?: string }).shell, "bash");
|
||||
assert.deepEqual((started as { shellArgs?: string[] }).shellArgs, ["--login", "-i"]);
|
||||
});
|
||||
@@ -3653,7 +3653,13 @@ test("restored local reconnect runs the host startup command while automatic ret
|
||||
assert.deepEqual(sessionWrites, []);
|
||||
});
|
||||
|
||||
test("local session start uses per-session directory before global default", async () => {
|
||||
for (const { name, hostDirectory, globalDirectory, expectedDirectory } of [
|
||||
{ name: "uses saved directory before global default", hostDirectory: "/Users/alice/project", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/project" },
|
||||
{ name: "inherits global directory when unset", hostDirectory: undefined, globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
|
||||
{ name: "inherits global directory when cleared", hostDirectory: "", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
|
||||
{ name: "leaves directory unset for backend home default", hostDirectory: undefined, globalDirectory: undefined, expectedDirectory: undefined },
|
||||
]) {
|
||||
test(`local session start ${name}`, async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
@@ -3685,16 +3691,17 @@ test("local session start uses per-session directory before global default", asy
|
||||
hostname: "local",
|
||||
username: "",
|
||||
protocol: "local",
|
||||
localStartDir: "/Users/alice/project",
|
||||
localStartDir: hostDirectory,
|
||||
},
|
||||
terminalSettings: { localStartDir: "/Users/alice/default" },
|
||||
terminalSettings: { localStartDir: globalDirectory },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
|
||||
|
||||
assert.equal(capturedOptions?.cwd, "/Users/alice/project");
|
||||
assert.equal(capturedOptions?.cwd, expectedDirectory);
|
||||
});
|
||||
}
|
||||
|
||||
test("local session restores cwd before startup command after attaching", async () => {
|
||||
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
||||
|
||||
@@ -1835,8 +1835,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
// which was already resolved to command+args and stored on the session object by App.tsx.
|
||||
// Only pass shell/shellArgs when we have concrete per-session values;
|
||||
// otherwise omit them so the backend uses its own default shell detection.
|
||||
// An empty array is truthy, so `|| undefined` does not filter it out.
|
||||
// Vault local hosts snapshot absent args as `[]` (to preserve
|
||||
// launch-time no-args semantics for clone/popup relaunch), but the
|
||||
// snapshot must still be omitted here: the bridge only applies its
|
||||
// discovered-shell args and getLocalShellArgs fallbacks when shellArgs
|
||||
// is absent (#1221).
|
||||
const sessionShell = ctx.host.localShell;
|
||||
const sessionShellArgs = ctx.host.localShellArgs;
|
||||
const sessionShellArgs = ctx.host.localShellArgs?.length ? ctx.host.localShellArgs : undefined;
|
||||
const localStartDir = ctx.host.localStartDir || ctx.terminalSettings?.localStartDir;
|
||||
|
||||
const id = await ctx.terminalBackend.startLocalSession({
|
||||
@@ -1844,7 +1850,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
shell: sessionShell || undefined,
|
||||
shellArgs: sessionShellArgs || undefined,
|
||||
shellArgs: sessionShellArgs,
|
||||
cwd: localStartDir,
|
||||
env: {
|
||||
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||||
|
||||
@@ -33,7 +33,7 @@ const createFakeTerm = (cols = 80) => {
|
||||
onRender: (handler: (element: FakeElement) => void) => { dispose: () => void };
|
||||
onDispose: (handler: Handler) => { dispose: () => void };
|
||||
}> = [];
|
||||
const markers: Array<{ line: number; disposed: boolean }> = [];
|
||||
const markers: Array<{ line: number; disposed: boolean; dispose: () => void }> = [];
|
||||
|
||||
const term = {
|
||||
cols,
|
||||
@@ -477,6 +477,84 @@ test('CursorLineHighlighter restores after the terminal resets its decorations',
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter self-heals when xterm disposes its decorations without a write', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
assert.equal(term.decorations.at(-1)?.disposed, false);
|
||||
|
||||
// xterm-side disposal (decoration service reset / term.reset): no cursor
|
||||
// move and no write follow, only the next render frame.
|
||||
term.resetDecorations();
|
||||
assert.equal(term.decorations[0]?.disposed, true);
|
||||
|
||||
term.render();
|
||||
assert.equal(term.decorations.at(-1)?.disposed, false);
|
||||
assert.equal(term.decorations.at(-1)?.options.width, 80);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter recreates after a buffer clear wipes markers and decorations', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
assert.equal(term.markers.at(-1)?.disposed, false);
|
||||
|
||||
// term.clear() disposes every marker; decorations follow their markers.
|
||||
for (const marker of term.markers) marker.dispose();
|
||||
term.resetDecorations();
|
||||
assert.equal(term.decorations.at(-1)?.disposed, true);
|
||||
|
||||
term.render();
|
||||
assert.equal(term.decorations.at(-1)?.disposed, false);
|
||||
assert.equal(term.markers.at(-1)?.line, 0);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter does not duplicate decorations on redundant renders', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
const aliveBefore = term.decorations.filter((decoration) => !decoration.disposed).length;
|
||||
|
||||
term.render();
|
||||
term.render();
|
||||
assert.equal(
|
||||
term.decorations.filter((decoration) => !decoration.disposed).length,
|
||||
aliveBefore,
|
||||
);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter does not churn markers when a fully colored row has no decorations', () => {
|
||||
const cols = 8;
|
||||
const term = createFakeTerm(cols);
|
||||
// Every cell has an explicit ANSI background and the row is full, so both
|
||||
// highlight ranges and the blank tail are legitimately empty.
|
||||
term.setColoredBackgrounds(Array.from({ length: cols }, (_, column) => column));
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
highlighter.setEnabled(true);
|
||||
|
||||
assert.equal(term.decorations.length, 0);
|
||||
assert.equal(term.markers.length, 1);
|
||||
const marker = term.markers[0];
|
||||
assert.ok(marker);
|
||||
assert.equal(marker.disposed, false);
|
||||
assert.equal(marker.line, 0);
|
||||
const aliveBefore = term.markers.filter((candidate) => !candidate.disposed).length;
|
||||
|
||||
term.render();
|
||||
term.render();
|
||||
term.render();
|
||||
|
||||
assert.equal(term.markers.length, 1);
|
||||
assert.equal(term.markers.filter((candidate) => !candidate.disposed).length, aliveBefore);
|
||||
assert.equal(marker.disposed, false);
|
||||
assert.equal(marker.line, 0);
|
||||
assert.equal(term.decorations.length, 0);
|
||||
highlighter.dispose();
|
||||
});
|
||||
|
||||
test('CursorLineHighlighter clears in the alternate buffer and restores in normal buffer', () => {
|
||||
const term = createFakeTerm(80);
|
||||
const highlighter = new CursorLineHighlighter(term as never);
|
||||
|
||||
@@ -37,6 +37,8 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
private activeColor: string | null = null;
|
||||
private activeRanges: HighlightRange[] = [];
|
||||
private activeTailRanges: HighlightRange[] = [];
|
||||
/** False once any tracked decoration has been disposed out from under us. */
|
||||
private decorationsAlive = false;
|
||||
private readonly disposables: IDisposable[] = [];
|
||||
private pendingRefresh = false;
|
||||
private disposed = false;
|
||||
@@ -46,10 +48,16 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
this.term.onCursorMove(() => this.markPendingRefresh()),
|
||||
this.term.onWriteParsed(() => this.markPendingRefresh()),
|
||||
this.term.onRender(() => {
|
||||
if (this.pendingRefresh) {
|
||||
this.pendingRefresh = false;
|
||||
this.refresh();
|
||||
}
|
||||
const pending = this.pendingRefresh;
|
||||
this.pendingRefresh = false;
|
||||
if (this.disposed || !this.enabled) return;
|
||||
// Writes and cursor moves defer to render, but xterm can also dispose
|
||||
// or clear our decorations out from under us (term.clear/reset,
|
||||
// decoration service resets, marker reindexing) without any further
|
||||
// cursor move or write. Verify the tracked set on every render so the
|
||||
// highlight can self-heal at the cursor line instead of staying
|
||||
// anchored to stale bookkeeping (wrong/missing line).
|
||||
if (pending || !this.isTrackingIntact()) this.refresh();
|
||||
}),
|
||||
this.term.onSelectionChange(() => this.refresh({ force: true })),
|
||||
this.term.onResize(() => this.refresh({ force: true })),
|
||||
@@ -111,6 +119,7 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
color === this.activeColor &&
|
||||
rangesEqual(ranges, this.activeRanges) &&
|
||||
rangesEqual(tailRanges, this.activeTailRanges) &&
|
||||
this.decorationsAlive &&
|
||||
this.marker &&
|
||||
!this.marker.isDisposed &&
|
||||
this.marker.line === absoluteLine
|
||||
@@ -160,13 +169,18 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
|
||||
this.marker = marker;
|
||||
this.decorations = decorations;
|
||||
// An empty set is valid when the whole row already has ANSI backgrounds or
|
||||
// inverse cells. Tracking stays intact so later frames do not churn markers.
|
||||
this.decorationsAlive = true;
|
||||
this.decorationDisposeListeners = decorations.map((decoration) =>
|
||||
decoration.onDispose(() => {
|
||||
if (this.decorations.includes(decoration)) {
|
||||
this.decorationsAlive = false;
|
||||
this.activeLine = null;
|
||||
this.activeCols = null;
|
||||
this.activeColor = null;
|
||||
this.activeRanges = [];
|
||||
this.activeTailRanges = [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -244,6 +258,7 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
this.marker = null;
|
||||
this.decorations = [];
|
||||
this.decorationDisposeListeners = [];
|
||||
this.decorationsAlive = false;
|
||||
this.activeLine = null;
|
||||
this.activeCols = null;
|
||||
this.activeColor = null;
|
||||
@@ -251,6 +266,19 @@ export class CursorLineHighlighter implements IDisposable {
|
||||
this.activeTailRanges = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap render-time check that the tracked marker and decorations are still
|
||||
* owned by xterm. xterm disposes highlight decorations on buffer clears,
|
||||
* resets, and marker reindexing without emitting cursor-move/write events,
|
||||
* so a stale tracking state must be recreated before the next frame paints.
|
||||
*/
|
||||
private isTrackingIntact(): boolean {
|
||||
if (this.marker === null || this.marker.isDisposed) {
|
||||
return this.marker === null && this.decorations.length === 0;
|
||||
}
|
||||
return this.decorationsAlive;
|
||||
}
|
||||
|
||||
private clearOwned(
|
||||
marker: IMarker | null,
|
||||
decorations: IDecoration[],
|
||||
|
||||
@@ -584,6 +584,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
addDraftFiles={aiConfig.addDraftFiles}
|
||||
removeDraftFile={aiConfig.removeDraftFile}
|
||||
createSession={aiConfig.createSession}
|
||||
undoLastTurnInSession={aiConfig.undoLastTurnInSession}
|
||||
deleteSession={aiConfig.deleteSession}
|
||||
updateSessionTitle={aiConfig.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type SidePanelTabRemap,
|
||||
} from '../../domain/workspaceSidePanelTabRemap';
|
||||
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
|
||||
import { attachWindowResizeFocusRestore } from '../terminal/restoreWindowResizeFocus';
|
||||
import { toast } from '../ui/toast';
|
||||
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
|
||||
import type { RendererCwdSource } from '../terminal/sftpCwd';
|
||||
@@ -747,6 +748,23 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
|
||||
refocusActiveTerminalSession();
|
||||
}, [activeTabId, isTerminalLayerVisible, refocusActiveTerminalSession]);
|
||||
|
||||
// Restore keyboard focus after the OS window is resized or maximized.
|
||||
// #3647: dragging the frame (or maximizing) leaves the active xterm helper
|
||||
// textarea unfocused on Windows, so keystrokes do nothing until a click.
|
||||
// Reuse the tab-change refocus path; the helper only fires once the resize
|
||||
// settles and is gated on the app window actually holding input focus.
|
||||
useEffect(() => {
|
||||
if (!isTerminalLayerVisible) return undefined;
|
||||
return attachWindowResizeFocusRestore({
|
||||
win: window,
|
||||
doc: document,
|
||||
refocus: () => {
|
||||
if (isComposeBarOpen) return;
|
||||
refocusActiveTerminalSession?.();
|
||||
},
|
||||
});
|
||||
}, [document, isComposeBarOpen, isTerminalLayerVisible, refocusActiveTerminalSession, window]);
|
||||
|
||||
// When focusedSessionId changes or terminal layer becomes visible,
|
||||
// focus the corresponding terminal to restore :focus-within CSS state
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import type { GroupNode, Host } from "../../domain/models";
|
||||
import { isPluginHostProtocol } from "../../domain/pluginConnection";
|
||||
import { OpenDualPaneSftpMenuItem } from "../host/HostTreeContextMenus";
|
||||
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
|
||||
|
||||
type VaultHostListSectionContext = Record<string, any>;
|
||||
|
||||
@@ -598,7 +599,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
@@ -718,7 +719,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
@@ -1097,7 +1098,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
@@ -1247,7 +1248,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../../domain/vaultGroupSelection";
|
||||
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from "@/infrastructure/config/storageKeys.ts";
|
||||
import { VaultHostListSection } from "./VaultHostListSection";
|
||||
import { LocalShellHostDetailsPanel } from "../LocalShellHostDetailsPanel";
|
||||
import { VaultImportProgressPanel } from "./ImportVaultDialog";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
@@ -172,6 +173,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
Edit2,
|
||||
editingGroupPath,
|
||||
editingHost,
|
||||
editingLocalShellHost,
|
||||
editingHostGroupDefaults,
|
||||
FileCode,
|
||||
FileSymlink,
|
||||
@@ -195,6 +197,9 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
handleHostConnect,
|
||||
handleImportFileSelected,
|
||||
handleNewHost,
|
||||
handleNewLocalShellHost,
|
||||
handleCancelLocalShellHost,
|
||||
handleSaveLocalShellHost,
|
||||
handleProtocolSelect,
|
||||
handleQuickConnect,
|
||||
handleQuickConnectSaveHost,
|
||||
@@ -214,6 +219,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
isDeleteGroupOpen,
|
||||
isGroupPanelOpen,
|
||||
isHostPanelOpen,
|
||||
isLocalShellHostPanelOpen,
|
||||
isHostsSectionActive,
|
||||
isImportOpen,
|
||||
isMultiSelectMode,
|
||||
@@ -380,6 +386,9 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
} = ctx;
|
||||
const { importProgress, onCommitPluginImporterData, resetImportProgress } =
|
||||
ctx;
|
||||
// Host aside panel (SSH/new host) or the local-shell host panel occupies the
|
||||
// aside area — both collapse the header action buttons the same way.
|
||||
const isHostAsideOpen = isHostPanelOpen || isLocalShellHostPanelOpen;
|
||||
const pendingDeleteGroupPaths =
|
||||
bulkDeleteGroupPaths.length > 0
|
||||
? bulkDeleteGroupPaths
|
||||
@@ -521,7 +530,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isHostPanelOpen) return;
|
||||
if (!isHostAsideOpen) return;
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement)) return;
|
||||
if (
|
||||
@@ -530,7 +539,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
) {
|
||||
activeElement.blur();
|
||||
}
|
||||
}, [isHostPanelOpen]);
|
||||
}, [isHostAsideOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -961,12 +970,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
ref={newHostActionsRef}
|
||||
className={cn(
|
||||
"flex items-center app-no-drag overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
|
||||
isHostPanelOpen
|
||||
isHostAsideOpen
|
||||
? "max-w-0 opacity-0 -ml-2 pointer-events-none"
|
||||
: "max-w-[260px] opacity-100",
|
||||
)}
|
||||
aria-hidden={isHostPanelOpen ? true : undefined}
|
||||
inert={isHostPanelOpen ? true : undefined}
|
||||
aria-hidden={isHostAsideOpen ? true : undefined}
|
||||
inert={isHostAsideOpen ? true : undefined}
|
||||
>
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
@@ -974,7 +983,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
size="sm"
|
||||
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||||
onClick={handleNewHost}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
>
|
||||
<Plus size={14} className="mr-2" />{" "}
|
||||
{t("vault.hosts.newHost")}
|
||||
@@ -983,13 +992,20 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleNewLocalShellHost}
|
||||
>
|
||||
<TerminalSquare size={14} /> {t("vault.hosts.newLocalShell")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
@@ -1029,19 +1045,19 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
ref={sessionActionsRef}
|
||||
className={cn(
|
||||
"flex items-center gap-3 overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
|
||||
isHostPanelOpen
|
||||
isHostAsideOpen
|
||||
? "max-w-0 opacity-0 -ml-3 pointer-events-none"
|
||||
: "max-w-[320px] opacity-100",
|
||||
)}
|
||||
aria-hidden={isHostPanelOpen ? true : undefined}
|
||||
inert={isHostPanelOpen ? true : undefined}
|
||||
aria-hidden={isHostAsideOpen ? true : undefined}
|
||||
inert={isHostAsideOpen ? true : undefined}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
onClick={onCreateLocalTerminal}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" />{" "}
|
||||
{t("common.terminal")}
|
||||
@@ -1051,7 +1067,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
variant="secondary"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
onClick={() => setIsSerialModalOpen(true)}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
>
|
||||
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||||
</Button>
|
||||
@@ -1527,6 +1543,26 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
{...vaultHostPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Local Shell Host Details Panel - for editing/saving local shell (CMD) hosts */}
|
||||
{currentSection === "hosts" &&
|
||||
isLocalShellHostPanelOpen && (
|
||||
<LocalShellHostDetailsPanel
|
||||
key={editingLocalShellHost?.id ?? "local-shell-new"}
|
||||
initialData={editingLocalShellHost}
|
||||
groups={allGroupPaths}
|
||||
defaultGroup={
|
||||
editingLocalShellHost
|
||||
? undefined
|
||||
: newHostGroupPath || selectedGroupPath
|
||||
}
|
||||
onSave={(host) => handleSaveLocalShellHost(host)}
|
||||
onSaveAndConnect={(host) => handleSaveLocalShellHost(host, true)}
|
||||
onCancel={handleCancelLocalShellHost}
|
||||
layout="inline"
|
||||
{...vaultHostPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AISession, ChatMessage } from '../infrastructure/ai/types.ts';
|
||||
import {
|
||||
buildUndoLastTurnBranch,
|
||||
hasUnresolvedToolCalls,
|
||||
resolveUndoLastTurnBoundary,
|
||||
} from './aiUndoLastTurn.ts';
|
||||
|
||||
const user = (content: string): ChatMessage => ({
|
||||
id: `u_${content}`,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: 1,
|
||||
});
|
||||
const assistant = (content: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({
|
||||
id: `a_${content}`,
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp: 2,
|
||||
...extra,
|
||||
});
|
||||
const session = (messages: ChatMessage[], extra: Partial<AISession> = {}): AISession => ({
|
||||
id: 'chat-1',
|
||||
title: 'Chat',
|
||||
agentId: 'catty',
|
||||
scope: { type: 'terminal', targetId: 't1' },
|
||||
messages,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('resolveUndoLastTurnBoundary finds the last user message index', () => {
|
||||
const messages = [user('one'), assistant('reply'), user('two'), assistant('reply2')];
|
||||
assert.equal(resolveUndoLastTurnBoundary(messages), 2);
|
||||
});
|
||||
|
||||
test('resolveUndoLastTurnBoundary returns null without a user message', () => {
|
||||
assert.equal(resolveUndoLastTurnBoundary([assistant('hello')]), null);
|
||||
assert.equal(resolveUndoLastTurnBoundary([]), null);
|
||||
});
|
||||
|
||||
test('resolveUndoLastTurnBoundary refuses boundaries inside the compacted prefix', () => {
|
||||
const messages = [user('compacted'), assistant('x'), user('last'), assistant('y')];
|
||||
assert.equal(resolveUndoLastTurnBoundary(messages, 3), null);
|
||||
assert.equal(resolveUndoLastTurnBoundary(messages, 2), 2);
|
||||
});
|
||||
|
||||
test('resolveUndoLastTurnBoundary refuses cuts between a tool call and its result', () => {
|
||||
// The call sits in the kept prefix while its result would be cut away.
|
||||
const split = [
|
||||
assistant('', {
|
||||
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
|
||||
}),
|
||||
user('again?'),
|
||||
assistant('', {
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
|
||||
}),
|
||||
];
|
||||
assert.equal(resolveUndoLastTurnBoundary(split), null);
|
||||
assert.ok(hasUnresolvedToolCalls(split.slice(0, 2)));
|
||||
// Result already inside the kept prefix — the boundary is safe.
|
||||
const resolved = [
|
||||
assistant('', {
|
||||
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
|
||||
}),
|
||||
user('again?'),
|
||||
assistant('done'),
|
||||
];
|
||||
assert.equal(hasUnresolvedToolCalls(resolved.slice(0, 1)), false);
|
||||
assert.equal(resolveUndoLastTurnBoundary(resolved), 1);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch branches before the last user turn and restores it', () => {
|
||||
const source = session([
|
||||
user('first'),
|
||||
assistant('answer'),
|
||||
user('bad prompt'),
|
||||
assistant('bad answer'),
|
||||
]);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
|
||||
assert.ok(result);
|
||||
assert.equal(result.session.id, 'chat-2');
|
||||
assert.deepEqual(result.session.messages.map((m) => m.content), ['first', 'answer']);
|
||||
assert.equal(result.restored.text, 'bad prompt');
|
||||
// Original session untouched (non-destructive).
|
||||
assert.equal(source.messages.length, 4);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch clears the external agent session id and keeps compaction', () => {
|
||||
const source = session(
|
||||
[user('compacted'), assistant('x'), user('last'), assistant('y')],
|
||||
{
|
||||
externalSessionId: 'prov-1',
|
||||
contextCompaction: { summary: 'summary', compactedMessageCount: 2 },
|
||||
},
|
||||
);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 7 });
|
||||
assert.ok(result);
|
||||
assert.equal(result.session.externalSessionId, undefined);
|
||||
assert.deepEqual(result.session.contextCompaction, { summary: 'summary', compactedMessageCount: 2 });
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch restores message attachments into composer uploads', () => {
|
||||
const source = session([
|
||||
user('with file'),
|
||||
assistant('ok'),
|
||||
{
|
||||
id: 'u_last',
|
||||
role: 'user',
|
||||
content: 'look at this',
|
||||
timestamp: 3,
|
||||
attachments: [
|
||||
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'notes.txt', lineCount: 3 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 99 });
|
||||
assert.ok(result);
|
||||
assert.deepEqual(result.restored.attachments, [
|
||||
{
|
||||
id: 'undo_99_0',
|
||||
filename: 'notes.txt',
|
||||
mediaType: 'text/plain',
|
||||
base64Data: 'QUJD',
|
||||
dataUrl: 'data:text/plain;base64,QUJD',
|
||||
lineCount: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch restores the undone turn selected user skills', () => {
|
||||
const source = session([
|
||||
user('first'),
|
||||
assistant('answer'),
|
||||
{ ...user('undo me'), selectedUserSkillSlugs: ['web-search', 'notes-writer'] },
|
||||
assistant('bad answer'),
|
||||
]);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
|
||||
assert.ok(result);
|
||||
assert.deepEqual(result.restored.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
|
||||
// Original session untouched (non-destructive).
|
||||
assert.deepEqual(source.messages[2]?.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch restores no skill slugs for a plain turn', () => {
|
||||
const source = session([user('first'), assistant('answer'), user('undo me'), assistant('ok')]);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
|
||||
assert.ok(result);
|
||||
assert.deepEqual(result.restored.selectedUserSkillSlugs, []);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch returns null when no undo boundary exists', () => {
|
||||
assert.equal(
|
||||
buildUndoLastTurnBranch(session([assistant('only answer')]), { newId: 'x', now: 1 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch resets the title when undo removes the first turn', () => {
|
||||
// Undoing the first turn leaves the branch with no user message at all, so
|
||||
// the inherited title was derived from the removed prompt and would stay
|
||||
// stale forever (auto-titling only runs for untitled/New Chat sessions).
|
||||
const source = session([user('first prompt'), assistant('reply')], { title: 'first prompt' });
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
|
||||
assert.ok(result);
|
||||
assert.equal(result?.session.messages.length, 0);
|
||||
assert.equal(result?.restored.text, 'first prompt');
|
||||
assert.equal(result?.session.title, 'New Chat');
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch keeps the title when the retained user turn is note-only', () => {
|
||||
// A note-only send persists empty user text while the mentioned note rides
|
||||
// on the attachment (its title seeded the conversation title), so a
|
||||
// blank-content retained user turn must still count as "a user message is
|
||||
// kept" and must not reset the title to the untitled placeholder.
|
||||
const noteOnlyUser: ChatMessage = {
|
||||
id: 'u_note-only',
|
||||
role: 'user',
|
||||
content: '',
|
||||
timestamp: 3,
|
||||
attachments: [{
|
||||
base64Data: '',
|
||||
mediaType: 'text/markdown',
|
||||
filename: 'plan.md',
|
||||
vaultNoteId: 'note-1',
|
||||
vaultNoteTitle: 'Deploy plan',
|
||||
previewText: '# Deploy plan',
|
||||
}],
|
||||
};
|
||||
const source = session(
|
||||
[noteOnlyUser, assistant('reply'), user('second prompt'), assistant('reply2')],
|
||||
{ title: 'Deploy plan' },
|
||||
);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
|
||||
assert.ok(result);
|
||||
assert.equal(result?.session.title, 'Deploy plan');
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch keeps the title when a user message is retained', () => {
|
||||
const source = session(
|
||||
[user('first prompt'), assistant('reply'), user('second prompt'), assistant('reply2')],
|
||||
{ title: 'renamed by user' },
|
||||
);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
|
||||
assert.ok(result);
|
||||
assert.equal(result?.session.messages.length, 2);
|
||||
assert.equal(result?.restored.text, 'second prompt');
|
||||
// The retained prefix still contains a user message, so the source title
|
||||
// keeps describing the branch's conversation.
|
||||
assert.equal(result?.session.title, 'renamed by user');
|
||||
});
|
||||
|
||||
test('undo allows a failed last reply while retaining portable replay metadata', () => {
|
||||
const continuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
|
||||
const source = session([
|
||||
user('first'),
|
||||
assistant('first answer', { providerContinuation: continuation }),
|
||||
user('retry this request'),
|
||||
assistant('request failed', { executionStatus: 'failed' }),
|
||||
], { externalSessionId: 'original-provider-thread' });
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'branch', now: 99 });
|
||||
assert.ok(result);
|
||||
assert.deepEqual(result.session.messages[1].providerContinuation, continuation);
|
||||
assert.equal(result.session.externalSessionId, undefined);
|
||||
assert.equal(result.restored.text, 'retry this request');
|
||||
assert.equal(source.messages.length, 4);
|
||||
});
|
||||
|
||||
test('safe boundary does not pair a reused call ID with an earlier result', () => {
|
||||
const prefix = [
|
||||
assistant('first call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
|
||||
assistant('first result', { toolResults: [{ toolCallId: 'reused', content: 'done' }] }),
|
||||
assistant('second call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
|
||||
];
|
||||
assert.equal(resolveUndoLastTurnBoundary([...prefix, user('latest')]), null);
|
||||
assert.equal(resolveUndoLastTurnBoundary([
|
||||
...prefix,
|
||||
assistant('second result', { toolResults: [{ toolCallId: 'reused', content: 'done again' }] }),
|
||||
user('latest'),
|
||||
]), 4);
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
UploadedFile,
|
||||
} from '../infrastructure/ai/types';
|
||||
|
||||
/**
|
||||
* Non-destructive "undo last turn" for AI chat sessions.
|
||||
*
|
||||
* Undo branches the conversation at the boundary *before* the latest user
|
||||
* message instead of truncating the original session. The original session
|
||||
* stays in history untouched; the branch (without the last turn) becomes the
|
||||
* conversation the user continues, and the undone user message is restored
|
||||
* into the composer for editing and resending.
|
||||
*/
|
||||
|
||||
export interface UndoLastTurnRestoredDraft {
|
||||
text: string;
|
||||
attachments: UploadedFile[];
|
||||
selectedUserSkillSlugs: string[];
|
||||
}
|
||||
|
||||
export interface UndoLastTurnResult {
|
||||
/** Branched copy of the source session without the last user turn. */
|
||||
session: AISession;
|
||||
/** Undone user message content for the composer. */
|
||||
restored: UndoLastTurnRestoredDraft;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when any assistant tool call in `messages` has no matching tool result
|
||||
* inside the same list. Cutting between a tool call and its result would
|
||||
* desync agent tool bookkeeping, so such boundaries are rejected.
|
||||
*/
|
||||
export function hasUnresolvedToolCalls(messages: readonly ChatMessage[]): boolean {
|
||||
// Providers may reuse tool-call IDs across a session. Pair each result with
|
||||
// the nearest preceding unresolved call carrying the same ID (same strategy
|
||||
// as `repairToolMessageIntegrity`), otherwise a result for an earlier
|
||||
// occurrence would satisfy a later call that reused the ID.
|
||||
const pendingCalls = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
for (const call of message.toolCalls ?? []) {
|
||||
pendingCalls.set(call.id, (pendingCalls.get(call.id) ?? 0) + 1);
|
||||
}
|
||||
for (const result of message.toolResults ?? []) {
|
||||
const pending = pendingCalls.get(result.toolCallId) ?? 0;
|
||||
if (pending > 0) pendingCalls.set(result.toolCallId, pending - 1);
|
||||
}
|
||||
}
|
||||
for (const pending of pendingCalls.values()) {
|
||||
if (pending > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the cut point for undoing the last turn (the last user message).
|
||||
* Returns null when undo is not offered:
|
||||
* - there is no user message at all,
|
||||
* - the boundary would fall inside the compacted prefix (its summary would no
|
||||
* longer match the kept messages),
|
||||
* - cutting there would split an assistant tool call from its result.
|
||||
*/
|
||||
export function resolveUndoLastTurnBoundary(
|
||||
messages: readonly ChatMessage[],
|
||||
compactedMessageCount = 0,
|
||||
): number | null {
|
||||
let lastUserIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i].role === 'user') {
|
||||
lastUserIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastUserIndex < 0) return null;
|
||||
if (lastUserIndex < compactedMessageCount) return null;
|
||||
const keptPrefix = messages.slice(0, lastUserIndex);
|
||||
if (hasUnresolvedToolCalls(keptPrefix)) return null;
|
||||
return lastUserIndex;
|
||||
}
|
||||
|
||||
/** Map a persisted message attachment back to a composer upload (by value). */
|
||||
export function buildRestoredAttachments(
|
||||
message: ChatMessage,
|
||||
now: number,
|
||||
): UploadedFile[] {
|
||||
const attachments: ChatMessageAttachment[] = message.attachments ?? message.images ?? [];
|
||||
return attachments.map((attachment, index) => ({
|
||||
id: `undo_${now}_${index}`,
|
||||
filename: attachment.filename ?? '',
|
||||
mediaType: attachment.mediaType,
|
||||
base64Data: attachment.base64Data,
|
||||
dataUrl: attachment.base64Data
|
||||
? `data:${attachment.mediaType};base64,${attachment.base64Data}`
|
||||
: '',
|
||||
...(attachment.filePath !== undefined ? { filePath: attachment.filePath } : {}),
|
||||
...(attachment.terminalSelection ? { terminalSelection: true } : {}),
|
||||
...(attachment.vaultNoteId !== undefined ? { vaultNoteId: attachment.vaultNoteId } : {}),
|
||||
...(attachment.vaultNoteTitle !== undefined ? { vaultNoteTitle: attachment.vaultNoteTitle } : {}),
|
||||
...(attachment.previewText !== undefined ? { previewText: attachment.previewText } : {}),
|
||||
...(attachment.lineCount !== undefined ? { lineCount: attachment.lineCount } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the branched "undo last turn" copy of a session.
|
||||
*
|
||||
* The branch keeps the compacted prefix intact (its summary still describes
|
||||
* the kept messages) and drops the external agent session id: external agents
|
||||
* resume provider-side sessions, so the branch must start a fresh one.
|
||||
*/
|
||||
export function buildUndoLastTurnBranch(
|
||||
source: AISession,
|
||||
options: { newId: string; now: number },
|
||||
): UndoLastTurnResult | null {
|
||||
const compactedMessageCount = source.contextCompaction?.compactedMessageCount ?? 0;
|
||||
const boundary = resolveUndoLastTurnBoundary(source.messages, compactedMessageCount);
|
||||
if (boundary == null) return null;
|
||||
|
||||
const removedUserMessage = source.messages[boundary];
|
||||
const retainedPrefix = source.messages.slice(0, boundary);
|
||||
// When undo removes the first turn, the branch has no user message left and
|
||||
// still carries the source title, which was auto-titled from the removed
|
||||
// prompt. Reset it to the untitled placeholder so `autoTitleSession` can
|
||||
// retitle the branch from the prompt the user sends next; otherwise the
|
||||
// branch is permanently labeled with text that is no longer its prompt.
|
||||
// A retained user turn can legitimately carry empty text: a note-only send
|
||||
// leaves `content` blank while the mentioned note rides on the attachment
|
||||
// (and its title) — that turn still anchored the conversation, so it must
|
||||
// keep the note-derived title and suppress the reset.
|
||||
const hasRetainedUserMessage = retainedPrefix.some(
|
||||
message => message.role === 'user',
|
||||
);
|
||||
const branchedSession: AISession = {
|
||||
...source,
|
||||
id: options.newId,
|
||||
messages: retainedPrefix,
|
||||
...(hasRetainedUserMessage ? {} : { title: 'New Chat' }),
|
||||
externalSessionId: undefined,
|
||||
createdAt: options.now,
|
||||
updatedAt: options.now,
|
||||
};
|
||||
return {
|
||||
session: branchedSession,
|
||||
restored: {
|
||||
text: removedUserMessage.content,
|
||||
attachments: buildRestoredAttachments(removedUserMessage, options.now),
|
||||
// The undone turn was selected with these skill pills: they shaped the
|
||||
// model context, so resending the turn must not silently omit them.
|
||||
selectedUserSkillSlugs: [...(removedUserMessage.selectedUserSkillSlugs ?? [])],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -59,6 +59,12 @@ test("applyGroupDefaults lets host device type override group device type", () =
|
||||
assert.equal(result.deviceType, "general");
|
||||
});
|
||||
|
||||
test("applyGroupDefaults skips remote-only device type for local shell hosts", () => {
|
||||
const result = applyGroupDefaults(host({ protocol: "local" }), { deviceType: "network" });
|
||||
|
||||
assert.equal(result.deviceType, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults inherits startup command run mode", () => {
|
||||
const result = applyGroupDefaults(host(), { startupCommandRunMode: "paste" });
|
||||
|
||||
@@ -638,3 +644,87 @@ test("applyGroupDefaults still inherits non-credential defaults into serial host
|
||||
assert.equal(result.username, undefined);
|
||||
assert.equal(result.charset, "UTF-8");
|
||||
});
|
||||
|
||||
test("applyGroupDefaults does not inherit group SSH credentials into local shell hosts", () => {
|
||||
const groupDefaults: Partial<GroupConfig> = {
|
||||
username: "group-ssh-user",
|
||||
password: "group-ssh-password",
|
||||
savePassword: true,
|
||||
authMethod: "password",
|
||||
identityId: "identity-1",
|
||||
identityFileId: "identity-file-1",
|
||||
};
|
||||
|
||||
const result = applyGroupDefaults(
|
||||
host({ protocol: "local", username: undefined, password: undefined }),
|
||||
groupDefaults,
|
||||
);
|
||||
|
||||
assert.equal(result.username, undefined);
|
||||
assert.equal(result.password, undefined);
|
||||
assert.equal(result.savePassword, undefined);
|
||||
assert.equal(result.authMethod, undefined);
|
||||
assert.equal(result.identityId, undefined);
|
||||
assert.equal(result.identityFileId, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults does not inherit SSH transport/input defaults into local shell hosts", () => {
|
||||
const groupDefaults: Partial<GroupConfig> = {
|
||||
backspaceBehavior: "ctrl-h",
|
||||
moshEnabled: true,
|
||||
moshServerPath: "/usr/bin/mosh-server",
|
||||
etEnabled: true,
|
||||
etPort: 8737,
|
||||
legacyAlgorithms: true,
|
||||
skipEcdsaHostKey: true,
|
||||
agentForwarding: true,
|
||||
hostChain: { hostIds: ["bastion-1"] },
|
||||
charset: "UTF-8",
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const result = applyGroupDefaults(
|
||||
host({
|
||||
protocol: "local",
|
||||
backspaceBehavior: undefined,
|
||||
algorithms: undefined,
|
||||
charset: undefined,
|
||||
fontSize: undefined,
|
||||
}),
|
||||
groupDefaults,
|
||||
);
|
||||
|
||||
assert.equal(result.backspaceBehavior, undefined);
|
||||
assert.equal(result.moshEnabled, undefined);
|
||||
assert.equal(result.moshServerPath, undefined);
|
||||
assert.equal(result.etEnabled, undefined);
|
||||
assert.equal(result.etPort, undefined);
|
||||
assert.equal(result.legacyAlgorithms, undefined);
|
||||
assert.equal(result.skipEcdsaHostKey, undefined);
|
||||
assert.equal(result.agentForwarding, undefined);
|
||||
assert.equal(result.hostChain, undefined);
|
||||
// Display/appearance defaults still apply to local shells.
|
||||
assert.equal(result.charset, "UTF-8");
|
||||
assert.equal(result.fontSize, 14);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults still inherits non-credential defaults into local shell hosts", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host({ protocol: "local", username: undefined, charset: undefined, fontSize: undefined }),
|
||||
{ username: "group-ssh-user", charset: "UTF-8", fontSize: 14 },
|
||||
);
|
||||
|
||||
assert.equal(result.username, undefined);
|
||||
assert.equal(result.charset, "UTF-8");
|
||||
assert.equal(result.fontSize, 14);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults does not inherit the group startup command into local shell hosts", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host({ protocol: "local" }),
|
||||
{ startupCommand: "rm -rf /", startupCommandRunMode: "paste" },
|
||||
);
|
||||
|
||||
assert.equal(result.startupCommand, undefined);
|
||||
assert.equal(result.startupCommandRunMode, "paste");
|
||||
});
|
||||
@@ -176,6 +176,25 @@ const SSH_CREDENTIAL_KEYS = new Set<keyof GroupConfig>([
|
||||
'identityFilePaths',
|
||||
]);
|
||||
|
||||
// SSH transport/input settings the local-shell editor does not expose: input
|
||||
// remapping (backspace), Mosh/Eternal-Terminal transports, and SSH-only
|
||||
// algorithm/agent/chain flags. A local shell never runs through SSH, so these
|
||||
// defaults would either remap local PTY input (`backspaceBehavior`,
|
||||
// createXTermRuntime) or make flag-first consumers report a purely local
|
||||
// session as Mosh/ET — with no way to override them in the local editor.
|
||||
const SSH_ONLY_TRANSPORT_KEYS = new Set<keyof GroupConfig>([
|
||||
'agentForwarding',
|
||||
'legacyAlgorithms',
|
||||
'skipEcdsaHostKey',
|
||||
'algorithms',
|
||||
'hostChain',
|
||||
'moshEnabled',
|
||||
'moshServerPath',
|
||||
'etEnabled',
|
||||
'etPort',
|
||||
'backspaceBehavior',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
|
||||
* Returns a new host object — does NOT mutate the original.
|
||||
@@ -217,9 +236,21 @@ export function applyGroupDefaults(
|
||||
// group's SSH password to anything that prints a login prompt.
|
||||
const isSerialHost = host.protocol === 'serial';
|
||||
|
||||
// Local-shell hosts run on the user's own machine. Group defaults are
|
||||
// SSH/remote-oriented: the SSH credential bundle and a saved startup
|
||||
// command were written for the group's remote hosts and must never be
|
||||
// inherited into a local shell, or clicking a local entry would
|
||||
// type/execute that command on the local machine — and "Copy Credentials"
|
||||
// on a local shell would copy an unrelated remote password. `deviceType`
|
||||
// is equally remote-only: a saved "network" device type would make a
|
||||
// local CMD/PowerShell/POSIX shell be treated as a network-device CLI
|
||||
// (no normal autocomplete, network semantics for AI/command execution).
|
||||
const isLocalShellHost = host.protocol === 'local';
|
||||
|
||||
for (const key of INHERITABLE_KEYS) {
|
||||
if (shouldSkipGroupSshCredentialBundle && SSH_CREDENTIAL_KEYS.has(key)) continue;
|
||||
if (isSerialHost && SSH_CREDENTIAL_KEYS.has(key)) continue;
|
||||
if (isLocalShellHost && (SSH_CREDENTIAL_KEYS.has(key) || SSH_ONLY_TRANSPORT_KEYS.has(key) || key === 'startupCommand' || key === 'deviceType')) continue;
|
||||
if (key === 'password' && effective.savePassword === false) continue;
|
||||
if (key === 'telnetIdentityId' && hostHasManualTelnetCredentials) continue;
|
||||
if (key === 'proxyProfileId') {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import type { TerminalSession } from './models';
|
||||
import { findReusableHostSession } from './hostOpenSession';
|
||||
|
||||
function tab(id: string, status: TerminalSession['status'], extra: Partial<TerminalSession> = {}): TerminalSession {
|
||||
return { id, hostId: 'host-1', hostLabel: 'Host', hostname: 'example.invalid', username: 'user', status, ...extra };
|
||||
}
|
||||
|
||||
test('host reuse prefers a connected tab over a connecting tab for the same host', () => {
|
||||
assert.deepEqual(findReusableHostSession([
|
||||
tab('other-host', 'connected', { hostId: 'host-2' }),
|
||||
tab('retrying', 'connecting'),
|
||||
tab('live', 'connected'),
|
||||
], 'host-1'), { sessionId: 'live', connected: true });
|
||||
});
|
||||
|
||||
test('host reuse keeps the same tab during the reconnect delay and connection attempt', () => {
|
||||
assert.deepEqual(findReusableHostSession([tab('retrying', 'connecting')], 'host-1'), {
|
||||
sessionId: 'retrying', connected: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('host reuse excludes restored placeholders and inactive disconnected tabs', () => {
|
||||
assert.equal(findReusableHostSession([
|
||||
tab('manual-disconnect', 'disconnected'),
|
||||
tab('restored', 'disconnected', { restoreState: 'restored-disconnected' }),
|
||||
tab('restoring', 'connecting', { restoreState: 'restored-disconnected' }),
|
||||
], 'host-1'), undefined);
|
||||
assert.equal(findReusableHostSession([tab('live', 'connected')], ''), undefined);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { TerminalSession } from './models';
|
||||
|
||||
export function findReusableHostSession(sessions: TerminalSession[], hostId: string) {
|
||||
if (!hostId) return undefined;
|
||||
// Auto-reconnect publishes connecting even during the retry delay. A plain
|
||||
// disconnected/restored tab needs user action and cannot satisfy host_open.
|
||||
const matches = sessions.filter((session) => (
|
||||
session.hostId === hostId
|
||||
&& session.restoreState !== 'restored-disconnected'
|
||||
&& (session.status === 'connected' || session.status === 'connecting')
|
||||
));
|
||||
const picked = matches.find((session) => session.status === 'connected') ?? matches[0];
|
||||
return picked ? { sessionId: picked.id, connected: picked.status === 'connected' } : undefined;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
createLocalShellHost,
|
||||
getLocalShellHostSubtitle,
|
||||
getVaultHostRowSubtitle,
|
||||
resolveHostDefaultLocalShell,
|
||||
} from './localShellHost';
|
||||
|
||||
test('createLocalShellHost builds a grouped local-protocol host', () => {
|
||||
const host = createLocalShellHost({
|
||||
label: 'Project A CMD',
|
||||
shell: 'powershell',
|
||||
shellName: 'Windows PowerShell',
|
||||
shellIcon: 'powershell',
|
||||
group: 'Projects/A',
|
||||
startDir: ' /home/user/work ',
|
||||
tags: ['local', 'build'],
|
||||
});
|
||||
|
||||
assert.equal(host.protocol, 'local');
|
||||
assert.equal(host.hostname, 'localhost');
|
||||
assert.equal(host.username, '');
|
||||
assert.equal(host.label, 'Project A CMD');
|
||||
assert.equal(host.group, 'Projects/A');
|
||||
assert.equal(host.localShell, 'powershell');
|
||||
assert.equal(host.localShellName, 'Windows PowerShell');
|
||||
assert.equal(host.localShellIcon, 'powershell');
|
||||
assert.equal(host.localStartDir, '/home/user/work');
|
||||
assert.deepEqual(host.tags, ['local', 'build']);
|
||||
assert.ok(host.id.startsWith('local-'));
|
||||
});
|
||||
|
||||
test('createLocalShellHost falls back to defaults for empty input', () => {
|
||||
const host = createLocalShellHost({});
|
||||
assert.equal(host.label, 'Local Shell');
|
||||
assert.equal(host.localShell, undefined);
|
||||
assert.equal(host.localShellName, undefined);
|
||||
assert.equal(host.localStartDir, undefined);
|
||||
assert.deepEqual(host.tags, ['local']);
|
||||
assert.equal(host.group, '');
|
||||
assert.equal(host.os, 'linux');
|
||||
});
|
||||
|
||||
test('createLocalShellHost preserves explicit id when editing', () => {
|
||||
const host = createLocalShellHost({ id: 'local-existing', label: 'Renamed' });
|
||||
assert.equal(host.id, 'local-existing');
|
||||
});
|
||||
|
||||
test('getVaultHostRowSubtitle uses shell name for local hosts', () => {
|
||||
assert.equal(
|
||||
getVaultHostRowSubtitle({ protocol: 'local', localShellName: 'CMD', username: '', hostname: 'localhost' }),
|
||||
'CMD',
|
||||
);
|
||||
assert.equal(
|
||||
getVaultHostRowSubtitle({ protocol: 'local', username: '', hostname: 'localhost' }),
|
||||
'Local Shell',
|
||||
);
|
||||
assert.equal(
|
||||
getVaultHostRowSubtitle(
|
||||
{ protocol: 'local', username: '', hostname: 'localhost' },
|
||||
{ localFallback: '本地终端' },
|
||||
),
|
||||
'本地终端',
|
||||
);
|
||||
});
|
||||
|
||||
test('getVaultHostRowSubtitle keeps user@host for remote hosts', () => {
|
||||
assert.equal(
|
||||
getVaultHostRowSubtitle({ protocol: 'ssh', username: 'root', hostname: 'example.com' }),
|
||||
'root@example.com',
|
||||
);
|
||||
assert.equal(
|
||||
getVaultHostRowSubtitle({ protocol: 'telnet', username: '', hostname: '10.0.0.1' }),
|
||||
'@10.0.0.1',
|
||||
);
|
||||
});
|
||||
|
||||
test('getLocalShellHostSubtitle ignores whitespace-only names and allows a fallback', () => {
|
||||
assert.equal(getLocalShellHostSubtitle({ protocol: 'local', localShellName: ' ' }), 'Local Shell');
|
||||
assert.equal(
|
||||
getLocalShellHostSubtitle({ protocol: 'local', localShellName: '' }, '本地终端'),
|
||||
'本地终端',
|
||||
);
|
||||
});
|
||||
|
||||
test('clearing the configured executable does not pass its old custom args to the backend default', () => {
|
||||
const effective = resolveHostDefaultLocalShell(createLocalShellHost({}), {
|
||||
terminalSettings: { localShell: '', localShellArgs: ['--norc'] },
|
||||
resolveShellSetting: () => null,
|
||||
});
|
||||
assert.equal(effective.localShell, '');
|
||||
assert.equal(effective.localShellArgs, undefined);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Host } from './models';
|
||||
|
||||
/**
|
||||
* Vault-side "local shell" (CMD / PowerShell / WSL / custom shell) host entry.
|
||||
*
|
||||
* A vault host with protocol 'local' is grouped, sorted and connected exactly
|
||||
* like any other saved host: clicking it opens a terminal session whose boot
|
||||
* path resolves the host's stored shell (see `startLocal` in
|
||||
* `createTerminalSessionStarters` and the bridge resolution of discovered
|
||||
* shell ids).
|
||||
*/
|
||||
|
||||
export type LocalShellHostOs = 'linux' | 'windows' | 'macos';
|
||||
|
||||
export interface LocalShellHostInput {
|
||||
id?: string;
|
||||
os?: LocalShellHostOs;
|
||||
label?: string;
|
||||
/** Discovered shell id (e.g. "wsl-ubuntu") or a custom shell path/command. Empty = system default. */
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
startDir?: string;
|
||||
group?: string;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const LOCAL_SHELL_HOST_TAG = 'local';
|
||||
|
||||
export const createLocalShellHost = (input: LocalShellHostInput): Host => ({
|
||||
id: input.id ?? `local-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
label: input.label?.trim() || input.shellName?.trim() || 'Local Shell',
|
||||
hostname: 'localhost',
|
||||
username: '',
|
||||
tags: input.tags ?? [LOCAL_SHELL_HOST_TAG],
|
||||
group: input.group?.trim() || '',
|
||||
os: input.os ?? 'linux',
|
||||
protocol: 'local',
|
||||
createdAt: Date.now(),
|
||||
notes: input.notes?.trim() || undefined,
|
||||
localShell: input.shell?.trim() || undefined,
|
||||
localShellArgs: input.shellArgs?.length ? input.shellArgs : undefined,
|
||||
localShellName: input.shellName?.trim() || undefined,
|
||||
localShellIcon: input.shellIcon?.trim() || undefined,
|
||||
localStartDir: input.startDir?.trim() || undefined,
|
||||
});
|
||||
|
||||
/** What an existing vault host shows as its local-shell display name. */
|
||||
export const getLocalShellHostSubtitle = (
|
||||
host: Pick<Host, 'protocol' | 'localShellName'>,
|
||||
fallback = 'Local Shell',
|
||||
): string => host.localShellName?.trim() || fallback;
|
||||
|
||||
export interface VaultHostRowSubtitleOptions {
|
||||
/** Translated fallback shown for default-shell local hosts. */
|
||||
localFallback?: string;
|
||||
}
|
||||
|
||||
/** Row subtitle shared by the vault tree rows and host cards. */
|
||||
export const getVaultHostRowSubtitle = (
|
||||
host: Pick<Host, 'protocol' | 'localShellName' | 'username' | 'hostname'>,
|
||||
options?: VaultHostRowSubtitleOptions,
|
||||
): string => (
|
||||
host.protocol === 'local'
|
||||
? getLocalShellHostSubtitle(host, options?.localFallback)
|
||||
: `${host.username ?? ''}@${host.hostname ?? ''}`
|
||||
);
|
||||
|
||||
export interface ResolveDefaultLocalShellContext {
|
||||
discoveredShells?: DiscoveredShell[];
|
||||
terminalSettings?: { localShell?: string; localShellArgs?: string[]; localStartDir?: string };
|
||||
resolveShellSetting?: (
|
||||
localShell: string,
|
||||
discoveredShells: DiscoveredShell[],
|
||||
customArgs?: string[],
|
||||
) => { command: string; args?: string[] } | null;
|
||||
}
|
||||
|
||||
/** Resolve launch settings before a local session snapshots them. */
|
||||
export const resolveHostDefaultLocalShell = (
|
||||
host: Host,
|
||||
ctx: ResolveDefaultLocalShellContext,
|
||||
): Host => {
|
||||
if (host.protocol !== 'local') return host;
|
||||
const configuredShell = host.localShell || ctx.terminalSettings?.localShell || '';
|
||||
const customArgs = host.localShell
|
||||
? host.localShellArgs
|
||||
: configuredShell ? ctx.terminalSettings?.localShellArgs : undefined;
|
||||
const resolved = ctx.resolveShellSetting?.(
|
||||
configuredShell, ctx.discoveredShells ?? [], customArgs,
|
||||
);
|
||||
const matchedShell = ctx.discoveredShells?.find((shell) => shell.id === configuredShell);
|
||||
return {
|
||||
...host,
|
||||
// Empty means the backend default, not a later Vault/Settings value.
|
||||
localShell: resolved?.command || configuredShell || '',
|
||||
localShellArgs: resolved?.args ?? customArgs,
|
||||
localShellName: host.localShell ? host.localShellName : matchedShell?.name,
|
||||
localShellIcon: host.localShell ? host.localShellIcon : matchedShell?.icon,
|
||||
// The existing launcher expands ~ to home. Capture it explicitly when
|
||||
// neither host nor Settings supplies a directory, so a later global edit
|
||||
// cannot change a copied session's original launch directory.
|
||||
localStartDir: host.localStartDir || ctx.terminalSettings?.localStartDir || '~',
|
||||
};
|
||||
};
|
||||
@@ -536,7 +536,10 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
middleClickPaste: true,
|
||||
wordSeparators: DEFAULT_TERMINAL_WORD_SEPARATORS,
|
||||
linkModifier: 'none',
|
||||
autoCloseOnExit: true,
|
||||
// Off by default: remote graceful channel closes can be reported as a clean
|
||||
// exit (code 0), which would auto-close tabs on network drops. Users who
|
||||
// want close-on-exit can enable it in Settings -> Terminal -> Behavior.
|
||||
autoCloseOnExit: false,
|
||||
// Issue #3087: keep terminal history visible after an established session disconnects.
|
||||
disconnectedNoticeMode: 'terminal',
|
||||
keywordHighlightEnabled: true,
|
||||
@@ -552,7 +555,11 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
verifyHostKeys: true,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 10,
|
||||
sshAutoReconnectEnabled: false,
|
||||
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
|
||||
// in-place reconnect every 5s with the same session id. Clean shell exits
|
||||
// still close the tab, and administrative idle/timeout exits stay
|
||||
// disconnected. Users on always-stable links can turn it off.
|
||||
sshAutoReconnectEnabled: true,
|
||||
x11Display: '', // Empty = use DISPLAY/default local X server
|
||||
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
|
||||
showHostInfoBar: true, // Preserve the existing host information bar by default
|
||||
|
||||
@@ -864,3 +864,14 @@ test("resolveInheritedCwdIntent: skips mosh/et and network devices and bad paths
|
||||
test("resolveInheritedCwdIntent: skips windows local shells", () => {
|
||||
assert.equal(resolveInheritedCwdIntent({ session: { protocol: "local", shellType: "powershell", cwd: "/x" }, isNetworkDevice: false }), null);
|
||||
});
|
||||
|
||||
test("restore retains an explicit backend-default local shell snapshot", () => {
|
||||
const payload = buildSessionRestorePayload({
|
||||
sessions: [{ ...session("local-default"), protocol: "local", localShell: "", localShellArgs: [], localStartDir: "~" }],
|
||||
workspaces: [], tabOrder: [], activeTabId: "local-default", now: 123,
|
||||
});
|
||||
const restored = sanitizeSessionRestorePayload(JSON.parse(JSON.stringify(payload)));
|
||||
assert.equal(restored.sessions[0].localShell, "");
|
||||
assert.deepEqual(restored.sessions[0].localShellArgs, []);
|
||||
assert.equal(restored.sessions[0].localStartDir, "~");
|
||||
});
|
||||
@@ -152,7 +152,7 @@ const restoreSession = (session: TerminalSession): RestoredTerminalSession => {
|
||||
...(shellType ? { shellType } : {}),
|
||||
...(session.charset ? { charset: session.charset } : {}),
|
||||
...(serialConfig ? { serialConfig } : {}),
|
||||
...(session.localShell ? { localShell: session.localShell } : {}),
|
||||
...(session.localShell !== undefined ? { localShell: session.localShell } : {}),
|
||||
...(session.localShellArgs ? { localShellArgs: [...session.localShellArgs] } : {}),
|
||||
...(session.localShellName ? { localShellName: session.localShellName } : {}),
|
||||
...(session.localShellIcon ? { localShellIcon: session.localShellIcon } : {}),
|
||||
@@ -195,7 +195,7 @@ const restoreSessionFromUnknown = (value: unknown): RestoredTerminalSession | nu
|
||||
...(shellType ? { shellType } : {}),
|
||||
...(readString(value, "charset") ? { charset: readString(value, "charset") } : {}),
|
||||
...(serialConfig ? { serialConfig } : {}),
|
||||
...(readString(value, "localShell") ? { localShell: readString(value, "localShell") } : {}),
|
||||
...(readStringAllowEmpty(value, "localShell") !== undefined ? { localShell: readStringAllowEmpty(value, "localShell") } : {}),
|
||||
...(Array.isArray(value.localShellArgs) ? { localShellArgs: value.localShellArgs.filter((arg): arg is string => typeof arg === "string") } : {}),
|
||||
...(readString(value, "localShellName") ? { localShellName: readString(value, "localShellName") } : {}),
|
||||
...(readString(value, "localShellIcon") ? { localShellIcon: readString(value, "localShellIcon") } : {}),
|
||||
|
||||
@@ -46,6 +46,9 @@ export type DualPaneSftpPlan = {
|
||||
export function canOpenDualPaneSftp(host: { protocol?: string }): boolean {
|
||||
const protocol = host.protocol ?? "ssh";
|
||||
if (protocol === "serial") return false;
|
||||
// Local-shell vault hosts have no SFTP/SSH channel: opening dual-pane SFTP
|
||||
// from one would try to SSH into localhost with empty credentials.
|
||||
if (protocol === "local") return false;
|
||||
if (isPluginHostProtocol(protocol)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -313,6 +313,12 @@ export interface SyncPayload {
|
||||
terminalFontFamily?: string;
|
||||
terminalFontSize?: number;
|
||||
terminalSettings?: Record<string, unknown>;
|
||||
/**
|
||||
* Present once the sender has run the autoCloseOnExit default migration.
|
||||
* Legacy payloads omit it; a materialized `true` in those payloads is the
|
||||
* old default, not an explicit opt-in.
|
||||
*/
|
||||
autoCloseOnExitDefaultMigrated?: boolean;
|
||||
terminalSidePanelAutoOpen?: boolean;
|
||||
terminalSidePanelAutoOpenTab?: import('./terminalSidePanelAutoOpen').TerminalSidePanelAutoOpenTab;
|
||||
localShellSidePanelAutoOpen?: boolean;
|
||||
@@ -390,6 +396,27 @@ export interface SyncPayload {
|
||||
convergentSync?: ConvergentSyncEnvelopeV2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-migration snapshots stored autoCloseOnExit: true as the default and do
|
||||
* not carry `autoCloseOnExitDefaultMigrated`. Rewrite that legacy true to
|
||||
* false. A payload that records the migration keeps an explicit true.
|
||||
*/
|
||||
export function reconcileInboundAutoCloseOnExit(
|
||||
autoCloseOnExit: unknown,
|
||||
payloadRecordsMigratedChoice: unknown,
|
||||
): unknown {
|
||||
if (autoCloseOnExit === true && payloadRecordsMigratedChoice !== true) return false;
|
||||
return autoCloseOnExit;
|
||||
}
|
||||
|
||||
/** True when this side both migrated and explicitly enabled auto-close. */
|
||||
export function payloadOptsIntoAutoClose(
|
||||
settings: SyncPayload['settings'] | undefined,
|
||||
): boolean {
|
||||
return settings?.autoCloseOnExitDefaultMigrated === true
|
||||
&& settings.terminalSettings?.autoCloseOnExit === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip device-local connection telemetry from hosts before any sync
|
||||
* comparison or upload (#2629). `lastConnectedAt` updates on every successful
|
||||
|
||||
@@ -631,3 +631,44 @@ test("group notes sync edits and clearing without dropping connection settings",
|
||||
const cleared = payload({ customGroups: ["Project"], groupConfigs: [{ path: "Project", username: "admin" }] });
|
||||
assert.equal(mergeSyncPayloads(merged, merged, cleared).payload.groupConfigs?.[0].notes, undefined);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads does not glue a local migration marker onto a legacy auto-close true", () => {
|
||||
const merged = mergeSyncPayloads(
|
||||
null,
|
||||
payload({
|
||||
settings: {
|
||||
autoCloseOnExitDefaultMigrated: true,
|
||||
terminalSettings: { autoCloseOnExit: false, scrollback: 3000 },
|
||||
},
|
||||
}),
|
||||
payload({
|
||||
settings: {
|
||||
terminalSettings: { autoCloseOnExit: true, scrollback: 3000 },
|
||||
},
|
||||
}),
|
||||
).payload.settings;
|
||||
|
||||
assert.equal(merged?.terminalSettings?.autoCloseOnExit, false);
|
||||
assert.equal(merged?.terminalSettings?.scrollback, 3000);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads keeps an explicit auto-close opt-in from a migrated peer", () => {
|
||||
const merged = mergeSyncPayloads(
|
||||
null,
|
||||
payload({
|
||||
settings: {
|
||||
autoCloseOnExitDefaultMigrated: true,
|
||||
terminalSettings: { autoCloseOnExit: false },
|
||||
},
|
||||
}),
|
||||
payload({
|
||||
settings: {
|
||||
autoCloseOnExitDefaultMigrated: true,
|
||||
terminalSettings: { autoCloseOnExit: true },
|
||||
},
|
||||
}),
|
||||
).payload.settings;
|
||||
|
||||
assert.equal(merged?.autoCloseOnExitDefaultMigrated, true);
|
||||
assert.equal(merged?.terminalSettings?.autoCloseOnExit, true);
|
||||
});
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
|
||||
import {
|
||||
payloadOptsIntoAutoClose,
|
||||
sanitizeHostsForSync,
|
||||
type CloudSyncPayloadEntityKey,
|
||||
type SyncPayload,
|
||||
@@ -403,6 +404,24 @@ function mergeSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// A three-way merge can keep this device's migration flag while adopting a
|
||||
// legacy remote autoCloseOnExit: true. That true is the old default unless
|
||||
// one of the sides explicitly opted in after migrating.
|
||||
const terminalSettings = merged.terminalSettings;
|
||||
if (
|
||||
terminalSettings
|
||||
&& typeof terminalSettings === 'object'
|
||||
&& !Array.isArray(terminalSettings)
|
||||
&& (terminalSettings as Record<string, unknown>).autoCloseOnExit === true
|
||||
&& !payloadOptsIntoAutoClose(local)
|
||||
&& !payloadOptsIntoAutoClose(remote)
|
||||
) {
|
||||
merged.terminalSettings = {
|
||||
...(terminalSettings as Record<string, unknown>),
|
||||
autoCloseOnExit: false,
|
||||
};
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,79 @@ test("resolveTerminalSessionHost keeps the session plugin configuration when the
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost preserves the session local-shell snapshot over an edited Vault host", () => {
|
||||
const host: Host = {
|
||||
id: "target",
|
||||
label: "Edited PowerShell",
|
||||
hostname: "localhost",
|
||||
username: "local",
|
||||
protocol: "local",
|
||||
tags: [],
|
||||
os: "windows",
|
||||
localShell: "powershell.exe",
|
||||
localShellArgs: ["-NoLogo"],
|
||||
localShellName: "PowerShell",
|
||||
localShellIcon: "powershell",
|
||||
localStartDir: "C:/edited",
|
||||
};
|
||||
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session: {
|
||||
...baseSession,
|
||||
hostname: "localhost",
|
||||
username: "local",
|
||||
port: undefined,
|
||||
protocol: "local",
|
||||
localShell: "cmd.exe",
|
||||
localShellArgs: [],
|
||||
localShellName: "Command Prompt",
|
||||
localShellIcon: "cmd",
|
||||
localStartDir: "C:/project",
|
||||
},
|
||||
hosts: [host],
|
||||
groupConfigs: [],
|
||||
proxyProfiles,
|
||||
localOs: "windows",
|
||||
});
|
||||
|
||||
assert.equal(resolved.localShell, "cmd.exe");
|
||||
assert.deepEqual(resolved.localShellArgs, []);
|
||||
assert.equal(resolved.localShellName, "Command Prompt");
|
||||
assert.equal(resolved.localShellIcon, "cmd");
|
||||
assert.equal(resolved.localStartDir, "C:/project");
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost falls back to the saved local shell when the session has no snapshot", () => {
|
||||
const host: Host = {
|
||||
id: "target",
|
||||
label: "Local Shell",
|
||||
hostname: "localhost",
|
||||
username: "local",
|
||||
protocol: "local",
|
||||
tags: [],
|
||||
os: "windows",
|
||||
localShell: "powershell.exe",
|
||||
localShellName: "PowerShell",
|
||||
};
|
||||
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session: {
|
||||
...baseSession,
|
||||
hostname: "localhost",
|
||||
username: "local",
|
||||
port: undefined,
|
||||
protocol: "local",
|
||||
},
|
||||
hosts: [host],
|
||||
groupConfigs: [],
|
||||
proxyProfiles,
|
||||
localOs: "windows",
|
||||
});
|
||||
|
||||
assert.equal(resolved.localShell, "powershell.exe");
|
||||
assert.equal(resolved.localShellName, "PowerShell");
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost carries local session start directory into fallback host", () => {
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session: {
|
||||
@@ -488,3 +561,77 @@ test("resolveTerminalChainHosts applies group default proxy profiles to jump hos
|
||||
assert.equal(resolved[0]?.proxyProfileId, "proxy-1");
|
||||
assert.deepEqual(resolved[0]?.proxyConfig, proxyProfiles[0].config);
|
||||
});
|
||||
|
||||
test("resolveTerminalChainHosts skips non-SSH jump candidates", () => {
|
||||
const target: Host = {
|
||||
id: "target",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
hostChain: { hostIds: ["jump-local", "jump-serial", "jump-telnet", "jump-ssh", "jump-mosh"] },
|
||||
};
|
||||
const localHost: Host = {
|
||||
id: "jump-local",
|
||||
label: "Local Shell",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
port: 22,
|
||||
protocol: "local",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
const serialHost: Host = {
|
||||
id: "jump-serial",
|
||||
label: "Serial",
|
||||
hostname: "/dev/ttyUSB0",
|
||||
username: "",
|
||||
port: 22,
|
||||
protocol: "serial",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
const telnetHost: Host = {
|
||||
id: "jump-telnet",
|
||||
label: "Telnet",
|
||||
hostname: "telnet.example.test",
|
||||
username: "bob",
|
||||
port: 23,
|
||||
protocol: "telnet",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
const sshHost: Host = {
|
||||
id: "jump-ssh",
|
||||
label: "Jump SSH",
|
||||
hostname: "jump.example.test",
|
||||
username: "jump",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
const moshHost: Host = {
|
||||
id: "jump-mosh",
|
||||
label: "Jump Mosh",
|
||||
hostname: "mosh.example.test",
|
||||
username: "jump",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
moshEnabled: true,
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
|
||||
const resolved = resolveTerminalChainHosts({
|
||||
host: target,
|
||||
hosts: [target, localHost, serialHost, telnetHost, sshHost, moshHost],
|
||||
groupConfigs: [],
|
||||
proxyProfiles,
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved.map((host) => host.id), ["jump-ssh", "jump-mosh"]);
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import type { GroupConfig, Host, ProxyProfile, TerminalSession } from "./models"
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from "./groupConfig";
|
||||
import { materializeHostProxyProfile } from "./proxyProfiles";
|
||||
import { sanitizePluginConnection } from "./pluginConnection";
|
||||
import { resolveHostDefaultLocalShell, type ResolveDefaultLocalShellContext } from "./localShellHost";
|
||||
import { canServeAsSshJumpHost } from "./terminalProtocol";
|
||||
|
||||
type LocalOs = Host["os"];
|
||||
|
||||
@@ -10,6 +12,8 @@ interface ResolveEffectiveHostOptions {
|
||||
groupConfigs: GroupConfig[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
validProxyProfileIds?: ReadonlySet<string>;
|
||||
/** When provided, saved local hosts with no shell resolve the Settings shell. */
|
||||
defaultLocalShell?: ResolveDefaultLocalShellContext;
|
||||
}
|
||||
|
||||
interface ResolveTerminalSessionHostOptions {
|
||||
@@ -18,6 +22,8 @@ interface ResolveTerminalSessionHostOptions {
|
||||
groupConfigs: GroupConfig[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
localOs: LocalOs;
|
||||
/** When provided, saved local hosts with no shell resolve the Settings shell. */
|
||||
defaultLocalShell?: ResolveDefaultLocalShellContext;
|
||||
}
|
||||
|
||||
interface ResolveTerminalChainHostsOptions {
|
||||
@@ -41,14 +47,18 @@ export function resolveEffectiveTerminalHost({
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
defaultLocalShell,
|
||||
}: ResolveEffectiveHostOptions): Host {
|
||||
const groupDefaults = host.group
|
||||
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds })
|
||||
: {};
|
||||
return materializeHostProxyProfile(
|
||||
const effectiveHost = materializeHostProxyProfile(
|
||||
applyGroupDefaults(host, groupDefaults, { validProxyProfileIds }),
|
||||
proxyProfiles,
|
||||
);
|
||||
return defaultLocalShell
|
||||
? resolveHostDefaultLocalShell(effectiveHost, defaultLocalShell)
|
||||
: effectiveHost;
|
||||
}
|
||||
|
||||
const suppressDeviceTypeForShellTransport = (host: Host): Host => {
|
||||
@@ -86,12 +96,30 @@ function buildFallbackHostFromSession(
|
||||
};
|
||||
}
|
||||
|
||||
// A local session carries a shell snapshot (shell path, args, name, icon,
|
||||
// start dir) captured at launch time; the saved host may have been edited
|
||||
// since. Popup/clone flows must relaunch the session's shell, not the host's
|
||||
// current one, so overlay the snapshot onto the resolved host.
|
||||
const overlaySessionLocalShell = (session: TerminalSession, host: Host): Host => {
|
||||
if (session.protocol !== "local") return host;
|
||||
return {
|
||||
...host,
|
||||
username: session.username,
|
||||
localShell: session.localShell ?? host.localShell,
|
||||
localShellArgs: session.localShellArgs ?? host.localShellArgs,
|
||||
localShellName: session.localShellName ?? host.localShellName,
|
||||
localShellIcon: session.localShellIcon ?? host.localShellIcon,
|
||||
localStartDir: session.localStartDir ?? host.localStartDir,
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
localOs,
|
||||
defaultLocalShell,
|
||||
}: ResolveTerminalSessionHostOptions): Host {
|
||||
const vaultHost = hosts.find((host) => host.id === session.hostId);
|
||||
if (!vaultHost) return buildFallbackHostFromSession(session, localOs);
|
||||
@@ -100,6 +128,7 @@ export function resolveTerminalSessionHost({
|
||||
host: vaultHost,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
defaultLocalShell,
|
||||
});
|
||||
|
||||
const protocol = session.protocol ?? existingHost.protocol;
|
||||
@@ -126,10 +155,12 @@ export function resolveTerminalSessionHost({
|
||||
JSON.stringify(pluginConnection) === JSON.stringify(existingHost.pluginConnection) &&
|
||||
backspaceBehavior === existingHost.backspaceBehavior
|
||||
) {
|
||||
return suppressDeviceTypeForShellTransport(existingHost);
|
||||
return suppressDeviceTypeForShellTransport(
|
||||
overlaySessionLocalShell(session, existingHost),
|
||||
);
|
||||
}
|
||||
|
||||
return suppressDeviceTypeForShellTransport({
|
||||
return suppressDeviceTypeForShellTransport(overlaySessionLocalShell(session, {
|
||||
...existingHost,
|
||||
protocol,
|
||||
port,
|
||||
@@ -137,7 +168,7 @@ export function resolveTerminalSessionHost({
|
||||
etEnabled,
|
||||
pluginConnection,
|
||||
backspaceBehavior,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveTerminalChainHosts({
|
||||
@@ -152,7 +183,9 @@ export function resolveTerminalChainHosts({
|
||||
return host.hostChain.hostIds
|
||||
.map((hostId) => {
|
||||
const chainHost = hostMap.get(hostId);
|
||||
if (!chainHost) return undefined;
|
||||
// Jump hops are dialed over SSH; skip local/serial/telnet/plugin entries
|
||||
// that can never serve as a ProxyJump hop (e.g. saved local shells).
|
||||
if (!chainHost || !canServeAsSshJumpHost(chainHost)) return undefined;
|
||||
return resolveEffectiveTerminalHost({
|
||||
host: chainHost,
|
||||
groupConfigs,
|
||||
|
||||
@@ -11,3 +11,18 @@ export function resolveEffectiveTerminalProtocol(host: TerminalTransportHost): H
|
||||
if (host.hostname === 'localhost') return 'local';
|
||||
return host.protocol ?? 'ssh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a vault host can serve as an SSH jump (ProxyJump) hop.
|
||||
*
|
||||
* Chain hops are dialed over SSH, so ssh-family transports (including mosh/et
|
||||
* hosts, which bootstrap over SSH) qualify, while local shells, serial links,
|
||||
* telnet and plugin transports must never be materialized as jump hosts.
|
||||
* Hosts without an explicit protocol are legacy SSH entries.
|
||||
*/
|
||||
export function canServeAsSshJumpHost(
|
||||
host: Pick<Host, 'protocol'>,
|
||||
): boolean {
|
||||
const protocol = host.protocol;
|
||||
return protocol === undefined || protocol === 'ssh' || protocol === 'mosh' || protocol === 'et';
|
||||
}
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user