Compare commits

...

10 Commits

Author SHA1 Message Date
陈大猫
37012da26a Use shadcn Button for the settings gear in the top tab bar (#967)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
Follow-up on #966 which added `hover:bg-accent` to the existing raw
`<button>` element. That element is `h-full w-10`, so the new hover
fill spanned the entire title-bar height — a giant vertical accent
strip instead of the small icon-button highlight we wanted.

Replace the raw element with the same shadcn `Button variant="ghost"
size="icon" h-6 w-6` that every other icon on the same row already
uses. Wrap it in a centered container that keeps the title-bar height
for window-control alignment and carries `app-drag` so the empty
space around the icon still drags the window; the button itself stays
`app-no-drag`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:28:29 +08:00
陈大猫
0fd6a8c31d Add hover background to settings gear in top tab bar (#966)
Hovering the gear icon in the top tab bar left no visual response while
every other icon on the same row (AI, theme toggle, sync) lights up on
hover with the accent fill. The gear button is a raw `<button>` rather
than the shadcn `Button variant="ghost"` because it spans the full
title-bar height to align with the window controls, so it never picked
up the ghost variant's `hover:bg-accent`.

Adds the matching `hover:bg-accent` class so the gear behaves the same
as its neighbours. The inline `color` style for the resting state stays
in place; the accent fill on hover is what was missing.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:22:19 +08:00
陈大猫
10af904681 Bring Duplicate / Copy Credentials to Pinned + Recently Connected menus (#965)
The right-click menu on host cards in the Pinned and Recently Connected
sections only exposed Connect / Edit / Pin-Unpin / Delete, while the
canonical "All hosts" listing also offers Duplicate and Copy Credentials.
There is no reason to omit those two for hosts you've pinned or recently
opened — the underlying handlers are already wired up.

Add the missing entries in the same order as the All-hosts menu so the
three context menus stay visually identical.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:17:28 +08:00
陈大猫
b02b83f225 Place per-host statusbar tooltips below their triggers (#964)
The copy-host-address, broadcast and focus-mode buttons sit on the
per-host statusbar directly under the top tab bar. With the default
top-side tooltip placement, hovering any of them paints the tooltip
on top of the tab title above (the visible "Copy host address …"
covering "Rainyun-114.66.26.174" in the bug report screenshot).

Drop the tooltips on the bottom side instead, matching the
HoverCardContent panels already used for the CPU/Memory/Disk stats
buttons on the same bar.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:13:07 +08:00
陈大猫
bca5d63a4e Fix #919: harden built-in Telnet handshake for legacy gear (#963)
* Fix #919: harden built-in Telnet handshake for legacy gear

The built-in Telnet client failed to advance past the welcome banner on
some older switch firmware (HP ProCurve 2610 reported in #919) and, in
the same session, leaked snippets of subnegotiation payloads into the
terminal display as random-looking characters. Three independent
correctness gaps in the old implementation, all rolled into one PR:

1. The negotiation parser was stateless per chunk. An IAC sequence
   split across TCP frames either dropped the lone IAC (lost command)
   or, for IAC SB...IAC SE blocks whose terminator landed in the next
   frame, fell through to "skip IAC SB and treat the rest as data" —
   spilling the subnegotiation payload (TERMINAL-TYPE strings,
   environment data) into the user's terminal as garbage.

2. The client was purely reactive — it only ever responded to options
   the server raised. Quite a bit of legacy equipment waits for the
   client to commit to SUPPRESS-GO-AHEAD / TERMINAL-TYPE / NAWS before
   it will continue past its banner, so connections silently hung at
   "Press any key to continue" forever.

3. Outbound user input was never IAC-escaped, so any 0xFF byte the user
   pastes (or that an alternate input encoding emits) would be read by
   the peer as the start of a command and eat the following byte.

Approach:

- New `electron/bridges/telnetProtocol.cjs` owns RFC 854 framing as a
  pure module. `createTelnetParser` is a stateful machine that buffers
  any partial command (lone IAC, IAC + verb, unterminated SB) across
  feeds and replays it once the rest arrives. Emits clean stream
  bytes, option commands and complete subnegotiations through
  callbacks. `escapeIacForWire` doubles 0xFF bytes on the way out with
  a cheap fast-path for the common (no 0xFF) case.

- `terminalBridge.cjs` flips telnet handling into a lazy mode: until
  the peer sends an IAC byte the connection is plain passthrough, so
  raw-TCP-on-port-23 services are not corrupted by the protocol layer.
  Once the protocol activates, we proactively request DO
  SUPPRESS-GO-AHEAD, WILL TERMINAL-TYPE and WILL NAWS, and track those
  in a `requestedOptions` Set so the peer's acknowledgement does not
  trigger another reply (the classic negotiation loop).

- TERMINAL-TYPE is now advertised as "XTERM-256COLOR" (upper-case);
  legacy boxes that case-sensitive-match termcap names recognise it.

- Resize-driven NAWS subnegotiations now only fire after the protocol
  has actually activated, so a passthrough session is never poisoned.

- Outbound writes for telnet sockets convert strings to UTF-8 buffers
  and run them through `escapeIacForWire`, so paste of binary content
  and non-ASCII input encodings round-trip safely.

Tests:
- 17 unit tests in `telnetProtocol.test.cjs` cover normal data,
  option commands, subnegotiation (including IAC IAC inside payload),
  every cross-frame split point (lone IAC, IAC + verb, mid-SB), the
  specific regression that previously leaked SB payload as data,
  ordering of data vs command callbacks, and the IAC escape helper.
- Existing 18 telnet auto-login tests still pass, exercising the
  end-to-end socket → parser → renderer path. Full suite: 825 / 0 / 3.

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

* Address review: per-direction Telnet negotiation tracking

RFC 858 §"Default Specification" treats WILL/WONT and DO/DONT as two
independent option streams. The first revision of this PR used a single
`requestedOptions` Set keyed by option byte, which incorrectly swallowed
a peer's independent request on the opposite direction whenever we had
our own request still pending for the same option.

Concrete failure mode (highlighted by code review on the PR): we send
`DO SGA` and the peer simultaneously sends `DO SGA` asking us to enable
SGA on our outgoing side. The old check matched the peer's DO against
our pending DO and returned silently, leaving the peer's request
unanswered — strict implementations would either time out or proceed in
the wrong mode.

Fix: split pending requests into `pendingDoRequests` (we sent DO,
awaiting WILL/WONT) and `pendingWillRequests` (we sent WILL, awaiting
DO/DONT). Acknowledgement matching is now direction-aware; the peer's
independent request on the orthogonal direction is treated as a fresh
negotiation and replied to.

While in there, the related bug uncovered by reviewing this code: when
the peer's `DO NAWS` acknowledges our own `WILL NAWS`, we previously
just dropped it on the floor — but the actual window-size SB payload
needs to follow the WILL handshake either way (whether the DO is an
acknowledgement of our WILL or an independent fresh request). The
negotiator now always pushes the size subnegotiation on `DO NAWS`.

Refactor: the negotiation policy lives in a new
`createTelnetNegotiator` factory inside `telnetProtocol.cjs`, separate
from the parser. That keeps `terminalBridge.cjs` thin and — more
importantly — makes the policy directly unit-testable. 13 new tests
cover the bidirectional-collision regression, the missing NAWS
follow-through, fresh vs ack handling for each verb, the canonical
handshake sequence, unsupported-option WONT/DONT replies, the
TERMINAL-TYPE SEND→IS roundtrip, and the 80×24 fallback for invalid
sizes.

Total: 30 parser+negotiator unit tests, 18 existing telnet auto-login
integration tests, full suite 838 / 0 / 3.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:07:51 +08:00
陈大猫
67c5571df5 Fix #958: highlight IPv6 + allow editing built-in keyword rules (#962)
Two changes addressing both halves of #958:

1. IPv6 highlighting
   The built-in 'URL, IP & MAC' rule only shipped URL, IPv4 and MAC
   patterns, so compressed IPv6 addresses such as 2001:11:22:33::5 or
   fe80::d2dd:bff:fe79:f2bb were never highlighted. Add an IPv6 regex
   covering full and compressed forms (including ::1 and leading-/trailing-
   :: variants) and merge it into the same 'ip-mac' rule's patterns. The
   normalizer's existing "fill missing defaults" path means existing users
   pick this up on next start with no migration step.

2. Editable built-in rules
   Add an optional `customized` flag to KeywordHighlightRule. When false /
   absent, normalize re-syncs the rule's label/patterns with the shipped
   defaults (so future default-pattern upgrades reach users automatically).
   When true, normalize keeps the user's label/patterns/color/enabled
   verbatim, allowing built-ins like 'ip-mac' to be tailored.

   SettingsTerminalTab:
   - Pencil icon now appears on built-ins too. Editing one routes through
     the same dialog and flips `customized` on save.
   - The pattern field becomes a Textarea so multi-pattern built-ins (e.g.
     'error' ships seven spellings) can all be edited in one go.
   - A per-rule "↺" reset icon appears on customized built-ins and restores
     the shipped label/patterns while preserving the user's color/enabled.
   - The footer's "Reset to default colors" button is broadened into
     "Reset built-ins to defaults", restoring every built-in to shipped
     label/patterns/color and clearing `customized`.

Tests:
   New domain/keywordHighlight.test.ts (6 tests) covers IPv6 matches for
   both #958 examples plus loopback and full-form, IPv4/MAC still match,
   normalize migrates legacy non-customized 'ip-mac' to include IPv6,
   normalize preserves customized patterns, and normalize keeps user
   custom rules verbatim. Full suite: 808/0/3.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:35:07 +08:00
陈大猫
ea5320d94a Fix #954: unify Tooltip styling + replace native selects (#961)
* Fix #954: unify Tooltip styling + replace native selects

Replace native HTML title= tooltips and native <select> dropdowns
with the existing Radix-based Tooltip / Select components so they
share the app's rounded styling, theme tokens and i18n pipeline.
Adds a global TooltipProvider in AppWithProviders so every
descendant Tooltip works without a per-file Provider wrapper.

Scope (driven by the issue #954 examples and "全部都处理" follow-up):

- TerminalLayer toolbar: Add Terminal / Split View / SFTP / Scripts
  / Theme / AI Chat / Move panel / Close panel.
- TopTabs middle bar: quick switcher, more tabs, AI assistant, theme
  toggle, settings; window-control buttons (min/max/close), tray
  close and hotkey reset/disable have their native title dropped per
  the user's explicit opt-out ("可以不用Tooltip,直接全局禁用
  原生title 属性").
- AI panels: AIChatSidePanel session history / new chat / delete,
  ConversationExport, AgentSelector, ChatInput attach / expand /
  permission, ModelSelector, ProviderCard, ai-elements/tool-call.
- SFTP: SftpSidePanel header, SftpBreadcrumb, SftpFileRow,
  SftpPaneToolbar, SftpTabBar, SftpTransferQueue.
- Settings: SettingsPage close, SettingsAppearanceTab theme/accent
  swatches, SettingsFileAssociationsTab edit/remove, SettingsSystemTab
  crash-log paths and global hotkey reset.
- Host vault: HostDetailsPanel (clear / suggestions / show-password /
  key path / browse key), GroupDetailsPanel, KnownHostsManager,
  ConnectionLogsManager, KeychainManager, SyncStatusButton,
  CloudSyncSettings, LogView, QuickSwitcher, ScriptsSidePanel,
  Terminal status bar copy-host + broadcast/focus, ZmodemProgressIndicator.
- Terminal subcomponents: HostKeywordHighlightPopover, TerminalComposeBar,
  TerminalConnectionDialog, TerminalSearchBar.
- Editor: TextEditorPane (subtitle, search, wrap, promote-to-tab).
- TrayPanel session rows and port-forwarding rows.

Native <select> migrated to custom Select component:
- SerialConnectModal (data bits, stop bits, parity, flow control)
- SerialHostDetailsPanel (same four fields)
- HostDetailsPanel backspace behavior
- GroupDetailsPanel backspace behavior
- SettingsTerminalTab local shell picker
- terminal/ThemeSidePanel font weight

Hardcoded English strings extracted to i18n. New keys for both
en and zh-CN: terminal.layer.*, topTabs.*, ai.chat.* (sessionHistory,
attach, collapse, expand, enableAgent), zmodem.*, settings.shortcuts.
resetToDefault. Inline help text on SnippetsManager package-name input
removed because the same hint is already shown in a visible <p> below
the input.

Existing per-file <TooltipProvider> wrappers (SnippetsManager,
ScriptsSidePanel, SelectHostPanel, RuleCard, HostDetailsPanel proxy
section) are left in place — they nest harmlessly under the global
provider and stay self-sufficient for component tests.

Tests:
- tsc clean for changed files (pre-existing repo-wide errors
  unrelated to this PR).
- All 802 tests pass (3 skipped pre-existing).
- HostDetailsPanel.proxyProfile.test and TextEditorPane.test
  updated to wrap with TooltipProvider, matching the runtime
  context now needed by the migrated components.

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

* Fix #954: wrap Settings + Tray windows with TooltipProvider

Settings and the tray panel mount as separate Electron windows with
their own React root in index.tsx, so they do not inherit the global
TooltipProvider added under AppWithProviders. After the unified
Tooltip migration, any settings tab that used a Tooltip (Appearance,
Application, FileAssociations, System, Shortcuts, Terminal, AI
ProviderCard, AI ModelSelector) — and TrayPanel — threw
"Tooltip must be used within TooltipProvider" and rendered nothing.

Wrap both branches with TooltipProvider at the same level as
ToastProvider in index.tsx.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:14:24 +08:00
陈大猫
ffd3111b71 Fix #957: persist SSH known-host trust across app restarts (#960)
useVaultState hydrates knownHosts asynchronously — its init awaits the
decryption of hosts, keys, identities and proxyProfiles before reading
knownHosts from localStorage. The state is briefly [] at boot even when
localStorage has saved entries.

The host-key verifier introduced in bce33f34 reads the renderer's
knownHosts state at connect time. Any SSH connect that fires inside
that hydration window (manual click or auto-restored session) sees an
empty trust list, marks every host as unknown, and prompts again. The
fix accepted by the user is saved to localStorage, but next restart
the same race repeats, giving the impression that fingerprints are
never persisted.

Use the existing getEffectiveKnownHosts helper at the two sites that
feed the SSH connect path (VaultView + TerminalLayerMount). The helper
falls back to localStorage while state is still settling, mirroring
the same pattern already applied to sync payloads (App.tsx:479).

Memoised on the knownHosts state so the prop reference is stable and
the TerminalLayer/VaultView React.memo equality checks still hold.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:24:06 +08:00
penguinway
b0949f1a1e feat(sftp): add drive switcher dropdown for local Windows panes (#953)
* feat(sftp): add drive switcher dropdown for local Windows panes

On Windows, the SFTP breadcrumb's first segment (drive letter) now shows
a dropdown to switch between available drives. This makes it easy to
navigate across drives without manually editing the path.

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

* fix(sftp): probe drives async to avoid blocking main process

fs.accessSync in the listDrives IPC handler could stall the Electron
main process for seconds per disconnected mapped drive or empty optical
drive. Use fs.promises.access with Promise.allSettled so the 26 probes
run in parallel without blocking the event loop.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-12 17:58:07 +08:00
陈大猫
84416d04bf [codex] Fix issue 957 long paste display (#959)
* Fix long paste display artifacts

* Fix serial line mode pasted chunks

* Narrow long paste display cleanup scope

* Strip only matched paste echo highlights

* Honor paste scroll setting through xterm paste
2026-05-12 17:33:31 +08:00
73 changed files with 4022 additions and 1734 deletions

19
App.tsx
View File

@@ -55,6 +55,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from './components/ui/input';
import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { VaultView, VaultSection } from './components/VaultView';
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog';
@@ -300,6 +301,16 @@ function App({ settings }: { settings: SettingsState }) {
keysRef.current = keys;
const knownHostsRef = useRef(knownHosts);
knownHostsRef.current = knownHosts;
// Bridge the gap while useVaultState hydrates: its async init awaits
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
// so the state is briefly [] at boot even when localStorage has entries.
// Any SSH connect during that window (manual click or restored session)
// would otherwise see no trusted hosts and prompt for fingerprint
// re-confirmation. Mirrors the same fallback already used by sync payloads.
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
const {
sessions,
@@ -1996,7 +2007,7 @@ function App({ settings }: { settings: SettingsState }) {
snippets={snippets}
snippetPackages={snippetPackages}
customGroups={customGroups}
knownHosts={knownHosts}
knownHosts={effectiveKnownHosts}
shellHistory={shellHistory}
connectionLogs={connectionLogs}
managedSources={managedSources}
@@ -2069,7 +2080,7 @@ function App({ settings }: { settings: SettingsState }) {
snippetPackages={snippetPackages}
sessions={sessions}
workspaces={workspaces}
knownHosts={knownHosts}
knownHosts={effectiveKnownHosts}
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
@@ -2426,7 +2437,9 @@ function AppWithProviders() {
return (
<I18nProvider locale={settings.uiLanguage}>
<ToastProvider>
<App settings={settings} />
<TooltipProvider delayDuration={300}>
<App settings={settings} />
</TooltipProvider>
</ToastProvider>
</I18nProvider>
);

View File

@@ -358,12 +358,16 @@ 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.resetDefaults': 'Reset built-ins to defaults',
'settings.terminal.keywordHighlight.resetBuiltIn': 'Restore default label and patterns',
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
'settings.terminal.keywordHighlight.editBuiltIn': 'Edit Built-in 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.patternField': 'Regex Patterns',
'settings.terminal.keywordHighlight.patternPlaceholder': 'One regex per line (e.g., \\bdown\\b)',
'settings.terminal.keywordHighlight.patternHint': 'One regex per line. Patterns are matched case-insensitively with the global flag.',
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
'settings.terminal.keywordHighlight.preview': 'Preview',
'settings.terminal.section.localShell': 'Local Shell',
@@ -2054,6 +2058,32 @@ const en: Messages = {
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Add Terminal',
'terminal.layer.switchToSplitView': 'Switch to Split View',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
'terminal.layer.theme': 'Theme',
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',
'topTabs.toggleTheme': 'Toggle theme',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',
'ai.chat.attach': 'Attach',
'ai.chat.collapse': 'Collapse',
'ai.chat.expand': 'Expand',
'ai.chat.enableAgent': 'Enable {name}',
'zmodem.waitingForRemote': 'Waiting for remote...',
'zmodem.uploading': 'Uploading',
'zmodem.downloading': 'Downloading',
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
'settings.shortcuts.resetToDefault': 'Reset to default',
};
export default en;

View File

@@ -1490,12 +1490,16 @@ const zhCN: Messages = {
'settings.terminal.scrollback.rows': '行数 *',
'settings.terminal.keywordHighlight.title': '关键字高亮',
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
'settings.terminal.keywordHighlight.resetDefaults': '把内置规则恢复为默认',
'settings.terminal.keywordHighlight.resetBuiltIn': '恢复内置标签与正则',
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
'settings.terminal.keywordHighlight.editBuiltIn': '编辑内置规则',
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down',
'settings.terminal.keywordHighlight.patternField': '正则表达式',
'settings.terminal.keywordHighlight.patternPlaceholder': '正则表达式(如 \\bdown\\b',
'settings.terminal.keywordHighlight.patternPlaceholder': '每行一个正则(如 \\bdown\\b',
'settings.terminal.keywordHighlight.patternHint': '每行一个正则。匹配忽略大小写,全局匹配。',
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
'settings.terminal.keywordHighlight.preview': '预览',
'settings.terminal.section.localShell': '本地 Shell',
@@ -2063,6 +2067,32 @@ const zhCN: Messages = {
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行ACP Agent 可能有自己的内部控制。',
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '添加终端',
'terminal.layer.switchToSplitView': '切换到分屏视图',
'terminal.layer.sftp': '文件传输',
'terminal.layer.scripts': '脚本',
'terminal.layer.theme': '主题',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.toggleTheme': '切换主题',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',
'ai.chat.attach': '附件',
'ai.chat.collapse': '收起',
'ai.chat.expand': '展开',
'ai.chat.enableAgent': '启用 {name}',
'zmodem.waitingForRemote': '等待远端...',
'zmodem.uploading': '上传中',
'zmodem.downloading': '下载中',
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
'settings.shortcuts.resetToDefault': '重置为默认',
};
export default zhCN;

View File

@@ -150,6 +150,10 @@ export const useSftpBackend = () => {
return bridge.getHomeDir();
}, []);
const listDrives = useCallback(async () => {
return await netcattyBridge.get()?.listDrives?.() ?? [];
}, []);
const startStreamTransfer = useCallback(
async (
options: Parameters<NonNullable<NetcattyBridge["startStreamTransfer"]>>[0],
@@ -268,6 +272,7 @@ export const useSftpBackend = () => {
mkdirLocal,
statLocal,
getHomeDir,
listDrives,
startStreamTransfer,
cancelTransfer,

View File

@@ -38,6 +38,7 @@ import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
@@ -1035,24 +1036,32 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
session={activeSession}
onExport={handleExport}
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
title="Session history"
>
<History size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
title="New chat"
>
<Plus size={15} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
>
<History size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
>
<Plus size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
</Tooltip>
</div>
</div>
@@ -1199,13 +1208,17 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
{timeStr}
</span>
<button
onClick={(e) => onDelete(e, session.id)}
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
title="Delete"
>
<Trash2 size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => onDelete(e, session.id)}
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
>
<Trash2 size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

@@ -54,6 +54,7 @@ import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { toast } from './ui/toast';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
// ============================================================================
// Provider Icons
@@ -377,12 +378,14 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
</span>
</div>
) : error ? (
<p
className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help"
title={error}
>
{error}
</p>
<Tooltip>
<TooltipTrigger asChild>
<p className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help">
{error}
</p>
</TooltipTrigger>
<TooltipContent>{error}</TooltipContent>
</Tooltip>
) : (
<p className="text-xs text-muted-foreground mt-1">
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
@@ -1904,9 +1907,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</div>
</div>
{entry.error && (
<span className="text-xs text-red-500 truncate max-w-24" title={entry.error}>
{t('cloudSync.history.error')}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-red-500 truncate max-w-24 cursor-default">
{t('cloudSync.history.error')}
</span>
</TooltipTrigger>
<TooltipContent>{entry.error}</TooltipContent>
</Tooltip>
)}
</div>
))}

View File

@@ -12,6 +12,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types";
import { ScrollArea } from "./ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
interface ConnectionLogsManagerProps {
logs: ConnectionLog[];
@@ -108,31 +109,39 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
{/* Saved column */}
<div className="flex items-center gap-2 shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onToggleSaved(log.id);
}}
className={cn(
"p-1.5 rounded-md transition-colors",
log.saved
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
)}
title={log.saved ? t("logs.action.unsave") : t("logs.action.save")}
>
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(log.id);
}}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
title={t("logs.action.delete")}
>
<Trash2 size={16} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onToggleSaved(log.id);
}}
className={cn(
"p-1.5 rounded-md transition-colors",
log.saved
? "text-primary bg-primary/10"
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
)}
>
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
</button>
</TooltipTrigger>
<TooltipContent>{log.saved ? t("logs.action.unsave") : t("logs.action.save")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(log.id);
}}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("logs.action.delete")}</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

@@ -51,9 +51,11 @@ import { Combobox } from "./ui/combobox";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
import { useAvailableFonts } from "../application/state/fontStore";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
@@ -814,29 +816,33 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
}
}}
/>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
title={t("hostDetails.credential.browseKeyFile")}
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
}
}}
>
<FolderOpen size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
}
}}
>
<FolderOpen size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
@@ -871,16 +877,20 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
/>
{/* Backspace behavior */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<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)}
<Select
value={form.backspaceBehavior ?? "default"}
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
>
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
<option value="ctrl-h">^H (0x08)</option>
</select>
<SelectTrigger className="h-8 w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Proxy */}
@@ -895,14 +905,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
</div>
<div className="flex min-w-0 items-center gap-2">
{(form.proxyConfig?.host || form.proxyProfileId) && (
<div title={proxySummaryLabel} className="min-w-0">
<Badge
variant="secondary"
className="max-w-[160px] truncate text-xs"
>
{proxySummaryLabel}
</Badge>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 cursor-default">
<Badge
variant="secondary"
className="max-w-[160px] truncate text-xs"
>
{proxySummaryLabel}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent>{proxySummaryLabel}</TooltipContent>
</Tooltip>
)}
<ChevronRight size={14} className="text-muted-foreground" />
</div>

View File

@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const hostWithMissingProxyProfile: Host = {
id: "host-1",
@@ -26,20 +27,24 @@ const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: [],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
onSave: () => {},
onCancel: () => {},
}),
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: [],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
onSave: () => {},
onCancel: () => {},
}),
),
),
);
@@ -111,29 +116,33 @@ test("HostDetailsPanel displays inherited telnet port before falling back to 23"
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: undefined,
port: undefined,
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: undefined,
port: undefined,
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
@@ -145,29 +154,33 @@ test("HostDetailsPanel uses group telnet port instead of ssh port for optional t
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "ssh",
telnetEnabled: true,
telnetPort: undefined,
port: 2222,
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "ssh",
telnetEnabled: true,
telnetPort: undefined,
port: 2222,
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
@@ -181,35 +194,39 @@ test("HostDetailsPanel displays inherited telnet credentials", () => {
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetUsername: undefined,
telnetPassword: undefined,
username: "ssh-user",
password: "ssh-password",
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
}],
onSave: () => {},
onCancel: () => {},
}),
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetUsername: undefined,
telnetPassword: undefined,
username: "ssh-user",
password: "ssh-password",
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
}],
onSave: () => {},
onCancel: () => {},
}),
),
),
);

View File

@@ -938,15 +938,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{selectedIdentity.label}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
title={t("common.clear")}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.clear")}</TooltipContent>
</Tooltip>
</div>
) : form.identityId ? (
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
@@ -956,15 +960,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.identity.missing")}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
title={t("common.clear")}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.clear")}</TooltipContent>
</Tooltip>
</div>
) : (
(() => {
@@ -1019,29 +1027,33 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}}
className="h-10 pr-9"
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setIdentitySuggestionsOpen((prev) => {
if (prev) return false;
const q = (form.username || "")
.toLowerCase()
.trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
return matches.length > 0;
});
}}
title={t("hostDetails.identity.suggestions")}
>
<ChevronDown size={16} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setIdentitySuggestionsOpen((prev) => {
if (prev) return false;
const q = (form.username || "")
.toLowerCase()
.trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
return matches.length > 0;
});
}}
>
<ChevronDown size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.identity.suggestions")}</TooltipContent>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverContent
@@ -1123,14 +1135,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onChange={(e) => update("password", e.target.value)}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</TooltipTrigger>
<TooltipContent>{showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}</TooltipContent>
</Tooltip>
</div>
)}
@@ -1153,9 +1169,14 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
{keyPath}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs w-0 flex-1 truncate font-mono cursor-default">
{keyPath}
</span>
</TooltipTrigger>
<TooltipContent>{keyPath}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
@@ -1366,26 +1387,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
}}
/>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
title={t("hostDetails.credential.browseKeyFile")}
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
addLocalKeyFilePath(filePath);
}
}}
>
<FolderOpen size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
addLocalKeyFilePath(filePath);
}
}}
>
<FolderOpen size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
@@ -1794,16 +1819,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<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)}
<Select
value={form.backspaceBehavior ?? "default"}
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
>
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
<option value="ctrl-h">^H (0x08)</option>
</select>
<SelectTrigger className="h-8 w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</div>
</Card>

View File

@@ -54,6 +54,7 @@ import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
// Import utilities and components from keychain module
import {
@@ -1168,9 +1169,14 @@ echo $3 >> "$FILE"`);
</Label>
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs font-mono truncate" title={draftKey.filePath}>
{draftKey.filePath}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs font-mono truncate cursor-default">
{draftKey.filePath}
</span>
</TooltipTrigger>
<TooltipContent>{draftKey.filePath}</TooltipContent>
</Tooltip>
</div>
</div>
)}

View File

@@ -37,6 +37,7 @@ import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { toast } from "./ui/toast";
interface KnownHostsManagerProps {
@@ -122,27 +123,35 @@ const HostItem = React.memo<HostItemProps>(
{/* Quick action buttons on hover */}
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{!converted && (
<button
className="p-1 rounded hover:bg-primary/20 text-primary"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
title={t("action.convertToHost")}
>
<ArrowRight size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-primary/20 text-primary"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
>
<ArrowRight size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)}
<button
className="p-1 rounded hover:bg-destructive/20 text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(knownHost.id);
}}
title={t("action.remove")}
>
<Trash2 size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-destructive/20 text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(knownHost.id);
}}
>
<Trash2 size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("action.remove")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
@@ -193,18 +202,22 @@ const HostItem = React.memo<HostItemProps>(
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!converted && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
title={t("action.convertToHost")}
>
<ArrowRight size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
>
<ArrowRight size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)}
</div>
</div>

View File

@@ -277,7 +277,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
className="gap-1.5 h-8 px-2"
onClick={handleExport}
disabled={isExporting}
title={t("logView.export")}
>
<Download size={14} />
<span className="text-xs">{t("logView.export")}</span>
@@ -290,7 +289,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
size="sm"
className="gap-1.5 h-8 px-2"
onClick={() => setThemeModalOpen(true)}
title={t("logView.customizeAppearance")}
>
<Palette size={14} />
<span className="text-xs">{t("logView.appearance")}</span>

View File

@@ -298,7 +298,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onClose();
}}
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
title="New Workspace"
>
<Plus size={11} />
<span>New Workspace</span>

View File

@@ -249,15 +249,19 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
<button
type="button"
onClick={handleAddSnippet}
title={t('snippets.action.newSnippet')}
aria-label={t('snippets.action.newSnippet')}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Plus size={14} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleAddSnippet}
aria-label={t('snippets.action.newSnippet')}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
>
<Plus size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('snippets.action.newSnippet')}</TooltipContent>
</Tooltip>
</div>
{/* Content */}

View File

@@ -20,6 +20,7 @@ import {
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
interface SerialPort {
@@ -262,35 +263,41 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<select
id="data-bits"
value={dataBits}
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
{DATA_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<select
id="stop-bits"
value={stopBits}
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
{STOP_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
@@ -302,35 +309,41 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<select
id="parity"
<Select
value={parity}
onChange={(e) => setParity(e.target.value as SerialParity)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onValueChange={(v) => setParity(v as SerialParity)}
>
{PARITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.parity.${option}`)}
</option>
))}
</select>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<select
id="flow-control"
<Select
value={flowControl}
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
{FLOW_CONTROL_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</option>
))}
</select>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Terminal Options */}

View File

@@ -12,6 +12,7 @@ import { Button } from './ui/button';
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import {
AsidePanel,
@@ -291,35 +292,41 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<select
id="data-bits"
value={dataBits}
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
{DATA_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<select
id="stop-bits"
value={stopBits}
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
{STOP_BITS.map((bits) => (
<option key={bits} value={bits}>
{bits}
</option>
))}
</select>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
@@ -331,35 +338,41 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<select
id="parity"
<Select
value={parity}
onChange={(e) => setParity(e.target.value as SerialParity)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onValueChange={(v) => setParity(v as SerialParity)}
>
{PARITY_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.parity.${option}`)}
</option>
))}
</select>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<select
id="flow-control"
<Select
value={flowControl}
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
{FLOW_CONTROL_OPTIONS.map((option) => (
<option key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</option>
))}
</select>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Terminal Options */}

View File

@@ -20,6 +20,7 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
@@ -187,13 +188,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<div className="flex items-center justify-between px-4 py-2">
<h1 className="text-lg font-semibold">{t("settings.title")}</h1>
{!isMac && (
<button
onClick={handleClose}
className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
title={t("common.close")}
>
<X size={16} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleClose}
className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
>
<X size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("common.close")}</TooltipContent>
</Tooltip>
)}
</div>
</div>

View File

@@ -26,6 +26,7 @@ import type { DropEntry } from "../lib/sftpFileUtils";
import { Host, Identity, SSHKey } from "../types";
import type { TransferTask } from "../types";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { DistroAvatar } from "./DistroAvatar";
import { SftpPaneView } from "./sftp/SftpPaneView";
@@ -133,6 +134,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
mkdirLocal,
deleteLocalFile,
listLocalDir,
listDrives,
} = useSftpBackend();
const sftpRef = useRef(sftp);
@@ -296,6 +298,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
listDrives,
});
const {
@@ -651,18 +654,22 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
size="sm"
className="h-5 w-5 rounded-sm shrink-0"
/>
<div
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
>
<span className="font-medium">
{displayHost.label}
</span>
<span className="mx-1 text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground">
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate cursor-default">
<span className="font-medium">
{displayHost.label}
</span>
<span className="mx-1 text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground">
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
{`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
</TooltipContent>
</Tooltip>
</div>
</div>
)}

View File

@@ -136,6 +136,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
mkdirLocal,
deleteLocalFile,
listLocalDir,
listDrives,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
@@ -262,6 +263,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
listDrives,
});
const visibleTransfers = useMemo(

View File

@@ -745,21 +745,25 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
actions={
<>
{editingSnippet.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => {
const id = editingSnippet.id;
if (!id) return;
onDelete(id);
handleClosePanel();
}}
aria-label={t('common.delete')}
title={t('common.delete')}
>
<Trash2 size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => {
const id = editingSnippet.id;
if (!id) return;
onDelete(id);
handleClosePanel();
}}
aria-label={t('common.delete')}
>
<Trash2 size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
)}
<Button
variant="ghost"
@@ -839,18 +843,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
{editingSnippet.shortkey && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
setShortkeyError(null);
}}
title={t('snippets.shortkey.clear')}
>
<RotateCcw size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
setShortkeyError(null);
}}
>
<RotateCcw size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('snippets.shortkey.clear')}</TooltipContent>
</Tooltip>
)}
</div>
<button
@@ -1269,7 +1277,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
value={newPackageName}
onChange={(e) => setNewPackageName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
/>
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
</div>

View File

@@ -35,6 +35,7 @@ import {
PopoverTrigger,
} from './ui/popover';
import { toast } from './ui/toast';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
// ============================================================================
// Provider Icons
@@ -169,26 +170,30 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
className
)}
title={t('sync.cloudSync')}
>
{getButtonIcon()}
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
className
)}
>
{getButtonIcon()}
{/* Status indicator dot */}
<StatusIndicator
status={overallStatus}
size="sm"
className="absolute top-0.5 right-0.5 ring-2 ring-background"
/>
</Button>
</PopoverTrigger>
{/* Status indicator dot */}
<StatusIndicator
status={overallStatus}
size="sm"
className="absolute top-0.5 right-0.5 ring-2 ring-background"
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t('sync.cloudSync')}</TooltipContent>
</Tooltip>
<PopoverContent
key={syncStateKey}
@@ -222,16 +227,20 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
</div>
{onOpenSettings && (
<button
onClick={() => {
setIsOpen(false);
onOpenSettings();
}}
className="p-1 rounded hover:bg-muted transition-colors"
title={t('sync.settings')}
>
<Settings size={14} className="text-muted-foreground" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
setIsOpen(false);
onOpenSettings();
}}
className="p-1 rounded hover:bg-muted transition-colors"
>
<Settings size={14} className="text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>{t('sync.settings')}</TooltipContent>
</Tooltip>
)}
</div>
</div>

View File

@@ -35,6 +35,7 @@ import { useTerminalBackend } from "../application/state/useTerminalBackend";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { toast } from "./ui/toast";
import { useAvailableFonts } from "../application/state/fontStore";
import { composeFontFamilyStack, type SupportedPlatform } from "../infrastructure/config/cjkFonts";
@@ -1506,9 +1507,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalContextActions = useTerminalContextActions({
termRef,
sessionRef,
terminalBackend,
onHasSelectionChange: setHasSelection,
disableBracketedPasteRef,
scrollOnPasteRef,
});
// Kept fresh on every render so the mouseTracking capture handler at
@@ -1879,21 +1878,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
/>
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
<button
type="button"
className="ml-0.5 p-0.5 rounded hover:bg-[color:var(--terminal-toolbar-btn-hover)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0"
onClick={() => {
void navigator.clipboard.writeText(host.hostname).then(() => {
toast.success(t("terminal.statusbar.copyHostname.toast", { hostname: host.hostname }));
}).catch(() => {
toast.error(t("terminal.statusbar.copyHostname.error"));
});
}}
title={t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}
aria-label={t("terminal.statusbar.copyHostname.label")}
>
<Copy size={10} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="ml-0.5 p-0.5 rounded hover:bg-[color:var(--terminal-toolbar-btn-hover)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0"
onClick={() => {
void navigator.clipboard.writeText(host.hostname).then(() => {
toast.success(t("terminal.statusbar.copyHostname.toast", { hostname: host.hostname }));
}).catch(() => {
toast.error(t("terminal.statusbar.copyHostname.error"));
});
}}
aria-label={t("terminal.statusbar.copyHostname.label")}
>
<Copy size={10} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}</TooltipContent>
</Tooltip>
)}
</div>
{/* Server Stats Display */}
@@ -1904,7 +1907,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.cpu")}
aria-label={t("terminal.serverStats.cpu")}
>
<Cpu size={10} className="flex-shrink-0" />
<span>
@@ -1973,7 +1976,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.memory")}
aria-label={t("terminal.serverStats.memory")}
>
<MemoryStick size={10} className="flex-shrink-0" />
<span>
@@ -1995,12 +1998,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
{serverStats.memTotal !== null && (
<div className="space-y-1.5">
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{/* Used (green) */}
{/* Used (green) — exact value shown in legend below */}
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
<div
className="h-full bg-emerald-500"
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memUsed")}: ${(serverStats.memUsed / 1024).toFixed(1)}G`}
/>
)}
{/* Buffers (blue) */}
@@ -2008,7 +2010,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="h-full bg-blue-500"
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memBuffers")}: ${(serverStats.memBuffers / 1024).toFixed(1)}G`}
/>
)}
{/* Cached (amber/orange) */}
@@ -2016,7 +2017,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="h-full bg-amber-500"
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memCached")}: ${(serverStats.memCached / 1024).toFixed(1)}G`}
/>
)}
</div>
@@ -2050,7 +2050,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="h-full bg-rose-500"
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
/>
)}
</div>
@@ -2083,9 +2082,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
/>
</div>
<span className="flex-shrink-0 font-mono truncate max-w-[140px]" title={proc.command}>
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0 font-mono truncate max-w-[140px] cursor-default">
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
</TooltipTrigger>
<TooltipContent>{proc.command}</TooltipContent>
</Tooltip>
</div>
))}
</div>
@@ -2099,7 +2103,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.disk")}
aria-label={t("terminal.serverStats.disk")}
>
<HardDrive size={10} className="flex-shrink-0" />
<span className={cn(
@@ -2127,9 +2131,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
{serverStats.disks.map((disk, index) => (
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
<div className="flex items-center justify-between gap-4">
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]" title={disk.mountPoint}>
{disk.mountPoint}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px] cursor-default">
{disk.mountPoint}
</span>
</TooltipTrigger>
<TooltipContent>{disk.mountPoint}</TooltipContent>
</Tooltip>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
@@ -2161,7 +2170,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild>
<button
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.network")}
aria-label={t("terminal.serverStats.network")}
>
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
@@ -2205,40 +2214,48 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div className="flex-1" />
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
<Button
variant="secondary"
size="icon"
className={cn(
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
"bg-transparent hover:bg-transparent",
isBroadcastEnabled && "text-green-500",
)}
onClick={onToggleBroadcast}
title={
isBroadcastEnabled
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={cn(
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
"bg-transparent hover:bg-transparent",
isBroadcastEnabled && "text-green-500",
)}
onClick={onToggleBroadcast}
aria-label={
isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
>
<Radio size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
aria-label={
isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
>
<Radio size={12} />
</Button>
: t("terminal.toolbar.broadcastEnable")}
</TooltipContent>
</Tooltip>
)}
{inWorkspace && !isFocusMode && onExpandToFocus && (
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onExpandToFocus}
title={t("terminal.toolbar.focusMode")}
aria-label={t("terminal.toolbar.focusMode")}
>
<Maximize2 size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onExpandToFocus}
aria-label={t("terminal.toolbar.focusMode")}
>
<Maximize2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("terminal.toolbar.focusMode")}</TooltipContent>
</Tooltip>
)}
{renderControls({ showClose: inWorkspace })}
</div>

View File

@@ -41,6 +41,8 @@ import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
import { DistroAvatar } from './DistroAvatar';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useI18n } from '../application/i18n/I18nProvider';
import Terminal from './Terminal';
import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
@@ -507,6 +509,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
toggleScriptsSidePanelRef,
activeSidePanelTabRef,
}) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store
const activeTabId = useActiveTabId();
const isVaultActive = activeTabId === 'vault';
@@ -2099,27 +2102,35 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
/>
</div>
{onRequestAddToWorkspace && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
style={{ color: mutedFg }}
onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}
title="Add Terminal"
>
<Plus size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
style={{ color: mutedFg }}
onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}
>
<Plus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.addTerminal')}</TooltipContent>
</Tooltip>
)}
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
style={{ color: mutedFg }}
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
title="Switch to Split View"
>
<Columns2 size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
style={{ color: mutedFg }}
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
>
<Columns2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.switchToSplitView')}</TooltipContent>
</Tooltip>
</div>
{/* Session list */}
@@ -2252,111 +2263,137 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
borderBottom: '1px solid var(--terminal-sidepanel-border)',
}}
>
<Button
variant="ghost"
size="icon"
data-tab-id="sftp"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'sftp'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'sftp'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleToggleSftpFromBar}
title="SFTP"
>
<FolderTree size={15} />
</Button>
<Button
variant="ghost"
size="icon"
data-tab-id="scripts"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'scripts'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'scripts'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleOpenScripts}
title="Scripts"
>
<Zap size={15} />
</Button>
<Button
variant="ghost"
size="icon"
data-tab-id="theme"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'theme'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'theme'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleOpenTheme}
title="Theme"
>
<Palette size={15} />
</Button>
<Button
variant="ghost"
size="icon"
data-tab-id="ai"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'ai'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'ai'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleOpenAI}
title="AI Chat"
>
<MessageSquare size={15} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
data-tab-id="sftp"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'sftp'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'sftp'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleToggleSftpFromBar}
>
<FolderTree size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.sftp')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
data-tab-id="scripts"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'scripts'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'scripts'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleOpenScripts}
>
<Zap size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.scripts')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
data-tab-id="theme"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'theme'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'theme'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleOpenTheme}
>
<Palette size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.theme')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
data-tab-id="ai"
data-tab-type="sidepanel"
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'ai'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'ai'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleOpenAI}
>
<MessageSquare size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.aiChat')}</TooltipContent>
</Tooltip>
<div className="flex-1" />
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: 'var(--terminal-sidepanel-muted)',
}}
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
title={sidePanelPosition === 'left' ? 'Move panel to right' : 'Move panel to left'}
>
{sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleCloseSidePanel}
title="Close panel"
>
<X size={15} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: 'var(--terminal-sidepanel-muted)',
}}
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
>
{sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />}
</Button>
</TooltipTrigger>
<TooltipContent>
{sidePanelPosition === 'left' ? t('terminal.layer.movePanelRight') : t('terminal.layer.movePanelLeft')}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleCloseSidePanel}
>
<X size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.closePanel')}</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex-1 min-h-0 relative">

View File

@@ -14,6 +14,7 @@ import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { SyncStatusButton } from './SyncStatusButton';
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
@@ -205,7 +206,6 @@ const WindowControls: React.FC = memo(() => {
onClick={handleMinimize}
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="Minimize"
>
<Minus size={16} />
</button>
@@ -213,20 +213,16 @@ const WindowControls: React.FC = memo(() => {
onClick={handleMaximize}
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
// Restore icon (two overlapping squares)
<Copy size={14} />
) : (
// Maximize icon (single square)
<Square size={14} />
)}
</button>
<button
onClick={handleClose}
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
title="Close"
>
<X size={16} />
</button>
@@ -577,60 +573,63 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText;
return (
<div
key={tabId}
data-tab-id={tabId}
data-tab-type="editor"
data-state={isActive ? 'active' : 'inactive'}
onClick={() => onSelectTab(tabId)}
title={tooltip}
className={cn(
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
)}
style={{
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileIcon
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<span className="truncate flex items-center gap-0.5">
{dirty && <span className="text-primary mr-0.5"></span>}
{editorTab.fileName}
{suffix && <span className="text-muted-foreground ml-1">{suffix}</span>}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onRequestCloseEditorTab(editorTab.id);
}}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label="Close editor tab"
>
<X size={12} />
</button>
</div>
<Tooltip key={tabId}>
<TooltipTrigger asChild>
<div
data-tab-id={tabId}
data-tab-type="editor"
data-state={isActive ? 'active' : 'inactive'}
onClick={() => onSelectTab(tabId)}
className={cn(
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
)}
style={{
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileIcon
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<span className="truncate flex items-center gap-0.5">
{dirty && <span className="text-primary mr-0.5"></span>}
{editorTab.fileName}
{suffix && <span className="text-muted-foreground ml-1">{suffix}</span>}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onRequestCloseEditorTab(editorTab.id);
}}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label="Close editor tab"
>
<X size={12} />
</button>
</div>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
@@ -1016,16 +1015,20 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{renderOrderedTabs()}
{/* Add new tab button - follows last tab when not overflowing */}
{!hasOverflow && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
title="Open quick switcher"
>
<Plus size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
>
<Plus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.openQuickSwitcher')}</TooltipContent>
</Tooltip>
)}
{/* Draggable spacer - fixed width handle at the end */}
<div className="min-w-[20px] h-7 app-drag flex-shrink-0" style={dragRegionStyle} />
@@ -1042,56 +1045,74 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* More tabs button - only when overflowing */}
{hasOverflow && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
title="More tabs"
>
<MoreHorizontal size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
>
<MoreHorizontal size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.moreTabs')}</TooltipContent>
</Tooltip>
)}
{/* Fixed right controls */}
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="AI Assistant"
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
>
<Sparkles size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
>
<Sparkles size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.aiAssistant')}</TooltipContent>
</Tooltip>
<Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}>
<Bell size={16} />
</Button>
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onToggleTheme}
disabled={isImmersiveActive && !followAppTerminalTheme}
title="Toggle theme"
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onToggleTheme}
disabled={isImmersiveActive && !followAppTerminalTheme}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.toggleTheme')}</TooltipContent>
</Tooltip>
</div>
{/* Settings gear button - sits to the left of WindowControls on win/linux, at the right edge on mac */}
<div className="self-stretch flex items-stretch">
<button
onClick={onOpenSettings}
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="Open Settings"
>
<Settings size={16} />
</button>
<div className="self-stretch flex items-center px-2 app-drag" style={dragRegionStyle}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenSettings}
>
<Settings size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.openSettings')}</TooltipContent>
</Tooltip>
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}

View File

@@ -4,6 +4,7 @@ import { useSessionState } from "../application/state/useSessionState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
import { I18nProvider } from "../application/i18n/I18nProvider";
@@ -78,28 +79,31 @@ const WorkspaceGroup: React.FC<{
{expanded && (
<div className="ml-4 mt-0.5 space-y-0.5">
{sessions.map((s) => (
<button
key={s.id}
title={s.hostLabel || s.label}
onClick={() => {
// Jump to session (using session id)
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted/60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
<Tooltip key={s.id}>
<TooltipTrigger asChild>
<button
onClick={() => {
// Jump to session (using session id)
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted/60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
</TooltipTrigger>
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
</Tooltip>
))}
</div>
)}
@@ -219,17 +223,20 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
<span className="text-sm font-medium">Netcatty</span>
</div>
<div className="flex items-center gap-1">
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleOpenMain}
title={t("tray.openMainWindow")}
>
<Maximize2 size={14} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleOpenMain}
>
<Maximize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("tray.openMainWindow")}</TooltipContent>
</Tooltip>
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleClose}
title="Close"
>
<X size={14} />
</button>
@@ -277,27 +284,30 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
))}
{/* Solo sessions */}
{soloSessions.map((s) => (
<button
key={s.id}
title={s.hostLabel || s.label}
onClick={() => {
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
<Tooltip key={s.id}>
<TooltipTrigger asChild>
<button
onClick={() => {
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
</TooltipTrigger>
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
</Tooltip>
))}
</div>
</div>
@@ -307,16 +317,20 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
{activeSession && (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
<Button
variant="ghost"
className="w-full justify-start px-2 h-8"
title={activeSession.hostLabel || activeSession.label}
onClick={() => {
void jumpToSession(activeSession.id);
}}
>
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="w-full justify-start px-2 h-8"
onClick={() => {
void jumpToSession(activeSession.id);
}}
>
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{activeSession.hostLabel || activeSession.label}</TooltipContent>
</Tooltip>
</div>
)}
@@ -332,55 +346,58 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
: `${rule.localPort}${rule.remoteHost}:${rule.remotePort}`);
return (
<button
key={rule.id}
disabled={isConnecting}
title={label}
onClick={() => {
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 resolveEffectiveHost = (host: Host) => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
};
const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings);
}
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
isConnecting ? "opacity-60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={
rule.status === "active"
? "success"
: rule.status === "connecting"
? "warning"
: rule.status === "error"
? "error"
: "neutral"
}
spinning={rule.status === "connecting"}
/>
<span className="truncate">{label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`tray.status.${rule.status}`)}
</span>
</button>
<Tooltip key={rule.id}>
<TooltipTrigger asChild>
<button
disabled={isConnecting}
onClick={() => {
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 resolveEffectiveHost = (host: Host) => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
};
const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings);
}
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
isConnecting ? "opacity-60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={
rule.status === "active"
? "success"
: rule.status === "connecting"
? "warning"
: rule.status === "error"
? "error"
: "neutral"
}
spinning={rule.status === "connecting"}
/>
<span className="truncate">{label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`tray.status.${rule.status}`)}
</span>
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
})}
</div>

View File

@@ -1907,21 +1907,25 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onChange={setSortMode}
className="h-10 w-10"
/>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
} else {
setIsMultiSelectMode(true);
}
}}
title={t("vault.hosts.multiSelect")}
>
<CheckSquare size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
} else {
setIsMultiSelectMode(true);
}
}}
>
<CheckSquare size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("vault.hosts.multiSelect")}</TooltipContent>
</Tooltip>
</div>
{/* New Host split button — collapses with an animation when the
host details / new-host aside panel is open, since the button
@@ -2229,6 +2233,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
<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" /> {t('vault.hosts.unpin')}
</ContextMenuItem>
@@ -2328,6 +2338,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
<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>

View File

@@ -4,6 +4,7 @@ import { cn } from '../../lib/utils';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useI18n } from '../../application/i18n/I18nProvider';
/**
@@ -142,9 +143,14 @@ export const ToolCall = ({
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
}
{name === 'terminal_execute' && args?.command ? (
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
</span>
</TooltipTrigger>
<TooltipContent>{String(args.command)}</TooltipContent>
</Tooltip>
) : (
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
)}

View File

@@ -20,6 +20,7 @@ import {
DropdownContent,
DropdownTrigger,
} from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface AgentSelectorProps {
currentAgentId: string;
@@ -80,6 +81,7 @@ const DiscoveredAgentRow: React.FC<{
agent: DiscoveredAgent;
onEnable: () => void;
}> = ({ agent, onEnable }) => {
const { t } = useI18n();
const agentLike: AgentInfo = {
id: `discovered_${agent.command}`,
name: agent.name,
@@ -98,13 +100,17 @@ const DiscoveredAgentRow: React.FC<{
{agent.version || agent.path}
</span>
</div>
<button
onClick={onEnable}
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
title={`Enable ${agent.name}`}
>
<Plus size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onEnable}
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
>
<Plus size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.enableAgent', { name: agent.name })}</TooltipContent>
</Tooltip>
</div>
);
};
@@ -250,14 +256,18 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<SectionLabel
action={
onRediscover && (
<button
onClick={onRediscover}
disabled={isDiscovering}
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
title={t('ai.chat.rescan')}
>
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onRediscover}
disabled={isDiscovering}
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
>
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.rescan')}</TooltipContent>
</Tooltip>
)
}
>

View File

@@ -22,6 +22,7 @@ import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
import { ScrollArea } from '../ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
@@ -415,24 +416,27 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div className="px-3 pt-3 pb-1.5">
<div className="flex flex-wrap gap-2">
{selectedUserSkills.map((skill) => (
<div
key={skill.id}
className={selectedSkillChipClassName}
title={skill.description || skill.name || skill.slug}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
<Tooltip key={skill.id}>
<TooltipTrigger asChild>
<div
className={selectedSkillChipClassName}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
</TooltipTrigger>
<TooltipContent>{skill.description || skill.name || skill.slug}</TooltipContent>
</Tooltip>
))}
</div>
</div>
@@ -450,14 +454,18 @@ const ChatInput: React.FC<ChatInputProps> = ({
].filter(Boolean).join(' ')}
maxLength={100000}
/>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
title={expanded ? 'Collapse' : 'Expand'}
>
<Expand size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
>
<Expand size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{expanded ? t('ai.chat.collapse') : t('ai.chat.expand')}</TooltipContent>
</Tooltip>
</div>
{/* @ mention popover */}
@@ -557,25 +565,29 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Footer toolbar */}
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
<PromptInputTools className="gap-1 flex-wrap">
<button
ref={attachBtnRef}
type="button"
onClick={() => {
if (!showAttachMenu) {
const rect = attachBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('attach');
} else {
closeAllMenus();
}
}}
className={iconButtonClassName}
title="Attach"
aria-label="Attach file"
aria-expanded={showAttachMenu}
>
<Plus size={13} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={attachBtnRef}
type="button"
onClick={() => {
if (!showAttachMenu) {
const rect = attachBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('attach');
} else {
closeAllMenus();
}
}}
className={iconButtonClassName}
aria-label={t('ai.chat.attach')}
aria-expanded={showAttachMenu}
>
<Plus size={13} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.attach')}</TooltipContent>
</Tooltip>
{showAttachMenu && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
@@ -743,33 +755,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Permission mode chip — only for Catty Agent */}
{permissionMode && onPermissionModeChange && (
<>
<button
ref={permBtnRef}
type="button"
onClick={() => {
if (!showPermPicker) {
const rect = permBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('perm');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
title={t('ai.safety.permissionMode')}
aria-label="Permission mode"
aria-expanded={showPermPicker}
>
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
<span className="truncate max-w-[72px]">
{permissionMode === 'observer' && t('ai.chat.permObserver')}
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
</span>
<ChevronDown size={9} className="text-muted-foreground/50" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={permBtnRef}
type="button"
onClick={() => {
if (!showPermPicker) {
const rect = permBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('perm');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
aria-label={t('ai.safety.permissionMode')}
aria-expanded={showPermPicker}
>
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
<span className="truncate max-w-[72px]">
{permissionMode === 'observer' && t('ai.chat.permObserver')}
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
</span>
<ChevronDown size={9} className="text-muted-foreground/50" />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.safety.permissionMode')}</TooltipContent>
</Tooltip>
{showPermPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />

View File

@@ -15,6 +15,7 @@ import {
DropdownContent,
DropdownTrigger,
} from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface ConversationExportProps {
session: AISession | null;
@@ -45,17 +46,21 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
return (
<Dropdown>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
disabled={!hasMessages}
title={t('ai.chat.exportConversation')}
>
<Download size={14} />
</Button>
</DropdownTrigger>
<Tooltip>
<TooltipTrigger asChild>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
disabled={!hasMessages}
>
<Download size={14} />
</Button>
</DropdownTrigger>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.exportConversation')}</TooltipContent>
</Tooltip>
<DropdownContent
align="end"
sideOffset={6}

View File

@@ -8,6 +8,10 @@ import {
isTextEditorReadOnly,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
const wrap = (child: React.ReactElement) =>
React.createElement(TooltipProvider, null, child);
test("disables promoting a modal editor to a tab while a save is running", () => {
assert.equal(canPromoteTextEditor({ saving: true }), false);
@@ -18,18 +22,22 @@ test("disables promoting a modal editor to a tab while a save is running", () =>
test("renders the promote button disabled while a save is running", () => {
const savingMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, {
saving: true,
onPromoteToTab: () => {},
title: "Maximize",
}),
wrap(
React.createElement(TextEditorPromoteButton, {
saving: true,
onPromoteToTab: () => {},
title: "Maximize",
}),
),
);
const idleMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, {
saving: false,
onPromoteToTab: () => {},
title: "Maximize",
}),
wrap(
React.createElement(TextEditorPromoteButton, {
saving: false,
onPromoteToTab: () => {},
title: "Maximize",
}),
),
);
assert.match(savingMarkup, /disabled=""/);

View File

@@ -28,6 +28,7 @@ import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Map our language IDs to Monaco language IDs
const languageIdToMonaco = (langId: string): string => {
@@ -186,16 +187,20 @@ export const TextEditorPromoteButton: React.FC<{
onPromoteToTab: () => void;
title: string;
}> = ({ saving, onPromoteToTab, title }) => (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
disabled={!canPromoteTextEditor({ saving })}
title={title}
>
<Maximize2 size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onPromoteToTab}
disabled={!canPromoteTextEditor({ saving })}
>
<Maximize2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
);
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
@@ -479,34 +484,47 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
{fileName}
</span>
{subtitle && (
<span className="text-xs text-muted-foreground truncate" title={subtitle}>
{subtitle}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground truncate cursor-default">
{subtitle}
</span>
</TooltipTrigger>
<TooltipContent>{subtitle}</TooltipContent>
</Tooltip>
)}
{saveError && <span className="text-xs text-destructive truncate">{saveError}</span>}
</div>
<div className="flex items-center gap-2 min-w-0">
{/* Search button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSearch}
title={t('common.search')}
>
<Search size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleSearch}
>
<Search size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.search')}</TooltipContent>
</Tooltip>
{/* Word wrap toggle */}
<Button
variant={wordWrap ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={onToggleWordWrap}
title={t('sftp.editor.wordWrap')}
>
<WrapText size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={wordWrap ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={onToggleWordWrap}
>
<WrapText size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.editor.wordWrap')}</TooltipContent>
</Tooltip>
{/* Language selector */}
<Combobox

View File

@@ -11,6 +11,7 @@ import { Combobox } from '../ui/combobox';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Popover,PopoverContent,PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface IdentityPanelProps {
draftIdentity: Partial<Identity>;
@@ -129,15 +130,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
<span className="text-sm flex-1 truncate">
{selectedKey?.label || t('hostDetails.credential.missing')}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={clearSelectedKey}
title={t('common.clear')}
>
<X size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={clearSelectedKey}
>
<X size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.clear')}</TooltipContent>
</Tooltip>
</div>
)}
@@ -202,15 +207,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
icon={<Key size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
title={t('common.cancel')}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.cancel')}</TooltipContent>
</Tooltip>
</div>
)}
@@ -230,15 +239,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
icon={<Shield size={14} className="text-muted-foreground" />}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
title={t('common.cancel')}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => setSelectedCredentialType(null)}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.cancel')}</TooltipContent>
</Tooltip>
</div>
)}

View File

@@ -12,6 +12,7 @@ import { TrafficDiagram } from '../TrafficDiagram';
import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
import { getTypeLabel } from './utils';
@@ -183,14 +184,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
>
{t('common.cancel')}
</Button>
<button
className="text-xs text-muted-foreground hover:text-foreground/80 flex items-center gap-1 px-2 py-1 rounded hover:bg-foreground/5 transition-colors"
onClick={onOpenWizard}
title={t('pf.form.openWizardTitle')}
>
<Zap size={12} />
{t('pf.form.openWizard')}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-xs text-muted-foreground hover:text-foreground/80 flex items-center gap-1 px-2 py-1 rounded hover:bg-foreground/5 transition-colors"
onClick={onOpenWizard}
>
<Zap size={12} />
{t('pf.form.openWizard')}
</button>
</TooltipTrigger>
<TooltipContent>{t('pf.form.openWizardTitle')}</TooltipContent>
</Tooltip>
</div>
</AsidePanelFooter>
</AsidePanel>

View File

@@ -68,13 +68,26 @@ export const RuleCard: React.FC<RuleCardProps> = ({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold truncate">{rule.label}</span>
<span
className={cn(
"h-2 w-2 rounded-full flex-shrink-0",
getStatusColor(rule.status)
)}
title={rule.status === 'error' && rule.error ? rule.error : undefined}
/>
{rule.status === 'error' && rule.error ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"h-2 w-2 rounded-full flex-shrink-0 cursor-default",
getStatusColor(rule.status)
)}
/>
</TooltipTrigger>
<TooltipContent>{rule.error}</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"h-2 w-2 rounded-full flex-shrink-0",
getStatusColor(rule.status)
)}
/>
)}
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<TooltipProvider delayDuration={300}>

View File

@@ -7,6 +7,7 @@ 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 { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light" | "system";
@@ -122,20 +123,23 @@ export default function SettingsAppearanceTab(props: {
) => (
<div className="flex flex-wrap gap-2 justify-end">
{options.map((preset) => (
<button
key={preset.id}
onClick={() => onChange(preset.id)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
value === preset.id
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(preset.tokens.background)}
title={preset.name}
>
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
</button>
<Tooltip key={preset.id}>
<TooltipTrigger asChild>
<button
onClick={() => onChange(preset.id)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
value === preset.id
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(preset.tokens.background)}
>
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
</button>
</TooltipTrigger>
<TooltipContent>{preset.name}</TooltipContent>
</Tooltip>
))}
</div>
);
@@ -212,42 +216,49 @@ export default function SettingsAppearanceTab(props: {
<div className="text-sm font-medium">{t("settings.appearance.accentColor.custom")}</div>
<div className="flex flex-wrap gap-2">
{ACCENT_COLORS.map((c) => (
<button
key={c.name}
onClick={() => setCustomAccent(c.value)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
customAccent === c.value
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(c.value)}
title={c.name}
>
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
</button>
<Tooltip key={c.name}>
<TooltipTrigger asChild>
<button
onClick={() => setCustomAccent(c.value)}
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
customAccent === c.value
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
style={getHslStyle(c.value)}
>
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
</button>
</TooltipTrigger>
<TooltipContent>{c.name}</TooltipContent>
</Tooltip>
))}
<label
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
!ACCENT_COLORS.some((c) => c.value === customAccent)
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
title={t("settings.appearance.customColor")}
>
<input
type="color"
className="sr-only"
onChange={(e) => setCustomAccent(hexToHsl(e.target.value))}
/>
{!ACCENT_COLORS.some((c) => c.value === customAccent) ? (
<Check className="text-white drop-shadow-md" size={10} />
) : (
<Palette size={12} className="text-white drop-shadow-md" />
)}
</label>
<Tooltip>
<TooltipTrigger asChild>
<label
className={cn(
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
!ACCENT_COLORS.some((c) => c.value === customAccent)
? "ring-2 ring-offset-2 ring-foreground scale-110"
: "hover:scale-105",
)}
>
<input
type="color"
className="sr-only"
onChange={(e) => setCustomAccent(hexToHsl(e.target.value))}
/>
{!ACCENT_COLORS.some((c) => c.value === customAccent) ? (
<Check className="text-white drop-shadow-md" size={10} />
) : (
<Palette size={12} className="text-white drop-shadow-md" />
)}
</label>
</TooltipTrigger>
<TooltipContent>{t("settings.appearance.customColor")}</TooltipContent>
</Tooltip>
</div>
</div>
)}

View File

@@ -11,6 +11,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { Label } from "../../ui/label";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { SectionHeader, SettingsTabContent } from "../settings-ui";
const getOpenerLabel = (
@@ -527,31 +528,44 @@ export default function SettingsFileAssociationsTab() {
</td>
<td className="px-4 py-3 text-muted-foreground">
{openerType === 'system-app' && systemApp ? (
<span title={systemApp.path}>{systemApp.name}</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{systemApp.name}</span>
</TooltipTrigger>
<TooltipContent>{systemApp.path}</TooltipContent>
</Tooltip>
) : (
getOpenerLabel(openerType, systemApp, t)
)}
</td>
<td className="px-4 py-3 text-right space-x-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(extension)}
disabled={editingExtension === extension}
title={t('common.edit')}
>
<Pencil size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemove(extension)}
title={t('settings.sftpFileAssociations.remove')}
>
<Trash2 size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(extension)}
disabled={editingExtension === extension}
>
<Pencil size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemove(extension)}
>
<Trash2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('settings.sftpFileAssociations.remove')}</TooltipContent>
</Tooltip>
</td>
</tr>
))}

View File

@@ -230,7 +230,7 @@ export default function SettingsShortcutsTab(props: {
<button
onClick={() => updateKeyBinding?.(binding.id, scheme, "Disabled")}
className="p-1 hover:bg-muted rounded"
title={t("settings.shortcuts.setDisabled")}
aria-label={t("settings.shortcuts.setDisabled")}
>
<Ban size={12} />
</button>
@@ -238,7 +238,7 @@ export default function SettingsShortcutsTab(props: {
<button
onClick={() => resetKeyBinding?.(binding.id, scheme)}
className="p-1 hover:bg-muted rounded"
title="Reset to default"
aria-label={t("settings.shortcuts.resetToDefault")}
>
<RotateCcw size={12} />
</button>

View File

@@ -10,6 +10,7 @@ import type { UpdateState } from '../../../application/state/useUpdateCheck';
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { Toggle, Select, SettingRow } from "../settings-ui";
import { cn } from "../../../lib/utils";
@@ -637,9 +638,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
const text = parts.join(' ');
return text ? (
<div className="text-muted-foreground truncate" title={text}>
{text}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-muted-foreground truncate cursor-default">
{text}
</div>
</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
) : null;
})()}
{entry.stack && (
@@ -678,14 +684,18 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
<Trash2 size={14} />
{t("settings.system.crashLogs.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleOpenCrashLogsDir}
title={t("settings.system.openFolder")}
>
<FolderOpen size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleOpenCrashLogsDir}
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
</Tooltip>
</div>
{crashLogClearResult && (
@@ -716,16 +726,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{isLoading ? "..." : (tempDirInfo?.path ?? "-")}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={handleOpenTempDir}
disabled={!tempDirInfo?.path}
title={t("settings.system.openFolder")}
>
<FolderOpen size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={handleOpenTempDir}
disabled={!tempDirInfo?.path}
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
</Tooltip>
</div>
{/* Stats */}
@@ -823,15 +837,19 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{t("settings.sessionLogs.browse")}
</Button>
{sessionLogsDir && (
<Button
variant="ghost"
size="icon"
onClick={handleOpenSessionLogsDir}
className="shrink-0"
title={t("settings.sessionLogs.openFolder")}
>
<FolderOpen size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleOpenSessionLogsDir}
className="shrink-0"
>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.sessionLogs.openFolder")}</TooltipContent>
</Tooltip>
)}
</div>
<p className="text-xs text-muted-foreground">
@@ -902,13 +920,17 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
</button>
{toggleWindowHotkey && (
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
title={t("settings.globalHotkey.reset")}
>
<RotateCcw size={14} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
>
<RotateCcw size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("settings.globalHotkey.reset")}</TooltipContent>
</Tooltip>
)}
</div>
</SettingRow>

View File

@@ -19,6 +19,8 @@ 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 { Textarea } from "../../ui/textarea";
import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { ThemeSelectModal } from "../ThemeSelectModal";
import { TerminalFontSelect } from "../TerminalFontSelect";
@@ -33,21 +35,25 @@ const AddCustomRuleDialog: React.FC<{
open: boolean;
onOpenChange: (open: boolean) => void;
editRule?: KeywordHighlightRule | null;
isBuiltIn?: boolean;
onAdd: (rule: KeywordHighlightRule) => void;
}> = ({ open, onOpenChange, editRule, onAdd }) => {
}> = ({ open, onOpenChange, editRule, isBuiltIn = false, onAdd }) => {
const { t } = useI18n();
const [label, setLabel] = useState('');
const [pattern, setPattern] = useState('');
// Multi-line text: one regex pattern per line. Built-in rules typically
// ship multiple patterns (e.g. several spellings of "error"), and the user
// is allowed to add as many as they like.
const [patternsText, setPatternsText] = 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); };
const reset = () => { setLabel(''); setPatternsText(''); setColor(DEFAULT_NEW_RULE_COLOR); setPatternError(null); };
// Populate form when editing
useEffect(() => {
if (open && editRule) {
setLabel(editRule.label);
setPattern(editRule.patterns[0] || '');
setPatternsText(editRule.patterns.join('\n'));
setColor(editRule.color);
setPatternError(null);
} else if (!open) {
@@ -56,25 +62,43 @@ const AddCustomRuleDialog: React.FC<{
}, [open, editRule]);
const handleSubmit = () => {
if (!label.trim() || !pattern.trim()) return;
try { new RegExp(pattern, 'gi'); } catch {
setPatternError(t('settings.terminal.keywordHighlight.invalidPattern'));
return;
if (!label.trim()) return;
const patterns = patternsText
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (patterns.length === 0) return;
for (const p of patterns) {
try { new RegExp(p, '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 });
onAdd({
id: editRule?.id ?? crypto.randomUUID(),
label: label.trim(),
patterns,
color,
enabled: editRule?.enabled ?? true,
// Editing a built-in rule flips it into "user-customized" mode so the
// normalizer keeps the user's patterns across restarts.
customized: isBuiltIn ? true : editRule?.customized,
});
reset();
onOpenChange(false);
};
const dialogTitleKey = editRule
? (isBuiltIn
? 'settings.terminal.keywordHighlight.editBuiltIn'
: 'settings.terminal.keywordHighlight.editCustom')
: 'settings.terminal.keywordHighlight.addCustom';
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) reset(); onOpenChange(v); }}>
<DialogContent className="sm:max-w-[400px]">
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{editRule ? t('settings.terminal.keywordHighlight.editCustom') : t('settings.terminal.keywordHighlight.addCustom')}</DialogTitle>
<DialogTitle>{t(dialogTitleKey)}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
@@ -94,16 +118,19 @@ const AddCustomRuleDialog: React.FC<{
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('settings.terminal.keywordHighlight.patternField')}</Label>
<Input
<Textarea
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")}
value={patternsText}
onChange={(e) => { setPatternsText(e.target.value); if (patternError) setPatternError(null); }}
rows={Math.max(3, Math.min(10, patternsText.split('\n').length + 1))}
className={cn("font-mono text-xs", patternError && "border-destructive")}
/>
<p className="text-[11px] text-muted-foreground">
{t('settings.terminal.keywordHighlight.patternHint')}
</p>
{patternError && <div className="text-xs text-destructive">{patternError}</div>}
</div>
{label.trim() && pattern.trim() && !patternError && (
{label.trim() && patternsText.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>
@@ -112,7 +139,7 @@ const AddCustomRuleDialog: React.FC<{
</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>
<Button onClick={handleSubmit} disabled={!label.trim() || !patternsText.trim()}>{editRule ? t('common.save') : t('common.add')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -132,26 +159,43 @@ const KeywordHighlightRulesEditor: React.FC<{
return (
<div className="space-y-2.5">
{rules.map((rule) => {
const custom = !isBuiltIn(rule.id);
const builtIn = isBuiltIn(rule.id);
const customized = builtIn && rule.customized;
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))}
/>
</>
<Pencil
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
onClick={() => { setEditingRule(rule); setAddDialogOpen(true); }}
/>
{!builtIn && (
<Trash2
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-destructive"
onClick={() => onChange(rules.filter((r) => r.id !== rule.id))}
/>
)}
{customized && (
<RotateCcw
size={10}
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground cursor-pointer hover:text-foreground"
aria-label={t('settings.terminal.keywordHighlight.resetBuiltIn')}
onClick={() => {
// Drop the user's customizations and restore the shipped
// defaults for label/patterns. Color stays whatever the
// user picked (color is the only built-in property they
// can edit without flipping `customized`).
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
if (!def) return;
onChange(rules.map((r) => r.id === rule.id
? { ...def, color: r.color, enabled: r.enabled, customized: false }
: r));
}}
/>
)}
</div>
<label className="relative flex-shrink-0">
@@ -185,14 +229,18 @@ const KeywordHighlightRulesEditor: React.FC<{
size="sm"
className="flex-1 text-muted-foreground hover:text-foreground"
onClick={() => {
// Restore every built-in rule back to shipped defaults
// (label/patterns/color), drop customizations, and keep the user's
// custom rules untouched.
onChange(rules.map((rule) => {
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === rule.id);
return def ? { ...rule, color: def.color } : rule;
if (!def) return rule;
return { ...def, enabled: rule.enabled, customized: false };
}));
}}
>
<RotateCcw size={14} className="mr-1.5" />
{t("settings.terminal.keywordHighlight.resetColors")}
{t("settings.terminal.keywordHighlight.resetDefaults")}
</Button>
</div>
@@ -200,6 +248,7 @@ const KeywordHighlightRulesEditor: React.FC<{
open={addDialogOpen}
onOpenChange={(v) => { setAddDialogOpen(v); if (!v) setEditingRule(null); }}
editRule={editingRule}
isBuiltIn={editingRule ? isBuiltIn(editingRule.id) : false}
onAdd={(rule) => {
if (editingRule) {
onChange(rules.map((r) => r.id === editingRule.id ? rule : r));
@@ -960,35 +1009,41 @@ export default function SettingsTerminalTab(props: {
description={t("settings.terminal.localShell.shell.desc")}
>
<div className="flex flex-col gap-1 items-end">
<select
className="h-9 w-48 rounded-md border border-input bg-background px-3 text-sm"
<ShadcnSelect
value={
showCustomShellInput
? "__custom__"
: terminalSettings.localShell || ""
: (terminalSettings.localShell || "__default__")
}
onChange={(e) => {
const value = e.target.value;
onValueChange={(value) => {
if (value === "__custom__") {
setCustomShellDraft(terminalSettings.localShell || "");
setCustomShellModalOpen(true);
} else if (value === "__default__") {
setShowCustomShellInput(false);
updateTerminalSetting("localShell", "");
} 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>
<SelectTrigger className="h-9 w-48 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t("settings.terminal.localShell.shell.default")}
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
</SelectItem>
{discoveredShells.map((shell) => (
<SelectItem key={shell.id} value={shell.id}>
{shell.name}
</SelectItem>
))}
<SelectItem value="__custom__">{t("settings.terminal.localShell.shell.custom")}</SelectItem>
</SelectContent>
</ShadcnSelect>
{showCustomShellInput && (
<span className="text-xs text-muted-foreground truncate max-w-48">
{terminalSettings.localShell}

View File

@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw } from "lucide-react";
import type { AIProviderId } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { cn } from "../../../../lib/utils";
import type { FetchedModel } from "./types";
import { getFetchBridge } from "./types";
@@ -120,16 +121,20 @@ export const ModelSelector: React.FC<{
)}
</div>
{canFetch && (
<Button
variant="outline"
size="sm"
onClick={() => { setHasFetched(false); void fetchModels(); }}
disabled={isLoading}
className="shrink-0 px-2"
title={t('ai.providers.refreshModels')}
>
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => { setHasFetched(false); void fetchModels(); }}
disabled={isLoading}
className="shrink-0 px-2"
>
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.refreshModels')}</TooltipContent>
</Tooltip>
)}
</div>

View File

@@ -3,6 +3,7 @@ import { Pencil, Trash2 } from "lucide-react";
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Toggle } from "../../settings-ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { cn } from "../../../../lib/utils";
import { ProviderIconBadge } from "./ProviderIconBadge";
import { ProviderConfigForm } from "./ProviderConfigForm";
@@ -61,20 +62,28 @@ export const ProviderCard: React.FC<{
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onEdit}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t('ai.providers.configure')}
>
<Pencil size={14} />
</button>
<button
onClick={onRemove}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title={t('ai.providers.remove')}
>
<Trash2 size={14} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onEdit}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Pencil size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.configure')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onRemove}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.remove')}</TooltipContent>
</Tooltip>
<Toggle checked={provider.enabled} onChange={onToggleEnabled} />
</div>
</div>

View File

@@ -2,9 +2,11 @@
* SFTP Breadcrumb navigation component
*/
import { ChevronRight, Home, MoreHorizontal } from 'lucide-react';
import React, { memo, useMemo } from 'react';
import { ChevronDown, ChevronRight, Home, MoreHorizontal } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
interface SftpBreadcrumbProps {
@@ -13,16 +15,31 @@ interface SftpBreadcrumbProps {
onHome: () => void;
/** Maximum number of visible path segments before truncation (default: 4) */
maxVisibleParts?: number;
isLocal?: boolean;
onListDrives?: () => Promise<string[]>;
}
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
path,
onNavigate,
const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
path,
onNavigate,
onHome,
maxVisibleParts = 4
maxVisibleParts = 4,
isLocal,
onListDrives,
}) => {
const { t } = useI18n();
const [drives, setDrives] = useState<string[]>([]);
const [driveDropdownOpen, setDriveDropdownOpen] = useState(false);
const handleDriveDropdownOpen = useCallback(async (open: boolean) => {
setDriveDropdownOpen(open);
if (open && onListDrives) {
const result = await onListDrives();
setDrives(result);
}
}, [onListDrives]);
// Handle both Windows (C:\path) and Unix (/path) style paths
const isWindowsPath = /^[A-Za-z]:/.test(path);
const separator = isWindowsPath ? /[\\/]/ : /\//;
@@ -70,52 +87,93 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
};
}, [parts, maxVisibleParts]);
const showDriveDropdown = isWindowsPath && isLocal && !!onListDrives;
return (
<div
className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden"
title={path}
>
<button
onClick={onHome}
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
title={t("sftp.goHome")}
>
<Home size={12} />
</button>
<ChevronRight size={12} className="opacity-40 shrink-0" />
{visibleParts.map(({ part, originalIndex }, displayIdx) => {
const partPath = buildPath(originalIndex);
const isLast = originalIndex === parts.length - 1;
const showEllipsisBefore = needsTruncation && displayIdx === 1;
return (
<React.Fragment key={partPath}>
{showEllipsisBefore && (
<>
<span
className="px-1 py-0.5 shrink-0 flex items-center text-muted-foreground cursor-default"
title={`${t("sftp.showHiddenPaths")}: ${hiddenParts.map(h => h.part).join(' > ')}`}
>
<MoreHorizontal size={14} />
</span>
<ChevronRight size={12} className="opacity-40 shrink-0" />
</>
)}
<button
onClick={() => onNavigate(partPath)}
className={cn(
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px] shrink-0",
isLast && "text-foreground font-medium"
)}
title={part}
>
{part}
</button>
{!isLast && <ChevronRight size={12} className="opacity-40 shrink-0" />}
</React.Fragment>
);
})}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden cursor-default">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onHome}
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
>
<Home size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("sftp.goHome")}</TooltipContent>
</Tooltip>
<ChevronRight size={12} className="opacity-40 shrink-0" />
{visibleParts.map(({ part, originalIndex }, displayIdx) => {
const partPath = buildPath(originalIndex);
const isLast = originalIndex === parts.length - 1;
const showEllipsisBefore = needsTruncation && displayIdx === 1;
return (
<React.Fragment key={partPath}>
{showEllipsisBefore && (
<>
<Tooltip>
<TooltipTrigger asChild>
<span className="px-1 py-0.5 shrink-0 flex items-center text-muted-foreground cursor-default">
<MoreHorizontal size={14} />
</span>
</TooltipTrigger>
<TooltipContent>
{`${t("sftp.showHiddenPaths")}: ${hiddenParts.map(h => h.part).join(' > ')}`}
</TooltipContent>
</Tooltip>
<ChevronRight size={12} className="opacity-40 shrink-0" />
</>
)}
{originalIndex === 0 && showDriveDropdown ? (
<Dropdown open={driveDropdownOpen} onOpenChange={handleDriveDropdownOpen}>
<DropdownTrigger asChild>
<button className="hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 shrink-0 flex items-center gap-0.5">
{part}
<ChevronDown size={10} className="opacity-60" />
</button>
</DropdownTrigger>
<DropdownContent align="start" className="w-16 p-1">
{drives.map(drive => (
<button
key={drive}
onClick={() => { onNavigate(drive + '\\'); setDriveDropdownOpen(false); }}
className={cn(
"w-full text-left px-2 py-1 text-xs rounded hover:bg-secondary/60",
drive === part && "bg-secondary font-medium"
)}
>
{drive}
</button>
))}
</DropdownContent>
</Dropdown>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onNavigate(partPath)}
className={cn(
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px] shrink-0",
isLast && "text-foreground font-medium"
)}
>
{part}
</button>
</TooltipTrigger>
<TooltipContent>{part}</TooltipContent>
</Tooltip>
)}
{!isLast && <ChevronRight size={12} className="opacity-40 shrink-0" />}
</React.Fragment>
);
})}
</div>
</TooltipTrigger>
<TooltipContent>{path}</TooltipContent>
</Tooltip>
);
};

View File

@@ -60,6 +60,7 @@ export interface SftpPaneCallbacks {
// External folder upload from native directory picker.
onUploadExternalFolder?: (targetPath?: string) => Promise<void>;
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
onListDrives: () => Promise<string[]>;
}
export interface SftpDragCallbacks {

View File

@@ -4,6 +4,7 @@
import { Folder, Link } from 'lucide-react';
import React, { memo, useCallback } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
import { SftpFileEntry } from '../../types';
import { buildSftpColumnTemplate, ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
@@ -106,17 +107,21 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
/>
)}
</div>
<span
className={cn(
"truncate",
entry.type === 'symlink' && "italic pr-1",
isSelectionVisible && "font-medium",
)}
title={entry.name}
>
{entry.name}
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"truncate cursor-default",
entry.type === 'symlink' && "italic pr-1",
isSelectionVisible && "font-medium",
)}
>
{entry.name}
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>
</TooltipTrigger>
<TooltipContent>{entry.name}</TooltipContent>
</Tooltip>
</div>
<span className={cn("text-xs truncate", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>{modifiedLabel}</span>
<span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>

View File

@@ -55,6 +55,7 @@ interface SftpPaneToolbarProps {
onGoToTerminalCwd?: () => void;
viewMode: 'list' | 'tree';
onSetViewMode: (mode: 'list' | 'tree') => void;
onListDrives?: () => Promise<string[]>;
}
// Prioritize breadcrumb path display. 6 action buttons need ~156px,
@@ -105,6 +106,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
onGoToTerminalCwd,
viewMode,
onSetViewMode,
onListDrives,
}) => {
const outerRef = useRef<HTMLDivElement>(null);
const [collapsed, setCollapsed] = useState(false);
@@ -475,20 +477,26 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
)}
</div>
) : (
<div
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={displayPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
>
<SftpBreadcrumb
path={displayPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
isLocal={!isRemote}
onListDrives={onListDrives}
/>
</div>
</TooltipTrigger>
<TooltipContent>{t("sftp.path.doubleClickToEdit")}</TooltipContent>
</Tooltip>
)}
{/* Bookmark button with dropdown */}
@@ -551,15 +559,19 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
{bm.global && (
<Globe size={10} className="shrink-0 text-primary" />
)}
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
title={bm.path}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
</TooltipTrigger>
<TooltipContent>{bm.path}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"

View File

@@ -512,6 +512,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onGoToTerminalCwd={onGoToTerminalCwd}
viewMode={viewMode}
onSetViewMode={handleSetViewMode}
onListDrives={callbacks.onListDrives}
/>
{treeEverMounted && (

View File

@@ -21,6 +21,7 @@ import React, {
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { useRenderTracker } from "../../lib/useRenderTracker";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { cn } from "../../lib/utils";
import { useActiveTabId } from "./SftpContext";
@@ -395,13 +396,17 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
</div>
{/* Add tab button */}
<button
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
onClick={handleAddTabClick}
title={t("sftp.tabs.addTab")}
>
<Plus size={14} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
onClick={handleAddTabClick}
>
<Plus size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("sftp.tabs.addTab")}</TooltipContent>
</Tooltip>
</div>
);
};

View File

@@ -9,6 +9,7 @@ import {
} from "../../infrastructure/config/storageKeys";
import type { TransferTask } from "../../types";
import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { SftpTransferItem } from "./SftpTransferItem";
type SftpState = ReturnType<typeof useSftpState>;
@@ -344,13 +345,17 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
className="border-t border-border/70 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0"
style={{ height: clampPanelHeight(panelHeight) }}
>
<div
className="group flex h-3 cursor-row-resize items-center justify-center border-b border-border/30 text-muted-foreground/70"
onMouseDown={handleResizeStart}
title={t("sftp.transfers.dragToResize")}
>
<GripHorizontal size={14} className="transition-colors group-hover:text-foreground/80" />
</div>
<Tooltip>
<TooltipTrigger asChild>
<div
className="group flex h-3 cursor-row-resize items-center justify-center border-b border-border/30 text-muted-foreground/70"
onMouseDown={handleResizeStart}
>
<GripHorizontal size={14} className="transition-colors group-hover:text-foreground/80" />
</div>
</TooltipTrigger>
<TooltipContent>{t("sftp.transfers.dragToResize")}</TooltipContent>
</Tooltip>
<div className="flex items-center justify-between border-b border-border/40 px-3 py-1.5 text-[11px] text-muted-foreground">
<span className="font-medium">

View File

@@ -48,6 +48,7 @@ interface UseSftpViewPaneCallbacksParams {
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<void>;
deleteLocalFile?: (path: string) => Promise<void>;
listDrives: () => Promise<string[]>;
}
export const useSftpViewPaneCallbacks = ({
@@ -63,6 +64,7 @@ export const useSftpViewPaneCallbacks = ({
startStreamTransfer,
getSftpIdForConnection,
listLocalFiles,
listDrives,
}: UseSftpViewPaneCallbacksParams) => {
const paneActions = useSftpViewPaneActions({ sftpRef });
const fileOps = useSftpViewFileOps({
@@ -174,6 +176,7 @@ export const useSftpViewPaneCallbacks = ({
onUploadExternalFileList: fileOps.onUploadExternalFileListLeft,
onUploadExternalFolder: fileOps.onUploadExternalFolderLeft,
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
onListDrives: listDrives,
}),
[],
);
@@ -214,6 +217,7 @@ export const useSftpViewPaneCallbacks = ({
onUploadExternalFileList: fileOps.onUploadExternalFileListRight,
onUploadExternalFolder: fileOps.onUploadExternalFolderRight,
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
onListDrives: listDrives,
}),
[],
);

View File

@@ -11,6 +11,7 @@ import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { ScrollArea } from '../ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export interface HostKeywordHighlightPopoverProps {
host?: Host;
@@ -120,18 +121,22 @@ export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverPr
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonClassName}
title={t('terminal.toolbar.hostHighlight.title')}
aria-label={t('terminal.toolbar.hostHighlight.title')}
disabled={isDisabled}
>
<Highlighter size={12} />
</Button>
</PopoverTrigger>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonClassName}
aria-label={t('terminal.toolbar.hostHighlight.title')}
disabled={isDisabled}
>
<Highlighter size={12} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t('terminal.toolbar.hostHighlight.title')}</TooltipContent>
</Tooltip>
<PopoverContent className="w-80 p-0" align="start" side="top">
<div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">
@@ -175,18 +180,22 @@ export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverPr
key={rule.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent/50 group"
>
<button
type="button"
onClick={() => handleToggleRule(rule.id)}
className={`
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
${rule.enabled
? 'bg-primary border-primary'
: 'bg-transparent border-muted-foreground/50'
}
`}
title={rule.enabled ? t('common.enabled') : t('common.disabled')}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => handleToggleRule(rule.id)}
className={`
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
${rule.enabled
? 'bg-primary border-primary'
: 'bg-transparent border-muted-foreground/50'
}
`}
/>
</TooltipTrigger>
<TooltipContent>{rule.enabled ? t('common.enabled') : t('common.disabled')}</TooltipContent>
</Tooltip>
<div className="flex-1 min-w-0">
<div
className="text-xs font-medium truncate"

View File

@@ -8,6 +8,7 @@
import { Radio, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils';
export interface TerminalComposeBarProps {
@@ -83,12 +84,14 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
<div className="flex items-center gap-2">
{/* Broadcast indicator */}
{isBroadcastEnabled && (
<div
className="flex items-center"
title={t("terminal.composeBar.broadcasting")}
>
<Radio size={14} className="text-amber-400 animate-pulse" />
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center cursor-default">
<Radio size={14} className="text-amber-400 animate-pulse" />
</div>
</TooltipTrigger>
<TooltipContent>{t("terminal.composeBar.broadcasting")}</TooltipContent>
</Tooltip>
)}
{/* Borderless input — lives flush on the terminal bg so the
@@ -114,25 +117,29 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
/>
{/* Minimal close button — no filled bg, hover only. */}
<button
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
style={{
color: `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`,
background: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`;
e.currentTarget.style.color = resolvedFg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`;
}}
onClick={onClose}
title={t("terminal.composeBar.close")}
>
<X size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
style={{
color: `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`,
background: 'transparent',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`;
e.currentTarget.style.color = resolvedFg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`;
}}
onClick={onClose}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("terminal.composeBar.close")}</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

@@ -10,6 +10,7 @@ import { Host, SSHKey } from '../../types';
import { formatHostPort, resolveTelnetPort } from '../../domain/host';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
import { TerminalConnectionProgress, TerminalConnectionProgressProps } from './TerminalConnectionProgress';
import { HostKeyInfo, TerminalHostKeyVerification } from './TerminalHostKeyVerification';
@@ -203,16 +204,20 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</Button>
)}
{canDismissDisconnected && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
title={t('terminal.connection.dismissDisconnectedDialog')}
onClick={onDismissDisconnected}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
onClick={onDismissDisconnected}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.connection.dismissDisconnectedDialog')}</TooltipContent>
</Tooltip>
)}
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { ChevronUp, ChevronDown, Search } from 'lucide-react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export interface TerminalSearchBarProps {
isOpen: boolean;
@@ -115,48 +116,56 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
{/* Navigation buttons */}
<div className="flex items-center gap-0.5 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindPrevious();
}}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
disabled={!searchTerm}
title={t("terminal.search.prevMatch")}
tabIndex={-1}
>
<ChevronUp size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindNext();
}}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
disabled={!searchTerm}
title={t("terminal.search.nextMatch")}
tabIndex={-1}
>
<ChevronDown size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindPrevious();
}}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
disabled={!searchTerm}
tabIndex={-1}
>
<ChevronUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.search.prevMatch")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFindNext();
}}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
disabled={!searchTerm}
tabIndex={-1}
>
<ChevronDown size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.search.nextMatch")}</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

@@ -15,6 +15,7 @@ import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
import { CustomThemeModal } from './CustomThemeModal';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { cn } from '../../lib/utils';
import { TerminalTheme } from '../../domain/models';
import { ScrollArea } from '../ui/scroll-area';
@@ -581,26 +582,32 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
)}
</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)',
}}
<Select
value={String(currentFontWeight)}
onValueChange={(v) => onFontWeightChange(Number(v))}
>
<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>
<SelectTrigger
className="flex-1 h-7 text-xs"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="100">100 Thin</SelectItem>
<SelectItem value="200">200 ExtraLight</SelectItem>
<SelectItem value="300">300 Light</SelectItem>
<SelectItem value="400">400 Normal</SelectItem>
<SelectItem value="500">500 Medium</SelectItem>
<SelectItem value="600">600 SemiBold</SelectItem>
<SelectItem value="700">700 Bold</SelectItem>
<SelectItem value="800">800 ExtraBold</SelectItem>
<SelectItem value="900">900 Black</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}

View File

@@ -1,5 +1,7 @@
import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface ZmodemProgressIndicatorProps {
transferType: 'upload' | 'download' | null;
@@ -30,9 +32,14 @@ export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = (
finalizing,
onCancel,
}) => {
const { t } = useI18n();
const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0;
const Icon = transferType === 'upload' ? ArrowUpFromLine : ArrowDownToLine;
const label = finalizing ? 'Waiting for remote...' : transferType === 'upload' ? 'Uploading' : 'Downloading';
const label = finalizing
? t('zmodem.waitingForRemote')
: transferType === 'upload'
? t('zmodem.uploading')
: t('zmodem.downloading');
const fileInfo = fileCount > 0 ? ` (${fileIndex + 1}/${fileCount})` : '';
return (
@@ -67,13 +74,17 @@ export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = (
{formatBytes(transferred)} / {formatBytes(total)}
</div>
</div>
<button
onClick={onCancel}
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
title="Cancel transfer (Ctrl+C)"
>
<X className="h-3.5 w-3.5 opacity-60" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onCancel}
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
>
<X className="h-3.5 w-3.5 opacity-60" />
</button>
</TooltipTrigger>
<TooltipContent>{t('zmodem.cancelTransfer')}</TooltipContent>
</Tooltip>
</div>
);
};

View File

@@ -2,26 +2,18 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import { useCallback } from "react";
import type { RefObject } from "react";
import { logger } from "../../../lib/logger";
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { pasteTextIntoTerminal } from "../runtime/terminalUserPaste";
import { clearTerminalViewport } from "../clearTerminalViewport";
type TerminalBackendWriteApi = {
writeToSession: (sessionId: string, data: string) => void;
};
export const useTerminalContextActions = ({
termRef,
sessionRef,
terminalBackend,
onHasSelectionChange,
disableBracketedPasteRef,
scrollOnPasteRef,
}: {
termRef: RefObject<XTerm | null>;
sessionRef: RefObject<string | null>;
terminalBackend: TerminalBackendWriteApi;
onHasSelectionChange?: (hasSelection: boolean) => void;
disableBracketedPasteRef?: RefObject<boolean>;
scrollOnPasteRef?: RefObject<boolean>;
}) => {
const onCopy = useCallback(() => {
@@ -39,40 +31,24 @@ export const useTerminalContextActions = ({
try {
const text = await navigator.clipboard.readText();
if (text && sessionRef.current) {
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
terminalBackend.writeToSession(sessionRef.current, data);
if (scrollOnPasteRef?.current) {
term.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
term.scrollToBottom();
});
}
}
pasteTextIntoTerminal(term, text, {
scrollOnPaste: scrollOnPasteRef?.current ?? false,
});
}
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
}, [sessionRef, termRef, scrollOnPasteRef]);
const onPasteSelection = useCallback(() => {
const term = termRef.current;
if (!term) return;
const selection = term.getSelection();
if (!selection || !sessionRef.current) return;
let data = normalizeLineEndings(selection);
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
terminalBackend.writeToSession(sessionRef.current, data);
if (scrollOnPasteRef?.current) {
term.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
term.scrollToBottom();
});
}
}
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
pasteTextIntoTerminal(term, selection, {
scrollOnPaste: scrollOnPasteRef?.current ?? false,
});
}, [sessionRef, termRef, scrollOnPasteRef]);
const onSelectAll = useCallback(() => {
const term = termRef.current;

View File

@@ -17,6 +17,10 @@ import {
resolveTelnetPort,
resolveTelnetUsername,
} from "../../../domain/host";
import {
clearPasteResidualAfterTerminalWrite,
prepareTerminalDataForUserPasteDisplay,
} from "./terminalUserPaste";
/**
* Per-connection token for stale-timer detection. The renderer reuses the
@@ -206,13 +210,17 @@ const writeSessionData = (
term: XTerm,
data: string,
) => {
const displayData = prepareTerminalDataForUserPasteDisplay(term, data);
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
if (!shouldScrollOnTerminalOutput(settings)) {
term.write(data);
term.write(displayData, () => {
clearPasteResidualAfterTerminalWrite(term);
});
return;
}
term.write(data, () => {
term.write(displayData, () => {
clearPasteResidualAfterTerminalWrite(term);
handleTerminalOutputAutoScroll(ctx, term);
});
};

View File

@@ -43,6 +43,11 @@ import {
} from "./kittyKeyboardProtocol";
import { installKittyKeyboardProtocolHandlers } from "./kittyKeyboardRuntime";
import { installUserCursorPreferenceGuard } from "./cursorPreference";
import { handleSerialLineModeInput } from "./serialLineInput";
import {
pasteTextIntoTerminal,
shouldSuppressTerminalInputScrollForUserPaste,
} from "./terminalUserPaste";
import type {
Host,
KeyBinding,
@@ -405,19 +410,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const appLevelActions = getAppLevelActions();
const terminalActions = getTerminalPassthroughActions();
const scrollViewportToBottom = () => {
term.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
term.scrollToBottom();
});
}
};
const scrollToBottomAfterPaste = () => {
if (shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current)) {
scrollViewportToBottom();
}
};
const scrollToBottomAfterInput = (data: string) => {
if (shouldScrollOnTerminalInput(ctx.terminalSettingsRef.current, data)) {
term.scrollToBottom();
@@ -542,15 +534,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
navigator.clipboard.readText().then((text) => {
const id = ctx.sessionRef.current;
if (id) {
const rawData = normalizeLineEndings(text);
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
? wrapBracketedPaste(rawData)
: rawData;
// Notify autocomplete with the final bytes so bracketed
// pastes preserve their inner newlines as literal input.
ctx.onAutocompleteInput?.(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
pasteTextIntoTerminal(term, text, {
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
});
}
});
break;
@@ -559,13 +545,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const selection = term.getSelection();
const id = ctx.sessionRef.current;
if (selection && id) {
const rawData = normalizeLineEndings(selection);
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
? wrapBracketedPaste(rawData)
: rawData;
ctx.onAutocompleteInput?.(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
pasteTextIntoTerminal(term, selection, {
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
});
}
break;
}
@@ -615,13 +597,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
try {
const text = await navigator.clipboard.readText();
if (text && ctx.sessionRef.current) {
const rawData = normalizeLineEndings(text);
const data = term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste
? wrapBracketedPaste(rawData)
: rawData;
ctx.onAutocompleteInput?.(data);
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
scrollToBottomAfterPaste();
pasteTextIntoTerminal(term, text, {
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
});
}
} catch (err) {
logger.warn("[Terminal] Failed to paste from clipboard:", err);
@@ -641,45 +619,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (id) {
// Serial line mode: buffer input and send on Enter
if (ctx.host.protocol === "serial" && ctx.serialLineMode && ctx.serialLineBufferRef) {
if (data === "\r") {
// Enter key: send buffered line + CR
const line = ctx.serialLineBufferRef.current + "\r";
ctx.terminalBackend.writeToSession(id, line);
ctx.serialLineBufferRef.current = "";
// Local echo newline if enabled
if (ctx.serialLocalEcho) {
term.write("\r\n");
}
} else if (data === "\x7f" || data === "\b") {
// Backspace: remove last character from buffer
if (ctx.serialLineBufferRef.current.length > 0) {
ctx.serialLineBufferRef.current = ctx.serialLineBufferRef.current.slice(0, -1);
if (ctx.serialLocalEcho) {
term.write("\b \b");
}
}
} else if (data === "\x03") {
// Ctrl+C: clear buffer and send Ctrl+C
ctx.serialLineBufferRef.current = "";
ctx.terminalBackend.writeToSession(id, data);
if (ctx.serialLocalEcho) {
term.write("^C\r\n");
}
} else if (data === "\x15") {
// Ctrl+U: clear line buffer
if (ctx.serialLocalEcho && ctx.serialLineBufferRef.current.length > 0) {
// Erase the displayed line
const len = ctx.serialLineBufferRef.current.length;
term.write("\b \b".repeat(len));
}
ctx.serialLineBufferRef.current = "";
} else if (data.charCodeAt(0) >= 32 || data.length > 1) {
// Regular characters: add to buffer
ctx.serialLineBufferRef.current += data;
if (ctx.serialLocalEcho) {
term.write(data);
}
}
handleSerialLineModeInput(data, {
bufferRef: ctx.serialLineBufferRef,
localEcho: ctx.serialLocalEcho,
writeToSession: (nextData) => ctx.terminalBackend.writeToSession(id, nextData),
writeToTerminal: (nextData) => term.write(nextData),
});
} else {
// Character mode (default): send immediately
// When backspaceBehavior is configured, remap the Backspace key output
@@ -709,7 +654,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ctx.onBroadcastInputRef.current(broadcastData, ctx.sessionId);
}
scrollToBottomAfterInput(data);
if (!shouldSuppressTerminalInputScrollForUserPaste(term, data)) {
scrollToBottomAfterInput(data);
}
// Notify autocomplete of input
ctx.onAutocompleteInput?.(data);

View File

@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { handleSerialLineModeInput } from "./serialLineInput";
test("serial line mode sends completed lines from a multi-line paste chunk", () => {
const writes: string[] = [];
const echoes: string[] = [];
const bufferRef = { current: "" };
handleSerialLineModeInput("show version\rshow clock", {
bufferRef,
writeToSession: (data) => writes.push(data),
writeToTerminal: (data) => echoes.push(data),
});
assert.deepEqual(writes, ["show version\r"]);
assert.equal(bufferRef.current, "show clock");
assert.deepEqual(echoes, []);
});
test("serial line mode sends every completed line when pasted text ends with enter", () => {
const writes: string[] = [];
const echoes: string[] = [];
const bufferRef = { current: "" };
handleSerialLineModeInput("show version\rshow clock\r", {
bufferRef,
localEcho: true,
writeToSession: (data) => writes.push(data),
writeToTerminal: (data) => echoes.push(data),
});
assert.deepEqual(writes, ["show version\r", "show clock\r"]);
assert.equal(bufferRef.current, "");
assert.deepEqual(echoes, ["show version", "\r\n", "show clock", "\r\n"]);
});

View File

@@ -0,0 +1,86 @@
type StringRef = {
current: string;
};
type SerialLineModeInputOptions = {
bufferRef: StringRef;
localEcho?: boolean;
writeToSession: (data: string) => void;
writeToTerminal: (data: string) => void;
};
const submitLine = ({
bufferRef,
localEcho,
writeToSession,
writeToTerminal,
}: SerialLineModeInputOptions) => {
const line = `${bufferRef.current}\r`;
writeToSession(line);
bufferRef.current = "";
if (localEcho) writeToTerminal("\r\n");
};
const appendText = (
text: string,
{ bufferRef, localEcho, writeToTerminal }: SerialLineModeInputOptions,
) => {
if (!text) return;
bufferRef.current += text;
if (localEcho) writeToTerminal(text);
};
const clearLine = ({
bufferRef,
localEcho,
writeToTerminal,
}: SerialLineModeInputOptions) => {
if (localEcho && bufferRef.current.length > 0) {
writeToTerminal("\b \b".repeat(bufferRef.current.length));
}
bufferRef.current = "";
};
export function handleSerialLineModeInput(
data: string,
options: SerialLineModeInputOptions,
): void {
if (data === "\r" || data === "\n") {
submitLine(options);
return;
}
if (data === "\x7f" || data === "\b") {
if (options.bufferRef.current.length > 0) {
options.bufferRef.current = options.bufferRef.current.slice(0, -1);
if (options.localEcho) options.writeToTerminal("\b \b");
}
return;
}
if (data === "\x03") {
options.bufferRef.current = "";
options.writeToSession(data);
if (options.localEcho) options.writeToTerminal("^C\r\n");
return;
}
if (data === "\x15") {
clearLine(options);
return;
}
const normalizedData = data.replace(/\r\n/g, "\r").replace(/\n/g, "\r");
if (normalizedData.includes("\r")) {
const parts = normalizedData.split("\r");
parts.forEach((part, index) => {
appendText(part, options);
if (index < parts.length - 1) submitLine(options);
});
return;
}
if (data.charCodeAt(0) >= 32 || data.length > 1) {
appendText(data, options);
}
}

View File

@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearPasteResidualAfterTerminalWrite,
pasteTextIntoTerminal,
prepareTerminalDataForUserPasteDisplay,
shouldSuppressTerminalInputScrollForUserPaste,
} from "./terminalUserPaste";
test("user paste delegates raw clipboard text to xterm paste handling", () => {
const pasted: string[] = [];
const term = {
paste: (text: string) => pasted.push(text),
scrollToBottom: () => {
throw new Error("scrollToBottom should not run when scrollOnPaste is false");
},
};
const text = "line one\r\nline two\nline three";
pasteTextIntoTerminal(term, text, { scrollOnPaste: false });
assert.deepEqual(pasted, [text]);
});
test("user paste preserves the existing scroll-on-paste behavior", () => {
const calls: string[] = [];
const term = {
paste: () => calls.push("paste"),
scrollToBottom: () => calls.push("scroll"),
};
pasteTextIntoTerminal(term, "echo ok", {
scrollOnPaste: true,
requestAnimationFrame: (callback) => {
calls.push("raf");
callback();
},
});
assert.deepEqual(calls, ["paste", "scroll", "raf", "scroll"]);
});
test("user paste with scroll disabled suppresses input auto-scroll for raw paste data", () => {
const term = {
paste: () => {},
scrollToBottom: () => {},
};
pasteTextIntoTerminal(term, "line one\nline two", {
scrollOnPaste: false,
});
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "line one\rline two"), true);
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "x"), false);
});
test("user paste with scroll disabled suppresses input auto-scroll for bracketed paste data", () => {
const term = {
paste: () => {},
scrollToBottom: () => {},
};
pasteTextIntoTerminal(term, "line one\nline two", {
scrollOnPaste: false,
});
assert.equal(
shouldSuppressTerminalInputScrollForUserPaste(term, "\x1b[200~line one\rline two\x1b[201~"),
true,
);
});
test("user paste with scroll enabled keeps input auto-scroll available", () => {
const term = {
paste: () => {},
scrollToBottom: () => {},
};
pasteTextIntoTerminal(term, "line one\nline two", {
scrollOnPaste: true,
requestAnimationFrame: () => {},
});
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "line one\rline two"), false);
});
test("user paste with scroll disabled suppresses split input chunks", () => {
const term = {
paste: () => {},
scrollToBottom: () => {},
};
pasteTextIntoTerminal(term, "line one\nline two", {
scrollOnPaste: false,
});
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "line one\r"), true);
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "line two"), true);
assert.equal(shouldSuppressTerminalInputScrollForUserPaste(term, "line two"), false);
});
test("long multi-line paste strips readline active-region highlighting from echo", () => {
const term = {
cols: 20,
rows: 4,
paste: () => {},
scrollToBottom: () => {},
write: () => {},
};
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
pasteTextIntoTerminal(term, longPaste, {
scrollOnPaste: false,
});
assert.equal(
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7mline 3 with enough content\x1b[27m"),
"line 3 with enough content",
);
});
test("long multi-line paste preserves unrelated reverse-video output", () => {
const term = {
cols: 20,
rows: 4,
paste: () => {},
scrollToBottom: () => {},
write: () => {},
};
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
pasteTextIntoTerminal(term, longPaste, {
scrollOnPaste: false,
});
assert.equal(
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7munrelated ncurses status\x1b[27m"),
"\x1b[7munrelated ncurses status\x1b[27m",
);
});
test("long multi-line paste strips only matched paste echo segments in mixed output", () => {
const term = {
cols: 20,
rows: 4,
paste: () => {},
scrollToBottom: () => {},
write: () => {},
};
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
pasteTextIntoTerminal(term, longPaste, {
scrollOnPaste: false,
});
assert.equal(
prepareTerminalDataForUserPasteDisplay(
term,
"mode \x1b[7mINSERT\x1b[27m \x1b[7mline 3 with enough content\x1b[27m done",
),
"mode \x1b[7mINSERT\x1b[27m line 3 with enough content done",
);
});
test("long multi-line paste strips matched paste echo when active-region spans chunks", () => {
const term = {
cols: 20,
rows: 4,
paste: () => {},
scrollToBottom: () => {},
write: () => {},
};
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
pasteTextIntoTerminal(term, longPaste, {
scrollOnPaste: false,
});
assert.equal(
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7mline 3 with enough"),
"line 3 with enough",
);
assert.equal(
prepareTerminalDataForUserPasteDisplay(term, " content\x1b[27m"),
" content",
);
});
test("long multi-line paste does not clear cursor-right residue before terminal echo", () => {
const writes: string[] = [];
const term = {
cols: 20,
rows: 4,
paste: () => {},
scrollToBottom: () => {},
write: (data: string) => writes.push(data),
};
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
pasteTextIntoTerminal(term, longPaste, {
scrollOnPaste: false,
});
clearPasteResidualAfterTerminalWrite(term);
assert.deepEqual(writes, []);
});
test("long multi-line paste clears cursor-right residue after terminal echo", () => {
const writes: string[] = [];
const term = {
cols: 20,
rows: 4,
paste: () => {},
scrollToBottom: () => {},
write: (data: string) => writes.push(data),
};
const longPaste = Array.from({ length: 20 }, (_, index) => `line ${index} with enough content`).join("\n");
pasteTextIntoTerminal(term, longPaste, {
scrollOnPaste: false,
});
prepareTerminalDataForUserPasteDisplay(term, "\x1b[7mline 3 with enough content\x1b[27m");
clearPasteResidualAfterTerminalWrite(term);
assert.deepEqual(writes, ["\x1b[K"]);
});

View File

@@ -0,0 +1,315 @@
import type { Terminal as XTerm } from "@xterm/xterm";
type PasteTarget = Pick<XTerm, "paste" | "scrollToBottom"> &
Partial<Pick<XTerm, "cols" | "rows" | "write">>;
type PasteOptions = {
scrollOnPaste?: boolean;
requestAnimationFrame?: (callback: () => void) => unknown;
};
type PasteDisplayState = {
expiresAt: number;
clearPending: number;
pasteEchoFragments: string[];
inPasteEchoActiveRegion: boolean;
};
type PasteInputScrollState = {
expiresAt: number;
remainingDataVariants: string[];
};
const pasteDisplayStates = new WeakMap<object, PasteDisplayState>();
const pasteInputScrollStates = new WeakMap<object, PasteInputScrollState>();
const LONG_PASTE_MIN_LENGTH = 200;
const PASTE_DISPLAY_FIX_WINDOW_MS = 4000;
const PASTE_INPUT_SCROLL_WINDOW_MS = 4000;
const READLINE_ACTIVE_REGION_START = "\x1b[7m";
const READLINE_ACTIVE_REGION_END = "\x1b[27m";
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";
const MIN_PASTE_ECHO_FRAGMENT_LENGTH = 6;
const ESC = "\x1b";
const BEL = "\x07";
const getNow = () => Date.now();
const isStateActive = <T extends { expiresAt: number }>(state: T | undefined): state is T =>
!!state && state.expiresAt > getNow();
const stripReadlineActiveRegion = (data: string): string =>
data
.split(READLINE_ACTIVE_REGION_START)
.join("")
.split(READLINE_ACTIVE_REGION_END)
.join("");
const isCsiFinalByte = (char: string): boolean => {
const code = char.charCodeAt(0);
return code >= 0x40 && code <= 0x7e;
};
const stripAnsiEscapeSequences = (data: string): string => {
let plainText = "";
for (let index = 0; index < data.length; index += 1) {
const char = data[index];
if (char !== ESC) {
plainText += char;
continue;
}
const nextChar = data[index + 1];
if (nextChar === "[") {
index += 2;
while (index < data.length && !isCsiFinalByte(data[index])) {
index += 1;
}
continue;
}
if (nextChar === "]") {
index += 2;
while (index < data.length) {
if (data[index] === BEL) break;
if (data[index] === ESC && data[index + 1] === "\\") {
index += 1;
break;
}
index += 1;
}
continue;
}
if (nextChar) {
index += 1;
}
}
return plainText;
};
const stripNonLineBreakControls = (data: string): string => {
let plainText = "";
for (const char of data) {
const code = char.charCodeAt(0);
if (char === "\n" || (code >= 0x20 && code !== 0x7f)) {
plainText += char;
}
}
return plainText;
};
const getPlainTerminalText = (data: string): string =>
stripNonLineBreakControls(
stripAnsiEscapeSequences(data).replace(/\r\n/g, "\n").replace(/\r/g, "\n"),
);
const getPasteEchoFragments = (text: string): string[] =>
Array.from(
new Set(
getPlainTerminalText(text)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length >= MIN_PASTE_ECHO_FRAGMENT_LENGTH),
),
);
const preparePasteTextForXterm = (text: string): string => text.replace(/\r?\n/g, "\r");
const getPasteInputDataVariants = (text: string): string[] => {
const preparedText = preparePasteTextForXterm(text);
return Array.from(
new Set([
preparedText,
`${BRACKETED_PASTE_START}${preparedText}${BRACKETED_PASTE_END}`,
]),
).filter((candidate) => candidate.length > 0);
};
const isExpectedPasteEcho = (data: string, state: PasteDisplayState): boolean => {
if (state.pasteEchoFragments.length === 0) return false;
const candidateLines = getPlainTerminalText(stripReadlineActiveRegion(data))
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length >= MIN_PASTE_ECHO_FRAGMENT_LENGTH);
return candidateLines.some((line) =>
state.pasteEchoFragments.some((fragment) => fragment.includes(line) || line.includes(fragment)),
);
};
const stripMatchedReadlineActiveRegion = (
data: string,
state: PasteDisplayState,
): { data: string; matched: boolean } => {
let index = 0;
let matched = false;
let nextData = "";
while (index < data.length) {
if (state.inPasteEchoActiveRegion) {
const endIndex = data.indexOf(READLINE_ACTIVE_REGION_END, index);
if (endIndex === -1) {
nextData += data.slice(index);
matched = true;
break;
}
nextData += data.slice(index, endIndex);
index = endIndex + READLINE_ACTIVE_REGION_END.length;
state.inPasteEchoActiveRegion = false;
matched = true;
continue;
}
const startIndex = data.indexOf(READLINE_ACTIVE_REGION_START, index);
if (startIndex === -1) {
nextData += data.slice(index);
break;
}
nextData += data.slice(index, startIndex);
const contentStart = startIndex + READLINE_ACTIVE_REGION_START.length;
const endIndex = data.indexOf(READLINE_ACTIVE_REGION_END, contentStart);
if (endIndex === -1) {
const highlightedContent = data.slice(contentStart);
if (isExpectedPasteEcho(highlightedContent, state)) {
nextData += highlightedContent;
state.inPasteEchoActiveRegion = true;
matched = true;
} else {
nextData += data.slice(startIndex);
}
break;
}
const highlightedContent = data.slice(contentStart, endIndex);
if (isExpectedPasteEcho(highlightedContent, state)) {
nextData += highlightedContent;
matched = true;
} else {
nextData += data.slice(startIndex, endIndex + READLINE_ACTIVE_REGION_END.length);
}
index = endIndex + READLINE_ACTIVE_REGION_END.length;
}
return { data: nextData, matched };
};
const estimateRows = (text: string, cols: number): number => {
const width = Math.max(1, cols);
return text
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / width)), 0);
};
const shouldApplyPasteDisplayFix = (term: PasteTarget, text: string): boolean => {
if (text.length < LONG_PASTE_MIN_LENGTH) return false;
const lineCount = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").length;
const rows = typeof term.rows === "number" && term.rows > 0 ? term.rows : 24;
const cols = typeof term.cols === "number" && term.cols > 0 ? term.cols : 80;
return lineCount >= rows - 1 || estimateRows(text, cols) >= rows - 1;
};
export function pasteTextIntoTerminal(
term: PasteTarget,
text: string,
options: PasteOptions = {},
): void {
if (!text) return;
if (shouldApplyPasteDisplayFix(term, text)) {
pasteDisplayStates.set(term, {
expiresAt: getNow() + PASTE_DISPLAY_FIX_WINDOW_MS,
clearPending: 0,
pasteEchoFragments: getPasteEchoFragments(text),
inPasteEchoActiveRegion: false,
});
}
if (options.scrollOnPaste === false) {
pasteInputScrollStates.set(term, {
expiresAt: getNow() + PASTE_INPUT_SCROLL_WINDOW_MS,
remainingDataVariants: getPasteInputDataVariants(text),
});
} else {
pasteInputScrollStates.delete(term);
}
term.paste(text);
if (!options.scrollOnPaste) return;
term.scrollToBottom();
const scheduleFrame =
options.requestAnimationFrame ??
(typeof globalThis.requestAnimationFrame === "function"
? globalThis.requestAnimationFrame.bind(globalThis)
: undefined);
if (scheduleFrame) {
scheduleFrame(() => {
term.scrollToBottom();
});
}
}
export function shouldSuppressTerminalInputScrollForUserPaste(term: object, data: string): boolean {
const state = pasteInputScrollStates.get(term);
if (!isStateActive(state)) {
pasteInputScrollStates.delete(term);
return false;
}
const matchingIndex = state.remainingDataVariants.findIndex((candidate) => {
if (candidate === data) return true;
return candidate.startsWith(data);
});
if (matchingIndex === -1) return false;
const candidate = state.remainingDataVariants[matchingIndex];
if (candidate.length > data.length) {
state.remainingDataVariants[matchingIndex] = candidate.slice(data.length);
} else {
pasteInputScrollStates.delete(term);
}
return true;
}
export function prepareTerminalDataForUserPasteDisplay(term: object, data: string): string {
const state = pasteDisplayStates.get(term);
if (!isStateActive(state)) return data;
const strippedActiveRegion = stripMatchedReadlineActiveRegion(data, state);
if (strippedActiveRegion.matched) {
state.clearPending = Math.max(state.clearPending, 3);
return strippedActiveRegion.data;
}
const isPasteEcho = isExpectedPasteEcho(data, state);
if (isPasteEcho && (data.length > LONG_PASTE_MIN_LENGTH || data.includes("\r"))) {
state.clearPending = Math.max(state.clearPending, 1);
}
return data;
}
export function clearPasteResidualAfterTerminalWrite(term: object): void {
const state = pasteDisplayStates.get(term);
if (!isStateActive(state)) return;
if (state.clearPending <= 0) return;
if (typeof (term as Partial<Pick<XTerm, "write">>).write !== "function") return;
// Readline can leave stale cells to the right of the cursor after very long
// bracketed paste redraws; clear them locally without sending bytes upstream.
state.clearPending -= 1;
(term as Pick<XTerm, "write">).write("\x1b[K");
}

View File

@@ -0,0 +1,151 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_KEYWORD_HIGHLIGHT_RULES,
KeywordHighlightRule,
normalizeTerminalSettings,
} from "./models";
const IP_MAC_RULE = "ip-mac";
const ipMacDefault = () => {
const def = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((r) => r.id === IP_MAC_RULE);
if (!def) throw new Error("ip-mac default rule missing");
return def;
};
const getRule = (
rules: KeywordHighlightRule[],
id: string,
): KeywordHighlightRule => {
const rule = rules.find((r) => r.id === id);
if (!rule) throw new Error(`rule ${id} missing`);
return rule;
};
const matchesAny = (patterns: string[], input: string): boolean =>
patterns.some((p) => new RegExp(p, "gi").test(input));
test("ip-mac built-in rule includes IPv6 patterns by default", () => {
const def = ipMacDefault();
// Compressed mid-form (issue #958 example #1)
assert.ok(
matchesAny(def.patterns, "2001:11:22:33::5"),
"expected default ip-mac rule to match 2001:11:22:33::5",
);
// Link-local compressed (issue #958 example #2)
assert.ok(
matchesAny(def.patterns, "fe80::d2dd:bff:fe79:f2bb"),
"expected default ip-mac rule to match fe80::d2dd:bff:fe79:f2bb",
);
// Loopback
assert.ok(matchesAny(def.patterns, "::1"), "expected ::1 to match");
// Full form
assert.ok(
matchesAny(def.patterns, "2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
"expected full-form IPv6 to match",
);
});
test("ip-mac IPv6 regex still matches IPv4 and MAC", () => {
const def = ipMacDefault();
assert.ok(matchesAny(def.patterns, "10.0.0.1"), "expected IPv4 still matches");
assert.ok(
matchesAny(def.patterns, "aa:bb:cc:dd:ee:ff"),
"expected MAC still matches",
);
});
test("ip-mac IPv6 regex does not match obviously-not-IPv6 hex blobs", () => {
const def = ipMacDefault();
// A single hex word without colons must not match
assert.ok(!matchesAny(def.patterns, "deadbeef"), "single hex word matched");
// A typical sha-like string with colons separating fewer than two groups
assert.ok(!matchesAny(def.patterns, "abc"), "stray hex matched");
});
test("normalize adds newly-shipped default rules to legacy saved sets", () => {
// Simulate an older save that only has 'error' and an old-shape 'ip-mac'
// (i.e. without IPv6). Because the rule is NOT marked customized, normalize
// should re-sync it with the latest shipped patterns.
const legacyIpMacPatterns = ["legacy-pattern-from-old-default"];
const saved: KeywordHighlightRule[] = [
{
id: "error",
label: "Error",
patterns: ["\\berror\\b"],
color: "#F87171",
enabled: true,
},
{
id: IP_MAC_RULE,
label: "URL, IP & MAC",
patterns: legacyIpMacPatterns,
color: "#EC4899",
enabled: true,
},
];
const settings = normalizeTerminalSettings({
keywordHighlightRules: saved,
});
const rules = settings.keywordHighlightRules;
// Every shipped default exists (warning/ok/info/debug get added).
for (const def of DEFAULT_KEYWORD_HIGHLIGHT_RULES) {
assert.ok(
rules.some((r) => r.id === def.id),
`expected normalize to include shipped rule ${def.id}`,
);
}
// ip-mac was not customized → patterns re-sync to defaults, picking up IPv6.
const ipMac = getRule(rules, IP_MAC_RULE);
assert.deepEqual(ipMac.patterns, ipMacDefault().patterns);
assert.ok(matchesAny(ipMac.patterns, "2001:11:22:33::5"));
});
test("normalize preserves user-edited patterns when rule.customized is set", () => {
const customPatterns = ["\\bMY_CUSTOM\\b", "\\bANOTHER\\b"];
const customLabel = "My Errors";
const saved: KeywordHighlightRule[] = [
{
id: "error",
label: customLabel,
patterns: customPatterns,
color: "#FF0000",
enabled: false,
customized: true,
},
];
const settings = normalizeTerminalSettings({
keywordHighlightRules: saved,
});
const rule = getRule(settings.keywordHighlightRules, "error");
assert.equal(rule.label, customLabel);
assert.deepEqual(rule.patterns, customPatterns);
assert.equal(rule.color, "#FF0000");
assert.equal(rule.enabled, false);
assert.equal(rule.customized, true);
});
test("normalize keeps custom (non-built-in) rules verbatim", () => {
const customRule: KeywordHighlightRule = {
id: "user-uuid-1",
label: "Pager",
patterns: ["\\b[A-Z]{3}-\\d+\\b"],
color: "#00FF00",
enabled: true,
};
const settings = normalizeTerminalSettings({
keywordHighlightRules: [customRule],
});
const rule = getRule(settings.keywordHighlightRules, "user-uuid-1");
assert.deepEqual(rule.patterns, customRule.patterns);
assert.equal(rule.label, customRule.label);
});

View File

@@ -464,6 +464,11 @@ export interface KeywordHighlightRule {
patterns: string[]; // Regex patterns to match
color: string; // Highlight color (hex)
enabled: boolean;
// Set to true when the user edits a built-in rule's label/patterns so
// normalize keeps the user-edited values instead of overwriting them with
// the latest shipped defaults. Absent / false means "still tracking defaults"
// and the rule picks up new built-in patterns added in later versions.
customized?: boolean;
}
export interface TerminalSettings {
@@ -560,6 +565,24 @@ const URL_HIGHLIGHT_PATTERN =
"(?:\\bhttps?:\\/\\/\\[[0-9A-Fa-f:.]+\\](?::\\d+)?(?:[/?#][^\\s<>\"'`]*)?(?<![.,;:!?\\)}])|\\b(?:https?:\\/\\/|www\\.)[^\\s<>\"'`]+(?<![.,;:!?\\])}]))";
const IPV4_HIGHLIGHT_PATTERN =
`(?<![\\w.])(?<!\\bver\\s)(?<!\\bversion\\s)(?:${STRICT_IPV4_OCTET_PATTERN}\\.){3}${STRICT_IPV4_OCTET_PATTERN}(?![\\w.])`;
// Covers full and compressed forms (1:2:3:4:5:6:7:8, fe80::1, ::1, 2001:db8::,
// etc.). Bracketed `[…]:port` URLs are matched by URL_HIGHLIGHT_PATTERN.
// Zone IDs (%eth0) and IPv4-mapped (::ffff:192.0.2.1) are intentionally out
// of scope here — add them as custom patterns if you need them.
const IPV6_HIGHLIGHT_PATTERN =
'(?<![\\w:.])' +
'(?:' +
'(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}' +
'|(?:[0-9A-Fa-f]{1,4}:){1,7}:' +
'|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}' +
'|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}' +
'|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}' +
'|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}' +
'|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}' +
'|[0-9A-Fa-f]{1,4}:(?::[0-9A-Fa-f]{1,4}){1,6}' +
'|::(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4}' +
')' +
'(?![\\w:.])';
const MAC_ADDRESS_HIGHLIGHT_PATTERN =
'\\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\\b';
@@ -569,7 +592,7 @@ export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
{ id: 'ok', label: 'OK', patterns: ['\\[ok\\]', '\\bok\\b', '\\bsuccess(ful)?\\b', '\\bpassed\\b', '\\bcompleted\\b', '\\bdone\\b'], color: '#34D399', enabled: true },
{ id: 'info', label: 'Info', patterns: ['\\[info\\]', '\\[notice\\]', '\\[note\\]', '\\bnotice\\b', '\\bnote\\b'], color: '#3B82F6', enabled: true },
{ id: 'debug', label: 'Debug', patterns: ['\\[debug\\]', '\\[trace\\]', '\\[verbose\\]', '\\bdebug\\b', '\\btrace\\b', '\\bverbose\\b'], color: '#A78BFA', enabled: true },
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, IPV6_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
];
const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlightRule => ({
@@ -594,6 +617,21 @@ const normalizeKeywordHighlightRules = (
return cloneKeywordHighlightRule(rule);
}
// A built-in rule the user has explicitly edited keeps its label/patterns;
// otherwise we re-sync with the latest defaults so newly shipped patterns
// (e.g. the IPv6 entry in `ip-mac`) propagate to existing users without
// a manual reset.
if (rule.customized) {
return {
...defaultRule,
label: rule.label,
patterns: [...rule.patterns],
color: rule.color,
enabled: rule.enabled,
customized: true,
};
}
return {
...defaultRule,
color: rule.color,

View File

@@ -348,6 +348,18 @@ async function readKnownHosts() {
return combinedContent || null;
}
async function listDrives() {
if (process.platform !== "win32") return [];
const letters = [];
for (let i = 65; i <= 90; i++) {
letters.push(String.fromCharCode(i));
}
const results = await Promise.allSettled(
letters.map((letter) => fs.promises.access(letter + ":\\"))
);
return letters.filter((_, idx) => results[idx].status === "fulfilled").map((letter) => letter + ":");
}
/**
* Register IPC handlers for local filesystem operations
*/
@@ -361,6 +373,7 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:local:stat", statLocal);
ipcMain.handle("netcatty:local:tree", listLocalTree);
ipcMain.handle("netcatty:local:homedir", getHomeDir);
ipcMain.handle("netcatty:local:drives", listDrives);
ipcMain.handle("netcatty:system:info", getSystemInfo);
ipcMain.handle("netcatty:known-hosts:read", readKnownHosts);
}
@@ -377,6 +390,7 @@ module.exports = {
collectLocalTreeEntries,
listLocalTree,
getHomeDir,
listDrives,
getSystemInfo,
readKnownHosts,
parseAttribOutput,

View File

@@ -0,0 +1,402 @@
/**
* Telnet protocol helpers — RFC 854 framing + RFC 858/1091/1184/1408 options.
*
* The pieces live here so the protocol layer can be exercised by unit tests
* without spinning up a socket. terminalBridge.cjs owns the policy (which
* options to enable, what TERM-TYPE to advertise, how to wire data back to
* the renderer), this module owns the parsing.
*/
// Command bytes (RFC 854 / RFC 855).
const IAC = 255;
const SE = 240;
const NOP = 241;
const DM = 242;
const BRK = 243;
const IP = 244;
const AO = 245;
const AYT = 246;
const EC = 247;
const EL = 248;
const GA = 249;
const SB = 250;
const WILL = 251;
const WONT = 252;
const DO = 253;
const DONT = 254;
// Options we care about. Servers may negotiate plenty of others, but we only
// surface these to the policy layer; everything else is rejected with DONT/
// WONT so the conversation terminates cleanly.
const OPT = {
ECHO: 1, // RFC 857
SUPPRESS_GO_AHEAD: 3, // RFC 858
STATUS: 5,
TERMINAL_TYPE: 24, // RFC 1091
NAWS: 31, // RFC 1073 — window size
TERMINAL_SPEED: 32,
LINEMODE: 34,
NEW_ENVIRON: 39,
};
const SUBOPTION_IS = 0;
const SUBOPTION_SEND = 1;
const isOptionCommand = (cmd) =>
cmd === WILL || cmd === WONT || cmd === DO || cmd === DONT;
const commandName = (cmd) => {
switch (cmd) {
case IAC: return "IAC";
case SE: return "SE";
case NOP: return "NOP";
case DM: return "DM";
case BRK: return "BRK";
case IP: return "IP";
case AO: return "AO";
case AYT: return "AYT";
case EC: return "EC";
case EL: return "EL";
case GA: return "GA";
case SB: return "SB";
case WILL: return "WILL";
case WONT: return "WONT";
case DO: return "DO";
case DONT: return "DONT";
default: return String(cmd);
}
};
/**
* Escape a buffer for wire transmission: any literal 0xFF byte becomes
* 0xFF 0xFF so the peer's parser does not treat it as IAC. Cheap fast-path
* for the common case (no 0xFF bytes) so user typing — which is UTF-8 and
* cannot contain 0xFF — pays nothing.
*/
function escapeIacForWire(buf) {
if (!Buffer.isBuffer(buf) || buf.length === 0) return buf;
if (buf.indexOf(0xff) < 0) return buf;
const out = [];
for (let i = 0; i < buf.length; i++) {
out.push(buf[i]);
if (buf[i] === 0xff) out.push(0xff);
}
return Buffer.from(out);
}
/**
* Build a stateful Telnet parser.
*
* The parser preserves any partial command (IAC alone, IAC + verb without
* option, or unterminated subnegotiation) between feeds so that a sequence
* split across TCP frames is reassembled before being acted on. The previous
* stateless approach would either drop the lone IAC or treat the tail of an
* unterminated SB block as data — exactly the source of the garbled-output
* symptom on chatty old equipment.
*
* Callbacks:
* onCommand(cmd, opt) — WILL/WONT/DO/DONT for `opt`.
* onSubnegotiation(opt, buf) — IAC SB <opt> ... IAC SE. `buf` is the
* payload between option byte and IAC SE,
* with any IAC IAC unescaped to a single
* 0xFF.
* onData(buf) — clean stream bytes, IAC IAC already
* unescaped. Emitted in chunks coinciding
* with command boundaries; never empty.
*/
function createTelnetParser({ onCommand, onSubnegotiation, onData } = {}) {
let pending = Buffer.alloc(0);
const noop = () => {};
const handleCommand = typeof onCommand === "function" ? onCommand : noop;
const handleSubnegotiation = typeof onSubnegotiation === "function" ? onSubnegotiation : noop;
const handleData = typeof onData === "function" ? onData : noop;
const feed = (chunk) => {
if (!chunk || chunk.length === 0) return;
const buf = pending.length === 0
? (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
: Buffer.concat([pending, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
pending = Buffer.alloc(0);
const out = [];
let i = 0;
const flushData = () => {
if (out.length > 0) {
handleData(Buffer.from(out));
out.length = 0;
}
};
while (i < buf.length) {
const byte = buf[i];
if (byte !== IAC) {
out.push(byte);
i++;
continue;
}
// We are at an IAC byte. Need at least one more byte to know the verb.
if (i + 1 >= buf.length) {
pending = buf.subarray(i);
break;
}
const cmd = buf[i + 1];
if (cmd === IAC) {
// Escaped literal 0xFF in the data stream.
out.push(0xff);
i += 2;
continue;
}
if (isOptionCommand(cmd)) {
if (i + 2 >= buf.length) {
pending = buf.subarray(i);
break;
}
const opt = buf[i + 2];
flushData();
handleCommand(cmd, opt);
i += 3;
continue;
}
if (cmd === SB) {
// Subnegotiation: IAC SB <opt> <payload...> IAC SE. We need to find
// the terminating IAC SE while ignoring escaped IAC IAC inside the
// payload (RFC 855).
let j = i + 3;
let seFound = false;
while (j + 1 < buf.length) {
if (buf[j] === IAC) {
if (buf[j + 1] === SE) {
seFound = true;
break;
}
// Escaped IAC IAC in payload, or another IAC verb (rare,
// ignored). Either way, skip two bytes and keep scanning.
j += 2;
continue;
}
j++;
}
if (!seFound) {
// Subnegotiation continues into the next frame.
pending = buf.subarray(i);
break;
}
if (i + 2 >= buf.length) {
pending = buf.subarray(i);
break;
}
const opt = buf[i + 2];
const rawPayload = buf.subarray(i + 3, j);
const payload = unescapeIacFromPayload(rawPayload);
flushData();
handleSubnegotiation(opt, payload);
i = j + 2;
continue;
}
// Other single-verb IAC commands (NOP, AYT, IP, ...). The protocol
// does not require us to act, but we must still consume the two bytes
// so they do not leak into the data stream.
flushData();
i += 2;
}
flushData();
};
return {
feed,
get pendingByteCount() {
return pending.length;
},
/** Reset state — used when a session is torn down or reconnected. */
reset() {
pending = Buffer.alloc(0);
},
};
}
function unescapeIacFromPayload(buf) {
if (!buf || buf.indexOf(0xff) < 0) return buf;
const out = [];
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0xff && i + 1 < buf.length && buf[i + 1] === 0xff) {
out.push(0xff);
i++;
continue;
}
out.push(buf[i]);
}
return Buffer.from(out);
}
/**
* Build a Telnet negotiation policy machine.
*
* The machine owns the rules for which options we accept, the
* direction-aware acknowledgement tracking, and the wire bytes sent in
* response to peer commands. It is intentionally separated from socket I/O
* so it can be exercised directly in unit tests.
*
* `writeCommand(cmd, opt)` is invoked to send `IAC <cmd> <opt>` on the
* wire; `writeSubnegotiation(opt, payload)` is invoked to send
* `IAC SB <opt> <payload...> IAC SE` (with `payload` already escaped if
* needed by the caller); `getWindowSize()` returns `{ cols, rows }` for
* the current terminal dimensions; `termType` is the string advertised
* for TERMINAL-TYPE subnegotiation (default "XTERM-256COLOR").
*/
function createTelnetNegotiator({
writeCommand,
writeSubnegotiation,
getWindowSize,
termType = "XTERM-256COLOR",
} = {}) {
const pendingDoRequests = new Set();
const pendingWillRequests = new Set();
const noopWrite = () => {};
const cmdSink = typeof writeCommand === "function" ? writeCommand : noopWrite;
const sbSink = typeof writeSubnegotiation === "function" ? writeSubnegotiation : noopWrite;
const sizeFn = typeof getWindowSize === "function"
? getWindowSize
: () => ({ cols: 80, rows: 24 });
const naws = () => {
const { cols, rows } = sizeFn() || {};
const safeCols = Number.isFinite(cols) && cols > 0 ? cols : 80;
const safeRows = Number.isFinite(rows) && rows > 0 ? rows : 24;
const payload = Buffer.from([
(safeCols >> 8) & 0xff, safeCols & 0xff,
(safeRows >> 8) & 0xff, safeRows & 0xff,
]);
sbSink(OPT.NAWS, escapeIacForWire(payload));
};
const sendTerminalType = () => {
sbSink(
OPT.TERMINAL_TYPE,
Buffer.concat([
Buffer.from([SUBOPTION_IS]),
Buffer.from(String(termType), "ascii"),
]),
);
};
const requestOption = (cmd, opt) => {
if (cmd === DO) pendingDoRequests.add(opt);
else if (cmd === WILL) pendingWillRequests.add(opt);
cmdSink(cmd, opt);
};
const start = () => {
// Drive the negotiation rather than waiting for the peer. Many legacy
// servers will not advance past their banner until the client commits
// to a basic option set.
requestOption(DO, OPT.SUPPRESS_GO_AHEAD);
requestOption(WILL, OPT.TERMINAL_TYPE);
requestOption(WILL, OPT.NAWS);
};
const handleCommand = (cmd, opt) => {
let acknowledgesOurRequest = false;
if ((cmd === WILL || cmd === WONT) && pendingDoRequests.has(opt)) {
pendingDoRequests.delete(opt);
acknowledgesOurRequest = true;
} else if ((cmd === DO || cmd === DONT) && pendingWillRequests.has(opt)) {
pendingWillRequests.delete(opt);
acknowledgesOurRequest = true;
}
if (cmd === WILL) {
if (!acknowledgesOurRequest) {
if (opt === OPT.SUPPRESS_GO_AHEAD || opt === OPT.ECHO) {
cmdSink(DO, opt);
} else {
cmdSink(DONT, opt);
}
}
return;
}
if (cmd === DO) {
if (opt === OPT.NAWS) {
if (!acknowledgesOurRequest) cmdSink(WILL, opt);
// Always follow through with the actual size, whether this DO is the
// peer's reply to our WILL or an independent fresh request.
naws();
} else if (opt === OPT.TERMINAL_TYPE || opt === OPT.SUPPRESS_GO_AHEAD) {
if (!acknowledgesOurRequest) cmdSink(WILL, opt);
} else {
if (!acknowledgesOurRequest) cmdSink(WONT, opt);
}
return;
}
if (cmd === DONT) {
if (!acknowledgesOurRequest) cmdSink(WONT, opt);
return;
}
if (cmd === WONT) {
if (!acknowledgesOurRequest) cmdSink(DONT, opt);
return;
}
};
const handleSubnegotiation = (opt, payload) => {
if (opt === OPT.TERMINAL_TYPE
&& payload && payload.length > 0
&& payload[0] === SUBOPTION_SEND) {
sendTerminalType();
}
};
return {
start,
handleCommand,
handleSubnegotiation,
sendWindowSize: naws,
/** Test/debug introspection — number of options awaiting a reply per direction. */
get pendingDoCount() {
return pendingDoRequests.size;
},
get pendingWillCount() {
return pendingWillRequests.size;
},
};
}
module.exports = {
// Command constants
IAC,
SE,
NOP,
DM,
BRK,
IP,
AO,
AYT,
EC,
EL,
GA,
SB,
WILL,
WONT,
DO,
DONT,
// Options
OPT,
SUBOPTION_IS,
SUBOPTION_SEND,
// Helpers
commandName,
escapeIacForWire,
createTelnetParser,
createTelnetNegotiator,
};

View File

@@ -0,0 +1,409 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
IAC,
SE,
NOP,
SB,
WILL,
WONT,
DO,
DONT,
OPT,
SUBOPTION_IS,
SUBOPTION_SEND,
escapeIacForWire,
createTelnetParser,
createTelnetNegotiator,
} = require("./telnetProtocol.cjs");
const collect = () => {
const data = [];
const commands = [];
const subnegs = [];
return {
data,
commands,
subnegs,
parser: createTelnetParser({
onData(buf) {
data.push(Buffer.from(buf));
},
onCommand(cmd, opt) {
commands.push({ cmd, opt });
},
onSubnegotiation(opt, payload) {
subnegs.push({ opt, payload: Buffer.from(payload) });
},
}),
};
};
test("escapeIacForWire — passthrough when no 0xFF byte", () => {
const input = Buffer.from([0x61, 0x62, 0x63]);
assert.equal(escapeIacForWire(input), input);
});
test("escapeIacForWire — doubles each 0xFF", () => {
const input = Buffer.from([0xff, 0x61, 0xff, 0xff, 0x62]);
const got = escapeIacForWire(input);
assert.deepEqual(
[...got],
[0xff, 0xff, 0x61, 0xff, 0xff, 0xff, 0xff, 0x62],
);
});
test("parser emits clean data when no IAC bytes are present", () => {
const { parser, data, commands, subnegs } = collect();
parser.feed(Buffer.from("hello world"));
assert.equal(Buffer.concat(data).toString("utf8"), "hello world");
assert.equal(commands.length, 0);
assert.equal(subnegs.length, 0);
});
test("parser handles a complete DO option command in one feed", () => {
const { parser, data, commands } = collect();
parser.feed(Buffer.from([IAC, DO, OPT.SUPPRESS_GO_AHEAD]));
assert.equal(data.length, 0);
assert.deepEqual(commands, [{ cmd: DO, opt: OPT.SUPPRESS_GO_AHEAD }]);
});
test("parser splits clean data around an option command", () => {
const { parser, data, commands } = collect();
parser.feed(
Buffer.concat([
Buffer.from("login: "),
Buffer.from([IAC, WILL, OPT.ECHO]),
Buffer.from("admin"),
]),
);
assert.equal(Buffer.concat(data).toString("utf8"), "login: admin");
assert.deepEqual(commands, [{ cmd: WILL, opt: OPT.ECHO }]);
});
test("parser unescapes IAC IAC into a literal 0xFF in the data stream", () => {
const { parser, data } = collect();
parser.feed(Buffer.from([0x61, IAC, IAC, 0x62]));
assert.deepEqual([...Buffer.concat(data)], [0x61, 0xff, 0x62]);
});
test("parser ignores stand-alone IAC verbs (NOP)", () => {
const { parser, data, commands } = collect();
parser.feed(Buffer.from([0x61, IAC, NOP, 0x62]));
assert.deepEqual([...Buffer.concat(data)], [0x61, 0x62]);
assert.equal(commands.length, 0);
});
test("parser parses a complete subnegotiation in one feed", () => {
const { parser, data, subnegs } = collect();
// IAC SB TERMINAL_TYPE IS "XTERM" IAC SE
parser.feed(
Buffer.concat([
Buffer.from([IAC, SB, OPT.TERMINAL_TYPE, 0]),
Buffer.from("XTERM"),
Buffer.from([IAC, SE]),
]),
);
assert.equal(data.length, 0);
assert.equal(subnegs.length, 1);
assert.equal(subnegs[0].opt, OPT.TERMINAL_TYPE);
assert.deepEqual(
[...subnegs[0].payload],
[0, 0x58, 0x54, 0x45, 0x52, 0x4d],
);
});
test("parser tolerates IAC IAC inside a subnegotiation payload", () => {
const { parser, subnegs } = collect();
// SB STATUS 0xFF (encoded as IAC IAC) 0x01 SE
parser.feed(Buffer.from([IAC, SB, OPT.STATUS, IAC, IAC, 0x01, IAC, SE]));
assert.equal(subnegs.length, 1);
assert.deepEqual([...subnegs[0].payload], [0xff, 0x01]);
});
test("parser preserves a lone IAC at end-of-chunk for the next feed", () => {
const { parser, data, commands } = collect();
parser.feed(Buffer.concat([Buffer.from("hi"), Buffer.from([IAC])]));
assert.equal(Buffer.concat(data).toString("utf8"), "hi");
assert.equal(commands.length, 0);
assert.equal(parser.pendingByteCount, 1);
// Next chunk completes the command.
parser.feed(Buffer.from([DO, OPT.NAWS, 0x61]));
assert.equal(parser.pendingByteCount, 0);
assert.deepEqual(commands, [{ cmd: DO, opt: OPT.NAWS }]);
// The trailing 'a' must have been emitted as data.
assert.equal(Buffer.concat(data).toString("utf8"), "hia");
});
test("parser preserves a half-finished option command (IAC DO) for the next feed", () => {
const { parser, data, commands } = collect();
parser.feed(Buffer.from([0x61, IAC, DO]));
assert.equal(Buffer.concat(data).toString("utf8"), "a");
assert.equal(commands.length, 0);
assert.equal(parser.pendingByteCount, 2);
parser.feed(Buffer.from([OPT.TERMINAL_TYPE, 0x62]));
assert.deepEqual(commands, [{ cmd: DO, opt: OPT.TERMINAL_TYPE }]);
assert.equal(Buffer.concat(data).toString("utf8"), "ab");
});
test("parser preserves an unterminated subnegotiation across multiple frames", () => {
const { parser, data, subnegs } = collect();
// Send IAC SB TT 0 "XTE" — the SE is intentionally missing.
parser.feed(
Buffer.concat([
Buffer.from("prefix"),
Buffer.from([IAC, SB, OPT.TERMINAL_TYPE, 0]),
Buffer.from("XTE"),
]),
);
assert.equal(Buffer.concat(data).toString("utf8"), "prefix");
assert.equal(subnegs.length, 0);
// Now the remaining payload + IAC SE arrive together with trailing data.
parser.feed(
Buffer.concat([
Buffer.from("RM-256COLOR"),
Buffer.from([IAC, SE]),
Buffer.from(" tail"),
]),
);
assert.equal(subnegs.length, 1);
assert.equal(subnegs[0].opt, OPT.TERMINAL_TYPE);
assert.deepEqual(
Buffer.from(subnegs[0].payload).toString("utf8"),
"\x00XTERM-256COLOR",
);
assert.equal(Buffer.concat(data).toString("utf8"), "prefix tail");
});
test("parser does not leak SB payload as data when the SE never arrives", () => {
// Regression: in the old stateless implementation, an unterminated SB block
// would fall through to "skip IAC SB and emit the rest as data" — leaking
// option-type names and other text into the terminal.
const { parser, data, subnegs } = collect();
parser.feed(
Buffer.concat([
Buffer.from([IAC, SB, OPT.TERMINAL_TYPE, 0]),
Buffer.from("XTERM-PARTIAL"),
]),
);
assert.equal(data.length, 0);
assert.equal(subnegs.length, 0);
assert.ok(parser.pendingByteCount > 0);
});
test("parser handles two consecutive option commands without losing either", () => {
const { parser, commands } = collect();
parser.feed(
Buffer.from([IAC, WILL, OPT.ECHO, IAC, DO, OPT.SUPPRESS_GO_AHEAD]),
);
assert.deepEqual(commands, [
{ cmd: WILL, opt: OPT.ECHO },
{ cmd: DO, opt: OPT.SUPPRESS_GO_AHEAD },
]);
});
test("parser feed is no-op for empty / null chunks", () => {
const { parser, data, commands } = collect();
parser.feed(Buffer.alloc(0));
parser.feed(null);
parser.feed(undefined);
assert.equal(data.length, 0);
assert.equal(commands.length, 0);
});
test("parser reset clears pending state", () => {
const { parser } = collect();
parser.feed(Buffer.from([IAC]));
assert.equal(parser.pendingByteCount, 1);
parser.reset();
assert.equal(parser.pendingByteCount, 0);
});
const recordNegotiator = (overrides = {}) => {
const commands = [];
const subnegs = [];
const negotiator = createTelnetNegotiator({
writeCommand(cmd, opt) {
commands.push({ cmd, opt });
},
writeSubnegotiation(opt, payload) {
subnegs.push({ opt, payload: Buffer.from(payload) });
},
getWindowSize: () => ({ cols: 120, rows: 40 }),
...overrides,
});
return { negotiator, commands, subnegs };
};
test("negotiator.start drives the canonical handshake (DO SGA / WILL TT / WILL NAWS)", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.start();
assert.deepEqual(commands, [
{ cmd: DO, opt: OPT.SUPPRESS_GO_AHEAD },
{ cmd: WILL, opt: OPT.TERMINAL_TYPE },
{ cmd: WILL, opt: OPT.NAWS },
]);
assert.equal(negotiator.pendingDoCount, 1);
assert.equal(negotiator.pendingWillCount, 2);
});
test("peer's WILL on our pending DO is swallowed (no double-DO loop)", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.start();
commands.length = 0;
// Server replies WILL SGA — acknowledges our DO SGA.
negotiator.handleCommand(WILL, OPT.SUPPRESS_GO_AHEAD);
assert.deepEqual(commands, []);
assert.equal(negotiator.pendingDoCount, 0);
});
test("peer's independent DO on a SGA where our DO is still pending is replied with WILL (regression)", () => {
// RFC 858: WILL/WONT and DO/DONT are independent per direction. The peer
// can ask us to enable SGA on our side while our request to enable it on
// its side is still in flight. The old implementation incorrectly treated
// the peer's DO as an ack of our DO and never replied.
const { negotiator, commands } = recordNegotiator();
negotiator.start();
commands.length = 0;
negotiator.handleCommand(DO, OPT.SUPPRESS_GO_AHEAD);
assert.deepEqual(commands, [{ cmd: WILL, opt: OPT.SUPPRESS_GO_AHEAD }]);
// The pending DO request stays open until the peer also says WILL/WONT.
assert.equal(negotiator.pendingDoCount, 1);
});
test("peer's DO NAWS that acknowledges our WILL NAWS still triggers a size subnegotiation", () => {
const { negotiator, commands, subnegs } = recordNegotiator();
negotiator.start();
commands.length = 0;
subnegs.length = 0;
negotiator.handleCommand(DO, OPT.NAWS);
// No echoed WILL NAWS — the peer was acknowledging our own WILL.
assert.deepEqual(commands, []);
// But the actual size payload must follow.
assert.equal(subnegs.length, 1);
assert.equal(subnegs[0].opt, OPT.NAWS);
assert.deepEqual(
[...subnegs[0].payload],
[(120 >> 8) & 0xff, 120 & 0xff, (40 >> 8) & 0xff, 40 & 0xff],
);
assert.equal(negotiator.pendingWillCount, 1); // TERMINAL-TYPE still outstanding
});
test("peer's independent DO NAWS (no WILL pending) replies WILL + size subneg", () => {
const { negotiator, commands, subnegs } = recordNegotiator();
// Note: not calling start(), so no WILL NAWS is pending.
negotiator.handleCommand(DO, OPT.NAWS);
assert.deepEqual(commands, [{ cmd: WILL, opt: OPT.NAWS }]);
assert.equal(subnegs.length, 1);
assert.equal(subnegs[0].opt, OPT.NAWS);
});
test("peer's WILL ECHO triggers DO ECHO, repeated WILL ECHO is swallowed as ack", () => {
const { negotiator, commands } = recordNegotiator();
// First WILL ECHO: fresh request → we reply DO ECHO. That DO is sent via
// the raw writer (not requestOption), so it is NOT in pendingDoRequests.
// The peer's subsequent WILL ECHO is a re-announce and we should reply
// with another DO. The negotiator does not currently de-duplicate, so
// this test pins down the actual behaviour: reply each time.
negotiator.handleCommand(WILL, OPT.ECHO);
negotiator.handleCommand(WILL, OPT.ECHO);
assert.deepEqual(commands, [
{ cmd: DO, opt: OPT.ECHO },
{ cmd: DO, opt: OPT.ECHO },
]);
});
test("peer's WONT on our pending DO is swallowed", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.start();
commands.length = 0;
negotiator.handleCommand(WONT, OPT.SUPPRESS_GO_AHEAD);
assert.deepEqual(commands, []);
assert.equal(negotiator.pendingDoCount, 0);
});
test("peer's DONT on our pending WILL is swallowed", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.start();
commands.length = 0;
negotiator.handleCommand(DONT, OPT.TERMINAL_TYPE);
assert.deepEqual(commands, []);
// NAWS still outstanding.
assert.equal(negotiator.pendingWillCount, 1);
});
test("peer's DO on an option we don't support replies WONT", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.handleCommand(DO, OPT.LINEMODE);
assert.deepEqual(commands, [{ cmd: WONT, opt: OPT.LINEMODE }]);
});
test("peer's WILL on an option we don't support replies DONT", () => {
const { negotiator, commands } = recordNegotiator();
negotiator.handleCommand(WILL, OPT.NEW_ENVIRON);
assert.deepEqual(commands, [{ cmd: DONT, opt: OPT.NEW_ENVIRON }]);
});
test("peer's TERMINAL-TYPE SEND subnegotiation replies with IS <termType>", () => {
const { negotiator, subnegs } = recordNegotiator();
negotiator.handleSubnegotiation(
OPT.TERMINAL_TYPE,
Buffer.from([SUBOPTION_SEND]),
);
assert.equal(subnegs.length, 1);
assert.equal(subnegs[0].opt, OPT.TERMINAL_TYPE);
assert.equal(
subnegs[0].payload.toString("ascii"),
"\x00XTERM-256COLOR",
);
});
test("negotiator honors a custom termType override", () => {
const { negotiator, subnegs } = recordNegotiator({ termType: "VT100" });
negotiator.handleSubnegotiation(
OPT.TERMINAL_TYPE,
Buffer.from([SUBOPTION_SEND]),
);
assert.equal(subnegs[0].payload.toString("ascii"), "\x00VT100");
});
test("sendWindowSize falls back to 80x24 when getWindowSize returns garbage", () => {
const { negotiator, subnegs } = recordNegotiator({
getWindowSize: () => ({ cols: NaN, rows: -3 }),
});
negotiator.sendWindowSize();
assert.deepEqual([...subnegs[0].payload], [0, 80, 0, 24]);
});
test("data emitted before a command is delivered before that command's callback", () => {
const order = [];
const parser = createTelnetParser({
onData(buf) {
order.push(`data:${buf.toString("utf8")}`);
},
onCommand(cmd, opt) {
order.push(`cmd:${cmd}:${opt}`);
},
});
parser.feed(
Buffer.concat([
Buffer.from("hi"),
Buffer.from([IAC, WONT, OPT.LINEMODE]),
Buffer.from("bye"),
]),
);
assert.deepEqual(order, [
"data:hi",
`cmd:${WONT}:${OPT.LINEMODE}`,
"data:bye",
]);
});

View File

@@ -24,6 +24,7 @@ const { discoverShells } = require("./shellDiscovery.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
const telnetProtocol = require("./telnetProtocol.cjs");
const execFileAsync = promisify(execFile);
@@ -547,121 +548,61 @@ async function startTelnetSession(event, options) {
},
});
// Telnet protocol constants
const TELNET = {
IAC: 255,
DONT: 254,
DO: 253,
WONT: 252,
WILL: 251,
SB: 250,
SE: 240,
ECHO: 1,
SUPPRESS_GO_AHEAD: 3,
STATUS: 5,
TERMINAL_TYPE: 24,
NAWS: 31,
TERMINAL_SPEED: 32,
LINEMODE: 34,
NEW_ENVIRON: 39,
// Telnet protocol state. Negotiation only activates once we see an IAC
// byte from the peer — if the remote never speaks the protocol (some
// legacy raw-TCP services on port 23), we fall back to passthrough so we
// do not corrupt their stream by misreading stray 0xFF bytes as IAC.
let telnetProtocolActive = false;
let telnetCleanData = Buffer.alloc(0);
const writeRawTelnetCommand = (cmd, opt) => {
if (socket.destroyed) return;
socket.write(Buffer.from([telnetProtocol.IAC, cmd, opt]));
};
const sendWindowSize = () => {
const buf = Buffer.from([
TELNET.IAC, TELNET.SB, TELNET.NAWS,
(cols >> 8) & 0xff, cols & 0xff,
(rows >> 8) & 0xff, rows & 0xff,
TELNET.IAC, TELNET.SE
]);
socket.write(buf);
const writeRawSubnegotiation = (opt, payload) => {
if (socket.destroyed) return;
socket.write(Buffer.concat([
Buffer.from([telnetProtocol.IAC, telnetProtocol.SB, opt]),
payload,
Buffer.from([telnetProtocol.IAC, telnetProtocol.SE]),
]));
};
const handleTelnetNegotiation = (data) => {
const output = [];
let i = 0;
const negotiator = telnetProtocol.createTelnetNegotiator({
writeCommand: writeRawTelnetCommand,
writeSubnegotiation: writeRawSubnegotiation,
getWindowSize: () => {
const session = sessions.get(sessionId);
return { cols: session?.cols ?? cols, rows: session?.rows ?? rows };
},
});
while (i < data.length) {
if (data[i] === TELNET.IAC) {
if (i + 1 >= data.length) break;
const cmd = data[i + 1];
if (cmd === TELNET.IAC) {
output.push(255);
i += 2;
continue;
}
const telnetParser = telnetProtocol.createTelnetParser({
onData: (clean) => {
if (clean.length === 0) return;
telnetCleanData = telnetCleanData.length === 0
? clean
: Buffer.concat([telnetCleanData, clean]);
},
onCommand: (cmd, opt) => negotiator.handleCommand(cmd, opt),
onSubnegotiation: (opt, payload) => negotiator.handleSubnegotiation(opt, payload),
});
if (cmd === TELNET.DO || cmd === TELNET.DONT || cmd === TELNET.WILL || cmd === TELNET.WONT) {
if (i + 2 >= data.length) break;
const opt = data[i + 2];
console.log(`[Telnet] Received: ${cmd === TELNET.DO ? 'DO' : cmd === TELNET.DONT ? 'DONT' : cmd === TELNET.WILL ? 'WILL' : 'WONT'} ${opt}`);
if (cmd === TELNET.DO) {
if (opt === TELNET.NAWS) {
socket.write(Buffer.from([TELNET.IAC, TELNET.WILL, opt]));
sendWindowSize();
} else if (opt === TELNET.TERMINAL_TYPE) {
socket.write(Buffer.from([TELNET.IAC, TELNET.WILL, opt]));
} else if (opt === TELNET.SUPPRESS_GO_AHEAD) {
socket.write(Buffer.from([TELNET.IAC, TELNET.WILL, opt]));
} else {
socket.write(Buffer.from([TELNET.IAC, TELNET.WONT, opt]));
}
} else if (cmd === TELNET.WILL) {
if (opt === TELNET.ECHO || opt === TELNET.SUPPRESS_GO_AHEAD) {
socket.write(Buffer.from([TELNET.IAC, TELNET.DO, opt]));
} else {
socket.write(Buffer.from([TELNET.IAC, TELNET.DONT, opt]));
}
} else if (cmd === TELNET.DONT) {
socket.write(Buffer.from([TELNET.IAC, TELNET.WONT, opt]));
} else if (cmd === TELNET.WONT) {
socket.write(Buffer.from([TELNET.IAC, TELNET.DONT, opt]));
}
i += 3;
continue;
}
if (cmd === TELNET.SB) {
let seIndex = i + 2;
while (seIndex < data.length - 1) {
if (data[seIndex] === TELNET.IAC && data[seIndex + 1] === TELNET.SE) {
break;
}
seIndex++;
}
if (seIndex < data.length - 1) {
const subOpt = data[i + 2];
console.log(`[Telnet] Sub-negotiation for option ${subOpt}`);
if (subOpt === TELNET.TERMINAL_TYPE && data[i + 3] === 1) {
const termType = 'xterm-256color';
const response = Buffer.concat([
Buffer.from([TELNET.IAC, TELNET.SB, TELNET.TERMINAL_TYPE, 0]),
Buffer.from(termType),
Buffer.from([TELNET.IAC, TELNET.SE])
]);
socket.write(response);
}
i = seIndex + 2;
continue;
}
}
i += 2;
continue;
}
output.push(data[i]);
i++;
const processIncomingTelnet = (data) => {
// Lazy protocol activation: only flip on once we see an IAC from the
// peer. Until then we just hand bytes back as-is so true raw-TCP-on-23
// services (the long tail of embedded devices) are not corrupted.
if (!telnetProtocolActive) {
if (data.indexOf(0xff) < 0) return data;
telnetProtocolActive = true;
negotiator.start();
}
return Buffer.from(output);
telnetCleanData = Buffer.alloc(0);
telnetParser.feed(data);
const out = telnetCleanData;
telnetCleanData = Buffer.alloc(0);
return out;
};
const connectTimeout = setTimeout(() => {
@@ -690,6 +631,12 @@ async function startTelnetSession(event, options) {
encoding: initialTelnetEncoding,
decoderRef: telnetDecoderRef,
autoLogin: telnetAutoLogin,
// Mirror of the closure-local `telnetProtocolActive` so the resize
// handler (which only sees the session record) can decide whether
// to push a NAWS subnegotiation.
get telnetProtocolActive() {
return telnetProtocolActive;
},
};
session.flushPendingData = flushTelnet;
sessions.set(sessionId, session);
@@ -769,7 +716,7 @@ async function startTelnetSession(event, options) {
// Always run Telnet negotiation — even during ZMODEM, the Telnet
// layer still escapes 0xFF as IAC IAC and sends control sequences.
const cleanData = handleTelnetNegotiation(data);
const cleanData = processIncomingTelnet(data);
if (cleanData.length > 0) {
telnetZmodemSentry.consume(cleanData);
}
@@ -1610,7 +1557,19 @@ function writeToSession(event, payload) {
} else if (session.proc) {
session.proc.write(payload.data);
} else if (session.socket) {
session.socket.write(payload.data);
// Telnet only: any 0xFF byte going out the wire must be doubled, or
// the peer will treat it as the start of an IAC command sequence and
// eat the next byte (RFC 854 §"Data Stream"). UTF-8 keyboard input
// never produces 0xFF, but paste of binary content and some legacy
// encodings do. Cheap no-op when there is no 0xFF.
let outgoing = payload.data;
if (session.type === 'telnet-native' && session.telnetProtocolActive) {
if (typeof outgoing === 'string') {
outgoing = Buffer.from(outgoing, 'utf8');
}
outgoing = telnetProtocol.escapeIacForWire(outgoing);
}
session.socket.write(outgoing);
} else if (session.serialPort) {
session.serialPort.write(payload.data);
}
@@ -1638,14 +1597,19 @@ function resizeSession(event, payload) {
} else if (session.socket && session.type === 'telnet-native') {
session.cols = payload.cols;
session.rows = payload.rows;
const TELNET = { IAC: 255, SB: 250, SE: 240, NAWS: 31 };
const buf = Buffer.from([
TELNET.IAC, TELNET.SB, TELNET.NAWS,
(payload.cols >> 8) & 0xff, payload.cols & 0xff,
(payload.rows >> 8) & 0xff, payload.rows & 0xff,
TELNET.IAC, TELNET.SE
]);
session.socket.write(buf);
// Only push a NAWS update once the peer has activated the protocol;
// sending an IAC sequence to a raw-TCP server would corrupt its stream.
if (session.telnetProtocolActive) {
const colsByte = Buffer.from([
(payload.cols >> 8) & 0xff, payload.cols & 0xff,
(payload.rows >> 8) & 0xff, payload.rows & 0xff,
]);
session.socket.write(Buffer.concat([
Buffer.from([telnetProtocol.IAC, telnetProtocol.SB, telnetProtocol.OPT.NAWS]),
telnetProtocol.escapeIacForWire(colsByte),
Buffer.from([telnetProtocol.IAC, telnetProtocol.SE]),
]));
}
}
} catch (err) {
if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') {

View File

@@ -856,6 +856,9 @@ const api = {
getHomeDir: async () => {
return ipcRenderer.invoke("netcatty:local:homedir");
},
listDrives: async () => {
return ipcRenderer.invoke("netcatty:local:drives");
},
getSystemInfo: async () => {
return ipcRenderer.invoke("netcatty:system:info");
},

1
global.d.ts vendored
View File

@@ -520,6 +520,7 @@ declare global {
lastModified: number;
}>>;
getHomeDir?(): Promise<string>;
listDrives?(): Promise<string[]>;
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
setTheme?(theme: 'light' | 'dark' | 'system'): Promise<boolean>;

View File

@@ -9,6 +9,7 @@ import '@fontsource/jetbrains-mono/500.css';
import '@fontsource/jetbrains-mono/600.css';
import App from './App';
import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
@@ -103,17 +104,21 @@ const renderApp = () => {
if (route === 'settings') {
root.render(
<ToastProvider>
<Suspense fallback={<SettingsWindowFallback />}>
<LazySettingsPage />
</Suspense>
<TooltipProvider delayDuration={300}>
<Suspense fallback={<SettingsWindowFallback />}>
<LazySettingsPage />
</Suspense>
</TooltipProvider>
</ToastProvider>
);
} else if (route === 'tray') {
root.render(
<ToastProvider>
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel</div>}>
<LazyTrayPanel />
</Suspense>
<TooltipProvider delayDuration={300}>
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel</div>}>
<LazyTrayPanel />
</Suspense>
</TooltipProvider>
</ToastProvider>
);
} else {