mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:15:03 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2564c7be5e | ||
|
|
e124bd51b5 | ||
|
|
23b9216572 | ||
|
|
4a7d29d354 | ||
|
|
079ff38a7b | ||
|
|
4fd1a425c2 | ||
|
|
bbf93fa73c |
No files matched your search
@@ -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': 'Отключить следование за каталогом терминала',
|
||||
|
||||
@@ -919,6 +919,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': '拖拽调整高度',
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.locatePathInTerminal': '定位路径到终端',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
|
||||
@@ -919,6 +919,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'sftp.transfers.dragToResize': '拖拽調整高度',
|
||||
'sftp.goUp': '上一級',
|
||||
'sftp.goToTerminalCwd': '定位到終端目前目錄',
|
||||
'sftp.locatePathInTerminal': '定位路徑到終端',
|
||||
'sftp.followTerminalCwd': '追隨終端目錄',
|
||||
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
|
||||
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
|
||||
|
||||
@@ -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 {
|
||||
@@ -535,6 +538,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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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` };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user