Compare commits

...
24 Commits
Author SHA1 Message Date
陈大猫 453202df8f perf(terminal): add output flow control / back-pressure for heavy streams (#1090) 2026-05-25 15:19:27 +08:00
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 bf1c95500a feat #826: optional Option+←/→ word jump on macOS (#1082)
* 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
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 02c80ae7d2 chore: silence two production build warnings (#1072)
- 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
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 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
陈大猫andClaude Opus 4.7 60eeafe7a9 feat #1005: Termius-style live-preview popup autocomplete (free the Tab key) (#1059)
* feat #1005: add live-preview keystroke calculator for popup autocomplete

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

No files matched your search

+8
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',
};
+8
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': 'Сбросить по умолчанию',
};
+7
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': '重置为默认',
};
@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
test("runtime remote checks wait for the startup check to finish", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: false,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
false,
);
});
test("runtime remote checks run immediately after startup gate opens", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
true,
);
});
test("runtime remote checks respect the minimum interval", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 40_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
});
test("forced runtime remote checks bypass only the interval gate", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
force: true,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
isSyncing: true,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
});
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
});
@@ -0,0 +1,35 @@
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
return Math.max(
MIN_RUNTIME_REMOTE_CHECK_MS,
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
);
}
export interface RuntimeRemoteCheckInput {
hasAnyConnectedProvider: boolean;
autoSyncEnabled: boolean;
isUnlocked: boolean;
startupRemoteCheckDone: boolean;
isSyncing: boolean;
isSyncRunning: boolean;
remoteCheckInFlight: boolean;
force?: boolean;
now: number;
lastRemoteCheckAt: number | null;
minIntervalMs: number;
}
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
if (!input.hasAnyConnectedProvider) return false;
if (!input.autoSyncEnabled) return false;
if (!input.isUnlocked) return false;
if (!input.startupRemoteCheckDone) return false;
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
if (input.force === true) return true;
if (input.lastRemoteCheckAt == null) return true;
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
}
@@ -3,10 +3,10 @@ import assert from "node:assert/strict";
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
test("backend exited events keep the tab and mark it disconnected", () => {
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "markDisconnected" },
{ kind: "closeSession" },
);
});
@@ -16,3 +16,17 @@ test("backend timeout events keep the tab and mark it disconnected", () => {
{ kind: "markDisconnected" },
);
});
test("backend error events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
{ kind: "markDisconnected" },
);
});
test("backend closed events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
{ kind: "markDisconnected" },
);
});
@@ -6,12 +6,17 @@ export type TerminalSessionExitEvent = {
};
export type TerminalSessionExitIntent =
| { kind: "closeSession" }
| { kind: "markDisconnected" };
export function resolveTerminalSessionExitIntent(
_evt: TerminalSessionExitEvent,
evt: TerminalSessionExitEvent,
): TerminalSessionExitIntent {
// Backend exits can be remote idle timeouts, shell termination, or transport closes.
// Explicit user closes bypass this policy and call the close-session path directly.
if (evt.reason === "exited") {
return { kind: "closeSession" };
}
// Timeouts, transport errors, and channel closes should keep the tab visible
// so the user can inspect output and reconnect.
return { kind: "markDisconnected" };
}
+99 -11
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,
@@ -31,6 +32,10 @@ import {
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
import { notify } from '../notification';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
interface AutoSyncConfig {
// Data to sync
@@ -95,6 +100,11 @@ interface SyncNowOptions {
trigger?: SyncTrigger;
}
interface RemoteVersionCheckOptions {
force?: boolean;
notifyOnFailure?: boolean;
}
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const sync = useCloudSync();
@@ -402,17 +412,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// windows but does NOT serialize same-window re-entry, so this
// in-flight guard closes that gap at the top of the call.
const checkRemoteInFlightRef = useRef(false);
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
if (checkRemoteInFlightRef.current) {
return;
}
const force = options?.force === true;
const notifyOnFailure = options?.notifyOnFailure !== false;
const state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
return;
}
@@ -494,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
@@ -548,14 +560,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
if (notifyOnFailure) {
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
}
// Leave hasCheckedRemoteRef=false so the next startup (or the next
// provider/unlock transition) can retry.
} finally {
@@ -726,12 +740,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
const now = Date.now();
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
if (!shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
autoSyncEnabled: sync.autoSyncEnabled,
isUnlocked: sync.isUnlocked,
startupRemoteCheckDone: remoteCheckDoneRef.current,
isSyncing: sync.isSyncing,
isSyncRunning: isSyncRunningRef.current,
remoteCheckInFlight: checkRemoteInFlightRef.current,
force: options?.force === true,
now,
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
minIntervalMs,
})) {
return;
}
lastRuntimeRemoteCheckAtRef.current = now;
await checkRemoteVersion({ force: true, notifyOnFailure: false });
}, [
checkRemoteVersion,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isSyncing,
sync.isUnlocked,
]);
// Keep checking the cloud while the app is open. This closes the gap where
// another device uploads changes after our startup inspection but before
// this device edits anything locally.
useEffect(() => {
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
const timerId = window.setInterval(() => {
void runRuntimeRemoteCheck();
}, intervalMs);
return () => window.clearInterval(timerId);
}, [
runRuntimeRemoteCheck,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isUnlocked,
]);
// Also re-check when the user returns to the app or the network comes back.
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void runRuntimeRemoteCheck({ force: true });
}
};
const handleOnline = () => {
void runRuntimeRemoteCheck({ force: true });
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('online', handleOnline);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('online', handleOnline);
};
}, [runRuntimeRemoteCheck]);
// Reset check flags when provider disconnects
useEffect(() => {
if (!sync.hasAnyConnectedProvider) {
hasCheckedRemoteRef.current = false;
remoteCheckDoneRef.current = false;
lastRuntimeRemoteCheckAtRef.current = null;
}
}, [sync.hasAnyConnectedProvider]);
+7
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,
+1 -1
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',
+110 -88
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";
@@ -29,7 +28,7 @@ import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { classifyDistroId } from "../domain/host";
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
@@ -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({
@@ -585,6 +572,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return clearTerminalCwd;
}, [clearTerminalCwd, host.id]);
// Classify the host's device family from the *detected* distro and the
// explicit deviceType only. This intentionally bypasses
// getEffectiveHostDistro(): the manual distro override (`distroMode:
// 'manual'` + `manualDistro`) is a purely cosmetic icon choice, and a
// user who pinned e.g. an "ubuntu" icon on what is actually a Cisco /
// Huawei host must not silently re-enable POSIX-shell probes against it.
// Several features gate on this — the working-directory probe below, the
// /etc/os-release probe, and the periodic server-stats poll (#674) —
// because each opens an extra exec channel that strict network-device
// CLIs reject or log as a new AAA session, and on Huawei VRP closes the
// whole session (#1043).
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
useEffect(() => {
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
return;
@@ -593,9 +595,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
let cancelled = false;
const timer = setTimeout(async () => {
if (!sessionRef.current) return;
const id = sessionRef.current;
if (!id) return;
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
// The pwd probe opens an extra POSIX-shell exec channel, which strict
// network-device CLIs like Huawei VRP answer by closing the whole
// session (#1043). Skip it for known network devices; for a brand-new
// host (distro not classified yet on the first connect) consult the
// SSH banner, which is captured for free at handshake time.
const info = await terminalBackend.getSessionRemoteInfo?.(id);
if (cancelled || id !== sessionRef.current) return;
if (!shouldProbeSessionCwd({ isNetworkDevice, remoteSshVersion: info?.remoteSshVersion })) {
return;
}
const result = await terminalBackend.getSessionPwd(id);
if (!cancelled && !terminalCwdTracker.getRendererCwd() && result.success && result.cwd) {
knownCwdRef.current = result.cwd;
}
@@ -608,37 +621,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
cancelled = true;
clearTimeout(timer);
};
}, [host.protocol, status, terminalBackend, terminalCwdTracker]);
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
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";
const isSerialConnection = host.protocol === "serial";
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, and never
// for hosts classified as network devices (either via explicit
// deviceType='network' or via SSH banner detection that populated
// host.distro with a network-vendor ID). See #674: polling the stats
// command on Cisco / Huawei / Juniper etc. generates one AAA session
// log entry per poll because each exec channel is counted as a new
// session on those devices.
//
// IMPORTANT: this gating must NOT go through getEffectiveHostDistro()
// because that honors the manual distro override (`distroMode: 'manual'`
// + `manualDistro`) which is purely a cosmetic icon choice. A user who
// pinned an "ubuntu" icon on what is actually a Cisco host would
// otherwise silently re-enable the polling loop and re-introduce the
// AAA log flood this patch is meant to eliminate. The display icon can
// still be overridden (see DistroAvatar) — gating uses the raw detected
// `host.distro` and the explicit `host.deviceType` only.
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
// Server stats (CPU, Memory, Disk) — only for Linux/macOS, never for
// network devices. See isNetworkDevice above for why the gating uses the
// raw detected distro / explicit deviceType (not getEffectiveHostDistro);
// #674 covers the AAA-log-flood motivation for stats specifically.
const isSupportedOs =
!isNetworkDevice &&
(host.os === 'linux' || host.os === 'macos' || detectedDeviceClass === 'linux-like');
@@ -927,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;
@@ -1236,11 +1234,18 @@ 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;
}
// Changing the font can leave the WebGL renderer drawing stale glyphs from
// the old metrics (xterm.js #3280), surfacing as garbled text (issue #1049).
// Clear the texture atlas so glyphs re-rasterize with the new font.
xtermRuntimeRef.current?.clearTextureAtlas();
if (isVisibleRef.current) {
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
} else {
@@ -1253,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") {
@@ -2352,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 && (
@@ -2465,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) */}
+4 -2
View File
@@ -978,10 +978,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(evt);
if (intent.kind === "markDisconnected") {
if (intent.kind === "closeSession") {
onCloseSession(sessionId);
} else {
onUpdateSessionStatus(sessionId, 'disconnected');
}
}, [onUpdateSessionStatus]);
}, [onCloseSession, onUpdateSessionStatus]);
const handleOsDetected = useCallback((hostId: string, distro: string) => {
onUpdateHostDistro(hostId, distro);
+9
View File
@@ -12,6 +12,7 @@ import { cn } from '../lib/utils';
import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../lib/tabInteractions';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
@@ -355,6 +356,8 @@ const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
data-tab-type="editor"
data-state={isActive ? 'active' : 'inactive'}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
style={{
backgroundColor: isActive
@@ -458,6 +461,8 @@ const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
data-tab-type="session"
data-state={isActive ? 'active' : 'inactive'}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
draggable
onDragStart={(e) => onTabDragStart(e, session.id)}
onDragEnd={onTabDragEnd}
@@ -586,6 +591,8 @@ const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
data-tab-type="workspace"
data-state={isActive ? 'active' : 'inactive'}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
draggable
onDragStart={(e) => onTabDragStart(e, workspace.id)}
onDragEnd={onTabDragEnd}
@@ -694,6 +701,8 @@ const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
data-tab-type="logView"
data-state={isActive ? 'active' : 'inactive'}
onClick={handleClick}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
className="netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0"
style={{
backgroundColor: isActive
@@ -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")} />
+3
View File
@@ -20,6 +20,7 @@ import React, {
} from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from "../../lib/tabInteractions";
import { useRenderTracker } from "../../lib/useRenderTracker";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { cn } from "../../lib/utils";
@@ -322,6 +323,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
data-tab-type="sftp"
data-state={isActive ? 'active' : 'inactive'}
onClick={(e) => handleSelectTabClick(e, tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
@@ -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,
);
}
@@ -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>
);
};
@@ -91,6 +91,32 @@ const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ vis
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}>›</span>
);
/** Small key-cap badge shown on the selected row to hint the actionable key. */
const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxSizing: "border-box",
height: "16px",
minWidth: "16px",
padding: "0 4px",
fontSize: "11px",
lineHeight: 1,
borderRadius: "4px",
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
flexShrink: 0,
fontFamily:
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
}}
>
{label}
</span>
);
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
suggestions,
selectedIndex,
@@ -361,6 +387,16 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
{suggestion.source === "path" && suggestion.fileType === "directory" && (
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
)}
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
{isSelected && (
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
{suggestion.source === "path" && suggestion.fileType === "directory" && (
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
)}
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
</span>
)}
</div>
);
})}
@@ -0,0 +1,24 @@
/**
* Compute the keystrokes to send so the terminal input line becomes exactly
* `candidate`, given what is currently on the line. Drives the popup
* autocomplete live-preview (#1005): moving the selection renders the chosen
* suggestion into the command line, and switching / reverting rewrites it.
*
* - Forward prefix (candidate continues the line): append only the new tail.
* - Otherwise: clear the current input, then write the full candidate. POSIX
* shells use Ctrl-U (kill-line); Windows (cmd/PowerShell) uses backspaces
* sized to the current line length.
*/
export function computeLivePreviewWrite(input: {
currentLine: string;
candidate: string;
os: string;
}): string {
const { currentLine, candidate, os } = input;
if (candidate === currentLine) return "";
if (candidate.startsWith(currentLine)) {
return candidate.slice(currentLine.length);
}
const clear = os === "windows" ? "\b".repeat(currentLine.length) : "\x15";
return clear + candidate;
}
@@ -24,6 +24,7 @@ import { preloadCommonSpecs } from "./figSpecLoader";
import { getXTermCellDimensions } from "./xtermUtils";
import { listDirectoryEntries, normalizePathTokenForLookup } from "./remotePathCompleter";
import { decideGhostSuggestion } from "./ghostSuggestionPolicy";
import { computeLivePreviewWrite } from "./livePreviewSequence";
export interface AutocompleteSettings {
enabled: boolean;
@@ -46,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: [],
@@ -253,6 +266,10 @@ export function useTerminalAutocomplete(
const fetchVersionRef = useRef(0);
/** Last accepted suggestion text — for accurate history recording on fast Enter after accept */
const lastAcceptedCommandRef = useRef<string | null>(null);
/** The user's typed input that produced the current popup suggestions (live-preview baseline). */
const previewBaselineRef = useRef<string>("");
/** Whether a popup candidate is currently rendered into the command line (#1005). */
const previewActiveRef = useRef(false);
/** Monotonic counter to invalidate stale async sub-dir fetches */
const subDirFetchVersionRef = useRef(0);
/**
@@ -536,6 +553,41 @@ export function useTerminalAutocomplete(
});
}, [termRef]);
/**
* Render the full path for a sub-dir entry into the line WITHOUT finalizing
* (no clearState). Used for live-preview while navigating sub-dir panels (#1005).
*/
const renderSubDirPath = useCallback((level: number, entry: SubDirEntry) => {
const s = stateRef.current;
const term = termRef.current;
if (!term) return;
const panel = s.subDirPanels[level];
if (!panel) return;
const { prompt } = getAlignedPrompt(
term, typedInputBufferRef.current, typedBufferReliableRef.current,
);
if (!prompt.isAtPrompt) return;
const parsed = parseCommandLine(prompt.userInput);
const cmdPrefix = parsed.tokens.slice(0, parsed.wordIndex).join(" ")
+ (parsed.wordIndex > 0 ? " " : "");
const currentToken = parsed.currentWord;
const quotePrefix = currentToken.startsWith('"') || currentToken.startsWith("'")
? currentToken[0] : "";
const quoteSuffix = quotePrefix && currentToken.endsWith(quotePrefix) ? quotePrefix : "";
const suffix = entry.type === "directory" ? "/" : "";
const entryName = quotePrefix || !/[\\$'"|!<>;#~` ]/.test(entry.name)
? entry.name : shellEscape(entry.name);
const newCommand = cmdPrefix + `${quotePrefix}${panel.dirPath}${entryName}${suffix}${quoteSuffix}`;
const seq = computeLivePreviewWrite({
currentLine: prompt.userInput, candidate: newCommand, os: hostOsRef.current,
});
if (seq) writeToTerminal(seq);
typedInputBufferRef.current = newCommand;
typedBufferReliableRef.current = true;
previewActiveRef.current = true;
lastAcceptedCommandRef.current = newCommand;
}, [termRef, writeToTerminal]);
/** Handle selecting a file/directory from any sub-dir panel.
* Builds the full path from the panel stack and replaces the current input. */
const handleSubDirSelect = useCallback((level: number, entry: SubDirEntry) => {
@@ -600,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;
@@ -666,6 +727,9 @@ export function useTerminalAutocomplete(
// Popup
if (settingsRef.current.showPopupMenu && completions.length > 0) {
// Live-preview baseline: the typed input these suggestions completed.
previewBaselineRef.current = input;
previewActiveRef.current = false;
const { position, cursorLineTop, cursorLineBottom, expandUpward } = calculatePopupPosition(term, completions.length);
startTransition(() => {
setState((prev) => {
@@ -876,6 +940,10 @@ export function useTerminalAutocomplete(
// User is typing more — invalidate accepted command fallback since the
// command is being edited further (e.g., accepted "git status" then added " --short")
lastAcceptedCommandRef.current = null;
// The previewed candidate is now edited, so the line is the user's own
// text. Drop preview-active so Escape dismisses the popup without
// reverting these edits back to the stale baseline (#1005).
previewActiveRef.current = false;
// Re-align any visible ghost text to the freshly-updated buffer
// immediately. Without this the ghost keeps the tail it captured at
@@ -1055,10 +1123,11 @@ export function useTerminalAutocomplete(
// which is otherwise shadowed by our single-Tab ghost accept.
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.suggestions.length > 0) {
e.preventDefault();
const selected = s.suggestions[Math.max(0, s.selectedIndex)];
if (selected) insertSuggestion(selected, false);
return false;
// #1005: don't intercept Tab. Keep whatever is currently rendered on
// the line and let Tab reach the shell for native completion.
clearState();
previewActiveRef.current = false;
return true;
}
// Hide stale ghost text before Tab reaches the shell — the shell's
// completion will rewrite the line and the old ghost would mislead.
@@ -1087,8 +1156,10 @@ export function useTerminalAutocomplete(
panels[focusLevel] = { ...p, selectedIndex: newIdx };
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
});
// Auto-expand next level if the newly selected item is a directory
// Live-render the highlighted entry's full path into the line (#1005).
const newEntry = focusedPanel.entries[newIdx];
if (newEntry) renderSubDirPath(focusLevel, newEntry);
// Auto-expand next level if the newly selected item is a directory
if (newEntry?.type === "directory") {
expandSubDir(focusLevel, newEntry);
}
@@ -1144,39 +1215,37 @@ export function useTerminalAutocomplete(
return true;
}
// Main panel navigation
if (e.key === "ArrowUp") {
// Main panel navigation. The cycle includes a -1 "no selection" slot so
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
// the selection live-renders the candidate into the command line (#1005).
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
const n = s.suggestions.length;
const cur = s.selectedIndex;
const next =
e.key === "ArrowDown"
? (cur >= n - 1 ? -1 : cur + 1)
: (cur <= -1 ? n - 1 : cur - 1);
setState((prev) => ({
...prev,
selectedIndex: prev.selectedIndex <= 0 ? prev.suggestions.length - 1 : prev.selectedIndex - 1,
selectedIndex: next,
subDirPanels: [], subDirFocusLevel: -1,
}));
fetchSubDirForIndex(s.selectedIndex <= 0 ? s.suggestions.length - 1 : s.selectedIndex - 1);
return false;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setState((prev) => ({
...prev,
selectedIndex: prev.selectedIndex >= prev.suggestions.length - 1 ? 0 : prev.selectedIndex + 1,
subDirPanels: [], subDirFocusLevel: -1,
}));
fetchSubDirForIndex(s.selectedIndex >= s.suggestions.length - 1 ? 0 : s.selectedIndex + 1);
renderPreviewSelection(next);
if (next >= 0) fetchSubDirForIndex(next);
return false;
}
// Enter on popup
// Enter on popup. The selected candidate is already rendered into the
// line by live-preview, so let Enter reach the shell. Don't record here:
// handleInput's Enter path records the *actual* line — it uses
// lastAcceptedCommandRef (set on select) but falls back to the live
// buffer when the user edited the previewed command (typing nulls that
// ref), so recording stays accurate in both cases.
if (e.key === "Enter") {
if (s.selectedIndex >= 0) {
const selected = s.suggestions[s.selectedIndex];
if (selected) {
e.preventDefault();
insertSuggestion(selected, true);
return false;
}
}
clearState();
previewActiveRef.current = false;
return true;
}
}
@@ -1185,8 +1254,12 @@ export function useTerminalAutocomplete(
// when only ghost text is showing (ghost text is passive/non-intrusive)
if (e.key === "Escape" && s.popupVisible) {
e.preventDefault();
if (previewActiveRef.current) {
renderPreviewSelection(-1); // restore the typed baseline
}
ghost?.hide();
clearState();
previewActiveRef.current = false;
return false;
}
@@ -1196,6 +1269,36 @@ export function useTerminalAutocomplete(
[writeToTerminal],
);
/**
* Render the suggestion at `index` straight into the command line (Termius
* live-preview, #1005). `index < 0` restores the user's typed baseline.
*/
const renderPreviewSelection = useCallback((index: number) => {
const s = stateRef.current;
const term = termRef.current;
if (!term) return;
const baseline = previewBaselineRef.current;
const candidate =
index >= 0 && s.suggestions[index] ? s.suggestions[index].text : baseline;
const { prompt } = getAlignedPrompt(
term,
typedInputBufferRef.current,
typedBufferReliableRef.current,
);
if (!prompt.isAtPrompt) return;
const seq = computeLivePreviewWrite({
currentLine: prompt.userInput,
candidate,
os: hostOsRef.current,
});
if (seq) writeToTerminal(seq);
typedInputBufferRef.current = candidate;
typedBufferReliableRef.current = true;
const isPreview = index >= 0 && candidate !== baseline;
previewActiveRef.current = isPreview;
lastAcceptedCommandRef.current = isPreview ? candidate : null;
}, [termRef, writeToTerminal]);
/**
* Insert a suggestion into the terminal.
* @param execute If true, also sends \r to execute the command.
@@ -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,
);
});
@@ -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"
});
@@ -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);
},
};
}
+15 -1
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 };
}
@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { computeLivePreviewWrite } from "./autocomplete/livePreviewSequence.ts";
test("appends only the tail when the candidate continues the current line", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "do", candidate: "docker", os: "linux" }),
"cker",
);
});
test("returns empty when the line already equals the candidate", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "docker", candidate: "docker", os: "linux" }),
"",
);
});
test("clears with Ctrl-U then writes the full candidate on a non-prefix change", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "docker", candidate: "df", os: "linux" }),
"\x15df",
);
});
test("clears when switching to a shorter prefix candidate", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "docker-compose", candidate: "docker", os: "linux" }),
"\x15docker",
);
});
test("reverting to the typed baseline clears then rewrites the baseline", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "docker", candidate: "do", os: "linux" }),
"\x15do",
);
});
test("Windows uses backspaces sized to the current line, not Ctrl-U", () => {
assert.equal(
computeLivePreviewWrite({ currentLine: "abc", candidate: "xy", os: "windows" }),
"\b\b\bxy",
);
});
@@ -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,
});
});
@@ -0,0 +1,20 @@
export interface TerminalAltKeyOptions {
/** xterm.js: treat macOS Option as the Meta key (emit ESC-prefixed sequences). */
macOptionIsMeta: boolean;
/** xterm.js: Option+click moves the cursor. Must be off when Option is Meta. */
altClickMovesCursor: boolean;
}
/**
* Map the user's "Use Option as Meta key" setting to xterm.js options.
*
* Kept in one place so terminal init (createXTermRuntime) and the live settings
* sync (Terminal.tsx) can't drift — that drift is what left `macOptionIsMeta`
* unset everywhere and broke Option/Meta shortcuts on macOS (issue #1078).
*/
export function terminalAltKeyOptions(altAsMeta: boolean): TerminalAltKeyOptions {
return {
macOptionIsMeta: altAsMeta,
altClickMovesCursor: !altAsMeta,
};
}
@@ -27,6 +27,7 @@ import {
syncPromptLineBreakState,
type PromptLineBreakState,
} from "./promptLineBreak";
import { createOutputFlowController, type OutputFlowController } from "./outputFlowController";
/**
* Per-connection token for stale-timer detection. The renderer reuses the
@@ -97,6 +98,8 @@ type TerminalBackendApi = {
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
/** Pause/resume the source stream for output back-pressure (optional). */
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
};
export type PendingAuth = {
@@ -251,6 +254,38 @@ const enqueueTerminalWrite = (
}
};
// Output back-pressure. Without this the renderer can't slow a flooding source,
// so a busy stream grows the write queue and xterm's buffer unbounded. The
// controller tracks bytes received-but-not-yet-rendered and asks the main
// process to pause/resume the session's source stream at these watermarks.
const FLOW_HIGH_WATER_MARK = 256 * 1024; // pause the source above ~256KB backlog
const FLOW_LOW_WATER_MARK = 64 * 1024; // resume once drained to ~64KB
const terminalFlowControllers = new WeakMap<XTerm, OutputFlowController>();
const getFlowController = (
ctx: TerminalSessionStartersContext,
term: XTerm,
): OutputFlowController => {
let controller = terminalFlowControllers.get(term);
if (!controller) {
controller = createOutputFlowController({
highWaterMark: FLOW_HIGH_WATER_MARK,
lowWaterMark: FLOW_LOW_WATER_MARK,
onPause: () => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, true);
},
onResume: () => {
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.setSessionFlowPaused?.(id, false);
},
});
terminalFlowControllers.set(term, controller);
}
return controller;
};
const writeTerminalLine = (
ctx: TerminalSessionStartersContext,
term: XTerm,
@@ -268,6 +303,8 @@ const writeSessionData = (
term: XTerm,
data: string,
) => {
const flow = getFlowController(ctx, term);
flow.received(data.length);
enqueueTerminalWrite(term, (done) => {
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
const forcePromptNewLine = settings?.forcePromptNewLine ?? false;
@@ -301,6 +338,8 @@ const writeSessionData = (
handleTerminalOutputAutoScroll(ctx, term);
}
done();
// Acknowledge the chunk so back-pressure can ease once xterm catches up.
flow.written(data.length);
};
term.write(displayData, afterWrite);
@@ -320,6 +359,8 @@ const attachSessionToTerminal = (
},
) => {
ctx.sessionRef.current = id;
// Clear any stale back-pressure accounting from a prior session on this term.
getFlowController(ctx, term).reset();
ctx.onSessionAttached?.(id);
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
@@ -1204,6 +1245,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
});
ctx.sessionRef.current = id;
getFlowController(ctx, term).reset();
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
writeSessionData(ctx, term, chunk);
if (!ctx.hasConnectedRef.current) {
@@ -43,6 +43,9 @@ 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 {
markExpectedTerminalCursorPositionReport,
@@ -79,6 +82,13 @@ export type XTermRuntime = {
/** Current working directory detected via OSC 7 */
currentCwd: string | undefined;
keywordHighlighter: KeywordHighlighter;
/**
* Clear the WebGL renderer's glyph texture atlas so glyphs re-rasterize on the
* next frame. No-op when the DOM renderer is active. Used to recover from the
* persistent "garbled / 花屏" corruption (issue #1049) that the WebGL atlas can
* fall into after font changes or device pixel ratio changes.
*/
clearTextureAtlas: () => void;
};
export type CreateXTermRuntimeContext = {
@@ -286,7 +296,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
smoothScrollDuration,
scrollOnUserInput,
macOptionClickForcesSelection: true,
altClickMovesCursor: !altIsMeta,
...terminalAltKeyOptions(altIsMeta),
wordSeparator,
theme: {
...ctx.terminalTheme.colors,
@@ -386,6 +396,45 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
? "dom"
: "webgl";
// The WebGL renderer caches rasterized glyphs in a texture atlas. Heavy TUIs
// (claude code / gemini cli / opencode and other full-screen agents), font
// changes, and device pixel ratio changes can leave that atlas in a corrupted
// state that persists for the life of the terminal — the "garbled / 花屏"
// report in issue #1049 where only opening a brand-new terminal helps. Clearing
// the atlas forces glyphs to re-rasterize at the correct scale on the next
// frame. No-op for the DOM renderer.
const clearWebglTextureAtlas = () => {
if (!webglAddon) return;
try {
webglAddon.clearTextureAtlas();
} catch (err) {
logger.warn("[XTerm] clearTextureAtlas failed", err);
}
};
// Recover the renderer when the device pixel ratio changes (moving the window
// between monitors with different DPI, or changing OS display scaling — a
// common Windows trigger). matchMedia change does not fire a normal resize, so
// this is needed in addition to the resize handling below.
let stopDprWatch: () => void = () => {};
if (
typeof window !== "undefined" &&
typeof window.matchMedia === "function"
) {
stopDprWatch = watchDevicePixelRatio({
getDevicePixelRatio: () => window.devicePixelRatio || 1,
matchMedia: (query) => window.matchMedia(query),
onChange: () => {
clearWebglTextureAtlas();
try {
fitAddon.fit();
} catch (err) {
logger.warn("[XTerm] fit after devicePixelRatio change failed", err);
}
},
});
}
const webLinksAddon = new WebLinksAddon((event, uri) => {
const currentLinkModifier = ctx.terminalSettingsRef.current?.linkModifier ?? "none";
let shouldOpen = false;
@@ -609,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;
});
@@ -858,6 +930,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
let resizeTimeout: NodeJS.Timeout | null = null;
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
term.onResize(({ cols, rows }) => {
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
// dimensions re-rasterize cleanly (issue #1049).
clearWebglTextureAtlas();
const id = ctx.sessionRef.current;
if (!id) return;
if (resizeTimeout) clearTimeout(resizeTimeout);
@@ -876,8 +951,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
serializeAddon,
searchAddon,
keywordHighlighter,
clearTextureAtlas: clearWebglTextureAtlas,
dispose: () => {
cleanupMiddleClick?.();
stopDprWatch();
keywordHighlighter.dispose();
eraseScrollbackDisposable.dispose();
for (const disposable of cursorPositionReportRequestDisposables) {
@@ -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);
});
@@ -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;
}
@@ -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, []);
});
@@ -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;
},
};
}
@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
type MediaQueryListLike,
watchDevicePixelRatio,
} from "./rendererDprWatch";
class FakeMediaQueryList implements MediaQueryListLike {
readonly query: string;
modernListeners: Array<() => void> = [];
legacyListeners: Array<() => void> = [];
private readonly supportsModern: boolean;
constructor(query: string, supportsModern = true) {
this.query = query;
this.supportsModern = supportsModern;
if (!supportsModern) {
// Strip the modern API to emulate legacy environments.
this.addEventListener = undefined;
this.removeEventListener = undefined;
}
}
addEventListener? = (_type: "change", listener: () => void) => {
this.modernListeners.push(listener);
};
removeEventListener? = (_type: "change", listener: () => void) => {
this.modernListeners = this.modernListeners.filter((l) => l !== listener);
};
addListener = (listener: () => void) => {
this.legacyListeners.push(listener);
};
removeListener = (listener: () => void) => {
this.legacyListeners = this.legacyListeners.filter((l) => l !== listener);
};
trigger() {
for (const l of [...this.modernListeners, ...this.legacyListeners]) l();
}
get listenerCount() {
return this.modernListeners.length + this.legacyListeners.length;
}
}
function makeEnv(initialDpr: number, supportsModern = true) {
let dpr = initialDpr;
const created: FakeMediaQueryList[] = [];
return {
created,
getDevicePixelRatio: () => dpr,
matchMedia: (query: string) => {
const mql = new FakeMediaQueryList(query, supportsModern);
created.push(mql);
return mql;
},
setDpr: (value: number) => {
dpr = value;
},
};
}
test("registers a change listener for the current devicePixelRatio", () => {
const env = makeEnv(1);
watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {},
});
assert.equal(env.created.length, 1);
assert.equal(env.created[0].query, "(resolution: 1dppx)");
assert.equal(env.created[0].listenerCount, 1);
});
test("invokes onChange when the media query reports a change", () => {
const env = makeEnv(1);
let calls = 0;
watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
env.setDpr(2);
env.created[0].trigger();
assert.equal(calls, 1);
});
test("re-registers for the new ratio so subsequent changes still fire", () => {
const env = makeEnv(1);
let calls = 0;
watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
env.setDpr(2);
env.created[0].trigger();
assert.equal(env.created.length, 2);
assert.equal(env.created[1].query, "(resolution: 2dppx)");
// The stale listener must be detached so it cannot double-fire.
assert.equal(env.created[0].listenerCount, 0);
env.setDpr(3);
env.created[1].trigger();
assert.equal(calls, 2);
});
test("cleanup stops further onChange callbacks", () => {
const env = makeEnv(1);
let calls = 0;
const stop = watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
stop();
assert.equal(env.created[0].listenerCount, 0);
env.created[0].trigger();
assert.equal(calls, 0);
});
test("falls back to addListener/removeListener when addEventListener is unavailable", () => {
const env = makeEnv(1, /* supportsModern */ false);
let calls = 0;
const stop = watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
assert.equal(env.created[0].legacyListeners.length, 1);
env.created[0].trigger();
assert.equal(calls, 1);
stop();
// After cleanup the most recently registered query has no listeners.
const latest = env.created[env.created.length - 1];
assert.equal(latest.listenerCount, 0);
});
@@ -0,0 +1,72 @@
/**
* Watches for devicePixelRatio changes (e.g. moving the window between monitors
* with different DPI, or changing the OS display scaling on Windows) and invokes
* a callback so the renderer can be repaired.
*
* The WebGL renderer caches rasterized glyphs in a texture atlas keyed to the
* device pixel ratio at creation time. When the ratio changes the cached glyphs
* are drawn at the wrong scale, producing the persistent "garbled / 花屏"
* corruption reported in issue #1049 that only goes away when a brand-new
* terminal is opened. xterm.js recommends calling `clearTextureAtlas()` on DPR
* change so glyphs re-rasterize at the new scale.
*
* `matchMedia('(resolution: Ndppx)')` only matches a single ratio, so after each
* change we must re-register the listener against the new ratio.
*/
export interface MediaQueryListLike {
addEventListener?: (type: "change", listener: () => void) => void;
removeEventListener?: (type: "change", listener: () => void) => void;
// Legacy API (older Safari / Electron) where addEventListener is unavailable.
addListener?: (listener: () => void) => void;
removeListener?: (listener: () => void) => void;
}
export interface WatchDevicePixelRatioOptions {
getDevicePixelRatio: () => number;
matchMedia: (query: string) => MediaQueryListLike;
onChange: () => void;
}
/**
* Start watching for devicePixelRatio changes. Returns a cleanup function that
* removes the active listener.
*/
export function watchDevicePixelRatio(
options: WatchDevicePixelRatioOptions,
): () => void {
const { getDevicePixelRatio, matchMedia, onChange } = options;
let current: { mql: MediaQueryListLike; listener: () => void } | null = null;
const detach = () => {
if (!current) return;
const { mql, listener } = current;
if (mql.removeEventListener) {
mql.removeEventListener("change", listener);
} else if (mql.removeListener) {
mql.removeListener(listener);
}
current = null;
};
const attach = () => {
const dpr = getDevicePixelRatio();
const mql = matchMedia(`(resolution: ${dpr}dppx)`);
const listener = () => {
// A media query only matches the ratio it was created with, so detach the
// stale listener and re-register against the new ratio before notifying.
detach();
attach();
onChange();
};
if (mql.addEventListener) {
mql.addEventListener("change", listener);
} else if (mql.addListener) {
mql.addListener(listener);
}
current = { mql, listener };
};
attach();
return detach;
}
+35
View File
@@ -3,12 +3,14 @@ import assert from "node:assert/strict";
import type { Host } from "./models.ts";
import {
detectVendorFromSshVersion,
normalizePrimaryTelnetState,
resolveHostKeepalive,
resolveTelnetPort,
resolveTelnetPassword,
resolveTelnetUsername,
sanitizeHost,
shouldProbeSessionCwd,
upsertHostById,
} from "./host.ts";
@@ -158,6 +160,39 @@ test("sanitizeHost keeps a still-valid fontFamily untouched", () => {
assert.equal(after.fontFamilyOverride, true);
});
test("detectVendorFromSshVersion recognizes legacy Huawei VRP dash banner", () => {
assert.equal(detectVendorFromSshVersion("-"), "huawei");
assert.equal(detectVendorFromSshVersion("SSH-2.0--"), "huawei");
});
test("shouldProbeSessionCwd allows the probe on a plain Linux host", () => {
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6" }),
true,
);
});
test("shouldProbeSessionCwd skips the probe on an already-classified network device", () => {
// Reconnect / manual deviceType='network': host.distro already says network.
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: true, remoteSshVersion: "OpenSSH_9.6" }),
false,
);
});
test("shouldProbeSessionCwd skips the probe when the SSH banner reveals a network vendor", () => {
// First connect to a brand-new Huawei VRP: host.distro not persisted yet, so
// isNetworkDevice is still false — the banner is the only signal (#1043).
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "-" }),
false,
);
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "SSH-1.99--" }),
false,
);
});
const GLOBAL_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
test("resolveHostKeepalive falls back to global when override is not set", () => {
+20 -1
View File
@@ -78,7 +78,7 @@ export const normalizeDistroId = (value?: string) => {
* plain `OpenSSH_*` with no distinct vendor marker.
*/
export const detectVendorFromSshVersion = (softwareVersion?: string): '' | NetworkDeviceVendor => {
const s = (softwareVersion || '').trim();
const s = (softwareVersion || '').trim().replace(/^SSH-(?:2\.0|1\.99)-/i, '');
if (!s) return '';
// Cisco family — IOS, IOS XA, Wireless LAN Controller
@@ -97,6 +97,7 @@ export const detectVendorFromSshVersion = (softwareVersion?: string): '' | Netwo
if (/^NetScreen\b/.test(s)) return 'juniper';
// Huawei VRP and related products
if (s === '-') return 'huawei';
if (/^HUAWEI[-_]/i.test(s)) return 'huawei';
if (/^VRP-/i.test(s)) return 'huawei';
@@ -135,6 +136,24 @@ export const classifyDistroId = (distroId?: string): DeviceClass => {
return 'other';
};
/**
* Decide whether it is safe to run the post-connect `pwd` probe that
* discovers the session's working directory. The probe opens an extra exec
* channel running a POSIX-shell script; strict network-device CLIs such as
* Huawei VRP respond by closing the whole SSH session (#1043), so it must be
* skipped for them.
*
* `isNetworkDevice` covers hosts we already classified (a reconnect, or an
* explicit `deviceType: 'network'`). On a brand-new host that field is not
* populated yet, so we also inspect the SSH server identification banner —
* captured for free at handshake — which identifies most vendors directly.
*/
export const shouldProbeSessionCwd = (opts: {
isNetworkDevice: boolean;
remoteSshVersion?: string;
}): boolean =>
!opts.isNetworkDevice && !detectVendorFromSshVersion(opts.remoteSshVersion);
export const getEffectiveHostDistro = (
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
) => {
+2
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,
+27
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,
+62
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);
});
+3
View File
@@ -98,6 +98,8 @@ const buildS3Client = (config) =>
region: config.region,
endpoint: normalizeEndpoint(config.endpoint),
forcePathStyle: config.forcePathStyle ?? true,
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
@@ -299,4 +301,5 @@ module.exports = {
// Exposed for tests
handleWebdavInitialize,
buildBasicAuthHeader,
buildS3Client,
};
@@ -0,0 +1,25 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildS3Client,
} = require("./cloudSyncBridge.cjs");
const config = {
endpoint: "https://s3.example.com",
region: "us-east-1",
bucket: "netcatty-test",
accessKeyId: "access",
secretAccessKey: "secret",
forcePathStyle: true,
};
test("S3 client only sends request checksums when required", async () => {
const client = buildS3Client(config);
assert.equal(await client.config.requestChecksumCalculation(), "WHEN_REQUIRED");
});
test("S3 client only validates response checksums when required", async () => {
const client = buildS3Client(config);
assert.equal(await client.config.responseChecksumValidation(), "WHEN_REQUIRED");
});
+63
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 };
+90
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"]);
});
+91
View File
@@ -1,6 +1,8 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { KexInit, HANDLERS: KEX_HANDLERS } = require("../../node_modules/ssh2/lib/protocol/kex.js");
const { COMPAT, COMPAT_CHECKS, MESSAGE } = require("../../node_modules/ssh2/lib/protocol/constants.js");
const sshBridge = require("./sshBridge.cjs");
const sftpBridge = require("./sftpBridge.cjs");
@@ -45,6 +47,81 @@ function withAlgorithmRuntime({ unsupportedGroups = new Set(), hashes = ["sha1",
}
}
function kexPayloadFrom(init) {
const payload = Buffer.alloc(1 + 16 + init.totalSize + 1 + 4);
payload[0] = MESSAGE.KEXINIT;
init.copyAllTo(payload, 17);
return payload;
}
function buildKexInit(algorithms) {
return new KexInit({
kex: algorithms.kex,
serverHostKey: algorithms.serverHostKey,
cs: {
cipher: algorithms.cipher,
mac: algorithms.hmac,
compress: algorithms.compress,
lang: [],
},
sc: {
cipher: algorithms.cipher,
mac: algorithms.hmac,
compress: algorithms.compress,
lang: [],
},
});
}
function readLegacyGexRequestBits(compatFlags) {
const algorithms = sshBridge.buildAlgorithms(true);
const writtenPackets = [];
const protocol = {
_server: false,
_compatFlags: compatFlags,
_offer: buildKexInit(algorithms),
_debug: undefined,
_strictMode: undefined,
_kex: undefined,
_kexinit: Buffer.from("local-kexinit"),
_identRaw: Buffer.from("SSH-2.0-netcatty-test"),
_remoteIdentRaw: Buffer.from("SSH-2.0-Comware-5.20"),
_packetRW: {
write: {
allocStartKEX: 0,
alloc(size) {
return Buffer.alloc(size);
},
finalize(packet) {
return packet;
},
},
},
_cipher: {
encrypt(packet) {
writtenPackets.push(Buffer.from(packet));
},
},
};
const remote = buildKexInit({
kex: ["diffie-hellman-group-exchange-sha1"],
serverHostKey: ["ecdsa-sha2-nistp256", "ssh-rsa"],
cipher: ["aes128-ctr"],
hmac: ["hmac-sha2-256"],
compress: ["none"],
});
KEX_HANDLERS[MESSAGE.KEXINIT](protocol, kexPayloadFrom(remote));
const request = writtenPackets.find((packet) => packet[0] === MESSAGE.KEXDH_GEX_REQUEST);
assert.ok(request, "expected a DH group-exchange request packet");
return {
min: request.readUInt32BE(1),
preferred: request.readUInt32BE(5),
max: request.readUInt32BE(9),
};
}
for (const [label, buildAlgorithms] of [
["SSH", sshBridge.buildAlgorithms],
["SFTP", sftpBridge.buildSftpAlgorithms],
@@ -123,3 +200,17 @@ test("legacy HMAC algorithms skip MD5 when the runtime disables it", () => {
}
});
});
test("Comware legacy group-exchange requests OpenSSH 6.4-sized DH groups", () => {
const comwareCompatRule = COMPAT_CHECKS.find(([pattern, flags]) => (
pattern instanceof RegExp
&& pattern.test("Comware-5.20")
&& (flags & COMPAT.COMWARE_DHGEX_1024)
));
assert.ok(comwareCompatRule, "Comware servers should opt into the old DH group-exchange request size");
assert.deepEqual(
readLegacyGexRequestBits(COMPAT.COMWARE_DHGEX_1024),
{ min: 1024, preferred: 1024, max: 8192 },
);
});
+196 -49
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
@@ -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");
+31 -48
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,
+111 -10
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 };
+74
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" }],
);
});
+17
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);
+29 -2
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",
},
},
];
Vendored
+10
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,
@@ -194,6 +194,8 @@ export class S3Adapter {
region: config.region,
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle ?? true,
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
+66
View File
@@ -0,0 +1,66 @@
import test from "node:test";
import assert from "node:assert/strict";
import type React from "react";
import {
MIDDLE_MOUSE_BUTTON,
handleTabMiddleClickClose,
handleTabMiddleMouseDown,
} from "./tabInteractions.ts";
interface FakeMouseEvent {
button: number;
preventDefault: () => void;
stopPropagation: () => void;
}
const makeEvent = (button: number) => {
const calls = { preventDefault: 0, stopPropagation: 0 };
const event = {
button,
preventDefault: () => {
calls.preventDefault++;
},
stopPropagation: () => {
calls.stopPropagation++;
},
} satisfies FakeMouseEvent;
return { event: event as unknown as React.MouseEvent, calls };
};
test("handleTabMiddleClickClose closes the tab on a middle click", () => {
let closed = 0;
const { event, calls } = makeEvent(MIDDLE_MOUSE_BUTTON);
handleTabMiddleClickClose(event, () => {
closed++;
});
assert.equal(closed, 1);
assert.equal(calls.preventDefault, 1);
assert.equal(calls.stopPropagation, 1);
});
test("handleTabMiddleClickClose ignores left and right clicks", () => {
for (const button of [0, 2]) {
let closed = 0;
const { event, calls } = makeEvent(button);
handleTabMiddleClickClose(event, () => {
closed++;
});
assert.equal(closed, 0, `button ${button} must not close the tab`);
assert.equal(calls.preventDefault, 0);
}
});
test("handleTabMiddleMouseDown suppresses autoscroll only for the middle button", () => {
const middle = makeEvent(MIDDLE_MOUSE_BUTTON);
handleTabMiddleMouseDown(middle.event);
assert.equal(middle.calls.preventDefault, 1);
const left = makeEvent(0);
handleTabMiddleMouseDown(left.event);
assert.equal(left.calls.preventDefault, 0);
});
+34
View File
@@ -0,0 +1,34 @@
import type React from "react";
/**
* The DOM `MouseEvent.button` value for the middle mouse button (wheel click).
* 0 = left/primary, 1 = middle, 2 = right/secondary.
*/
export const MIDDLE_MOUSE_BUTTON = 1;
/**
* Suppress the Chromium/Electron middle-click autoscroll affordance on a tab.
* Wire to `onMouseDown`: autoscroll is armed on mousedown, so preventing the
* default there stops the panning-cursor overlay from appearing when a user
* middle-clicks a tab to close it (#1044).
*/
export const handleTabMiddleMouseDown = (e: React.MouseEvent): void => {
if (e.button === MIDDLE_MOUSE_BUTTON) {
e.preventDefault();
}
};
/**
* Close a tab when it is middle-clicked. Wire to `onAuxClick`, which fires for
* a completed non-primary click. Left clicks (tab activation) and right clicks
* (context menu) are ignored so existing behavior is untouched.
*/
export const handleTabMiddleClickClose = (
e: React.MouseEvent,
close: () => void,
): void => {
if (e.button !== MIDDLE_MOUSE_BUTTON) return;
e.preventDefault();
e.stopPropagation();
close();
};
+1 -1
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 .",
+37
View File
@@ -738,3 +738,40 @@ index 9f33c02..9751164 100644
}
if (names !== undefined) {
sftp._debug && sftp._debug(
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
index ad77592..4b3f71a 100644
--- a/node_modules/ssh2/lib/protocol/constants.js
+++ b/node_modules/ssh2/lib/protocol/constants.js
@@ -160,4 +160,5 @@ const COMPAT = {
DYN_RPORT_BUG: 1 << 2,
BUG_DHGEX_LARGE: 1 << 3,
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
+ COMWARE_DHGEX_1024: 1 << 5,
};
@@ -330,6 +331,7 @@ module.exports = {
COMPAT_CHECKS: [
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
+ [ /^Comware-/, COMPAT.COMWARE_DHGEX_1024 ],
[ /^[0-9.]+$/, COMPAT.OLD_EXIT ], // old SSH.com implementations
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
index 811e631..4b5f792 100644
--- a/node_modules/ssh2/lib/protocol/kex.js
+++ b/node_modules/ssh2/lib/protocol/kex.js
@@ -1377,8 +1377,13 @@ const createKeyExchange = (() => {
this._generator = null;
this._minBits = GEX_MIN_BITS;
this._prefBits = dhEstimate(this.negotiated);
- if (this._protocol._compatFlags & COMPAT.BUG_DHGEX_LARGE)
+ if (hashName === 'sha1'
+ && (this._protocol._compatFlags & COMPAT.COMWARE_DHGEX_1024)) {
+ this._minBits = 1024;
+ this._prefBits = 1024;
+ } else if (this._protocol._compatFlags & COMPAT.BUG_DHGEX_LARGE) {
this._prefBits = Math.min(this._prefBits, 4096);
+ }
this._maxBits = GEX_MAX_BITS;
}
start() {
+74
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}`,
);
-1
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',