Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453202df8f | ||
|
|
a78c052d86 | ||
|
|
e6b0a551e8 | ||
|
|
38775245d2 | ||
|
|
fcb699ffb9 | ||
|
|
e889d8fc20 | ||
|
|
bf1c95500a | ||
|
|
f9d00c9d23 | ||
|
|
8fd7ff6475 | ||
|
|
02c80ae7d2 | ||
|
|
e5d3d02b17 | ||
|
|
78186d8d46 | ||
|
|
c899653621 | ||
|
|
a91fbcdd68 | ||
|
|
74b315e285 |
@@ -301,6 +301,9 @@ const en: Messages = {
|
||||
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Use Option (Alt) as the Meta key instead of for special characters',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
|
||||
@@ -2092,6 +2095,11 @@ const en: Messages = {
|
||||
'zmodem.uploading': 'Uploading',
|
||||
'zmodem.downloading': 'Downloading',
|
||||
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Reset to default',
|
||||
};
|
||||
|
||||
|
||||
@@ -301,6 +301,9 @@ const ru: Messages = {
|
||||
'settings.terminal.keyboard.altAsMeta': 'Использовать Option как клавишу Meta',
|
||||
'settings.terminal.keyboard.altAsMeta.desc':
|
||||
'Использовать Option (Alt) как клавишу Meta вместо ввода специальных символов',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ переход по словам',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc':
|
||||
'Отправлять Meta-b / Meta-f при Option+Влево/Вправо, чтобы оболочка перемещалась по словам, вместо стандартного ^[[1;3D / ^[[1;3C',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': 'Минимальный коэффициент контрастности',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc':
|
||||
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
|
||||
@@ -2124,6 +2127,11 @@ const ru: Messages = {
|
||||
'zmodem.uploading': 'Загрузка',
|
||||
'zmodem.downloading': 'Скачивание',
|
||||
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
|
||||
'zmodem.overwrite.title': 'Remote file already exists',
|
||||
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
|
||||
'zmodem.overwrite.overwrite': 'Overwrite',
|
||||
'zmodem.overwrite.skip': 'Skip',
|
||||
'zmodem.overwrite.cancel': 'Cancel',
|
||||
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
|
||||
};
|
||||
|
||||
|
||||
@@ -1445,6 +1445,8 @@ const zhCN: Messages = {
|
||||
'settings.terminal.cursor.blink': '光标闪烁',
|
||||
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
|
||||
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
|
||||
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
|
||||
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f,让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C)',
|
||||
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
|
||||
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
|
||||
'settings.terminal.behavior.rightClick': '右键行为',
|
||||
@@ -2101,6 +2103,11 @@ const zhCN: Messages = {
|
||||
'zmodem.uploading': '上传中',
|
||||
'zmodem.downloading': '下载中',
|
||||
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
|
||||
'zmodem.overwrite.title': '远端已存在同名文件',
|
||||
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
|
||||
'zmodem.overwrite.overwrite': '覆盖',
|
||||
'zmodem.overwrite.skip': '跳过',
|
||||
'zmodem.overwrite.cancel': '取消',
|
||||
'settings.shortcuts.resetToDefault': '重置为默认',
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import {
|
||||
SYNCABLE_SETTING_STORAGE_KEYS,
|
||||
collectSyncableSettings,
|
||||
@@ -506,7 +507,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
|
||||
@@ -73,6 +73,11 @@ export const useTerminalBackend = () => {
|
||||
bridge?.resizeSession?.(sessionId, cols, rows);
|
||||
}, []);
|
||||
|
||||
const setSessionFlowPaused = useCallback((sessionId: string, paused: boolean) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setSessionFlowPaused?.(sessionId, paused);
|
||||
}, []);
|
||||
|
||||
const closeSession = useCallback((sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.closeSession?.(sessionId);
|
||||
@@ -208,6 +213,7 @@ export const useTerminalBackend = () => {
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
@@ -240,6 +246,7 @@ export const useTerminalBackend = () => {
|
||||
getServerStats,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
|
||||
@@ -164,7 +164,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
|
||||
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
|
||||
@@ -5,7 +5,6 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
@@ -49,12 +48,15 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { ZmodemOverwriteDialog } from "./terminal/ZmodemOverwriteDialog";
|
||||
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { createReplaySafeTerminalLogSanitizer } from "./terminal/replaySafeTerminalLog";
|
||||
import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
|
||||
import {
|
||||
createPromptLineBreakState,
|
||||
type PromptLineBreakState,
|
||||
@@ -68,7 +70,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
|
||||
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
|
||||
import { createTerminalCwdTracker, resolvePreferredTerminalCwd } from "./terminal/sftpCwd";
|
||||
|
||||
const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
|
||||
@@ -297,7 +299,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// cancelled retry can't fire a startNewSession after the fact.
|
||||
const retryTokenRef = useRef<symbol | null>(null);
|
||||
const terminalDataCapturedRef = useRef(false);
|
||||
const terminalLogDataRef = useRef("");
|
||||
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
|
||||
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
@@ -318,21 +320,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const captureTerminalLogData = useCallback((data: string) => {
|
||||
const replaySafeData = terminalLogSanitizerRef.current.append(data);
|
||||
if (!replaySafeData) return;
|
||||
terminalLogDataRef.current += replaySafeData;
|
||||
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
|
||||
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
|
||||
}
|
||||
connectionLogBufferRef.current.append(replaySafeData);
|
||||
}, []);
|
||||
|
||||
const finalizeTerminalLogData = useCallback(() => {
|
||||
const replaySafeData = terminalLogSanitizerRef.current.finish();
|
||||
if (replaySafeData) {
|
||||
terminalLogDataRef.current += replaySafeData;
|
||||
if (terminalLogDataRef.current.length > MAX_CONNECTION_LOG_DATA_CHARS) {
|
||||
terminalLogDataRef.current = terminalLogDataRef.current.slice(-MAX_CONNECTION_LOG_DATA_CHARS);
|
||||
}
|
||||
connectionLogBufferRef.current.append(replaySafeData);
|
||||
}
|
||||
return terminalLogDataRef.current;
|
||||
return connectionLogBufferRef.current.toString();
|
||||
}, []);
|
||||
|
||||
const writeLocalTerminalData = useCallback((data: string) => {
|
||||
@@ -387,10 +383,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
// Autocomplete handler refs (set after hook initialization)
|
||||
// Autocomplete handler refs — populated by <TerminalAutocomplete> so the
|
||||
// xterm runtime (and a few effects here) can drive the hook without making
|
||||
// Terminal re-render on every suggestion update.
|
||||
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
|
||||
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
||||
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
||||
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
@@ -539,31 +538,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostOs: host.os || (host.protocol === "local"
|
||||
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
|
||||
: "linux"),
|
||||
settings: terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined,
|
||||
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
|
||||
protocol: host.protocol,
|
||||
getCwd: () => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current,
|
||||
});
|
||||
|
||||
// Wire up autocomplete handler refs so createXTermRuntime can use them
|
||||
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
|
||||
autocompleteInputRef.current = autocomplete.handleInput;
|
||||
autocompleteRepositionRef.current = autocomplete.repositionPopup;
|
||||
const autocompleteClosePopup = autocomplete.closePopup;
|
||||
// Autocomplete config — the hook itself lives in <TerminalAutocomplete> so
|
||||
// its state updates don't re-render this component (see render below).
|
||||
const autocompleteHostOs: "linux" | "windows" | "macos" = host.os || (host.protocol === "local"
|
||||
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
|
||||
: "linux");
|
||||
const autocompleteSettings = terminalSettings ? {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
|
||||
} : undefined;
|
||||
|
||||
const resolveSftpInitialPath = useCallback(async (): Promise<string | undefined> => {
|
||||
const cwd = await resolvePreferredTerminalCwd({
|
||||
@@ -638,9 +625,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
autocompleteClosePopup();
|
||||
autocompleteCloseRef.current?.();
|
||||
}
|
||||
}, [isVisible, autocompleteClosePopup]);
|
||||
}, [isVisible]);
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
@@ -938,7 +925,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
terminalDataCapturedRef.current = false;
|
||||
terminalLogDataRef.current = "";
|
||||
connectionLogBufferRef.current.reset();
|
||||
terminalLogSanitizerRef.current = createReplaySafeTerminalLogSanitizer();
|
||||
setError(null);
|
||||
hasConnectedRef.current = false;
|
||||
@@ -1247,7 +1234,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: 0;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
const altKeyOpts = terminalAltKeyOptions(terminalSettings.altAsMeta);
|
||||
termRef.current.options.macOptionIsMeta = altKeyOpts.macOptionIsMeta;
|
||||
termRef.current.options.altClickMovesCursor = altKeyOpts.altClickMovesCursor;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
@@ -1269,6 +1258,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!isVisible) return;
|
||||
const timer = setTimeout(() => {
|
||||
safeFit({ requireVisible: true });
|
||||
// Recover the WebGL renderer now that this tab is visible again. Hidden
|
||||
// panes stay mounted off-screen (visibility:hidden) so each keeps a live
|
||||
// WebGL context; creating another terminal's context — or the GPU dropping
|
||||
// a non-composited off-screen canvas — can leave this terminal's drawing
|
||||
// buffer corrupted ("花屏", issue #1063). Because a hidden pane keeps its
|
||||
// dimensions, becoming visible triggers no resize and therefore no redraw,
|
||||
// so the corruption persists until the user resizes the window. Force the
|
||||
// same recovery a resize performs: clear the texture atlas (no-op on the
|
||||
// DOM renderer) and synchronously repaint every row.
|
||||
xtermRuntimeRef.current?.clearTextureAtlas();
|
||||
const visibleTerm = termRef.current;
|
||||
if (visibleTerm) forceSyncRenderAfterResize(visibleTerm);
|
||||
if (pendingOutputScrollRef.current) {
|
||||
termRef.current?.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
@@ -2368,29 +2369,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
|
||||
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
|
||||
ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={autocomplete.state.suggestions}
|
||||
selectedIndex={autocomplete.state.selectedIndex}
|
||||
position={autocomplete.state.popupPosition}
|
||||
cursorLineTop={autocomplete.state.popupCursorLineTop}
|
||||
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
|
||||
visible={autocomplete.state.popupVisible}
|
||||
expandUpward={autocomplete.state.expandUpward}
|
||||
themeColors={effectiveTheme.colors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={autocomplete.state.subDirPanels}
|
||||
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
onDismiss={autocompleteClosePopup}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
{/* Autocomplete — owns the hook + popup in its own component so
|
||||
suggestion/selection updates don't re-render Terminal. Mounted
|
||||
unconditionally; it gates the popup on `visible` internally. */}
|
||||
<TerminalAutocomplete
|
||||
termRef={termRef}
|
||||
sessionId={sessionId}
|
||||
hostId={host.id}
|
||||
hostOs={autocompleteHostOs}
|
||||
settings={autocompleteSettings}
|
||||
protocol={host.protocol}
|
||||
getCwd={() => terminalCwdTracker.getRendererCwd() ?? knownCwdRef.current}
|
||||
onAcceptText={(text) => autocompleteAcceptTextRef.current?.(text)}
|
||||
visible={isVisible}
|
||||
themeColors={effectiveTheme.colors}
|
||||
containerRef={containerRef}
|
||||
searchBarOffset={isSearchOpen ? 64 : 30}
|
||||
keyEventRef={autocompleteKeyEventRef}
|
||||
inputRef={autocompleteInputRef}
|
||||
repositionRef={autocompleteRepositionRef}
|
||||
closeRef={autocompleteCloseRef}
|
||||
/>
|
||||
|
||||
{/* OSC-52 clipboard read prompt */}
|
||||
{osc52ReadPromptVisible && (
|
||||
@@ -2481,6 +2480,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ZMODEM overwrite conflict dialog */}
|
||||
{zmodem.overwriteRequest && (
|
||||
<ZmodemOverwriteDialog
|
||||
filename={zmodem.overwriteRequest.filename}
|
||||
onRespond={zmodem.respondOverwrite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
|
||||
@@ -810,6 +810,12 @@ export default function SettingsTerminalTab(props: {
|
||||
>
|
||||
<Toggle checked={terminalSettings.altAsMeta} onChange={(v) => updateTerminalSetting("altAsMeta", v)} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("settings.terminal.keyboard.optionArrowWordJump")}
|
||||
description={t("settings.terminal.keyboard.optionArrowWordJump.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.optionArrowWordJump} onChange={(v) => updateTerminalSetting("optionArrowWordJump", v)} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.accessibility")} />
|
||||
|
||||
111
components/terminal/TerminalAutocomplete.tsx
Normal file
111
components/terminal/TerminalAutocomplete.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import type { ComponentProps, RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import {
|
||||
useTerminalAutocomplete,
|
||||
AutocompletePopup,
|
||||
type AutocompleteSettings,
|
||||
} from "./autocomplete";
|
||||
|
||||
type PopupProps = ComponentProps<typeof AutocompletePopup>;
|
||||
|
||||
/** A mutable handler ref Terminal hands down for the xterm runtime to call. */
|
||||
type HandlerRef<T> = { current: T | undefined };
|
||||
|
||||
interface TerminalAutocompleteProps {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
protocol?: string;
|
||||
getCwd?: () => string | undefined;
|
||||
onAcceptText: (text: string) => void;
|
||||
/** Whether this terminal tab is the visible one. */
|
||||
visible: boolean;
|
||||
themeColors: PopupProps["themeColors"];
|
||||
containerRef: PopupProps["containerRef"];
|
||||
searchBarOffset: number;
|
||||
// Handlers exposed back to Terminal so createXTermRuntime can drive them.
|
||||
keyEventRef: HandlerRef<(e: KeyboardEvent) => boolean>;
|
||||
inputRef: HandlerRef<(data: string) => void>;
|
||||
repositionRef: HandlerRef<() => void>;
|
||||
closeRef: HandlerRef<() => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the terminal autocomplete hook and renders its popup.
|
||||
*
|
||||
* Kept as its own component so the frequent autocomplete state updates
|
||||
* (suggestions, selection, live-preview navigation) re-render only this small
|
||||
* subtree rather than the whole Terminal component. The hook's handlers are
|
||||
* surfaced back to Terminal through refs so the xterm runtime can call them.
|
||||
*
|
||||
* Must be mounted unconditionally for the terminal session's lifetime: the hook
|
||||
* records command history on Enter and intercepts completion keys even while no
|
||||
* popup is visible. Visibility only gates the rendered popup, not the hook.
|
||||
*/
|
||||
export function TerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostOs,
|
||||
settings,
|
||||
protocol,
|
||||
getCwd,
|
||||
onAcceptText,
|
||||
visible,
|
||||
themeColors,
|
||||
containerRef,
|
||||
searchBarOffset,
|
||||
keyEventRef,
|
||||
inputRef,
|
||||
repositionRef,
|
||||
closeRef,
|
||||
}: TerminalAutocompleteProps) {
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostOs,
|
||||
settings,
|
||||
onAcceptText,
|
||||
protocol,
|
||||
getCwd,
|
||||
});
|
||||
|
||||
// Surface the handlers for runtime wiring. They have stable identities
|
||||
// (useCallback over refs), so assigning during render is cheap and mirrors
|
||||
// the wiring Terminal did inline before this was extracted.
|
||||
keyEventRef.current = autocomplete.handleKeyEvent;
|
||||
inputRef.current = autocomplete.handleInput;
|
||||
repositionRef.current = autocomplete.repositionPopup;
|
||||
closeRef.current = autocomplete.closePopup;
|
||||
|
||||
const { state } = autocomplete;
|
||||
if (!visible || !state.popupVisible || state.suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Portal to body so the popup escapes the terminal container's overflow.
|
||||
return ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={state.suggestions}
|
||||
selectedIndex={state.selectedIndex}
|
||||
position={state.popupPosition}
|
||||
cursorLineTop={state.popupCursorLineTop}
|
||||
cursorLineBottom={state.popupCursorLineBottom}
|
||||
visible={state.popupVisible}
|
||||
expandUpward={state.expandUpward}
|
||||
themeColors={themeColors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={state.subDirPanels}
|
||||
subDirFocusLevel={state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={searchBarOffset}
|
||||
onDismiss={autocomplete.closePopup}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface Props {
|
||||
filename: string;
|
||||
onRespond: (action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => void;
|
||||
}
|
||||
|
||||
export const ZmodemOverwriteDialog: React.FC<Props> = ({ filename, onRespond }) => {
|
||||
const { t } = useI18n();
|
||||
const [applyToRest, setApplyToRest] = useState(false);
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onRespond("cancel", false); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("zmodem.overwrite.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground break-all">{filename}</p>
|
||||
<label className="flex items-center gap-2 text-sm mt-2">
|
||||
<input type="checkbox" checked={applyToRest} onChange={(e) => setApplyToRest(e.target.checked)} />
|
||||
{t("zmodem.overwrite.applyToRest")}
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onRespond("cancel", applyToRest)}>{t("zmodem.overwrite.cancel")}</Button>
|
||||
<Button variant="outline" onClick={() => onRespond("skip", applyToRest)}>{t("zmodem.overwrite.skip")}</Button>
|
||||
<Button onClick={() => onRespond("overwrite", applyToRest)}>{t("zmodem.overwrite.overwrite")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -47,6 +47,18 @@ export const DEFAULT_AUTOCOMPLETE_SETTINGS: AutocompleteSettings = {
|
||||
fastTypingThresholdMs: 40,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether completion work is worth doing — i.e. whether anything would
|
||||
* actually be rendered. With both the popup and ghost text disabled, querying
|
||||
* completions only to discard the result is pure main-thread waste, so callers
|
||||
* skip it entirely.
|
||||
*/
|
||||
export function shouldQueryCompletions(
|
||||
settings: Pick<AutocompleteSettings, "showPopupMenu" | "showGhostText">,
|
||||
): boolean {
|
||||
return settings.showPopupMenu || settings.showGhostText;
|
||||
}
|
||||
|
||||
/** Shared empty state to avoid creating new objects on every reset */
|
||||
const EMPTY_STATE: AutocompleteState = Object.freeze({
|
||||
suggestions: [],
|
||||
@@ -640,6 +652,15 @@ export function useTerminalAutocomplete(
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing will be rendered when both the popup and ghost text are off, so
|
||||
// don't run the (potentially expensive) completion query just to throw the
|
||||
// result away. Clear any stale state and bail before touching history,
|
||||
// fig specs, or remote path lookups.
|
||||
if (!shouldQueryCompletions(settingsRef.current)) {
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture version at start — if it changes during async work, discard results
|
||||
const version = ++fetchVersionRef.current;
|
||||
|
||||
|
||||
25
components/terminal/completionGate.test.ts
Normal file
25
components/terminal/completionGate.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { shouldQueryCompletions } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
|
||||
test("queries completions when the popup menu is enabled", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: true, showGhostText: false }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("queries completions when ghost text is enabled", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: false, showGhostText: true }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("skips completion work when both popup and ghost text are off", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: false, showGhostText: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
98
components/terminal/connectionLogBuffer.test.ts
Normal file
98
components/terminal/connectionLogBuffer.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createConnectionLogBuffer } from "./connectionLogBuffer.ts";
|
||||
|
||||
test("concatenates appended chunks while under the cap", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("foo");
|
||||
buf.append("bar");
|
||||
buf.append("baz");
|
||||
assert.equal(buf.toString(), "foobarbaz");
|
||||
});
|
||||
|
||||
test("keeps only the last maxChars, matching slice(-max) semantics", () => {
|
||||
const max = 10;
|
||||
const buf = createConnectionLogBuffer(max);
|
||||
const chunks = ["abcd", "efgh", "ijkl", "mnop"]; // 16 chars total
|
||||
let naive = "";
|
||||
for (const c of chunks) {
|
||||
buf.append(c);
|
||||
naive += c;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-max));
|
||||
assert.equal(buf.toString().length, max);
|
||||
});
|
||||
|
||||
test("trims a single chunk larger than the cap to its last maxChars", () => {
|
||||
const buf = createConnectionLogBuffer(5);
|
||||
buf.append("0123456789");
|
||||
assert.equal(buf.toString(), "56789");
|
||||
});
|
||||
|
||||
test("partial-trims the boundary chunk to keep exactly maxChars", () => {
|
||||
const buf = createConnectionLogBuffer(6);
|
||||
buf.append("abcde"); // 5
|
||||
buf.append("fghij"); // total 10 -> keep last 6 => "efghij"
|
||||
assert.equal(buf.toString(), "efghij");
|
||||
});
|
||||
|
||||
test("stays correct across many small appends (ring semantics)", () => {
|
||||
const max = 50;
|
||||
const buf = createConnectionLogBuffer(max);
|
||||
let naive = "";
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const chunk = `x${i}-`;
|
||||
buf.append(chunk);
|
||||
naive += chunk;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-max));
|
||||
});
|
||||
|
||||
test("reset clears the buffer", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("hello");
|
||||
buf.reset();
|
||||
assert.equal(buf.toString(), "");
|
||||
buf.append("world");
|
||||
assert.equal(buf.toString(), "world");
|
||||
});
|
||||
|
||||
test("ignores empty appends", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("a");
|
||||
buf.append("");
|
||||
buf.append("b");
|
||||
assert.equal(buf.toString(), "ab");
|
||||
});
|
||||
|
||||
test("keeps the segment count bounded across many tiny appends", () => {
|
||||
// The whole point of the rewrite: trimming must not walk one array entry
|
||||
// per append. With a blockSize of 10 and a 100-char cap, the buffer should
|
||||
// never hold more than ~ceil(cap/blockSize)+1 segments no matter how many
|
||||
// single-char appends arrive once it's at capacity.
|
||||
const maxChars = 100;
|
||||
const blockSize = 10;
|
||||
const buf = createConnectionLogBuffer(maxChars, blockSize);
|
||||
let naive = "";
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
buf.append("x");
|
||||
naive += "x";
|
||||
}
|
||||
assert.ok(
|
||||
buf.segmentCount() <= Math.ceil(maxChars / blockSize) + 1,
|
||||
`segmentCount ${buf.segmentCount()} exceeded the bound`,
|
||||
);
|
||||
assert.equal(buf.toString(), naive.slice(-maxChars));
|
||||
});
|
||||
|
||||
test("seals and trims whole blocks with a small blockSize", () => {
|
||||
const buf = createConnectionLogBuffer(10, 4);
|
||||
const chunks = ["abcd", "efgh", "ijkl"]; // 12 chars total
|
||||
let naive = "";
|
||||
for (const c of chunks) {
|
||||
buf.append(c);
|
||||
naive += c;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-10)); // "cdefghijkl"
|
||||
});
|
||||
94
components/terminal/connectionLogBuffer.ts
Normal file
94
components/terminal/connectionLogBuffer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* A bounded, append-only text buffer that retains only the last `maxChars`
|
||||
* characters — the connection log used for diagnostics/replay.
|
||||
*
|
||||
* The naive implementation (`log += chunk; if (log.length > max) log =
|
||||
* log.slice(-max)`) flattens a ~max-length string on *every* append once the
|
||||
* cap is reached — on the render thread, for every output chunk including each
|
||||
* echoed keystroke.
|
||||
*
|
||||
* Instead, data is coalesced into a small, bounded number of fixed-size blocks
|
||||
* (~`maxChars / blockSize`, e.g. ~16 for the 1 MB cap). New data accumulates in
|
||||
* an open `tail`; once it reaches `blockSize` it is sealed into a block. Trimming
|
||||
* the oldest data therefore only ever drops/slices a handful of blocks — never
|
||||
* one array element per append, which would make trim O(number of appends) and
|
||||
* defeat the purpose. Append is amortized O(chunk); the full string is
|
||||
* materialized only on `toString()` (called rarely, on finalize).
|
||||
*/
|
||||
export interface ConnectionLogBuffer {
|
||||
append(chunk: string): void;
|
||||
toString(): string;
|
||||
reset(): void;
|
||||
/**
|
||||
* Number of internal string segments currently retained. Exposed for tests
|
||||
* to assert the bounded-memory / bounded-trim property.
|
||||
*/
|
||||
segmentCount(): number;
|
||||
}
|
||||
|
||||
const DEFAULT_BLOCK_SIZE = 64 * 1024;
|
||||
|
||||
export function createConnectionLogBuffer(
|
||||
maxChars: number,
|
||||
blockSize: number = DEFAULT_BLOCK_SIZE,
|
||||
): ConnectionLogBuffer {
|
||||
let blocks: string[] = []; // sealed blocks, oldest first, each up to ~blockSize
|
||||
let tail = ""; // open block currently being filled (newest data)
|
||||
let total = 0; // total retained length across blocks + tail
|
||||
|
||||
const trim = () => {
|
||||
let overflow = total - maxChars;
|
||||
if (overflow <= 0) return;
|
||||
// Drop/slice whole blocks from the front. `blocks.length` is bounded by
|
||||
// ~maxChars/blockSize, so this shift is O(small constant), not O(appends).
|
||||
while (overflow > 0 && blocks.length > 0) {
|
||||
const head = blocks[0];
|
||||
if (head.length <= overflow) {
|
||||
blocks.shift();
|
||||
total -= head.length;
|
||||
overflow -= head.length;
|
||||
} else {
|
||||
blocks[0] = head.slice(overflow);
|
||||
total -= overflow;
|
||||
overflow = 0;
|
||||
}
|
||||
}
|
||||
// Only reachable when the tail alone exceeds the cap (e.g. blockSize >=
|
||||
// maxChars); keep its last `maxChars` characters.
|
||||
if (overflow > 0) {
|
||||
tail = tail.slice(overflow);
|
||||
total -= overflow;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
append(chunk: string): void {
|
||||
if (!chunk) return;
|
||||
// A single chunk at/over the cap can only contribute its own tail.
|
||||
if (chunk.length >= maxChars) {
|
||||
blocks = [];
|
||||
tail = chunk.slice(chunk.length - maxChars);
|
||||
total = tail.length;
|
||||
return;
|
||||
}
|
||||
tail += chunk;
|
||||
total += chunk.length;
|
||||
if (tail.length >= blockSize) {
|
||||
blocks.push(tail);
|
||||
tail = "";
|
||||
}
|
||||
if (total > maxChars) trim();
|
||||
},
|
||||
toString(): string {
|
||||
return blocks.length > 0 ? blocks.join("") + tail : tail;
|
||||
},
|
||||
reset(): void {
|
||||
blocks = [];
|
||||
tail = "";
|
||||
total = 0;
|
||||
},
|
||||
segmentCount(): number {
|
||||
return blocks.length + (tail.length > 0 ? 1 : 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const initialState: ZmodemTransferState = {
|
||||
|
||||
export function useZmodemTransfer(sessionId: string | null) {
|
||||
const [state, setState] = useState<ZmodemTransferState>(initialState);
|
||||
const [overwriteRequest, setOverwriteRequest] = useState<{ requestId: string; filename: string } | null>(null);
|
||||
const disposeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
@@ -77,6 +78,10 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
}
|
||||
});
|
||||
|
||||
const disposeOverwrite = bridge.onZmodemOverwriteRequest?.(sessionId, (payload) => {
|
||||
setOverwriteRequest({ requestId: payload.requestId, filename: payload.filename });
|
||||
});
|
||||
|
||||
// If the session exits mid-transfer (disconnect, shell exit, etc.),
|
||||
// reset state so the progress indicator doesn't stay stuck.
|
||||
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
|
||||
@@ -86,9 +91,11 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
return () => {
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
disposeOverwrite?.();
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
setState(initialState);
|
||||
setOverwriteRequest(null);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -98,5 +105,12 @@ export function useZmodemTransfer(sessionId: string | null) {
|
||||
bridge?.cancelZmodem?.(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
return { ...state, cancel };
|
||||
const respondOverwrite = useCallback((action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => {
|
||||
setOverwriteRequest((req) => {
|
||||
if (req) netcattyBridge.get()?.respondZmodemOverwrite?.({ requestId: req.requestId, action, applyToRest });
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { ...state, cancel, overwriteRequest, respondOverwrite };
|
||||
}
|
||||
|
||||
23
components/terminal/runtime/altKeyOptions.test.ts
Normal file
23
components/terminal/runtime/altKeyOptions.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
|
||||
// Issue #1078: with "Use Option as Meta key" enabled, macOS Option must send
|
||||
// ESC-prefixed (Meta) sequences. xterm.js gates that on `macOptionIsMeta`. The
|
||||
// flag was read from settings but only ever wired to the mouse alt-click
|
||||
// behavior, so Option kept emitting layout characters (ƒ, ∫, …) instead of Meta.
|
||||
|
||||
test("Option-as-Meta enabled: Option emits Meta and alt-click cursor move is disabled", () => {
|
||||
assert.deepEqual(terminalAltKeyOptions(true), {
|
||||
macOptionIsMeta: true,
|
||||
altClickMovesCursor: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("Option-as-Meta disabled: xterm keeps default macOS Option behavior", () => {
|
||||
assert.deepEqual(terminalAltKeyOptions(false), {
|
||||
macOptionIsMeta: false,
|
||||
altClickMovesCursor: true,
|
||||
});
|
||||
});
|
||||
20
components/terminal/runtime/altKeyOptions.ts
Normal file
20
components/terminal/runtime/altKeyOptions.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface TerminalAltKeyOptions {
|
||||
/** xterm.js: treat macOS Option as the Meta key (emit ESC-prefixed sequences). */
|
||||
macOptionIsMeta: boolean;
|
||||
/** xterm.js: Option+click moves the cursor. Must be off when Option is Meta. */
|
||||
altClickMovesCursor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the user's "Use Option as Meta key" setting to xterm.js options.
|
||||
*
|
||||
* Kept in one place so terminal init (createXTermRuntime) and the live settings
|
||||
* sync (Terminal.tsx) can't drift — that drift is what left `macOptionIsMeta`
|
||||
* unset everywhere and broke Option/Meta shortcuts on macOS (issue #1078).
|
||||
*/
|
||||
export function terminalAltKeyOptions(altAsMeta: boolean): TerminalAltKeyOptions {
|
||||
return {
|
||||
macOptionIsMeta: altAsMeta,
|
||||
altClickMovesCursor: !altAsMeta,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
syncPromptLineBreakState,
|
||||
type PromptLineBreakState,
|
||||
} from "./promptLineBreak";
|
||||
import { createOutputFlowController, type OutputFlowController } from "./outputFlowController";
|
||||
|
||||
/**
|
||||
* Per-connection token for stale-timer detection. The renderer reuses the
|
||||
@@ -97,6 +98,8 @@ type TerminalBackendApi = {
|
||||
) => (() => void) | undefined;
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
|
||||
resizeSession: (sessionId: string, cols: number, rows: number) => void;
|
||||
/** Pause/resume the source stream for output back-pressure (optional). */
|
||||
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
|
||||
};
|
||||
|
||||
export type PendingAuth = {
|
||||
@@ -251,6 +254,38 @@ const enqueueTerminalWrite = (
|
||||
}
|
||||
};
|
||||
|
||||
// Output back-pressure. Without this the renderer can't slow a flooding source,
|
||||
// so a busy stream grows the write queue and xterm's buffer unbounded. The
|
||||
// controller tracks bytes received-but-not-yet-rendered and asks the main
|
||||
// process to pause/resume the session's source stream at these watermarks.
|
||||
const FLOW_HIGH_WATER_MARK = 256 * 1024; // pause the source above ~256KB backlog
|
||||
const FLOW_LOW_WATER_MARK = 64 * 1024; // resume once drained to ~64KB
|
||||
|
||||
const terminalFlowControllers = new WeakMap<XTerm, OutputFlowController>();
|
||||
|
||||
const getFlowController = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
): OutputFlowController => {
|
||||
let controller = terminalFlowControllers.get(term);
|
||||
if (!controller) {
|
||||
controller = createOutputFlowController({
|
||||
highWaterMark: FLOW_HIGH_WATER_MARK,
|
||||
lowWaterMark: FLOW_LOW_WATER_MARK,
|
||||
onPause: () => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, true);
|
||||
},
|
||||
onResume: () => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, false);
|
||||
},
|
||||
});
|
||||
terminalFlowControllers.set(term, controller);
|
||||
}
|
||||
return controller;
|
||||
};
|
||||
|
||||
const writeTerminalLine = (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
term: XTerm,
|
||||
@@ -268,6 +303,8 @@ const writeSessionData = (
|
||||
term: XTerm,
|
||||
data: string,
|
||||
) => {
|
||||
const flow = getFlowController(ctx, term);
|
||||
flow.received(data.length);
|
||||
enqueueTerminalWrite(term, (done) => {
|
||||
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
|
||||
const forcePromptNewLine = settings?.forcePromptNewLine ?? false;
|
||||
@@ -301,6 +338,8 @@ const writeSessionData = (
|
||||
handleTerminalOutputAutoScroll(ctx, term);
|
||||
}
|
||||
done();
|
||||
// Acknowledge the chunk so back-pressure can ease once xterm catches up.
|
||||
flow.written(data.length);
|
||||
};
|
||||
|
||||
term.write(displayData, afterWrite);
|
||||
@@ -320,6 +359,8 @@ const attachSessionToTerminal = (
|
||||
},
|
||||
) => {
|
||||
ctx.sessionRef.current = id;
|
||||
// Clear any stale back-pressure accounting from a prior session on this term.
|
||||
getFlowController(ctx, term).reset();
|
||||
ctx.onSessionAttached?.(id);
|
||||
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
@@ -1204,6 +1245,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
});
|
||||
|
||||
ctx.sessionRef.current = id;
|
||||
getFlowController(ctx, term).reset();
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
writeSessionData(ctx, term, chunk);
|
||||
if (!ctx.hasConnectedRef.current) {
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
} from "./kittyKeyboardProtocol";
|
||||
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
|
||||
import { installUserCursorPreferenceGuard } from "./cursorPreference";
|
||||
import { terminalAltKeyOptions } from "./altKeyOptions";
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
import { watchDevicePixelRatio } from "./rendererDprWatch";
|
||||
import { handleSerialLineModeInput } from "./serialLineInput";
|
||||
import {
|
||||
@@ -294,7 +296,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
smoothScrollDuration,
|
||||
scrollOnUserInput,
|
||||
macOptionClickForcesSelection: true,
|
||||
altClickMovesCursor: !altIsMeta,
|
||||
...terminalAltKeyOptions(altIsMeta),
|
||||
wordSeparator,
|
||||
theme: {
|
||||
...ctx.terminalTheme.colors,
|
||||
@@ -656,6 +658,29 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
}
|
||||
|
||||
// macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
|
||||
// #826). After kitty mode so apps using the kitty protocol keep their own
|
||||
// arrow encoding; read live so the toggle applies without reconnecting.
|
||||
const wordJumpSequence = optionArrowWordJumpSequence(
|
||||
e,
|
||||
ctx.terminalSettingsRef.current?.optionArrowWordJump ?? false,
|
||||
isMacPlatform(),
|
||||
);
|
||||
if (wordJumpSequence) {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
ctx.onAutocompleteInput?.(wordJumpSequence);
|
||||
ctx.terminalBackend.writeToSession(id, wordJumpSequence);
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(wordJumpSequence, ctx.sessionId);
|
||||
}
|
||||
scrollToBottomAfterInput(wordJumpSequence);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
51
components/terminal/runtime/optionArrowWordJump.test.ts
Normal file
51
components/terminal/runtime/optionArrowWordJump.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
|
||||
|
||||
// Discussion #826: on macOS, Option+←/→ defaults to xterm's ^[[1;3D / ^[[1;3C,
|
||||
// which most shells don't bind. When enabled, remap them to Meta-b / Meta-f so
|
||||
// readline/zle does backward-word / forward-word out of the box (Termius-style).
|
||||
// Gated to macOS so the syncable setting can't rewrite Alt+←/→ on other platforms.
|
||||
|
||||
const ev = (over: Partial<Parameters<typeof optionArrowWordJumpSequence>[0]> = {}) => ({
|
||||
key: "ArrowLeft",
|
||||
altKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("Option+Left → Meta-b (backward-word) when enabled on macOS", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, true), "\x1bb");
|
||||
});
|
||||
|
||||
test("Option+Right → Meta-f (forward-word) when enabled on macOS", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, true), "\x1bf");
|
||||
});
|
||||
|
||||
test("not macOS → null (don't rewrite Alt+←/→ on Linux/Windows even if synced on)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, false), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, false), null);
|
||||
});
|
||||
|
||||
test("disabled → null (xterm default ^[[1;3D/C is kept)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), false, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), false, true), null);
|
||||
});
|
||||
|
||||
test("no Option held → null", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ altKey: false }), true, true), null);
|
||||
});
|
||||
|
||||
test("extra modifiers with Option → null (don't hijack Shift/Ctrl/Cmd combos)", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ shiftKey: true }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ ctrlKey: true }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ metaKey: true }), true, true), null);
|
||||
});
|
||||
|
||||
test("non-arrow keys → null", () => {
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowUp" }), true, true), null);
|
||||
assert.equal(optionArrowWordJumpSequence(ev({ key: "f" }), true, true), null);
|
||||
});
|
||||
33
components/terminal/runtime/optionArrowWordJump.ts
Normal file
33
components/terminal/runtime/optionArrowWordJump.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface OptionArrowKeyEvent {
|
||||
key: string;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* macOS Option+←/→ word-jump (discussion #826).
|
||||
*
|
||||
* When enabled, maps a bare Option+Left/Right to the Meta-b / Meta-f sequence so
|
||||
* readline/zle does backward-word / forward-word without per-host bindkey setup.
|
||||
* Returns the bytes to send, or null when the mapping doesn't apply (disabled,
|
||||
* non-macOS, not an arrow, or other modifiers held) — in which case xterm's
|
||||
* default ^[[1;3D / ^[[1;3C is left untouched.
|
||||
*
|
||||
* Gated to macOS (`isMac`): the setting is syncable, so without the gate,
|
||||
* enabling it on a Mac would also rewrite Alt+←/→ on synced Linux/Windows
|
||||
* devices (discussion #826 review).
|
||||
*/
|
||||
export function optionArrowWordJumpSequence(
|
||||
e: OptionArrowKeyEvent,
|
||||
enabled: boolean,
|
||||
isMac: boolean,
|
||||
): string | null {
|
||||
if (!enabled || !isMac) return null;
|
||||
// Only a bare Option+Arrow — leave Shift/Ctrl/Cmd combos to xterm's defaults.
|
||||
if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return null;
|
||||
if (e.key === "ArrowLeft") return "\x1bb"; // Meta-b → backward-word
|
||||
if (e.key === "ArrowRight") return "\x1bf"; // Meta-f → forward-word
|
||||
return null;
|
||||
}
|
||||
89
components/terminal/runtime/outputFlowController.test.ts
Normal file
89
components/terminal/runtime/outputFlowController.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createOutputFlowController } from "./outputFlowController.ts";
|
||||
|
||||
function make(high = 100, low = 30) {
|
||||
const events: string[] = [];
|
||||
const controller = createOutputFlowController({
|
||||
highWaterMark: high,
|
||||
lowWaterMark: low,
|
||||
onPause: () => events.push("pause"),
|
||||
onResume: () => events.push("resume"),
|
||||
});
|
||||
return { controller, events };
|
||||
}
|
||||
|
||||
test("does not pause while below the high watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(50);
|
||||
controller.received(49); // 99 < 100
|
||||
assert.deepEqual(events, []);
|
||||
assert.equal(controller.isPaused(), false);
|
||||
});
|
||||
|
||||
test("pauses once when crossing the high watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(60);
|
||||
controller.received(60); // 120 >= 100 -> pause
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
assert.equal(controller.isPaused(), true);
|
||||
// Further received while already paused must not re-fire pause.
|
||||
controller.received(100);
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
});
|
||||
|
||||
test("resumes once when draining to at/below the low watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(50); // 70 still > 30, no resume
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
controller.written(50); // 20 <= 30 -> resume
|
||||
assert.deepEqual(events, ["pause", "resume"]);
|
||||
assert.equal(controller.isPaused(), false);
|
||||
});
|
||||
|
||||
test("does not resume when still above the low watermark", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(80); // 40 > 30
|
||||
assert.deepEqual(events, ["pause"]);
|
||||
assert.equal(controller.isPaused(), true);
|
||||
});
|
||||
|
||||
test("never lets pending go negative", () => {
|
||||
const { controller } = make(100, 30);
|
||||
controller.received(10);
|
||||
controller.written(50); // over-written
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
});
|
||||
|
||||
test("supports repeated pause/resume cycles", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.written(120); // resume (0 <= 30)
|
||||
controller.received(120); // pause again
|
||||
controller.written(120); // resume again
|
||||
assert.deepEqual(events, ["pause", "resume", "pause", "resume"]);
|
||||
});
|
||||
|
||||
test("reset clears state without firing callbacks", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(120); // pause
|
||||
controller.reset();
|
||||
assert.equal(controller.isPaused(), false);
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
assert.deepEqual(events, ["pause"]); // reset itself is silent
|
||||
// A fresh cycle works after reset.
|
||||
controller.received(120);
|
||||
assert.deepEqual(events, ["pause", "pause"]);
|
||||
});
|
||||
|
||||
test("ignores non-positive amounts", () => {
|
||||
const { controller, events } = make(100, 30);
|
||||
controller.received(0);
|
||||
controller.written(0);
|
||||
controller.received(-5);
|
||||
assert.equal(controller.pendingBytes(), 0);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
73
components/terminal/runtime/outputFlowController.ts
Normal file
73
components/terminal/runtime/outputFlowController.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Watermark-based flow control for terminal output.
|
||||
*
|
||||
* SSH/PTY output has no back-pressure by default: the source streams as fast as
|
||||
* it can, the main process forwards it over IPC, and the renderer queues every
|
||||
* chunk into xterm. When output outpaces rendering (e.g. `cat` of a big file, a
|
||||
* noisy build, `tail -f`, `yes`), the renderer-side backlog and xterm's internal
|
||||
* buffer grow without bound — memory climbs and the whole UI, typing included,
|
||||
* janks.
|
||||
*
|
||||
* This tracks bytes that have been received but not yet acknowledged by xterm's
|
||||
* write callback. When the backlog crosses `highWaterMark` it asks the caller to
|
||||
* pause the source; once it drains back to `lowWaterMark` it asks to resume. The
|
||||
* hysteresis gap avoids rapid pause/resume flapping. During interactive use the
|
||||
* backlog hovers near zero, so this never engages.
|
||||
*/
|
||||
export interface OutputFlowController {
|
||||
/** Account bytes handed to xterm (call when a chunk is received). */
|
||||
received(bytes: number): void;
|
||||
/** Account bytes whose xterm write callback has fired. */
|
||||
written(bytes: number): void;
|
||||
/** Clear all state (e.g. on a fresh session attach). Fires no callbacks. */
|
||||
reset(): void;
|
||||
pendingBytes(): number;
|
||||
isPaused(): boolean;
|
||||
}
|
||||
|
||||
export interface OutputFlowControllerOptions {
|
||||
highWaterMark: number;
|
||||
lowWaterMark: number;
|
||||
/** Asked to pause the source when the backlog crosses the high watermark. */
|
||||
onPause: () => void;
|
||||
/** Asked to resume the source when the backlog drains to the low watermark. */
|
||||
onResume: () => void;
|
||||
}
|
||||
|
||||
export function createOutputFlowController(
|
||||
options: OutputFlowControllerOptions,
|
||||
): OutputFlowController {
|
||||
const { highWaterMark, lowWaterMark, onPause, onResume } = options;
|
||||
let pending = 0;
|
||||
let paused = false;
|
||||
|
||||
return {
|
||||
received(bytes: number): void {
|
||||
if (bytes <= 0) return;
|
||||
pending += bytes;
|
||||
if (!paused && pending >= highWaterMark) {
|
||||
paused = true;
|
||||
onPause();
|
||||
}
|
||||
},
|
||||
written(bytes: number): void {
|
||||
if (bytes <= 0) return;
|
||||
pending -= bytes;
|
||||
if (pending < 0) pending = 0;
|
||||
if (paused && pending <= lowWaterMark) {
|
||||
paused = false;
|
||||
onResume();
|
||||
}
|
||||
},
|
||||
reset(): void {
|
||||
pending = 0;
|
||||
paused = false;
|
||||
},
|
||||
pendingBytes(): number {
|
||||
return pending;
|
||||
},
|
||||
isPaused(): boolean {
|
||||
return paused;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -493,6 +493,7 @@ export interface TerminalSettings {
|
||||
|
||||
// Keyboard
|
||||
altAsMeta: boolean; // Use ⌥ as the Meta key
|
||||
optionArrowWordJump: boolean; // macOS: Option+←/→ send Meta-b/f for word jump
|
||||
scrollOnInput: boolean; // Scroll terminal to bottom on input
|
||||
scrollOnOutput: boolean; // Scroll terminal to bottom on output
|
||||
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
|
||||
@@ -692,6 +693,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
cursorBlink: true,
|
||||
minimumContrastRatio: 1,
|
||||
altAsMeta: false,
|
||||
optionArrowWordJump: false,
|
||||
scrollOnInput: true,
|
||||
scrollOnOutput: false,
|
||||
scrollOnKeyPress: false,
|
||||
|
||||
@@ -58,6 +58,32 @@ function extractTrailingIdlePrompt(output) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// bash and csh/tcsh print a banner to the terminal right before exiting due to
|
||||
// the shell's TMOUT idle-timeout setting ("timed out waiting for input:
|
||||
// auto-logout" / "auto-logout"). That exit is a clean shell exit — numeric
|
||||
// code, no signal — so it is indistinguishable from a user-typed `exit` by
|
||||
// exit code alone (verified: bash auto-logout exits 0). The banner is the only
|
||||
// reliable discriminator, letting the SSH bridge keep the tab open for
|
||||
// reconnect instead of auto-closing it (#1062, regression of #977).
|
||||
const IDLE_AUTO_LOGOUT_PATTERN = /(?:timed out waiting for input:\s*)?auto-?logout$/i;
|
||||
|
||||
function looksLikeIdleAutoLogout(outputTail) {
|
||||
if (typeof outputTail !== "string" || !outputTail) return false;
|
||||
// The shell prints this banner on its own line as the very last thing before
|
||||
// it exits, so anchor on the final non-empty line rather than a loose
|
||||
// substring. Otherwise unrelated output that merely mentions "auto-logout"
|
||||
// (e.g. `grep auto-logout /etc/profile`) followed by an intentional `exit`
|
||||
// would be misclassified as a timeout and wrongly keep the tab open.
|
||||
const lines = stripAnsi(outputTail.slice(-512)).replace(/\r/g, "\n").split("\n");
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
// Drop control bytes (e.g. the BEL bash rings before the banner) and trim.
|
||||
const line = lines[i].replace(/[\x00-\x1f\x7f]/g, "").trim();
|
||||
if (!line) continue;
|
||||
return IDLE_AUTO_LOGOUT_PATTERN.test(line);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
@@ -399,6 +425,7 @@ module.exports = {
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
trackSessionIdlePrompt,
|
||||
looksLikeIdleAutoLogout,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
normalizeCliPathForPlatform,
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isPlausibleCliVersionOutput,
|
||||
looksLikeIdleAutoLogout,
|
||||
prepareCommandForSpawn,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
@@ -175,3 +176,64 @@ test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PT
|
||||
// with the cached PS line, so downstream wrapper selection sees "".
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout detects the bash TMOUT banner at the tail", () => {
|
||||
// bash prints this immediately before a TMOUT auto-logout exit. The exit
|
||||
// itself is a clean shell exit (code 0, no signal), so the banner is the
|
||||
// only reliable discriminator from a user-typed `exit` (#1062 / #977).
|
||||
assert.equal(
|
||||
looksLikeIdleAutoLogout("user@host:~$ \x07timed out waiting for input: auto-logout\r\n"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout detects the csh/tcsh auto-logout banner", () => {
|
||||
assert.equal(looksLikeIdleAutoLogout("\r\nauto-logout\r\n"), true);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout sees through ANSI escapes around the banner", () => {
|
||||
assert.equal(
|
||||
looksLikeIdleAutoLogout("\x1b[0m\x1b[33mtimed out waiting for input: auto-logout\x1b[0m\r\n"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores a plain (non-timeout) logout", () => {
|
||||
// A normal login-shell exit prints "logout" — without the "auto-" prefix —
|
||||
// and must still auto-close the tab.
|
||||
assert.equal(looksLikeIdleAutoLogout("user@host:~$ logout\r\n"), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores the banner when it is not at the tail", () => {
|
||||
// "auto-logout" scrolled past long ago; the user then ran more commands and
|
||||
// exited normally. Only the tail end is inspected, so this is not a timeout.
|
||||
const tail = "auto-logout\n" + "x".repeat(400) + "\nuser@host:~$ logout\r\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout ignores auto-logout in command output before an intentional exit", () => {
|
||||
// Investigating TMOUT: the user greps the profile (output mentions
|
||||
// "auto-logout"), reads it, then exits on purpose. The banner is not the
|
||||
// final line, so the tab must still auto-close. Guards against matching an
|
||||
// unanchored substring anywhere in the recent output.
|
||||
const tail =
|
||||
"root@h:~# grep -i auto-logout /etc/profile\r\n" +
|
||||
"# bash TMOUT auto-logout setting\r\nTMOUT=300\r\n" +
|
||||
"root@h:~# exit\r\nlogout\r\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), false);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout matches the real-server banner shape (prompt + banner on one line)", () => {
|
||||
// The banner can share a line with the trailing prompt after ANSI/control
|
||||
// bytes are stripped (observed over real SSH); anchoring on the line end
|
||||
// must still match.
|
||||
const tail =
|
||||
"\x1b]0;root@VM:~\x07root@VM:~# \x1b[?2004l\x07timed out waiting for input: auto-logout\n";
|
||||
assert.equal(looksLikeIdleAutoLogout(tail), true);
|
||||
});
|
||||
|
||||
test("looksLikeIdleAutoLogout returns false for empty / non-string input", () => {
|
||||
assert.equal(looksLikeIdleAutoLogout(""), false);
|
||||
assert.equal(looksLikeIdleAutoLogout(undefined), false);
|
||||
assert.equal(looksLikeIdleAutoLogout(null), false);
|
||||
});
|
||||
|
||||
63
electron/bridges/ptyOutputBuffer.cjs
Normal file
63
electron/bridges/ptyOutputBuffer.cjs
Normal file
@@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Coalescing output buffer for terminal/PTY data on its way to the renderer.
|
||||
*
|
||||
* Incoming shell data is accumulated and delivered to `sendFn` in batches to
|
||||
* keep IPC traffic down, but the batch is flushed on the *next event-loop turn*
|
||||
* (`setImmediate`) rather than after a fixed time interval. A fixed interval
|
||||
* adds that whole interval as latency to interactive echo — every keystroke
|
||||
* round-trips through the buffer and waits out the timer before it can paint.
|
||||
* Turn-based flushing coalesces only the data that has already arrived in the
|
||||
* current turn, so a single echoed keystroke is forwarded almost immediately
|
||||
* while bursts of output still collapse into one send.
|
||||
*
|
||||
* A byte cap still forces an immediate, synchronous flush so a flood of output
|
||||
* can't grow the buffer without bound between turns.
|
||||
*
|
||||
* @param {(data: string) => void} sendFn delivers an accumulated batch
|
||||
* @param {{ maxBufferSize?: number }} [options]
|
||||
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
|
||||
*/
|
||||
function createPtyOutputBuffer(sendFn, options = {}) {
|
||||
const maxBufferSize = options.maxBufferSize ?? 16384; // 16KB
|
||||
|
||||
let dataBuffer = "";
|
||||
let scheduled = null;
|
||||
|
||||
const cancelScheduled = () => {
|
||||
if (scheduled) {
|
||||
clearImmediate(scheduled);
|
||||
scheduled = null;
|
||||
}
|
||||
};
|
||||
|
||||
const flushNow = () => {
|
||||
scheduled = null;
|
||||
if (dataBuffer.length > 0) {
|
||||
const pending = dataBuffer;
|
||||
dataBuffer = "";
|
||||
sendFn(pending);
|
||||
}
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
if (dataBuffer.length >= maxBufferSize) {
|
||||
// Large enough to ship right now — don't wait for the turn flush.
|
||||
cancelScheduled();
|
||||
flushNow();
|
||||
} else if (!scheduled) {
|
||||
scheduled = setImmediate(flushNow);
|
||||
}
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
cancelScheduled();
|
||||
flushNow();
|
||||
};
|
||||
|
||||
return { bufferData, flush };
|
||||
}
|
||||
|
||||
module.exports = { createPtyOutputBuffer };
|
||||
90
electron/bridges/ptyOutputBuffer.test.cjs
Normal file
90
electron/bridges/ptyOutputBuffer.test.cjs
Normal file
@@ -0,0 +1,90 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
|
||||
/** Resolve after one event-loop turn (immediates have run). */
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test("coalesces data buffered within the same turn into a single send", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("a");
|
||||
buffer.bufferData("b");
|
||||
buffer.bufferData("c");
|
||||
|
||||
// Nothing is sent synchronously while still in the same turn.
|
||||
assert.equal(sends.length, 0);
|
||||
|
||||
await tick();
|
||||
|
||||
assert.deepEqual(sends, ["abc"]);
|
||||
});
|
||||
|
||||
test("flushes within a single event-loop turn (not on a fixed delay)", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("x");
|
||||
|
||||
// A fixed-interval (e.g. 8ms) buffer would NOT have flushed after one
|
||||
// immediate turn. Turn-based flushing must have delivered it by now.
|
||||
await tick();
|
||||
|
||||
assert.deepEqual(sends, ["x"]);
|
||||
});
|
||||
|
||||
test("flushes immediately and synchronously once the size cap is reached", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
|
||||
maxBufferSize: 4,
|
||||
});
|
||||
|
||||
buffer.bufferData("ab");
|
||||
assert.equal(sends.length, 0); // under cap, still pending
|
||||
|
||||
buffer.bufferData("cd"); // now "abcd" hits the 4-byte cap
|
||||
|
||||
// Cap flush happens synchronously, without waiting for the turn.
|
||||
assert.deepEqual(sends, ["abcd"]);
|
||||
|
||||
// The pending turn flush must have been cancelled — no empty/duplicate send.
|
||||
await tick();
|
||||
assert.deepEqual(sends, ["abcd"]);
|
||||
});
|
||||
|
||||
test("flush() forces a synchronous send and cancels the pending turn", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("hello");
|
||||
buffer.flush();
|
||||
|
||||
assert.deepEqual(sends, ["hello"]);
|
||||
|
||||
await tick();
|
||||
assert.deepEqual(sends, ["hello"]); // not sent twice
|
||||
});
|
||||
|
||||
test("flush() with an empty buffer does not send", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.flush();
|
||||
|
||||
assert.equal(sends.length, 0);
|
||||
});
|
||||
|
||||
test("keeps batching after a flush", async () => {
|
||||
const sends = [];
|
||||
const buffer = createPtyOutputBuffer((data) => sends.push(data));
|
||||
|
||||
buffer.bufferData("first");
|
||||
await tick();
|
||||
|
||||
buffer.bufferData("second");
|
||||
await tick();
|
||||
|
||||
assert.deepEqual(sends, ["first", "second"]);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const { attachX11Forwarding } = require("./x11Forwarding.cjs");
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
@@ -33,7 +34,7 @@ const {
|
||||
isPassphraseCancelledError,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { trackSessionIdlePrompt, looksLikeIdleAutoLogout } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const {
|
||||
buildAlgorithms,
|
||||
@@ -365,6 +366,8 @@ function resolveLangFromCharset(charset) {
|
||||
|
||||
const { safeSend } = require("./ipcUtils.cjs");
|
||||
|
||||
const zmodemOverwritePending = new Map(); // requestId -> (decision) => void
|
||||
|
||||
/**
|
||||
* Initialize the SSH bridge with dependencies
|
||||
*/
|
||||
@@ -1285,35 +1288,14 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
}
|
||||
|
||||
// Data buffering for reduced IPC overhead
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms for ~120fps equivalent
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer gets too large
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:data", { sessionId, data: dataBuffer });
|
||||
dataBuffer = '';
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
// Immediate flush for large chunks
|
||||
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
} else if (!flushTimeout) {
|
||||
// Schedule flush
|
||||
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
|
||||
}
|
||||
};
|
||||
// Coalesce shell output and deliver it to the renderer on the next
|
||||
// event-loop turn (see ptyOutputBuffer) rather than on a fixed timer,
|
||||
// so interactive echo isn't held back by the batch interval. A size
|
||||
// cap still forces an immediate flush for bursts of output.
|
||||
const { bufferData, flush: flushBuffer } = createPtyOutputBuffer((data) => {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:data", { sessionId, data });
|
||||
});
|
||||
|
||||
const sshZmodemSentry = createZmodemSentry({
|
||||
sessionId,
|
||||
@@ -1330,6 +1312,31 @@ async function startSSHSession(event, options) {
|
||||
interruptRemote() {
|
||||
try { stream.signal?.("INT"); } catch { /* ignore */ }
|
||||
},
|
||||
probeReceiveConflicts(names) {
|
||||
return probeReceiveConflicts(sessions.get(sessionId), names);
|
||||
},
|
||||
removeRemoteFiles(paths) {
|
||||
return removeRemoteFiles(sessions.get(sessionId), paths);
|
||||
},
|
||||
restoreRemoteModes(entries) {
|
||||
return restoreRemoteModes(sessions.get(sessionId), entries);
|
||||
},
|
||||
requestOverwriteDecision(filename) {
|
||||
return new Promise((resolve) => {
|
||||
const requestId = randomUUID();
|
||||
const timer = setTimeout(() => {
|
||||
zmodemOverwritePending.delete(requestId);
|
||||
resolve({ action: "skip", applyToRest: false });
|
||||
}, 120000);
|
||||
zmodemOverwritePending.set(requestId, (payload) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ action: payload.action, applyToRest: !!payload.applyToRest });
|
||||
});
|
||||
safeSend(event.sender, "netcatty:zmodem:overwrite-request", {
|
||||
sessionId, requestId, filename,
|
||||
});
|
||||
});
|
||||
},
|
||||
getWebContents() {
|
||||
return event.sender;
|
||||
},
|
||||
@@ -1365,10 +1372,8 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
// Always flush buffered data regardless of session state
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
// Always flush buffered data regardless of session state.
|
||||
// flushBuffer() cancels any pending scheduled flush internally.
|
||||
flushBuffer();
|
||||
sessionLogStreamManager.stopStream(sessionId, logStreamToken);
|
||||
if (detachX11Forwarding) {
|
||||
@@ -1386,7 +1391,14 @@ async function startSSHSession(event, options) {
|
||||
if (transportError) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
// A shell TMOUT auto-logout is a clean exit (numeric code, no
|
||||
// signal) — identical to a user-typed `exit` by code/signal —
|
||||
// so detect it via the banner the shell prints just before
|
||||
// exiting and report it as a timeout. That keeps the tab open
|
||||
// for reconnect instead of auto-closing it (#1062 / #977).
|
||||
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(session?._promptTrackTail);
|
||||
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason });
|
||||
}
|
||||
sessions.get(sessionId)?.zmodemSentry?.cancel();
|
||||
sessions.delete(sessionId);
|
||||
@@ -2012,24 +2024,58 @@ async function getSessionPwd(event, payload) {
|
||||
// so sh keeps the same PID and $PPID = sshd. Starting another shell
|
||||
// without exec would make $PPID point at the intermediate shell instead.
|
||||
const posixScript = `SELF=$$
|
||||
find_child_shell() {
|
||||
mode=$2
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" -v mode="$mode" '
|
||||
$1 != self && $2 == pp && $4 ~ /^(ba|z|fi|k|da)?sh$/ {
|
||||
if (index($3, "+") > 0) { print $1; found=1; exit }
|
||||
if (mode != "foreground" && pid == "") pid=$1
|
||||
# Find the interactive shell child of this exec channel's sshd ($PPID).
|
||||
# Prefer the one attached to a controlling tty (the user's shell): probe exec
|
||||
# channels like this one have no tty ("?"), and ps output is unsorted, so
|
||||
# without the tty preference a concurrent probe's shell could be picked when
|
||||
# several exist under the same sshd (#1065 review). Falls back to any shell
|
||||
# child if none has a tty.
|
||||
find_login_shell() {
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
||||
if ($3 != "?") { print $1; found=1; exit }
|
||||
if (any == "") any=$1
|
||||
}
|
||||
END { if (!found && mode != "foreground" && pid != "") print pid }
|
||||
END { if (!found && any != "") print any }
|
||||
'
|
||||
}
|
||||
pid=$(find_child_shell "$PPID" any)
|
||||
while [ -n "$pid" ]; do
|
||||
child=$(find_child_shell "$pid" foreground)
|
||||
[ -n "$child" ] || break
|
||||
pid="$child"
|
||||
done
|
||||
if [ -n "$pid" ]; then
|
||||
# From the login shell, pick the DEEPEST foreground shell in its process
|
||||
# subtree. "Foreground" = the controlling tty's foreground process group ("+"
|
||||
# in stat), i.e. the shell the user is actually typing in. Walking the whole
|
||||
# subtree (rather than only direct shell children) lets us follow through
|
||||
# non-shell foreground parents like su / sudo, so we read the cwd of the
|
||||
# su'd / sudo'd shell instead of stopping at the login shell (#1065). Falls
|
||||
# back to the login shell when no foreground shell is found.
|
||||
find_active_shell() {
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
||||
{ pp[$1]=$2; st[$1]=$3; cm[$1]=$4; ord[NR]=$1 }
|
||||
function isshell(c) { return c ~ /^-?(ba|z|fi|k|da|a)?sh$/ }
|
||||
function depth(p, d) { d=0; while (p != "" && d < 64) { if (p == start) return d; p=pp[p]; d++ } return -1 }
|
||||
END {
|
||||
best=-1; bp="";
|
||||
for (i=1; i<=NR; i++) {
|
||||
p=ord[i];
|
||||
if (!isshell(cm[p])) continue;
|
||||
if (index(st[p], "+") == 0) continue;
|
||||
d=depth(p); if (d < 0) continue;
|
||||
if (d > best) { best=d; bp=p }
|
||||
}
|
||||
print (bp != "" ? bp : start)
|
||||
}
|
||||
'
|
||||
}
|
||||
login=$(find_login_shell "$PPID")
|
||||
if [ -n "$login" ]; then
|
||||
pid=$(find_active_shell "$login")
|
||||
[ -n "$pid" ] || pid="$login"
|
||||
cwd=$(readlink /proc/$pid/cwd 2>/dev/null)
|
||||
# /proc/<pid>/cwd is only readable for same-uid processes (ptrace perms), so
|
||||
# this unprivileged exec channel cannot read a su'd / sudo'd shell owned by
|
||||
# another user. Fall back to the same-uid login shell's cwd before giving up
|
||||
# to the home directory (#1065 review).
|
||||
if [ -z "$cwd" ] && [ "$pid" != "$login" ]; then
|
||||
cwd=$(readlink /proc/$login/cwd 2>/dev/null)
|
||||
fi
|
||||
[ -n "$cwd" ] && printf '%s\\n' "$cwd" && exit 0
|
||||
fi
|
||||
emit_home() {
|
||||
@@ -2077,6 +2123,103 @@ exit 1`;
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the directory the running `rz` writes to (its own cwd) and report
|
||||
// which of `names` already exist there. Returns { dir, existing } or null.
|
||||
function probeReceiveConflicts(session, names) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(names) || names.length === 0) {
|
||||
return resolve(null);
|
||||
}
|
||||
const timer = setTimeout(() => resolve(null), 5000);
|
||||
const script = `SELF=$$
|
||||
find_login_shell() {
|
||||
ps -e -o pid=,ppid=,tty=,comm= 2>/dev/null | awk -v pp="$1" -v self="$SELF" '
|
||||
$1 != self && $2 == pp && $4 ~ /^-?(ba|z|fi|k|da|a)?sh$/ {
|
||||
if ($3 != "?") { print $1; found=1; exit }
|
||||
if (any == "") any=$1
|
||||
}
|
||||
END { if (!found && any != "") print any }'
|
||||
}
|
||||
find_fg_leaf() {
|
||||
ps -e -o pid=,ppid=,stat=,comm= 2>/dev/null | awk -v start="$1" '
|
||||
{ pp[$1]=$2; st[$1]=$3; ord[NR]=$1 }
|
||||
function depth(p, d){ d=0; while(p!="" && d<64){ if(p==start) return d; p=pp[p]; d++ } return -1 }
|
||||
END { best=-1; bp=""; for(i=1;i<=NR;i++){ p=ord[i];
|
||||
if(index(st[p],"+")==0) continue; d=depth(p); if(d<0) continue;
|
||||
if(d>best){best=d; bp=p} } print bp }'
|
||||
}
|
||||
login=$(find_login_shell "$PPID")
|
||||
[ -n "$login" ] || exit 0
|
||||
leaf=$(find_fg_leaf "$login")
|
||||
[ -n "$leaf" ] || leaf="$login"
|
||||
dir=$(readlink /proc/$leaf/cwd 2>/dev/null)
|
||||
[ -n "$dir" ] || exit 0
|
||||
printf 'DIR\\t%s\\n' "$dir"
|
||||
cd "$dir" 2>/dev/null || exit 0
|
||||
for n in "$@"; do
|
||||
[ -e "$n" ] || continue
|
||||
m=$(stat -c %a -- "$n" 2>/dev/null || stat -f %Lp -- "$n" 2>/dev/null)
|
||||
printf 'EXIST\\t%s\\t%s\\n' "$n" "$m"
|
||||
done`;
|
||||
const argv = names.map((n) => quoteShellArg(n)).join(" ");
|
||||
const cmd = `exec sh -c ${quoteShellArg(script)} sh ${argv}`;
|
||||
session.conn.exec(cmd, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(null); }
|
||||
let out = "";
|
||||
stream.on("data", (d) => { out += d.toString(); });
|
||||
stream.on("close", () => {
|
||||
clearTimeout(timer);
|
||||
let dir = null; const existing = []; const modes = {};
|
||||
for (const line of out.split("\n")) {
|
||||
const [tag, val, mode] = line.split("\t");
|
||||
if (tag === "DIR") dir = val;
|
||||
else if (tag === "EXIST" && val) {
|
||||
existing.push(val);
|
||||
if (mode && /^[0-7]{3,4}$/.test(mode)) modes[val] = mode;
|
||||
}
|
||||
}
|
||||
resolve(dir ? { dir, existing, modes } : null);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// rm -f the given absolute remote paths (quoted; injection-safe).
|
||||
function removeRemoteFiles(session, paths) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(paths) || paths.length === 0) return resolve();
|
||||
const argv = paths.map((p) => quoteShellArg(p)).join(" ");
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
session.conn.exec(`exec sh -c 'rm -f -- "$@"' sh ${argv}`, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(); }
|
||||
stream.on("data", () => {}); stream.stderr?.on("data", () => {});
|
||||
stream.on("close", () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// chmod the given { path, mode } entries back to their captured permissions
|
||||
// (parameterized; injection-safe). Modes are validated octal before use.
|
||||
function restoreRemoteModes(session, entries) {
|
||||
return new Promise((resolve) => {
|
||||
if (!session || !session.conn || !Array.isArray(entries) || entries.length === 0) return resolve();
|
||||
const args = [];
|
||||
for (const e of entries) {
|
||||
if (!e || !e.path || !/^[0-7]{3,4}$/.test(String(e.mode))) continue;
|
||||
args.push(quoteShellArg(String(e.mode)));
|
||||
args.push(quoteShellArg(e.path));
|
||||
}
|
||||
if (args.length === 0) return resolve();
|
||||
const timer = setTimeout(resolve, 5000);
|
||||
const script = 'while [ "$#" -ge 2 ]; do chmod "$1" "$2" 2>/dev/null; shift 2; done';
|
||||
session.conn.exec(`exec sh -c ${quoteShellArg(script)} sh ${args.join(" ")}`, (err, stream) => {
|
||||
if (err) { clearTimeout(timer); return resolve(); }
|
||||
stream.on("data", () => {}); stream.stderr?.on("data", () => {});
|
||||
stream.on("close", () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List directory contents on remote machine for path autocomplete.
|
||||
* Uses a separate exec channel — does not touch the interactive shell.
|
||||
@@ -2653,6 +2796,10 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
ipcMain.on("netcatty:zmodem:overwrite-response", (_event, payload) => {
|
||||
const resolve = zmodemOverwritePending.get(payload?.requestId);
|
||||
if (resolve) { zmodemOverwritePending.delete(payload.requestId); resolve(payload); }
|
||||
});
|
||||
// Register the shared keyboard-interactive response handler
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
// Register the passphrase response handler
|
||||
|
||||
@@ -81,6 +81,7 @@ test("execCommand stops when an identity file passphrase prompt is cancelled", a
|
||||
handle(channel, handler) {
|
||||
this.handlers.set(channel, handler);
|
||||
},
|
||||
on() {},
|
||||
};
|
||||
bridge.registerHandlers(ipcMain);
|
||||
const execHandler = ipcMain.handlers.get("netcatty:ssh:exec");
|
||||
|
||||
@@ -25,6 +25,7 @@ const moshHandshake = require("./moshHandshake.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
|
||||
const telnetProtocol = require("./telnetProtocol.cjs");
|
||||
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -80,51 +81,6 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an 8ms/16KB PTY data buffer for reduced IPC overhead.
|
||||
* Mirrors the SSH stream buffering strategy in sshBridge.cjs.
|
||||
* @param {Function} sendFn - called with the accumulated string to deliver
|
||||
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
|
||||
*/
|
||||
function createPtyBuffer(sendFn) {
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms (~120fps equivalent)
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer grows too large
|
||||
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
sendFn(dataBuffer);
|
||||
dataBuffer = '';
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
} else if (!flushTimeout) {
|
||||
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
return { bufferData, flush };
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an executable on POSIX systems by name.
|
||||
*
|
||||
@@ -454,7 +410,7 @@ function startLocalSession(event, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyBuffer((data) => {
|
||||
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyOutputBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
@@ -662,7 +618,7 @@ async function startTelnetSession(event, options) {
|
||||
const telnetDecoderRef = { current: iconv.getDecoder(initialTelnetEncoding) };
|
||||
|
||||
const telnetWebContentsId = event.sender.id;
|
||||
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyBuffer((data) => {
|
||||
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyOutputBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(telnetWebContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
@@ -1189,7 +1145,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
// it to scope its stopStream call.
|
||||
session.logStreamToken = logStreamToken;
|
||||
|
||||
const { bufferData, flush } = createPtyBuffer((data) => {
|
||||
const { bufferData, flush } = createPtyOutputBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
@@ -1593,6 +1549,31 @@ function writeToSession(event, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause or resume a session's source stream for output back-pressure.
|
||||
* The renderer asks for this when its write backlog crosses a watermark, so a
|
||||
* flooding source can't outrun the terminal renderer. Works across session
|
||||
* kinds: ssh2 channel (stream), node-pty (proc), telnet socket, serial port —
|
||||
* all expose pause()/resume().
|
||||
*/
|
||||
function setSessionFlowPaused(event, payload) {
|
||||
const session = sessions.get(payload.sessionId);
|
||||
if (!session) return;
|
||||
const target = session.stream || session.proc || session.socket || session.serialPort;
|
||||
if (!target) return;
|
||||
try {
|
||||
if (payload.paused) {
|
||||
target.pause?.();
|
||||
} else {
|
||||
target.resume?.();
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.code !== 'EPIPE' && err?.code !== 'ERR_STREAM_DESTROYED') {
|
||||
console.warn("Flow control toggle failed", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a session terminal
|
||||
*/
|
||||
@@ -1707,6 +1688,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:terminal:setEncoding", setSessionEncoding);
|
||||
ipcMain.on("netcatty:write", writeToSession);
|
||||
ipcMain.on("netcatty:resize", resizeSession);
|
||||
ipcMain.on("netcatty:flow", setSessionFlowPaused);
|
||||
ipcMain.on("netcatty:close", closeSession);
|
||||
}
|
||||
|
||||
@@ -1892,6 +1874,7 @@ module.exports = {
|
||||
listSerialPorts,
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
setSessionFlowPaused,
|
||||
closeSession,
|
||||
cleanupAllSessions,
|
||||
getDefaultShell,
|
||||
|
||||
@@ -20,6 +20,58 @@ function getElectron() {
|
||||
return _electron;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve per-file overwrite choices into an upload plan. Pure (no I/O):
|
||||
* `resolveDecision(name)` is awaited only for files in `existingList`, in input
|
||||
* order; `{ applyToRest: true }` reuses that action for the remaining conflicts.
|
||||
* Returns indices into the original `names` array so callers preserve per-file
|
||||
* identity even when two files share a basename.
|
||||
* Actions: 'overwrite' (rm remote then send), 'skip' (don't send), 'cancel' (abort all).
|
||||
*/
|
||||
async function buildUploadPlan(names, existingList, resolveDecision) {
|
||||
const existing = new Set(existingList);
|
||||
const offerIndices = [];
|
||||
const removeIndices = [];
|
||||
let bulkAction = null;
|
||||
for (let idx = 0; idx < names.length; idx++) {
|
||||
const name = names[idx];
|
||||
if (!existing.has(name)) { offerIndices.push(idx); continue; }
|
||||
let action = bulkAction;
|
||||
if (!action) {
|
||||
const decision = (await resolveDecision(name)) || { action: "skip" };
|
||||
action = decision.action;
|
||||
if (decision.applyToRest && action !== "cancel") bulkAction = action;
|
||||
}
|
||||
if (action === "cancel") return { offerIndices: [], removeIndices: [], aborted: true };
|
||||
if (action === "overwrite") { removeIndices.push(idx); offerIndices.push(idx); }
|
||||
// 'skip' → omit from both
|
||||
}
|
||||
return { offerIndices, removeIndices, aborted: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which overwritten files need their original mode restored after rz
|
||||
* re-creates them. rz writes new files with the remote umask, dropping the
|
||||
* prior permission bits (issue #1079). Pure: returns absolute `{ path, mode }`
|
||||
* entries for the overwritten files, skipping any whose mode wasn't captured
|
||||
* and de-duplicating shared basenames.
|
||||
*/
|
||||
function buildModeRestores(dir, names, removeIndices, modes) {
|
||||
const base = String(dir).replace(/\/+$/, "");
|
||||
const seen = new Set();
|
||||
const restores = [];
|
||||
for (const i of removeIndices) {
|
||||
const name = names[i];
|
||||
const mode = modes && modes[name];
|
||||
if (!mode) continue;
|
||||
const target = `${base}/${name}`;
|
||||
if (seen.has(target)) continue;
|
||||
seen.add(target);
|
||||
restores.push({ path: target, mode });
|
||||
}
|
||||
return restores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ZMODEM sentry that wraps a session's data stream.
|
||||
*
|
||||
@@ -524,10 +576,45 @@ async function handleUpload(zsession, opts) {
|
||||
const filePaths = result.filePaths;
|
||||
const fileStats = filePaths.map((fp) => fs.statSync(fp));
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
const stat = fileStats[i];
|
||||
const name = path.basename(filePath);
|
||||
const allNames = filePaths.map((fp) => path.basename(fp));
|
||||
|
||||
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
|
||||
// On any failure we fall back to today's behavior (rz silently skips).
|
||||
let plan = { offerIndices: allNames.map((_, i) => i), removeIndices: [], aborted: false };
|
||||
let probeDir = null;
|
||||
let probeModes = null;
|
||||
if (opts.probeReceiveConflicts && opts.requestOverwriteDecision) {
|
||||
try {
|
||||
const probe = await opts.probeReceiveConflicts(allNames);
|
||||
if (probe && probe.dir && Array.isArray(probe.existing) && probe.existing.length > 0) {
|
||||
probeDir = probe.dir;
|
||||
probeModes = probe.modes || {};
|
||||
plan = await buildUploadPlan(allNames, probe.existing, opts.requestOverwriteDecision);
|
||||
if (plan.aborted) {
|
||||
try { zsession.abort(); } catch { /* ignore */ }
|
||||
abortRemoteProcess(opts.writeToRemote);
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
if (plan.removeIndices.length && opts.removeRemoteFiles) {
|
||||
const base = probe.dir.replace(/\/+$/, "");
|
||||
const targets = [...new Set(plan.removeIndices.map((i) => `${base}/${allNames[i]}`))];
|
||||
try {
|
||||
await opts.removeRemoteFiles(targets);
|
||||
} catch (err) {
|
||||
console.warn("[ZMODEM] removeRemoteFiles failed; rz will skip:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Transfer cancelled") throw err;
|
||||
console.warn("[ZMODEM] conflict probe failed; proceeding:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
const offers = plan.offerIndices.map((i) => ({ filePath: filePaths[i], stat: fileStats[i], name: allNames[i] }));
|
||||
|
||||
for (let i = 0; i < offers.length; i++) {
|
||||
const { filePath, stat, name } = offers[i];
|
||||
|
||||
safeSend(contents, "netcatty:zmodem:progress", {
|
||||
sessionId,
|
||||
@@ -535,18 +622,18 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: 0,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
let bytesRemaining = 0;
|
||||
for (let j = i; j < fileStats.length; j++) bytesRemaining += fileStats[j].size;
|
||||
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
|
||||
|
||||
const xfer = await zsession.send_offer({
|
||||
name,
|
||||
size: stat.size,
|
||||
mtime: new Date(stat.mtimeMs),
|
||||
files_remaining: filePaths.length - i,
|
||||
files_remaining: offers.length - i,
|
||||
bytes_remaining: bytesRemaining,
|
||||
});
|
||||
|
||||
@@ -579,7 +666,7 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: sent,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
});
|
||||
|
||||
@@ -597,7 +684,7 @@ async function handleUpload(zsession, opts) {
|
||||
transferred: stat.size,
|
||||
total: stat.size,
|
||||
fileIndex: i,
|
||||
fileCount: filePaths.length,
|
||||
fileCount: offers.length,
|
||||
transferType: "upload",
|
||||
finalizing: true,
|
||||
});
|
||||
@@ -608,6 +695,20 @@ async function handleUpload(zsession, opts) {
|
||||
}
|
||||
|
||||
await withTimeout(zsession.close(), 120000);
|
||||
|
||||
// rz re-creates overwritten files with the remote umask, dropping their
|
||||
// original permission bits. Now that everything is on disk, restore them
|
||||
// to the modes captured before the rm (issue #1079).
|
||||
if (plan.removeIndices.length && probeDir && opts.restoreRemoteModes) {
|
||||
const restores = buildModeRestores(probeDir, allNames, plan.removeIndices, probeModes);
|
||||
if (restores.length) {
|
||||
try {
|
||||
await opts.restoreRemoteModes(restores);
|
||||
} catch (err) {
|
||||
console.warn("[ZMODEM] restoreRemoteModes failed:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -791,4 +892,4 @@ function safeSend(contents, channel, data) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createZmodemSentry };
|
||||
module.exports = { createZmodemSentry, buildUploadPlan, buildModeRestores };
|
||||
|
||||
74
electron/bridges/zmodemHelper.test.cjs
Normal file
74
electron/bridges/zmodemHelper.test.cjs
Normal file
@@ -0,0 +1,74 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { buildUploadPlan, buildModeRestores } = require("./zmodemHelper.cjs");
|
||||
|
||||
const never = () => { throw new Error("resolver should not be called"); };
|
||||
|
||||
test("no conflicts: all indices offered, none removed, resolver untouched", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], [], never);
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [], aborted: false });
|
||||
});
|
||||
|
||||
test("overwrite a conflict: index both removed and offered", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "overwrite" }));
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [1], aborted: false });
|
||||
});
|
||||
|
||||
test("skip a conflict: index omitted from offer and remove", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "skip" }));
|
||||
assert.deepEqual(plan, { offerIndices: [0], removeIndices: [], aborted: false });
|
||||
});
|
||||
|
||||
test("cancel aborts the whole transfer", async () => {
|
||||
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "cancel" }));
|
||||
assert.deepEqual(plan, { offerIndices: [], removeIndices: [], aborted: true });
|
||||
});
|
||||
|
||||
test("applyToRest reuses the action and stops prompting", async () => {
|
||||
let calls = 0;
|
||||
const plan = await buildUploadPlan(["a", "b", "c"], ["a", "b", "c"],
|
||||
async () => { calls++; return { action: "overwrite", applyToRest: true }; });
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(plan, { offerIndices: [0, 1, 2], removeIndices: [0, 1, 2], aborted: false });
|
||||
});
|
||||
|
||||
test("only conflicting files invoke the resolver; order preserved", async () => {
|
||||
const seen = [];
|
||||
const plan = await buildUploadPlan(["a", "b", "c"], ["b"],
|
||||
async (n) => { seen.push(n); return { action: "skip" }; });
|
||||
assert.deepEqual(seen, ["b"]);
|
||||
assert.deepEqual(plan.offerIndices, [0, 2]);
|
||||
});
|
||||
|
||||
test("duplicate basenames keep independent per-file decisions", async () => {
|
||||
// Two different local files share a basename; skip the first, overwrite the second.
|
||||
const actions = ["skip", "overwrite"];
|
||||
let i = 0;
|
||||
const plan = await buildUploadPlan(["x.txt", "x.txt"], ["x.txt"],
|
||||
async () => ({ action: actions[i++] }));
|
||||
assert.deepEqual(plan, { offerIndices: [1], removeIndices: [1], aborted: false });
|
||||
});
|
||||
|
||||
// Issue #1079: overwriting (rm + rz re-create) drops the original permission
|
||||
// bits. buildModeRestores resolves which overwritten files to chmod back.
|
||||
|
||||
test("buildModeRestores maps overwritten files to their captured modes", () => {
|
||||
assert.deepEqual(
|
||||
buildModeRestores("/home/u", ["a.sh", "b.txt"], [0], { "a.sh": "755" }),
|
||||
[{ path: "/home/u/a.sh", mode: "755" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildModeRestores skips files whose mode was not captured", () => {
|
||||
assert.deepEqual(
|
||||
buildModeRestores("/srv", ["a", "b"], [0, 1], { a: "644" }),
|
||||
[{ path: "/srv/a", mode: "644" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildModeRestores strips trailing slashes and dedupes duplicate basenames", () => {
|
||||
assert.deepEqual(
|
||||
buildModeRestores("/srv//", ["x", "x"], [0, 1], { x: "600" }),
|
||||
[{ path: "/srv/x", mode: "600" }],
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const transferErrorListeners = new Map();
|
||||
const transferCancelledListeners = new Map();
|
||||
const chainProgressListeners = new Map();
|
||||
const zmodemListeners = new Map();
|
||||
const zmodemOverwriteListeners = new Map(); // sessionId -> Set<cb>
|
||||
const sftpConnectionProgressListeners = new Set();
|
||||
const authFailedListeners = new Map();
|
||||
const telnetAutoLoginCompleteListeners = new Map();
|
||||
@@ -137,6 +138,10 @@ ipcRenderer.on("netcatty:zmodem:error", (_event, payload) => {
|
||||
if (!set) return;
|
||||
set.forEach((cb) => { try { cb({ type: "error", ...payload }); } catch {} });
|
||||
});
|
||||
ipcRenderer.on("netcatty:zmodem:overwrite-request", (_event, payload) => {
|
||||
const set = zmodemOverwriteListeners.get(payload.sessionId);
|
||||
if (set) set.forEach((cb) => cb(payload));
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
@@ -185,6 +190,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
telnetAutoLoginCompleteListeners.delete(payload.sessionId);
|
||||
telnetAutoLoginCancelledListeners.delete(payload.sessionId);
|
||||
zmodemListeners.delete(payload.sessionId);
|
||||
zmodemOverwriteListeners.delete(payload.sessionId);
|
||||
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
@@ -663,6 +669,9 @@ const api = {
|
||||
resizeSession: (sessionId, cols, rows) => {
|
||||
ipcRenderer.send("netcatty:resize", { sessionId, cols, rows });
|
||||
},
|
||||
setSessionFlowPaused: (sessionId, paused) => {
|
||||
ipcRenderer.send("netcatty:flow", { sessionId, paused: Boolean(paused) });
|
||||
},
|
||||
closeSession: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:close", { sessionId });
|
||||
},
|
||||
@@ -682,6 +691,14 @@ const api = {
|
||||
cancelZmodem: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
|
||||
},
|
||||
onZmodemOverwriteRequest: (sessionId, cb) => {
|
||||
if (!zmodemOverwriteListeners.has(sessionId)) zmodemOverwriteListeners.set(sessionId, new Set());
|
||||
zmodemOverwriteListeners.get(sessionId).add(cb);
|
||||
return () => zmodemOverwriteListeners.get(sessionId)?.delete(cb);
|
||||
},
|
||||
respondZmodemOverwrite: (payload) => {
|
||||
ipcRenderer.send("netcatty:zmodem:overwrite-response", payload);
|
||||
},
|
||||
onSessionData: (sessionId, cb) => {
|
||||
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
|
||||
dataListeners.get(sessionId).add(cb);
|
||||
|
||||
@@ -3,11 +3,16 @@ import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
// The recommended preset has no file scope of its own, so scope it off all of
|
||||
// electron/ — that main-process tree is historically unlinted. The bridges
|
||||
// get a focused rule set in the dedicated block at the end of this config;
|
||||
// every other electron/ file matches no config and stays unlinted as before.
|
||||
{ ...js.configs.recommended, ignores: ["electron/**"] },
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**", ".worktrees/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**", ".worktrees/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
@@ -168,4 +173,26 @@ export default [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Electron main-process bridges are CommonJS and were historically excluded
|
||||
// from linting. Lint them for undefined references only — the cheap,
|
||||
// high-value guard against e.g. a removed variable still referenced
|
||||
// elsewhere. (The TS config disables no-undef because the type-checker
|
||||
// already covers it there; these .cjs files have no such safety net.)
|
||||
files: ["electron/bridges/**/*.cjs"],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "commonjs",
|
||||
globals: globals.node,
|
||||
},
|
||||
linterOptions: {
|
||||
// Only no-undef is enabled here, so pre-existing eslint-disable comments
|
||||
// for other rules (no-console, no-control-regex, …) would all report as
|
||||
// "unused". Don't flag them — they stay valid for future rule additions.
|
||||
reportUnusedDisableDirectives: "off",
|
||||
},
|
||||
rules: {
|
||||
"no-undef": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
10
global.d.ts
vendored
10
global.d.ts
vendored
@@ -323,6 +323,7 @@ declare global {
|
||||
setSessionEncoding?(sessionId: string, encoding: string): Promise<{ ok: boolean; encoding: string }>;
|
||||
writeToSession(sessionId: string, data: string, options?: { automated?: boolean }): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
setSessionFlowPaused(sessionId: string, paused: boolean): void;
|
||||
closeSession(sessionId: string): void;
|
||||
// ZMODEM file transfer
|
||||
onZmodemEvent?(
|
||||
@@ -341,6 +342,15 @@ declare global {
|
||||
}) => void
|
||||
): () => void;
|
||||
cancelZmodem?(sessionId: string): void;
|
||||
onZmodemOverwriteRequest?(
|
||||
sessionId: string,
|
||||
cb: (payload: { sessionId: string; requestId: string; filename: string }) => void
|
||||
): () => void;
|
||||
respondZmodemOverwrite?(payload: {
|
||||
requestId: string;
|
||||
action: "overwrite" | "skip" | "cancel";
|
||||
applyToRest: boolean;
|
||||
}): void;
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package && node scripts/patch-xterm-webgl-atlas.cjs",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
|
||||
74
scripts/patch-xterm-webgl-atlas.cjs
Normal file
74
scripts/patch-xterm-webgl-atlas.cjs
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Disable @xterm/addon-webgl's cross-terminal texture-atlas sharing.
|
||||
*
|
||||
* xterm's WebGL addon shares ONE TextureAtlas across terminal instances whose
|
||||
* config (font / size / theme / device-pixel-ratio) is equal — see
|
||||
* `acquireTextureAtlas`, which does `if (configEquals) { ownedBy.push; return
|
||||
* atlas }`. In a split workspace two panes then share an atlas, so clearing or
|
||||
* rebuilding it for one pane (which netcatty does on resize / DPR change / font
|
||||
* change / tab show to recover from glyph corruption) corrupts the OTHER pane's
|
||||
* rendering — the persistent "花屏 / garbled" report in issue #1063, most
|
||||
* visible in split view where both panes stay on screen.
|
||||
*
|
||||
* Fix: give every terminal its own atlas by removing the "reuse a matching
|
||||
* atlas" loop, so each terminal falls through to creating its own. The published
|
||||
* package is minified, so we string-replace the exact loop in both the CJS and
|
||||
* ESM builds. This runs from `postinstall` (after patch-package).
|
||||
*
|
||||
* Idempotent. If the upstream code changes (e.g. an @xterm/addon-webgl upgrade)
|
||||
* the loop won't be found; we warn loudly but do not fail the install, and the
|
||||
* strings below must then be refreshed for the new version.
|
||||
*/
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MARKER = "/*netcatty:#1063 atlas-isolation*/";
|
||||
|
||||
// Exact (minified) "reuse a shared atlas" loop, per @xterm/addon-webgl@0.19.0.
|
||||
const TARGETS = [
|
||||
{
|
||||
file: "node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs",
|
||||
loop: "for(let h=0;h<le.length;h++){let f=le[h];if(Mi(f.config,u))return f.ownedBy.push(i),f.atlas}",
|
||||
},
|
||||
{
|
||||
file: "node_modules/@xterm/addon-webgl/lib/addon-webgl.js",
|
||||
loop: "for(let t=0;t<r.length;t++){const i=r[t];if((0,n.configEquals)(i.config,d))return i.ownedBy.push(e),i.atlas}",
|
||||
},
|
||||
];
|
||||
|
||||
let patched = 0;
|
||||
let already = 0;
|
||||
let missing = 0;
|
||||
|
||||
for (const { file, loop } of TARGETS) {
|
||||
const abs = path.resolve(process.cwd(), file);
|
||||
let src;
|
||||
try {
|
||||
src = fs.readFileSync(abs, "utf8");
|
||||
} catch {
|
||||
console.warn(`[patch-xterm-webgl-atlas] skip (not found): ${file}`);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
if (src.includes(MARKER)) {
|
||||
already++;
|
||||
continue;
|
||||
}
|
||||
if (!src.includes(loop)) {
|
||||
console.warn(
|
||||
`[patch-xterm-webgl-atlas] WARNING: atlas-sharing loop not found in ${file}. ` +
|
||||
"@xterm/addon-webgl likely changed — split-view WebGL may garble again (#1063). " +
|
||||
"Refresh the minified target strings in scripts/patch-xterm-webgl-atlas.cjs.",
|
||||
);
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
fs.writeFileSync(abs, src.replace(loop, MARKER));
|
||||
patched++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[patch-xterm-webgl-atlas] atlas isolation: patched=${patched} already=${already} missing=${missing}`,
|
||||
);
|
||||
@@ -44,7 +44,6 @@ export default defineConfig(() => {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Vendor chunks - rarely change, can be cached aggressively
|
||||
'vendor-react': ['react', 'react-dom'],
|
||||
'vendor-radix': [
|
||||
'@radix-ui/react-collapsible',
|
||||
'@radix-ui/react-context-menu',
|
||||
|
||||
Reference in New Issue
Block a user