Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d29c8d91a | ||
|
|
196b1f8dbb | ||
|
|
f1065745bc | ||
|
|
c67befa0e9 | ||
|
|
cea83d6cb1 | ||
|
|
293ee46b26 | ||
|
|
a6af1dffed |
@@ -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)',
|
||||
|
||||
@@ -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': '无(直接点击)',
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}...`);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
4
global.d.ts
vendored
@@ -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 }>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user