Compare commits

..
16 changed files with 140 additions and 293 deletions

No files matched your search

-1
View File
@@ -417,7 +417,6 @@ export const enVaultMessages: Messages = {
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.locatePathInTerminal': 'Open path in terminal',
'sftp.refreshAfterCommand': 'Refresh after terminal commands',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
-1
View File
@@ -1046,7 +1046,6 @@ export const zhCNCoreMessages: Messages = {
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.locatePathInTerminal': '定位路径到终端',
'sftp.refreshAfterCommand': '终端命令完成后刷新',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
-1
View File
@@ -1048,7 +1048,6 @@ export const zhTWCoreMessages: Messages = {
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.locatePathInTerminal': '定位路徑到終端',
'sftp.refreshAfterCommand': '終端命令完成後重新整理',
'sftp.followTerminalCwd': '追隨終端目錄',
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
@@ -1,60 +0,0 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { publishTerminalCommandCompletion } from '../terminalCommandCompletion';
import { useSftpCommandRefresh } from './useSftpCommandRefresh';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('completion refresh is coalesced, visible, idle and bound to its original pane', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let count = 0;
let options = {
sessionId: 'terminal-1', enabled: true, visible: true, busy: false,
matchesTerminal: true, paneId: 'pane-1', connectionId: 'connection-1',
path: '/browsed', connected: true, refresh: async () => { count += 1; },
};
function Probe() { useSftpCommandRefresh(options); return null; }
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
const update = async (patch: Partial<typeof options>) => {
options = { ...options, ...patch };
await act(async () => { renderer.update(React.createElement(Probe)); });
};
const finish = async () => { await act(async () => { t.mock.timers.tick(250); }); };
publishTerminalCommandCompletion('another-terminal');
await finish();
assert.equal(count, 0);
publishTerminalCommandCompletion('terminal-1');
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
for (const patch of [{ enabled: false }, { visible: false }, { connected: false }, { matchesTerminal: false }]) {
await update(patch);
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
await update({ enabled: true, visible: true, busy: false, connected: true, matchesTerminal: true });
}
await update({ busy: true });
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
await update({ busy: false });
await finish();
assert.equal(count, 2);
for (const patch of [{ path: '/new' }, { paneId: 'pane-2' }, { connectionId: 'connection-2' }, { sessionId: 'terminal-2' }]) {
publishTerminalCommandCompletion(options.sessionId);
await update(patch);
await finish();
assert.equal(count, 2);
}
publishTerminalCommandCompletion(options.sessionId);
await act(async () => { renderer.unmount(); });
await finish();
assert.equal(count, 2);
});
@@ -1,56 +0,0 @@
import { useEffect, useRef } from 'react';
import { subscribeTerminalCommandCompletion } from '../terminalCommandCompletion';
interface Options {
sessionId: string | null;
enabled: boolean;
visible: boolean;
busy: boolean;
matchesTerminal: boolean;
paneId: string;
connectionId: string | null;
path: string | null;
connected: boolean;
refresh: () => Promise<void>;
}
/** Coalesce completion bursts; never poll or carry refreshes across pane changes. */
export function useSftpCommandRefresh(options: Options): void {
const latest = useRef(options);
latest.current = options;
const drain = useRef(() => {});
useEffect(() => {
if (!options.enabled || !options.visible || !options.sessionId) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending = false;
let refreshing = false;
let disposed = false;
const eligible = () => {
const live = latest.current;
return !disposed && live.enabled && live.visible && live.connected && live.matchesTerminal
&& live.sessionId === options.sessionId && live.paneId === options.paneId
&& live.connectionId === options.connectionId && live.path === options.path;
};
const schedule = () => {
if (!pending || refreshing || latest.current.busy || !eligible()) return;
clearTimeout(timer);
timer = setTimeout(() => {
if (!eligible() || latest.current.busy) return;
pending = false;
refreshing = true;
void latest.current.refresh().catch(() => {}).finally(() => {
refreshing = false;
schedule();
});
}, 250);
};
drain.current = schedule;
const unsubscribe = subscribeTerminalCommandCompletion((sessionId) => {
if (sessionId !== options.sessionId || !eligible()) return;
pending = true;
schedule();
});
return () => { disposed = true; clearTimeout(timer); unsubscribe(); drain.current = () => {}; };
}, [options.enabled, options.visible, options.sessionId, options.paneId, options.connectionId, options.path]);
useEffect(() => { drain.current(); }, [options.busy]);
}
@@ -1,51 +0,0 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useSftpPaneActions } from './useSftpPaneActions';
import { createEmptyPane, type SftpPane } from './types';
import type { SftpFileEntry } from '../../../types';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('command refresh keeps current directory/filter/selection and drops deleted selections', async () => {
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '1970-01-01' });
let result = [file('kept'), file('new')];
const selection = new Set(['kept']);
const pane: SftpPane = { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', status: 'connected', currentPath: '/browsed', homeDir: '/', isLocal: true }, files: [file('kept')], selectedFiles: selection, filter: 'ke' };
const left = { current: { tabs: [pane], activeTabId: pane.id } };
const calls: string[] = [];
let actions: ReturnType<typeof useSftpPaneActions>;
const update: Parameters<typeof useSftpPaneActions>[0]['updateTab'] = (_side, id, updater) => {
left.current.tabs = left.current.tabs.map((p) => p.id === id ? updater(p) : p);
};
function Probe() {
actions = useSftpPaneActions({
hosts: [], getActivePane: () => left.current.tabs[0], updateTab: update,
updateActiveTab: (side, updater) => update(side, pane.id, updater),
leftTabsRef: left, rightTabsRef: { current: { tabs: [], activeTabId: null } },
navSeqRef: { current: { left: 0, right: 0 } }, dirCacheRef: { current: new Map() },
sftpSessionsRef: { current: new Map() }, lastConnectedHostRef: { current: { left: null, right: null } },
connectionCacheKeyMapRef: { current: new Map() }, makeCacheKey: (id, path) => id + path,
clearCacheForConnection() {}, listLocalFiles: async (path) => { calls.push(path); return result; },
listRemoteFiles: async () => [], handleSessionError() {}, releaseConnection: async () => {},
isSessionError: () => false, clearSelectionsExcept() {}, dirCacheTtlMs: 1000,
});
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
await actions!.refresh('left', { tabId: pane.id, preserveSelection: true });
assert.deepEqual(calls, ['/browsed']);
assert.equal(left.current.tabs[0].selectedFiles, selection);
assert.equal(left.current.tabs[0].filter, 'ke');
assert.deepEqual(left.current.tabs[0].files.map((f) => f.name), ['kept', 'new']);
result = [file('new')];
await actions!.refresh('left', { preserveSelection: true });
assert.equal(left.current.tabs[0].selectedFiles.size, 0);
assert.equal(left.current.tabs[0].connection?.currentPath, '/browsed');
await act(async () => { renderer.unmount(); });
});
+6 -15
View File
@@ -27,13 +27,6 @@ import {
/** Shared empty set for navigation resets — never mutate this. */
const EMPTY_SET = new Set<string>();
/** Retain selection identity when no selected entry disappeared. */
export function retainSftpSelection(selected: Set<string>, files: SftpFileEntry[]): Set<string> {
const names = new Set(files.map((file) => file.name));
const retained = new Set([...selected].filter((name) => names.has(name)));
return retained.size === selected.size ? selected : retained;
}
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
@@ -60,14 +53,13 @@ interface UseSftpPaneActionsParams {
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
export type SftpNavigateOptions = {
force?: boolean;
preserveSelection?: boolean;
tabId?: string;
shouldApply?: () => boolean;
};
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
@@ -187,7 +179,6 @@ export const useSftpPaneActions = ({
pane.connection.homeDir,
);
const preserveSelection = options?.preserveSelection && normalizedPath === pane.connection.currentPath;
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, normalizedPath, pane.filenameEncoding);
@@ -290,7 +281,7 @@ export const useSftpPaneActions = ({
connection: prev.connection
? { ...prev.connection, currentPath: normalizedPath }
: null,
selectedFiles: preserveSelection ? prev.selectedFiles : EMPTY_SET,
selectedFiles: EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
loading: true,
error: null,
@@ -378,7 +369,7 @@ export const useSftpPaneActions = ({
connectionId,
path: normalizedPath,
files,
selectedFiles: preserveSelection ? retainSftpSelection(getTargetPane()?.selectedFiles ?? EMPTY_SET, files) : EMPTY_SET,
selectedFiles: EMPTY_SET,
filter: nextConfirmedFilter,
});
@@ -389,7 +380,7 @@ export const useSftpPaneActions = ({
: null,
files,
loading: false,
selectedFiles: preserveSelection ? retainSftpSelection(prev.selectedFiles, files) : EMPTY_SET,
selectedFiles: EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
}));
if (!pane.connection.isLocal) {
@@ -454,7 +445,7 @@ export const useSftpPaneActions = ({
);
const refresh = useCallback(
async (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => {
async (side: "left" | "right", options?: { tabId?: string }) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
@@ -485,7 +476,7 @@ export const useSftpPaneActions = ({
}
return;
}
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId, preserveSelection: options?.preserveSelection });
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
} else if (!pane?.connection && pane?.error) {
// For background tabs, don't trigger reconnection (it operates on
// the active tab). Just leave the error state for the user to see
@@ -1,12 +0,0 @@
type Listener = (sessionId: string) => void;
const listeners = new Set<Listener>();
/** Renderer-local fan-out of the terminal's existing completion detection. */
export function publishTerminalCommandCompletion(sessionId: string): void {
for (const listener of listeners) listener(sessionId);
}
export function subscribeTerminalCommandCompletion(listener: Listener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
-21
View File
@@ -1,4 +1,3 @@
import { useSftpCommandRefresh } from "../application/state/sftp/useSftpCommandRefresh";
/**
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
*
@@ -1620,22 +1619,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|| (sftp.activeExternalEditCount ?? 0) > 0;
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
const [autoRefresh, setAutoRefresh] = useState(true);
useSftpCommandRefresh({
sessionId: focusedSessionId ?? activeSessionId,
enabled: autoRefresh,
visible: isVisible && ownerPanelOpen,
busy: hasActiveWork || sftp.leftPane.loading,
matchesTerminal: !!activeHost && !!connectedHostObjRef.current
&& sftpHostEndpointsEqual(activeHost, connectedHostObjRef.current)
&& sftp.leftPane.connection?.hostId === activeHost.id,
paneId: sftp.leftPane.id,
connectionId,
path: connectionPath,
connected: sftp.leftPane.connection?.status === "connected",
refresh: () => sftpRef.current.refresh("left", { tabId: sftp.leftPane.id, preserveSelection: true }),
});
const {
handleGoToTerminalCwd,
@@ -1877,10 +1860,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
data-section="terminal-sftp-panel"
onClick={handlePaneFocus}
>
<label className="flex shrink-0 items-center gap-2 border-b border-border/50 px-3 py-1 text-xs text-muted-foreground">
<input type="checkbox" checked={autoRefresh} onChange={(event) => setAutoRefresh(event.target.checked)} />
{t("sftp.refreshAfterCommand")}
</label>
{showWorkspaceHostHeader && displayHost && (
<div
className={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 bg-muted/20 px-3 flex items-center`}
+1 -3
View File
@@ -1,4 +1,3 @@
import { publishTerminalCommandCompletion } from "../application/state/terminalCommandCompletion";
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
@@ -2368,10 +2367,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandSubmitted?.(...args);
}, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
const pluginAwareOnCommandCompleted = useCallback(() => {
publishTerminalCommandCompletion(sessionId);
pluginTerminalLifecycle.onCommandCompleted();
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
}, [pluginTerminalLifecycle, sessionId]);
}, [pluginTerminalLifecycle]);
const pluginAwareOnTerminalCwdChange = useCallback((
changedSessionId: string,
cwd: string | null,
@@ -1,52 +0,0 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { SftpPaneFileList } from './SftpPaneFileList';
import { TooltipProvider } from '../ui/tooltip';
import { installDomEnvironment } from '../test-support/renderReactDom';
import { createEmptyPane } from '../../application/state/sftp/types';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('refresh keeps scroll, while explicit sorting and filters reveal the selection', async (t) => {
const dom = installDomEnvironment();
t.after(() => dom.cleanup());
let scrolls = 0;
const container = { querySelectorAll: () => [{ dataset: { entryName: 'selected' }, scrollIntoView: () => { scrolls += 1; } }] };
const noop = () => {};
const selected = { name: 'selected', type: 'file' as const, size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '' };
let props: React.ComponentProps<typeof SftpPaneFileList> = {
t: (key) => key, pane: { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', isLocal: false, status: 'connected', currentPath: '/browsed' }, selectedFiles: new Set(['selected']), files: [selected] },
side: 'left', isPaneFocused: true,
sorting: { sortField: 'name', sortOrder: 'asc', directoriesFirst: true,
columnWidths: { name: 50, modified: 24, size: 7, type: 9, owner: 10 },
visibleColumns: { name: true, modified: true, size: true, type: true, owner: true },
handleSort: noop, handleResizeStart: noop, toggleColumnVisibility: noop, toggleDirectoriesFirst: noop },
fileListRef: { current: container as unknown as HTMLDivElement },
handleFileListScroll: noop, shouldVirtualize: false, totalHeight: 0, sortedDisplayFiles: [selected],
isDragOverPane: false, draggedFiles: null, onRefresh: noop, onNavigateTo: noop, onClearSelection: noop,
setShowNewFolderDialog: noop, setShowNewFileDialog: noop, getNextUntitledName: () => '', setNewFileName: noop, setFileNameError: noop,
dragOverEntry: null, handleRowSelect: noop, handleRowOpen: noop, handleFileDragStart: noop,
onDragEnd: noop, handleEntryDragOver: noop, handleRowDragLeave: noop, handleEntryDrop: noop,
onCopyToOtherPane: noop, onMoveEntriesToPath: async () => {}, openRenameDialog: noop, openDeleteConfirm: noop,
rowHeight: 28, visibleRows: [],
};
const render = () => React.createElement(TooltipProvider, null, React.createElement(SftpPaneFileList, props));
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(render(), { createNodeMock: (element) => element.props['data-section'] === 'terminal-sftp-list' ? container : null }); });
assert.equal(scrolls, 1);
props = { ...props, pane: { ...props.pane, files: [...props.pane.files] }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 1, 'refresh must not pull the viewport back to the selection');
props = { ...props, sorting: { ...props.sorting, sortOrder: 'desc' }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 2, 'sorting retains the prior reveal-selection behavior');
props = { ...props, pane: { ...props.pane, filter: 'selected' }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 3, 'filter changes retain the prior reveal-selection behavior');
await act(async () => { renderer.unmount(); });
});
+1 -17
View File
@@ -196,23 +196,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
return () => sftpListOrderStore.clearPane(pane.id);
}, [sortedDisplayFiles, pane.id]);
const selectionScrollContext = useMemo(() => ({
paneId: pane.id, filter: pane.filter, showHiddenFiles: pane.showHiddenFiles,
sortField, sortOrder, directoriesFirst, rowHeight, shouldVirtualize,
}), [
pane.id, pane.filter, pane.showHiddenFiles, sortField, sortOrder,
directoriesFirst, rowHeight, shouldVirtualize,
]);
const lastScrolledSelectionRef = useRef<{
selection: Set<string>;
context: object;
} | null>(null);
useEffect(() => {
// Refreshes preserve selection identity; explicit sorting/filter/layout
// changes still reveal the selected row using the usual behavior.
const last = lastScrolledSelectionRef.current;
if (last?.selection === pane.selectedFiles && last.context === selectionScrollContext) return;
lastScrolledSelectionRef.current = { selection: pane.selectedFiles, context: selectionScrollContext };
if (pane.selectedFiles.size !== 1) return;
const selectedName = Array.from(pane.selectedFiles)[0];
if (!selectedName) return;
@@ -236,7 +220,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
currentScrollTop: container.scrollTop,
viewportHeight: container.clientHeight,
});
}, [fileListRef, pane.selectedFiles, rowHeight, selectionScrollContext, shouldVirtualize, sortedDisplayFiles]);
}, [fileListRef, pane.selectedFiles, rowHeight, shouldVirtualize, sortedDisplayFiles]);
// Use refs for frequently-changing values in context-menu actions
const selectedFilesRef = useRef(pane.selectedFiles);
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { commandArrowLineJumpSequence } from "./commandArrowLineJump";
const event = {
key: "ArrowLeft", metaKey: true, altKey: false, ctrlKey: false,
shiftKey: false, isComposing: false, keyCode: 37,
};
test("macOS Command arrows send beginning/end-of-line controls", () => {
assert.equal(commandArrowLineJumpSequence(event, true), "\x01");
assert.equal(commandArrowLineJumpSequence({ ...event, key: "ArrowRight" }, true), "\x05");
});
test("other platforms, keys, modifiers and composition are untouched", () => {
assert.equal(commandArrowLineJumpSequence(event, false), null);
for (const override of [
{ metaKey: false }, { altKey: true }, { ctrlKey: true }, { shiftKey: true },
{ isComposing: true }, { keyCode: 229 }, { key: "ArrowUp" }, { key: "ArrowDown" },
{ key: "Home" }, { key: "a" },
]) {
assert.equal(commandArrowLineJumpSequence({ ...event, ...override }, true), null);
}
});
// Exercise the runtime fallback itself, including its protocol/screen guards.
// This avoids constructing the renderer's WebGL/addon stack in Node.
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const start = runtime.indexOf(" const lineJumpSequence =");
const end = runtime.indexOf(" // Autocomplete key handler", start);
const applyStart = runtime.indexOf(" if (lineJumpSequence && ctx.sessionRef.current)");
const applyEnd = runtime.indexOf(" // macOS Option+", applyStart);
assert.ok(start > 0 && end > start && applyEnd > applyStart);
const fallback = `(function () { ${runtime.slice(start, end)} ${runtime.slice(applyStart, applyEnd)} return true; })()`;
for (const mode of ["shell", "alternate", "kitty", "win32", "disconnected", "non-mac", "password", "sudo-picker"]) {
test(`runtime Command arrow fallback: ${mode}`, () => {
const writes: string[] = [];
let prevented = 0;
let stopped = 0;
const result = runInNewContext(fallback, {
e: { ...event, preventDefault: () => prevented++, stopPropagation: () => stopped++ },
term: { buffer: { active: { type: mode === "alternate" ? "alternate" : "normal" } }, modes: { win32InputMode: mode === "win32" } },
ctx: {
sessionRef: { current: mode === "disconnected" ? null : "session" },
passwordPromptActiveRef: { current: mode === "password" },
},
sudoAutofill: { isPromptPending: () => mode === "sudo-picker" },
kittyKeyboardMode: {},
isKittyKeyboardModeActive: () => mode === "kitty",
isMacPlatform: () => mode !== "non-mac",
commandArrowLineJumpSequence,
handleTerminalInputData: (data: string, options: { skipBroadcast: boolean }) => {
assert.equal(options.skipBroadcast, true, "a peer TUI must not receive mapped Ctrl+A/E");
writes.push(data);
},
scrollToBottomAfterInput: () => {},
});
assert.equal(result, mode !== "shell");
assert.deepEqual(writes, mode === "shell" ? ["\x01"] : []);
assert.equal(prevented, mode === "shell" ? 1 : 0);
assert.equal(stopped, prevented);
});
}
for (const key of ["ArrowLeft", "ArrowRight"]) {
test(`Command ${key} bypasses directory autocomplete, bare arrows still navigate it`, () => {
const autocompleteEnd = runtime.indexOf(" const kittySequenceForKeyDown =", end);
assert.ok(autocompleteEnd > end);
const route = `(function () { ${runtime.slice(start, autocompleteEnd)} ${runtime.slice(applyStart, applyEnd)} return true; })()`;
for (const metaKey of [true, false]) {
let autocompleteCalls = 0;
const writes: string[] = [];
runInNewContext(route, {
e: { ...event, key, metaKey, preventDefault: () => {}, stopPropagation: () => {} },
term: { buffer: { active: { type: "normal" } }, modes: {} },
ctx: {
sessionRef: { current: "session" },
// A focused directory panel consumes either arrow.
onAutocompleteKeyEvent: () => { autocompleteCalls++; return false; },
},
sudoAutofill: undefined,
kittyKeyboardMode: {},
isKittyKeyboardModeActive: () => false,
isMacPlatform: () => true,
commandArrowLineJumpSequence,
handleTerminalInputData: (data: string) => writes.push(data),
scrollToBottomAfterInput: () => {},
});
assert.equal(autocompleteCalls, metaKey ? 0 : 1);
assert.deepEqual(writes, metaKey ? [key === "ArrowLeft" ? "\x01" : "\x05"] : []);
}
});
}
@@ -0,0 +1,11 @@
/** macOS line editing fallback for shells using readline/ZLE bindings. */
export function commandArrowLineJumpSequence(
e: Pick<KeyboardEvent, "key" | "metaKey" | "altKey" | "ctrlKey" | "shiftKey" | "isComposing" | "keyCode">,
isMac: boolean,
): string | null {
if (!isMac || !e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return null;
if (e.isComposing || e.keyCode === 229) return null;
if (e.key === "ArrowLeft") return "\x01";
if (e.key === "ArrowRight") return "\x05";
return null;
}
@@ -91,6 +91,7 @@ import {
import { installUserCursorPreferenceGuard } from "./cursorPreference";
import { terminalAltKeyOptions } from "./altKeyOptions";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
import { commandArrowLineJumpSequence } from "./commandArrowLineJump";
import { optionYankLastArgSequence } from "./optionYankLastArg";
import { watchDevicePixelRatio } from "./rendererDprWatch";
import { dispatchWin32InputModeEvent } from "./win32InputMode";
@@ -1958,8 +1959,19 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return false;
}
// Resolve the local shell chord before autocomplete can consume directory
// arrows, but apply it only after app/snippet shortcuts below.
const lineJumpSequence =
term.buffer.active.type === "normal" &&
ctx.passwordPromptActiveRef?.current !== true &&
!sudoAutofill?.isPromptPending() &&
!term.modes.win32InputMode &&
!isKittyKeyboardModeActive(kittyKeyboardMode)
? commandArrowLineJumpSequence(e, isMacPlatform())
: null;
// Autocomplete key handler (must be checked before other handlers)
if (ctx.onAutocompleteKeyEvent && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
if (ctx.onAutocompleteKeyEvent && !lineJumpSequence && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
const consumed = ctx.onAutocompleteKeyEvent(e);
if (!consumed) return false; // Event was consumed by autocomplete
}
@@ -2267,6 +2279,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return false;
}
// Keep app/snippet shortcuts and negotiated keyboard protocols ahead of
// the shell-only macOS line-editing fallback (issue #3139).
if (lineJumpSequence && ctx.sessionRef.current) {
e.preventDefault();
e.stopPropagation();
// This is a local shell editing shortcut. Peers may be running a TUI
// or another keyboard protocol, so do not fan out the mapped Ctrl key.
handleTerminalInputData(lineJumpSequence, { skipBroadcast: true });
scrollToBottomAfterInput(lineJumpSequence);
return false;
}
// macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
// #826). After kitty mode so apps using the kitty protocol keep their own
// arrow encoding; read live so the toggle applies without reconnecting.
@@ -1,4 +1,3 @@
import { publishTerminalCommandCompletion } from "../../application/state/terminalCommandCompletion";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useRef } from 'react';
import { publishPluginTerminalRuntimeLifecycleEvent } from '../../application/state/pluginTerminalRuntimeLifecycle';
@@ -190,7 +189,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(args[0]);
};
const pluginAwareOnCommandCompleted = () => {
publishTerminalCommandCompletion(sessionId);
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
};