Compare commits

...

7 Commits

Author SHA1 Message Date
陈大猫
5d29c8d91a fix: support IPv6 addresses in quick connect and fix display formatting (#472)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: support bare IPv6 addresses in quick connect and fix IPv6 display

- Accept un-bracketed IPv6 addresses (e.g. 2607:f130::4f06) in quick
  connect input. The main regex requires brackets for IPv6+port, but now
  falls back to detecting bare IPv6 (2+ colons, hex-only) when the
  primary pattern fails.
- Add formatHostPort() helper that wraps IPv6 addresses in brackets
  when appending a port, preventing ambiguous displays like
  "2607:f130::4f06:22"
- Apply formatHostPort in QuickConnectWizard, TerminalConnectionDialog,
  and SftpSidePanel
- Fix hop label formatting in sshBridge and sftpBridge for IPv6 jump
  hosts

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

* fix: truncate long hostnames in connection dialog

Add truncate to the host label and protocol subtitle in the connection
dialog so long IPv6 addresses don't overflow into the action buttons.

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

* fix: constrain connection dialog header so truncate works correctly

Add min-w-0/flex-1 to the left side of the header flex container and
shrink-0 to the avatar so long hostnames truncate instead of pushing
into the Show logs / close buttons.

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

* fix: prevent action buttons from being squeezed by long hostname

Add shrink-0 and left margin to the right-side button group so truncated
text doesn't crowd into Show logs / close buttons.

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

* fix: tighten bare IPv6 detection to avoid MAC address false positives

Only accept bare (un-bracketed) hex:colon strings as IPv6 if they
contain '::' (unambiguously IPv6) or have exactly 7 colons (full
8-group notation). This rejects MAC addresses like aa:bb:cc:dd:ee:ff
(5 colons) which would otherwise trigger quick-connect mode.

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

* fix: avoid double-wrapping already-bracketed IPv6 hop labels

Add !startsWith('[') guard so hostnames that are already bracketed
(e.g. from URL-imported hosts) don't produce malformed labels like
[[2607:f130::4f06]]:22.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:13:58 +08:00
陈大猫
196b1f8dbb feat: add terminal smooth scrolling setting (#471)
- Add smoothScrolling boolean to TerminalSettings (default: true)
- Wire setting to xterm.js smoothScrollDuration (120ms when on, 0 when off)
- Add toggle in terminal settings UI
- Include in sync payload and i18n strings (en, zh-CN)

Inspired by #467 (@crawt).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:39:03 +08:00
陈大猫
f1065745bc perf(keyword-highlight): skip cellMap for ASCII lines and share empty result array (#470)
- Use a regex ASCII test to detect lines where string indices equal cell
  columns, skipping the buildStringToCellMap buffer walk entirely. Most
  terminal output is ASCII, so this avoids the majority of cell API calls.
- Share a frozen empty array for non-matching lines instead of allocating
  a new array per scanLine call, reducing GC pressure during scrollback.

Inspired by #466 (@crawt).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:24:39 +08:00
陈大猫
c67befa0e9 perf(keyword-highlight): reduce latency with throttled rAF and line cache (#469)
* perf(keyword-highlight): reduce highlight latency with throttled rAF and line cache

Based on #464 by @crawt with fixes for review feedback:

- Split triggerRefresh into immediate (rAF) and debounced (setTimeout) modes
  so onWriteParsed highlights land with fresh content instead of trailing
  by 200ms
- Throttle the immediate path (50ms min interval) to prevent heavy output
  like tail -f from refreshing every frame
- Add per-line match result cache (LRU, bounded by cacheEntries config)
  so repeated or scrolled-back lines skip regex scanning entirely
- Lazily build cellMap only when a regex match is found, avoiding
  unnecessary work on non-matching lines
- Fix buildStringToCellMap to handle empty cells (codepoint 0) which
  translateToString() renders as spaces — keeps the map aligned with
  the string and makes lineText a safe cache key
- Clean up animationFrameId and matchCache on dispose/rule change

Co-Authored-By: Leo Pan <crawt@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard rAF callback against stale state and add debounce fallback

- Re-check enabled/alternate-buffer inside the rAF callback so a
  pending frame doesn't resurrect decorations after the user disables
  highlighting or enters an alternate-buffer app
- Schedule a debounce timer alongside rAF so background/hidden tabs
  (where Chromium suspends rAF) still get highlight updates

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

* fix: prevent fallback timer from being cleared on rAF-pending path

- Don't clear debounceTimer at the start of immediate mode — in hidden
  tabs rAF stays pending indefinitely, so repeated onWriteParsed calls
  were clearing the only timer that could actually fire
- Cancel debounceTimer inside the rAF callback instead, so foreground
  tabs don't get a redundant second refreshViewport() 200ms later
- Only arm a new fallback timer if one isn't already pending

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

* fix: clear stale rAF in fallback timer and add alternate buffer guard

- Cancel the pending rAF and clear animationFrameId in the fallback
  timer callback so hidden-tab refreshes don't leave animationFrameId
  stuck, which would block all future immediate refreshes
- Add enabled/alternate-buffer re-check in the fallback callback,
  matching the guard already present in the rAF callback

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

* fix: extract executeRefresh to ensure all timer paths clear stale rAF

A debounced-path timer (from scroll/resize) could fire without clearing
a stale animationFrameId left by an earlier immediate-path rAF that
never executed (hidden tab). This left the immediate path permanently
blocked.

Extract executeRefresh() with rAF cleanup + state guards, used by all
three callback sites (rAF, immediate fallback, debounced timer).

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

---------

Co-authored-by: Leo Pan <crawt@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:17:01 +08:00
陈大猫
cea83d6cb1 Revert "Mod:perf(keyword-highlight): reduce highlight latency and redundant regex scanning (#464)" (#468)
This reverts commit 293ee46b26.
2026-03-23 21:46:04 +08:00
Leo Pan
293ee46b26 Mod:perf(keyword-highlight): reduce highlight latency and redundant regex scanning (#464)
* perf(keyword-highlight): reduce highlight latency and redundant regex scanning

- Split triggerRefresh into two modes: "immediate" (rAF, for new output
  and rule changes) and "debounced" (setTimeout, for scroll/resize),
  eliminating the fixed 200ms delay after each write that caused visible
  highlight lag on commands like `ls`.
- Add per-line match result cache (LRU, bounded by cacheEntries config)
  so repeated or scrolled-back lines skip regex scanning entirely.
- Lazily build the string-to-cell column map only when a regex match is
  actually found, avoiding unnecessary work on non-matching lines.
- Clean up animationFrameId and matchCache on dispose/rule change to
  prevent leaks and stale results.

* fix: include cell layout in highlight cache key to prevent misplaced decorations

Two IBufferLines can produce identical translateToString() output but
differ in cell layout (e.g. empty cells vs real space characters after
tab stops). Using lineText alone as the cache key could return cached
x/width ranges computed from a different cell layout, producing
misplaced or truncated highlights.

Build the cellMap eagerly and include it in the cache key so lines with
different cell structures get separate cache entries. Pass the pre-built
cellMap into scanLine to avoid redundant work.

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

---------

Co-authored-by: panwk <panwukan@suangoo.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:43:29 +08:00
陈大猫
a6af1dffed fix: resolve SSH chain connection hang and improve connection progress (#465)
* fix: resolve SSH chain connection hang and improve connection progress

- Fix Promise never settling when conn 'close' fires before 'ready'
  during chain connections, which caused "reply was never sent" error
- Replace fake timed progress animation with real backend events
- Send granular connection progress for all SSH connections (not just
  chain), including: connecting, key exchange, auth attempts, forwarding,
  shell opening
- Surface auth method attempts (SSH agent, key names, password) in
  progress logs so users can diagnose authentication failures
- Include error details in progress events for better error visibility

Closes #463

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

* fix: scope progress events by sessionId, prevent duplicate errors, hide chain UI for direct SSH

- Add sessionId to chain progress payload so events are scoped per session (P1)
- Set settled=true in error/timeout handlers to prevent close handler from
  emitting a second misleading 'closed unexpectedly' error (P2)
- Only show chain progress UI when total > 1 so direct SSH connections
  don't render as 'Chain 1/1' (P3)

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

* fix: mark shell-open failure as settled before closing connection

The conn.shell() error branch calls conn.end() which triggers the close
handler, but settled was not set yet, causing a duplicate 'closed
unexpectedly' error to overwrite the real shell-open failure message.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:28:44 +08:00
21 changed files with 407 additions and 119 deletions

View File

@@ -311,6 +311,9 @@ const en: Messages = {
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
'settings.terminal.behavior.scrollOnPaste.desc':
'Scroll terminal to bottom when pasting text',
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
'settings.terminal.behavior.smoothScrolling.desc':
'Animate terminal viewport scrolling instead of jumping instantly',
'settings.terminal.behavior.linkModifier': 'Link modifier key',
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',

View File

@@ -1219,6 +1219,8 @@ const zhCN: Messages = {
'settings.terminal.behavior.scrollOnKeyPress.desc': '按键(例如 Enter时将终端滚动到底部',
'settings.terminal.behavior.scrollOnPaste': '粘贴时自动滚动',
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
'settings.terminal.behavior.linkModifier': '链接修饰键',
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',

View File

@@ -96,7 +96,7 @@ export const useTerminalBackend = () => {
return bridge.onSessionExit(sessionId, cb);
}, []);
const onChainProgress = useCallback((cb: (hop: number, total: number, label: string, status: string) => void) => {
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onChainProgress?.(cb);
}, []);

View File

@@ -13,6 +13,7 @@ import {
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { formatHostPort } from "../domain/host";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
@@ -531,11 +532,11 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
case "protocol":
return target.hostname;
case "username":
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
case "knownhost":
return `${protocol.toUpperCase()} ${effectiveUsername}@${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${effectiveUsername}@${formatHostPort(target.hostname, port)}`;
case "auth":
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
}
};

View File

@@ -11,6 +11,7 @@
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
@@ -518,7 +519,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
/>
<div
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
title={`${displayHost.label} · ${(displayHost.username || "root")}@${displayHost.hostname}:${displayHost.port || 22}`}
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
>
<span className="font-medium">
{displayHost.label}

View File

@@ -635,28 +635,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Local terminal and serial connections don't need timeout/progress UI
if (isLocalConnection || isSerialConnection) return;
// Only show SSH-specific scripted logs for SSH connections
const isSSH = host.protocol !== "telnet";
let stepTimer: ReturnType<typeof setInterval> | undefined;
if (isSSH) {
const scripted = [
"Resolving host and keys...",
"Negotiating ciphers...",
"Exchanging keys...",
"Authenticating user...",
"Waiting for server greeting...",
];
let idx = 0;
stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
}
setTimeLeft(CONNECTION_TIMEOUT / 1000);
const countdown = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
@@ -679,7 +657,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, 200);
return () => {
if (stepTimer) clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
@@ -787,6 +764,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.smoothScrollDuration =
terminalSettings.smoothScrolling
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
: 0;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;

View File

@@ -616,6 +616,13 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={terminalSettings.scrollOnPaste} onChange={(v) => updateTerminalSetting("scrollOnPaste", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.smoothScrolling")}
description={t("settings.terminal.behavior.smoothScrolling.desc")}
>
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.linkModifier")}
description={t("settings.terminal.behavior.linkModifier.desc")}

View File

@@ -7,6 +7,7 @@ import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Host, SSHKey } from '../../types';
import { formatHostPort } from '../../domain/host';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '../ui/button';
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
@@ -85,12 +86,12 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
)}>
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg" />
<div>
<div className="flex items-center gap-3 min-w-0 flex-1">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg shrink-0" />
<div className="min-w-0">
{chainProgress ? (
<>
<div className="text-sm font-semibold">
<div className="text-sm font-semibold truncate">
<span className="text-muted-foreground">
{t('terminal.connection.chainOf', {
current: chainProgress.currentHop,
@@ -100,21 +101,21 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</span>
<span>{chainProgress.currentHostLabel}</span>
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
<div className="text-[11px] text-muted-foreground font-mono truncate">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
) : (
<>
<div className="text-lg font-semibold">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
<div className="text-lg font-semibold truncate">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono truncate">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0 ml-3">
{!needsAuth && (
<Button
size="sm"

View File

@@ -10,6 +10,19 @@ interface CompiledRule {
color: string;
}
interface CachedDecorationRange {
x: number;
width: number;
color: string;
}
/** Shared empty array for non-matching lines to avoid per-call allocations. */
const EMPTY_RANGES: readonly CachedDecorationRange[] = Object.freeze([]);
/** ASCII-only test — when true, string indices equal cell columns. */
// eslint-disable-next-line no-control-regex
const RE_ASCII_ONLY = /^[\x00-\x7f]*$/;
/**
* Manages terminal decorations for keyword highlighting.
* Uses xterm.js Decoration API to overlay styles without modifying the data stream.
@@ -20,6 +33,9 @@ export class KeywordHighlighter implements IDisposable {
private compiledRules: CompiledRule[] = [];
private decorations: { decoration: IDecoration; marker: IMarker }[] = [];
private debounceTimer: NodeJS.Timeout | null = null;
private animationFrameId: number | null = null;
private lastRefreshTime: number = 0;
private matchCache = new Map<string, CachedDecorationRange[]>();
private enabled: boolean = false;
private disposables: IDisposable[] = [];
private lastViewportY: number = -1;
@@ -31,23 +47,22 @@ export class KeywordHighlighter implements IDisposable {
this.disposables.push(
// When user scrolls, refresh visible area
this.term.onScroll(() => {
// console.log('[KeywordHighlighter] onScroll');
this.triggerRefresh();
this.triggerRefresh("debounced");
}),
// When new data is written, refresh
// When new data is written, refresh on the next frame so highlights land
// with the freshly rendered content instead of trailing behind it.
this.term.onWriteParsed(() => {
// console.log('[KeywordHighlighter] onWriteParsed');
this.triggerRefresh();
this.triggerRefresh("immediate");
}),
// Also refresh on resize as viewport content changes
this.term.onResize(() => this.triggerRefresh()),
this.term.onResize(() => this.triggerRefresh("debounced")),
// onRender fires after each render cycle - catch scrolls that onScroll might miss
this.term.onRender(() => {
// Only trigger refresh if viewport position changed
const currentViewportY = this.term.buffer.active?.viewportY ?? 0;
if (currentViewportY !== this.lastViewportY) {
this.lastViewportY = currentViewportY;
this.triggerRefresh();
this.triggerRefresh("debounced");
}
})
);
@@ -55,6 +70,7 @@ export class KeywordHighlighter implements IDisposable {
public setRules(rules: KeywordHighlightRule[], enabled: boolean) {
this.enabled = enabled;
this.matchCache.clear();
// Pre-compile all patterns into regexes for better performance
// This avoids creating new RegExp objects on every viewport refresh
@@ -76,7 +92,7 @@ export class KeywordHighlighter implements IDisposable {
// Clear existing and force an immediate refresh if enabling
this.clearDecorations();
if (this.enabled && this.compiledRules.length > 0) {
this.triggerRefresh();
this.triggerRefresh("immediate");
}
}
@@ -87,9 +103,14 @@ export class KeywordHighlighter implements IDisposable {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.matchCache.clear();
}
private triggerRefresh() {
private triggerRefresh(mode: "immediate" | "debounced") {
if (!this.enabled || this.compiledRules.length === 0) return;
// Optimization: Disable highlighting in Alternate Buffer (e.g. Vim, Htop)
@@ -101,12 +122,72 @@ export class KeywordHighlighter implements IDisposable {
return;
}
if (mode === "immediate") {
// Throttle: skip if a rAF is already pending.
// Don't clear the debounce timer here — in a hidden tab rAF never
// fires, so the fallback timer is the only path that will run.
if (this.animationFrameId !== null) {
return;
}
const now = performance.now();
const minInterval = XTERM_PERFORMANCE_CONFIG.highlighting.immediateMinIntervalMs;
if (now - this.lastRefreshTime < minInterval) {
// Too soon — fall through to debounced path instead of dropping
this.triggerRefresh("debounced");
return;
}
this.animationFrameId = requestAnimationFrame(() => {
this.animationFrameId = null;
// rAF fired — cancel the fallback timer to avoid a redundant refresh
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this.executeRefresh();
});
// Arm a debounced fallback: rAF does not fire in background/hidden
// tabs (Chromium throttles it), so the timer ensures highlights
// still update for ongoing output. If rAF fires first it cancels
// this timer (see above), preventing a double refresh.
if (!this.debounceTimer) {
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.executeRefresh();
}, XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs);
}
return;
}
if (this.animationFrameId !== null) {
return;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
const delay = XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs;
this.debounceTimer = setTimeout(() => this.refreshViewport(), delay);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.executeRefresh();
}, delay);
}
/** Shared refresh execution for both rAF and timer callbacks. */
private executeRefresh() {
// Cancel any stale rAF that will never fire (e.g. hidden tab)
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Re-check state: may have changed since the refresh was scheduled
if (!this.enabled || this.compiledRules.length === 0) return;
if (this.term.buffer.active.type === 'alternate') {
if (this.decorations.length > 0) this.clearDecorations();
return;
}
this.lastRefreshTime = performance.now();
this.refreshViewport();
}
private clearDecorations() {
@@ -140,8 +221,14 @@ export class KeywordHighlighter implements IDisposable {
// Skip continuation cells (width 0) - these are the 2nd cell of wide characters
if (width === 0) continue;
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
if (chars.length > 0) {
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
map.push(cellCol);
}
} else {
// Empty cell (codepoint 0) — translateToString() outputs a space
// for it, so we must push one entry to keep the map aligned.
map.push(cellCol);
}
@@ -177,49 +264,106 @@ export class KeywordHighlighter implements IDisposable {
const lineText = line.translateToString(true); // true = trim right whitespace
if (!lineText) continue;
// Build mapping from string index to cell column for wide char support
const cellMap = this.buildStringToCellMap(line);
const cachedRanges = this.getCachedRanges(line, lineText);
if (cachedRanges.length === 0) continue;
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
for (const range of cachedRanges) {
const marker = this.term.registerMarker(offset);
// Map string indices to cell columns
const cellStartCol = cellMap[strStart] ?? strStart;
const cellEndCol = cellMap[strEnd] ?? strEnd;
const cellWidth = cellEndCol - cellStartCol;
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: range.x,
width: range.width,
foregroundColor: range.color,
});
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
const marker = this.term.registerMarker(offset);
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: cellStartCol,
width: cellWidth,
foregroundColor: color,
});
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
}
}
}
}
private getCachedRanges(line: IBufferLine, lineText: string): CachedDecorationRange[] {
const cached = this.matchCache.get(lineText);
if (cached) {
// LRU: move to end
this.matchCache.delete(lineText);
this.matchCache.set(lineText, cached);
return cached;
}
const ranges = this.scanLine(line, lineText);
this.matchCache.set(lineText, ranges);
const maxEntries = XTERM_PERFORMANCE_CONFIG.highlighting.cacheEntries;
if (this.matchCache.size > maxEntries) {
const oldestKey = this.matchCache.keys().next().value;
if (oldestKey !== undefined) {
this.matchCache.delete(oldestKey);
}
}
return ranges;
}
private scanLine(line: IBufferLine, lineText: string): CachedDecorationRange[] {
// ASCII-only lines have a 1:1 string-index-to-cell-column mapping,
// so we can skip the expensive buildStringToCellMap call entirely.
const asciiOnly = RE_ASCII_ONLY.test(lineText);
let cellMap: number[] | null = null;
let ranges: CachedDecorationRange[] | null = null;
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
let cellStartCol: number;
let cellEndCol: number;
if (asciiOnly) {
cellStartCol = strStart;
cellEndCol = strEnd;
} else {
// Lazily build cellMap only when a match is found
if (cellMap === null) {
cellMap = this.buildStringToCellMap(line);
}
cellStartCol = cellMap[strStart] ?? strStart;
cellEndCol = cellMap[strEnd] ?? strEnd;
}
const cellWidth = cellEndCol - cellStartCol;
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
if (ranges === null) {
ranges = [];
}
ranges.push({
x: cellStartCol,
width: cellWidth,
color,
});
}
}
return ranges ?? (EMPTY_RANGES as CachedDecorationRange[]);
}
}

View File

@@ -44,7 +44,7 @@ type TerminalBackendApi = {
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
) => () => void;
onChainProgress: (
cb: (hop: number, total: number, label: string, status: string) => void,
cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
@@ -403,21 +403,56 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
currentHostLabel:
jumpHosts[0]?.label || jumpHosts[0]?.hostname || ctx.host.hostname,
});
ctx.setProgressLogs((prev) => [
...prev,
`Starting chain connection (${totalHops} hops)...`,
]);
}
const unsub = ctx.terminalBackend.onChainProgress((hop, total, label, status) => {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
ctx.setProgressLogs((prev) => [
...prev,
`Chain ${hop} of ${total}: ${label} - ${status}`,
]);
{
const unsub = ctx.terminalBackend.onChainProgress((sid, hop, total, label, status, error) => {
// P1: Only process events for this session
if (sid !== ctx.sessionId) return;
// P3: Only show chain progress UI for multi-hop connections
if (total > 1) {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
}
// Build human-readable log line
let logLine: string;
const prefix = total > 1 ? `[${hop}/${total}] ` : '';
switch (status) {
case 'connecting':
logLine = `${prefix}${tr("terminal.progress.connecting", "Connecting to")} ${label}...`;
break;
case 'authenticating':
logLine = `${prefix}${label} - ${tr("terminal.progress.keyExchangeComplete", "Key exchange complete")}`;
break;
case 'auth-attempt':
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
break;
case 'authenticated':
logLine = `${prefix}${label} - ${tr("terminal.progress.authenticated", "Authenticated")}`;
break;
case 'connected':
logLine = `${prefix}${label} - ${tr("terminal.progress.connected", "Connected")}`;
break;
case 'forwarding':
logLine = `${prefix}${label} - ${tr("terminal.progress.forwarding", "Forwarding")}...`;
break;
case 'shell':
logLine = `${prefix}${tr("terminal.progress.openingShell", "Opening shell")}...`;
break;
case 'error':
logLine = `${prefix}${label} - ${tr("terminal.progress.error", "Error")}${error ? `: ${error}` : ''}`;
break;
default:
logLine = `${prefix}${label} - ${status}${error ? `: ${error}` : ''}`;
}
ctx.setProgressLogs((prev) => [...prev, logLine]);
const hopProgress = (hop / total) * 80 + 10;
ctx.setProgressValue(Math.min(95, hopProgress));
});

View File

@@ -161,6 +161,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
const smoothScrollDuration = settings?.smoothScrolling
? performanceConfig.options.smoothScrollDuration
: 0;
const altIsMeta = settings?.altAsMeta ?? false;
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
@@ -213,6 +216,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
allowProposedApi: true,
drawBoldTextInBrightColors,
minimumContrastRatio,
smoothScrollDuration,
scrollOnUserInput,
macOptionClickForcesSelection: true,
altClickMovesCursor: !altIsMeta,

View File

@@ -48,6 +48,14 @@ export const getEffectiveHostDistro = (
return detected;
};
/** Format hostname:port for display, wrapping IPv6 addresses in brackets. */
export const formatHostPort = (hostname: string, port?: number | null): string => {
if (port == null) return hostname;
const isIPv6 = hostname.includes(':') && !hostname.startsWith('[');
const display = isIPv6 ? `[${hostname}]` : hostname;
return `${display}:${port}`;
};
export const sanitizeHost = (host: Host): Host => {
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);

View File

@@ -410,6 +410,8 @@ export interface TerminalSettings {
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
scrollOnPaste: boolean; // Scroll terminal to bottom on paste
smoothScrolling: boolean; // Animate viewport scrolling instead of jumping instantly
// Mouse
rightClickBehavior: RightClickBehavior;
copyOnSelect: boolean; // Automatically copy selected text
@@ -532,6 +534,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollOnOutput: false,
scrollOnKeyPress: false,
scrollOnPaste: true,
smoothScrolling: true,
rightClickBehavior: 'context-menu',
copyOnSelect: false,
middleClickPaste: true,

View File

@@ -9,15 +9,45 @@ interface QuickConnectParseResult {
warnings: string[];
}
/** Test whether a string looks like a bare (un-bracketed) IPv6 address.
* Must have only hex digits and colons, with either:
* - A "::" shorthand (unambiguously IPv6), or
* - Exactly 7 colons (full 8-group notation like 2607:f130:0:179:0:0:b0df:eec4)
* This avoids false positives on MAC addresses (6 groups, 5 colons). */
const BARE_IPV6_RE = /^[a-fA-F0-9:]+$/;
const isBareIPv6 = (s: string): boolean => {
if (!BARE_IPV6_RE.test(s)) return false;
if (s.includes('::')) return true;
return (s.match(/:/g) || []).length === 7;
};
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
const trimmed = input.trim();
if (!trimmed) return null;
// Pattern: [user@]hostname[:port]
// Hostname can be IP (v4 or v6) or domain name
// Hostname can be IP (v4 or v6 in brackets) or domain name
const regex = /^(?:([^@]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/;
const match = trimmed.match(regex);
if (!match) return null;
// If the main regex fails, try bare IPv6: [user@]ipv6_address
// Bare IPv6 contains colons so the main regex can't distinguish host:port.
// Port must be specified via brackets: [ipv6]:port
if (!match) {
const bareIpv6Regex = /^(?:([^@]+)@)?([a-fA-F0-9:]+)$/;
const bareMatch = trimmed.match(bareIpv6Regex);
if (bareMatch) {
const [, bareUser, bareHost] = bareMatch;
if (isBareIPv6(bareHost)) {
return {
hostname: bareHost,
username: bareUser || undefined,
port: undefined,
};
}
}
return null;
}
const [, username, hostname, portStr] = match;

View File

@@ -74,6 +74,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',

View File

@@ -444,7 +444,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);

View File

@@ -213,7 +213,7 @@ async function getAvailableAgentSocket() {
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride } = options;
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride, onAuthAttempt } = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
@@ -394,9 +394,19 @@ function buildAuthHandler(options) {
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
console.log(`${logPrefix} Trying agent auth`);
onAuthAttempt?.("SSH agent");
return callback("agent");
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
console.log(`${logPrefix} Trying publickey auth:`, method.id);
// Build a readable label for the key
const keyLabel = method.id.startsWith("publickey-default-")
? `key ${method.id.replace("publickey-default-", "")}`
: method.id.startsWith("publickey-encrypted-")
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
: method.id === "publickey-user"
? "configured key"
: method.id;
onAuthAttempt?.(keyLabel);
const pubkeyAuth = {
type: "publickey",
username,
@@ -408,12 +418,14 @@ function buildAuthHandler(options) {
return callback(pubkeyAuth);
} else if (method.type === "password" && availableMethods.includes("password")) {
console.log(`${logPrefix} Trying password auth`);
onAuthAttempt?.("password");
return callback({
type: "password",
username,
password,
});
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
onAuthAttempt?.("keyboard-interactive");
return callback("keyboard-interactive");
}
}

View File

@@ -333,9 +333,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
const connections = [];
let currentSocket = null;
const sendProgress = (hop, total, label, status) => {
const sendProgress = (hop, total, label, status, error) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
}
};
@@ -347,7 +347,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
@@ -406,6 +406,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
logPrefix: `[Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
onAuthAttempt: (method) => {
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', method);
},
});
applyAuthToConnOpts(connOpts, authConfig);
@@ -424,6 +427,10 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
// Connect this hop
await new Promise((resolve, reject) => {
conn.once('handshake', () => {
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} handshake complete`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'authenticating');
});
conn.once('ready', () => {
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} connected`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'connected');
@@ -431,12 +438,14 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
});
conn.once('error', (err) => {
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} error:`, err.message);
sendProgress(i + 1, totalHops + 1, hopLabel, 'error');
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', err.message);
reject(err);
});
conn.once('timeout', () => {
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
const errMsg = `Connection timeout to ${hopLabel}`;
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', errMsg);
reject(new Error(errMsg));
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
@@ -508,9 +517,9 @@ async function startSSHSession(event, options) {
const rows = options.rows || 24;
const sender = event.sender;
const sendProgress = (hop, total, label, status) => {
const sendProgress = (hop, total, label, status, error) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
}
};
@@ -850,10 +859,19 @@ async function startSSHSession(event, options) {
// Only log safe identifier, not the full agent object which may contain private keys
const agentType = typeof connectOpts.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth", { id: method.id, agentType });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'SSH agent');
// Return "agent" string to use SSH agent for authentication
return callback("agent");
} else if (method.type === "publickey") {
log("Trying publickey auth", { id: method.id, isDefault: method.isDefault || false });
const keyLabel = method.id.startsWith("publickey-default-")
? `key ${method.id.replace("publickey-default-", "")}`
: method.id.startsWith("publickey-encrypted-")
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
: method.id === "publickey-user"
? "configured key"
: method.id;
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', keyLabel);
return callback({
type: "publickey",
username: connectOpts.username,
@@ -862,6 +880,7 @@ async function startSSHSession(event, options) {
});
} else if (method.type === "password") {
log("Trying password auth", { id: method.id });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'password');
return callback({
type: "password",
username: connectOpts.username,
@@ -869,6 +888,7 @@ async function startSSHSession(event, options) {
});
} else if (method.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth", { id: method.id });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'keyboard-interactive');
// Return string instead of object - ssh2 requires a prompt function
// for keyboard-interactive objects. Returning the string lets ssh2
// use its default handling and trigger the keyboard-interactive event.
@@ -924,10 +944,20 @@ async function startSSHSession(event, options) {
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
} else {
// Direct connection (no jump hosts, no proxy)
sendProgress(1, 1, options.hostname, 'connecting');
}
return new Promise((resolve, reject) => {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
let settled = false;
conn.once("handshake", () => {
console.log(`${logPrefix} ${options.hostname} handshake complete`);
sendProgress(totalHops, totalHops, options.hostname, 'authenticating');
});
conn.once("ready", () => {
console.log(`${logPrefix} ${options.hostname} ready`);
@@ -939,9 +969,8 @@ async function startSSHSession(event, options) {
}
}
if (hasJumpHosts || hasProxy) {
sendProgress(totalHops, totalHops, options.hostname, 'connected');
}
sendProgress(totalHops, totalHops, options.hostname, 'authenticated');
sendProgress(totalHops, totalHops, options.hostname, 'shell');
conn.shell(
{
@@ -958,14 +987,18 @@ async function startSSHSession(event, options) {
},
(err, stream) => {
if (err) {
settled = true;
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
}
sendProgress(totalHops, totalHops, options.hostname, 'error', `Failed to open shell: ${err.message}`);
reject(err);
return;
}
sendProgress(totalHops, totalHops, options.hostname, 'connected');
const session = {
conn,
stream,
@@ -1076,6 +1109,7 @@ async function startSSHSession(event, options) {
}, 300);
}
settled = true;
resolve({ sessionId });
}
);
@@ -1102,6 +1136,7 @@ async function startSSHSession(event, options) {
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
}
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
@@ -1110,6 +1145,7 @@ async function startSSHSession(event, options) {
for (const c of chainConnections) {
try { c.end(); } catch { }
}
settled = true;
reject(err);
});
@@ -1117,6 +1153,7 @@ async function startSSHSession(event, options) {
console.error(`${logPrefix} ${options.hostname} connection timeout`);
const err = new Error(`Connection timeout to ${options.hostname}`);
const contents = event.sender;
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
@@ -1125,11 +1162,15 @@ async function startSSHSession(event, options) {
for (const c of chainConnections) {
try { c.end(); } catch { }
}
settled = true;
reject(err);
});
conn.once("close", () => {
const contents = event.sender;
if (!settled) {
sendProgress(totalHops, totalHops, options.hostname, 'error', `Connection to ${options.hostname} closed unexpectedly`);
}
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
@@ -1138,6 +1179,10 @@ async function startSSHSession(event, options) {
for (const c of chainConnections) {
try { c.end(); } catch { }
}
if (!settled) {
settled = true;
reject(new Error(`Connection to ${options.hostname} closed unexpectedly`));
}
});
// Handle keyboard-interactive authentication (2FA/MFA)

View File

@@ -123,11 +123,11 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
// Chain progress events (for jump host connections)
ipcRenderer.on("netcatty:chain:progress", (_event, payload) => {
const { hop, total, label, status } = payload;
const { sessionId, hop, total, label, status, error } = payload;
// Notify all registered chain progress listeners
chainProgressListeners.forEach((cb) => {
try {
cb(hop, total, label, status);
cb(sessionId, hop, total, label, status, error);
} catch (err) {
console.error("Chain progress callback failed", err);
}

4
global.d.ts vendored
View File

@@ -462,8 +462,8 @@ declare global {
onLanguageChanged?(cb: (language: string) => void): () => void;
// Chain progress listener for jump host connections
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
// Callback receives: (sessionId: string, currentHop: number, totalHops: number, hostLabel: string, status: string, error?: string)
onChainProgress?(cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void): () => void;
// OAuth callback server for cloud sync
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;

View File

@@ -36,6 +36,9 @@ export const XTERM_PERFORMANCE_CONFIG = {
// Font rendering settings
letterSpacing: 0,
lineHeight: 1,
// Keep viewport movement smooth without feeling sluggish.
smoothScrollDuration: 120,
},
// WebGL-specific optimizations
@@ -94,6 +97,11 @@ export const XTERM_PERFORMANCE_CONFIG = {
// Debounce time for viewport scanning (ms)
// Higher values = better scrolling performance, but slower highlight "catch up"
debounceMs: 200,
// Minimum interval between immediate (rAF) refreshes in ms.
// Prevents heavy output (e.g. tail -f) from refreshing every frame.
immediateMinIntervalMs: 50,
// Number of unique line scan results to keep cached.
cacheEntries: 1200,
},
};
@@ -110,6 +118,7 @@ export type ResolvedXTermPerformance = {
customGlyphs: boolean;
letterSpacing: number;
lineHeight: number;
smoothScrollDuration: number;
documentOverride: boolean;
tabStopWidth: number;
convertEol: boolean;
@@ -177,6 +186,7 @@ export function resolveXTermPerformanceConfig({
customGlyphs: baseConfig.rendering.customGlyphs,
letterSpacing: baseConfig.rendering.letterSpacing,
lineHeight: baseConfig.rendering.lineHeight,
smoothScrollDuration: baseConfig.rendering.smoothScrollDuration,
documentOverride: baseConfig.events.documentOverride,
tabStopWidth: baseConfig.events.tabStopWidth,
convertEol: baseConfig.events.convertEol,