Compare commits

...

18 Commits

Author SHA1 Message Date
陈大猫
bf1c95500a feat #826: optional Option+←/→ word jump on macOS (#1082)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat #826: optional Option+←/→ word jump on macOS

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

No behavior change; production build is warning-free.

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

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

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

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

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

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

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

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

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

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

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

* feat #1064: renderer prompt for rz overwrite conflicts

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:56:30 +08:00
陈大猫
60eeafe7a9 feat #1005: Termius-style live-preview popup autocomplete (free the Tab key) (#1059)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat #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
陈大猫
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
陈大猫
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
48 changed files with 1949 additions and 102 deletions

View File

@@ -301,6 +301,9 @@ const en: Messages = {
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
'settings.terminal.keyboard.altAsMeta.desc':
'Use Option (Alt) as the Meta key instead of for special characters',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
@@ -2092,6 +2095,11 @@ const en: Messages = {
'zmodem.uploading': 'Uploading',
'zmodem.downloading': 'Downloading',
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
'zmodem.overwrite.title': 'Remote file already exists',
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
'zmodem.overwrite.overwrite': 'Overwrite',
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default',
};

View File

@@ -301,6 +301,9 @@ const ru: Messages = {
'settings.terminal.keyboard.altAsMeta': 'Использовать Option как клавишу Meta',
'settings.terminal.keyboard.altAsMeta.desc':
'Использовать Option (Alt) как клавишу Meta вместо ввода специальных символов',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ переход по словам',
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Отправлять Meta-b / Meta-f при Option+Влево/Вправо, чтобы оболочка перемещалась по словам, вместо стандартного ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Минимальный коэффициент контрастности',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Подстраивать цвета под требования контрастности (1 = отключено, 21 = максимум)',
@@ -2124,6 +2127,11 @@ const ru: Messages = {
'zmodem.uploading': 'Загрузка',
'zmodem.downloading': 'Скачивание',
'zmodem.cancelTransfer': 'Отменить передачу (Ctrl+C)',
'zmodem.overwrite.title': 'Remote file already exists',
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
'zmodem.overwrite.overwrite': 'Overwrite',
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
};

View File

@@ -1445,6 +1445,8 @@ const zhCN: Messages = {
'settings.terminal.cursor.blink': '光标闪烁',
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右键行为',
@@ -2101,6 +2103,11 @@ const zhCN: Messages = {
'zmodem.uploading': '上传中',
'zmodem.downloading': '下载中',
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
'zmodem.overwrite.title': '远端已存在同名文件',
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
'zmodem.overwrite.overwrite': '覆盖',
'zmodem.overwrite.skip': '跳过',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置为默认',
};

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -164,7 +164,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'altAsMeta', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',

View File

@@ -29,7 +29,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 +49,14 @@ 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 { 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,
@@ -585,6 +587,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 +610,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,7 +636,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
cancelled = true;
clearTimeout(timer);
};
}, [host.protocol, status, terminalBackend, terminalCwdTracker]);
}, [host.protocol, status, terminalBackend, terminalCwdTracker, isNetworkDevice]);
useEffect(() => {
if (!isVisible) {
@@ -620,25 +648,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
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');
@@ -1236,11 +1249,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 +1273,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") {
@@ -2465,6 +2497,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
/>
</div>
)}
{/* ZMODEM overwrite conflict dialog */}
{zmodem.overwriteRequest && (
<ZmodemOverwriteDialog
filename={zmodem.overwriteRequest.filename}
onRespond={zmodem.respondOverwrite}
/>
)}
</div>
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}

View File

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

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

View File

@@ -810,6 +810,12 @@ export default function SettingsTerminalTab(props: {
>
<Toggle checked={terminalSettings.altAsMeta} onChange={(v) => updateTerminalSetting("altAsMeta", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.keyboard.optionArrowWordJump")}
description={t("settings.terminal.keyboard.optionArrowWordJump.desc")}
>
<Toggle checked={terminalSettings.optionArrowWordJump} onChange={(v) => updateTerminalSetting("optionArrowWordJump", v)} />
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.accessibility")} />

View File

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

View File

@@ -0,0 +1,33 @@
import React, { useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../ui/dialog";
import { Button } from "../ui/button";
interface Props {
filename: string;
onRespond: (action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => void;
}
export const ZmodemOverwriteDialog: React.FC<Props> = ({ filename, onRespond }) => {
const { t } = useI18n();
const [applyToRest, setApplyToRest] = useState(false);
return (
<Dialog open onOpenChange={(o) => { if (!o) onRespond("cancel", false); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("zmodem.overwrite.title")}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground break-all">{filename}</p>
<label className="flex items-center gap-2 text-sm mt-2">
<input type="checkbox" checked={applyToRest} onChange={(e) => setApplyToRest(e.target.checked)} />
{t("zmodem.overwrite.applyToRest")}
</label>
<DialogFooter>
<Button variant="ghost" onClick={() => onRespond("cancel", applyToRest)}>{t("zmodem.overwrite.cancel")}</Button>
<Button variant="outline" onClick={() => onRespond("skip", applyToRest)}>{t("zmodem.overwrite.skip")}</Button>
<Button onClick={() => onRespond("overwrite", applyToRest)}>{t("zmodem.overwrite.overwrite")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

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

View File

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

View File

@@ -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;
@@ -253,6 +254,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 +541,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) => {
@@ -666,6 +706,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 +919,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 +1102,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 +1135,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 +1194,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 +1233,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 +1248,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.

View File

@@ -27,6 +27,7 @@ const initialState: ZmodemTransferState = {
export function useZmodemTransfer(sessionId: string | null) {
const [state, setState] = useState<ZmodemTransferState>(initialState);
const [overwriteRequest, setOverwriteRequest] = useState<{ requestId: string; filename: string } | null>(null);
const disposeRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
@@ -77,6 +78,10 @@ export function useZmodemTransfer(sessionId: string | null) {
}
});
const disposeOverwrite = bridge.onZmodemOverwriteRequest?.(sessionId, (payload) => {
setOverwriteRequest({ requestId: payload.requestId, filename: payload.filename });
});
// If the session exits mid-transfer (disconnect, shell exit, etc.),
// reset state so the progress indicator doesn't stay stuck.
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
@@ -86,9 +91,11 @@ export function useZmodemTransfer(sessionId: string | null) {
return () => {
disposeRef.current?.();
disposeRef.current = null;
disposeOverwrite?.();
disposeExitRef.current?.();
disposeExitRef.current = null;
setState(initialState);
setOverwriteRequest(null);
};
}, [sessionId]);
@@ -98,5 +105,12 @@ export function useZmodemTransfer(sessionId: string | null) {
bridge?.cancelZmodem?.(sessionId);
}, [sessionId]);
return { ...state, cancel };
const respondOverwrite = useCallback((action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => {
setOverwriteRequest((req) => {
if (req) netcattyBridge.get()?.respondZmodemOverwrite?.({ requestId: req.requestId, action, applyToRest });
return null;
});
}, []);
return { ...state, cancel, overwriteRequest, respondOverwrite };
}

View File

@@ -0,0 +1,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",
);
});

View File

@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { terminalAltKeyOptions } from "./altKeyOptions";
// Issue #1078: with "Use Option as Meta key" enabled, macOS Option must send
// ESC-prefixed (Meta) sequences. xterm.js gates that on `macOptionIsMeta`. The
// flag was read from settings but only ever wired to the mouse alt-click
// behavior, so Option kept emitting layout characters (ƒ, ∫, …) instead of Meta.
test("Option-as-Meta enabled: Option emits Meta and alt-click cursor move is disabled", () => {
assert.deepEqual(terminalAltKeyOptions(true), {
macOptionIsMeta: true,
altClickMovesCursor: false,
});
});
test("Option-as-Meta disabled: xterm keeps default macOS Option behavior", () => {
assert.deepEqual(terminalAltKeyOptions(false), {
macOptionIsMeta: false,
altClickMovesCursor: true,
});
});

View File

@@ -0,0 +1,20 @@
export interface TerminalAltKeyOptions {
/** xterm.js: treat macOS Option as the Meta key (emit ESC-prefixed sequences). */
macOptionIsMeta: boolean;
/** xterm.js: Option+click moves the cursor. Must be off when Option is Meta. */
altClickMovesCursor: boolean;
}
/**
* Map the user's "Use Option as Meta key" setting to xterm.js options.
*
* Kept in one place so terminal init (createXTermRuntime) and the live settings
* sync (Terminal.tsx) can't drift — that drift is what left `macOptionIsMeta`
* unset everywhere and broke Option/Meta shortcuts on macOS (issue #1078).
*/
export function terminalAltKeyOptions(altAsMeta: boolean): TerminalAltKeyOptions {
return {
macOptionIsMeta: altAsMeta,
altClickMovesCursor: !altAsMeta,
};
}

View File

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

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
// Discussion #826: on macOS, Option+←/→ defaults to xterm's ^[[1;3D / ^[[1;3C,
// which most shells don't bind. When enabled, remap them to Meta-b / Meta-f so
// readline/zle does backward-word / forward-word out of the box (Termius-style).
// Gated to macOS so the syncable setting can't rewrite Alt+←/→ on other platforms.
const ev = (over: Partial<Parameters<typeof optionArrowWordJumpSequence>[0]> = {}) => ({
key: "ArrowLeft",
altKey: true,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...over,
});
test("Option+Left → Meta-b (backward-word) when enabled on macOS", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, true), "\x1bb");
});
test("Option+Right → Meta-f (forward-word) when enabled on macOS", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, true), "\x1bf");
});
test("not macOS → null (don't rewrite Alt+←/→ on Linux/Windows even if synced on)", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, false), null);
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, false), null);
});
test("disabled → null (xterm default ^[[1;3D/C is kept)", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), false, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), false, true), null);
});
test("no Option held → null", () => {
assert.equal(optionArrowWordJumpSequence(ev({ altKey: false }), true, true), null);
});
test("extra modifiers with Option → null (don't hijack Shift/Ctrl/Cmd combos)", () => {
assert.equal(optionArrowWordJumpSequence(ev({ shiftKey: true }), true, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ ctrlKey: true }), true, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ metaKey: true }), true, true), null);
});
test("non-arrow keys → null", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowUp" }), true, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ key: "f" }), true, true), null);
});

View File

@@ -0,0 +1,33 @@
export interface OptionArrowKeyEvent {
key: string;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}
/**
* macOS Option+←/→ word-jump (discussion #826).
*
* When enabled, maps a bare Option+Left/Right to the Meta-b / Meta-f sequence so
* readline/zle does backward-word / forward-word without per-host bindkey setup.
* Returns the bytes to send, or null when the mapping doesn't apply (disabled,
* non-macOS, not an arrow, or other modifiers held) — in which case xterm's
* default ^[[1;3D / ^[[1;3C is left untouched.
*
* Gated to macOS (`isMac`): the setting is syncable, so without the gate,
* enabling it on a Mac would also rewrite Alt+←/→ on synced Linux/Windows
* devices (discussion #826 review).
*/
export function optionArrowWordJumpSequence(
e: OptionArrowKeyEvent,
enabled: boolean,
isMac: boolean,
): string | null {
if (!enabled || !isMac) return null;
// Only a bare Option+Arrow — leave Shift/Ctrl/Cmd combos to xterm's defaults.
if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return null;
if (e.key === "ArrowLeft") return "\x1bb"; // Meta-b → backward-word
if (e.key === "ArrowRight") return "\x1bf"; // Meta-f → forward-word
return null;
}

View File

@@ -0,0 +1,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);
});

View File

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

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", () => {

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,
) => {

View File

@@ -493,6 +493,7 @@ export interface TerminalSettings {
// Keyboard
altAsMeta: boolean; // Use ⌥ as the Meta key
optionArrowWordJump: boolean; // macOS: Option+←/→ send Meta-b/f for word jump
scrollOnInput: boolean; // Scroll terminal to bottom on input
scrollOnOutput: boolean; // Scroll terminal to bottom on output
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
@@ -692,6 +693,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
cursorBlink: true,
minimumContrastRatio: 1,
altAsMeta: false,
optionArrowWordJump: false,
scrollOnInput: true,
scrollOnOutput: false,
scrollOnKeyPress: false,

View File

@@ -58,6 +58,32 @@ function extractTrailingIdlePrompt(output) {
return "";
}
// bash and csh/tcsh print a banner to the terminal right before exiting due to
// the shell's TMOUT idle-timeout setting ("timed out waiting for input:
// auto-logout" / "auto-logout"). That exit is a clean shell exit — numeric
// code, no signal — so it is indistinguishable from a user-typed `exit` by
// exit code alone (verified: bash auto-logout exits 0). The banner is the only
// reliable discriminator, letting the SSH bridge keep the tab open for
// reconnect instead of auto-closing it (#1062, regression of #977).
const IDLE_AUTO_LOGOUT_PATTERN = /(?:timed out waiting for input:\s*)?auto-?logout$/i;
function looksLikeIdleAutoLogout(outputTail) {
if (typeof outputTail !== "string" || !outputTail) return false;
// The shell prints this banner on its own line as the very last thing before
// it exits, so anchor on the final non-empty line rather than a loose
// substring. Otherwise unrelated output that merely mentions "auto-logout"
// (e.g. `grep auto-logout /etc/profile`) followed by an intentional `exit`
// would be misclassified as a timeout and wrongly keep the tab open.
const lines = stripAnsi(outputTail.slice(-512)).replace(/\r/g, "\n").split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
// Drop control bytes (e.g. the BEL bash rings before the banner) and trim.
const line = lines[i].replace(/[\x00-\x1f\x7f]/g, "").trim();
if (!line) continue;
return IDLE_AUTO_LOGOUT_PATTERN.test(line);
}
return false;
}
function trackSessionIdlePrompt(session, chunk) {
if (!session || typeof chunk !== "string" || !chunk) return "";
@@ -399,6 +425,7 @@ module.exports = {
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
trackSessionIdlePrompt,
looksLikeIdleAutoLogout,
isLocalhostHostname,
extractFirstNonLocalhostUrl,
normalizeCliPathForPlatform,

View File

@@ -7,6 +7,7 @@ const {
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isPlausibleCliVersionOutput,
looksLikeIdleAutoLogout,
prepareCommandForSpawn,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
@@ -175,3 +176,64 @@ test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PT
// with the cached PS line, so downstream wrapper selection sees "".
assert.equal(getFreshIdlePrompt(session), "");
});
test("looksLikeIdleAutoLogout detects the bash TMOUT banner at the tail", () => {
// bash prints this immediately before a TMOUT auto-logout exit. The exit
// itself is a clean shell exit (code 0, no signal), so the banner is the
// only reliable discriminator from a user-typed `exit` (#1062 / #977).
assert.equal(
looksLikeIdleAutoLogout("user@host:~$ \x07timed out waiting for input: auto-logout\r\n"),
true,
);
});
test("looksLikeIdleAutoLogout detects the csh/tcsh auto-logout banner", () => {
assert.equal(looksLikeIdleAutoLogout("\r\nauto-logout\r\n"), true);
});
test("looksLikeIdleAutoLogout sees through ANSI escapes around the banner", () => {
assert.equal(
looksLikeIdleAutoLogout("\x1b[0m\x1b[33mtimed out waiting for input: auto-logout\x1b[0m\r\n"),
true,
);
});
test("looksLikeIdleAutoLogout ignores a plain (non-timeout) logout", () => {
// A normal login-shell exit prints "logout" — without the "auto-" prefix —
// and must still auto-close the tab.
assert.equal(looksLikeIdleAutoLogout("user@host:~$ logout\r\n"), false);
});
test("looksLikeIdleAutoLogout ignores the banner when it is not at the tail", () => {
// "auto-logout" scrolled past long ago; the user then ran more commands and
// exited normally. Only the tail end is inspected, so this is not a timeout.
const tail = "auto-logout\n" + "x".repeat(400) + "\nuser@host:~$ logout\r\n";
assert.equal(looksLikeIdleAutoLogout(tail), false);
});
test("looksLikeIdleAutoLogout ignores auto-logout in command output before an intentional exit", () => {
// Investigating TMOUT: the user greps the profile (output mentions
// "auto-logout"), reads it, then exits on purpose. The banner is not the
// final line, so the tab must still auto-close. Guards against matching an
// unanchored substring anywhere in the recent output.
const tail =
"root@h:~# grep -i auto-logout /etc/profile\r\n" +
"# bash TMOUT auto-logout setting\r\nTMOUT=300\r\n" +
"root@h:~# exit\r\nlogout\r\n";
assert.equal(looksLikeIdleAutoLogout(tail), false);
});
test("looksLikeIdleAutoLogout matches the real-server banner shape (prompt + banner on one line)", () => {
// The banner can share a line with the trailing prompt after ANSI/control
// bytes are stripped (observed over real SSH); anchoring on the line end
// must still match.
const tail =
"\x1b]0;root@VM:~\x07root@VM:~# \x1b[?2004l\x07timed out waiting for input: auto-logout\n";
assert.equal(looksLikeIdleAutoLogout(tail), true);
});
test("looksLikeIdleAutoLogout returns false for empty / non-string input", () => {
assert.equal(looksLikeIdleAutoLogout(""), false);
assert.equal(looksLikeIdleAutoLogout(undefined), false);
assert.equal(looksLikeIdleAutoLogout(null), false);
});

View File

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

View File

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

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

View File

@@ -33,7 +33,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 +365,8 @@ function resolveLangFromCharset(charset) {
const { safeSend } = require("./ipcUtils.cjs");
const zmodemOverwritePending = new Map(); // requestId -> (decision) => void
/**
* Initialize the SSH bridge with dependencies
*/
@@ -1330,6 +1332,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;
},
@@ -1386,7 +1413,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 +2046,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 +2145,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 +2818,10 @@ function registerHandlers(ipcMain) {
}
return keys;
});
ipcMain.on("netcatty:zmodem:overwrite-response", (_event, payload) => {
const resolve = zmodemOverwritePending.get(payload?.requestId);
if (resolve) { zmodemOverwritePending.delete(payload.requestId); resolve(payload); }
});
// Register the shared keyboard-interactive response handler
keyboardInteractiveHandler.registerHandler(ipcMain);
// Register the passphrase response handler

View File

@@ -81,6 +81,7 @@ test("execCommand stops when an identity file passphrase prompt is cancelled", a
handle(channel, handler) {
this.handlers.set(channel, handler);
},
on() {},
};
bridge.registerHandlers(ipcMain);
const execHandler = ipcMain.handlers.get("netcatty:ssh:exec");

View File

@@ -20,6 +20,58 @@ function getElectron() {
return _electron;
}
/**
* Resolve per-file overwrite choices into an upload plan. Pure (no I/O):
* `resolveDecision(name)` is awaited only for files in `existingList`, in input
* order; `{ applyToRest: true }` reuses that action for the remaining conflicts.
* Returns indices into the original `names` array so callers preserve per-file
* identity even when two files share a basename.
* Actions: 'overwrite' (rm remote then send), 'skip' (don't send), 'cancel' (abort all).
*/
async function buildUploadPlan(names, existingList, resolveDecision) {
const existing = new Set(existingList);
const offerIndices = [];
const removeIndices = [];
let bulkAction = null;
for (let idx = 0; idx < names.length; idx++) {
const name = names[idx];
if (!existing.has(name)) { offerIndices.push(idx); continue; }
let action = bulkAction;
if (!action) {
const decision = (await resolveDecision(name)) || { action: "skip" };
action = decision.action;
if (decision.applyToRest && action !== "cancel") bulkAction = action;
}
if (action === "cancel") return { offerIndices: [], removeIndices: [], aborted: true };
if (action === "overwrite") { removeIndices.push(idx); offerIndices.push(idx); }
// 'skip' → omit from both
}
return { offerIndices, removeIndices, aborted: false };
}
/**
* Resolve which overwritten files need their original mode restored after rz
* re-creates them. rz writes new files with the remote umask, dropping the
* prior permission bits (issue #1079). Pure: returns absolute `{ path, mode }`
* entries for the overwritten files, skipping any whose mode wasn't captured
* and de-duplicating shared basenames.
*/
function buildModeRestores(dir, names, removeIndices, modes) {
const base = String(dir).replace(/\/+$/, "");
const seen = new Set();
const restores = [];
for (const i of removeIndices) {
const name = names[i];
const mode = modes && modes[name];
if (!mode) continue;
const target = `${base}/${name}`;
if (seen.has(target)) continue;
seen.add(target);
restores.push({ path: target, mode });
}
return restores;
}
/**
* Create a ZMODEM sentry that wraps a session's data stream.
*
@@ -524,10 +576,45 @@ async function handleUpload(zsession, opts) {
const filePaths = result.filePaths;
const fileStats = filePaths.map((fp) => fs.statSync(fp));
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
const stat = fileStats[i];
const name = path.basename(filePath);
const allNames = filePaths.map((fp) => path.basename(fp));
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
// On any failure we fall back to today's behavior (rz silently skips).
let plan = { offerIndices: allNames.map((_, i) => i), removeIndices: [], aborted: false };
let probeDir = null;
let probeModes = null;
if (opts.probeReceiveConflicts && opts.requestOverwriteDecision) {
try {
const probe = await opts.probeReceiveConflicts(allNames);
if (probe && probe.dir && Array.isArray(probe.existing) && probe.existing.length > 0) {
probeDir = probe.dir;
probeModes = probe.modes || {};
plan = await buildUploadPlan(allNames, probe.existing, opts.requestOverwriteDecision);
if (plan.aborted) {
try { zsession.abort(); } catch { /* ignore */ }
abortRemoteProcess(opts.writeToRemote);
throw new Error("Transfer cancelled");
}
if (plan.removeIndices.length && opts.removeRemoteFiles) {
const base = probe.dir.replace(/\/+$/, "");
const targets = [...new Set(plan.removeIndices.map((i) => `${base}/${allNames[i]}`))];
try {
await opts.removeRemoteFiles(targets);
} catch (err) {
console.warn("[ZMODEM] removeRemoteFiles failed; rz will skip:", err?.message || err);
}
}
}
} catch (err) {
if (err instanceof Error && err.message === "Transfer cancelled") throw err;
console.warn("[ZMODEM] conflict probe failed; proceeding:", err?.message || err);
}
}
const offers = plan.offerIndices.map((i) => ({ filePath: filePaths[i], stat: fileStats[i], name: allNames[i] }));
for (let i = 0; i < offers.length; i++) {
const { filePath, stat, name } = offers[i];
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
@@ -535,18 +622,18 @@ async function handleUpload(zsession, opts) {
transferred: 0,
total: stat.size,
fileIndex: i,
fileCount: filePaths.length,
fileCount: offers.length,
transferType: "upload",
});
let bytesRemaining = 0;
for (let j = i; j < fileStats.length; j++) bytesRemaining += fileStats[j].size;
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
const xfer = await zsession.send_offer({
name,
size: stat.size,
mtime: new Date(stat.mtimeMs),
files_remaining: filePaths.length - i,
files_remaining: offers.length - i,
bytes_remaining: bytesRemaining,
});
@@ -579,7 +666,7 @@ async function handleUpload(zsession, opts) {
transferred: sent,
total: stat.size,
fileIndex: i,
fileCount: filePaths.length,
fileCount: offers.length,
transferType: "upload",
});
@@ -597,7 +684,7 @@ async function handleUpload(zsession, opts) {
transferred: stat.size,
total: stat.size,
fileIndex: i,
fileCount: filePaths.length,
fileCount: offers.length,
transferType: "upload",
finalizing: true,
});
@@ -608,6 +695,20 @@ async function handleUpload(zsession, opts) {
}
await withTimeout(zsession.close(), 120000);
// rz re-creates overwritten files with the remote umask, dropping their
// original permission bits. Now that everything is on disk, restore them
// to the modes captured before the rm (issue #1079).
if (plan.removeIndices.length && probeDir && opts.restoreRemoteModes) {
const restores = buildModeRestores(probeDir, allNames, plan.removeIndices, probeModes);
if (restores.length) {
try {
await opts.restoreRemoteModes(restores);
} catch (err) {
console.warn("[ZMODEM] restoreRemoteModes failed:", err?.message || err);
}
}
}
}
/**
@@ -791,4 +892,4 @@ function safeSend(contents, channel, data) {
}
}
module.exports = { createZmodemSentry };
module.exports = { createZmodemSentry, buildUploadPlan, buildModeRestores };

View File

@@ -0,0 +1,74 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { buildUploadPlan, buildModeRestores } = require("./zmodemHelper.cjs");
const never = () => { throw new Error("resolver should not be called"); };
test("no conflicts: all indices offered, none removed, resolver untouched", async () => {
const plan = await buildUploadPlan(["a.txt", "b.txt"], [], never);
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [], aborted: false });
});
test("overwrite a conflict: index both removed and offered", async () => {
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "overwrite" }));
assert.deepEqual(plan, { offerIndices: [0, 1], removeIndices: [1], aborted: false });
});
test("skip a conflict: index omitted from offer and remove", async () => {
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "skip" }));
assert.deepEqual(plan, { offerIndices: [0], removeIndices: [], aborted: false });
});
test("cancel aborts the whole transfer", async () => {
const plan = await buildUploadPlan(["a.txt", "b.txt"], ["b.txt"], async () => ({ action: "cancel" }));
assert.deepEqual(plan, { offerIndices: [], removeIndices: [], aborted: true });
});
test("applyToRest reuses the action and stops prompting", async () => {
let calls = 0;
const plan = await buildUploadPlan(["a", "b", "c"], ["a", "b", "c"],
async () => { calls++; return { action: "overwrite", applyToRest: true }; });
assert.equal(calls, 1);
assert.deepEqual(plan, { offerIndices: [0, 1, 2], removeIndices: [0, 1, 2], aborted: false });
});
test("only conflicting files invoke the resolver; order preserved", async () => {
const seen = [];
const plan = await buildUploadPlan(["a", "b", "c"], ["b"],
async (n) => { seen.push(n); return { action: "skip" }; });
assert.deepEqual(seen, ["b"]);
assert.deepEqual(plan.offerIndices, [0, 2]);
});
test("duplicate basenames keep independent per-file decisions", async () => {
// Two different local files share a basename; skip the first, overwrite the second.
const actions = ["skip", "overwrite"];
let i = 0;
const plan = await buildUploadPlan(["x.txt", "x.txt"], ["x.txt"],
async () => ({ action: actions[i++] }));
assert.deepEqual(plan, { offerIndices: [1], removeIndices: [1], aborted: false });
});
// Issue #1079: overwriting (rm + rz re-create) drops the original permission
// bits. buildModeRestores resolves which overwritten files to chmod back.
test("buildModeRestores maps overwritten files to their captured modes", () => {
assert.deepEqual(
buildModeRestores("/home/u", ["a.sh", "b.txt"], [0], { "a.sh": "755" }),
[{ path: "/home/u/a.sh", mode: "755" }],
);
});
test("buildModeRestores skips files whose mode was not captured", () => {
assert.deepEqual(
buildModeRestores("/srv", ["a", "b"], [0, 1], { a: "644" }),
[{ path: "/srv/a", mode: "644" }],
);
});
test("buildModeRestores strips trailing slashes and dedupes duplicate basenames", () => {
assert.deepEqual(
buildModeRestores("/srv//", ["x", "x"], [0, 1], { x: "600" }),
[{ path: "/srv/x", mode: "600" }],
);
});

View File

@@ -10,6 +10,7 @@ const transferErrorListeners = new Map();
const transferCancelledListeners = new Map();
const chainProgressListeners = new Map();
const zmodemListeners = new Map();
const zmodemOverwriteListeners = new Map(); // sessionId -> Set<cb>
const sftpConnectionProgressListeners = new Set();
const authFailedListeners = new Map();
const telnetAutoLoginCompleteListeners = new Map();
@@ -137,6 +138,10 @@ ipcRenderer.on("netcatty:zmodem:error", (_event, payload) => {
if (!set) return;
set.forEach((cb) => { try { cb({ type: "error", ...payload }); } catch {} });
});
ipcRenderer.on("netcatty:zmodem:overwrite-request", (_event, payload) => {
const set = zmodemOverwriteListeners.get(payload.sessionId);
if (set) set.forEach((cb) => cb(payload));
});
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
@@ -185,6 +190,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
telnetAutoLoginCompleteListeners.delete(payload.sessionId);
telnetAutoLoginCancelledListeners.delete(payload.sessionId);
zmodemListeners.delete(payload.sessionId);
zmodemOverwriteListeners.delete(payload.sessionId);
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
if (pendingTimer) {
clearTimeout(pendingTimer);
@@ -682,6 +688,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);

9
global.d.ts vendored
View File

@@ -341,6 +341,15 @@ declare global {
}) => void
): () => void;
cancelZmodem?(sessionId: string): void;
onZmodemOverwriteRequest?(
sessionId: string,
cb: (payload: { sessionId: string; requestId: string; filename: string }) => void
): () => void;
respondZmodemOverwrite?(payload: {
requestId: string;
action: "overwrite" | "skip" | "cancel";
applyToRest: boolean;
}): void;
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
onSessionExit(
sessionId: string,

View File

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

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
lib/tabInteractions.ts Normal file
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();
};

View File

@@ -28,7 +28,7 @@
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
"postinstall": "electron-builder install-app-deps && patch-package",
"postinstall": "electron-builder install-app-deps && patch-package && node scripts/patch-xterm-webgl-atlas.cjs",
"rebuild": "electron-builder install-app-deps",
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"lint": "eslint .",

View File

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

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* Disable @xterm/addon-webgl's cross-terminal texture-atlas sharing.
*
* xterm's WebGL addon shares ONE TextureAtlas across terminal instances whose
* config (font / size / theme / device-pixel-ratio) is equal — see
* `acquireTextureAtlas`, which does `if (configEquals) { ownedBy.push; return
* atlas }`. In a split workspace two panes then share an atlas, so clearing or
* rebuilding it for one pane (which netcatty does on resize / DPR change / font
* change / tab show to recover from glyph corruption) corrupts the OTHER pane's
* rendering — the persistent "花屏 / garbled" report in issue #1063, most
* visible in split view where both panes stay on screen.
*
* Fix: give every terminal its own atlas by removing the "reuse a matching
* atlas" loop, so each terminal falls through to creating its own. The published
* package is minified, so we string-replace the exact loop in both the CJS and
* ESM builds. This runs from `postinstall` (after patch-package).
*
* Idempotent. If the upstream code changes (e.g. an @xterm/addon-webgl upgrade)
* the loop won't be found; we warn loudly but do not fail the install, and the
* strings below must then be refreshed for the new version.
*/
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const MARKER = "/*netcatty:#1063 atlas-isolation*/";
// Exact (minified) "reuse a shared atlas" loop, per @xterm/addon-webgl@0.19.0.
const TARGETS = [
{
file: "node_modules/@xterm/addon-webgl/lib/addon-webgl.mjs",
loop: "for(let h=0;h<le.length;h++){let f=le[h];if(Mi(f.config,u))return f.ownedBy.push(i),f.atlas}",
},
{
file: "node_modules/@xterm/addon-webgl/lib/addon-webgl.js",
loop: "for(let t=0;t<r.length;t++){const i=r[t];if((0,n.configEquals)(i.config,d))return i.ownedBy.push(e),i.atlas}",
},
];
let patched = 0;
let already = 0;
let missing = 0;
for (const { file, loop } of TARGETS) {
const abs = path.resolve(process.cwd(), file);
let src;
try {
src = fs.readFileSync(abs, "utf8");
} catch {
console.warn(`[patch-xterm-webgl-atlas] skip (not found): ${file}`);
missing++;
continue;
}
if (src.includes(MARKER)) {
already++;
continue;
}
if (!src.includes(loop)) {
console.warn(
`[patch-xterm-webgl-atlas] WARNING: atlas-sharing loop not found in ${file}. ` +
"@xterm/addon-webgl likely changed — split-view WebGL may garble again (#1063). " +
"Refresh the minified target strings in scripts/patch-xterm-webgl-atlas.cjs.",
);
missing++;
continue;
}
fs.writeFileSync(abs, src.replace(loop, MARKER));
patched++;
}
console.log(
`[patch-xterm-webgl-atlas] atlas isolation: patched=${patched} already=${already} missing=${missing}`,
);

View File

@@ -44,7 +44,6 @@ export default defineConfig(() => {
output: {
manualChunks: {
// Vendor chunks - rarely change, can be cached aggressively
'vendor-react': ['react', 'react-dom'],
'vendor-radix': [
'@radix-ui/react-collapsible',
'@radix-ui/react-context-menu',