mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 14:03:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0d032ec3c | ||
|
|
28f7193c4a | ||
|
|
51d4d97419 |
No files matched your search
@@ -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',
|
||||
|
||||
@@ -1046,7 +1046,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'sftp.goUp': '上一级',
|
||||
'sftp.goToTerminalCwd': '定位到终端当前目录',
|
||||
'sftp.locatePathInTerminal': '定位路径到终端',
|
||||
'sftp.refreshAfterCommand': '终端命令完成后刷新',
|
||||
'sftp.followTerminalCwd': '追随终端目录',
|
||||
'sftp.followTerminalCwd.enable': '开启追随终端目录',
|
||||
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
|
||||
|
||||
@@ -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(); });
|
||||
});
|
||||
@@ -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); };
|
||||
}
|
||||
@@ -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,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(); });
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user