Compare commits

...

41 Commits

Author SHA1 Message Date
bincxz
edf013164b fix: limit recently connected hosts to 6
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:59:47 +08:00
陈大猫
504b576e1c fix: stop deduplicating pinned/recent hosts from main host list (#632) (#636)
Previously hosts shown in the pinned or recently-connected sections
were excluded from the main list and group view, causing incomplete
group counts and missing hosts under group sort mode.

Closes #632

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:53:46 +08:00
Leo Pan
890abd1c4c Fix/terminal clear preserve scrollback (#633)
* fixd:issure #622

* fix: use baseY instead of viewportY for active screen row count

When the user scrolls up to browse history, viewportY differs from
baseY (the active screen origin). _core.scroll always operates on
the active screen, so counting rows from viewportY preserves the
wrong number of lines and may evict older scrollback unexpectedly.

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

* fix: use term.clear() for local clear to preserve prompt line

The escape sequence \x1b[H\x1b[2J erases the entire display including
the current prompt/input line, which is a regression from term.clear()
that keeps the prompt as the first visible line. Remote CSI 2 J is
already handled separately by the CSI parser handler.

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

* fix: preserve both scrollback and prompt in local clear

term.clear() destroys scrollback (truncates buffer lines). The escape
sequence approach erases the prompt. This commit uses _core.scroll to
push lines above cursor into scrollback, then clears below the prompt
with CSI 0 J and repositions the cursor — preserving both history and
the current prompt line.

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

---------

Co-authored-by: panwk <panwk@88.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 00:03:39 +08:00
陈大猫
0827dd416f fix: truncate long command text in snippet list to prevent layout overflow (#628) (#630)
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
- Use w-0 flex-1 pattern on text containers to enforce width constraint
- Add overflow-hidden on list item containers
- Add tooltip on snippet command text to show full content on hover

Closes #628

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:05:56 +08:00
陈大猫
24df4b6548 fix: support CSV password import and save password in keyboard-interactive auth (#629)
* fix: support CSV password import and save password in keyboard-interactive auth (#627)

- Add Password column support to CSV import/export/template
- Add isAPasswordPrompt detection (prompt contains "password" + echo=false)
- Auto-fill saved password in keyboard-interactive modal
- Add "Save password" checkbox for password prompts in keyboard-interactive modal
- Wire save callback through sessionId → host to persist password

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

* fix: address review feedback for keyboard-interactive and CSV changes

- Merge password field in dedupeHosts to avoid losing passwords from duplicate CSV rows
- Extract isAPasswordPrompt to module-level pure function
- Only render save-password checkbox at the first password prompt index
- Clean up orphaned i18n keys (useSaved, useSavedPassword, fill, fillSaved)

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

* fix: preserve whitespace in CSV imported passwords

Passwords may intentionally contain leading/trailing whitespace.
Removing .trim() ensures lossless CSV round-trip and correct auth.

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

* fix: exclude OTP prompts from password detection and guard jump host save

- Add negative patterns (one-time, otp, verification, token, code) to
  isAPasswordPrompt to avoid auto-filling SSH password into OTP fields
- Only save password when request hostname matches session hostname,
  preventing jump host passwords from overwriting the destination host

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

* fix: skip formula injection guard for password column in CSV export

Password values starting with =, +, -, @ were getting a ' prefix from
the CSV formula injection protection, breaking round-trip fidelity.
Now password column is escaped for CSV syntax only, preserving the
credential verbatim.

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

* fix: only skip formula guard for data rows, not header row

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-04-05 14:39:39 +08:00
陈大猫
7db4b18cce fix: add missing props destructuring in HostTreeView causing white screen (#625) (#626)
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
getDropTargetClasses and setDragOverDropTarget were added to
HostTreeViewProps interface and used in JSX but never destructured
from the component's props parameter. TypeScript didn't catch it
because the interface defined them as optional, but at runtime the
bare variable references caused ReferenceError, crashing React and
producing a white screen on startup.

Closes #625

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:38:15 +08:00
陈大猫
844c55e99d fix: sync built-in editor theme with terminal theme in immersive mode (#623) (#624)
The Monaco editor only synced background color from CSS variables and missed
foreground, cursor, selection, line numbers, and widget colors. Additionally,
switching between terminal themes of the same type (e.g. two dark themes)
did not trigger an editor theme update because the MutationObserver only
watched class/style attributes on <html>.

- Read 6 CSS variables (bg, fg, primary, card, muted-fg, border) and map
  them to 14 Monaco theme color tokens
- Set data-immersive-theme attribute on <html> when immersive mode applies
  a theme, so the MutationObserver detects same-type theme switches
- Clean up the data attribute when immersive mode is removed

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:03:40 +08:00
陈大猫
778b43ceff fix: reset mouse tracking on start over to prevent escape sequence leak (#616) (#621)
When "Start Over" reconnects a session, the xterm instance retained
mouse tracking modes from the previous session. Mouse movements during
reconnection generated SGR mouse sequences (e.g. 35;XX;YYM) that were
sent to the new session as visible text input.

Fix: disable all mouse tracking modes (?1000l, ?1002l, ?1003l, ?1006l)
and reset the terminal before reconnecting.

Closes #616

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 15:03:04 +08:00
陈大猫
6b2e5041d2 fix: sort default shell to top in quick switcher (#613) (#620)
The local shell list was displayed in discovery order (alphabetical),
burying the default shell (e.g. Zsh) at the bottom. Now sorts
isDefault shells to the top of the list.

Closes #613

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:55:46 +08:00
陈大猫
1464cba6da feat: add xterm-container class for custom CSS bottom spacing (#614) (#619)
Add a stable .xterm-container CSS class to the terminal container div
so users can adjust bottom spacing via Custom CSS without color
mismatch issues.

Example custom CSS:
  .xterm-container { bottom: 10px !important; }

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:51:26 +08:00
陈大猫
d74d9e28a0 fix: split shortcut in workspace panes and host delete form freeze (#612) (#618)
* fix: split shortcut in workspace panes and host delete form freeze (#612)

Bug 1: Split-pane shortcuts (Ctrl+Shift+D/E) did nothing after the
first split because the workspace branch in executeHotkeyAction only
logged a message. Now uses workspace.focusedSessionId to split the
focused pane.

Bug 2: Deleting a host left editingHost state pointing to the removed
host, keeping HostDetailsPanel mounted as an overlay that blocked all
form interactions. Added a useEffect to close the panel when the
edited host is no longer in the hosts array.

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

* fix: Shift+right-click context menu and split content loss (#612)

Bug 4: When rightClickBehavior is 'paste' or 'select-word', the context
menu was completely disabled with no fallback. Now Shift+Right-Click
always opens the context menu regardless of the right-click behavior
setting.

Bug 5: Splitting a terminal occasionally caused the original pane's
content to disappear due to a race between layout reflow and xterm
fit(). Added a second delayed fit (350ms) after workspace layout
changes as a safety net for cases where the first fit (100ms) runs
before the container dimensions have settled.

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

* fix: guard host-deletion cleanup against unsaved duplicates

The cleanup effect that closes the host panel on deletion incorrectly
closed it for duplicated/new hosts whose IDs were never in the hosts
array. Track known host IDs via ref so the effect only fires when a
previously-saved host is actually removed.

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

* fix: check previous host IDs before updating ref in deletion cleanup

Merge the two effects into one so the deletion check reads from the
previous knownHostIdsRef before overwriting it with the current hosts.
Previously both effects ran in the same render cycle, causing the ref
to be updated before the check, making it impossible to detect deleted
hosts.

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

* fix: open context menu on first Shift+right-click

Replace state-based forceMenu approach with always-enabled
ContextMenuTrigger. The onContextMenu handler intercepts paste/
select-word actions unless Shift is held, so the Radix context menu
opens immediately on the first Shift+Right-Click without needing a
second click.

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

* fix: fallback to first live pane when workspace focus is stale

When the focused pane is closed, focusedSessionId may point to a
non-existent session. Split shortcuts now fall back to the first
session in the workspace tree via collectSessionIds() so the hotkey
never silently no-ops.

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

* fix: validate focusedSessionId against live workspace panes

focusedSessionId can be stale (non-null but pointing to a closed pane)
after pane closure. Now check it exists in collectSessionIds() before
using it, otherwise fall back to the first live pane.

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-04-03 14:38:02 +08:00
陈大猫
32b74f4fea fix: persist sidebar appearance overrides for quick-connect hosts (#611)
* fix: persist sidebar appearance overrides for quick-connect hosts

Quick-connect hosts (id starting with `quick-`) are not in the saved
hosts array, so per-host overrides set via the sidebar (fontWeight,
theme, fontFamily, fontSize) were silently lost:

1. onUpdateHost only updated existing entries (map), never inserted —
   change to upsert so quick-connect hosts are added on first override.
2. fontWeight handlers guarded on rawHost from hostMap, which is
   undefined for quick-connect hosts — fall back to focusedHost.

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

* fix: only auto-add quick-connect hosts, never re-add deleted saved hosts

Restrict the onUpdateHost upsert to quick-connect hosts (id starts with
`quick-`). This prevents sidebar appearance changes from silently
re-adding a host that was intentionally deleted while its session was
still running.

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

* fix: use primary font only in document.fonts.check to fix bold weight fallback

document.fonts.check returns false when ANY listed font in the family
string is still loading. Our font family strings include a long CJK
fallback chain (Sarasa Mono SC, Noto Sans Mono CJK, PingFang SC, etc.)
that may not be loaded during early terminal creation. This caused
fontWeightBold to incorrectly fall back to the normal fontWeight,
making bold text (including shell prompts) render too thin in freshly
created terminals while live-updated terminals looked correct.

Fix: extract only the primary font family for the check, ignoring the
fallback chain that is irrelevant for bold weight availability.

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

* fix: normalize WebGL fontWeight rendering after terminal connection

Work around xterm.js WebGL renderer bug where glyphs rendered via the
constructor look visually different from those set dynamically. After
the terminal connects and text is on screen, force a fontWeight
round-trip (original → normal → original) so the WebGL texture atlas
rebuilds through the dynamic path, producing consistent rendering
that matches sidebar font weight changes.

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

* fix: use global settings for quick-connect host appearance changes

Quick-connect hosts have ephemeral IDs (quick-${Date.now()}-...) that
are never reused across connections. Auto-adding them to the hosts
array would accumulate orphaned entries over time.

Instead, treat quick-connect hosts like local terminals: sidebar
appearance changes (fontWeight, etc.) update the global terminal
settings rather than creating per-host overrides.

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

* fix: address code review findings

- Apply isFocusedHostEphemeral to theme, fontFamily, fontSize handlers
  (not just fontWeight) so all appearance changes on ephemeral hosts
  update global settings
- Use hostMap.has() instead of id.startsWith('quick-') to detect
  ephemeral hosts — saved hosts with quick- prefix are handled correctly
- Re-read fontWeight at timer fire time to avoid stale closure
- Handle quoted font names with commas in primaryFontFamily parser

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-04-03 13:52:26 +08:00
Eric Chan
f284fb0505 Refine host group drop feedback (#617) 2026-04-03 12:15:07 +08:00
bincxz
1769edb881 fix: use existing common.save i18n key for custom shell modal button
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
2026-04-02 14:38:20 +08:00
bincxz
a7873672c5 Revert "fix: replace native select with project Select component for shell dropdown"
This reverts commit 3261e481ee.
2026-04-02 14:36:04 +08:00
bincxz
d2fe0ecefe feat: replace inline custom shell input with modal dialog
When selecting "Custom..." from the shell dropdown, opens a modal with:
- Full-width input field for shell executable path
- Path validation feedback (valid/not found/is directory)
- Quick-pick buttons for common shell paths
- Confirm/Cancel buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:33:44 +08:00
bincxz
3261e481ee fix: replace native select with project Select component for shell dropdown
Use the same styled Select component as other Settings dropdowns for
visual consistency. Removes the unstyled native <select> element.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:30:05 +08:00
陈大猫
3dfc84918b fix: prevent Chromium from consuming Alt+Arrow as browser navigation (#608)
* fix: prevent Chromium from consuming Alt+Arrow as browser navigation (#606)

Chromium intercepts Alt+Left/Right as back/forward navigation shortcuts,
which prevents these keys from reaching the terminal (needed by byobu,
tmux, etc. for window switching). Block this at the Electron level via
before-input-event so the keys pass through to xterm.js and the remote shell.

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

* fix: use setIgnoreMenuShortcuts instead of preventDefault for Alt+Arrow

preventDefault in before-input-event blocks the keydown from reaching
xterm.js. Instead, use setIgnoreMenuShortcuts to disable Chromium's
built-in navigation shortcut while letting the key event pass through
to the terminal renderer.

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-04-02 14:27:08 +08:00
bincxz
3dc9581be6 Revert "fix: prevent Chromium from consuming Alt+Arrow as browser navigation (#606)"
This reverts commit 4e7d69c9ff.
2026-04-02 14:13:06 +08:00
bincxz
4e7d69c9ff fix: prevent Chromium from consuming Alt+Arrow as browser navigation (#606)
Chromium intercepts Alt+Left/Right as back/forward navigation shortcuts,
which prevents these keys from reaching the terminal (needed by byobu,
tmux, etc. for window switching). Block this at the Electron level via
before-input-event so the keys pass through to xterm.js and the remote shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 14:06:04 +08:00
bincxz
7649243021 fix: replace font weight slider with select dropdown 2026-04-02 12:43:40 +08:00
bincxz
b770dbe6f5 fix: widen scrollbar hit area (12px track, 6px slider) for smoother dragging 2026-04-02 12:42:03 +08:00
bincxz
1e0979e441 fix: persist fontWeight in group config save, fix stale closure in font-loading effect
- Add fontWeight/fontWeightOverride to GroupDetailsPanel handleSubmit whitelist
- Add effectiveFontWeight to async font-loading effect dependency array

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:21:09 +08:00
bincxz
9dbd2a5cf7 fix: use raw host for font weight save, fix bold fallback to use effective weight
- Font weight change/reset now patches the raw (un-merged) host record
  instead of writing back the merged host with group defaults baked in
- Bold font fallback uses effectiveFontWeight (per-host) instead of
  global terminalSettings.fontWeight in both update paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:12:27 +08:00
bincxz
702700d93c fix: live-sync font weight and scrollbar colors on theme/setting changes
- Font weight now updates on running terminals when slider is adjusted
  (uses per-host effectiveFontWeight instead of global terminalSettings)
- Scrollbar theme colors preserved when switching terminal themes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:54:57 +08:00
bincxz
0413e02bf0 feat: make font weight a per-host setting with override support
- Add fontWeight/fontWeightOverride to Host and GroupConfig interfaces
- Add resolve/has/clear helpers in terminalAppearance.ts
- Wire per-host font weight through TerminalLayer → ThemeSidePanel
- ThemeSidePanel shows "Use Global" button when host overrides weight
- createXTermRuntime resolves per-host font weight
- Add to INHERITABLE_KEYS for group config inheritance

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:46:45 +08:00
bincxz
1cccbfe5fb fix: update renderer description text from Canvas to DOM
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:39:13 +08:00
bincxz
1c5960a054 feat: add font weight slider to terminal theme side panel
- Add range slider (100-900) in the Font tab of ThemeSidePanel
- Wire through TerminalLayer → App.tsx → useSettingsState
- Changes persist immediately via updateTerminalSetting('fontWeight')
- Display current weight value in status bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:13:37 +08:00
bincxz
2ae1219bb7 fix: make scrollbar thinner (5px) 2026-04-02 11:05:04 +08:00
bincxz
591b2ba010 fix: slim down xterm 6.0 scrollbar width to 8px with rounded corners
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:04:02 +08:00
bincxz
e26f1350f5 feat(xterm-6): add scrollbar theming and cleanup log messages
- Add scrollbar slider theme colors derived from foreground color
  (scrollbarSliderBackground/Hover/Active — new in xterm 6.0)
- Update log messages to say 'DOM' instead of 'canvas'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:01:59 +08:00
bincxz
d36fc2db1b fix: use correct unicode version name '15-graphemes' 2026-04-02 10:47:43 +08:00
bincxz
32ebc01552 feat: upgrade xterm.js to 6.0.0 with all addons
- @xterm/xterm: 5.5.0 → 6.0.0
- @xterm/addon-webgl: 0.18.0 → 0.19.0
- @xterm/addon-fit: 0.10.0 → 0.11.0
- @xterm/addon-search: 0.15.0 → 0.16.0
- @xterm/addon-serialize: 0.13.0 → 0.14.0
- @xterm/addon-web-links: 0.11.0 → 0.12.0
- Replace @xterm/addon-unicode11 with @xterm/addon-unicode-graphemes
  for more accurate CJK/emoji character width handling
- Enable rescaleOverlappingGlyphs for CJK glyph rendering compliance
- Replace 'canvas' renderer option with 'dom' (canvas removed in 6.0)
- Migrate saved 'canvas' setting to 'dom' automatically
- Fixes WebGL glyph atlas corruption causing garbled text (#5278)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:45:27 +08:00
bincxz
6f93a741ff fix: remove accent bar from pwsh icon 2026-04-02 10:26:42 +08:00
bincxz
d77b0531f6 fix: use rounded rectangle for fish shell icon 2026-04-02 10:25:02 +08:00
bincxz
0bc45417c7 fix: redesign shell icons without window chrome
Remove macOS traffic light dots and title bars from shell SVG icons.
Replace with clean, simple, iconic designs using rounded squares,
bold typography, and distinctive colors for each shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:20:25 +08:00
bincxz
fd88b3a36b chore: remove superpowers plan/spec docs from repo
These are local working documents and should not be tracked in git.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:02:37 +08:00
陈大猫
6ac36be04b feat: local shell selection with auto-discovery (#605) 2026-04-02 08:59:49 +08:00
陈大猫
8ed1588fdb feat: add per-host option for Backspace sends ^H (#604)
* feat: add per-host option for Backspace sends ^H (#602)

Add backspaceSendsCtrlH option at host and group level to send ^H (0x08)
instead of DEL (0x7F) when pressing Backspace, for legacy system compatibility.

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

* feat: add per-host backspace behavior option (#602)

Add backspaceBehavior option at host and group level. When not configured,
xterm default behavior is preserved with zero interception. When set to
'ctrl-h', remaps DEL (0x7F) → ^H (0x08) for legacy system compatibility.

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

* fix: use remapped backspace byte for broadcast input

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-04-01 23:39:04 +08:00
陈大猫
762255443b fix: deduplicate font list when local fonts overlap with built-in fonts (#586) (#603)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:10:43 +08:00
Rory Chou
fdf38b0a6a [codex] Fix SFTP editor close-tab hotkey handling (#598)
* fix sftp editor close-tab hotkey handling

* fix close-tab hotkey routing for open dialogs

* refine dialog close-tab fallback handling
2026-04-01 17:29:55 +08:00
54 changed files with 2112 additions and 297 deletions

114
App.tsx
View File

@@ -36,6 +36,7 @@ import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './componen
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { classifyLocalShellType } from './lib/localShell';
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
@@ -186,6 +187,7 @@ function App({ settings }: { settings: SettingsState }) {
terminalFontSize,
setTerminalFontSize,
terminalSettings,
updateTerminalSetting,
hotkeyScheme,
keyBindings,
isHotkeyRecording,
@@ -204,6 +206,8 @@ function App({ settings }: { settings: SettingsState }) {
workspaceFocusStyle,
} = settings;
const discoveredShells = useDiscoveredShells();
// Sync workspace focus indicator style to DOM for CSS targeting
useEffect(() => {
if (workspaceFocusStyle === 'border') {
@@ -487,6 +491,25 @@ function App({ settings }: { settings: SettingsState }) {
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => {
const isMac = hotkeyScheme === 'mac';
const target = e.target as HTMLElement;
const isCloseTabHotkey = closeTabKeyStr ? matchesKeyBinding(e, closeTabKeyStr, isMac) : false;
const dialogHotkeyScope = target.closest?.('[data-hotkey-close-tab="true"]');
if (isCloseTabHotkey && dialogHotkeyScope) {
return;
}
if (isCloseTabHotkey) {
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (topmostDialogClose) {
e.preventDefault();
e.stopPropagation();
topmostDialogClose.click();
return;
}
}
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
@@ -699,6 +722,7 @@ function App({ settings }: { settings: SettingsState }) {
// Add to queue instead of replacing - supports multiple concurrent sessions
setKeyboardInteractiveQueue(prev => [...prev, {
requestId: request.requestId,
sessionId: request.sessionId,
name: request.name,
instructions: request.instructions,
prompts: request.prompts,
@@ -713,14 +737,29 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[]) => {
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondKeyboardInteractive) {
void bridge.respondKeyboardInteractive(requestId, responses, false);
}
// Save password to host if requested
if (savePassword) {
const request = keyboardInteractiveQueue.find(r => r.requestId === requestId);
if (request?.sessionId) {
const session = sessions.find(s => s.id === request.sessionId);
// Only save when the prompting hostname matches the session's host,
// to avoid overwriting the destination host's password with a jump host's password
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
const host = hosts.find(h => h.id === session.hostId);
if (host) {
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
}
}
}
}
// Remove from queue by requestId
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
}, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
// Handle keyboard-interactive cancel
const handleKeyboardInteractiveCancel = useCallback((requestId: string) => {
@@ -811,22 +850,37 @@ function App({ settings }: { settings: SettingsState }) {
addConnectionLogRef.current = addConnectionLog;
const createLocalTerminalWithCurrentShell = useCallback(() => {
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
return createLocalTerminal({
shellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
shellArgs: resolved?.args,
shellName: matchedShell?.name,
shellIcon: matchedShell?.icon,
});
}, [createLocalTerminal, terminalSettings.localShell]);
}, [createLocalTerminal, terminalSettings.localShell, discoveredShells]);
const splitSessionWithCurrentShell = useCallback((sessionId: string, direction: 'horizontal' | 'vertical') => {
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
return splitSession(sessionId, direction, {
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
});
}, [splitSession, terminalSettings.localShell]);
}, [splitSession, terminalSettings.localShell, discoveredShells]);
const copySessionWithCurrentShell = useCallback((sessionId: string) => {
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
return copySession(sessionId, {
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
});
}, [copySession, terminalSettings.localShell]);
}, [copySession, terminalSettings.localShell, discoveredShells]);
const closeTabKeyStr = useMemo(() => {
if (hotkeyScheme === 'disabled') return null;
const closeTabBinding = keyBindings.find((binding) => binding.action === 'closeTab');
if (!closeTabBinding) return null;
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
}, [hotkeyScheme, keyBindings]);
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
@@ -931,32 +985,32 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
case 'splitHorizontal': {
// Split current terminal horizontally (top/bottom)
const currentId = activeTabStore.getActiveTabId();
// Check if it's a standalone session or we're in a workspace
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
} else if (activeWs) {
// In a workspace - need to determine focused session
// For now, we'll need the terminal to handle this via context menu
if (IS_DEV) console.log('[Hotkey] Split horizontal in workspace - use context menu on specific terminal');
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'horizontal');
}
break;
}
case 'splitVertical': {
// Split current terminal vertically (left/right)
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSessionWithCurrentShell(activeSession.id, 'vertical');
} else if (activeWs) {
// In a workspace - need to determine focused session
if (IS_DEV) console.log('[Hotkey] Split vertical in workspace - use context menu on specific terminal');
const liveIds = collectSessionIds(activeWs.root);
const targetId = (activeWs.focusedSessionId && liveIds.includes(activeWs.focusedSessionId))
? activeWs.focusedSessionId
: liveIds[0];
if (targetId) splitSessionWithCurrentShell(targetId, 'vertical');
}
break;
}
@@ -1065,13 +1119,24 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
// Wrapper to create local terminal with logging
const handleCreateLocalTerminal = useCallback(() => {
const handleCreateLocalTerminal = useCallback((shell?: { command: string; args?: string[]; name?: string; icon?: string }) => {
const { username, hostname } = systemInfoRef.current;
const sessionId = createLocalTerminalWithCurrentShell();
const resolved = shell ?? resolveShellSetting(terminalSettings.localShell, discoveredShells);
// Match by ID (not command) to avoid WSL distros all sharing wsl.exe
const matchedShell = !shell ? discoveredShells.find(s => s.id === terminalSettings.localShell) : undefined;
const shellName = shell?.name ?? matchedShell?.name;
const shellIcon = shell?.icon ?? matchedShell?.icon;
const sessionId = createLocalTerminal({
shellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, navigator.userAgent),
shell: resolved?.command,
shellArgs: resolved?.args,
shellName,
shellIcon,
});
addConnectionLog({
sessionId,
hostId: '',
hostLabel: 'Local Terminal',
hostLabel: shellName || 'Local Terminal',
hostname: 'localhost',
username: username,
protocol: 'local',
@@ -1080,7 +1145,7 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: hostname,
saved: false,
});
}, [addConnectionLog, createLocalTerminalWithCurrentShell]);
}, [addConnectionLog, createLocalTerminal, terminalSettings.localShell, discoveredShells]);
const resolveEffectiveHost = useCallback((host: Host): Host => {
if (!host.group) return host;
@@ -1423,6 +1488,7 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateTerminalThemeId={setTerminalThemeId}
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
onUpdateTerminalFontSize={setTerminalFontSize}
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
onCloseSession={closeSession}
onUpdateSessionStatus={handleSessionStatusChange}
onUpdateHostDistro={updateHostDistro}
@@ -1487,8 +1553,8 @@ function App({ settings }: { settings: SettingsState }) {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateLocalTerminal={() => {
handleCreateLocalTerminal();
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}

View File

@@ -343,6 +343,11 @@ const en: Messages = {
'settings.terminal.localShell.shell.detected': 'Detected',
'settings.terminal.localShell.shell.notFound': 'Shell executable not found',
'settings.terminal.localShell.shell.isDirectory': 'Path is a directory, not an executable',
'settings.terminal.localShell.shell.default': 'System Default',
'settings.terminal.localShell.shell.custom': 'Custom...',
'settings.terminal.localShell.shell.customPath': 'Shell executable path',
'settings.terminal.localShell.shell.commonPaths': 'Common paths',
'settings.terminal.localShell.shell.pathValid': 'Path valid',
'settings.terminal.localShell.startDir': 'Starting directory',
'settings.terminal.localShell.startDir.desc': 'Directory to start in when opening a local terminal. Leave empty for home directory.',
'settings.terminal.localShell.startDir.placeholder': 'Home directory',
@@ -361,7 +366,7 @@ const en: Messages = {
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Rendering',
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
// Settings > Terminal > Workspace Focus Indicator
@@ -524,6 +529,7 @@ const en: Messages = {
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
@@ -911,6 +917,8 @@ const en: Messages = {
'qs.search.placeholder': 'Search hosts or tabs',
'qs.jumpTo': 'Jump To',
'qs.localTerminal': 'Local Terminal',
'qs.localShells': 'Local Shells',
'qs.default': 'Default',
// Select Host panel
'selectHost.title': 'Select Host',
@@ -1008,6 +1016,8 @@ const en: Messages = {
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
@@ -1215,6 +1225,7 @@ const en: Messages = {
'terminal.themeModal.globalTheme': 'Global Theme',
'terminal.themeModal.globalFont': 'Global Font',
'terminal.themeModal.fontSize': 'Font Size',
'terminal.themeModal.fontWeight': 'Font Weight',
'terminal.themeModal.livePreview': 'Live Preview',
'terminal.themeModal.themeType': '{type} theme',
@@ -1633,10 +1644,7 @@ const en: Messages = {
'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.fill': 'Fill',
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
'keyboard.interactive.savePassword': 'Save password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',

View File

@@ -349,6 +349,7 @@ const zhCN: Messages = {
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
@@ -563,6 +564,8 @@ const zhCN: Messages = {
'qs.search.placeholder': '搜索主机或标签页',
'qs.jumpTo': '跳转到',
'qs.localTerminal': '本地终端',
'qs.localShells': '本地 Shell',
'qs.default': '默认',
// Select Host panel
'selectHost.title': '选择主机',
@@ -656,6 +659,8 @@ const zhCN: Messages = {
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
@@ -835,6 +840,7 @@ const zhCN: Messages = {
'terminal.themeModal.globalTheme': '全局主题',
'terminal.themeModal.globalFont': '全局字体',
'terminal.themeModal.fontSize': '字体大小',
'terminal.themeModal.fontWeight': '字体粗细',
'terminal.themeModal.livePreview': '实时预览',
'terminal.themeModal.themeType': '{type} 主题',
@@ -1319,6 +1325,11 @@ const zhCN: Messages = {
'settings.terminal.localShell.shell.detected': '检测到',
'settings.terminal.localShell.shell.notFound': '未找到 Shell 可执行文件',
'settings.terminal.localShell.shell.isDirectory': '路径是目录,不是可执行文件',
'settings.terminal.localShell.shell.default': '系统默认',
'settings.terminal.localShell.shell.custom': '自定义...',
'settings.terminal.localShell.shell.customPath': 'Shell 可执行文件路径',
'settings.terminal.localShell.shell.commonPaths': '常用路径',
'settings.terminal.localShell.shell.pathValid': '路径有效',
'settings.terminal.localShell.startDir': '起始目录',
'settings.terminal.localShell.startDir.desc': '打开本地终端时的起始目录。留空使用用户主目录。',
'settings.terminal.localShell.startDir.placeholder': '用户主目录',
@@ -1337,7 +1348,7 @@ const zhCN: Messages = {
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Terminal > Autocomplete
@@ -1640,10 +1651,7 @@ const zhCN: Messages = {
'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.fill': '填入',
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
'keyboard.interactive.savePassword': '保存密码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',

View File

@@ -68,8 +68,14 @@ class FontStore {
// Add default fonts first
TERMINAL_FONTS.forEach(font => fontMap.set(font.id, font));
// Add local fonts with a distinct ID namespace to avoid collisions
// Build a set of built-in font family names for dedup (case-insensitive)
const builtinFamilyNames = new Set(
TERMINAL_FONTS.map(f => f.name.toLowerCase())
);
// Add local fonts, skipping those already covered by built-in fonts
localFonts.forEach(font => {
if (builtinFamilyNames.has(font.name.toLowerCase())) return;
const localId = font.id.startsWith('local-') ? font.id : `local-${font.id}`;
fontMap.set(localId, { ...font, id: localId });
});

View File

@@ -144,6 +144,7 @@ function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
function removeImmersiveStyle() {
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.immersiveTheme;
}
// ---------------------------------------------------------------------------
@@ -174,6 +175,7 @@ export function useImmersiveMode({
overrideActiveRef.current = true;
appliedFpRef.current = fp;
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
document.documentElement.dataset.immersiveTheme = fp;
}
}, [isTerminalTab, activeTerminalTheme]);

View File

@@ -40,18 +40,26 @@ export const useSessionState = () => {
const createLocalTerminal = useCallback((options?: {
shellType?: TerminalSession['shellType'];
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
}) => {
const sessionId = crypto.randomUUID();
const localHostId = `local-${sessionId}`;
const newSession: TerminalSession = {
id: sessionId,
hostId: localHostId,
hostLabel: 'Local Terminal',
hostLabel: options?.shellName || 'Local Terminal',
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: options?.shellType,
localShell: options?.shell,
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
@@ -451,6 +459,10 @@ export const useSessionState = () => {
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
// Add pane to existing workspace
@@ -483,6 +495,10 @@ export const useSessionState = () => {
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
const hint: SplitHint = {
@@ -659,6 +675,10 @@ export const useSessionState = () => {
shellType: nextShellType,
charset: session.charset,
serialConfig: session.serialConfig,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
};
setActiveTabId(newSession.id);

View File

@@ -99,7 +99,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
c.protocol === 'ssh' ||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
!!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined ||
!!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
(c.identityFilePaths && c.identityFilePaths.length > 0);
@@ -149,6 +149,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
delete next.agentForwarding;
delete next.startupCommand;
delete next.legacyAlgorithms;
delete next.backspaceBehavior;
delete next.proxyConfig;
delete next.hostChain;
delete next.environmentVariables;
@@ -305,6 +306,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
...(form.backspaceBehavior !== undefined && { backspaceBehavior: form.backspaceBehavior }),
...(form.proxyConfig !== undefined && { proxyConfig: form.proxyConfig }),
...(form.hostChain !== undefined && { hostChain: form.hostChain }),
...(form.environmentVariables !== undefined && { environmentVariables: form.environmentVariables }),
@@ -326,6 +328,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
...(form.fontFamilyOverride !== undefined && { fontFamilyOverride: form.fontFamilyOverride }),
...(form.fontSize !== undefined && { fontSize: form.fontSize }),
...(form.fontSizeOverride !== undefined && { fontSizeOverride: form.fontSizeOverride }),
...(form.fontWeight !== undefined && { fontWeight: form.fontWeight }),
...(form.fontWeightOverride !== undefined && { fontWeightOverride: form.fontWeightOverride }),
};
const nameChanged = trimmedName !== originalName;
@@ -799,6 +803,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
/>
{/* Backspace behavior */}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
value={form.backspaceBehavior ?? ""}
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
>
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
<option value="ctrl-h">^H (0x08)</option>
</select>
</div>
{/* Proxy */}
<button
type="button"

View File

@@ -1605,6 +1605,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
value={form.backspaceBehavior ?? ""}
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
>
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
<option value="ctrl-h">^H (0x08)</option>
</select>
</div>
</Card>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}

View File

@@ -36,6 +36,8 @@ interface HostTreeViewProps {
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
}
interface TreeNodeProps {
@@ -61,6 +63,8 @@ interface TreeNodeProps {
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
}
@@ -87,6 +91,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
@@ -140,6 +146,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
<div
className={cn(
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
getDropTargetClasses?.(node.path),
)}
style={{ paddingLeft }}
draggable
@@ -147,10 +154,19 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(node.path);
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget?.(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget?.(null);
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
@@ -242,6 +258,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
@@ -425,9 +443,11 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
}) => {
const { t } = useI18n();
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
@@ -548,6 +568,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
@@ -578,4 +600,4 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
)}
</div>
);
};
};

View File

@@ -4,7 +4,7 @@
* This modal displays prompts from the SSH server and collects user responses.
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
@@ -24,6 +24,7 @@ export interface KeyboardInteractivePrompt {
export interface KeyboardInteractiveRequest {
requestId: string;
sessionId?: string;
name: string;
instructions: string;
prompts: KeyboardInteractivePrompt[];
@@ -31,9 +32,18 @@ export interface KeyboardInteractiveRequest {
savedPassword?: string | null;
}
const isAPasswordPrompt = (prompt: KeyboardInteractivePrompt) => {
if (prompt.echo) return false;
const lower = prompt.prompt.toLowerCase();
if (!lower.includes("password")) return false;
// Exclude OTP / one-time password / verification code prompts
if (lower.includes("one-time") || lower.includes("otp") || lower.includes("verification") || lower.includes("token") || lower.includes("code")) return false;
return true;
};
interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[]) => void;
onSubmit: (requestId: string, responses: string[], savePassword?: string) => void;
onCancel: (requestId: string) => void;
}
@@ -46,15 +56,28 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const [responses, setResponses] = useState<string[]>([]);
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePassword, setSavePassword] = useState(false);
// Index of the first password prompt (if any)
const passwordPromptIndex = useMemo(() => {
if (!request) return -1;
return request.prompts.findIndex(p => isAPasswordPrompt(p));
}, [request]);
// Reset state when request changes
useEffect(() => {
if (request) {
setResponses(request.prompts.map(() => ""));
const initial = request.prompts.map(() => "");
// Auto-fill saved password into the password prompt
if (request.savedPassword && passwordPromptIndex >= 0) {
initial[passwordPromptIndex] = request.savedPassword;
}
setResponses(initial);
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
setSavePassword(false);
}
}, [request]);
}, [request, passwordPromptIndex]);
const handleResponseChange = useCallback((index: number, value: string) => {
setResponses((prev) => {
@@ -75,8 +98,11 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const handleSubmit = useCallback(() => {
if (!request || isSubmitting) return;
setIsSubmitting(true);
onSubmit(request.requestId, responses);
}, [request, responses, onSubmit, isSubmitting]);
const passwordToSave = savePassword && passwordPromptIndex >= 0
? responses[passwordPromptIndex]
: undefined;
onSubmit(request.requestId, responses, passwordToSave);
}, [request, responses, onSubmit, isSubmitting, savePassword, passwordPromptIndex]);
const handleCancel = useCallback(() => {
if (!request) return;
@@ -154,19 +180,20 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</button>
)}
</div>
{/* Use saved password button - shown below input, right-aligned */}
{isPassword && request.savedPassword && !responses[index] && (
<div className="flex justify-end">
<button
type="button"
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 disabled:opacity-50"
onClick={() => handleResponseChange(index, request.savedPassword!)}
{/* Save password checkbox - shown only for the first password prompt */}
{index === passwordPromptIndex && (
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={savePassword}
onChange={(e) => setSavePassword(e.target.checked)}
disabled={isSubmitting}
>
<KeyRound size={12} />
<span>{t("keyboard.interactive.useSavedPassword")}</span>
</button>
</div>
className="accent-primary"
/>
<span className="text-xs text-muted-foreground">
{t("keyboard.interactive.savePassword")}
</span>
</label>
)}
</div>
);

View File

@@ -10,9 +10,10 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, TerminalSession, Workspace } from "../types";
import { KeyBinding } from "../domain/models";
import { useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
type QuickSwitcherItem = {
type: "host" | "tab" | "workspace" | "action";
type: "host" | "tab" | "workspace" | "action" | "shell";
id: string;
data?: Host | TerminalSession | Workspace;
};
@@ -66,7 +67,7 @@ interface QuickSwitcherProps {
onSelect: (host: Host) => void;
onSelectTab: (tabId: string) => void;
onClose: () => void;
onCreateLocalTerminal?: () => void;
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
// onCreateWorkspace removed - feature not currently used
keyBindings?: KeyBinding[];
}
@@ -85,6 +86,18 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
keyBindings,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
const filteredShells = useMemo(() => {
const list = !query.trim()
? discoveredShells
: discoveredShells.filter(
(s) => s.name.toLowerCase().includes(query.toLowerCase()) || s.id.toLowerCase().includes(query.toLowerCase())
);
// Default shell first
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
}, [discoveredShells, query]);
// Get hotkey display strings
const getHotkeyLabel = useCallback((actionId: string) => {
const binding = keyBindings?.find(k => k.id === actionId);
@@ -155,13 +168,23 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
workspaces.forEach((w) =>
items.push({ type: "workspace", id: w.id, data: w }),
);
// Quick connect actions
items.push({ type: "action", id: "local-terminal" });
// Local shells (or fallback action if discovery not ready)
if (filteredShells.length > 0) {
filteredShells.forEach((shell) =>
items.push({ type: "shell", id: shell.id }),
);
} else {
items.push({ type: "action", id: "local-terminal" });
}
} else {
// Recent connections only
results.forEach((host) =>
items.push({ type: "host", id: host.id, data: host }),
);
// Also include matching shells in search results
filteredShells.forEach((shell) =>
items.push({ type: "shell", id: shell.id }),
);
}
// Build index map for O(1) lookup
@@ -171,7 +194,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
});
return { flatItems: items, itemIndexMap: indexMap };
}, [showCategorized, results, orphanSessions, workspaces]);
}, [showCategorized, results, orphanSessions, workspaces, filteredShells]);
// O(1) index lookup
const getItemIndex = useCallback((type: string, id: string) => {
@@ -210,6 +233,14 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onClose();
}
break;
case "shell": {
const shell = discoveredShells.find(s => s.id === item.id);
if (shell && onCreateLocalTerminal) {
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
onClose();
}
break;
}
}
};
@@ -369,21 +400,60 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
})}
</div>
{/* Quick connect section */}
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Quick connect
</span>
{/* Local Shells section */}
{/* Local Shells or fallback Local Terminal */}
{filteredShells.length > 0 ? (
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t("qs.localShells")}
</span>
</div>
{filteredShells.map((shell) => {
const idx = getItemIndex("shell", shell.id);
const isSelected = idx === selectedIndex;
return (
<div
key={shell.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
if (onCreateLocalTerminal) {
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
onClose();
}
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<img
src={getShellIconPath(shell.icon)}
alt={shell.name}
className={`h-6 w-6 shrink-0${isMonochromeShellIcon(shell.icon) ? " dark:invert" : ""}`}
/>
<span className="text-sm font-medium">{shell.name}</span>
{shell.isDefault && (
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{t("qs.default")}
</span>
)}
</div>
);
})}
</div>
{/* Local Terminal */}
{onCreateLocalTerminal && (
) : onCreateLocalTerminal && (
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
{t("qs.localShells")}
</span>
</div>
<div
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
? "bg-primary/15"
: "hover:bg-muted/50"
}`}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
getItemIndex("action", "local-terminal") === selectedIndex
? "bg-primary/15"
: "hover:bg-muted/50"
}`}
onClick={() => {
onCreateLocalTerminal();
onClose();
@@ -397,10 +467,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
</div>
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
</div>
)}
{/* Serial removed (not supported) */}
</div>
</div>
)}
</div>
</ScrollArea>
</div>

View File

@@ -671,6 +671,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
handleSaveTextFile={handleSaveTextFile}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
showFileOpenerDialog={showFileOpenerDialog}
setShowFileOpenerDialog={setShowFileOpenerDialog}
fileOpenerTarget={fileOpenerTarget}
@@ -700,6 +702,8 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onGetTerminalCwd === next.onGetTerminalCwd &&

View File

@@ -467,6 +467,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handleSaveTextFile={handleSaveTextFile}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
showFileOpenerDialog={showFileOpenerDialog}
setShowFileOpenerDialog={setShowFileOpenerDialog}
fileOpenerTarget={fileOpenerTarget}

View File

@@ -18,6 +18,7 @@ import { Input } from './ui/input';
import { Label } from './ui/label';
import { SortDropdown, SortMode } from './ui/sort-dropdown';
import { Textarea } from './ui/textarea';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
interface SnippetsManagerProps {
snippets: Snippet[];
@@ -951,8 +952,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
};
return (
<TooltipProvider delayDuration={300}>
<div className="h-full flex gap-3 relative">
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
<div className="h-14 px-4 py-2 flex items-center gap-2">
{/* Search box */}
@@ -1059,7 +1061,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
@@ -1079,11 +1081,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
onClick={() => setSelectedPackage(pkg.path)}
>
<div className="flex items-center gap-3 h-full">
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<Package size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{pkg.name}</div>
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
</div>
@@ -1114,7 +1116,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer overflow-hidden",
viewMode === 'grid'
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors"
@@ -1126,15 +1128,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
onClick={() => handleEdit(snippet)}
>
<div className="flex items-center gap-3 h-full">
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<FileCode size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{snippet.label}</div>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground font-mono leading-4 truncate">
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-sm break-all font-mono text-xs">
{snippet.command}
</TooltipContent>
</Tooltip>
</div>
{snippet.shortkey && (
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
@@ -1254,6 +1263,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{/* Right Panel */}
{renderRightPanel()}
</div>
</TooltipProvider>
);
};

View File

@@ -47,7 +47,7 @@ import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
@@ -256,6 +256,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
const fontWeightFixupDoneRef = useRef(false);
useEffect(() => {
if (xtermRuntimeRef.current) {
@@ -329,6 +330,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
// Work around xterm.js WebGL renderer bug: glyphs rendered via the constructor
// look different from dynamically-set ones. After text appears on screen (status
// becomes "connected"), do a fontWeight round-trip to normalize the rendering.
useEffect(() => {
if (status !== 'connected' || fontWeightFixupDoneRef.current || !termRef.current) return;
fontWeightFixupDoneRef.current = true;
const timer = setTimeout(() => {
if (!termRef.current) return;
// Re-read the current weight at fire time to avoid stale closures
const w = termRef.current.options.fontWeight;
if (w === 'normal' || w === 400) return;
termRef.current.options.fontWeight = 'normal';
termRef.current.options.fontWeight = w;
}, 200);
return () => clearTimeout(timer);
}, [status]);
const [chainProgress, setChainProgress] = useState<{
currentHop: number;
totalHops: number;
@@ -576,10 +594,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const customThemes = useCustomThemes();
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily);
const hasFontWeightOverride = host.fontWeightOverride === true || (host.fontWeightOverride === undefined && host.fontWeight != null);
const effectiveFontSize = useMemo(
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
[fontSize, hasFontSizeOverride, host.fontSize],
);
const effectiveFontWeight = useMemo(
() => (hasFontWeightOverride && host.fontWeight != null ? host.fontWeight : (terminalSettings?.fontWeight ?? 400)),
[terminalSettings?.fontWeight, hasFontWeightOverride, host.fontWeight],
);
const resolvedFontFamily = useMemo(() => {
const hostFontId = hasFontFamilyOverride && host.fontFamily
? host.fontFamily
@@ -923,6 +946,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.theme = {
...effectiveTheme.colors,
selectionBackground: effectiveTheme.colors.selection,
scrollbarSliderBackground: effectiveTheme.colors.foreground + '33',
scrollbarSliderHoverBackground: effectiveTheme.colors.foreground + '66',
scrollbarSliderActiveBackground: effectiveTheme.colors.foreground + '80',
};
}
}, [effectiveTheme]);
@@ -936,7 +962,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
termRef.current.options.cursorBlink = terminalSettings.cursorBlink;
termRef.current.options.scrollback = terminalSettings.scrollback;
termRef.current.options.fontWeight = terminalSettings.fontWeight as
termRef.current.options.fontWeight = effectiveFontWeight as
| 100
| 200
| 300
@@ -951,10 +977,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (typeof document === "undefined" || !document.fonts?.check) {
return terminalSettings.fontWeightBold;
}
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
: effectiveFontWeight;
})();
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
@@ -989,7 +1015,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
lastFittedSizeRef.current = null;
}
}
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, terminalSettings]);
useEffect(() => {
if (!isVisible) return;
@@ -1038,10 +1064,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
const resolvedBold = document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
: effectiveFontWeight;
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
@@ -1072,7 +1098,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [effectiveFontSize, resizeSession, terminalSettings]);
}, [effectiveFontSize, effectiveFontWeight, resizeSession, terminalSettings]);
useEffect(() => {
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
@@ -1109,10 +1135,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
const timer = setTimeout(() => {
// Fit twice: once after initial layout (100ms) and again after layout settles
// (350ms) to handle race conditions during split operations where the container
// dimensions may not be final on the first pass.
const timer1 = setTimeout(() => {
safeFit({ requireVisible: true });
}, 100);
return () => clearTimeout(timer);
const timer2 = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
}, 350);
return () => { clearTimeout(timer1); clearTimeout(timer2); };
}, [inWorkspace, isVisible]);
// When search bar opens/closes, re-fit terminal and maintain scroll position
@@ -1398,6 +1430,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleRetry = () => {
if (!termRef.current) return;
cleanupSession();
// Reset terminal state: disable mouse tracking modes and clear screen so
// stale SGR mouse sequences don't leak into the new session as text input.
termRef.current.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
termRef.current.reset();
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
@@ -1980,7 +2016,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
>
<div
ref={containerRef}
className="absolute inset-x-0 bottom-0"
className="xterm-container absolute inset-x-0 bottom-0"
style={{
top: isSearchOpen ? "64px" : "30px",
paddingLeft: 6,

View File

@@ -14,12 +14,15 @@ import { KeyBinding, TerminalSettings } from '../domain/models';
import {
clearHostFontFamilyOverride,
clearHostFontSizeOverride,
clearHostFontWeightOverride,
clearHostThemeOverride,
hasHostFontFamilyOverride,
hasHostFontSizeOverride,
hasHostFontWeightOverride,
hasHostThemeOverride,
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
resolveHostTerminalThemeId,
} from '../domain/terminalAppearance';
import { cn, normalizeLineEndings } from '../lib/utils';
@@ -358,6 +361,7 @@ interface TerminalLayerProps {
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onUpdateSessionStatus: (sessionId: string, status: TerminalSession['status']) => void;
onUpdateHostDistro: (hostId: string, distro: string) => void;
@@ -412,6 +416,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onCloseSession,
onUpdateSessionStatus,
onUpdateHostDistro,
@@ -813,6 +818,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
protocol: session.protocol ?? 'local' as const,
moshEnabled: session.moshEnabled,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
});
}
}
@@ -1370,6 +1379,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const isFocusedHostLocal = useMemo(() => {
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
}, [focusedHost]);
// Hosts not in the persisted hostMap (e.g. quick-connect) are ephemeral —
// sidebar appearance changes should update global settings, not per-host overrides.
const isFocusedHostEphemeral = useMemo(() => {
if (isFocusedHostLocal) return true;
if (!focusedHost) return true;
return !hostMap.has(focusedHost.id);
}, [focusedHost, isFocusedHostLocal, hostMap]);
const previewTargetSessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id ?? null;
const activeThemePreviewId = themePreview.targetSessionId === previewTargetSessionId
? themePreview.themeId
@@ -1382,6 +1398,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
const focusedFontWeight = resolveHostTerminalFontWeight(focusedHost, terminalSettings?.fontWeight ?? 400);
const focusedFontWeightOverridden = hasHostFontWeightOverride(focusedHost);
const activeTopTabsThemeId = activeSidePanelTab === 'theme' && previewTargetSessionId
? (activeThemePreviewId ?? focusedThemeId)
: (isVisible ? focusedThemeId : null);
@@ -1514,14 +1532,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
themeCommitTimerRef.current = setTimeout(() => {
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalThemeId?.(themeId);
return;
}
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
});
}, 160);
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostEphemeral, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
const handleThemeResetForFocusedSession = useCallback(() => {
if (themeCommitTimerRef.current) {
@@ -1529,41 +1547,64 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
clearTerminalPreviewVars(previewTargetSessionId);
setThemePreview({ targetSessionId: null, themeId: null });
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostThemeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost, previewTargetSessionId]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, previewTargetSessionId]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (!focusedHost || fontFamilyId === focusedFontFamilyId) return;
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
});
}, [focusedHost, focusedFontFamilyId, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (!focusedHost || newFontSize === focusedFontSize) return;
startTransition(() => {
if (isFocusedHostLocal) {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontSize?.(newFontSize);
return;
}
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
});
}, [focusedHost, focusedFontSize, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
}, [focusedHost, focusedFontSize, isFocusedHostEphemeral, onUpdateTerminalFontSize, onUpdateHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
if (!focusedHost || isFocusedHostEphemeral) return;
onUpdateHost(clearHostFontSizeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost]);
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
if (!focusedHost || newFontWeight === focusedFontWeight) return;
startTransition(() => {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontWeight?.(newFontWeight);
return;
}
// Prefer raw (un-merged) host to avoid flattening group defaults
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost({ ...rawHost, fontWeight: newFontWeight, fontWeightOverride: true });
}
});
}, [focusedHost, focusedFontWeight, isFocusedHostEphemeral, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
const handleFontWeightResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostEphemeral) return;
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost(clearHostFontWeightOverride(rawHost));
}
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, hostMap]);
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
@@ -2035,15 +2076,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
currentFontFamilyId={focusedFontFamilyId}
globalFontFamilyId={terminalFontFamilyId}
currentFontSize={focusedFontSize}
currentFontWeight={focusedFontWeight}
canResetTheme={focusedThemeOverridden}
canResetFontFamily={focusedFontFamilyOverridden}
canResetFontSize={focusedFontSizeOverridden}
canResetFontWeight={focusedFontWeightOverridden}
onThemeChange={handleThemeChangeForFocusedSession}
onThemeReset={handleThemeResetForFocusedSession}
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
onFontSizeChange={handleFontSizeChangeForFocusedSession}
onFontSizeReset={handleFontSizeResetForFocusedSession}
onFontWeightChange={handleFontWeightChangeForFocusedSession}
onFontWeightReset={handleFontWeightResetForFocusedSession}
previewColors={resolvedPreviewTheme.colors}
/>
</div>

View File

@@ -20,6 +20,7 @@ loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../application/i18n/I18nProvider';
import { useClipboardBackend } from '../application/state/useClipboardBackend';
import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../domain/models';
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
@@ -34,6 +35,8 @@ interface TextEditorModalProps {
onSave: (content: string) => Promise<void>;
editorWordWrap: boolean;
onToggleWordWrap: () => void;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
}
// Map our language IDs to Monaco language IDs
@@ -122,12 +125,38 @@ const hslToHex = (hslString: string): string => {
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Get background color from CSS variable
const getBackgroundColor = (): string => {
const bgValue = getComputedStyle(document.documentElement)
.getPropertyValue('--background')
// Read a CSS custom-property and convert from HSL to hex
const getCssColor = (varName: string, fallback: string): string => {
const value = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
return value ? hslToHex(value) : fallback;
};
interface EditorColors {
bg: string;
fg: string;
primary: string;
card: string;
mutedFg: string;
border: string;
}
/** Read all UI CSS variables that matter for the Monaco theme. */
const getEditorColors = (isDark: boolean): EditorColors => ({
bg: getCssColor('--background', isDark ? '#1e1e1e' : '#ffffff'),
fg: getCssColor('--foreground', isDark ? '#d4d4d4' : '#1e1e1e'),
primary: getCssColor('--primary', isDark ? '#569cd6' : '#0078d4'),
card: getCssColor('--card', isDark ? '#252526' : '#f3f3f3'),
mutedFg: getCssColor('--muted-foreground', isDark ? '#858585' : '#858585'),
border: getCssColor('--border', isDark ? '#3c3c3c' : '#d4d4d4'),
});
/** Build a fingerprint string so we can detect immersive-mode color changes cheaply. */
const getThemeSignal = (): string => {
const root = document.documentElement;
return root.dataset.immersiveTheme
?? getComputedStyle(root).getPropertyValue('--background').trim();
};
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
@@ -138,6 +167,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
onSave,
editorWordWrap,
onToggleWordWrap,
hotkeyScheme,
keyBindings,
}) => {
const { t } = useI18n();
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
@@ -158,49 +189,64 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
document.documentElement.classList.contains('dark')
);
// Track background color for custom theme
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
// Track a signal that changes whenever immersive-mode or base theme colors change
const [themeSignal, setThemeSignal] = useState(() => getThemeSignal());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes based on UI background color
// Define and update custom Monaco themes — syncs with immersive-mode / base UI colors
useEffect(() => {
if (!monaco) return;
// Define dark theme with custom background
const colors = getEditorColors(isDarkTheme);
const themeColors: Record<string, string> = {
'editor.background': colors.bg,
'editor.foreground': colors.fg,
'editorCursor.foreground': colors.primary,
'editor.selectionBackground': colors.primary + '40',
'editor.inactiveSelectionBackground': colors.primary + '25',
'editorLineNumber.foreground': colors.mutedFg,
'editorLineNumber.activeForeground': colors.fg,
'editor.lineHighlightBackground': colors.fg + '08',
'editorWidget.background': colors.card,
'editorWidget.foreground': colors.fg,
'editorWidget.border': colors.border,
'input.background': colors.card,
'input.foreground': colors.fg,
'input.border': colors.border,
};
monaco.editor.defineTheme('netcatty-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
colors: themeColors,
});
// Define light theme with custom background
monaco.editor.defineTheme('netcatty-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
colors: themeColors,
});
// Apply the current theme
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, bgColor, customThemeName]);
}, [monaco, isDarkTheme, themeSignal, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class and style
// Listen for theme changes via MutationObserver on <html> class, style, and immersive data attr
useEffect(() => {
const root = document.documentElement;
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
setBgColor(getBackgroundColor());
setThemeSignal(getThemeSignal());
};
const observer = new MutationObserver(updateTheme);
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
observer.observe(root, {
attributes: true,
attributeFilter: ['class', 'style', 'data-immersive-theme'],
});
return () => observer.disconnect();
}, []);
@@ -216,6 +262,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
setHasChanges(content !== initialContent);
}, [content, initialContent]);
const closeTabBinding = useMemo(
() => keyBindings.find((binding) => binding.action === 'closeTab'),
[keyBindings],
);
const handleSave = useCallback(async () => {
if (saving) return;
setSaving(true);
@@ -347,8 +398,33 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
}
void handlePasteRef.current();
});
editor.focus();
}, []);
useEffect(() => {
if (!open) return;
const frame = window.requestAnimationFrame(() => {
editorRef.current?.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [open]);
const handleDialogKeyDownCapture = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (hotkeyScheme === 'disabled' || !closeTabBinding) return;
const isMac = hotkeyScheme === 'mac';
const keyStr = isMac ? closeTabBinding.mac : closeTabBinding.pc;
if (!matchesKeyBinding(e.nativeEvent, keyStr, isMac)) return;
e.preventDefault();
e.stopPropagation();
e.nativeEvent.stopPropagation();
handleClose();
}, [closeTabBinding, handleClose, hotkeyScheme]);
// Trigger search dialog
const handleSearch = useCallback(() => {
if (editorRef.current) {
@@ -370,7 +446,12 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0" hideCloseButton>
<DialogContent
className="max-w-5xl h-[85vh] flex flex-col p-0 gap-0"
hideCloseButton
data-hotkey-close-tab="true"
onKeyDownCapture={handleDialogKeyDownCapture}
>
{/* Header */}
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center justify-between gap-4">

View File

@@ -10,6 +10,7 @@ import { getEffectiveHostDistro } from '../domain/host';
import { cn } from '../lib/utils';
import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { SyncStatusButton } from './SyncStatusButton';
@@ -54,7 +55,7 @@ const localOsId = (() => {
})();
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string; shellIcon?: string }> = memo(({ host, isActive, protocol, shellIcon }) => {
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
const iconSize = "h-2.5 w-2.5";
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
@@ -68,8 +69,19 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
);
}
// Local protocol → OS-specific icon (protocol may be undefined for local sessions)
// Local protocol → shell-specific icon if available, else OS-specific icon
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
// Use shell icon from discovery when available
const iconId = shellIcon || host?.localShellIcon;
if (iconId) {
return (
<img
src={getShellIconPath(iconId)}
alt={iconId}
className={cn("shrink-0 h-4 w-4 object-contain", isMonochromeShellIcon(iconId) && "dark:invert")}
/>
);
}
const logo = DISTRO_LOGOS[localOsId];
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
if (logo) {
@@ -540,7 +552,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} shellIcon={session.localShellIcon} />
<span className="truncate">{session.hostLabel}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
</div>

View File

@@ -101,6 +101,10 @@ const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
type DropTarget =
| { kind: "root" }
| { kind: "group"; path: string };
// Props without isActive - it's now subscribed internally
interface VaultViewProps {
hosts: Host[];
@@ -222,7 +226,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
false,
);
const [isBreadcrumbDragOver, setIsBreadcrumbDragOver] = useState(false);
const [dragOverDropTarget, setDragOverDropTarget] = useState<DropTarget | null>(null);
const [confirmedDropTarget, setConfirmedDropTarget] = useState<DropTarget | null>(null);
const dropTargetPulseTimeoutRef = useRef<number | null>(null);
const [showRecentHosts, _setShowRecentHosts] = useStoredBoolean(
STORAGE_KEY_SHOW_RECENT_HOSTS,
@@ -237,6 +243,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
}, [navigateToSection, onNavigateToSectionHandled]);
useEffect(() => {
return () => {
if (dropTargetPulseTimeoutRef.current !== null) {
window.clearTimeout(dropTargetPulseTimeoutRef.current);
}
};
}, []);
// View mode, sorting, and tag filter state
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
@@ -253,6 +267,21 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [editingHost, setEditingHost] = useState<Host | null>(null);
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
// Close host panel if the host being edited was deleted.
// Track previous host IDs so we only close for actual deletions, not for
// unsaved new/duplicated hosts whose IDs were never in the hosts array.
const knownHostIdsRef = useRef(new Set(hosts.map(h => h.id)));
useEffect(() => {
const currentIds = new Set(hosts.map(h => h.id));
// Check against previous IDs before updating the ref
if (editingHost && knownHostIdsRef.current.has(editingHost.id) && !currentIds.has(editingHost.id)) {
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}
knownHostIdsRef.current = currentIds;
}, [hosts, editingHost]);
// Group panel state
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
const [editingGroupPath, setEditingGroupPath] = useState<string | null>(null);
@@ -928,19 +957,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
return filtered
.sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0))
.slice(0, 20);
.slice(0, 6);
}, [hosts, selectedGroupPath, search, selectedTags]);
// IDs of hosts already shown in Pinned/Recent sections at root level,
// so the main host list can exclude them to avoid duplicates.
const pinnedRecentIds = useMemo(() => {
const ids = new Set<string>();
for (const h of pinnedHosts) ids.add(h.id);
if (showRecentHosts) {
for (const h of recentHosts) ids.add(h.id);
}
return ids;
}, [pinnedHosts, recentHosts, showRecentHosts]);
// No longer deduplicate pinned/recent hosts from the main list,
// so hosts always appear in their groups regardless of pinned/recent status.
const pinnedRecentIds = useMemo(() => new Set<string>(), []);
// For tree view: apply search, tag filter, and sorting, but not group filtering
const treeViewHosts = useMemo(() => {
@@ -1422,8 +1444,36 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const isHostsSectionActive = currentSection === "hosts";
const moveHostToGroup = (hostId: string, groupPath: string | null) => {
const isSameDropTarget = useCallback((a: DropTarget | null, b: DropTarget | null) => {
if (!a || !b) return a === b;
if (a.kind !== b.kind) return false;
if (a.kind === "root") return true;
return a.path === b.path;
}, []);
const pulseDropTarget = useCallback((target: DropTarget) => {
setConfirmedDropTarget(target);
if (dropTargetPulseTimeoutRef.current !== null) {
window.clearTimeout(dropTargetPulseTimeoutRef.current);
}
dropTargetPulseTimeoutRef.current = window.setTimeout(() => {
setConfirmedDropTarget((current) => (isSameDropTarget(current, target) ? null : current));
dropTargetPulseTimeoutRef.current = null;
}, 900);
}, [isSameDropTarget]);
const setGroupDragOverDropTarget = useCallback((path: string | null) => {
setDragOverDropTarget(path ? { kind: "group", path } : null);
}, []);
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
const targetGroup = groupPath || "";
const hostToMove = hosts.find((h) => h.id === hostId);
if (!hostToMove || (hostToMove.group || "") === targetGroup) {
setDragOverDropTarget(null);
return;
}
// Find the most specific (deepest) managed source that matches the target group
const targetManagedSource = managedSources
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
@@ -1450,7 +1500,23 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
};
}),
);
};
setDragOverDropTarget(null);
pulseDropTarget(groupPath ? { kind: "group", path: groupPath } : { kind: "root" });
toast.success(
t("vault.hosts.moveToGroup.success", {
host: hostToMove.label,
group: groupPath || t("vault.hosts.allHosts"),
}),
);
}, [hosts, managedSources, onUpdateHosts, pulseDropTarget, t]);
const getDropTargetClasses = (target: DropTarget) =>
cn(
isSameDropTarget(dragOverDropTarget, target) &&
"!bg-[#e7ebf0] dark:!bg-white/[0.10]",
isSameDropTarget(confirmedDropTarget, target) &&
"!bg-[#dde3ea] dark:!bg-white/[0.14]",
);
const handleUnmanageGroup = useCallback((groupPath: string) => {
const source = managedSources.find(s => s.groupName === groupPath);
@@ -1833,24 +1899,33 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
"flex-1 overflow-auto px-4 py-4 space-y-6",
!isHostsSectionActive && "hidden",
)}
onDragEndCapture={() => setDragOverDropTarget(null)}
>
<section className="space-y-2">
{viewMode !== "tree" && (
<div className="flex items-center gap-2 text-sm font-semibold">
<button
className={cn(
"text-primary hover:underline transition-all rounded px-1 -mx-1",
isBreadcrumbDragOver && "ring-2 ring-primary bg-primary/10",
"text-primary hover:underline transition-colors duration-150 rounded px-1 -mx-1",
getDropTargetClasses({ kind: "root" }),
)}
onClick={() => setSelectedGroupPath(null)}
onDragOver={(e) => {
e.preventDefault();
setIsBreadcrumbDragOver(true);
setDragOverDropTarget({ kind: "root" });
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget((current) =>
current?.kind === "root" ? null : current,
);
}}
onDragLeave={() => setIsBreadcrumbDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsBreadcrumbDragOver(false);
setDragOverDropTarget(null);
const groupPath = e.dataTransfer.getData("group-path");
const hostId = e.dataTransfer.getData("host-id");
if (groupPath) moveGroup(groupPath, null);
@@ -2120,10 +2195,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuTrigger asChild>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer transition-colors duration-150",
viewMode === "grid"
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
getDropTargetClasses({ kind: "group", path: node.path }),
)}
draggable
onDragStart={(e) =>
@@ -2136,10 +2212,21 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget({ kind: "group", path: node.path });
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (nextTarget instanceof Node && e.currentTarget.contains(nextTarget)) {
return;
}
setDragOverDropTarget((current) =>
current?.kind === "group" && current.path === node.path ? null : current,
);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget(null);
const hostId =
e.dataTransfer.getData("host-id");
const groupPath =
@@ -2306,6 +2393,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={(path) =>
getDropTargetClasses({ kind: "group", path })
}
setDragOverDropTarget={setGroupDragOverDropTarget}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<div className="space-y-6">

View File

@@ -14,6 +14,7 @@ import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser";
import { cn } from "../../../lib/utils";
import { useDiscoveredShells } from "../../../lib/useDiscoveredShells";
import { Button } from "../../ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
import { Input } from "../../ui/input";
@@ -294,6 +295,20 @@ export default function SettingsTerminalTab(props: {
const [defaultShell, setDefaultShell] = useState<string>("");
const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const discoveredShells = useDiscoveredShells();
const [showCustomShellInput, setShowCustomShellInput] = useState(() => {
if (!terminalSettings.localShell) return false;
return !discoveredShells.some(s => s.id === terminalSettings.localShell);
});
const [customShellModalOpen, setCustomShellModalOpen] = useState(false);
const [customShellDraft, setCustomShellDraft] = useState("");
// Update showCustomShellInput once discovered shells load
useEffect(() => {
if (!terminalSettings.localShell) return;
setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell));
}, [discoveredShells, terminalSettings.localShell]);
const [themeModalOpen, setThemeModalOpen] = useState(false);
// Subscribe to custom theme changes so editing in-place triggers re-render
@@ -398,7 +413,7 @@ export default function SettingsTerminalTab(props: {
}
}, []);
// Validate shell path when it changes
// Validate shell path when it changes (only for custom paths, not discovered shell ids)
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const shellPath = terminalSettings.localShell;
@@ -408,6 +423,12 @@ export default function SettingsTerminalTab(props: {
return;
}
// Skip validation for discovered shell ids — only validate custom paths
if (discoveredShells.some(s => s.id === shellPath)) {
setShellValidation(null);
return;
}
if (!bridge?.validatePath) {
setShellValidation(null);
return;
@@ -428,7 +449,7 @@ export default function SettingsTerminalTab(props: {
}, 300);
return () => clearTimeout(timeoutId);
}, [terminalSettings.localShell, t]);
}, [terminalSettings.localShell, discoveredShells, t]);
// Validate directory path when it changes
useEffect(() => {
@@ -896,24 +917,43 @@ export default function SettingsTerminalTab(props: {
description={t("settings.terminal.localShell.shell.desc")}
>
<div className="flex flex-col gap-1 items-end">
<Input
value={terminalSettings.localShell}
placeholder={t("settings.terminal.localShell.shell.placeholder")}
onChange={(e) => updateTerminalSetting("localShell", e.target.value)}
className={cn(
"w-48",
shellValidation && !shellValidation.valid && "border-destructive focus-visible:ring-destructive"
)}
/>
{defaultShell && !terminalSettings.localShell && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
<select
className="h-9 w-48 rounded-md border border-input bg-background px-3 text-sm"
value={
showCustomShellInput
? "__custom__"
: terminalSettings.localShell || ""
}
onChange={(e) => {
const value = e.target.value;
if (value === "__custom__") {
setCustomShellDraft(terminalSettings.localShell || "");
setCustomShellModalOpen(true);
} else {
setShowCustomShellInput(false);
updateTerminalSetting("localShell", value);
}
}}
>
<option value="">
{t("settings.terminal.localShell.shell.default")}
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
</option>
{discoveredShells.map((shell) => (
<option key={shell.id} value={shell.id}>
{shell.name}
</option>
))}
<option value="__custom__">{t("settings.terminal.localShell.shell.custom")}</option>
</select>
{showCustomShellInput && (
<span className="text-xs text-muted-foreground truncate max-w-48">
{terminalSettings.localShell}
</span>
)}
{shellValidation && !shellValidation.valid && shellValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{shellValidation.message}
{!showCustomShellInput && defaultShell && !terminalSettings.localShell && (
<span className="text-xs text-muted-foreground">
{t("settings.terminal.localShell.shell.detected")}: {defaultShell}
</span>
)}
</div>
@@ -1013,9 +1053,9 @@ export default function SettingsTerminalTab(props: {
options={[
{ value: "auto", label: t("settings.terminal.rendering.auto") },
{ value: "webgl", label: "WebGL" },
{ value: "canvas", label: "Canvas" },
{ value: "dom", label: "DOM" },
]}
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "canvas")}
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "dom")}
className="w-32"
/>
</SettingRow>
@@ -1070,6 +1110,73 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
{/* Custom Shell Modal */}
<Dialog open={customShellModalOpen} onOpenChange={setCustomShellModalOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{t("settings.terminal.localShell.shell.custom")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm font-medium">{t("settings.terminal.localShell.shell.customPath")}</label>
<Input
value={customShellDraft}
placeholder={t("settings.terminal.localShell.shell.placeholder")}
onChange={(e) => setCustomShellDraft(e.target.value)}
className="w-full"
autoFocus
/>
{shellValidation && !shellValidation.valid && shellValidation.message && (
<span className="text-xs text-destructive flex items-center gap-1">
<AlertCircle size={12} />
{shellValidation.message}
</span>
)}
{shellValidation?.valid && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
{t("settings.terminal.localShell.shell.pathValid")}
</span>
)}
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">{t("settings.terminal.localShell.shell.commonPaths")}</label>
<div className="flex flex-wrap gap-1.5">
{["/bin/bash", "/bin/zsh", "/usr/bin/fish", "/bin/sh", "powershell.exe", "pwsh.exe", "cmd.exe"].map((p) => (
<button
key={p}
type="button"
onClick={() => setCustomShellDraft(p)}
className="text-xs px-2 py-1 rounded-md border border-border bg-muted/50 hover:bg-muted transition-colors"
>
{p}
</button>
))}
</div>
</div>
</div>
<DialogFooter>
<button
type="button"
onClick={() => setCustomShellModalOpen(false)}
className="px-3 py-1.5 text-sm rounded-md border border-border hover:bg-muted transition-colors"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={() => {
updateTerminalSetting("localShell", customShellDraft);
setShowCustomShellInput(true);
setCustomShellModalOpen(false);
}}
disabled={!customShellDraft.trim()}
className="px-3 py-1.5 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{t("common.save")}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</SettingsTabContent>
);
}

View File

@@ -2,6 +2,7 @@ import React from "react";
import type { Host, SftpFileEntry } from "../../types";
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
import type { useSftpState } from "../../application/state/useSftpState";
import type { HotkeyScheme, KeyBinding } from "../../domain/models";
import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal";
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog } from "./index";
@@ -35,6 +36,8 @@ interface SftpOverlaysProps {
handleSaveTextFile: (content: string) => Promise<void>;
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
@@ -69,6 +72,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
handleSaveTextFile,
editorWordWrap,
setEditorWordWrap,
hotkeyScheme,
keyBindings,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
@@ -139,6 +144,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
onSave={handleSaveTextFile}
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
/>
{/* File Opener Dialog */}

View File

@@ -75,21 +75,26 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showContextMenu = rightClickBehavior === 'context-menu' && !isAlternateScreen;
// Handle right-click: intercept for paste/select-word unless Shift is held
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
// enabled so Shift+Right-Click opens the menu on the first click.
const handleRightClick = useCallback(
(e: React.MouseEvent) => {
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus
if (isAlternateScreen) return;
if (rightClickBehavior === 'paste') {
if (isAlternateScreen) {
e.preventDefault();
e.stopPropagation();
return;
}
// Shift+Right-Click or context-menu mode: let Radix open the menu
if (e.shiftKey || rightClickBehavior === 'context-menu') return;
// Paste / select-word: intercept and prevent the context menu
e.preventDefault();
if (rightClickBehavior === 'paste') {
onPaste?.();
} else if (rightClickBehavior === 'select-word') {
e.preventDefault();
e.stopPropagation();
onSelectWord?.();
}
},
@@ -102,12 +107,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
<ContextMenu>
<ContextMenuTrigger
asChild
disabled={!showContextMenu}
onContextMenu={!showContextMenu ? handleRightClick : undefined}
onContextMenu={handleRightClick}
>
{children}
</ContextMenuTrigger>
{showContextMenu && (
{!isAlternateScreen && (
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />

View File

@@ -131,15 +131,19 @@ interface ThemeSidePanelProps {
currentFontFamilyId: string;
globalFontFamilyId: string;
currentFontSize: number;
currentFontWeight: number;
canResetTheme?: boolean;
canResetFontFamily?: boolean;
canResetFontSize?: boolean;
canResetFontWeight?: boolean;
onThemeChange: (themeId: string) => void;
onThemeReset?: () => void;
onFontFamilyChange: (fontFamilyId: string) => void;
onFontFamilyReset?: () => void;
onFontSizeChange: (fontSize: number) => void;
onFontSizeReset?: () => void;
onFontWeightChange: (fontWeight: number) => void;
onFontWeightReset?: () => void;
isVisible?: boolean;
previewColors?: {
background: string;
@@ -153,15 +157,19 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
currentFontFamilyId,
globalFontFamilyId,
currentFontSize,
currentFontWeight,
canResetTheme = false,
canResetFontFamily = false,
canResetFontSize = false,
canResetFontWeight = false,
onThemeChange,
onThemeReset,
onFontFamilyChange,
onFontFamilyReset,
onFontSizeChange,
onFontSizeReset,
onFontWeightChange,
onFontWeightReset,
isVisible = true,
previewColors,
}) => {
@@ -497,10 +505,52 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
</div>
)}
{/* Font Weight Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.fontWeight')}
</div>
{canResetFontWeight && (
<button
onClick={onFontWeightReset}
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--terminal-panel-fg)' }}
>
{t('common.useGlobal')}
</button>
)}
</div>
<div className="flex items-center gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
<select
value={currentFontWeight}
onChange={(e) => onFontWeightChange(Number(e.target.value))}
className="flex-1 h-7 rounded-md border text-xs px-2 cursor-pointer"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
<option value={100}>100 Thin</option>
<option value={200}>200 ExtraLight</option>
<option value={300}>300 Light</option>
<option value={400}>400 Normal</option>
<option value={500}>500 Medium</option>
<option value={600}>600 SemiBold</option>
<option value={700}>700 Bold</option>
<option value={800}>800 ExtraBold</option>
<option value={900}>900 Black</option>
</select>
</div>
</div>
)}
{/* Current selection info */}
<div className="px-2.5 py-1.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="text-[9px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} {currentFontSize}px
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} {currentFontSize}px {currentFontWeight}
</div>
</div>
</div>

View File

@@ -0,0 +1,85 @@
import type { Terminal as XTerm } from "@xterm/xterm";
type CsiParam = number | number[];
type InternalTerminal = XTerm & {
_core?: {
scroll?: (eraseAttr: unknown, isWrapped?: boolean) => void;
_inputHandler?: {
_eraseAttrData?: () => unknown;
};
};
};
const getVisibleContentRowCount = (term: XTerm): number => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") {
return 0;
}
const baseY = buffer.baseY;
for (let row = term.rows - 1; row >= 0; row--) {
const line = buffer.getLine(baseY + row);
if (!line) {
continue;
}
if (line.translateToString(true).length > 0) {
return row + 1;
}
}
return 0;
};
export const preserveTerminalViewportInScrollback = (term: XTerm): void => {
const rowsToPreserve = getVisibleContentRowCount(term);
if (rowsToPreserve <= 0) {
return;
}
const internal = term as InternalTerminal;
const scroll = internal._core?.scroll;
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
if (typeof scroll !== "function" || eraseAttr === undefined) {
return;
}
for (let row = 0; row < rowsToPreserve; row++) {
scroll.call(internal._core, eraseAttr, false);
}
};
export const clearTerminalViewport = (term: XTerm): void => {
const buffer = term.buffer.active;
if (buffer.type !== "normal") return;
const cursorY = buffer.cursorY;
const cursorX = buffer.cursorX;
if (cursorY === 0 && buffer.baseY === 0) return;
const internal = term as InternalTerminal;
const scroll = internal._core?.scroll;
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
if (typeof scroll !== "function" || eraseAttr === undefined) return;
// Push lines above cursor into scrollback so they are preserved.
// After cursorY scrolls the prompt line shifts to active-screen row 0.
for (let i = 0; i < cursorY; i++) {
scroll.call(internal._core, eraseAttr, false);
}
// Clear everything below the prompt and reposition the cursor on it.
// CSI coordinates are 1-indexed.
const col = cursorX + 1;
term.write(`\x1b[2;1H\x1b[J\x1b[1;${col}H`, () => {
term.scrollToBottom();
});
};
export const isEraseScrollbackSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 3;
export const isEraseViewportSequence = (params: CsiParam[]): boolean =>
params.length > 0 && params[0] === 2;

View File

@@ -3,6 +3,7 @@ import { useCallback } from "react";
import type { RefObject } from "react";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { clearTerminalViewport } from "../clearTerminalViewport";
type TerminalBackendWriteApi = {
writeToSession: (sessionId: string, data: string) => void;
@@ -65,7 +66,7 @@ export const useTerminalContextActions = ({
const onClear = useCallback(() => {
const term = termRef.current;
if (!term) return;
term.clear();
clearTerminalViewport(term);
}, [termRef]);
const onSelectWord = useCallback(() => {

View File

@@ -764,15 +764,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
try {
// Get local shell configuration from terminal settings
const localShell = ctx.terminalSettings?.localShell;
// Per-session shell (from QuickSwitcher discovery or split/copy) takes priority.
// The global terminalSettings.localShell may contain a shell ID (e.g., "wsl-ubuntu")
// which was already resolved to command+args and stored on the session object by App.tsx.
// Only pass shell/shellArgs when we have concrete per-session values;
// otherwise omit them so the backend uses its own default shell detection.
const sessionShell = ctx.host.localShell;
const sessionShellArgs = ctx.host.localShellArgs;
const localStartDir = ctx.terminalSettings?.localStartDir;
const id = await ctx.terminalBackend.startLocalSession({
sessionId: ctx.sessionId,
cols: term.cols,
rows: term.rows,
shell: localShell,
shell: sessionShell || undefined,
shellArgs: sessionShellArgs || undefined,
cwd: localStartDir,
env: {
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",

View File

@@ -1,7 +1,7 @@
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal as XTerm } from "@xterm/xterm";
@@ -26,10 +26,17 @@ import {
import {
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
} from "../../../domain/terminalAppearance";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
clearTerminalViewport,
isEraseViewportSequence,
isEraseScrollbackSequence,
preserveTerminalViewportInScrollback,
} from "../clearTerminalViewport";
import type {
Host,
KeyBinding,
@@ -128,6 +135,21 @@ const detectPlatform = (): XTermPlatform => {
return "darwin";
};
/**
* Extract the primary font family from a CSS font-family string that may
* include fallback fonts. `document.fonts.check` returns `false` when *any*
* listed font is still loading, so passing the entire CJK fallback stack
* causes false negatives during early terminal creation which in turn makes
* `fontWeightBold` fall back to the normal weight and renders bold text too
* thin.
*/
export const primaryFontFamily = (fontFamily: string): string => {
// Split on commas that are NOT inside quotes to handle font names like "Foo, Bar"
const match = fontFamily.match(/^(?:"[^"]*"|'[^']*'|[^,])+/);
const first = match?.[0]?.trim();
return first || fontFamily;
};
export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime => {
const platform = detectPlatform();
const deviceMemoryGb =
@@ -162,7 +184,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const cursorBlink = settings?.cursorBlink ?? true;
const scrollback = settings?.scrollback ?? 10000;
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
const fontWeight = settings?.fontWeight ?? 400;
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
const fontWeightBold = settings?.fontWeightBold ?? 700;
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
@@ -179,7 +201,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (typeof document === "undefined" || !document.fonts?.check) {
return fontWeightBold;
}
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${primaryFontFamily(fontFamily)}`;
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
})();
@@ -188,6 +210,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
...(windowsPty ? { windowsPty } : {}),
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
// Rescale glyphs that would visually overlap into the next cell (CJK compliance)
rescaleOverlappingGlyphs: true,
fontSize: effectiveFontSize,
fontFamily,
fontWeight: fontWeight as
@@ -230,6 +254,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
theme: {
...ctx.terminalTheme.colors,
selectionBackground: ctx.terminalTheme.colors.selection,
// Scrollbar theming (xterm 6.0) — derive from foreground color
scrollbarSliderBackground: ctx.terminalTheme.colors.foreground + '33', // 20% opacity
scrollbarSliderHoverBackground: ctx.terminalTheme.colors.foreground + '66', // 40% opacity
scrollbarSliderActiveBackground: ctx.terminalTheme.colors.foreground + '80', // 50% opacity
},
});
@@ -307,19 +335,19 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
webglLoaded = true;
} catch (webglErr) {
logger.warn(
"[XTerm] WebGL addon failed, using canvas renderer. Error:",
"[XTerm] WebGL addon failed, using DOM renderer. Error:",
webglErr instanceof Error ? webglErr.message : webglErr,
);
}
} else {
logger.info(
"[XTerm] Skipping WebGL addon (canvas preferred for macOS profile or low-memory devices)",
"[XTerm] Skipping WebGL addon (DOM preferred for low-memory devices)",
);
}
scopedWindow.__xtermWebGLLoaded = webglLoaded;
scopedWindow.__xtermRendererPreference = performanceConfig.preferCanvasRenderer
? "canvas"
scopedWindow.__xtermRendererPreference = performanceConfig.preferDOMRenderer
? "dom"
: "webgl";
const webLinksAddon = new WebLinksAddon((event, uri) => {
@@ -354,9 +382,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
term.loadAddon(webLinksAddon);
// Enable Unicode 11 for better Nerd Fonts / Powerline / CJK character width handling
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = '11';
// Enable Unicode graphemes for accurate CJK / emoji / Nerd Font character width handling
const unicodeGraphemes = new UnicodeGraphemesAddon();
term.loadAddon(unicodeGraphemes);
term.unicode.activeVersion = '15-graphemes';
logRenderer();
@@ -475,7 +504,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
break;
}
case "clearBuffer": {
term.clear();
clearTerminalViewport(term);
break;
}
case "searchTerminal": {
@@ -562,7 +591,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
} else {
// Character mode (default): send immediately
ctx.terminalBackend.writeToSession(id, data);
// When backspaceBehavior is configured, remap the Backspace key output
let outData = data;
if (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") {
outData = "\x08";
}
ctx.terminalBackend.writeToSession(id, outData);
// Local echo for serial connections only when explicitly enabled
if (ctx.host.protocol === "serial" && ctx.serialLocalEcho) {
@@ -579,7 +613,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
// Use remapped data so broadcast peers also receive the correct byte
const broadcastData = (data === "\x7f" && ctx.host.backspaceBehavior === "ctrl-h") ? "\x08" : data;
ctx.onBroadcastInputRef.current(broadcastData, ctx.sessionId);
}
scrollToBottomAfterInput(data);
@@ -611,6 +647,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
// OSC 7 format: \x1b]7;file://hostname/path\x07 or \x1b]7;file://hostname/path\x1b\\
let currentCwd: string | undefined = undefined;
const eraseScrollbackDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
if (isEraseViewportSequence(params)) {
preserveTerminalViewportInScrollback(term);
return false;
}
if (!isEraseScrollbackSequence(params)) {
return false;
}
return true;
});
// Register OSC 7 handler using xterm.js parser
// OSC 7 is the standard way for shells to report the current working directory
const osc7Disposable = term.parser.registerOscHandler(7, (data) => {
@@ -733,6 +780,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
dispose: () => {
cleanupMiddleClick?.();
keywordHighlighter.dispose();
eraseScrollbackDisposable.dispose();
osc7Disposable.dispose();
osc52Disposable.dispose();
try {

View File

@@ -48,8 +48,19 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close
data-dialog-close="true"
tabIndex={-1}
aria-hidden="true"
className="sr-only"
>
{t("common.close")}
</DialogPrimitive.Close>
{!hideCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground">
<DialogPrimitive.Close
data-dialog-close="true"
className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground"
>
<X className="h-4 w-4" />
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>

View File

@@ -31,7 +31,8 @@ const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
'port', 'protocol', 'agentForwarding', 'proxyConfig', 'hostChain', 'startupCommand',
'legacyAlgorithms', 'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
'telnetEnabled', 'telnetPort', 'telnetUsername', 'telnetPassword',
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride',
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
'backspaceBehavior',
];
/**

View File

@@ -95,6 +95,8 @@ export interface Host {
fontFamilyOverride?: boolean; // Explicitly override the global terminal font family for this host
fontSize?: number; // Terminal font size for this host (pt)
fontSizeOverride?: boolean; // Explicitly override the global terminal font size for this host
fontWeight?: number; // Terminal font weight for this host (100-900)
fontWeightOverride?: boolean; // Explicitly override the global terminal font weight for this host
distro?: string; // detected distro id (e.g., ubuntu, debian)
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
manualDistro?: string; // manually selected distro id when distroMode='manual'
@@ -117,6 +119,8 @@ export interface Host {
keywordHighlightEnabled?: boolean;
// Legacy SSH algorithm support for older network equipment (switches, routers)
legacyAlgorithms?: boolean;
// What the Backspace key sends: undefined = xterm default (no interception), 'ctrl-h' = ^H (0x08)
backspaceBehavior?: 'ctrl-h';
// Local SSH key file paths (from SSH config IdentityFile or user-added)
// Resolved at connection time — the app reads the file content when connecting.
identityFilePaths?: string[];
@@ -124,6 +128,11 @@ export interface Host {
pinned?: boolean;
// Timestamp of last successful connection, used for Recently Connected section
lastConnectedAt?: number;
// Per-session shell override for local terminals (from shell discovery)
localShell?: string;
localShellArgs?: string[];
localShellName?: string;
localShellIcon?: string;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -213,6 +222,9 @@ export interface GroupConfig {
fontFamilyOverride?: boolean;
fontSize?: number;
fontSizeOverride?: boolean;
fontWeight?: number;
fontWeightOverride?: boolean;
backspaceBehavior?: 'ctrl-h';
}
export interface SyncConfig {
@@ -488,7 +500,7 @@ export interface TerminalSettings {
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
// Rendering
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
rendererType: 'auto' | 'webgl' | 'dom'; // Terminal renderer: auto (detect based on hardware), webgl, or dom
// Autocomplete
autocompleteEnabled: boolean; // Enable terminal command autocomplete
@@ -564,8 +576,14 @@ export const normalizeTerminalSettings = (
...(settings ?? {}),
};
// Migrate legacy 'canvas' renderer to 'dom' (canvas removed in xterm.js 6.0)
const rendererType = (mergedSettings.rendererType as string) === 'canvas'
? 'dom' as const
: mergedSettings.rendererType;
return {
...mergedSettings,
rendererType,
autocompleteGhostText: mergedSettings.autocompletePopupMenu
? false
: mergedSettings.autocompleteGhostText,
@@ -663,6 +681,10 @@ export interface TerminalSession {
charset?: string; // Connection-time charset override (e.g. for quick-connect serial)
// Serial-specific connection settings
serialConfig?: SerialConfig;
localShell?: string; // Shell command for local terminals (from discovery)
localShellArgs?: string[]; // Shell args for local terminals (from discovery)
localShellName?: string; // Display name for local shell (e.g., "Zsh", "Ubuntu (WSL)")
localShellIcon?: string; // Icon identifier for local shell (e.g., "zsh", "ubuntu")
}
export interface RemoteFile {

View File

@@ -47,3 +47,15 @@ export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, d
export const resolveHostTerminalFontSize = (host: Host | null | undefined, defaultFontSize: number): number =>
hasHostFontSizeOverride(host) && host?.fontSize != null ? host.fontSize : defaultFontSize;
export const hasHostFontWeightOverride = (host?: Pick<Host, 'fontWeightOverride' | 'fontWeight'> | null): boolean =>
hasEffectiveOverride(host?.fontWeightOverride, hasLegacyNumberValue(host?.fontWeight));
export const clearHostFontWeightOverride = (host: Host): Host => ({
...host,
fontWeight: undefined,
fontWeightOverride: false,
});
export const resolveHostTerminalFontWeight = (host: Host | null | undefined, defaultFontWeight: number): number =>
hasHostFontWeightOverride(host) && host?.fontWeight != null ? host.fontWeight : defaultFontWeight;

View File

@@ -155,6 +155,7 @@ const createHost = (input: {
label?: string;
hostname: string;
username?: string;
password?: string;
port?: number;
protocol?: Exclude<HostProtocol, "mosh">;
group?: string;
@@ -167,6 +168,7 @@ const createHost = (input: {
hostname: input.hostname.trim(),
port: input.port ?? DEFAULT_SSH_PORT,
username: input.username?.trim() ?? "",
password: input.password || undefined,
group: normalizeGroupPath(input.group),
tags: (input.tags ?? []).filter(Boolean),
os: "linux",
@@ -189,6 +191,7 @@ const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
duplicates++;
const mergedTags = Array.from(new Set([...(existing.tags ?? []), ...(host.tags ?? [])]));
existing.tags = mergedTags;
if (!existing.password && host.password) existing.password = host.password;
if (existing.group == null && host.group != null) existing.group = host.group;
if (existing.label === existing.hostname && host.label && host.label !== host.hostname) {
existing.label = host.label;
@@ -333,6 +336,7 @@ const importFromCsv = (text: string): VaultImportResult => {
const protocolIdx = findHeaderIndex(header, ["protocol", "proto", "scheme"]);
const portIdx = findHeaderIndex(header, ["port"]);
const usernameIdx = findHeaderIndex(header, ["username", "user", "login"]);
const passwordIdx = findHeaderIndex(header, ["password", "pass", "passwd"]);
if (hostnameIdx === -1) {
return {
@@ -378,12 +382,14 @@ const importFromCsv = (text: string): VaultImportResult => {
"ssh";
const port = parsePort(portIdx >= 0 ? row[portIdx] : undefined) ?? target.port;
const username = (usernameIdx >= 0 ? row[usernameIdx] : undefined)?.trim() || target.username;
const password = (passwordIdx >= 0 ? row[passwordIdx] : undefined) || undefined;
parsedHosts.push(
createHost({
label,
hostname: target.hostname,
username,
password,
port,
protocol,
group,
@@ -993,12 +999,12 @@ export const getVaultCsvTemplate = (
opts: VaultCsvTemplateOptions = {},
): string => {
const includeExampleRows = opts.includeExampleRows !== false;
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const rows: string[][] = [header];
if (includeExampleRows) {
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root"]);
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu"]);
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin"]);
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "192.168.1.10", "ssh", "22", "root", ""]);
rows.push(["Project/Prod", "Web Server (prod)", "prod,web", "server-a.example.com", "ssh", "22", "ubuntu", ""]);
rows.push(["Database", "DB", "db,mysql", "db.example.com", "ssh", "4567", "admin", ""]);
}
const escapeCsv = (value: string) => {
@@ -1011,13 +1017,14 @@ export const getVaultCsvTemplate = (
};
const exportHostsToCsv = (hosts: Host[]): string => {
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username", "Password"];
const rows: string[][] = [header];
const escapeCsv = (value: string) => {
const escapeCsv = (value: string, skipFormulaGuard = false) => {
// Prevent CSV formula injection by prefixing dangerous characters with a single quote
// These characters can be interpreted as formulas by spreadsheet applications
if (/^[=+\-@\t\r]/.test(value)) {
// Skip for password fields to preserve credentials verbatim for round-trip
if (!skipFormulaGuard && /^[=+\-@\t\r]/.test(value)) {
value = "'" + value;
}
if (value.includes('"')) value = value.replace(/"/g, '""');
@@ -1059,10 +1066,12 @@ const exportHostsToCsv = (hosts: Host[]): string => {
host.protocol ?? "ssh",
String(effectivePort),
effectiveUsername,
host.password ?? "",
]);
}
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
const passwordColIdx = header.indexOf("Password");
return rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(c, rowIdx > 0 && i === passwordColIdx)).join(",")).join("\r\n") + "\r\n";
};
interface ExportHostsResult {

View File

@@ -0,0 +1,710 @@
/**
* Shell Discovery — cross-platform shell detection
*
* Detects available shells on Windows (CMD, PowerShell, WSL, Git Bash, Cygwin)
* and Unix/macOS (via /etc/shells). Registry access on Windows uses `reg.exe`
* via child_process — no native npm dependency.
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const EXEC_OPTS = { encoding: "utf8", timeout: 5000, windowsHide: true };
/** Module-level cache for later use by the unified discoverShells() (Task 3). */
let cachedShells = null;
// ---------------------------------------------------------------------------
// Helper utilities
// ---------------------------------------------------------------------------
/**
* Query a specific value from a Windows registry key.
* Returns the value string, or `null` on failure.
*
* @param {string} keyPath e.g. "HKLM\\SOFTWARE\\GitForWindows"
* @param {string} valueName e.g. "InstallPath"
* @returns {string|null}
*/
function regQueryValue(keyPath, valueName) {
try {
// /ve queries the default (unnamed) value; /v queries a named value.
const args =
valueName === "" || valueName == null
? ["query", keyPath, "/ve"]
: ["query", keyPath, "/v", valueName];
const output = execFileSync("reg", args, EXEC_OPTS);
// Output format:
// HKEY_LOCAL_MACHINE\SOFTWARE\GitForWindows
// InstallPath REG_SZ C:\Program Files\Git
const lines = output.split(/\r?\n/).filter(Boolean);
for (const line of lines) {
const match = line.match(/^\s+.+?\s+REG_\w+\s+(.+)$/);
if (match) {
return match[1].trim();
}
}
} catch (_err) {
// Key or value not found — expected on many systems.
}
return null;
}
/**
* Enumerate immediate subkey names under a registry key.
* Returns an array of full subkey paths, or an empty array on failure.
*
* @param {string} keyPath e.g. "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss"
* @returns {string[]}
*/
function regEnumSubkeys(keyPath) {
try {
const output = execFileSync(
"reg",
["query", keyPath],
EXEC_OPTS,
);
// `reg query <key>` prints the key itself, then each subkey on its own line
// prefixed with the full path. Values appear with leading whitespace.
const lines = output.split(/\r?\n/).filter(Boolean);
const subkeys = [];
const normalizedParent = keyPath.toLowerCase();
for (const line of lines) {
const trimmed = line.trim();
// Subkeys start with "HK" and are longer than the parent key.
if (
trimmed.toLowerCase().startsWith("hk") &&
trimmed.toLowerCase() !== normalizedParent &&
trimmed.toLowerCase().startsWith(normalizedParent + "\\")
) {
subkeys.push(trimmed);
}
}
return subkeys;
} catch (_err) {
// Key not found or access denied.
}
return [];
}
/**
* Locate an executable on the system PATH using `where.exe`.
* Returns the first valid, non-alias path, or `null` if not found.
*
* @param {string} name Executable name, e.g. "pwsh"
* @returns {string|null}
*/
function findExecutableOnPath(name) {
try {
const result = execFileSync("where.exe", [name], EXEC_OPTS);
const candidates = result
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) continue;
// Skip Windows App Execution Aliases (WindowsApps zero-byte stubs).
try {
const localAppData = (process.env.LOCALAPPDATA || "").toLowerCase();
if (
localAppData &&
candidate.toLowerCase().startsWith(
path.join(localAppData, "Microsoft", "WindowsApps").toLowerCase() +
path.sep,
)
) {
continue;
}
} catch (_e) {
// Ignore — just use the candidate.
}
return candidate;
}
} catch (_err) {
// Not found on PATH.
}
return null;
}
/**
* Map a WSL distro name to an icon identifier for SVG lookup.
*
* @param {string} distroName e.g. "Ubuntu-22.04", "Debian", "kali-linux"
* @returns {string}
*/
function mapWslDistroIcon(distroName) {
const lower = (distroName || "").toLowerCase();
if (lower.includes("ubuntu")) return "ubuntu";
if (lower.includes("debian")) return "debian";
if (lower.includes("kali")) return "kali";
if (lower.includes("alpine")) return "alpine";
if (lower.includes("opensuse") || lower.includes("suse")) return "opensuse";
if (lower.includes("fedora")) return "fedora";
if (lower.includes("arch")) return "arch";
if (lower.includes("oracle")) return "oracle";
return "linux";
}
// ---------------------------------------------------------------------------
// Individual shell detectors
// ---------------------------------------------------------------------------
/**
* Detect CMD.
* @returns {object|null} DiscoveredShell or null
*/
function detectCmd() {
try {
const comSpec = process.env.ComSpec;
const cmdPath = comSpec || "cmd.exe";
// Verify the path actually exists when ComSpec provides a full path.
if (comSpec && !fs.existsSync(comSpec)) {
// Fallback to bare name — Windows will resolve it.
return {
id: "cmd",
name: "CMD",
command: "cmd.exe",
args: [],
icon: "cmd",
};
}
return {
id: "cmd",
name: "CMD",
command: cmdPath,
args: [],
icon: "cmd",
};
} catch (_err) {
// Should never fail, but guard anyway.
}
return null;
}
/**
* Detect Windows PowerShell 5.1.
* @returns {object|null}
*/
function detectPowerShell() {
try {
// Try where.exe first.
const found = findExecutableOnPath("powershell");
if (found) {
return {
id: "powershell",
name: "Windows PowerShell",
command: found,
args: ["-NoLogo"],
icon: "powershell",
};
}
// Fallback: well-known path.
const fallback = path.join(
process.env.SystemRoot || "C:\\Windows",
"System32",
"WindowsPowerShell",
"v1.0",
"powershell.exe",
);
if (fs.existsSync(fallback)) {
return {
id: "powershell",
name: "Windows PowerShell",
command: fallback,
args: ["-NoLogo"],
icon: "powershell",
};
}
} catch (_err) {
// Detection failed — not critical.
}
return null;
}
/**
* Detect PowerShell Core (pwsh 7+).
* @returns {object|null}
*/
function detectPwsh() {
try {
// 1. where.exe
const found = findExecutableOnPath("pwsh");
if (found) {
return {
id: "pwsh",
name: "PowerShell 7",
command: found,
args: ["-NoLogo"],
icon: "pwsh",
};
}
// 2. Registry App Paths
const regPath = regQueryValue(
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\pwsh.exe",
"",
);
if (regPath && fs.existsSync(regPath)) {
return {
id: "pwsh",
name: "PowerShell 7",
command: regPath,
args: ["-NoLogo"],
icon: "pwsh",
};
}
// 3. Common fallback path.
const fallback = path.join(
process.env.ProgramFiles || "C:\\Program Files",
"PowerShell",
"7",
"pwsh.exe",
);
if (fs.existsSync(fallback)) {
return {
id: "pwsh",
name: "PowerShell 7",
command: fallback,
args: ["-NoLogo"],
icon: "pwsh",
};
}
} catch (_err) {
// Detection failed.
}
return null;
}
/**
* Detect installed WSL distributions via the registry.
* @returns {object[]} Array of DiscoveredShell objects (may be empty).
*/
function detectWslDistros() {
const wslExe = path.join(
process.env.SystemRoot || "C:\\Windows",
"System32",
"wsl.exe",
);
if (!fs.existsSync(wslExe)) return [];
const distros = [];
// Primary: use `wsl.exe -l -q` which lists installed distros one per line.
// More reliable than registry parsing across Windows versions.
// Note: wsl.exe outputs UTF-16LE on some builds, so we read as buffer and decode.
try {
const buf = execFileSync(wslExe, ["-l", "-q"], {
timeout: 5000,
windowsHide: true,
maxBuffer: 1024 * 64,
});
// wsl.exe outputs UTF-16LE on most Windows builds (has NUL bytes between chars).
// Detect by checking for NUL bytes in the raw buffer; if present → UTF-16LE, else UTF-8.
const isUtf16 = buf.length >= 2 && buf.includes(0x00);
const output = buf.toString(isUtf16 ? "utf16le" : "utf8");
const names = output
.split(/\r?\n/)
.map((l) => l.replace(/\0/g, "").trim())
.filter(Boolean);
for (const distroName of names) {
distros.push({
id: `wsl-${distroName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
name: `${distroName} (WSL)`,
command: wslExe,
args: ["-d", distroName],
icon: mapWslDistroIcon(distroName),
});
}
if (distros.length > 0) return distros;
} catch (_err) {
// wsl.exe -l -q failed, fall through to registry method.
}
// Fallback: enumerate registry subkeys under Lxss
try {
const lxssKey = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss";
const subkeys = regEnumSubkeys(lxssKey);
for (const subkey of subkeys) {
try {
const distroName = regQueryValue(subkey, "DistributionName");
if (!distroName) continue;
distros.push({
id: `wsl-${distroName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
name: `${distroName} (WSL)`,
command: wslExe,
args: ["-d", distroName],
icon: mapWslDistroIcon(distroName),
});
} catch (_err) {
// Skip this distro but continue with others.
}
}
} catch (_err) {
// WSL not installed or registry not accessible.
}
return distros;
}
/**
* Detect Git Bash (from Git for Windows).
* @returns {object|null}
*/
function detectGitBash() {
try {
// Try registry first.
const installPath = regQueryValue(
"HKLM\\SOFTWARE\\GitForWindows",
"InstallPath",
);
if (installPath) {
const bashExe = path.join(installPath, "bin", "bash.exe");
if (fs.existsSync(bashExe)) {
return {
id: "git-bash",
name: "Git Bash",
command: bashExe,
args: ["--login", "-i"],
icon: "git-bash",
};
}
}
// Fallback: common installation path.
const fallbackPaths = [
path.join(
process.env.ProgramFiles || "C:\\Program Files",
"Git",
"bin",
"bash.exe",
),
path.join(
process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)",
"Git",
"bin",
"bash.exe",
),
];
for (const p of fallbackPaths) {
if (fs.existsSync(p)) {
return {
id: "git-bash",
name: "Git Bash",
command: p,
args: ["--login", "-i"],
icon: "git-bash",
};
}
}
} catch (_err) {
// Git Bash not installed.
}
return null;
}
/**
* Detect Cygwin bash.
* @returns {object|null}
*/
function detectCygwin() {
try {
// Try 64-bit registry key first, then 32-bit (WOW6432Node).
const rootDir =
regQueryValue("HKLM\\SOFTWARE\\Cygwin\\setup", "rootdir") ||
regQueryValue("HKLM\\SOFTWARE\\WOW6432Node\\Cygwin\\setup", "rootdir");
if (rootDir) {
const bashExe = path.join(rootDir, "bin", "bash.exe");
if (fs.existsSync(bashExe)) {
return {
id: "cygwin",
name: "Cygwin",
command: bashExe,
args: ["--login", "-i"],
icon: "cygwin",
};
}
}
// Fallback: common path.
const fallback = "C:\\cygwin64\\bin\\bash.exe";
if (fs.existsSync(fallback)) {
return {
id: "cygwin",
name: "Cygwin",
command: fallback,
args: ["--login", "-i"],
icon: "cygwin",
};
}
} catch (_err) {
// Cygwin not installed.
}
return null;
}
// ---------------------------------------------------------------------------
// Main discovery entry point for Windows
// ---------------------------------------------------------------------------
/**
* Discover all available shells on a Windows system.
* Returns an array of DiscoveredShell objects. Exactly one shell will have
* `isDefault: true` based on priority: pwsh > powershell > cmd.
*
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
*/
function discoverWindowsShells() {
const shells = [];
// Detect each shell type independently — failures are isolated.
const cmd = detectCmd();
if (cmd) shells.push(cmd);
const powershell = detectPowerShell();
if (powershell) shells.push(powershell);
const pwsh = detectPwsh();
if (pwsh) shells.push(pwsh);
const wslDistros = detectWslDistros();
shells.push(...wslDistros);
const gitBash = detectGitBash();
if (gitBash) shells.push(gitBash);
const cygwin = detectCygwin();
if (cygwin) shells.push(cygwin);
// Assign default: pwsh > powershell > cmd
const defaultShell =
shells.find((s) => s.id === "pwsh") ||
shells.find((s) => s.id === "powershell") ||
shells.find((s) => s.id === "cmd");
if (defaultShell) {
defaultShell.isDefault = true;
}
return shells;
}
// ---------------------------------------------------------------------------
// Unix shell detection helpers
// ---------------------------------------------------------------------------
/**
* Map a Unix shell binary basename to a human-readable display name.
*
* @param {string} basename e.g. "zsh", "bash", "nu"
* @returns {string}
*/
function mapUnixShellName(basename) {
const map = {
zsh: "Zsh",
bash: "Bash",
fish: "Fish",
sh: "sh",
ksh: "Ksh",
tcsh: "Tcsh",
csh: "Csh",
dash: "Dash",
nu: "Nushell",
pwsh: "PowerShell",
};
return map[basename] || basename;
}
/**
* Map a Unix shell binary basename to an icon identifier.
*
* @param {string} basename e.g. "zsh", "fish", "nu"
* @returns {string}
*/
function mapUnixShellIcon(basename) {
const map = {
zsh: "zsh",
bash: "bash",
fish: "fish",
sh: "terminal",
ksh: "terminal",
tcsh: "terminal",
csh: "terminal",
dash: "terminal",
nu: "nushell",
pwsh: "pwsh",
};
return map[basename] || "terminal";
}
/**
* Returns true for shells that should be launched with the `-l` (login) flag.
*
* @param {string} basename
* @returns {boolean}
*/
function isLoginShell(basename) {
return ["bash", "zsh", "fish", "ksh", "sh"].includes(basename);
}
// ---------------------------------------------------------------------------
// Main discovery entry point for Unix
// ---------------------------------------------------------------------------
/**
* Discover all available shells on a Unix/macOS system by reading /etc/shells.
* The shell referenced by $SHELL is marked as default. If $SHELL is not in
* /etc/shells it is prepended to the list.
*
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
*/
function discoverUnixShells() {
const shells = [];
const seen = new Set();
// Read /etc/shells — each non-comment line is an absolute path.
let etcShellPaths = [];
try {
const content = fs.readFileSync("/etc/shells", "utf8");
etcShellPaths = content
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
} catch (_err) {
// /etc/shells not readable — fall through to $SHELL only.
}
// Filter to existing files and deduplicate by real path.
const validPaths = [];
for (const shellPath of etcShellPaths) {
try {
if (!fs.existsSync(shellPath)) continue;
const real = fs.realpathSync(shellPath);
if (seen.has(real)) continue;
seen.add(real);
validPaths.push(shellPath);
} catch (_err) {
// Skip unresolvable paths.
}
}
// Build DiscoveredShell objects.
// Track basename counts to detect duplicates (e.g., /bin/bash vs /usr/local/bin/bash)
const baseCount = new Map();
for (const shellPath of validPaths) {
const base = path.basename(shellPath);
baseCount.set(base, (baseCount.get(base) || 0) + 1);
}
for (const shellPath of validPaths) {
const base = path.basename(shellPath);
const args = isLoginShell(base) ? ["-l"] : [];
// Use basename as id when unique, otherwise use path slug to guarantee uniqueness
const needsDisambiguation = baseCount.get(base) > 1;
const id = needsDisambiguation
? shellPath.replace(/^\/+/, "").replace(/[/\\]+/g, "-")
: base;
const name = needsDisambiguation
? `${mapUnixShellName(base)} (${shellPath})`
: mapUnixShellName(base);
shells.push({
id,
name,
command: shellPath,
args,
icon: mapUnixShellIcon(base),
});
}
// Ensure $SHELL is present — prepend it if missing.
const envShell = process.env.SHELL;
if (envShell) {
try {
const envReal = fs.realpathSync(envShell);
if (!seen.has(envReal) && fs.existsSync(envShell)) {
const base = path.basename(envShell);
const args = isLoginShell(base) ? ["-l"] : [];
// Check if basename already exists in the list to disambiguate
const hasDuplicate = shells.some((s) => path.basename(s.command) === base);
const id = hasDuplicate
? envShell.replace(/^\/+/, "").replace(/[/\\]+/g, "-")
: base;
const name = hasDuplicate
? `${mapUnixShellName(base)} (${envShell})`
: mapUnixShellName(base);
shells.unshift({
id,
name,
command: envShell,
args,
icon: mapUnixShellIcon(base),
});
}
} catch (_err) {
// $SHELL path invalid — ignore.
}
}
// Mark $SHELL as default (match by command path or basename).
if (envShell) {
const defaultShell =
shells.find((s) => s.command === envShell) ||
shells.find((s) => s.id === path.basename(envShell));
if (defaultShell) {
defaultShell.isDefault = true;
}
}
// Fallback: mark first shell as default if none matched.
if (shells.length > 0 && !shells.some((s) => s.isDefault)) {
shells[0].isDefault = true;
}
return shells;
}
// ---------------------------------------------------------------------------
// Unified shell discovery entry point
// ---------------------------------------------------------------------------
/**
* Discover all available shells for the current platform.
* Results are cached after the first call.
*
* @returns {Array<{id: string, name: string, command: string, args: string[], icon: string, isDefault?: boolean}>}
*/
function discoverShells() {
if (cachedShells) return cachedShells;
if (process.platform === "win32") {
cachedShells = discoverWindowsShells();
} else {
cachedShells = discoverUnixShells();
}
return cachedShells;
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
discoverShells,
discoverWindowsShells,
discoverUnixShells,
mapUnixShellName,
mapUnixShellIcon,
isLoginShell,
regQueryValue,
regEnumSubkeys,
findExecutableOnPath,
mapWslDistroIcon,
};

View File

@@ -15,6 +15,7 @@ const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { detectShellKind } = require("./ai/ptyExec.cjs");
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
const { discoverShells } = require("./shellDiscovery.cjs");
// Shared references
let sessions = null;
@@ -252,8 +253,20 @@ function startLocalSession(event, payload) {
payload?.sessionId ||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
const defaultShell = getDefaultLocalShell();
const shell = normalizeExecutablePath(payload?.shell) || defaultShell;
const shellArgs = getLocalShellArgs(shell);
// payload.shell may be a discovered shell ID (e.g., "wsl-ubuntu") — resolve it
let resolvedShell = payload?.shell;
let resolvedArgs = payload?.shellArgs;
if (resolvedShell && !/[/\\]/.test(resolvedShell)) {
// Looks like a shell ID, not a path — try to resolve from discovery cache
const shells = discoverShells();
const match = shells.find((s) => s.id === resolvedShell);
if (match) {
resolvedShell = match.command;
resolvedArgs = resolvedArgs ?? match.args;
}
}
const shell = normalizeExecutablePath(resolvedShell) || defaultShell;
const shellArgs = resolvedArgs ?? getLocalShellArgs(shell);
const shellKind = detectShellKind(shell);
const env = applyLocaleDefaults({
...process.env,
@@ -1044,6 +1057,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:serial:list", listSerialPorts);
ipcMain.handle("netcatty:local:defaultShell", getDefaultShell);
ipcMain.handle("netcatty:local:validatePath", validatePath);
ipcMain.handle("netcatty:shells:discover", () => discoverShells());
ipcMain.on("netcatty:write", writeToSession);
ipcMain.on("netcatty:resize", resizeSession);
ipcMain.on("netcatty:close", closeSession);

View File

@@ -721,6 +721,20 @@ async function createWindow(electronModule, options) {
win.webContents.on("will-navigate", blockUntrustedNavigation);
win.webContents.on("will-redirect", blockUntrustedNavigation);
// Prevent Chromium from consuming Alt+Arrow as browser back/forward navigation.
// Terminal apps need these keys to pass through to the remote shell (e.g., byobu, tmux).
// Using setIgnoreMenuShortcuts lets the keydown still reach the page (xterm.js)
// while preventing Chromium's built-in shortcuts from triggering.
win.webContents.on("before-input-event", (_event, input) => {
if (input.alt && !input.control && !input.meta) {
if (input.key === "ArrowLeft" || input.key === "ArrowRight") {
win.webContents.setIgnoreMenuShortcuts(true);
return;
}
}
win.webContents.setIgnoreMenuShortcuts(false);
});
// Restore maximized state if it was saved
if (savedState?.isMaximized && !savedState?.isFullScreen) {
win.once("ready-to-show", () => {

View File

@@ -561,6 +561,7 @@ const api = {
getDefaultShell: async () => {
return ipcRenderer.invoke("netcatty:local:defaultShell");
},
discoverShells: () => ipcRenderer.invoke("netcatty:shells:discover"),
validatePath: async (path, type) => {
return ipcRenderer.invoke("netcatty:local:validatePath", { path, type });
},

13
global.d.ts vendored
View File

@@ -25,6 +25,16 @@ declare global {
password?: string;
}
// Discovered local shell (e.g. CMD, PowerShell, WSL, Git Bash)
interface DiscoveredShell {
id: string;
name: string;
command: string;
args?: string[];
icon: string;
isDefault?: boolean;
}
// Jump host configuration for SSH tunneling
interface NetcattyJumpHost {
hostname: string;
@@ -176,7 +186,7 @@ declare global {
env?: Record<string, string>;
sessionLog?: { enabled: boolean; directory: string; format: string };
}): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; shellArgs?: string[]; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
startSerialSession?(options: {
sessionId?: string;
path: string;
@@ -197,6 +207,7 @@ declare global {
pnpId: string;
}>>;
getDefaultShell?(): Promise<string>;
discoverShells?(): Promise<DiscoveredShell[]>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean }>;
generateKeyPair?(options: {
type: 'RSA' | 'ECDSA' | 'ED25519';

View File

@@ -166,6 +166,16 @@ body {
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 60%, hsl(var(--background) / 0.9) 100%);
}
/* Slim down xterm 6.0 VS Code scrollbar — wide hit area, thin visual slider */
.xterm .xterm-scrollable-element > .scrollbar.vertical {
width: 12px !important;
}
.xterm .xterm-scrollable-element > .scrollbar.vertical > .slider {
width: 6px !important;
border-radius: 3px;
left: 3px !important;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);

View File

@@ -46,8 +46,8 @@ export const XTERM_PERFORMANCE_CONFIG = {
// Enable WebGL by default for GPU acceleration
enabled: true,
// User can choose Canvas renderer on any platform
preferCanvas: false,
// User can choose DOM renderer on any platform (canvas removed in xterm 6.0)
preferDOM: false,
// Handle WebGL context loss gracefully
enableContextLoss: true,
@@ -107,7 +107,7 @@ export const XTERM_PERFORMANCE_CONFIG = {
export type XTermPlatform = "darwin" | "win32" | "linux";
type RendererType = "canvas" | "dom";
type RendererType = "dom";
type LogLevel = "off" | "error" | "warn" | "info" | "debug";
export type ResolvedXTermPerformance = {
@@ -127,7 +127,7 @@ export type ResolvedXTermPerformance = {
rendererType?: RendererType;
};
useWebGLAddon: boolean;
preferCanvasRenderer: boolean;
preferDOMRenderer: boolean;
};
const isLowMemoryDevice = (deviceMemoryGb?: number) =>
@@ -141,11 +141,11 @@ export function getXTermConfig(platform: XTermPlatform = "darwin") {
return resolveXTermPerformanceConfig({ platform }).options;
}
export type RendererPreference = "auto" | "webgl" | "canvas";
export type RendererPreference = "auto" | "webgl" | "dom";
/**
* Resolve a platform and hardware aware performance profile.
* When rendererType is 'auto', uses Canvas on low-memory devices to avoid WebGL overhead.
* When rendererType is 'auto', uses DOM on low-memory devices to avoid WebGL overhead.
*/
export function resolveXTermPerformanceConfig({
platform = "darwin",
@@ -160,15 +160,15 @@ export function resolveXTermPerformanceConfig({
const lowMem = isLowMemoryDevice(deviceMemoryGb);
// Determine if we should use Canvas renderer
let resolvedPreferCanvas: boolean;
if (rendererType === "canvas") {
resolvedPreferCanvas = true;
// Determine if we should use DOM renderer (canvas removed in xterm 6.0)
let resolvedPreferDOM: boolean;
if (rendererType === "dom") {
resolvedPreferDOM = true;
} else if (rendererType === "webgl") {
resolvedPreferCanvas = false;
resolvedPreferDOM = false;
} else {
// Auto mode: use Canvas on low-memory devices
resolvedPreferCanvas = baseConfig.webgl.preferCanvas || lowMem;
// Auto mode: use DOM on low-memory devices
resolvedPreferDOM = baseConfig.webgl.preferDOM || lowMem;
}
const scrollbackProfile = lowMem
@@ -177,7 +177,7 @@ export function resolveXTermPerformanceConfig({
? "macOS"
: "default";
const resolvedRendererType = resolvedPreferCanvas ? ("canvas" as const) : undefined;
const resolvedRendererType = resolvedPreferDOM ? ("dom" as const) : undefined;
const baseOptions = {
scrollback: baseConfig.scrollback[scrollbackProfile],
@@ -200,7 +200,7 @@ export function resolveXTermPerformanceConfig({
return {
options,
useWebGLAddon: baseConfig.webgl.enabled && !resolvedPreferCanvas,
preferCanvasRenderer: resolvedPreferCanvas,
useWebGLAddon: baseConfig.webgl.enabled && !resolvedPreferDOM,
preferDOMRenderer: resolvedPreferDOM,
};
}

View File

@@ -104,11 +104,12 @@ export async function getMonospaceFonts(): Promise<TerminalFont[]> {
// Filter monospace fonts using robust word boundary matching
const monoFonts = fonts.filter(f => isMonospaceFont(f.family));
// Deduplicate by family name (API may return multiple entries per family)
// Deduplicate by family name, case-insensitive (API may return multiple entries per family)
const uniqueFamilies = new Set<string>();
const dedupedFonts = monoFonts.filter(f => {
if (uniqueFamilies.has(f.family)) return false;
uniqueFamilies.add(f.family);
const key = f.family.toLowerCase();
if (uniqueFamilies.has(key)) return false;
uniqueFamilies.add(key);
return true;
});

View File

@@ -4,7 +4,9 @@ export type LocalOs = 'linux' | 'macos' | 'windows';
const POWERSHELL_SHELLS = new Set(['powershell', 'powershell.exe', 'pwsh', 'pwsh.exe']);
const CMD_SHELLS = new Set(['cmd', 'cmd.exe']);
const FISH_SHELLS = new Set(['fish']);
const POSIX_SHELLS = new Set(['sh', 'bash', 'zsh', 'ksh', 'dash', 'ash']);
const POSIX_SHELLS = new Set(['sh', 'bash', 'zsh', 'ksh', 'dash', 'ash', 'bash.exe']);
// WSL launcher — runs a Linux shell inside WSL, classify as posix
const WSL_SHELLS = new Set(['wsl', 'wsl.exe']);
const getExecutableBaseName = (filePath: string | undefined): string => {
const normalized = String(filePath || '').trim();
@@ -29,6 +31,7 @@ export const classifyLocalShellType = (
if (CMD_SHELLS.has(shellName)) return 'cmd';
if (FISH_SHELLS.has(shellName)) return 'fish';
if (POSIX_SHELLS.has(shellName)) return 'posix';
if (WSL_SHELLS.has(shellName)) return 'posix';
if (!shellName) {
return detectLocalOs(platformLike) === 'windows' ? 'powershell' : 'posix';
}

View File

@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
let shellCache: DiscoveredShell[] | null = null;
let shellPromise: Promise<DiscoveredShell[]> | null = null;
export function useDiscoveredShells(): DiscoveredShell[] {
const [shells, setShells] = useState<DiscoveredShell[]>(shellCache ?? []);
useEffect(() => {
if (shellCache) {
setShells(shellCache);
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.discoverShells) return;
if (!shellPromise) {
shellPromise = bridge.discoverShells();
}
shellPromise.then((result) => {
shellCache = result;
setShells(result);
}).catch((err) => {
console.warn("Failed to discover shells:", err);
// Clear the failed promise so the next mount can retry
shellPromise = null;
});
}, []);
return shells;
}
/**
* Resolve a localShell setting value to shell command and args.
* The value can be a discovered shell id (e.g., "wsl-ubuntu", "pwsh")
* or a custom path/command (e.g., "/usr/local/bin/fish" or "fish").
* Returns { command, args } or null when discovery hasn't loaded yet
* and the value might be a shell ID that can't be resolved yet.
*/
export function resolveShellSetting(
localShell: string,
discoveredShells: DiscoveredShell[]
): { command: string; args?: string[] } | null {
if (!localShell) return null;
// Try to match as a discovered shell id
const shell = discoveredShells.find(s => s.id === localShell);
if (shell) {
return { command: shell.command, args: shell.args };
}
// No ID match — treat as a custom shell path/command and pass through.
// This handles both custom executables (e.g., "/usr/local/bin/fish", "pwsh-preview")
// and stale/synced IDs that no longer exist on this machine (graceful fallback
// to whatever the OS resolves the name to, or a spawn error the user can see).
return { command: localShell };
}
const DISTRO_ICONS = new Set([
"ubuntu", "debian", "kali", "alpine", "opensuse",
"fedora", "arch", "oracle", "linux",
]);
export function getShellIconPath(iconId: string): string {
if (DISTRO_ICONS.has(iconId)) {
return `/distro/${iconId}.svg`;
}
return `/shells/${iconId}.svg`;
}
/** Distro icons are monochrome black and need `dark:invert` in dark mode */
export function isMonochromeShellIcon(iconId: string): boolean {
return DISTRO_ICONS.has(iconId);
}

87
package-lock.json generated
View File

@@ -32,13 +32,13 @@
"@streamdown/cjk": "^1.0.2",
"@streamdown/code": "^1.1.0",
"@withfig/autocomplete": "^2.692.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode-graphemes": "^0.4.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"@zed-industries/claude-agent-acp": "0.22.2",
"@zed-industries/codex-acp": "0.10.0",
"ai": "^6.0.116",
@@ -6685,62 +6685,49 @@
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-search": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==",
"license": "MIT"
},
"node_modules/@xterm/addon-serialize": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.13.0.tgz",
"integrity": "sha512-kGs8o6LWAmN1l2NpMp01/YkpxbmO4UrfWybeGu79Khw5K9+Krp7XhXbBTOTc3GJRRhd6EmILjpR8k5+odY39YQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
"node_modules/@xterm/addon-unicode-graphemes": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode-graphemes/-/addon-unicode-graphemes-0.4.0.tgz",
"integrity": "sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/addon-webgl": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz",
"integrity": "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"peer": true
"workspaces": [
"addons/*"
]
},
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",

View File

@@ -50,13 +50,13 @@
"@streamdown/cjk": "^1.0.2",
"@streamdown/code": "^1.1.0",
"@withfig/autocomplete": "^2.692.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-search": "^0.15.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-serialize": "^0.14.0",
"@xterm/addon-unicode-graphemes": "^0.4.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"@zed-industries/claude-agent-acp": "0.22.2",
"@zed-industries/codex-acp": "0.10.0",
"ai": "^6.0.116",

6
public/shells/bash.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Dark charcoal rounded square -->
<rect width="32" height="32" rx="6" fill="#2D2D2D"/>
<!-- Green $_ prompt, bold and centered -->
<text x="7" y="21" font-family="monospace" font-size="16" font-weight="bold" fill="#4EC9B0">$_</text>
</svg>

After

Width:  |  Height:  |  Size: 313 B

8
public/shells/cmd.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Dark background -->
<rect width="32" height="32" rx="6" fill="#1E1E1E"/>
<!-- Classic green C:\> prompt -->
<text x="3" y="15" font-family="monospace" font-size="8" font-weight="bold" fill="#00FF00">C:\&gt;</text>
<!-- Blinking cursor line -->
<rect x="3" y="20" width="8" height="2" fill="#00FF00" opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 400 B

8
public/shells/cygwin.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Dark rounded square -->
<rect width="32" height="32" rx="6" fill="#1A1A2E"/>
<!-- Gold border accent -->
<rect x="1" y="1" width="30" height="30" rx="5" fill="none" stroke="#FFD700" stroke-width="1.5"/>
<!-- CY text in gold -->
<text x="16" y="21" font-family="monospace" font-size="13" font-weight="bold" fill="#FFD700" text-anchor="middle">CY</text>
</svg>

After

Width:  |  Height:  |  Size: 437 B

15
public/shells/fish.svg Normal file
View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Fish shell: simple fish shape in Fish shell green -->
<rect width="32" height="32" rx="6" fill="#1A2E1A"/>
<!-- Fish body -->
<ellipse cx="15" cy="16" rx="9" ry="6" fill="#4DB380"/>
<!-- Fish tail -->
<path d="M24 16 L29 11 L29 21 Z" fill="#3A9966"/>
<!-- Fish eye -->
<circle cx="10" cy="14" r="1.5" fill="#FFFFFF"/>
<circle cx="10" cy="14" r="0.8" fill="#1A2E1A"/>
<!-- Fish fin -->
<path d="M13 11 Q16 8 19 11" fill="none" stroke="#98C379" stroke-width="1.5" stroke-linecap="round"/>
<!-- Subtle highlight -->
<ellipse cx="14" cy="14" rx="4" ry="2" fill="#98C379" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 682 B

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Git logo inspired icon -->
<rect width="32" height="32" rx="5" fill="#F05032"/>
<!-- Git diamond shape -->
<path d="M16 4 L28 16 L16 28 L4 16 Z" fill="none" stroke="#FFFFFF" stroke-width="2.5"/>
<!-- Git branch lines inside -->
<circle cx="16" cy="11" r="2.5" fill="#FFFFFF"/>
<circle cx="11" cy="16" r="2.5" fill="#FFFFFF"/>
<circle cx="21" cy="16" r="2.5" fill="#FFFFFF"/>
<line x1="16" y1="13.5" x2="16" y2="22" stroke="#FFFFFF" stroke-width="2"/>
<line x1="13.3" y1="17.5" x2="16" y2="22" stroke="#FFFFFF" stroke-width="2"/>
<circle cx="16" cy="22" r="2" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 671 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Dark background -->
<rect width="32" height="32" rx="6" fill="#0D1117"/>
<!-- Teal "nu" text -->
<text x="5" y="17" font-family="monospace" font-size="12" font-weight="bold" fill="#4EAA97">nu</text>
<!-- > prompt with cursor -->
<text x="5" y="27" font-family="monospace" font-size="10" font-weight="bold" fill="#56D4C0">&gt;</text>
<text x="13" y="27" font-family="monospace" font-size="10" fill="#3A9985">_</text>
</svg>

After

Width:  |  Height:  |  Size: 503 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- PowerShell blue rounded square -->
<rect width="32" height="32" rx="6" fill="#012456"/>
<!-- PS> prompt -->
<text x="4" y="15" font-family="monospace" font-size="10" font-weight="bold" fill="#FFFFFF">PS</text>
<text x="4" y="27" font-family="monospace" font-size="12" font-weight="bold" fill="#2CA5E0">&gt;_</text>
</svg>

After

Width:  |  Height:  |  Size: 398 B

8
public/shells/pwsh.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Darker background for PowerShell Core -->
<rect width="32" height="32" rx="6" fill="#0D1117"/>
<!-- PS7 label -->
<text x="4" y="16" font-family="monospace" font-size="9" font-weight="bold" fill="#5BC4F5">PS7</text>
<!-- >_ prompt -->
<text x="4" y="27" font-family="monospace" font-size="11" font-weight="bold" fill="#2CA5E0">&gt;_</text>
</svg>

After

Width:  |  Height:  |  Size: 425 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Neutral gray rounded square -->
<rect width="32" height="32" rx="6" fill="#3C3C3C"/>
<!-- White >_ prompt -->
<text x="6" y="21" font-family="monospace" font-size="16" font-weight="bold" fill="#FFFFFF">&gt;_</text>
</svg>

After

Width:  |  Height:  |  Size: 296 B

8
public/shells/zsh.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Dark navy rounded square -->
<rect width="32" height="32" rx="6" fill="#1B1F3B"/>
<!-- Teal % prompt -->
<text x="8" y="18" font-family="monospace" font-size="14" font-weight="bold" fill="#00D4AA">%</text>
<!-- zsh label underneath -->
<text x="16" y="28" font-family="sans-serif" font-size="7" font-weight="bold" fill="#00D4AA" text-anchor="middle" opacity="0.7">zsh</text>
</svg>

After

Width:  |  Height:  |  Size: 460 B