Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 fa8412d70c fix: rebase queued snippet saves onto latest vault snapshot
Locking updateSnippets alone still wrote a pre-delete full array, so a
popup bulk-delete could be resurrected (or a storage-event version bump
could cancel the local edit). Rebase under the vault lock and supersede
only newer local writers via snippetsWriteOwnerRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 14:48:17 +00:00
Cursor Agentand陈大猫 7bb4e96c55 Merge remote-tracking branch 'origin/main' into cursor/fix-snippets-bulk-delete-lock-e2cf
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 14:44:16 +00:00
Cursor Agentand陈大猫 7a66161355 fix: serialize snippet saves with bulk-delete vault lock
updateSnippets wrote STORAGE_KEY_SNIPPETS without withVaultImportLock, so a
concurrent edit could land between bulk-delete's snapshot read and journal
commit and be discarded. Queue ordinary snippet writes on the shared lock and
include them in waitForPendingVaultWrites.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 14:36:20 +00:00
Netcatty-Bot c9bc39d1a1 feat(sftp): open current path in linked terminal (#2825) 2026-08-09 22:00:12 +08:00
Netcatty-Bot 03acd48585 fix(terminal): commit CJK punctuation from IME insertText (#2834) 2026-08-09 21:38:32 +08:00
Netcatty-Bot fd295e7529 fix(ssh): open Copy Tab shell before PID discovery on rate-limited bastions (#2808) 2026-08-09 21:16:45 +08:00
Netcatty-Bot f4e14afc9d fix(#2839): automated Cursor CLI fix (#2840) 2026-08-09 20:19:26 +08:00
netcatty-bot edfe5ba517 fix: address Codex review on PR #2828 2026-08-09 05:07:53 +00:00
netcatty-bot 7bce9e55e3 fix: address Codex review on PR #2828 2026-08-09 04:41:03 +00:00
Netcatty-Bot 93dce066fd fix(sftp): apply search filter in tree view (#2829) 2026-08-09 12:32:43 +08:00
Netcatty-Bot 49b88241db fix(autocomplete): match Chinese snippet titles via pinyin and pre-echo input (#2814) 2026-08-09 12:27:07 +08:00
netcatty-bot 0cb692b17e fix: address Codex review on PR #2828 2026-08-09 04:07:09 +00:00
netcatty-bot 337191382a fix: address Codex review on PR #2828 2026-08-09 03:26:42 +00:00
netcatty-bot 18b19b1ccc fix: address Codex review on PR #2828 2026-08-09 02:45:25 +00:00
Cursor Agentand陈大猫 ce2611d868 fix(scripts): hoist bulk-delete confirm outside toolbar popover
Portalled Dialog focus dismissed the compact Scripts popover and cleared
pendingDeleteIds before confirm. Own the confirm in TerminalToolbar and
defer via onBulkDeleteRequest when nested in that popover.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-09 02:19:33 +00:00
netcatty-bot a0dce456fa fix: address Codex review on PR #2828 2026-08-08 21:17:25 +00:00
Cursor Agentand陈大猫 2c7c0f9fc4 fix(scripts): keep delete refs current within the same tick
Update snippetsRef/hostsRef immediately after applying a delete so a
same-tick second event cannot rebuild vault state from the pre-delete
snapshot and resurrect removed snippets or host bindings.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:26:20 +00:00
Cursor Agentand陈大猫 db2152dcfa fix(scripts): harden delete handler against stale vault state
Read snippets/hosts from refs in the shared delete listener so rapid
confirms cannot resurrect already-removed items, and clear pending bulk
delete when the scripts panel hides so the confirm dialog does not
resurface after a mid-prompt hide.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:25:27 +00:00
Cursor Agentand陈大猫 877df24f59 fix(scripts): confirm bulk delete and clear host bindings
Scripts side-panel mass-delete skipped confirmation and, when
onSnippetsChange was wired, filtered snippets without clearing host
login/connect script bindings. Confirm first, always dispatch the shared
delete event, and apply deletes via deleteSelectedSnippetsFromVault.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-08 07:24:27 +00:00
netcatty-bot 40d114eab6 fix(#2826): automated Cursor CLI fix 2026-08-08 02:29:37 +00:00
61 changed files with 3858 additions and 256 deletions

No files matched your search

@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
test("snippets delete handler cleans host bindings via deleteSelectedSnippets", () => {
assert.match(source, /collectSnippetDeleteIds/);
assert.match(source, /deleteSelectedSnippets/);
assert.match(
source,
/netcatty:snippets:delete[\s\S]*void deleteSelectedSnippets\(ids\)/,
);
assert.doesNotMatch(
source,
/updateSnippets\(snippets\.filter\(\(s\) => !ids\.has\(s\.id\)\)\)/,
);
});
test("snippets delete handler uses vault live snapshot instead of component refs", () => {
// Component-level snippetsRef/hostsRef lag concurrent vault mutations that
// already advanced useVaultState refs before React re-renders AppSideEffects.
// Deletion must go through the vault hook's atomic live-snapshot path.
assert.match(source, /deleteSelectedSnippets,/);
assert.doesNotMatch(source, /snippetsRef\.current\s*=\s*snippets/);
assert.doesNotMatch(
source,
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current/,
);
});
+13 -7
View File
@@ -82,6 +82,7 @@ import { useDiscoveredShells, resolveShellSetting } from '../../lib/useDiscovere
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider';
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
@@ -184,6 +185,7 @@ export function AppSideEffects() {
updateHosts,
updateKeys,
updateSnippets,
deleteSelectedSnippets,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
@@ -1600,18 +1602,22 @@ export function AppSideEffects() {
};
}, [handleOpenSettings, t]);
// Delete-from-sidepanel plumbing: ScriptsSidePanel's right-click menu
// dispatches `netcatty:snippets:delete` with the snippet id. Handled here
// (rather than in QuickAddSnippetDialog) because delete needs no UI.
// Delete-from-sidepanel plumbing: ScriptsSidePanel dispatches
// `netcatty:snippets:delete` with `id` (single) or `ids` (bulk). Handled
// here (rather than in QuickAddSnippetDialog) because delete needs no UI.
// Goes through useVaultState.deleteSelectedSnippets so login/connect script
// bindings clear with the snippets (SnippetsManager parity) against the
// latest persisted vault snapshot under the shared cross-window lock.
useEffect(() => {
const handler = (e: Event) => {
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
if (!id) return;
updateSnippets(snippets.filter((s) => s.id !== id));
const detail = (e as CustomEvent<{ id?: string; ids?: string[] }>).detail;
const ids = collectSnippetDeleteIds(detail);
if (ids.size === 0) return;
void deleteSelectedSnippets(ids);
};
window.addEventListener('netcatty:snippets:delete', handler);
return () => window.removeEventListener('netcatty:snippets:delete', handler);
}, [snippets, updateSnippets]);
}, [deleteSelectedSnippets]);
const handleEndSessionDrag = useCallback(() => {
setDraggingSessionId(null);
+1
View File
@@ -325,6 +325,7 @@ export const enVaultMessages: Messages = {
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.locatePathInTerminal': 'Open path in terminal',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
+1
View File
@@ -361,6 +361,7 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
'sftp.goUp': 'Наверх',
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
'sftp.locatePathInTerminal': 'Открыть путь в терминале',
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
+1
View File
@@ -924,6 +924,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.locatePathInTerminal': '定位路径到终端',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
+1
View File
@@ -924,6 +924,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.transfers.dragToResize': '拖拽調整高度',
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.locatePathInTerminal': '定位路徑到終端',
'sftp.followTerminalCwd': '追隨終端目錄',
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./useVaultState.ts", import.meta.url), "utf8");
test("deleteSelectedSnippets merges into persisted vault under the shared lock", () => {
assert.match(source, /const deleteSelectedSnippets = useCallback\(async/);
assert.match(
source,
/deleteSelectedSnippets[\s\S]*withVaultImportLock\("vault"/,
);
assert.match(
source,
/deleteSelectedSnippetsFromVault\(\s*latestSnippets\s*,\s*latestHosts\s*,\s*selectedSnippetIds,?\s*\)/,
);
assert.match(
source,
/deleteSelectedSnippets[\s\S]*commitPluginImporterTransaction\(localStorageAdapter, \[[\s\S]*STORAGE_KEY_HOSTS[\s\S]*STORAGE_KEY_SNIPPETS/,
);
// Persistence rejection must be caught: callers void the promise after the
// confirm dialog closes, so an uncaught throw would leave no retry feedback.
assert.match(
source,
/deleteSelectedSnippets[\s\S]*try \{\s*commitPluginImporterTransaction[\s\S]*catch \{[\s\S]*notify\.error\([\s\S]*Snippets could not be deleted/,
);
// Must not rebuild hosts from a per-window in-memory snapshot (popup race).
assert.doesNotMatch(
source,
/deleteSelectedSnippetsFromVault\(\s*snippetsRef\.current\s*,\s*hostsRef\.current/,
);
// Paired writes must not publish in-memory state if only one key lands.
assert.doesNotMatch(
source,
/deleteSelectedSnippets[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_HOSTS, encryptedHosts\)[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS/,
);
});
test("updateSnippets disk writes take the shared vault lock", () => {
// Unlocked snippet saves can interleave with bulk-delete's journaled commit
// and discard concurrent edits. Ordinary writers must queue on the same lock
// and be visible to waitForPendingVaultWrites.
assert.match(
source,
/const updateSnippets = useCallback\(\(data: Snippet\[\]\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
);
assert.match(
source,
/snippetsWritePendingRef\.current = writePromise/,
);
assert.match(
source,
/waitForPendingVaultWrites[\s\S]*snippetsWritePendingRef\.current/,
);
assert.doesNotMatch(
source,
/const updateSnippets = useCallback\(\(data: Snippet\[\]\) => \{[\s\S]*setSnippets\(cleaned\);\s*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, cleaned\);\s*\}, \[\]\);/,
);
});
test("updateSnippets rebases onto the latest persisted snapshot under the lock", () => {
// Web Locks serialize writers but do not merge snapshots. A popup bulk-delete
// that lands while a main-window save is queued must not be resurrected.
assert.match(
source,
/rebaseSnippetVaultWrite\(\{\s*base,\s*ours: cleaned,\s*theirs: latest\s*\}\)/,
);
assert.match(
source,
/snippetsWriteOwnerRef\.current !== ver/,
);
});
+133 -5
View File
@@ -64,6 +64,10 @@ import {
type ConnectionLogTerminalDataMap,
} from "../../domain/connectionLogTerminalData";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import {
deleteSelectedSnippetsFromVault,
rebaseSnippetVaultWrite,
} from "../../domain/snippetSelection";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
publishConnectionLogsSnapshot,
@@ -290,12 +294,14 @@ export const useVaultState = () => {
const customGroupsRef = useRef<string[]>([]);
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const snippetsRef = useRef<Snippet[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
customGroupsRef.current = customGroups;
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
snippetsRef.current = snippets;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
@@ -307,6 +313,10 @@ export const useVaultState = () => {
const identitiesWriteVersion = useRef(0);
const proxyProfilesWriteVersion = useRef(0);
const snippetsWriteVersion = useRef(0);
// Tracks the latest local updateSnippets schedule. Storage events also bump
// snippetsWriteVersion (to invalidate naive writers), so queued saves must
// key supersede checks off this owner instead of that shared counter.
const snippetsWriteOwnerRef = useRef(0);
const customGroupsWriteVersion = useRef(0);
const groupConfigsWriteVersion = useRef(0);
// Encrypt-phase promises can always be awaited, even under the vault lock.
@@ -320,6 +330,7 @@ export const useVaultState = () => {
const keysWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const waitForPendingVaultWrites = useCallback(async () => {
while (true) {
@@ -344,6 +355,7 @@ export const useVaultState = () => {
keysWritePendingRef.current,
identitiesWritePendingRef.current,
groupConfigsWritePendingRef.current,
snippetsWritePendingRef.current,
];
await Promise.all(writePending);
if (
@@ -351,6 +363,7 @@ export const useVaultState = () => {
&& writePending[1] === keysWritePendingRef.current
&& writePending[2] === identitiesWritePendingRef.current
&& writePending[3] === groupConfigsWritePendingRef.current
&& writePending[4] === snippetsWritePendingRef.current
&& encryptPending[0] === hostsEncryptPendingRef.current
&& encryptPending[1] === keysEncryptPendingRef.current
&& encryptPending[2] === identitiesEncryptPendingRef.current
@@ -593,12 +606,120 @@ export const useVaultState = () => {
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
// Callers pass a full array derived from this window's view; a popup delete
// (or another window's import) may land on disk before our write runs.
const base = snippetsRef.current;
const cleaned = normalizeVaultOrder(data);
++snippetsWriteVersion.current;
const ver = ++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = ver;
// Keep live snapshot ahead of React commit so same-tick readers (delete,
// agent bridge) do not observe a stale pre-write array.
snippetsRef.current = cleaned;
setSnippets(cleaned);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, cleaned);
// Serialize with deleteSelectedSnippets / plugin importer under the shared
// vault lock, then rebase onto the latest persisted snapshot so a queued
// full-array write cannot resurrect concurrently deleted snippets (or drop
// concurrent additions).
const writePromise = withVaultImportLock("vault", async () => {
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
const rawSnippets = localStorageAdapter.readString(STORAGE_KEY_SNIPPETS);
const latest = normalizeVaultOrder(
readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, rawSnippets),
);
const rebased = normalizeVaultOrder(
rebaseSnippetVaultWrite({ base, ours: cleaned, theirs: latest }),
);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
if (snippetsWriteOwnerRef.current === ver) {
snippetsRef.current = rebased;
setSnippets(rebased);
}
return "written" as const;
});
snippetsWritePendingRef.current = writePromise;
return writePromise;
}, []);
// Cross-window safe: merge binding cleanup into the latest persisted
// hosts/snippets under the shared vault lock. Popup terminals own a separate
// useVaultState instance — writing hostsRef from that window can discard a
// main-window host edit, or a storage-event version bump can cancel the host
// write after snippets were already removed.
const deleteSelectedSnippets = useCallback(async (selectedSnippetIds: ReadonlySet<string>) => {
if (selectedSnippetIds.size === 0) {
return {
snippets: snippetsRef.current,
hosts: hostsRef.current,
deletedCount: 0,
};
}
while (true) {
await waitForPendingVaultWrites();
const attempt = await withVaultImportLock("vault", async () => {
const writeVersions = {
hosts: hostsWriteVersion.current,
snippets: snippetsWriteVersion.current,
};
const rawHosts = localStorageAdapter.readString(STORAGE_KEY_HOSTS);
const rawSnippets = localStorageAdapter.readString(STORAGE_KEY_SNIPPETS);
const storedHosts = readStoredArray<Host>(STORAGE_KEY_HOSTS, rawHosts);
const storedSnippets = readStoredArray<Snippet>(STORAGE_KEY_SNIPPETS, rawSnippets);
const latestHosts = normalizeVaultOrder(
(await decryptHosts(storedHosts)).map((host) => sanitizeHost(host)),
);
const latestSnippets = normalizeVaultOrder(storedSnippets);
const result = deleteSelectedSnippetsFromVault(
latestSnippets,
latestHosts,
selectedSnippetIds,
);
if (result.deletedCount === 0) return result;
const encryptedHosts = await encryptHosts(result.hosts);
const changedWhilePreparing = (
writeVersions.hosts !== hostsWriteVersion.current
|| writeVersions.snippets !== snippetsWriteVersion.current
|| localStorageAdapter.readString(STORAGE_KEY_HOSTS) !== rawHosts
|| localStorageAdapter.readString(STORAGE_KEY_SNIPPETS) !== rawSnippets
);
if (changedWhilePreparing) return null;
++hostsWriteVersion.current;
++snippetsWriteVersion.current;
// Journaled pair write: a partial hosts/snippets persist would drop
// login/connect bindings on restart while leaving the snippets behind.
// Callers fire-and-forget this promise after closing the confirm dialog,
// so storage rejection must not become an unhandled rejection.
try {
commitPluginImporterTransaction(localStorageAdapter, [
[STORAGE_KEY_HOSTS, encryptedHosts],
[STORAGE_KEY_SNIPPETS, result.snippets],
]);
} catch {
notify.error(
"Snippets could not be deleted. Free some local storage space and try again.",
"Scripts",
);
return {
snippets: latestSnippets,
hosts: latestHosts,
deletedCount: 0,
};
}
hostsRef.current = result.hosts;
snippetsRef.current = result.snippets;
setHosts(result.hosts);
setSnippets(result.snippets);
hostsWritePendingRef.current = Promise.resolve("unchanged" as const);
snippetsWritePendingRef.current = Promise.resolve("unchanged" as const);
return result;
});
if (attempt !== null) return attempt;
}
}, [waitForPendingVaultWrites]);
const updateSnippetPackages = useCallback((data: string[]) => {
setSnippetPackages(data);
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
@@ -1356,9 +1477,13 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_SNIPPETS) {
const next = safeParse<Snippet[]>(event.newValue) ?? [];
const next = normalizeVaultOrder(safeParse<Snippet[]>(event.newValue) ?? []);
// Invalidate write-version readers, but do not clear snippetsWriteOwnerRef:
// an in-flight updateSnippets rebases onto this disk snapshot under the
// vault lock instead of being cancelled (which would drop local edits).
++snippetsWriteVersion.current;
setSnippets(normalizeVaultOrder(next));
snippetsRef.current = next;
setSnippets(next);
return;
}
@@ -1528,7 +1653,9 @@ export const useVaultState = () => {
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
if (payload.snippets) updateSnippets(payload.snippets);
if (payload.snippets) {
encryptedWrites.push(updateSnippets(payload.snippets).then(() => undefined));
}
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.notes) updateNotes(payload.notes);
@@ -1720,6 +1847,7 @@ export const useVaultState = () => {
updateIdentities,
updateProxyProfiles,
updateSnippets,
deleteSelectedSnippets,
updateSnippetPackages,
updateNotes,
updateNoteGroups,
+55 -1
View File
@@ -2,7 +2,10 @@ import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { buildScriptsSidePanelRows } from "./ScriptsSidePanel.tsx";
import {
buildScriptsSidePanelRows,
collectScriptsSidePanelPackagePaths,
} from "./ScriptsSidePanel.tsx";
import type { Snippet } from "../types";
const snippet = (overrides: Partial<Snippet>): Snippet => ({
@@ -56,6 +59,57 @@ test("scripts side panel add control is a split button with secondary create act
assert.match(source, /snippets\.action\.newSnippet/);
});
test("collectScriptsSidePanelPackagePaths includes implied ancestors", () => {
assert.deepEqual(
collectScriptsSidePanelPackagePaths(["ops/linux"], [
snippet({ id: "a", package: "ops/linux/disk" }),
snippet({ id: "b", package: "" }),
]).sort(),
["ops", "ops/linux", "ops/linux/disk"],
);
});
test("scripts side panel toolbar exposes expand, collapse, and delete-selected actions", () => {
assert.match(source, /vault\.tree\.expandAll/);
assert.match(source, /vault\.tree\.collapseAll/);
assert.match(source, /snippets\.selection\.deleteSelected/);
assert.match(source, /expandAllGroups/);
assert.match(source, /collapseAllGroups/);
assert.match(source, /deleteSelectedSnippets/);
assert.match(source, /isMultiSelectMode/);
});
test("scripts side panel confirms bulk delete and routes it through the shared delete event", () => {
// Mass-delete must confirm first (SnippetsManager parity) and must not bypass
// AppSideEffects via onSnippetsChange filtering — that path skips host binding cleanup.
assert.match(source, /VaultDeleteConfirmDialog/);
assert.match(source, /snippets\.selection\.deleteConfirmTitle/);
assert.match(source, /snippets\.selection\.deleteConfirmDesc/);
assert.match(source, /detail:\s*\{\s*ids\s*\}/);
assert.doesNotMatch(
source,
/onSnippetsChange\(snippets\.filter\(\(snippet\) => !selectedSnippetIds\.has/,
);
});
test("scripts side panel defers bulk-delete confirm when a parent owns the dialog", () => {
// Compact TerminalToolbar nests this panel in a Popover; the portalled confirm
// must be owned outside that tree so focus cannot unmount the prompt.
assert.match(source, /onBulkDeleteRequest\?:/);
assert.match(source, /if\s*\(\s*onBulkDeleteRequest\s*\)\s*\{/);
assert.match(source, /onBulkDeleteRequest\(ids\)/);
assert.match(source, /!onBulkDeleteRequest/);
});
test("scripts side panel clears pending bulk delete when the panel hides", () => {
// Returning null while isVisible is false unmounts the confirm dialog; drop
// pending deletes so a later re-show does not resurrect a half-dismissed prompt.
assert.match(
source,
/if\s*\(\s*!isVisible\s*\)\s*setPendingDeleteIds\(\s*null\s*\)/,
);
});
test("scripts side panel package dialog traps focus and exposes dialog close contract", () => {
assert.match(source, /packageDialogRef/);
assert.match(source, /data-dialog-close="true"/);
+252 -34
View File
@@ -7,10 +7,26 @@
* list of matching snippets regardless of package nesting.
*/
import { ChevronDown, ChevronRight, Edit2, FolderPlus, Layers, Package, Play, Plus, Search, Trash2, Zap } from 'lucide-react';
import {
CheckSquare,
ChevronDown,
ChevronRight,
Edit2,
Expand,
FolderPlus,
Layers,
Minimize2,
Package,
Play,
Plus,
Search,
Trash2,
Zap,
} from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import { reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from '../domain/vaultOrder';
import { isScriptSnippet } from '../domain/snippetScript.ts';
import { cn } from '../lib/utils';
@@ -33,6 +49,9 @@ import { SnippetCommandTooltipContent } from './snippets/SnippetCommandTooltipCo
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
const toolbarIconButtonClass =
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
const SCRIPT_ROW_HEIGHT = 34;
const isRootPackagePath = (path: string): boolean => {
@@ -55,6 +74,12 @@ interface ScriptsSidePanelProps {
onResumeRun?: (runId: string) => void;
onStartRecording?: () => void;
focusedSessionId?: string;
/**
* When set, bulk-delete confirmation is owned by the parent. Required when
* this panel is nested in a Popover — the portalled confirm would otherwise
* steal focus, dismiss the popover, and unmount the prompt.
*/
onBulkDeleteRequest?: (ids: string[]) => void;
}
type TreeRow =
@@ -126,15 +151,11 @@ const getVerticalDropIntent = (
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
}): TreeRow[] {
/** Collect every package path (including implied ancestors) shown in the tree. */
export function collectScriptsSidePanelPackagePaths(
packages: string[],
snippets: Snippet[],
): string[] {
const normalizedPackages = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
@@ -152,6 +173,19 @@ export function buildScriptsSidePanelRows({
snippets.forEach((snippet) => {
if (snippet.package) addWithAncestors(snippet.package);
});
return Array.from(normalizedPackages);
}
export function buildScriptsSidePanelRows({
snippets,
packages,
expandedPaths,
}: {
snippets: Snippet[];
packages: string[];
expandedPaths: Set<string>;
}): TreeRow[] {
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
const snippetsByPackage = new Map<string, Snippet[]>();
const descendantCountByPackage = new Map<string, number>();
@@ -250,11 +284,15 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
onResumeRun,
onStartRecording,
focusedSessionId,
onBulkDeleteRequest,
}) => {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [subView, setSubView] = useState<'library' | 'running'>('library');
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(null);
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
const [newPackageName, setNewPackageName] = useState('');
const [packageError, setPackageError] = useState('');
@@ -290,24 +328,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
const normalizedPackages = useMemo(() => {
if (!isVisible) return new Set<string>();
const set = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
if (!path) return;
const isAbs = path.startsWith('/');
const body = isAbs ? path.slice(1) : path;
const parts = body.split('/').filter(Boolean);
for (let i = 1; i <= parts.length; i++) {
const sub = parts.slice(0, i).join('/');
set.add(isAbs ? `/${sub}` : sub);
}
};
packages.forEach(addWithAncestors);
// A snippet may reference a package path that's not in `packages` yet.
snippets.forEach((s) => {
if (s.package) addWithAncestors(s.package);
});
return set;
return new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
}, [packages, snippets, isVisible]);
// Track every package we've ever observed so we can tell "new" from
@@ -348,6 +369,92 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
});
}, []);
const expandAllGroups = useCallback(() => {
setExpandedPaths(new Set(normalizedPackages));
}, [normalizedPackages]);
const collapseAllGroups = useCallback(() => {
setExpandedPaths(new Set());
}, []);
const clearSnippetSelection = useCallback(() => {
setSelectedSnippetIds(new Set());
setIsMultiSelectMode(false);
}, []);
useEffect(() => {
setSelectedSnippetIds((prev) => {
if (prev.size === 0) return prev;
const alive = new Set(snippets.map((snippet) => snippet.id));
let changed = false;
const next = new Set<string>();
prev.forEach((id) => {
if (alive.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
}, [snippets]);
useEffect(() => {
if (!isVisible) setPendingDeleteIds(null);
}, [isVisible]);
// Parent-owned confirm (compact toolbar popover) dispatches the shared delete
// event; clear local multi-select when that bulk delete lands.
useEffect(() => {
if (!onBulkDeleteRequest) return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ ids?: string[] }>).detail;
if (!detail?.ids?.length) return;
clearSnippetSelection();
};
window.addEventListener('netcatty:snippets:delete', handler);
return () => window.removeEventListener('netcatty:snippets:delete', handler);
}, [clearSnippetSelection, onBulkDeleteRequest]);
const toggleSnippetSelection = useCallback((id: string) => {
setSelectedSnippetIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const deleteSelectedSnippets = useCallback(() => {
const ids = snippets
.filter((snippet) => selectedSnippetIds.has(snippet.id))
.map((snippet) => snippet.id);
if (ids.length === 0) return;
if (onBulkDeleteRequest) {
onBulkDeleteRequest(ids);
return;
}
setPendingDeleteIds(ids);
}, [onBulkDeleteRequest, selectedSnippetIds, snippets]);
const existingPendingDeleteIds = pendingDeleteIds
? pendingDeleteIds.filter((id) => snippets.some((snippet) => snippet.id === id))
: [];
const confirmDeleteSelectedSnippets = useCallback(() => {
const ids = (pendingDeleteIds ?? []).filter((id) =>
snippets.some((snippet) => snippet.id === id),
);
setPendingDeleteIds(null);
if (ids.length === 0) {
clearSnippetSelection();
return;
}
// Always route through the shared event so AppSideEffects can clear host
// login/connect bindings (onSnippetsChange alone would leave them stale).
window.dispatchEvent(
new CustomEvent('netcatty:snippets:delete', { detail: { ids } }),
);
clearSnippetSelection();
}, [clearSnippetSelection, pendingDeleteIds, snippets]);
// When search is active, flatten everything (no tree, no packages).
const searchMatches = useMemo(() => {
if (!isVisible) return null;
@@ -400,6 +507,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
const handleSnippetClick = useCallback(
(snippet: Snippet) => {
if (isMultiSelectMode) {
toggleSnippetSelection(snippet.id);
return;
}
if (isScriptSnippet(snippet)) {
onRunScript?.(snippet);
setSubView('running');
@@ -407,9 +518,12 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
}
onSnippetClick(snippet);
},
[onRunScript, onSnippetClick],
[isMultiSelectMode, onRunScript, onSnippetClick, toggleSnippetSelection],
);
const hasSearch = search.trim().length > 0;
const canExpandCollapse = normalizedPackages.size > 0 && !hasSearch;
const sessionRuns = useMemo(() => {
if (!focusedSessionId) return runs;
return runs.filter((run) => run.sessionId === focusedSessionId);
@@ -757,8 +871,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</div>
) : (
<>
{/* Search + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1.5">
{/* Search + tree actions + Add */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50 flex items-center gap-1">
<div className="relative flex-1 min-w-0">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -768,6 +882,76 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={toolbarIconButtonClass}
disabled={!canExpandCollapse}
aria-label={t('vault.tree.expandAll')}
onClick={expandAllGroups}
>
<Expand size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('vault.tree.expandAll')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={toolbarIconButtonClass}
disabled={!canExpandCollapse}
aria-label={t('vault.tree.collapseAll')}
onClick={collapseAllGroups}
>
<Minimize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('vault.tree.collapseAll')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
isMultiSelectMode && 'bg-muted/70 text-foreground',
)}
aria-label={t('snippets.action.selectSnippets')}
aria-pressed={isMultiSelectMode}
onClick={() => {
if (isMultiSelectMode) {
clearSnippetSelection();
} else {
setIsMultiSelectMode(true);
}
}}
>
<CheckSquare size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('snippets.action.selectSnippets')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
toolbarIconButtonClass,
selectedSnippetIds.size > 0 && 'text-destructive hover:text-destructive',
)}
disabled={selectedSnippetIds.size === 0}
aria-label={t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
onClick={deleteSelectedSnippets}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('snippets.selection.deleteSelected', { count: selectedSnippetIds.size })}
</TooltipContent>
</Tooltip>
{/* Split add control: primary = new snippet; menu = package / automation script */}
<div className="shrink-0 flex items-center">
<Tooltip>
@@ -862,6 +1046,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippet={item.snippet}
depth={0}
subtitle={item.snippet.package || t('terminal.toolbar.library')}
selected={selectedSnippetIds.has(item.snippet.id)}
multiSelect={isMultiSelectMode}
draggable={false}
sortableTarget={false}
onDragOver={handleRowDragOver}
@@ -904,7 +1090,9 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
<SnippetRow
snippet={item.row.snippet}
depth={item.row.depth}
draggable={Boolean(onSnippetsChange)}
selected={selectedSnippetIds.has(item.row.snippet.id)}
multiSelect={isMultiSelectMode}
draggable={Boolean(onSnippetsChange) && !isMultiSelectMode}
sortableTarget={true}
onDragOver={handleRowDragOver}
onDrop={handleRowDrop}
@@ -1051,6 +1239,20 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</div>
</div>
) : null}
{!onBulkDeleteRequest ? (
<VaultDeleteConfirmDialog
open={Boolean(pendingDeleteIds)}
title={t('snippets.selection.deleteConfirmTitle', {
count: existingPendingDeleteIds.length,
})}
description={t('snippets.selection.deleteConfirmDesc')}
onOpenChange={(open) => {
if (!open) setPendingDeleteIds(null);
}}
onConfirm={confirmDeleteSelectedSnippets}
/>
) : null}
</div>
</TooltipProvider>
);
@@ -1102,6 +1304,8 @@ interface SnippetRowProps {
snippet: Snippet;
depth: number;
subtitle?: string;
selected?: boolean;
multiSelect?: boolean;
draggable: boolean;
sortableTarget: boolean;
onDragOver: (event: React.DragEvent<HTMLElement>) => void;
@@ -1122,6 +1326,8 @@ const SnippetRow = memo<SnippetRowProps>(({
snippet,
depth,
subtitle,
selected = false,
multiSelect = false,
draggable,
sortableTarget,
onDragOver,
@@ -1157,13 +1363,25 @@ const SnippetRow = memo<SnippetRowProps>(({
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden"
aria-pressed={multiSelect ? selected : undefined}
className={cn(
'w-full flex items-center gap-1.5 pr-3 py-1.5 text-left hover:bg-accent/50 transition-colors overflow-hidden',
selected && 'bg-primary/10 hover:bg-primary/15',
)}
style={{ paddingLeft: 8 + depth * 14 }}
>
{/* Hidden chevron column mirrors PackageRow's layout so the
snippet icon lines up exactly with the package icon above. */}
<ChevronRight size={12} className="shrink-0 opacity-0" aria-hidden />
{isScriptSnippet(snippet) ? (
{multiSelect ? (
<CheckSquare
size={12}
className={cn(
'shrink-0',
selected ? 'text-primary' : 'text-muted-foreground/70',
)}
/>
) : isScriptSnippet(snippet) ? (
<Play size={12} className="shrink-0 text-primary" />
) : (
<Zap size={12} className="shrink-0 text-muted-foreground" />
+136
View File
@@ -72,6 +72,17 @@ import {
shouldFollowTerminalCwdNavigate,
type SftpFollowTerminalCwdBlock,
} from "./sftp/sftpFollowTerminalCwd";
import {
canLocateSftpPathInTerminal,
resolveLocateSftpPathInTerminalAction,
resolveLocateSftpPathSessionId,
} from "../domain/sftpLocatePathInTerminal";
import { classifyDistroId } from "../domain/host";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { getNextSftpToolbarDisplayPath } from "./sftp/SftpPaneToolbar";
import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
import {
connectionKeyMatchesHost,
findReusableSftpSidePanelTab,
@@ -107,6 +118,8 @@ interface SftpSidePanelProps {
activeHost: Host | null;
/** The terminal session id whose SSH connection can be reused for SFTP */
activeSessionId?: string | null;
/** Focused terminal session (includes mosh/et/local) for locate-path writes */
focusedSessionId?: string | null;
initialLocation?: { hostId: string; path: string } | null;
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
onCurrentPathChange?: (location: { hostId: string; connectionKey: string; path: string }) => void;
@@ -156,6 +169,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpDefaultViewMode,
activeHost,
activeSessionId,
focusedSessionId = null,
initialLocation,
onInitialLocationApplied,
onCurrentPathChange,
@@ -881,11 +895,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts={hosts}
hostWriteSource={hostWriteSource}
connectedHosts={connectedHosts}
sessions={sessions}
updateHosts={updateHosts}
sftp={sftp}
sftpRef={sftpRef}
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={activeHost}
activeSessionId={activeSessionId}
focusedSessionId={focusedSessionId}
showWorkspaceHostHeader={showWorkspaceHostHeader}
renderOverlays={renderOverlays}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
@@ -923,11 +940,14 @@ type SftpSidePanelInteractiveBodyProps = {
hosts: Host[];
hostWriteSource: Host[];
connectedHosts: import("../domain/sftpConnectedHosts").SftpConnectedHostEntry[];
sessions: TerminalSession[];
updateHosts: (hosts: Host[]) => void;
sftp: ReturnType<typeof useSftpState>;
sftpRef: MutableRefObject<ReturnType<typeof useSftpState>>;
sftpDefaultViewMode: "list" | "tree";
activeHost: Host | null;
activeSessionId?: string | null;
focusedSessionId?: string | null;
showWorkspaceHostHeader: boolean;
renderOverlays: boolean;
sftpDoubleClickBehavior: "open" | "transfer";
@@ -965,11 +985,14 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
hosts,
hostWriteSource,
connectedHosts,
sessions,
updateHosts,
sftp,
sftpRef,
sftpDefaultViewMode,
activeHost,
activeSessionId = null,
focusedSessionId = null,
showWorkspaceHostHeader,
renderOverlays,
hotkeyScheme,
@@ -999,6 +1022,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}) => {
const panelRootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
const terminalBackend = useTerminalBackend();
const [hasPaneFocus, setHasPaneFocus] = useState(false);
const [pendingFollowOverride, setPendingFollowOverride] = useState<{
hostId: string;
@@ -1234,6 +1258,27 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}
}, [connectionId, connectionPath, sftp.leftPane.loading]);
// Match toolbar path semantics: keep the last confirmed path while navigateTo
// has optimistically replaced connection.currentPath during an uncached load.
const confirmedLocatePathRef = useRef(connectionPath ?? "");
const prevLocateConnectionIdRef = useRef(connectionId ?? undefined);
const [confirmedLocatePath, setConfirmedLocatePath] = useState(connectionPath ?? "");
useEffect(() => {
const previousConnectionId = prevLocateConnectionIdRef.current;
prevLocateConnectionIdRef.current = connectionId ?? undefined;
setConfirmedLocatePath((previousDisplayPath) => {
const next = getNextSftpToolbarDisplayPath({
previousDisplayPath,
previousConnectionId,
connectionId: connectionId ?? undefined,
currentPath: connectionPath ?? undefined,
loading: sftp.leftPane.loading,
});
confirmedLocatePathRef.current = next;
return next;
});
}, [connectionId, connectionPath, sftp.leftPane.loading]);
const handleGoToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd) return;
const cwd = await onGetTerminalCwd({ preferFreshBackend: true });
@@ -1248,6 +1293,95 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}
}, [onGetTerminalCwd, sftpRef]);
const locatePathInTerminalContext = useMemo(() => {
const connection = sftp.leftPane.connection;
const locateSessionId = resolveLocateSftpPathSessionId({
activeSessionId,
focusedSessionId,
});
const session = sessions.find((candidate) => candidate.id === locateSessionId) ?? null;
const host = displayHost ?? activeHost;
const isNetworkDevice = host?.deviceType === "network"
|| classifyDistroId(host?.distro) === "network-device";
return {
path: confirmedLocatePath || connection?.currentPath,
sessionId: locateSessionId,
sessionStatus: session?.status,
sessionHostId: session?.hostId,
sftpHostId: connection?.hostId,
sftpIsLocal: Boolean(connection?.isLocal),
protocol: session?.protocol ?? host?.protocol,
shellType: session?.shellType,
isNetworkDevice,
moshEnabled: session?.moshEnabled,
etEnabled: session?.etEnabled,
sessionHostname: session?.hostname,
sessionUsername: session?.username,
sessionPort: session?.port,
sftpHostname: host?.hostname,
sftpUsername: host?.username,
sftpPort: host?.port,
};
}, [
activeHost,
activeSessionId,
confirmedLocatePath,
displayHost,
focusedSessionId,
sessions,
sftp.leftPane.connection,
]);
const canLocatePathInTerminal = canLocateSftpPathInTerminal(locatePathInTerminalContext);
const handleLocatePathInTerminal = useCallback(() => {
const connection = sftpRef.current.leftPane.connection;
const locateSessionId = resolveLocateSftpPathSessionId({
activeSessionId,
focusedSessionId,
});
const session = sessions.find((candidate) => candidate.id === locateSessionId) ?? null;
const host = displayHost ?? activeHost;
const isNetworkDevice = host?.deviceType === "network"
|| classifyDistroId(host?.distro) === "network-device";
const action = resolveLocateSftpPathInTerminalAction({
// Prefer the path shown in the toolbar, not an in-flight optimistic cwd.
path: confirmedLocatePathRef.current || connection?.currentPath,
sessionId: locateSessionId,
sessionStatus: session?.status,
sessionHostId: session?.hostId,
sftpHostId: connection?.hostId,
sftpIsLocal: Boolean(connection?.isLocal),
protocol: session?.protocol ?? host?.protocol,
shellType: session?.shellType,
isNetworkDevice,
moshEnabled: session?.moshEnabled,
etEnabled: session?.etEnabled,
sessionHostname: session?.hostname,
sessionUsername: session?.username,
sessionPort: session?.port,
sftpHostname: host?.hostname,
sftpUsername: host?.username,
sftpPort: host?.port,
});
if (!action) return;
// Never inject cd into a password/sudo prompt (same guard as snippets/broadcast).
if (isTerminalSensitiveInputActive(action.sessionId)) return;
// Only submit at an idle shell prompt -- never append into typed input or a TUI.
if (!isTerminalReadyForCommandInjection(action.sessionId)) return;
terminalBackend.writeToSession(action.sessionId, action.data, { automated: true });
scheduleDeferredTerminalFocus(onRequestTerminalFocus);
}, [
activeHost,
activeSessionId,
displayHost,
focusedSessionId,
onRequestTerminalFocus,
sessions,
sftpRef,
terminalBackend,
]);
const syncFollowToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd || !effectiveFollowTerminalCwd || !canFollowTerminalCwd) {
return;
@@ -1644,6 +1778,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
onLocatePathInTerminal={canLocatePathInTerminal ? handleLocatePathInTerminal : undefined}
followTerminalCwd={canFollowTerminalCwd ? effectiveFollowTerminalCwd : undefined}
onToggleFollowTerminalCwd={canFollowTerminalCwd ? handleToggleFollowTerminalCwd : undefined}
/>
@@ -1723,6 +1858,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.activeHost === next.activeHost &&
prev.activeSessionId === next.activeSessionId &&
prev.focusedSessionId === next.focusedSessionId &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&
prev.renderOverlays === next.renderOverlays &&
+19
View File
@@ -145,6 +145,9 @@ import {
} from "./terminal/runtime/createXTermRuntime";
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
import {
@@ -247,6 +250,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets,
snippetPackages = [],
compactToolbar = false,
onDeleteSnippets,
lineTimestampsAvailable = true,
chainHosts = EMPTY_CHAIN_HOSTS,
appearanceTheme,
@@ -535,6 +539,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const onSessionExitRef = useRef(onSessionExit);
const commandBufferRef = useRef<string>("");
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
const term = termRef.current;
if (!term) return false;
const prompt = detectPrompt(term);
return isIdleShellReadyForCommandInjection({
sensitiveInputActive: passwordPromptActiveRef.current,
hasLiveTerminal: true,
alternateScreenActive: isTerminalAlternateScreenActive(term),
isAtPrompt: prompt.isAtPrompt,
userInputLength: prompt.userInput.length,
pendingTypedInputLength: commandBufferRef.current.length,
});
}), [sessionId]);
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
@@ -3615,6 +3632,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippets={snippets}
snippetPackages={snippetPackages}
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
onDeleteSnippets={onDeleteSnippets}
onOpenSFTP={handleOpenSFTP}
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
@@ -3678,6 +3696,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isSessionLogging,
isWorkspaceComposeBarOpen,
onCloseSession,
onDeleteSnippets,
onOpenScripts,
onOpenHistory,
onOpenTheme,
+3
View File
@@ -133,9 +133,12 @@ test('resolveTerminalPopupReuseId uses the explicit reuse id from the prepared s
test('popup terminals resolve complete host config and pass jump hosts into Terminal', () => {
assert.match(source, /proxyProfiles,\s+knownHosts,\s+snippets,\s+snippetPackages,\s+groupConfigs,/);
assert.match(source, /deleteSelectedSnippets,/);
assert.match(source, /resolveTerminalPopupHost\(config,\s*hosts,\s*\{\s+groupConfigs,\s+proxyProfiles,/);
assert.match(source, /resolveTerminalChainHosts\(\{\s+host,\s+hosts,\s+groupConfigs,\s+proxyProfiles,/);
assert.match(source, /chainHosts=\{chainHosts\}/);
// Popup has no AppSideEffects listener; bulk delete must hit this vault instance.
assert.match(source, /onDeleteSnippets=\{deleteSelectedSnippets\}/);
});
test('popup provider tree mounts the plugin authentication host', () => {
+2
View File
@@ -272,6 +272,7 @@ function TerminalPopupPageInner() {
snippetPackages,
groupConfigs,
updateKnownHosts,
deleteSelectedSnippets,
} = useVaultState();
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
const [terminalReady, setTerminalReady] = useState(false);
@@ -453,6 +454,7 @@ function TerminalPopupPageInner() {
snippetPackages={snippetPackages}
chainHosts={chainHosts}
compactToolbar
onDeleteSnippets={deleteSelectedSnippets}
lineTimestampsAvailable={false}
knownHosts={effectiveKnownHosts}
onAddKnownHost={handleAddKnownHost}
+78
View File
@@ -166,6 +166,84 @@ test("toolbar renders one view-mode toggle instead of separate list and tree but
assert.match(markup, /aria-label="Bookmarked paths"/);
});
test("toolbar exposes locate-path-in-terminal when the callback is provided", () => {
const pane: SftpPane = {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/var/www/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
};
const markup = renderToStaticMarkup(
React.createElement(TooltipProvider, {
children: React.createElement(SftpPaneToolbar, {
t: (key: string) => ({
"sftp.locatePathInTerminal": "Open path in terminal",
"sftp.viewMode.switchToTree": "Switch to tree view",
"sftp.bookmark.add": "Bookmark current path",
}[key] ?? key),
pane,
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
onLocatePathInTerminal: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
}),
);
assert.match(markup, /aria-label="Open path in terminal"/);
});
test("toolbar exposes copy-current-path action for the active directory", () => {
const pane: SftpPane = {
id: "pane-1",
+36 -2
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, RefreshCw, Search, Terminal, TerminalSquare, Trash2, X } from "lucide-react";
import { useToolbarItemLayout } from "../../application/state/useToolbarItemLayout";
import type { ToolbarItemLayoutDefaults } from "../../domain/toolbarItemLayout";
import { STORAGE_KEY_SFTP_TOOLBAR_LAYOUT } from "../../infrastructure/config/storageKeys";
@@ -28,6 +28,7 @@ import { toast } from "../ui/toast";
export const SFTP_TOOLBAR_ITEM_IDS = [
"bookmark",
"goToTerminalCwd",
"locatePathInTerminal",
"followTerminalCwd",
"copyPath",
"viewMode",
@@ -46,6 +47,7 @@ export const SFTP_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
placement: {
bookmark: "show",
goToTerminalCwd: "show",
locatePathInTerminal: "show",
followTerminalCwd: "show",
copyPath: "show",
viewMode: "show",
@@ -67,6 +69,7 @@ export const SFTP_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
export const SFTP_TOOLBAR_NARROW_INLINE_IDS = new Set<SftpToolbarItemId>([
"bookmark",
"goToTerminalCwd",
"locatePathInTerminal",
"followTerminalCwd",
"copyPath",
"viewMode",
@@ -215,6 +218,7 @@ interface SftpPaneToolbarProps {
showHiddenFiles: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
onLocatePathInTerminal?: () => void;
followTerminalCwd?: boolean;
onToggleFollowTerminalCwd?: () => void;
viewMode: SftpPaneViewMode;
@@ -323,6 +327,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
showHiddenFiles,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
onLocatePathInTerminal,
followTerminalCwd,
onToggleFollowTerminalCwd,
viewMode,
@@ -469,15 +474,17 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
"refresh",
];
if (onGoToTerminalCwd) ids.push("goToTerminalCwd");
if (onLocatePathInTerminal) ids.push("locatePathInTerminal");
if (onToggleFollowTerminalCwd) ids.push("followTerminalCwd");
if (isRemote) ids.push("encoding");
return ids;
}, [isRemote, onGoToTerminalCwd, onToggleFollowTerminalCwd]);
}, [isRemote, onGoToTerminalCwd, onLocatePathInTerminal, onToggleFollowTerminalCwd]);
const itemLabels = useMemo(
(): Record<SftpToolbarItemId, string> => ({
bookmark: bookmarkButtonLabel,
goToTerminalCwd: t("sftp.goToTerminalCwd"),
locatePathInTerminal: t("sftp.locatePathInTerminal"),
followTerminalCwd: t("sftp.followTerminalCwd"),
copyPath: t("sftp.copyCurrentPath"),
viewMode: viewModeToggleLabel,
@@ -495,6 +502,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
(): Record<SftpToolbarItemId, React.ReactNode> => ({
bookmark: <Bookmark size={14} />,
goToTerminalCwd: <TerminalSquare size={14} />,
locatePathInTerminal: <Terminal size={14} />,
followTerminalCwd: <FolderSync size={14} />,
copyPath: <ClipboardCopy size={14} />,
viewMode: viewMode === "list" ? <List size={14} /> : <ListTree size={14} />,
@@ -681,6 +689,24 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
</Tooltip>
);
case "locatePathInTerminal":
if (!onLocatePathInTerminal) return null;
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label={t("sftp.locatePathInTerminal")}
onClick={onLocatePathInTerminal}
>
<Terminal size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.locatePathInTerminal")}</TooltipContent>
</Tooltip>
);
case "followTerminalCwd":
if (!onToggleFollowTerminalCwd) return null;
return (
@@ -830,6 +856,14 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
{t("sftp.goToTerminalCwd")}
</button>
);
case "locatePathInTerminal":
if (!onLocatePathInTerminal) return null;
return (
<button key={id} type="button" className={menuItemClass} onClick={onLocatePathInTerminal}>
<Terminal size={14} className="shrink-0" />
{t("sftp.locatePathInTerminal")}
</button>
);
case "followTerminalCwd":
if (!onToggleFollowTerminalCwd) return null;
return (
+52 -6
View File
@@ -9,7 +9,7 @@ import { useSftpPaneTreeRows } from './useSftpPaneTreeRows';
import { SftpMoveToDialog } from './SftpMoveToDialog';
import type { SftpFileEntry } from '../../types';
import { getParentPath, isWindowsRoot, joinPath, resolveSftpWindowsPathOptions } from '../../application/state/sftp/utils';
import { buildSftpColumnTemplate, filterHiddenFiles, isNavigableDirectory, isSftpColumnMenuKey, sortSftpEntries } from './utils';
import { buildSftpColumnTemplate, filterHiddenFiles, filterSftpTreeEntriesByName, isNavigableDirectory, isSftpColumnMenuKey, sortSftpEntries } from './utils';
import type { SftpTransferSource } from './SftpContext';
import type { SftpPaneTreeViewProps } from './SftpPaneTreeView.types';
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from '../../application/state/sftp/sftpTreeSelectionStore';
@@ -225,12 +225,29 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
childrenCacheRef.current.delete(targetPath);
sortedChildrenCacheRef.current.delete(targetPath);
}, []);
const prevSortKeyRef = useRef(`${sortField}:${sortOrder}:${directoriesFirst}:${pane.showHiddenFiles}`);
const sortKey = `${sortField}:${sortOrder}:${directoriesFirst}:${pane.showHiddenFiles}`;
const prevSortKeyRef = useRef(`${sortField}:${sortOrder}:${directoriesFirst}:${pane.showHiddenFiles}:${pane.filter}`);
const sortKey = `${sortField}:${sortOrder}:${directoriesFirst}:${pane.showHiddenFiles}:${pane.filter}`;
if (prevSortKeyRef.current !== sortKey) {
prevSortKeyRef.current = sortKey;
sortedChildrenCacheRef.current.clear();
}
// Ancestor keep decisions follow expand/load/error visibility; invalidate
// sorted snapshots when those states change which descendants the filter may use.
const prevExpandedPathsRef = useRef(expandedPaths);
if (prevExpandedPathsRef.current !== expandedPaths) {
prevExpandedPathsRef.current = expandedPaths;
sortedChildrenCacheRef.current.clear();
}
const prevLoadingPathsRef = useRef(loadingPaths);
if (prevLoadingPathsRef.current !== loadingPaths) {
prevLoadingPathsRef.current = loadingPaths;
sortedChildrenCacheRef.current.clear();
}
const prevErrorPathsRef = useRef(errorPaths);
if (prevErrorPathsRef.current !== errorPaths) {
prevErrorPathsRef.current = errorPaths;
sortedChildrenCacheRef.current.clear();
}
useEffect(() => {
const currentPath = pane.connection?.currentPath ?? '';
if (!currentPath) {
@@ -253,6 +270,9 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
return false;
}
childrenCacheRef.current.set(entryPath, children);
// Ancestor visibility depends on loaded descendants, so drop every
// sorted/filtered snapshot (not only this path) before the next row build.
sortedChildrenCacheRef.current.clear();
dispatchTreePaths({ type: 'FINISH_LOADING', path: entryPath });
return true;
} catch {
@@ -481,7 +501,30 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
const cached = sortedChildrenCacheRef.current.get(parentPath);
if (cached) return cached;
const sorted = sortSftpEntries(
filterHiddenFiles(entries, pane.showHiddenFiles),
filterSftpTreeEntriesByName(
filterHiddenFiles(entries, pane.showHiddenFiles),
pane.filter,
{
parentPath,
joinPath,
isDirectory: isNavigableDirectory,
getChildren: (entryPath) => {
// Match buildTree visibility: collapsed, loading, and error paths
// do not render children, so cached descendants must not keep
// ancestors either (reload/failure would otherwise leave a
// nonmatching parent with only a spinner/error row).
if (!expandedPaths.has(entryPath)) return undefined;
if (loadingPaths.has(entryPath) || errorPaths.has(entryPath)) {
return undefined;
}
const children = childrenCacheRef.current.get(entryPath);
if (!children) return undefined;
// Match visible-row hidden policy so ancestor keep decisions
// cannot latch onto descendants the user cannot see.
return filterHiddenFiles(children, pane.showHiddenFiles);
},
},
),
sortField,
sortOrder,
directoriesFirst,
@@ -521,6 +564,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
resolvedRootPath,
pane.connection?.homeDir,
pane.showHiddenFiles,
pane.filter,
sortField,
sortOrder,
directoriesFirst,
@@ -662,7 +706,6 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
sourceParent,
cached.filter((entry) => !movedNameSet.has(entry.name)),
);
sortedChildrenCacheRef.current.delete(sourceParent);
}
if (targetPath !== currentPath) {
const targetCache = childrenCacheRef.current.get(targetPath);
@@ -674,9 +717,12 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
}
}
childrenCacheRef.current.set(targetPath, next);
sortedChildrenCacheRef.current.delete(targetPath);
}
}
// Ancestor filter keep decisions depend on cached descendants; drop every
// sorted/filtered snapshot (not only source/target) so Move To / drag-move
// cannot leave stale parents visible or hide the new parent under search.
sortedChildrenCacheRef.current.clear();
}, [pane.connection?.currentPath]);
const executeMoveAction = useCallback(async (sourcePaths: string[], targetPath: string) => {
try {
+4
View File
@@ -79,6 +79,7 @@ interface SftpPaneViewProps {
showEmptyHeader?: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
onLocatePathInTerminal?: () => void;
followTerminalCwd?: boolean;
onToggleFollowTerminalCwd?: () => void;
/** When true, treat this pane as always active (used by SftpSidePanel which manages visibility itself) */
@@ -95,6 +96,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
showEmptyHeader = true,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
onLocatePathInTerminal,
followTerminalCwd,
onToggleFollowTerminalCwd,
forceActive,
@@ -567,6 +569,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
showHiddenFiles={pane.showHiddenFiles}
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
onGoToTerminalCwd={onGoToTerminalCwd}
onLocatePathInTerminal={onLocatePathInTerminal}
followTerminalCwd={followTerminalCwd}
onToggleFollowTerminalCwd={onToggleFollowTerminalCwd}
viewMode={viewMode}
@@ -732,6 +735,7 @@ const sftpPaneViewAreEqual = (
if (prev.followTerminalCwd !== next.followTerminalCwd) return false;
if (prev.onToggleFollowTerminalCwd !== next.onToggleFollowTerminalCwd) return false;
if (prev.onGoToTerminalCwd !== next.onGoToTerminalCwd) return false;
if (prev.onLocatePathInTerminal !== next.onLocatePathInTerminal) return false;
if (prev.onToggleShowHiddenFiles !== next.onToggleShowHiddenFiles) return false;
return true;
+4 -6
View File
@@ -3,7 +3,7 @@ import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import { isWindowsRoot, resolveSftpWindowsPathOptions } from "../../../application/state/sftp/utils";
import type { SortField, SortOrder } from "../utils";
import { filterHiddenFiles, sortSftpEntries } from "../utils";
import { filterHiddenFiles, filterSftpEntriesByName, sortSftpEntries } from "../utils";
interface UseSftpPaneFilesParams {
files: SftpFileEntry[];
@@ -36,11 +36,9 @@ export const useSftpPaneFiles = ({
// in fewer passes, instead of repeatedly filtering/finding ".." entries.
const filteredFiles = useMemo(() => {
if (!enableListView) return [] as SftpFileEntry[];
const term = filter.trim().toLowerCase();
let nextFiles = filterHiddenFiles(files, showHiddenFiles);
if (!term) return nextFiles;
return nextFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
return filterSftpEntriesByName(
filterHiddenFiles(files, showHiddenFiles),
filter,
);
}, [enableListView, files, filter, showHiddenFiles]);
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
const slotSource = readFileSync(
new URL("../terminalLayer/terminalLayerSidePanelSlots.tsx", import.meta.url),
"utf8",
);
test("locate-path write skips sessions waiting on sensitive/password prompts", () => {
assert.match(
sidePanelSource,
/isTerminalSensitiveInputActive\(action\.sessionId\)[\s\S]*?writeToSession\(action\.sessionId, action\.data/,
);
assert.match(
sidePanelSource,
/if \(isTerminalSensitiveInputActive\(action\.sessionId\)\) return;/,
);
});
test("locate-path write requires an idle shell prompt before PTY injection", () => {
assert.match(
sidePanelSource,
/isTerminalReadyForCommandInjection\(action\.sessionId\)[\s\S]*?writeToSession\(action\.sessionId, action\.data/,
);
assert.match(
sidePanelSource,
/if \(!isTerminalReadyForCommandInjection\(action\.sessionId\)\) return;/,
);
});
test("locate-path uses the confirmed toolbar path rather than an optimistic navigate target", () => {
assert.match(sidePanelSource, /getNextSftpToolbarDisplayPath\(/);
assert.match(
sidePanelSource,
/path: confirmedLocatePathRef\.current \|\| connection\?\.currentPath/,
);
});
test("locate-path uses focused session fallback when SFTP cannot reuse the terminal", () => {
assert.match(sidePanelSource, /resolveLocateSftpPathSessionId\(\{\s*activeSessionId,\s*focusedSessionId,/);
assert.match(
slotSource,
/focusedSessionId=\{isVisible \? live\.focusedSessionId : null\}/,
);
});
+240
View File
@@ -0,0 +1,240 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { filterSftpEntriesByName, filterSftpTreeEntriesByName } from './utils.ts';
const entry = (name: string, type: 'file' | 'directory' = 'file') => ({ name, type });
const isDirectory = (e: { type: string }) => e.type === 'directory';
test('SFTP name filter returns all entries when term is empty', () => {
const files = [entry('..'), entry('README.md'), entry('src')];
assert.deepEqual(filterSftpEntriesByName(files, ' '), files);
});
test('SFTP name filter matches case-insensitively and keeps parent entry', () => {
const files = [entry('..'), entry('README.md'), entry('src'), entry('read-notes.txt')];
assert.deepEqual(
filterSftpEntriesByName(files, 'Read').map(({ name }) => name),
['..', 'README.md', 'read-notes.txt'],
);
});
test('SFTP name filter hides non-matching siblings including directories', () => {
const files = [entry('config'), entry('logs'), entry('app.js')];
assert.deepEqual(
filterSftpEntriesByName(files, 'log').map(({ name }) => name),
['logs'],
);
});
test('SFTP tree filter keeps loaded ancestors of matching children', () => {
const childrenByPath = new Map<string, ReturnType<typeof entry>[]>([
['/project/src', [entry('README.md'), entry('utils.ts'), entry('components', 'directory')]],
['/project/src/components', [entry('Button.tsx'), entry('readme-local.txt')]],
]);
const root = [
entry('src', 'directory'),
entry('logs', 'directory'),
entry('app.js'),
];
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: (path) => childrenByPath.get(path),
}).map(({ name }) => name),
['src'],
);
assert.deepEqual(
filterSftpTreeEntriesByName(childrenByPath.get('/project/src')!, 'readme', {
parentPath: '/project/src',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: (path) => childrenByPath.get(path),
}).map(({ name }) => name),
['README.md', 'components'],
);
});
test('SFTP tree filter hides unloaded non-matching directories', () => {
const root = [entry('src', 'directory'), entry('README.md')];
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: () => undefined,
}).map(({ name }) => name),
['README.md'],
);
});
test('SFTP tree filter does not keep ancestors for hidden-only descendant matches', () => {
// getChildren must apply the same hidden-file policy as visible rows; otherwise a
// dotfile match can keep a parent folder while the matching child stays invisible.
const childrenByPath = new Map<string, ReturnType<typeof entry>[]>([
['/project/src', [entry('.readme'), entry('utils.ts')]],
]);
const root = [entry('src', 'directory')];
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: (path) => {
const children = childrenByPath.get(path);
if (!children) return undefined;
return children.filter((child) => !child.name.startsWith('.'));
},
}).map(({ name }) => name),
[],
);
});
test('SFTP tree filter does not keep ancestors for collapsed descendant matches', () => {
// Collapsed directories keep children in cache, but buildTree does not render
// them. getChildren must treat collapsed paths as unavailable or the parent
// stays visible with no matching row.
const childrenByPath = new Map<string, ReturnType<typeof entry>[]>([
['/project/src', [entry('README.md'), entry('utils.ts')]],
]);
const expandedPaths = new Set<string>();
const root = [entry('src', 'directory'), entry('app.js')];
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: (path) => {
if (!expandedPaths.has(path)) return undefined;
return childrenByPath.get(path);
},
}).map(({ name }) => name),
[],
);
expandedPaths.add('/project/src');
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren: (path) => {
if (!expandedPaths.has(path)) return undefined;
return childrenByPath.get(path);
},
}).map(({ name }) => name),
['src'],
);
});
test('SFTP tree filter does not keep ancestors for loading or error descendant matches', () => {
// Expanded dirs keep children in cache during reload / after LOAD_ERROR, but
// buildTree only shows the loading or error row. getChildren must treat those
// paths as unavailable or a nonmatching parent stays as an empty result.
const childrenByPath = new Map<string, ReturnType<typeof entry>[]>([
['/project/src', [entry('README.md'), entry('utils.ts')]],
]);
const expandedPaths = new Set(['/project/src']);
const loadingPaths = new Set<string>();
const errorPaths = new Set<string>();
const root = [entry('src', 'directory'), entry('app.js')];
const getChildren = (path: string) => {
if (!expandedPaths.has(path)) return undefined;
if (loadingPaths.has(path) || errorPaths.has(path)) return undefined;
return childrenByPath.get(path);
};
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren,
}).map(({ name }) => name),
['src'],
);
loadingPaths.add('/project/src');
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren,
}).map(({ name }) => name),
[],
);
loadingPaths.clear();
errorPaths.add('/project/src');
assert.deepEqual(
filterSftpTreeEntriesByName(root, 'readme', {
parentPath: '/project',
joinPath: (parent, name) => `${parent}/${name}`,
isDirectory,
getChildren,
}).map(({ name }) => name),
[],
);
});
test('SFTP tree view applies the tree name filter to visible rows', () => {
const treeSource = readFileSync(new URL('./SftpPaneTreeView.tsx', import.meta.url), 'utf8');
assert.match(
treeSource,
/sortSftpEntries\(\s*filterSftpTreeEntriesByName\(\s*filterHiddenFiles\(entries, pane\.showHiddenFiles\),\s*pane\.filter,/s,
);
assert.match(treeSource, /pane\.showHiddenFiles\}:\$\{pane\.filter\}/);
assert.match(
treeSource,
/getChildren:\s*\(entryPath\)\s*=>\s*\{[\s\S]*?expandedPaths\.has\(entryPath\)[\s\S]*?loadingPaths\.has\(entryPath\)[\s\S]*?errorPaths\.has\(entryPath\)[\s\S]*?filterHiddenFiles\([\s\S]*?pane\.showHiddenFiles/,
);
assert.match(
treeSource,
/prevExpandedPathsRef[\s\S]*?sortedChildrenCacheRef\.current\.clear\(\)/,
);
assert.match(
treeSource,
/prevLoadingPathsRef[\s\S]*?sortedChildrenCacheRef\.current\.clear\(\)/,
);
assert.match(
treeSource,
/prevErrorPathsRef[\s\S]*?sortedChildrenCacheRef\.current\.clear\(\)/,
);
});
test('SFTP tree child reload invalidates sorted cache so filter reapplies', () => {
// After expand/reload, childrenCache is replaced. Ancestor visibility also
// depends on those descendants, so the sorted cache must be cleared broadly
// (not only for the loaded path) or parents stay hidden.
const treeSource = readFileSync(new URL('./SftpPaneTreeView.tsx', import.meta.url), 'utf8');
const loadFn = treeSource.match(
/const loadChildrenForPath = useCallback\(async \(entryPath: string\) => \{[\s\S]*?\n {2}\}, \[\]\);/,
);
assert.ok(loadFn, 'expected loadChildrenForPath callback');
const setIdx = loadFn[0].indexOf('childrenCacheRef.current.set(entryPath, children)');
const clearIdx = loadFn[0].indexOf('sortedChildrenCacheRef.current.clear()');
assert.ok(setIdx >= 0, 'expected childrenCache write on successful load');
assert.ok(clearIdx > setIdx, 'sorted cache must clear after childrenCache write');
});
test('SFTP tree move mutation clears all sorted snapshots for ancestor filter', () => {
// With an active search, ancestor keep decisions depend on cached descendants.
// Move To / drag-move update childrenCache for source/target only; deleting just
// those sorted keys leaves grandparent/root snapshots stale (old parent kept,
// new parent hidden). Mirror loadChildrenForPath and clear every sorted snapshot.
const treeSource = readFileSync(new URL('./SftpPaneTreeView.tsx', import.meta.url), 'utf8');
const moveFn = treeSource.match(
/const applyLocalMoveMutation = useCallback\(\(\s*sourceParentPaths: string\[\],\s*targetPath: string,\s*movedEntries: SftpFileEntry\[\],\s*\) => \{[\s\S]*?\n {2}\}, \[pane\.connection\?\.currentPath\]\);/,
);
assert.ok(moveFn, 'expected applyLocalMoveMutation callback');
const body = moveFn[0];
assert.match(body, /childrenCacheRef\.current\.set\(/);
const lastChildrenSet = body.lastIndexOf('childrenCacheRef.current.set(');
const clearIdx = body.indexOf('sortedChildrenCacheRef.current.clear()');
assert.ok(clearIdx > lastChildrenSet, 'sorted cache must clear after childrenCache mutations');
assert.equal(
(body.match(/sortedChildrenCacheRef\.current\.delete\(/g) || []).length,
0,
'move mutation must not rely on per-path sorted deletes alone',
);
});
+60
View File
@@ -347,3 +347,63 @@ export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
if (showHiddenFiles) return files;
return files.filter((f) => !isHiddenFile(f));
};
/**
* Filter files by search term (case-insensitive substring match on name).
* Always preserves ".." parent directory entry. Empty/whitespace terms are no-ops.
*/
export const filterSftpEntriesByName = <T extends { name: string }>(
files: T[],
filter: string,
): T[] => {
const term = filter.trim().toLowerCase();
if (!term) return files;
return files.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
};
export type SftpTreeNameFilterOptions<T extends { name: string }> = {
parentPath: string;
joinPath: (parentPath: string, name: string) => string;
isDirectory: (entry: T) => boolean;
/** Loaded children for a directory path; undefined means not loaded yet. */
getChildren: (entryPath: string) => T[] | undefined;
};
/**
* Tree-aware name filter: keeps list-view match rules, and also keeps directory
* ancestors when an expanded loaded descendant matches. Collapsed, loading,
* error, and unloaded directories only appear when their own name matches
* (callers should treat those paths as unavailable in getChildren; no
* server-side recursive search).
*/
export const filterSftpTreeEntriesByName = <T extends { name: string }>(
files: T[],
filter: string,
options: SftpTreeNameFilterOptions<T>,
): T[] => {
const term = filter.trim().toLowerCase();
if (!term) return files;
const subtreeHasMatch = (entries: T[], parentPath: string): boolean => {
for (const entry of entries) {
if (entry.name === "..") continue;
if (entry.name.toLowerCase().includes(term)) return true;
if (!options.isDirectory(entry)) continue;
const entryPath = options.joinPath(parentPath, entry.name);
const children = options.getChildren(entryPath);
if (children && subtreeHasMatch(children, entryPath)) return true;
}
return false;
};
return files.filter((entry) => {
if (entry.name === "..") return true;
if (entry.name.toLowerCase().includes(term)) return true;
if (!options.isDirectory(entry)) return false;
const entryPath = options.joinPath(options.parentPath, entry.name);
const children = options.getChildren(entryPath);
return Boolean(children && subtreeHasMatch(children, entryPath));
});
};
+223
View File
@@ -274,6 +274,229 @@ test("self-heals a stale anchor on render while no adjustToInput has fired", ()
}
});
test("self-heal adopts live X/Y when echo wraps instead of double-counting", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Prompt ends at col 8; four pending cells predict X=12 on a 10-col
// terminal (wraps to col 2 / row 1). When echo lands there, Math.max
// on X alone would keep 12 and paint the ghost on row 2.
term.cols = 10;
term.buffer.active.cursorX = 8;
term.buffer.active.cursorY = 0;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ ",
});
addon.activate(term as never);
addon.show("abcdefghij", "abcd");
const ghost = ghostElement();
assert.ok(ghost);
// Predicted wrap: col 2 on row 1 → left 18px, top 18px.
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "18px");
term.buffer.active.cursorX = 2;
term.buffer.active.cursorY = 1;
term.buffer.active.getLine = () => ({
isWrapped: true,
translateToString: () => "abcd",
});
fireRender();
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "18px");
} finally {
restoreDocument();
}
});
test("self-heal adopts live X when a bottom-row wrap scrolls instead of changing Y", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement, fireRender } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Bottom-row prediction: prompt at col 8 + 4 pending cells → X=12.
// Echo scrolls the buffer so Y stays on the last row while live X
// becomes the normalized wrap column (2). Math.max would keep 12 and
// paint the ghost one row below the visible screen.
term.cols = 10;
term.rows = 24;
term.buffer.active.cursorX = 8;
term.buffer.active.cursorY = 23;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ ",
});
addon.activate(term as never);
addon.show("abcdefghij", "abcd");
const ghost = ghostElement();
assert.ok(ghost);
// Pre-echo: col 2 on predicted row 24 → top 24*18.
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "432px");
term.buffer.active.cursorX = 2;
term.buffer.active.cursorY = 23;
term.buffer.active.getLine = () => ({
isWrapped: true,
translateToString: () => "abcd",
});
fireRender();
assert.equal(ghost.style.left, "18px");
assert.equal(ghost.style.top, "414px");
} finally {
restoreDocument();
}
});
test("anchors ghost after wide pre-echo input when the line has not echoed yet", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ ",
});
addon.activate(term as never);
// Live cursor still at the prompt; typed "部署" is only in the keystroke buffer.
addon.show("部署脚本", "部署");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, "脚本");
// cursorX=2 + 4 wide cells → column 6 → left 54px.
assert.equal(ghost.style.left, "54px");
} finally {
restoreDocument();
}
});
test("anchors ghost using only the unechoed suffix after a partial shell echo", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Shell has echoed "$ doc"; buffered input is still the full "docker".
term.buffer.active.cursorX = 5;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: () => "$ doc",
});
addon.activate(term as never);
addon.show("docker compose", "docker");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, " compose");
// Unechoed "ker" is 3 cells → column 8 → left 72px (not 5+6=11).
assert.equal(ghost.style.left, "72px");
} finally {
restoreDocument();
}
});
test("anchors ghost by cell columns when the prompt is a multi-code-unit emoji", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// Family ZWJ emoji is many UTF-16 units but only 2 terminal cells.
// A UTF-16 slice(0, cursorX) would stop inside the emoji and treat the
// fully-echoed "docker" as unechoed, shifting the ghost right.
const emoji = "👨‍👩‍👧‍👦";
const cells: Array<{ chars: string; width: number }> = [
{ chars: emoji, width: 2 },
{ chars: " ", width: 1 },
{ chars: "$", width: 1 },
{ chars: " ", width: 1 },
...Array.from("docker", (ch) => ({ chars: ch, width: 1 })),
];
const totalCols = cells.reduce((sum, cell) => sum + cell.width, 0);
term.buffer.active.cursorX = totalCols;
term.buffer.active.baseY = 0;
term.buffer.active.getLine = () => ({
translateToString: (
_trimRight?: boolean,
startColumn = 0,
endColumn = totalCols,
) => {
let col = 0;
let text = "";
for (const cell of cells) {
if (col >= endColumn) break;
if (col >= startColumn) text += cell.chars;
col += cell.width;
}
return text;
},
});
addon.activate(term as never);
addon.show("docker compose", "docker");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, " compose");
// Fully echoed → stay at live cursor (11 cells → 99px), not 11+6.
assert.equal(ghost.style.left, "99px");
} finally {
restoreDocument();
}
});
test("does not overshoot the ghost when the echoed command already wrapped", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
const addon = new GhostTextAddon();
try {
// cols=10, prompt "$ " + "docker com" wraps; cursor sits on the
// continuation row after a fully-echoed "docker compose".
// Physical rows: "$ docker c" | "ompose|"
term.cols = 10;
term.buffer.active.baseY = 0;
term.buffer.active.cursorY = 1;
term.buffer.active.cursorX = 6;
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
0: { text: "$ docker c" },
1: { isWrapped: true, text: "ompose" },
};
term.buffer.active.getLine = (y: number) => {
const row = lines[y];
if (!row) return undefined;
return {
isWrapped: row.isWrapped,
translateToString: (
_trimRight?: boolean,
startColumn?: number,
endColumn?: number,
) => {
if (startColumn !== undefined && endColumn !== undefined) {
return row.text.padEnd(endColumn).slice(startColumn, endColumn);
}
return row.text;
},
};
};
addon.activate(term as never);
addon.show("docker compose up", "docker compose");
const ghost = ghostElement();
assert.ok(ghost);
assert.equal(ghost.textContent, " up");
// Fully echoed across the wrap → anchor stays at live cursor (col 6),
// not liveX + full input width.
assert.equal(ghost.style.left, "54px");
assert.equal(ghost.style.top, "18px");
} finally {
restoreDocument();
}
});
test("wraps the ghost to the previous row when deletion crosses a row boundary", () => {
const restoreDocument = installFakeDocument();
const { term, ghostElement } = createFakeTerm();
@@ -59,6 +59,61 @@ test("keeps raw input when a standard shell prompt echo is still behind", () =>
assert.equal(result.prompt.cursorOffset, 2);
assert.equal(result.alignedTyped, null);
});
test("uses reliable typed buffer when prompt echo has not started (IME / high-latency commit)", () => {
const term = createFakeTerm("$ ", 2);
const result = getAlignedPrompt(term as never, "部署", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, "$ ");
assert.equal(result.prompt.userInput, "部署");
assert.equal(result.prompt.cursorOffset, 2);
// Pre-echo input is surfaced for alignment, but empty echo alone must not
// authorize history recording or any completions (password-shaped
// `read -s -p '$ '` prompts look identical until echo arrives).
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("uses typed buffer before echo on padded themed prompts", () => {
// robbyrussell-style PS1 pads after the arrow: detectPrompt keeps the
// trailing space in userInput, which must still count as visually empty.
const term = createFakeTerm("➜ ", 3);
const result = getAlignedPrompt(term as never, "部署", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.userInput, "部署");
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("uses typed buffer before echo on Nerd Font themed terminators", () => {
const term = createFakeTerm(" ", 2);
const result = getAlignedPrompt(term as never, "部署", true);
assert.equal(result.prompt.isAtPrompt, true);
assert.equal(result.prompt.promptText, " ");
assert.equal(result.prompt.userInput, "部署");
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("does not treat empty-echo shell-shaped prompts as validated typed input", () => {
const term = createFakeTerm("$ ", 2);
const secret = "s3cret-token";
const result = getAlignedPrompt(term as never, secret, true);
assert.equal(result.prompt.isAtPrompt, true);
// Keystroke buffer is still visible on the prompt view, but empty echo
// must not authorize history recording or suggestion acceptance.
assert.equal(result.prompt.userInput, secret);
assert.equal(result.alignedTyped, null);
assert.equal(result.allowExternalProviders, false);
});
test("still trims prompt decorations out of the detected input", () => {
const term = createFakeTerm("➜ ~ do", 7);
@@ -95,6 +95,7 @@ export function TerminalAutocomplete({
) => {
const normalizedProtocol: NetcattyTerminalSessionSnapshot['protocol'] = protocol ?? "ssh";
const pluginRegistry = isPluginCompletionProviderAvailable?.() === false
|| options.allowExternalProviders === false
|| !shouldUsePluginTerminalCompletionProvider({
sensitiveInputActive: sensitiveInputActiveRef.current === true,
promptText: options.promptText,
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
@@ -7,6 +8,8 @@ import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { Host } from "../../types.ts";
import { TerminalToolbar } from "./TerminalToolbar.tsx";
const toolbarSource = readFileSync(new URL("./TerminalToolbar.tsx", import.meta.url), "utf8");
const sshHost: Host = {
id: "host-1",
label: "Host",
@@ -169,3 +172,23 @@ test("uses the terminal active button color for pressed toolbar actions", () =>
/aria-label="Search terminal"[^>]*style="background-color:var\(--terminal-toolbar-btn-active\)"/,
);
});
test("compact scripts popover hosts bulk-delete confirm outside the popover", () => {
// Dialog focus leaves PopoverContent; if confirm stays inside ScriptsSidePanel,
// the popover closes, isVisible clears pendingDeleteIds, and the prompt dies.
assert.match(toolbarSource, /onBulkDeleteRequest=\{setPendingScriptDeleteIds\}/);
assert.match(toolbarSource, /<VaultDeleteConfirmDialog/);
// Popup vault mutation goes through the prop; the event only clears popover selection.
assert.match(toolbarSource, /onDeleteSnippets\?\.\(new Set\(ids\)\)/);
assert.match(toolbarSource, /netcatty:snippets:delete/);
const scriptsPopoverIdx = toolbarSource.indexOf("open={scriptsPopoverOpen}");
const popoverEndIdx = toolbarSource.indexOf("</Popover>", scriptsPopoverIdx);
const dialogIdx = toolbarSource.indexOf("<VaultDeleteConfirmDialog", popoverEndIdx);
assert.ok(scriptsPopoverIdx >= 0, "scripts popover open binding");
assert.ok(popoverEndIdx > scriptsPopoverIdx, "scripts popover closes");
assert.ok(dialogIdx > popoverEndIdx, "confirm dialog is a sibling after the popover");
assert.equal(
toolbarSource.includes("document.querySelector('[data-vault-delete-confirm=\"true\"]')"),
false,
);
});
+34
View File
@@ -29,6 +29,7 @@ import type { ToolbarItemLayoutDefaults } from '../../domain/toolbarItemLayout';
import { STORAGE_KEY_TERMINAL_TOOLBAR_LAYOUT } from '../../infrastructure/config/storageKeys';
import { Host, Snippet } from '../../types';
import { ScriptsSidePanel } from '../ScriptsSidePanel';
import { VaultDeleteConfirmDialog } from '../vault/VaultDeleteConfirmDialog';
import { Button } from '../ui/button';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
import { ToolbarCustomizeContextMenu } from '../ui/toolbar-item-layout';
@@ -90,6 +91,8 @@ export interface TerminalToolbarProps {
snippets?: Snippet[];
snippetPackages?: string[];
onSnippetClick?: (snippet: Snippet) => void;
/** Popup vault mutation path — compact toolbar has no AppSideEffects listener. */
onDeleteSnippets?: (ids: ReadonlySet<string>) => void;
onOpenSFTP: () => void;
onSendYmodem?: () => void;
onReceiveYmodem?: () => void;
@@ -127,6 +130,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
snippets = [],
snippetPackages = [],
onSnippetClick,
onDeleteSnippets,
onOpenSFTP,
onSendYmodem,
onReceiveYmodem,
@@ -179,6 +183,12 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
.sort(comparePluginMenus);
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
const [scriptsPopoverOpen, setScriptsPopoverOpen] = useState(false);
// Owned outside the scripts Popover so portalled Dialog focus cannot dismiss
// the menu and unmount ScriptsSidePanel before the user confirms.
const [pendingScriptDeleteIds, setPendingScriptDeleteIds] = useState<string[] | null>(null);
const pendingScriptDeleteCount = (pendingScriptDeleteIds ?? []).filter((id) =>
snippets.some((snippet) => snippet.id === id),
).length;
// Overflow popover + encoding submenu are both controlled so that
// picking an encoding closes the whole chain, and so the parent popover
// can ignore clicks that land in the submenu portal (otherwise the
@@ -379,6 +389,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
snippets={snippets}
packages={snippetPackages}
isVisible={scriptsPopoverOpen}
onBulkDeleteRequest={setPendingScriptDeleteIds}
onSnippetClick={(snippet) => {
onSnippetClick?.(snippet);
setScriptsPopoverOpen(false);
@@ -386,6 +397,29 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
/>
</PopoverContent>
</Popover>
<VaultDeleteConfirmDialog
open={Boolean(pendingScriptDeleteIds)}
title={t('snippets.selection.deleteConfirmTitle', {
count: pendingScriptDeleteCount,
})}
description={t('snippets.selection.deleteConfirmDesc')}
onOpenChange={(open) => {
if (!open) setPendingScriptDeleteIds(null);
}}
onConfirm={() => {
const ids = (pendingScriptDeleteIds ?? []).filter((id) =>
snippets.some((snippet) => snippet.id === id),
);
setPendingScriptDeleteIds(null);
if (ids.length === 0) return;
// Popup terminals own vault state locally (no AppSideEffects).
onDeleteSnippets?.(new Set(ids));
// Still emit so ScriptsSidePanel can clear multi-select in the popover.
window.dispatchEvent(
new CustomEvent('netcatty:snippets:delete', { detail: { ids } }),
);
}}
/>
</TooltipProvider>
</div>
);
@@ -10,42 +10,7 @@
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
import { lineHasUntrackedTrailingInput } from "./ghostTextConsistency";
/**
* Minimal East-Asian-Width-style classifier: returns 2 for wide glyphs
* (CJK ideographs, fullwidth forms, most emoji, hangul syllables) and
* 1 otherwise. Not full wcwidth — just enough to keep the predicted
* ghost column from drifting by one cell per CJK char typed.
*/
function codePointCellWidth(cp: number): number {
if (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals, Kangxi
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK Compat
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji blocks
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Extension B-F, G
) {
return 2;
}
return 1;
}
function stringCellWidth(s: string): number {
let w = 0;
for (const ch of s) {
const cp = ch.codePointAt(0) ?? 0;
w += codePointCellWidth(cp);
}
return w;
}
import { stringCellWidth } from "./terminalStringCellWidth";
function commonPrefixLength(a: string, b: string): number {
const max = Math.min(a.length, b.length);
@@ -54,6 +19,15 @@ function commonPrefixLength(a: string, b: string): number {
return i;
}
/** Longest prefix of `input` that is already a suffix of `beforeCursor`. */
function echoedInputPrefixLength(beforeCursor: string, input: string): number {
let n = Math.min(beforeCursor.length, input.length);
while (n > 0 && !beforeCursor.endsWith(input.slice(0, n))) {
n -= 1;
}
return n;
}
function hasVisibleGhostPrefix(ghostText: string, afterCursor: string): boolean {
if (!ghostText || !afterCursor) return false;
const visibleAfterCursor = afterCursor.trimEnd();
@@ -67,6 +41,55 @@ function hasVisibleGhostPrefix(ghostText: string, afterCursor: string): boolean
);
}
type BufferLineLike = {
isWrapped?: boolean;
translateToString?: (
trimRight?: boolean,
startColumn?: number,
endColumn?: number,
) => string;
};
type ActiveBufferLike = {
baseY: number;
cursorY: number;
cursorX: number;
getLine?: (y: number) => BufferLineLike | undefined;
};
/**
* Text before the cursor across wrapped physical rows. `getLine` only returns
* one row, so a wrapped command's current row cannot end with the full
* `currentInput` — callers must reconstruct the logical line or they will
* treat already-echoed text as unechoed.
*/
function readBeforeCursorAcrossWraps(
buf: ActiveBufferLike,
cols: number,
): string | null {
if (typeof buf.getLine !== "function") return null;
const absY = buf.baseY + buf.cursorY;
let line = buf.getLine(absY);
if (!line || typeof line.translateToString !== "function") return null;
// cursorX is a cell column, not a UTF-16 offset — slice() breaks on
// wide / multi-code-unit graphemes (emoji prompts, CJK).
let beforeCursor = line.translateToString(false, 0, buf.cursorX);
let y = absY;
while (line.isWrapped && y > 0) {
y -= 1;
line = buf.getLine(y);
if (!line || typeof line.translateToString !== "function") break;
// Keep wrap seams aligned with the terminal width (do not trimRight).
const rowCols = cols > 0 ? cols : undefined;
const rowText = rowCols === undefined
? line.translateToString(false)
: line.translateToString(false, 0, rowCols);
beforeCursor = rowText + beforeCursor;
}
return beforeCursor;
}
export class GhostTextAddon implements IDisposable {
private term: XTerm | null = null;
private ghostElement: HTMLSpanElement | null = null;
@@ -201,8 +224,36 @@ export class GhostTextAddon implements IDisposable {
this.currentSuggestion = fullSuggestion;
this.currentInput = currentInput;
this.anchorCursorX = this.term.buffer.active.cursorX;
this.anchorCursorY = this.term.buffer.active.cursorY;
const buf = this.term.buffer.active;
const liveX = buf.cursorX;
// When show() runs before the shell echoes `currentInput` (CJK IME /
// high-latency SSH), live cursorX is still at the prompt. Advance the
// anchor by the pending input's cell width so the ghost sits after it
// instead of painting over it. Skip the probe when getLine is unavailable
// (unit fakes) so those tests keep the legacy "cursor already at end"
// contract.
let anchorX = liveX;
if (
currentInput.length > 0 &&
typeof buf.getLine === "function"
) {
const beforeCursor = readBeforeCursorAcrossWraps(
buf as ActiveBufferLike,
this.term.cols,
);
if (beforeCursor !== null && !beforeCursor.endsWith(currentInput)) {
// Shell may have echoed only a prefix (e.g. "$ doc" while
// currentInput is "docker"). Advance by the unechoed suffix only —
// adding the full input width on top of a partially-advanced liveX
// overshoots and Math.max self-heal cannot move the ghost left.
const unechoed = currentInput.slice(
echoedInputPrefixLength(beforeCursor, currentInput),
);
anchorX = liveX + stringCellWidth(unechoed, this.term);
}
}
this.anchorCursorX = anchorX;
this.anchorCursorY = buf.cursorY;
this.anchorInputLength = currentInput.length;
// Force position recalc since the text also changed.
this.lastLeft = -1;
@@ -399,14 +450,34 @@ export class GhostTextAddon implements IDisposable {
private updatePosition(): void {
if (!this.term || !this.ghostElement) return;
// Self-heal a stale anchor: when show() fires during the SSH
// keystroke→echo gap, cursorX captured there is still the
// Self-heal a stale anchor: when show() fired during the SSH
// keystroke→echo gap without a line probe, cursorX may still be the
// pre-echo column. While no adjustToInput has moved us from the
// show-time baseline, re-read live cursor on each render tick so
// the anchor snaps to the echoed position once it arrives.
// show-time baseline, adopt a live cursor that has advanced (echo
// caught up). Use max on the same row so a cell-width-predicted
// pre-echo anchor is not collapsed back onto the prompt before echo
// arrives. When the live row advances, the predicted X may already
// encode a wrap (column >= cols); adopting the live X/Y pair avoids
// counting that wrap again in the modulo math below.
// When the predicted wrap happens on the bottom row, the echo scrolls
// the buffer and Y stays put — adopt live X/Y once it matches the
// normalized wrap column so Math.max cannot keep the unnormalized X.
if (this.currentInput.length === this.anchorInputLength) {
this.anchorCursorX = this.term.buffer.active.cursorX;
this.anchorCursorY = this.term.buffer.active.cursorY;
const liveX = this.term.buffer.active.cursorX;
const liveY = this.term.buffer.active.cursorY;
const cols = Math.max(1, this.term.cols);
if (liveY !== this.anchorCursorY) {
this.anchorCursorX = liveX;
this.anchorCursorY = liveY;
} else if (
this.anchorCursorX >= cols &&
liveX === this.anchorCursorX % cols
) {
this.anchorCursorX = liveX;
this.anchorCursorY = liveY;
} else {
this.anchorCursorX = Math.max(this.anchorCursorX, liveX);
}
}
const dims = getXTermCellDimensions(this.term);
@@ -417,12 +488,13 @@ export class GhostTextAddon implements IDisposable {
// advance by 2 cells instead of 1. Backspace / Ctrl-W produces a
// negative delta by shrinking currentInput below anchorInputLength.
const cellDelta = this.currentInput.length >= this.anchorInputLength
? stringCellWidth(this.currentInput.slice(this.anchorInputLength))
? stringCellWidth(this.currentInput.slice(this.anchorInputLength), this.term)
: -stringCellWidth(
// currentSuggestion[0..anchorInputLength] equals what was typed
// when show() fired (prefix-match invariant), so its slice gives
// the correct cell widths for the deleted glyphs.
this.currentSuggestion.slice(this.currentInput.length, this.anchorInputLength),
this.term,
);
const cols = Math.max(1, this.term.cols);
const targetCol = this.anchorCursorX + cellDelta;
@@ -142,26 +142,30 @@ export async function hasSpec(commandName: string): Promise<boolean> {
return availableSpecsSet?.has(commandName) ?? false;
}
/**
* Common shell commands preloaded when autocomplete is enabled.
* Includes local overrides under electron/specs/ (e.g. yum, dnf, awk).
*/
export const COMMON_FIG_SPECS = [
"git", "docker", "kubectl", "npm", "yarn", "pnpm",
"ls", "cd", "cat", "grep", "find", "ssh", "scp",
"curl", "wget", "tar", "zip", "unzip", "make",
"python", "python3", "pip", "pip3", "node",
"systemctl", "journalctl", "apt", "yum", "dnf", "brew",
"vim", "nano", "less", "head", "tail", "sort",
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
] as const;
/**
* Preload commonly used specs in batches to avoid overwhelming IPC.
* Only call this when autocomplete is enabled.
*/
export function preloadCommonSpecs(): void {
const common = [
"git", "docker", "kubectl", "npm", "yarn", "pnpm",
"ls", "cd", "cat", "grep", "find", "ssh", "scp",
"curl", "wget", "tar", "zip", "unzip", "make",
"python", "python3", "pip", "pip3", "node",
"systemctl", "journalctl", "apt", "yum", "brew",
"vim", "nano", "less", "head", "tail", "sort",
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
];
const BATCH_SIZE = 8;
let offset = 0;
const loadBatch = () => {
const batch = common.slice(offset, offset + BATCH_SIZE);
const batch = COMMON_FIG_SPECS.slice(offset, offset + BATCH_SIZE);
if (batch.length === 0) return;
for (const name of batch) {
@@ -169,7 +173,7 @@ export function preloadCommonSpecs(): void {
}
offset += BATCH_SIZE;
if (offset < common.length) {
if (offset < COMMON_FIG_SPECS.length) {
if (typeof requestIdleCallback === "function") {
requestIdleCallback(() => loadBatch());
} else {
@@ -66,6 +66,14 @@ export interface AlignedPromptResult {
* to record it as the executed command.
*/
alignedTyped: string | null;
/**
* When false, `prompt.userInput` was filled from the keystroke buffer
* before any shell echo. Empty echo is also what echo-disabled password
* prompts look like, so callers must not surface or accept completions
* (built-in or external) and must not authorize history recording
* (`alignedTyped`). Omitted/true means the live line validated input.
*/
allowExternalProviders?: boolean;
}
function getCursorLinePrefix(term: XTerm): string | null {
@@ -392,6 +400,10 @@ function canUseReliablePromptPrefix(
raw: PromptDetectionResult,
typedBuffer: string,
): boolean {
// Empty echo alone is not validation: echo-disabled prompts can look like
// a normal shell PS1 (e.g. `read -s -p '$ '`), and treating the keystroke
// buffer as alignedTyped would authorize history recording. Pre-echo
// autocomplete uses a separate path that keeps alignedTyped null.
if (!raw.isAtPrompt || typedBuffer.length === 0 || raw.userInput.length === 0) {
return false;
}
@@ -909,6 +921,22 @@ export function getAlignedPrompt(
alignedTyped: typedBuffer,
};
}
// No echo yet (CJK IME / high-latency SSH): surface the keystroke buffer
// on prompts detectPrompt already recognizes (#2813), but do not set
// alignedTyped. Empty / whitespace-only echo is also what echo-disabled
// password prompts and padded themed PS1s look like, so this path must
// not authorize history recording, built-in suggestion acceptance, or
// third-party completion providers until echo validates the line.
if (
raw.userInput.trim().length === 0 &&
(allowsShortPromptEcho(raw.promptText) || isThemedPromptText(raw.promptText))
) {
return {
prompt: withTypedUserInput(raw, typedBuffer),
alignedTyped: null,
allowExternalProviders: false,
};
}
}
const cursorPrefixCandidates = [
@@ -2,10 +2,12 @@
* Snippet completion source. Surfaces custom snippets in terminal autocomplete
* when the user is typing the command name. Matches against the snippet label
* and the first line of its command (case-insensitive; prefix matches rank
* above substring matches). Each suggestion carries the full Snippet so the
* accept path can run it through the canonical executeSnippetCommand.
* above substring matches). Chinese labels also match via pinyin / initials
* through the shared search matcher (#2813). Each suggestion carries the full
* Snippet so the accept path can run it through the canonical executeSnippetCommand.
*/
import type { Snippet } from "../../../domain/models";
import { matchesSearchQuery } from "../../../lib/searchMatcher";
import type { CompletionSuggestion } from "./completionEngine";
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
@@ -34,7 +36,12 @@ export function getSnippetSuggestions(
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
const labelPrefix = label.startsWith(needle);
const matches = labelPrefix || label.includes(needle) || firstLine.startsWith(needle);
// Literal prefix/substring first (cheap); fall back to shared smart matcher
// so Chinese titles surface for pinyin / initials the same way host search does.
const matches = labelPrefix
|| label.includes(needle)
|| firstLine.startsWith(needle)
|| matchesSearchQuery(needle, snippet.label, firstLine);
if (!matches) continue;
out.push({
@@ -2,6 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import type { CompletionSuggestion } from "./completionEngine";
import type { PromptDetectionResult } from "./promptDetector";
import type { SubDirPanel } from "./useTerminalAutocomplete";
import { stringCellWidth } from "./terminalStringCellWidth";
import { getXTermCellDimensions } from "./xtermUtils";
export function resolveAutocompleteCwd(
@@ -338,34 +339,79 @@ function shouldExpandAutocompleteUpward(
return cursorY > 2 && spaceAbovePx >= spaceBelowPx;
}
/** Predicted cursor cell for popup anchoring (column within the row + row). */
export type AutocompleteCursorCell = {
column: number;
row: number;
};
/**
* Best-effort cursor column for popup anchoring. xterm's helper textarea and
* Best-effort cursor cell for popup anchoring. xterm's helper textarea and
* buffer.cursorX can lag behind the keystroke that triggered completion, so
* derive the column from the aligned prompt when the command still fits on one
* row.
* derive the column from the aligned prompt and wrap onto following rows when
* unechoed wide input crosses `term.cols`.
*
* When the live cursor already sits on a soft-wrapped continuation row,
* measure from the logical line start so a still-unechoed `userInput` suffix
* advances past the partial wrap instead of anchoring at the lagged cell.
*
* A wrap past the last visible row scrolls the buffer; xterm keeps the cursor
* on `term.rows - 1`. Clamp the predicted row so a completion that resolves
* before that scroll does not place the popup one cell below the grid.
*/
export function resolveAutocompleteCursorCell(
term: XTerm,
prompt: Pick<PromptDetectionResult, "promptText" | "userInput">,
): AutocompleteCursorCell {
const buffer = term.buffer.active;
const cols = Math.max(1, Number(term.cols) || 80);
const termRows = Number(term.rows);
const absY = buffer.cursorY + buffer.baseY;
// Walk back to the first physical row of this soft-wrapped logical line.
let startAbsY = absY;
let startLine = buffer.getLine(startAbsY);
while (startLine?.isWrapped && startAbsY > 0) {
startAbsY -= 1;
startLine = buffer.getLine(startAbsY);
}
const startRowY = startAbsY - buffer.baseY;
let fromLine = (buffer.cursorY - startRowY) * cols + buffer.cursorX;
const cursorLine = buffer.getLine(absY);
if (cursorLine) {
const lineText = cursorLine.translateToString(false);
const tail = lineText.substring(buffer.cursorX).trimEnd();
if (tail.length === 0) {
const endCol = Math.max(buffer.cursorX, lineText.trimEnd().length);
fromLine = (buffer.cursorY - startRowY) * cols + endCol;
}
}
// Use xterm's active Unicode width so CJK / emoji / fullwidth glyphs in
// the synthetic pre-echo userInput advance the popup with the same cell
// count as the real cursor (#2813).
const fromPrompt =
stringCellWidth(prompt.promptText, term) + stringCellWidth(prompt.userInput, term);
const rawColumn = Math.max(fromLine, fromPrompt);
const predictedRow = Math.max(0, startRowY + Math.floor(rawColumn / cols));
// Only clamp when the terminal reports a real viewport height; missing
// `rows` (tests/mocks) must not collapse every wrap onto row 0.
const row = Number.isFinite(termRows) && termRows > 0
? Math.min(predictedRow, termRows - 1)
: predictedRow;
return {
column: rawColumn % cols,
row,
};
}
/** Column-only helper for callers that do not need the predicted wrap row. */
export function resolveAutocompleteCursorColumn(
term: XTerm,
prompt: Pick<PromptDetectionResult, "promptText" | "userInput">,
): number {
const buffer = term.buffer.active;
const absY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(absY);
if (line?.isWrapped) {
return buffer.cursorX;
}
let fromLine = buffer.cursorX;
if (line) {
const lineText = line.translateToString(false);
const tail = lineText.substring(buffer.cursorX).trimEnd();
if (tail.length === 0) {
fromLine = Math.max(buffer.cursorX, lineText.trimEnd().length);
}
}
const fromPrompt = prompt.promptText.length + prompt.userInput.length;
return Math.max(fromLine, fromPrompt);
return resolveAutocompleteCursorCell(term, prompt).column;
}
/** Clamp autocomplete popups to the active terminal screen in split workspaces.
@@ -413,6 +459,7 @@ export function resolveAutocompleteAnchorInViewport(
container: HTMLElement | null,
itemCount: number,
cursorColumn = term.buffer.active.cursorX,
cursorRow = term.buffer.active.cursorY,
): AutocompleteViewportAnchor {
const empty: AutocompleteViewportAnchor = {
anchorLeft: 0,
@@ -422,8 +469,6 @@ export function resolveAutocompleteAnchorInViewport(
};
if (!container || !term.element) return empty;
const buffer = term.buffer.active;
const cursorY = buffer.cursorY;
const rows = Math.max(1, term.rows);
const estimatedPopupHeight = estimatePopupHeight(itemCount);
const dims = getXTermCellDimensions(term);
@@ -434,15 +479,15 @@ export function resolveAutocompleteAnchorInViewport(
?? container;
const screenRect = screen.getBoundingClientRect();
const anchorLeft = screenRect.left + cursorColumn * dims.width;
const anchorTop = screenRect.top + cursorY * dims.height;
const anchorBottom = screenRect.top + (cursorY + 1) * dims.height;
const spaceBelow = Math.max(0, (rows - cursorY - 1) * dims.height);
const spaceAbove = Math.max(0, cursorY * dims.height);
const anchorTop = screenRect.top + cursorRow * dims.height;
const anchorBottom = screenRect.top + (cursorRow + 1) * dims.height;
const spaceBelow = Math.max(0, (rows - cursorRow - 1) * dims.height);
const spaceAbove = Math.max(0, cursorRow * dims.height);
return {
anchorLeft,
anchorTop,
anchorBottom,
expandUpward: shouldExpandAutocompleteUpward(cursorY, spaceBelow, spaceAbove, estimatedPopupHeight),
expandUpward: shouldExpandAutocompleteUpward(cursorRow, spaceBelow, spaceAbove, estimatedPopupHeight),
};
}
@@ -0,0 +1,104 @@
/**
* Terminal cell-column width for autocomplete / ghost positioning.
*
* When an xterm instance is available, prefer its active Unicode provider
* (`15-graphemes` via UnicodeGraphemesAddon) so emoji / VS-16 clusters match
* the cursor advance. Fall back to a small East-Asian-Width-style classifier
* for unit fakes that lack `_core.unicodeService`.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
type UnicodeServiceLike = {
getStringCellWidth?: (s: string) => number;
};
type TermWithUnicodeService = {
_core?: {
unicodeService?: UnicodeServiceLike;
};
};
const unicodeMarkPattern = /\p{Mark}/u;
function codePointCellWidth(cp: number): number {
// Zero-width joiners / format / variation selectors / marks — xterm
// folds these into the surrounding grapheme (wcwidth 0 or shouldJoin).
if (
cp === 0x00ad ||
cp === 0x200d || // ZWJ
(cp >= 0x200b && cp <= 0x200f) || // ZWSP..RLM
(cp >= 0x202a && cp <= 0x202e) || // bidi overrides
(cp >= 0x2060 && cp <= 0x206f) || // word joiner, invisible ops
(cp >= 0xfe00 && cp <= 0xfe0f) || // Variation Selectors
cp === 0xfeff ||
(cp >= 0x1f3fb && cp <= 0x1f3ff) || // Emoji skin-tone modifiers
(cp >= 0xe0100 && cp <= 0xe01ef) || // Variation Selectors Supplement
unicodeMarkPattern.test(String.fromCodePoint(cp))
) {
return 0;
}
if (
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals, Kangxi
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK Compat
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji blocks
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Extension B-F, G
) {
return 2;
}
return 1;
}
function graphemeCellWidth(grapheme: string): number {
let max = 0;
for (const ch of grapheme) {
const w = codePointCellWidth(ch.codePointAt(0) ?? 0);
if (w > max) max = w;
}
return max;
}
const graphemeSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
function fallbackStringCellWidth(s: string): number {
if (graphemeSegmenter) {
let w = 0;
for (const { segment } of graphemeSegmenter.segment(s)) {
w += graphemeCellWidth(segment);
}
return w;
}
// Fallback without Segmenter: sum code-point widths (ZWJ/marks already 0).
let w = 0;
for (const ch of s) {
w += codePointCellWidth(ch.codePointAt(0) ?? 0);
}
return w;
}
/** Terminal cell columns occupied by `s` (wide glyphs / grapheme clusters). */
export function stringCellWidth(
s: string,
term?: XTerm | TermWithUnicodeService | null,
): number {
if (!s) return 0;
const unicodeService = (term as TermWithUnicodeService | null | undefined)
?._core?.unicodeService;
const getWidth = unicodeService?.getStringCellWidth;
if (typeof getWidth === "function") {
return getWidth.call(unicodeService, s);
}
return fallbackStringCellWidth(s);
}
@@ -32,7 +32,7 @@ import {
areSubDirPanelsEqual,
areSuggestionsEqual,
resolveAutocompleteAnchorInViewport,
resolveAutocompleteCursorColumn,
resolveAutocompleteCursorCell,
resolveAutocompleteCwdWithSource,
} from "./terminalAutocompleteLayout";
import { handleTerminalAutocompleteInput } from "./terminalAutocompleteInput";
@@ -72,6 +72,9 @@ export const DEFAULT_AUTOCOMPLETE_SETTINGS: AutocompleteSettings = {
historyScope: "host",
};
/** Max time to poll for shell echo after a pre-echo debounce cycle (#2813). */
const ECHO_VALIDATION_MAX_WAIT_MS = 3000;
/**
* Whether completion work is worth doing — i.e. whether anything would
* actually be rendered. With both the popup and ghost text disabled, querying
@@ -124,6 +127,11 @@ export interface AutocompleteState {
type HostCompletionProviderOptions = Parameters<typeof getCompletions>[1] & {
/** Host-owned prompt identity used to gate third-party Provider access. */
promptText: string;
/**
* False when input was synthesized from the pre-echo keystroke buffer.
* External Providers must stay disabled until the live line validates input.
*/
allowExternalProviders?: boolean;
/** Aborted whenever the prompt/session security state invalidates this query. */
signal?: AbortSignal;
};
@@ -213,6 +221,14 @@ export function useTerminalAutocomplete(
const ghostAddonRef = useRef<GhostTextAddon | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/**
* Pre-echo debounce cycles must poll until the live line validates the
* keystroke buffer (or we give up). PTY echo updates xterm only and does
* not schedule another fetchSuggestions — without this, whole-word IME /
* high-latency SSH commits never show completions until the next key.
*/
const echoValidationTypedRef = useRef<string | null>(null);
const echoValidationStartedAtRef = useRef<number | null>(null);
const lastKeystrokeRef = useRef<number>(0);
const lastPromptRef = useRef<PromptDetectionResult | null>(null);
const disposedRef = useRef(false);
@@ -346,6 +362,8 @@ export function useTerminalAutocomplete(
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
echoValidationTypedRef.current = null;
echoValidationStartedAtRef.current = null;
ghostAddonRef.current?.hide();
completionAbortRef.current?.abort();
completionAbortRef.current = null;
@@ -364,14 +382,18 @@ export function useTerminalAutocomplete(
setState((prev) => {
if (!prev.popupVisible || prev.suggestions.length === 0) return prev;
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
const cursorColumn = prompt.isAtPrompt
? resolveAutocompleteCursorColumn(term, prompt)
: term.buffer.active.cursorX;
const cursorCell = prompt.isAtPrompt
? resolveAutocompleteCursorCell(term, prompt)
: {
column: term.buffer.active.cursorX,
row: term.buffer.active.cursorY,
};
const anchor = resolveAutocompleteAnchorInViewport(
term,
containerRef.current,
prev.suggestions.length,
cursorColumn,
cursorCell.column,
cursorCell.row,
);
// Force a re-render even when the relative cursor cell hasn't changed.
@@ -669,12 +691,49 @@ export function useTerminalAutocomplete(
return;
}
const { prompt, allowExternalProviders = true } = getAlignedPrompt(
term,
typedInputBufferRef.current,
typedBufferReliableRef.current,
);
lastPromptRef.current = prompt;
// Pre-echo keystroke buffer can look identical to an echo-disabled
// password prompt (`read -s -p '$ '`). Do not render or accept
// built-in history/snippet suggestions until the shell echoes input.
// Incoming PTY echo does not re-schedule fetches, so keep polling this
// debounce cycle until the live line validates — or until max wait
// (silent prompts never echo). clearState cancels timers and echo-wait
// refs; re-arm the wait afterward when still within the window.
if (allowExternalProviders === false) {
const typed = typedInputBufferRef.current;
const startedAt =
echoValidationTypedRef.current === typed &&
echoValidationStartedAtRef.current != null
? echoValidationStartedAtRef.current
: Date.now();
clearState();
const withinWait =
typed.length > 0 &&
!disposedRef.current &&
Date.now() - startedAt < ECHO_VALIDATION_MAX_WAIT_MS;
if (withinWait) {
echoValidationTypedRef.current = typed;
echoValidationStartedAtRef.current = startedAt;
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
void fetchSuggestionsRef.current();
}, settingsRef.current.debounceMs);
}
return;
}
echoValidationTypedRef.current = null;
echoValidationStartedAtRef.current = null;
// Capture version at start — if it changes during async work, discard results
const version = ++fetchVersionRef.current;
const { prompt } = getAlignedPrompt(term, typedInputBufferRef.current, typedBufferReliableRef.current);
lastPromptRef.current = prompt;
if (!prompt.isAtPrompt || prompt.userInput.length < settingsRef.current.minChars) {
clearState();
return;
@@ -716,6 +775,7 @@ export function useTerminalAutocomplete(
cwdSource: cwdResolution.source,
snippets: snippetsRef.current,
promptText: prompt.promptText,
allowExternalProviders,
signal: completionController.signal,
});
} finally {
@@ -766,12 +826,13 @@ export function useTerminalAutocomplete(
// Live-preview baseline: the typed input these suggestions completed.
previewBaselineRef.current = input;
previewActiveRef.current = false;
const cursorColumn = resolveAutocompleteCursorColumn(term, currentPrompt);
const cursorCell = resolveAutocompleteCursorCell(term, currentPrompt);
const anchor = resolveAutocompleteAnchorInViewport(
term,
containerRef.current,
completions.length,
cursorColumn,
cursorCell.column,
cursorCell.row,
);
startTransition(() => {
setState((prev) => {
+14
View File
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import { COMMON_FIG_SPECS, normalizeCommandName } from "./autocomplete/figSpecLoader";
test("preload common specs includes dnf alongside yum and apt", () => {
assert.ok(COMMON_FIG_SPECS.includes("apt"));
assert.ok(COMMON_FIG_SPECS.includes("yum"));
assert.ok(COMMON_FIG_SPECS.includes("dnf"));
});
test("normalizeCommandName strips path and extension", () => {
assert.equal(normalizeCommandName("/usr/bin/dnf"), "dnf");
assert.equal(normalizeCommandName("DNF"), "dnf");
});
@@ -95,6 +95,12 @@ import {
resolveShiftEnterText,
shouldSendShiftEnterText,
} from "./shiftEnterText";
import {
isUnchangedDeferredImeTextInput,
shouldBlockKeyPressForImeTextInput,
shouldCommitDeferredImeTextInput,
shouldDeferKeyDownForImeTextInput,
} from "./terminalImeTextInput";
import { formatSerialLocalEcho } from "./serialLocalEcho";
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
import { formatTelnetLocalEcho } from "./telnetLocalEcho";
@@ -1142,6 +1148,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
let kittyCompositionPending = false;
let kittyCompositionClearTimer: number | undefined;
let imeTextInputDeferredKey: string | null = null;
let imeTextInputDeferredKittyEvent: KittyKeyboardEvent | null = null;
const kittyForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const broadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>();
const broadcastEncodedKeys = new Set<string>();
@@ -1151,6 +1159,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
let suppressNextTerminalDataBroadcast = false;
let broadcastLegacyDataPending: string | null = null;
let broadcastLegacyDataClearTimer: number | undefined;
const clearImeTextInputDeferral = () => {
imeTextInputDeferredKey = null;
imeTextInputDeferredKittyEvent = null;
};
const clearBroadcastLegacyDataPending = () => {
broadcastLegacyDataPending = null;
suppressNextTerminalDataBroadcast = false;
@@ -1177,6 +1189,137 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
getDispatcher: () => ctx.onBroadcastInputRef.current,
});
const commitImeTextInput = (text: string) => {
const deferredKey = imeTextInputDeferredKey;
const deferredKittyEvent = imeTextInputDeferredKittyEvent;
clearImeTextInputDeferral();
clearBroadcastLegacyDataPending();
// Deferred punctuation reaches the PTY via this manual commit instead of
// xterm's keydown → onUserInput pipeline, so SelectionService never clears.
// Match ordinary typed characters unless preserveSelectionOnInput is on.
if (
!ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
term.hasSelection()
) {
term.clearSelection();
}
// Unchanged ASCII must keep the Kitty press/release path. Composition
// encoding under report-all emits unidentified CSI 0 u (or associated
// text without a physical key), so ordinary punctuation breaks in TUIs.
if (
isUnchangedDeferredImeTextInput(deferredKey, text) &&
deferredKittyEvent &&
kittyKeyboardProtocolEnabled
) {
const pressEvent: KittyKeyboardEvent = {
...deferredKittyEvent,
type: "keydown",
};
const sequence = encodeKittyKeyEvent(kittyKeyboardMode, pressEvent);
if (sequence) {
const identity = pressEvent.code || pressEvent.key;
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
identity,
pressEvent,
[],
);
handleTerminalInputData(sequence, { source: "kitty" });
const forwarded = broadcastKittyInput({
kind: "key",
event: pressEvent,
fallbackToLegacy: true,
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
identity,
pressEvent,
forwarded.targetSessionIds,
);
}
return;
}
}
if (isUnchangedDeferredImeTextInput(deferredKey, text)) {
// Source may write the literal glyph (Kitty off / no sequence), but
// broadcast peers still need the physical key + paired release so
// report-all/event-type targets do not receive composition text.
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
}
handleTerminalInputData(text);
if (deferredKittyEvent) {
const pressEvent: KittyKeyboardEvent = {
...deferredKittyEvent,
type: "keydown",
};
const identity = pressEvent.code || pressEvent.key;
// Event-type mode (flag 2) without report-all leaves printable presses
// on the text path, but still emits a paired :3 release on keyup.
// Mirror the ordinary keydown handler so that release is not dropped.
if (
kittyKeyboardProtocolEnabled &&
shouldTrackKittyKeyRelease(kittyKeyboardMode, pressEvent)
) {
upsertKittyKeyboardForwardedPress(
kittyForwardedKeys,
identity,
pressEvent,
[],
);
}
const forwarded = broadcastKittyInput({
kind: "key",
event: pressEvent,
fallbackToLegacy: true,
});
if (forwarded) {
upsertKittyKeyboardForwardedPress(
broadcastForwardedKeys,
identity,
pressEvent,
forwarded.targetSessionIds,
);
}
} else {
broadcastKittyInput({ kind: "text", text });
}
return;
}
// Actual IME remap — Kitty associated-text composition when negotiated;
// otherwise send the committed glyph (report-all alone cannot carry it).
const encoded = encodeKittyCompositionText(kittyKeyboardMode, text);
if (encoded) {
handleTerminalInputData(encoded, { source: "kitty" });
} else {
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
suppressNextTerminalDataBroadcast = true;
}
handleTerminalInputData(text);
}
broadcastKittyInput({ kind: "text", text });
};
const flushImeTextInputDeferral = () => {
const fallback = imeTextInputDeferredKey;
if (!fallback) return;
commitImeTextInput(fallback);
};
const armImeTextInputDeferral = (event: KeyboardEvent) => {
clearImeTextInputDeferral();
clearBroadcastLegacyDataPending();
// Wait for insertText (full-width) before keyup; keyup/blur flushes ASCII
// when the IME did not remap the key (English punctuation mode).
imeTextInputDeferredKey = event.key;
// Keep the physical key even when the source is not in Kitty mode so
// broadcast targets can still receive paired key events.
imeTextInputDeferredKittyEvent = toKittyKeyboardEvent(event);
};
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
// Preserve mouse selection across keystrokes when enabled. xterm.js
// unconditionally clears the selection on user input
@@ -1221,6 +1364,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
if (e.type === "keyup") {
// insertText for this keystroke has already run when present; flush the
// deferred ASCII key only when the IME did not remap it.
if (imeTextInputDeferredKey !== null && e.key === imeTextInputDeferredKey) {
flushImeTextInputDeferral();
}
const identity = kittyKeyIdentity(e);
if (broadcastLegacyDataPending === identity) clearBroadcastLegacyDataPending();
const forwardedPress = broadcastForwardedKeys.get(identity);
@@ -1246,6 +1394,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return true;
}
// Block keypress so xterm cannot re-emit the half-width ASCII char after we
// deferred the matching keydown for IME insertText (#2833).
if (shouldBlockKeyPressForImeTextInput(imeTextInputDeferredKey, e)) {
return false;
}
if (e.type !== "keydown") {
return true;
}
@@ -1557,6 +1711,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
}
// Sogou/macOS CJK punctuation: keydown still reports ASCII "," while the
// full-width "," arrives on insertText. Returning false (without
// preventDefault) stops xterm/Kitty from sending the half-width key so the
// input listener can commit the real glyph (#2833).
if (shouldDeferKeyDownForImeTextInput(e)) {
armImeTextInputDeferral(e);
return false;
}
if (kittySequenceForKeyDown) {
e.preventDefault();
e.stopPropagation();
@@ -1723,6 +1886,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}, 0);
};
const clearKittyConnectionInputState = () => {
// Drop deferred IME punctuation on session reset; do not write into the
// next connection. Focus loss uses clearKittyTransientInputState instead.
clearImeTextInputDeferral();
kittyCompositionPending = false;
kittyForwardedKeys.clear();
clearKittyKeyboardBroadcastPairingState(
@@ -1736,6 +1902,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
};
const clearKittyTransientInputState = () => {
flushImeTextInputDeferral();
flushKittyKeyboardBroadcastReleases(
kittyForwardedKeys,
(input) => {
@@ -1755,6 +1922,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const textarea = term.textarea;
const startKittyComposition = () => markKittyCompositionPending();
const markKittyTextInput = (event: InputEvent) => {
if (shouldCommitDeferredImeTextInput(imeTextInputDeferredKey, event)) {
commitImeTextInput(event.data);
// Keep the helper textarea empty so the next composition does not treat
// the deferred punctuation as pre-existing suffix text.
try {
if (textarea) textarea.value = "";
} catch {
// ignore
}
return;
}
if (shouldMarkKittyTextInputEvent(event)) markKittyCompositionPending(true);
};
textarea?.addEventListener("compositionstart", startKittyComposition);
@@ -456,8 +456,8 @@ test("broadcast composition text follows each target's negotiated mode", () => {
urgentInterrupt: false,
});
assert.deepEqual(resolve(reportAll), {
data: "\u001b[0u",
kittyEncoded: true,
data: "你a",
kittyEncoded: false,
urgentInterrupt: false,
});
assert.deepEqual(resolve(associatedText), {
@@ -688,8 +688,9 @@ test("associated text supports multiple code points and pure composition text",
assert.equal(encodeKittyCompositionText(state, "\n\u0085"), null);
});
test("report-all without associated text emits an unidentified CSI-u composition event", () => {
assert.equal(encodeKittyCompositionText(withFlags(8), "你"), "\u001b[0u");
test("report-all without associated text leaves composition text for literal fallback", () => {
assert.equal(encodeKittyCompositionText(withFlags(8), "你"), null);
assert.equal(encodeKittyCompositionText(withFlags(8), ","), null);
});
test("encodes the complete functional, keypad, media, and modifier ranges", () => {
@@ -766,7 +766,9 @@ export const encodeKittyCompositionText = (
): string | null => {
const flags = getKittyKeyboardModeFlags(state);
if (!(flags & KITTY_KEYBOARD_REPORT_ALL_KEYS_AS_ESC_CODES)) return null;
if (!(flags & KITTY_KEYBOARD_REPORT_ASSOCIATED_TEXT)) return "\u001b[0u";
// Without associated text, CSI 0 u is unidentified and drops the glyph.
// Callers fall back to the literal committed text instead.
if (!(flags & KITTY_KEYBOARD_REPORT_ASSOCIATED_TEXT)) return null;
const points = textCodePoints(text);
return points.length > 0 ? `\u001b[0;;${points.join(":")}u` : null;
};
@@ -0,0 +1,18 @@
const commandInjectionReadyReaders = new Map<string, () => boolean>();
export function registerTerminalCommandInjectionReadyReader(
sessionId: string,
reader: () => boolean,
): () => void {
commandInjectionReadyReaders.set(sessionId, reader);
return () => {
if (commandInjectionReadyReaders.get(sessionId) === reader) {
commandInjectionReadyReaders.delete(sessionId);
}
};
}
/** True when the live terminal reports an idle shell prompt ready for injection. */
export function isTerminalReadyForCommandInjection(sessionId: string): boolean {
return commandInjectionReadyReaders.get(sessionId)?.() === true;
}
@@ -0,0 +1,166 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
isAsciiPunctuationKey,
isUnchangedDeferredImeTextInput,
shouldBlockKeyPressForImeTextInput,
shouldCommitDeferredImeTextInput,
shouldDeferKeyDownForImeTextInput,
} from "./terminalImeTextInput";
const runtimeSource = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "createXTermRuntime.ts"),
"utf8",
);
test("isAsciiPunctuationKey accepts common remappable punctuation", () => {
for (const key of [",", ".", "/", ";", "'", "[", "]", "\\", "-", "=", "`", "?", "!", ":", '"', "<", ">", "{", "}", "|", "_", "+", "~", "@", "#", "$", "%", "^", "&", "*", "(", ")"]) {
assert.equal(isAsciiPunctuationKey(key), true, key);
}
});
test("isAsciiPunctuationKey rejects letters, digits, space, and CJK", () => {
for (const key of ["a", "Z", "0", " ", ",", "、", "?", "Enter", "ArrowLeft"]) {
assert.equal(isAsciiPunctuationKey(key), false, key);
}
});
test("shouldDeferKeyDownForImeTextInput defers bare ASCII punctuation keydowns", () => {
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", keyCode: 188 }),
true,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: "?", keyCode: 191 }),
true,
);
});
test("shouldDeferKeyDownForImeTextInput leaves composition and modified keys alone", () => {
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", keyCode: 229 }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", isComposing: true }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", ctrlKey: true }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: "a", keyCode: 65 }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keypress", key: "," }),
false,
);
});
test("shouldBlockKeyPressForImeTextInput only while a deferral is armed", () => {
assert.equal(shouldBlockKeyPressForImeTextInput(",", { type: "keypress" }), true);
assert.equal(shouldBlockKeyPressForImeTextInput(null, { type: "keypress" }), false);
assert.equal(shouldBlockKeyPressForImeTextInput(",", { type: "keydown" }), false);
});
test("shouldCommitDeferredImeTextInput accepts insertText payloads while deferred", () => {
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertText", data: "," }),
true,
);
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertText", data: "," }),
true,
);
assert.equal(
shouldCommitDeferredImeTextInput(null, { inputType: "insertText", data: "," }),
false,
);
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertFromPaste", data: "," }),
false,
);
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertText", data: null }),
false,
);
});
test("isUnchangedDeferredImeTextInput detects English punctuation flush", () => {
assert.equal(isUnchangedDeferredImeTextInput(",", ","), true);
assert.equal(isUnchangedDeferredImeTextInput(",", ","), false);
assert.equal(isUnchangedDeferredImeTextInput(null, ","), false);
});
test("createXTermRuntime defers ASCII punctuation keydowns to insertText", () => {
assert.match(runtimeSource, /shouldDeferKeyDownForImeTextInput\(e\)/);
assert.match(runtimeSource, /armImeTextInputDeferral\(e\)/);
assert.match(runtimeSource, /shouldBlockKeyPressForImeTextInput\(imeTextInputDeferredKey, e\)/);
assert.match(
runtimeSource,
/shouldCommitDeferredImeTextInput\(imeTextInputDeferredKey, event\)/,
);
assert.match(runtimeSource, /commitImeTextInput\(event\.data\)/);
assert.match(runtimeSource, /isUnchangedDeferredImeTextInput\(deferredKey, text\)/);
assert.match(runtimeSource, /imeTextInputDeferredKittyEvent/);
// Manual commit bypasses xterm onUserInput; clear selection unless preserved.
assert.match(
runtimeSource,
/!ctx\.terminalSettingsRef\.current\?\.preserveSelectionOnInput/,
);
assert.match(runtimeSource, /term\.clearSelection\(\)/);
const clearSelIdx = runtimeSource.indexOf("term.clearSelection()");
const commitIdx = runtimeSource.indexOf("const commitImeTextInput = (text: string)");
const firstHandleIdx = runtimeSource.indexOf(
"handleTerminalInputData",
commitIdx,
);
assert.ok(
commitIdx >= 0 &&
clearSelIdx > commitIdx &&
firstHandleIdx > clearSelIdx,
);
// Must run before Kitty/xterm send the half-width key from keydown.
const deferIdx = runtimeSource.indexOf("shouldDeferKeyDownForImeTextInput(e)");
const kittySendIdx = runtimeSource.indexOf("if (kittySequenceForKeyDown)");
assert.ok(deferIdx >= 0 && kittySendIdx > deferIdx);
// Unchanged ASCII must encode via Kitty key events, not composition text.
const unchangedIdx = runtimeSource.indexOf("isUnchangedDeferredImeTextInput(deferredKey, text)");
const compositionIdx = runtimeSource.indexOf(
"encodeKittyCompositionText(kittyKeyboardMode, text)",
);
assert.ok(unchangedIdx >= 0 && compositionIdx > unchangedIdx);
// Even when the source writes the literal glyph, broadcast peers still get
// the deferred physical key (with legacy fallback), not composition text.
const armIdx = runtimeSource.indexOf("const armImeTextInputDeferral");
assert.ok(armIdx >= 0);
const armSlice = runtimeSource.slice(armIdx, armIdx + 700);
assert.match(
armSlice,
/imeTextInputDeferredKittyEvent = toKittyKeyboardEvent\(event\)/,
);
assert.doesNotMatch(
armSlice,
/imeTextInputDeferredKittyEvent = kittyKeyboardProtocolEnabled/,
);
const unchangedFallbackIdx = runtimeSource.indexOf(
"isUnchangedDeferredImeTextInput(deferredKey, text))",
unchangedIdx + 1,
);
assert.ok(unchangedFallbackIdx > unchangedIdx);
assert.match(
runtimeSource.slice(unchangedFallbackIdx, unchangedFallbackIdx + 1800),
/handleTerminalInputData\(text\);[\s\S]*shouldTrackKittyKeyRelease\(kittyKeyboardMode, pressEvent\)[\s\S]*upsertKittyKeyboardForwardedPress\(\s*kittyForwardedKeys,[\s\S]*broadcastKittyInput\(\{[\s\S]*kind: "key",[\s\S]*fallbackToLegacy: true,/,
);
// Remap path must fall back to literal text when composition encoding is null
// (report-all without associated text).
assert.match(
runtimeSource.slice(compositionIdx, compositionIdx + 420),
/if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(text\);/,
);
});
@@ -0,0 +1,70 @@
/**
* CJK IMEs (notably Sogou on macOS) often emit a keydown whose `event.key` is
* still the half-width ASCII punctuation, then commit the full-width glyph via
* an `input`/`insertText` event. xterm.js sends the keydown character and then
* drops the input event because `_keyDownSeen` is set — so the PTY receives
* "," instead of ",".
*
* Defer those keydowns to the following insertText. If no remap arrives before
* keyup/blur, the original ASCII key is flushed. Composition (keyCode 229 /
* isComposing) stays on xterm's CompositionHelper path.
*/
export type ImeTextInputKeyEvent = {
type?: string;
key: string;
keyCode?: number;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
isComposing?: boolean;
};
export type ImeTextInputEvent = Pick<InputEvent, "data" | "inputType">;
/** Printable ASCII punctuation IMEs commonly remap to full-width forms. */
const ASCII_PUNCTUATION_RE = /^[\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]$/;
export function isAsciiPunctuationKey(key: string): boolean {
return ASCII_PUNCTUATION_RE.test(key);
}
export function shouldDeferKeyDownForImeTextInput(
event: ImeTextInputKeyEvent,
): boolean {
if (event.type !== undefined && event.type !== "keydown") return false;
if (event.isComposing === true || event.keyCode === 229) return false;
if (event.altKey || event.ctrlKey || event.metaKey) return false;
return isAsciiPunctuationKey(event.key);
}
export function shouldBlockKeyPressForImeTextInput(
deferredKey: string | null | undefined,
event: Pick<ImeTextInputKeyEvent, "type">,
): boolean {
return Boolean(deferredKey) && event.type === "keypress";
}
export function shouldCommitDeferredImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputEvent,
): event is ImeTextInputEvent & { data: string } {
return (
Boolean(deferredKey) &&
event.inputType === "insertText" &&
typeof event.data === "string" &&
event.data.length > 0
);
}
/**
* True when insertText/flush kept the deferred ASCII key (no CJK remap).
* Those commits must not use Kitty composition encoding — under report-all
* that emits unidentified CSI 0 u and drops press/release.
*/
export function isUnchangedDeferredImeTextInput(
deferredKey: string | null | undefined,
text: string,
): boolean {
return deferredKey != null && text === deferredKey;
}
@@ -46,3 +46,17 @@ test("no match returns empty; empty input returns empty", () => {
assert.deepEqual(getSnippetSuggestions("zzz", [snip({})], {}), []);
assert.deepEqual(getSnippetSuggestions("", [snip({})], {}), []);
});
test("matches Chinese labels by literal Chinese input", () => {
const s = snip({ id: "zh", label: "部署服务", command: "kubectl apply -f ." });
const out = getSnippetSuggestions("部署", [s], {});
assert.equal(out.length, 1);
assert.equal(out[0].snippet?.id, "zh");
assert.equal(out[0].displayText, "部署服务");
});
test("matches Chinese labels by pinyin and initials (smart suggest)", () => {
const s = snip({ id: "zh", label: "部署服务", command: "kubectl apply -f ." });
assert.equal(getSnippetSuggestions("bushu", [s], {})[0]?.snippet?.id, "zh");
assert.equal(getSnippetSuggestions("bsfw", [s], {})[0]?.snippet?.id, "zh");
});
@@ -6,6 +6,7 @@ import {
computeAutocompletePopupPlacement,
resolveAutocompleteAnchorInViewport,
resolveAutocompleteClampViewport,
resolveAutocompleteCursorCell,
resolveAutocompleteCursorColumn,
type PopupPlacementInput,
} from "./autocomplete/terminalAutocompleteLayout.ts";
@@ -274,6 +275,144 @@ test("resolveAutocompleteCursorColumn prefers prompt-aligned column when xterm l
assert.equal(column, "root@host:~# ".length + 1);
});
test("resolveAutocompleteCursorColumn counts wide glyphs as two cells for pre-echo input", () => {
const term = {
cols: 80,
buffer: {
active: {
cursorX: 2,
cursorY: 0,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const column = resolveAutocompleteCursorColumn(term as never, {
promptText: "$ ",
userInput: "部署",
});
// "$ " = 2 cells, each CJK ideograph = 2 cells → column 6 (not char-length 4).
assert.equal(column, 6);
});
test("resolveAutocompleteCursorCell wraps synthetic pre-echo columns onto the next row", () => {
const term = {
cols: 10,
rows: 24,
buffer: {
active: {
cursorX: 8,
cursorY: 0,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const cell = resolveAutocompleteCursorCell(term as never, {
promptText: "$ ",
// Four wide cells after a prompt ending at column 8 → raw column 12.
userInput: "部署",
});
assert.equal(cell.column, 2);
assert.equal(cell.row, 1);
});
test("resolveAutocompleteCursorCell clamps a bottom-row wrap to the visible grid", () => {
// Pending wrap from the last visible row would predict row === term.rows,
// but xterm scrolls and keeps the cursor on term.rows - 1.
const term = {
cols: 10,
rows: 24,
buffer: {
active: {
cursorX: 8,
cursorY: 23,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const cell = resolveAutocompleteCursorCell(term as never, {
promptText: "$ ",
userInput: "部署",
});
assert.equal(cell.column, 2);
assert.equal(cell.row, 23);
});
test("resolveAutocompleteCursorCell advances past a partial wrap using unechoed userInput", () => {
// cols=10: "$ docker c" | "o|" — echo wrapped after a prefix while the
// buffered input is still the full "docker compose". Anchoring at the
// live wrapped cell (col 1) would leave the popup behind.
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
0: { text: "$ docker c" },
1: { isWrapped: true, text: "o" },
};
const term = {
cols: 10,
rows: 24,
buffer: {
active: {
cursorX: 1,
cursorY: 1,
baseY: 0,
getLine: (y: number) => {
const row = lines[y];
if (!row) return undefined;
return {
isWrapped: row.isWrapped,
translateToString: () => row.text,
};
},
},
},
};
const cell = resolveAutocompleteCursorCell(term as never, {
promptText: "$ ",
userInput: "docker compose",
});
// "$ " + "docker compose" = 16 cells → col 6 on row 1.
assert.equal(cell.column, 6);
assert.equal(cell.row, 1);
});
test("resolveAutocompleteCursorColumn counts ZWJ emoji graphemes as one wide cluster", () => {
const term = {
buffer: {
active: {
cursorX: 2,
cursorY: 0,
baseY: 0,
getLine: () => ({
isWrapped: false,
translateToString: () => "$ ",
}),
},
},
};
const column = resolveAutocompleteCursorColumn(term as never, {
promptText: "$ ",
// Technologist emoji is one grapheme (👨 + ZWJ + 💻); xterm paints it
// as two cells, not five code-point widths.
userInput: "👨‍💻",
});
assert.equal(column, 4);
});
test("short popup flips upward when the cursor is at the bottom of the screen", () => {
// Regression for issue #1710: a *short* suggestion list must flip up when
// the anchor line is the last visible row, even if there is a tiny positive
+2
View File
@@ -108,6 +108,8 @@ export interface TerminalProps {
compactToolbar?: boolean;
/** Line timestamps are unavailable in popup terminals that stream shell output without timestamp metadata. */
lineTimestampsAvailable?: boolean;
/** Compact/popup path: delete snippets against the caller's vault hook. */
onDeleteSnippets?: (ids: ReadonlySet<string>) => void;
chainHosts?: Host[];
appearanceTheme?: TerminalTheme;
knownHosts?: KnownHost[];
+1
View File
@@ -15,6 +15,7 @@ export const terminalPropsAreEqual = (
&& prev.snippetPackages === next.snippetPackages
&& prev.compactToolbar === next.compactToolbar
&& prev.lineTimestampsAvailable === next.lineTimestampsAvailable
&& prev.onDeleteSnippets === next.onDeleteSnippets
&& prev.chainHosts === next.chainHosts
&& themeFingerprint(prev.appearanceTheme ?? prev.terminalTheme) === themeFingerprint(next.appearanceTheme ?? next.terminalTheme)
&& prev.knownHosts === next.knownHosts
@@ -58,7 +58,7 @@ test('disabled or absent plugin hosts do not receive terminal completion request
const autocompleteSource = readFileSync(new URL('./TerminalAutocomplete.tsx', import.meta.url), 'utf8');
assert.match(
autocompleteSource,
/const pluginRegistry = isPluginCompletionProviderAvailable\?\.\(\) === false[\s\S]*?shouldUsePluginTerminalCompletionProvider[\s\S]*?\? null\s*\n\s*: getWindowPluginTerminalProviderRegistry\(\)/,
/const pluginRegistry = isPluginCompletionProviderAvailable\?\.\(\) === false[\s\S]*?options\.allowExternalProviders === false[\s\S]*?shouldUsePluginTerminalCompletionProvider[\s\S]*?\? null\s*\n\s*: getWindowPluginTerminalProviderRegistry\(\)/,
);
assert.match(
terminalSource,
@@ -0,0 +1,101 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
const require = createRequire(import.meta.url);
test("stringCellWidth counts ASCII as one cell each", () => {
assert.equal(stringCellWidth("docker"), 6);
});
test("stringCellWidth counts CJK ideographs as two cells each", () => {
assert.equal(stringCellWidth("部署"), 4);
});
test("stringCellWidth collapses ZWJ emoji to one wide grapheme", () => {
assert.equal(stringCellWidth("👨‍💻"), 2);
});
test("stringCellWidth ignores combining marks inside a grapheme", () => {
// e + combining acute accent
assert.equal(stringCellWidth("e\u0301"), 1);
});
test("stringCellWidth matches xterm 15-graphemes for common emoji clusters", () => {
class El {
tagName: string;
style: Record<string, string> = {};
children: El[] = [];
classList = { add() {}, remove() {}, contains() { return false; } };
constructor(tag: string) { this.tagName = tag; }
appendChild(c: El) { this.children.push(c); return c; }
removeChild(c: El) { return c; }
addEventListener() {}
removeEventListener() {}
setAttribute() {}
getAttribute() { return null; }
getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0 }; }
remove() {}
}
const previous = {
document: globalThis.document,
window: globalThis.window,
HTMLElement: globalThis.HTMLElement,
Element: globalThis.Element,
DocumentFragment: globalThis.DocumentFragment,
getComputedStyle: globalThis.getComputedStyle,
requestAnimationFrame: globalThis.requestAnimationFrame,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
};
// Minimal DOM so @xterm/xterm can construct a Terminal in node tests.
(globalThis as { document?: unknown }).document = {
createElement: (t: string) => new El(t),
createDocumentFragment: () => new El("frag"),
addEventListener() {},
removeEventListener() {},
};
(globalThis as { window?: unknown }).window = globalThis;
(globalThis as { HTMLElement?: unknown }).HTMLElement = El;
(globalThis as { Element?: unknown }).Element = El;
(globalThis as { DocumentFragment?: unknown }).DocumentFragment = El;
(globalThis as { getComputedStyle?: unknown }).getComputedStyle = () => ({
getPropertyValue: () => "",
});
(globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame = (
cb: (t: number) => void,
) => setTimeout(() => cb(0), 0);
(globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame = (
id: number,
) => clearTimeout(id);
try {
const xterm = require("@xterm/xterm") as typeof import("@xterm/xterm");
const graphemes = require("@xterm/addon-unicode-graphemes") as typeof import("@xterm/addon-unicode-graphemes");
const term = new xterm.Terminal({ cols: 80, rows: 24, allowProposedApi: true });
term.loadAddon(new graphemes.UnicodeGraphemesAddon());
term.unicode.activeVersion = "15-graphemes";
const samples = ["🇨🇳", "1️⃣", "©️", "🖥", "👨‍💻", "部署", "docker"];
for (const s of samples) {
const expected = (
term as unknown as {
_core: { unicodeService: { getStringCellWidth: (v: string) => number } };
}
)._core.unicodeService.getStringCellWidth(s);
assert.equal(
stringCellWidth(s, term),
expected,
`${JSON.stringify(s)} should match xterm width ${expected}`,
);
}
// These disagree with the hand-rolled fallback; the term path must win.
assert.equal(stringCellWidth("🇨🇳", term), 2);
assert.equal(stringCellWidth("1️⃣", term), 2);
assert.equal(stringCellWidth("©️", term), 2);
assert.equal(stringCellWidth("🖥", term), 1);
} finally {
Object.assign(globalThis, previous);
}
});
@@ -187,6 +187,7 @@ function SidePanelSftpSlotInner({
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={panelActiveHost}
activeSessionId={isVisible ? live.activeTerminalSessionIdForSftp : null}
focusedSessionId={isVisible ? live.focusedSessionId : null}
initialLocation={isVisible ? (sftpInitialLocationForTab.get(tabId) ?? null) : null}
onInitialLocationApplied={handleInitialLocationApplied}
onCurrentPathChange={handleCurrentPathChange}
+27
View File
@@ -7,6 +7,7 @@ import {
quoteRestoreCwdForShell,
resolveRestoredActiveTabId,
resolveInheritedCwdIntent,
resolveInteractiveTerminalCdIntent,
resolveRestoreCwdIntent,
sanitizeSessionRestorePayload,
shouldAttemptRestoreCwd,
@@ -680,6 +681,32 @@ test("quoteRestoreCwdForShell treats cwd as shell data", () => {
assert.equal(quoteRestoreCwdForShell("/tmp/it's ok; $(rm -rf ~)"), "'/tmp/it'\\''s ok; $(rm -rf ~)'");
});
test("resolveInteractiveTerminalCdIntent quotes path-only cd without session transport checks", () => {
assert.deepEqual(resolveInteractiveTerminalCdIntent("/srv/app dir"), {
cwd: "/srv/app dir",
command: "cd -- '/srv/app dir'",
});
assert.deepEqual(resolveInteractiveTerminalCdIntent("/srv/project "), {
cwd: "/srv/project ",
command: "cd -- '/srv/project '",
});
assert.equal(resolveInteractiveTerminalCdIntent("C:\\Users\\alice"), null);
});
test("resolveInteractiveTerminalCdIntent rejects control bytes that readline would interpret", () => {
// Tab / ESC / Ctrl-U / newline / DEL must not be typed into an interactive PTY.
assert.equal(resolveInteractiveTerminalCdIntent("/srv/app\tdir"), null);
assert.equal(resolveInteractiveTerminalCdIntent("/srv/app\u001bdir"), null);
assert.equal(resolveInteractiveTerminalCdIntent("/srv/app\u0015dir"), null);
assert.equal(resolveInteractiveTerminalCdIntent("/srv/app\ndir"), null);
assert.equal(resolveInteractiveTerminalCdIntent("/srv/app\u007fdir"), null);
// Trailing spaces remain valid POSIX names and must still quote exactly.
assert.deepEqual(resolveInteractiveTerminalCdIntent("/srv/project "), {
cwd: "/srv/project ",
command: "cd -- '/srv/project '",
});
});
test("resolveRestoreCwdIntent captures a one-shot restore command", () => {
assert.deepEqual(resolveRestoreCwdIntent({
enabled: true,
+21
View File
@@ -246,6 +246,27 @@ function quoteRestoreCwdArgument(cwd: string): string {
return quoteRestoreCwdForShell(cwd);
}
/** True when a path contains C0/DEL bytes that interactive readline would act on. */
function pathHasInteractivePtyControlBytes(path: string): boolean {
for (let i = 0; i < path.length; i++) {
const code = path.charCodeAt(i);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/** Build a user-initiated `cd` for an absolute/home path (SFTP -> terminal). */
export function resolveInteractiveTerminalCdIntent(
cwd: string | null | undefined,
): { cwd: string; command: string } | null {
// Eligibility uses a normalized view; keep the exact path for quoting so
// trailing whitespace in POSIX names is preserved.
if (!isRestoreCwdPathEligible(cwd)) return null;
// Shell quoting does not protect tab/ESC/Ctrl-U while readline processes keystrokes.
if (pathHasInteractivePtyControlBytes(cwd)) return null;
return { cwd, command: `cd -- ${quoteRestoreCwdArgument(cwd)}` };
}
export function resolveRestoreCwdIntent(options: {
enabled: boolean;
session: RestoreCwdSession;
+115
View File
@@ -0,0 +1,115 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
canLocateSftpPathInTerminal,
resolveLocateSftpPathInTerminalAction,
resolveLocateSftpPathSessionId,
} from "./sftpLocatePathInTerminal.ts";
const base = {
path: "/home/user/project",
sessionId: "sess-1",
sessionStatus: "connected" as const,
sessionHostId: "host-1",
sftpHostId: "host-1",
sftpIsLocal: false,
protocol: "ssh" as const,
shellType: "posix" as const,
isNetworkDevice: false,
};
test("canLocateSftpPathInTerminal allows connected SSH sessions on the same host", () => {
assert.equal(canLocateSftpPathInTerminal(base), true);
});
test("canLocateSftpPathInTerminal allows mosh/et interactive cd (unlike silent restore)", () => {
assert.equal(canLocateSftpPathInTerminal({ ...base, moshEnabled: true }), true);
assert.equal(canLocateSftpPathInTerminal({ ...base, etEnabled: true }), true);
assert.equal(canLocateSftpPathInTerminal({ ...base, protocol: "mosh" }), true);
assert.equal(canLocateSftpPathInTerminal({ ...base, protocol: "et" }), true);
});
test("canLocateSftpPathInTerminal rejects missing session, disconnected session, or host mismatch", () => {
assert.equal(canLocateSftpPathInTerminal({ ...base, sessionId: null }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, sessionStatus: "disconnected" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, sftpHostId: "other-host" }), false);
});
test("canLocateSftpPathInTerminal rejects same hostId with different live endpoints", () => {
assert.equal(
canLocateSftpPathInTerminal({
...base,
sessionHostname: "a.example.test",
sessionUsername: "root",
sessionPort: 22,
sftpHostname: "b.example.test",
sftpUsername: "root",
sftpPort: 22,
}),
false,
);
});
test("resolveLocateSftpPathSessionId prefers reusable active session then focused fallback", () => {
assert.equal(
resolveLocateSftpPathSessionId({ activeSessionId: "ssh-reuse", focusedSessionId: "focused" }),
"ssh-reuse",
);
assert.equal(
resolveLocateSftpPathSessionId({ activeSessionId: null, focusedSessionId: "mosh-1" }),
"mosh-1",
);
assert.equal(
resolveLocateSftpPathSessionId({ activeSessionId: null, focusedSessionId: null }),
null,
);
});
test("canLocateSftpPathInTerminal rejects network devices, telnet/serial, and Windows local shells", () => {
assert.equal(canLocateSftpPathInTerminal({ ...base, isNetworkDevice: true }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, protocol: "telnet" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, protocol: "serial" }), false);
assert.equal(
canLocateSftpPathInTerminal({
...base,
protocol: "local",
shellType: "powershell",
sftpIsLocal: true,
sessionHostId: "local-1",
sftpHostId: "local-1",
}),
false,
);
});
test("canLocateSftpPathInTerminal rejects ineligible paths", () => {
assert.equal(canLocateSftpPathInTerminal({ ...base, path: "" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, path: "C:\\Users\\alice" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, path: "relative/path" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, path: "/srv/app\tdir" }), false);
assert.equal(canLocateSftpPathInTerminal({ ...base, path: "/srv/app\u001bdir" }), false);
});
test("resolveLocateSftpPathInTerminalAction builds a quoted cd payload", () => {
assert.deepEqual(
resolveLocateSftpPathInTerminalAction({
...base,
path: "/srv/app dir",
}),
{
sessionId: "sess-1",
data: "cd -- '/srv/app dir'\r",
},
);
});
test("resolveLocateSftpPathInTerminalAction returns null when locate is unavailable", () => {
assert.equal(
resolveLocateSftpPathInTerminalAction({
...base,
sessionStatus: "connecting",
}),
null,
);
});
+79
View File
@@ -0,0 +1,79 @@
import { resolveInteractiveTerminalCdIntent } from "./sessionRestore";
export type LocateSftpPathInTerminalContext = {
path?: string | null;
sessionId?: string | null;
sessionStatus?: string | null;
sessionHostId?: string | null;
sftpHostId?: string | null;
sftpIsLocal?: boolean;
protocol?: string | null;
shellType?: string | null;
isNetworkDevice?: boolean;
moshEnabled?: boolean;
etEnabled?: boolean;
sessionHostname?: string | null;
sessionUsername?: string | null;
sessionPort?: number | null;
sftpHostname?: string | null;
sftpUsername?: string | null;
sftpPort?: number | null;
};
/**
* Prefer the SFTP-reusable SSH session id when present; otherwise use the
* focused terminal (mosh/et/local) so locate is not stuck behind connection reuse.
*/
export function resolveLocateSftpPathSessionId(options: {
activeSessionId?: string | null;
focusedSessionId?: string | null;
}): string | null {
return options.activeSessionId ?? options.focusedSessionId ?? null;
}
function remoteEndpointsMatch(options: LocateSftpPathInTerminalContext): boolean {
if (!options.sessionHostname || !options.sftpHostname) return true;
return options.sessionHostname === options.sftpHostname
&& (options.sessionPort ?? 22) === (options.sftpPort ?? 22)
&& (options.sessionUsername || "root") === (options.sftpUsername || "root");
}
/** Whether the SFTP current path can be sent as `cd` to the linked terminal. */
export function canLocateSftpPathInTerminal(
options: LocateSftpPathInTerminalContext,
): boolean {
if (!options.sessionId || options.sessionStatus !== "connected") return false;
if (options.isNetworkDevice) return false;
if (!resolveInteractiveTerminalCdIntent(options.path)) return false;
const protocol = options.protocol ?? "ssh";
if (protocol === "telnet" || protocol === "serial") return false;
if (protocol === "local" && (options.shellType === "powershell" || options.shellType === "cmd")) {
return false;
}
if (options.sftpIsLocal) {
return protocol === "local";
}
if (!options.sftpHostId || !options.sessionHostId) return false;
if (options.sftpHostId !== options.sessionHostId) return false;
if (!remoteEndpointsMatch(options)) return false;
// Interactive locate allows mosh/et (unlike silent restore). Accept both the
// transport protocol strings and ssh+flag forms used by session factories.
return protocol === "ssh"
|| protocol === "mosh"
|| protocol === "et"
|| protocol === "local";
}
/** Session write payload for locating the SFTP path in the linked terminal. */
export function resolveLocateSftpPathInTerminalAction(
options: LocateSftpPathInTerminalContext,
): { sessionId: string; data: string } | null {
if (!canLocateSftpPathInTerminal(options) || !options.sessionId) return null;
const intent = resolveInteractiveTerminalCdIntent(options.path);
if (!intent) return null;
return { sessionId: options.sessionId, data: `${intent.command}\r` };
}
+67 -1
View File
@@ -1,7 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Host, Snippet } from './models';
import { deleteSelectedSnippetsFromVault } from './snippetSelection.ts';
import {
collectSnippetDeleteIds,
deleteSelectedSnippetsFromVault,
rebaseSnippetVaultWrite,
} from './snippetSelection.ts';
test('collectSnippetDeleteIds merges id and ids payloads', () => {
assert.deepEqual(
[...collectSnippetDeleteIds({ id: 'a', ids: ['b', 'a', ''] })].sort(),
['a', 'b'],
);
assert.equal(collectSnippetDeleteIds(undefined).size, 0);
assert.equal(collectSnippetDeleteIds({ ids: [] }).size, 0);
});
test('deleteSelectedSnippetsFromVault removes host bindings for every selected snippet', () => {
const snippets: Snippet[] = [
@@ -57,3 +70,56 @@ test('deleteSelectedSnippetsFromVault removes host bindings for every selected s
assert.equal(hosts[0].loginScriptId, 'login');
assert.deepEqual(hosts[0].connectScriptIds, ['connect', 'keep']);
});
test('rebaseSnippetVaultWrite does not resurrect snippets deleted on disk', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'b', label: 'B', command: 'echo b' },
];
const ours: Snippet[] = [
{ id: 'a', label: 'A edited', command: 'echo a2' },
{ id: 'b', label: 'B', command: 'echo b' },
];
// Concurrent bulk-delete removed B before our queued save ran.
const theirs: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id), ['a']);
assert.equal(merged[0]?.label, 'A edited');
});
test('rebaseSnippetVaultWrite keeps concurrent disk additions and local additions', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
];
const ours: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'local', label: 'Local', command: 'echo local' },
];
const theirs: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'remote', label: 'Remote', command: 'echo remote' },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id).sort(), ['a', 'local', 'remote']);
});
test('rebaseSnippetVaultWrite keeps local deletes even when disk still has the row', () => {
const base: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'b', label: 'B', command: 'echo b' },
];
const ours: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
];
const theirs: Snippet[] = [
{ id: 'a', label: 'A', command: 'echo a' },
{ id: 'b', label: 'B edited elsewhere', command: 'echo b2' },
];
const merged = rebaseSnippetVaultWrite({ base, ours, theirs });
assert.deepEqual(merged.map((snippet) => snippet.id), ['a']);
});
+89
View File
@@ -1,6 +1,20 @@
import type { Host, Snippet } from './models';
import { deleteSnippetFromVault } from './snippetAgentOps.ts';
/** Normalize `netcatty:snippets:delete` detail into a set of snippet ids. */
export function collectSnippetDeleteIds(
detail?: { id?: string; ids?: readonly string[] } | null,
): Set<string> {
const ids = new Set<string>();
for (const id of detail?.ids ?? []) {
if (typeof id === 'string' && id.length > 0) ids.add(id);
}
if (typeof detail?.id === 'string' && detail.id.length > 0) {
ids.add(detail.id);
}
return ids;
}
export function deleteSelectedSnippetsFromVault(
snippets: Snippet[],
hosts: Host[],
@@ -21,3 +35,78 @@ export function deleteSelectedSnippetsFromVault(
return { snippets: nextSnippets, hosts: nextHosts, deletedCount };
}
/**
* Three-way rebase for a queued full-array snippet save against the latest
* persisted vault snapshot.
*
* Unlike sync merge, a concurrent disk delete always wins over a local edit of
* the same id so bulk-delete cannot be resurrected by a stale window write.
*/
export function rebaseSnippetVaultWrite({
base,
ours,
theirs,
}: {
base: readonly Snippet[];
ours: readonly Snippet[];
theirs: readonly Snippet[];
}): Snippet[] {
const baseMap = new Map(base.map((snippet) => [snippet.id, snippet]));
const oursMap = new Map(ours.map((snippet) => [snippet.id, snippet]));
const theirsMap = new Map(theirs.map((snippet) => [snippet.id, snippet]));
const keep = new Map<string, Snippet>();
const allIds = new Set<string>([
...baseMap.keys(),
...oursMap.keys(),
...theirsMap.keys(),
]);
for (const id of allIds) {
if (!id) continue;
const baseItem = baseMap.get(id);
const ourItem = oursMap.get(id);
const theirItem = theirsMap.get(id);
const inBase = baseItem !== undefined;
const inOurs = ourItem !== undefined;
const inTheirs = theirItem !== undefined;
if (!inBase && inOurs && !inTheirs) {
keep.set(id, ourItem);
continue;
}
if (!inBase && !inOurs && inTheirs) {
keep.set(id, theirItem);
continue;
}
if (!inBase && inOurs && inTheirs) {
keep.set(id, ourItem);
continue;
}
if (inBase && inOurs && inTheirs) {
keep.set(id, ourItem);
continue;
}
// Local delete (even if disk still has / edited the row).
if (inBase && !inOurs && inTheirs) continue;
// Concurrent disk delete — do not resurrect from a stale local edit.
if (inBase && inOurs && !inTheirs) continue;
}
const ordered: Snippet[] = [];
const seen = new Set<string>();
for (const snippet of ours) {
const kept = keep.get(snippet.id);
if (!kept || seen.has(snippet.id)) continue;
ordered.push(kept);
seen.add(snippet.id);
}
for (const snippet of theirs) {
const kept = keep.get(snippet.id);
if (!kept || seen.has(snippet.id)) continue;
ordered.push(kept);
seen.add(snippet.id);
}
return ordered;
}
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isIdleShellReadyForCommandInjection } from "./terminalCommandInjectionReady.ts";
test("idle shell is ready only at a clean primary-screen prompt", () => {
assert.equal(
isIdleShellReadyForCommandInjection({
hasLiveTerminal: true,
alternateScreenActive: false,
isAtPrompt: true,
userInputLength: 0,
}),
true,
);
});
test("rejects sensitive prompts, alt-screen TUIs, partial input, and missing terminals", () => {
const ready = {
hasLiveTerminal: true,
alternateScreenActive: false,
isAtPrompt: true,
userInputLength: 0,
};
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, sensitiveInputActive: true }),
false,
);
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, alternateScreenActive: true }),
false,
);
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, isAtPrompt: false }),
false,
);
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, userInputLength: 3 }),
false,
);
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, pendingTypedInputLength: 2 }),
false,
);
assert.equal(
isIdleShellReadyForCommandInjection({ ...ready, hasLiveTerminal: false }),
false,
);
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Whether an automated PTY write (e.g. SFTP "open path in terminal") may run
* at a clean interactive shell prompt -- not mid-line, not in an editor/TUI.
*/
export function isIdleShellReadyForCommandInjection(options: {
sensitiveInputActive?: boolean;
hasLiveTerminal: boolean;
alternateScreenActive: boolean;
isAtPrompt: boolean;
userInputLength: number;
pendingTypedInputLength?: number;
}): boolean {
if (options.sensitiveInputActive) return false;
if (!options.hasLiveTerminal) return false;
if (options.alternateScreenActive) return false;
if (!options.isAtPrompt) return false;
if (options.userInputLength > 0) return false;
if ((options.pendingTypedInputLength ?? 0) > 0) return false;
return true;
}
+60
View File
@@ -0,0 +1,60 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const fs = require("node:fs");
const dnfSpecPath = path.join(__dirname, "..", "specs", "dnf.js");
function resolveNames(name) {
return Array.isArray(name) ? name : [name];
}
function collectSubcommandNames(spec) {
const names = new Set();
for (const sub of spec.subcommands || []) {
for (const n of resolveNames(sub.name)) names.add(n);
}
return names;
}
test("local dnf fig spec covers common Rocky/RHEL package-manager commands", async () => {
assert.equal(fs.existsSync(dnfSpecPath), true, "electron/specs/dnf.js should exist");
const mod = await import(pathToFileURL(dnfSpecPath).href);
const spec = mod.default?.default ?? mod.default;
assert.ok(spec, "dnf spec should export a default completionSpec");
assert.equal(spec.name, "dnf");
const names = collectSubcommandNames(spec);
for (const required of [
"install",
"remove",
"upgrade",
"update",
"autoremove",
"reinstall",
"downgrade",
"search",
"info",
"list",
"provides",
"clean",
"makecache",
"repolist",
"history",
"group",
"distro-sync",
"swap",
"module",
]) {
assert.equal(names.has(required), true, `missing dnf subcommand: ${required}`);
}
const group = (spec.subcommands || []).find((s) => resolveNames(s.name).includes("group"));
assert.ok(group, "group subcommand should exist");
const groupNames = collectSubcommandNames(group);
for (const required of ["install", "remove", "list", "info"]) {
assert.equal(groupNames.has(required), true, `missing dnf group subcommand: ${required}`);
}
});
@@ -5,6 +5,7 @@ const Module = require("node:module");
const {
createConnectionRef,
acquireConnectionRef,
releaseConnectionRef,
resetSshTransportRegistryForTests,
} = require("./sshConnectionPool.cjs");
@@ -431,6 +432,9 @@ test("Copy Tab records a distinct remote shell for each shared terminal", async
const sessions = new Map();
const sourceConn = makePidTrackingReusableConn();
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
// Live tabs normally already know their login shellPid (cwd / sessionOps).
// Copy Tab must not burn a discovery exec before opening the reused shell.
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
@@ -441,10 +445,12 @@ test("Copy Tab records a distinct remote shell for each shared terminal", async
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.deepEqual(sourceConn.shellPidSnapshots, [["111"], ["111", "222"]]);
assert.ok(sourceConn.shellPidSnapshots.length >= 1);
assert.deepEqual(sourceConn.shellPidSnapshots.at(-1), ["111", "222"]);
assert.equal(source.shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
@@ -484,6 +490,7 @@ test("Copy Tab with jump hosts still discovers shell PIDs after a short delay",
username: "alice",
jumpHosts,
});
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
@@ -499,20 +506,20 @@ test("Copy Tab with jump hosts still discovers shell PIDs after a short delay",
},
);
assert.ok(sourceConn.shellPidSnapshots.length >= 2, "jump-host reuse must still run discovery");
assert.ok(sourceConn.shellPidSnapshots.length >= 1, "jump-host reuse must still run post-open discovery");
assert.equal(source.shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
assert.equal(sourceConn.openedShells.length, 1);
});
test("Copy Tab retries rate-limited pre-open shell PID discovery on jump hosts", async (t) => {
test("Copy Tab retries rate-limited post-open shell PID discovery on jump hosts", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
// First pre-open scan is rate-limited; later scans succeed.
// First post-open scan is rate-limited; later scans succeed.
if (execCalls === 1) {
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
@@ -520,8 +527,7 @@ test("Copy Tab retries rate-limited pre-open shell PID discovery on jump hosts",
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
const snapshot = execCalls === 2 ? ["111"] : ["111", "222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
const pids = "111\n222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
@@ -534,6 +540,7 @@ test("Copy Tab retries rate-limited pre-open shell PID discovery on jump hosts",
username: "alice",
jumpHosts,
});
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
@@ -549,7 +556,7 @@ test("Copy Tab retries rate-limited pre-open shell PID discovery on jump hosts",
},
);
assert.ok(execCalls >= 3, "pre-open discovery must retry after channelOpen too offen");
assert.ok(execCalls >= 2, "post-open discovery must retry after channelOpen too offen");
assert.equal(source.shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
@@ -561,7 +568,7 @@ test("Copy Tab retries rate-limited PID discovery for direct bastion targets", a
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
// Direct bastion (no jumpHosts): first pre-open scan is rate-limited.
// Direct bastion (no jumpHosts): first post-open scan is rate-limited.
if (execCalls === 1) {
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
@@ -569,18 +576,19 @@ test("Copy Tab retries rate-limited PID discovery for direct bastion targets", a
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
const snapshot = execCalls === 2 ? ["111"] : ["111", "222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
const pids = "111\n222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
const source = makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
}));
});
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
@@ -594,27 +602,23 @@ test("Copy Tab retries rate-limited PID discovery for direct bastion targets", a
},
);
assert.ok(execCalls >= 3, "direct bastion reuse must retry rate-limited PID discovery");
assert.ok(execCalls >= 2, "direct bastion reuse must retry rate-limited PID discovery");
assert.equal(sessions.get("source").shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab retries rate-limited post-open shell PID discovery on jump hosts", async (t) => {
test("Copy Tab claims an unambiguous leftover PID when the source never recorded one", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
// Pre-open discovery (1) succeeds with the source shell. The first
// post-open discovery (2) is rate-limited; later attempts succeed.
if (execCalls === 2) {
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// First post-open snapshot still only sees the source shell; later scans
// include the copied shell so waitForNewInteractiveShellPid can finish.
const snapshot = execCalls === 1 ? ["111"] : ["111", "222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
setImmediate(() => {
@@ -623,14 +627,267 @@ test("Copy Tab retries rate-limited post-open shell PID discovery on jump hosts"
});
callback(null, stream);
};
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(execCalls >= 2);
assert.equal(sessions.get("source").shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab waits before claiming a sole PID after the source closes", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const terminalBridge = require("./terminalBridge.cjs");
const sessions = new Map();
const sourceConn = makeDeferredShellConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// Source tab already closed: early scans can still list the dying source
// shell before the copied shell is visible.
const snapshot = execCalls < 3 ? ["111"] : ["222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
const source = makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
jumpHosts,
});
sessions.set("source", source);
terminalBridge.init({ sessions, electronModule: {} });
const start = registerStartHandler(bridge, sessions);
const startPromise = start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
await new Promise((resolve) => setImmediate(resolve));
terminalBridge.closeSession({ sender: {} }, { sessionId: "source" });
sourceConn.flushShell();
await startPromise;
assert.ok(execCalls >= 3, "must wait through the dying-source-only scans");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab disambiguates a two-PID first scan when the source never recorded shellPid", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// OSC 7 sources skip the cwd probe, so shellPid is unset. The copied shell
// is already visible in the first post-open scan — no gradual appearance.
// Ages (etimes seconds; higher = older) distinguish source from copy.
const pids = "111 40\n222 1\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(execCalls, 1, "must not require waitForNew retries once both PIDs are visible");
assert.equal(sessions.get("source").shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab uses process age when PID wrap makes the copy numerically smaller", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sourceConn.exec = (_command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// After PID wrap the copied shell can receive a lower numeric PID while
// still being the younger process (etimes 1 vs 90).
const pids = "50 1\n4000 90\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(sessions.get("source").shellPid, "4000");
assert.equal(sessions.get("copy").shellPid, "50");
});
test("Copy Tab leaves PIDs unassigned when both shells share the same etimes second", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sourceConn.exec = (_command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// Same whole-second etimes tick: age inequality cannot separate them, and
// numeric PID order is unsafe after wrap — leave both unassigned.
const pids = "111 1\n222 1\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(sessions.get("source").shellPid, undefined);
assert.equal(sessions.get("copy").shellPid, undefined);
});
test("Copy Tab leaves PIDs unassigned when remote ps lacks etimes", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sourceConn.exec = (_command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// Age-less "pid" lines only — hosts without etimes. Do not guess via PID
// order; a wrap between source and copy would swap the assignment.
const pids = "111\n222\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(sessions.get("source").shellPid, undefined);
assert.equal(sessions.get("copy").shellPid, undefined);
});
test("Copy Tab reconciles an untracked source even when another sibling PID is known", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
sourceConn.exec = (_command, callback) => {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
// Tracked sibling 100 plus untracked source 200 and new copy 300.
const pids = "100 120\n200 40\n300 1\n__NETCATTY_SHELL_SCAN_COMPLETE__\n";
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
const source = makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
});
const tracked = {
conn: sourceConn,
stream: makeStream(),
chainConnections: [],
webContentsId: 1,
zmodemSentry: { cancel() {} },
hostname: "10.0.0.1",
username: "alice",
_reuseEndpoint: source._reuseEndpoint,
shellPid: "100",
};
acquireConnectionRef(tracked, source.connRef);
sessions.set("tracked", tracked);
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
@@ -639,14 +896,13 @@ test("Copy Tab retries rate-limited post-open shell PID discovery on jump hosts"
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
jumpHosts,
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(execCalls >= 3, "post-open discovery must retry after channelOpen too offen");
assert.equal(source.shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
assert.equal(sessions.get("tracked").shellPid, "100");
assert.equal(sessions.get("source").shellPid, "200");
assert.equal(sessions.get("copy").shellPid, "300");
});
test("Copy Tab retries bastion channelOpen too offen before falling back", async (t) => {
@@ -692,11 +948,78 @@ test("Copy Tab retries bastion channelOpen too offen before falling back", async
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
});
test("Copy Tab opens the shell before PID discovery so bastion rate limits do not burn the session slot", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
const channelOrder = [];
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
channelOrder.push("exec");
// Bastions rate-limit rapid session channels. If discovery runs first it
// burns the budget and every subsequent shell open is rejected.
if (sourceConn.openedShells.length === 0) {
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
const snapshot = ["111", "222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sourceConn.shell = (_opts, _shellOpts, cb) => {
channelOrder.push("shell");
if (execCalls > 0 && sourceConn.openedShells.length === 0) {
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
return;
}
const stream = makeStream();
sourceConn.openedShells.push(stream);
setImmediate(() => cb(null, stream));
};
const source = makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
});
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const result = await start(
{ sender },
{
sessionId: "copy",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(result.sessionId, "copy");
assert.equal(getClientConstructCount(), 0);
assert.equal(sourceConn.openedShells.length, 1);
assert.equal(channelOrder[0], "shell", "shell must open before any discovery exec");
assert.ok(execCalls >= 1, "PID discovery still runs after the shell opens");
assert.equal(sessions.get("copy").shellPid, "222");
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
});
test("Copy Tab waits briefly when the new remote shell is not visible immediately", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makePidTrackingReusableConn({ delayFirstNewPid: true });
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
@@ -706,10 +1029,13 @@ test("Copy Tab waits briefly when the new remote shell is not visible immediatel
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.deepEqual(sourceConn.shellPidSnapshots, [["111"], ["111"], ["111", "222"]]);
assert.ok(sourceConn.shellPidSnapshots.length >= 2);
assert.deepEqual(sourceConn.shellPidSnapshots[0], ["111"]);
assert.deepEqual(sourceConn.shellPidSnapshots.at(-1), ["111", "222"]);
assert.equal(sessions.get("copy").shellPid, "222");
});
@@ -760,17 +1086,31 @@ test("concurrent Copy Tab requests serialize shell discovery per connection", as
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makePidTrackingReusableConn();
sessions.set("source", makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" }));
const source = makeSourceSession(sourceConn, { hostname: "10.0.0.1", username: "alice" });
source.shellPid = "111";
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await Promise.all([
start(
{ sender: makeSender() },
{ sessionId: "copy-1", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
{
sessionId: "copy-1",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
),
start(
{ sender: makeSender() },
{ sessionId: "copy-2", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
{
sessionId: "copy-2",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
),
]);
@@ -803,11 +1143,23 @@ test("concurrent copies keep distinct shell IDs when the source closes immediate
const copies = Promise.all([
start(
{ sender: makeSender() },
{ sessionId: "copy-1", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
{
sessionId: "copy-1",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
),
start(
{ sender: makeSender() },
{ sessionId: "copy-2", hostname: "10.0.0.1", username: "alice", sourceSessionId: "source" },
{
sessionId: "copy-2",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
),
]);
+148 -57
View File
@@ -191,16 +191,22 @@ function createStartSessionApi(ctx) {
with (ctx) {
const listInteractiveShellPids = async (conn) => {
if (!conn || typeof conn.exec !== "function") {
return Promise.resolve({ available: false, pids: [] });
return Promise.resolve({ available: false, pids: [], ages: {} });
}
const scanCompleteMarker = "__NETCATTY_SHELL_SCAN_COMPLETE__";
// Emit "pid etimes" when possible. etimes (elapsed seconds) is a
// wrap-safe ordering key: higher means older. Plain "pid" remains valid
// for hosts that lack etimes.
const script = `SELF=$$
ps_output=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null) || exit 69
ps_output=$(ps -e -o pid=,ppid=,tty=,comm=,etimes= 2>/dev/null) || ps_output=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null) || exit 69
{
printf '%s\n' "$ps_output" | awk -v pp="$PPID" -v self="$SELF" '
function isshell(c) { sub(/^.*\\//, "", c); sub(/^-/, "", c); return c ~ /^(ba|z|fi|k|da|a|c|tc)?sh$/ }
$1 != self && $2 == pp && $3 !~ /^\\?+$/ && isshell($4) { print $1 }
$1 != self && $2 == pp && $3 !~ /^\\?+$/ && isshell($4) {
if (NF >= 5 && $5 ~ /^[0-9]+$/) print $1, $5+0
else print $1
}
'
if [ -r /proc/$SELF/environ ]; then
conn=$(tr '\\0' '\\n' < /proc/$SELF/environ 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n1)
@@ -217,11 +223,16 @@ ps_output=$(ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null) || exit 69
pcomm=$(cat "/proc/$ppid/comm" 2>/dev/null)
case "$pcomm" in sshd|dropbear|dropbearmulti) ;; *) continue ;; esac
tty=$(ps -p "$pid" -o tty= 2>/dev/null | tr -d '[:space:]')
[ -n "$tty" ] && [ "$tty" != "?" ] && printf '%s\\n' "$pid"
[ -n "$tty" ] && [ "$tty" != "?" ] || continue
etimes=$(ps -p "$pid" -o etimes= 2>/dev/null | tr -d '[:space:]')
case "$etimes" in
''|*[!0-9]*) printf '%s\\n' "$pid" ;;
*) printf '%s %s\\n' "$pid" "$etimes" ;;
esac
done
fi
fi
} | awk '/^[0-9]+$/ && !seen[$1]++ { print $1 }'
} | awk '/^[0-9]+/ && !seen[$1]++ { print }'
printf '%s\n' '${scanCompleteMarker}'`;
try {
@@ -237,15 +248,24 @@ printf '%s\n' '${scanCompleteMarker}'`;
const lines = result.stdout.split(/\r?\n/);
const completed = lines.includes(scanCompleteMarker);
const available = completed && (result.code === null || result.code === 0);
return {
available,
pids: available ? lines.filter((value) => /^\d+$/.test(value)) : [],
};
const pids = [];
const ages = {};
if (available) {
for (const line of lines) {
const match = /^(\d+)(?:\s+(\d+))?$/.exec(String(line || "").trim());
if (!match) continue;
const pid = match[1];
if (!pids.includes(pid)) pids.push(pid);
if (match[2] !== undefined) ages[pid] = Number(match[2]);
}
}
return { available, pids, ages };
} catch (error) {
return {
available: false,
rateLimited: isSshChannelOpenRateLimitedError(error),
pids: [],
ages: {},
};
}
};
@@ -700,48 +720,17 @@ printf '%s\n' '${scanCompleteMarker}'`;
const rows = options.rows || 24;
const sender = event.sender;
const conn = sourceSession.conn;
let discoveryConnectionError = null;
const onDiscoveryConnectionError = (err) => {
discoveryConnectionError = err;
};
let shellDiscoveryBeforeOpen = { available: false, pids: [] };
// Bastions (whether configured as jumpHosts or used as the direct SSH
// target) often rate-limit rapid session channel opens ("channelOpen too
// offen"). Retry rate-limited PID scans on every Copy Tab reuse so a
// successful shell open still gets shellPid tracking. Only the optional
// pre-scan pause is jump-host specific to avoid delaying the happy path
// on ordinary direct hosts.
const hasJumpHosts = Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0;
// Bastions (jumpHosts or direct targets) rate-limit rapid session channel
// opens ("channelOpen too offen"). Opening a discovery exec *before* the
// shell burns that budget and Copy Tab falls back to a fresh login
// (issue #2704). Open the shell first; discover shellPid afterwards.
const configuredBackoffMs = Number(options.sshChannelOpenRateLimitBackoffMs);
const discoveryBackoffMs = Number.isFinite(configuredBackoffMs) && configuredBackoffMs > 0
? configuredBackoffMs
: 150;
if (!options.skipShellPidDiscovery) {
conn.once("error", onDiscoveryConnectionError);
shellDiscoveryBeforeOpen = await listInteractiveShellPidsResilient(conn, {
initialDelayMs: hasJumpHosts ? discoveryBackoffMs : 0,
attempts: 4,
backoffMs: discoveryBackoffMs,
});
conn.removeListener("error", onDiscoveryConnectionError);
}
const shellPidsBeforeOpen = shellDiscoveryBeforeOpen.pids;
if (discoveryConnectionError) {
releaseConnectionRef(refHolder);
throw discoveryConnectionError;
}
const assignedPids = new Set(
[...sessions.values()]
.filter((candidate) => candidate?.connRef === connRef && candidate.shellPid)
.map((candidate) => String(candidate.shellPid)),
);
const unclaimedPids = shellPidsBeforeOpen.filter((pid) => !assignedPids.has(pid));
const unassignedSessions = [...sessions.values()].filter(
(candidate) => candidate?.connRef === connRef && !candidate.shellPid,
);
if (unclaimedPids.length === 1 && unassignedSessions.length === 1) {
unassignedSessions[0].shellPid = unclaimedPids[0];
}
const shellPidsBeforeOpen = [...sessions.values()]
.filter((candidate) => candidate?.connRef === connRef && candidate.shellPid)
.map((candidate) => String(candidate.shellPid));
log("reusing existing connection for new shell channel", {
sessionId,
@@ -856,17 +845,119 @@ printf '%s\n' '${scanCompleteMarker}'`;
} else {
refHolder.connRef = null;
}
const newShellPidPromise = shellDiscoveryBeforeOpen.available
? waitForNewInteractiveShellPid(
conn,
shellPidsBeforeOpen,
{
initialDelayMs: hasJumpHosts ? discoveryBackoffMs : 0,
const discoverCopiedShellPid = async () => {
if (options.skipShellPidDiscovery) return null;
const liveBaseline = () => [...sessions.values()]
.filter((candidate) => (
candidate?.connRef === connRef
&& candidate !== copiedSession
&& candidate.shellPid
))
.map((candidate) => String(candidate.shellPid));
const listUnassignedSiblings = () => [...sessions.values()].filter(
(candidate) => (
candidate?.connRef === connRef
&& candidate !== copiedSession
&& !candidate.shellPid
),
);
// Prefer PIDs already recorded on sibling tabs of this shared
// transport. Fall back to the pre-shell snapshot when the source
// closed before discovery runs but had a known shellPid.
let baseline = liveBaseline();
if (baseline.length === 0) {
baseline = shellPidsBeforeOpen;
}
// Also reconcile when some siblings are already tracked but the
// copy source (or another tab) still lacks shellPid — otherwise
// waitForNew sees multiple "new" PIDs and returns null.
const needsUntrackedReconcile = listUnassignedSiblings().length > 0;
if (baseline.length === 0 || needsUntrackedReconcile) {
const discovery = await listInteractiveShellPidsResilient(conn, {
initialDelayMs: discoveryBackoffMs,
attempts: 4,
backoffMs: discoveryBackoffMs,
},
)
: Promise.resolve(null);
void newShellPidPromise.then((newShellPid) => {
});
if (!discovery.available && !discovery.rateLimited) {
// Discovery is permanently unavailable (not rate-limited).
return null;
}
if (discovery.available && discovery.pids.length > 0) {
const assignedPids = new Set(liveBaseline());
for (const pid of baseline) assignedPids.add(String(pid));
const unclaimed = discovery.pids.filter((pid) => !assignedPids.has(pid));
const unassignedSiblings = listUnassignedSiblings();
if (unclaimed.length === 1 && unassignedSiblings.length === 1) {
unassignedSiblings[0].shellPid = unclaimed[0];
baseline = liveBaseline();
} else if (unassignedSiblings.length === 0 && unclaimed.length === 1) {
// Sole unclaimed PID is ambiguous once the source tab is
// gone: the closing source process may still be listed
// while the copied shell has not appeared yet. Wait for a
// PID beyond that candidate; if none appears and the
// candidate remains the only unclaimed shell, it is the
// copy (source process already exited).
const candidatePid = String(unclaimed[0]);
const waited = await waitForNewInteractiveShellPid(conn, [candidatePid], {
initialDelayMs: discoveryBackoffMs,
backoffMs: discoveryBackoffMs,
});
if (waited) return waited;
const recheck = await listInteractiveShellPids(conn);
if (!recheck.available) return null;
const assigned = new Set(liveBaseline());
const remaining = recheck.pids
.map(String)
.filter((pid) => !assigned.has(pid));
if (remaining.length === 1) return remaining[0];
return null;
} else if (unassignedSiblings.length === 1 && unclaimed.length === 2) {
// Source never recorded shellPid (e.g. OSC 7 cwd skipped
// the probe), so the first post-open scan already lists
// both shared shells. Reintroducing a pre-open exec would
// burn bastion channel budget (#2704). Disambiguate by
// process age (etimes): login shells on one transport are
// created sequentially, so the copied shell is younger.
// Numeric PID order is not a timestamp and fails when the
// PID allocator wraps between source and copy.
const ages = discovery.ages || {};
const left = String(unclaimed[0]);
const right = String(unclaimed[1]);
const leftAge = ages[left];
const rightAge = ages[right];
if (
Number.isFinite(leftAge)
&& Number.isFinite(rightAge)
&& leftAge !== rightAge
) {
const older = leftAge > rightAge ? left : right;
const newer = older === left ? right : left;
unassignedSiblings[0].shellPid = older;
return newer;
}
// Ages tied (same etimes second) or unavailable: refuse
// numeric PID order — after wrap the lower PID can be the
// copy. Leave the pair unassigned and keep only already-
// tracked PIDs in the baseline so waitForNew can still
// claim the copy if one of the two later disappears.
baseline = [...assignedPids];
} else if (assignedPids.size > 0) {
baseline = [...assignedPids];
}
}
// Empty successful scans (shell not visible yet) must not
// abort — waitForNew retries until the new shell appears.
}
return waitForNewInteractiveShellPid(conn, baseline, {
// Brief pause after the shell channel so bastion rate limits
// have a chance to clear before the discovery exec.
initialDelayMs: discoveryBackoffMs,
backoffMs: discoveryBackoffMs,
});
};
void discoverCopiedShellPid().then((newShellPid) => {
// Bind PID only to the session this reuse opened. A higher
// bootEpoch reconnect may already own sessionId in the map.
const liveSession = sessions.get(sessionId);
+90
View File
@@ -0,0 +1,90 @@
// dnf spec — DNF package manager (RHEL/Rocky/Alma/Fedora)
const completionSpec = {
name: "dnf",
description: "DNF package manager (RHEL/Rocky/Alma/Fedora)",
subcommands: [
{ name: "install", description: "Install a package", args: { name: "package", isVariadic: true } },
{ name: "remove", description: "Remove a package", args: { name: "package", isVariadic: true } },
{ name: "upgrade", description: "Upgrade installed packages", args: { name: "package", isOptional: true, isVariadic: true } },
{ name: "update", description: "Alias for upgrade", args: { name: "package", isOptional: true, isVariadic: true } },
{ name: "autoremove", description: "Remove unneeded packages installed as dependencies" },
{ name: "reinstall", description: "Reinstall a package", args: { name: "package", isVariadic: true } },
{ name: "downgrade", description: "Downgrade a package", args: { name: "package", isVariadic: true } },
{ name: "distro-sync", description: "Synchronize installed packages to the latest available versions", args: { name: "package", isOptional: true, isVariadic: true } },
{ name: "swap", description: "Remove one package and install another in a single transaction", args: [
{ name: "remove-spec" },
{ name: "install-spec" },
]},
{ name: "search", description: "Search package details for the given string", args: { name: "keyword", isVariadic: true } },
{ name: "info", description: "Display details about a package", args: { name: "package", isVariadic: true } },
{ name: "list", description: "List packages", subcommands: [
{ name: "installed", description: "List installed packages" },
{ name: "available", description: "List available packages" },
{ name: "updates", description: "List packages with updates available" },
{ name: "obsoletes", description: "List obsoleting packages" },
{ name: "all", description: "List all packages" },
]},
{ name: "provides", description: "Find which package provides a file/feature", args: { name: "feature" } },
{ name: "clean", description: "Clean cached data", subcommands: [
{ name: "all", description: "Clean all cached data" },
{ name: "packages", description: "Clean cached packages" },
{ name: "metadata", description: "Clean cached metadata" },
{ name: "dbcache", description: "Clean cached db data" },
{ name: "expire-cache", description: "Expire the cache" },
]},
{ name: "makecache", description: "Generate the metadata cache" },
{ name: "repolist", description: "Display configured software repositories", subcommands: [
{ name: "all", description: "List all repos" },
{ name: "enabled", description: "List enabled repos" },
{ name: "disabled", description: "List disabled repos" },
]},
{ name: "repoinfo", description: "Display repository information" },
{ name: "history", description: "View and manage DNF transaction history", subcommands: [
{ name: "list", description: "List transactions" },
{ name: "info", description: "Show transaction details", args: { name: "id" } },
{ name: "undo", description: "Undo a transaction", args: { name: "id" } },
{ name: "redo", description: "Redo a transaction", args: { name: "id" } },
{ name: "rollback", description: "Rollback to a previous transaction", args: { name: "id" } },
]},
{ name: "group", description: "Manage package groups", subcommands: [
{ name: "install", description: "Install a package group", args: { name: "group" } },
{ name: "remove", description: "Remove a package group", args: { name: "group" } },
{ name: "list", description: "List available package groups" },
{ name: "info", description: "Display details about a package group", args: { name: "group" } },
{ name: "upgrade", description: "Upgrade a package group", args: { name: "group", isOptional: true } },
]},
{ name: "module", description: "Manage application stream modules", subcommands: [
{ name: "list", description: "List modules", subcommands: [
{ name: "all", description: "List all modules" },
{ name: "enabled", description: "List enabled modules" },
{ name: "disabled", description: "List disabled modules" },
{ name: "installed", description: "List installed modules" },
]},
{ name: "info", description: "Show module details", args: { name: "module", isVariadic: true } },
{ name: "install", description: "Install a module profile", args: { name: "module", isVariadic: true } },
{ name: "remove", description: "Remove installed module profiles", args: { name: "module", isVariadic: true } },
{ name: "enable", description: "Enable a module stream", args: { name: "module", isVariadic: true } },
{ name: "disable", description: "Disable a module", args: { name: "module", isVariadic: true } },
{ name: "reset", description: "Reset module state", args: { name: "module", isVariadic: true } },
{ name: "update", description: "Update installed modules", args: { name: "module", isOptional: true, isVariadic: true } },
]},
{ name: "check-update", description: "Check for available package upgrades" },
{ name: "check-upgrade", description: "Alias for check-update" },
],
options: [
{ name: "-y", description: "Answer yes to all questions" },
{ name: "-q", description: "Quiet operation" },
{ name: "-v", description: "Verbose operation" },
{ name: "--enablerepo", description: "Enable a repository", args: { name: "repo" } },
{ name: "--disablerepo", description: "Disable a repository", args: { name: "repo" } },
{ name: "--nogpgcheck", description: "Disable GPG signature checking" },
{ name: "--skip-broken", description: "Skip packages with dependency problems" },
{ name: "--showduplicates", description: "Show duplicate packages in repos" },
{ name: "--installroot", description: "Set install root", args: { name: "path", template: "folders" } },
{ name: "-C", description: "Run entirely from system cache" },
{ name: "--security", description: "Include only security-related packages" },
{ name: ["-h", "--help"], description: "Show help" },
],
};
export default completionSpec;