Compare commits

...

32 Commits

Author SHA1 Message Date
陈大猫
5d29c8d91a fix: support IPv6 addresses in quick connect and fix display formatting (#472)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: support bare IPv6 addresses in quick connect and fix IPv6 display

- Accept un-bracketed IPv6 addresses (e.g. 2607:f130::4f06) in quick
  connect input. The main regex requires brackets for IPv6+port, but now
  falls back to detecting bare IPv6 (2+ colons, hex-only) when the
  primary pattern fails.
- Add formatHostPort() helper that wraps IPv6 addresses in brackets
  when appending a port, preventing ambiguous displays like
  "2607:f130::4f06:22"
- Apply formatHostPort in QuickConnectWizard, TerminalConnectionDialog,
  and SftpSidePanel
- Fix hop label formatting in sshBridge and sftpBridge for IPv6 jump
  hosts

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

* fix: truncate long hostnames in connection dialog

Add truncate to the host label and protocol subtitle in the connection
dialog so long IPv6 addresses don't overflow into the action buttons.

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

* fix: constrain connection dialog header so truncate works correctly

Add min-w-0/flex-1 to the left side of the header flex container and
shrink-0 to the avatar so long hostnames truncate instead of pushing
into the Show logs / close buttons.

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

* fix: prevent action buttons from being squeezed by long hostname

Add shrink-0 and left margin to the right-side button group so truncated
text doesn't crowd into Show logs / close buttons.

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

* fix: tighten bare IPv6 detection to avoid MAC address false positives

Only accept bare (un-bracketed) hex:colon strings as IPv6 if they
contain '::' (unambiguously IPv6) or have exactly 7 colons (full
8-group notation). This rejects MAC addresses like aa:bb:cc:dd:ee:ff
(5 colons) which would otherwise trigger quick-connect mode.

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

* fix: avoid double-wrapping already-bracketed IPv6 hop labels

Add !startsWith('[') guard so hostnames that are already bracketed
(e.g. from URL-imported hosts) don't produce malformed labels like
[[2607:f130::4f06]]:22.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:13:58 +08:00
陈大猫
196b1f8dbb feat: add terminal smooth scrolling setting (#471)
- Add smoothScrolling boolean to TerminalSettings (default: true)
- Wire setting to xterm.js smoothScrollDuration (120ms when on, 0 when off)
- Add toggle in terminal settings UI
- Include in sync payload and i18n strings (en, zh-CN)

Inspired by #467 (@crawt).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:39:03 +08:00
陈大猫
f1065745bc perf(keyword-highlight): skip cellMap for ASCII lines and share empty result array (#470)
- Use a regex ASCII test to detect lines where string indices equal cell
  columns, skipping the buildStringToCellMap buffer walk entirely. Most
  terminal output is ASCII, so this avoids the majority of cell API calls.
- Share a frozen empty array for non-matching lines instead of allocating
  a new array per scanLine call, reducing GC pressure during scrollback.

Inspired by #466 (@crawt).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:24:39 +08:00
陈大猫
c67befa0e9 perf(keyword-highlight): reduce latency with throttled rAF and line cache (#469)
* perf(keyword-highlight): reduce highlight latency with throttled rAF and line cache

Based on #464 by @crawt with fixes for review feedback:

- Split triggerRefresh into immediate (rAF) and debounced (setTimeout) modes
  so onWriteParsed highlights land with fresh content instead of trailing
  by 200ms
- Throttle the immediate path (50ms min interval) to prevent heavy output
  like tail -f from refreshing every frame
- Add per-line match result cache (LRU, bounded by cacheEntries config)
  so repeated or scrolled-back lines skip regex scanning entirely
- Lazily build cellMap only when a regex match is found, avoiding
  unnecessary work on non-matching lines
- Fix buildStringToCellMap to handle empty cells (codepoint 0) which
  translateToString() renders as spaces — keeps the map aligned with
  the string and makes lineText a safe cache key
- Clean up animationFrameId and matchCache on dispose/rule change

Co-Authored-By: Leo Pan <crawt@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard rAF callback against stale state and add debounce fallback

- Re-check enabled/alternate-buffer inside the rAF callback so a
  pending frame doesn't resurrect decorations after the user disables
  highlighting or enters an alternate-buffer app
- Schedule a debounce timer alongside rAF so background/hidden tabs
  (where Chromium suspends rAF) still get highlight updates

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

* fix: prevent fallback timer from being cleared on rAF-pending path

- Don't clear debounceTimer at the start of immediate mode — in hidden
  tabs rAF stays pending indefinitely, so repeated onWriteParsed calls
  were clearing the only timer that could actually fire
- Cancel debounceTimer inside the rAF callback instead, so foreground
  tabs don't get a redundant second refreshViewport() 200ms later
- Only arm a new fallback timer if one isn't already pending

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

* fix: clear stale rAF in fallback timer and add alternate buffer guard

- Cancel the pending rAF and clear animationFrameId in the fallback
  timer callback so hidden-tab refreshes don't leave animationFrameId
  stuck, which would block all future immediate refreshes
- Add enabled/alternate-buffer re-check in the fallback callback,
  matching the guard already present in the rAF callback

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

* fix: extract executeRefresh to ensure all timer paths clear stale rAF

A debounced-path timer (from scroll/resize) could fire without clearing
a stale animationFrameId left by an earlier immediate-path rAF that
never executed (hidden tab). This left the immediate path permanently
blocked.

Extract executeRefresh() with rAF cleanup + state guards, used by all
three callback sites (rAF, immediate fallback, debounced timer).

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

---------

Co-authored-by: Leo Pan <crawt@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:17:01 +08:00
陈大猫
cea83d6cb1 Revert "Mod:perf(keyword-highlight): reduce highlight latency and redundant regex scanning (#464)" (#468)
This reverts commit 293ee46b26.
2026-03-23 21:46:04 +08:00
Leo Pan
293ee46b26 Mod:perf(keyword-highlight): reduce highlight latency and redundant regex scanning (#464)
* perf(keyword-highlight): reduce highlight latency and redundant regex scanning

- Split triggerRefresh into two modes: "immediate" (rAF, for new output
  and rule changes) and "debounced" (setTimeout, for scroll/resize),
  eliminating the fixed 200ms delay after each write that caused visible
  highlight lag on commands like `ls`.
- Add per-line match result cache (LRU, bounded by cacheEntries config)
  so repeated or scrolled-back lines skip regex scanning entirely.
- Lazily build the string-to-cell column map only when a regex match is
  actually found, avoiding unnecessary work on non-matching lines.
- Clean up animationFrameId and matchCache on dispose/rule change to
  prevent leaks and stale results.

* fix: include cell layout in highlight cache key to prevent misplaced decorations

Two IBufferLines can produce identical translateToString() output but
differ in cell layout (e.g. empty cells vs real space characters after
tab stops). Using lineText alone as the cache key could return cached
x/width ranges computed from a different cell layout, producing
misplaced or truncated highlights.

Build the cellMap eagerly and include it in the cache key so lines with
different cell structures get separate cache entries. Pass the pre-built
cellMap into scanLine to avoid redundant work.

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

---------

Co-authored-by: panwk <panwukan@suangoo.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:43:29 +08:00
陈大猫
a6af1dffed fix: resolve SSH chain connection hang and improve connection progress (#465)
* fix: resolve SSH chain connection hang and improve connection progress

- Fix Promise never settling when conn 'close' fires before 'ready'
  during chain connections, which caused "reply was never sent" error
- Replace fake timed progress animation with real backend events
- Send granular connection progress for all SSH connections (not just
  chain), including: connecting, key exchange, auth attempts, forwarding,
  shell opening
- Surface auth method attempts (SSH agent, key names, password) in
  progress logs so users can diagnose authentication failures
- Include error details in progress events for better error visibility

Closes #463

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

* fix: scope progress events by sessionId, prevent duplicate errors, hide chain UI for direct SSH

- Add sessionId to chain progress payload so events are scoped per session (P1)
- Set settled=true in error/timeout handlers to prevent close handler from
  emitting a second misleading 'closed unexpectedly' error (P2)
- Only show chain progress UI when total > 1 so direct SSH connections
  don't render as 'Chain 1/1' (P3)

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

* fix: mark shell-open failure as settled before closing connection

The conn.shell() error branch calls conn.end() which triggers the close
handler, but settled was not set yet, causing a duplicate 'closed
unexpectedly' error to overwrite the real shell-open failure message.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:28:44 +08:00
陈大猫
0a3e61af4b Merge pull request #462 from binaricat/fix/snippet-execution-order
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: normalize line endings and bracket-paste multi-line snippets
2026-03-23 17:51:06 +08:00
bincxz
9e4a79acd7 fix: remove unconditional bracket paste from sidebar, fix broadcast
- TerminalLayer: remove bracket paste wrapping since we can't check
  term.modes.bracketedPasteMode here — keep only normalizeLineEndings
- createXTermRuntime: broadcast un-wrapped data before applying
  bracket paste, so target sessions don't receive literal escape
  sequences meant for the source terminal's paste mode state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:44:49 +08:00
bincxz
a62353bb41 fix: respect bracketedPasteMode and disableBracketedPaste for snippets
Only wrap multi-line snippets in bracket paste sequences when:
- createXTermRuntime: term.modes.bracketedPasteMode is active AND
  disableBracketedPaste setting is false (matches paste handler)
- TerminalLayer: disableBracketedPaste setting is false (no access
  to term.modes, but respects user opt-out)

Prevents sending literal ^[[200~ escape sequences to shells that
don't support or have disabled bracketed paste mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:39:48 +08:00
bincxz
d2ab27ab92 fix: normalize line endings and bracket-paste multi-line snippets
Snippet execution via sidebar click was missing normalizeLineEndings()
and bracket paste wrapping that the paste handler and shortkey handler
already apply. On Windows ConPTY/PowerShell, sending raw multi-line
input without bracket paste can cause out-of-order line execution
because the shell processes lines individually and asynchronously.

- Add normalizeLineEndings() to sidebar snippet click handler
- Wrap multi-line snippets in bracketed paste sequences (\e[200~...\e[201~)
  so the shell treats them as a single atomic paste
- Apply same fix to shortkey snippet handler for consistency
- Fix broadcast payload to use the processed data

Fixes #455

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:33:36 +08:00
陈大猫
65f62983b6 Merge pull request #461 from binaricat/fix/sftp-home-dir
fix: detect actual home directory for SFTP auto-open
2026-03-23 17:21:16 +08:00
bincxz
56d3109d23 fix: abort timed-out exec channel, treat realpath '/' as ambiguous
- Close/destroy the SSH exec stream when the 5s timeout fires to
  avoid leaking session slots (MaxSessions).
- Treat SFTP realpath('.') returning '/' as non-authoritative so
  non-root users fall through to the candidate probe chain instead
  of incorrectly opening at root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:15:13 +08:00
bincxz
34ab6c0e98 fix: add 5s timeout to SSH echo ~ home dir probe
Prevent indefinite blocking when the remote shell init hangs or a
forced command never exits. Falls through to SFTP realpath after
timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:07:32 +08:00
bincxz
3db9b0aa26 fix: restore listSftp fallback when statSftp is unavailable
Preserve the original fallback behavior for bridges that don't expose
statSftp — probe candidate directories via listSftp instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:03:06 +08:00
陈大猫
fe49ea74e2 Merge pull request #460 from binaricat/fix/update-metadata-verify
ci: verify and recover update metadata after artifact merge
2026-03-23 16:59:38 +08:00
bincxz
be91740582 fix: add actions:read permission for artifact recovery in release job
gh run download requires actions:read scope. Without it, the recovery
step would fail silently when trying to re-download individual artifacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:56:27 +08:00
bincxz
ad15d8ceb5 fix: detect actual home directory for SFTP instead of hardcoding /home
Query the remote server for the real home directory using two methods:
1. SSH exec `echo ~` — works for any user regardless of home path
2. SFTP realpath('.') — fallback, SFTP cwd is typically home dir

Falls back to the previous hardcoded /home/{username} candidates if
both methods fail. This fixes SFTP auto-open sidebar not navigating
to the correct directory for users with non-standard home paths
(e.g. /usr/home, /export/home, custom paths).

Fixes #458

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:54:36 +08:00
bincxz
c37fe8f9e0 ci: verify and recover update metadata after artifact merge
download-artifact@v4 merge-multiple can silently drop files when
multiple artifacts contain same-named files (builder-debug.yml).
This caused latest-mac.yml to be missing from v1.0.64 release.

Add a verification step that checks all platform update yml files
exist after merge. If any are missing, re-downloads individual
artifacts to recover them. Fails the release if recovery fails.

Fixes #456

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:44:52 +08:00
陈大猫
b0924c14b1 Merge pull request #454 from binaricat/feat/crash-logs
feat: crash log capture and viewer in Settings
2026-03-23 15:56:12 +08:00
bincxz
774c25086e fix: truncate crash log env info with tooltip on overflow
Replace flex-wrap layout with single-line truncate + title tooltip
for the environment metadata row, preventing awkward wrapping when
the settings window is narrow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:45:45 +08:00
bincxz
05c0d43bc4 feat: enrich crash logs with error metadata and process details
- Extract error properties (code, errno, syscall, hostname, port,
  signal, level) into errorMeta field for system-level diagnostics.
- Add extra field for structured context (e.g. render-process-gone
  reason and exitCode as separate fields, not just a string).
- Add process PID for correlating with OS-level logs.
- Accept optional extra parameter in captureError() for callers to
  attach structured context data.
- Display errorMeta and extra as tagged badges in the crash log viewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:41:45 +08:00
bincxz
baac8670d3 feat: enrich crash log entries with environment diagnostics
Add electronVersion, osVersion, memoryUsage (RSS/heap in MB),
activeSessionCount, and process uptime to each crash log entry.
Display these fields inline in the Settings crash log viewer.

These extra fields help diagnose issues like #452 where knowing
the session count and memory state at crash time is critical.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:34:02 +08:00
bincxz
c84bf497f2 fix: address codex review round 6 — stream line counting, tail-read logs
- listLogs: stream-count newlines instead of reading entire file content
  just to compute entryCount.
- readLog: read only the last 256KB of large files and parse the tail,
  avoiding O(file_size) memory/CPU for crash-loop scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:23:14 +08:00
bincxz
ac5f708eba fix: address codex review round 5 — filter benign rejections and clean exits
- Skip EPIPE/ERR_STREAM_DESTROYED in unhandledRejection handler to
  avoid false positives in crash logs.
- Skip render-process-gone events with reason 'clean-exit' since
  those are normal shutdowns, not crashes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:12:46 +08:00
bincxz
ecba2560c9 fix: address codex review round 4 — skip benign errors, check openPath result
- Move EPIPE/ERR_STREAM_DESTROYED check before captureError so benign
  stream teardown errors don't pollute crash logs.
- Check shell.openPath return value (error string) instead of always
  returning success: true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:03:27 +08:00
bincxz
ff638c64cd fix: address codex review round 3 — dedupe logs, reload after clear
- Mark re-thrown unhandledRejection errors so uncaughtException handler
  skips duplicate logging.
- Reload crash log list after clearing instead of blindly emptying,
  so partial delete failures still show remaining files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:54:23 +08:00
bincxz
3db6465340 fix: address codex review round 2 — early require, stale request guard
- Move crashLogBridge require before process error handlers so it is
  available if a bridge import throws during startup.
- Add request ID ref to handleExpandCrashLog to discard out-of-order
  results when the user clicks different log files in quick succession.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:21:50 +08:00
bincxz
2b4f8d33c9 fix: address codex review — re-throw unhandled rejections, early crash capture
- P1: Re-throw in unhandledRejection handler to preserve default fatal
  semantics instead of silently swallowing rejections.
- P2: Fall back to require('electron').app.getPath('userData') in
  ensureLogDir() so crash logs work even before init() is called,
  catching early startup failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:14:04 +08:00
bincxz
bc6c0a2ef6 feat: add crash log capture and viewer in Settings > System
Capture main-process errors (uncaughtException, unhandledRejection,
render-process-gone) to JSONL log files in userData/crash-logs/ with
30-day auto-rotation. Users can view, expand, and clear crash logs
from Settings > System to help diagnose issues like #452.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:05:56 +08:00
陈大猫
9cccc943ff Merge pull request #451 from tces1/patch-1 2026-03-23 12:31:30 +08:00
Eric Chan
cecda50ce2 Add 'meslolgs nf' to local fonts list
Fixes an issue on macOS where MesloLGS NF was incorrectly filtered out of the terminal font list
2026-03-23 12:28:30 +08:00
29 changed files with 1226 additions and 181 deletions

View File

@@ -230,6 +230,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -243,6 +244,40 @@ jobs:
- name: List artifacts
run: ls -la artifacts/
- name: Verify update metadata files
run: |
missing=0
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
if [ ! -f "artifacts/$f" ]; then
echo "::warning::Missing $f in merged artifacts, attempting recovery..."
missing=1
fi
done
if [ "$missing" = "1" ]; then
echo "Re-downloading individual artifacts to recover missing files..."
for name in netcatty-macos netcatty-windows netcatty-linux-x64 netcatty-linux-arm64; do
tmpdir="/tmp/artifact-${name}"
gh run download ${{ github.run_id }} --name "${name}" --dir "${tmpdir}" 2>/dev/null || true
if [ -d "${tmpdir}" ]; then
for yml in "${tmpdir}"/latest*.yml; do
[ -f "$yml" ] && cp -v "$yml" artifacts/
done
fi
done
echo "After recovery:"
ls -la artifacts/*.yml
fi
# Final check — fail if any update yml is still missing
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
if [ ! -f "artifacts/$f" ]; then
echo "::error::$f is still missing after recovery attempt"
exit 1
fi
done
echo "All update metadata files present."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify downloaded Linux amd64 deb artifact
run: |
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-amd64.deb' -print | sort | head -n 1)"

View File

@@ -99,6 +99,21 @@ const en: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Crash Logs',
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
'settings.system.crashLogs.noLogs': 'No crash logs found.',
'settings.system.crashLogs.entries': '{count} entries',
'settings.system.crashLogs.clear': 'Clear all logs',
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
'settings.system.crashLogs.source': 'Source',
'settings.system.crashLogs.time': 'Time',
'settings.system.crashLogs.message': 'Message',
'settings.system.crashLogs.stack': 'Stack Trace',
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
'settings.system.crashLogs.collapse': 'Collapse',
'settings.system.crashLogs.expand': 'Show details',
// Settings > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
@@ -296,6 +311,9 @@ const en: Messages = {
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
'settings.terminal.behavior.scrollOnPaste.desc':
'Scroll terminal to bottom when pasting text',
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
'settings.terminal.behavior.smoothScrolling.desc':
'Animate terminal viewport scrolling instead of jumping instantly',
'settings.terminal.behavior.linkModifier': 'Link modifier key',
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',

View File

@@ -83,6 +83,21 @@ const zhCN: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩溃日志',
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
'settings.system.crashLogs.entries': '{count} 条记录',
'settings.system.crashLogs.clear': '清除所有日志',
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
'settings.system.crashLogs.source': '来源',
'settings.system.crashLogs.time': '时间',
'settings.system.crashLogs.message': '消息',
'settings.system.crashLogs.stack': '堆栈跟踪',
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
'settings.system.crashLogs.collapse': '收起',
'settings.system.crashLogs.expand': '查看详情',
// Settings > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
@@ -1204,6 +1219,8 @@ const zhCN: Messages = {
'settings.terminal.behavior.scrollOnKeyPress.desc': '按键(例如 Enter时将终端滚动到底部',
'settings.terminal.behavior.scrollOnPaste': '粘贴时自动滚动',
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
'settings.terminal.behavior.linkModifier': '链接修饰键',
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',

View File

@@ -278,8 +278,24 @@ export const useSftpConnections = ({
let homeDir = sharedHostCache?.homeDir ?? startPath;
if (!sharedHostCache) {
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
// Detect home directory: SSH exec `echo ~` → SFTP realpath('.') → hardcoded fallback
const bridge = netcattyBridge.get();
let detected = false;
if (bridge?.getSftpHomeDir) {
try {
const result = await bridge.getSftpHomeDir(sftpId);
if (result?.success && result.homeDir) {
startPath = result.homeDir;
homeDir = result.homeDir;
detected = true;
}
} catch {
// Fall through to hardcoded candidates
}
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
@@ -289,63 +305,33 @@ export const useSftpConnections = ({
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) {
startPath = `/home/${credentials.username}`;
homeDir = startPath;
}
} catch {
// Fall through to /root check
}
if (startPath === "/") {
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Fallback path not available
// Ignore missing/permission errors
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
// Fallback: probe candidates via listSftp when statSftp is unavailable
for (const candidate of candidates) {
try {
const files = await bridge?.listSftp(sftpId, candidate, filenameEncoding);
if (files) {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
} catch {
// Fallback path not available
}
}
}

View File

@@ -96,7 +96,7 @@ export const useTerminalBackend = () => {
return bridge.onSessionExit(sessionId, cb);
}, []);
const onChainProgress = useCallback((cb: (hop: number, total: number, label: string, status: string) => void) => {
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onChainProgress?.(cb);
}, []);

View File

@@ -13,6 +13,7 @@ import {
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { formatHostPort } from "../domain/host";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
@@ -531,11 +532,11 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
case "protocol":
return target.hostname;
case "username":
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
case "knownhost":
return `${protocol.toUpperCase()} ${effectiveUsername}@${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${effectiveUsername}@${formatHostPort(target.hostname, port)}`;
case "auth":
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
}
};

View File

@@ -11,6 +11,7 @@
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
@@ -518,7 +519,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
/>
<div
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
title={`${displayHost.label} · ${(displayHost.username || "root")}@${displayHost.hostname}:${displayHost.port || 22}`}
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
>
<span className="font-medium">
{displayHost.label}

View File

@@ -635,28 +635,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Local terminal and serial connections don't need timeout/progress UI
if (isLocalConnection || isSerialConnection) return;
// Only show SSH-specific scripted logs for SSH connections
const isSSH = host.protocol !== "telnet";
let stepTimer: ReturnType<typeof setInterval> | undefined;
if (isSSH) {
const scripted = [
"Resolving host and keys...",
"Negotiating ciphers...",
"Exchanging keys...",
"Authenticating user...",
"Waiting for server greeting...",
];
let idx = 0;
stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
}
setTimeLeft(CONNECTION_TIMEOUT / 1000);
const countdown = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
@@ -679,7 +657,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, 200);
return () => {
if (stepTimer) clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
@@ -787,6 +764,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.smoothScrollDuration =
terminalSettings.smoothScrolling
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
: 0;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;

View File

@@ -16,7 +16,7 @@ import {
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from '../domain/terminalAppearance';
import { cn } from '../lib/utils';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
import { useStoredString } from '../application/state/useStoredString';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
@@ -982,8 +982,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
if (!sessionId) return;
const payload = noAutoRun ? command : `${command}\r`;
terminalBackend.writeToSession(sessionId, payload);
let data = normalizeLineEndings(command);
if (!noAutoRun) data = `${data}\r`;
terminalBackend.writeToSession(sessionId, data);
// Re-focus the terminal so the user can interact immediately
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;

View File

@@ -1,7 +1,7 @@
/**
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { AlertTriangle, ChevronDown, ChevronRight, Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
@@ -13,6 +13,31 @@ import { Button } from "../../ui/button";
import { Toggle, Select, SettingRow } from "../settings-ui";
import { cn } from "../../../lib/utils";
interface CrashLogFile {
fileName: string;
date: string;
size: number;
entryCount: number;
}
interface CrashLogEntry {
timestamp: string;
source: string;
message: string;
stack?: string;
errorMeta?: Record<string, unknown>;
extra?: Record<string, unknown>;
pid?: number;
platform?: string;
arch?: string;
version?: string;
electronVersion?: string;
osVersion?: string;
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
activeSessionCount?: number;
uptimeSeconds?: number;
}
interface TempDirInfo {
path: string;
fileCount: number;
@@ -98,6 +123,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
const [crashLogs, setCrashLogs] = useState<CrashLogFile[]>([]);
const [isLoadingCrashLogs, setIsLoadingCrashLogs] = useState(false);
const [expandedLog, setExpandedLog] = useState<string | null>(null);
const [logEntries, setLogEntries] = useState<CrashLogEntry[]>([]);
const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false);
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
const [appVersion, setAppVersion] = useState('');
@@ -144,6 +175,73 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
void loadCredentialProtectionStatus();
}, [loadCredentialProtectionStatus]);
const loadCrashLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getCrashLogs) return;
setIsLoadingCrashLogs(true);
try {
const logs = await bridge.getCrashLogs();
setCrashLogs(logs);
} catch (err) {
console.error("[SettingsSystemTab] Failed to load crash logs:", err);
} finally {
setIsLoadingCrashLogs(false);
}
}, []);
useEffect(() => {
void loadCrashLogs();
}, [loadCrashLogs]);
const expandRequestRef = React.useRef(0);
const handleExpandCrashLog = useCallback(async (fileName: string) => {
if (expandedLog === fileName) {
setExpandedLog(null);
setLogEntries([]);
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.readCrashLog) return;
const requestId = ++expandRequestRef.current;
// Optimistically show expanded state while loading
setExpandedLog(fileName);
setLogEntries([]);
try {
const entries = await bridge.readCrashLog(fileName);
// Discard if user clicked a different file while awaiting
if (expandRequestRef.current !== requestId) return;
setLogEntries(entries);
} catch (err) {
if (expandRequestRef.current !== requestId) return;
console.error("[SettingsSystemTab] Failed to read crash log:", err);
}
}, [expandedLog]);
const handleClearCrashLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearCrashLogs) return;
setIsClearingCrashLogs(true);
setCrashLogClearResult(null);
try {
const result = await bridge.clearCrashLogs();
setCrashLogClearResult(result);
setExpandedLog(null);
setLogEntries([]);
// Reload the list so partial failures still show remaining files
await loadCrashLogs();
} catch (err) {
console.error("[SettingsSystemTab] Failed to clear crash logs:", err);
} finally {
setIsClearingCrashLogs(false);
}
}, [loadCrashLogs]);
const handleOpenCrashLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.openCrashLogsDir) return;
await bridge.openCrashLogsDir();
}, []);
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
@@ -449,6 +547,148 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</div>
</div>
{/* Crash Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.crashLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.description")}
</p>
{crashLogs.length === 0 && !isLoadingCrashLogs && (
<p className="text-sm text-muted-foreground italic">
{t("settings.system.crashLogs.noLogs")}
</p>
)}
{crashLogs.length > 0 && (
<div className="space-y-2">
{crashLogs.map((log) => (
<div key={log.fileName} className="border border-border/60 rounded-md overflow-hidden">
<button
onClick={() => handleExpandCrashLog(log.fileName)}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
{expandedLog === log.fileName ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="font-mono">{log.date}</span>
<span className="text-muted-foreground">
({t("settings.system.crashLogs.entries").replace("{count}", String(log.entryCount))})
</span>
</div>
<span className="text-xs text-muted-foreground">{formatBytes(log.size)}</span>
</button>
{expandedLog === log.fileName && logEntries.length > 0 && (
<div className="border-t border-border/60 max-h-64 overflow-y-auto">
{logEntries.map((entry, idx) => (
<div key={idx} className="px-3 py-2 text-xs border-b border-border/30 last:border-b-0 space-y-1">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-mono text-muted-foreground">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
<span className="px-1.5 py-0.5 rounded bg-destructive/10 text-destructive font-medium">
{entry.source}
</span>
</div>
<p className="font-mono break-all">{entry.message}</p>
{entry.errorMeta && Object.keys(entry.errorMeta).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.errorMeta).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{entry.extra && Object.keys(entry.extra).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.extra).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{(() => {
const parts: string[] = [];
if (entry.version) parts.push(`v${entry.version}`);
if (entry.electronVersion) parts.push(`Electron ${entry.electronVersion}`);
if (entry.platform) parts.push(`${entry.platform}/${entry.arch}`);
if (entry.osVersion) parts.push(`OS ${entry.osVersion}`);
if (entry.pid) parts.push(`PID ${entry.pid}`);
if (entry.activeSessionCount != null && entry.activeSessionCount >= 0) parts.push(`Sessions: ${entry.activeSessionCount}`);
if (entry.memoryMB) parts.push(`RAM: ${entry.memoryMB.rss}MB`);
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
const text = parts.join(' ');
return text ? (
<div className="text-muted-foreground truncate" title={text}>
{text}
</div>
) : null;
})()}
{entry.stack && (
<pre className="mt-1 p-2 bg-muted rounded text-[11px] leading-relaxed overflow-x-auto whitespace-pre-wrap break-all text-muted-foreground">
{entry.stack}
</pre>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={loadCrashLogs}
disabled={isLoadingCrashLogs}
className="gap-1.5"
>
<RefreshCw size={14} className={isLoadingCrashLogs ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClearCrashLogs}
disabled={isClearingCrashLogs || crashLogs.length === 0}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} />
{t("settings.system.crashLogs.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleOpenCrashLogsDir}
title={t("settings.system.openFolder")}
>
<FolderOpen size={16} />
</Button>
</div>
{crashLogClearResult && (
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.cleared").replace("{count}", String(crashLogClearResult.deletedCount))}
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.system.crashLogs.hint")}
</p>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -616,6 +616,13 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={terminalSettings.scrollOnPaste} onChange={(v) => updateTerminalSetting("scrollOnPaste", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.smoothScrolling")}
description={t("settings.terminal.behavior.smoothScrolling.desc")}
>
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.linkModifier")}
description={t("settings.terminal.behavior.linkModifier.desc")}

View File

@@ -7,6 +7,7 @@ import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Host, SSHKey } from '../../types';
import { formatHostPort } from '../../domain/host';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '../ui/button';
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
@@ -85,12 +86,12 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
)}>
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg" />
<div>
<div className="flex items-center gap-3 min-w-0 flex-1">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg shrink-0" />
<div className="min-w-0">
{chainProgress ? (
<>
<div className="text-sm font-semibold">
<div className="text-sm font-semibold truncate">
<span className="text-muted-foreground">
{t('terminal.connection.chainOf', {
current: chainProgress.currentHop,
@@ -100,21 +101,21 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</span>
<span>{chainProgress.currentHostLabel}</span>
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
<div className="text-[11px] text-muted-foreground font-mono truncate">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
) : (
<>
<div className="text-lg font-semibold">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
<div className="text-lg font-semibold truncate">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono truncate">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0 ml-3">
{!needsAuth && (
<Button
size="sm"

View File

@@ -10,6 +10,19 @@ interface CompiledRule {
color: string;
}
interface CachedDecorationRange {
x: number;
width: number;
color: string;
}
/** Shared empty array for non-matching lines to avoid per-call allocations. */
const EMPTY_RANGES: readonly CachedDecorationRange[] = Object.freeze([]);
/** ASCII-only test — when true, string indices equal cell columns. */
// eslint-disable-next-line no-control-regex
const RE_ASCII_ONLY = /^[\x00-\x7f]*$/;
/**
* Manages terminal decorations for keyword highlighting.
* Uses xterm.js Decoration API to overlay styles without modifying the data stream.
@@ -20,6 +33,9 @@ export class KeywordHighlighter implements IDisposable {
private compiledRules: CompiledRule[] = [];
private decorations: { decoration: IDecoration; marker: IMarker }[] = [];
private debounceTimer: NodeJS.Timeout | null = null;
private animationFrameId: number | null = null;
private lastRefreshTime: number = 0;
private matchCache = new Map<string, CachedDecorationRange[]>();
private enabled: boolean = false;
private disposables: IDisposable[] = [];
private lastViewportY: number = -1;
@@ -31,23 +47,22 @@ export class KeywordHighlighter implements IDisposable {
this.disposables.push(
// When user scrolls, refresh visible area
this.term.onScroll(() => {
// console.log('[KeywordHighlighter] onScroll');
this.triggerRefresh();
this.triggerRefresh("debounced");
}),
// When new data is written, refresh
// When new data is written, refresh on the next frame so highlights land
// with the freshly rendered content instead of trailing behind it.
this.term.onWriteParsed(() => {
// console.log('[KeywordHighlighter] onWriteParsed');
this.triggerRefresh();
this.triggerRefresh("immediate");
}),
// Also refresh on resize as viewport content changes
this.term.onResize(() => this.triggerRefresh()),
this.term.onResize(() => this.triggerRefresh("debounced")),
// onRender fires after each render cycle - catch scrolls that onScroll might miss
this.term.onRender(() => {
// Only trigger refresh if viewport position changed
const currentViewportY = this.term.buffer.active?.viewportY ?? 0;
if (currentViewportY !== this.lastViewportY) {
this.lastViewportY = currentViewportY;
this.triggerRefresh();
this.triggerRefresh("debounced");
}
})
);
@@ -55,6 +70,7 @@ export class KeywordHighlighter implements IDisposable {
public setRules(rules: KeywordHighlightRule[], enabled: boolean) {
this.enabled = enabled;
this.matchCache.clear();
// Pre-compile all patterns into regexes for better performance
// This avoids creating new RegExp objects on every viewport refresh
@@ -76,7 +92,7 @@ export class KeywordHighlighter implements IDisposable {
// Clear existing and force an immediate refresh if enabling
this.clearDecorations();
if (this.enabled && this.compiledRules.length > 0) {
this.triggerRefresh();
this.triggerRefresh("immediate");
}
}
@@ -87,9 +103,14 @@ export class KeywordHighlighter implements IDisposable {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.matchCache.clear();
}
private triggerRefresh() {
private triggerRefresh(mode: "immediate" | "debounced") {
if (!this.enabled || this.compiledRules.length === 0) return;
// Optimization: Disable highlighting in Alternate Buffer (e.g. Vim, Htop)
@@ -101,12 +122,72 @@ export class KeywordHighlighter implements IDisposable {
return;
}
if (mode === "immediate") {
// Throttle: skip if a rAF is already pending.
// Don't clear the debounce timer here — in a hidden tab rAF never
// fires, so the fallback timer is the only path that will run.
if (this.animationFrameId !== null) {
return;
}
const now = performance.now();
const minInterval = XTERM_PERFORMANCE_CONFIG.highlighting.immediateMinIntervalMs;
if (now - this.lastRefreshTime < minInterval) {
// Too soon — fall through to debounced path instead of dropping
this.triggerRefresh("debounced");
return;
}
this.animationFrameId = requestAnimationFrame(() => {
this.animationFrameId = null;
// rAF fired — cancel the fallback timer to avoid a redundant refresh
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this.executeRefresh();
});
// Arm a debounced fallback: rAF does not fire in background/hidden
// tabs (Chromium throttles it), so the timer ensures highlights
// still update for ongoing output. If rAF fires first it cancels
// this timer (see above), preventing a double refresh.
if (!this.debounceTimer) {
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.executeRefresh();
}, XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs);
}
return;
}
if (this.animationFrameId !== null) {
return;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
const delay = XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs;
this.debounceTimer = setTimeout(() => this.refreshViewport(), delay);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.executeRefresh();
}, delay);
}
/** Shared refresh execution for both rAF and timer callbacks. */
private executeRefresh() {
// Cancel any stale rAF that will never fire (e.g. hidden tab)
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Re-check state: may have changed since the refresh was scheduled
if (!this.enabled || this.compiledRules.length === 0) return;
if (this.term.buffer.active.type === 'alternate') {
if (this.decorations.length > 0) this.clearDecorations();
return;
}
this.lastRefreshTime = performance.now();
this.refreshViewport();
}
private clearDecorations() {
@@ -140,8 +221,14 @@ export class KeywordHighlighter implements IDisposable {
// Skip continuation cells (width 0) - these are the 2nd cell of wide characters
if (width === 0) continue;
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
if (chars.length > 0) {
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
map.push(cellCol);
}
} else {
// Empty cell (codepoint 0) — translateToString() outputs a space
// for it, so we must push one entry to keep the map aligned.
map.push(cellCol);
}
@@ -177,49 +264,106 @@ export class KeywordHighlighter implements IDisposable {
const lineText = line.translateToString(true); // true = trim right whitespace
if (!lineText) continue;
// Build mapping from string index to cell column for wide char support
const cellMap = this.buildStringToCellMap(line);
const cachedRanges = this.getCachedRanges(line, lineText);
if (cachedRanges.length === 0) continue;
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
for (const range of cachedRanges) {
const marker = this.term.registerMarker(offset);
// Map string indices to cell columns
const cellStartCol = cellMap[strStart] ?? strStart;
const cellEndCol = cellMap[strEnd] ?? strEnd;
const cellWidth = cellEndCol - cellStartCol;
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: range.x,
width: range.width,
foregroundColor: range.color,
});
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
const marker = this.term.registerMarker(offset);
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: cellStartCol,
width: cellWidth,
foregroundColor: color,
});
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
}
}
}
}
private getCachedRanges(line: IBufferLine, lineText: string): CachedDecorationRange[] {
const cached = this.matchCache.get(lineText);
if (cached) {
// LRU: move to end
this.matchCache.delete(lineText);
this.matchCache.set(lineText, cached);
return cached;
}
const ranges = this.scanLine(line, lineText);
this.matchCache.set(lineText, ranges);
const maxEntries = XTERM_PERFORMANCE_CONFIG.highlighting.cacheEntries;
if (this.matchCache.size > maxEntries) {
const oldestKey = this.matchCache.keys().next().value;
if (oldestKey !== undefined) {
this.matchCache.delete(oldestKey);
}
}
return ranges;
}
private scanLine(line: IBufferLine, lineText: string): CachedDecorationRange[] {
// ASCII-only lines have a 1:1 string-index-to-cell-column mapping,
// so we can skip the expensive buildStringToCellMap call entirely.
const asciiOnly = RE_ASCII_ONLY.test(lineText);
let cellMap: number[] | null = null;
let ranges: CachedDecorationRange[] | null = null;
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
let cellStartCol: number;
let cellEndCol: number;
if (asciiOnly) {
cellStartCol = strStart;
cellEndCol = strEnd;
} else {
// Lazily build cellMap only when a match is found
if (cellMap === null) {
cellMap = this.buildStringToCellMap(line);
}
cellStartCol = cellMap[strStart] ?? strStart;
cellEndCol = cellMap[strEnd] ?? strEnd;
}
const cellWidth = cellEndCol - cellStartCol;
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
if (ranges === null) {
ranges = [];
}
ranges.push({
x: cellStartCol,
width: cellWidth,
color,
});
}
}
return ranges ?? (EMPTY_RANGES as CachedDecorationRange[]);
}
}

View File

@@ -44,7 +44,7 @@ type TerminalBackendApi = {
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
) => () => void;
onChainProgress: (
cb: (hop: number, total: number, label: string, status: string) => void,
cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
@@ -403,21 +403,56 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
currentHostLabel:
jumpHosts[0]?.label || jumpHosts[0]?.hostname || ctx.host.hostname,
});
ctx.setProgressLogs((prev) => [
...prev,
`Starting chain connection (${totalHops} hops)...`,
]);
}
const unsub = ctx.terminalBackend.onChainProgress((hop, total, label, status) => {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
ctx.setProgressLogs((prev) => [
...prev,
`Chain ${hop} of ${total}: ${label} - ${status}`,
]);
{
const unsub = ctx.terminalBackend.onChainProgress((sid, hop, total, label, status, error) => {
// P1: Only process events for this session
if (sid !== ctx.sessionId) return;
// P3: Only show chain progress UI for multi-hop connections
if (total > 1) {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
}
// Build human-readable log line
let logLine: string;
const prefix = total > 1 ? `[${hop}/${total}] ` : '';
switch (status) {
case 'connecting':
logLine = `${prefix}${tr("terminal.progress.connecting", "Connecting to")} ${label}...`;
break;
case 'authenticating':
logLine = `${prefix}${label} - ${tr("terminal.progress.keyExchangeComplete", "Key exchange complete")}`;
break;
case 'auth-attempt':
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
break;
case 'authenticated':
logLine = `${prefix}${label} - ${tr("terminal.progress.authenticated", "Authenticated")}`;
break;
case 'connected':
logLine = `${prefix}${label} - ${tr("terminal.progress.connected", "Connected")}`;
break;
case 'forwarding':
logLine = `${prefix}${label} - ${tr("terminal.progress.forwarding", "Forwarding")}...`;
break;
case 'shell':
logLine = `${prefix}${tr("terminal.progress.openingShell", "Opening shell")}...`;
break;
case 'error':
logLine = `${prefix}${label} - ${tr("terminal.progress.error", "Error")}${error ? `: ${error}` : ''}`;
break;
default:
logLine = `${prefix}${label} - ${status}${error ? `: ${error}` : ''}`;
}
ctx.setProgressLogs((prev) => [...prev, logLine]);
const hopProgress = (hop / total) * 80 + 10;
ctx.setProgressValue(Math.min(95, hopProgress));
});

View File

@@ -161,6 +161,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
const smoothScrollDuration = settings?.smoothScrolling
? performanceConfig.options.smoothScrollDuration
: 0;
const altIsMeta = settings?.altAsMeta ?? false;
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
@@ -213,6 +216,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
allowProposedApi: true,
drawBoldTextInBrightColors,
minimumContrastRatio,
smoothScrollDuration,
scrollOnUserInput,
macOptionClickForcesSelection: true,
altClickMovesCursor: !altIsMeta,
@@ -391,13 +395,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
e.preventDefault();
e.stopPropagation();
// Send the snippet command to the terminal
const payload = snippet.noAutoRun
? normalizeLineEndings(snippet.command)
: `${normalizeLineEndings(snippet.command)}\r`;
ctx.terminalBackend.writeToSession(id, payload);
let snippetData = normalizeLineEndings(snippet.command);
if (!snippet.noAutoRun) snippetData = `${snippetData}\r`;
// Broadcast the normalized (un-wrapped) data so each target
// session can apply its own bracket paste state
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
ctx.onBroadcastInputRef.current(snippetData, ctx.sessionId);
}
// Wrap for this terminal only, after broadcasting
const snippetIsMultiLine = snippetData.includes("\n");
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
ctx.terminalBackend.writeToSession(id, snippetData);
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
const cmd = snippet.command.trim();
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);

View File

@@ -48,6 +48,14 @@ export const getEffectiveHostDistro = (
return detected;
};
/** Format hostname:port for display, wrapping IPv6 addresses in brackets. */
export const formatHostPort = (hostname: string, port?: number | null): string => {
if (port == null) return hostname;
const isIPv6 = hostname.includes(':') && !hostname.startsWith('[');
const display = isIPv6 ? `[${hostname}]` : hostname;
return `${display}:${port}`;
};
export const sanitizeHost = (host: Host): Host => {
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);

View File

@@ -410,6 +410,8 @@ export interface TerminalSettings {
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
scrollOnPaste: boolean; // Scroll terminal to bottom on paste
smoothScrolling: boolean; // Animate viewport scrolling instead of jumping instantly
// Mouse
rightClickBehavior: RightClickBehavior;
copyOnSelect: boolean; // Automatically copy selected text
@@ -532,6 +534,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollOnOutput: false,
scrollOnKeyPress: false,
scrollOnPaste: true,
smoothScrolling: true,
rightClickBehavior: 'context-menu',
copyOnSelect: false,
middleClickPaste: true,

View File

@@ -9,15 +9,45 @@ interface QuickConnectParseResult {
warnings: string[];
}
/** Test whether a string looks like a bare (un-bracketed) IPv6 address.
* Must have only hex digits and colons, with either:
* - A "::" shorthand (unambiguously IPv6), or
* - Exactly 7 colons (full 8-group notation like 2607:f130:0:179:0:0:b0df:eec4)
* This avoids false positives on MAC addresses (6 groups, 5 colons). */
const BARE_IPV6_RE = /^[a-fA-F0-9:]+$/;
const isBareIPv6 = (s: string): boolean => {
if (!BARE_IPV6_RE.test(s)) return false;
if (s.includes('::')) return true;
return (s.match(/:/g) || []).length === 7;
};
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
const trimmed = input.trim();
if (!trimmed) return null;
// Pattern: [user@]hostname[:port]
// Hostname can be IP (v4 or v6) or domain name
// Hostname can be IP (v4 or v6 in brackets) or domain name
const regex = /^(?:([^@]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/;
const match = trimmed.match(regex);
if (!match) return null;
// If the main regex fails, try bare IPv6: [user@]ipv6_address
// Bare IPv6 contains colons so the main regex can't distinguish host:port.
// Port must be specified via brackets: [ipv6]:port
if (!match) {
const bareIpv6Regex = /^(?:([^@]+)@)?([a-fA-F0-9:]+)$/;
const bareMatch = trimmed.match(bareIpv6Regex);
if (bareMatch) {
const [, bareUser, bareHost] = bareMatch;
if (isBareIPv6(bareHost)) {
return {
hostname: bareHost,
username: bareUser || undefined,
port: undefined,
};
}
}
return null;
}
const [, username, hostname, portStr] = match;

View File

@@ -74,6 +74,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',

View File

@@ -0,0 +1,326 @@
/**
* Crash Log Bridge - Captures main-process errors and writes them to local log files.
*
* Log files are stored as JSONL (one JSON object per line) under
* {userData}/crash-logs/crash-YYYY-MM-DD.log so that appending is cheap and
* atomic. Files older than 30 days are pruned on startup.
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let logDir = null;
let electronApp = null;
let electronShell = null;
let sessionsMap = null;
const LOG_RETENTION_DAYS = 30;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function ensureLogDir() {
if (logDir) return logDir;
try {
// Try the stored app reference first, then fall back to requiring electron
// directly so crash logging works even before init() is called.
let userDataPath = null;
if (electronApp) {
userDataPath = electronApp.getPath("userData");
} else {
try {
const { app } = require("node:electron");
userDataPath = app?.getPath?.("userData") ?? null;
} catch {
try {
const { app } = require("electron");
userDataPath = app?.getPath?.("userData") ?? null;
} catch {
// Electron not available yet
}
}
}
if (!userDataPath) return null;
logDir = path.join(userDataPath, "crash-logs");
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
return logDir;
} catch {
return null;
}
}
function todayFileName() {
const d = new Date();
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
return `crash-${ymd}.log`;
}
function buildEntry(source, err, extra) {
const error = err instanceof Error ? err : new Error(String(err ?? "unknown"));
let mem;
try {
const m = process.memoryUsage();
mem = {
rss: Math.round(m.rss / 1048576),
heapUsed: Math.round(m.heapUsed / 1048576),
heapTotal: Math.round(m.heapTotal / 1048576),
};
} catch {
// ignore
}
// Extract extra properties from the error object (code, errno, syscall, etc.)
const errorMeta = {};
for (const key of ["code", "errno", "syscall", "hostname", "port", "signal", "level"]) {
if (error[key] !== undefined) {
errorMeta[key] = error[key];
}
}
return {
timestamp: new Date().toISOString(),
source,
message: error.message || String(err),
stack: error.stack || undefined,
errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined,
extra: extra || undefined,
pid: process.pid,
platform: process.platform,
arch: process.arch,
version: electronApp?.getVersion?.() ?? "unknown",
electronVersion: process.versions?.electron ?? "unknown",
osVersion: os.release(),
memoryMB: mem,
activeSessionCount: sessionsMap?.size ?? -1,
uptimeSeconds: Math.round(process.uptime()),
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Write a crash/error entry to today's log file (sync, safe for use in
* uncaughtException handlers).
*/
function captureError(source, err, extra) {
try {
const dir = ensureLogDir();
if (!dir) return;
const entry = buildEntry(source, err, extra);
const filePath = path.join(dir, todayFileName());
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
} catch {
// Never throw from the crash logger itself.
}
}
/**
* Delete log files older than LOG_RETENTION_DAYS.
*/
function pruneOldLogs() {
try {
const dir = ensureLogDir();
if (!dir) return;
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
const files = fs.readdirSync(dir);
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.mtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log(`[CrashLog] Pruned old log: ${file}`);
}
} catch {
// skip
}
}
} catch {
// skip
}
}
// ---------------------------------------------------------------------------
// IPC handlers
// ---------------------------------------------------------------------------
/**
* Count newlines in a file by streaming instead of reading entire content.
*/
async function countLines(filePath) {
return new Promise((resolve) => {
let count = 0;
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
stream.on("data", (chunk) => {
for (let i = 0; i < chunk.length; i++) {
if (chunk[i] === "\n") count++;
}
});
stream.on("end", () => resolve(count));
stream.on("error", () => resolve(0));
});
}
async function listLogs() {
const dir = ensureLogDir();
if (!dir) return [];
try {
const files = await fs.promises.readdir(dir);
const results = [];
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
const filePath = path.join(dir, file);
const stat = await fs.promises.stat(filePath);
const entryCount = await countLines(filePath);
results.push({
fileName: file,
date: file.replace("crash-", "").replace(".log", ""),
size: stat.size,
entryCount,
});
} catch {
// skip unreadable files
}
}
// Sort newest first
results.sort((a, b) => b.date.localeCompare(a.date));
return results;
} catch {
return [];
}
}
const MAX_READ_ENTRIES = 500;
// Read up to ~256KB from the tail of the file to cap memory/CPU usage
const MAX_TAIL_BYTES = 256 * 1024;
async function readLog(fileName) {
const dir = ensureLogDir();
if (!dir) return [];
// Validate fileName to prevent path traversal
if (!/^crash-\d{4}-\d{2}-\d{2}\.log$/.test(fileName)) return [];
try {
const filePath = path.join(dir, fileName);
const stat = await fs.promises.stat(filePath);
let content;
if (stat.size > MAX_TAIL_BYTES) {
// Only read the tail of the file
const buf = Buffer.alloc(MAX_TAIL_BYTES);
const fd = await fs.promises.open(filePath, "r");
try {
await fd.read(buf, 0, MAX_TAIL_BYTES, stat.size - MAX_TAIL_BYTES);
} finally {
await fd.close();
}
const raw = buf.toString("utf-8");
// Drop the first partial line
const firstNewline = raw.indexOf("\n");
content = firstNewline >= 0 ? raw.slice(firstNewline + 1) : raw;
} else {
content = await fs.promises.readFile(filePath, "utf-8");
}
const lines = content.split("\n").filter(Boolean);
// Only parse the last MAX_READ_ENTRIES lines
const tail = lines.slice(-MAX_READ_ENTRIES);
const entries = [];
for (const line of tail) {
try {
entries.push(JSON.parse(line));
} catch {
// skip malformed lines
}
}
return entries;
} catch {
return [];
}
}
async function clearLogs() {
const dir = ensureLogDir();
if (!dir) return { deletedCount: 0 };
let deletedCount = 0;
try {
const files = await fs.promises.readdir(dir);
for (const file of files) {
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
try {
await fs.promises.unlink(path.join(dir, file));
deletedCount++;
} catch {
// skip
}
}
} catch {
// skip
}
return { deletedCount };
}
async function openDir() {
const dir = ensureLogDir();
if (!dir || !electronShell?.openPath) return { success: false };
try {
const errorMessage = await electronShell.openPath(dir);
// shell.openPath resolves to an error string on failure, empty string on success
return { success: !errorMessage };
} catch {
return { success: false };
}
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
function init(deps) {
const { electronModule, sessions } = deps;
const { app, shell } = electronModule || {};
electronApp = app;
electronShell = shell;
sessionsMap = sessions || null;
ensureLogDir();
pruneOldLogs();
console.log(`[CrashLog] Crash log directory: ${logDir}`);
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:crashLogs:list", async () => listLogs());
ipcMain.handle("netcatty:crashLogs:read", async (_event, { fileName }) => readLog(fileName));
ipcMain.handle("netcatty:crashLogs:clear", async () => clearLogs());
ipcMain.handle("netcatty:crashLogs:openDir", async () => openDir());
}
module.exports = {
init,
captureError,
registerHandlers,
};

View File

@@ -225,6 +225,11 @@ const requireSftpChannel = async (client) => {
return sftp;
};
const realpathAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.realpath(targetPath, (err, absPath) => (err ? reject(err) : resolve(absPath)));
});
const statAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
sftp.stat(targetPath, (err, stats) => (err ? reject(err) : resolve(stats)));
@@ -439,7 +444,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
@@ -1586,6 +1591,62 @@ async function chmodSftp(event, payload) {
return true;
}
/**
* Resolve the remote user's home directory.
* Strategy: exec `echo ~` via SSH, fallback to SFTP realpath('.').
*/
async function getSftpHomeDir(_event, payload) {
const { sftpId } = payload;
const client = sftpClients.get(sftpId);
if (!client) return { success: false, error: "SFTP session not found" };
// Method 1: SSH exec `echo ~` (with 5s timeout to avoid hanging on
// hosts with blocking shell init scripts or forced commands)
const sshClient = client.client;
if (sshClient && typeof sshClient.exec === "function") {
let execStream = null;
try {
const execPromise = new Promise((resolve, reject) => {
sshClient.exec("echo ~", (err, stream) => {
if (err) return reject(err);
execStream = stream;
let stdout = "";
stream.on("close", (code) => resolve({ stdout, code }));
stream.on("data", (data) => { stdout += data.toString(); });
stream.stderr.on("data", () => {});
});
});
const result = await Promise.race([
execPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
]);
const home = result.stdout?.trim();
if (home && home.startsWith("/")) {
return { success: true, homeDir: home };
}
} catch {
// Timeout or error — kill the exec channel if still open
try { execStream?.close?.(); } catch {}
try { execStream?.destroy?.(); } catch {}
// Fall through to SFTP realpath
}
}
// Method 2: SFTP realpath('.') — skip if result is '/' for non-root users
// because some SFTP servers start in '/' rather than the user's home
try {
const sftp = await requireSftpChannel(client);
const absPath = await realpathAsync(sftp, ".");
if (absPath && absPath !== "/") {
return { success: true, homeDir: absPath };
}
} catch {
// ignore
}
return { success: false, error: "Could not determine home directory" };
}
/**
* Register IPC handlers for SFTP operations
*/
@@ -1604,6 +1665,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:sftp:rename", renameSftp);
ipcMain.handle("netcatty:sftp:stat", statSftp);
ipcMain.handle("netcatty:sftp:chmod", chmodSftp);
ipcMain.handle("netcatty:sftp:homeDir", getSftpHomeDir);
}
/**

View File

@@ -213,7 +213,7 @@ async function getAvailableAgentSocket() {
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
function buildAuthHandler(options) {
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride } = options;
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride, onAuthAttempt } = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
@@ -394,9 +394,19 @@ function buildAuthHandler(options) {
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
console.log(`${logPrefix} Trying agent auth`);
onAuthAttempt?.("SSH agent");
return callback("agent");
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
console.log(`${logPrefix} Trying publickey auth:`, method.id);
// Build a readable label for the key
const keyLabel = method.id.startsWith("publickey-default-")
? `key ${method.id.replace("publickey-default-", "")}`
: method.id.startsWith("publickey-encrypted-")
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
: method.id === "publickey-user"
? "configured key"
: method.id;
onAuthAttempt?.(keyLabel);
const pubkeyAuth = {
type: "publickey",
username,
@@ -408,12 +418,14 @@ function buildAuthHandler(options) {
return callback(pubkeyAuth);
} else if (method.type === "password" && availableMethods.includes("password")) {
console.log(`${logPrefix} Trying password auth`);
onAuthAttempt?.("password");
return callback({
type: "password",
username,
password,
});
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
onAuthAttempt?.("keyboard-interactive");
return callback("keyboard-interactive");
}
}

View File

@@ -333,9 +333,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
const connections = [];
let currentSocket = null;
const sendProgress = (hop, total, label, status) => {
const sendProgress = (hop, total, label, status, error) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
}
};
@@ -347,7 +347,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
const jump = jumpHosts[i];
const isFirst = i === 0;
const isLast = i === jumpHosts.length - 1;
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
@@ -406,6 +406,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
logPrefix: `[Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
onAuthAttempt: (method) => {
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', method);
},
});
applyAuthToConnOpts(connOpts, authConfig);
@@ -424,6 +427,10 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
// Connect this hop
await new Promise((resolve, reject) => {
conn.once('handshake', () => {
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} handshake complete`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'authenticating');
});
conn.once('ready', () => {
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} connected`);
sendProgress(i + 1, totalHops + 1, hopLabel, 'connected');
@@ -431,12 +438,14 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
});
conn.once('error', (err) => {
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} error:`, err.message);
sendProgress(i + 1, totalHops + 1, hopLabel, 'error');
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', err.message);
reject(err);
});
conn.once('timeout', () => {
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
reject(new Error(`Connection timeout to ${hopLabel}`));
const errMsg = `Connection timeout to ${hopLabel}`;
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', errMsg);
reject(new Error(errMsg));
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
@@ -508,9 +517,9 @@ async function startSSHSession(event, options) {
const rows = options.rows || 24;
const sender = event.sender;
const sendProgress = (hop, total, label, status) => {
const sendProgress = (hop, total, label, status, error) => {
if (!sender.isDestroyed()) {
sender.send("netcatty:chain:progress", { hop, total, label, status });
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
}
};
@@ -850,10 +859,19 @@ async function startSSHSession(event, options) {
// Only log safe identifier, not the full agent object which may contain private keys
const agentType = typeof connectOpts.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth", { id: method.id, agentType });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'SSH agent');
// Return "agent" string to use SSH agent for authentication
return callback("agent");
} else if (method.type === "publickey") {
log("Trying publickey auth", { id: method.id, isDefault: method.isDefault || false });
const keyLabel = method.id.startsWith("publickey-default-")
? `key ${method.id.replace("publickey-default-", "")}`
: method.id.startsWith("publickey-encrypted-")
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
: method.id === "publickey-user"
? "configured key"
: method.id;
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', keyLabel);
return callback({
type: "publickey",
username: connectOpts.username,
@@ -862,6 +880,7 @@ async function startSSHSession(event, options) {
});
} else if (method.type === "password") {
log("Trying password auth", { id: method.id });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'password');
return callback({
type: "password",
username: connectOpts.username,
@@ -869,6 +888,7 @@ async function startSSHSession(event, options) {
});
} else if (method.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth", { id: method.id });
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'keyboard-interactive');
// Return string instead of object - ssh2 requires a prompt function
// for keyboard-interactive objects. Returning the string lets ssh2
// use its default handling and trigger the keyboard-interactive event.
@@ -924,10 +944,20 @@ async function startSSHSession(event, options) {
connectOpts.sock = connectionSocket;
delete connectOpts.host;
delete connectOpts.port;
} else {
// Direct connection (no jump hosts, no proxy)
sendProgress(1, 1, options.hostname, 'connecting');
}
return new Promise((resolve, reject) => {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
let settled = false;
conn.once("handshake", () => {
console.log(`${logPrefix} ${options.hostname} handshake complete`);
sendProgress(totalHops, totalHops, options.hostname, 'authenticating');
});
conn.once("ready", () => {
console.log(`${logPrefix} ${options.hostname} ready`);
@@ -939,9 +969,8 @@ async function startSSHSession(event, options) {
}
}
if (hasJumpHosts || hasProxy) {
sendProgress(totalHops, totalHops, options.hostname, 'connected');
}
sendProgress(totalHops, totalHops, options.hostname, 'authenticated');
sendProgress(totalHops, totalHops, options.hostname, 'shell');
conn.shell(
{
@@ -958,14 +987,18 @@ async function startSSHSession(event, options) {
},
(err, stream) => {
if (err) {
settled = true;
conn.end();
for (const c of chainConnections) {
try { c.end(); } catch { }
}
sendProgress(totalHops, totalHops, options.hostname, 'error', `Failed to open shell: ${err.message}`);
reject(err);
return;
}
sendProgress(totalHops, totalHops, options.hostname, 'connected');
const session = {
conn,
stream,
@@ -1076,6 +1109,7 @@ async function startSSHSession(event, options) {
}, 300);
}
settled = true;
resolve({ sessionId });
}
);
@@ -1102,6 +1136,7 @@ async function startSSHSession(event, options) {
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
}
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
@@ -1110,6 +1145,7 @@ async function startSSHSession(event, options) {
for (const c of chainConnections) {
try { c.end(); } catch { }
}
settled = true;
reject(err);
});
@@ -1117,6 +1153,7 @@ async function startSSHSession(event, options) {
console.error(`${logPrefix} ${options.hostname} connection timeout`);
const err = new Error(`Connection timeout to ${options.hostname}`);
const contents = event.sender;
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
@@ -1125,11 +1162,15 @@ async function startSSHSession(event, options) {
for (const c of chainConnections) {
try { c.end(); } catch { }
}
settled = true;
reject(err);
});
conn.once("close", () => {
const contents = event.sender;
if (!settled) {
sendProgress(totalHops, totalHops, options.hostname, 'error', `Connection to ${options.hostname} closed unexpectedly`);
}
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
@@ -1138,6 +1179,10 @@ async function startSSHSession(event, options) {
for (const c of chainConnections) {
try { c.end(); } catch { }
}
if (!settled) {
settled = true;
reject(new Error(`Connection to ${options.hostname} closed unexpectedly`));
}
});
// Handle keyboard-interactive authentication (2FA/MFA)

View File

@@ -692,6 +692,18 @@ async function createWindow(electronModule, options) {
mainWindow = win;
// Log renderer crashes for diagnostics (skip normal clean exits)
win.webContents.on("render-process-gone", (_event, details) => {
if (details?.reason === "clean-exit") return;
try {
const crashLogBridge = require("./crashLogBridge.cjs");
crashLogBridge.captureError("render-process-gone", new Error(
`Renderer process gone: reason=${details?.reason}, exitCode=${details?.exitCode}`
), { reason: details?.reason, exitCode: details?.exitCode });
} catch {}
console.error("[WindowManager] Renderer process gone:", details);
});
// Prevent top-level navigation away from the app origin. If a remote origin ever
// loads in a privileged window (with preload), it can become an RCE vector.
const allowedOrigins = new Set(["app://netcatty"]);

View File

@@ -18,16 +18,37 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
delete process.env.ELECTRON_RUN_AS_NODE;
}
// Load crash log bridge early so process-level error handlers can use it
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
// Handle uncaught exceptions for EPIPE errors
process.on('uncaughtException', (err) => {
// Skip benign stream teardown errors — don't pollute crash logs with false positives
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
console.warn('Ignored stream error:', err.code);
return;
}
// Skip logging if already captured by unhandledRejection handler
if (!err.__fromUnhandledRejection) {
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
}
console.error('Uncaught exception:', err);
throw err;
});
process.on('unhandledRejection', (reason) => {
// Skip benign stream teardown errors
const code = reason?.code;
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
console.error('Unhandled rejection:', reason);
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
// can skip duplicate logging.
const err = reason instanceof Error ? reason : new Error(String(reason));
err.__fromUnhandledRejection = true;
throw err;
});
// Load Electron
let electronModule;
try {
@@ -85,6 +106,7 @@ const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
const credentialBridge = require("./bridges/credentialBridge.cjs");
const autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
const aiBridge = require("./bridges/aiBridge.cjs");
// crashLogBridge is required at the top of the file (before error handlers)
const windowManager = require("./bridges/windowManager.cjs");
// GPU settings
@@ -381,6 +403,7 @@ const registerBridges = (win) => {
fileWatcherBridge.init(deps);
globalShortcutBridge.init(deps);
aiBridge.init(deps);
crashLogBridge.init(deps);
// Initialize compress upload bridge with transferBridge dependency
compressUploadBridge.init({
@@ -412,6 +435,7 @@ const registerBridges = (win) => {
autoUpdateBridge.init(deps);
autoUpdateBridge.registerHandlers(ipcMain);
aiBridge.registerHandlers(ipcMain);
crashLogBridge.registerHandlers(ipcMain);
// Settings window handler
ipcMain.handle("netcatty:settings:open", async () => {

View File

@@ -123,11 +123,11 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
// Chain progress events (for jump host connections)
ipcRenderer.on("netcatty:chain:progress", (_event, payload) => {
const { hop, total, label, status } = payload;
const { sessionId, hop, total, label, status, error } = payload;
// Notify all registered chain progress listeners
chainProgressListeners.forEach((cb) => {
try {
cb(hop, total, label, status);
cb(sessionId, hop, total, label, status, error);
} catch (err) {
console.error("Chain progress callback failed", err);
}
@@ -605,6 +605,9 @@ const api = {
chmodSftp: async (sftpId, path, mode, encoding) => {
return ipcRenderer.invoke("netcatty:sftp:chmod", { sftpId, path, mode, encoding });
},
getSftpHomeDir: async (sftpId) => {
return ipcRenderer.invoke("netcatty:sftp:homeDir", { sftpId });
},
// Write binary with real-time progress callback
writeSftpBinaryWithProgress: async (sftpId, path, content, transferId, encoding, onProgress, onComplete, onError) => {
// Register callbacks
@@ -918,6 +921,16 @@ const api = {
openSessionLogsDir: (directory) =>
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
// Crash Logs
getCrashLogs: () =>
ipcRenderer.invoke("netcatty:crashLogs:list"),
readCrashLog: (fileName) =>
ipcRenderer.invoke("netcatty:crashLogs:read", { fileName }),
clearCrashLogs: () =>
ipcRenderer.invoke("netcatty:crashLogs:clear"),
openCrashLogsDir: () =>
ipcRenderer.invoke("netcatty:crashLogs:openDir"),
// Global Toggle Hotkey (Quake Mode)
registerGlobalHotkey: (hotkey) =>
ipcRenderer.invoke("netcatty:globalHotkey:register", { hotkey }),

27
global.d.ts vendored
View File

@@ -314,6 +314,7 @@ declare global {
renameSftp?(sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding): Promise<void>;
statSftp?(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpStatResult>;
chmodSftp?(sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding): Promise<void>;
getSftpHomeDir?(sftpId: string): Promise<{ success: boolean; homeDir?: string; error?: string }>;
// Write binary with real-time progress callback
writeSftpBinaryWithProgress?(
@@ -461,8 +462,8 @@ declare global {
onLanguageChanged?(cb: (language: string) => void): () => void;
// Chain progress listener for jump host connections
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
// Callback receives: (sessionId: string, currentHop: number, totalHops: number, hostLabel: string, status: string, error?: string)
onChainProgress?(cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void): () => void;
// OAuth callback server for cloud sync
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
@@ -590,6 +591,28 @@ declare global {
// Temp file cleanup
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
// Crash Logs
getCrashLogs?(): Promise<Array<{ fileName: string; date: string; size: number; entryCount: number }>>;
readCrashLog?(fileName: string): Promise<Array<{
timestamp: string;
source: string;
message: string;
stack?: string;
errorMeta?: Record<string, unknown>;
extra?: Record<string, unknown>;
pid?: number;
platform?: string;
arch?: string;
version?: string;
electronVersion?: string;
osVersion?: string;
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
activeSessionCount?: number;
uptimeSeconds?: number;
}>>;
clearCrashLogs?(): Promise<{ deletedCount: number }>;
openCrashLogsDir?(): Promise<{ success: boolean }>;
// Temp directory management
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;

View File

@@ -36,6 +36,9 @@ export const XTERM_PERFORMANCE_CONFIG = {
// Font rendering settings
letterSpacing: 0,
lineHeight: 1,
// Keep viewport movement smooth without feeling sluggish.
smoothScrollDuration: 120,
},
// WebGL-specific optimizations
@@ -94,6 +97,11 @@ export const XTERM_PERFORMANCE_CONFIG = {
// Debounce time for viewport scanning (ms)
// Higher values = better scrolling performance, but slower highlight "catch up"
debounceMs: 200,
// Minimum interval between immediate (rAF) refreshes in ms.
// Prevents heavy output (e.g. tail -f) from refreshing every frame.
immediateMinIntervalMs: 50,
// Number of unique line scan results to keep cached.
cacheEntries: 1200,
},
};
@@ -110,6 +118,7 @@ export type ResolvedXTermPerformance = {
customGlyphs: boolean;
letterSpacing: number;
lineHeight: number;
smoothScrollDuration: number;
documentOverride: boolean;
tabStopWidth: number;
convertEol: boolean;
@@ -177,6 +186,7 @@ export function resolveXTermPerformanceConfig({
customGlyphs: baseConfig.rendering.customGlyphs,
letterSpacing: baseConfig.rendering.letterSpacing,
lineHeight: baseConfig.rendering.lineHeight,
smoothScrollDuration: baseConfig.rendering.smoothScrollDuration,
documentOverride: baseConfig.events.documentOverride,
tabStopWidth: baseConfig.events.tabStopWidth,
convertEol: baseConfig.events.convertEol,

View File

@@ -54,6 +54,7 @@ const KNOWN_MONOSPACE_FONTS = new Set([
'noto sans mono',
'sarasa mono',
'maple mono',
'meslolgs nf',
]);
/**
@@ -124,4 +125,4 @@ export async function getMonospaceFonts(): Promise<TerminalFont[]> {
console.warn('Failed to query local fonts:', error);
return [];
}
}
}