mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 00:30:57 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8412d70c | ||
|
|
7bb4e96c55 | ||
|
|
7a66161355 | ||
|
|
c9bc39d1a1 | ||
|
|
03acd48585 | ||
|
|
fd295e7529 | ||
|
|
f4e14afc9d | ||
|
|
edfe5ba517 | ||
|
|
7bce9e55e3 | ||
|
|
93dce066fd | ||
|
|
49b88241db | ||
|
|
0cb692b17e | ||
|
|
337191382a | ||
|
|
18b19b1ccc | ||
|
|
ce2611d868 | ||
|
|
a0dce456fa | ||
|
|
2c7c0f9fc4 | ||
|
|
db2152dcfa | ||
|
|
877df24f59 | ||
|
|
40d114eab6 |
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/,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Отключить следование за каталогом терминала',
|
||||
|
||||
@@ -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': '关闭追随终端目录',
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\}/,
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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` };
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user