Compare commits

...

9 Commits

Author SHA1 Message Date
陈大猫
60eeafe7a9 feat #1005: Termius-style live-preview popup autocomplete (free the Tab key) (#1059)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat #1005: add live-preview keystroke calculator for popup autocomplete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: live-render the selected popup suggestion on arrow navigation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: free Tab for the shell; Enter runs the rendered line; Esc reverts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: show key hint (→ expand / ↵ run) on the selected popup row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat #1005: live-render full path while navigating sub-directory panels

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test #1005: move live-preview test into the npm test glob

The test runner only scans components/terminal/*.test.ts (not the
autocomplete/ subdir), matching where the other autocomplete-module tests
live (e.g. completionEngine.test.ts). Relocate so it actually runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: center and refine the popup key-cap hint

Use inline-flex centering (the ↵ glyph was vertically off with line-height +
padding), softer color-mixed border/background, a system-sans font so the
glyph renders consistently regardless of the terminal font, and the more
balanced ⏎ return symbol.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: record the actual executed line on Enter, not the stale suggestion

Codex review (P2): the popup Enter handler recorded selected.text and
suppressed handleInput's recorder, so editing a previewed command (select
docker, type ' ps', Enter before the re-query) logged the stale 'docker'
instead of 'docker ps'. Delegate to handleInput's Enter path, which records
lastAcceptedCommandRef on a clean select and falls back to the live buffer
after an edit (typing nulls that ref).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix #1005: don't revert user edits when Escape closes the popup

Codex review (P2): previewActiveRef stayed true after the user edited a
previewed command, so Escape (before the debounced re-query reset state)
called renderPreviewSelection(-1) and rewrote the line back to the stale
baseline, dropping the edits. Clear previewActiveRef when the user types
(alongside the existing lastAcceptedCommandRef reset), so Escape only reverts
a pristine preview and otherwise just dismisses the popup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:58:57 +08:00
陈大猫
ee2c21e712 feat #1044: close tabs with the middle mouse button (#1058)
Middle-clicking a tab (mouse wheel click) is a conventional "close tab"
gesture in browsers and editors. Wire it to every closeable tab strip:
the top session / workspace / log-view / editor tabs and the SFTP tab bar.

A small shared helper (lib/tabInteractions.ts) handles the gesture:
onAuxClick closes the tab when button === 1, and onMouseDown calls
preventDefault for the middle button so the Chromium/Electron autoscroll
overlay does not appear. Left-click activation and right-click context
menus are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:58:19 +08:00
陈大猫
e678ad3546 fix terminal exit auto close (#1057) 2026-05-22 22:49:15 +08:00
陈大猫
c47c780b48 fix s3 checksum compatibility (#1056) 2026-05-22 22:41:25 +08:00
陈大猫
88074ac9b3 fix auto sync remote checks (#1055) 2026-05-22 22:26:05 +08:00
陈大猫
59cb0c4b65 fix #1043: skip pwd probe on network devices to keep Huawei VRP sessions alive (#1052) 2026-05-22 22:06:03 +08:00
陈大猫
bf0bd193eb fix #1049: clear WebGL texture atlas to recover from garbled terminal (#1050)
Heavy full-screen TUIs (claude code / gemini cli / opencode), font changes,
and device pixel ratio changes can leave xterm.js's WebGL glyph texture atlas
in a corrupted state that persists for the life of the terminal — users see
persistent "garbled / 花屏" output that only clears when a brand-new terminal
is opened (most often on Windows with display scaling / multi-monitor setups).

Clear the texture atlas so glyphs re-rasterize at the correct scale instead of
forcing users to reopen the terminal:

- Add watchDevicePixelRatio() helper (TDD, unit-tested) that re-registers a
  matchMedia listener across DPI changes and fires a repair callback.
- Wire it into createXTermRuntime: on devicePixelRatio change, clear the atlas
  and refit; also clear the atlas on reflow (term.onResize). Watcher is torn
  down on dispose.
- Expose clearTextureAtlas() on XTermRuntime and call it after font changes in
  Terminal.tsx (xterm.js #3280). All calls are no-ops under the DOM renderer.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:25:38 +08:00
陈大猫
7661375925 fix huawei vrp ssh detection (#1046) 2026-05-22 01:05:46 +08:00
陈大猫
308fb45985 fix comware legacy ssh handshake (#1045) 2026-05-22 00:13:59 +08:00
25 changed files with 1133 additions and 69 deletions

View 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);
});

View 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;
}

View File

@@ -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" },
);
});

View File

@@ -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" };
}

View File

@@ -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]);

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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

View File

@@ -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}

View File

@@ -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>
);
})}

View 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;
}

View File

@@ -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.

View 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",
);
});

View File

@@ -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) {

View 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);
});

View 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;
}

View File

@@ -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", () => {

View File

@@ -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,
) => {

View File

@@ -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,
};

View 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");
});

View File

@@ -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 },
);
});

View File

@@ -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,

View 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
View 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();
};

View File

@@ -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() {