Compare commits

...

15 Commits

Author SHA1 Message Date
陈大猫
453202df8f perf(terminal): add output flow control / back-pressure for heavy streams (#1090)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-05-25 15:19:27 +08:00
陈大猫
a78c052d86 perf(autocomplete): skip completion queries when nothing is shown (#1088)
fetchSuggestions ran the full completion pipeline (history scan, fig specs, remote path lookups) on the main thread even when both the popup and ghost text were disabled — the results were then discarded. Add a shouldQueryCompletions(settings) gate and bail out early (clearing any stale state) when neither display mode is on.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:49 +08:00
陈大猫
e6b0a551e8 perf(terminal): isolate autocomplete re-renders into a child component (#1089)
The autocomplete hook (useState) lived in Terminal, so every suggestion / selection / live-preview update re-rendered the whole ~2775-line Terminal component. Move the hook and its popup into a dedicated <TerminalAutocomplete> component so those frequent state updates re-render only that small subtree.

The hook's handlers are surfaced back to Terminal via refs (the same refs already used to wire the xterm runtime), and the component is mounted unconditionally so the hook keeps recording command history and intercepting completion keys for the session's lifetime. No behavior change intended.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:38 +08:00
陈大猫
38775245d2 perf(terminal): bound the connection log with a chunk ring buffer (#1087)
* perf(terminal): bound the connection log with a chunk ring buffer

The connection log kept the last 1,000,000 chars via `log += chunk; log = log.slice(-MAX)`. Once a session emits more than that, the slice flattens a ~1M-char string on every subsequent output chunk — on the render thread, for each echoed keystroke included — on long/busy sessions.

Replace the string with a small chunk-queue ring buffer that trims only the boundary chunk (amortized O(chunk) append) and materializes the full string once on read. Behavior is unchanged: it still retains exactly the last MAX_CONNECTION_LOG_DATA_CHARS characters.

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

* perf(terminal): coalesce connection log into bounded blocks (O(1) trim)

The first cut used one array entry per append and trimmed with chunks.shift(). For interactive output (many tiny chunks) the array grows toward the cap in entries, so once full, shift() reindexes ~N elements on every append — O(appends) per chunk, no better than the slice it replaced.

Coalesce appends into a small, bounded set of fixed-size blocks (~maxChars/blockSize). New data fills an open tail that seals into a block at blockSize; trimming only drops/slices a handful of blocks. Adds segmentCount() and a test asserting the segment count stays bounded across many tiny appends.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:02 +08:00
陈大猫
fcb699ffb9 chore(eslint): lint electron/bridges for undefined references (#1086) 2026-05-25 13:53:53 +08:00
陈大猫
e889d8fc20 perf(terminal): flush shell output on the event-loop turn instead of a fixed 8ms timer (#1085)
* perf(terminal): flush shell output on the event-loop turn, not a fixed 8ms timer

SSH/PTY output was coalesced and shipped to the renderer on a fixed 8ms timer. For interactive use that interval is pure added latency: every echoed keystroke waits out the timer before it can paint, so typing feels slightly behind.

Replace the timer with turn-based (setImmediate) coalescing in a single shared ptyOutputBuffer module, used by the SSH, local, telnet, and mosh paths. A single echoed keystroke is now forwarded almost immediately, while data arriving in the same turn still collapses into one IPC send, and a 16KB size cap still forces an immediate flush under heavy output.

Also de-duplicates two copies of the buffering logic (SSH had an inline copy; local/telnet/mosh shared another) and adds unit tests for the buffer.

Related to #1084.

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

* fix(terminal): drop orphaned flushTimeout reference in SSH close handler

The SSH stream "close" handler still cleared `flushTimeout`, a variable that lived in the inline buffer removed when this path moved to the shared ptyOutputBuffer. Reading it now throws ReferenceError on every channel close, aborting the cleanup and exit signaling. The shared buffer's flush() cancels any pending flush internally, so the timer bookkeeping is removed.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:42:49 +08:00
陈大猫
bf1c95500a feat #826: optional Option+←/→ word jump on macOS (#1082)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat #826: optional Option+←/→ word jump on macOS

Adds a Terminal → Keyboard toggle "Option+←/→ jumps by word" (off by default,
synced). When on, a bare Option+Left/Right sends Meta-b / Meta-f instead of
xterm's default ^[[1;3D / ^[[1;3C, so readline/zle moves by word without
per-host bindkey setup (Termius-style).

The key→sequence mapping is a tested pure function; the handler reads the
setting live (no reconnect) and runs after kitty mode + autocomplete so it
doesn't override them.

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

* fix #826: gate Option+←/→ word jump to macOS

The setting is syncable, so without a platform gate, enabling it on a Mac
would also rewrite Alt+←/→ to Meta-b/f on synced Linux/Windows devices,
breaking apps/shells that expect the default ^[[1;3D / ^[[1;3C. Pass
isMacPlatform() into the mapping so it only applies on macOS; add a test
for the non-macOS case.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:10:40 +08:00
陈大猫
f9d00c9d23 fix #1079: preserve remote file mode when rz overwrites a same-named file (#1081)
* fix #1079: preserve remote file mode when rz overwrites a same-named file

#1070's overwrite path rm's the remote file and lets rz re-create it, which
writes with the remote umask and drops the original permission bits — e.g. a
0755 script became 0644 after choosing "replace". (It didn't happen before
because rz used to skip same-named files, leaving the original untouched.)

Capture each conflicting file's mode during the pre-upload probe
(stat -c %a, BSD stat -f %Lp fallback) and chmod it back once the transfer
finishes and the files are on disk. Restore is best-effort: any failure
silently falls back to today's behavior.

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

* fix #1079: probe file mode with `stat -- "$n"` for dash-prefixed names

Without `--`, `stat -c %a "-x.sh"` (and the BSD `-f %Lp` fallback) parse a
leading-dash filename as options, so the mode was never captured and overwrite
fell back to rz defaults — losing permission preservation for a valid filename
class. Mirrors the existing `rm -f --` handling. (chmod left as-is: its path is
always absolute, and BSD chmod doesn't accept `--`.)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:44:41 +08:00
陈大猫
8fd7ff6475 fix #1078: send macOS Option as Meta (wire altAsMeta to xterm macOptionIsMeta) (#1080)
"Use Option as Meta key" was read into `altIsMeta` but only applied to the
mouse alt-click options (`altClickMovesCursor`). xterm.js's `macOptionIsMeta`
— the option that actually makes Option emit ESC-prefixed (Meta) sequences —
was never set, so on macOS Option kept producing layout characters (ƒ, ∫, …)
and readline/zle word shortcuts (Alt+f, Alt+b, Alt+Backspace) were dead.

Extract the altAsMeta→xterm mapping into one tested helper used by both the
terminal init path (createXTermRuntime) and the live settings sync
(Terminal.tsx) so the two can't drift again.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:30:35 +08:00
陈大猫
02c80ae7d2 chore: silence two production build warnings (#1072)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
- Drop the manualChunks 'vendor-react' entry: react/react-dom already land
  in another chunk, so it only ever produced an empty chunk + a build
  warning, with no caching benefit.
- Import domain/syncMerge statically in useAutoSync. It's already in the
  eager graph via CloudSyncManager's static import, so the dynamic
  `import()` couldn't be code-split anyway and only emitted a mixed
  static/dynamic-import warning.

No behavior change; production build is warning-free.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:50:33 +08:00
陈大猫
e5d3d02b17 fix #1063: give each terminal its own WebGL texture atlas (disable cross-terminal sharing) (#1071)
Root cause of the persistent split-view 花屏: xterm's WebGL addon shares
ONE TextureAtlas across terminal instances with equal config (font / size
/ theme / DPR) — acquireTextureAtlas does `if (configEquals) { ownedBy.push;
return atlas }`. Two split panes then share an atlas, so the
clearTextureAtlas calls netcatty makes to recover from glyph corruption
(on resize / DPR / font change / tab show, from #1049 and #1066) clobber
the *other* pane's rendering. That's why the earlier redraw/clear-based
recovery attempts didn't help and only bounced the garble between panes.

Disable the sharing: remove the "reuse a matching atlas" loop so every
terminal creates its own atlas. The published bundle is minified, so this
is done with a small idempotent postinstall script (a patch-package patch
would be a ~550KB unreadable blob of the whole minified line). It
string-replaces the exact loop in the CJS + ESM builds, runs after
patch-package, and warns without failing if @xterm/addon-webgl changes.

Verified: split-view WebGL no longer garbles; script is idempotent
(patched=2 → already=2) and the production build is unaffected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:45:05 +08:00
陈大猫
78186d8d46 feat #1064: prompt to overwrite when rz upload hits a remote filename conflict (#1070)
* feat #1064: add buildUploadPlan for rz overwrite/skip/cancel resolution

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

* feat #1064: handle remote filename conflicts in rz handleUpload

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

* feat #1064: SSH exec probe + remove for rz upload conflicts

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

* feat #1064: IPC for rz overwrite-conflict prompt

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

* feat #1064: renderer prompt for rz overwrite conflicts

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

* fix #1064: repair sshBridge test mock (ipcMain.on) and i18n the overwrite dialog

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

* fix #1064: make upload plan index-based to preserve per-file decisions

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:20:20 +08:00
陈大猫
c899653621 fix #1065: resolve terminal cwd through su/sudo for the SFTP locate (#1068)
* fix #1065: resolve terminal cwd through su/sudo for the SFTP locate

The SFTP "locate to terminal's current directory" feature kept showing the
login user's home (e.g. /root) after the user switched accounts with su /
sudo -s and cd'd elsewhere.

getSessionPwd walks the remote process tree from a sibling exec channel to
find the interactive shell's cwd, but it only followed children whose comm
is a shell name (bash/zsh/...). su and sudo are named "su"/"sudo", so the
walk stopped at the login shell and read its cwd. The actual shell the user
is typing in lives *under* su/sudo as the controlling tty's foreground
process group.

Rewrite the walk to pick the deepest foreground shell ("+" in stat) within
the login shell's whole process subtree, which transparently follows
through su/sudo to the active shell, falling back to the login shell when
no foreground shell is found.

Verified on a real server (root -> su user -> cd /tmp):
  before: /root   after: /tmp
and confirmed the no-su case is unchanged (cd /var -> /var).

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

* Fall back to login shell cwd when the active shell's /proc is unreadable (Codex review)

When an unprivileged user runs `sudo -s` / `su root`, find_active_shell
correctly selects the root-owned foreground shell, but the exec channel
(running as the login user) cannot readlink another uid's /proc/<pid>/cwd
due to ptrace permissions. Without a fallback the script dropped straight
to the home directory, regressing user→root sessions.

Retry readlink on the same-uid login shell before falling back to home.

Verified live (user -> cd /var -> sudo -s -> cd /tmp): the root shell's
cwd is unreadable, and the result is now /var (login shell cwd) instead of
/home/<user>.

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

* Select the interactive (tty-bearing) login shell deterministically (Codex review)

find_login_shell picked the first shell child of sshd and exited, but ps
output is unsorted, so when other exec channels (server-stats polls, etc.)
are running on the same connection their transient sh could be chosen,
making find_active_shell walk the wrong subtree.

Prefer the shell child that has a controlling tty: the interactive shell
has a pts, while non-PTY probe exec channels have tty "?". This is
deterministic regardless of ps order, in both the su and no-su cases (the
old "prefer foreground" heuristic was itself nondeterministic under su).
Falls back to any shell child if none has a tty.

Verified live with a concurrent no-tty `sh -c sleep` under the same sshd:
the pts/0 bash is selected and the result is /tmp, not the probe shell.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:26:20 +08:00
陈大猫
a91fbcdd68 fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit (#1067)
* fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit

A shell-level TMOUT idle auto-logout makes bash/csh exit cleanly (numeric
exit code, no signal), which is byte-for-byte indistinguishable from a
user-typed `exit` at the SSH protocol level. PR #1057 keyed the
close-vs-keep decision on `streamExited` (numeric code + no signal), so
TMOUT exits were reported as reason "exited" and the tab was auto-closed —
reintroducing the problem from #977.

Verified against a real server that bash TMOUT exits with code 0 / no
signal and prints "timed out waiting for input: auto-logout" to the
channel before it closes. Since exit code/signal can't distinguish it from
an intentional exit, detect that banner in the session's existing rolling
output tail (_promptTrackTail) and report reason "timeout" instead, which
routes to the existing markDisconnected path (keep tab + reconnect). A
normal `exit`/`logout` (no "auto-" prefix) still auto-closes the tab, so
PR #1057's behavior is preserved.

zsh's TMOUT raises SIGALRM (a signal), so it already took the
keep-tab/reconnect path and is unaffected.

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

* Anchor TMOUT auto-logout match to the banner's final line (Codex review)

The detector matched "auto-logout" as an unanchored substring within the
last 256 chars, so command output that merely mentions it (e.g. `grep
auto-logout /etc/profile` while investigating TMOUT) followed by an
intentional `exit` could be misclassified as a timeout and wrongly keep
the tab open. Anchor on the final non-empty line of output instead — the
banner the shell prints right before exiting — which loses no true
positives (verified against the real-server output shape) while rejecting
mid-stream mentions.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:53:03 +08:00
陈大猫
74b315e285 fix #1063: force WebGL redraw on tab show to recover from garbled multi-tab terminals (#1066)
Hidden tabs stay mounted off-screen (visibility:hidden) so each keeps a
live WebGL context. Creating another terminal's WebGL context — or the GPU
dropping a non-composited off-screen canvas — leaves the hidden terminals'
drawing buffers corrupted ("花屏"). This reproduces on both Windows and
macOS: opening 2 tabs garbles the 1st, opening 3 garbles the 1st and 2nd,
while the just-created (visible) one is always fine. The DOM renderer is
immune because it uses real DOM nodes.

A window resize recovers the display because it triggers a full repaint
(clearTextureAtlas + RenderService._renderRows). A tab switch did not:
the visibility effect only calls safeFit, which early-returns when the
pane's dimensions are unchanged, so no redraw happened.

Perform the same recovery a resize does when a tab becomes visible:
clear the texture atlas (no-op on the DOM renderer) and synchronously
repaint every row. Verified against xterm core that _renderRows draws
unconditionally, independent of dimension changes or dirty-row tracking.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:56:30 +08:00
39 changed files with 1651 additions and 180 deletions

View File

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

View File

@@ -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': 'Сбросить по умолчанию',
};

View File

@@ -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': '重置为默认',
};

View File

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

View File

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

View File

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

View File

@@ -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) */}

View File

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

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

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

View File

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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