Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60eeafe7a9 | ||
|
|
ee2c21e712 | ||
|
|
e678ad3546 | ||
|
|
c47c780b48 | ||
|
|
88074ac9b3 | ||
|
|
59cb0c4b65 | ||
|
|
bf0bd193eb | ||
|
|
7661375925 | ||
|
|
308fb45985 |
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
111
application/state/autoSyncRemoteSchedule.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
test("runtime remote checks wait for the startup check to finish", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: false,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks run immediately after startup gate opens", () => {
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
now: 10_000,
|
||||
lastRemoteCheckAt: null,
|
||||
minIntervalMs: 30_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime remote checks respect the minimum interval", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 40_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("forced runtime remote checks bypass only the interval gate", () => {
|
||||
const common = {
|
||||
hasAnyConnectedProvider: true,
|
||||
autoSyncEnabled: true,
|
||||
isUnlocked: true,
|
||||
startupRemoteCheckDone: true,
|
||||
isSyncing: false,
|
||||
isSyncRunning: false,
|
||||
remoteCheckInFlight: false,
|
||||
minIntervalMs: 30_000,
|
||||
force: true,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRunRuntimeRemoteCheck({
|
||||
...common,
|
||||
isSyncing: true,
|
||||
now: 35_000,
|
||||
lastRemoteCheckAt: 10_000,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
|
||||
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
|
||||
});
|
||||
35
application/state/autoSyncRemoteSchedule.ts
Normal file
35
application/state/autoSyncRemoteSchedule.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
|
||||
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
|
||||
|
||||
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
|
||||
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
|
||||
return Math.max(
|
||||
MIN_RUNTIME_REMOTE_CHECK_MS,
|
||||
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RuntimeRemoteCheckInput {
|
||||
hasAnyConnectedProvider: boolean;
|
||||
autoSyncEnabled: boolean;
|
||||
isUnlocked: boolean;
|
||||
startupRemoteCheckDone: boolean;
|
||||
isSyncing: boolean;
|
||||
isSyncRunning: boolean;
|
||||
remoteCheckInFlight: boolean;
|
||||
force?: boolean;
|
||||
now: number;
|
||||
lastRemoteCheckAt: number | null;
|
||||
minIntervalMs: number;
|
||||
}
|
||||
|
||||
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
|
||||
if (!input.hasAnyConnectedProvider) return false;
|
||||
if (!input.autoSyncEnabled) return false;
|
||||
if (!input.isUnlocked) return false;
|
||||
if (!input.startupRemoteCheckDone) return false;
|
||||
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
|
||||
if (input.force === true) return true;
|
||||
if (input.lastRemoteCheckAt == null) return true;
|
||||
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import assert from "node:assert/strict";
|
||||
|
||||
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
|
||||
|
||||
test("backend exited events keep the tab and mark it disconnected", () => {
|
||||
test("normal backend exited events close the session tab", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
{ kind: "closeSession" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,3 +16,17 @@ test("backend timeout events keep the tab and mark it disconnected", () => {
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend error events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
test("backend closed events keep the tab and mark it disconnected", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
|
||||
{ kind: "markDisconnected" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,12 +6,17 @@ export type TerminalSessionExitEvent = {
|
||||
};
|
||||
|
||||
export type TerminalSessionExitIntent =
|
||||
| { kind: "closeSession" }
|
||||
| { kind: "markDisconnected" };
|
||||
|
||||
export function resolveTerminalSessionExitIntent(
|
||||
_evt: TerminalSessionExitEvent,
|
||||
evt: TerminalSessionExitEvent,
|
||||
): TerminalSessionExitIntent {
|
||||
// Backend exits can be remote idle timeouts, shell termination, or transport closes.
|
||||
// Explicit user closes bypass this policy and call the close-session path directly.
|
||||
if (evt.reason === "exited") {
|
||||
return { kind: "closeSession" };
|
||||
}
|
||||
|
||||
// Timeouts, transport errors, and channel closes should keep the tab visible
|
||||
// so the user can inspect output and reconnect.
|
||||
return { kind: "markDisconnected" };
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
localStorageAdapter,
|
||||
} from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { notify } from '../notification';
|
||||
import {
|
||||
getRuntimeRemoteCheckIntervalMs,
|
||||
shouldRunRuntimeRemoteCheck,
|
||||
} from './autoSyncRemoteSchedule';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
@@ -95,6 +99,11 @@ interface SyncNowOptions {
|
||||
trigger?: SyncTrigger;
|
||||
}
|
||||
|
||||
interface RemoteVersionCheckOptions {
|
||||
force?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
}
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const sync = useCloudSync();
|
||||
@@ -402,17 +411,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// windows but does NOT serialize same-window re-entry, so this
|
||||
// in-flight guard closes that gap at the top of the call.
|
||||
const checkRemoteInFlightRef = useRef(false);
|
||||
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const force = options?.force === true;
|
||||
const notifyOnFailure = options?.notifyOnFailure !== false;
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
|
||||
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -548,14 +560,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
if (notifyOnFailure) {
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
}
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
// provider/unlock transition) can retry.
|
||||
} finally {
|
||||
@@ -726,12 +740,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
|
||||
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
|
||||
const now = Date.now();
|
||||
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
if (!shouldRunRuntimeRemoteCheck({
|
||||
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
|
||||
autoSyncEnabled: sync.autoSyncEnabled,
|
||||
isUnlocked: sync.isUnlocked,
|
||||
startupRemoteCheckDone: remoteCheckDoneRef.current,
|
||||
isSyncing: sync.isSyncing,
|
||||
isSyncRunning: isSyncRunningRef.current,
|
||||
remoteCheckInFlight: checkRemoteInFlightRef.current,
|
||||
force: options?.force === true,
|
||||
now,
|
||||
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
|
||||
minIntervalMs,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRuntimeRemoteCheckAtRef.current = now;
|
||||
await checkRemoteVersion({ force: true, notifyOnFailure: false });
|
||||
}, [
|
||||
checkRemoteVersion,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isSyncing,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Keep checking the cloud while the app is open. This closes the gap where
|
||||
// another device uploads changes after our startup inspection but before
|
||||
// this device edits anything locally.
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
|
||||
const timerId = window.setInterval(() => {
|
||||
void runRuntimeRemoteCheck();
|
||||
}, intervalMs);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [
|
||||
runRuntimeRemoteCheck,
|
||||
sync.autoSyncEnabled,
|
||||
sync.autoSyncInterval,
|
||||
sync.hasAnyConnectedProvider,
|
||||
sync.isUnlocked,
|
||||
]);
|
||||
|
||||
// Also re-check when the user returns to the app or the network comes back.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
}
|
||||
};
|
||||
const handleOnline = () => {
|
||||
void runRuntimeRemoteCheck({ force: true });
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('online', handleOnline);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
};
|
||||
}, [runRuntimeRemoteCheck]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
if (!sync.hasAnyConnectedProvider) {
|
||||
hasCheckedRemoteRef.current = false;
|
||||
remoteCheckDoneRef.current = false;
|
||||
lastRuntimeRemoteCheckAtRef.current = null;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
applyCustomAccentToTerminalTheme,
|
||||
resolveHostTerminalThemeId,
|
||||
} from "../domain/terminalAppearance";
|
||||
import { classifyDistroId } from "../domain/host";
|
||||
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
|
||||
import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
@@ -585,6 +585,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return clearTerminalCwd;
|
||||
}, [clearTerminalCwd, host.id]);
|
||||
|
||||
// Classify the host's device family from the *detected* distro and the
|
||||
// explicit deviceType only. This intentionally bypasses
|
||||
// getEffectiveHostDistro(): the manual distro override (`distroMode:
|
||||
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
|
||||
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
|
||||
// Huawei host must not silently re-enable POSIX-shell probes against it.
|
||||
// Several features gate on this — the working-directory probe below, the
|
||||
// /etc/os-release probe, and the periodic server-stats poll (#674) —
|
||||
// because each opens an extra exec channel that strict network-device
|
||||
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
|
||||
// whole session (#1043).
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
|
||||
useEffect(() => {
|
||||
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
|
||||
return;
|
||||
@@ -593,9 +608,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
if (!sessionRef.current) return;
|
||||
const id = sessionRef.current;
|
||||
if (!id) return;
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
// The pwd probe opens an extra POSIX-shell exec channel, which strict
|
||||
// network-device CLIs like Huawei VRP answer by closing the whole
|
||||
// session (#1043). Skip it for known network devices; for a brand-new
|
||||
// host (distro not classified yet on the first connect) consult the
|
||||
// SSH banner, which is captured for free at handshake time.
|
||||
const info = await terminalBackend.getSessionRemoteInfo?.(id);
|
||||
if (cancelled || id !== sessionRef.current) return;
|
||||
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
|
||||
return;
|
||||
}
|
||||
const result = await terminalBackend.getSessionPwd(id);
|
||||
if (!cancelled && !terminalCwdTracker.getRendererCwd() && result.success && result.cwd) {
|
||||
knownCwdRef.current = result.cwd;
|
||||
}
|
||||
@@ -608,7 +634,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [host.protocol, status, terminalBackend, terminalCwdTracker]);
|
||||
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
@@ -620,25 +646,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
|
||||
// for hosts classified as network devices (either via explicit
|
||||
// deviceType='network' or via SSH banner detection that populated
|
||||
// host.distro with a network-vendor ID). See #674: polling the stats
|
||||
// command on Cisco / Huawei / Juniper etc. generates one AAA session
|
||||
// log entry per poll because each exec channel is counted as a new
|
||||
// session on those devices.
|
||||
//
|
||||
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
|
||||
// because that honors the manual distro override (`distroMode: 'manual'`
|
||||
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
|
||||
// pinned an "ubuntu" icon on what is actually a Cisco host would
|
||||
// otherwise silently re-enable the polling loop and re-introduce the
|
||||
// AAA log flood this patch is meant to eliminate. The display icon can
|
||||
// still be overridden (see DistroAvatar) — gating uses the raw detected
|
||||
// `host.distro` and the explicit `host.deviceType` only.
|
||||
const detectedDeviceClass = classifyDistroId(host.distro);
|
||||
const isNetworkDevice =
|
||||
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
|
||||
// network devices. See isNetworkDevice above for why the gating uses the
|
||||
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
|
||||
// #674 covers the AAA-log-flood motivation for stats specifically.
|
||||
const isSupportedOs =
|
||||
!isNetworkDevice &&
|
||||
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
|
||||
@@ -1241,6 +1252,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
// Changing the font can leave the WebGL renderer drawing stale glyphs from
|
||||
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
|
||||
// Clear the texture atlas so glyphs re-rasterize with the new font.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
|
||||
if (isVisibleRef.current) {
|
||||
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
|
||||
} else {
|
||||
|
||||
@@ -978,10 +978,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
|
||||
const intent = resolveTerminalSessionExitIntent(evt);
|
||||
if (intent.kind === "markDisconnected") {
|
||||
if (intent.kind === "closeSession") {
|
||||
onCloseSession(sessionId);
|
||||
} else {
|
||||
onUpdateSessionStatus(sessionId, 'disconnected');
|
||||
}
|
||||
}, [onUpdateSessionStatus]);
|
||||
}, [onCloseSession, onUpdateSessionStatus]);
|
||||
|
||||
const handleOsDetected = useCallback((hostId: string, distro: string) => {
|
||||
onUpdateHostDistro(hostId, distro);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { cn } from '../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../lib/tabInteractions';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
@@ -355,6 +356,8 @@ const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
||||
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
@@ -458,6 +461,8 @@ const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
data-tab-type="session"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, session.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
@@ -586,6 +591,8 @@ const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, workspace.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
@@ -694,6 +701,8 @@ const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
||||
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
|
||||
@@ -20,6 +20,7 @@ import React, {
|
||||
} from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from "../../lib/tabInteractions";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
@@ -322,6 +323,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
onDragEnd={handleTabDragEnd}
|
||||
|
||||
@@ -91,6 +91,32 @@ const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ vis
|
||||
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}>›</span>
|
||||
);
|
||||
|
||||
/** Small key-cap badge shown on the selected row to hint the actionable key. */
|
||||
const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxSizing: "border-box",
|
||||
height: "16px",
|
||||
minWidth: "16px",
|
||||
padding: "0 4px",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1,
|
||||
borderRadius: "4px",
|
||||
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
|
||||
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
|
||||
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
|
||||
flexShrink: 0,
|
||||
fontFamily:
|
||||
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
@@ -361,6 +387,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
|
||||
)}
|
||||
|
||||
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
|
||||
{isSelected && (
|
||||
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
|
||||
)}
|
||||
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
24
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
24
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Compute the keystrokes to send so the terminal input line becomes exactly
|
||||
* `candidate`, given what is currently on the line. Drives the popup
|
||||
* autocomplete live-preview (#1005): moving the selection renders the chosen
|
||||
* suggestion into the command line, and switching / reverting rewrites it.
|
||||
*
|
||||
* - Forward prefix (candidate continues the line): append only the new tail.
|
||||
* - Otherwise: clear the current input, then write the full candidate. POSIX
|
||||
* shells use Ctrl-U (kill-line); Windows (cmd/PowerShell) uses backspaces
|
||||
* sized to the current line length.
|
||||
*/
|
||||
export function computeLivePreviewWrite(input: {
|
||||
currentLine: string;
|
||||
candidate: string;
|
||||
os: string;
|
||||
}): string {
|
||||
const { currentLine, candidate, os } = input;
|
||||
if (candidate === currentLine) return "";
|
||||
if (candidate.startsWith(currentLine)) {
|
||||
return candidate.slice(currentLine.length);
|
||||
}
|
||||
const clear = os === "windows" ? "\b".repeat(currentLine.length) : "\x15";
|
||||
return clear + candidate;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { preloadCommonSpecs } from "./figSpecLoader";
|
||||
import { getXTermCellDimensions } from "./xtermUtils";
|
||||
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
|
||||
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
|
||||
import { computeLivePreviewWrite } from "./livePreviewSequence";
|
||||
|
||||
export interface AutocompleteSettings {
|
||||
enabled: boolean;
|
||||
@@ -253,6 +254,10 @@ export function useTerminalAutocomplete(
|
||||
const fetchVersionRef = useRef(0);
|
||||
/** Last accepted suggestion text — for accurate history recording on fast Enter after accept */
|
||||
const lastAcceptedCommandRef = useRef<string | null>(null);
|
||||
/** The user's typed input that produced the current popup suggestions (live-preview baseline). */
|
||||
const previewBaselineRef = useRef<string>("");
|
||||
/** Whether a popup candidate is currently rendered into the command line (#1005). */
|
||||
const previewActiveRef = useRef(false);
|
||||
/** Monotonic counter to invalidate stale async sub-dir fetches */
|
||||
const subDirFetchVersionRef = useRef(0);
|
||||
/**
|
||||
@@ -536,6 +541,41 @@ export function useTerminalAutocomplete(
|
||||
});
|
||||
}, [termRef]);
|
||||
|
||||
/**
|
||||
* Render the full path for a sub-dir entry into the line WITHOUT finalizing
|
||||
* (no clearState). Used for live-preview while navigating sub-dir panels (#1005).
|
||||
*/
|
||||
const renderSubDirPath = useCallback((level: number, entry: SubDirEntry) => {
|
||||
const s = stateRef.current;
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const panel = s.subDirPanels[level];
|
||||
if (!panel) return;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term, typedInputBufferRef.current, typedBufferReliableRef.current,
|
||||
);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
const parsed = parseCommandLine(prompt.userInput);
|
||||
const cmdPrefix = parsed.tokens.slice(0, parsed.wordIndex).join(" ")
|
||||
+ (parsed.wordIndex > 0 ? " " : "");
|
||||
const currentToken = parsed.currentWord;
|
||||
const quotePrefix = currentToken.startsWith('"') || currentToken.startsWith("'")
|
||||
? currentToken[0] : "";
|
||||
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
|
||||
? entry.name : shellEscape(entry.name);
|
||||
const newCommand = cmdPrefix + `${quotePrefix}${panel.dirPath}${entryName}${suffix}${quoteSuffix}`;
|
||||
const seq = computeLivePreviewWrite({
|
||||
currentLine: prompt.userInput, candidate: newCommand, os: hostOsRef.current,
|
||||
});
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = newCommand;
|
||||
typedBufferReliableRef.current = true;
|
||||
previewActiveRef.current = true;
|
||||
lastAcceptedCommandRef.current = newCommand;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/** Handle selecting a file/directory from any sub-dir panel.
|
||||
* Builds the full path from the panel stack and replaces the current input. */
|
||||
const handleSubDirSelect = useCallback((level: number, entry: SubDirEntry) => {
|
||||
@@ -666,6 +706,9 @@ export function useTerminalAutocomplete(
|
||||
|
||||
// Popup
|
||||
if (settingsRef.current.showPopupMenu && completions.length > 0) {
|
||||
// Live-preview baseline: the typed input these suggestions completed.
|
||||
previewBaselineRef.current = input;
|
||||
previewActiveRef.current = false;
|
||||
const { position, cursorLineTop, cursorLineBottom, expandUpward } = calculatePopupPosition(term, completions.length);
|
||||
startTransition(() => {
|
||||
setState((prev) => {
|
||||
@@ -876,6 +919,10 @@ export function useTerminalAutocomplete(
|
||||
// User is typing more — invalidate accepted command fallback since the
|
||||
// command is being edited further (e.g., accepted "git status" then added " --short")
|
||||
lastAcceptedCommandRef.current = null;
|
||||
// The previewed candidate is now edited, so the line is the user's own
|
||||
// text. Drop preview-active so Escape dismisses the popup without
|
||||
// reverting these edits back to the stale baseline (#1005).
|
||||
previewActiveRef.current = false;
|
||||
|
||||
// Re-align any visible ghost text to the freshly-updated buffer
|
||||
// immediately. Without this the ghost keeps the tail it captured at
|
||||
@@ -1055,10 +1102,11 @@ export function useTerminalAutocomplete(
|
||||
// which is otherwise shadowed by our single-Tab ghost accept.
|
||||
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
|
||||
if (s.popupVisible && s.suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
const selected = s.suggestions[Math.max(0, s.selectedIndex)];
|
||||
if (selected) insertSuggestion(selected, false);
|
||||
return false;
|
||||
// #1005: don't intercept Tab. Keep whatever is currently rendered on
|
||||
// the line and let Tab reach the shell for native completion.
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
// completion will rewrite the line and the old ghost would mislead.
|
||||
@@ -1087,8 +1135,10 @@ export function useTerminalAutocomplete(
|
||||
panels[focusLevel] = { ...p, selectedIndex: newIdx };
|
||||
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
|
||||
});
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
// Live-render the highlighted entry's full path into the line (#1005).
|
||||
const newEntry = focusedPanel.entries[newIdx];
|
||||
if (newEntry) renderSubDirPath(focusLevel, newEntry);
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
if (newEntry?.type === "directory") {
|
||||
expandSubDir(focusLevel, newEntry);
|
||||
}
|
||||
@@ -1144,39 +1194,37 @@ export function useTerminalAutocomplete(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main panel navigation
|
||||
if (e.key === "ArrowUp") {
|
||||
// Main panel navigation. The cycle includes a -1 "no selection" slot so
|
||||
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
|
||||
// the selection live-renders the candidate into the command line (#1005).
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const n = s.suggestions.length;
|
||||
const cur = s.selectedIndex;
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? (cur >= n - 1 ? -1 : cur + 1)
|
||||
: (cur <= -1 ? n - 1 : cur - 1);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: prev.selectedIndex <= 0 ? prev.suggestions.length - 1 : prev.selectedIndex - 1,
|
||||
selectedIndex: next,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
fetchSubDirForIndex(s.selectedIndex <= 0 ? s.suggestions.length - 1 : s.selectedIndex - 1);
|
||||
return false;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: prev.selectedIndex >= prev.suggestions.length - 1 ? 0 : prev.selectedIndex + 1,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
fetchSubDirForIndex(s.selectedIndex >= s.suggestions.length - 1 ? 0 : s.selectedIndex + 1);
|
||||
renderPreviewSelection(next);
|
||||
if (next >= 0) fetchSubDirForIndex(next);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter on popup
|
||||
// Enter on popup. The selected candidate is already rendered into the
|
||||
// line by live-preview, so let Enter reach the shell. Don't record here:
|
||||
// handleInput's Enter path records the *actual* line — it uses
|
||||
// lastAcceptedCommandRef (set on select) but falls back to the live
|
||||
// buffer when the user edited the previewed command (typing nulls that
|
||||
// ref), so recording stays accurate in both cases.
|
||||
if (e.key === "Enter") {
|
||||
if (s.selectedIndex >= 0) {
|
||||
const selected = s.suggestions[s.selectedIndex];
|
||||
if (selected) {
|
||||
e.preventDefault();
|
||||
insertSuggestion(selected, true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1185,8 +1233,12 @@ export function useTerminalAutocomplete(
|
||||
// when only ghost text is showing (ghost text is passive/non-intrusive)
|
||||
if (e.key === "Escape" && s.popupVisible) {
|
||||
e.preventDefault();
|
||||
if (previewActiveRef.current) {
|
||||
renderPreviewSelection(-1); // restore the typed baseline
|
||||
}
|
||||
ghost?.hide();
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1196,6 +1248,36 @@ export function useTerminalAutocomplete(
|
||||
[writeToTerminal],
|
||||
);
|
||||
|
||||
/**
|
||||
* Render the suggestion at `index` straight into the command line (Termius
|
||||
* live-preview, #1005). `index < 0` restores the user's typed baseline.
|
||||
*/
|
||||
const renderPreviewSelection = useCallback((index: number) => {
|
||||
const s = stateRef.current;
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const baseline = previewBaselineRef.current;
|
||||
const candidate =
|
||||
index >= 0 && s.suggestions[index] ? s.suggestions[index].text : baseline;
|
||||
const { prompt } = getAlignedPrompt(
|
||||
term,
|
||||
typedInputBufferRef.current,
|
||||
typedBufferReliableRef.current,
|
||||
);
|
||||
if (!prompt.isAtPrompt) return;
|
||||
const seq = computeLivePreviewWrite({
|
||||
currentLine: prompt.userInput,
|
||||
candidate,
|
||||
os: hostOsRef.current,
|
||||
});
|
||||
if (seq) writeToTerminal(seq);
|
||||
typedInputBufferRef.current = candidate;
|
||||
typedBufferReliableRef.current = true;
|
||||
const isPreview = index >= 0 && candidate !== baseline;
|
||||
previewActiveRef.current = isPreview;
|
||||
lastAcceptedCommandRef.current = isPreview ? candidate : null;
|
||||
}, [termRef, writeToTerminal]);
|
||||
|
||||
/**
|
||||
* Insert a suggestion into the terminal.
|
||||
* @param execute If true, also sends \r to execute the command.
|
||||
|
||||
45
components/terminal/livePreviewSequence.test.ts
Normal file
45
components/terminal/livePreviewSequence.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { computeLivePreviewWrite } from "./autocomplete/livePreviewSequence.ts";
|
||||
|
||||
test("appends only the tail when the candidate continues the current line", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "do", candidate: "docker", os: "linux" }),
|
||||
"cker",
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty when the line already equals the candidate", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "docker", os: "linux" }),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears with Ctrl-U then writes the full candidate on a non-prefix change", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "df", os: "linux" }),
|
||||
"\x15df",
|
||||
);
|
||||
});
|
||||
|
||||
test("clears when switching to a shorter prefix candidate", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker-compose", candidate: "docker", os: "linux" }),
|
||||
"\x15docker",
|
||||
);
|
||||
});
|
||||
|
||||
test("reverting to the typed baseline clears then rewrites the baseline", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "docker", candidate: "do", os: "linux" }),
|
||||
"\x15do",
|
||||
);
|
||||
});
|
||||
|
||||
test("Windows uses backspaces sized to the current line, not Ctrl-U", () => {
|
||||
assert.equal(
|
||||
computeLivePreviewWrite({ currentLine: "abc", candidate: "xy", os: "windows" }),
|
||||
"\b\b\bxy",
|
||||
);
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "./kittyKeyboardProtocol";
|
||||
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
markExpectedTerminalCursorPositionReport,
|
||||
@@ -79,6 +80,13 @@ export type XTermRuntime = {
|
||||
/** Current working directory detected via OSC 7 */
|
||||
currentCwd: string | undefined;
|
||||
keywordHighlighter: KeywordHighlighter;
|
||||
/**
|
||||
* Clear the WebGL renderer's glyph texture atlas so glyphs re-rasterize on the
|
||||
* next frame. No-op when the DOM renderer is active. Used to recover from the
|
||||
* persistent "garbled / 花屏" corruption (issue #1049) that the WebGL atlas can
|
||||
* fall into after font changes or device pixel ratio changes.
|
||||
*/
|
||||
clearTextureAtlas: () => void;
|
||||
};
|
||||
|
||||
export type CreateXTermRuntimeContext = {
|
||||
@@ -386,6 +394,45 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
? "dom"
|
||||
: "webgl";
|
||||
|
||||
// The WebGL renderer caches rasterized glyphs in a texture atlas. Heavy TUIs
|
||||
// (claude code / gemini cli / opencode and other full-screen agents), font
|
||||
// changes, and device pixel ratio changes can leave that atlas in a corrupted
|
||||
// state that persists for the life of the terminal — the "garbled / 花屏"
|
||||
// report in issue #1049 where only opening a brand-new terminal helps. Clearing
|
||||
// the atlas forces glyphs to re-rasterize at the correct scale on the next
|
||||
// frame. No-op for the DOM renderer.
|
||||
const clearWebglTextureAtlas = () => {
|
||||
if (!webglAddon) return;
|
||||
try {
|
||||
webglAddon.clearTextureAtlas();
|
||||
} catch (err) {
|
||||
logger.warn("[XTerm] clearTextureAtlas failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Recover the renderer when the device pixel ratio changes (moving the window
|
||||
// between monitors with different DPI, or changing OS display scaling — a
|
||||
// common Windows trigger). matchMedia change does not fire a normal resize, so
|
||||
// this is needed in addition to the resize handling below.
|
||||
let stopDprWatch: () => void = () => {};
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function"
|
||||
) {
|
||||
stopDprWatch = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: () => window.devicePixelRatio || 1,
|
||||
matchMedia: (query) => window.matchMedia(query),
|
||||
onChange: () => {
|
||||
clearWebglTextureAtlas();
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch (err) {
|
||||
logger.warn("[XTerm] fit after devicePixelRatio change failed", err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||||
const currentLinkModifier = ctx.terminalSettingsRef.current?.linkModifier ?? "none";
|
||||
let shouldOpen = false;
|
||||
@@ -858,6 +905,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
|
||||
// dimensions re-rasterize cleanly (issue #1049).
|
||||
clearWebglTextureAtlas();
|
||||
const id = ctx.sessionRef.current;
|
||||
if (!id) return;
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout);
|
||||
@@ -876,8 +926,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
serializeAddon,
|
||||
searchAddon,
|
||||
keywordHighlighter,
|
||||
clearTextureAtlas: clearWebglTextureAtlas,
|
||||
dispose: () => {
|
||||
cleanupMiddleClick?.();
|
||||
stopDprWatch();
|
||||
keywordHighlighter.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
for (const disposable of cursorPositionReportRequestDisposables) {
|
||||
|
||||
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
type MediaQueryListLike,
|
||||
watchDevicePixelRatio,
|
||||
} from "./rendererDprWatch";
|
||||
|
||||
class FakeMediaQueryList implements MediaQueryListLike {
|
||||
readonly query: string;
|
||||
modernListeners: Array<() => void> = [];
|
||||
legacyListeners: Array<() => void> = [];
|
||||
private readonly supportsModern: boolean;
|
||||
|
||||
constructor(query: string, supportsModern = true) {
|
||||
this.query = query;
|
||||
this.supportsModern = supportsModern;
|
||||
if (!supportsModern) {
|
||||
// Strip the modern API to emulate legacy environments.
|
||||
this.addEventListener = undefined;
|
||||
this.removeEventListener = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener? = (_type: "change", listener: () => void) => {
|
||||
this.modernListeners.push(listener);
|
||||
};
|
||||
|
||||
removeEventListener? = (_type: "change", listener: () => void) => {
|
||||
this.modernListeners = this.modernListeners.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
addListener = (listener: () => void) => {
|
||||
this.legacyListeners.push(listener);
|
||||
};
|
||||
|
||||
removeListener = (listener: () => void) => {
|
||||
this.legacyListeners = this.legacyListeners.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
trigger() {
|
||||
for (const l of [...this.modernListeners, ...this.legacyListeners]) l();
|
||||
}
|
||||
|
||||
get listenerCount() {
|
||||
return this.modernListeners.length + this.legacyListeners.length;
|
||||
}
|
||||
}
|
||||
|
||||
function makeEnv(initialDpr: number, supportsModern = true) {
|
||||
let dpr = initialDpr;
|
||||
const created: FakeMediaQueryList[] = [];
|
||||
return {
|
||||
created,
|
||||
getDevicePixelRatio: () => dpr,
|
||||
matchMedia: (query: string) => {
|
||||
const mql = new FakeMediaQueryList(query, supportsModern);
|
||||
created.push(mql);
|
||||
return mql;
|
||||
},
|
||||
setDpr: (value: number) => {
|
||||
dpr = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("registers a change listener for the current devicePixelRatio", () => {
|
||||
const env = makeEnv(1);
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
assert.equal(env.created.length, 1);
|
||||
assert.equal(env.created[0].query, "(resolution: 1dppx)");
|
||||
assert.equal(env.created[0].listenerCount, 1);
|
||||
});
|
||||
|
||||
test("invokes onChange when the media query reports a change", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
env.setDpr(2);
|
||||
env.created[0].trigger();
|
||||
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test("re-registers for the new ratio so subsequent changes still fire", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
env.setDpr(2);
|
||||
env.created[0].trigger();
|
||||
|
||||
assert.equal(env.created.length, 2);
|
||||
assert.equal(env.created[1].query, "(resolution: 2dppx)");
|
||||
// The stale listener must be detached so it cannot double-fire.
|
||||
assert.equal(env.created[0].listenerCount, 0);
|
||||
|
||||
env.setDpr(3);
|
||||
env.created[1].trigger();
|
||||
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test("cleanup stops further onChange callbacks", () => {
|
||||
const env = makeEnv(1);
|
||||
let calls = 0;
|
||||
const stop = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
stop();
|
||||
|
||||
assert.equal(env.created[0].listenerCount, 0);
|
||||
env.created[0].trigger();
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("falls back to addListener/removeListener when addEventListener is unavailable", () => {
|
||||
const env = makeEnv(1, /* supportsModern */ false);
|
||||
let calls = 0;
|
||||
const stop = watchDevicePixelRatio({
|
||||
getDevicePixelRatio: env.getDevicePixelRatio,
|
||||
matchMedia: env.matchMedia,
|
||||
onChange: () => {
|
||||
calls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(env.created[0].legacyListeners.length, 1);
|
||||
env.created[0].trigger();
|
||||
assert.equal(calls, 1);
|
||||
|
||||
stop();
|
||||
// After cleanup the most recently registered query has no listeners.
|
||||
const latest = env.created[env.created.length - 1];
|
||||
assert.equal(latest.listenerCount, 0);
|
||||
});
|
||||
72
components/terminal/runtime/rendererDprWatch.ts
Normal file
72
components/terminal/runtime/rendererDprWatch.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Watches for devicePixelRatio changes (e.g. moving the window between monitors
|
||||
* with different DPI, or changing the OS display scaling on Windows) and invokes
|
||||
* a callback so the renderer can be repaired.
|
||||
*
|
||||
* The WebGL renderer caches rasterized glyphs in a texture atlas keyed to the
|
||||
* device pixel ratio at creation time. When the ratio changes the cached glyphs
|
||||
* are drawn at the wrong scale, producing the persistent "garbled / 花屏"
|
||||
* corruption reported in issue #1049 that only goes away when a brand-new
|
||||
* terminal is opened. xterm.js recommends calling `clearTextureAtlas()` on DPR
|
||||
* change so glyphs re-rasterize at the new scale.
|
||||
*
|
||||
* `matchMedia('(resolution: Ndppx)')` only matches a single ratio, so after each
|
||||
* change we must re-register the listener against the new ratio.
|
||||
*/
|
||||
export interface MediaQueryListLike {
|
||||
addEventListener?: (type: "change", listener: () => void) => void;
|
||||
removeEventListener?: (type: "change", listener: () => void) => void;
|
||||
// Legacy API (older Safari / Electron) where addEventListener is unavailable.
|
||||
addListener?: (listener: () => void) => void;
|
||||
removeListener?: (listener: () => void) => void;
|
||||
}
|
||||
|
||||
export interface WatchDevicePixelRatioOptions {
|
||||
getDevicePixelRatio: () => number;
|
||||
matchMedia: (query: string) => MediaQueryListLike;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching for devicePixelRatio changes. Returns a cleanup function that
|
||||
* removes the active listener.
|
||||
*/
|
||||
export function watchDevicePixelRatio(
|
||||
options: WatchDevicePixelRatioOptions,
|
||||
): () => void {
|
||||
const { getDevicePixelRatio, matchMedia, onChange } = options;
|
||||
let current: { mql: MediaQueryListLike; listener: () => void } | null = null;
|
||||
|
||||
const detach = () => {
|
||||
if (!current) return;
|
||||
const { mql, listener } = current;
|
||||
if (mql.removeEventListener) {
|
||||
mql.removeEventListener("change", listener);
|
||||
} else if (mql.removeListener) {
|
||||
mql.removeListener(listener);
|
||||
}
|
||||
current = null;
|
||||
};
|
||||
|
||||
const attach = () => {
|
||||
const dpr = getDevicePixelRatio();
|
||||
const mql = matchMedia(`(resolution: ${dpr}dppx)`);
|
||||
const listener = () => {
|
||||
// A media query only matches the ratio it was created with, so detach the
|
||||
// stale listener and re-register against the new ratio before notifying.
|
||||
detach();
|
||||
attach();
|
||||
onChange();
|
||||
};
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener("change", listener);
|
||||
} else if (mql.addListener) {
|
||||
mql.addListener(listener);
|
||||
}
|
||||
current = { mql, listener };
|
||||
};
|
||||
|
||||
attach();
|
||||
|
||||
return detach;
|
||||
}
|
||||
@@ -3,12 +3,14 @@ import assert from "node:assert/strict";
|
||||
|
||||
import type { Host } from "./models.ts";
|
||||
import {
|
||||
detectVendorFromSshVersion,
|
||||
normalizePrimaryTelnetState,
|
||||
resolveHostKeepalive,
|
||||
resolveTelnetPort,
|
||||
resolveTelnetPassword,
|
||||
resolveTelnetUsername,
|
||||
sanitizeHost,
|
||||
shouldProbeSessionCwd,
|
||||
upsertHostById,
|
||||
} from "./host.ts";
|
||||
|
||||
@@ -158,6 +160,39 @@ test("sanitizeHost keeps a still-valid fontFamily untouched", () => {
|
||||
assert.equal(after.fontFamilyOverride, true);
|
||||
});
|
||||
|
||||
test("detectVendorFromSshVersion recognizes legacy Huawei VRP dash banner", () => {
|
||||
assert.equal(detectVendorFromSshVersion("-"), "huawei");
|
||||
assert.equal(detectVendorFromSshVersion("SSH-2.0--"), "huawei");
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd allows the probe on a plain Linux host", () => {
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd skips the probe on an already-classified network device", () => {
|
||||
// Reconnect / manual deviceType='network': host.distro already says network.
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: true, remoteSshVersion: "OpenSSH_9.6" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldProbeSessionCwd skips the probe when the SSH banner reveals a network vendor", () => {
|
||||
// First connect to a brand-new Huawei VRP: host.distro not persisted yet, so
|
||||
// isNetworkDevice is still false — the banner is the only signal (#1043).
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "-" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "SSH-1.99--" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
const GLOBAL_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
|
||||
|
||||
test("resolveHostKeepalive falls back to global when override is not set", () => {
|
||||
|
||||
@@ -78,7 +78,7 @@ export const normalizeDistroId = (value?: string) => {
|
||||
* plain `OpenSSH_*` with no distinct vendor marker.
|
||||
*/
|
||||
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
|
||||
const s = (softwareVersion || '').trim();
|
||||
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
|
||||
if (!s) return '';
|
||||
|
||||
// Cisco family — IOS, IOS XA, Wireless LAN Controller
|
||||
@@ -97,6 +97,7 @@ export const detectVendorFromSshVersion = (softwareVersion?: string): '' | Netwo
|
||||
if (/^NetScreen\b/.test(s)) return 'juniper';
|
||||
|
||||
// Huawei VRP and related products
|
||||
if (s === '-') return 'huawei';
|
||||
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
|
||||
if (/^VRP-/i.test(s)) return 'huawei';
|
||||
|
||||
@@ -135,6 +136,24 @@ export const classifyDistroId = (distroId?: string): DeviceClass => {
|
||||
return 'other';
|
||||
};
|
||||
|
||||
/**
|
||||
* Decide whether it is safe to run the post-connect `pwd` probe that
|
||||
* discovers the session's working directory. The probe opens an extra exec
|
||||
* channel running a POSIX-shell script; strict network-device CLIs such as
|
||||
* Huawei VRP respond by closing the whole SSH session (#1043), so it must be
|
||||
* skipped for them.
|
||||
*
|
||||
* `isNetworkDevice` covers hosts we already classified (a reconnect, or an
|
||||
* explicit `deviceType: 'network'`). On a brand-new host that field is not
|
||||
* populated yet, so we also inspect the SSH server identification banner —
|
||||
* captured for free at handshake — which identifies most vendors directly.
|
||||
*/
|
||||
export const shouldProbeSessionCwd = (opts: {
|
||||
isNetworkDevice: boolean;
|
||||
remoteSshVersion?: string;
|
||||
}): boolean =>
|
||||
!opts.isNetworkDevice && !detectVendorFromSshVersion(opts.remoteSshVersion);
|
||||
|
||||
export const getEffectiveHostDistro = (
|
||||
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
|
||||
) => {
|
||||
|
||||
@@ -98,6 +98,8 @@ const buildS3Client = (config) =>
|
||||
region: config.region,
|
||||
endpoint: normalizeEndpoint(config.endpoint),
|
||||
forcePathStyle: config.forcePathStyle ?? true,
|
||||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||||
responseChecksumValidation: "WHEN_REQUIRED",
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
@@ -299,4 +301,5 @@ module.exports = {
|
||||
// Exposed for tests
|
||||
handleWebdavInitialize,
|
||||
buildBasicAuthHeader,
|
||||
buildS3Client,
|
||||
};
|
||||
|
||||
25
electron/bridges/cloudSyncBridge.s3Checksum.test.cjs
Normal file
25
electron/bridges/cloudSyncBridge.s3Checksum.test.cjs
Normal file
@@ -0,0 +1,25 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
buildS3Client,
|
||||
} = require("./cloudSyncBridge.cjs");
|
||||
|
||||
const config = {
|
||||
endpoint: "https://s3.example.com",
|
||||
region: "us-east-1",
|
||||
bucket: "netcatty-test",
|
||||
accessKeyId: "access",
|
||||
secretAccessKey: "secret",
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
test("S3 client only sends request checksums when required", async () => {
|
||||
const client = buildS3Client(config);
|
||||
assert.equal(await client.config.requestChecksumCalculation(), "WHEN_REQUIRED");
|
||||
});
|
||||
|
||||
test("S3 client only validates response checksums when required", async () => {
|
||||
const client = buildS3Client(config);
|
||||
assert.equal(await client.config.responseChecksumValidation(), "WHEN_REQUIRED");
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const { KexInit, HANDLERS: KEX_HANDLERS } = require("../../node_modules/ssh2/lib/protocol/kex.js");
|
||||
const { COMPAT, COMPAT_CHECKS, MESSAGE } = require("../../node_modules/ssh2/lib/protocol/constants.js");
|
||||
|
||||
const sshBridge = require("./sshBridge.cjs");
|
||||
const sftpBridge = require("./sftpBridge.cjs");
|
||||
@@ -45,6 +47,81 @@ function withAlgorithmRuntime({ unsupportedGroups = new Set(), hashes = ["sha1",
|
||||
}
|
||||
}
|
||||
|
||||
function kexPayloadFrom(init) {
|
||||
const payload = Buffer.alloc(1 + 16 + init.totalSize + 1 + 4);
|
||||
payload[0] = MESSAGE.KEXINIT;
|
||||
init.copyAllTo(payload, 17);
|
||||
return payload;
|
||||
}
|
||||
|
||||
function buildKexInit(algorithms) {
|
||||
return new KexInit({
|
||||
kex: algorithms.kex,
|
||||
serverHostKey: algorithms.serverHostKey,
|
||||
cs: {
|
||||
cipher: algorithms.cipher,
|
||||
mac: algorithms.hmac,
|
||||
compress: algorithms.compress,
|
||||
lang: [],
|
||||
},
|
||||
sc: {
|
||||
cipher: algorithms.cipher,
|
||||
mac: algorithms.hmac,
|
||||
compress: algorithms.compress,
|
||||
lang: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readLegacyGexRequestBits(compatFlags) {
|
||||
const algorithms = sshBridge.buildAlgorithms(true);
|
||||
const writtenPackets = [];
|
||||
const protocol = {
|
||||
_server: false,
|
||||
_compatFlags: compatFlags,
|
||||
_offer: buildKexInit(algorithms),
|
||||
_debug: undefined,
|
||||
_strictMode: undefined,
|
||||
_kex: undefined,
|
||||
_kexinit: Buffer.from("local-kexinit"),
|
||||
_identRaw: Buffer.from("SSH-2.0-netcatty-test"),
|
||||
_remoteIdentRaw: Buffer.from("SSH-2.0-Comware-5.20"),
|
||||
_packetRW: {
|
||||
write: {
|
||||
allocStartKEX: 0,
|
||||
alloc(size) {
|
||||
return Buffer.alloc(size);
|
||||
},
|
||||
finalize(packet) {
|
||||
return packet;
|
||||
},
|
||||
},
|
||||
},
|
||||
_cipher: {
|
||||
encrypt(packet) {
|
||||
writtenPackets.push(Buffer.from(packet));
|
||||
},
|
||||
},
|
||||
};
|
||||
const remote = buildKexInit({
|
||||
kex: ["diffie-hellman-group-exchange-sha1"],
|
||||
serverHostKey: ["ecdsa-sha2-nistp256", "ssh-rsa"],
|
||||
cipher: ["aes128-ctr"],
|
||||
hmac: ["hmac-sha2-256"],
|
||||
compress: ["none"],
|
||||
});
|
||||
|
||||
KEX_HANDLERS[MESSAGE.KEXINIT](protocol, kexPayloadFrom(remote));
|
||||
|
||||
const request = writtenPackets.find((packet) => packet[0] === MESSAGE.KEXDH_GEX_REQUEST);
|
||||
assert.ok(request, "expected a DH group-exchange request packet");
|
||||
return {
|
||||
min: request.readUInt32BE(1),
|
||||
preferred: request.readUInt32BE(5),
|
||||
max: request.readUInt32BE(9),
|
||||
};
|
||||
}
|
||||
|
||||
for (const [label, buildAlgorithms] of [
|
||||
["SSH", sshBridge.buildAlgorithms],
|
||||
["SFTP", sftpBridge.buildSftpAlgorithms],
|
||||
@@ -123,3 +200,17 @@ test("legacy HMAC algorithms skip MD5 when the runtime disables it", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("Comware legacy group-exchange requests OpenSSH 6.4-sized DH groups", () => {
|
||||
const comwareCompatRule = COMPAT_CHECKS.find(([pattern, flags]) => (
|
||||
pattern instanceof RegExp
|
||||
&& pattern.test("Comware-5.20")
|
||||
&& (flags & COMPAT.COMWARE_DHGEX_1024)
|
||||
));
|
||||
|
||||
assert.ok(comwareCompatRule, "Comware servers should opt into the old DH group-exchange request size");
|
||||
assert.deepEqual(
|
||||
readLegacyGexRequestBits(COMPAT.COMWARE_DHGEX_1024),
|
||||
{ min: 1024, preferred: 1024, max: 8192 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -194,6 +194,8 @@ export class S3Adapter {
|
||||
region: config.region,
|
||||
endpoint: config.endpoint,
|
||||
forcePathStyle: config.forcePathStyle ?? true,
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
|
||||
66
lib/tabInteractions.test.ts
Normal file
66
lib/tabInteractions.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type React from "react";
|
||||
|
||||
import {
|
||||
MIDDLE_MOUSE_BUTTON,
|
||||
handleTabMiddleClickClose,
|
||||
handleTabMiddleMouseDown,
|
||||
} from "./tabInteractions.ts";
|
||||
|
||||
interface FakeMouseEvent {
|
||||
button: number;
|
||||
preventDefault: () => void;
|
||||
stopPropagation: () => void;
|
||||
}
|
||||
|
||||
const makeEvent = (button: number) => {
|
||||
const calls = { preventDefault: 0, stopPropagation: 0 };
|
||||
const event = {
|
||||
button,
|
||||
preventDefault: () => {
|
||||
calls.preventDefault++;
|
||||
},
|
||||
stopPropagation: () => {
|
||||
calls.stopPropagation++;
|
||||
},
|
||||
} satisfies FakeMouseEvent;
|
||||
return { event: event as unknown as React.MouseEvent, calls };
|
||||
};
|
||||
|
||||
test("handleTabMiddleClickClose closes the tab on a middle click", () => {
|
||||
let closed = 0;
|
||||
const { event, calls } = makeEvent(MIDDLE_MOUSE_BUTTON);
|
||||
|
||||
handleTabMiddleClickClose(event, () => {
|
||||
closed++;
|
||||
});
|
||||
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(calls.preventDefault, 1);
|
||||
assert.equal(calls.stopPropagation, 1);
|
||||
});
|
||||
|
||||
test("handleTabMiddleClickClose ignores left and right clicks", () => {
|
||||
for (const button of [0, 2]) {
|
||||
let closed = 0;
|
||||
const { event, calls } = makeEvent(button);
|
||||
|
||||
handleTabMiddleClickClose(event, () => {
|
||||
closed++;
|
||||
});
|
||||
|
||||
assert.equal(closed, 0, `button ${button} must not close the tab`);
|
||||
assert.equal(calls.preventDefault, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("handleTabMiddleMouseDown suppresses autoscroll only for the middle button", () => {
|
||||
const middle = makeEvent(MIDDLE_MOUSE_BUTTON);
|
||||
handleTabMiddleMouseDown(middle.event);
|
||||
assert.equal(middle.calls.preventDefault, 1);
|
||||
|
||||
const left = makeEvent(0);
|
||||
handleTabMiddleMouseDown(left.event);
|
||||
assert.equal(left.calls.preventDefault, 0);
|
||||
});
|
||||
34
lib/tabInteractions.ts
Normal file
34
lib/tabInteractions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type React from "react";
|
||||
|
||||
/**
|
||||
* The DOM `MouseEvent.button` value for the middle mouse button (wheel click).
|
||||
* 0 = left/primary, 1 = middle, 2 = right/secondary.
|
||||
*/
|
||||
export const MIDDLE_MOUSE_BUTTON = 1;
|
||||
|
||||
/**
|
||||
* Suppress the Chromium/Electron middle-click autoscroll affordance on a tab.
|
||||
* Wire to `onMouseDown`: autoscroll is armed on mousedown, so preventing the
|
||||
* default there stops the panning-cursor overlay from appearing when a user
|
||||
* middle-clicks a tab to close it (#1044).
|
||||
*/
|
||||
export const handleTabMiddleMouseDown = (e: React.MouseEvent): void => {
|
||||
if (e.button === MIDDLE_MOUSE_BUTTON) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Close a tab when it is middle-clicked. Wire to `onAuxClick`, which fires for
|
||||
* a completed non-primary click. Left clicks (tab activation) and right clicks
|
||||
* (context menu) are ignored so existing behavior is untouched.
|
||||
*/
|
||||
export const handleTabMiddleClickClose = (
|
||||
e: React.MouseEvent,
|
||||
close: () => void,
|
||||
): void => {
|
||||
if (e.button !== MIDDLE_MOUSE_BUTTON) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
};
|
||||
@@ -738,3 +738,40 @@ index 9f33c02..9751164 100644
|
||||
}
|
||||
if (names !== undefined) {
|
||||
sftp._debug && sftp._debug(
|
||||
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
|
||||
index ad77592..4b3f71a 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/constants.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/constants.js
|
||||
@@ -160,4 +160,5 @@ const COMPAT = {
|
||||
DYN_RPORT_BUG: 1 << 2,
|
||||
BUG_DHGEX_LARGE: 1 << 3,
|
||||
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
|
||||
+ COMWARE_DHGEX_1024: 1 << 5,
|
||||
};
|
||||
@@ -330,6 +331,7 @@ module.exports = {
|
||||
COMPAT_CHECKS: [
|
||||
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
|
||||
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
|
||||
+ [ /^Comware-/, COMPAT.COMWARE_DHGEX_1024 ],
|
||||
[ /^[0-9.]+$/, COMPAT.OLD_EXIT ], // old SSH.com implementations
|
||||
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
|
||||
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
|
||||
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
|
||||
index 811e631..4b5f792 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/kex.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/kex.js
|
||||
@@ -1377,8 +1377,13 @@ const createKeyExchange = (() => {
|
||||
this._generator = null;
|
||||
this._minBits = GEX_MIN_BITS;
|
||||
this._prefBits = dhEstimate(this.negotiated);
|
||||
- if (this._protocol._compatFlags & COMPAT.BUG_DHGEX_LARGE)
|
||||
+ if (hashName === 'sha1'
|
||||
+ && (this._protocol._compatFlags & COMPAT.COMWARE_DHGEX_1024)) {
|
||||
+ this._minBits = 1024;
|
||||
+ this._prefBits = 1024;
|
||||
+ } else if (this._protocol._compatFlags & COMPAT.BUG_DHGEX_LARGE) {
|
||||
this._prefBits = Math.min(this._prefBits, 4096);
|
||||
+ }
|
||||
this._maxBits = GEX_MAX_BITS;
|
||||
}
|
||||
start() {
|
||||
|
||||
Reference in New Issue
Block a user