mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e794b1300e | ||
|
|
a7c67033c6 | ||
|
|
45af6072fd | ||
|
|
15b16ab7b2 | ||
|
|
f83b5f6693 | ||
|
|
8017c144fe | ||
|
|
462ffbb182 | ||
|
|
4288948d94 | ||
|
|
66deeb8dd9 | ||
|
|
f0b3bca13f | ||
|
|
6c05f0b450 | ||
|
|
aaced70fc6 | ||
|
|
891faff31c | ||
|
|
08557ff596 | ||
|
|
7f75662c9f | ||
|
|
561f4b3c71 | ||
|
|
6ea6c23e88 | ||
|
|
55c2b18738 | ||
|
|
84bef23e1f | ||
|
|
ddd2bcb08f | ||
|
|
d9b23e690a | ||
|
|
abe7433509 | ||
|
|
8be56264fc |
No files matched your search
@@ -76,6 +76,7 @@ on:
|
||||
- "scripts/mosh-extra-resources.cjs"
|
||||
- "scripts/nodePtyConptyPatch.cjs"
|
||||
- "scripts/patch-xterm-macos-column-selection.cjs"
|
||||
- "scripts/patch-xterm-foreground-intense.cjs"
|
||||
- "scripts/patch-xterm-webgl-atlas.cjs"
|
||||
- "scripts/rebuildPatchedNodePty.cjs"
|
||||
- "scripts/resolve-et-bin-release.cjs"
|
||||
@@ -695,11 +696,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Security CDN endpoints can lack files advertised in their indexes.
|
||||
# Fall back per file, with the final Bullseye security snapshot for packages only.
|
||||
# Live signed indexes still select versions and verify every download.
|
||||
printf 'http://deb.debian.org/debian-security\tpriority:1\nhttp://security.debian.org/debian-security\tpriority:2\nhttp://snapshot.debian.org/archive/debian-security/20260901T000000Z/\tpriority:3 type:deb\n' > /etc/apt/security-mirrors.txt
|
||||
sed -i 's|http://deb.debian.org/debian-security|mirror+file:/etc/apt/security-mirrors.txt|g' /etc/apt/sources.list
|
||||
# Bullseye LTS ended on 2026-08-31; its security index is now frozen.
|
||||
# Pin the final snapshot and waive expiry only for this archived source.
|
||||
# APT still verifies its signature and every package's checksum.
|
||||
sed -i '/ bullseye-security /d' /etc/apt/sources.list
|
||||
printf 'deb [check-valid-until=no] http://snapshot.debian.org/archive/debian-security/20260901T000000Z/ bullseye-security main\n' >> /etc/apt/sources.list
|
||||
apt-get -o Acquire::Retries=4 update
|
||||
apt-get -o Acquire::Retries=4 install -y curl build-essential python3 git libfuse2 file rpm \
|
||||
libarchive-tools \
|
||||
|
||||
@@ -1,57 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { closeTabsBatchImpl } from './app/AppHandlers.ts';
|
||||
|
||||
test('batch tab close removes standalone sessions in one state update', async () => {
|
||||
const closedSessionBatches: string[][] = [];
|
||||
const probedSessionBatches: string[][] = [];
|
||||
const closeTabsInFlightRef = { current: false };
|
||||
const sessions = [
|
||||
{ id: 's1', protocol: 'ssh' },
|
||||
{ id: 's2', protocol: 'ssh' },
|
||||
{ id: 's3', protocol: 'ssh' },
|
||||
];
|
||||
function setup() {
|
||||
const closed: string[] = [];
|
||||
const probes: string[][] = [];
|
||||
const editors = [{ id: 'e', sessionId: 'connection', sftpTabId: 'pane' }];
|
||||
let active = 's1';
|
||||
const ctx = {
|
||||
closeLogView: (id: string) => { closed.push(id); },
|
||||
closeSessions: (ids: string[]) => { closed.push(...ids); },
|
||||
closeTabsInFlightRef: { current: false },
|
||||
closeWorkspace: (id: string) => { closed.push(id); },
|
||||
confirmIfBusyLocalTerminal: async (ids: string[]) => { probes.push(ids); return true; },
|
||||
logViews: [{ id: 'log' }],
|
||||
sessions: [{ id: 's1', workspaceId: null as string | null }, { id: 's2', workspaceId: null as string | null }],
|
||||
workspaces: [{ id: 'ws' }],
|
||||
activeTabStore: { getActiveTabId: () => active, setActiveTabId: (id: string) => { active = id; } },
|
||||
editorTabStore: { getTabs: () => editors },
|
||||
pluginViewTabStore: {
|
||||
getTab: (id: string) => id === 'plugin',
|
||||
close: (id: string) => { closed.push(id); },
|
||||
},
|
||||
handleRequestCloseEditorTabRef: { current: async (id: string) => {
|
||||
closed.push(`editor:${id}`);
|
||||
editors.splice(editors.findIndex((editor) => editor.id === id), 1);
|
||||
return true;
|
||||
} },
|
||||
findEditorSftpOwnerTabId: (_connection?: string, _pane?: string): string | null => null,
|
||||
orderedTabsWithEditors: ['s1', 's2', 'ws', 'editor:e', 'log', 'plugin'],
|
||||
};
|
||||
return { ctx, closed, probes, run: (ids: string[]) => closeTabsBatchImpl(() => ctx, ids) };
|
||||
}
|
||||
|
||||
const result = await closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView: () => {},
|
||||
closeSessions: (sessionIds: string[]) => closedSessionBatches.push(sessionIds),
|
||||
closeTabsInFlightRef,
|
||||
closeWorkspace: () => {},
|
||||
confirmIfBusyLocalTerminal: async (sessionIds: string[]) => {
|
||||
probedSessionBatches.push(sessionIds);
|
||||
return true;
|
||||
},
|
||||
logViews: [],
|
||||
sessions,
|
||||
workspaces: [],
|
||||
}),
|
||||
['s1', 's2', 's3'],
|
||||
);
|
||||
|
||||
assert.deepEqual(probedSessionBatches, [['s1', 's2', 's3']]);
|
||||
assert.deepEqual(closedSessionBatches, [['s1', 's2', 's3']]);
|
||||
assert.equal(closeTabsInFlightRef.current, false);
|
||||
assert.equal(result, true);
|
||||
test('batch closes sessions, workspaces, editors, logs and plugins and selects surviving tab', async () => {
|
||||
const { ctx, closed, probes, run } = setup();
|
||||
assert.equal(await run(['s1', 'ws', 'editor:e', 'log', 'plugin']), true);
|
||||
assert.deepEqual(closed, ['editor:e', 's1', 'ws', 'log', 'plugin']);
|
||||
assert.deepEqual(probes, [['s1']]);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 's2');
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
});
|
||||
|
||||
test('batch tab close reports cancellation before mutating any tab', async () => {
|
||||
let mutated = false;
|
||||
const result = await closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView: () => { mutated = true; },
|
||||
closeSessions: () => { mutated = true; },
|
||||
closeTabsInFlightRef: { current: false },
|
||||
closeWorkspace: () => { mutated = true; },
|
||||
confirmIfBusyLocalTerminal: async () => false,
|
||||
logViews: [],
|
||||
sessions: [{ id: 's1', protocol: 'local' }],
|
||||
workspaces: [],
|
||||
}),
|
||||
['s1'],
|
||||
);
|
||||
test('busy cancellation happens before any editor or other tab mutation', async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
ctx.confirmIfBusyLocalTerminal = async () => false;
|
||||
assert.equal(await run(ctx.orderedTabsWithEditors), false);
|
||||
assert.deepEqual(closed, []);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 's1');
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(mutated, false);
|
||||
test('cancelled editor preserves its owner and active focus', async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
ctx.findEditorSftpOwnerTabId = () => 's1';
|
||||
ctx.handleRequestCloseEditorTabRef.current = async () => false;
|
||||
await run(ctx.orderedTabsWithEditors);
|
||||
assert.deepEqual(closed, ['s2', 'ws', 'log', 'plugin']);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 's1');
|
||||
});
|
||||
|
||||
test('editor outside close-left range preserves its owner', async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
ctx.findEditorSftpOwnerTabId = () => 's1';
|
||||
await run(['s1']);
|
||||
assert.deepEqual(closed, []);
|
||||
});
|
||||
|
||||
test('preserved workspace containing an editor owner remains focused', async () => {
|
||||
const { ctx, closed, probes, run } = setup();
|
||||
ctx.sessions[0].workspaceId = 'ws';
|
||||
ctx.findEditorSftpOwnerTabId = () => 's1';
|
||||
ctx.activeTabStore.setActiveTabId('ws');
|
||||
await run(['ws']);
|
||||
assert.deepEqual(closed, []);
|
||||
assert.deepEqual(probes, [['s1']]);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 'ws');
|
||||
});
|
||||
|
||||
test('cancelled editor becomes focus destination when other tabs close', async () => {
|
||||
const { ctx, run } = setup();
|
||||
ctx.handleRequestCloseEditorTabRef.current = async () => false;
|
||||
await run(ctx.orderedTabsWithEditors);
|
||||
assert.equal(ctx.activeTabStore.getActiveTabId(), 'editor:e');
|
||||
});
|
||||
|
||||
for (const phase of ['busy', 'editor'] as const) {
|
||||
test(`overlapping batch is ignored during ${phase} prompt, including editor-only batches`, async () => {
|
||||
const { ctx, closed, run } = setup();
|
||||
let release!: (result: boolean) => void;
|
||||
const pending = new Promise<boolean>((resolve) => { release = resolve; });
|
||||
if (phase === 'busy') ctx.confirmIfBusyLocalTerminal = () => pending;
|
||||
else ctx.handleRequestCloseEditorTabRef.current = () => pending;
|
||||
const first = run(phase === 'busy' ? ['s1'] : ['editor:e']);
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, true);
|
||||
assert.equal(await run(['s2', 'plugin']), false);
|
||||
assert.deepEqual(closed, []);
|
||||
release(false);
|
||||
await first;
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
});
|
||||
}
|
||||
|
||||
test('failed prompt releases guard so next batch can run', async () => {
|
||||
const { ctx, run } = setup();
|
||||
ctx.handleRequestCloseEditorTabRef.current = async () => { throw new Error('save failed'); };
|
||||
await assert.rejects(run(['editor:e']), /save failed/);
|
||||
assert.equal(ctx.closeTabsInFlightRef.current, false);
|
||||
assert.equal(await run(['s1']), true);
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
|
||||
|
||||
type AppContextGetter = () => Record<string, any>;
|
||||
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
|
||||
@@ -677,48 +679,86 @@ export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, s
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
|
||||
const { closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, workspaces } = getCtx();
|
||||
{
|
||||
if (targetIds.length === 0) return true;
|
||||
if (closeTabsInFlightRef.current) return false;
|
||||
|
||||
// Expand workspace ids into their constituent session ids so the busy
|
||||
// probe sees every local shell that's about to be killed.
|
||||
const sessionIdsToProbe: string[] = [];
|
||||
for (const tabId of targetIds) {
|
||||
const ws = workspaces.find((w) => w.id === tabId);
|
||||
if (ws) {
|
||||
for (const s of sessions) {
|
||||
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
||||
}
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
sessionIdsToProbe.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
closeTabsInFlightRef.current = true;
|
||||
try {
|
||||
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
|
||||
if (!ok) return false;
|
||||
const standaloneSessionIds = targetIds.filter((tabId) => (
|
||||
sessions.some((session) => session.id === tabId)
|
||||
));
|
||||
if (standaloneSessionIds.length > 0) {
|
||||
closeSessions(standaloneSessionIds);
|
||||
}
|
||||
for (const tabId of targetIds) {
|
||||
if (workspaces.find((w) => w.id === tabId)) {
|
||||
closeWorkspace(tabId);
|
||||
} else if (logViews.find((lv) => lv.id === tabId)) {
|
||||
closeLogView(tabId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
closeTabsInFlightRef.current = false;
|
||||
/**
|
||||
* Expand batch-close target ids into the terminal session ids whose local
|
||||
* shells the busy probe must inspect: workspace ids contribute every session
|
||||
* they contain; standalone session ids pass through.
|
||||
*/
|
||||
export function collectBatchBusyProbeSessionIds(
|
||||
sessions: readonly { id: string; workspaceId?: string | null }[],
|
||||
workspaces: readonly { id: string }[],
|
||||
targetIds: readonly string[],
|
||||
): string[] {
|
||||
const sessionIdsToProbe: string[] = [];
|
||||
for (const tabId of targetIds) {
|
||||
const ws = workspaces.find((w) => w.id === tabId);
|
||||
if (ws) {
|
||||
for (const s of sessions) {
|
||||
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
||||
}
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
sessionIdsToProbe.push(tabId);
|
||||
}
|
||||
}
|
||||
return sessionIdsToProbe;
|
||||
}
|
||||
|
||||
export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: string[]) {
|
||||
const {
|
||||
closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace,
|
||||
confirmIfBusyLocalTerminal, logViews, sessions, workspaces,
|
||||
activeTabStore, editorTabStore, pluginViewTabStore,
|
||||
handleRequestCloseEditorTabRef, findEditorSftpOwnerTabId, orderedTabsWithEditors,
|
||||
} = getCtx();
|
||||
if (targetIds.length === 0) return true;
|
||||
if (closeTabsInFlightRef.current) return false;
|
||||
closeTabsInFlightRef.current = true;
|
||||
try {
|
||||
const activeBeforeClose = activeTabStore.getActiveTabId();
|
||||
const pluginIds = targetIds.filter((id) => pluginViewTabStore.getTab(id));
|
||||
const editorIds = targetIds.filter(isEditorTabId);
|
||||
const regularIds = targetIds.filter((id) => !pluginViewTabStore.getTab(id) && !isEditorTabId(id));
|
||||
// Cancel busy-terminal confirmation before making any changes, including editors.
|
||||
if (regularIds.length && !await confirmIfBusyLocalTerminal(
|
||||
collectBatchBusyProbeSessionIds(sessions, workspaces, regularIds),
|
||||
)) return false;
|
||||
|
||||
const closedEditorIds: string[] = [];
|
||||
for (const id of editorIds) {
|
||||
if (await handleRequestCloseEditorTabRef.current(fromEditorTabId(id))) closedEditorIds.push(id);
|
||||
}
|
||||
|
||||
// Owners must remain mounted while any editor survives (cancelled or outside
|
||||
// the range), because unmounting their SFTP panel discards its editors.
|
||||
const keepTabIds = new Set<string>();
|
||||
for (const editor of editorTabStore.getTabs()) {
|
||||
const owner = findEditorSftpOwnerTabId(editor.sessionId, editor.sftpTabId);
|
||||
if (owner) keepTabIds.add(owner);
|
||||
}
|
||||
const effectiveRegularIds = regularIds.filter((id) => !keepTabIds.has(id) && !(
|
||||
workspaces.some((workspace) => workspace.id === id) &&
|
||||
sessions.some((session) => session.workspaceId === id && keepTabIds.has(session.id))
|
||||
));
|
||||
const standaloneSessionIds = effectiveRegularIds.filter((id) => sessions.some((session) => session.id === id));
|
||||
if (standaloneSessionIds.length) closeSessions(standaloneSessionIds);
|
||||
for (const id of effectiveRegularIds) {
|
||||
if (workspaces.some((workspace) => workspace.id === id)) closeWorkspace(id);
|
||||
else if (logViews.some((logView) => logView.id === id)) closeLogView(id);
|
||||
}
|
||||
for (const id of pluginIds) pluginViewTabStore.close(id);
|
||||
|
||||
const closingTabIds = new Set([...effectiveRegularIds, ...closedEditorIds, ...pluginIds]);
|
||||
if (closingTabIds.has(activeBeforeClose)) {
|
||||
activeTabStore.setActiveTabId(resolveBatchTabCloseFocus({
|
||||
orderedTabIds: orderedTabsWithEditors,
|
||||
closingTabIds,
|
||||
activeTabId: activeBeforeClose,
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
closeTabsInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appSideEffectsSource = readFileSync(join(here, 'AppSideEffects.tsx'), 'utf8');
|
||||
|
||||
test('terminal keyboard focus tracking is always published to the main process', () => {
|
||||
assert.match(
|
||||
appSideEffectsSource,
|
||||
/useTerminalKeyboardFocus\(\s*\)/,
|
||||
'the terminal keyboard focus signal must be unconditional',
|
||||
);
|
||||
});
|
||||
|
||||
test('terminal keyboard focus is not gated on hotkey scheme or font zoom settings', () => {
|
||||
// When the signal is turned off, the main process falls back to window zoom
|
||||
// for Ctrl+= / Ctrl+- / Ctrl+0 while the user is typing in a terminal, so
|
||||
// "disable terminal zoom" and per-binding overrides stop having any effect
|
||||
// on those chords (#3327).
|
||||
assert.doesNotMatch(appSideEffectsSource, /useTerminalKeyboardFocus\(\s*hotkeyScheme/);
|
||||
assert.doesNotMatch(
|
||||
appSideEffectsSource,
|
||||
/useTerminalKeyboardFocus\(\s*[^)\s][^)]*disableTerminalFontZoom/,
|
||||
);
|
||||
});
|
||||
@@ -21,11 +21,11 @@ import { getNotesSnapshot } from '../state/notesStore';
|
||||
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||
import { useWindowControls } from '../state/useWindowControls';
|
||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||
import { useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import { findEditorSftpOwnerTabId } from '../state/editorSftpOwnerRegistry';
|
||||
import {
|
||||
isPluginViewTabId,
|
||||
pluginViewTabStore,
|
||||
resolveBatchTabCloseFocus,
|
||||
usePluginViewTabs,
|
||||
} from '../state/pluginViewTabStore';
|
||||
import {
|
||||
@@ -139,14 +139,17 @@ export function AppSideEffects() {
|
||||
terminalSettings,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
disableTerminalFontZoom,
|
||||
isHotkeyRecording,
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
useTerminalKeyboardFocus(hotkeyScheme !== 'disabled' && !disableTerminalFontZoom);
|
||||
// Always publish terminal keyboard focus to the main process. When font zoom
|
||||
// is disabled (or hotkeys are off) the renderer ignores the font chords, so
|
||||
// the main process must keep handing them to the page instead of falling
|
||||
// back to window zoom while the user is typing in a terminal (#3327).
|
||||
useTerminalKeyboardFocus();
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
@@ -1010,27 +1013,18 @@ export function AppSideEffects() {
|
||||
}, [orderedTabsWithEditors]);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
// Used by the "Close all / Close others / Close to the left / Close to the
|
||||
// right" context-menu actions on tabs (#748).
|
||||
const closeTabsBatch = useCallback(
|
||||
async (targetIds: string[]) => {
|
||||
const closingTabIds = new Set(targetIds);
|
||||
const activeBeforeClose = activeTabStore.getActiveTabId();
|
||||
const focusAfterClose = resolveBatchTabCloseFocus({
|
||||
orderedTabIds: orderedTabsWithEditors,
|
||||
closingTabIds,
|
||||
activeTabId: activeBeforeClose,
|
||||
});
|
||||
const pluginIds = targetIds.filter((id) => pluginViewTabStore.getTab(id));
|
||||
const regularIds = targetIds.filter((id) => !pluginViewTabStore.getTab(id));
|
||||
const canClose = !regularIds.length || await closeTabsBatchImpl(
|
||||
() => ({ closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace, confirmIfBusyLocalTerminal, logViews, sessions, targetIds: regularIds, workspaces }),
|
||||
regularIds,
|
||||
);
|
||||
if (!canClose) return;
|
||||
for (const id of pluginIds) pluginViewTabStore.close(id);
|
||||
if (closingTabIds.has(activeBeforeClose)) activeTabStore.setActiveTabId(focusAfterClose);
|
||||
},
|
||||
(targetIds: string[]) => closeTabsBatchImpl(
|
||||
() => ({
|
||||
closeLogView, closeSessions, closeTabsInFlightRef, closeWorkspace,
|
||||
confirmIfBusyLocalTerminal, logViews, sessions, workspaces,
|
||||
activeTabStore, editorTabStore, pluginViewTabStore,
|
||||
handleRequestCloseEditorTabRef, findEditorSftpOwnerTabId, orderedTabsWithEditors,
|
||||
}),
|
||||
targetIds,
|
||||
),
|
||||
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSessions, closeLogView, orderedTabsWithEditors],
|
||||
);
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export const enCoreMessages: Messages = {
|
||||
'field.selectHosts': 'Select Hosts',
|
||||
'placeholder.workspaceName': 'Workspace name',
|
||||
'placeholder.sessionName': 'Session name',
|
||||
'placeholder.searchHosts': 'Search hosts...',
|
||||
'placeholder.searchHosts': 'Search hosts or tags...',
|
||||
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
|
||||
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
|
||||
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
|
||||
|
||||
@@ -251,6 +251,7 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': 'Bright Colors',
|
||||
'terminal.customTheme.color.background': 'Background',
|
||||
'terminal.customTheme.color.foreground': 'Foreground',
|
||||
'terminal.customTheme.color.foregroundIntense': 'Intense foreground (bold)',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selection',
|
||||
'terminal.customTheme.color.black': 'Black',
|
||||
@@ -637,6 +638,7 @@ export const enTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
|
||||
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToLeft': 'Close Tabs to the Left',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
@@ -807,6 +809,9 @@ export const enTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
|
||||
'serial.field.backspaceBehavior': 'Backspace key',
|
||||
'serial.field.backspaceBehaviorDesc': 'Use Ctrl+H for network devices that do not respond to the default Backspace code.',
|
||||
'serial.encoding.pendingInput': 'Submit or clear the current input before changing the encoding.',
|
||||
'serial.field.byteOrientedBackspace': 'Byte-Oriented Backspace',
|
||||
'serial.field.byteOrientedBackspaceDesc': 'Send one DEL per byte so the device deletes a whole multi-byte character (for devices that delete bytes). Disable for character-aware line editors (e.g. Linux readline).',
|
||||
'serial.backspace.default': 'Default (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Charset',
|
||||
|
||||
@@ -116,6 +116,11 @@ export const enVaultMessages: Messages = {
|
||||
'vault.import.securecrt.folderDesc': 'Import every session and keep the folder structure.',
|
||||
'vault.import.securecrt.file': 'Single session file',
|
||||
'vault.import.securecrt.fileDesc': 'Import one SecureCRT .ini session.',
|
||||
'vault.import.finalshell.promptTitle': 'Import from FinalShell',
|
||||
'vault.import.finalshell.folder': 'conn folder',
|
||||
'vault.import.finalshell.folderDesc': 'Import every JSON connection and keep the folder structure.',
|
||||
'vault.import.finalshell.file': 'Single connection file',
|
||||
'vault.import.finalshell.fileDesc': 'Import one FinalShell JSON connection.',
|
||||
'vault.import.csv.tip': 'Bulk import: use the CSV template.',
|
||||
'vault.import.csv.downloadTemplate': 'Download CSV template',
|
||||
'vault.import.toast.start': 'Importing from {format}...',
|
||||
|
||||
@@ -77,7 +77,7 @@ export const esCoreMessages: Messages = {
|
||||
'field.selectHosts': 'Seleccionar hosts',
|
||||
'placeholder.workspaceName': 'Nombre del espacio de trabajo',
|
||||
'placeholder.sessionName': 'Nombre de la sesión',
|
||||
'placeholder.searchHosts': 'Buscar hosts...',
|
||||
'placeholder.searchHosts': 'Buscar hosts o etiquetas...',
|
||||
'toast.settingsUnavailable': 'La ventana de configuración no está disponible en esta plataforma.',
|
||||
'credentials.protectionUnavailable.title': 'Protección de credenciales no disponible',
|
||||
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
|
||||
|
||||
@@ -251,6 +251,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': 'Colores brillantes',
|
||||
'terminal.customTheme.color.background': 'Fondo',
|
||||
'terminal.customTheme.color.foreground': 'Primer plano',
|
||||
'terminal.customTheme.color.foregroundIntense': 'Primer plano intenso (negrita)',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selección',
|
||||
'terminal.customTheme.color.black': 'Negro',
|
||||
@@ -637,6 +638,7 @@ export const esTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
|
||||
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
|
||||
'tabs.closeOthers': 'Cerrar las demás',
|
||||
'tabs.closeToLeft': 'Cerrar pestañas a la izquierda',
|
||||
'tabs.closeToRight': 'Cerrar pestañas a la derecha',
|
||||
'tabs.closeAll': 'Cerrar todas',
|
||||
'keychain.edit.labelRequired': 'Etiqueta *',
|
||||
@@ -807,6 +809,9 @@ export const esTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': 'Almacenar en búfer la entrada y enviarla al presionar Enter (en lugar de carácter por carácter)',
|
||||
'serial.field.backspaceBehavior': 'Tecla de retroceso',
|
||||
'serial.field.backspaceBehaviorDesc': 'Usa Ctrl+H para dispositivos de red que no responden al código de Retroceso predeterminado.',
|
||||
'serial.encoding.pendingInput': 'Envía o borra la entrada actual antes de cambiar la codificación.',
|
||||
'serial.field.byteOrientedBackspace': 'Retroceso orientado a bytes',
|
||||
'serial.field.byteOrientedBackspaceDesc': 'Envía un DEL por byte para que el dispositivo elimine un carácter multibyte completo (para dispositivos que eliminan por bytes). Desactívelo para editores de línea conscientes de caracteres (p. ej. readline de Linux).',
|
||||
'serial.backspace.default': 'Predeterminado (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Codificación de caracteres',
|
||||
|
||||
@@ -116,6 +116,11 @@ export const esVaultMessages: Messages = {
|
||||
'vault.import.securecrt.folderDesc': 'Importa todas las sesiones y conserva la estructura de carpetas.',
|
||||
'vault.import.securecrt.file': 'Archivo de sesión único',
|
||||
'vault.import.securecrt.fileDesc': 'Importa una sesión .ini de SecureCRT.',
|
||||
'vault.import.finalshell.promptTitle': 'Importar desde FinalShell',
|
||||
'vault.import.finalshell.folder': 'Carpeta conn',
|
||||
'vault.import.finalshell.folderDesc': 'Importa todas las conexiones JSON y conserva la estructura de carpetas.',
|
||||
'vault.import.finalshell.file': 'Archivo de conexión único',
|
||||
'vault.import.finalshell.fileDesc': 'Importa una conexión JSON de FinalShell.',
|
||||
'vault.import.csv.tip': 'Importación masiva: usa la plantilla CSV.',
|
||||
'vault.import.csv.downloadTemplate': 'Descargar plantilla CSV',
|
||||
'vault.import.toast.start': 'Importando desde {format}...',
|
||||
|
||||
@@ -76,7 +76,7 @@ export const ruCoreMessages: Messages = {
|
||||
'field.selectHosts': 'Выбрать хосты',
|
||||
'placeholder.workspaceName': 'Имя рабочего пространства',
|
||||
'placeholder.sessionName': 'Имя сессии',
|
||||
'placeholder.searchHosts': 'Поиск хостов...',
|
||||
'placeholder.searchHosts': 'Поиск хостов или тегов...',
|
||||
'toast.settingsUnavailable': 'Окно настроек недоступно на этой платформе.',
|
||||
'credentials.protectionUnavailable.title': 'Защита учётных данных недоступна',
|
||||
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
|
||||
|
||||
@@ -269,6 +269,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': 'Яркие цвета',
|
||||
'terminal.customTheme.color.background': 'Фон',
|
||||
'terminal.customTheme.color.foreground': 'Текст',
|
||||
'terminal.customTheme.color.foregroundIntense': 'Яркий текст (жирный)',
|
||||
'terminal.customTheme.color.cursor': 'Курсор',
|
||||
'terminal.customTheme.color.selection': 'Выделение',
|
||||
'terminal.customTheme.color.black': 'Чёрный',
|
||||
@@ -652,6 +653,7 @@ export const ruTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
|
||||
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
|
||||
'tabs.closeOthers': 'Закрыть остальные',
|
||||
'tabs.closeToLeft': 'Закрыть вкладки слева',
|
||||
'tabs.closeToRight': 'Закрыть вкладки справа',
|
||||
'tabs.closeAll': 'Закрыть все',
|
||||
'keychain.edit.labelRequired': 'Метка *',
|
||||
@@ -814,6 +816,9 @@ export const ruTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': 'Буферизовать ввод и отправлять по Enter (вместо посимвольной отправки)',
|
||||
'serial.field.backspaceBehavior': 'Клавиша Backspace',
|
||||
'serial.field.backspaceBehaviorDesc': 'Используйте Ctrl+H для сетевых устройств, которые не реагируют на код Backspace по умолчанию.',
|
||||
'serial.encoding.pendingInput': 'Отправьте или очистите текущий ввод перед сменой кодировки.',
|
||||
'serial.field.byteOrientedBackspace': 'Побайтовый Backspace',
|
||||
'serial.field.byteOrientedBackspaceDesc': 'Отправляет по одному DEL на байт, чтобы устройство удалило весь многобайтовый символ (для устройств, удаляющих побайтово). Отключите для редакторов, удаляющих посимвольно (например, readline в Linux).',
|
||||
'serial.backspace.default': 'По умолчанию (DEL, 0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
|
||||
'serial.field.charset': 'Кодировка',
|
||||
|
||||
@@ -154,6 +154,11 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.import.securecrt.folderDesc': 'Импортировать все сеансы с сохранением структуры папок.',
|
||||
'vault.import.securecrt.file': 'Один файл сеанса',
|
||||
'vault.import.securecrt.fileDesc': 'Импортировать один файл сеанса SecureCRT .ini.',
|
||||
'vault.import.finalshell.promptTitle': 'Импорт из FinalShell',
|
||||
'vault.import.finalshell.folder': 'Папка conn',
|
||||
'vault.import.finalshell.folderDesc': 'Импортировать все JSON-подключения с сохранением структуры папок.',
|
||||
'vault.import.finalshell.file': 'Один файл подключения',
|
||||
'vault.import.finalshell.fileDesc': 'Импортировать одно JSON-подключение FinalShell.',
|
||||
'vault.import.csv.tip': 'Массовый импорт: используйте шаблон CSV.',
|
||||
'vault.import.csv.downloadTemplate': 'Скачать шаблон CSV',
|
||||
'vault.import.toast.start': 'Импорт из {format}...',
|
||||
|
||||
@@ -61,6 +61,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'field.name': '名称',
|
||||
'placeholder.workspaceName': '工作区名称',
|
||||
'placeholder.sessionName': '会话名称',
|
||||
'placeholder.searchHosts': '搜索主机或标签...',
|
||||
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
|
||||
'credentials.protectionUnavailable.title': '凭据保护不可用',
|
||||
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
@@ -781,6 +782,11 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.import.securecrt.folderDesc': '一次导入全部会话,并保留目录结构。',
|
||||
'vault.import.securecrt.file': '单个会话文件',
|
||||
'vault.import.securecrt.fileDesc': '导入一个 SecureCRT .ini 会话。',
|
||||
'vault.import.finalshell.promptTitle': '从 FinalShell 导入',
|
||||
'vault.import.finalshell.folder': 'conn 文件夹',
|
||||
'vault.import.finalshell.folderDesc': '导入全部 JSON 连接,并保留目录结构。',
|
||||
'vault.import.finalshell.file': '单个连接文件',
|
||||
'vault.import.finalshell.fileDesc': '导入一个 FinalShell JSON 连接。',
|
||||
'vault.import.csv.tip': '批量导入:可使用 CSV 模板填写后导入。',
|
||||
'vault.import.csv.downloadTemplate': '下载 CSV 模板',
|
||||
'vault.import.toast.start': '正在从 {format} 导入...',
|
||||
|
||||
@@ -722,6 +722,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
|
||||
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToLeft': '关闭左侧标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
@@ -892,6 +893,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
|
||||
'serial.field.backspaceBehavior': '回退键',
|
||||
'serial.field.backspaceBehaviorDesc': '如果网络设备不响应默认回退键,请改用 Ctrl+H。',
|
||||
'serial.encoding.pendingInput': '请先提交或清空当前输入,再切换编码。',
|
||||
'serial.field.byteOrientedBackspace': '按字节退格',
|
||||
'serial.field.byteOrientedBackspaceDesc': '发送与字符字节数相同的 DEL,使设备删除整个多字节字符(适用于按字节删除的设备)。若为逐字符删除的行编辑器(如 Linux readline),请关闭。',
|
||||
'serial.backspace.default': '默认(DEL,0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
|
||||
'serial.field.charset': '字符编码',
|
||||
|
||||
@@ -644,6 +644,7 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': '高亮色',
|
||||
'terminal.customTheme.color.background': '背景',
|
||||
'terminal.customTheme.color.foreground': '前景',
|
||||
'terminal.customTheme.color.foregroundIntense': '高亮前景(粗体)',
|
||||
'terminal.customTheme.color.cursor': '光标',
|
||||
'terminal.customTheme.color.selection': '选区',
|
||||
'terminal.customTheme.color.black': '黑色',
|
||||
|
||||
@@ -63,7 +63,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'field.selectHosts': '選擇主機',
|
||||
'placeholder.workspaceName': '工作區名稱',
|
||||
'placeholder.sessionName': '工作階段名稱',
|
||||
'placeholder.searchHosts': '搜尋主機...',
|
||||
'placeholder.searchHosts': '搜尋主機或標籤...',
|
||||
'toast.settingsUnavailable': '目前平台無法開啟設定視窗。',
|
||||
'credentials.protectionUnavailable.title': '憑證保護不可用',
|
||||
'credentials.protectionUnavailable.message': '目前裝置無法自動解密已儲存的密碼和金鑰。連線前請重新輸入憑證。',
|
||||
@@ -784,6 +784,11 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.import.securecrt.folderDesc': '一次匯入全部工作階段,並保留目錄結構。',
|
||||
'vault.import.securecrt.file': '單一工作階段檔案',
|
||||
'vault.import.securecrt.fileDesc': '匯入一個 SecureCRT .ini 工作階段。',
|
||||
'vault.import.finalshell.promptTitle': '從 FinalShell 匯入',
|
||||
'vault.import.finalshell.folder': 'conn 資料夾',
|
||||
'vault.import.finalshell.folderDesc': '匯入全部 JSON 連線,並保留目錄結構。',
|
||||
'vault.import.finalshell.file': '單一連線檔案',
|
||||
'vault.import.finalshell.fileDesc': '匯入一個 FinalShell JSON 連線。',
|
||||
'vault.import.csv.tip': '批次匯入:可使用 CSV 模板填寫後匯入。',
|
||||
'vault.import.csv.downloadTemplate': '下載 CSV 模板',
|
||||
'vault.import.toast.start': '正在從 {format} 匯入...',
|
||||
|
||||
@@ -722,6 +722,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'tabs.copyTabToNewWindow': '複製分頁到新視窗',
|
||||
'tabs.copyTabToNewWindowFailed': '無法在新視窗開啟分頁',
|
||||
'tabs.closeOthers': '關閉其他分頁',
|
||||
'tabs.closeToLeft': '關閉左側分頁',
|
||||
'tabs.closeToRight': '關閉右側分頁',
|
||||
'tabs.closeAll': '關閉所有分頁',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
@@ -892,6 +893,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'serial.field.lineModeDesc': '緩衝輸入,按 Enter 鍵後傳送(而不是逐字元傳送)',
|
||||
'serial.field.backspaceBehavior': '退格鍵',
|
||||
'serial.field.backspaceBehaviorDesc': '如果網路裝置不回應預設退格鍵,請改用 Ctrl+H。',
|
||||
'serial.encoding.pendingInput': '請先提交或清空目前輸入,再切換編碼。',
|
||||
'serial.field.byteOrientedBackspace': '按位元組退格',
|
||||
'serial.field.byteOrientedBackspaceDesc': '傳送與字元位元組數相同的 DEL,使裝置刪除整個多位元組字元(適用於按位元組刪除的裝置)。若為逐字元刪除的行編輯器(如 Linux readline),請關閉。',
|
||||
'serial.backspace.default': '預設(DEL,0x7F)',
|
||||
'serial.backspace.ctrlH': 'Ctrl+H(BS,0x08)',
|
||||
'serial.field.charset': '字元編碼',
|
||||
|
||||
@@ -644,6 +644,7 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.customTheme.group.bright': '標示色',
|
||||
'terminal.customTheme.color.background': '背景',
|
||||
'terminal.customTheme.color.foreground': '前景',
|
||||
'terminal.customTheme.color.foregroundIntense': '高亮前景(粗體)',
|
||||
'terminal.customTheme.color.cursor': '游標',
|
||||
'terminal.customTheme.color.selection': '選區',
|
||||
'terminal.customTheme.color.black': '黑色',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { findEditorSftpOwnerTabId, registerEditorSftpOwnerResolver } from "./editorSftpOwnerRegistry";
|
||||
|
||||
test("resolves the owning tab for a registered SFTP connection id", () => {
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["left-abc", "right-def"],
|
||||
ownerTabId: "session-1",
|
||||
}));
|
||||
try {
|
||||
assert.equal(findEditorSftpOwnerTabId("left-abc"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId("right-def"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId("left-unknown"), null);
|
||||
assert.equal(findEditorSftpOwnerTabId(undefined), null);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
test("falls back to the stable pane tab id when the connection id is stale", () => {
|
||||
// Browse reconnects regenerate connection ids; an unsaved editor may still
|
||||
// reference the pre-reconnect id, so owner resolution must also match on the
|
||||
// stable pane tab id.
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["conn_new"],
|
||||
paneTabIds: ["pane_1"],
|
||||
ownerTabId: "session-1",
|
||||
}));
|
||||
try {
|
||||
assert.equal(findEditorSftpOwnerTabId("conn_old", "pane_1"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId(undefined, "pane_1"), "session-1");
|
||||
assert.equal(findEditorSftpOwnerTabId("conn_old", "pane_unknown"), null);
|
||||
assert.equal(findEditorSftpOwnerTabId(undefined, undefined), null);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
test("unregistering a resolver stops it from resolving owners", () => {
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["conn-x"],
|
||||
ownerTabId: "tab-x",
|
||||
}));
|
||||
unregister();
|
||||
assert.equal(findEditorSftpOwnerTabId("conn-x"), null);
|
||||
});
|
||||
|
||||
test("resolvers without an owner tab id never match", () => {
|
||||
const unregister = registerEditorSftpOwnerResolver(() => ({
|
||||
connectionIds: ["conn-y"],
|
||||
ownerTabId: null,
|
||||
}));
|
||||
try {
|
||||
assert.equal(findEditorSftpOwnerTabId("conn-y"), null);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Maps live SFTP connection ids to the top-level tab that owns the panel
|
||||
* hosting them (a terminal/workspace tab for SftpSidePanel instances).
|
||||
*
|
||||
* Editor tabs record the SFTP `connection.id` as their `sessionId` — not the
|
||||
* owning terminal session id — so batch-close logic that wants to keep an
|
||||
* editor's owning tab open after a cancelled dirty-save prompt must resolve
|
||||
* the owner through this registry instead of comparing `editorTab.sessionId`
|
||||
* against terminal session ids.
|
||||
*
|
||||
* Each mounted SftpSidePanel registers a resolver that reports the connection
|
||||
* ids it currently owns plus its host tab id; the top-level SftpView either
|
||||
* registers nothing (its owner is not a closable terminal tab) or registers
|
||||
* with a non-terminal owner id that simply never matches.
|
||||
*/
|
||||
export interface EditorSftpOwnerSnapshot {
|
||||
connectionIds: readonly string[];
|
||||
/**
|
||||
* Stable SFTP pane tab ids (pane.id) hosted by this panel. Unlike connection
|
||||
* ids these survive browse reconnects that regenerate connection ids, so
|
||||
* owner resolution can fall back to them when an editor still references a
|
||||
* pre-reconnect connection id.
|
||||
*/
|
||||
paneTabIds?: readonly string[];
|
||||
ownerTabId: string | null;
|
||||
}
|
||||
|
||||
type EditorSftpOwnerResolver = () => EditorSftpOwnerSnapshot;
|
||||
|
||||
const resolvers = new Set<EditorSftpOwnerResolver>();
|
||||
|
||||
export const registerEditorSftpOwnerResolver = (resolver: EditorSftpOwnerResolver): (() => void) => {
|
||||
resolvers.add(resolver);
|
||||
return () => {
|
||||
resolvers.delete(resolver);
|
||||
};
|
||||
};
|
||||
|
||||
export const findEditorSftpOwnerTabId = (
|
||||
connectionId: string | undefined,
|
||||
paneTabId?: string,
|
||||
): string | null => {
|
||||
if (!connectionId && !paneTabId) return null;
|
||||
for (const resolve of resolvers) {
|
||||
const snapshot = resolve();
|
||||
if (!snapshot.ownerTabId) continue;
|
||||
if (connectionId && snapshot.connectionIds.includes(connectionId)) {
|
||||
return snapshot.ownerTabId;
|
||||
}
|
||||
if (paneTabId && snapshot.paneTabIds?.includes(paneTabId)) {
|
||||
return snapshot.ownerTabId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -101,6 +101,28 @@ test("workspace host factory creates a complete serial session", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("serial session factories preserve byteOrientedBackspace", () => {
|
||||
const quickSession = createSerialTerminalSession("session-q", {
|
||||
path: "COM4",
|
||||
baudRate: 9600,
|
||||
byteOrientedBackspace: false,
|
||||
});
|
||||
const savedHostSession = createHostTerminalSession("session-h", host({
|
||||
protocol: "serial",
|
||||
hostname: "COM3",
|
||||
port: 115200,
|
||||
username: "",
|
||||
serialConfig: {
|
||||
path: "COM3",
|
||||
baudRate: 115200,
|
||||
byteOrientedBackspace: false,
|
||||
},
|
||||
}));
|
||||
|
||||
assert.equal(quickSession.serialConfig?.byteOrientedBackspace, false);
|
||||
assert.equal(savedHostSession.serialConfig?.byteOrientedBackspace, false);
|
||||
});
|
||||
|
||||
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
|
||||
const savedHost = host({
|
||||
protocol: "serial",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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 { SftpFileEntry, TransferTask } from "../../../domain/models";
|
||||
import { createSftpDirectoryTraversalBudget } from "../../../domain/sftpDirectoryCheckpoint";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
|
||||
const modes = [
|
||||
{ name: "ordinary local upload", sourceLocal: true, targetLocal: false, sameConnection: false, bounded: false },
|
||||
{ name: "cross-pane local copy", sourceLocal: true, targetLocal: true, sameConnection: false, bounded: false },
|
||||
{ name: "same-pane local copy", sourceLocal: true, targetLocal: true, sameConnection: true, bounded: true },
|
||||
{ name: "remote download", sourceLocal: false, targetLocal: true, sameConnection: false, bounded: true },
|
||||
] as const;
|
||||
|
||||
for (const mode of modes) {
|
||||
for (const limit of ["entries", "directories"] as const) {
|
||||
test(`${mode.name} ${mode.bounded ? "enforces" : "does not inherit"} the ${limit} budget`, async () => {
|
||||
const previousGet = netcattyBridge.get;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpDirectoryTransferOps>;
|
||||
const listed: string[] = [];
|
||||
const root: TransferTask = {
|
||||
id: crypto.randomUUID(), fileName: "source", sourcePath: "/source", targetPath: "/target",
|
||||
sourceConnectionId: "source", targetConnectionId: mode.sameConnection ? "source" : "target",
|
||||
direction: mode.sourceLocal ? "upload" : "download", status: "transferring",
|
||||
totalBytes: 0, transferredBytes: 0, speed: 0, startTime: 0, isDirectory: true,
|
||||
};
|
||||
const transfersRef = { current: [root] };
|
||||
const list = async (value: string): Promise<SftpFileEntry[]> => {
|
||||
listed.push(value);
|
||||
return value === "/source" ? ["one", "two"].map(name => ({
|
||||
name, type: "directory", size: 0, sizeFormatted: "0 B", lastModified: 0, lastModifiedFormatted: "",
|
||||
})) : [];
|
||||
};
|
||||
netcattyBridge.get = () => ({
|
||||
mkdirLocal: async () => undefined,
|
||||
mkdirSftp: async () => undefined,
|
||||
realpathSftp: async (_id: string, value: string) => value,
|
||||
} as unknown as NetcattyBridge);
|
||||
function Probe() {
|
||||
ops = useSftpDirectoryTransferOps({
|
||||
ownerId: root.id, cancelledTasksRef: { current: new Set() }, pausedTasksRef: { current: new Set() },
|
||||
waitUntilTransferResumed: async () => undefined, activeChildIdsRef: { current: new Map() },
|
||||
transfersRef,
|
||||
setTransfers: update => { transfersRef.current = typeof update === "function" ? update(transfersRef.current) : update; },
|
||||
listLocalFiles: list, listRemoteFiles: (_id, value) => list(value),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
// Three empty directories exercise real recursion without large fixtures or file streams.
|
||||
const budget = createSftpDirectoryTraversalBudget({
|
||||
maxEntries: limit === "entries" ? 1 : 10,
|
||||
maxDirectories: limit === "directories" ? 1 : 10,
|
||||
});
|
||||
const run = ops.transferDirectory(root, mode.sourceLocal ? null : "remote-source",
|
||||
mode.targetLocal ? null : "remote-target", mode.sourceLocal, mode.targetLocal,
|
||||
"auto", "auto", root.id, false, 0, false, undefined, budget);
|
||||
if (mode.bounded) {
|
||||
await assert.rejects(run, limit === "entries" ? /entry limit exceeded/ : /directory limit exceeded/);
|
||||
assert.deepEqual(listed, ["/source"]);
|
||||
} else {
|
||||
assert.equal(await run, 0);
|
||||
assert.deepEqual(listed, ["/source", "/source/one", "/source/two"]);
|
||||
assert.equal(budget.visitedDirectories, 0);
|
||||
assert.equal(budget.visitedEntries, 0);
|
||||
}
|
||||
assert.equal(budget.activeCanonicalDirectories.size, 0);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
netcattyBridge.get = previousGet;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useSftpTransfers } from "./useSftpTransfers";
|
||||
import { createEmptyPane } from "./types";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
for (const action of ["merge", "replace", "duplicate"] as const) {
|
||||
test(`same-directory ${action} preserves source links through the real transfer hook`, async () => {
|
||||
const dir = await fs.mkdtemp(path.join(process.cwd(), ".same-directory-copy-"));
|
||||
const ownerId = `self-copy-${action}-${crypto.randomUUID()}`;
|
||||
const previousWindow = globalThis.window;
|
||||
const previousStorage = globalThis.localStorage;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let writes = 0;
|
||||
let listings = 0;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpTransfers>;
|
||||
const names = ["one", "two"];
|
||||
for (const name of names) {
|
||||
await fs.mkdir(path.join(dir, name));
|
||||
await fs.writeFile(path.join(dir, name, "file"), "original data");
|
||||
await fs.symlink("file", path.join(dir, name, "link"));
|
||||
}
|
||||
const pane = createEmptyPane("self-copy-pane");
|
||||
pane.connection = { id: ownerId, isLocal: true, currentPath: dir, status: "connected" } as NonNullable<typeof pane.connection>;
|
||||
const statPath = async (value: string) => {
|
||||
const stat = await fs.lstat(value);
|
||||
return { type: stat.isSymbolicLink() ? "symlink" : stat.isDirectory() ? "directory" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: clearTimeout, netcatty: {
|
||||
realpathLocal: fs.realpath, statLocal: statPath, lstatLocal: statPath,
|
||||
mkdirLocal: async (value: string) => { writes++; await fs.mkdir(value, { recursive: true }); },
|
||||
deleteLocalFile: async (value: string) => { writes++; await fs.rm(value, { recursive: true, force: true }); },
|
||||
renameLocalFile: async (source: string, target: string) => { writes++; await fs.rename(source, target); },
|
||||
startStreamTransfer: async (options: { sourcePath: string; targetPath: string; transferId: string }) => {
|
||||
writes++;
|
||||
await fs.copyFile(options.sourcePath, options.targetPath);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 13, totalBytes: 13, lifecycleEpoch: 0 });
|
||||
return {};
|
||||
},
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId, getActivePane: () => pane, getPaneByConnectionId: () => pane, getTabByConnectionId: () => ({ side: "left", tabId: pane.id, pane }),
|
||||
updateTab: () => undefined, refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
|
||||
listRemoteFiles: async () => [],
|
||||
listLocalFiles: async (value: string) => {
|
||||
listings++;
|
||||
return Promise.all((await fs.readdir(value)).map(async name => {
|
||||
const stat = await fs.lstat(path.join(value, name));
|
||||
return { name, type: stat.isSymbolicLink() ? "symlink" as const : "file" as const, size: stat.size, sizeFormatted: "", lastModified: stat.mtimeMs, lastModifiedFormatted: "" };
|
||||
}));
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => { await ops.startTransfer(names.map(name => ({ name, isDirectory: true })), "left", "left"); });
|
||||
assert.equal(ops.conflicts.length, 2, JSON.stringify(sftpTransferCenterStore.getOwnerTasks(ownerId)));
|
||||
const firstId = ops.conflicts[0].transferId;
|
||||
// Apply to all exercises shared batch resolution rather than just one row.
|
||||
await act(async () => { await ops.resolveConflict(firstId, action, true); });
|
||||
await act(async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline) {
|
||||
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
||||
if (rows.length === 2 && rows.every(row => ["completed", "failed"].includes(row.status))) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
});
|
||||
for (const name of names) {
|
||||
assert.equal(await fs.readlink(path.join(dir, name, "link")), "file");
|
||||
assert.equal(await fs.readFile(path.join(dir, name, "file"), "utf8"), "original data");
|
||||
}
|
||||
const roots = sftpTransferCenterStore.getOwnerTasks(ownerId).filter(row => !row.parentTaskId);
|
||||
assert.equal(roots.length, 2);
|
||||
assert.ok(roots.every(row => row.status === "completed"), JSON.stringify(roots.map(row => ({ status: row.status, error: row.error }))));
|
||||
if (action === "duplicate") {
|
||||
assert.ok(writes > 0);
|
||||
assert.ok(listings > 0);
|
||||
for (const name of names) assert.equal(await fs.readFile(path.join(dir, `${name} (copy)`, "file"), "utf8"), "original data");
|
||||
} else {
|
||||
assert.equal(writes, 0, "self merge/replace must never write or unlink anything");
|
||||
assert.equal(listings, 0, "self merge/replace must not walk the source tree");
|
||||
}
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
getWindowsUncRoot,
|
||||
isSameSftpPath,
|
||||
isSftpDescendantPath,
|
||||
isWindowsPath,
|
||||
joinPath,
|
||||
} from "./utils";
|
||||
|
||||
export type SamePanePasteAction = "allow" | "block-same-folder" | "block-into-source";
|
||||
|
||||
export interface SamePanePasteFile {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
/** Filesystem identity of a directory (stat dev/ino). */
|
||||
export interface SamePanePasteIdentity {
|
||||
dev: number;
|
||||
ino: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lexically resolve "." / ".." segments and repeated separators so equivalent
|
||||
* spellings of the same directory (e.g. /home/user/., /home//user) compare
|
||||
* equal in the same-pane paste guards. Purely string-level: it never touches
|
||||
* the server, and it is only used to make the guards stricter, never to build
|
||||
* transfer paths.
|
||||
*/
|
||||
const canonicalizeSftpPath = (path: string): string => {
|
||||
const isWindows = isWindowsPath(path);
|
||||
const separator = isWindows ? "\\" : "/";
|
||||
const unified = isWindows ? path.replace(/\//g, "\\") : path;
|
||||
|
||||
let root = "";
|
||||
let rest = unified;
|
||||
if (isWindows) {
|
||||
const uncRoot = getWindowsUncRoot(unified, { acceptForwardSlashUnc: true });
|
||||
const driveRoot = unified.match(/^[A-Za-z]:\\/)?.[0];
|
||||
if (uncRoot) {
|
||||
root = uncRoot;
|
||||
rest = unified.slice(uncRoot.length);
|
||||
} else if (driveRoot) {
|
||||
root = driveRoot;
|
||||
rest = unified.slice(driveRoot.length);
|
||||
}
|
||||
} else if (unified.startsWith("/")) {
|
||||
// Collapse a leading "//" to "/" for comparisons: on ordinary POSIX
|
||||
// filesystems "//home/user" names the same directory as "/home/user", and
|
||||
// treating it as a distinct root would let a same-pane cut reach the
|
||||
// replace/delete flow. This helper never builds transfer paths, so the
|
||||
// guards stay strict either way.
|
||||
root = "/";
|
||||
rest = unified.replace(/^\/+/, "");
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const segment of rest.split(separator)) {
|
||||
if (!segment || segment === ".") continue;
|
||||
if (segment === "..") {
|
||||
parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(segment);
|
||||
}
|
||||
|
||||
// UNC roots come back without a trailing separator, so re-insert one before
|
||||
// joining the segments; without it \\server\share\docs would collapse to
|
||||
// \\server\sharedocs and collide with an unrelated share's path.
|
||||
if (root) {
|
||||
return root.endsWith(separator)
|
||||
? root + parts.join(separator)
|
||||
: root + separator + parts.join(separator);
|
||||
}
|
||||
return parts.join(separator) || ".";
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether an internal SFTP paste that targets the same connection as the
|
||||
* clipboard source can proceed. Copying files into their own source folder is
|
||||
* allowed (the conflict dialog handles same-name collisions), but pasting a
|
||||
* clipboard directory into itself or one of its descendants is blocked for both
|
||||
* copy and cut: the transfer creates the destination before listing the source,
|
||||
* so the fresh copy would be rediscovered and nested until the traversal limit.
|
||||
* For cuts, pasting into the clipboard's source folder is also blocked because
|
||||
* the post-move source delete would remove the freshly moved items. Note that
|
||||
* `sourcePath` is the folder containing every clipboard item, so the per-item
|
||||
* checks must join it with each selected entry's name.
|
||||
*
|
||||
* Lexical canonicalization alone cannot see through symlinks: when the
|
||||
* destination reaches the copied directory through an alias (e.g.
|
||||
* `/a/link -> /a/docs/sub`), a purely string-level comparison would allow the
|
||||
* paste and the recursive transfer would rediscover its own output. When
|
||||
* `resolvePath` is provided (SFTP realpath for remote connections, the local
|
||||
* realpath bridge for local panes), both sides are resolved through the
|
||||
* filesystem before comparing. If that resolution fails, the guard fails
|
||||
* closed and blocks the paste rather than risking a runaway transfer.
|
||||
*
|
||||
* realpath also cannot see through bind mounts: two mount paths for the same
|
||||
* directory resolve to different strings. When `statIdentity` is provided
|
||||
* (local panes only — remote SFTP stats carry no dev/ino), both sides' stat
|
||||
* identities are compared as a final check; equal identities block the paste.
|
||||
* The check only ever blocks on a positive identity match — an unavailable
|
||||
* stat falls through to the symlink guards above rather than blocking
|
||||
* unrelated pastes on transient stat failures.
|
||||
*/
|
||||
export const resolveSamePanePasteAction = async (params: {
|
||||
operation: "copy" | "cut";
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
files: readonly SamePanePasteFile[];
|
||||
/** Optional filesystem resolver (e.g. realpath). Throws → unresolvable. */
|
||||
resolvePath?: (path: string) => Promise<string>;
|
||||
/** Optional stat identity provider (dev/ino). Returns null → unknown. */
|
||||
statIdentity?: (path: string) => Promise<SamePanePasteIdentity | null>;
|
||||
}): Promise<SamePanePasteAction> => {
|
||||
const { resolvePath, statIdentity } = params;
|
||||
|
||||
// Canonicalize first so equivalent spellings of the same directory (dot
|
||||
// segments, repeated separators) are still caught by the guards below, then
|
||||
// resolve through the filesystem when a resolver is available so symlink
|
||||
// aliases compare equal to their real targets. Returns null when resolution
|
||||
// fails, which callers treat as fail-closed.
|
||||
const canonicalizeForComparison = async (path: string): Promise<string | null> => {
|
||||
const lexical = canonicalizeSftpPath(path);
|
||||
if (!resolvePath) return lexical;
|
||||
try {
|
||||
return canonicalizeSftpPath(await resolvePath(path));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const [targetPath, sourcePath] = await Promise.all([
|
||||
canonicalizeForComparison(params.targetPath),
|
||||
canonicalizeForComparison(params.sourcePath),
|
||||
]);
|
||||
// A files-only copy has no nesting hazard, so an unresolvable path only
|
||||
// blocks pastes that could actually recurse (directory items, or a cut).
|
||||
const failClosed = !targetPath || !sourcePath;
|
||||
if (failClosed && (params.operation === "cut" || params.files.some((file) => file.isDirectory))) {
|
||||
return "block-into-source";
|
||||
}
|
||||
if (failClosed) return "allow";
|
||||
|
||||
if (params.operation === "cut" && isSameSftpPath(targetPath, sourcePath)) {
|
||||
return "block-same-folder";
|
||||
}
|
||||
|
||||
// Bind-mount alias detection: realpath above maps each mount path to itself,
|
||||
// so a cut (whose post-transfer source delete would destroy the only copy)
|
||||
// must additionally compare filesystem identities. A positive match means
|
||||
// both paths name the same directory. dev/ino are only trusted when at least
|
||||
// one side is non-zero — Windows stats report meaningless 0 identities, and
|
||||
// the local bridge omits them there.
|
||||
const identityOf = async (path: string): Promise<SamePanePasteIdentity | null> => {
|
||||
if (!statIdentity) return null;
|
||||
try {
|
||||
const identity = await statIdentity(path);
|
||||
if (!identity || identity.dev === undefined || identity.ino === undefined) return null;
|
||||
if (identity.dev === 0 && identity.ino === 0) return null;
|
||||
return identity;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const sameIdentity = (a: SamePanePasteIdentity, b: SamePanePasteIdentity): boolean =>
|
||||
a.dev === b.dev && a.ino === b.ino;
|
||||
|
||||
if (params.operation === "cut") {
|
||||
const [targetIdentity, sourceIdentity] = await Promise.all([
|
||||
identityOf(params.targetPath),
|
||||
identityOf(params.sourcePath),
|
||||
]);
|
||||
if (targetIdentity && sourceIdentity && sameIdentity(targetIdentity, sourceIdentity)) {
|
||||
return "block-same-folder";
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of params.files) {
|
||||
if (!file.isDirectory) continue;
|
||||
const itemPath = await canonicalizeForComparison(joinPath(params.sourcePath, file.name));
|
||||
if (!itemPath) return "block-into-source";
|
||||
if (
|
||||
isSameSftpPath(targetPath, itemPath)
|
||||
|| isSftpDescendantPath(targetPath, itemPath)
|
||||
) {
|
||||
return "block-into-source";
|
||||
}
|
||||
// A bind-mount alias of the directory item itself also names the paste
|
||||
// destination, for both copy (recursive rediscovery) and cut (source
|
||||
// delete would remove the freshly pasted entry).
|
||||
if (statIdentity) {
|
||||
const [targetIdentity, itemIdentity] = await Promise.all([
|
||||
identityOf(params.targetPath),
|
||||
identityOf(joinPath(params.sourcePath, file.name)),
|
||||
]);
|
||||
if (targetIdentity && itemIdentity && sameIdentity(targetIdentity, itemIdentity)) {
|
||||
return "block-into-source";
|
||||
}
|
||||
}
|
||||
}
|
||||
return "allow";
|
||||
};
|
||||
|
||||
/** Application boundary for filesystem checks used by every internal paste entry. */
|
||||
export const resolveSameConnectionPasteAction = async (params: {
|
||||
operation: "copy" | "cut";
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
files: readonly SamePanePasteFile[];
|
||||
isLocal: boolean;
|
||||
sftpId: string | null;
|
||||
}): Promise<SamePanePasteAction> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let resolvePath: ((path: string) => Promise<string>) | undefined;
|
||||
let statIdentity: ((path: string) => Promise<SamePanePasteIdentity | null>) | undefined;
|
||||
if (params.isLocal) {
|
||||
if (bridge?.realpathLocal) resolvePath = (path) => bridge.realpathLocal!(path);
|
||||
if (bridge?.statLocal) {
|
||||
statIdentity = async (path) => {
|
||||
const stat = await bridge.statLocal!(path);
|
||||
if (stat.dev === undefined || stat.ino === undefined) return null;
|
||||
return { dev: stat.dev, ino: stat.ino };
|
||||
};
|
||||
}
|
||||
} else if (params.sftpId && bridge?.realpathSftp) {
|
||||
const sftpId = params.sftpId;
|
||||
resolvePath = (path) => bridge.realpathSftp!(sftpId, path);
|
||||
}
|
||||
return resolveSamePanePasteAction({ ...params, resolvePath, statIdentity });
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type { SftpPane } from "./types";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
|
||||
test("bind-mounted parents cannot cause replacement to delete the source link", async (t) => {
|
||||
const originalGet = netcattyBridge.get;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let ops!: ReturnType<typeof useSftpTransferConflictOps>;
|
||||
let deletes = 0;
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
netcattyBridge.get = originalGet;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
});
|
||||
netcattyBridge.get = () => ({
|
||||
// Bind mounts retain different realpaths but expose the same parent identity.
|
||||
realpathLocal: async (value: string) => value,
|
||||
statLocal: async (value: string) => ({
|
||||
type: "directory", size: 0, lastModified: 0, dev: 42,
|
||||
ino: value === "/a" || value === "/mnt/alias" ? 7 : 8,
|
||||
}),
|
||||
deleteLocalFile: async () => { deletes++; },
|
||||
} as unknown as NetcattyBridge);
|
||||
function Probe() { ops = useSftpTransferConflictOps(); return null; }
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const pane = { connection: { id: "local-pane", isLocal: true } } as SftpPane;
|
||||
const task = {
|
||||
sourceConnectionId: "local-pane", targetConnectionId: "local-pane",
|
||||
sourcePath: "/a/link", targetPath: "/mnt/alias/link", fileName: "link", isDirectory: false,
|
||||
} as TransferTask;
|
||||
await assert.rejects(ops.deleteTargetPath(task, pane, null, "auto", "symlink"), /Choose Duplicate or Skip/);
|
||||
assert.equal(deletes, 0);
|
||||
assert.equal(await ops.isSameSourceEntry(task, pane, null, "auto"), true);
|
||||
// Different entries and genuinely different parents must remain replaceable.
|
||||
for (const targetPath of ["/mnt/alias/another-link", "/other/link"]) {
|
||||
const other = { ...task, targetPath };
|
||||
assert.equal(await ops.isSameSourceEntry(other, pane, null, "auto"), false);
|
||||
await ops.deleteTargetPath(other, pane, null, "auto", "symlink");
|
||||
}
|
||||
assert.equal(deletes, 2);
|
||||
});
|
||||
|
||||
test("same-pane link replacement preserves the source, including aliased parent paths", async (t) => {
|
||||
const dir = await fs.mkdtemp(path.join(process.cwd(), ".pr3318-links-"));
|
||||
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const originalGet = netcattyBridge.get;
|
||||
let renderer: ReactTestRenderer;
|
||||
let ops!: ReturnType<typeof useSftpTransferConflictOps>;
|
||||
let deletes = 0;
|
||||
t.after(async () => {
|
||||
await act(async () => renderer?.unmount());
|
||||
netcattyBridge.get = originalGet;
|
||||
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
await fs.mkdir(path.join(dir, "source"));
|
||||
await fs.mkdir(path.join(dir, "other"));
|
||||
await fs.writeFile(path.join(dir, "source", "file"), "keep original contents");
|
||||
await fs.symlink("file", path.join(dir, "source", "link"));
|
||||
await fs.symlink("source", path.join(dir, "alias"));
|
||||
netcattyBridge.get = () => ({
|
||||
realpathLocal: fs.realpath,
|
||||
lstatLocal: async (value: string) => {
|
||||
const stat = await fs.lstat(value);
|
||||
return { type: stat.isSymbolicLink() ? "symlink" : "file", size: stat.size, lastModified: stat.mtimeMs };
|
||||
},
|
||||
deleteLocalFile: async (value: string) => { deletes++; await fs.unlink(value); },
|
||||
} as unknown as NetcattyBridge);
|
||||
function Probe() { ops = useSftpTransferConflictOps(); return null; }
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const pane = { connection: { id: "local-pane", isLocal: true } } as SftpPane;
|
||||
const task = {
|
||||
sourceConnectionId: "local-pane", targetConnectionId: "local-pane",
|
||||
sourcePath: path.join(dir, "source", "link"),
|
||||
targetPath: path.join(dir, "source", "link"),
|
||||
fileName: "link", isDirectory: false,
|
||||
} as TransferTask;
|
||||
for (const parent of ["source", "alias", "source/."]) {
|
||||
await assert.rejects(ops.deleteTargetPath(
|
||||
{ ...task, targetPath: `${dir}/${parent}/link` }, pane, null, "auto", "symlink",
|
||||
), /Choose Duplicate or Skip/);
|
||||
assert.equal(await fs.readlink(task.sourcePath), "file");
|
||||
assert.equal(await fs.readFile(task.sourcePath, "utf8"), "keep original contents");
|
||||
}
|
||||
assert.equal(deletes, 0);
|
||||
const duplicate = await ops.getDuplicateTarget(task, pane, null, "auto");
|
||||
assert.equal(duplicate.fileName, "link (copy)");
|
||||
await fs.symlink("../source/file", path.join(dir, "other", "link"));
|
||||
await ops.deleteTargetPath({ ...task, targetPath: path.join(dir, "other", "link") }, pane, null, "auto", "symlink");
|
||||
assert.equal(deletes, 1);
|
||||
assert.equal(await fs.readlink(task.sourcePath), "file");
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models"
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import type { SftpPane } from "./types";
|
||||
import { getParentPath, joinPath } from "./utils";
|
||||
import { getFileName, getParentPath, isSameSftpPath, joinPath } from "./utils";
|
||||
|
||||
export function useSftpTransferConflictOps() {
|
||||
const splitNameForDuplicate = useCallback((fileName: string, isDirectory: boolean) => {
|
||||
@@ -84,6 +84,42 @@ export function useSftpTransferConflictOps() {
|
||||
[splitNameForDuplicate, statTargetPath],
|
||||
);
|
||||
|
||||
const isSameSourceEntry = useCallback(async (
|
||||
task: TransferTask,
|
||||
targetPane: SftpPane,
|
||||
targetSftpId: string | null,
|
||||
targetEncoding: SftpFilenameEncoding,
|
||||
): Promise<boolean> => {
|
||||
if (!targetPane.connection || task.sourceConnectionId !== task.targetConnectionId) return false;
|
||||
if (isSameSftpPath(task.sourcePath, task.targetPath)) return true;
|
||||
const bridge = netcattyBridge.get();
|
||||
const resolve = targetPane.connection.isLocal
|
||||
? bridge?.realpathLocal
|
||||
: targetSftpId && bridge?.realpathSftp
|
||||
? (value: string) => bridge.realpathSftp!(targetSftpId, value, targetEncoding)
|
||||
: undefined;
|
||||
const [sourceParent, targetParent] = await Promise.all(
|
||||
[getParentPath(task.sourcePath), getParentPath(task.targetPath)].map(
|
||||
(value) => resolve ? resolve(value) : Promise.resolve(value),
|
||||
),
|
||||
);
|
||||
if (isSameSftpPath(
|
||||
joinPath(sourceParent, getFileName(task.sourcePath)),
|
||||
joinPath(targetParent, getFileName(task.targetPath)),
|
||||
)) return true;
|
||||
// Bind mounts keep distinct realpaths while naming the same directory.
|
||||
// Compare parents so a symlink is identified as an entry, not its referent.
|
||||
if (targetPane.connection.isLocal && bridge?.statLocal
|
||||
&& getFileName(task.sourcePath) === getFileName(task.targetPath)) {
|
||||
const [sourceStat, targetStat] = await Promise.all([
|
||||
bridge.statLocal(sourceParent), bridge.statLocal(targetParent),
|
||||
]);
|
||||
return sourceStat.dev !== undefined && sourceStat.ino !== undefined
|
||||
&& sourceStat.dev === targetStat.dev && sourceStat.ino === targetStat.ino;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const deleteTargetPath = useCallback(
|
||||
async (
|
||||
task: TransferTask,
|
||||
@@ -93,6 +129,11 @@ export function useSftpTransferConflictOps() {
|
||||
expectedType?: "file" | "directory" | "symlink",
|
||||
) => {
|
||||
if (!targetPane.connection) return;
|
||||
// Replace unlinks symlinks before copying. Never unlink the source entry
|
||||
// when a clipboard copy is pasted back into its own directory.
|
||||
if (expectedType === "symlink" && await isSameSourceEntry(task, targetPane, targetSftpId, targetEncoding)) {
|
||||
throw new Error("Cannot replace the source link with itself. Choose Duplicate or Skip.");
|
||||
}
|
||||
if (targetPane.connection.isLocal) {
|
||||
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
|
||||
if (!deleteLocalFile) throw new Error("Local delete unavailable");
|
||||
@@ -104,9 +145,9 @@ export function useSftpTransferConflictOps() {
|
||||
if (!deleteSftp) throw new Error("SFTP delete unavailable");
|
||||
await deleteSftp(targetSftpId, task.targetPath, targetEncoding, expectedType);
|
||||
},
|
||||
[],
|
||||
[isSameSourceEntry],
|
||||
);
|
||||
|
||||
|
||||
return { statTargetPath, getDuplicateTarget, deleteTargetPath };
|
||||
return { statTargetPath, getDuplicateTarget, deleteTargetPath, isSameSourceEntry };
|
||||
}
|
||||
@@ -558,11 +558,20 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
const listingGate = progress.listingGate;
|
||||
const traversal = traversalBudget ?? createSftpDirectoryTraversalBudget();
|
||||
// Only the newly supported same-pane local copy needs this fallback.
|
||||
// Preserve existing large local uploads and cross-pane copies.
|
||||
const boundLocalTraversal = sourceIsLocal && targetIsLocal
|
||||
&& task.sourceConnectionId === task.targetConnectionId;
|
||||
let claimedCanonicalPath: string | null = null;
|
||||
let regularFiles: SftpFileEntry[] = [];
|
||||
// Keep the current remote ancestor active through child discovery.
|
||||
try {
|
||||
if (!sourceIsLocal && sourceSftpId) {
|
||||
// Bound same-pane local copies that rediscover their own output through
|
||||
// filesystem aliases not detected by the paste guard.
|
||||
if (boundLocalTraversal) {
|
||||
claimedCanonicalPath = claimSftpDirectoryVisit(traversal, task.sourcePath);
|
||||
if (!claimedCanonicalPath) return totalErrors;
|
||||
} else if (!sourceIsLocal && sourceSftpId) {
|
||||
const bridge = netcattyBridge.get();
|
||||
const canonicalPath = await bridge?.realpathSftp?.(sourceSftpId, task.sourcePath, sourceEncoding)
|
||||
.catch(() => task.sourcePath) ?? task.sourcePath;
|
||||
@@ -611,7 +620,7 @@ export function useSftpDirectoryTransferOps({
|
||||
|
||||
// Filter both "." and ".." — some SFTP servers include "." in readdir
|
||||
const filtered = files.filter((f) => f.name !== ".." && f.name !== ".");
|
||||
if (!sourceIsLocal) accountSftpDirectoryEntries(traversal, filtered.length);
|
||||
if (!sourceIsLocal || boundLocalTraversal) accountSftpDirectoryEntries(traversal, filtered.length);
|
||||
// Separate directories from files.
|
||||
// Symlink directories are only followed when followSymlinks is true
|
||||
// (downloadToLocal). Uploads/copies treat symlinks as regular entries
|
||||
|
||||
@@ -442,7 +442,7 @@ export const useSftpTransfers = ({
|
||||
cleanupTaskArtifacts,
|
||||
});
|
||||
|
||||
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
|
||||
const { statTargetPath, getDuplicateTarget, deleteTargetPath, isSameSourceEntry } = useSftpTransferConflictOps();
|
||||
|
||||
const { transferFile, transferDirectory } = useSftpDirectoryTransferOps({
|
||||
ownerId,
|
||||
@@ -840,9 +840,14 @@ export const useSftpTransfers = ({
|
||||
|
||||
// Try same-host directory optimization first; falls back to recursive transfer
|
||||
// if remote cp is unavailable (e.g. Windows SSH servers).
|
||||
let dirHandledBySameHost = false;
|
||||
// Merge/Replace onto the source directory is already satisfied. Walking
|
||||
// it would rewrite its children (including replacing links with files).
|
||||
// Duplicate has a different target and continues through normal copying.
|
||||
let directoryHandled = task.isDirectory
|
||||
&& await isSameSourceEntry(task, targetPane, targetSftpId, targetEncoding);
|
||||
if (
|
||||
task.isDirectory
|
||||
&& !directoryHandled
|
||||
&& !task.replaceExistingTarget
|
||||
&& task.resumable === false
|
||||
&& sameHost
|
||||
@@ -862,10 +867,10 @@ export const useSftpTransfers = ({
|
||||
if (cancelledTasksRef.current.has(task.id)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
dirHandledBySameHost = result.success;
|
||||
directoryHandled = result.success;
|
||||
}
|
||||
|
||||
if (task.isDirectory && !dirHandledBySameHost) {
|
||||
if (task.isDirectory && !directoryHandled) {
|
||||
// For directory transfers, parent task uses:
|
||||
// totalBytes = total file count (discovered async)
|
||||
// transferredBytes = completed file count (incremented by child completions)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isPluginTerminalProviderRefreshCurrent,
|
||||
mergePluginTerminalThemeColors,
|
||||
resolvePluginTerminalTheme,
|
||||
toPluginTerminalTheme,
|
||||
waitForProviderResponse,
|
||||
} from './usePluginTerminalProviders.ts';
|
||||
|
||||
@@ -24,6 +25,15 @@ const baseTheme = {
|
||||
},
|
||||
} as const satisfies TerminalTheme;
|
||||
|
||||
test('theme provider requests keep the existing palette contract with intense color enabled or disabled', () => {
|
||||
for (const foregroundIntense of ['#ff8800', undefined]) {
|
||||
const theme = { ...baseTheme, colors: { ...baseTheme.colors, foregroundIntense } };
|
||||
assert.deepEqual(toPluginTerminalTheme(theme), { type: baseTheme.type, colors: baseTheme.colors });
|
||||
assert.equal(theme.colors.foregroundIntense, foregroundIntense);
|
||||
assert.equal(resolvePluginTerminalTheme(theme, { background: '#112233' }).colors.foregroundIntense, foregroundIntense);
|
||||
}
|
||||
});
|
||||
|
||||
test('terminal theme Providers merge deterministically over the host theme', () => {
|
||||
const merged = mergePluginTerminalThemeColors([
|
||||
{ background: '#102030', cursor: '#abcdef' },
|
||||
|
||||
@@ -51,6 +51,13 @@ interface UsePluginTerminalProvidersOptions extends PluginTerminalProviderRefres
|
||||
getSnapshotState(): Partial<NetcattyTerminalSessionSnapshot>;
|
||||
}
|
||||
|
||||
export function toPluginTerminalTheme(theme: TerminalTheme) {
|
||||
// The preview plugin contract exposes the existing palette only. Keep the
|
||||
// host-only optional color out of its strictly validated request payload.
|
||||
const { foregroundIntense: _foregroundIntense, ...colors } = theme.colors;
|
||||
return { type: theme.type, colors };
|
||||
}
|
||||
|
||||
export function mergePluginTerminalThemeColors(
|
||||
results: readonly PluginTerminalThemeColors[],
|
||||
): PluginTerminalThemeColors {
|
||||
@@ -211,10 +218,7 @@ export function usePluginTerminalProviders(options: UsePluginTerminalProvidersOp
|
||||
if (hasProvider('terminal.theme')) {
|
||||
tasks.push(applyCurrentProviderResponse(waitForProviderResponse(request('terminal.theme', 'provideTheme', {
|
||||
reason,
|
||||
currentTheme: {
|
||||
type: metadata.baseTheme.type,
|
||||
colors: metadata.baseTheme.colors,
|
||||
},
|
||||
currentTheme: toPluginTerminalTheme(metadata.baseTheme),
|
||||
}, PROVIDER_DEADLINE_MS, undefined, controller.signal), controller), isCurrent,
|
||||
(response) => {
|
||||
setThemeColors(mergePluginTerminalThemeColors(response.results.map((result) => result.status === 'ok'
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface ServerStats {
|
||||
disks: DiskInfo[]; // All mounted disks
|
||||
netRxSpeed: number; // Total network receive speed (bytes/sec)
|
||||
netTxSpeed: number; // Total network transmit speed (bytes/sec)
|
||||
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
|
||||
latencyMs: number | null; // SSH transport ping round-trip to the SSH endpoint
|
||||
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
|
||||
lastUpdated: number | null; // Timestamp of last successful update
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ export function useVaultImportHandlers({
|
||||
|
||||
const handleImportFileSelected = useCallback(
|
||||
async (format: VaultImportFormat, files: File[], options?: VaultImportOptions) => {
|
||||
const preserveProfiles = format === "securecrt" || format === "finalshell";
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
if (importInFlightRef.current) return;
|
||||
@@ -141,7 +142,9 @@ export function useVaultImportHandlers({
|
||||
? "CSV"
|
||||
: format === "securecrt"
|
||||
? "SecureCRT"
|
||||
: "ssh_config";
|
||||
: format === "finalshell"
|
||||
? "FinalShell"
|
||||
: "ssh_config";
|
||||
const updateProgress = (next: Partial<VaultImportProgress>) => {
|
||||
setImportProgress((current) => ({
|
||||
status: "running",
|
||||
@@ -183,9 +186,9 @@ export function useVaultImportHandlers({
|
||||
result = applyVaultImportDestination(
|
||||
result,
|
||||
options?.destination ?? { mode: "preserve" },
|
||||
// SecureCRT keeps distinct session files that share an endpoint;
|
||||
// Folder imports keep distinct profiles that share an endpoint;
|
||||
// only rewrite their group when the user picks an import location.
|
||||
{ collapseDuplicateEndpoints: format !== "securecrt" },
|
||||
{ collapseDuplicateEndpoints: !preserveProfiles },
|
||||
);
|
||||
}
|
||||
updateProgress({ stage: "preparing", percent: 70 });
|
||||
@@ -321,7 +324,7 @@ export function useVaultImportHandlers({
|
||||
|
||||
const existingKeys = new Set(currentHosts.map(makeKey));
|
||||
// Filter out duplicates for both managed and non-managed imports
|
||||
let newHosts = format === "securecrt"
|
||||
let newHosts = preserveProfiles
|
||||
? result.hosts
|
||||
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
|
||||
|
||||
@@ -486,7 +489,7 @@ export function useVaultImportHandlers({
|
||||
importBaselineHosts,
|
||||
importBaselineGroups,
|
||||
result,
|
||||
{ skipDuplicates: format !== "securecrt" },
|
||||
{ skipDuplicates: !preserveProfiles },
|
||||
);
|
||||
newHosts = merged.addedHosts;
|
||||
addedHostIds = new Set(merged.addedHosts.map((host) => host.id));
|
||||
|
||||
@@ -25,6 +25,23 @@ test("SecureCRT uses a directory picker and keeps every selected session file",
|
||||
assert.deepEqual(selectVaultImportFiles("csv", files), [files[0]]);
|
||||
});
|
||||
|
||||
test("FinalShell supports conn directory and single JSON file selection", () => {
|
||||
const files = [
|
||||
new File(["{}"], "one.json"),
|
||||
new File(["{}"], "two.json"),
|
||||
];
|
||||
|
||||
assert.deepEqual(getVaultImportPickerMode("finalshell"), {
|
||||
directory: true,
|
||||
multiple: true,
|
||||
});
|
||||
assert.deepEqual(getVaultImportPickerMode("finalshell", "file"), {
|
||||
directory: false,
|
||||
multiple: false,
|
||||
});
|
||||
assert.deepEqual(selectVaultImportFiles("finalshell", files), files);
|
||||
});
|
||||
|
||||
test("vault import destination supports preserve, existing, and new groups", () => {
|
||||
assert.deepEqual(buildVaultImportDestination({ mode: "preserve" }), {
|
||||
mode: "preserve",
|
||||
|
||||
@@ -7,15 +7,16 @@ export type VaultImportDestinationMode = "preserve" | "existing" | "new";
|
||||
|
||||
export function getVaultImportPickerMode(
|
||||
format: VaultImportFormat,
|
||||
secureCrtSource: "folder" | "file" = "folder",
|
||||
source: "folder" | "file" = "folder",
|
||||
): {
|
||||
directory: boolean;
|
||||
multiple: boolean;
|
||||
} {
|
||||
const isSecureCrtFolder = format === "securecrt" && secureCrtSource === "folder";
|
||||
const isBatchFolder = (format === "securecrt" || format === "finalshell")
|
||||
&& source === "folder";
|
||||
return {
|
||||
directory: isSecureCrtFolder,
|
||||
multiple: isSecureCrtFolder,
|
||||
directory: isBatchFolder,
|
||||
multiple: isBatchFolder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +25,9 @@ export function selectVaultImportFiles(
|
||||
files: ArrayLike<File>,
|
||||
): File[] {
|
||||
const selected = Array.from(files);
|
||||
return format === "securecrt" ? selected : selected.slice(0, 1);
|
||||
return format === "securecrt" || format === "finalshell"
|
||||
? selected
|
||||
: selected.slice(0, 1);
|
||||
}
|
||||
|
||||
const normalizeGroup = (raw: string | undefined): string | undefined => {
|
||||
|
||||
@@ -28,6 +28,15 @@ test('checked targets keep a visible background after the cursor moves away', ()
|
||||
assert.equal(getWorkspaceTargetRowStateClass(true, true), 'bg-primary/20');
|
||||
});
|
||||
|
||||
test('workspace host picker search matches tags through the shared matcher', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /matchesWorkspaceHostPickerQuery/);
|
||||
assert.doesNotMatch(source, /h\.label\?\.toLowerCase\(\)\.includes\(term\)/);
|
||||
});
|
||||
|
||||
test('clicking a target aligns the keyboard cursor with that target', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { matchesWorkspaceHostPickerQuery } from '../lib/searchMatcher';
|
||||
import { Host } from '../types';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
@@ -30,13 +31,9 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!search.trim()) return hosts;
|
||||
const term = search.toLowerCase();
|
||||
return hosts.filter(h =>
|
||||
h.label.toLowerCase().includes(term) ||
|
||||
h.hostname.toLowerCase().includes(term) ||
|
||||
(h.group || '').toLowerCase().includes(term)
|
||||
);
|
||||
const term = search.trim();
|
||||
if (!term) return hosts;
|
||||
return hosts.filter((host) => matchesWorkspaceHostPickerQuery(term, host));
|
||||
}, [hosts, search]);
|
||||
|
||||
const toggleHost = useCallback((hostId: string) => {
|
||||
@@ -109,7 +106,7 @@ export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts...' })}
|
||||
placeholder={t('placeholder.searchHosts', { defaultValue: 'Search hosts or tags...' })}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
|
||||
@@ -68,6 +68,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
const [localEcho, setLocalEcho] = useState(false);
|
||||
const [lineMode, setLineMode] = useState(false);
|
||||
const [backspaceBehavior, setBackspaceBehavior] = useState<SerialConfig['backspaceBehavior']>('default');
|
||||
const [byteOrientedBackspace, setByteOrientedBackspace] = useState(false);
|
||||
const [charset, setCharset] = useState('UTF-8');
|
||||
|
||||
// Save configuration state
|
||||
@@ -119,6 +120,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
localEcho,
|
||||
lineMode,
|
||||
backspaceBehavior,
|
||||
byteOrientedBackspace,
|
||||
};
|
||||
|
||||
// Save as host if checkbox is checked and onSaveHost is provided
|
||||
@@ -370,6 +372,24 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="byte-oriented-backspace" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.byteOrientedBackspace')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.byteOrientedBackspaceDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="byte-oriented-backspace"
|
||||
checked={byteOrientedBackspace}
|
||||
onChange={(e) => setByteOrientedBackspace(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
|
||||
|
||||
@@ -90,6 +90,9 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
resolveSerialBackspaceFormValue(initialData, groupDefaults),
|
||||
);
|
||||
const [backspaceBehaviorChanged, setBackspaceBehaviorChanged] = useState(false);
|
||||
const [byteOrientedBackspace, setByteOrientedBackspace] = useState(
|
||||
initialData.serialConfig?.byteOrientedBackspace ?? false,
|
||||
);
|
||||
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
|
||||
const [tags, setTags] = useState<string[]>(initialData.tags || []);
|
||||
const [group, setGroup] = useState(initialData.group || '');
|
||||
@@ -128,6 +131,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
selectedBehavior: backspaceBehavior,
|
||||
behaviorChanged: backspaceBehaviorChanged,
|
||||
}),
|
||||
byteOrientedBackspace,
|
||||
};
|
||||
|
||||
const portName = selectedPort.split('/').pop() || selectedPort;
|
||||
@@ -436,6 +440,24 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="byte-oriented-backspace" className="text-sm font-medium cursor-pointer">
|
||||
{t('serial.field.byteOrientedBackspace')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('serial.field.byteOrientedBackspaceDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="byte-oriented-backspace"
|
||||
checked={byteOrientedBackspace}
|
||||
onChange={(e) => setByteOrientedBackspace(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="local-echo" className="text-sm font-medium cursor-pointer">
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { useSftpFollowTerminalCwd } from "../application/state/sftp/useSftpFollowTerminalCwd";
|
||||
import { usePendingSftpUploadRebind } from "../application/state/sftp/usePendingSftpUploadRebind";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { registerEditorSftpOwnerResolver } from "../application/state/editorSftpOwnerRegistry";
|
||||
import {
|
||||
editorTabStore,
|
||||
useEditorTabPresenceRevision,
|
||||
@@ -147,6 +148,9 @@ interface SftpSidePanelProps {
|
||||
* focused). Keeps browse SFTP sessions warm across History/System switches.
|
||||
*/
|
||||
ownerPanelOpen?: boolean;
|
||||
/** Top-level tab id hosting this panel (terminal/workspace tab), used to
|
||||
* resolve which tab owns an editor's SFTP connection. */
|
||||
ownerTabId?: string | null;
|
||||
renderOverlays?: boolean;
|
||||
pendingUpload?: {
|
||||
requestId: string;
|
||||
@@ -201,6 +205,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
showWorkspaceHostHeader = false,
|
||||
isVisible = true,
|
||||
ownerPanelOpen = false,
|
||||
ownerTabId = null,
|
||||
renderOverlays = true,
|
||||
pendingUpload = null,
|
||||
onPendingUploadHandled,
|
||||
@@ -478,6 +483,32 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
);
|
||||
}, []);
|
||||
|
||||
const ownerTabIdRef = useRef(ownerTabId);
|
||||
ownerTabIdRef.current = ownerTabId;
|
||||
|
||||
// While mounted, report which SFTP connections this panel owns and which
|
||||
// top-level tab hosts it, so batch-close logic can keep the owning tab open
|
||||
// when an editor bound to one of those connections survives its close
|
||||
// prompt. Intentionally no deps — go through sftpRef so connection churn
|
||||
// doesn't register/unregister on every re-render.
|
||||
useEffect(() => {
|
||||
return registerEditorSftpOwnerResolver(() => {
|
||||
const s = sftpRef.current;
|
||||
if (!s) return { connectionIds: [], ownerTabId: ownerTabIdRef.current };
|
||||
const connectionIds: string[] = [];
|
||||
const paneTabIds: string[] = [];
|
||||
for (const tab of [...(s.leftTabs?.tabs ?? []), ...(s.rightTabs?.tabs ?? [])]) {
|
||||
const id = tab.connection?.id;
|
||||
if (id) connectionIds.push(id);
|
||||
// Pane tab ids are stable across browse reconnects (unlike connection
|
||||
// ids), so owner resolution can match editors bound to a stale
|
||||
// pre-reconnect connection id.
|
||||
if (tab.id) paneTabIds.push(tab.id);
|
||||
}
|
||||
return { connectionIds, paneTabIds, ownerTabId: ownerTabIdRef.current };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// When this side panel unmounts (its hosting terminal tab was closed) we
|
||||
// force-close any editor tabs bound to connections this panel owned — the
|
||||
// save channel is gone with the SFTP session and there's no way to recover
|
||||
|
||||
+148
-5
@@ -1,5 +1,7 @@
|
||||
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
|
||||
import { resolveHostOs } from '../domain/host';
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { IMarker } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
@@ -232,6 +234,8 @@ import {
|
||||
resolveTerminalHibernateReplayChunkBytes,
|
||||
type TerminalHibernateWakePayload,
|
||||
} from "../domain/terminalHibernate";
|
||||
import { getLastChar, removeLastChar } from "../domain/serialCharMetrics";
|
||||
import { stringCellWidth } from "./terminal/autocomplete/terminalStringCellWidth";
|
||||
import { terminalHiddenRendererStore } from "../application/state/terminalHiddenRendererStore";
|
||||
import {
|
||||
wakeTerminalFromHibernate,
|
||||
@@ -255,6 +259,7 @@ import {
|
||||
import {
|
||||
alignTerminalViewportScroll,
|
||||
createSynchronizedOutputFitScheduler,
|
||||
resolveTerminalReflowScrollAnchor,
|
||||
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
|
||||
forceSyncRenderAfterResize,
|
||||
MAX_CONNECTION_LOG_DATA_CHARS,
|
||||
@@ -642,6 +647,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isRendererActiveRef = useRef(isRendererActive);
|
||||
isRendererActiveRef.current = isRendererActive;
|
||||
const pendingOutputScrollRef = useRef(false);
|
||||
const reflowReadingPositionRef = useRef(createTerminalReflowReadingPosition());
|
||||
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const fontWeightFixupDoneRef = useRef(false);
|
||||
|
||||
@@ -1019,8 +1025,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLineBufferRef.current = "";
|
||||
} else if (ch === "\b" || ch === "\x7f") {
|
||||
if (serialLineBufferRef.current.length > 0) {
|
||||
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
|
||||
if (serialConfig?.localEcho) writeLocalTerminalData("\b \b");
|
||||
const lastChar = getLastChar(serialLineBufferRef.current);
|
||||
const cells = stringCellWidth(lastChar, termRef.current);
|
||||
serialLineBufferRef.current = removeLastChar(serialLineBufferRef.current);
|
||||
if (serialConfig?.localEcho) writeLocalTerminalData("\b \b".repeat(cells));
|
||||
}
|
||||
} else if (ch.charCodeAt(0) >= 32) {
|
||||
serialLineBufferRef.current += ch;
|
||||
@@ -2424,6 +2432,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isNetworkDevice,
|
||||
startupCommand,
|
||||
noAutoRun,
|
||||
recordSerialSnippetInput: (data) => xtermRuntimeRef.current?.recordSerialSnippetInput(data),
|
||||
multiLineRunMode,
|
||||
shellType,
|
||||
suppressHostStartupCommandRef,
|
||||
@@ -3009,6 +3018,48 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const dimensions = fitAddon.proposeDimensions();
|
||||
if (!dimensions || Number.isNaN(dimensions.cols) || Number.isNaN(dimensions.rows)) return;
|
||||
|
||||
// A column change rewraps the scrollback, moving rows above the
|
||||
// reading position, so a saved row index no longer points at the same
|
||||
// content after the resize. Capture the content instead of an index.
|
||||
// Row-only and pixel-only fits never rewrap, so skip the O(scrollback)
|
||||
// anchor scan on those frames.
|
||||
const previousCols = term.cols;
|
||||
const reflowAnchor = wasPinnedToBottom || term.cols === dimensions.cols
|
||||
? null
|
||||
: reflowReadingPositionRef.current.capture(buffer, {
|
||||
// xterm keeps at most rows + scrollback buffer rows and trims
|
||||
// from the top beyond that (Buffer._getCorrectBufferLength), so
|
||||
// these bounds let the capture skip its whole-line measurement
|
||||
// when no trim can reach the anchored line.
|
||||
maxRows: dimensions.rows + (term.options.scrollback ?? 1000),
|
||||
oldCols: previousCols,
|
||||
newCols: dimensions.cols,
|
||||
});
|
||||
|
||||
// Markers pinned to the viewed row and to the viewed logical line's
|
||||
// start. xterm adjusts marker rows through the rewrap (and disposes
|
||||
// them when their row is deleted or trimmed), so after the resize a
|
||||
// surviving marker marks where the viewed content moved without
|
||||
// scanning for it. Each rewrap direction deletes a different row:
|
||||
// a column grow merges a wrapped continuation into its predecessor
|
||||
// (disposing a marker on the viewed continuation row), while a column
|
||||
// shrink on a full scrollback trims the line's first physical rows
|
||||
// (disposing a marker on the start row; the viewed content survives).
|
||||
// Pinning one marker to each of those rows keeps the resolver seeded
|
||||
// in both directions.
|
||||
let reflowMarker: IMarker | null = null;
|
||||
let reflowStartMarker: IMarker | null = null;
|
||||
if (reflowAnchor) {
|
||||
reflowMarker = term.registerMarker(
|
||||
savedViewportY - (buffer.baseY + buffer.cursorY),
|
||||
);
|
||||
if (reflowAnchor.startRow !== savedViewportY) {
|
||||
reflowStartMarker = term.registerMarker(
|
||||
reflowAnchor.startRow - (buffer.baseY + buffer.cursorY),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lastFittedSizeRef.current = { width, height };
|
||||
// addon-fit 0.11 clears the renderer before resizing, which can show
|
||||
// as a one-frame WebGL blink during layout changes. Resize directly
|
||||
@@ -3030,15 +3081,95 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Align first so the relative restore below does not apply that delta twice.
|
||||
alignTerminalViewportScroll(term);
|
||||
|
||||
let anchoredViewportY: number | null = null;
|
||||
// Preserve scroll position across resize (superset/Tabby pattern).
|
||||
if (wasPinnedToBottom) {
|
||||
term.scrollToBottom();
|
||||
} else {
|
||||
const targetY = Math.min(savedViewportY, term.buffer.active.baseY);
|
||||
// Re-locate the anchored content; fall back to the saved row index
|
||||
// when the anchored content is gone (scrollback trim). Prefer the
|
||||
// viewport-row marker (it marks the viewed row itself) over the
|
||||
// line-start marker, which only seeds the scan.
|
||||
const viewedMarkerRow = reflowMarker && !reflowMarker.isDisposed && reflowMarker.line >= 0
|
||||
? reflowMarker.line
|
||||
: null;
|
||||
const startMarkerRow = reflowStartMarker && !reflowStartMarker.isDisposed
|
||||
&& reflowStartMarker.line >= 0
|
||||
? reflowStartMarker.line
|
||||
: null;
|
||||
const markerRow = viewedMarkerRow ?? startMarkerRow;
|
||||
reflowMarker?.dispose();
|
||||
reflowMarker = null;
|
||||
reflowStartMarker?.dispose();
|
||||
reflowStartMarker = null;
|
||||
// A continuation anchor registers both markers, so both coming back
|
||||
// disposed means the trim deleted the rows they pin — but marker
|
||||
// disposal alone is not proof the anchored content is gone: a
|
||||
// narrowing rewrap relocates the viewed characters into newly
|
||||
// appended continuation rows before the trim cuts the same number
|
||||
// of rows from the top, so the characters can survive at the buffer
|
||||
// top while both pinned rows are gone. `trimReachedStart` switches
|
||||
// the resolver to that remnant check (a trim removes from the top,
|
||||
// so a line whose start row it reached leaves any surviving part at
|
||||
// row 0) instead of letting it sweep the stale row for repetitive
|
||||
// duplicates on every divider-drag frame. A single disposed
|
||||
// viewport marker keeps the seeded resolve: a column-grow merge
|
||||
// disposes it while the merged line still matches the anchor.
|
||||
const trimReachedStart = reflowAnchor !== null
|
||||
&& reflowAnchor.startRow !== savedViewportY
|
||||
&& viewedMarkerRow === null
|
||||
&& startMarkerRow === null;
|
||||
anchoredViewportY = reflowAnchor === null
|
||||
? null
|
||||
: resolveTerminalReflowScrollAnchor(
|
||||
term.buffer.active,
|
||||
reflowAnchor,
|
||||
markerRow,
|
||||
trimReachedStart,
|
||||
);
|
||||
// Content matching can still fail when the viewed line is the
|
||||
// cursor's own logical line: with the pinned `reflowCursorLine:
|
||||
// false` default a narrowing resize skips rewrapping that line and
|
||||
// truncates its rows, so the captured prefix no longer matches even
|
||||
// though the viewport-row marker survived and tracks the exact
|
||||
// viewed row. Restore the marker position there rather than the
|
||||
// stale saved index, which would jump upward by the accumulated
|
||||
// reflow delta of the rewrapped lines above. (The line-start marker
|
||||
// must not stand in for a failed match: its row only seeds the
|
||||
// scan, and the column-grow merge disposes the viewport marker
|
||||
// while the merged line still matches the anchor.)
|
||||
// Only those two cases may trust the viewport-row marker. When the
|
||||
// viewport sits partway into a wrapped non-cursor line, a column
|
||||
// shrink rewraps that line and xterm appends the group's newly
|
||||
// created rows after the existing ones, so the marker keeps its old
|
||||
// within-line row while the viewed characters move deeper — even
|
||||
// though xterm still adjusts the marker through unrelated
|
||||
// insertions and would have disposed it only if its row were
|
||||
// trimmed. That mismatch is unobservable through marker disposal,
|
||||
// so trust the marker as a restore position only when the anchored
|
||||
// line is the cursor's own (row indices survive truncation) or the
|
||||
// marker sits at the anchored line's start (the group's new rows
|
||||
// are appended below it, and insertions above shift it onto the
|
||||
// relocated start). Anything else falls back to the plain row
|
||||
// restore.
|
||||
const markerTrackedViewport = reflowAnchor !== null
|
||||
&& (reflowAnchor.startRow === savedViewportY
|
||||
|| reflowAnchor.containsCursor === true)
|
||||
? viewedMarkerRow
|
||||
: null;
|
||||
const targetY = Math.min(
|
||||
anchoredViewportY ?? markerTrackedViewport ?? savedViewportY,
|
||||
term.buffer.active.baseY,
|
||||
);
|
||||
if (term.buffer.active.viewportY !== targetY) {
|
||||
term.scrollToLine(targetY);
|
||||
}
|
||||
}
|
||||
if (term.cols !== previousCols) {
|
||||
reflowReadingPositionRef.current.remember(
|
||||
term.buffer.active, anchoredViewportY === null ? null : reflowAnchor,
|
||||
);
|
||||
}
|
||||
term.refresh(0, Math.max(0, term.rows - 1));
|
||||
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
@@ -3195,12 +3326,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sensitive,
|
||||
...(lineDelayMs ? { lineDelayMs } : {}),
|
||||
});
|
||||
// Snippets left for editing share the serial typed-input buffer.
|
||||
if (host.protocol === 'serial' && noAutoRun && !serialConfig?.lineMode) {
|
||||
xtermRuntimeRef.current?.recordSerialSnippetInput(data);
|
||||
}
|
||||
scrollToBottomAfterProgrammaticInput(data);
|
||||
if (options?.focus !== false) {
|
||||
term.focus();
|
||||
}
|
||||
return true;
|
||||
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId]);
|
||||
}, [prepareProgrammaticSudoInput, scrollToBottomAfterProgrammaticInput, terminalBackend, sessionId, host.protocol, serialConfig?.lineMode]);
|
||||
|
||||
const executeSnippet = useCallback(async (snippet: Snippet) => {
|
||||
if (isScriptSnippet(snippet)) {
|
||||
@@ -3278,6 +3413,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [onAddSelectionToAI, sessionId, terminalSettings?.normalizeTextOnCopy]);
|
||||
|
||||
const handleSetTerminalEncoding = useCallback((encoding: TerminalEncodingPreference) => {
|
||||
// A byte-oriented device still holds bytes in the previous encoding.
|
||||
// Require an empty input line before changing that encoding.
|
||||
if (host.protocol === 'serial' && serialConfig?.byteOrientedBackspace === true
|
||||
&& !serialConfig?.lineMode && commandBufferRef.current) {
|
||||
toast.info(t('serial.encoding.pendingInput'));
|
||||
return;
|
||||
}
|
||||
setTerminalEncoding(encoding);
|
||||
setRememberedTerminalEncoding(encoding);
|
||||
userPickedEncodingRef.current = true;
|
||||
@@ -3290,7 +3432,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (sessionRef.current) {
|
||||
setSessionEncoding(sessionRef.current, encoding);
|
||||
}
|
||||
}, [handleUpdateHostFromTerminal, host.id, host.protocol, setRememberedTerminalEncoding, setSessionEncoding]);
|
||||
}, [handleUpdateHostFromTerminal, host.id, host.protocol, serialConfig?.byteOrientedBackspace, serialConfig?.lineMode, setRememberedTerminalEncoding, setSessionEncoding, t]);
|
||||
|
||||
const handleOpenSFTP = useCallback(async () => {
|
||||
if (onOpenSftp) {
|
||||
@@ -4086,6 +4228,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
requestSearchFocus,
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialByteOrientedBackspace: serialConfig?.byteOrientedBackspace ?? false,
|
||||
serialLineBufferRef,
|
||||
telnetLocalEchoRef,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
|
||||
@@ -634,3 +634,24 @@ test("App shell retains presentation-stable sessions for domain memos", () => {
|
||||
assert.match(appSource, /sessions: sessionsForShell/);
|
||||
assert.match(appSource, /orphanSessions: orphanSessionsForShell/);
|
||||
});
|
||||
|
||||
test("bulk close context menu offers symmetric close-to-left option", () => {
|
||||
const bulkCloseBlock = topTabsSource.slice(
|
||||
topTabsSource.indexOf("Bulk-close menu items shared by session and workspace context menus"),
|
||||
topTabsSource.indexOf("// Render the tabs"),
|
||||
);
|
||||
// Left of the anchor is symmetric with close-to-right: slice(0, anchorIdx).
|
||||
assert.match(bulkCloseBlock, /orderedTabs\.slice\(0, anchorIdx\)/);
|
||||
assert.match(bulkCloseBlock, /tabs\.closeToLeft/);
|
||||
assert.match(bulkCloseBlock, /onCloseTabsBatch\(leftIds\)/);
|
||||
|
||||
// Locale parity: the new key is translated in every terminal locale.
|
||||
const locales = ["en", "es", "ru", "zh-CN", "zh-TW"] as const;
|
||||
for (const locale of locales) {
|
||||
const source = readFileSync(
|
||||
new URL(`../application/i18n/locales/${locale}/terminal.ts`, import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /'tabs\.closeToLeft':/);
|
||||
}
|
||||
});
|
||||
@@ -769,6 +769,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const anchorIdx = orderedTabs.indexOf(anchorId);
|
||||
const othersIds = orderedTabs.filter((id) => id !== anchorId);
|
||||
const rightIds = anchorIdx >= 0 ? orderedTabs.slice(anchorIdx + 1) : [];
|
||||
const leftIds = anchorIdx > 0 ? orderedTabs.slice(0, anchorIdx) : [];
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
@@ -778,6 +779,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
>
|
||||
{t('tabs.closeOthers')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={leftIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(leftIds)}
|
||||
>
|
||||
{t('tabs.closeToLeft')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={rightIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(rightIds)}
|
||||
|
||||
@@ -83,6 +83,7 @@ test('AddToWorkspaceDialog virtualizes and clamps selection on shrink', () => {
|
||||
const source = read('workspace/AddToWorkspaceDialog.tsx');
|
||||
assert.match(source, /VariableSizeVirtualList/);
|
||||
assert.match(source, /data-host-picker-virtual="add-workspace"/);
|
||||
assert.match(source, /matchesWorkspaceHostPickerQuery/);
|
||||
assert.match(source, /clampListIndex\(prev, items\.length\)/);
|
||||
assert.match(source, /onClick=\{\(\) => handleTargetClick\(idx, LOCAL_ITEM_ID\)\}/);
|
||||
assert.match(source, /onClick=\{\(\) => handleTargetClick\(idx, host\.id\)\}/);
|
||||
@@ -96,6 +97,7 @@ test('CreateWorkspaceDialog virtualizes selectable hosts', () => {
|
||||
const source = read('CreateWorkspaceDialog.tsx');
|
||||
assert.match(source, /FixedSizeVirtualList/);
|
||||
assert.match(source, /data-host-picker-virtual="create-workspace"/);
|
||||
assert.match(source, /matchesWorkspaceHostPickerQuery/);
|
||||
assert.doesNotMatch(source, /filteredHosts\.map\(host =>/);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
requireCopyToOtherPaneTarget,
|
||||
type SftpPaneSide,
|
||||
} from "./copyToOtherPane";
|
||||
import { resolveSamePanePasteAction } from "../../application/state/sftp/samePanePaste";
|
||||
|
||||
test("copy to other pane is unavailable when the destination pane is missing", () => {
|
||||
assert.equal(canCopyToOtherPane({ getActivePane: () => null }, "right"), false);
|
||||
@@ -62,3 +63,414 @@ test("copy to other pane reports why it cannot start instead of silently returni
|
||||
);
|
||||
assert.equal(unavailableCount, 1);
|
||||
});
|
||||
|
||||
test("same-pane copy of files into their own source folder is allowed", async () => {
|
||||
const files = [
|
||||
{ name: "report.txt", isDirectory: false },
|
||||
{ name: "notes.txt", isDirectory: false },
|
||||
];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/home/user", targetPath: "/home/user", files }),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/home/user", targetPath: "/home/user/docs", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane copy of a directory into itself or a descendant is blocked", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a", targetPath: "/a/docs", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a", targetPath: "/a/docs/sub", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a", targetPath: "/a/docs/sub/deep", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane copy of a directory into a sibling is allowed", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a/docs", targetPath: "/a/sub", files }),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "copy", sourcePath: "/a/docs", targetPath: "/a/docsx", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane cut into the source folder is blocked", async () => {
|
||||
const files = [{ name: "report.txt", isDirectory: false }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane cut of files into a child of the source folder is allowed", async () => {
|
||||
const files = [
|
||||
{ name: "report.txt", isDirectory: false },
|
||||
{ name: "photos", isDirectory: false },
|
||||
];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs", files }),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs/sub", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane cut of a directory into itself or a descendant is blocked", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs/sub", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/docs/sub/deep", files }),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane cut into a sibling folder is allowed", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/other", files }),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user2", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane paste guard understands Windows paths", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:/Users/me",
|
||||
files,
|
||||
}),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\me\\docs",
|
||||
files,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\me\\docs",
|
||||
files: [{ name: "report.txt", isDirectory: false }],
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\other",
|
||||
files,
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards canonicalize equivalent path spellings", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/.", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home//user", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/user/../user", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/home/user",
|
||||
targetPath: "/home/./user/docs",
|
||||
files,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/userx", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards collapse a leading double slash for POSIX comparisons", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "//home/user", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/home/user",
|
||||
targetPath: "//home/user/docs",
|
||||
files,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "//home/user", targetPath: "/home/user", files }),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({ operation: "cut", sourcePath: "/home/user", targetPath: "/home/other", files }),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards canonicalize equivalent Windows path spellings", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\me\\.",
|
||||
files,
|
||||
}),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\\\me",
|
||||
files,
|
||||
}),
|
||||
"block-same-folder",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "C:\\Users\\me",
|
||||
targetPath: "C:\\Users\\me\\docs\\sub\\..\\docs",
|
||||
files,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards keep the separator after UNC share roots", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
// Copying docs from \\server\share into \\server\sharedocs (a different
|
||||
// share) must not collide with the source item \\server\share\docs.
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "\\\\server\\share",
|
||||
targetPath: "\\\\server\\sharedocs",
|
||||
files,
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "\\\\server\\share",
|
||||
targetPath: "\\\\server\\share\\docs",
|
||||
files,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards resolve filesystem aliases before comparing", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
// /a/link -> /a/docs/sub: pasting /a/docs from /a/link must be blocked even
|
||||
// though the lexical paths look unrelated.
|
||||
const aliasedResolver = (path: string) => {
|
||||
const aliases: Record<string, string> = {
|
||||
"/a/link": "/a/docs/sub",
|
||||
"/a/docs": "/a/docs",
|
||||
"/a": "/a",
|
||||
};
|
||||
return Promise.resolve(aliases[path] ?? path);
|
||||
};
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/a/link",
|
||||
files,
|
||||
resolvePath: aliasedResolver,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/a/link",
|
||||
files,
|
||||
resolvePath: aliasedResolver,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards still allow unrelated real paths when resolving", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
const resolver = (path: string) => {
|
||||
const resolved: Record<string, string> = {
|
||||
"/a/docs": "/data/docs",
|
||||
"/a/other": "/data/other",
|
||||
"/a/docs/docs": "/data/docs/docs",
|
||||
};
|
||||
return Promise.resolve(resolved[path] ?? path);
|
||||
};
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "/a/docs",
|
||||
targetPath: "/a/other",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane guards fail closed when filesystem resolution fails", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "/a/docs",
|
||||
targetPath: "/a/other",
|
||||
files,
|
||||
resolvePath: () => Promise.reject(new Error("realpath unavailable")),
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
|
||||
test("files-only copy still passes when path resolution is unavailable", async () => {
|
||||
const files = [{ name: "report.txt", isDirectory: false }];
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/b",
|
||||
files,
|
||||
resolvePath: () => Promise.reject(new Error("realpath unavailable")),
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane cut into a bind-mount alias of the source folder is blocked", async () => {
|
||||
const files = [{ name: "report.txt", isDirectory: false }];
|
||||
// realpath cannot see through bind mounts: /mnt/alias is bind-mounted to /a,
|
||||
// so both sides resolve to themselves and only stat identities match.
|
||||
const resolver = (path: string) => Promise.resolve(path);
|
||||
const statIdentity = (path: string) => Promise.resolve(
|
||||
path === "/a" || path === "/mnt/alias" ? { dev: 42, ino: 7 } : { dev: 42, ino: 9 },
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/mnt/alias",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
statIdentity,
|
||||
}),
|
||||
"block-same-folder",
|
||||
);
|
||||
// Distinct filesystem identities must not block the cut.
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/mnt/other",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
statIdentity: (path) => Promise.resolve(
|
||||
path === "/a" ? { dev: 42, ino: 7 } : { dev: 42, ino: 8 },
|
||||
),
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
// A provider that cannot stat falls through instead of blocking.
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/mnt/alias",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
statIdentity: () => Promise.resolve(null),
|
||||
}),
|
||||
"allow",
|
||||
);
|
||||
});
|
||||
|
||||
test("same-pane paste into a bind-mount alias of a clipboard directory is blocked", async () => {
|
||||
const files = [{ name: "docs", isDirectory: true }];
|
||||
// /mnt/docs-alias is bind-mounted to /a/docs: pasting /a/docs there names
|
||||
// the same directory, for copy (recursive rediscovery) and cut (the source
|
||||
// delete would remove the freshly pasted entry).
|
||||
const resolver = (path: string) => Promise.resolve(path);
|
||||
const statIdentity = (path: string) => Promise.resolve(
|
||||
path === "/a/docs" || path === "/mnt/docs-alias" ? { dev: 42, ino: 11 } : { dev: 42, ino: 3 },
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "copy",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/mnt/docs-alias",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
statIdentity,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
assert.equal(
|
||||
await resolveSamePanePasteAction({
|
||||
operation: "cut",
|
||||
sourcePath: "/a",
|
||||
targetPath: "/mnt/docs-alias",
|
||||
files,
|
||||
resolvePath: resolver,
|
||||
statIdentity,
|
||||
}),
|
||||
"block-into-source",
|
||||
);
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { keepOnlyPaneSelections } from "./selectionScope";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import type { UploadEndpointPin } from "../../../application/state/sftp/uploadTargetPin";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../utils";
|
||||
import { resolveSameConnectionPasteAction } from "../../../application/state/sftp/samePanePaste";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
import { toast } from "../../ui/toast";
|
||||
@@ -351,14 +352,51 @@ export const useSftpKeyboardShortcuts = ({
|
||||
const clipboard = sftpClipboardStore.get();
|
||||
if (!clipboard || clipboard.files.length === 0) return;
|
||||
|
||||
// Pin the destination this paste was validated against. Native pastes may
|
||||
// await clipboard reads before reaching here, and the user can navigate or
|
||||
// switch tabs meanwhile — startTransfer would otherwise resolve the
|
||||
// then-active pane and could land inside a directory this guard approved
|
||||
// as safe.
|
||||
const targetConnectionId = pane.connection!.id;
|
||||
const targetPath = pane.connection!.currentPath;
|
||||
|
||||
const isSameConnection = clipboard.sourceSide === focusedSide
|
||||
&& clipboard.sourceConnectionId === pane.connection!.id;
|
||||
if (isSameConnection) {
|
||||
toast.info("Paste within the same pane is not supported. Use copy to other pane instead.", "SFTP");
|
||||
return;
|
||||
const connection = pane.connection!;
|
||||
const pasteAction = await resolveSameConnectionPasteAction({
|
||||
operation: clipboard.operation,
|
||||
sourcePath: clipboard.sourcePath,
|
||||
targetPath,
|
||||
files: clipboard.files,
|
||||
isLocal: connection.isLocal,
|
||||
sftpId: connection.isLocal ? null : (sftp.getSftpIdForConnection(connection.id) ?? null),
|
||||
});
|
||||
if (pasteAction === "block-same-folder") {
|
||||
toast.info("The cut items are already in this folder.", "SFTP");
|
||||
return;
|
||||
}
|
||||
if (pasteAction === "block-into-source") {
|
||||
toast.info(
|
||||
clipboard.operation === "cut"
|
||||
? "A folder can't be moved into itself. Choose a different folder."
|
||||
: "A folder can't be copied into itself or one of its subfolders. Choose a different folder.",
|
||||
"SFTP",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Same-pane copy (and cut into a different folder) falls through to the
|
||||
// shared transfer path below.
|
||||
}
|
||||
|
||||
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
|
||||
// Re-read live state, not the pre-await snapshot: the same-connection
|
||||
// guard above can await realpath while the user switches tabs, and
|
||||
// startTransfer resolves the currently active pane. Comparing against the
|
||||
// stale snapshot would let the pinned targetPath land on a different
|
||||
// connection (and a cut would then delete the original after the wrong
|
||||
// host received the data).
|
||||
const liveSftp = sftpRef.current;
|
||||
const sourceTabs = clipboard.sourceSide === "left" ? liveSftp.leftTabs.tabs : liveSftp.rightTabs.tabs;
|
||||
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
|
||||
|
||||
if (!sourcePane?.connection) {
|
||||
@@ -426,10 +464,24 @@ export const useSftpKeyboardShortcuts = ({
|
||||
updateClipboardAfterCompletion(pendingNames.size === 0);
|
||||
};
|
||||
|
||||
// Abandon the paste when the destination pane changed while the paste
|
||||
// was pending: startTransfer resolves the currently active pane, and a
|
||||
// different connection would receive a path pinned from another host.
|
||||
// Read the live tabs (see the liveSftp note above), not the snapshot
|
||||
// captured before the await.
|
||||
const activeTargetPane = focusedSide === "left"
|
||||
? liveSftp.leftTabs.tabs.find((tab) => tab.id === liveSftp.leftTabs.activeTabId)
|
||||
: liveSftp.rightTabs.tabs.find((tab) => tab.id === liveSftp.rightTabs.activeTabId);
|
||||
if (!activeTargetPane?.connection || activeTargetPane.connection.id !== targetConnectionId) {
|
||||
toast.info("Paste cancelled: the destination connection changed.", "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
await sftp.startTransfer(clipboard.files, clipboard.sourceSide, focusedSide, {
|
||||
sourcePane,
|
||||
sourcePath: clipboard.sourcePath,
|
||||
sourceConnectionId: clipboard.sourceConnectionId,
|
||||
targetPath,
|
||||
onTransferComplete: handleTransferComplete,
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { CustomThemeEditor } from './CustomThemeEditor';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import type { TerminalTheme } from '../../domain/models';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
test('intense foreground can be enabled, edited, disabled and saved without changing the palette', async () => {
|
||||
let theme: TerminalTheme = structuredClone(TERMINAL_THEMES[0]);
|
||||
delete theme.colors.foregroundIntense;
|
||||
const palette = { ...theme.colors };
|
||||
let renderer: ReturnType<typeof create>;
|
||||
function Editor() {
|
||||
const [current, setCurrent] = React.useState(theme);
|
||||
return <CustomThemeEditor theme={current} onChange={(next) => { theme = next; setCurrent(next); }} />;
|
||||
}
|
||||
await act(async () => { renderer = create(<Editor />); });
|
||||
const toggle = () => renderer.root.findByProps({ role: 'switch' });
|
||||
const color = () => renderer.root.findAllByType('input').find((input) =>
|
||||
input.props.type === 'color' && input.props['aria-label'] === 'terminal.customTheme.color.foregroundIntense')!;
|
||||
assert.equal(toggle().props['aria-checked'], false);
|
||||
assert.equal(color().props.disabled, true);
|
||||
await act(async () => toggle().props.onClick());
|
||||
assert.equal(theme.colors.foregroundIntense, theme.colors.foreground);
|
||||
await act(async () => color().props.onChange({ target: { value: '#ff8800' } }));
|
||||
assert.equal(theme.colors.foregroundIntense, '#ff8800');
|
||||
assert.equal(JSON.parse(JSON.stringify(theme)).colors.foregroundIntense, '#ff8800');
|
||||
await act(async () => toggle().props.onClick());
|
||||
assert.equal(theme.colors.foregroundIntense, undefined);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(theme.colors)), palette);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
|
||||
test('iTerm import preserves Bold Color and leaves older themes disabled', async () => {
|
||||
const { JSDOM } = await import('jsdom');
|
||||
const { parseItermcolors } = await import('../../infrastructure/parsers/itermcolorsParser');
|
||||
const dom = new JSDOM();
|
||||
const original = globalThis.DOMParser;
|
||||
globalThis.DOMParser = dom.window.DOMParser;
|
||||
const color = (key: string, r: number, g: number, b: number) => `<key>${key}</key><dict><key>Red Component</key><real>${r}</real><key>Green Component</key><real>${g}</real><key>Blue Component</key><real>${b}</real></dict>`;
|
||||
const essential = color('Background Color', 0, 0, 0) + color('Foreground Color', 1, 1, 1);
|
||||
try {
|
||||
assert.equal(parseItermcolors(`<plist><dict>${essential}${color('Bold Color', 1, 0.5, 0)}</dict></plist>`, 'imported')?.colors.foregroundIntense, '#ff8000');
|
||||
assert.equal(parseItermcolors(`<plist><dict>${essential}</dict></plist>`, 'older')?.colors.foregroundIntense, undefined);
|
||||
} finally {
|
||||
if (original === undefined) delete (globalThis as { DOMParser?: typeof DOMParser }).DOMParser;
|
||||
else globalThis.DOMParser = original;
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -7,16 +7,24 @@
|
||||
import React, { useCallback, memo } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Switch } from '../ui/switch';
|
||||
|
||||
|
||||
interface ColorFieldDef {
|
||||
key: keyof TerminalTheme['colors'];
|
||||
labelKey: string;
|
||||
/** Fallback shown when the (optional) field has no value yet. */
|
||||
fallbackKey?: keyof TerminalTheme['colors'];
|
||||
}
|
||||
|
||||
const GENERAL_COLORS: ColorFieldDef[] = [
|
||||
{ key: 'background', labelKey: 'terminal.customTheme.color.background' },
|
||||
{ key: 'foreground', labelKey: 'terminal.customTheme.color.foreground' },
|
||||
{
|
||||
key: 'foregroundIntense',
|
||||
labelKey: 'terminal.customTheme.color.foregroundIntense',
|
||||
fallbackKey: 'foreground',
|
||||
},
|
||||
{ key: 'cursor', labelKey: 'terminal.customTheme.color.cursor' },
|
||||
{ key: 'selection', labelKey: 'terminal.customTheme.color.selection' },
|
||||
];
|
||||
@@ -47,10 +55,14 @@ const ColorInput = memo(({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
enabled?: boolean;
|
||||
onEnabledChange?: (enabled: boolean) => void;
|
||||
}) => {
|
||||
// Local state for text input — allows partial hex while typing
|
||||
const [textValue, setTextValue] = React.useState(value);
|
||||
@@ -76,9 +88,14 @@ const ColorInput = memo(({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{onEnabledChange && (
|
||||
<Switch checked={enabled} onCheckedChange={onEnabledChange} aria-label={label} />
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="color"
|
||||
aria-label={label}
|
||||
disabled={enabled === false}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-6 h-6 rounded cursor-pointer border border-border/50 p-0"
|
||||
@@ -88,6 +105,8 @@ const ColorInput = memo(({
|
||||
<span className="text-[10px] text-muted-foreground flex-1 truncate">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={label}
|
||||
disabled={enabled === false}
|
||||
value={textValue}
|
||||
onChange={handleTextChange}
|
||||
onBlur={handleBlur}
|
||||
@@ -114,7 +133,7 @@ export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string) => {
|
||||
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string | undefined) => {
|
||||
onChange({
|
||||
...theme,
|
||||
colors: { ...theme.colors, [key]: value },
|
||||
@@ -135,12 +154,16 @@ export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
|
||||
{title}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{fields.map(({ key, labelKey }) => (
|
||||
{fields.map(({ key, labelKey, fallbackKey }) => (
|
||||
<ColorInput
|
||||
key={key}
|
||||
label={t(labelKey)}
|
||||
value={theme.colors[key]}
|
||||
value={theme.colors[key] ?? (fallbackKey ? theme.colors[fallbackKey] : '') ?? ''}
|
||||
onChange={(v) => updateColor(key, v)}
|
||||
enabled={key === 'foregroundIntense' ? theme.colors.foregroundIntense !== undefined : undefined}
|
||||
onEnabledChange={key === 'foregroundIntense'
|
||||
? (enabled) => updateColor(key, enabled ? theme.colors.foreground : undefined)
|
||||
: undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,13 @@ function codePointCellWidth(cp: number): number {
|
||||
}
|
||||
|
||||
function graphemeCellWidth(grapheme: string): number {
|
||||
// Emoji-presentation graphemes (base + VS16 / U+FE0F) always render as
|
||||
// 2 cells regardless of the base character's text width. xterm's
|
||||
// 15-graphemes Unicode provider handles this via getStringCellWidth;
|
||||
// the fallback must detect VS16 explicitly because codePointCellWidth
|
||||
// returns 0 for the variation selector itself and 1 for bases like
|
||||
// U+2764 (heavy black heart) that are outside the standard emoji range.
|
||||
if (grapheme.includes("\uFE0F")) return 2;
|
||||
let max = 0;
|
||||
for (const ch of grapheme) {
|
||||
const w = codePointCellWidth(ch.codePointAt(0) ?? 0);
|
||||
|
||||
@@ -176,6 +176,7 @@ export type TerminalSessionStartersContext = {
|
||||
isNetworkDevice?: boolean;
|
||||
startupCommand?: string;
|
||||
noAutoRun?: boolean;
|
||||
recordSerialSnippetInput?: (data: string) => void;
|
||||
multiLineRunMode?: TerminalSession["multiLineRunMode"];
|
||||
shellType?: TerminalSession["shellType"];
|
||||
suppressHostStartupCommandRef?: RefObject<boolean>;
|
||||
|
||||
@@ -998,7 +998,7 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
|
||||
assert.ok(writeLoopIdx >= 0);
|
||||
const writeLoop = source.slice(writeLoopIdx, writeLoopIdx + 320);
|
||||
assert.match(writeLoop, /for \(const chunk of/);
|
||||
assert.match(writeLoop, /writeToSession\(id, chunk, \{ sensitive \}\)/);
|
||||
assert.match(writeLoop, /writeToSession\(id, chunk, \{ sensitive, serialEraseChar \}\)/);
|
||||
|
||||
// IME commits split the committed glyph, not the Kitty CSI-u encoding.
|
||||
assert.match(
|
||||
|
||||
@@ -119,6 +119,7 @@ import {
|
||||
shouldFlushStaleDeferredImeTextInput,
|
||||
} from "./terminalImeTextInput";
|
||||
import { formatSerialLocalEcho } from "./serialLocalEcho";
|
||||
import { getLastChar, removeLastChar, isPrintableInput } from "../../../domain/serialCharMetrics";
|
||||
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
|
||||
import {
|
||||
getTextInputWireChunks,
|
||||
@@ -239,6 +240,8 @@ export type XTermRuntime = {
|
||||
serializeAddon: SerializeAddon;
|
||||
searchAddon: SearchAddon;
|
||||
dispose: () => void;
|
||||
/** Track the pending final line of a serial snippet left for editing. */
|
||||
recordSerialSnippetInput: (data: string) => void;
|
||||
/** Current working directory detected via OSC 7 */
|
||||
currentCwd: string | undefined;
|
||||
keywordHighlighter: KeywordHighlighter;
|
||||
@@ -365,6 +368,8 @@ export type CreateXTermRuntimeContext = {
|
||||
// Serial-specific options
|
||||
serialLocalEcho?: boolean;
|
||||
serialLineMode?: boolean;
|
||||
/** Opt-in for devices whose line editor deletes bytes instead of characters. */
|
||||
serialByteOrientedBackspace?: boolean;
|
||||
serialLineBufferRef?: RefObject<string>;
|
||||
telnetLocalEchoRef?: RefObject<boolean>;
|
||||
onTerminalLogData?: (data: string) => void;
|
||||
@@ -1140,6 +1145,46 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
writeLocalTerminalDataInOrder(term, nextData, ctx.onTerminalLogData);
|
||||
};
|
||||
|
||||
// Tracks whether the most recent non-backspace input was a printable
|
||||
// character (not an escape sequence or control char). Backspace byte
|
||||
// expansion is only safe when the cursor is at the end of the typed
|
||||
// buffer — i.e. the last input advanced the cursor, not moved it.
|
||||
//
|
||||
// Starts true when the buffer is empty (new session: cursor is at the
|
||||
// beginning of an empty buffer). Starts false when the buffer is
|
||||
// nonempty (hibernation wake: the pre-hibernation cursor may have been
|
||||
// moved away from the tail, so we conservatively assume it is not).
|
||||
let lastInputWasPrintable = !ctx.commandBufferRef?.current;
|
||||
|
||||
const restoreSerialTailForEmptyInput = (data: string) => {
|
||||
// Apply the same rule to typed text, pasted text, and editable snippets.
|
||||
// Preserve uncertainty when inserting into an existing edited line.
|
||||
if (!ctx.commandBufferRef.current &&
|
||||
(isPrintableInput(data) || getSingleBracketedPasteLine(data))) {
|
||||
lastInputWasPrintable = true;
|
||||
}
|
||||
};
|
||||
|
||||
const recordSerialTextInput = (data: string) => {
|
||||
const text = data.startsWith("\x1b[200~") && data.endsWith("\x1b[201~")
|
||||
? data.slice(6, -6)
|
||||
: data;
|
||||
const lastLineBreak = Math.max(text.lastIndexOf("\r"), text.lastIndexOf("\n"));
|
||||
if (lastLineBreak >= 0) {
|
||||
ctx.commandBufferRef.current = "";
|
||||
lastInputWasPrintable = true;
|
||||
}
|
||||
const pendingText = text.slice(lastLineBreak + 1);
|
||||
restoreSerialTailForEmptyInput(pendingText);
|
||||
ctx.commandBufferRef.current += pendingText;
|
||||
return text;
|
||||
};
|
||||
|
||||
const recordSerialSnippetInput = (data: string) => {
|
||||
const text = recordSerialTextInput(data);
|
||||
if (ctx.serialLocalEcho) writeLocalTerminalData(formatSerialLocalEcho(text));
|
||||
};
|
||||
|
||||
const handleTerminalInputData = (
|
||||
data: string,
|
||||
options?: {
|
||||
@@ -1179,6 +1224,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.sudoAutofillRef?.current?.dismissOnUserContentInput(logicalData);
|
||||
}
|
||||
|
||||
if (logicalData !== null) restoreSerialTailForEmptyInput(logicalData);
|
||||
|
||||
const inputSource = options?.source ?? "terminal";
|
||||
const id = ctx.sessionRef.current;
|
||||
const dataToWrite = data;
|
||||
@@ -1263,6 +1310,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
|
||||
// Both Enter and a submitted paste start a new, empty input line.
|
||||
if (handledSubmittedInput) lastInputWasPrintable = true;
|
||||
|
||||
if (id) {
|
||||
prioritizeTerminalInput(
|
||||
term,
|
||||
@@ -1286,25 +1336,80 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ctx.terminalBackend.writeToSession(id, nextData, { sensitive });
|
||||
},
|
||||
writeToTerminal: writeLocalTerminalData,
|
||||
term,
|
||||
});
|
||||
} else {
|
||||
// Character mode (default): send immediately
|
||||
// When backspaceBehavior is configured, remap the Backspace key output
|
||||
// Character mode sends input immediately. Byte-oriented devices opt in
|
||||
// to expansion in the backend, using the session's actual wire encoder.
|
||||
const isBackspace = dataToWrite === "\x7f" || dataToWrite === "\b";
|
||||
const outData = mapTerminalBackspaceInput(dataToWrite, ctx.host.backspaceBehavior);
|
||||
let serialEraseChar: string | undefined;
|
||||
let backspaceCells = 1;
|
||||
|
||||
if (
|
||||
isBackspace &&
|
||||
ctx.host.protocol === "serial" &&
|
||||
ctx.commandBufferRef &&
|
||||
ctx.commandBufferRef.current.length > 0 &&
|
||||
lastInputWasPrintable
|
||||
) {
|
||||
const lastChar = getLastChar(ctx.commandBufferRef.current);
|
||||
// Always compute display width for local echo, independently of
|
||||
// whether wire-level byte expansion is enabled. A wide CJK or
|
||||
// emoji grapheme needs its full cell count erased even when
|
||||
// byteOrientedBackspace is disabled (character-aware endpoint).
|
||||
backspaceCells = stringCellWidth(lastChar, term);
|
||||
|
||||
if (ctx.serialByteOrientedBackspace === true) {
|
||||
serialEraseChar = lastChar;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.onOutputTriggerUserInputRef?.current?.(outData);
|
||||
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
|
||||
ctx.terminalBackend.writeToSession(id, chunk, { sensitive });
|
||||
ctx.terminalBackend.writeToSession(id, chunk, { sensitive, serialEraseChar });
|
||||
}
|
||||
|
||||
// Local echo for serial connections only when explicitly enabled
|
||||
if (inputSource !== "kitty" && ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
|
||||
const localEcho = formatSerialLocalEcho(dataToWrite);
|
||||
const localEcho = formatSerialLocalEcho(dataToWrite, isBackspace ? backspaceCells : undefined);
|
||||
if (localEcho) writeLocalTerminalData(localEcho);
|
||||
}
|
||||
if (inputSource !== "kitty" && ctx.host.protocol === "telnet" && ctx.telnetLocalEchoRef?.current) {
|
||||
const localEcho = formatTelnetLocalEcho(dataToWrite);
|
||||
if (localEcho) writeLocalTerminalData(localEcho);
|
||||
}
|
||||
|
||||
// Update printable tracking. Backspace is not printable.
|
||||
// Escape sequences (cursor movements, special keys) and control
|
||||
// chars set lastInputWasPrintable to false — the cursor position
|
||||
// is now unknown. Printable characters keep the flag as-is:
|
||||
// typing at the end advances the cursor to the end (stays true),
|
||||
// but typing after a cursor movement inserts at the cursor
|
||||
// position, not the buffer end (stays false).
|
||||
//
|
||||
// Bracketed paste (\x1b[200~...\x1b[201~) is special: xterm wraps
|
||||
// pasted text in these markers, so the data starts with ESC and
|
||||
// isPrintableInput returns false. But the pasted content is
|
||||
// printable. However, the cursor may not be at the buffer tail
|
||||
// (e.g. after a left-arrow movement), so we keep the flag as-is:
|
||||
// paste at the end keeps confidence, paste after a cursor movement
|
||||
// keeps it unknown.
|
||||
if (!isBackspace) {
|
||||
const isBracketedPaste =
|
||||
dataToWrite.startsWith("\x1b[200~") && dataToWrite.endsWith("\x1b[201~");
|
||||
if (!isBracketedPaste && !isPrintableInput(dataToWrite) && !handledSubmittedInput) {
|
||||
// Escape sequence or control char: cursor position unknown.
|
||||
// Skip when handledSubmittedInput is true: the submission handler
|
||||
// already set lastInputWasPrintable = true (buffer cleared, cursor
|
||||
// at next prompt), and the same \r/\n should not reset it.
|
||||
lastInputWasPrintable = false;
|
||||
}
|
||||
// If printable (non-backspace, non-escape, non-bracketed-paste),
|
||||
// keep flag as-is: typing at the end keeps cursor at end; typing
|
||||
// after a cursor movement keeps cursor not at end.
|
||||
// If bracketed paste, also keep flag as-is (see above).
|
||||
}
|
||||
}
|
||||
|
||||
// Use logical data for raw-string broadcast. Transport-specific wire
|
||||
@@ -1332,17 +1437,25 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
// Command recording and sudo command preparation happen before the
|
||||
// input is written so sudo can receive a one-time prompt marker.
|
||||
} else if (logicalData === "\x7f" || logicalData === "\b") {
|
||||
ctx.commandBufferRef.current = ctx.commandBufferRef.current.slice(0, -1);
|
||||
ctx.commandBufferRef.current = removeLastChar(ctx.commandBufferRef.current);
|
||||
ctx.scriptRecorderRef?.current?.recordBackspace();
|
||||
} else if (logicalData === "\x03") {
|
||||
ctx.commandBufferRef.current = "";
|
||||
ctx.scriptRecorderRef?.current?.recordClearLine();
|
||||
lastInputWasPrintable = true; // buffer cleared, cursor at start
|
||||
// Hard-abort password assist when Ctrl+C reaches the input path
|
||||
// (e.g. broadcast peers) so a later su re-arms cleanly (#2191).
|
||||
ctx.sudoAutofillRef?.current?.abort();
|
||||
} else if (logicalData === "\x15") {
|
||||
ctx.commandBufferRef.current = "";
|
||||
ctx.scriptRecorderRef?.current?.recordClearLine();
|
||||
lastInputWasPrintable = true;
|
||||
} else if (ctx.host.protocol === "serial" && (
|
||||
isPrintableInput(logicalData) || /[\r\n]/.test(logicalData) ||
|
||||
(logicalData.startsWith("\x1b[200~") && logicalData.endsWith("\x1b[201~"))
|
||||
)) {
|
||||
recordSerialTextInput(logicalData);
|
||||
ctx.scriptRecorderRef?.current?.recordInput(logicalData);
|
||||
} else if (logicalData.length === 1 && logicalData.charCodeAt(0) >= 32) {
|
||||
ctx.commandBufferRef.current += logicalData;
|
||||
ctx.scriptRecorderRef?.current?.recordInput(logicalData);
|
||||
@@ -1893,6 +2006,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
serialLineBufferRef: ctx.serialLineBufferRef,
|
||||
onAutocompleteInput: ctx.onAutocompleteInput,
|
||||
});
|
||||
lastInputWasPrintable = true;
|
||||
if (ctx.passwordPromptActiveRef) {
|
||||
ctx.passwordPromptActiveRef.current = false;
|
||||
}
|
||||
@@ -2858,6 +2972,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
),
|
||||
getKittyKeyboardProtocolEnabled: () => kittyKeyboardProtocolEnabled,
|
||||
setKittyKeyboardProtocolEnabled,
|
||||
recordSerialSnippetInput,
|
||||
dispose: () => {
|
||||
runtimeDisposed = true;
|
||||
resizeScheduler.dispose();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
import {createRequire} from 'node:module';
|
||||
const root=fileURLToPath(new URL('../../../', import.meta.url)).replace(/\/$/, '');
|
||||
const require=createRequire(root+'/package.json');
|
||||
const ts=require('typescript');
|
||||
const bridge=require(root+'/electron/bridges/terminalBridge.cjs');
|
||||
test('serial input combinations preserve the preceding device bytes after CJK deletion', async () => {
|
||||
const modules = await Promise.all([
|
||||
'domain/serialCharMetrics.ts', 'components/terminal/runtime/terminalInputSanitize.ts',
|
||||
'components/terminal/runtime/terminalBackspaceInput.ts', 'components/terminal/runtime/terminalPerCharacterInput.ts',
|
||||
'components/terminal/runtime/terminalSudoAutofill.ts', 'components/terminal/runtime/terminalCommandExecution.ts',
|
||||
'components/terminal/runtime/serialLocalEcho.ts', 'components/terminal/autocomplete/terminalStringCellWidth.ts',
|
||||
'components/terminal/runtime/shiftEnterText.ts', 'components/terminal/runtime/terminalStartupCommands.ts',
|
||||
].map(p=>import(root+'/'+p)));
|
||||
const source=fs.readFileSync(root+'/components/terminal/runtime/createXTermRuntime.ts','utf8');
|
||||
const start=source.indexOf('let lastInputWasPrintable =');
|
||||
const end=source.indexOf(' let kittyCompositionPending',start);
|
||||
const code=ts.transpileModule(source.slice(start,end)+ '\n globalThis.api = {handleTerminalInputData,recordSerialSnippetInput,getConfidence:()=>lastInputWasPrintable};',{compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText;
|
||||
for (const encoding of ['utf-8','gb18030']) {
|
||||
for (const insert of ['typed','raw','bracketed','snippet','startup','multi-snippet','multi-startup','bracketed-snippet','multi-raw','multi-bracketed']) {
|
||||
for (const prefix of ['fresh','empty-arrow','submit','paste-submit','interrupt','clear']) {
|
||||
let wire=Buffer.alloc(0);
|
||||
const session={encoding, serialPort:{write(data: string | Buffer){for(const byte of Buffer.from(data)) { if(byte===127||byte===8) wire=wire.subarray(0,Math.max(0,wire.length-1)); else wire=Buffer.concat([wire,Buffer.from([byte])]);}}}};
|
||||
bridge.init({sessions:new Map([['s',session]]),electronModule:{}});
|
||||
const ctx={host:{protocol:'serial',id:'h',label:'h'},sessionId:'s',sessionRef:{current:'s'},statusRef:{current:'connected'},commandBufferRef:{current:''},onBroadcastInputRef:{current:null},isBroadcastEnabledRef:{current:false},terminalBackend:{writeToSession(id:string,data:string,opts: {serialEraseChar?: string; sensitive?: boolean}){bridge.writeToSession({}, {sessionId:id,data,...opts})}},serialByteOrientedBackspace:true,serialLocalEcho:false};
|
||||
const env={...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
|
||||
vm.runInNewContext(code,env); const {handleTerminalInputData:input,recordSerialSnippetInput:snippet}=env.api;
|
||||
if(prefix==='empty-arrow') input('\x1b[D');
|
||||
if(prefix==='submit') {input('旧');input('\x1b[D');input('\r');}
|
||||
if(prefix==='paste-submit') {input('\x1b[D');input('旧\r');}
|
||||
if(prefix==='interrupt') {input('旧');input('\x1b[D');input('\x03');}
|
||||
if(prefix==='clear') {input('旧');input('\x1b[D');input('\x15');}
|
||||
assert.equal(ctx.commandBufferRef.current,''); wire=Buffer.alloc(0);
|
||||
if(insert==='typed') {input('a');input('b');input('你');input('好');}
|
||||
if(insert==='raw') input('ab你好');
|
||||
if(insert==='multi-raw') input('旧\nab你好');
|
||||
if(insert==='multi-bracketed') input('\x1b[200~旧\nab你好\x1b[201~');
|
||||
if(insert==='bracketed') input('\x1b[200~ab你好\x1b[201~');
|
||||
if(insert==='snippet') {ctx.terminalBackend.writeToSession('s','ab你好',{});snippet('ab你好');}
|
||||
if(insert==='multi-snippet'||insert==='bracketed-snippet') {const text=insert==='multi-snippet'?'旧\nab你好':'\x1b[200~旧\nab你好\x1b[201~';ctx.terminalBackend.writeToSession('s',text,{});snippet(text);}
|
||||
if(insert==='startup'||insert==='multi-startup') await new Promise<void>(resolve => {Object.assign({}, ...modules).scheduleStartupCommand({...ctx, noAutoRun:true, startupCommand:insert==='multi-startup'?'旧\nab你好':'ab你好', hasRunStartupCommandRef:{current:false}, terminalSettings:{startupCommandDelayMs:0}, recordSerialSnippetInput:snippet}, env.term, 's', resolve);});
|
||||
// Device paste framing is a transport protocol, remove it from our byte-deleting model.
|
||||
if(insert==='bracketed'||insert==='bracketed-snippet'||insert==='multi-bracketed') wire=wire.subarray(6,wire.length-6);
|
||||
if(insert==='multi-snippet'||insert==='multi-startup'||insert==='bracketed-snippet'||insert==='multi-raw'||insert==='multi-bracketed') wire=wire.subarray(wire.lastIndexOf(10)+1);
|
||||
input('\x7f');input('\x7f');
|
||||
assert.equal(ctx.commandBufferRef.current,'ab',`${encoding}/${insert}/${prefix} buffer`);
|
||||
assert.equal(wire.toString(),'ab',`${encoding}/${insert}/${prefix} device`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -35,3 +35,97 @@ test("serial line mode sends every completed line when pasted text ends with ent
|
||||
assert.equal(bufferRef.current, "");
|
||||
assert.deepEqual(echoes, ["show version", "\r\n", "show clock", "\r\n"]);
|
||||
});
|
||||
|
||||
test("serial line mode backspace erases one cell for ASCII characters", () => {
|
||||
const echoes: string[] = [];
|
||||
const bufferRef = { current: "abc" };
|
||||
|
||||
handleSerialLineModeInput("\x7f", {
|
||||
bufferRef,
|
||||
localEcho: true,
|
||||
writeToSession: () => {},
|
||||
writeToTerminal: (data) => echoes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(bufferRef.current, "ab");
|
||||
assert.deepEqual(echoes, ["\b \b"]); // 1 cell erased
|
||||
});
|
||||
|
||||
test("serial line mode backspace erases two cells for CJK characters", () => {
|
||||
const echoes: string[] = [];
|
||||
const bufferRef = { current: "你好" };
|
||||
|
||||
handleSerialLineModeInput("\x7f", {
|
||||
bufferRef,
|
||||
localEcho: true,
|
||||
writeToSession: () => {},
|
||||
writeToTerminal: (data) => echoes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(bufferRef.current, "你");
|
||||
assert.deepEqual(echoes, ["\b \b\b \b"]); // 2 cells erased
|
||||
});
|
||||
|
||||
test("serial line mode backspace erases two cells for fullwidth characters", () => {
|
||||
const echoes: string[] = [];
|
||||
const bufferRef = { current: "test!" };
|
||||
|
||||
handleSerialLineModeInput("\b", {
|
||||
bufferRef,
|
||||
localEcho: true,
|
||||
writeToSession: () => {},
|
||||
writeToTerminal: (data) => echoes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(bufferRef.current, "test");
|
||||
assert.deepEqual(echoes, ["\b \b\b \b"]); // 2 cells erased
|
||||
});
|
||||
|
||||
test("serial line mode backspace on empty buffer does nothing", () => {
|
||||
const echoes: string[] = [];
|
||||
const bufferRef = { current: "" };
|
||||
|
||||
handleSerialLineModeInput("\x7f", {
|
||||
bufferRef,
|
||||
localEcho: true,
|
||||
writeToSession: () => {},
|
||||
writeToTerminal: (data) => echoes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(bufferRef.current, "");
|
||||
assert.deepEqual(echoes, []);
|
||||
});
|
||||
|
||||
test("serial line mode backspace removes full surrogate pair for CJK Extension B", () => {
|
||||
const echoes: string[] = [];
|
||||
const extB = "\u{20000}"; // CJK Extension B, stored as surrogate pair
|
||||
const bufferRef = { current: "a" + extB };
|
||||
|
||||
handleSerialLineModeInput("\x7f", {
|
||||
bufferRef,
|
||||
localEcho: true,
|
||||
writeToSession: () => {},
|
||||
writeToTerminal: (data) => echoes.push(data),
|
||||
});
|
||||
|
||||
// Buffer should have "a" remaining (surrogate pair fully removed).
|
||||
assert.equal(bufferRef.current, "a");
|
||||
// Two cells erased for the wide character.
|
||||
assert.deepEqual(echoes, ["\b \b\b \b"]);
|
||||
});
|
||||
|
||||
test("serial line mode backspace removes full surrogate pair for emoji", () => {
|
||||
const echoes: string[] = [];
|
||||
const emoji = "\u{1F600}"; // Grinning face, stored as surrogate pair
|
||||
const bufferRef = { current: "hi" + emoji };
|
||||
|
||||
handleSerialLineModeInput("\x7f", {
|
||||
bufferRef,
|
||||
localEcho: true,
|
||||
writeToSession: () => {},
|
||||
writeToTerminal: (data) => echoes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(bufferRef.current, "hi");
|
||||
assert.deepEqual(echoes, ["\b \b\b \b"]);
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import { getLastChar, removeLastChar } from '../../../domain/serialCharMetrics';
|
||||
import { stringCellWidth } from '../autocomplete/terminalStringCellWidth';
|
||||
import type { Terminal as XTerm } from '@xterm/xterm';
|
||||
|
||||
type StringRef = {
|
||||
current: string;
|
||||
};
|
||||
@@ -7,6 +11,8 @@ type SerialLineModeInputOptions = {
|
||||
localEcho?: boolean;
|
||||
writeToSession: (data: string) => void;
|
||||
writeToTerminal: (data: string) => void;
|
||||
/** xterm instance for grapheme-accurate width via UnicodeService. */
|
||||
term?: XTerm | null;
|
||||
};
|
||||
|
||||
const submitLine = ({
|
||||
@@ -52,8 +58,10 @@ export function handleSerialLineModeInput(
|
||||
|
||||
if (data === "\x7f" || data === "\b") {
|
||||
if (options.bufferRef.current.length > 0) {
|
||||
options.bufferRef.current = options.bufferRef.current.slice(0, -1);
|
||||
if (options.localEcho) options.writeToTerminal("\b \b");
|
||||
const lastChar = getLastChar(options.bufferRef.current);
|
||||
const cells = stringCellWidth(lastChar, options.term);
|
||||
options.bufferRef.current = removeLastChar(options.bufferRef.current);
|
||||
if (options.localEcho) options.writeToTerminal("\b \b".repeat(cells));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isPrintableInput } from "../../../domain/serialCharMetrics";
|
||||
import { getSingleBracketedPasteLine } from "./terminalSudoAutofill";
|
||||
import { formatSerialLocalEcho } from "./serialLocalEcho";
|
||||
|
||||
test("formatSerialLocalEcho echoes printable input and normalizes newlines", () => {
|
||||
@@ -20,3 +22,109 @@ test("formatSerialLocalEcho renders local editing control keys", () => {
|
||||
test("formatSerialLocalEcho ignores single non-display control input", () => {
|
||||
assert.equal(formatSerialLocalEcho("\x15"), "");
|
||||
});
|
||||
|
||||
test("formatSerialLocalEcho returns empty for empty input", () => {
|
||||
assert.equal(formatSerialLocalEcho(""), "");
|
||||
});
|
||||
|
||||
test("formatSerialLocalEcho erases 1 cell by default for backspace", () => {
|
||||
assert.equal(formatSerialLocalEcho("\x7f"), "\b \b");
|
||||
assert.equal(formatSerialLocalEcho("\x7f", 1), "\b \b");
|
||||
});
|
||||
|
||||
test("formatSerialLocalEcho erases 2 cells for wide-character backspace", () => {
|
||||
// CJK ideograph = 2 cells; backspace must erase both.
|
||||
assert.equal(formatSerialLocalEcho("\x7f", 2), "\b \b\b \b");
|
||||
assert.equal(formatSerialLocalEcho("\b", 2), "\b \b\b \b");
|
||||
});
|
||||
|
||||
test("serial byte deletion metadata is forwarded through the runtime input loop", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const source = await readFile(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /writeToSession\(id, chunk, \{ sensitive, serialEraseChar \}\)/);
|
||||
});
|
||||
|
||||
test("both Enter and submitted paste restore serial tail confidence", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const source = await readFile(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
const pasteBranch = source.indexOf("const pastedCommand = logicalData");
|
||||
const restoreTail = source.indexOf("if (handledSubmittedInput) lastInputWasPrintable = true;");
|
||||
const writeInput = source.indexOf("prioritizeTerminalInput(", pasteBranch);
|
||||
assert.ok(pasteBranch > 0 && restoreTail > pasteBranch && restoreTail < writeInput);
|
||||
});
|
||||
|
||||
test("byte-oriented serial input prevents encoding changes while wire bytes are pending", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { runInNewContext } = await import("node:vm");
|
||||
const source = await readFile(new URL("../../Terminal.tsx", import.meta.url), "utf8");
|
||||
const handlerStart = source.indexOf("const handleSetTerminalEncoding = useCallback");
|
||||
const start = source.indexOf("if (host.protocol === 'serial'", handlerStart);
|
||||
const end = source.indexOf("setTerminalEncoding(encoding);", start);
|
||||
assert.ok(handlerStart > 0 && start > handlerStart && end > start);
|
||||
const guard = source.slice(start, end);
|
||||
for (const [protocol, byteMode, lineMode, pending, blocked] of [
|
||||
["serial", true, false, "abc你", true],
|
||||
["serial", true, false, "", false],
|
||||
["serial", false, false, "abc你", false],
|
||||
["serial", true, true, "abc你", false],
|
||||
["ssh", true, false, "abc你", false],
|
||||
] as const) {
|
||||
const notices: string[] = [];
|
||||
const state = {host: {protocol}, serialConfig: {byteOrientedBackspace: byteMode, lineMode}, commandBufferRef: {current: pending}, toast: {info: (text: string) => notices.push(text)}, t: (key: string) => key};
|
||||
const changed = runInNewContext(`(() => { ${guard} return true; })()`, state);
|
||||
assert.equal(changed, blocked ? undefined : true);
|
||||
assert.equal(notices.length, blocked ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("urgent Ctrl+C restores serial tail confidence when it clears pending input", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const source = await readFile(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /clearTerminalInputStateForInterrupt\(\{[^]*?\}\);\s*lastInputWasPrintable = true;/);
|
||||
});
|
||||
|
||||
test("pending serial snippets share runtime editing bookkeeping", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { runInNewContext } = await import("node:vm");
|
||||
const source = await readFile(new URL("../../Terminal.tsx", import.meta.url), "utf8");
|
||||
const start = source.indexOf("if (host.protocol === 'serial' && noAutoRun");
|
||||
const end = source.indexOf("scrollToBottomAfterProgrammaticInput(data);", start);
|
||||
assert.ok(start > 0 && end > start);
|
||||
for (const [protocol, noAutoRun, isMultiLine, lineMode, tracked] of [
|
||||
["serial", true, false, false, true],
|
||||
["serial", false, false, false, false],
|
||||
["serial", true, true, false, true],
|
||||
["serial", true, false, true, false],
|
||||
["ssh", true, false, false, false],
|
||||
] as const) {
|
||||
const recorded: string[] = [];
|
||||
const state = {host: {protocol}, noAutoRun, isMultiLine, serialConfig: {lineMode}, data: "你", xtermRuntimeRef: {current: {recordSerialSnippetInput: (text: string) => recorded.push(text)}}};
|
||||
runInNewContext(source.slice(start, end), state);
|
||||
assert.deepEqual(recorded, tracked ? ["你"] : []);
|
||||
}
|
||||
});
|
||||
|
||||
test("serial snippets restore an empty tail but preserve uncertainty on an edited line", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { runInNewContext } = await import("node:vm");
|
||||
const source = await readFile(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
const start = source.indexOf("const recordSerialSnippetInput = (data: string) => {");
|
||||
const end = source.indexOf("\n };", start);
|
||||
assert.ok(start > 0 && end > start);
|
||||
const body = source.slice(source.indexOf("{", start) + 1, end);
|
||||
const helperStart = source.indexOf("const restoreSerialTailForEmptyInput = (data: string) => {");
|
||||
const helper = source.slice(helperStart, start).replaceAll("data: string", "data");
|
||||
for (const [pending, confident, localEcho, expected] of [
|
||||
["", false, true, true],
|
||||
["", false, false, true],
|
||||
["abc", false, true, false],
|
||||
["abc", true, true, true],
|
||||
] as const) {
|
||||
const echoed: string[] = [];
|
||||
const state = {ctx: {commandBufferRef: {current: pending}, serialLocalEcho: localEcho}, lastInputWasPrintable: confident, data: "你", writeLocalTerminalData: (text: string) => echoed.push(text)};
|
||||
runInNewContext(`(() => { ${helper} ${body} })()`, {...state, isPrintableInput, getSingleBracketedPasteLine, formatSerialLocalEcho, ctx: state.ctx, get lastInputWasPrintable() {return state.lastInputWasPrintable;}, set lastInputWasPrintable(value) {state.lastInputWasPrintable = value;}});
|
||||
assert.equal(state.lastInputWasPrintable, expected);
|
||||
assert.equal(state.ctx.commandBufferRef.current, pending + "你");
|
||||
assert.deepEqual(echoed, localEcho ? ["你"] : []);
|
||||
}
|
||||
});
|
||||
@@ -14,9 +14,17 @@ function normalizeSerialLocalEchoLineEndings(data: string): string {
|
||||
return output;
|
||||
}
|
||||
|
||||
export function formatSerialLocalEcho(data: string): string {
|
||||
/**
|
||||
* Format a terminal input string as local echo for serial connections.
|
||||
*
|
||||
* When `backspaceCells` is provided it overrides the default 1-cell erase
|
||||
* for backspace. CJK ideographs occupy 2 terminal cells, so the caller
|
||||
* should pass 2 when the character being deleted is wide — otherwise the
|
||||
* local echo only erases half the glyph and leaves a hidden remnant.
|
||||
*/
|
||||
export function formatSerialLocalEcho(data: string, backspaceCells?: number): string {
|
||||
if (!data) return "";
|
||||
if (data === "\x7f" || data === "\b") return "\b \b";
|
||||
if (data === "\x7f" || data === "\b") return "\b \b".repeat(backspaceCells ?? 1);
|
||||
if (data === "\x03") return "^C";
|
||||
if (data === "\r" || data === "\n" || data.charCodeAt(0) >= 32 || data.length > 1) {
|
||||
return normalizeSerialLocalEchoLineEndings(data);
|
||||
|
||||
@@ -38,7 +38,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{ sensitive \}\);\s*\}/u,
|
||||
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{ sensitive, serialEraseChar \}\);\s*\}/u,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
|
||||
@@ -74,6 +74,9 @@ export const scheduleStartupCommand = (
|
||||
return;
|
||||
}
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, commandToRun, { automated: true });
|
||||
if (ctx.host.protocol === "serial" && !ctx.serialConfig?.lineMode) {
|
||||
ctx.recordSerialSnippetInput?.(commandToRun);
|
||||
}
|
||||
onSettled?.();
|
||||
}, delayMs);
|
||||
return () => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -56,3 +56,17 @@ test("terminal memo refreshes when terminal context reader callback changes", ()
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('terminal memo refreshes for intense foreground edits and toggles', async () => {
|
||||
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes');
|
||||
const theme = TERMINAL_THEMES[0];
|
||||
for (const field of ['terminalTheme', 'appearanceTheme'] as const) {
|
||||
const props = { ...baseProps, [field]: theme };
|
||||
const enabled = { ...props, [field]: { ...theme, colors: { ...theme.colors, foregroundIntense: '#ff8800' } } };
|
||||
const recolored = { ...props, [field]: { ...theme, colors: { ...theme.colors, foregroundIntense: '#00ff00' } } };
|
||||
assert.equal(terminalPropsAreEqual(props, enabled), false);
|
||||
assert.equal(terminalPropsAreEqual(enabled, recolored), false);
|
||||
assert.equal(terminalPropsAreEqual(enabled, props), false);
|
||||
assert.equal(terminalPropsAreEqual(enabled, { ...enabled }), true);
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TerminalProps } from './terminalHelpers';
|
||||
|
||||
const themeFingerprint = (theme: TerminalProps['terminalTheme'] | undefined): string => (
|
||||
theme?.colors ? `${theme.id}:${theme.colors.background}:${theme.colors.foreground}:${theme.colors.cursor}` : ''
|
||||
theme?.colors ? `${theme.id}:${theme.colors.background}:${theme.colors.foreground}:${theme.colors.foregroundIntense ?? ''}:${theme.colors.cursor}` : ''
|
||||
);
|
||||
|
||||
export const terminalPropsAreEqual = (
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { createTerminalReflowReadingPosition } from './terminalReflowReadingPosition';
|
||||
import { resolveTerminalReflowScrollAnchor } from './terminalHelpers';
|
||||
|
||||
function fixture() {
|
||||
let cols = 80;
|
||||
let text = Array.from({ length: 1000 }, (_, i) => String(i).padStart(4, '0') + ' ').join('');
|
||||
const buffer = {
|
||||
viewportY: 30,
|
||||
get length() { return Math.ceil(text.length / cols) + 30; },
|
||||
get baseY() { return this.length - 25; },
|
||||
getLine(row: number) {
|
||||
const count = Math.ceil(text.length / cols);
|
||||
if (row >= this.length) return undefined;
|
||||
return {
|
||||
isWrapped: row > 0 && row < count,
|
||||
translateToString: () => row < count ? text.slice(row * cols, (row + 1) * cols) : `follower ${row - count}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
const tracker = createTerminalReflowReadingPosition();
|
||||
return {
|
||||
buffer,
|
||||
changeText() { text = text.replace('0480', 'EDIT'); },
|
||||
fit(nextCols: number) {
|
||||
const anchor = tracker.capture(buffer, { oldCols: cols, newCols: nextCols, maxRows: 1030 });
|
||||
cols = nextCols;
|
||||
const row = anchor ? resolveTerminalReflowScrollAnchor(buffer, anchor, 0) : null;
|
||||
if (row !== null) buffer.viewportY = row;
|
||||
tracker.remember(buffer, row === null ? null : anchor);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('continuous shrink and grow retain the original character within the top row', () => {
|
||||
const f = fixture();
|
||||
for (const cols of [...Array.from({ length: 40 }, (_, i) => 79 - i), ...Array.from({ length: 40 }, (_, i) => 41 + i)]) {
|
||||
f.fit(cols);
|
||||
assert.equal(f.buffer.viewportY, Math.floor(2400 / cols));
|
||||
}
|
||||
});
|
||||
|
||||
test('user scroll and changed visible output establish a new reading position', () => {
|
||||
const f = fixture();
|
||||
f.fit(79);
|
||||
f.buffer.viewportY = 20;
|
||||
f.fit(78);
|
||||
assert.equal(f.buffer.viewportY, Math.floor(20 * 79 / 78));
|
||||
const g = fixture();
|
||||
g.fit(79);
|
||||
g.changeText();
|
||||
g.fit(78);
|
||||
assert.equal(g.buffer.viewportY, Math.floor(30 * 79 / 78));
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { captureTerminalReflowScrollAnchor } from './terminalHelpers';
|
||||
import type { TerminalReflowScrollAnchor } from './terminalHelpers';
|
||||
|
||||
type Buffer = Parameters<typeof captureTerminalReflowScrollAnchor>[0];
|
||||
|
||||
/** Keep the character within the top row across consecutive resize frames. */
|
||||
export function createTerminalReflowReadingPosition() {
|
||||
let previous: {
|
||||
buffer: Buffer;
|
||||
viewportY: number;
|
||||
anchor: TerminalReflowScrollAnchor;
|
||||
remainder: number;
|
||||
} | null = null;
|
||||
|
||||
return {
|
||||
capture(buffer: Buffer, limits: Parameters<typeof captureTerminalReflowScrollAnchor>[1]) {
|
||||
const anchor = captureTerminalReflowScrollAnchor(buffer, limits);
|
||||
const saved = previous;
|
||||
previous = null;
|
||||
if (!anchor || anchor.containsCursor) return anchor;
|
||||
// A user scroll or changed output establishes a new reading position.
|
||||
if (saved?.buffer === buffer && saved.viewportY === buffer.viewportY
|
||||
&& saved.anchor.startRow === anchor.startRow
|
||||
&& saved.anchor.charOffset === anchor.charOffset
|
||||
&& saved.anchor.textPrefix === anchor.textPrefix
|
||||
&& saved.anchor.viewedText === anchor.viewedText
|
||||
&& saved.anchor.contextSuffix === anchor.contextSuffix) {
|
||||
anchor.charOffset += saved.remainder;
|
||||
anchor.viewedText = anchor.viewedText?.slice(saved.remainder);
|
||||
}
|
||||
return anchor;
|
||||
},
|
||||
// The caller passes null unless content resolution succeeded. A cursor
|
||||
// follower may change during resize; the resolver already validates that
|
||||
// tolerance. Save its new identity for the next pre-fit comparison.
|
||||
remember(buffer: Buffer, anchor: TerminalReflowScrollAnchor | null) {
|
||||
previous = null;
|
||||
if (!anchor || anchor.containsCursor) return;
|
||||
const current = captureTerminalReflowScrollAnchor(buffer, {
|
||||
maxRows: Number.MAX_SAFE_INTEGER, oldCols: 1, newCols: 1,
|
||||
});
|
||||
// Trim, clamping and failed content resolution must not carry a stale
|
||||
// offset into the next frame. Only retain the sub-row rounding loss.
|
||||
if (!current || current.containsCursor || current.textPrefix !== anchor.textPrefix) return;
|
||||
const remainder = anchor.charOffset - current.charOffset;
|
||||
const rowLength = buffer.getLine(buffer.viewportY)?.translateToString(false).length ?? 0;
|
||||
if (remainder < 0 || remainder >= rowLength) return;
|
||||
previous = { buffer, viewportY: buffer.viewportY, anchor: current, remainder };
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -37,6 +37,25 @@ test("stringCellWidth ignores combining marks inside a grapheme", () => {
|
||||
assert.equal(stringCellWidth("e\u0301"), 1);
|
||||
});
|
||||
|
||||
test("stringCellWidth fallback returns 2 for VS16 emoji-presentation grapheme", () => {
|
||||
// Without xterm, the fallback must detect VS16 (U+FE0F) and return 2
|
||||
// even when the base character is outside the standard emoji range.
|
||||
// U+2764 (text heart, not in 0x1F300-0x1FAFF) + U+FE0F → emoji (2 cells)
|
||||
assert.equal(stringCellWidth("\u2764\uFE0F"), 2);
|
||||
// U+00A9 (copyright) + U+FE0F → emoji (2 cells)
|
||||
assert.equal(stringCellWidth("\u00A9\uFE0F"), 2);
|
||||
});
|
||||
|
||||
test("stringCellWidth fallback returns 2 for VS16 keycap grapheme", () => {
|
||||
// digit + VS16 + combining keycap → emoji-presentation keycap (2 cells)
|
||||
assert.equal(stringCellWidth("1\uFE0F\u20E3"), 2);
|
||||
});
|
||||
|
||||
test("stringCellWidth fallback returns 1 for text heart without VS16", () => {
|
||||
// U+2764 alone is text presentation, not emoji
|
||||
assert.equal(stringCellWidth("\u2764"), 1);
|
||||
});
|
||||
|
||||
test("stringCellWidth matches xterm 15-graphemes for common emoji clusters", () => {
|
||||
class El {
|
||||
tagName: string;
|
||||
|
||||
@@ -519,6 +519,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
// Serial-specific options
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialByteOrientedBackspace: serialConfig?.byteOrientedBackspace ?? false,
|
||||
serialLineBufferRef,
|
||||
telnetLocalEchoRef,
|
||||
onTerminalLogData: captureTerminalLogData,
|
||||
@@ -1047,7 +1048,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
}, [status]);
|
||||
|
||||
|
||||
const effectiveThemeKey = `${effectiveTheme.id}:${effectiveTheme.colors.background}:${effectiveTheme.colors.foreground}:${effectiveTheme.colors.cursor}`;
|
||||
const effectiveThemeKey = `${effectiveTheme.id}:${effectiveTheme.colors.background}:${effectiveTheme.colors.foreground}:${effectiveTheme.colors.foregroundIntense ?? ""}:${effectiveTheme.colors.cursor}`;
|
||||
|
||||
// Sync xterm theme before browser paint; apply synchronously on visible panes.
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -196,6 +196,7 @@ function SidePanelSftpSlotInner({
|
||||
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
|
||||
<SftpSidePanel
|
||||
transferOwnerId={`terminal:${tabId}`}
|
||||
ownerTabId={tabId}
|
||||
hosts={effectiveHosts}
|
||||
writableHosts={hosts}
|
||||
sessions={sessions}
|
||||
|
||||
@@ -187,7 +187,7 @@ test("import format step keeps destination settings off the main card grid", asy
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /data-import-destination-settings="true"/);
|
||||
assert.match(source, /data-import-securecrt-prompt="true"/);
|
||||
assert.match(source, /data-import-source-prompt=\{step === "securecrt-source" \? "securecrt" : "finalshell"\}/);
|
||||
assert.match(source, /step === "destination"/);
|
||||
assert.match(source, /FolderTree/);
|
||||
assert.doesNotMatch(
|
||||
|
||||
@@ -70,6 +70,12 @@ const OPTIONS: ImportOption[] = [
|
||||
iconSrc: "/import/securecrt.png",
|
||||
accept: ".ini,.txt",
|
||||
},
|
||||
{
|
||||
format: "finalshell",
|
||||
label: "FinalShell",
|
||||
iconSrc: "/import/file.png",
|
||||
accept: ".json",
|
||||
},
|
||||
{
|
||||
format: "ssh_config",
|
||||
label: "ssh_config",
|
||||
@@ -106,7 +112,8 @@ type ImportDialogStep =
|
||||
| "destination"
|
||||
| "ssh-mode"
|
||||
| "moba-encoding"
|
||||
| "securecrt-source";
|
||||
| "securecrt-source"
|
||||
| "finalshell-source";
|
||||
|
||||
export function VaultImportDestinationControls({
|
||||
mode,
|
||||
@@ -466,11 +473,11 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
format: VaultImportFormat,
|
||||
accept: string,
|
||||
options?: ImportOptions,
|
||||
secureCrtSource: "folder" | "file" = "folder",
|
||||
source: "folder" | "file" = "folder",
|
||||
) => {
|
||||
const input = fileInputRef.current;
|
||||
if (!input || !destination) return;
|
||||
const pickerMode = getVaultImportPickerMode(format, secureCrtSource);
|
||||
const pickerMode = getVaultImportPickerMode(format, source);
|
||||
pendingFormatRef.current = format;
|
||||
pendingOptionsRef.current = { ...options, destination };
|
||||
input.accept = accept;
|
||||
@@ -496,6 +503,8 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
setStep("moba-encoding");
|
||||
} else if (opt.format === "securecrt") {
|
||||
setStep("securecrt-source");
|
||||
} else if (opt.format === "finalshell") {
|
||||
setStep("finalshell-source");
|
||||
} else {
|
||||
pickFile(opt.format, opt.accept);
|
||||
}
|
||||
@@ -522,10 +531,10 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
[mobaMasterPassword, pickFile],
|
||||
);
|
||||
|
||||
const handleSecureCrtChoice = useCallback(
|
||||
(source: "folder" | "file") => {
|
||||
const handleBatchSourceChoice = useCallback(
|
||||
(format: "securecrt" | "finalshell", source: "folder" | "file") => {
|
||||
setStep("format");
|
||||
pickFile("securecrt", ".ini", undefined, source);
|
||||
pickFile(format, format === "securecrt" ? ".ini" : ".json", undefined, source);
|
||||
},
|
||||
[pickFile],
|
||||
);
|
||||
@@ -582,10 +591,10 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-border/60 bg-muted/60 text-muted-foreground">
|
||||
<FolderTree className="h-6 w-6" />
|
||||
</div>
|
||||
) : step === "securecrt-source" ? (
|
||||
) : step === "securecrt-source" || step === "finalshell-source" ? (
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center">
|
||||
<img
|
||||
src="/import/securecrt.png"
|
||||
src={step === "securecrt-source" ? "/import/securecrt.png" : "/import/file.png"}
|
||||
alt=""
|
||||
className="h-10 w-10 object-contain"
|
||||
/>
|
||||
@@ -599,14 +608,16 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DialogTitle className={step === "destination" || step === "securecrt-source" ? "text-lg" : "text-xl"}>
|
||||
<DialogTitle className={step === "destination" || step.endsWith("-source") ? "text-lg" : "text-xl"}>
|
||||
{step === "securecrt-source"
|
||||
? t("vault.import.securecrt.promptTitle")
|
||||
: step === "destination"
|
||||
? t("vault.import.destination.settings")
|
||||
: t("vault.import.title")}
|
||||
: step === "finalshell-source"
|
||||
? t("vault.import.finalshell.promptTitle")
|
||||
: step === "destination"
|
||||
? t("vault.import.destination.settings")
|
||||
: t("vault.import.title")}
|
||||
</DialogTitle>
|
||||
{step !== "destination" && step !== "securecrt-source" && (
|
||||
{step !== "destination" && !step.endsWith("-source") && (
|
||||
<DialogDescription className="mx-auto max-w-xl">
|
||||
{step === "ssh-mode"
|
||||
? t("vault.import.sshConfig.chooseMode")
|
||||
@@ -879,11 +890,11 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
{t("common.back")}
|
||||
</button>
|
||||
</>
|
||||
) : step === "securecrt-source" ? (
|
||||
) : step === "securecrt-source" || step === "finalshell-source" ? (
|
||||
<>
|
||||
<div
|
||||
className="grid grid-cols-2 gap-3"
|
||||
data-import-securecrt-prompt="true"
|
||||
data-import-source-prompt={step === "securecrt-source" ? "securecrt" : "finalshell"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -892,16 +903,19 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
"px-3 py-5 hover:bg-primary/10 hover:border-primary transition-colors",
|
||||
"flex flex-col items-center gap-2.5",
|
||||
)}
|
||||
onClick={() => handleSecureCrtChoice("folder")}
|
||||
onClick={() => handleBatchSourceChoice(
|
||||
step === "securecrt-source" ? "securecrt" : "finalshell",
|
||||
"folder",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-primary/10">
|
||||
<FolderOpen className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("vault.import.securecrt.folder")}
|
||||
{t(`vault.import.${step === "securecrt-source" ? "securecrt" : "finalshell"}.folder`)}
|
||||
</div>
|
||||
<div className="text-xs leading-4 text-muted-foreground text-center">
|
||||
{t("vault.import.securecrt.folderDesc")}
|
||||
{t(`vault.import.${step === "securecrt-source" ? "securecrt" : "finalshell"}.folderDesc`)}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -911,16 +925,19 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
"px-3 py-5 hover:bg-muted/30 hover:border-border transition-colors",
|
||||
"flex flex-col items-center gap-2.5",
|
||||
)}
|
||||
onClick={() => handleSecureCrtChoice("file")}
|
||||
onClick={() => handleBatchSourceChoice(
|
||||
step === "securecrt-source" ? "securecrt" : "finalshell",
|
||||
"file",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-muted/60">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("vault.import.securecrt.file")}
|
||||
{t(`vault.import.${step === "securecrt-source" ? "securecrt" : "finalshell"}.file`)}
|
||||
</div>
|
||||
<div className="text-xs leading-4 text-muted-foreground text-center">
|
||||
{t("vault.import.securecrt.fileDesc")}
|
||||
{t(`vault.import.${step === "securecrt-source" ? "securecrt" : "finalshell"}.fileDesc`)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -985,7 +1002,7 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
{t("vault.import.chooseFormat")}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.format}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import { Check, Search, Terminal } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { matchesWorkspaceHostPickerQuery } from '../../lib/searchMatcher';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -112,14 +113,9 @@ export const AddToWorkspaceDialog: React.FC<AddToWorkspaceDialogProps> = ({
|
||||
}, [query]);
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
const term = query.trim().toLowerCase();
|
||||
const term = query.trim();
|
||||
if (!term) return selectableHosts;
|
||||
return selectableHosts.filter((h) =>
|
||||
(h.label?.toLowerCase().includes(term))
|
||||
|| (h.hostname?.toLowerCase().includes(term))
|
||||
|| (h.username?.toLowerCase().includes(term))
|
||||
|| (h.group?.toLowerCase().includes(term)),
|
||||
);
|
||||
return selectableHosts.filter((host) => matchesWorkspaceHostPickerQuery(term, host));
|
||||
}, [selectableHosts, query]);
|
||||
|
||||
const { items, visualRows, itemIndexToVisualIndex } = useMemo(() => {
|
||||
@@ -305,7 +301,7 @@ export const AddToWorkspaceDialog: React.FC<AddToWorkspaceDialogProps> = ({
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search hosts or local shells..."
|
||||
placeholder="Search hosts, tags, or local shells..."
|
||||
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"
|
||||
/>
|
||||
{workspaceTitle && (
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
const JAVA_RANDOM_MULTIPLIER = 0x5deece66dn;
|
||||
const JAVA_RANDOM_ADDEND = 0xbn;
|
||||
const JAVA_RANDOM_MASK = (1n << 48n) - 1n;
|
||||
|
||||
class JavaRandom {
|
||||
private seed: bigint;
|
||||
|
||||
constructor(seed: bigint) {
|
||||
this.seed = (BigInt.asIntN(64, seed) ^ JAVA_RANDOM_MULTIPLIER) & JAVA_RANDOM_MASK;
|
||||
}
|
||||
|
||||
private next(bits: number): number {
|
||||
this.seed = (this.seed * JAVA_RANDOM_MULTIPLIER + JAVA_RANDOM_ADDEND) & JAVA_RANDOM_MASK;
|
||||
return Number(this.seed >> BigInt(48 - bits));
|
||||
}
|
||||
|
||||
nextInt(bound: number): number {
|
||||
if ((bound & -bound) === bound) {
|
||||
return Math.floor((bound * this.next(31)) / 0x80000000);
|
||||
}
|
||||
let bits: number;
|
||||
let value: number;
|
||||
do {
|
||||
bits = this.next(31);
|
||||
value = bits % bound;
|
||||
} while (((bits - value + (bound - 1)) | 0) < 0);
|
||||
return value;
|
||||
}
|
||||
|
||||
nextLong(): bigint {
|
||||
const high = BigInt.asIntN(32, BigInt(this.next(32)));
|
||||
const low = BigInt.asIntN(32, BigInt(this.next(32)));
|
||||
return BigInt.asIntN(64, (high << 32n) + low);
|
||||
}
|
||||
}
|
||||
|
||||
const IP = [58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8, 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3, 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7];
|
||||
const FP = [40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29, 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27, 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25];
|
||||
const E = [32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16, 17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25, 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1];
|
||||
const P = [16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10, 2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25];
|
||||
const PC1 = [57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36, 63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4];
|
||||
const PC2 = [14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, 23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2, 41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48, 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32];
|
||||
const SHIFTS = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
|
||||
const SBOXES = [
|
||||
[14,4,13,1,2,15,11,8,3,10,6,12,5,9,0,7,0,15,7,4,14,2,13,1,10,6,12,11,9,5,3,8,4,1,14,8,13,6,2,11,15,12,9,7,3,10,5,0,15,12,8,2,4,9,1,7,5,11,3,14,10,0,6,13],
|
||||
[15,1,8,14,6,11,3,4,9,7,2,13,12,0,5,10,3,13,4,7,15,2,8,14,12,0,1,10,6,9,11,5,0,14,7,11,10,4,13,1,5,8,12,6,9,3,2,15,13,8,10,1,3,15,4,2,11,6,7,12,0,5,14,9],
|
||||
[10,0,9,14,6,3,15,5,1,13,12,7,11,4,2,8,13,7,0,9,3,4,6,10,2,8,5,14,12,11,15,1,13,6,4,9,8,15,3,0,11,1,2,12,5,10,14,7,1,10,13,0,6,9,8,7,4,15,14,3,11,5,2,12],
|
||||
[7,13,14,3,0,6,9,10,1,2,8,5,11,12,4,15,13,8,11,5,6,15,0,3,4,7,2,12,1,10,14,9,10,6,9,0,12,11,7,13,15,1,3,14,5,2,8,4,3,15,0,6,10,1,13,8,9,4,5,11,12,7,2,14],
|
||||
[2,12,4,1,7,10,11,6,8,5,3,15,13,0,14,9,14,11,2,12,4,7,13,1,5,0,15,10,3,9,8,6,4,2,1,11,10,13,7,8,15,9,12,5,6,3,0,14,11,8,12,7,1,14,2,13,6,15,0,9,10,4,5,3],
|
||||
[12,1,10,15,9,2,6,8,0,13,3,4,14,7,5,11,10,15,4,2,7,12,9,5,6,1,13,14,0,11,3,8,9,14,15,5,2,8,12,3,7,0,4,10,1,13,11,6,4,3,2,12,9,5,15,10,11,14,1,7,6,0,8,13],
|
||||
[4,11,2,14,15,0,8,13,3,12,9,7,5,10,6,1,13,0,11,7,4,9,1,10,14,3,5,12,2,15,8,6,1,4,11,13,12,3,7,14,10,15,6,8,0,5,9,2,6,11,13,8,1,4,10,7,9,5,0,15,14,2,3,12],
|
||||
[13,2,8,4,6,15,11,1,10,9,3,14,5,0,12,7,1,15,13,8,10,3,7,4,12,5,6,11,0,14,9,2,7,11,4,1,9,12,14,2,0,6,10,13,15,3,5,8,2,1,14,7,4,10,8,13,15,12,9,0,3,5,6,11],
|
||||
];
|
||||
|
||||
const permute = (value: bigint, inputBits: number, table: number[]): bigint => {
|
||||
let result = 0n;
|
||||
for (const position of table) {
|
||||
result = (result << 1n) | ((value >> BigInt(inputBits - position)) & 1n);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const bytesToBigInt = (bytes: Uint8Array): bigint => {
|
||||
let value = 0n;
|
||||
for (const byte of bytes) value = (value << 8n) | BigInt(byte);
|
||||
return value;
|
||||
};
|
||||
|
||||
const bigIntToBytes = (value: bigint, length: number): Uint8Array => {
|
||||
const result = new Uint8Array(length);
|
||||
for (let index = length - 1; index >= 0; index--) {
|
||||
result[index] = Number(value & 0xffn);
|
||||
value >>= 8n;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const createSubkeys = (key: Uint8Array): bigint[] => {
|
||||
const selected = permute(bytesToBigInt(key), 64, PC1);
|
||||
let left = Number((selected >> 28n) & 0xfffffffn);
|
||||
let right = Number(selected & 0xfffffffn);
|
||||
return SHIFTS.map((shift) => {
|
||||
left = ((left << shift) | (left >>> (28 - shift))) & 0xfffffff;
|
||||
right = ((right << shift) | (right >>> (28 - shift))) & 0xfffffff;
|
||||
return permute((BigInt(left) << 28n) | BigInt(right), 56, PC2);
|
||||
});
|
||||
};
|
||||
|
||||
const feistel = (right: number, subkey: bigint): number => {
|
||||
const expanded = permute(BigInt(right >>> 0), 32, E) ^ subkey;
|
||||
let output = 0;
|
||||
for (let box = 0; box < 8; box++) {
|
||||
const sixBits = Number((expanded >> BigInt(42 - box * 6)) & 0x3fn);
|
||||
const row = ((sixBits & 0x20) >> 4) | (sixBits & 1);
|
||||
const column = (sixBits >> 1) & 0xf;
|
||||
output = (output << 4) | SBOXES[box][row * 16 + column];
|
||||
}
|
||||
return Number(permute(BigInt(output >>> 0), 32, P));
|
||||
};
|
||||
|
||||
const decryptDesBlock = (block: Uint8Array, subkeys: bigint[]): Uint8Array => {
|
||||
const initial = permute(bytesToBigInt(block), 64, IP);
|
||||
let left = Number((initial >> 32n) & 0xffffffffn);
|
||||
let right = Number(initial & 0xffffffffn);
|
||||
for (let round = 15; round >= 0; round--) {
|
||||
const next = (left ^ feistel(right, subkeys[round])) >>> 0;
|
||||
left = right;
|
||||
right = next;
|
||||
}
|
||||
return bigIntToBytes(permute((BigInt(right) << 32n) | BigInt(left), 64, FP), 8);
|
||||
};
|
||||
|
||||
const add32 = (...values: number[]): number => values.reduce((sum, value) => (sum + value) >>> 0, 0);
|
||||
const rotateLeft = (value: number, shift: number): number => ((value << shift) | (value >>> (32 - shift))) >>> 0;
|
||||
|
||||
const md5 = (input: Uint8Array): Uint8Array => {
|
||||
const bitLength = BigInt(input.length) * 8n;
|
||||
const paddedLength = Math.ceil((input.length + 9) / 64) * 64;
|
||||
const padded = new Uint8Array(paddedLength);
|
||||
padded.set(input);
|
||||
padded[input.length] = 0x80;
|
||||
for (let index = 0; index < 8; index++) padded[paddedLength - 8 + index] = Number((bitLength >> BigInt(index * 8)) & 0xffn);
|
||||
|
||||
let a0 = 0x67452301;
|
||||
let b0 = 0xefcdab89;
|
||||
let c0 = 0x98badcfe;
|
||||
let d0 = 0x10325476;
|
||||
const shifts = [7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21];
|
||||
const constants = Array.from({ length: 64 }, (_, index) => Math.floor(Math.abs(Math.sin(index + 1)) * 0x100000000) >>> 0);
|
||||
|
||||
for (let offset = 0; offset < padded.length; offset += 64) {
|
||||
const words = new Uint32Array(16);
|
||||
for (let index = 0; index < 16; index++) {
|
||||
const start = offset + index * 4;
|
||||
words[index] = padded[start] | (padded[start + 1] << 8) | (padded[start + 2] << 16) | (padded[start + 3] << 24);
|
||||
}
|
||||
let a = a0;
|
||||
let b = b0;
|
||||
let c = c0;
|
||||
let d = d0;
|
||||
for (let index = 0; index < 64; index++) {
|
||||
let f: number;
|
||||
let wordIndex: number;
|
||||
if (index < 16) {
|
||||
f = (b & c) | (~b & d);
|
||||
wordIndex = index;
|
||||
} else if (index < 32) {
|
||||
f = (d & b) | (~d & c);
|
||||
wordIndex = (5 * index + 1) % 16;
|
||||
} else if (index < 48) {
|
||||
f = b ^ c ^ d;
|
||||
wordIndex = (3 * index + 5) % 16;
|
||||
} else {
|
||||
f = c ^ (b | ~d);
|
||||
wordIndex = (7 * index) % 16;
|
||||
}
|
||||
const previousD = d;
|
||||
d = c;
|
||||
c = b;
|
||||
b = add32(b, rotateLeft(add32(a, f, constants[index], words[wordIndex]), shifts[index]));
|
||||
a = previousD;
|
||||
}
|
||||
a0 = add32(a0, a);
|
||||
b0 = add32(b0, b);
|
||||
c0 = add32(c0, c);
|
||||
d0 = add32(d0, d);
|
||||
}
|
||||
|
||||
const output = new Uint8Array(16);
|
||||
[a0, b0, c0, d0].forEach((word, wordIndex) => {
|
||||
for (let byte = 0; byte < 4; byte++) output[wordIndex * 4 + byte] = (word >>> (byte * 8)) & 0xff;
|
||||
});
|
||||
return output;
|
||||
};
|
||||
|
||||
const signedByte = (value: number): bigint => BigInt(value > 127 ? value - 256 : value);
|
||||
|
||||
const decodeBase64 = (value: string): Uint8Array | undefined => {
|
||||
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) return undefined;
|
||||
try {
|
||||
const binary = atob(value);
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
const canonical = btoa(String.fromCharCode(...bytes));
|
||||
return canonical === value ? bytes : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const deriveKey = (head: Uint8Array): Uint8Array | undefined => {
|
||||
const divisor = new JavaRandom(signedByte(head[5])).nextInt(127);
|
||||
if (divisor === 0) return undefined;
|
||||
const random = new JavaRandom(BigInt.asIntN(64, 3680984568597093857n / BigInt(divisor)));
|
||||
const iterations = Number(signedByte(head[0]));
|
||||
for (let index = 0; index < iterations; index++) random.nextLong();
|
||||
const secondRandom = new JavaRandom(random.nextLong());
|
||||
const longs = [
|
||||
signedByte(head[4]),
|
||||
secondRandom.nextLong(),
|
||||
signedByte(head[7]),
|
||||
signedByte(head[3]),
|
||||
secondRandom.nextLong(),
|
||||
signedByte(head[1]),
|
||||
random.nextLong(),
|
||||
signedByte(head[2]),
|
||||
];
|
||||
const source = new Uint8Array(64);
|
||||
longs.forEach((value, index) => source.set(bigIntToBytes(BigInt.asUintN(64, value), 8), index * 8));
|
||||
return md5(source).slice(0, 8);
|
||||
};
|
||||
|
||||
export const decodeFinalShellPassword = (encoded: string): string | undefined => {
|
||||
const decoded = decodeBase64(encoded);
|
||||
if (!decoded || decoded.length < 16 || (decoded.length - 8) % 8 !== 0) return undefined;
|
||||
const key = deriveKey(decoded.slice(0, 8));
|
||||
if (!key) return undefined;
|
||||
const subkeys = createSubkeys(key);
|
||||
const ciphertext = decoded.slice(8);
|
||||
const plaintext = new Uint8Array(ciphertext.length);
|
||||
for (let offset = 0; offset < ciphertext.length; offset += 8) {
|
||||
plaintext.set(decryptDesBlock(ciphertext.slice(offset, offset + 8), subkeys), offset);
|
||||
}
|
||||
const padding = plaintext[plaintext.length - 1];
|
||||
if (padding < 1 || padding > 8) return undefined;
|
||||
for (let index = plaintext.length - padding; index < plaintext.length; index++) {
|
||||
if (plaintext[index] !== padding) return undefined;
|
||||
}
|
||||
try {
|
||||
const password = new TextDecoder("utf-8", { fatal: true }).decode(plaintext.slice(0, -padding));
|
||||
const hasControlCharacters = Array.from(password).some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
|
||||
});
|
||||
return password && !hasControlCharacters ? password : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -127,6 +127,12 @@ export interface SerialConfig {
|
||||
lineMode?: boolean; // Line mode - buffer input and send on Enter (default: false)
|
||||
// Store the default explicitly so an open/restored session keeps its launch-time behavior.
|
||||
backspaceBehavior?: 'default' | 'ctrl-h';
|
||||
// Byte-oriented backspace: when explicitly enabled, a Backspace deletes the
|
||||
// whole multi-byte character by sending one DEL per wire byte (correct for
|
||||
// MCUs/routers that remove bytes). Set to false for character-aware line
|
||||
// editors (e.g. a Linux console with readline) where one DEL already
|
||||
// removes a whole character and repeating it would over-delete.
|
||||
byteOrientedBackspace?: boolean;
|
||||
}
|
||||
|
||||
// Per-protocol configuration
|
||||
|
||||
@@ -569,6 +569,13 @@ export interface TerminalTheme {
|
||||
colors: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
/**
|
||||
* Optional intense variant for bold text drawn with the default foreground
|
||||
* (SGR 1 / SGR 39;1). When set and different from `foreground`, bold text
|
||||
* without an explicit ANSI color renders with this color instead (#3352).
|
||||
* Explicit ANSI colors keep the existing normal/bright palette behavior.
|
||||
*/
|
||||
foregroundIntense?: string;
|
||||
cursor: string;
|
||||
selection: string;
|
||||
black: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getHostSearchMatch,
|
||||
matchesHostSearchQuery,
|
||||
matchesSearchQuery,
|
||||
matchesWorkspaceHostPickerQuery,
|
||||
resetPinyinCacheForTests,
|
||||
} from "../lib/searchMatcher.ts";
|
||||
|
||||
@@ -136,6 +137,57 @@ test("host search avoids compact hostname false positives on numeric segments",
|
||||
);
|
||||
});
|
||||
|
||||
test("host search matches tags without requiring the label or group", () => {
|
||||
assert.equal(
|
||||
matchesHostSearchQuery("prod", {
|
||||
label: "web-1",
|
||||
hostname: "10.0.0.8",
|
||||
group: "infra",
|
||||
tags: ["prod", "k8s"],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesHostSearchQuery("prod", {
|
||||
label: "web-1",
|
||||
hostname: "10.0.0.8",
|
||||
group: "infra",
|
||||
tags: ["staging"],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace picker search matches tags and usernames", () => {
|
||||
assert.equal(
|
||||
matchesWorkspaceHostPickerQuery("k8s", {
|
||||
label: "api-gateway",
|
||||
hostname: "10.0.0.9",
|
||||
group: "edge",
|
||||
tags: ["k8s", "prod"],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesWorkspaceHostPickerQuery("deploy", {
|
||||
label: "api-gateway",
|
||||
hostname: "10.0.0.9",
|
||||
username: "deploy",
|
||||
tags: [],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
matchesWorkspaceHostPickerQuery("k8s", {
|
||||
label: "api-gateway",
|
||||
hostname: "10.0.0.9",
|
||||
username: "deploy",
|
||||
tags: ["prod"],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("host search scoring favors label over group when both match", () => {
|
||||
const labelHit = getHostSearchMatch("山东 6-1", {
|
||||
label: "山东-业务交换机6-1",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getLastChar, removeLastChar, isPrintableInput } from "./serialCharMetrics";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Byte length — UTF-8 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Byte length — GB18030 / GBK */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Byte length — GB18030 4-byte supplementary characters */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* getLastChar / removeLastChar (surrogate-pair-safe slicing) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
test("getLastChar returns single BMP character", () => {
|
||||
assert.equal(getLastChar("abc"), "c");
|
||||
assert.equal(getLastChar("a你"), "你");
|
||||
});
|
||||
|
||||
test("getLastChar returns full surrogate pair for supplementary characters", () => {
|
||||
// U+20000 is stored as a surrogate pair (D840 DC00).
|
||||
const extB = "\u{20000}";
|
||||
assert.equal(getLastChar("a" + extB), extB);
|
||||
assert.equal(getLastChar(extB), extB);
|
||||
});
|
||||
|
||||
test("removeLastChar removes single BMP character", () => {
|
||||
assert.equal(removeLastChar("abc"), "ab");
|
||||
assert.equal(removeLastChar("a你"), "a");
|
||||
});
|
||||
|
||||
test("removeLastChar removes full surrogate pair for supplementary characters", () => {
|
||||
// U+20000 is stored as a surrogate pair (D840 DC00).
|
||||
const extB = "\u{20000}";
|
||||
assert.equal(removeLastChar("a" + extB), "a");
|
||||
assert.equal(removeLastChar(extB), "");
|
||||
});
|
||||
|
||||
test("removeLastChar returns empty string for single character", () => {
|
||||
assert.equal(removeLastChar("a"), "");
|
||||
});
|
||||
|
||||
test("getLastChar and removeLastChar handle empty string", () => {
|
||||
assert.equal(getLastChar(""), "");
|
||||
assert.equal(removeLastChar(""), "");
|
||||
});
|
||||
|
||||
test("getLastChar returns whole decomposed grapheme (base + combining mark)", () => {
|
||||
// e + U+0301 (combining acute) renders as one visible é.
|
||||
const decomposed = "e\u0301";
|
||||
assert.equal(getLastChar("a" + decomposed), decomposed);
|
||||
assert.equal(getLastChar(decomposed), decomposed);
|
||||
});
|
||||
|
||||
test("removeLastChar removes whole decomposed grapheme (base + combining mark)", () => {
|
||||
const decomposed = "e\u0301";
|
||||
assert.equal(removeLastChar("a" + decomposed), "a");
|
||||
assert.equal(removeLastChar(decomposed), "");
|
||||
});
|
||||
|
||||
test("getLastChar returns whole ZWJ emoji sequence", () => {
|
||||
// Man + ZWJ + Woman renders as one grapheme.
|
||||
const zwj = "\u{1F468}\u200D\u{1F469}";
|
||||
assert.equal(getLastChar("hi" + zwj), zwj);
|
||||
});
|
||||
|
||||
test("removeLastChar removes whole ZWJ emoji sequence", () => {
|
||||
const zwj = "\u{1F468}\u200D\u{1F469}";
|
||||
assert.equal(removeLastChar("hi" + zwj), "hi");
|
||||
assert.equal(removeLastChar(zwj), "");
|
||||
});
|
||||
|
||||
test("getLastChar returns whole variation-selector sequence", () => {
|
||||
// Heart + VS16 (U+FE0F) renders as one emoji grapheme.
|
||||
const heart = "\u2764\uFE0F";
|
||||
assert.equal(getLastChar("a" + heart), heart);
|
||||
});
|
||||
|
||||
test("removeLastChar removes whole variation-selector sequence", () => {
|
||||
const heart = "\u2764\uFE0F";
|
||||
assert.equal(removeLastChar("a" + heart), "a");
|
||||
assert.equal(removeLastChar(heart), "");
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* isPrintableInput */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
test("isPrintableInput returns true for printable characters", () => {
|
||||
assert.equal(isPrintableInput("a"), true);
|
||||
assert.equal(isPrintableInput("你"), true);
|
||||
assert.equal(isPrintableInput("hello"), true);
|
||||
assert.equal(isPrintableInput("123"), true);
|
||||
assert.equal(isPrintableInput(" "), true); // space is printable
|
||||
});
|
||||
|
||||
test("isPrintableInput returns false for escape sequences", () => {
|
||||
assert.equal(isPrintableInput("\x1b[A"), false); // up arrow
|
||||
assert.equal(isPrintableInput("\x1b[D"), false); // left arrow
|
||||
assert.equal(isPrintableInput("\x1b[32m"), false); // color escape
|
||||
});
|
||||
|
||||
test("isPrintableInput returns false for control characters", () => {
|
||||
assert.equal(isPrintableInput("\x7f"), false); // DEL
|
||||
assert.equal(isPrintableInput("\b"), false); // backspace
|
||||
assert.equal(isPrintableInput("\r"), false); // carriage return
|
||||
assert.equal(isPrintableInput("\n"), false); // newline
|
||||
assert.equal(isPrintableInput("\x03"), false); // Ctrl+C
|
||||
assert.equal(isPrintableInput("\x15"), false); // Ctrl+U
|
||||
});
|
||||
|
||||
test("isPrintableInput returns false for empty string", () => {
|
||||
assert.equal(isPrintableInput(""), false);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Whole-grapheme slicing for serial input buffers.
|
||||
let graphemeSegmenter: Intl.Segmenter | undefined;
|
||||
|
||||
function getGraphemeSegmenter(): Intl.Segmenter {
|
||||
graphemeSegmenter ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
return graphemeSegmenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last complete grapheme of `str`.
|
||||
*
|
||||
* Unlike `slice(-1)` — which returns a lone low surrogate for a
|
||||
* supplementary-plane character (e.g. emoji, CJK Extension B) or only a
|
||||
* combining mark for a decomposed grapheme — this returns the whole
|
||||
* rendered character.
|
||||
*
|
||||
* Returns an empty string for an empty input.
|
||||
*/
|
||||
export function getLastChar(str: string): string {
|
||||
if (!str) return "";
|
||||
const segments = Array.from(getGraphemeSegmenter().segment(str));
|
||||
const last = segments[segments.length - 1];
|
||||
return last ? last.segment : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `str` with the last complete grapheme removed.
|
||||
*
|
||||
* Unlike `slice(0, -1)` this correctly removes a full surrogate pair, a
|
||||
* combining sequence, or a ZWJ sequence — not just the trailing code unit.
|
||||
*/
|
||||
export function removeLastChar(str: string): string {
|
||||
if (!str) return "";
|
||||
const segments = Array.from(getGraphemeSegmenter().segment(str));
|
||||
const last = segments[segments.length - 1];
|
||||
return last ? str.slice(0, last.index) : "";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Input classification */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* True when `data` represents a printable character or string that advances
|
||||
* the cursor (i.e. the remote line editor's cursor is likely at the end of
|
||||
* the typed buffer).
|
||||
*
|
||||
* False for escape sequences (cursor movements, special keys) and control
|
||||
* characters that do not add to the input buffer. Used to decide whether
|
||||
* backspace byte expansion is safe — only expand when the last input was
|
||||
* a printable character, not a cursor movement.
|
||||
*/
|
||||
export function isPrintableInput(data: string): boolean {
|
||||
if (!data) return false;
|
||||
// Escape sequences (cursor movements, special keys, etc.)
|
||||
if (data.charCodeAt(0) === 0x1b) return false;
|
||||
// Control characters below 0x20 and DEL (0x7F)
|
||||
const cp = data.charCodeAt(0);
|
||||
if (cp < 0x20 || cp === 0x7f) return false;
|
||||
// Everything else is printable
|
||||
return true;
|
||||
}
|
||||
@@ -189,6 +189,7 @@ test("buildSessionRestorePayload deeply allowlists serial config fields", () =>
|
||||
localEcho: true,
|
||||
lineMode: true,
|
||||
backspaceBehavior: "ctrl-h",
|
||||
byteOrientedBackspace: false,
|
||||
password: "do-not-store",
|
||||
},
|
||||
} as TerminalSession & { serialConfig: TerminalSession["serialConfig"] & { password: string } }],
|
||||
@@ -208,6 +209,7 @@ test("buildSessionRestorePayload deeply allowlists serial config fields", () =>
|
||||
localEcho: true,
|
||||
lineMode: true,
|
||||
backspaceBehavior: "ctrl-h",
|
||||
byteOrientedBackspace: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -124,6 +124,11 @@ const sanitizeSerialConfig = (value: unknown): SerialConfig | undefined => {
|
||||
...(isOneOf(value.backspaceBehavior, ["default", "ctrl-h"] as const)
|
||||
? { backspaceBehavior: value.backspaceBehavior }
|
||||
: {}),
|
||||
// Missing value = legacy session saved before byte-oriented backspace
|
||||
// existed; the runtime defaults to enabled (byte-oriented deletion).
|
||||
...(readBoolean(value, "byteOrientedBackspace") !== undefined
|
||||
? { byteOrientedBackspace: readBoolean(value, "byteOrientedBackspace") }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -665,6 +665,7 @@ test('applyVaultHostUpdate rejects malformed advanced connection settings', () =
|
||||
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, localEcho: 'maybe' } }, error: /localEcho/i },
|
||||
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, lineMode: 'maybe' } }, error: /lineMode/i },
|
||||
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, backspaceBehavior: 'bad' } }, error: /backspaceBehavior/i },
|
||||
{ patch: { serialConfig: { path: '/dev/ttyUSB0', baudRate: 9600, byteOrientedBackspace: 'maybe' } }, error: /byteOrientedBackspace/i },
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
@@ -810,6 +811,35 @@ test('applyVaultHostUpdate preserves serial Backspace override semantics', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('applyVaultHostUpdate preserves serial byteOrientedBackspace semantics', () => {
|
||||
for (const byteOrientedBackspace of [true, false, undefined] as const) {
|
||||
const host: Host = {
|
||||
id: `serial-${byteOrientedBackspace ?? 'inherited'}`,
|
||||
label: 'serial',
|
||||
hostname: 'serial',
|
||||
username: '',
|
||||
protocol: 'serial',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
serialConfig: {
|
||||
path: '/dev/ttyUSB0',
|
||||
baudRate: 9600,
|
||||
...(byteOrientedBackspace !== undefined ? { byteOrientedBackspace } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const updated = applyVaultHostUpdate([host], [], host.id, {
|
||||
serialConfig: { path: '/dev/ttyUSB0', baudRate: 115200 },
|
||||
});
|
||||
assert.equal(updated.ok, true);
|
||||
if (!updated.ok) continue;
|
||||
assert.equal(
|
||||
updated.updatedHost.serialConfig?.byteOrientedBackspace,
|
||||
byteOrientedBackspace,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('applyVaultHostUpdate can switch a host to a referenced key path', () => {
|
||||
const existing: Host = {
|
||||
id: 'host-1',
|
||||
|
||||
@@ -581,6 +581,12 @@ export function applyVaultHostUpdate(
|
||||
if (backspaceBehavior !== undefined && !['default', 'ctrl-h'].includes(backspaceBehavior)) {
|
||||
return { ok: false, error: 'serialConfig.backspaceBehavior must be default or ctrl-h.' };
|
||||
}
|
||||
const byteOrientedBackspace = config.byteOrientedBackspace === undefined
|
||||
? updated.serialConfig?.byteOrientedBackspace
|
||||
: parseBoolean(config.byteOrientedBackspace);
|
||||
if (config.byteOrientedBackspace !== undefined && byteOrientedBackspace === undefined) {
|
||||
return { ok: false, error: 'serialConfig.byteOrientedBackspace must be true or false.' };
|
||||
}
|
||||
updated.serialConfig = {
|
||||
path,
|
||||
baudRate,
|
||||
@@ -591,6 +597,7 @@ export function applyVaultHostUpdate(
|
||||
...(localEcho !== undefined ? { localEcho } : {}),
|
||||
...(lineMode !== undefined ? { lineMode } : {}),
|
||||
...(backspaceBehavior !== undefined ? { backspaceBehavior: backspaceBehavior as 'default' | 'ctrl-h' } : {}),
|
||||
...(byteOrientedBackspace !== undefined ? { byteOrientedBackspace } : {}),
|
||||
};
|
||||
}
|
||||
if (updated.protocol === 'serial' && updated.serialConfig) {
|
||||
|
||||
@@ -19,6 +19,79 @@ const mobaXtermSshSession = (
|
||||
username = "root",
|
||||
) => `#109#0%${hostname}%${port}%${username}%%-1%-1%%%%%0%0%0%%%-1%0%0%0%%1080%%0%0%1%#MobaFont%10%0%0%-1#0# #-1`;
|
||||
|
||||
const JAVA_RANDOM_MULTIPLIER = 0x5deece66dn;
|
||||
const JAVA_RANDOM_ADDEND = 0xbn;
|
||||
const JAVA_RANDOM_MASK = (1n << 48n) - 1n;
|
||||
|
||||
class TestJavaRandom {
|
||||
private seed: bigint;
|
||||
|
||||
constructor(seed: bigint) {
|
||||
this.seed = (BigInt.asIntN(64, seed) ^ JAVA_RANDOM_MULTIPLIER) & JAVA_RANDOM_MASK;
|
||||
}
|
||||
|
||||
private next(bits: number): number {
|
||||
this.seed = (this.seed * JAVA_RANDOM_MULTIPLIER + JAVA_RANDOM_ADDEND) & JAVA_RANDOM_MASK;
|
||||
return Number(this.seed >> BigInt(48 - bits));
|
||||
}
|
||||
|
||||
nextInt(bound: number): number {
|
||||
if ((bound & -bound) === bound) {
|
||||
return Math.floor((bound * this.next(31)) / 0x80000000);
|
||||
}
|
||||
let bits: number;
|
||||
let value: number;
|
||||
do {
|
||||
bits = this.next(31);
|
||||
value = bits % bound;
|
||||
} while (((bits - value + (bound - 1)) | 0) < 0);
|
||||
return value;
|
||||
}
|
||||
|
||||
nextLong(): bigint {
|
||||
const high = BigInt.asIntN(32, BigInt(this.next(32)));
|
||||
const low = BigInt.asIntN(32, BigInt(this.next(32)));
|
||||
return BigInt.asIntN(64, (high << 32n) + low);
|
||||
}
|
||||
}
|
||||
|
||||
const signedByte = (value: number): bigint => BigInt(value > 127 ? value - 256 : value);
|
||||
|
||||
const encodeFinalShellPassword = (password: string, head: Uint8Array): string => {
|
||||
assert.equal(head.length, 8);
|
||||
const divisor = new TestJavaRandom(signedByte(head[5])).nextInt(127);
|
||||
assert.notEqual(divisor, 0);
|
||||
const random = new TestJavaRandom(BigInt.asIntN(64, 3680984568597093857n / BigInt(divisor)));
|
||||
const iterations = Number(signedByte(head[0]));
|
||||
for (let index = 0; index < iterations; index++) random.nextLong();
|
||||
const secondRandom = new TestJavaRandom(random.nextLong());
|
||||
const longs = [
|
||||
signedByte(head[4]),
|
||||
secondRandom.nextLong(),
|
||||
signedByte(head[7]),
|
||||
signedByte(head[3]),
|
||||
secondRandom.nextLong(),
|
||||
signedByte(head[1]),
|
||||
random.nextLong(),
|
||||
signedByte(head[2]),
|
||||
];
|
||||
const keySource = Buffer.alloc(64);
|
||||
longs.forEach((value, index) => keySource.writeBigInt64BE(BigInt.asIntN(64, value), index * 8));
|
||||
const key = createHash("md5").update(keySource).digest().subarray(0, 8);
|
||||
const cipher = createCipheriv("des-ede3", Buffer.concat([key, key, key]), null);
|
||||
const encrypted = Buffer.concat([cipher.update(password, "utf8"), cipher.final()]);
|
||||
return Buffer.concat([head, encrypted]).toString("base64");
|
||||
};
|
||||
|
||||
const finalShellConnection = (password: string) => JSON.stringify({
|
||||
name: "Synthetic FinalShell",
|
||||
host: "synthetic.example.test",
|
||||
port: 2222,
|
||||
user_name: "tester",
|
||||
conection_type: 100,
|
||||
password,
|
||||
});
|
||||
|
||||
test("ssh_config import maps ForwardX11 yes to host X11 forwarding", () => {
|
||||
const result = importVaultHostsFromText("ssh_config", [
|
||||
"Host x11-host",
|
||||
@@ -135,6 +208,95 @@ test("detectVaultImportFormat recognizes csv and ssh_config exports", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("FinalShell import decrypts a synthetic ASCII password", () => {
|
||||
const password = "synthetic-secret";
|
||||
const encrypted = encodeFinalShellPassword(password, Uint8Array.from([3, 18, 52, 86, 120, 11, 34, 56]));
|
||||
const result = importVaultHostsFromText("finalshell", finalShellConnection(encrypted));
|
||||
|
||||
assert.equal(result.hosts[0]?.password, password);
|
||||
assert.deepEqual(result.issues, []);
|
||||
});
|
||||
|
||||
test("FinalShell import decrypts a synthetic letter-number-dot password", () => {
|
||||
const password = "Alpha9.beta2026";
|
||||
const encrypted = encodeFinalShellPassword(password, Uint8Array.from([5, 201, 17, 88, 144, 23, 77, 199]));
|
||||
const result = importVaultHostsFromText("finalshell", finalShellConnection(encrypted));
|
||||
|
||||
assert.equal(result.hosts[0]?.password, password);
|
||||
assert.deepEqual(result.issues, []);
|
||||
});
|
||||
|
||||
test("FinalShell import skips damaged ciphertext and warns", () => {
|
||||
const encrypted = encodeFinalShellPassword("synthetic-secret", Uint8Array.from([2, 41, 63, 85, 107, 29, 51, 73]));
|
||||
const damaged = `${encrypted.slice(0, -2)}AA`;
|
||||
const result = importVaultHostsFromText("finalshell", finalShellConnection(damaged));
|
||||
|
||||
assert.equal(result.hosts.length, 1);
|
||||
assert.equal(result.hosts[0]?.password, undefined);
|
||||
assert.match(result.issues[0]?.message ?? "", /password.*decrypt/i);
|
||||
});
|
||||
|
||||
test("FinalShell import accepts an empty password without warning", () => {
|
||||
const result = importVaultHostsFromText("finalshell", finalShellConnection(""));
|
||||
|
||||
assert.equal(result.hosts.length, 1);
|
||||
assert.equal(result.hosts[0]?.password, undefined);
|
||||
assert.deepEqual(result.issues, []);
|
||||
});
|
||||
|
||||
test("FinalShell import rejects decrypted passwords containing control characters", () => {
|
||||
const encrypted = encodeFinalShellPassword("invalid\u0085password", Uint8Array.from([4, 26, 48, 70, 92, 31, 53, 75]));
|
||||
const result = importVaultHostsFromText("finalshell", finalShellConnection(encrypted));
|
||||
|
||||
assert.equal(result.hosts[0]?.password, undefined);
|
||||
assert.match(result.issues[0]?.message ?? "", /password.*decrypt/i);
|
||||
});
|
||||
|
||||
test("FinalShell import skips non-SSH connection types", () => {
|
||||
const result = importVaultHostsFromText("finalshell", JSON.stringify({
|
||||
name: "RDP",
|
||||
host: "windows.example.com",
|
||||
port: 3389,
|
||||
user_name: "Administrator",
|
||||
conection_type: 1,
|
||||
}));
|
||||
|
||||
assert.equal(result.hosts.length, 0);
|
||||
assert.equal(result.stats.parsed, 1);
|
||||
assert.equal(result.stats.skipped, 1);
|
||||
assert.match(result.issues[0]?.message ?? "", /unsupported.*type/i);
|
||||
});
|
||||
|
||||
test("FinalShell detection and parsing accept a UTF-8 BOM", () => {
|
||||
const text = "\uFEFF" + JSON.stringify({ host: "bom.example.com", conection_type: 100 });
|
||||
assert.equal(detectVaultImportFormat(text), "finalshell");
|
||||
assert.equal(importVaultHostsFromText("finalshell", text).hosts[0]?.hostname, "bom.example.com");
|
||||
});
|
||||
|
||||
test("FinalShell auto-detection accepts missing and null usernames", () => {
|
||||
for (const user_name of [undefined, null]) {
|
||||
const text = JSON.stringify({ host: "optional-user.example.com", conection_type: 100, user_name });
|
||||
assert.equal(detectVaultImportFormat(text), "finalshell");
|
||||
assert.equal(importVaultHostsFromText("finalshell", text).hosts.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("FinalShell detection requires characteristic fields and avoids generic JSON", () => {
|
||||
assert.equal(detectVaultImportFormat(JSON.stringify({
|
||||
name: "Production",
|
||||
host: "prod.example.com",
|
||||
port: 22,
|
||||
user_name: "root",
|
||||
conection_type: 100,
|
||||
})), "finalshell");
|
||||
assert.equal(detectVaultImportFormat(JSON.stringify({
|
||||
name: "Generic host",
|
||||
host: "generic.example.com",
|
||||
port: 22,
|
||||
username: "root",
|
||||
})), null);
|
||||
});
|
||||
|
||||
test("SecureCRT import reads the protocol-specific hexadecimal port", () => {
|
||||
const result = importVaultHostsFromText("securecrt", [
|
||||
'S:"Hostname"=secure.example.com',
|
||||
|
||||
+87
-1
@@ -17,6 +17,7 @@ import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||||
import { decodeFinalShellPassword } from "./finalShellPassword";
|
||||
export {
|
||||
exportHostsToCsvWithStats,
|
||||
getVaultCsvTemplate,
|
||||
@@ -103,6 +104,7 @@ export type VaultImportFormat =
|
||||
| "mobaxterm"
|
||||
| "csv"
|
||||
| "securecrt"
|
||||
| "finalshell"
|
||||
| "ssh_config";
|
||||
|
||||
export const VAULT_IMPORT_FORMATS: VaultImportFormat[] = [
|
||||
@@ -110,6 +112,7 @@ export const VAULT_IMPORT_FORMATS: VaultImportFormat[] = [
|
||||
"putty",
|
||||
"mobaxterm",
|
||||
"securecrt",
|
||||
"finalshell",
|
||||
"ssh_config",
|
||||
];
|
||||
|
||||
@@ -986,6 +989,70 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
};
|
||||
};
|
||||
|
||||
const importFromFinalShell = (text: string): VaultImportResult => {
|
||||
let connection: Record<string, unknown>;
|
||||
try {
|
||||
const parsed = JSON.parse(text.trim()) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("FinalShell connection must be a JSON object.");
|
||||
}
|
||||
connection = parsed as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof SyntaxError ? "Invalid FinalShell JSON." : String(error));
|
||||
}
|
||||
|
||||
const issues: VaultImportIssue[] = [];
|
||||
const connectionType = Number(connection.conection_type);
|
||||
if (connectionType !== 100) {
|
||||
return {
|
||||
hosts: [],
|
||||
groups: [],
|
||||
issues: [{ level: "warning", message: "FinalShell connection: unsupported connection type." }],
|
||||
stats: { parsed: 1, imported: 0, skipped: 1, duplicates: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const hostname = typeof connection.host === "string" ? connection.host.trim() : "";
|
||||
if (!hostname) {
|
||||
return {
|
||||
hosts: [],
|
||||
groups: [],
|
||||
issues: [{ level: "warning", message: "FinalShell connection: missing host." }],
|
||||
stats: { parsed: 1, imported: 0, skipped: 1, duplicates: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const encryptedPassword = typeof connection.password === "string"
|
||||
? connection.password.trim()
|
||||
: "";
|
||||
const password = encryptedPassword ? decodeFinalShellPassword(encryptedPassword) : undefined;
|
||||
if (encryptedPassword && password === undefined) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: "FinalShell password could not be decrypted and was skipped.",
|
||||
});
|
||||
}
|
||||
|
||||
const port = typeof connection.port === "number" || typeof connection.port === "string"
|
||||
? parsePort(String(connection.port))
|
||||
: undefined;
|
||||
const host = createHost({
|
||||
label: typeof connection.name === "string" ? connection.name : undefined,
|
||||
hostname,
|
||||
port,
|
||||
username: typeof connection.user_name === "string" ? connection.user_name : undefined,
|
||||
password,
|
||||
notes: typeof connection.description === "string" ? connection.description : undefined,
|
||||
protocol: "ssh",
|
||||
});
|
||||
return {
|
||||
hosts: [host],
|
||||
groups: [],
|
||||
issues,
|
||||
stats: { parsed: 1, imported: 1, skipped: 0, duplicates: 0 },
|
||||
};
|
||||
};
|
||||
|
||||
const importFromSecureCrt = (text: string, fileName?: string): VaultImportResult => {
|
||||
const issues: VaultImportIssue[] = [];
|
||||
const lines = text.split(/\r?\n/);
|
||||
@@ -1320,6 +1387,8 @@ export const importVaultHostsFromText = (
|
||||
return importFromSshConfig(input);
|
||||
case "securecrt":
|
||||
return importFromSecureCrt(input, options?.fileName);
|
||||
case "finalshell":
|
||||
return importFromFinalShell(input);
|
||||
case "mobaxterm":
|
||||
return importFromMobaXterm(input, options);
|
||||
default: {
|
||||
@@ -1361,9 +1430,26 @@ export function detectVaultImportFormat(text: string): VaultImportFormat | null
|
||||
return "securecrt";
|
||||
}
|
||||
|
||||
try {
|
||||
const candidate = JSON.parse(input) as unknown;
|
||||
if (
|
||||
candidate
|
||||
&& typeof candidate === "object"
|
||||
&& !Array.isArray(candidate)
|
||||
&& Object.prototype.hasOwnProperty.call(candidate, "conection_type")
|
||||
&& typeof (candidate as Record<string, unknown>).host === "string"
|
||||
) {
|
||||
return "finalshell";
|
||||
}
|
||||
} catch {
|
||||
// Not JSON; continue with text formats.
|
||||
}
|
||||
|
||||
const firstLine = input.split(/\r?\n/, 1)[0] ?? "";
|
||||
if (
|
||||
/hostname|host(name)?|server/i.test(firstLine)
|
||||
!input.startsWith("{")
|
||||
&& !input.startsWith("[")
|
||||
&& /hostname|host(name)?|server/i.test(firstLine)
|
||||
&& (firstLine.includes(",") || firstLine.includes("\t"))
|
||||
) {
|
||||
return "csv";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecHelpers.cjs");
|
||||
const { buildBashHistoryCleanup, bashHistoryScratchNames, buildInlineHistoryDrop } = require("./ptyExecHelpers.cjs");
|
||||
|
||||
// Both fish and POSIX shells accept this command. Inspect the parent of a
|
||||
// short-lived sh in the interactive PTY, rather than the SSH login shell.
|
||||
@@ -14,9 +14,13 @@ function buildLiveShellProbe(marker) {
|
||||
const { dispatcher } = bashHistoryScratchNames(marker);
|
||||
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
|
||||
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
|
||||
// Continuation lines stay within canonical input limits. Each echo carries
|
||||
// the marker so the renderer also hides continuation prompts.
|
||||
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
|
||||
// Start display suppression in the PTY before any continuation is read,
|
||||
// independently of PS2 and echo mode. Keep every later physical line short.
|
||||
// The standalone first line crosses a prompt boundary, so delete its history
|
||||
// entry inline (buildInlineHistoryDrop) before an immediate-history hook
|
||||
// such as PROMPT_COMMAND='history -a' can persist it — same reason as the
|
||||
// wrapper input marker in ptyExecHelpers.cjs.
|
||||
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'; ${buildInlineHistoryDrop(marker)}\n : '${marker}'; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -34,19 +34,27 @@ test('probe wrapper keeps the start marker separate when terminal echo is disabl
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const pty = new EventEmitter();
|
||||
let job;
|
||||
let probing = true;
|
||||
let pendingInput = '';
|
||||
pty.write = (data) => {
|
||||
if (String(data).includes('command sh -c')) return;
|
||||
if (data === '\x03') return;
|
||||
if (data === "\x03") return;
|
||||
pendingInput += String(data);
|
||||
if (!pendingInput.endsWith('\n')) return;
|
||||
if (!pendingInput.endsWith('\n') || pendingInput.endsWith('\\\n')) return;
|
||||
if (probing) {
|
||||
// Wait for the complete probe, including paced chunks and continuations.
|
||||
if (!pendingInput.includes("printf '%s'")) return;
|
||||
pendingInput = '';
|
||||
probing = false;
|
||||
queueMicrotask(() => pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`));
|
||||
return;
|
||||
}
|
||||
if (!pendingInput.includes(`${job.marker}_E:`)) return;
|
||||
const script = pendingInput.replace(/^\x0b\x15/, '');
|
||||
pendingInput = '';
|
||||
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' });
|
||||
queueMicrotask(() => pty.emit('data', `${job.marker}_QPROMPT> ${result.stdout}`));
|
||||
};
|
||||
job = startPtyJob(pty, 'printf no-newline-output', { probeLiveShell: true, timeoutMs: 1000 });
|
||||
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
|
||||
job = startPtyJob(pty, 'printf no-newline-output', { probeLiveShell: true, timeoutMs: 3000 });
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /no-newline-output/);
|
||||
|
||||
@@ -57,6 +57,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
shellKind,
|
||||
loginShellHint,
|
||||
probeLiveShell = false,
|
||||
bastionKeystrokes = false,
|
||||
onProbeAborted,
|
||||
chatSessionId,
|
||||
abortSignal,
|
||||
@@ -417,7 +418,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
function finish(stdout, exitCode, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (usesLiveShellProbe && !foundStart && typeof onProbeAborted === "function") {
|
||||
if (!foundStart && typeof onProbeAborted === "function") {
|
||||
try {
|
||||
onProbeAborted(marker);
|
||||
} catch {
|
||||
@@ -733,14 +734,27 @@ function startPtyJob(ptyStream, command, options) {
|
||||
}
|
||||
|
||||
let inputWriteTimer = null;
|
||||
let inputDrainListener = null;
|
||||
let inputWriteGeneration = 0;
|
||||
function stopInputWrite() {
|
||||
inputWriteGeneration += 1;
|
||||
clearTimeout(inputWriteTimer);
|
||||
inputWriteTimer = null;
|
||||
if (inputDrainListener) ptyStream.removeListener("drain", inputDrainListener);
|
||||
inputDrainListener = null;
|
||||
}
|
||||
cleanupFns.push(stopInputWrite);
|
||||
|
||||
function completeInputDelivery(generation) {
|
||||
// Input delivery is complete: only now does the startup deadline begin,
|
||||
// so paced typing time never consumes the startup budget.
|
||||
if (!finished && !cancelRequested && generation === inputWriteGeneration) {
|
||||
deliveringInput = false;
|
||||
if (!pendingEnd) armOutputTimeout();
|
||||
if (!foundStart) armStartupTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
function writeInput(text) {
|
||||
stopInputWrite();
|
||||
// Each delivery gets a fresh wait budget, including the transition from
|
||||
@@ -749,32 +763,45 @@ function startPtyJob(ptyStream, command, options) {
|
||||
clearTimeout(timeoutId);
|
||||
deliveringInput = true;
|
||||
const generation = inputWriteGeneration;
|
||||
|
||||
// Keep each write to one Unicode code point for strict bastions, while
|
||||
// retaining bounded pacing so long input cannot overrun shell queues.
|
||||
let offset = 0;
|
||||
// Readline may overrun its input queue when long probes arrive in one
|
||||
// write. Yield between bounded chunks; cancelled/replaced jobs must never
|
||||
// finish typing an old command into a subsequent prompt.
|
||||
const chunkSize = usesLiveShellProbe && text.length > 1024 ? 128 : text.length;
|
||||
const batchSize = (usesLiveShellProbe || bastionKeystrokes) && text.length > 1024 ? 128 : text.length;
|
||||
const isCurrent = () => !finished && !cancelRequested && generation === inputWriteGeneration;
|
||||
const scheduleNext = () => {
|
||||
if (!isCurrent()) return;
|
||||
inputWriteTimer = setTimeout(writeNext, 30);
|
||||
};
|
||||
const writeNext = () => {
|
||||
if (finished || cancelRequested || generation !== inputWriteGeneration) return;
|
||||
let end = Math.min(offset + chunkSize, text.length);
|
||||
// Do not split a UTF-16 surrogate pair across independently encoded writes.
|
||||
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1;
|
||||
ptyStream.write(text.slice(offset, end));
|
||||
offset = end;
|
||||
if (offset < text.length && generation === inputWriteGeneration && !finished && !cancelRequested) {
|
||||
inputWriteTimer = setTimeout(() => {
|
||||
try { writeNext(); } catch (error) {
|
||||
finish(preStartOutput, -1, `Terminal input failed: ${error.message}`);
|
||||
try {
|
||||
let end = Math.min(offset + batchSize, text.length);
|
||||
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1;
|
||||
while (offset < end) {
|
||||
if (!isCurrent()) return;
|
||||
const chunk = bastionKeystrokes
|
||||
? String.fromCodePoint(text.codePointAt(offset))
|
||||
: text.slice(offset, end);
|
||||
offset += chunk.length;
|
||||
const writable = ptyStream.write(chunk);
|
||||
if (!isCurrent()) return;
|
||||
if (writable === false) {
|
||||
inputDrainListener = () => {
|
||||
inputDrainListener = null;
|
||||
clearTimeout(inputWriteTimer);
|
||||
scheduleNext();
|
||||
};
|
||||
ptyStream.once("drain", inputDrainListener);
|
||||
inputWriteTimer = setTimeout(() => {
|
||||
finish(preStartOutput, -1, "Terminal input timed out waiting for drain");
|
||||
}, maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs);
|
||||
return;
|
||||
}
|
||||
}, 30);
|
||||
} else {
|
||||
// Input delivery is complete: only now does the startup deadline
|
||||
// begin, so paced typing time never consumes the startup budget.
|
||||
if (!finished && !cancelRequested && generation === inputWriteGeneration) {
|
||||
deliveringInput = false;
|
||||
armOutputTimeout();
|
||||
if (!foundStart) armStartupTimeout();
|
||||
}
|
||||
if (offset < text.length) scheduleNext();
|
||||
else completeInputDelivery(generation);
|
||||
} catch (error) {
|
||||
finish(preStartOutput, -1, `Terminal input failed: ${error.message}`);
|
||||
}
|
||||
};
|
||||
writeNext();
|
||||
|
||||
@@ -902,16 +902,15 @@ test("posix wrapper isolates set -e failures from the active shell", () => {
|
||||
assert.doesNotMatch(result.stdout, /SHOULD_NOT_PRINT/);
|
||||
});
|
||||
|
||||
test("posix wrapper types multi-line commands as one physical line (no PS2 leak) and preserves semantics", () => {
|
||||
test("posix wrapper marks every continuation and preserves multi-line command semantics", () => {
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand(
|
||||
"echo first\necho \"it's quoted\"\n\necho last",
|
||||
"posix",
|
||||
marker,
|
||||
);
|
||||
// A single physical line: the interactive shell must never show PS2
|
||||
// ("> ") continuation echoes, which would leak past the preload filter.
|
||||
assert.equal(wrapped.indexOf("\n"), wrapped.length - 1);
|
||||
// Every continuation carries the marker so its PS2 echo stays hidden.
|
||||
assert.ok(wrapped.trimEnd().split("\n").every(line => line.includes(marker)));
|
||||
|
||||
const result = spawnSync("sh", ["-c", wrapped], { encoding: "utf8" });
|
||||
assert.equal(result.error, undefined);
|
||||
@@ -926,10 +925,10 @@ test("long POSIX assignments preserve quotes, Unicode, and heredoc newlines on b
|
||||
const literal = "quote' \\ $HOME `false` " + "\u4e2d\u6587\ud83d\ude42".repeat(600);
|
||||
const command = `cat <<'SMOKE_END'\n${literal}\n\nsecond line\nSMOKE_END\nprintf tail`;
|
||||
const wrapped = buildWrappedCommand(command, "posix", marker, true);
|
||||
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') < 1000));
|
||||
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') <= 480));
|
||||
const result = spawnSync('sh', ['-c', wrapped], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, `\n${marker}_S\n${literal}\n\nsecond line\ntail${marker}_E:0\n`);
|
||||
assert.equal(result.stdout, `\n${marker}_I\n\n${marker}_S\n${literal}\n\nsecond line\ntail${marker}_E:0\n`);
|
||||
});
|
||||
|
||||
test("posix wrapper isolates explicit exit from the active shell and reports its code", () => {
|
||||
@@ -1376,3 +1375,48 @@ test("posix wrapper avoids history expansion in interactive zsh", (t) => {
|
||||
assert.match(result.stdout, /HISTORY_PROBE/);
|
||||
assert.doesNotMatch(result.stderr, /event not found/);
|
||||
});
|
||||
|
||||
|
||||
test("cancelled POSIX input without a live probe resets display suppression", async () => {
|
||||
const pty = new EventEmitter();
|
||||
const resets = [];
|
||||
pty.write = () => {};
|
||||
const job = startPtyJob(pty, "echo should-not-start", {
|
||||
shellKind: "posix", probeLiveShell: false, timeoutMs: 1000,
|
||||
expectedPrompt: "ready$ ", onProbeAborted: marker => resets.push(marker),
|
||||
});
|
||||
pty.emit("data", `${job.marker}_I\nCUSTOM> `);
|
||||
job.cancel();
|
||||
pty.emit("data", "\nready$ ");
|
||||
assert.equal((await job.resultPromise).error, "Cancelled");
|
||||
assert.deepEqual(resets, [job.marker]);
|
||||
});
|
||||
|
||||
test("startPtyJob types the wrapper one code point per write for strict bastions (#3146)", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const writes = [];
|
||||
class CapturePty extends EventEmitter {
|
||||
write(data) {
|
||||
writes.push(String(data));
|
||||
}
|
||||
}
|
||||
const pty = new CapturePty();
|
||||
const job = startPtyJob(pty, "echo test", {
|
||||
shellKind: "posix",
|
||||
bastionKeystrokes: true,
|
||||
timeoutMs: 50,
|
||||
expectedPrompt: "$ ",
|
||||
});
|
||||
const wrapped = buildWrappedCommand("echo test", "posix", job.marker);
|
||||
const expected = Array.from(`${buildPendingInputClearPrefix("posix")}${wrapped}`);
|
||||
while (writes.length < expected.length) t.mock.timers.tick(30);
|
||||
assert.deepEqual(writes, expected, "one write per code point");
|
||||
for (const chunk of writes) {
|
||||
assert.equal(Array.from(chunk).length, 1, "each write is a single code point");
|
||||
}
|
||||
job.cancel();
|
||||
pty.emit("data", Buffer.from("$ "));
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.error, "Cancelled");
|
||||
});
|
||||
@@ -213,8 +213,39 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) {
|
||||
// Invocation-specific scratch names avoid readonly user variables. Clear the
|
||||
// history-bearing scratch through the verified dispatcher, never plain unset.
|
||||
const { entry, dispatcher } = bashHistoryScratchNames(marker);
|
||||
const unsetNames = keepDispatcher ? entry : `${entry} ${dispatcher}`;
|
||||
return `[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) ${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};$${dispatcher} history -d "\${${entry}%%[[:space:]]*}";${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) continue;;esac;;esac;$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`;
|
||||
const previous = `${entry}_prev`;
|
||||
const unsetNames = `${entry} ${previous}${keepDispatcher ? '' : ` ${dispatcher}`}`;
|
||||
// With cmdhist disabled each physical line is a separate history entry.
|
||||
// Keep deleting marked tail entries only while their numbers decrease; a
|
||||
// shadowed history function that makes no progress must fall back, not loop.
|
||||
return [
|
||||
`[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;`,
|
||||
`${entry}=$($${dispatcher} history 1);while :;do case "$${entry}" in *${marker}*)`,
|
||||
`${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};${previous}=\${${entry}%%[[:space:]]*};$${dispatcher} history -d "$${previous}";`,
|
||||
`${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*)`,
|
||||
`${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};[ "\${${entry}%%[[:space:]]*}" -lt "$${previous}" ]||break;;*) break;;esac;;*) break;;esac;done;`,
|
||||
`case "$${entry}" in *${marker}*) continue;;esac;`,
|
||||
`$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`,
|
||||
].join(` \\\n: "${marker}"; `);
|
||||
}
|
||||
|
||||
// Delete the just-read interactive Bash history entry for a standalone
|
||||
// internal line (the `_I` input marker) before a prompt-boundary
|
||||
// PROMPT_COMMAND='history -a' can persist it. Only an entry whose text
|
||||
// carries this invocation's marker is deleted, so a customization that
|
||||
// suppressed the line (HISTCONTROL=ignorespace) leaves the user's previous
|
||||
// entry untouched — never trust $HISTCMD here. Bash-guarded and
|
||||
// shadowing-safe (verified dispatcher, mirroring buildBashHistoryCleanup);
|
||||
// other POSIX shells leave their entry for the tail cleanup to handle.
|
||||
function buildInlineHistoryDrop(marker) {
|
||||
const { entry, dispatcher } = bashHistoryScratchNames(marker);
|
||||
const inlineEntry = `${entry}_i`;
|
||||
const inlineDispatcher = `${dispatcher}_i`;
|
||||
return [
|
||||
`[ "\${BASH_VERSION-}" ]&&{ for ${inlineDispatcher} in command builtin;do ${inlineEntry}=$($${inlineDispatcher} printf x);[ "$${inlineEntry}" = x ]||continue;`,
|
||||
`${inlineEntry}=$($${inlineDispatcher} history 1);${inlineEntry}=\${${inlineEntry}#"\${${inlineEntry}%%[^[:space:]]*}"};case "$${inlineEntry}" in *'${marker}'*)`,
|
||||
`$${inlineDispatcher} history -d "\${${inlineEntry}%%[[:space:]]*}";;esac;break;done; } 2>/dev/null`,
|
||||
].join(` \\\n: '${marker}'; `);
|
||||
}
|
||||
|
||||
function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
@@ -223,9 +254,10 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
let cmdAssign = commandLines.length > 1
|
||||
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
|
||||
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
|
||||
if (Buffer.byteLength(cmdAssign, 'utf8') > 650) {
|
||||
// Canonical PTYs limit bytes per physical input line, regardless of write
|
||||
// pacing. Emit bounded quoted pieces inside one command substitution; each
|
||||
if (Buffer.byteLength(cmdAssign, 'utf8') > 350) {
|
||||
// Reserve room below BusyBox's 512-byte editor limit for the assignment,
|
||||
// marker and continuation syntax. Write pacing cannot bypass this limit.
|
||||
// Emit bounded quoted pieces inside one command substitution; each
|
||||
// continuation keeps the marker visible to the terminal echo filter.
|
||||
const writes = [];
|
||||
for (const [index, line] of commandLines.entries()) {
|
||||
@@ -234,7 +266,7 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
for (const character of line) {
|
||||
const quoted = escapePosixSingleQuoted(character);
|
||||
const size = Buffer.byteLength(quoted, 'utf8');
|
||||
if (bytes + size > 512) {
|
||||
if (bytes + size > 300) {
|
||||
writes.push(`printf '%s' '${chunk}'`);
|
||||
chunk = '';
|
||||
bytes = 0;
|
||||
@@ -247,16 +279,27 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
|
||||
}
|
||||
const historyCleanup = buildBashHistoryCleanup(marker);
|
||||
const prefix = `${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"`;
|
||||
const suffix = `${historyCleanup}; (exit $__NCMCP_rc); }`;
|
||||
const separator = prefix.length + suffix.length + 2 > 1000
|
||||
? `; \\\n: '${marker}'; ` : "; ";
|
||||
return `${prefix}${separator}${suffix}`;
|
||||
// OpenWrt builds BusyBox with a 512-byte interactive line editor. Keep
|
||||
// every physical line below that limit, even for short user commands.
|
||||
// Emit a short standalone input marker before the shell reads any PS2
|
||||
// continuation. A leading newline separates it from concurrent PTY echo.
|
||||
// Because this line ends at a real prompt boundary, an immediate-history
|
||||
// hook (PROMPT_COMMAND='history -a') can persist it before the wrapper's
|
||||
// tail cleanup runs — so delete the entry inline while it is still only in
|
||||
// memory (buildInlineHistoryDrop).
|
||||
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'; ${buildInlineHistoryDrop(marker)}\n` + [
|
||||
` : '${marker}'; ${cmdAssign}`,
|
||||
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
|
||||
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
|
||||
historyCleanup,
|
||||
`(exit $__NCMCP_rc); }`,
|
||||
].join(`; \\\n: '${marker}'; `);
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
|
||||
// A live probe leaves its completion marker unterminated to hide the next
|
||||
// prompt. With terminal echo disabled, only the wrapper can end that line.
|
||||
// prompt. POSIX input also hides arbitrary PS2 prompts until a fresh start
|
||||
// marker line; keep the fish behavior controlled by separateStartMarker.
|
||||
const startFormat = separateStartMarker ? "\\n%s\\n" : "%s\\n";
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
@@ -332,7 +375,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
|
||||
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
|
||||
// Without that config the prefix is harmless; it just doesn't
|
||||
// suppress history recording.
|
||||
return ` ${buildPosixWrapperBody(command, marker, startFormat)}\n`;
|
||||
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n")}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,6 +544,7 @@ module.exports = {
|
||||
buildPendingInputClearPrefix,
|
||||
buildWrappedCommand,
|
||||
buildBashHistoryCleanup,
|
||||
buildInlineHistoryDrop,
|
||||
bashHistoryScratchNames,
|
||||
findEndMarker,
|
||||
normalizePtyOutput,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
|
||||
for (const [customization, listHistory] of [
|
||||
[':', 'command builtin history'],
|
||||
['history() { builtin history "$@" | cat; }', 'command builtin history'],
|
||||
['command() { :; }', '\\builtin history'],
|
||||
]) {
|
||||
for (const probe of [false, true]) {
|
||||
test(`separate Bash history lines are removed (${customization}, probe=${probe})`, () => {
|
||||
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nshopt -u cmdhist\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
|
||||
+ (probe ? buildLiveShellProbe(marker) : '')
|
||||
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
|
||||
+ '\nprintf "\\n"\n' + listHistory + '\nexit\n';
|
||||
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
|
||||
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /command_ok/);
|
||||
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
|
||||
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
|
||||
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
|
||||
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const probe of [false, true]) {
|
||||
test(`zsh ignores both logical input commands with HIST_IGNORE_SPACE (probe=${probe})`, (t) => {
|
||||
const input = 'HISTFILE=/dev/null; HISTSIZE=100; setopt HIST_IGNORE_SPACE\necho user_one\n'
|
||||
+ (probe ? buildLiveShellProbe(marker) : '')
|
||||
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
|
||||
+ '\necho user_two\nfc -l 1\nexit\n';
|
||||
const result = spawnSync('zsh', ['-f', '-i'], {
|
||||
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
if (result.error?.code === 'ENOENT') {
|
||||
t.skip('zsh is not installed');
|
||||
return;
|
||||
}
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /command_ok/);
|
||||
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
|
||||
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
|
||||
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
|
||||
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
const { execViaPty } = require('./ptyExec.cjs');
|
||||
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
const reportedCommand = "head -5 /etc/openwrt_release 2>/dev/null; echo '---'; uname -a";
|
||||
const literal = "quote' \\ $HOME `false` " + '\u4e2d\u6587\ud83d\ude42'.repeat(100);
|
||||
const cases = [
|
||||
[reportedCommand, /DISTRIB_RELEASE='24\.10\.6'/, 0],
|
||||
["printf '%s' \"it's quoted\"", "it's quoted", 0],
|
||||
[`cat <<'END_LITERAL'\n${literal}\n\nlast\nEND_LITERAL`, `${literal}\n\nlast`, 0],
|
||||
["printf '%s' '" + 'x'.repeat(340) + "'", 'x'.repeat(340), 0],
|
||||
['printf failed; exit 7', 'failed', 7],
|
||||
];
|
||||
|
||||
test('OpenWrt input lines fit its 512-byte editor, including probe and cleanup', () => {
|
||||
for (const source of [buildLiveShellProbe(marker), ...cases.map(([cmd]) => buildWrappedCommand(cmd, 'posix', marker, true))]) {
|
||||
for (const line of source.trimEnd().split('\n')) {
|
||||
assert.ok(Buffer.byteLength(line) <= 480, `input line has ${Buffer.byteLength(line)} bytes: ${line}`);
|
||||
assert.ok(line.includes(marker), 'each continuation must remain hidden by the terminal echo filter');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Point at a disposable OpenWrt 24.10.6 Dropbear instance with blank root
|
||||
// password enabled (-B). No existing router configuration is read or modified.
|
||||
test('real OpenWrt Dropbear PTY executes commands with and without the live probe', {
|
||||
skip: !process.env.NETCATTY_OPENWRT_SSH_PORT,
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const { Client } = require('ssh2');
|
||||
const client = new Client();
|
||||
await new Promise((resolve, reject) => client.once('ready', resolve).once('error', reject).connect({
|
||||
host: '127.0.0.1', port: Number(process.env.NETCATTY_OPENWRT_SSH_PORT), username: 'root', password: '',
|
||||
}));
|
||||
try {
|
||||
for (const probeLiveShell of [false, true]) {
|
||||
const stream = await new Promise((resolve, reject) => client.shell({ term: 'xterm', cols: 240, rows: 30 }, (error, value) => error ? reject(error) : resolve(value)));
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
const timer = setTimeout(() => reject(new Error('OpenWrt prompt missing')), 5000);
|
||||
const onData = data => {
|
||||
output += data;
|
||||
if (output.includes(':~# ')) {
|
||||
clearTimeout(timer);
|
||||
stream.removeListener('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
stream.on('data', onData);
|
||||
});
|
||||
for (const [command, expected, exitCode] of cases) {
|
||||
const result = await execViaPty(stream, command, {
|
||||
shellKind: 'posix', probeLiveShell, timeoutMs: 3000,
|
||||
});
|
||||
assert.equal(result.exitCode, exitCode, JSON.stringify({ probeLiveShell, result }));
|
||||
if (typeof expected === 'string') assert.equal(result.stdout, expected);
|
||||
else assert.match(result.stdout, expected);
|
||||
}
|
||||
} finally { stream.close(); }
|
||||
}
|
||||
} finally { client.end(); }
|
||||
});
|
||||
@@ -60,3 +60,68 @@ test('completed delivery still has a bounded wait for a missing probe reply', as
|
||||
assert.match(result.error, /Command startup timed out/);
|
||||
assert.ok(writes.includes('\x03'));
|
||||
});
|
||||
|
||||
for (const cancel of [false, true]) {
|
||||
test(`backpressure pauses input until ${cancel ? 'cancellation' : 'drain'}`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
let blocked = true;
|
||||
pty.write = (data) => { writes.push(data); return !blocked; };
|
||||
const job = startPtyJob(pty, 'echo 😀', { shellKind: 'posix', bastionKeystrokes: true, timeoutMs: 500 });
|
||||
assert.equal(writes.length, 1);
|
||||
t.mock.timers.tick(300);
|
||||
assert.equal(writes.length, 1, 'no more input is queued while blocked');
|
||||
if (cancel) {
|
||||
job.cancel();
|
||||
const count = writes.length;
|
||||
pty.emit('drain');
|
||||
t.mock.timers.tick(30);
|
||||
assert.equal(writes.length, count, 'cancelled input never resumes');
|
||||
assert.equal(pty.listenerCount('drain'), 0);
|
||||
pty.emit('close');
|
||||
assert.match((await job.resultPromise).error, /Cancelled/);
|
||||
} else {
|
||||
blocked = false;
|
||||
pty.emit('drain');
|
||||
t.mock.timers.tick(30);
|
||||
while (!writes.join('').endsWith('\n')) t.mock.timers.tick(30);
|
||||
assert.ok(writes.every((value) => Array.from(value).length === 1));
|
||||
assert.ok(writes.includes('😀'));
|
||||
pty.emit('data', `${job.marker}_S\nOK\n${job.marker}_E:0\n`);
|
||||
assert.equal((await job.resultPromise).exitCode, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a stalled drain has a bounded wait and releases its listener', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pty = new EventEmitter();
|
||||
pty.write = () => false;
|
||||
const job = startPtyJob(pty, 'echo blocked', { shellKind: 'posix', timeoutMs: 500 });
|
||||
t.mock.timers.tick(500);
|
||||
assert.match((await job.resultPromise).error, /input timed out waiting for drain/);
|
||||
assert.equal(pty.listenerCount('drain'), 0);
|
||||
});
|
||||
|
||||
for (const shellKind of ['powershell', 'cmd']) {
|
||||
test(`bastion ${shellKind} input yields without a live probe and stops on cancel`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = (data) => { writes.push(data); };
|
||||
const job = startPtyJob(pty, `echo ${'x'.repeat(12000)}`, {
|
||||
shellKind, bastionKeystrokes: true, timeoutMs: 500,
|
||||
});
|
||||
assert.equal(writes.length, 128);
|
||||
t.mock.timers.tick(30);
|
||||
assert.equal(writes.length, 256);
|
||||
assert.ok(writes.every((value) => Array.from(value).length === 1));
|
||||
job.cancel();
|
||||
const count = writes.length;
|
||||
t.mock.timers.tick(30);
|
||||
assert.equal(writes.length, count);
|
||||
pty.emit('close');
|
||||
assert.match((await job.resultPromise).error, /Cancelled/);
|
||||
});
|
||||
}
|
||||
@@ -60,6 +60,21 @@ function isWindowsOpenSshRemote(remoteSshVersion) {
|
||||
return /openssh_for_windows/i.test(String(remoteSshVersion || ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this SSH banner identifies a bastion that binds the transport to the
|
||||
* first interactive session and tears it down when a second channel opens
|
||||
* (BHostSSH, QiZhi TERM-SSHD, and other menu-driven jump hosts). For these the
|
||||
* exec-channel login-shell probe must be skipped so approving an AI command
|
||||
* does not disconnect the session (#3146).
|
||||
*/
|
||||
function remoteDisallowsExecChannelProbe(remoteSshVersion) {
|
||||
const software = String(remoteSshVersion || "").trim();
|
||||
if (!software) return false;
|
||||
if (/BHostSSH/i.test(software)) return true;
|
||||
if (/TERM-SSHD/i.test(software)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a remote shell path / basename to a wrapper kind.
|
||||
* Returns null when we cannot classify (leave session.shellKind unset).
|
||||
@@ -181,9 +196,16 @@ function createSshConnExecProbe(conn) {
|
||||
*/
|
||||
function createSessionExecProbe(session) {
|
||||
if (!session || typeof session !== "object") return null;
|
||||
// A session-level probe (mosh/et companion stats connection) is independent
|
||||
// of the interactive transport, so it is safe even on bastions.
|
||||
if (typeof session._shellKindExecProbe === "function") {
|
||||
return (command, timeoutMs) => session._shellKindExecProbe(command, timeoutMs);
|
||||
}
|
||||
// Bastions bind the interactive transport to the first session and tear it
|
||||
// down when a second SSH channel opens. Our exec-channel login-shell probe
|
||||
// would disconnect them before the command ever runs, so skip it and let the
|
||||
// PTY live shell probe (probeLiveShell) determine the wrapper (#3146).
|
||||
if (remoteDisallowsExecChannelProbe(session.remoteSshVersion)) return null;
|
||||
return (
|
||||
createSshConnExecProbe(session.conn)
|
||||
|| createSshConnExecProbe(session.sshClient)
|
||||
@@ -447,6 +469,7 @@ module.exports = {
|
||||
WINDOWS_NO_DEFAULT_SHELL_MARKER,
|
||||
isConfirmedShellKind,
|
||||
isWindowsOpenSshRemote,
|
||||
remoteDisallowsExecChannelProbe,
|
||||
classifyShellKindFromRemotePath,
|
||||
buildRemoteLoginShellProbeCommand,
|
||||
buildRemoteWindowsLoginShellProbeCommand,
|
||||
|
||||
@@ -13,6 +13,7 @@ const {
|
||||
parseRemoteLoginShellProbeOutput,
|
||||
parseRemoteWindowsLoginShellProbeOutput,
|
||||
isWindowsOpenSshRemote,
|
||||
remoteDisallowsExecChannelProbe,
|
||||
createSshConnExecProbe,
|
||||
createSessionExecProbe,
|
||||
ensureSessionShellKind,
|
||||
@@ -785,6 +786,40 @@ test("createSessionExecProbe prefers session.conn over companions", () => {
|
||||
assert.equal(createSessionExecProbe({}), null);
|
||||
});
|
||||
|
||||
test("remoteDisallowsExecChannelProbe flags bastion banners that drop a second channel (#3146)", () => {
|
||||
assert.equal(remoteDisallowsExecChannelProbe("BHostSSH_7.0"), true);
|
||||
assert.equal(remoteDisallowsExecChannelProbe("TERM-SSHD"), true);
|
||||
assert.equal(remoteDisallowsExecChannelProbe("OpenSSH_9.6"), false);
|
||||
assert.equal(remoteDisallowsExecChannelProbe("dropbear_2022.83"), false);
|
||||
assert.equal(remoteDisallowsExecChannelProbe("OpenSSH_for_Windows_9.5"), false);
|
||||
assert.equal(remoteDisallowsExecChannelProbe(undefined), false);
|
||||
assert.equal(remoteDisallowsExecChannelProbe(""), false);
|
||||
});
|
||||
|
||||
test("createSessionExecProbe skips the exec channel on bastions so AI exec does not disconnect (#3146)", () => {
|
||||
const session = {
|
||||
remoteSshVersion: "BHostSSH_7.0",
|
||||
conn: { exec() {} },
|
||||
};
|
||||
assert.equal(createSessionExecProbe(session), null);
|
||||
});
|
||||
|
||||
test("ensureSessionShellKind does not exec-probe bastions (#3146)", async () => {
|
||||
let execCalls = 0;
|
||||
const session = {
|
||||
protocol: "ssh",
|
||||
remoteSshVersion: "BHostSSH_7.0",
|
||||
conn: {
|
||||
exec() {
|
||||
execCalls += 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
const kind = await ensureSessionShellKind(session);
|
||||
assert.equal(kind, undefined);
|
||||
assert.equal(execCalls, 0, "bastion must never open a second SSH channel");
|
||||
});
|
||||
|
||||
// --- Real fish binary: wrapper must produce markers (issue #1854) -----------
|
||||
|
||||
function resolveFishBinary() {
|
||||
|
||||
@@ -18,6 +18,7 @@ const { appendVaultAgentGuidance } = require("../shared/vaultAgentGuidance.cjs")
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
const { createExternalMcpController } = require("./externalMcpController.cjs");
|
||||
const { getCliLauncherPath, TOOL_CLI_DISCOVERY_ENV_VAR } = require("../cli/discoveryPath.cjs");
|
||||
const { injectCliTransportEnv } = require("../cli/cliChatSession.cjs");
|
||||
const { getExternalMcpDiscoveryFilePath } = require("../cli/externalMcpDiscoveryPath.cjs");
|
||||
const {
|
||||
scanUserSkills,
|
||||
@@ -148,7 +149,7 @@ function buildExternalAgentSystemContext({ mode, chatSessionId, defaultTargetSes
|
||||
? `For this chat session, the exact Netcatty CLI command prefix is \`${cliCommandPrefix}\`.`
|
||||
: "Use the exact Netcatty CLI command prefix provided by the host application for this chat session. ";
|
||||
const scopeHint = chatSessionId
|
||||
? `Always include \`--chat-session ${chatSessionId}\` on every Netcatty CLI call so you stay inside the current scoped session set. `
|
||||
? `The current Netcatty chat session is already bound in this process via the host environment. Keep using \`--session <session-id>\` to select a terminal. `
|
||||
: "";
|
||||
const defaultTargetHint = defaultTargetSession
|
||||
? (
|
||||
@@ -159,13 +160,13 @@ function buildExternalAgentSystemContext({ mode, chatSessionId, defaultTargetSes
|
||||
`protocol="${defaultTargetSession.protocol || ""}", ` +
|
||||
`connected=${defaultTargetSession.connected !== false}. ` +
|
||||
(defaultTargetSession.connected !== false
|
||||
? `For routine requests that do not mention another session or host, use this default target directly and prefer \`${cliCommandPrefix} session --session ${defaultTargetSession.sessionId} --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` as the first call instead of starting with \`env\` discovery. Only run \`env\` when the user explicitly points to another session (for example with @), when the task is ambiguous, or when that direct session lookup fails. `
|
||||
? `For routine requests that do not mention another session or host, use this default target directly and prefer \`${cliCommandPrefix} session --session ${defaultTargetSession.sessionId} --json\` as the first call instead of starting with \`env\` discovery. Only run \`env\` when the user explicitly points to another session (for example with @), when the task is ambiguous, or when that direct session lookup fails. `
|
||||
: `This default target is currently not connected, so do not execute against it directly. Fall back to \`env\` / \`session\` lookup if the user may want another available session. `)
|
||||
)
|
||||
: "";
|
||||
const discoveryHint = defaultTargetSession?.connected !== false
|
||||
? `If you do need discovery because the task is ambiguous or points to another session, start with \`${cliCommandPrefix} env --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` to discover available sessions and their IDs. `
|
||||
: `Start with \`${cliCommandPrefix} env --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` to discover available sessions and their IDs. `;
|
||||
? `If you do need discovery because the task is ambiguous or points to another session, start with \`${cliCommandPrefix} env --json\` to discover available sessions and their IDs. `
|
||||
: `Start with \`${cliCommandPrefix} env --json\` to discover available sessions and their IDs. `;
|
||||
|
||||
return (
|
||||
`${userSkillsPreamble}` +
|
||||
@@ -176,24 +177,24 @@ function buildExternalAgentSystemContext({ mode, chatSessionId, defaultTargetSes
|
||||
`${defaultTargetHint}` +
|
||||
`Use Skills + CLI instead of the "netcatty-remote-hosts" MCP server for Netcatty session access. ` +
|
||||
`Use the local shell only to invoke Netcatty CLI commands. Do not use local shell or filesystem tools for unrelated local-machine work. ` +
|
||||
`For files explicitly attached by the user, call \`${cliCommandPrefix} attachment list --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`, then read the selected file with \`${cliCommandPrefix} attachment read --filename <filename> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`. ` +
|
||||
`For files explicitly attached by the user, call \`${cliCommandPrefix} attachment list --json\`, then read the selected file with \`${cliCommandPrefix} attachment read --filename <filename> --json\`. ` +
|
||||
`First classify the task: remote command execution tasks go through \`exec\`, while remote file or directory tasks go through \`sftp\`. If the user explicitly says to avoid shell or \`exec\`, do not use \`exec\`. Treat \`exec\` as the short-command path only: use it only for commands expected to finish within about 60 seconds. For builds, scans, watch mode, tail-following, ping, or anything likely to exceed that budget or stream output for an extended period, do not use plain \`exec\`; use the long-running job commands instead. ` +
|
||||
`${discoveryHint}` +
|
||||
`After choosing a target session ID, call \`${cliCommandPrefix} session --session <id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` before executing anything. Do not infer protocol, shell type, device type, or connection readiness from the \`env\` result alone when you are about to run a command. ` +
|
||||
`For remote file operations, use the Netcatty SFTP CLI surface instead of trying to reconstruct SSH credentials or open your own SSH/SFTP connection, but only when the chosen session is SSH-backed and connected. After the required \`session --session <id>\` confirmation step, inspect the reported protocol, shell type, device type, and connected state before picking a file-operation path. For SSH-backed sessions, prefer one-off commands such as \`${cliCommandPrefix} sftp list --session <id> --remote-path <remote-path> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`, \`${cliCommandPrefix} sftp read --session <id> --remote-path <remote-path> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`, \`${cliCommandPrefix} sftp write --session <id> --remote-path <remote-path> --content <text> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`, \`${cliCommandPrefix} sftp download --session <id> --remote-path <remote-path> --local-path <local-path> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`, or \`${cliCommandPrefix} sftp upload --session <id> --local-path <local-path> --remote-path <remote-path> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`. For local sessions, use normal local filesystem tools instead of Netcatty SFTP. For Mosh, Telnet, serial/raw, or network-device sessions, do not call SFTP; use a real SSH session, vendor CLI commands, or tell the user that the requested file transfer is unsupported on that transport. ` +
|
||||
`After choosing a target session ID, call \`${cliCommandPrefix} session --session <id> --json\` before executing anything. Do not infer protocol, shell type, device type, or connection readiness from the \`env\` result alone when you are about to run a command. ` +
|
||||
`For remote file operations, use the Netcatty SFTP CLI surface instead of trying to reconstruct SSH credentials or open your own SSH/SFTP connection, but only when the chosen session is SSH-backed and connected. After the required \`session --session <id>\` confirmation step, inspect the reported protocol, shell type, device type, and connected state before picking a file-operation path. For SSH-backed sessions, prefer one-off commands such as \`${cliCommandPrefix} sftp list --session <id> --remote-path <remote-path> --json\`, \`${cliCommandPrefix} sftp read --session <id> --remote-path <remote-path> --json\`, \`${cliCommandPrefix} sftp write --session <id> --remote-path <remote-path> --content <text> --json\`, \`${cliCommandPrefix} sftp download --session <id> --remote-path <remote-path> --local-path <local-path> --json\`, or \`${cliCommandPrefix} sftp upload --session <id> --local-path <local-path> --remote-path <remote-path> --json\`. For local sessions, use normal local filesystem tools instead of Netcatty SFTP. For Mosh, Telnet, serial/raw, or network-device sessions, do not call SFTP; use a real SSH session, vendor CLI commands, or tell the user that the requested file transfer is unsupported on that transport. ` +
|
||||
`Keep local and remote path semantics strict: \`--remote-path\` always refers to the remote host, while \`--local-path\` always refers to the local machine running Netcatty. If the user asks to download a file to a local destination such as \`/tmp\`, \`~/Downloads\`, or a desktop path, use \`sftp download\`, not \`sftp read\` or \`sftp write\`. If the user asks to create or modify a file on the remote host, use \`sftp write\` or another remote SFTP operation, not \`sftp download\`. ` +
|
||||
`If you need to create or update a small text file with known content on the remote host, prefer \`${cliCommandPrefix} sftp write ...\` directly. Use \`sftp upload\` only when a real local file already exists and must be transferred to the remote host. Do not create temporary local files just to upload text that could be sent with \`sftp write\`. ` +
|
||||
`Keep SFTP usage one-off and explicit: every \`sftp\` command should include both \`--session <id>\` and \`--chat-session ${chatSessionId || "<chat-session-id>"}\`. Do not open reusable SFTP handles or use \`--sftp <id>\`. ` +
|
||||
`Keep SFTP usage one-off and explicit: every \`sftp\` command should include \`--session <id>\`. Do not open reusable SFTP handles or use \`--sftp <id>\`. ` +
|
||||
`Run Netcatty CLI calls strictly one at a time. Do not issue concurrent or background Netcatty CLI commands for the same chat session, and always wait for each call to finish before starting the next one. ` +
|
||||
`For simple read-only requests such as hostname, IP address, CPU info, memory info, disk usage, pwd, whoami, uname, or process checks, use the shortest possible path: one \`env\`, one \`session\`, then one \`exec\`. Prefer a single straightforward command over creating helper scripts or multi-step shell orchestration. ` +
|
||||
`For long-running command tasks, start them with \`${cliCommandPrefix} job-start --session <id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""} -- <command>\`, then use \`${cliCommandPrefix} job-poll --job <job-id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` to fetch incremental output, and \`${cliCommandPrefix} job-stop --job <job-id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` if the user asks to stop them. Do not poll aggressively; wait roughly 30 seconds between polls unless the output clearly justifies checking sooner. ` +
|
||||
`For long-running command tasks, start them with \`${cliCommandPrefix} job-start --session <id> --json -- <command>\`, then use \`${cliCommandPrefix} job-poll --job <job-id> --json\` to fetch incremental output, and \`${cliCommandPrefix} job-stop --job <job-id> --json\` if the user asks to stop them. Do not poll aggressively; wait roughly 30 seconds between polls unless the output clearly justifies checking sooner. ` +
|
||||
`For those simple read-only requests, do not spend time reading extra files, designing scripts, or narrating a plan unless the first direct command fails or the session metadata shows a special device type. ` +
|
||||
`Do not create temporary scripts, JSON post-processing scripts, or extra wrapper commands unless the task genuinely requires logic that cannot fit cleanly in one direct command. ` +
|
||||
`Avoid shell command substitution such as \`$()\` and backticks, because Netcatty safety policy may block them. Prefer straightforward command chains such as \`hostname && hostname -I && lscpu\`. ` +
|
||||
`Avoid wrapping simple commands in \`sh -c\`, \`bash -c\`, or similar shell launcher patterns unless the task genuinely requires shell parsing that cannot be expressed as a direct command. ` +
|
||||
`Do not spend time narrating intent before every CLI call for routine read-only checks. Execute the minimal command sequence and then report the result. ` +
|
||||
`Only after that confirmation step should you call \`${cliCommandPrefix} exec --session <id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""} -- <command>\` for command execution. ` +
|
||||
`If the user stops the run or asks to abort outstanding Netcatty work, use \`${cliCommandPrefix} cancel --chat-session ${chatSessionId || "<chat-session-id>"} --json\`, and use \`resume\` to re-enable execs for that scope if needed. ` +
|
||||
`Only after that confirmation step should you call \`${cliCommandPrefix} exec --session <id> --json -- <command>\` for command execution. ` +
|
||||
`If the user stops the run or asks to abort outstanding Netcatty work, use \`${cliCommandPrefix} cancel --json\`, and use \`resume\` to re-enable execs for that scope if needed. ` +
|
||||
`For serial/raw sessions and network device sessions (deviceType: network), commands are sent as-is without shell wrapping and exit codes are unavailable. Use vendor CLI commands directly.]`
|
||||
);
|
||||
}
|
||||
@@ -430,12 +431,11 @@ function init(deps) {
|
||||
}
|
||||
}
|
||||
|
||||
function withCliDiscoveryEnv(env) {
|
||||
if (!cliDiscoveryFilePath) return env;
|
||||
return {
|
||||
...env,
|
||||
[TOOL_CLI_DISCOVERY_ENV_VAR]: cliDiscoveryFilePath,
|
||||
};
|
||||
function withCliDiscoveryEnv(env, chatSessionId) {
|
||||
return injectCliTransportEnv(env, {
|
||||
discoveryFilePath: cliDiscoveryFilePath,
|
||||
chatSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,9 @@ test("buildExternalAgentSystemContext (skills mode) routes attachments through N
|
||||
chatSessionId: "chat-1",
|
||||
});
|
||||
|
||||
assert.match(context, /attachment list --json --chat-session chat-1/i);
|
||||
assert.match(context, /attachment read --filename <filename> --json --chat-session chat-1/i);
|
||||
assert.match(context, /attachment list --json/i);
|
||||
assert.match(context, /attachment read --filename <filename> --json/i);
|
||||
assert.match(context, /already bound in this process via the host environment/i);
|
||||
assert.doesNotMatch(context, /--chat-session/);
|
||||
assert.match(context, /Use the local shell only to invoke Netcatty CLI commands/i);
|
||||
});
|
||||
@@ -47,9 +47,10 @@ function loadBridgeWithMocks(options = {}) {
|
||||
cleanupScopedMetadata: async () => {},
|
||||
cleanup() {},
|
||||
},
|
||||
"../cli/discoveryPath.cjs": {
|
||||
"../cli/discoveryPath.cjs": {
|
||||
getCliLauncherPath: () => "/tmp/netcatty-tool-cli",
|
||||
TOOL_CLI_DISCOVERY_ENV_VAR: "NETCATTY_TOOL_CLI_DISCOVERY_FILE",
|
||||
TOOL_CLI_CHAT_SESSION_ENV_VAR: "NETCATTY_CLI_CHAT_SESSION_ID",
|
||||
},
|
||||
"./ai/userSkills.cjs": {
|
||||
scanUserSkills: async () => ({ readyCount: 0, warningCount: 0, skills: [], warnings: [] }),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// runs under `with (ctx)` where bare `require` resolves to ctx.require
|
||||
// (based in electron/bridges/). Requiring here keeps the path unambiguous.
|
||||
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
|
||||
const { ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
|
||||
const { remoteDisallowsExecChannelProbe, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
|
||||
|
||||
function getWorkerExecutionMeta(mcpServerBridge, sessionId, chatSessionId) {
|
||||
return mcpServerBridge.getSessionMeta?.(sessionId, chatSessionId) || {};
|
||||
@@ -177,6 +177,7 @@ function registerCattyExecHandlers(ctx) {
|
||||
shellKind: session.shellKind,
|
||||
loginShellHint: session._loginShellKind,
|
||||
probeLiveShell: true,
|
||||
bastionKeystrokes: remoteDisallowsExecChannelProbe(session.remoteSshVersion),
|
||||
onProbeAborted: (marker) => {
|
||||
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
|
||||
safeSend(contents, "netcatty:data", { sessionId, data: `${marker}_R\n` });
|
||||
|
||||
@@ -799,7 +799,8 @@ function buildCodebuddyHooks(
|
||||
decision: "block",
|
||||
reason:
|
||||
"Only Netcatty CLI commands are allowed in Skills mode. " +
|
||||
"Use the netcatty-tool-cli command prefix provided by the host.",
|
||||
"Use the netcatty-tool-cli command prefix provided by the host. " +
|
||||
"Do not pass --chat-session or override NETCATTY_CLI_CHAT_SESSION_ID; the host already bound this process.",
|
||||
};
|
||||
}
|
||||
return { continue: true };
|
||||
|
||||
@@ -603,7 +603,8 @@ test("buildCodebuddyHooks blocks non-Netcatty Bash commands in skills mode", asy
|
||||
decision: "block",
|
||||
reason:
|
||||
"Only Netcatty CLI commands are allowed in Skills mode. " +
|
||||
"Use the netcatty-tool-cli command prefix provided by the host.",
|
||||
"Use the netcatty-tool-cli command prefix provided by the host. " +
|
||||
"Do not pass --chat-session or override NETCATTY_CLI_CHAT_SESSION_ID; the host already bound this process.",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -611,7 +612,7 @@ test("buildCodebuddyHooks blocks non-Netcatty Bash commands in skills mode", asy
|
||||
{
|
||||
tool_name: "Bash",
|
||||
tool_input: {
|
||||
command: "netcatty-tool-cli session --session s1 --chat-session c1 --json",
|
||||
command: "netcatty-tool-cli session --session s1 --json",
|
||||
},
|
||||
tool_use_id: "tu-cli",
|
||||
},
|
||||
@@ -620,6 +621,34 @@ test("buildCodebuddyHooks blocks non-Netcatty Bash commands in skills mode", asy
|
||||
),
|
||||
{ continue: true },
|
||||
);
|
||||
assert.equal(
|
||||
(await preHook(
|
||||
{
|
||||
tool_name: "Bash",
|
||||
tool_input: {
|
||||
command: "netcatty-tool-cli session --session s1 --chat-session c1 --json",
|
||||
},
|
||||
tool_use_id: "tu-legacy",
|
||||
},
|
||||
"tu-legacy",
|
||||
{ signal: new AbortController().signal },
|
||||
)).decision,
|
||||
"block",
|
||||
);
|
||||
assert.equal(
|
||||
(await preHook(
|
||||
{
|
||||
tool_name: "Bash",
|
||||
tool_input: {
|
||||
command: "NETCATTY_CLI_CHAT_SESSION_ID=other netcatty-tool-cli env --json",
|
||||
},
|
||||
tool_use_id: "tu-env-override",
|
||||
},
|
||||
"tu-env-override",
|
||||
{ signal: new AbortController().signal },
|
||||
)).decision,
|
||||
"block",
|
||||
);
|
||||
assert.equal(
|
||||
(await preHook(
|
||||
{
|
||||
|
||||
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user