Compare commits

..

42 Commits

Author SHA1 Message Date
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
陈大猫
be80741314 feat: custom keywords and colors in keyword highlighting (#597)
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
* feat: support custom keywords and colors in global keyword highlighting (#590)

Add ability to create custom keyword highlight rules in global settings
(Settings > Terminal > Keyword Highlighting):

- Per-rule enable/disable toggle for both built-in and custom rules
- Add custom rules with label, regex pattern, and color picker
- Delete custom rules (built-in rules cannot be deleted)
- Pattern validation with error feedback
- Custom rules sync across devices via cloud sync
- i18n support (en, zh-CN)

Built-in categories (Error, Warning, OK, Info, Debug, URL/IP/MAC) are
preserved and cannot be deleted, only toggled and recolored.

Closes #590

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

* refactor: use dialog modal for adding custom keyword highlight rules

Replace inline form with a proper modal dialog:
- Button opens dialog instead of showing inline inputs
- Dialog has label+color, regex pattern, and live preview
- Reset and Add buttons side by side in footer area
- Add common.add i18n key (en, zh-CN)

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

* ui: unify button styles in keyword highlight section

Both buttons now use ghost variant with equal flex-1 width for a
cleaner, balanced layout.

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

* ui: fix keyword highlight rule list alignment

- Add placeholder spacer (w-5) for built-in rules to match delete
  button width on custom rules, keeping color pickers aligned
- Move regex pattern to second line for custom rules
- Use block+truncate for label and pattern text

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

* ui: hide regex, show edit/delete icons after label for custom rules

- Remove regex pattern display from rule list
- Add pencil (edit) and trash (delete) icons after custom rule label,
  visible on hover
- Edit opens the same dialog pre-filled with rule data
- Dialog supports both add and edit modes with appropriate titles/buttons

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

* ui: remove toggle dots, simplify edit/delete to plain icons

- Remove the red enable/disable dot button from all rules
- Replace Button wrappers with plain Lucide icons for edit/delete
  (no hover background, just cursor pointer)

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

* fix: preserve multi-pattern rules on edit, keep disabled state on reset

- Editing a custom rule now preserves patterns beyond the first one
- Reset to default colors no longer force-enables disabled rules

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

* fix: replace all patterns on edit instead of preserving hidden ones

When editing a custom rule, save only the single user-visible pattern
rather than silently keeping extra patterns the user cannot see.

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

* fix: preserve regex whitespace and multi-pattern rules on edit

- Stop trimming regex patterns on save (only trim for empty check)
- If pattern field unchanged during edit, preserve all original
  patterns so changing just label/color doesn't drop extra regexes

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

* fix: preserve additional patterns when editing custom rule

When editing, replace only the first pattern (the one shown in the
dialog) and keep any additional patterns intact to prevent data loss
for multi-pattern rules from sync or import.

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 15:05:18 +08:00
bincxz
7efb6d2adb fix: remove remaining isImmersive reference in useImmersiveMode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:57:30 +08:00
bincxz
33f8221d5c refactor: remove immersive mode toggle remnants — always enabled
Immersive mode was already hardcoded to true with a no-op setter.
Clean up all dead code:
- Remove isImmersive param from useImmersiveMode hook
- Remove immersiveMode/setImmersiveMode from useSettingsState
- Remove toggle from SettingsPage and SettingsAppearanceTab
- Remove sync read/write of immersiveMode setting
- Remove i18n keys for the removed toggle
- Simplify App.tsx conditionals

Kept: useImmersiveMode hook (core logic), CSS classes (fade overlay),
sync type field (backward compat), storage key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:49:03 +08:00
bincxz
f7eeb855aa fix: only apply terminal theme to tab bar when terminal view is active
When viewing Vault/SFTP, clear terminal theme vars from tab bar so it
uses the UI theme colors. Terminal theme is only applied when the
terminal layer is visible, or during theme sidebar preview.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:11:51 +08:00
bincxz
a87a4ff09f fix: tab top accent line always reflects active terminal theme
activeTopTabsThemeId was only set when the theme sidebar was open,
causing the tab accent line to lose its terminal-derived color when
the sidebar was closed. Now it always tracks the focused terminal's
theme, with sidebar preview taking priority when open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:10:52 +08:00
bincxz
fbb6cf4dd3 fix: active tab indicator line uses --top-tabs-accent with fallback
The tab top accent line was using hsl(var(--primary)) which is only set
when the sidebar theme preview is active. Changed to use
var(--top-tabs-accent, hsl(var(--accent))) matching all other tab
elements, so the color is correct both with and without sidebar open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:09:02 +08:00
bincxz
cceae92f97 fix: add missing dependency 't' to handleSaveGroupConfig useCallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 13:00:02 +08:00
陈大猫
2f314c3588 feat: group configuration inheritance (#220) (#593)
* feat(i18n): add translations for group config panel

* feat(models): add GroupConfig data model, resolution logic, and encryption

Add the GroupConfig interface for group-level default settings that hosts
inherit. Includes ancestor-chain resolution (A/B/C merges from A, A/B,
A/B/C), host-level application logic, storage key, and secure field
encryption/decryption for sensitive GroupConfig fields.

Part of #220.

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

* feat(state): add groupConfigs state management with encryption

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

* feat(ui): create GroupDetailsPanel with full config editing

Side panel for editing group-level default configuration using AsidePanel.
Includes General, SSH, Telnet, Advanced, Mosh, and Appearance sections
with sub-panel navigation for Proxy, Chain, EnvVars, and Theme selection.

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

* feat(vault): wire GroupDetailsPanel, replace rename dialog with full config panel

Replace all group rename dialog triggers with the new GroupDetailsPanel sidebar.
The hover edit button, context menu, and tree view edit callbacks now open the
full group configuration panel instead of a simple rename dialog.

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

* feat(connect): apply group config defaults at connection time

When connecting to a host, merge group-level default configuration so
hosts inherit their group's settings for auth, protocol, appearance,
and other inheritable fields. Connection logs still reference the
original host's label/hostname.

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

* feat(sync): include groupConfigs in sync and export payloads

Add groupConfigs to SyncPayload, SyncableVaultData, buildSyncPayload,
and applySyncPayload so group connection defaults are preserved during
cloud sync and data import/export. Also wire groupConfigs into the
vault object in SettingsPage so it flows through to the sync payload
builder.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(vault): update group configs on move and delete

* feat(host-panel): show inherited group defaults as placeholders

When editing a host that belongs to a group with configuration, group
default values now appear as placeholder text in username, startup
command, and charset fields where the host doesn't have its own value.

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

* fix: clean up unused imports in GroupDetailsPanel

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

* feat(group-panel): add/remove protocol sections, editable parent group

- SSH and Telnet sections are now add/remove — click "Add Protocol"
  to enable, "..." menu to remove. Only enabled protocols override hosts.
- Parent Group is now editable via Combobox dropdown for quick
  group moving.

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

* fix: move SSH-specific fields into SSH protocol section

Startup Command, Legacy Algorithms, Proxy, Host Chaining,
Environment Variables, and Mosh are all SSH-specific and now only
visible when SSH protocol is added. Only Charset remains as a
shared field in the Advanced section.

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

* fix: hide charset and appearance when no protocol is added

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

* fix: close Add Protocol dropdown after selection

Use controlled open state to explicitly close the dropdown when a
protocol is selected, preventing residual content from overlapping
the newly rendered section.

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

* fix: apply group defaults in TerminalLayer sessionHostsMap

Terminal component was re-reading the original host from the hosts
array by hostId, bypassing the group defaults applied in
handleConnectToHost. Now sessionHostsMap applies resolveGroupDefaults
+ applyGroupDefaults when building the host object for each session,
so Terminal sees the merged credentials/settings.

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

* fix: move Add Protocol to bottom, fix i18n for protocol/font labels

- Add Protocol button moved below Appearance section
- Added i18n keys: addProtocol, removeProtocol, fontFamily, fontSize
- All hardcoded English strings replaced with t() calls

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

* fix: replace font family text input with TerminalFontSelect dropdown

Use the same font selector component as settings, showing available
terminal fonts with preview. Includes "Use Global" reset button.

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

* feat(group-panel): match HostDetailsPanel key/certificate selection pattern

Replace the simple Combobox key selector with the same credential selection
flow used in HostDetailsPanel: a popover with Key/Certificate options,
inline combobox per type, and proper badge display with certificate icon.

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

* feat(group-panel): add Local Key File option to credential selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(group-panel): add identityFilePaths to GroupConfig and Local Key File option

- Added identityFilePaths to GroupConfig interface and INHERITABLE_KEYS
- GroupDetailsPanel now supports Key, Certificate, and Local Key File
  credential selection, matching HostDetailsPanel's full credential flow

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

* fix: prevent local key file input from overflowing panel width

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

* fix: constrain local key file input width with w-0 flex-1

Native input elements have a large default min-width. Using w-0 with
flex-1 forces the input to shrink within the flex container.

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

* fix: add overflow-hidden to SSH Card to contain local key file input

Matches HostDetailsPanel's Card which uses overflow-hidden on the
credentials section to prevent long file paths from overflowing.

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

* fix: add min-w-0 to key file path row for proper text truncation

Flex children need min-w-0 for truncate to work correctly,
otherwise the text pushes the container wider.

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

* fix: force key file path text truncation with inline max-width calc

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

* fix: use fixed 320px max-width on key file path text to force truncation

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

* fix: add overflow-hidden to AsidePanelContent to prevent content overflow

The root cause was the inner div of AsidePanelContent only had
overflow-x-hidden which was being overridden by ScrollArea's viewport.
Changed to full overflow-hidden with w-full box-border.

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

* fix: override Radix ScrollArea viewport's display:table in AsidePanel

Radix ScrollArea Viewport wraps content in a div with
display:table and min-width:100%, causing content to expand beyond
the panel width. Override this on AsidePanelContent's ScrollArea
to use display:block and min-width:0 instead.

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

* fix: critical issues — seed new hosts from group defaults, validate group names, fix empty import

- HostDetailsPanel: When groupDefaults has values for port/username/charset,
  new hosts start with undefined/empty so group defaults take effect via
  applyGroupDefaults() instead of being blocked by hardcoded values
- GroupDetailsPanel: Validate group name in handleSubmit to reject '/' and
  '\' characters, matching the old rename dialog behavior, with visual error
- useVaultState: Check groupConfigs !== undefined instead of truthy so that
  importing an empty array [] properly clears all group configs

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

* fix: safe prefix replacement, remove dead code, extract shared resolveEffectiveHost

- Replace all .replace(oldPath, newPath) / .replace(sourcePath, newPath) with
  explicit prefix slicing (newPath + str.slice(oldPath.length)) in handleSaveGroupConfig
  and moveGroup for more robust path renaming
- Remove dead c.path === oldPath branch in finalConfigs mapping since updatedConfigs
  already contains the config with newPath
- Extract resolveEffectiveHost helper in App.tsx to deduplicate group defaults
  resolution in _handleTrayPanelConnect and handleConnectToHost

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

* fix: preserve undefined port on save when group has port default

form.port || 22 was forcing port to 22 even when intentionally left
undefined for group inheritance. Now uses nullish coalescing and only
defaults to 22 when no group port default exists.

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

* fix: SSH-adjacent field detection, chain host defaults, telnet inheritance, theme clear

- hasSshFields() now checks proxyConfig, hostChain, startupCommand,
  legacyAlgorithms, environmentVariables, moshEnabled, moshServerPath,
  and identityFilePaths so the SSH section auto-opens when editing
- Chain hosts in sessionChainHostsMap now get group defaults applied
  via resolveGroupDefaults + applyGroupDefaults
- Added telnetEnabled to GroupConfig interface and INHERITABLE_KEYS;
  save handler sets telnetEnabled: true when Telnet section is on
- Theme/font "Use global" clear now sets override to false instead of
  undefined, preventing parent group theme from leaking through

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

* fix: review round 4 — sync, SFTP, port forwarding, type safety, UX

- Scan groupConfigs in encrypted credential guard (P1 security)
- Add groupConfigs to auto-sync payload and three-way merge (P1 sync)
- Apply group defaults in SFTP connections (P1 SFTP)
- Apply group defaults in all port forwarding paths (P1 port forwarding)
- Make Host.port optional to fix unsafe type cast (P1 type safety)
- Fix port input empty → 0 instead of undefined (P2)
- Add port placeholder showing inherited value (P2)
- Mutual exclusion of group/host detail panels (P2)
- Fix sub-panel width jump 420px → 380px (P2)
- Validate duplicate group path on rename/reparent (P2)

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

* fix: review round 5 — null guard, empty array inheritance, memo comparator, form reset

- Guard groupConfigs import against null payload (P1 crash)
- Validate duplicate path on moveGroup drag-drop (P2 data corruption)
- Clear empty environmentVariables to undefined for group inheritance (P1)
- Clear empty hostChain to undefined for group inheritance (P2)
- Add groupConfigs to SftpView memo comparator (P1 stale defaults)
- Add key={editingGroupPath} to GroupDetailsPanel for form reset (P1)

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

* fix: review round 6 — copy credentials, protocol dialog use effective host

- Apply group defaults in handleCopyCredentials (P2)
- Apply group defaults in hasMultipleProtocols check (P2)
- Pass effective host to ProtocolSelectDialog (P2)

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

* fix: serialize protocol:'ssh' marker to persist SSH section in group config

- Add protocol:'ssh' as marker field in handleSubmit SSH block
- Detect protocol:'ssh' in hasSshFields() to preserve section on reopen
- Clean up protocol field in removeSsh()

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 12:40:40 +08:00
陈大猫
84fd2c46f6 fix: resolve shell cwd for relative path autocomplete (#594) (#596)
* fix: resolve interactive shell cwd for relative path autocomplete (#594)

When `listSessionDir` receives a relative path (e.g. "."), the exec
channel defaults to the home directory instead of the interactive
shell's cwd. Prepend a cwd-resolution preamble that finds the sibling
shell process via $PPID and reads its /proc/<pid>/cwd, then cd's into
it before running `find`. Gracefully degrades to the old behavior if
resolution fails.

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

* fix: prefer prompt-based cwd over stale fallback for path autocomplete

Two bugs caused `cd ` autocomplete to show home dir instead of current dir:

1. resolveAutocompleteCwd skipped prompt cwd extraction when currentWord
   was empty (the "cd " trailing space case), always returning the stale
   fallbackCwd set at connection time.

2. chooseAutocompleteCwd discarded prompt cwd starting with "~/" in favor
   of fallbackCwd, even though the prompt cwd is more current when OSC 7
   is not supported by the remote shell.

Now: always attempt prompt extraction for empty/relative words, and prefer
prompt cwd ("~/path") over potentially stale fallback.

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 10:45:42 +08:00
bincxz
31dd757729 fix: adjust section header icon vertical alignment upward
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:12:29 +08:00
bincxz
cb79036d96 fix: vertically center section header icons with text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:11:50 +08:00
bincxz
32a208eec5 fix: allow pinned hosts to appear in Recently Connected section
Removing the !h.pinned filter from recentHosts — if user only
connects to pinned hosts, the Recent section would never appear.
Showing a host in both Pinned and Recent is acceptable since they
convey different information (favorite vs just used). Also removes
debug console.log statements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:10:09 +08:00
bincxz
6cbe1be5c5 fix: use ref for sessionById in handleSessionStatusChange
The useMemo-derived sessionById could be stale in the callback
closure, preventing lastConnectedAt from being set on connect.
Use a ref to always read the latest session map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:01:01 +08:00
陈大猫
c7ae51b952 feat: host/group management improvements (#506) (#589)
* feat(models): add pinned and lastConnectedAt fields to Host

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(i18n): add translations for pinned and recently connected sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(vault): add pin toggle, lastConnectedAt tracking, and computed sections

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

* feat(vault): render Pinned and Recently Connected sections at root level

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

* feat(vault): add pin/unpin context menus and hover edit buttons in all views

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

* feat(vault): make breadcrumb a drop target for moving groups back to root

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(settings): add toggle for showing recently connected hosts section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: resolve lint warnings for unused vars and unnecessary dependency

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

* fix: improve pin performance and add pop-in animation

- Use ref for hosts in callbacks to avoid stale closures and
  unnecessary re-renders when hosts array changes
- Add pop-in spring animation on pinned host cards with staggered
  delay for a satisfying visual effect

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

* fix: fix pop-in animation visibility and improve pin responsiveness

- Move @keyframes pop-in out of @layer base to global scope so inline
  styles can reference it
- Add translateY to animation for a bouncier, more satisfying feel
- Use pinnedAnimKey to force card remount on pin changes so animation
  replays each time
- Wrap onUpdateHosts in startTransition for non-blocking pin updates

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

* fix: only animate newly pinned card, increase section spacing

- Track lastPinnedId instead of global animKey so only the newly pinned
  card gets the pop-in animation, not all existing pinned cards
- Clear animation state via onAnimationEnd for clean re-trigger
- Add mb-4 to Pinned and Recent sections for better visual separation

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

* feat(vault): show pin indicator icon on pinned host cards

Small semi-transparent pin icon in top-right corner of pinned host
cards in the Hosts section (grid view only).

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

* style: use solid amber/yellow pin indicator icon

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

* style: tilt pin indicator icon 45 degrees

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

* style: replace pin indicator with filled amber star on all pinned cards

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

* fix: move lastConnectedAt tracking to App-level handleConnectToHost

Previously updating lastConnectedAt in VaultView's handleHostConnect
which could be lost during tab switches. Now tracked at the App level
where all connections are handled, ensuring the timestamp persists
regardless of UI navigation state.

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

* fix: address Codex review findings (P2 issues)

1. useStoredBoolean now syncs across same-window components via
   CustomEvent dispatch, so Settings toggle immediately updates VaultView
2. lastConnectedAt updated after connectToHost succeeds, not before
3. Pinned and Recently Connected sections now respect active search
   and tag filters

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

* fix: address second round Codex review findings

1. Track lastConnectedAt on actual 'connected' status instead of
   session creation - handles via handleSessionStatusChange wrapper
2. Covers tray panel connections since all paths go through
   updateSessionStatus
3. Pinned/Recent cards now honor multi-select mode with checkbox
   UI instead of triggering connections

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

* fix: address third round Codex review findings

1. [P1] Use hostsRef in handleSessionStatusChange to avoid
   overwriting concurrent host changes with stale snapshot
2. [P2] Exclude pinned/recent hosts from main host list at root
   level to prevent duplicate cards on screen
3. [P2] Remove Pin action from tree view context menu since tree
   view has no pinned ordering/indicator support

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

* fix: address fourth round Codex review findings

1. [P1] Remove leftover onToggleHostPinned references in HostTreeView
   root-level component that were missed in previous cleanup
2. [P2] Add draggable + onDragStart to pinned/recent host cards so
   drag-and-drop between groups still works
3. [P3] Fix grouped view header count to exclude hosts already shown
   in pinned/recent sections

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

* fix: use functional state update for lastConnectedAt, dedupe pinned from recent

1. [P2] Add updateHostLastConnected using setHosts(prev => ...) functional
   update pattern (same as updateHostDistro) to avoid overwriting concurrent
   host changes when multiple sessions connect simultaneously
2. [P3] Exclude pinned hosts from Recently Connected section to prevent
   duplicate cards between the two top sections

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

* fix: wire showRecentHosts into settings sync, clear pin on duplicate

1. [P2] Add showRecentHosts to SyncPayload settings so the preference
   survives cloud sync and settings export/import
2. [P2] Clear pinned and lastConnectedAt on duplicated hosts so copies
   don't inherit pin/recent status from the original

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:55:45 +08:00
66 changed files with 4042 additions and 420 deletions

160
App.tsx
View File

@@ -14,6 +14,7 @@ import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { resolveHostAuth } from './domain/sshAuth';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
@@ -35,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';
@@ -185,6 +187,7 @@ function App({ settings }: { settings: SettingsState }) {
terminalFontSize,
setTerminalFontSize,
terminalSettings,
updateTerminalSetting,
hotkeyScheme,
keyBindings,
isHotkeyRecording,
@@ -200,10 +203,11 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsDir,
sessionLogsFormat,
reapplyCurrentTheme,
immersiveMode,
workspaceFocusStyle,
} = settings;
const discoveredShells = useDiscoveredShells();
// Sync workspace focus indicator style to DOM for CSS targeting
useEffect(() => {
if (workspaceFocusStyle === 'border') {
@@ -239,8 +243,11 @@ function App({ settings }: { settings: SettingsState }) {
deleteConnectionLog,
clearUnsavedConnectionLogs,
updateHostDistro,
updateHostLastConnected,
convertKnownHostToHost,
importDataFromString,
groupConfigs,
updateGroupConfigs,
} = useVaultState();
const {
@@ -305,6 +312,8 @@ function App({ settings }: { settings: SettingsState }) {
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
);
const sessionByIdRef = useRef(sessionById);
sessionByIdRef.current = sessionById;
const workspaceById = useMemo(
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
[workspaces],
@@ -352,7 +361,6 @@ function App({ settings }: { settings: SettingsState }) {
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
isImmersive: immersiveMode,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme: reapplyCurrentTheme,
@@ -382,6 +390,7 @@ function App({ settings }: { settings: SettingsState }) {
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
groupConfigs,
settingsVersion: settings.settingsVersion,
onApplyPayload: (payload) => {
applySyncPayload(payload, {
@@ -426,7 +435,8 @@ function App({ settings }: { settings: SettingsState }) {
}
if (start) {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
return;
@@ -441,10 +451,12 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const effectiveHost = resolveEffectiveHost(host);
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
@@ -460,9 +472,9 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
@@ -479,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 &&
@@ -611,6 +642,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
groupConfigs,
});
// Sync tray menu data + handle tray actions
@@ -802,22 +834,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) => {
@@ -1032,6 +1079,9 @@ function App({ settings }: { settings: SettingsState }) {
}, [hosts, updateHosts, t]);
// System info for connection logs
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const systemInfoRef = useRef<{ username: string; hostname: string }>({
username: 'user',
hostname: 'localhost',
@@ -1053,13 +1103,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',
@@ -1068,16 +1129,24 @@ 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;
const groupDefaults = resolveGroupDefaults(host.group, groupConfigs);
return applyGroupDefaults(host, groupDefaults);
}, [groupConfigs]);
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
const { username, hostname: localHost } = systemInfoRef.current;
const effectiveHost = resolveEffectiveHost(host);
// Handle serial hosts separately
if (host.protocol === 'serial') {
if (effectiveHost.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
@@ -1093,9 +1162,9 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
const protocol = effectiveHost.moshEnabled ? 'mosh' : (effectiveHost.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host: effectiveHost, keys, identities });
const sessionId = connectToHost(effectiveHost);
addConnectionLog({
sessionId,
hostId: host.id,
@@ -1108,7 +1177,18 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: localHost,
saved: false,
});
}, [addConnectionLog, connectToHost, identities, keys]);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
// Wrap updateSessionStatus to track lastConnectedAt on successful connection
const handleSessionStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
updateSessionStatus(sessionId, status);
if (status === 'connected') {
const session = sessionByIdRef.current.get(sessionId);
if (session?.hostId) {
updateHostLastConnected(session.hostId);
}
}
}, [updateSessionStatus, updateHostLastConnected]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
@@ -1162,24 +1242,25 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [sessions, connectionLogs, updateConnectionLog]);
// Check if host has multiple protocols enabled
// Check if host has multiple protocols enabled (using effective/resolved host)
const hasMultipleProtocols = useCallback((host: Host) => {
const effective = resolveEffectiveHost(host);
let count = 0;
// SSH is always available as base protocol (unless explicitly set to something else)
if (host.protocol === 'ssh' || !host.protocol) count++;
if (effective.protocol === 'ssh' || !effective.protocol) count++;
// Mosh adds another option
if (host.moshEnabled) count++;
if (effective.moshEnabled) count++;
// Telnet adds another option
if (host.telnetEnabled) count++;
if (effective.telnetEnabled) count++;
// If protocol is explicitly telnet (not ssh), count it
if (host.protocol === 'telnet' && !host.telnetEnabled) count++;
if (effective.protocol === 'telnet' && !effective.telnetEnabled) count++;
return count > 1;
}, []);
}, [resolveEffectiveHost]);
// Handle host connect with protocol selection (used by QuickSwitcher)
const handleHostConnectWithProtocolCheck = useCallback((host: Host) => {
if (hasMultipleProtocols(host)) {
setProtocolSelectHost(host);
setProtocolSelectHost(resolveEffectiveHost(host));
setIsQuickSwitcherOpen(false);
setQuickSearch('');
} else {
@@ -1187,7 +1268,7 @@ function App({ settings }: { settings: SettingsState }) {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}
}, [hasMultipleProtocols, handleConnectToHost]);
}, [hasMultipleProtocols, handleConnectToHost, resolveEffectiveHost]);
// Handle protocol selection from dialog
const handleProtocolSelect = useCallback((protocol: HostProtocol, port: number) => {
@@ -1278,7 +1359,7 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
return (
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", immersiveMode && activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
hosts={hosts}
@@ -1299,7 +1380,7 @@ function App({ settings }: { settings: SettingsState }) {
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
onSyncNow={handleSyncNowManual}
isImmersiveActive={immersiveMode && activeTerminalTheme !== null}
isImmersiveActive={activeTerminalTheme !== null}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
@@ -1329,6 +1410,8 @@ function App({ settings }: { settings: SettingsState }) {
onConnectSerial={handleConnectSerial}
onDeleteHost={handleDeleteHost}
onConnect={handleConnectToHost}
groupConfigs={groupConfigs}
onUpdateGroupConfigs={updateGroupConfigs}
onUpdateHosts={updateHosts}
onUpdateKeys={updateKeys}
onUpdateIdentities={updateIdentities}
@@ -1355,6 +1438,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
@@ -1369,6 +1453,7 @@ function App({ settings }: { settings: SettingsState }) {
<TerminalLayerMount
hosts={hosts}
groupConfigs={groupConfigs}
keys={keys}
identities={identities}
snippets={snippets}
@@ -1387,8 +1472,9 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateTerminalThemeId={setTerminalThemeId}
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
onUpdateTerminalFontSize={setTerminalFontSize}
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
onCloseSession={closeSession}
onUpdateSessionStatus={updateSessionStatus}
onUpdateSessionStatus={handleSessionStatusChange}
onUpdateHostDistro={updateHostDistro}
onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))}
onAddKnownHost={(kh) => updateKnownHosts([...knownHosts, kh])}
@@ -1451,8 +1537,8 @@ function App({ settings }: { settings: SettingsState }) {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}
onCreateLocalTerminal={() => {
handleCreateLocalTerminal();
onCreateLocalTerminal={(shell) => {
handleCreateLocalTerminal(shell);
setIsQuickSwitcherOpen(false);
setQuickSearch('');
}}

View File

@@ -21,6 +21,7 @@ const en: Messages = {
'common.clear': 'Clear',
'common.optional': 'Optional',
'common.selectPlaceholder': 'Select...',
'common.add': 'Add',
'common.rename': 'Rename',
'common.refresh': 'Refresh',
'common.continue': 'Continue',
@@ -196,6 +197,9 @@ const en: Messages = {
'settings.application.github.subtitle': 'Source code',
'settings.application.whatsNew': "What's new",
'settings.application.whatsNew.subtitle': 'Show release notes',
'settings.vault.title': 'Vault',
'settings.vault.showRecentHosts': 'Show recently connected hosts',
'settings.vault.showRecentHostsDesc': 'Display a section of recently connected hosts at the top of the vault',
// Update notifications
'update.available.title': 'Update Available',
@@ -231,9 +235,6 @@ const en: Messages = {
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
'settings.appearance.themeColor.light': 'Light palette',
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.immersiveMode': 'Immersive Mode',
'settings.appearance.immersiveMode.desc':
'When enabled, the UI chrome (tab bar, sidebar, status bar) adapts its colors to match the active terminal theme for a visually cohesive experience.',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
@@ -327,6 +328,14 @@ const en: Messages = {
'settings.terminal.scrollback.rows': 'Number of rows *',
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
'settings.terminal.keywordHighlight.patternField': 'Regex Pattern',
'settings.terminal.keywordHighlight.patternPlaceholder': 'Regex (e.g., \\bdown\\b)',
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
'settings.terminal.keywordHighlight.preview': 'Preview',
'settings.terminal.section.localShell': 'Local Shell',
'settings.terminal.localShell.shell': 'Shell executable',
'settings.terminal.localShell.shell.desc': 'Path to the shell executable (e.g., /bin/zsh, pwsh.exe). Leave empty for system default.',
@@ -334,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',
@@ -352,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
@@ -461,8 +475,24 @@ const en: Messages = {
'vault.groups.placeholder.example': 'e.g. Production',
'vault.groups.parentLabel': 'Parent',
'vault.groups.pathLabel': 'Path',
'vault.groups.settings': 'Group Settings',
'vault.groups.details': 'Group Details',
'vault.groups.details.general': 'General',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': 'Advanced',
'vault.groups.details.appearance': 'Appearance',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': 'Parent Group',
'vault.groups.details.none': 'None',
'vault.groups.details.inherited': 'Inherited from group',
'vault.groups.details.addProtocol': 'Add Protocol',
'vault.groups.details.removeProtocol': 'Remove Protocol',
'vault.groups.details.fontFamily': 'Font Family',
'vault.groups.details.fontSize': 'Font Size',
'vault.groups.errors.required': 'Group name is required.',
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
'vault.managedSource.unmanage': 'Unmanage',
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
@@ -486,6 +516,10 @@ const en: Messages = {
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
'vault.hosts.export.toast.noHosts': 'No hosts to export',
'vault.hosts.allHosts': 'All hosts',
'vault.hosts.pinned': 'Pinned',
'vault.hosts.recentlyConnected': 'Recently Connected',
'vault.hosts.pinToTop': 'Pin to Top',
'vault.hosts.unpin': 'Unpin',
'vault.hosts.copyCredentials': 'Copy Credentials',
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
@@ -882,6 +916,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',
@@ -979,6 +1015,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',
@@ -1186,6 +1224,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',

View File

@@ -13,6 +13,7 @@ const zhCN: Messages = {
'common.connect': '连接',
'common.terminal': '终端',
'common.create': '创建',
'common.add': '添加',
'common.rename': '重命名',
'common.refresh': '刷新',
'common.continue': '继续',
@@ -180,6 +181,9 @@ const zhCN: Messages = {
'settings.application.github.subtitle': '源代码',
'settings.application.whatsNew': '更新内容',
'settings.application.whatsNew.subtitle': '查看发布说明',
'settings.vault.title': '主机库',
'settings.vault.showRecentHosts': '显示最近连接的主机',
'settings.vault.showRecentHostsDesc': '在主机列表顶部显示最近连接过的主机',
// Update notifications
'update.available.title': '发现新版本',
@@ -215,9 +219,6 @@ const zhCN: Messages = {
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
'settings.appearance.themeColor.light': '浅色主题',
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.immersiveMode': '沉浸模式',
'settings.appearance.immersiveMode.desc':
'启用后UI 外观(标签栏、侧边栏、状态栏)会自动适配当前终端主题的配色,营造视觉一体化的沉浸体验。',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
'settings.appearance.customCss.placeholder':
@@ -294,8 +295,24 @@ const zhCN: Messages = {
'vault.groups.placeholder.example': '例如Production',
'vault.groups.parentLabel': '父级',
'vault.groups.pathLabel': '路径',
'vault.groups.settings': '分组设置',
'vault.groups.details': '分组详情',
'vault.groups.details.general': '常规',
'vault.groups.details.ssh': 'SSH',
'vault.groups.details.telnet': 'Telnet',
'vault.groups.details.advanced': '高级',
'vault.groups.details.appearance': '外观',
'vault.groups.details.mosh': 'Mosh',
'vault.groups.details.parentGroup': '父分组',
'vault.groups.details.none': '无',
'vault.groups.details.inherited': '继承自分组',
'vault.groups.details.addProtocol': '添加协议',
'vault.groups.details.removeProtocol': '移除协议',
'vault.groups.details.fontFamily': '字体',
'vault.groups.details.fontSize': '字号',
'vault.groups.errors.required': '分组名称不能为空。',
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
'vault.managedSource.unmanage': '取消托管',
'vault.managedSource.unmanageSuccess': '已取消托管分组',
@@ -319,6 +336,10 @@ const zhCN: Messages = {
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV跳过 {skipped} 个不支持的主机)',
'vault.hosts.export.toast.noHosts': '没有主机可导出',
'vault.hosts.allHosts': '全部主机',
'vault.hosts.pinned': '已置顶',
'vault.hosts.recentlyConnected': '最近连接',
'vault.hosts.pinToTop': '置顶',
'vault.hosts.unpin': '取消置顶',
'vault.hosts.copyCredentials': '复制账密信息',
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
@@ -542,6 +563,8 @@ const zhCN: Messages = {
'qs.search.placeholder': '搜索主机或标签页',
'qs.jumpTo': '跳转到',
'qs.localTerminal': '本地终端',
'qs.localShells': '本地 Shell',
'qs.default': '默认',
// Select Host panel
'selectHost.title': '选择主机',
@@ -635,6 +658,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': '直连',
@@ -814,6 +839,7 @@ const zhCN: Messages = {
'terminal.themeModal.globalTheme': '全局主题',
'terminal.themeModal.globalFont': '全局字体',
'terminal.themeModal.fontSize': '字体大小',
'terminal.themeModal.fontWeight': '字体粗细',
'terminal.themeModal.livePreview': '实时预览',
'terminal.themeModal.themeType': '{type} 主题',
@@ -1283,6 +1309,14 @@ const zhCN: Messages = {
'settings.terminal.scrollback.rows': '行数 *',
'settings.terminal.keywordHighlight.title': '关键字高亮',
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down',
'settings.terminal.keywordHighlight.patternField': '正则表达式',
'settings.terminal.keywordHighlight.patternPlaceholder': '正则表达式(如 \\bdown\\b',
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
'settings.terminal.keywordHighlight.preview': '预览',
'settings.terminal.section.localShell': '本地 Shell',
'settings.terminal.localShell.shell': 'Shell 可执行文件',
'settings.terminal.localShell.shell.desc': 'Shell 可执行文件的路径(例如 /bin/zsh、pwsh.exe。留空使用系统默认。',
@@ -1290,6 +1324,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': '用户主目录',
@@ -1308,7 +1347,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

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

@@ -32,6 +32,7 @@ interface AutoSyncConfig {
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
groupConfigs?: SyncPayload['groupConfigs'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
@@ -95,6 +96,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: effectiveKnownHosts,
groupConfigs: config.groupConfigs,
};
}, [
config.hosts,
@@ -105,6 +107,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.snippetPackages,
config.portForwardingRules,
config.knownHosts,
config.groupConfigs,
]);
// Build sync payload

View File

@@ -151,12 +151,10 @@ function removeImmersiveStyle() {
// ---------------------------------------------------------------------------
export function useImmersiveMode({
isImmersive,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme,
}: {
isImmersive: boolean;
activeTabId: string;
activeTerminalTheme: TerminalTheme | null;
restoreOriginalTheme: () => void;
@@ -170,18 +168,18 @@ export function useImmersiveMode({
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
useLayoutEffect(() => {
if (isImmersive && isTerminalTab && activeTerminalTheme) {
if (isTerminalTab && activeTerminalTheme) {
const fp = themeFingerprint(activeTerminalTheme);
if (appliedFpRef.current === fp) return;
overrideActiveRef.current = true;
appliedFpRef.current = fp;
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
}
}, [isImmersive, isTerminalTab, activeTerminalTheme]);
}, [isTerminalTab, activeTerminalTheme]);
// RESTORE: useEffect — runs after paint, with fade overlay
useEffect(() => {
if (isImmersive && isTerminalTab && activeTerminalTheme) return;
if (isTerminalTab && activeTerminalTheme) return;
if (!overrideActiveRef.current) return;
overrideActiveRef.current = false;
appliedFpRef.current = null;
@@ -198,7 +196,7 @@ export function useImmersiveMode({
});
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
}, [isImmersive, isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
}, [isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
// Cleanup on unmount
useEffect(() => {

View File

@@ -4,7 +4,8 @@
* when the application starts, not when the user navigates to the port forwarding page.
*/
import { useCallback, useEffect, useRef } from "react";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { GroupConfig, Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
@@ -19,6 +20,7 @@ export interface UsePortForwardingAutoStartOptions {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
groupConfigs: GroupConfig[];
}
/**
@@ -29,11 +31,13 @@ export const usePortForwardingAutoStart = ({
hosts,
keys,
identities,
groupConfigs,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<SSHKey[]>(keys);
const identitiesRef = useRef<Identity[]>(identities);
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
@@ -73,6 +77,16 @@ export const usePortForwardingAutoStart = ({
identitiesRef.current = identities;
}, [identities]);
useEffect(() => {
groupConfigsRef.current = groupConfigs;
}, [groupConfigs]);
const resolveEffectiveHost = useCallback((host: Host): Host => {
if (!host.group) return host;
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
return applyGroupDefaults(host, defaults);
}, []);
// Set up the reconnect callback
useEffect(() => {
const handleReconnect = async (
@@ -89,11 +103,12 @@ export const usePortForwardingAutoStart = ({
return { success: false, error: "Rule or host not found" };
}
const host = hostsRef.current.find((h) => h.id === rule.hostId);
if (!host) {
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
if (!rawHost) {
return { success: false, error: "Host not found" };
}
const host = resolveEffectiveHost(rawHost);
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
};
@@ -101,7 +116,7 @@ export const usePortForwardingAutoStart = ({
return () => {
setReconnectCallback(null);
};
}, []);
}, [resolveEffectiveHost]);
// Auto-start rules on app launch
useEffect(() => {
@@ -146,8 +161,9 @@ export const usePortForwardingAutoStart = ({
// Start each auto-start rule
for (const rule of autoStartRules) {
const host = hosts.find((h) => h.id === rule.hostId);
if (host) {
const rawHost = hosts.find((h) => h.id === rule.hostId);
if (rawHost) {
const host = resolveEffectiveHost(rawHost);
void startPortForward(
rule,
host,
@@ -180,5 +196,5 @@ export const usePortForwardingAutoStart = ({
};
void runAutoStart();
}, [hosts, identities, isHostAuthReady, keys]);
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
};

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

@@ -125,7 +125,7 @@ const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
// If immersive mode is active (style tag present), it owns the dark/light class — don't override
// If immersive override is active (style tag present), it owns the dark/light class — don't override
if (!document.getElementById('netcatty-immersive-override')) {
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
@@ -340,11 +340,6 @@ export const useSettingsState = () => {
}
}, []);
// Immersive mode is always enabled — the toggle has been removed from settings
const immersiveMode = true;
const setImmersiveMode = useCallback((_enabled: boolean) => {
// no-op: immersive mode is always on
}, []);
const setSftpTransferConcurrency = useCallback((value: number) => {
const clamped = Math.max(1, Math.min(16, Math.round(value)));
@@ -462,7 +457,7 @@ export const useSettingsState = () => {
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
@@ -651,7 +646,7 @@ export const useSettingsState = () => {
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
globalHotkeyEnabled, autoUpdateEnabled,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
@@ -660,7 +655,7 @@ export const useSettingsState = () => {
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
globalHotkeyEnabled, autoUpdateEnabled,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -1220,8 +1215,6 @@ export const useSettingsState = () => {
setGlobalHotkeyEnabled,
rehydrateAllFromStorage,
reapplyCurrentTheme,
immersiveMode,
setImmersiveMode,
workspaceFocusStyle,
setWorkspaceFocusStyle,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
@@ -1232,7 +1225,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
customThemes, immersiveMode, workspaceFocusStyle,
customThemes, workspaceFocusStyle,
]),
};
};

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for persisting a boolean value to localStorage.
* Syncs across components in the same window via a custom event,
* and across windows via the native storage event.
* @param storageKey - The key to use for localStorage
* @param fallback - The default value if no stored value exists (defaults to false)
* @returns A tuple of [value, setValue] similar to useState
@@ -16,9 +18,38 @@ export const useStoredBoolean = (
return stored ?? fallback;
});
useEffect(() => {
localStorageAdapter.writeBoolean(storageKey, value);
}, [storageKey, value]);
const setAndPersist = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
setValue((prev) => {
const resolved = typeof next === "function" ? next(prev) : next;
localStorageAdapter.writeBoolean(storageKey, resolved);
// Notify other same-window consumers
window.dispatchEvent(
new CustomEvent("stored-boolean-change", { detail: { key: storageKey, value: resolved } }),
);
return resolved;
});
}, [storageKey]);
return [value, setValue] as const;
useEffect(() => {
// Sync from other components in the same window
const handleCustom = (e: Event) => {
const { key, value: newValue } = (e as CustomEvent).detail;
if (key === storageKey) setValue(newValue);
};
// Sync from other windows
const handleStorage = (e: StorageEvent) => {
if (e.key === storageKey) {
const stored = localStorageAdapter.readBoolean(storageKey);
setValue(stored ?? fallback);
}
};
window.addEventListener("stored-boolean-change", handleCustom);
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("stored-boolean-change", handleCustom);
window.removeEventListener("storage", handleStorage);
};
}, [storageKey, fallback]);
return [value, setAndPersist] as const;
};

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import {
ConnectionLog,
GroupConfig,
Host,
Identity,
KeyCategory,
@@ -17,6 +18,7 @@ import {
} from "../../infrastructure/config/defaultData";
import {
STORAGE_KEY_CONNECTION_LOGS,
STORAGE_KEY_GROUP_CONFIGS,
STORAGE_KEY_GROUPS,
STORAGE_KEY_HOSTS,
STORAGE_KEY_IDENTITIES,
@@ -30,9 +32,11 @@ import {
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
decryptGroupConfigs,
decryptHosts,
decryptIdentities,
decryptKeys,
encryptGroupConfigs,
encryptHosts,
encryptIdentities,
encryptKeys,
@@ -46,6 +50,7 @@ type ExportableVaultData = {
customGroups: string[];
snippetPackages?: string[];
knownHosts?: KnownHost[];
groupConfigs?: GroupConfig[];
};
type LegacyKeyRecord = Record<string, unknown> & { id?: string; source?: string };
@@ -107,6 +112,7 @@ export const useVaultState = () => {
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
// Write-version counters prevent out-of-order async writes from overwriting
// newer data. Each update bumps the counter; the .then() callback only
@@ -114,6 +120,7 @@ export const useVaultState = () => {
const hostsWriteVersion = useRef(0);
const keysWriteVersion = useRef(0);
const identitiesWriteVersion = useRef(0);
const groupConfigsWriteVersion = useRef(0);
// Read-sequence counters for cross-window storage events. Each incoming
// event bumps the counter; the async decrypt callback only applies state if
@@ -122,6 +129,7 @@ export const useVaultState = () => {
const hostsReadSeq = useRef(0);
const keysReadSeq = useRef(0);
const identitiesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
@@ -176,6 +184,15 @@ export const useVaultState = () => {
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
}, []);
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
setGroupConfigs(data);
const ver = ++groupConfigsWriteVersion.current;
encryptGroupConfigs(data).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}, []);
const clearVaultData = useCallback(() => {
updateHosts([]);
updateKeys([]);
@@ -185,6 +202,7 @@ export const useVaultState = () => {
updateCustomGroups([]);
updateKnownHosts([]);
updateManagedSources([]);
updateGroupConfigs([]);
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
}, [
updateHosts,
@@ -195,6 +213,7 @@ export const useVaultState = () => {
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
]);
const addShellHistoryEntry = useCallback(
@@ -430,6 +449,20 @@ export const useVaultState = () => {
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
if (savedGroupConfigs) {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
setGroupConfigs(decryptedGC);
encryptGroupConfigs(decryptedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}
}
};
init();
@@ -529,6 +562,19 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_MANAGED_SOURCES) {
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
setManagedSources(next);
return;
}
if (key === STORAGE_KEY_GROUP_CONFIGS) {
const next = safeParse<GroupConfig[]>(event.newValue) ?? [];
++groupConfigsWriteVersion.current;
const seq = ++groupConfigsReadSeq.current;
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(dec);
});
return;
}
};
@@ -536,6 +582,20 @@ export const useVaultState = () => {
return () => window.removeEventListener("storage", handleStorage);
}, []);
const updateHostLastConnected = useCallback((hostId: string) => {
setHosts((prev) => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, lastConnectedAt: Date.now() } : h,
);
const ver = ++hostsWriteVersion.current;
encryptHosts(next).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return next;
});
}, []);
const updateHostDistro = useCallback((hostId: string, distro: string) => {
const normalized = normalizeDistroId(distro);
setHosts((prev) => {
@@ -560,8 +620,9 @@ export const useVaultState = () => {
customGroups,
snippetPackages,
knownHosts,
groupConfigs,
}),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
);
const importData = useCallback(
@@ -573,6 +634,7 @@ export const useVaultState = () => {
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) updateGroupConfigs(payload.groupConfigs);
},
[
updateHosts,
@@ -582,6 +644,7 @@ export const useVaultState = () => {
updateCustomGroups,
updateSnippetPackages,
updateKnownHosts,
updateGroupConfigs,
],
);
@@ -604,6 +667,7 @@ export const useVaultState = () => {
shellHistory,
connectionLogs,
managedSources,
groupConfigs,
updateHosts,
updateKeys,
updateIdentities,
@@ -612,6 +676,7 @@ export const useVaultState = () => {
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
addConnectionLog,
@@ -620,6 +685,7 @@ export const useVaultState = () => {
deleteConnectionLog,
clearUnsavedConnectionLogs,
updateHostDistro,
updateHostLastConnected,
convertKnownHostToHost,
exportData,
importDataFromString,

View File

@@ -8,6 +8,7 @@
*/
import type {
GroupConfig,
Host,
Identity,
KnownHost,
@@ -41,7 +42,7 @@ import {
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_IMMERSIVE_MODE,
STORAGE_KEY_SHOW_RECENT_HOSTS,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -57,6 +58,7 @@ export interface SyncableVaultData {
customGroups: string[];
snippetPackages?: string[];
knownHosts: KnownHost[];
groupConfigs?: GroupConfig[];
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
@@ -168,9 +170,9 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
// Immersive mode
const immersive = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (immersive === 'true' || immersive === 'false') settings.immersiveMode = immersive === 'true';
const showRecent = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
if (showRecent != null) settings.showRecentHosts = showRecent;
return Object.keys(settings).length > 0 ? settings : undefined;
}
@@ -234,8 +236,8 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
// Immersive mode
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
// Immersive mode (legacy — always enabled, ignore incoming value)
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
}
// ---------------------------------------------------------------------------
@@ -261,6 +263,7 @@ export function buildSyncPayload(
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
knownHosts: vault.knownHosts,
groupConfigs: vault.groupConfigs,
portForwardingRules,
settings: collectSyncableSettings(),
syncedAt: Date.now(),
@@ -294,6 +297,9 @@ export function applySyncPayload(
if (payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}
if (Array.isArray(payload.groupConfigs)) {
vaultImport.groupConfigs = payload.groupConfigs;
}
importers.importVaultData(JSON.stringify(vaultImport));

File diff suppressed because it is too large Load Diff

View File

@@ -99,6 +99,7 @@ interface HostDetailsPanelProps {
onCancel: () => void;
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
onCreateTag?: (tag: string) => void; // Callback to create a new tag
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
}
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
@@ -116,6 +117,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onCancel,
onCreateGroup,
onCreateTag,
groupDefaults,
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
@@ -126,13 +128,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
id: crypto.randomUUID(),
label: "",
hostname: "",
port: 22,
username: "root",
port: groupDefaults?.port ? undefined : 22,
username: groupDefaults?.username ? "" : "root",
protocol: "ssh",
tags: [],
os: "linux",
authMethod: "password",
charset: "UTF-8",
charset: groupDefaults?.charset ? undefined : "UTF-8",
distroMode: "auto",
createdAt: Date.now(),
group: defaultGroup || undefined, // Pre-fill with current navigation group
@@ -282,12 +284,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
};
const removeHostFromChain = (index: number) => {
setForm((prev) => ({
...prev,
hostChain: {
hostIds: (prev.hostChain?.hostIds || []).filter((_, i) => i !== index),
},
}));
setForm((prev) => {
const ids = (prev.hostChain?.hostIds || []).filter((_, i) => i !== index);
return { ...prev, hostChain: ids.length > 0 ? { hostIds: ids } : undefined };
});
};
const clearHostChain = useCallback(() => {
@@ -313,12 +313,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
};
const removeEnvVar = (index: number) => {
setForm((prev) => ({
...prev,
environmentVariables: (prev.environmentVariables || []).filter(
(_, i) => i !== index,
),
}));
setForm((prev) => {
const filtered = (prev.environmentVariables || []).filter((_, i) => i !== index);
return { ...prev, environmentVariables: filtered.length > 0 ? filtered : undefined };
});
};
const handleSubmit = () => {
@@ -363,7 +361,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
label: finalLabel,
group: finalGroup,
tags: form.tags || [],
port: form.port || 22,
port: form.port ?? (groupDefaults?.port ? undefined : 22),
// Clear password if savePassword is explicitly set to false
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
@@ -752,8 +750,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
<Input
type="number"
value={form.port}
onChange={(e) => update("port", Number(e.target.value))}
value={form.port ?? ""}
onChange={(e) => update("port", e.target.value ? Number(e.target.value) : undefined)}
placeholder={groupDefaults?.port ? String(groupDefaults.port) : "22"}
className="h-8 flex-1 min-w-0 text-center"
/>
<span className="text-xs text-muted-foreground">
@@ -805,7 +804,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
if (!hasIdentities) {
return (
<Input
placeholder={t("hostDetails.username.placeholder")}
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
value={form.username}
onChange={(e) => update("username", e.target.value)}
className="h-10"
@@ -824,7 +823,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<PopoverTrigger asChild>
<div className="relative">
<Input
placeholder={t("hostDetails.username.placeholder")}
placeholder={groupDefaults?.username || t("hostDetails.username.placeholder")}
value={form.username}
onChange={(e) => {
const next = e.target.value;
@@ -1606,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) */}
@@ -1755,7 +1765,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="text-muted-foreground hover:text-destructive flex-shrink-0 ml-auto"
onClick={(e) => {
e.stopPropagation();
setForm((prev) => ({ ...prev, environmentVariables: [] }));
setForm((prev) => ({ ...prev, environmentVariables: undefined }));
}}
/>
</button>
@@ -1778,7 +1788,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<p className="text-xs font-semibold">{t("hostDetails.startupCommand")}</p>
</div>
<Textarea
placeholder={t("hostDetails.startupCommand.placeholder")}
placeholder={groupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
value={form.startupCommand || ""}
onChange={(e) => update("startupCommand", e.target.value)}
className="min-h-[80px] font-mono text-sm"
@@ -1842,7 +1852,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{/* Telnet Charset */}
<Input
placeholder={t("hostDetails.charset.placeholder")}
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
value={form.charset || "UTF-8"}
onChange={(e) => update("charset", e.target.value)}
className="h-10"

View File

@@ -1,4 +1,4 @@
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
@@ -32,6 +32,7 @@ interface HostTreeViewProps {
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
@@ -56,6 +57,7 @@ interface TreeNodeProps {
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
@@ -81,6 +83,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
@@ -176,6 +179,15 @@ const TreeNode: React.FC<TreeNodeProps> = ({
{hostsCountInNode}
</span>
)}
<button
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={13} />
</button>
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
@@ -226,6 +238,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
@@ -244,6 +257,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
@@ -264,6 +278,7 @@ interface HostTreeItemProps {
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
@@ -278,6 +293,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
onDeleteHost,
onCopyCredentials,
moveHostToGroup: _moveHostToGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
@@ -348,6 +364,15 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
{tags.length > 2 && '...'}
</span>
)}
<button
className="h-7 w-7 flex items-center justify-center rounded-md hover:bg-secondary/80 transition-colors"
onClick={(e) => {
e.stopPropagation();
onEditHost(host);
}}
>
<Edit2 size={13} />
</button>
</div>
</div>
</ContextMenuTrigger>
@@ -364,7 +389,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
</ContextMenuItem>
<ContextMenuItem
<ContextMenuItem
onClick={() => onDeleteHost(host)}
className="text-destructive focus:text-destructive"
>
@@ -396,6 +421,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,

View File

@@ -14,12 +14,14 @@ import React, { useCallback, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import {
GroupConfig,
Host,
ManagedSource,
PortForwardingRule,
PortForwardingType,
SSHKey,
} from "../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { cn } from "../lib/utils";
import SelectHostPanel from "./SelectHostPanel";
import {
@@ -66,6 +68,7 @@ interface PortForwardingProps {
identities?: import('../domain/models').Identity[];
customGroups: string[];
managedSources?: ManagedSource[];
groupConfigs?: GroupConfig[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -77,6 +80,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
identities = [],
customGroups: _customGroups,
managedSources = [],
groupConfigs = [],
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
@@ -113,8 +117,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
// Start a port forwarding tunnel
const handleStartTunnel = useCallback(
async (rule: PortForwardingRule) => {
const _host = hosts.find((h) => h.id === rule.hostId);
if (!_host) {
const _rawHost = hosts.find((h) => h.id === rule.hostId);
if (!_rawHost) {
setRuleStatus(rule.id, "error", t("pf.error.hostNotFound"));
toast.error(
t("pf.error.hostNotFound"),
@@ -123,6 +127,10 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
return;
}
const _host = _rawHost.group
? applyGroupDefaults(_rawHost, resolveGroupDefaults(_rawHost.group, groupConfigs))
: _rawHost;
setPendingOperations((prev) => new Set([...prev, rule.id]));
let errorShown = false;
@@ -161,7 +169,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, identities, keys, setRuleStatus, startTunnel, t],
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
);
// Stop a port forwarding tunnel

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,16 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
keyBindings,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
const filteredShells = useMemo(() => {
if (!query.trim()) return discoveredShells;
const q = query.toLowerCase();
return discoveredShells.filter(
(s) => s.name.toLowerCase().includes(q) || s.id.toLowerCase().includes(q)
);
}, [discoveredShells, query]);
// Get hotkey display strings
const getHotkeyLabel = useCallback((actionId: string) => {
const binding = keyBindings?.find(k => k.id === actionId);
@@ -155,13 +166,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 +192,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 +231,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 +398,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 +465,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

@@ -113,6 +113,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
customGroups,
snippetPackages,
knownHosts,
groupConfigs,
importDataFromString,
clearVaultData,
} = useVaultState();
@@ -132,8 +133,8 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
);
const vault = useMemo(
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts }),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs }),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
);
return (
@@ -154,10 +155,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
const isImmersive = settings.immersiveMode;
const toggleImmersive = useCallback(() => {
settings.setImmersiveMode(!isImmersive);
}, [settings, isImmersive]);
useEffect(() => {
notifyRendererReady();
@@ -285,8 +282,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
setCustomCSS={settings.setCustomCSS}
isImmersive={isImmersive}
onToggleImmersive={toggleImmersive}
/>
)}

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

@@ -25,6 +25,7 @@ import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, SSHKey } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { toast } from "./ui/toast";
@@ -51,6 +52,7 @@ interface SftpViewProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
groupConfigs?: import('../domain/models').GroupConfig[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer";
@@ -67,6 +69,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
hosts,
keys,
identities,
groupConfigs = [],
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
@@ -104,7 +107,17 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
defaultShowHiddenFiles: sftpShowHiddenFiles,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
// Pre-resolve group defaults so SFTP connections inherit group config
const effectiveHosts = useMemo(() =>
hosts.map(h => {
if (!h.group) return h;
const defaults = resolveGroupDefaults(h.group, groupConfigs);
return applyGroupDefaults(h, defaults);
}),
[hosts, groupConfigs],
);
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
// Get backend helpers for file downloads and local filesystem writes.
const {
@@ -454,6 +467,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handleSaveTextFile={handleSaveTextFile}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
showFileOpenerDialog={showFileOpenerDialog}
setShowFileOpenerDialog={setShowFileOpenerDialog}
fileOpenerTarget={fileOpenerTarget}
@@ -471,6 +486,7 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.groupConfigs === next.groupConfigs &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&

View File

@@ -576,10 +576,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 +928,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 +944,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
@@ -954,7 +962,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
return document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
: effectiveFontWeight;
})();
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
@@ -989,7 +997,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
lastFittedSizeRef.current = null;
}
}
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
}, [effectiveFontSize, effectiveFontWeight, resolvedFontFamily, terminalSettings]);
useEffect(() => {
if (!isVisible) return;
@@ -1041,7 +1049,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const resolvedBold = document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
: effectiveFontWeight;
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
@@ -1072,7 +1080,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [effectiveFontSize, resizeSession, terminalSettings]);
}, [effectiveFontSize, effectiveFontWeight, resizeSession, terminalSettings]);
useEffect(() => {
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;

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';
@@ -29,7 +32,8 @@ import { useStoredNumber } from '../application/state/useStoredNumber';
import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKeys';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { DistroAvatar } from './DistroAvatar';
import Terminal from './Terminal';
import { SftpSidePanel } from './SftpSidePanel';
@@ -338,6 +342,7 @@ AIChatPanelsHost.displayName = 'AIChatPanelsHost';
interface TerminalLayerProps {
hosts: Host[];
groupConfigs: GroupConfig[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
@@ -356,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;
@@ -391,6 +397,7 @@ interface TerminalLayerProps {
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
groupConfigs,
keys,
identities,
snippets,
@@ -409,6 +416,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onCloseSession,
onUpdateSessionStatus,
onUpdateHostDistro,
@@ -770,8 +778,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sessionHostsMap = useMemo(() => {
const map = new Map<string, Host>();
for (const session of sessions) {
const existingHost = hostMap.get(session.hostId);
if (existingHost) {
const rawHost = hostMap.get(session.hostId);
if (rawHost) {
// Apply group config defaults so Terminal sees the merged host
const groupDefaults = rawHost.group
? resolveGroupDefaults(rawHost.group, groupConfigs)
: {};
const existingHost = applyGroupDefaults(rawHost, groupDefaults);
const protocol = session.protocol ?? existingHost.protocol;
const port = session.port ?? existingHost.port;
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
@@ -804,11 +818,15 @@ 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,
});
}
}
return map;
}, [sessions, hostMap]);
}, [sessions, hostMap, groupConfigs]);
const sessionChainHostsMap = useMemo(() => {
const map = new Map<string, Host[]>();
for (const session of sessions) {
@@ -817,12 +835,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
map.set(
session.id,
host.hostChain.hostIds
.map((hostId) => hostMap.get(hostId))
.map((hostId) => {
const rawChainHost = hostMap.get(hostId);
if (!rawChainHost) return undefined;
const chainGroupDefaults = rawChainHost.group
? resolveGroupDefaults(rawChainHost.group, groupConfigs)
: {};
return applyGroupDefaults(rawChainHost, chainGroupDefaults);
})
.filter((value): value is Host => Boolean(value)),
);
}
return map;
}, [sessions, sessionHostsMap, hostMap]);
}, [sessions, sessionHostsMap, hostMap, groupConfigs]);
const validTerminalTabIds = useMemo(() => {
const ids = new Set<string>();
@@ -1366,9 +1391,11 @@ 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)
: null;
: (isVisible ? focusedThemeId : null);
const appliedPreviewSessionRef = useRef<string | null>(null);
const customThemes = useCustomThemes();
const applyTerminalPreviewVars = useCallback((sessionId: string | null, themeId: string | null) => {
@@ -1549,6 +1576,29 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateHost(clearHostFontSizeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
if (!focusedHost || newFontWeight === focusedFontWeight) return;
startTransition(() => {
if (isFocusedHostLocal) {
onUpdateTerminalFontWeight?.(newFontWeight);
return;
}
// Patch only fontWeight fields on the 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, isFocusedHostLocal, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
const handleFontWeightResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost(clearHostFontWeightOverride(rawHost));
}
}, [focusedHost, isFocusedHostLocal, 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
// or hiding the panel never tears down approval handling mid-execution.
@@ -2019,15 +2069,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
@@ -138,6 +141,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
onSave,
editorWordWrap,
onToggleWordWrap,
hotkeyScheme,
keyBindings,
}) => {
const { t } = useI18n();
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
@@ -216,6 +221,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 +357,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 +405,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) {
@@ -522,7 +534,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{activeTabId === session.id && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'hsl(var(--primary))' }}
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
{/* Drop indicator line - before */}
@@ -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>
@@ -621,7 +633,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{isActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'hsl(var(--primary))' }}
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
{/* Drop indicator line - before */}

View File

@@ -10,6 +10,7 @@ import { I18nProvider } from "../application/i18n/I18nProvider";
import { useSettingsState } from "../application/state/useSettingsState";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
import { AppLogo } from "./AppLogo";
@@ -116,7 +117,7 @@ const TrayPanelContent: React.FC = () => {
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys, identities } = useVaultState();
const { hosts, keys, identities, groupConfigs } = useVaultState();
useSessionState();
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const activeTabId = useActiveTabId();
@@ -326,14 +327,17 @@ const TrayPanelContent: React.FC = () => {
disabled={isConnecting}
title={label}
onClick={() => {
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!host) {
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!rawHost) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (isActive) {
void stopTunnel(rule.id);
} else {
const host = rawHost.group
? applyGroupDefaults(rawHost, resolveGroupDefaults(rawHost.group, groupConfigs))
: rawHost;
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);

View File

@@ -4,6 +4,7 @@ import {
CheckSquare,
ChevronDown,
ClipboardCopy,
Clock,
Copy,
Download,
Edit2,
@@ -15,11 +16,13 @@ import {
LayoutGrid,
List,
Network,
Pin,
Plug,
Plus,
Search,
Settings,
Square,
Star,
TerminalSquare,
Trash2,
Upload,
@@ -27,19 +30,21 @@ import {
X,
Zap,
} from "lucide-react";
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { Suspense, lazy, memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED } from "../infrastructure/config/storageKeys";
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED, STORAGE_KEY_SHOW_RECENT_HOSTS } from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import {
ConnectionLog,
GroupConfig,
GroupNode,
Host,
HostProtocol,
@@ -54,6 +59,7 @@ import {
} from "../types";
import { AppLogo } from "./AppLogo";
import { DistroAvatar } from "./DistroAvatar";
import GroupDetailsPanel from "./GroupDetailsPanel";
import HostDetailsPanel from "./HostDetailsPanel";
import { HostTreeView } from "./HostTreeView";
import KeychainManager from "./KeychainManager";
@@ -135,6 +141,8 @@ interface VaultViewProps {
onClearUnsavedConnectionLogs: () => void;
onOpenLogView: (log: ConnectionLog) => void;
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
groupConfigs: GroupConfig[];
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
// Optional: navigate to a specific section on mount or when changed
navigateToSection?: VaultSection | null;
onNavigateToSectionHandled?: () => void;
@@ -179,11 +187,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onClearUnsavedConnectionLogs,
onOpenLogView,
onRunSnippet,
groupConfigs,
onUpdateGroupConfigs,
navigateToSection,
onNavigateToSectionHandled,
}) => {
const { t } = useI18n();
const rootRef = useRef<HTMLDivElement>(null);
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
const [search, setSearch] = useState("");
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
@@ -210,6 +222,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
false,
);
const [isBreadcrumbDragOver, setIsBreadcrumbDragOver] = useState(false);
const [showRecentHosts, _setShowRecentHosts] = useStoredBoolean(
STORAGE_KEY_SHOW_RECENT_HOSTS,
true,
);
// Handle external navigation requests
useEffect(() => {
if (navigateToSection) {
@@ -234,6 +253,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [editingHost, setEditingHost] = useState<Host | null>(null);
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
// Group panel state
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
const [editingGroupPath, setEditingGroupPath] = useState<string | null>(null);
// Compute inherited group defaults for the host being edited
const editingHostGroupDefaults = useMemo(() => {
const group = editingHost?.group || newHostGroupPath || selectedGroupPath;
if (!group) return undefined;
return resolveGroupDefaults(group, groupConfigs);
}, [editingHost, newHostGroupPath, selectedGroupPath, groupConfigs]);
// Quick connect state
const [quickConnectTarget, setQuickConnectTarget] = useState<{
hostname: string;
@@ -278,30 +308,37 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
[isSearchQuickConnect, handleConnectClick],
);
// Check if host has multiple protocols enabled
// Check if host has multiple protocols enabled (using effective/resolved host)
const hasMultipleProtocols = useCallback((host: Host) => {
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
let count = 0;
// SSH is always available as base protocol (unless explicitly set to something else)
if (host.protocol === "ssh" || !host.protocol) count++;
if (effective.protocol === "ssh" || !effective.protocol) count++;
// Mosh adds another option
if (host.moshEnabled) count++;
if (effective.moshEnabled) count++;
// Telnet adds another option
if (host.telnetEnabled) count++;
if (effective.telnetEnabled) count++;
// If protocol is explicitly telnet (not ssh), count it
if (host.protocol === "telnet" && !host.telnetEnabled) count++;
if (effective.protocol === "telnet" && !effective.telnetEnabled) count++;
return count > 1;
}, []);
}, [groupConfigs]);
// Handle host connect with protocol selection
const handleHostConnect = useCallback(
(host: Host) => {
if (hasMultipleProtocols(host)) {
setProtocolSelectHost(host);
// Pass effective host to protocol dialog so it shows correct ports/protocols
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
setProtocolSelectHost(effective);
} else {
onConnect(host);
}
},
[hasMultipleProtocols, onConnect],
[hasMultipleProtocols, onConnect, groupConfigs],
);
// Handle protocol selection
@@ -342,12 +379,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
);
const handleNewHost = useCallback(() => {
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
setEditingHost(null);
setNewHostGroupPath(null);
setIsHostPanelOpen(true);
}, []);
const handleEditHost = useCallback((host: Host) => {
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
setEditingHost(host);
setIsHostPanelOpen(true);
}, []);
@@ -359,6 +400,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
id: crypto.randomUUID(),
label: `${host.label} (${t('action.copy')})`,
createdAt: Date.now(),
pinned: undefined,
lastConnectedAt: undefined,
};
// Open the edit panel with the duplicated host for modification
setEditingHost(duplicatedHost);
@@ -398,38 +441,42 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Copy host credentials to clipboard
const handleCopyCredentials = useCallback((host: Host) => {
// Apply group defaults so inherited credentials are included
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
// Only use telnet-specific port and credentials when protocol is explicitly telnet
// Don't treat telnetEnabled as primary - that's just an optional protocol
const isTelnet = host.protocol === "telnet";
const isTelnet = effective.protocol === "telnet";
const defaultPort = isTelnet ? 23 : 22;
const effectivePort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
? (effective.telnetPort ?? effective.port ?? 23)
: (effective.port ?? 22);
// Bracket IPv6 addresses when appending non-default port
let address: string;
if (effectivePort !== defaultPort) {
const isIPv6 = host.hostname.includes(":") && !host.hostname.startsWith("[");
const hostname = isIPv6 ? `[${host.hostname}]` : host.hostname;
const isIPv6 = effective.hostname.includes(":") && !effective.hostname.startsWith("[");
const hostname = isIPv6 ? `[${effective.hostname}]` : effective.hostname;
address = `${hostname}:${effectivePort}`;
} else {
address = host.hostname;
address = effective.hostname;
}
// Resolve credentials from identity if configured, otherwise use host credentials
// For telnet hosts, use telnet-specific credentials
const identity = host.identityId
? identities.find((i) => i.id === host.identityId)
const identity = effective.identityId
? identities.find((i) => i.id === effective.identityId)
: undefined;
const username = isTelnet
? (host.telnetUsername?.trim() || host.username?.trim())
: (identity?.username?.trim() || host.username?.trim());
? (effective.telnetUsername?.trim() || effective.username?.trim())
: (identity?.username?.trim() || effective.username?.trim());
const password = isTelnet
? (host.telnetPassword || host.password)
: (identity?.password || host.password);
? (effective.telnetPassword || effective.password)
: (identity?.password || effective.password);
if (!password) {
toast.warning(t('vault.hosts.copyCredentials.toast.noPassword'));
@@ -440,7 +487,19 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
navigator.clipboard.writeText(text).then(() => {
toast.success(t('vault.hosts.copyCredentials.toast.success'));
});
}, [identities, t]);
}, [identities, groupConfigs, t]);
const [lastPinnedId, setLastPinnedId] = useState<string | null>(null);
const toggleHostPinned = useCallback((hostId: string) => {
const host = hostsRef.current.find((h) => h.id === hostId);
const isPinning = host && !host.pinned;
startTransition(() => {
onUpdateHosts(hostsRef.current.map((h) =>
h.id === hostId ? { ...h, pinned: !h.pinned } : h
));
});
setLastPinnedId(isPinning ? hostId : null);
}, [onUpdateHosts]);
const toggleHostSelection = useCallback((hostId: string) => {
setSelectedHostIds(prev => {
@@ -826,6 +885,63 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
return filtered;
}, [hosts, selectedGroupPath, search, selectedTags, sortMode]);
// Pinned hosts for root-level display (not inside a subgroup)
// Respects active search and tag filters
const pinnedHosts = useMemo(() => {
if (selectedGroupPath) return [];
let filtered = hosts.filter((h) => h.pinned);
if (search.trim()) {
const s = search.toLowerCase();
filtered = filtered.filter(
(h) =>
h.label.toLowerCase().includes(s) ||
h.hostname.toLowerCase().includes(s) ||
h.tags.some((t) => t.toLowerCase().includes(s)),
);
}
if (selectedTags.length > 0) {
filtered = filtered.filter((h) =>
selectedTags.some((t) => h.tags?.includes(t)),
);
}
return filtered.sort((a, b) => a.label.localeCompare(b.label));
}, [hosts, selectedGroupPath, search, selectedTags]);
// Recently connected hosts for root-level display
// Respects active search and tag filters
const recentHosts = useMemo(() => {
if (selectedGroupPath) return [];
let filtered = hosts.filter((h) => h.lastConnectedAt);
if (search.trim()) {
const s = search.toLowerCase();
filtered = filtered.filter(
(h) =>
h.label.toLowerCase().includes(s) ||
h.hostname.toLowerCase().includes(s) ||
h.tags.some((t) => t.toLowerCase().includes(s)),
);
}
if (selectedTags.length > 0) {
filtered = filtered.filter((h) =>
selectedTags.some((t) => h.tags?.includes(t)),
);
}
return filtered
.sort((a, b) => (b.lastConnectedAt || 0) - (a.lastConnectedAt || 0))
.slice(0, 20);
}, [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]);
// For tree view: apply search, tag filter, and sorting, but not group filtering
const treeViewHosts = useMemo(() => {
let filtered = hosts;
@@ -1118,6 +1234,68 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsRenameGroupOpen(false);
};
const handleEditGroupConfig = useCallback((groupPath: string) => {
setIsHostPanelOpen(false);
setEditingHost(null);
setEditingGroupPath(groupPath);
setIsGroupPanelOpen(true);
}, []);
const handleSaveGroupConfig = useCallback((config: GroupConfig, _newName?: string, _newParent?: string | null) => {
const oldPath = editingGroupPath!;
const newPath = config.path; // Panel already computed the correct path
// Validate no duplicate path on rename/reparent
if (newPath !== oldPath && customGroups.includes(newPath)) {
toast.error(t('vault.groups.errors.duplicatePath'));
return;
}
// Save config (use new path)
const updatedConfigs = [...groupConfigs.filter(c => c.path !== oldPath), config];
// Handle path change (rename or parent change)
if (newPath !== oldPath) {
// Update groups, hosts, managed sources, and configs for path change
const updatedGroups = customGroups.map((g) => {
if (g === oldPath) return newPath;
if (g.startsWith(oldPath + '/')) return newPath + g.slice(oldPath.length);
return g;
});
const updatedHosts = hosts.map((h) => {
const g = h.group || '';
if (g === oldPath) return { ...h, group: newPath };
if (g.startsWith(oldPath + '/')) return { ...h, group: newPath + g.slice(oldPath.length) };
return h;
});
const updatedManagedSources = managedSources.map((s) => {
if (s.groupName === oldPath) return { ...s, groupName: newPath };
if (s.groupName.startsWith(oldPath + '/')) return { ...s, groupName: newPath + s.groupName.slice(oldPath.length) };
return s;
});
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
onUpdateManagedSources(updatedManagedSources);
}
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
onUpdateHosts(updatedHosts);
// Update child config paths too
const finalConfigs = updatedConfigs.map(c => {
if (c.path.startsWith(oldPath + '/')) return { ...c, path: newPath + c.path.slice(oldPath.length) };
return c;
});
onUpdateGroupConfigs(finalConfigs);
if (selectedGroupPath === oldPath) setSelectedGroupPath(newPath);
if (selectedGroupPath?.startsWith(oldPath + '/')) {
setSelectedGroupPath(newPath + selectedGroupPath.slice(oldPath.length));
}
} else {
onUpdateGroupConfigs(updatedConfigs);
}
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
}, [groupConfigs, editingGroupPath, customGroups, hosts, managedSources, selectedGroupPath, onUpdateGroupConfigs, onUpdateCustomGroups, onUpdateHosts, onUpdateManagedSources, t]);
const deleteGroupPath = async (path: string, deleteHosts: boolean = false) => {
const keepGroups = customGroups.filter(
(g) => !(g === path || g.startsWith(path + "/")),
@@ -1172,6 +1350,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onUpdateCustomGroups(keepGroups);
onUpdateHosts(keepHosts);
// Remove configs for deleted group and its children
const updatedGroupConfigs = groupConfigs.filter(
(c) => c.path !== path && !c.path.startsWith(path + '/')
);
if (updatedGroupConfigs.length !== groupConfigs.length) {
onUpdateGroupConfigs(updatedGroupConfigs);
}
if (
selectedGroupPath &&
(selectedGroupPath === path || selectedGroupPath.startsWith(path + "/"))
@@ -1184,23 +1369,27 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const name = sourcePath.split("/").filter(Boolean).pop() || "";
const newPath = targetParent ? `${targetParent}/${name}` : name;
if (newPath === sourcePath || newPath.startsWith(sourcePath + "/")) return;
if (customGroups.includes(newPath)) {
toast.error(t('vault.groups.errors.duplicatePath'));
return;
}
const updatedGroups = customGroups.map((g) => {
if (g === sourcePath) return newPath;
if (g.startsWith(sourcePath + "/")) return g.replace(sourcePath, newPath);
if (g.startsWith(sourcePath + "/")) return newPath + g.slice(sourcePath.length);
return g;
});
const updatedHosts = hosts.map((h) => {
const g = h.group || "";
if (g === sourcePath) return { ...h, group: newPath };
if (g.startsWith(sourcePath + "/"))
return { ...h, group: g.replace(sourcePath, newPath) };
return { ...h, group: newPath + g.slice(sourcePath.length) };
return h;
});
// Update managed sources if any match the moved group path
const updatedManagedSources = managedSources.map((s) => {
if (s.groupName === sourcePath) return { ...s, groupName: newPath };
if (s.groupName.startsWith(sourcePath + "/"))
return { ...s, groupName: s.groupName.replace(sourcePath, newPath) };
return { ...s, groupName: newPath + s.groupName.slice(sourcePath.length) };
return s;
});
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
@@ -1208,6 +1397,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
onUpdateHosts(updatedHosts);
// Update group configs for moved paths
const updatedGroupConfigs = groupConfigs.map((c) => {
if (c.path === sourcePath) return { ...c, path: newPath };
if (c.path.startsWith(sourcePath + '/'))
return { ...c, path: newPath + c.path.slice(sourcePath.length) };
return c;
});
if (updatedGroupConfigs.some((c, i) => c !== groupConfigs[i])) {
onUpdateGroupConfigs(updatedGroupConfigs);
}
if (
selectedGroupPath &&
(selectedGroupPath === sourcePath ||
@@ -1639,8 +1838,24 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
{viewMode !== "tree" && (
<div className="flex items-center gap-2 text-sm font-semibold">
<button
className="text-primary hover:underline"
className={cn(
"text-primary hover:underline transition-all rounded px-1 -mx-1",
isBreadcrumbDragOver && "ring-2 ring-primary bg-primary/10",
)}
onClick={() => setSelectedGroupPath(null)}
onDragOver={(e) => {
e.preventDefault();
setIsBreadcrumbDragOver(true);
}}
onDragLeave={() => setIsBreadcrumbDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsBreadcrumbDragOver(false);
const groupPath = e.dataTransfer.getData("group-path");
const hostId = e.dataTransfer.getData("host-id");
if (groupPath) moveGroup(groupPath, null);
if (hostId) moveHostToGroup(hostId, null);
}}
>
{t("vault.hosts.allHosts")}
</button>
@@ -1674,6 +1889,201 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
})}
</div>
)}
{/* Pinned hosts section - only at root level */}
{viewMode !== "tree" && !selectedGroupPath && pinnedHosts.length > 0 && (
<section className="space-y-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground inline-flex items-center gap-1.5">
<Pin size={14} className="shrink-0 -translate-y-[1px]" />
{t("vault.hosts.pinned")}
</h3>
<div className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0",
)}>
{pinnedHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
text: (safeHost.os || "L")[0].toUpperCase(),
label: effectiveDistro || safeHost.os || "Linux",
};
return (
<ContextMenu key={host.id}>
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer relative",
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",
)}
style={lastPinnedId === host.id ? { animation: "pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both" } : undefined}
onAnimationEnd={() => { if (lastPinnedId === host.id) setLastPinnedId(null); }}
draggable={!isMultiSelectMode}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("host-id", host.id);
}}
onClick={() => {
if (isMultiSelectMode) {
toggleHostSelection(host.id);
} else {
handleHostConnect(safeHost);
}
}}
>
{viewMode === "grid" && (
<Star size={10} className="absolute top-1.5 right-1.5 text-amber-400 fill-amber-400" />
)}
<div className="flex items-center gap-3 h-full">
{isMultiSelectMode && (
<div className="shrink-0">
{selectedHostIds.has(host.id) ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
<DistroAvatar host={safeHost} fallback={distroBadge.text} />
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
<span className="text-sm font-semibold truncate leading-5">
{safeHost.label}
</span>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{safeHost.username}@{safeHost.hostname}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
handleEditHost(host);
}}
>
<Edit2 size={14} />
</Button>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleHostConnect(host)}>
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {t('vault.hosts.unpin')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onDeleteHost(host.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</section>
)}
{/* Recently Connected section - only at root level, toggleable */}
{viewMode !== "tree" && !selectedGroupPath && showRecentHosts && recentHosts.length > 0 && (
<section className="space-y-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground inline-flex items-center gap-1.5">
<Clock size={14} className="shrink-0 -translate-y-[1px]" />
{t("vault.hosts.recentlyConnected")}
</h3>
<div className={cn(
viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0",
)}>
{recentHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
text: (safeHost.os || "L")[0].toUpperCase(),
label: effectiveDistro || safeHost.os || "Linux",
};
return (
<ContextMenu key={host.id}>
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer relative",
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",
)}
draggable={!isMultiSelectMode}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("host-id", host.id);
}}
onClick={() => {
if (isMultiSelectMode) {
toggleHostSelection(host.id);
} else {
handleHostConnect(safeHost);
}
}}
>
<div className="flex items-center gap-3 h-full">
{isMultiSelectMode && (
<div className="shrink-0">
{selectedHostIds.has(host.id) ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
<DistroAvatar host={safeHost} fallback={distroBadge.text} />
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
<span className="text-sm font-semibold truncate leading-5">
{safeHost.label}
</span>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{safeHost.username}@{safeHost.hostname}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
handleEditHost(host);
}}
>
<Edit2 size={14} />
</Button>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleHostConnect(host)}>
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onDeleteHost(host.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</section>
)}
{viewMode !== "tree" && displayedGroups.length > 0 && (
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
@@ -1756,6 +2166,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
handleEditGroupConfig(node.path);
}}
>
<Edit2 size={14} />
</Button>
</div>
</div>
</ContextMenuTrigger>
@@ -1770,14 +2191,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
setRenameTargetPath(node.path);
setRenameGroupName(node.name);
setRenameGroupError(null);
setIsRenameGroupOpen(true);
}}
onClick={() => handleEditGroupConfig(node.path)}
>
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.settings")}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
@@ -1867,6 +2283,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onDuplicateHost={handleDuplicateHost}
onDeleteHost={(host) => onDeleteHost(host.id)}
onCopyCredentials={handleCopyCredentials}
onNewHost={(groupPath) => {
setEditingHost(null);
setNewHostGroupPath(groupPath || null);
@@ -1877,13 +2294,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setNewFolderName("");
setIsNewFolderOpen(true);
}}
onEditGroup={(groupPath) => {
setRenameTargetPath(groupPath);
const groupName = groupPath.split('/').pop() || '';
setRenameGroupName(groupName);
setRenameGroupError(null);
setIsRenameGroupOpen(true);
}}
onEditGroup={(groupPath) => handleEditGroupConfig(groupPath)}
onDeleteGroup={(groupPath) => {
setDeleteTargetPath(groupPath);
setIsDeleteGroupOpen(true);
@@ -1906,7 +2317,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
{group.name || t("vault.groups.ungrouped")}
</span>
<span className="text-xs text-muted-foreground/60">
({group.hosts.length})
({selectedGroupPath ? group.hosts.length : group.hosts.filter((h) => !pinnedRecentIds.has(h.id)).length})
</span>
</div>
<div
@@ -1916,7 +2327,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
: "flex flex-col gap-0",
)}
>
{group.hosts.map((host) => {
{group.hosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
@@ -1928,7 +2339,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer relative",
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",
@@ -1946,6 +2357,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
}}
>
{host.pinned && viewMode === "grid" && (
<Star size={10} className="absolute top-1.5 right-1.5 text-amber-400 fill-amber-400" />
)}
<div className="flex items-center gap-3 h-full">
{isMultiSelectMode && (
<div
@@ -1981,21 +2395,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
{safeHost.username}@{safeHost.hostname}
</div>
</div>
{viewMode === "list" && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
handleEditHost(host);
}}
>
<Edit2 size={14} />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
handleEditHost(host);
}}
>
<Edit2 size={14} />
</Button>
</div>
</div>
</ContextMenuTrigger>
@@ -2020,6 +2430,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => onDeleteHost(host.id)}
@@ -2055,7 +2468,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
: "flex flex-col gap-0",
)}
>
{displayedHosts.map((host) => {
{displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
@@ -2067,7 +2480,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuTrigger>
<div
className={cn(
"group cursor-pointer",
"group cursor-pointer relative",
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",
@@ -2085,6 +2498,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
}}
>
{host.pinned && viewMode === "grid" && (
<Star size={10} className="absolute top-1.5 right-1.5 text-amber-400 fill-amber-400" />
)}
<div className="flex items-center gap-3 h-full">
{isMultiSelectMode && (
<div
@@ -2120,21 +2536,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
{safeHost.username}@{safeHost.hostname}
</div>
</div>
{viewMode === "list" && (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
handleEditHost(host);
}}
>
<Edit2 size={14} />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
handleEditHost(host);
}}
>
<Edit2 size={14} />
</Button>
</div>
</div>
</ContextMenuTrigger>
@@ -2159,6 +2571,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => onDeleteHost(host.id)}
@@ -2268,6 +2683,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
identities={identities}
customGroups={customGroups}
managedSources={managedSources}
groupConfigs={groupConfigs}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onCreateGroup={(groupPath) =>
onUpdateCustomGroups(
@@ -2299,6 +2715,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
</div>
{/* Group Details Panel */}
{currentSection === "hosts" && isGroupPanelOpen && editingGroupPath && (
<GroupDetailsPanel
key={editingGroupPath}
groupPath={editingGroupPath}
config={groupConfigs.find(c => c.path === editingGroupPath)}
availableKeys={keys}
identities={identities}
allHosts={hosts}
groups={allGroupPaths}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
onSave={handleSaveGroupConfig}
onCancel={() => {
setIsGroupPanelOpen(false);
setEditingGroupPath(null);
}}
/>
)}
{/* Host Details Panel - positioned at VaultView root level for correct top alignment */}
{currentSection === "hosts" && isHostPanelOpen && editingHost?.protocol !== 'serial' && (
<HostDetailsPanel
@@ -2312,6 +2748,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
defaultGroup={editingHost ? undefined : (newHostGroupPath || selectedGroupPath)}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
groupDefaults={editingHostGroupDefaults}
onSave={(host) => {
// Check if host already exists in the list (for updates vs. new/duplicate)
const hostExists = hosts.some((h) => h.id === host.id);
@@ -2578,6 +3015,7 @@ const vaultViewAreEqual = (
prev.connectionLogs === next.connectionLogs &&
prev.sessions === next.sessions &&
prev.managedSources === next.managedSources &&
prev.groupConfigs === next.groupConfigs &&
prev.terminalThemeId === next.terminalThemeId &&
prev.terminalFontSize === next.terminalFontSize;

View File

@@ -7,6 +7,8 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
import { STORAGE_KEY_SHOW_RECENT_HOSTS } from "../../../infrastructure/config/storageKeys";
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light" | "system";
@@ -25,8 +27,6 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
isImmersive?: boolean;
onToggleImmersive?: () => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
@@ -47,10 +47,13 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage,
customCSS,
setCustomCSS,
isImmersive,
onToggleImmersive,
} = props;
const [showRecentHosts, setShowRecentHosts] = useStoredBoolean(
STORAGE_KEY_SHOW_RECENT_HOSTS,
true,
);
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
const hexToHsl = useCallback((hex: string) => {
@@ -258,6 +261,16 @@ export default function SettingsAppearanceTab(props: {
</SettingRow>
</div>
<SectionHeader title={t("settings.vault.title")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t('settings.vault.showRecentHosts')}
description={t('settings.vault.showRecentHostsDesc')}
>
<Toggle checked={showRecentHosts} onChange={setShowRecentHosts} />
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.customCss")} />
<div className="space-y-2">
<p className="text-xs text-muted-foreground">

View File

@@ -7,14 +7,16 @@ import type {
TerminalEmulationType,
TerminalSettings,
} from "../../../domain/models";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../../domain/models";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES, type KeywordHighlightRule } from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { MAX_FONT_SIZE, MIN_FONT_SIZE, type TerminalFont } from "../../../infrastructure/config/fonts";
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";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
@@ -23,6 +25,193 @@ import { TerminalFontSelect } from "../TerminalFontSelect";
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
import type { TerminalTheme } from "../../../domain/models";
// Keyword highlight rules editor for global settings
const DEFAULT_NEW_RULE_COLOR = '#F87171';
const AddCustomRuleDialog: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
editRule?: KeywordHighlightRule | null;
onAdd: (rule: KeywordHighlightRule) => void;
}> = ({ open, onOpenChange, editRule, onAdd }) => {
const { t } = useI18n();
const [label, setLabel] = useState('');
const [pattern, setPattern] = useState('');
const [color, setColor] = useState(DEFAULT_NEW_RULE_COLOR);
const [patternError, setPatternError] = useState<string | null>(null);
const reset = () => { setLabel(''); setPattern(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
// Populate form when editing
useEffect(() => {
if (open && editRule) {
setLabel(editRule.label);
setPattern(editRule.patterns[0] || '');
setColor(editRule.color);
setPatternError(null);
} else if (!open) {
reset();
}
}, [open, editRule]);
const handleSubmit = () => {
if (!label.trim() || !pattern.trim()) return;
try { new RegExp(pattern, 'gi'); } catch {
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
return;
}
// When editing, replace only the first pattern and keep any additional ones
const patterns = editRule
? [pattern, ...editRule.patterns.slice(1)]
: [pattern];
onAdd({ id: editRule?.id ?? crypto.randomUUID(), label: label.trim(), patterns, color, enabled: editRule?.enabled ?? true });
reset();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{editRule ? t('settings.terminal.keywordHighlight.editCustom') : t('settings.terminal.keywordHighlight.addCustom')}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label className="text-xs">{t('settings.terminal.keywordHighlight.labelField')}</Label>
<div className="flex gap-2">
<Input
placeholder={t('settings.terminal.keywordHighlight.labelPlaceholder')}
value={label}
onChange={(e) => setLabel(e.target.value)}
className="flex-1"
/>
<label className="relative flex-shrink-0">
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} className="sr-only" />
<span className="block w-9 h-9 rounded-md cursor-pointer border border-border/50 hover:border-border" style={{ backgroundColor: color }} />
</label>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('settings.terminal.keywordHighlight.patternField')}</Label>
<Input
placeholder={t('settings.terminal.keywordHighlight.patternPlaceholder')}
value={pattern}
onChange={(e) => { setPattern(e.target.value); if (patternError) setPatternError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
className={cn("font-mono", patternError && "border-destructive")}
/>
{patternError && <div className="text-xs text-destructive">{patternError}</div>}
</div>
{label.trim() && pattern.trim() && !patternError && (
<div className="flex items-center gap-2 p-2 rounded-md bg-muted/50">
<span className="text-xs text-muted-foreground">{t('settings.terminal.keywordHighlight.preview')}:</span>
<span className="text-sm font-medium" style={{ color }}>{label}</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!label.trim() || !pattern.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const KeywordHighlightRulesEditor: React.FC<{
rules: KeywordHighlightRule[];
onChange: (rules: KeywordHighlightRule[]) => void;
}> = ({ rules, onChange }) => {
const { t } = useI18n();
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<KeywordHighlightRule | null>(null);
const isBuiltIn = (id: string) => DEFAULT_KEYWORD_HIGHLIGHT_RULES.some((r) => r.id === id);
return (
<div className="space-y-2.5">
{rules.map((rule) => {
const custom = !isBuiltIn(rule.id);
return (
<div key={rule.id} className="flex items-center gap-2 group">
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<span className={cn("text-sm truncate", !rule.enabled && "text-muted-foreground line-through")} style={rule.enabled ? { color: rule.color } : undefined}>
{rule.label}
</span>
{custom && (
<>
<Pencil
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
/>
<Trash2
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer"
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
/>
</>
)}
</div>
<label className="relative flex-shrink-0">
<input
type="color"
value={rule.color}
onChange={(e) => onChange(rules.map((r) => r.id === rule.id ? { ...r, color: e.target.value } : r))}
className="sr-only"
/>
<span
className="block w-8 h-5 rounded cursor-pointer border border-border/50 hover:border-border transition-colors"
style={{ backgroundColor: rule.color }}
/>
</label>
</div>
);
})}
<div className="flex pt-2 mt-2 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className="flex-1 text-muted-foreground hover:text-foreground"
onClick={() => setAddDialogOpen(true)}
>
<Plus size={14} className="mr-1.5" />
{t('settings.terminal.keywordHighlight.addCustom')}
</Button>
<Button
variant="ghost"
size="sm"
className="flex-1 text-muted-foreground hover:text-foreground"
onClick={() => {
onChange(rules.map((rule) => {
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
return def ? { ...rule, color: def.color } : rule;
}));
}}
>
<RotateCcw size={14} className="mr-1.5" />
{t("settings.terminal.keywordHighlight.resetColors")}
</Button>
</div>
<AddCustomRuleDialog
open={addDialogOpen}
onOpenChange={(v) => { setAddDialogOpen(v); if (!v) setEditingRule(null); }}
editRule={editingRule}
onAdd={(rule) => {
if (editingRule) {
onChange(rules.map((r) => r.id === editingRule.id ? rule : r));
} else {
onChange([...rules, rule]);
}
setEditingRule(null);
}}
/>
</div>
);
};
// Theme preview button component
const ThemePreviewButton: React.FC<{
theme: (typeof TERMINAL_THEMES)[0];
@@ -106,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
@@ -210,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;
@@ -220,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;
@@ -240,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(() => {
@@ -694,47 +903,10 @@ export default function SettingsTerminalTab(props: {
/>
</div>
{terminalSettings.keywordHighlightEnabled && (
<div className="space-y-2.5">
{terminalSettings.keywordHighlightRules.map((rule) => (
<div key={rule.id} className="flex items-center justify-between">
<span className="text-sm" style={{ color: rule.color }}>
{rule.label}
</span>
<label className="relative">
<input
type="color"
value={rule.color}
onChange={(e) => {
const newRules = terminalSettings.keywordHighlightRules.map((r) =>
r.id === rule.id ? { ...r, color: e.target.value } : r,
);
updateTerminalSetting("keywordHighlightRules", newRules);
}}
className="sr-only"
/>
<span
className="block w-10 h-6 rounded-md cursor-pointer border border-border/50 hover:border-border transition-colors"
style={{ backgroundColor: rule.color }}
/>
</label>
</div>
))}
<Button
variant="ghost"
size="sm"
className="w-full mt-3 text-muted-foreground hover:text-foreground"
onClick={() => {
const resetRules = terminalSettings.keywordHighlightRules.map((rule) => {
const defaultRule = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
return defaultRule ? { ...rule, color: defaultRule.color } : rule;
});
updateTerminalSetting("keywordHighlightRules", resetRules);
}}
>
<RotateCcw size={14} className="mr-2" />
{t("settings.terminal.keywordHighlight.resetColors")}
</Button>
</div>
<KeywordHighlightRulesEditor
rules={terminalSettings.keywordHighlightRules}
onChange={(rules) => updateTerminalSetting("keywordHighlightRules", rules)}
/>
)}
</div>
@@ -745,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>
@@ -862,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>
@@ -919,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

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

@@ -943,15 +943,15 @@ function resolveAutocompleteCwd(
if (os === "windows") return fallbackCwd;
const normalizedWord = currentWord.trim().replace(/^['"]/, "");
const isRelativePathWord = normalizedWord.length > 0 &&
!normalizedWord.startsWith("/") &&
!normalizedWord.startsWith("~/") &&
!normalizedWord.startsWith("-");
if (!isRelativePathWord) {
// Absolute or home-relative paths don't depend on cwd
if (normalizedWord.startsWith("/") || normalizedWord.startsWith("~/")) {
return fallbackCwd;
}
// For empty word (e.g. "cd ") and relative paths, try prompt-based cwd
// extraction which reflects the current visible prompt — more up-to-date
// than fallbackCwd when OSC 7 is not supported.
const promptCwd = extractPosixCwdFromPrompt(promptText);
return chooseAutocompleteCwd(promptCwd, fallbackCwd);
}
@@ -963,15 +963,16 @@ function chooseAutocompleteCwd(
if (!promptCwd) return fallbackCwd;
if (!fallbackCwd) return promptCwd;
if (promptCwd.startsWith("/")) {
// Prompt cwd is extracted from the currently visible prompt, so it tracks
// directory changes even when OSC 7 is not supported. Prefer it over
// fallbackCwd (which may be stale from initial connection) whenever it
// looks like a usable path.
if (promptCwd.startsWith("/") || promptCwd === "~" || promptCwd.startsWith("~/")) {
return promptCwd;
}
if (promptCwd === "~" || promptCwd.startsWith("~/")) {
return fallbackCwd;
}
return promptCwd;
// Bare directory name (e.g. "xunlong") can't be used as a path — fallback
return fallbackCwd;
}
function extractPosixCwdFromPrompt(promptText: string): string | undefined {

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,6 +26,7 @@ import {
import {
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
} from "../../../domain/terminalAppearance";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
@@ -162,7 +163,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;
@@ -188,6 +189,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 +233,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 +314,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 +361,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();
@@ -562,7 +570,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 +592,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);

View File

@@ -101,8 +101,8 @@ export const AsidePanelContent: React.FC<{ children: ReactNode; className?: stri
className,
}) => {
return (
<ScrollArea className={cn("flex-1 min-w-0", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-x-hidden">
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
{children}
</div>
</ScrollArea>

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

@@ -88,5 +88,17 @@ export const findSyncPayloadEncryptedCredentialPaths = (
}
});
payload.groupConfigs?.forEach((config, index) => {
if (isEncryptedCredentialPlaceholder(config.password)) {
issues.push(`groupConfigs[${index}].password`);
}
if (isEncryptedCredentialPlaceholder(config.telnetPassword)) {
issues.push(`groupConfigs[${index}].telnetPassword`);
}
if (isEncryptedCredentialPlaceholder(config.proxyConfig?.password)) {
issues.push(`groupConfigs[${index}].proxyConfig.password`);
}
});
return issues;
};

52
domain/groupConfig.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { GroupConfig, Host } from './models';
/**
* Resolve merged group defaults by walking the ancestor chain.
* For group "A/B/C", merges configs from A, A/B, A/B/C (child overrides parent).
*/
export function resolveGroupDefaults(
groupPath: string,
groupConfigs: GroupConfig[],
): Partial<GroupConfig> {
const configMap = new Map(groupConfigs.map((c) => [c.path, c]));
const parts = groupPath.split('/').filter(Boolean);
const merged: Record<string, unknown> = {};
for (let i = 0; i < parts.length; i++) {
const ancestorPath = parts.slice(0, i + 1).join('/');
const config = configMap.get(ancestorPath);
if (config) {
for (const [key, value] of Object.entries(config)) {
if (key !== 'path' && value !== undefined) {
merged[key] = value;
}
}
}
}
return merged as Partial<GroupConfig>;
}
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
'port', 'protocol', 'agentForwarding', 'proxyConfig', 'hostChain', 'startupCommand',
'legacyAlgorithms', 'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
'telnetEnabled', 'telnetPort', 'telnetUsername', 'telnetPassword',
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
'backspaceBehavior',
];
/**
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
* Returns a new host object — does NOT mutate the original.
*/
export function applyGroupDefaults(host: Host, groupDefaults: Partial<GroupConfig>): Host {
const effective = { ...host };
for (const key of INHERITABLE_KEYS) {
const hostValue = (host as unknown as Record<string, unknown>)[key];
const groupValue = (groupDefaults as unknown as Record<string, unknown>)[key];
if ((hostValue === undefined || hostValue === '' || hostValue === null) && groupValue !== undefined) {
(effective as unknown as Record<string, unknown>)[key] = groupValue;
}
}
return effective;
}

View File

@@ -62,7 +62,7 @@ export interface Host {
id: string;
label: string;
hostname: string;
port: number;
port?: number;
username: string;
// Optional reference to a reusable identity (username + auth) stored in Keychain.
identityId?: string;
@@ -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,9 +119,20 @@ 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[];
// Pin host to top of All hosts view for quick access
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';
@@ -178,6 +191,42 @@ export interface GroupNode {
totalHostCount?: number;
}
/** Default configuration for a group. Hosts in this group inherit these values when not explicitly set. */
export interface GroupConfig {
path: string;
username?: string;
password?: string;
savePassword?: boolean;
authMethod?: 'password' | 'key' | 'certificate';
identityId?: string;
identityFileId?: string;
identityFilePaths?: string[];
port?: number;
protocol?: 'ssh' | 'telnet';
agentForwarding?: boolean;
proxyConfig?: ProxyConfig;
hostChain?: HostChainConfig;
startupCommand?: string;
legacyAlgorithms?: boolean;
environmentVariables?: EnvVar[];
charset?: string;
moshEnabled?: boolean;
moshServerPath?: string;
telnetEnabled?: boolean;
telnetPort?: number;
telnetUsername?: string;
telnetPassword?: string;
theme?: string;
themeOverride?: boolean;
fontFamily?: string;
fontFamilyOverride?: boolean;
fontSize?: number;
fontSizeOverride?: boolean;
fontWeight?: number;
fontWeightOverride?: boolean;
backspaceBehavior?: 'ctrl-h';
}
export interface SyncConfig {
gistId: string;
githubToken: string;
@@ -451,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
@@ -527,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,
@@ -626,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

@@ -165,6 +165,9 @@ export interface SyncPayload {
customGroups: string[];
snippetPackages?: string[];
// Group configs (connection defaults per host group)
groupConfigs?: import('./models').GroupConfig[];
// Port forwarding rules
portForwardingRules?: import('./models').PortForwardingRule[];
@@ -201,6 +204,8 @@ export interface SyncPayload {
sftpGlobalBookmarks?: import('./models').SftpBookmark[];
// Immersive mode
immersiveMode?: boolean;
// Vault: show recently connected hosts
showRecentHosts?: boolean;
};
// Sync metadata

View File

@@ -384,8 +384,17 @@ export function mergeSyncPayloads(
remote.portForwardingRules ?? [],
);
// Merge group configs (keyed by path — wrap with virtual id for entity merge)
type GCWithId = import('./models').GroupConfig & { id: string };
const wrapGC = (arr: import('./models').GroupConfig[] | undefined): GCWithId[] =>
(arr ?? []).map(gc => ({ ...gc, id: gc.path }));
const unwrapGC = (arr: GCWithId[]): import('./models').GroupConfig[] =>
arr.map(({ id: _id, ...rest }) => rest as import('./models').GroupConfig);
const groupConfigsResult = mergeEntityArrays(wrapGC(b.groupConfigs), wrapGC(local.groupConfigs), wrapGC(remote.groupConfigs));
// Aggregate stats
const entityResults = [hosts, keys, identities, snippets, knownHosts, portForwardingRules];
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
[hosts, keys, identities, snippets, knownHosts, portForwardingRules, groupConfigsResult];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
@@ -430,6 +439,7 @@ export function mergeSyncPayloads(
snippetPackages,
knownHosts: knownHosts.merged,
portForwardingRules: portForwardingRules.merged,
groupConfigs: unwrapGC(groupConfigsResult.merged),
settings,
syncedAt: Date.now(),
};

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

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

@@ -1912,7 +1912,14 @@ async function listSessionDir(_event, payload) {
: dirPath.startsWith("~/")
? `"$HOME/${tildePathSuffix}"`
: `'${safePath}'`;
const cmd = `find ${pathExpr} -mindepth 1 -maxdepth 1 -exec sh -c '
// When dirPath is relative (not absolute and not ~/...), exec channels default
// to the user's home directory. Resolve the interactive shell's actual cwd first
// so that relative paths like "." or "src" are resolved correctly.
const needsCwdResolve = !dirPath.startsWith('/') && dirPath !== '~' && !dirPath.startsWith('~/');
const cwdResolveCmd = needsCwdResolve
? `_sc_p=$(ps --ppid $PPID -o pid=,comm= 2>/dev/null | awk -v self=$$ '$1!=self && $2~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -z "$_sc_p" ] && _sc_p=$(ps -e -o pid=,ppid=,comm= 2>/dev/null | awk -v pp=$PPID -v self=$$ '$1!=self && $2==pp && $3~/^(ba|z|fi|k|da)?sh$/{pid=$1}END{print pid}'); [ -n "$_sc_p" ] && { _sc_d=$(readlink /proc/$_sc_p/cwd 2>/dev/null); [ -n "$_sc_d" ] && cd "$_sc_d" 2>/dev/null; }; `
: '';
const cmd = `${cwdResolveCmd}find ${pathExpr} -mindepth 1 -maxdepth 1 -exec sh -c '
prefix="$1"
folders_only="$2"
limit="$3"

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

@@ -78,6 +78,28 @@
opacity: 1;
}
}
}
@keyframes pop-in {
0% {
opacity: 0;
transform: scale(0.82) translateY(6px);
}
45% {
opacity: 1;
transform: scale(1.06) translateY(-2px);
}
72% {
transform: scale(0.97) translateY(1px);
}
88% {
transform: scale(1.01);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
:root {
@@ -144,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

@@ -108,6 +108,12 @@ export const STORAGE_KEY_WORKSPACE_FOCUS_STYLE = 'netcatty_workspace_focus_style
// Immersive Mode
export const STORAGE_KEY_IMMERSIVE_MODE = 'netcatty_immersive_mode_v1';
// Vault: Show Recently Connected hosts section
export const STORAGE_KEY_SHOW_RECENT_HOSTS = 'netcatty_show_recent_hosts_v1';
// Group Configurations (default settings inherited by hosts)
export const STORAGE_KEY_GROUP_CONFIGS = 'netcatty_group_configs_v1';
// Side Panel
export const STORAGE_KEY_SIDE_PANEL_WIDTH = 'netcatty_side_panel_width';

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

@@ -9,7 +9,7 @@
* function degrades to a no-op — values pass through unmodified.
*/
import type { Host, Identity, SSHKey } from "../../domain/models";
import type { GroupConfig, Host, Identity, SSHKey } from "../../domain/models";
import type { ProviderConnection, S3Config, WebDAVConfig } from "../../domain/sync";
import { netcattyBridge } from "../services/netcattyBridge";
@@ -91,6 +91,38 @@ export async function decryptIdentitySecrets(identity: Identity): Promise<Identi
return out;
}
// ---------------------------------------------------------------------------
// GroupConfig
// ---------------------------------------------------------------------------
export async function encryptGroupConfigSecrets(config: GroupConfig): Promise<GroupConfig> {
const out = { ...config };
out.password = await encryptField(out.password);
out.telnetPassword = await encryptField(out.telnetPassword);
if (out.proxyConfig?.password) {
out.proxyConfig = { ...out.proxyConfig, password: await encryptField(out.proxyConfig.password) };
}
return out;
}
export async function decryptGroupConfigSecrets(config: GroupConfig): Promise<GroupConfig> {
const out = { ...config };
out.password = await decryptField(out.password);
out.telnetPassword = await decryptField(out.telnetPassword);
if (out.proxyConfig?.password) {
out.proxyConfig = { ...out.proxyConfig, password: await decryptField(out.proxyConfig.password) };
}
return out;
}
export function encryptGroupConfigs(configs: GroupConfig[]): Promise<GroupConfig[]> {
return Promise.all(configs.map(encryptGroupConfigSecrets));
}
export function decryptGroupConfigs(configs: GroupConfig[]): Promise<GroupConfig[]> {
return Promise.all(configs.map(decryptGroupConfigSecrets));
}
// ---------------------------------------------------------------------------
// Provider Connection (Cloud Sync)
// ---------------------------------------------------------------------------

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