Compare commits

...

353 Commits

Author SHA1 Message Date
陈大猫
ccfa2d4dd0 fix: non-zero exit code is not a failure, include output on real errors (#377)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: treat non-zero exit code as success and include output on failure

- Non-zero exit codes (e.g. grep returning 1, ls on missing file) are
  valid command results, not execution failures. Changed execViaPty and
  execViaChannel to always return ok:true when the command actually ran.
- ok:false is now reserved for real failures: timeout, session gone,
  stream not writable, etc.
- When ok:false, include any partial stdout/stderr in the error message
  so the user and LLM can see what happened before the failure.

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

* fix: return stdout+exitCode for all completed commands, clean up dead code

- ptyExec: preserve original ok semantics (non-zero = ok:false) so MCP
  server bridge callers (handleMultiExec, stopOnError) still work
- execViaChannel: null exit code (SSH disconnect) returns ok:false
- toolExecutors: Catty Agent always returns stdout+exitCode to the LLM
  regardless of exit code, only treats real failures (timeout, disconnect)
  as errors — with partial output included
- Remove dead code: executeTerminalSendInput, executeSftp*, executeMultiHost
- Clean up unused imports, bridge interface, ExecutorContext

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:53:23 +08:00
陈大猫
7c5478b2a5 refactor: remove SFTP tools from AI agent (#376)
Remove sftp_list_directory, sftp_read_file, and sftp_write_file tools.
The AI can use terminal_execute with standard shell commands (ls, cat,
tee, etc.) which is more flexible, supports sudo/pipes/redirects, and
reduces tool choice complexity for the LLM.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:59:47 +08:00
陈大猫
338ba94d42 feat: add paste-only option for snippets (no auto-execute) (#375)
* feat: add "paste only" option for snippets (no auto-execute)

Add a noAutoRun flag to snippets that pastes the command into the
terminal without appending a carriage return, so users can review
and edit before manually pressing Enter.

Applies to all snippet execution paths: snippet runner (new session),
keyboard shortcut, and startup command.

Closes #371

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

* fix: use clearer wording "仅粘贴" instead of "仅上屏"

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

* fix: skip onCommandExecuted for paste-only shortcut snippets

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

* fix: persist noAutoRun on save and apply to Scripts panel clicks

- Include noAutoRun in handleSubmit serialization (was being lost)
- Pass noAutoRun through ScriptsSidePanel click handler to TerminalLayer
  so paste-only snippets work from the Scripts panel too

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:09:17 +08:00
陈大猫
b7b2e91fab fix: show real error message instead of [object Object] (#373)
* fix: show real error message instead of [object Object]

When an error object (not a string or Error instance) reaches the
error display path, String(obj) produces "[object Object]". Now
extract .message from error-like objects, or JSON.stringify as fallback.

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

* fix: guard JSON.stringify fallback against undefined return

JSON.stringify(undefined) returns undefined (not a string), which would
crash classifyError().toLowerCase(). Add ?? 'Unknown error' fallback.

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

* fix: use non-throwing fallback for error serialization

JSON.stringify can throw on circular objects or BigInt values. Wrap in
try-catch to avoid losing the original error and leaving the stream
stuck in a streaming state.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:23:05 +08:00
yuzifu
cd723000fc fix: show host count in tree view (#364)
* fix: show host count in tree view

* update show host count in tree view

* perf: memoize subtree host count to avoid repeated traversals

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

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:00:31 +08:00
陈大猫
fff031eb25 fix: remove multi_host_execute and fix MissingToolResultsError (#372)
Remove multi_host_execute tool — the AI can call terminal_execute for
each host individually, which is simpler, more reliable, and avoids
the hang issue where parallel remote commands block the stream.

Fix AI_MissingToolResultsError that occurs after user stops a stream
mid-tool-execution: when building SDK messages, skip orphaned tool
calls that have no matching tool result instead of including them
(which causes the SDK to reject the next message).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:29:57 +08:00
yuzifu
2f1fd399cf fix: avoid repeated sync (#370)
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-03-17 16:17:04 +08:00
陈大猫
43c4d4c430 fix: open settings window on the same display as the main window (#367)
Use Electron's screen.getDisplayMatching() to find which display the
main window is on, then center the settings window on that display's
work area. Previously the settings window used Electron's default
placement which could open on the primary display even when the main
window was on an external monitor.

Ref #294

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:05:35 +08:00
陈大猫
835a1231a6 feat: add skip TLS verification option for self-hosted AI providers (#369)
* feat: add skip TLS verification option for AI providers

Self-hosted AI endpoints (vLLM, text-generation-webui, etc.) often use
self-signed TLS certificates which Node.js rejects by default, causing
502 Bad Gateway errors. Add a per-provider "Skip TLS certificate
verification" checkbox that sets rejectUnauthorized=false on both
streaming and non-streaming requests.

Ref #294

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

* fix: surface real error message instead of generic 502 Bad Gateway

- Pass the actual bridge error message in statusText so Vercel AI SDK
  shows the real cause (e.g. "HTTP is only allowed for localhost",
  "URL host is not in the allowed list", TLS errors)
- Show real error details for 5xx provider errors instead of generic
  "The AI provider returned a server error" message

Previously all connection-level errors were masked as "Bad Gateway"
making it impossible for users to diagnose configuration issues.

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

* fix: pass server error body details through to the user

- Read HTTP error response body before resolving (was resolving before
  body was read, losing the error detail)
- Parse OpenAI-compatible JSON error format to extract error.message
- Return error Response with body+statusText for non-2xx instead of
  empty stream, so Vercel AI SDK shows the real server error
- Now users see e.g. "502 model not loaded" instead of just "Bad Gateway"

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

* fix: widen link modifier key dropdown to prevent text wrapping

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

* Revert "fix: widen link modifier key dropdown to prevent text wrapping"

This reverts commit 1f756863910d7450c6ffd8c373ef156e90adcce7.

* fix: apply skipTLSVerify to model listing requests

ModelSelector.aiFetch() didn't pass providerId, so the provider-level
skipTLSVerify was not applied when refreshing/listing models. Add
skipTLSVerify as a direct parameter alongside the provider lookup.

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

* fix: keep error detail in Response body, not statusText

statusText only accepts single-line Latin-1 — multiline or non-ASCII
error messages from self-hosted gateways would throw TypeError before
the AI SDK could read them. Move detailed error to body instead.

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

* fix: return JSON error body for AI SDK compatibility, fix FetchBridge type

- Wrap error responses in OpenAI-compatible JSON format so Vercel AI
  SDK's failedResponseHandler extracts the message correctly instead
  of showing a blank error
- Update FetchBridge type to match the expanded aiFetch parameter list

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

* fix: add ASCII statusText fallback for non-OpenAI SDK providers

Anthropic/Google SDKs fall back to Response.statusText when they can't
parse the error body. Add safe ASCII statusText alongside the JSON body.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:05:09 +08:00
陈大猫
cd512d0800 fix: host-level keyword highlight toggle now overrides global setting (#368)
When a host explicitly disables keyword highlighting, global rules are
no longer applied to that terminal. Previously the OR logic
(globalEnabled || hostEnabled) meant per-host disable had no effect
when global highlighting was enabled.

Now: hostEnabled=false suppresses global rules; hostEnabled=undefined
inherits global setting (backward compatible).

Ref #294

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:38:59 +08:00
陈大猫
0c5ae13692 fix: widen settings dropdown selects to prevent text wrapping (#366)
Log Format "Plain Text (.txt)" and Link modifier key "None (click
directly)" were wrapping to two lines due to narrow widths.

Closes #294 (dropdown text wrapping)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:36:14 +08:00
陈大猫
6727248924 feat: add web search & URL fetch tools for AI agent (#365)
* feat: add web search and URL fetch tools for AI agent

Add web_search and url_fetch tools to Catty Agent, allowing the AI to
search the internet for current information and fetch webpage content.

- Support 5 search providers: Tavily, Exa, Bocha, Zhipu, SearXNG
- Settings UI with provider selection, API key encryption, and config
- web_search is conditional on config; url_fetch is always available
- Both tools are read-only and work in all permission modes (incl. observer)
- aiFetch skipHostCheck for AI tool requests to arbitrary URLs
- System prompt guidelines for when to use search/fetch
- i18n support (en + zh-CN)

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

* fix: address code review findings (SSRF, key exposure, state race)

- P1: Restore SSRF protection when skipHostCheck is true — still block
  localhost, RFC1918, link-local, and cloud metadata endpoints; only
  skip the domain allowlist for public HTTPS hosts
- P2: Move web search API key decryption to main process via dedicated
  IPC handler, matching the existing provider key security model
- P2: Use configRef to avoid stale closure in async settings callbacks
  that could overwrite newer user changes

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

* fix: address second review — DNS rebinding, url_fetch approval, maxResults

- P1: url_fetch now requires approval in confirm mode (outbound GET is
  a side effect that could exfiltrate data via query strings)
- P1: Add DNS resolution check when skipHostCheck is set — resolve
  hostname and reject if any IP is private/loopback/link-local, blocking
  DNS rebinding attacks against internal services
- P2: Slice search results after provider call to enforce maxResults
  consistently (Zhipu and SearXNG ignore the limit parameter)

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

* fix: address third review — localhost/IPv6 SSRF, API key blur race

- P1: Block localhost/loopback when skipHostCheck is enabled — restructure
  isAllowedFetchUrl to check private hosts first in the skipHostCheck path,
  preventing access to local services on allowlisted ports
- P1: Handle IPv6 private ranges (fc00::/7, fe80::/10, ::ffff: mapped),
  strip brackets from URL.hostname, block [::1] and fd00:: addresses
- P2: Guard handleApiKeyBlur against provider change during async
  encryption — skip stale write if provider switched while encrypting

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

* fix: address fourth review — main-process key isolation, SearXNG compat

- P1: Replace aiWebSearchDecryptKey IPC with __WEB_SEARCH_KEY__ placeholder
  pattern — renderer never sees plaintext keys; main process replaces
  placeholder in headers before HTTP request, matching provider key flow
- P1: Search API requests use normal allowlist path (not skipHostCheck),
  so SearXNG on localhost/HTTP/private networks works via aiSyncWebSearch;
  only url_fetch uses skipHostCheck for arbitrary public HTTPS URLs
- P2: Remove needsApproval from url_fetch — treat as read-only like
  sftp_read_file, consistent with observer mode allowlist

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

* fix: address fifth review — private LAN providers, maxResults default

- P1: Allow private-IP hosts that are explicitly in the provider/search
  allowlist (e.g. https://192.168.x.x model providers or SearXNG)
- P2: Remove .default(5) from web_search maxResults schema so the user's
  configured maxResults setting is used when the model omits the param

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

* fix: address sixth review — HTTPS scope, config gate, redirects

- P2: Scope HTTP exception to private/LAN IPs only — remote allowlisted
  hosts still require HTTPS to protect API keys in transit
- P2: Gate web_search tool on complete config (API key for providers that
  require it, apiHost for SearXNG) to avoid advertising a broken tool
- P2: Add redirect following (up to 5 hops) to aiFetch for url_fetch —
  handles 301/302/307 for short links, www canonicalization, etc.

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

* fix: address seventh review — redirect SSRF, decrypt race, HTTPS-only

- P1: Revalidate each redirect hop against SSRF guards (allowlist check
  + DNS resolution) before following, preventing open-redirect SSRF
- P2: Add sequence counter to API key decryption effect — stale promise
  results from a previous provider are discarded on provider switch
- P3: Restrict url_fetch to HTTPS-only URLs, matching the skipHostCheck
  policy that already rejects HTTP in the bridge

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

* fix: address eighth review — OS resolver, allowlisted HTTP hosts

- P1: Use dns.lookup (OS resolver) instead of dns.resolve4/6 for private
  IP checks — matches what http.request actually connects to, respects
  /etc/hosts, mDNS, and other local resolver sources
- P2: Allow HTTP for any explicitly allowlisted host (not just literal
  private IPs), so self-hosted SearXNG at http://searxng.lan works

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

* fix: address ninth review — HTTP scope, blur ordering, decrypt flag

- P1: Narrow HTTP exception to web search apiHost only — AI provider
  endpoints remain HTTPS-only to protect credentials in transit
- P2: Add blur sequence counter to prevent out-of-order encryption
  results from overwriting newer API key saves
- P2: Reset isDecrypting flag when cancelling decrypt on provider switch,
  preventing permanently disabled API key input

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

* fix: address tenth review — DNS pinning, prompt/tool alignment

- P1: Pin validated DNS result to the HTTP request via custom lookup
  function, preventing TOCTOU/DNS-rebinding between validation and
  actual connection
- P2: Extract isWebSearchReady() helper and use it consistently in
  both tool registration and system prompt, so the model isn't told
  web search is available when config is incomplete

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

* fix: address eleventh review — single DNS lookup, redirect pinning, CGNAT

- P1: Combine DNS validation and pinning into a single lookup call,
  eliminating the TOCTOU window between hasPrivateResolution and pinnedLookup
- P1: Pin DNS for redirect targets too — resolve/validate/pin in one step
  before following each redirect hop
- P2: Add 100.64.0.0/10 (CGNAT) to private IP ranges for Tailscale and
  similar CGNAT-addressed internal services

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

* fix: address twelfth review — apiHost validation, sync on enable

- P2: Validate apiHost is a well-formed URL in isWebSearchReady(),
  preventing tool exposure when user enters a malformed host
- P2: Add webSearchConfig.enabled to sync effect deps so the main
  process gets updated immediately when the toggle changes

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

* fix: remove DNS-level SSRF checks that break fakedns/proxy environments

DNS resolution validation (dns.lookup + IP pinning) breaks in proxy
environments where fakedns resolves all domains to LAN addresses.
Revert to hostname-level checks only (blocking localhost, 127.0.0.1,
metadata endpoints, etc.) which are sufficient without false positives.

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

* fix: resolve empty catch block lint warning

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:19:29 +08:00
陈大猫
0eee7bf95a Merge pull request #363 from binaricat/feat/osc52-clipboard
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: add OSC-52 clipboard support
2026-03-16 22:04:39 +08:00
bincxz
b2406ec8a5 fix: auto-reject OSC-52 prompt for hidden tabs and restore focus
- Reject clipboard read requests when terminal is not visible (background
  tab), preventing invisible prompts that block remote programs
- Restore terminal focus after user responds to the prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:53:52 +08:00
bincxz
5fde9c2d61 fix: improve OSC-52 prompt UX
- Reject concurrent read requests instead of overwriting resolver
- Add autoFocus to Allow button for keyboard accessibility
- Support Escape key to deny the prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:49:47 +08:00
bincxz
06a6a0ac12 feat: add 'prompt' mode for OSC-52 clipboard reads
Add a fourth option 'Write + Prompt on Read' that allows clipboard
writes but shows a confirmation dialog before granting read access.
This lets users benefit from remote copy (tmux/vim) while maintaining
control over clipboard reads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:42:22 +08:00
bincxz
024e60ead1 fix: reject unsupported OSC-52 selection targets
Only handle clipboard target ('c'); silently ignore unsupported targets
like 'p' (PRIMARY selection) which Electron cannot access, rather than
incorrectly mapping them to the system clipboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:24:49 +08:00
bincxz
fe71790f0a fix: add osc52Clipboard to syncable terminal settings
Ensures the OSC-52 clipboard preference is preserved across cloud sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:18:54 +08:00
bincxz
9371b3d01b fix: use Electron bridge for OSC-52 read and chunk base64 encoding
- Fall back to netcattyBridge.readClipboardText() for clipboard reads
  since navigator.clipboard.readText() may be unavailable in Electron
- Chunk String.fromCharCode() calls in 8KB batches to avoid stack
  overflow on large clipboard contents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:14:25 +08:00
bincxz
5a1d279efd fix: add OSC-52 settings, UTF-8 support, and clipboard read
- Add osc52Clipboard setting (off/write-only/read-write), default write-only
- Fix UTF-8 decoding: use TextDecoder instead of atob for non-ASCII content
- Support clipboard read requests when mode is read-write
- Add settings UI with Select dropdown and i18n (en + zh-CN)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:08:11 +08:00
bincxz
8b0cbf02c3 feat: add OSC-52 clipboard support for terminal
Register an OSC-52 handler on the xterm parser to allow remote programs
(e.g. tmux, vim, neovim) to write to the local system clipboard via
escape sequences. Read requests are ignored for security.

Closes #362

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:52:29 +08:00
陈大猫
d19fe45a14 Merge pull request #361 from binaricat/fix/win-ssh-agent-pipe-detect
fix: use net.connect() for Windows SSH agent pipe detection
2026-03-16 20:40:26 +08:00
bincxz
344946b096 fix: use net.connect() for Windows SSH agent pipe detection
fs.statSync() is unreliable for Windows named pipes — it returns EBUSY
even when the pipe is fully usable, causing ssh-agent to appear
unavailable. Replaced with net.connect() which is the authoritative
check for named pipe connectivity.

Fixes #360

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:33:58 +08:00
陈大猫
fcd15707d2 Merge pull request #359 from binaricat/fix/auth-split-button
fix: split auth button for clear save/no-save options
2026-03-16 20:07:46 +08:00
bincxz
42c82e46ea fix: split auth button so "continue without save" is clearly separated
The auth dialog's "Continue and Save" button had a dropdown arrow embedded
inside it, but clicking anywhere on the button (including the arrow)
triggered save. Users expected the arrow to offer a no-save option but
couldn't discover it. Refactored to a proper split button: left side
triggers "Continue and Save", right arrow opens a dropdown with
"Continue" (without saving).

Refs #356

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:55:04 +08:00
陈大猫
0e1c3b621a Merge pull request #358 from binaricat/fix/snippet-package-rename
fix: snippet package rename losing snippets and blocking case changes
2026-03-16 19:45:31 +08:00
bincxz
3cd3bbaaf7 fix: snippet package rename losing snippets and blocking case changes
Two bugs in snippet package management:

1. Renaming a package with only case changes (e.g. Speedtest → speedtest)
   was rejected as duplicate because the case-insensitive check didn't
   exclude the package being renamed.

2. Renaming/moving/deleting a package caused its snippets to disappear
   because forEach(onSave) called the state updater multiple times with
   a stale closure, each call overwriting the previous. Only the last
   snippet's update survived. Fixed by adding onBulkSave prop that
   passes the entire updated array in one call.

Fixes #357

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:41:27 +08:00
陈大猫
8bfb50fcbb Merge pull request #355 from yuzifu/fix-distro-detect
fix distro detect
2026-03-16 19:30:54 +08:00
bincxz
c39ef879c3 fix: use effective passphrase for distro detection probe
The distro detection was using the stored key passphrase instead of the
runtime-resolved passphrase, causing silent failures when users retry
with a manually entered passphrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:22:20 +08:00
陈大猫
b3d5785477 fix: allow settings window as trusted IPC sender (#354)
* fix: allow settings window as trusted IPC sender

The settings window runs in a separate BrowserWindow with its own
webContents id. validateSender() only checked the main window id,
causing "Unauthorized IPC sender" errors when fetching AI model
lists from the settings page.

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

* fix: add validateSender to all remaining AI IPC handlers

15 handlers in aiBridge were missing sender validation, allowing
potential unauthorized IPC calls. Now every netcatty:ai:* handler
consistently validates the sender against trusted windows.

Affected handlers: chat:cancel, agents:discover, resolve-cli,
codex:get-integration, codex:start-login, codex:get-login-session,
codex:cancel-login, codex:logout, mcp:update-sessions,
mcp:set-command-blocklist, mcp:set-command-timeout,
mcp:set-max-iterations, mcp:set-permission-mode, acp:cancel,
acp:cleanup.

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

* fix: scope settings window trust to config-only IPC handlers

Per code review feedback: the previous commit allowed the settings
window to access ALL AI IPC handlers including high-risk ones like
exec, terminal:write, and agent:spawn.

Split into two validators:
- validateSender(): main window only (exec, terminal, agent, stream)
- validateSenderOrSettings(): main + settings (fetch, sync, codex
  login, MCP config, agent discovery)

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

* fix: refresh main window id on recreation and allow settings fetch

Two fixes from code review:

1. Always resolve mainWebContentsId from windowManager instead of
   caching it once, so a recreated main window is recognized.

2. Skip static host allowlist for settings window ai:fetch calls,
   since the settings UI lets users configure custom provider URLs
   that haven't been synced to providerFetchHosts yet. Basic URL
   safety (HTTPS-only, no file:// schemes) is still enforced.

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

* fix: enforce HTTPS/port safety for settings window fetch requests

Per review: previous commit skipped isAllowedFetchUrl entirely for
settings window, which removed SSRF protection. Now settings window
fetches still bypass the static host allowlist (since the user is
configuring new providers) but enforce the same safety rules:
- Remote hosts must use HTTPS
- Localhost must use known ports

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

* fix: sync provider config before fetching models in settings

Instead of bypassing the URL allowlist for settings window fetches
(which weakens SSRF protection), have ModelSelector sync the current
provider's baseURL to the backend allowlist before fetching models.
This keeps the full URL safety checks intact while allowing settings
to test custom provider endpoints.

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

* fix: use dedicated allowlist handler instead of syncing providers

Replace the approach of calling aiSyncProviders (which overwrites
the shared providerConfigs) with a new lightweight IPC handler
netcatty:ai:allowlist:add-host that only adds a host to the fetch
allowlist without affecting provider configs or API key resolution.

This preserves the SSRF protection while allowing settings to test
custom provider URLs that haven't been synced from the main window.

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

* fix: auto-expire temporary allowlist entries after 30 seconds

Temporary hosts added via allowlist:add-host now auto-remove after
30s to prevent permanently expanding the SSRF boundary. Built-in
ports and hosts re-added by provider sync are preserved.

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

* fix: prevent temp allowlist cleanup from removing synced providers

The setTimeout cleanup now checks whether the host/port belongs to
a currently synced provider config before removing it. This prevents
the scenario where a user saves a provider within the 30s TTL window
and then loses access when the timer fires.

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

* fix: preserve temp allowlist entries across provider sync rebuilds

rebuildProviderFetchHosts() clears and rebuilds the allowlist from
providerConfigs, which would wipe temporary entries added by
allowlist:add-host. Now re-adds active temp entries after rebuild
to prevent race conditions between settings model listing and
provider sync from the main window.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:11:42 +08:00
yuzifu
05de49f7da fix distro detect
Support distro detection with passphrase keys
2026-03-16 17:32:33 +08:00
bincxz
f77c2b2de9 fix: resolve ESLint errors blocking dev startup
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Add release/** to ESLint ignores (build artifacts were being linted)
- Remove unused eslint-disable directives in useAutoSync and useSettingsState
- Add missing setTerminalSettings dependency to rehydrateAllFromStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:09:00 +08:00
陈大猫
f79f27d737 feat: add settings cloud sync support (#353)
* feat: add settings cloud sync support (closes #347)

Expand SyncPayload.settings to include all syncable user preferences
(theme, appearance, terminal, keyboard, editor, SFTP). Add
collectSyncableSettings/applySyncableSettings helpers in syncPayload.ts,
wire rehydrateAllFromStorage through App.tsx and SettingsPage.tsx so
in-memory React state updates after a cloud download.

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

* fix: include settings in auto-sync uploads and sync empty customCSS

P1: useAutoSync.buildPayload now includes collectSyncableSettings()
so settings are uploaded alongside vault data.

P2: customCSS uses != null check instead of truthy, so clearing CSS
on one device is properly synced to others.

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

* fix: include settings in auto-sync change detection hash

Settings-only changes (theme, terminal options, etc.) now trigger
auto-sync uploads. The data hash comparison includes the settings
snapshot alongside vault data.

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

* fix: trigger auto-sync on settings changes and sync custom terminal themes

P1: Added settingsVersion (derived from all synced settings via useMemo)
to useAutoSync debounce effect dependencies. Settings-only changes now
trigger auto-sync uploads.

P2: Custom terminal themes (STORAGE_KEY_CUSTOM_THEMES) are now included
in the sync payload so custom themes are available on other devices.

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

* fix: reload custom theme store after sync, include in change detection

P1: customThemeStore.loadFromStorage() is now called in
rehydrateAllFromStorage so synced custom themes are immediately
reflected in the live theme store.

P2a: customThemes added to settingsVersion dependencies so custom
theme edits trigger auto-sync.

P2b: Empty custom themes array is now preserved in sync payload
to properly propagate theme deletion.

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

* fix: notify subscribers after custom theme store reload

loadFromStorage now calls notify() to trigger useSyncExternalStore
subscribers, so synced custom terminal themes are immediately
visible in all windows after apply.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:57:41 +08:00
陈大猫
ec35daa0dd feat: add auto-update toggle setting (#351)
* feat: add auto-update toggle setting (closes #346)

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

* fix: re-check auto-update toggle when startup timer fires

Address review feedback: the startup check effect now re-reads the
toggle from localStorage when the delayed timer fires, so toggling
off after launch cancels the pending check. Also avoids setting
hasCheckedOnStartupRef when disabled, allowing re-enable to trigger
a check without restart.

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

* fix: address review feedback on auto-update toggle

P1: When autoDownload=false, onUpdateAvailable no longer transitions
to 'downloading' status. Instead keeps autoDownloadStatus idle so
the manual download link surfaces correctly.

P2: Added reactive autoUpdateEnabled state (synced via storage event)
as a dependency to the startup check effect. Re-enabling the toggle
mid-session now re-triggers the deferred startup check.

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

* fix: address P1/P2 review feedback on auto-update toggle

P1: Main process update-available handler now checks updater.autoDownload
before setting _lastStatus to 'downloading'. When autoDownload=false,
status stays 'idle' so late-opened windows don't hydrate to a stuck
0% download state.

P2: useUpdateCheck now accepts autoUpdateEnabled as a prop from the
caller instead of relying solely on storage events (which don't fire
in the same window). SettingsPage passes settings.autoUpdateEnabled
directly, so toggling in the current window takes effect immediately.

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

* fix: preserve update-available info for late-opening windows

When autoDownload is off, use status 'available' (instead of 'idle')
in the main process snapshot so late-opening windows can hydrate
version info. The renderer maps 'available' to hasUpdate=true while
keeping autoDownloadStatus='idle' for the manual download path.

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

* fix: re-schedule auto-check on re-enable and guard startup timer

- IPC handler now calls startAutoCheck(2000) when re-enabling so the
  user gets automatic checks without restarting the app.
- startAutoCheck timer checks updater.autoDownload at fire time, so
  if the renderer disables auto-update via IPC before the 5s startup
  timer fires, the check is skipped.

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

* fix: deduplicate auto-check scheduling and clear error on fallback success

P1: startAutoCheck now cancels any existing timer before scheduling
a new one, preventing duplicate concurrent checks from multiple
windows or re-enable toggles.

P2: checkNow fallback now clears manualCheckStatus='error' when
electron-updater successfully finds an update (res.available=true),
so the UI shows 'available' instead of a stale error state.

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

* fix: only reschedule on actual re-enable and hydrate cache before toggle check

P2: Track previous autoDownload state in IPC handler so startAutoCheck
is only called on actual false→true transitions, not on every window
mount that syncs the current value.

P3: Move cache hydration (STORAGE_KEY_UPDATE_LATEST_RELEASE) before
the auto-update toggle check so cached update info is always visible
even when automatic updates are disabled.

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

* fix: persist auto-update preference in main process across restarts

Read/write auto-update preference to a JSON file in userData so the
main process honors it on next launch without waiting for renderer IPC.
getAutoUpdater() now initializes autoDownload from the persisted value.

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

* fix: suppress cached update toast when disabled and update IPC types

P2: Cache hydration now gates hasUpdate on autoUpdateEnabled so the
App.tsx toast doesn't fire when automatic updates are disabled.

P3: Updated global.d.ts to include 'available' in getUpdateStatus
status union and 'checking' in checkForUpdate return type.

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

* fix: preserve dismissed releases, show cached updates in Settings, guard concurrent checks

P2a: Updater fallback now checks STORAGE_KEY_UPDATE_DISMISSED_VERSION
before re-surfacing a release found by electron-updater.

P2b: Cache hydration always sets hasUpdate truthfully so Settings
shows the available update. Toast suppression for disabled auto-update
moved to App.tsx (reads localStorage directly).

P3: Re-enable IPC handler checks _isChecking before scheduling
startAutoCheck to prevent concurrent electron-updater calls.

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

* fix: use localStorageAdapter for lint compliance, skip IPC on initial mount

P1: Replace direct localStorage access with localStorageAdapter in
App.tsx toast guard to fix no-restricted-globals lint error.

P2: Skip setAutoUpdate IPC on initial mount to prevent overwriting
the main-process preference file when renderer localStorage has been
cleared (where the default would be true).

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

* fix: hydrate auto-update state from main-process preference on mount

Add getAutoUpdate IPC handler so the renderer can query the persisted
preference from auto-update-pref.json. On mount, useSettingsState
reconciles localStorage with the main-process truth, preventing the
toggle from showing 'enabled' when the user had previously disabled
it and localStorage was cleared.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:54:40 +08:00
陈大猫
ed0775d9d2 Merge pull request #352 from binaricat/feat/global-hotkey-toggle
feat: add global hotkey enable/disable toggle
2026-03-16 12:41:54 +08:00
bincxz
1f31629ce0 feat: add global hotkey enable/disable toggle (closes #349)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:36:37 +08:00
陈大猫
cc4a904dea Merge pull request #350 from binaricat/fix/gemini-empty-function-response-name
fix: resolve Gemini API error caused by empty functionResponse name
2026-03-16 11:56:57 +08:00
bincxz
e9e1d87ff5 fix: resolve Gemini API error caused by empty functionResponse name
When rebuilding SDK messages from conversation history, tool-result
messages had toolName hardcoded to an empty string. This works for
OpenAI/Claude APIs but Gemini requires functionResponse.name to be
non-empty, causing AI_APICallError on every follow-up message.

Now looks up the tool name from the matching assistant tool call
via toolCallId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:43:28 +08:00
陈大猫
a6b07f39ad Merge pull request #348 from yuzifu/fix-dropdown-lists-height
enable scrollbar in dropdown lists when content exceeds max-height
2026-03-16 11:23:36 +08:00
yuzifu
6892e11952 enable scrollbar in dropdown lists when content exceeds max-height 2026-03-16 11:07:56 +08:00
bincxz
ec9be922cb fix: unpack MCP server transitive dependencies from asar
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The MCP server runs as a standalone Node process (not Electron), so it
cannot access modules inside app.asar. Add missing transitive deps
(zod-to-json-schema, ajv, ajv-formats, fast-deep-equal, fast-uri,
json-schema-traverse) to asarUnpack so they are available on disk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:10:56 +08:00
陈大猫
6e961b0efd Merge pull request #345 from binaricat/fix/upgrade-node-pty
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: upgrade node-pty to 1.1.0 stable (#264)
2026-03-16 01:52:13 +08:00
bincxz
d3fe2f9f53 ci: pin Linux x64 build to ubuntu-22.04 for broader glibc compatibility
ubuntu-latest (24.04) links native modules against glibc 2.39 which can
cause dlopen failures on some distros. Pin to 22.04 (glibc 2.35) for
wider compatibility across Linux distributions.

Related: #264

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:51:45 +08:00
bincxz
88760b763e fix: upgrade node-pty from 1.1.0-beta19 to 1.1.0 stable
The beta version had native module loading issues on Arch Linux AppImage
builds (ERR_DLOPEN_FAILED). The stable release uses an improved module
loading strategy with prebuild support for macOS/Windows and better
build-from-source fallback for Linux.

Related: #264

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:50:39 +08:00
陈大猫
6dfe543ab5 Merge pull request #344 from binaricat/fix/windows-ssh-agent-pipe-detection
fix: detect SSH agent via named pipe instead of service status (#343)
2026-03-16 01:41:40 +08:00
bincxz
c000996cb4 fix: detect SSH agent via named pipe instead of service status on Windows
Previously, Netcatty checked if the OpenSSH Authentication Agent Windows
service was running via `sc query ssh-agent`. This broke compatibility
with third-party SSH agents (Bitwarden, 1Password, gpg-agent) that
provide the same named pipe without running the system service.

Now we probe `\\.\pipe\openssh-ssh-agent` directly with fs.statSync,
which works regardless of which agent provides the pipe.

Fixes #343

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:40:05 +08:00
陈大猫
f70b604996 Merge pull request #340 from binaricat/feat/ai-chat-panel
feat: AI chat panel with Vercel AI SDK
2026-03-16 01:35:15 +08:00
bincxz
b973382f9f fix: return real HTTP status in streaming bridge and dynamic provider URL allowlist
- streamRequest now resolves on headers arrival with statusCode/statusText
  so the renderer constructs Response with the real HTTP status (e.g. 401)
  instead of hardcoded 200
- Provider fetch URL allowlist is now dynamically rebuilt from configured
  provider baseURLs on sync, supporting custom provider endpoints
- Localhost port allowlist properly resets on provider sync (no stale ports)
- PTY marker detection requires line-boundary match to avoid false positives
- Clarify terminal_send_input vs terminal_execute usage in tool descriptions
  and system prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 01:34:35 +08:00
bincxz
eeb300295d refactor: improve security, accessibility, performance, and code organization
- Security: API keys no longer transit IPC as plaintext; renderer sends
  providerId and main process decrypts via safeStorage. Add ReDoS
  protection for user-supplied blocklist regex. Sanitize error messages
  to strip file paths and sensitive URLs before displaying in chat.
- Bug fixes: approval timeout now notifies user in chat instead of
  silently aborting. statusText cleared consistently across all 7 code
  paths. useAIState persistence race condition fixed with mountedRef
  guard, storage sync validated with type checks.
- Accessibility: InlineApprovalCard uses role="alertdialog" with focus
  on approve button. ChatInput menus have proper ARIA roles, labels,
  and aria-expanded. ThinkingBlock toggle has aria-expanded/controls.
  Model selector submenu supports keyboard navigation.
- UX: switching agents preserves old session and creates new one.
  Approval card buttons disabled immediately after click.
- Performance: text-delta streaming batched via requestAnimationFrame.
- Refactor: extract useAIChatStreaming, useToolApproval, and
  useConversationExport hooks from AIChatSidePanel (1514 → 751 lines).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:51:23 +08:00
bincxz
be36ccd167 fix: address security, correctness, and performance issues from code review
Security: add validateSender to agent IPC handlers, remove CODEX_API_KEY
and NODE_PATH from SAFE_ENV_KEYS, validate URL scheme before openExternal,
add backtick substitution to command blocklist, add 10MB buffer limits.

Bugs: fix runExternalAgentTurn timeout hang, fix limitConcurrency undefined
entries on error, treat null exitCode as failure, enforce maxBytes for SFTP reads.

Performance: debounce addMessageToSession persistence, cache compiled
blocklist regexes, add QuotaExceededError handling for localStorage writes.

UI: use local onKeyDown for PermissionDialog, move CRITICAL_PATHS to module level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:18:45 +08:00
bincxz
71b13a77a3 fix: address security, correctness, and performance issues from code review
Security: validate agent spawn commands against allowlist, replace execSync
with execFileSync in agent discovery, restrict localhost SSRF to known ports,
add observer mode enforcement at IPC layer, add IPC sender validation,
expand dangerous env key blocklist, add pending approval timeout.

Correctness: replace updateLastMessage with ID-based updateMessageById to
fix race condition during streaming, include tool-call/tool-result messages
in SDK context, flush debounced persist on unmount, fix ACP client break
placement, use proper AI SDK message types.

Performance: pre-compile command blocklist regexps, extract shared tool
executors to deduplicate executor.ts and tools.ts (~50% reduction each).

Also: i18n for hardcoded English strings, remove dead detectDoomLoop code,
add bypass-resistant blocklist patterns, include permissionMode in ACP
provider reuse fingerprint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 00:10:17 +08:00
bincxz
808d021ebe fix: address security, correctness, and performance issues from code review
- Use crypto.randomBytes for PTY markers instead of Math.random
- Replace execSync with execFileSync to prevent command injection
- Fix sftp_write_file missing safety check (pass path to guardWriteOperation)
- Add IPC input validation for handleExec, handleSftpRead, handleMultiExec
- Add maxBytes bounds validation in executor, tools, and MCP server
- Fix approval race condition (clear ref after destructuring, strict messageId match)
- Shorten API key plaintext lifetime in memory
- Fix stream cancellation race with AbortController registered before request
- Delay revokeObjectURL for download timing
- Extract shared limitConcurrency to infrastructure/ai/concurrency.ts
- Add execStream null check in execViaChannel

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:46:13 +08:00
bincxz
d03117733d fix: clear statusText on tool-call and tool-result events
The "Waiting for response from agent..." status text was persisting
after tool completion because only onTextDelta cleared statusText.
When an agent went directly from tool-result to a new tool-call
without emitting text, the stale statusText remained visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:34:10 +08:00
bincxz
1816c3d0df fix: resolve eslint warnings in AI chat panel components
- Copy abortControllersRef to variable before cleanup
- Remove unnecessary useMemo deps (activeTabId, workspaces)
- Remove unused Shield import
- Add missing deps (t, isCustom) to useMemo/useCallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:28:50 +08:00
bincxz
b192ee1764 fix: address security, correctness, and performance issues from code review
Security: sanitize command input in resolveCliFromPath, add host allowlist
to streaming endpoint, enforce permission model in MCP server tools, add
safety check to terminal:write IPC, fix broken blocklist regex, remove
renderer-controlled allowedHosts parameter.

Correctness: use sessionsRef for latest state in handleSend, merge
add+update to avoid race condition in streaming, mark assistant message
completed after tool-result, return JSON-RPC error for unhandled ACP
permission requests, add finished guard in ptyExec.

Performance: custom React.memo comparator for ChatMessageList, fix doom
loop detection threshold.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:27:47 +08:00
bincxz
0b9cb86c4e fix: address security and correctness issues from code review
- Add command blocklist check to terminal_send_input (executor + SDK tools)
- Add session scope validation to all tools in executor.ts
- Fix abort handler to call aiChatCancel instead of aiChatStream
- Enforce URL allowlist in fetch proxy to prevent SSRF
- Wrap event.sender.send with safeSend for destroyed window check
- Filter dangerous env vars (LD_PRELOAD, NODE_OPTIONS, etc.) from agent spawn
- Fix stale debouncedPersistSessions closure using sessionsRef
- Fix cleanupOrphanedSessions race when sessions load before workspaces
- Fix limitConcurrency implementation using Set + finally pattern
- Improve command blocklist regex patterns with word boundaries
- Add regex validation with error feedback in SafetySettings
- Add confirmation dialog for provider removal
- Fix React key warning for tool-role messages in ChatMessageList
- Remove debug console.warn in TerminalLayer
- Remove unused nanoid dependency
- Remove redundant platform-specific codex-acp binary from dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 23:10:51 +08:00
bincxz
bcd44f0177 refactor: remove Claude Agent SDK integration in favor of ACP
All external agents now use ACP protocol exclusively. The Claude Agent
SDK flow was fully implemented but never wired into the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:53:37 +08:00
bincxz
d8d29d1709 fix: address code review findings for AI chat panel
Security:
- Apply checkCommandSafety() in aiExec IPC handler to enforce command blocklist
- Add critical path validation in handleSftpRemove (normalize + blocklist)
- Change rm -rf to rm -r so permission errors surface

Stream lifecycle:
- Abort all active streams on component unmount (abortControllersRef cleanup)
- Wrap stream reader in try/finally with releaseLock() to prevent leaks
- Use refs for inputValue/images in handleSend to stabilize callback identity

State persistence:
- Clear debounce timer before synchronous persist in destructive operations
  (clearSessionMessages, deleteSession, deleteSessionsByTarget)
- Add 10MB max buffer guard in ACP client and MCP server NDJSON parsing

i18n:
- Replace hardcoded English strings with t() calls in InlineApprovalCard,
  PermissionDialog, ConversationExport, ThinkingBlock, AgentSelector
- Add 23 new i18n keys to en.ts and zh-CN.ts

Misc:
- Remove debug console.log statements in mcpServerBridge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:38:03 +08:00
bincxz
0820569166 fix: use session ID in approval finally block for streaming cleanup
The finally block in handleApprovalResponse still used scope key (sk)
instead of session ID (sid) for setStreamingForScope and abort controller
cleanup, causing the streaming indicator to not clear after approval flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:09:28 +08:00
bincxz
545506ac86 fix: track streaming state per session instead of per scope
When switching agents or creating a new chat within the same scope,
the stop button stayed red because streaming was keyed by scopeKey
(which doesn't change). Now streaming and abort controllers are keyed
by sessionId, so switching to a different session correctly shows the
idle state while the old session continues streaming in background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 21:00:29 +08:00
bincxz
29fca33ffd fix: reduce stall timeout to 3s and show status with shimmer effect
- Reduce ACP stall detection from 15s to 3s for faster feedback
- Add statusText field to ChatMessage for transient status display
- Render status text with thinking-shimmer CSS animation
- Clear statusText when real content arrives (onTextDelta)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:35:17 +08:00
bincxz
216ea7f177 feat: show ACP agent status messages (stall detection) in chat
- Add stall detection in ACP stream loop: if no chunk received for 15s,
  send a "Waiting for response..." status event to the chat panel
- Add onStatus callback to AcpAgentCallbacks, render as italic text
- Forward status events from main process to renderer via acp:event IPC

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:26:11 +08:00
bincxz
b280caded2 fix: default Codex model selection includes thinking level suffix
When no model is stored, the default was bare "gpt-5.4" which Codex
rejects. Now defaults to "gpt-5.4/xhigh" (highest thinking level)
for models that require a thinking level suffix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:12:42 +08:00
bincxz
2d4f260f0b fix: address review findings from refactoring review
- Fix stale closure: add updateLastMessage to handleApprovalResponse deps
- Use random heredoc delimiter to prevent content corruption when file
  contains the literal delimiter string
- Remove dead ensureClaudeConfigDir function from claudeHelpers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 20:03:36 +08:00
bincxz
e69bc53aa4 feat: improve error display with structured error messages in AI chat
- Add errorInfo field to ChatMessage with type classification
  (network/auth/timeout/provider/agent/unknown) and retryable flag
- Create errorClassifier.ts to map raw error strings into user-friendly
  structured messages with actionable hints
- Replace inline "**Error:**" text appending with dedicated error messages
  rendered as styled error cards with AlertCircle icon
- Ensure streaming indicator is cleared immediately on error in ACP and
  external agent flows (not just in finally block)
- Mark previous message executionStatus as failed only when it was running

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:55:04 +08:00
bincxz
a55da77471 refactor: fix security issues, remove dead code, and split monolithic files
Security:
- Fix command injection in 15+ shell interpolation sites with shellQuote()
- Add token-based authentication to MCP TCP server
- Fix heredoc delimiter collision in SFTP write fallbacks
- Add case-insensitive flag to mcpServerBridge command blocklist
- Add exhaustive default case to createModelFromConfig

Architecture:
- Split aiBridge.cjs (1971→1483 lines) into 4 modules:
  shellUtils, codexHelpers, claudeHelpers, ptyExec
- Split SettingsAITab.tsx (1480→520 lines) into 10 sub-components
- Extract shared processCattyStream from AIChatSidePanel.tsx (-168 lines)
- Consolidate duplicate PTY execution logic into shared ptyExec.cjs
- Consolidate duplicate stripAnsi/toUnpackedAsarPath utilities

Code quality:
- Remove ~33 debug console.log statements from production code
- Remove dead code: terminal_read_output stub, checkToolPermission,
  isCommandAllowed, READ_ONLY_TOOLS
- Unify bridge type accessor in useAIState.ts (eliminate 15 unsafe casts)
- Add localStorage session pruning (50 sessions / 200 messages cap)
- Fix ModelSelector onBlur race condition (setTimeout → preventDefault)
- Fix duplicate getShellEnv() calls in Codex helpers
- Remove unused _config param from claudeAgentAdapter
- Type claudeAgentAdapter bridge parameter directly as ClaudeBridge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 19:45:50 +08:00
bincxz
33d3a86d83 feat: show permission mode switcher for all agents, not just Catty
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:57:41 +08:00
bincxz
f73c060351 fix: enforce Observer mode for Catty Agent write tools
Catty Agent tools call the bridge directly (not via MCP Server), so the
MCP-level observer enforcement doesn't apply. Add explicit isObserver
guards in all write tool execute functions (terminal_execute,
terminal_send_input, sftp_write_file, multi_host_execute) to return
an error when Observer mode is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:54:19 +08:00
bincxz
304ebf1e3b feat: add inline permission mode switcher for Catty Agent in chat input
Show a clickable chip next to the model selector in ChatInput footer
that lets users quickly toggle between Observer/Confirm/Auto permission
modes. Only visible when Catty Agent is selected (ACP agents handle
their own tool approval flow).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:52:19 +08:00
bincxz
2788dbdff5 feat: replace modal permission dialog with inline approval cards and fix approval continuation flow
Replace the full-screen PermissionDialog modal with inline InlineApprovalCard
rendered within chat messages. Implement the multi-turn approval flow so that
clicking Approve actually resumes the agent loop via a new streamText call with
proper SDK tool-approval-response messages.

Fix toolCallId extraction (toolCall.toolCallId, not chunk.toolCallId) and use
correct SDK field name (input, not args) for ToolCallPart content parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:30:55 +08:00
bincxz
84fe0134c9 feat: implement UI-level tool confirmation for Catty Agent confirm mode
Use Vercel AI SDK's native `needsApproval` on write tools (terminal_execute,
terminal_send_input, sftp_write_file, multi_host_execute) when permission
mode is "confirm". When the SDK emits a `tool-approval-request` stream event,
show the existing PermissionDialog component for user to approve/reject.

- tools.ts: replace manual checkToolPermission() calls with `needsApproval`
  property on write tools; keep blocklist checks in execute()
- AIChatSidePanel: handle `tool-approval-request` chunk type, show
  PermissionDialog via Promise-based pause, resolve on user action
- Add i18n key for tool denied message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:04:11 +08:00
bincxz
06dc7400f2 fix: use Sparkles icon for AI settings tab to match top toolbar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:10:26 +08:00
bincxz
d1a59ed40c fix: add missing cross-window sync for host permissions and agent model map
The StorageEvent handler was missing cases for STORAGE_KEY_AI_HOST_PERMISSIONS
and STORAGE_KEY_AI_AGENT_MODEL_MAP, so changes made in the settings window
were not picked up by the main window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:08:50 +08:00
bincxz
f90aa81b2c feat: enforce safety settings, blocklist UI, provider UX improvements, and full i18n
Safety enforcement:
- Command Timeout: use user setting in MCP Server (execViaPty/execViaChannel)
  and Catty Agent path (aiBridge execViaPtyForCatty/execViaChannel fallback)
- Max Iterations: read user setting for Catty (stepCountIs) and ACP (via IPC)
- Permission Mode: observer mode hard-blocks write ops in MCP Server dispatch;
  Catty SDK tools wired to checkToolPermission(); all synced via IPC on change

Command Blocklist UI:
- Add editable regex pattern list in Settings AI Safety section
- Reset to defaults button, add/remove patterns
- IPC sync to MCP Server on change and on mount

Provider UX:
- ModelSelector rewritten as combobox: type-to-filter with suggestions dropdown
- All providers use unified ModelSelector (modelsEndpoint optional)
- API key passed as auth header for model fetching (Bearer / x-api-key)
- Skip model fetch when no API key (except Ollama)
- Provider toggle is now mutually exclusive (activating one disables others)
- New providers default to disabled (switch off)
- Custom provider supports editable display name
- No-provider error: friendly message shown in chat

i18n:
- Full i18n coverage for Settings AI tab (~55 keys)
- Full i18n for AI chat panel (placeholder, empty state, time, sessions)
- en.ts and zh-CN.ts locale files updated

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:05:41 +08:00
bincxz
950819746e fix: per-agent model memory and correct Claude ACP model presets
- Replace single selectedAgentModel state with per-agent agentModelMap
  persisted to localStorage, so each agent remembers its last selected model
- Default to first preset when no prior selection exists
- Fix Claude model presets: use 'default' instead of 'opus' to match
  what claude-code-acp actually exposes (default=Opus 4.6)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:52:58 +08:00
bincxz
4a3a4b9d9b fix: restore agent on tab switch, i18n agent settings, icon improvements
- Restore currentAgentId from active session when switching scopes
- Add i18n for "Agent Settings" label in agent selector
- Add agent icons to settings page default agent dropdown
- Replace catty.svg with new icon, use Settings icon for manage button
- Fix lint: missing useCallback deps, no-restricted-globals, useMemo deps
- Guard webContents.send against disposed render frames in windowManager

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 15:25:54 +08:00
bincxz
726ff82a9e fix: improve side panel UI and add Catty agent icon
Enlarge side panel tab buttons for better clickability, persist panel
width to localStorage, switch AI tab icon to MessageSquare, and add
dedicated catty.svg with violet badge styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:40:15 +08:00
bincxz
7e8682d10d fix: preserve ACP provider session when switching chat sessions
Remove chatSessionId from MCP server config env vars so it doesn't
affect the fingerprint calculation. Previously, different chat sessions
in the same workspace produced different fingerprints, causing the ACP
provider to be recreated when switching back to a previous session,
losing all conversation history.

Now the fingerprint only depends on the workspace scope (host session
IDs and MCP port), so providers are correctly reused when returning
to a previous chat session. Each chat session still has its own
provider instance (keyed by chatSessionId in the acpProviders Map).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:35:48 +08:00
bincxz
b2447b06d2 fix: per-scope AI chat isolation and workspace host discovery
- Fix workspace host discovery: use `activeWorkspace.root` instead of
  non-existent `.tree` property, which caused `collectSessionIds` to
  always return empty for workspaces
- Isolate AI chat state per-scope (tab/workspace): activeSessionId,
  isStreaming, abortController, and inputValue are now keyed by
  `${scopeType}:${scopeTargetId}` so different tabs don't interfere
- Add per-chatSession MCP scope metadata to prevent host list mixing
  across workspaces, with chatSessionId passed through the full IPC chain
- Store hostname/username in SSH session objects as fallback for MCP
  host info when renderer metadata is unavailable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:59:47 +08:00
bincxz
ed8a6a6cf2 fix: remove unused Claude Agent SDK path, fix scope for all agent types
- Remove claude-agent-sdk streaming path (unused, causes confusion)
- Claude Agent now uses ACP path like other external agents
- ACP path already has aiMcpUpdateSessions for scope isolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 04:17:28 +08:00
bincxz
f0f5803a6d fix: enforce workspace scope isolation for MCP server sessions
The MCP server was exposing ALL terminal sessions to AI agents regardless
of which workspace the agent belonged to. Fixed by:

- Track scoped session IDs when updateSessionMetadata is called
- buildMcpServerConfig now auto-uses current scoped IDs when no explicit
  scope is provided, setting NETCATTY_MCP_SESSION_IDS env var
- handleGetContext falls back to sessionMetadata keys when no explicit
  scopedSessionIds param is passed

This ensures agents only see hosts within their workspace/terminal scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 04:12:21 +08:00
bincxz
f53bc05cb3 feat: overhaul AI settings, fix Catty Agent streaming and tool execution
AI Settings:
- Remove External Agents section, add dedicated Codex/Claude Code sections
  with auto-detection and manual path override
- Add OpenRouter model list auto-fetch with searchable dropdown
- Remove standalone Default Model section, integrate into provider cards
- Provider toggle now acts as mutual-exclusive active selector
- Add cross-window state sync via storage events
- Add ErrorBoundary around AI tab for graceful error handling

Catty Agent fixes:
- Fix streaming: use .chat() instead of default Responses API, use
  getReader() pattern instead of for-await, handle text-delta/text chunk
  types correctly per SDK v6
- Fix API key: decrypt encrypted key before passing to SDK
- Fix terminal_execute: use PTY stream (visible in terminal) with MCP
  markers instead of invisible SSH exec channel
- Fix multi-turn: only pass user/assistant text history, skip tool
  messages to avoid SDK schema validation errors
- Fix tool result display: create new assistant message after tool
  results so follow-up text renders correctly

Other:
- Add netcatty:ai:resolve-cli IPC handler for CLI path validation
- Remove Gemini from agent discovery (only Codex + Claude Code)
- Fix lint errors across ChatInput, TerminalLayer, SettingsAITab
- Strip MCP markers from tool execution stdout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 03:53:39 +08:00
bincxz
3136100514 feat: image attachments, model selector with thinking levels, PTY marker filtering, and UX improvements
- Add image paste/drop support with base64 encoding and chat display
- Add + button popover (Files, Image, Mention Host) with @ auto-complete
- Add model selector with Codex thinking level sub-menus (GPT 5.4, Codex 5.x, o3/o4-mini)
- Switch Claude Code to ACP protocol; remove CLAUDE_CONFIG_DIR isolation
- Filter PTY exec markers in preload data pipe (precise regex, preserves command echo)
- Increase PTY exec timeout to 5min with Ctrl+C cancellation on abort
- Fix tool call loading spinner (only animate during active streaming)
- Reset active session on terminal/workspace switch
- Add AI sparkle button in top bar to toggle AI panel
- Display user-attached images in chat message list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 02:03:11 +08:00
bincxz
847df7a023 feat: add MCP server for remote host access, model selector, and PTY execution
- Create netcatty-remote-hosts MCP server (TCP bridge + stdio child process)
  exposing terminal_execute, sftp_*, get_environment tools to ACP agents
- Execute commands via PTY stream with self-erasing markers for terminal
  visibility; disable pagers automatically; 60s timeout fallback
- Inject MCP server into both Codex (ACP) and Claude Code (ACP) sessions
- Add model selector popover with hardcoded presets for Claude (Opus/Sonnet/
  Haiku) and Codex (GPT 5.4, Codex 5.3/5.2/5.1, o3/o4-mini) with thinking
  level sub-menus
- Fix multi-step tool call message flow (mutable flag instead of stale closure)
- Remove CLAUDE_CONFIG_DIR isolation to fix Claude auth; switch Claude to ACP
- Add startup cleanup for orphaned AI sessions
- Guard collectSessionIds against undefined workspace tree
- Remove permission mode chip from chat input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 00:43:04 +08:00
bincxz
150724fc7c fix: enforce session-agent binding and scope-based lifecycle management
- Switching agent deactivates current session, next message creates a new one
- Filter history sessions by both scopeType and targetId
- Restore agent selector when resuming a historical session
- Auto-cleanup AI sessions when terminal/workspace instances are destroyed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:42:09 +08:00
bincxz
8949394756 feat: add Claude Agent SDK streaming and Codex OAuth integration
Integrate Claude Agent SDK for direct streaming chat, add Codex login/logout
flow with OAuth support in settings, improve AI chat panel UI and agent
discovery, and update build config for new dependencies.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:27:41 +08:00
bincxz
7f3214e088 feat: integrate external AI agents via ACP protocol
- Add auto-discovery of CLI agents (Claude Code, Codex, Gemini) from system PATH
- Integrate ACP (Agent Client Protocol) for real-time streaming with codex-acp
- Bundle @zed-industries/codex-acp binary for reliable agent spawning
- Add ThinkingBlock component with shimmer animation and auto-collapse
- Refactor chat UI: no avatars, bordered user bubbles, plain assistant text
- Support {prompt} placeholder in agent args for flexible invocation
- Add persistent ACP sessions with proper cleanup on app exit
- Detect auth errors and show actionable messages to users
- Fallback to raw process spawn for agents without ACP support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 17:21:28 +08:00
陈大猫
eaab7d72cb Merge pull request #341 from yuzifu/fix-theme-and-fontsize-to-new-host 2026-03-14 16:22:46 +08:00
yuzifu
63a7c06037 Allow get theme/fontsize from system config for new host 2026-03-14 15:57:49 +08:00
bincxz
72887c35b5 feat: add AI chat panel with Vercel AI SDK integration
Add a comprehensive AI assistant feature to netcatty, enabling AI-powered
terminal automation and multi-host orchestration.

Core features:
- AI chat side panel (Zed-style) with agent selector, session history,
  conversation export (Markdown/JSON/TXT), and streaming responses
- Catty Agent: built-in terminal assistant with 9 tools (terminal exec,
  SFTP read/write, multi-host orchestration) using zod schemas
- BYOK provider support: OpenAI, Anthropic, Google, Ollama, OpenRouter,
  and custom OpenAI-compatible endpoints
- Three-tier permission model: Observer / Confirm / Autonomous
- Command safety: blocklist patterns, doom loop detection, abort support
- External agent support: ACP protocol (JSON-RPC over stdio) for
  Claude Agent, Codex CLI, Gemini CLI, and custom agents

Tech stack:
- Vercel AI SDK (ai, @ai-sdk/openai, @ai-sdk/anthropic, @ai-sdk/google)
  with streamText + fullStream for streaming tool-call loops
- AI Elements: adapted conversation (use-stick-to-bottom), message
  (streamdown markdown), prompt-input (InputGroup) components
- Custom bridge fetch adapter routing all API calls through Electron
  main process IPC to avoid CORS
- zod for tool parameter schemas

UI components:
- AIChatSidePanel, AgentSelector, ChatInput, ChatMessageList
- PermissionDialog, ExecutionPlan, ConversationExport
- AI Elements: conversation, message, tool-call, prompt-input
- New UI primitives: InputGroup, Spinner
- Settings AI tab for BYOK configuration and safety settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 14:22:28 +08:00
陈大猫
4373a8ce14 Merge pull request #339 from binaricat/feat/toolbar-tooltips
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
ui: add styled tooltips to terminal and SFTP toolbar buttons
2026-03-14 01:51:15 +08:00
bincxz
007fe47310 ui: add styled tooltips to terminal and SFTP toolbar buttons
Replace native title attributes with Radix UI Tooltip components for
a consistent, styled tooltip experience across both toolbars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:47:46 +08:00
陈大猫
9109fc2f6e Merge pull request #338 from binaricat/feat/default-dark-theme
feat: default theme to dark for new users
2026-03-14 01:42:30 +08:00
bincxz
961f79d3d8 feat: change default theme from system to dark for new users
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:41:54 +08:00
陈大猫
494fc27454 Merge pull request #337 from binaricat/fix/memory-leaks-and-cpu-optimization
fix: resolve memory leaks and reduce unnecessary CPU consumption
2026-03-14 01:38:36 +08:00
bincxz
a85324c9fb fix: resolve memory leaks and reduce unnecessary CPU consumption
- Fix onSelectionChange listener leak in Terminal.tsx (missing dispose on cleanup)
- Debounce window resize handler in TopTabs.tsx to prevent IPC storm
- Use .once() for SSH/SFTP/PortForward connection lifecycle events (ready/error/timeout/close)
  to prevent listener accumulation across sessions
- Clean up sessionEncodings/sessionDecoders maps in all error paths in sshBridge
- Use .once() for execCommand() connection events (creates new conn per call)
- Remove duplicate requestAnimationFrame in useSftpPaneVirtualList
- Capture and dispose OSC 7 parser handler in createXTermRuntime

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:36:12 +08:00
陈大猫
860739bb97 Merge pull request #336 from binaricat/feat/side-panel-position-toggle
feat: add toggle to move side panel between left and right
2026-03-14 01:14:39 +08:00
bincxz
a6494bfb78 feat: add toggle button to move side panel between left and right
Add a position toggle button next to the close button in the side panel
header. The position preference is persisted in localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 01:11:16 +08:00
bincxz
1fa11c2c2d ui: show spinning icon during terminal connection progress
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:51:47 +08:00
陈大猫
35b8990a9c Merge pull request #335 from binaricat/fix/terminal-block-char-gaps
fix: enable customGlyphs to eliminate gaps between block characters
2026-03-14 00:39:47 +08:00
bincxz
67536c9424 fix: enable customGlyphs to eliminate gaps between block characters
Enable xterm.js customGlyphs option so box-drawing and block characters
are rendered by canvas instead of font glyphs, eliminating visible gaps.

Closes #331

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:39:29 +08:00
陈大猫
4dbbb96e4d Merge pull request #334 from binaricat/fix/webdav-self-signed-cert
feat: allow ignoring certificate errors for WebDAV connections
2026-03-14 00:35:19 +08:00
bincxz
5cb8b348b3 fix: handle allowInsecure in WebDAVAdapter fallback path
- Add httpsAgent with rejectUnauthorized:false in WebDAVAdapter.createClient()
  so the fallback (non-bridge) path also respects the allowInsecure option
- Use explicit ternary for allowInsecure config serialization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:32:54 +08:00
bincxz
06efcfe384 feat: add option to ignore certificate errors for WebDAV connections
Allow users to bypass TLS certificate verification for WebDAV endpoints
using self-signed certificates, which is common for LAN NAS devices
(Synology, FNAS, Unraid, etc.).

Closes #332

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:28:23 +08:00
陈大猫
4877c934fa feat: move Scripts and Theme to side panel sub-tabs (#333)
* feat: move Scripts and Theme from toolbar popups to side panel sub-tabs

Migrate Scripts (snippet library) and Theme customization from toolbar
popover/modal dialogs into the left side panel alongside SFTP. The panel
header now shows three tab buttons (SFTP / Scripts / Theme) so users can
switch between sub-panels without losing SFTP connections.

- Add ScriptsSidePanel with package hierarchy, breadcrumb nav and search
- Add ThemeSidePanel adapted from ThemeCustomizeModal (no preview pane)
- Generalize TerminalLayer state from sftpOpenTabs to sidePanelOpenTabs
- Simplify TerminalToolbar by removing inline popover and modal rendering
- Clicking the already-active tab button is a no-op; only X closes panel
- Theme/font changes apply in real-time to the actual terminal behind

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

* fix: address PR review findings for side panel migration

- Clean up sftpInitialLocationForTab on panel close
- Remove unused handleCloseSidePanel from deps array
- Re-focus terminal after snippet execution from side panel
- Use props directly in ThemeSidePanel instead of mirrored local state
- Use ?? instead of || for falsy-safe theme/font/size defaults
- Extract isFocusedHostLocal into memoized value

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 00:21:34 +08:00
陈大猫
c542520dee feat: SFTP sidebar polish - workspace caching, toolbar overflow, terminal cwd navigation
## Summary
- Add SFTP side panel with workspace-level connection caching for instant switching between terminal endpoints
- Responsive toolbar with overflow menu that collapses action buttons when panel is narrow, prioritizing breadcrumb path display
- Silent terminal CWD detection via separate SSH exec channel (no visible commands in terminal)
- Extract SftpTransferQueue as reusable component with i18n support
- Remove passphrase from port forwarding credentials (decrypted at load time)
- Add compressed upload support to uploadEntriesDirect
- Fix various eslint warnings and code quality issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:39:56 +08:00
陈大猫
0b61d10953 Merge pull request #329 from MiracleLau/fix-portforward-no-passphrase-given
fix: no passphrase given error on port forwarding launch
2026-03-13 18:45:02 +08:00
MiracleLau
347361bc7b fix: complete incomplete parameters for startTunnel 2026-03-13 17:02:15 +08:00
MiracleLau
746c336ee1 fix: no passphrase given error on port forwarding launch 2026-03-13 16:43:22 +08:00
陈大猫
6373762399 Merge pull request #327 from binaricat/feat/tab-redesign-os-icons
feat: redesign tab bar with OS/distro icons
2026-03-13 15:08:47 +08:00
陈大猫
27b8d4a410 Merge pull request #328 from yuzifu/fix-host-group
fix: show all nodes in the Group field of host details.
2026-03-13 15:07:06 +08:00
yuzifu
27773c58db fix: show all nodes in the Group field of host details. 2026-03-13 14:59:06 +08:00
bincxz
ecb48e89a5 feat: redesign tab bar to Windows Terminal style with OS/distro icons
- Redesign tabs from rounded rectangle + accent border to flat-bottom
  Windows Terminal style with top accent line indicator
- Show OS/distro icons with brand background colors in session tabs
- Add OS-specific icons (macOS/Windows/Linux) for local terminal tabs
  with auto-detection via navigator.userAgent
- Add SVG assets for macOS, Windows, and Linux logos
- Give Vaults tab a distinct style (rounded, semi-transparent bg,
  no accent line) to differentiate from session tabs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:54:00 +08:00
陈大猫
d609d8edb3 Merge pull request #326 from binaricat/fix/sftp-modal-upload-race-condition
fix: prevent SFTP modal drag-upload from targeting stale directory
2026-03-13 14:13:37 +08:00
bincxz
5f91fbbab8 fix: prevent SFTP modal drag-upload from targeting stale directory
When reopening the SFTP modal via drag-and-drop, the session effect's
initialization IIFE runs async (ensureSftp + listSftp ~0.5s). During
this window, dependency changes (e.g. loadFiles recreation from
files.length change by the layout effect clearing stale cache) can
re-trigger the session effect. Since initializedRef is already true,
the effect falls through to loadFiles(currentPath) with the OLD path.
If this loadFiles resolves before the IIFE, loading transitions to
false prematurely, causing the auto-upload to snapshot the stale
currentPathRef and upload to the wrong directory.

Add an initializingRef flag that is set when the initialization IIFE
starts and cleared in its finally block. The fallthrough loadFiles
call is skipped while initializingRef is true, ensuring only the
IIFE's completion triggers the loading transition that the auto-upload
effect relies on.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:06:26 +08:00
陈大猫
89c3c7f83a Merge pull request #325 from binaricat/fix/mac-tray-update-restart
fix: destroy system tray before quitAndInstall on macOS
2026-03-13 13:36:52 +08:00
bincxz
ee391bcc32 fix: destroy system tray before quitAndInstall on macOS
On macOS, the system tray keeps the app process alive after all windows
are closed, preventing quitAndInstall from completing the restart.
Clean up the tray and its panel window before calling quitAndInstall so
the app can exit cleanly and the installer can proceed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:32:44 +08:00
陈大猫
26fd5023f5 Merge pull request #324 from binaricat/fix-sync-knowhosts
fix: known hosts sync not work
2026-03-13 13:27:09 +08:00
bincxz
49543abcff Merge main into fix-sync-knowhosts and resolve conflicts
Resolve conflict in useAutoSync.ts by integrating getEffectiveKnownHosts
into the refactored getSyncSnapshot function, avoiding duplication in
getDataHash which now delegates to getSyncSnapshot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:02:35 +08:00
陈大猫
6bab971de8 Merge pull request #323 from binaricat/codex/fix-auto-sync-overlap
Fix overlapping auto-sync retries
2026-03-13 11:44:26 +08:00
bincxz
392a57f95b Fix overlapping auto-sync handling 2026-03-13 11:38:17 +08:00
yuzifu
85e3e8b26f fix: known hosts sync not work 2026-03-13 11:30:29 +08:00
陈大猫
9747498833 Merge pull request #321 from yuzifu/fix-hosts-count
fix: show hosts count in the group
2026-03-13 11:02:59 +08:00
yuzifu
520e2c3f9d fix: show hosts count in the group 2026-03-13 10:47:58 +08:00
陈大猫
cb5333e336 Merge pull request #320 from binaricat/codex/sftpmodal-parent-entry
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Fix SFTP modal parent navigation in empty directories
2026-03-12 19:00:41 +08:00
bincxz
d3153148c8 Fix SFTP modal empty directory parent navigation 2026-03-12 18:57:19 +08:00
陈大猫
899cb109b4 Merge pull request #319 from binaricat/codex/fix-sftp-drop-target-race
fix: keep terminal drag-drop uploads on the resolved SFTP path
2026-03-12 18:47:15 +08:00
bincxz
d031bf355d fix: use resolved sftp path for initial auto upload 2026-03-12 18:40:24 +08:00
bincxz
489b7711f5 fix: pin terminal drop uploads to the resolved sftp path 2026-03-12 18:07:10 +08:00
陈大猫
65877fd912 feat(sync): include snippetPackages in cloud sync payload (#318)
* feat(sync): include snippetPackages in cloud sync payload (#315)

Snippet packages (the grouping tree for code snippets) were not included
in the cloud sync payload, causing them to be lost when syncing across
devices. This adds snippetPackages as an optional field following the
same backward-compatible pattern used by knownHosts and
portForwardingRules: old payloads that lack the field leave local
packages untouched.

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

* fix: make snippetPackages optional in SyncableVaultData for consistency

Aligns with the pattern used by knownHosts — optional in both
SyncableVaultData and SyncPayload so that legacy data without the field
is handled gracefully. Also updates the SyncPayloadImporters docstring.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:02:52 +08:00
陈大猫
117ec260b6 fix: address issue #294 follow-up regressions (#316)
* fix: address issue 294 regressions

* fix: scope sftp hidden files toggle per pane

* fix: restore terminal auto-follow behaviors

* fix: keep keypress auto-scroll scoped to keypress

* feat: add hidden files toggle to sftp modal

* fix: tighten sftp and terminal review findings
2026-03-12 16:19:22 +08:00
陈大猫
c76ff7ac9a Merge pull request #317 from yuzifu/feat-support-almalinux
feat: support almalinux distro
2026-03-12 15:37:21 +08:00
yuzifu
17da21b1cd feat: support almalinux distro 2026-03-12 14:49:54 +08:00
陈大猫
733e36a728 Merge pull request #314 from penguinway/feat/auto-update-unified
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
LGTM! 经过多轮 review 和修复,代码质量已经很好。

核心改进:
- 统一 useUpdateCheck 作为跨窗口的单一更新状态源
- 多窗口 IPC 广播(broadcastToAllWindows)
- 启动检查竞态缓解(8s delay + onUpdateAvailable/NotAvailable 取消)
- dismissed version 在 renderer 侧完整支持
- electron-updater fallback(GitHub API 不可用时)
- _isChecking 标记防止并发 checkForUpdates 调用

感谢合并!
2026-03-11 20:34:20 +08:00
bincxz
35174246cc fix(update): handle checking sentinel, restore dismiss for unsupported platforms
- Handle { checking: true } response from bridge.checkForUpdate()
  separately instead of treating it as "no update" — an in-flight
  check will resolve via IPC events
- Restore dismissUpdate() in "View in Settings" toast onClick so
  unsupported-platform users can suppress the notification; on
  supported platforms the Settings window picks up download state
  via IPC events independently

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:24:54 +08:00
bincxz
ab13670eaa fix(update): sync dismissed ref for late windows, clear error on fallback success
- Set dismissedAutoDownloadRef when hydration skips a dismissed version
  so subsequent IPC events (progress/downloaded) are also suppressed
- When GitHub API fails but electron-updater fallback finds no update,
  clear manualCheckStatus from 'error' to 'up-to-date' instead of
  leaving Settings stuck in error state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:18:50 +08:00
bincxz
4f3e39e378 fix(update): fall back to electron-updater when GitHub API fails
When checkNow's GitHub API call fails (blocked/rate-limited), still
trigger electron-updater's checkForUpdate as a fallback. This restores
the update path for environments where api.github.com is unreachable
but the updater feed is still accessible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:12:18 +08:00
bincxz
2281d1df68 fix(update): don't throttle GitHub fallback from updater not-available
Remove STORAGE_KEY_UPDATE_LAST_CHECK write from onUpdateNotAvailable
handler — it would prevent the GitHub API fallback from running on app
restart, hiding releases that exist on GitHub but aren't yet in the
electron-updater feed. Let performCheck write the timestamp instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:05:47 +08:00
bincxz
e570185e2f fix(update): don't dismiss when navigating to Settings from toast
- Remove dismissUpdate() from "View in Settings" toast onClick — writing
  to STORAGE_KEY_UPDATE_DISMISSED_VERSION would cause the Settings window
  hydration to skip download state, making it appear idle
- Remove unused dismissUpdate import from App.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:58:33 +08:00
bincxz
12884165b5 fix(update): preserve GitHub fallback on not-available, recheck on reschedule
- Don't cancel startup GitHub API fallback when electron-updater says
  not-available — the GitHub release may exist before updater feed
  assets are published, and the fallback provides manual download link
- Rescheduled fallback now re-queries getUpdateStatus to avoid duplicate
  notifications on very slow networks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:52:04 +08:00
bincxz
11f82defc3 fix(update): use dismissed ref instead of idle check, preserve GitHub fallback
- Replace autoDownloadStatus==='idle' guard in progress/downloaded/error
  callbacks with a dedicated dismissedAutoDownloadRef to distinguish
  "dismissed version" from "not hydrated yet" in late-opening windows
- Don't clear hasUpdate on update-not-available — GitHub release may
  exist even when electron-updater feed says no compatible update,
  preserving the manual download fallback path
- Reset dismissedAutoDownloadRef on manual retry via checkNow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:45:24 +08:00
bincxz
ac9175b770 fix(update): reschedule fallback on in-flight check, clear stale state
- When the startup fallback sees the main process check still in flight,
  reschedule after 5s instead of permanently skipping — handles the case
  where the auto-check fails silently (check-phase errors not broadcast)
- onUpdateNotAvailable: clear hasUpdate and manualCheckStatus to remove
  stale "update available" state from earlier GitHub API checks, since
  the updater feed is authoritative on supported platforms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:38:02 +08:00
bincxz
71c6f68934 fix(update): honor dismissed version in manual check, clear isChecking safely
- checkNow: check dismissed version before marking status as 'available'
  to prevent re-downloading a release the user explicitly skipped
- startAutoCheck: verify updater exists before setting _isChecking flag
  to avoid permanent stuck state when electron-updater fails to load
- Clear _isChecking in all catch paths to prevent stuck state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:32:57 +08:00
bincxz
01bee794ee fix(update): track in-flight check state to prevent concurrent races
- Add _isChecking flag in autoUpdateBridge to track whether
  checkForUpdates is in flight; return sentinel when manual check
  arrives during an active auto-check instead of starting a concurrent
  call that electron-updater would reject
- Include isChecking in getUpdateStatus snapshot so the renderer can
  query it before starting the GitHub API fallback
- Startup fallback now checks getUpdateStatus().isChecking to skip
  when electron-updater is still checking on slow networks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:23:53 +08:00
bincxz
29dc01306d fix(update): make auto-check cancellable, persist no-update check time
- Store startAutoCheck timer ID so it can be cancelled; cancel it when
  the renderer triggers a manual checkForUpdate to avoid concurrent
  electron-updater calls that produce false errors
- Record lastCheckedAt and STORAGE_KEY_UPDATE_LAST_CHECK when
  update-not-available fires so the throttle works on the common
  no-update path and "Last checked" UI shows correctly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:17:53 +08:00
bincxz
0dcfd1489b fix(update): eliminate redundant startup check, serialize manual checks
- Broadcast 'update-not-available' from electron-updater to renderer so
  the startup GitHub API check is cancelled when no update exists
- Cancel pending startup timeout in checkNow() to prevent racing with
  electron-updater's startAutoCheck (concurrent calls cause false errors)
- Add onUpdateNotAvailable bridge event (preload + global.d.ts types)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:09:48 +08:00
bincxz
72f61141c4 fix(update): respect dismissed version in hydration, don't cache failed checks
- getUpdateStatus hydration: skip restoring download state for dismissed
  versions so late-opening windows don't show dismissed release UI
- performCheck: only advance lastCheck timestamp and cache release data
  on successful checks — failed checks no longer suppress re-checks for
  an hour while leaving stale cached release visible

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:03:15 +08:00
bincxz
37150ea379 fix(update): suppress download UI for dismissed versions, cancel racy startup check
- onUpdateAvailable: skip autoDownloadStatus→'downloading' transition when
  version is dismissed, preventing download progress/ready toasts
- onUpdateAvailable: cancel pending startup GitHub API check timeout to
  eliminate race where electron-updater is still checking at 8s
- onUpdateDownloadProgress/Downloaded/Error: suppress state transitions
  when autoDownloadStatus is 'idle' (dismissed version background download)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:55:51 +08:00
bincxz
5706af3f33 fix(update): don't falsely report up-to-date, fix stale hydration
- Return idle instead of up-to-date for dev/invalid builds in
  checkNow to avoid false positive status
- Replace stale cached release in getUpdateStatus hydration when
  the snapshot reports a different version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:45:18 +08:00
bincxz
6871c82ab8 fix(update): semantic version compare, restore dismiss, show fallback on error
- Use semantic version comparison for cached release hydration to
  avoid false positives when running a newer build than latest release
- Restore dismissUpdate() in startup toast so unsupported-platform
  users can silence repeated notifications
- Remove dismissed-version check from ready-to-install toast since
  dismissing availability should not block the install prompt
- Show manual download link in Settings on check errors too

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:38:10 +08:00
bincxz
b90ff692eb fix(update): hydrate cached release for late windows, fix dismiss flow
- Persist latestRelease to localStorage so windows opened after the
  initial check can hydrate release info without re-fetching
- Remove dismissUpdate() from "View in Settings" toast click — the
  dismissed version key was preventing the later install-ready toast
- Hydrate cached release data when startup check is throttled so
  Settings windows show the already-found update

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:31:23 +08:00
bincxz
ce71725dba fix(update): handle unsupported platforms, remove auto-check, update badge
- Don't treat unsupported auto-update platforms as download errors;
  keep autoDownloadStatus at idle so manual download link shows
- Remove auto-check on SettingsApplicationTab mount to avoid
  implicitly triggering downloads when opening Settings
- Update Application tab badge to reflect download/ready state
  instead of always showing "Download Now"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:25:32 +08:00
bincxz
fb5c4aaa58 fix(update): update latestRelease on version mismatch, surface check failures
- Replace stale latestRelease when electron-updater reports a different
  version than the cached GitHub API result
- Surface checkForUpdate() failures by setting autoDownloadStatus to
  error instead of silently dropping them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:15:34 +08:00
bincxz
45c059ae53 fix(update): suppress install toast for dismissed releases, reset stale status
- Check dismissed version before showing ready-to-install toast so
  users who skipped a release are not re-prompted after restart
- Reset _lastStatus on update-not-available so late-opening windows
  don't hydrate stale error/ready state from a previous check cycle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:09:23 +08:00
bincxz
1d67eb40c4 fix(update): remove stale autoDownloadStatus check from manual update handler
The autoDownloadStatus read from updateState was captured at render
time, so after checkNow() resolves it still shows 'idle' even when
electron-updater has already started downloading. Remove the
openReleasePage() call entirely — checkNow() already triggers
electron-updater on supported platforms, and SettingsSystemTab shows
a manual download link on unsupported platforms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:02:12 +08:00
bincxz
c6e3989a1b fix(auto-update): restrict error broadcasts to download phase only
- Remove hasUpdate gate from ready-to-install toast so dismissing
  availability notification doesn't prevent restart prompt
- Only open releases page on platforms without auto-download
- Increase startup check delay to 8s to let electron-updater fire first

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:57:35 +08:00
bincxz
ace081414f fix(review): skip redundant startup check, honor dismissed version in toasts
- Skip renderer's GitHub API startup check if electron-updater's
  auto-download has already started, preventing duplicate toast
  notifications for the same release
- Set hasUpdate in onUpdateAvailable IPC handler, checking dismissed
  version so that dismissed releases don't trigger the persistent
  "restart now" toast after auto-download completes
- Guard "ready to install" toast with hasUpdate check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:49:25 +08:00
bincxz
049a609bca fix(review): fix retry path, consolidate useUpdateCheck, show startup updates
- Fix autoDownloadStatusRef stale read during checkNow retry: eagerly
  sync the ref when resetting error->idle so checkForUpdate() fires
- Refactor SettingsApplicationTab to accept update props instead of
  creating its own useUpdateCheck instance, preventing duplicate checks
  and inconsistent state between Application and System tabs
- Show startup-detected updates (hasUpdate) in System tab, not only
  manualCheckStatus=available, so Linux/unsupported platforms see the
  update and manual download button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:43:34 +08:00
bincxz
44409e6d32 fix(review): hydrate update status for late-opening windows, fix toast race
- Add getUpdateStatus IPC handler so windows opened after download started
  can immediately reflect the current state instead of showing stale 'idle'
- Track _lastStatus in main process across all updater events
- Hydrate autoDownloadStatus on useUpdateCheck mount via getUpdateStatus()
- Fix toast race: use ref to track previous autoDownloadStatus so ready/error
  toasts only fire on actual status transitions, not when unrelated callback
  references change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:37:15 +08:00
bincxz
5246489ef9 fix(review): remove stale getSenderWindow reference from JSDoc
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:26:14 +08:00
bincxz
83d0d917ad fix(review): guard formatLastChecked against negative timestamps
Handle clock skew (timestamp in the future) by treating negative
diff as "just now" instead of displaying negative time values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:25:43 +08:00
bincxz
73557d0af1 fix(review): remove dead code, fix gitignore pattern, correct changelog
- Remove unused getSenderWindow() from autoUpdateBridge (replaced by broadcastToAllWindows)
- Fix .gitignore: /CLAUDE.md instead of CLAUDE.md to only match root
- Merge duplicate [Unreleased] sections in CHANGELOG.md
- Correct checkNow description: uses GitHub API, then triggers electron-updater async

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:23:47 +08:00
penguinway
aa67455c8c fix(auto-update): restrict error broadcasts to download phase only
Add _isDownloading flag to track whether a download is in progress.
Set true on update-available (autoDownload=true starts download immediately),
reset on update-downloaded or error.

In the error handler, only broadcast netcatty:update:error when _isDownloading
is true — check-phase errors (e.g. startup network failures) are logged to
console only and do not set autoDownloadStatus in the renderer, preventing
false 'download failed' states when no download was ever attempted.
2026-03-11 17:09:31 +08:00
penguinway
c7d2482996 fix(update): classify checkNow error when result.error is set
performCheck returns a non-null UpdateCheckResult with error populated
on GitHub API/network failures. Extend the status derivation to treat
result.error as an error state instead of falling through to up-to-date.
2026-03-11 17:08:31 +08:00
penguinway
d2391f5472 fix(update): restore checkNow return type and add error state retry
P1: change checkNow return type from Promise<null> to Promise<UpdateCheckResult | null>
and return actual result so callers can read hasUpdate/latestRelease.

P2: reset autoDownloadStatus from 'error' to 'idle' when user triggers manual
check, enabling a retry path; also show Check for Updates button in error state.
2026-03-11 16:58:21 +08:00
penguinway
9be84c71f5 feat(auto-update): improve UX — auto-reset badge, trigger download, show last checked time
- Fix 1: when manual check finds update and electron-updater hasn't started
  downloading yet (autoDownloadStatus=idle), fire-and-forget checkForUpdate()
  to kick off the auto-download pipeline without blocking the UI

- Fix 2: manualCheckStatus='up-to-date' now auto-resets to 'idle' after 5s
  so the badge doesn't stay stale until the next check; any new check cancels
  the pending timer first

- Fix 3: SettingsSystemTab shows "last checked: X min ago" below the update
  section using lastCheckedAt from updateState; new i18n keys added for both
  zh-CN and en locales (lastCheckedJustNow, lastCheckedMinutesAgo,
  lastCheckedHoursAgo, lastCheckedPrefix)

Internal: add autoDownloadStatusRef and manualCheckResetTimeoutRef to
useUpdateCheck for reliable cross-closure state access and timer lifecycle.
2026-03-11 16:08:32 +08:00
penguinway
effb98b91a chore: gitignore local dev files (.serena/, VS build scripts, Directory.Build.*) 2026-03-11 16:06:05 +08:00
陈大猫
77fd7a42a8 fix(sftp): drag-upload goes to wrong directory after navigation (#311)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix(sftp): update currentPath immediately on navigation to prevent stale upload target

When navigating directories without a cache hit, currentPath was only
updated after the async file listing completed. If a drag-and-drop upload
occurred during or shortly after this window, getActivePane would return
the old currentPath, causing files to upload to the previous directory.

Now currentPath is updated immediately when loading begins, ensuring
upload operations always target the correct directory.

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

* fix(sftp): revert currentPath to previous value when navigation fails

Address review feedback: if the directory listing throws a non-session
error, restore currentPath to its previous value so later operations
(e.g. uploads) don't target a path that was never successfully loaded.

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

* fix(sftp): clear files when entering loading state to prevent stale interactions

Address P1 review: the loading overlay is pointer-events-none, so users
could still interact with old files during navigation. Since currentPath
is now updated immediately, actions like delete/rename would resolve
against the new path but display old files. Clear files and selection
when loading begins to eliminate this inconsistency.

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

* fix(sftp): restore previous files when reverting path on navigation error

Address P2 review: since files are now cleared when loading begins,
a failed navigation would leave the pane with the old path but an
empty file list. Save and restore the previous files alongside the
previous path in the error handler.

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

* fix(sftp): restore selected files when reverting on navigation error

Address P2 review: save and restore selectedFiles alongside path and
files in the error handler so users don't lose their selection when
a navigation attempt fails.

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

* fix(sftp): restore tab state when navigation is superseded by another request

Address P1 review: navSeqRef is tracked per-side not per-tab, so a
navigation from a different tab on the same side can invalidate this
request. When the sequence check causes an early return, restore this
tab's previous path, files, and selection instead of leaving it with
cleared files and a stale loading state.

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

* fix(sftp): avoid overwriting newer navigation state when superseded

When a navigation request is superseded by a newer one on the same tab
(e.g., fast A→B→C), the completing request should not blindly restore
its previous state, as that would overwrite the latest navigation's
optimistic update. Now we check if the tab's current path still matches
what this request set before restoring.

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

* fix(sftp): use per-tab request ID to guard superseded navigation restores

Replace the ambiguous currentPath equality check with a per-tab
navigation request ID (tabNavSeqRef). The old check failed when
refresh() triggered a navigation to the same path — the stale request
would incorrectly match and restore previous state.

The new approach tracks the latest requestId per tab, so:
- Same-tab superseded navigations (including same-path refreshes)
  correctly skip the restore.
- Cross-tab superseded navigations (different tab on the same side)
  correctly restore the orphaned tab's state.

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

* fix(sftp): track per-tab nav sequence to prevent cache-hit state overwrite

When a cache-miss request (A) is pending and a cache-hit request (B) runs
on the same tab, A's superseded handler could overwrite B's result because
it only checked path equality. Add tabNavSeqRef to track the latest
requestId per tab, so superseded requests correctly skip restore when
a newer navigation (including cache hits) has already handled the tab.

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

* fix: remove leftover merge conflict markers

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

* fix(sftp): restore to last confirmed state instead of optimistic state

When multiple navigations are in flight (A→B→C), the second navigation
would snapshot the optimistic state (path=B, files=[]) as its "previous"
state. If it then failed or was superseded, it would restore to an empty
file list instead of the last successfully loaded directory.

Introduce lastConfirmedRef to track the last known-good state per tab,
updated only on successful navigation (cache hit or listing success).
Restore-on-error and restore-on-supersede now always revert to this
confirmed state.

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

* fix(sftp): guard restores against stale connection after reconnect/disconnect

connect() and disconnect() reuse the same tab ID but bump navSeqRef
without updating tabNavSeqRef, so a pending navigation could restore
stale state from a previous host into a freshly reconnected tab.

Fix by:
- Capturing connectionId at navigation start and checking it in every
  updateTab restore callback (prev.connection?.id !== connectionId)
- Storing connectionId in lastConfirmedRef and re-seeding confirmed
  state when the connection changes, preventing old host data from
  being used as the restore target

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

* fix(sftp): keep files visible during loading and re-seed confirmed state

Two UI regressions fixed:

1. After a file mutation (delete/create/rename), lastConfirmedRef still
   held the pre-mutation snapshot. If the subsequent refresh failed, the
   error handler would restore stale files (e.g. resurrecting deleted
   items). Fix: re-seed confirmed state from the pane whenever it is
   settled (not loading), capturing any optimistic mutation updates.

2. Clearing files to [] on navigation start left a tab blank when
   superseded by another tab navigating on the same side. Fix: keep
   existing files visible during loading — the loading overlay already
   has pointer-events-none to prevent interaction. Files are replaced
   on success or restored from lastConfirmedRef on error/supersede.

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

* fix(sftp): block interaction with stale files during directory loading

The loading overlay used pointer-events-none, allowing clicks to pass
through to stale file rows underneath. Since currentPath is updated
immediately on navigation, interacting with old filenames during a slow
load would resolve paths against the new directory.

Remove pointer-events-none from the loading overlay so it properly
blocks all interaction with the stale file list while loading.

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

* chore: ignore .claude/ directory in eslint config

The .claude/worktrees/ directory contains full repo copies from agent
worktrees. ESLint was scanning these, causing 621 pre-existing errors
(no-undef for Node.js globals in .cjs files) that blocked npm run dev.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:05:41 +08:00
penguinway
a86a5e6839 fix(auto-update): revert checkNow to use performCheck (GitHub API) instead of electron-updater IPC
checkNow was calling bridge.checkForUpdate() which invokes updater.checkForUpdates()
via IPC. startAutoCheck() in the main process already calls checkForUpdates() on a
5s timer, and if that network request is still pending, the concurrent IPC call from
checkNow hangs indefinitely, causing the UI to be stuck in "checking" state forever.

Per the original design spec, checkNow should use performCheck() (GitHub API) directly.
This is completely independent of electron-updater's internal state machine, so it
never conflicts with the background startAutoCheck(). performCheck handles isCheckingRef,
isChecking, hasUpdate, and latestRelease; checkNow only manages manualCheckStatus.
2026-03-11 15:46:05 +08:00
penguinway
7ed4940e18 docs: update CHANGELOG for auto-update unification 2026-03-11 15:32:39 +08:00
penguinway
410d1ef097 feat(settings): pass unified updateState and update actions to SettingsSystemTab 2026-03-11 15:28:41 +08:00
penguinway
c386ee2e2e refactor(settings): remove local update state from SettingsSystemTab, use unified updateState props 2026-03-11 15:26:16 +08:00
penguinway
4c08888b60 fix(auto-update): fix isCheckingRef conflict in checkNow fallback path and stale version closure
- Critical: In Linux fallback path, temporarily reset isCheckingRef before calling
  performCheck so its own guard can run (was silently returning null due to double-set)
- Critical: Replace updateState.currentVersion closure in checkNow with currentVersionRef
  to avoid reading stale '' version on early user click; remove from useCallback deps
- Important: Add explicit !result guard when bridge is unavailable, returning 'error'
  status instead of silently falling through to 'up-to-date'
2026-03-11 15:20:27 +08:00
penguinway
2ea4c88680 feat(auto-update): add manualCheckStatus to UpdateState, rewrite checkNow to use electron-updater IPC 2026-03-11 15:13:54 +08:00
penguinway
0ba75f9af0 fix(auto-update): broadcast IPC events to all windows instead of single window 2026-03-11 15:09:29 +08:00
penguinway
4610348b0d Merge branch 'feat/auto-update' 2026-03-11 14:39:22 +08:00
penguinway
8d11b71bc1 Merge branch 'main' of github.com:penguinway/Netcatty 2026-03-11 14:39:08 +08:00
penguinway
6683001032 chore: exclude tests/ and CLAUDE.md from eslint and gitignore 2026-03-11 14:28:46 +08:00
penguinway
3b313ff933 chore: gitignore local test suite (tests/, vitest.config.ts) 2026-03-11 14:08:06 +08:00
penguinway
eaa27461fa docs: add CHANGELOG for auto-update feature 2026-03-11 13:16:39 +08:00
penguinway
20b65366be chore: ignore dev-app-update.yml, revert forceDevUpdateConfig test flag 2026-03-11 13:12:39 +08:00
penguinway
b8c08ba3ca chore: ignore AI-generated docs (docs/superpowers/) 2026-03-11 12:54:11 +08:00
陈大猫
981c5de90d Merge pull request #310 from binaricat/fix/windows-auto-update-signing
fix: prevent macOS signing credentials from leaking to Windows builds
2026-03-11 11:40:58 +08:00
bincxz
0097d65a6e fix: prevent macOS signing credentials from leaking to Windows builds
Only pass CSC_LINK, CSC_KEY_PASSWORD, and Apple notarization secrets
to the macOS matrix job. Previously these were passed to all matrix
jobs, causing electron-builder to sign Windows .exe with the Apple
Developer ID certificate. Windows doesn't trust Apple's certificate
chain, so electron-updater's signature verification failed during
auto-update.

Closes #309
2026-03-11 11:15:04 +08:00
penguinway
e4aa03c474 fix(auto-update): use duration:0 for persistent toast, remove stale comment 2026-03-11 02:44:44 +08:00
penguinway
b94386236c feat(auto-update): add ready-to-install and download-failed toast notifications 2026-03-11 02:40:10 +08:00
penguinway
0883585704 feat(auto-update): add autoDownloadStatus state and IPC subscriptions to useUpdateCheck 2026-03-11 02:35:45 +08:00
penguinway
5b38f4663d feat(auto-update): add i18n keys for ready-to-install and download-failed toasts 2026-03-11 02:35:18 +08:00
penguinway
a6a6dd1aac feat(auto-update): expose onUpdateAvailable in preload bridge 2026-03-11 02:32:20 +08:00
penguinway
506c60ea44 feat(auto-update): trigger startAutoCheck after main window ready 2026-03-11 02:32:09 +08:00
penguinway
9d9b24fe7b feat(auto-update): add onUpdateAvailable type to NetcattyBridge 2026-03-11 02:32:06 +08:00
penguinway
584b9859ef fix(auto-update): guard setupGlobalListeners against duplicate registration 2026-03-11 02:30:52 +08:00
penguinway
b005065949 feat(auto-update): enable autoDownload and global IPC event listeners 2026-03-11 02:27:59 +08:00
penguinway
a4fdb6758d docs: add auto-update implementation plan
Detailed step-by-step plan for feat/auto-update branch. Addresses
reviewer feedback: specific line anchors, SettingsSystemTab props
pattern, removeAllListeners risk, i18n key conflict notes, and
hasUpdate toast suppression when auto-download is active.
2026-03-11 02:23:20 +08:00
penguinway
a2b5c9d067 docs: add auto-update design spec
Spec for changing update flow from manual to auto-download + prompt
install: autoDownload=true in main process, renderer subscribes to
electron-updater IPC events, toast notification on download complete.
2026-03-11 02:11:39 +08:00
陈大猫
a451fd8811 Merge pull request #308 from binaricat/fix/issue-307-display-upload-path
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix(sftp): display upload destination path on completed task items (#307)
2026-03-10 21:26:06 +08:00
bincxz
49cef792a8 fix(sftp): display upload destination path on completed task items (#307)
Show the remote target path inline on completed upload task items
(e.g. "Completed - 1.2 MB → /home/user/dir") so users know exactly
where their files were uploaded after drag-and-drop to terminal.

- Add `targetPath` field to modal's TransferTask type
- Populate targetPath from currentPath in onTaskCreated callback
- Display targetPath on completed upload items in SftpModalUploadTasks
- Add i18n key `sftp.upload.completedToPath` (en/zh-CN)
2026-03-10 21:14:25 +08:00
陈大猫
62511ceb21 Merge pull request #305 from binaricat/fix/sftp-mfa-auth-304
fix(sftp): handle non-fatal agent auth errors for MFA/keyboard-interactive (#304)
2026-03-10 10:54:37 +08:00
bincxz
00cbb05d71 fix(sftp): handle end/close events during SSH connect phase
Address code review feedback: the direct ssh2.Client connect path
was missing end/close event handlers. If the server closes the
connection before 'ready' (e.g. rejected handshake, hop drops),
the promise now properly rejects instead of hanging forever.

Uses a settle/cleanup pattern to ensure listeners are removed and
the promise is resolved/rejected exactly once.
2026-03-10 10:40:47 +08:00
bincxz
3497614165 fix(sftp): fallback to standard SFTP when sudo sftp-server not found
When sudo SFTP fails with exit code 127 (sftp-server binary not found,
e.g. on ESXi), automatically fall back to the standard SFTP subsystem
channel instead of failing the entire connection. This avoids requiring
users to manually disable sudo mode for hosts that lack sftp-server.
2026-03-10 10:37:47 +08:00
bincxz
b652b836a7 fix(sftp): handle non-fatal agent auth errors for MFA/keyboard-interactive (#304)
Two compounding issues caused SFTP connections to fail when
keyboard-interactive (MFA) authentication was required:

1. ssh2-sftp-client's connect() installs error listeners that reject
   the entire connection on ANY error, including non-fatal agent auth
   failures. This prevents ssh2 from falling through to
   keyboard-interactive. Fix: bypass ssh2-sftp-client's connect() and
   use direct ssh2.Client with err.level === 'agent' filtering.

2. getSshAgentSocket() on Windows unconditionally returned the agent
   pipe path without checking if the SSH Agent service is running.
   Fix: added async getAvailableAgentSocket() that runs
   'sc query ssh-agent' before returning the pipe path.
2026-03-10 10:12:37 +08:00
陈大猫
cd604107ee Merge pull request #303 from binaricat/fix/unify-sync-payload
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: unify sync payload logic & harden port forwarding lifecycle
2026-03-10 03:10:30 +08:00
bincxz
adc4c25dc9 fix: set cancelled flag in stopPortForward(tunnelId) IPC handler\n\nThe legacy stopPortForward(tunnelId) path also needs to mark\ntunnel.cancelled = true before conn.end() and skip the immediate\ndelete. Otherwise, when a user clicks Stop on a connecting rule,\nconn.on('close') sees no cancelled flag and rejects the Promise,\ncausing a false error toast. 2026-03-10 03:04:29 +08:00
bincxz
eaaf0265f8 fix: preserve cancelled markers in stopAllPortForwards\n\nMark all tunnel entries as cancelled before calling conn.end()\nand remove the .clear() call. Let each conn.on('close') handler\ndelete its own entry so it can read the cancelled flag first.\n\nPreviously, .clear() removed all entries before the async close\nevents fired, so the close handler saw no entry and treated the\nshutdown as an unexpected failure — surfacing error toasts or\ntriggering auto-reconnect for rules the user just cleared. 2026-03-10 02:57:56 +08:00
bincxz
f4d833497d fix: ref-count singleton effects and use only stopPortForwardByRuleId for cleanup\n\n1. Replace boolean guard flags (reconnectCancelListenerActive,\n heartbeatActive) with ref-counting. Resources are created when\n count goes 0→1 and destroyed when count goes 1→0. The previous\n boolean approach broke when React ran child effects before parent\n ones: opening the Port Forwarding page let the child register\n the listener/heartbeat, but navigating away tore them down even\n though the App instance was still mounted.\n\n2. stopAndCleanupRule now uses only stopPortForwardByRuleId (which\n sets tunnel.cancelled = true before conn.end()). The old code\n called stopPortForward(tunnelId) first, which deletes the\n main-process tunnel entry immediately — making the cancelled\n flag invisible to conn.on('close') and causing intentional\n deletions to surface as error toasts. 2026-03-10 02:50:58 +08:00
bincxz
75871717a9 fix: capture cancelled flag before close handler cleanup deletes the entry\n\nstopPortForwardByRuleId previously deleted the tunnel entry from\nportForwardingTunnels before conn.end() fired the async close\nevent. By the time conn.on('close') ran, the entry was gone and\nthe cancelled flag was invisible. The fallback check\n!portForwardingTunnels.has(tunnelId) was ambiguous — it was also\ntrue when conn.on('error') deleted the entry for a real failure.\n\nFix:\n1. Capture tunnel.cancelled BEFORE cleanup deletes the entry.\n2. Don't delete in stopPortForwardByRuleId — let conn.on('close')\n handle deletion so it can read the flag first.\n3. Remove the ambiguous !has() fallback check entirely. 2026-03-10 02:42:08 +08:00
bincxz
f6619c28ed fix: strip lastUsedAt from SettingsSyncTab localStorage fallback\n\nConsistent with the useAutoSync fallback, also clear lastUsedAt\nfrom rules read from localStorage before building the sync payload.\nPreviously, device-local usage timestamps leaked into the cloud\nsnapshot and were replicated to other devices on import. 2026-03-10 02:33:43 +08:00
bincxz
ca77315257 fix: handle cancelled handshakes gracefully and evict stale connecting entries\n\n1. startPortForward now checks result.cancelled for intentional\n cancellations (rule deleted/replaced during SSH handshake).\n Instead of triggering error state or reconnect, it transitions\n to 'inactive' and returns cleanly. Previously, success:false\n from a cancelled handshake would schedule another reconnect,\n resurrecting the tunnel a few seconds later.\n\n2. reconcileWithBackend now evicts 'connecting' entries seeded by\n a previous reconcile (observed from another window's handshake)\n when the backend no longer reports them. Only locally-initiated\n connecting entries (which have an unsubscribe callback from\n their startPortForward call) are preserved. Previously, stale\n connecting entries from failed/cancelled handshakes stayed\n forever, with the rule stuck showing 'connecting' in the UI. 2026-03-10 02:25:53 +08:00
bincxz
3ab681e63b fix: update heartbeat entries on status change and graceful intentional cancellation\n\n1. reconcileWithBackend Case 3: when a tunnel already exists in\n activeConnections but the backend reports a different status\n (e.g. connecting→active after handshake completed in another\n window), update the entry and include it in the 'appeared' set.\n Previously, existing entries were never updated, leaving\n secondary windows stuck on 'connecting' permanently.\n\n2. stopPortForwardByRuleId now marks tunnel.cancelled = true\n before calling conn.end(). The close handler checks this flag:\n intentional cancellations resolve with { cancelled: true }\n instead of rejecting with an Error. This prevents the renderer\n from showing a bogus error toast when a rule is deleted or\n replaced while its SSH handshake is still in progress. 2026-03-10 02:16:44 +08:00
bincxz
2ee7781b82 fix: reconnect stuck state, side-effect guards, and syncWithBackend status\n\n1. scheduleReconnectIfNeeded now returns false when the\n activeConnections entry is missing (deleted by stopAndCleanupRule\n while handshake was in-flight). Previously it returned true\n but never set the timeout, leaving reconnect-enabled rules\n stuck in 'connecting' permanently.\n\n2. Module-level guards (reconnectCancelListenerActive,\n heartbeatActive) prevent duplicate initReconnectCancelListener\n and reconcile heartbeat instances. The hook mounts from both\n App.tsx and PortForwardingNew.tsx, so without guards each\n window gets double listeners and double backend polling.\n\n3. syncWithBackend now uses tunnel.status from the backend\n (connecting or active) instead of hardcoding 'active',\n matching the reconcileWithBackend fix from the previous commit. 2026-03-10 02:09:03 +08:00
bincxz
95780a29dc fix: strip lastUsedAt from sync fallback and use real tunnel status in reconciliation\n\n1. useAutoSync localStorage fallback now also strips lastUsedAt\n (alongside status/error). Without this, the hash computed\n before async init (with lastUsedAt) differs from the hash\n after init (App.tsx strips it), causing a needless sync upload\n on every launch.\n\n2. reconcileWithBackend now uses tunnel.status from the backend\n (connecting or active) instead of hardcoding 'active' when\n seeding activeConnections. This prevents falsely marking a\n handshaking tunnel as active in the renderer. 2026-03-10 02:01:05 +08:00
bincxz
060c35f66a fix: auto-sync localStorage fallback for PF rules and settled Promise in bridge\n\n1. useAutoSync buildPayload/getDataHash now fall back to localStorage\n when portForwardingRules is empty (async init not complete).\n Previously, clicking sync immediately after launch would upload\n portForwardingRules: [] and overwrite the cloud snapshot.\n\n2. SettingsSyncTab localStorage fallback strips transient per-device\n fields (status, error) before building the sync payload.\n\n3. startPortForward Promise now tracks a settled flag across all\n resolve/reject paths. conn.on('close') rejects the Promise when\n it hasn't been settled yet (tunnel killed during SSH handshake\n by stopPortForwardByRuleId), preventing callers from hanging\n indefinitely in pendingOperations. 2026-03-10 01:52:48 +08:00
bincxz
ee5d3827d5 fix: reconnect cancel on clear-all, strip transient sync fields, tunnel connecting status\n\n1. importRules([]) now iterates stored rules calling\n stopAndCleanupRule() for each one, broadcasting per-rule reconnect\n cancellation to other windows. Previously only called\n stopAllPortForwards() which doesn't signal reconnect cancel.\n\n2. SettingsSyncTab localStorage fallback strips transient per-device\n fields (status, error) before feeding rules to buildSyncPayload.\n This prevents uploading stale connection state to the cloud.\n\n3. portForwardingBridge tunnel entries now track status explicitly:\n 'connecting' on early registration, 'active' after server.listen\n or forwardIn succeeds. listPortForwards and getPortForwardStatus\n report the actual status instead of hardcoding 'active'. 2026-03-10 01:42:01 +08:00
bincxz
f06333b95e fix: register tunnel in portForwardingTunnels before SSH handshake\n\nThe previous stopPortForwardByRuleId couldn't catch tunnels during\nSSH handshake because they were only added to portForwardingTunnels\nafter conn.on('ready') + server.listen/forwardIn succeeded.\n\nNow the connection is registered immediately before conn.connect()\nwith server: null. The conn.on('ready') handler updates the entry\nwith the real server object. Error/close handlers already delete\nthe entry, so cleanup is unchanged.\n\nThis closes the last timing window where a deleted rule's tunnel\ncould become orphaned. 2026-03-10 01:33:59 +08:00
bincxz
a07c644ec8 fix: add stopPortForwardByRuleId IPC and fix uninitialized diff baseline\n\nTwo issues:\n\n1. Cross-window cleanup couldn't stop tunnels still in SSH handshake\n because listPortForwards doesn't list them. New approach:\n stopPortForwardByRuleId IPC directly iterates the main process\n portForwardingTunnels map matching by rule ID in the tunnel ID\n string, catching tunnels in ANY state.\n\n - portForwardingBridge.cjs: new stopPortForwardByRuleId function\n - preload.cjs + global.d.ts: expose the new IPC\n - portForwardingService.ts: stopAndCleanupRule and\n initReconnectCancelListener now use stopPortForwardByRuleId\n instead of fragile listPortForwards + match\n\n2. importRules diff loop missed removed/changed rules in a freshly\n opened settings window where globalRules was still empty (async\n initializeStore hadn't finished). Now falls back to reading from\n localStorage as the diff baseline. 2026-03-10 01:28:06 +08:00
bincxz
1d5c40c665 fix: expose stopAllPortForwards via IPC for cross-window tunnel cleanup\n\nThe renderer's stopAllPortForwards only iterated activeConnections\nwhich is empty in a freshly opened settings window. The backend's\nstopAllPortForwards (which iterates portForwardingTunnels in the\nmain process) was only called from will-quit, never via IPC.\n\nChanges:\n- portForwardingBridge.cjs: register netcatty:portforward:stopAll\n- preload.cjs: expose stopAllPortForwards in the bridge API\n- global.d.ts: add type for stopAllPortForwards\n- portForwardingService.ts: after clearing local activeConnections,\n also call bridge.stopAllPortForwards() to stop any backend\n tunnels this renderer doesn't know about 2026-03-10 01:17:55 +08:00
bincxz
ab0c4ede7e fix: handle settings window initialization timing for sync and cleanup\n\nTwo race conditions in the settings window when hooks haven't finished\nasync initialization:\n\n1. clearAllLocalData calls importRules([]) but globalRules is still\n empty, so no stopAndCleanupRule calls are made. Fix: when\n importRules receives an empty array, call stopAllPortForwards()\n on the backend as a safety net.\n\n2. onBuildPayload reads portForwardingRules from hook state which\n starts as [] until initializeStore finishes. Fix: fall back to\n reading directly from localStorage when hook state is empty,\n preventing empty-array upload that would overwrite remote data. 2026-03-10 01:10:07 +08:00
bincxz
cf86c166cf fix: Prevent xterm.js right-click behavior from interfering with tmux/vim popups when mouse tracking is active. 2026-03-10 00:59:44 +08:00
bincxz
686a707fef fix: address Codex round-3 reviews (legacy payload, heartbeat, cross-window reconnect)\n\n1. Preserve local state for legacy payloads: use !== undefined\n checks instead of ?? [] so older cloud snapshots that omit\n knownHosts/portForwardingRules don't wipe local data.\n\n2. Skip connecting tunnels during heartbeat eviction: the backend\n only lists tunnels after SSH handshake completes, so slow\n connections would be falsely evicted.\n\n3. Cross-window reconnect cancellation: stopAndCleanupRule now\n broadcasts via localStorage so other windows cancel pending\n reconnect timers. initReconnectCancelListener listens for\n these events and clears timers + activeConnections entries. 2026-03-10 00:55:07 +08:00
bincxz
159a5eccd2 fix: address Codex round-2 reviews (legacy payload, heartbeat, cross-window)\n\n1. Preserve omitted sync fields for legacy payloads: revert ?? []\n to !== undefined checks so older cloud snapshots that lack\n knownHosts/portForwardingRules don't destructively wipe local data.\n\n2. Exclude connecting tunnels from heartbeat eviction: backend\n doesn't report a tunnel until SSH handshake completes, so slow\n connections (MFA, network latency) were being falsely evicted\n every 4 seconds.\n\n3. Cross-window tunnel cleanup: stopAndCleanupRule now queries\n the backend for the tunnel ID when no local activeConnections\n entry exists (settings window stopping a tunnel started by\n the main window). 2026-03-10 00:46:45 +08:00
bincxz
8a6e915dd7 fix: address Codex review P1 (stale tunnel on config change) and P2 (additive-only sync)\n\nP1: importRules now compares 6 connection-relevant fields\n(type, localPort, remoteHost, remotePort, bindAddress, hostId)\nbetween existing and incoming rules. If any differ, the old\ntunnel is torn down so the UI no longer shows 'active' for\na tunnel pointing at stale parameters.\n\nP2: applySyncPayload now uses ?? [] fallback for\nportForwardingRules and knownHosts. This ensures 'download\nand replace' truly replaces all data, even when the payload\nwas created by an older client that didn't emit these fields. 2026-03-10 00:36:49 +08:00
bincxz
474a8bae87 chore: reduce heartbeat interval from 30s to 4s 2026-03-10 00:23:55 +08:00
bincxz
6c2e902007 feat: add periodic heartbeat to reconcile port forwarding state\n\nAdd a 30-second heartbeat that queries the main process for actual\nactive tunnels and reconciles with the renderer's state. This\nprevents state drift caused by:\n- Tunnel dying without IPC notification reaching renderer\n- Status callbacks being unsubscribed after page navigation\n- Any other edge case where renderer and backend disagree\n\nChanges:\n- Add reconcileWithBackend() to portForwardingService that detects\n gone (renderer has it, backend doesn't) and appeared (backend\n has it, renderer doesn't) tunnels\n- Add 30s heartbeat useEffect in usePortForwardingState that\n auto-corrects rule statuses when drift is detected 2026-03-10 00:18:17 +08:00
bincxz
0e61262bc0 fix: stop active tunnels when rules are deleted or replaced\n\nPreviously, deleteRule() and importRules() only removed port\nforwarding rules from state/UI without stopping the backend SSH\ntunnels. This left orphaned tunnels listening on ports with no\nUI control to shut them down.\n\nChanges:\n- Add stopAndCleanupRule() to portForwardingService for fire-and-\n forget tunnel teardown (clears reconnect timers, unsubscribes\n status events, sends IPC stop to main process)\n- deleteRule() now calls stopAndCleanupRule() before removing\n- importRules() now diffs old vs new rule IDs and stops tunnels\n for any rules being removed (covers cloud sync download and\n Clear Local Data scenarios) 2026-03-10 00:12:18 +08:00
bincxz
200d710cc9 fix: clear port forwarding rules when clearing local data
Address Codex review: since the sync payload now includes
portForwardingRules, "Clear Local Data" must also reset them
to prevent stale rules from being re-uploaded on the next sync.
2026-03-09 23:55:04 +08:00
bincxz
a7873fc457 fix: unify sync payload build/apply logic to prevent data loss\n\nThe settings window was building sync payloads with customGroups\nhardcoded to [] and missing portForwardingRules/knownHosts entirely.\nThis caused data loss when syncing from the settings window.\n\nChanges:\n- Add domain/syncPayload.ts with buildSyncPayload/applySyncPayload\n pure functions as the single source of truth\n- Update App.tsx to use applySyncPayload instead of inline logic\n- Rewrite SettingsSyncTab.tsx to use unified domain functions\n- Wire portForwardingRules through SettingsPage.tsx to the sync tab\n- Fix useAutoSync getDataHash to include customGroups and knownHosts\n so their changes trigger auto-sync 2026-03-09 23:40:07 +08:00
陈大猫
1286975a4b fix: improve URL highlighting precision (#302)
* fix: improve URL highlighting precision

* fix: tighten ipv4 highlight boundaries

* fix: narrow version prefix exclusion

* fix: trim trailing URL delimiters

* fix: preserve bracketed ipv6 urls
2026-03-09 23:07:10 +08:00
陈大猫
2933e108bc feat: support system theme auto-switching (#301)
* feat: support system theme auto-switching\n\nAdd 'system' as a third theme option alongside 'light' and 'dark'.\nWhen set to 'system', the UI theme automatically follows the OS\ncolor scheme preference and switches in real-time when the system\nappearance changes.\n\nChanges:\n- useSettingsState.ts: Add resolvedTheme state derived from\n  matchMedia('prefers-color-scheme: dark'), add listener for\n  system preference changes, update applyThemeTokens to use\n  resolvedTheme instead of theme directly\n- SettingsAppearanceTab.tsx: Replace dark mode Toggle with\n  3-segment selector (Light / System / Dark) using Sun/Monitor/Moon\n  icons\n- en.ts/zh-CN.ts: Replace darkMode i18n keys with new theme keys\n  including 'system' option\n- Default theme changed from 'light' to 'system' for new users\n\nPartially addresses #294

* fix: derive resolvedTheme synchronously and guard matchMedia\n\nAddress Codex review feedback:\n1. Replace resolvedTheme useState+useEffect with synchronous\n   derivation from systemPreference state. This eliminates the\n   one-frame stale render where useLayoutEffect could apply\n   tokens from the old palette before useEffect updated\n   resolvedTheme.\n2. Add window.matchMedia guard in the system preference listener\n   to prevent crashes in jsdom tests or constrained webviews.\n3. Make the matchMedia listener unconditional (always tracks OS\n   preference) to avoid setup/teardown churn when toggling modes.

* fix: resolve 'system' theme in pre-hydration bootstrap to prevent flash

The index.html bootstrap script only handled 'dark'/'light' stored
values. Since DEFAULT_THEME is now 'system', new users (or users who
chose system mode) would get a wrong-theme first paint until React
mounted. Now resolve 'system' via matchMedia('(prefers-color-scheme:
dark)') before applying the CSS class, eliminating the visible flash.

Also use the resolved theme (not raw stored value) for accent foreground
calculation to ensure correct contrast on first paint.

Addresses Codex review on PR #301.

* fix: use resolvedTheme for top-bar toggle to avoid no-op in system mode

When theme preference is 'system' and the OS is dark, the toggle button
showed a moon icon and clicking it just switched from 'system' to 'dark'
— visually a no-op. Now we:

1. Pass resolvedTheme (always 'light'|'dark') to TopTabs for icon display
2. Toggle based on resolvedTheme so the first click always produces a
   visible change (e.g. system+dark → light, system+light → dark)

Addresses Codex review on PR #301.
2026-03-09 21:49:00 +08:00
陈大猫
8278bfde0f feat: show hidden files (dotfiles) on local filesystem browser\n\nPreviously, the showHiddenFiles setting only hid dotfiles on remote\nconnections. Local filesystem panes always showed dotfiles like\n.gitignore, .env, etc. regardless of the setting.\n\nNow the setting consistently hides/shows dotfiles on both local and\nremote connections. Also updated the i18n descriptions in EN and\nzh-CN to remove outdated Windows-only references.\n\nChanges:\n- utils.ts: Remove isLocal bypass from isHiddenFile/filterHiddenFiles\n- useSftpPaneFiles.ts: Remove isLocal from filterHiddenFiles call\n- useSftpKeyboardShortcuts.ts: Remove isLocal from filterHiddenFiles\n- SFTPModal.tsx: Remove isLocalSession from filterHiddenFiles call\n- en.ts/zh-CN.ts: Update descriptions to be platform-agnostic\n\nPartially addresses #294 (#299) 2026-03-09 19:12:43 +08:00
陈大猫
d0b941eabf docs: add Shift+Drag hint for tmux/vim in copy-on-select setting\n\nUpdate the copy-on-select setting description in both EN and zh-CN\nlocales to guide users on how to select text when tmux or vim has\nmouse mode enabled: hold Shift while dragging.\n\nPartially addresses #294 (#298) 2026-03-09 19:00:52 +08:00
陈大猫
a98821acb7 fix: re-run startup command on Start Over after SSH disconnect (#297)
* fix: re-run startup command on Start Over after SSH disconnect\n\nThe hasRunStartupCommandRef was set to true on first connection but\nnever reset when the user clicked Start Over (handleRetry). This\ncaused the startup command to be skipped on all subsequent retries.\n\nReset the ref to false in handleRetry so the startup command\nexecutes again on reconnection.\n\nPartially addresses #294

* fix: guard startup-command timer against stale sessions\n\nCapture the session ID when scheduling the startup command timer\nand verify it still matches sessionRef.current when the timer fires.\n\nThis prevents double execution when the user clicks Start Over\nquickly: the old timer detects the session ID mismatch and bails\nout, so only the new connection's timer runs the startup command.\n\nApplied to both SSH and Mosh startup command paths.
2026-03-09 18:20:03 +08:00
陈大猫
4edc28113e fix: scroll terminal to bottom on paste when scrollOnPaste is enabled\n\nThe scrollOnPaste setting only affected xterm.js native paste events.\nWhen pasting via Netcatty's context menu or keyboard shortcut, the\nterminal did not scroll to bottom because the custom paste path uses\nwriteToSession() which bypasses xterm's built-in scroll-on-paste.\n\nNow explicitly calls term.scrollToBottom() after writing paste data\nwhen the scrollOnPaste setting is enabled (default: true).\n\nPartially addresses #294 (#296) 2026-03-09 18:07:42 +08:00
陈大猫
adc712e121 fix: disable context menu in alternate screen to prevent tmux double menu (#295)
* fix: disable context menu in alternate screen to prevent tmux double menu\n\nWhen applications like tmux enable mouse mode in xterm's alternate\nscreen buffer, right-clicking would show both tmux's context menu\nand Netcatty's context menu simultaneously.\n\nThis fix detects alternate screen mode via xterm.js buffer.onBufferChange\nand disables Netcatty's context menu, letting the terminal application\nhandle mouse events natively.\n\nFixes #294 (Bug 1: Tmux duplicate context menus)

* refactor: use mouse tracking mode detection instead of alternate screen\n\nReplace alternate screen detection with mouseTrackingMode check.\nThis is more precise: context menu is only disabled when the terminal\napplication is actively capturing mouse events (e.g. tmux with\n`set -g mouse on`, vim with `set mouse=a`).\n\nPrograms that use alternate screen without mouse tracking (e.g.\nless, man, vim without mouse) will still show Netcatty's context menu.
2026-03-09 17:50:19 +08:00
陈大猫
81d1b4602d feat: add auto-update support via electron-updater (#289) (#293)
* feat: add auto-update support via electron-updater (#289)

- Add autoUpdateBridge.cjs wrapping electron-updater for check/download/install
- Register bridge in main.cjs, expose IPC in preload.cjs
- Add auto-update methods to NetcattyBridge type in global.d.ts
- Extend updateService.ts with electron-updater bridge functions
- Add Software Update section in Settings > System tab with state machine UI
- Add i18n keys for update UI (en + zh-CN)
- Add publish config for GitHub Releases in electron-builder.config.cjs
- Update CI workflow to upload update metadata (*.yml, *.blockmap, *.zip)
- Fallback to manual GitHub download for unsupported platforms or errors

* fix: address Codex review - guard bridge call and pin sender window

- Guard optional bridge call in SettingsSystemTab to prevent TypeError
  when getAppInfo is unavailable (e.g. browser/dev/test rendering)
- Capture senderWindow at download initiation in autoUpdateBridge so
  progress/downloaded/error events always go to the requesting renderer,
  even if focus changes during download

* fix: use semver ordering for version check and clean up listeners on rejection

- Replace strict equality (===) with localeCompare for version comparison
  to avoid false positives on pre-release/nightly builds
- Clean up download-progress/update-downloaded/error listeners in the
  catch path when downloadUpdate() rejects before emitting events

* feat: redirect update toast to Settings window for in-app update

- Update toast notification now opens Settings window instead of
  GitHub Releases page, enabling the in-app download/install flow
- Add 'update.viewInSettings' i18n key (en + zh-CN)
- Remove unused openReleasePage from App.tsx destructuring
- Move useWindowControls() before the update effect to fix declaration order
2026-03-09 13:34:05 +08:00
陈大猫
540aabb676 fix: skip invalid ssh agent sockets (#292) 2026-03-09 11:59:42 +08:00
陈大猫
8d014193ca Remove dead code and unused components (#288) 2026-03-08 10:55:17 +08:00
陈大猫
892c6da44d fix: cloud sync 401 Unauthorized on first app launch (#287)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: cloud sync 401 Unauthorized on first app launch

Root cause: CloudSyncManager.initProviderDecryption() runs before the
Electron bridge (window.netcatty) is available. decryptField() silently
returns encrypted ciphertext as-is (no-op fallback), so tokens remain
encrypted. When checkRemoteVersion() fires, the adapter sends encrypted
ciphertext as the Bearer token → 401 Unauthorized.

Fix: Add a decryptionEffective flag to detect when decryption was a
no-op. In getConnectedAdapter(), retry decryption for the requested
provider if startup decryption failed due to bridge unavailability.

* fix: track actual decryption success instead of bridge function existence

The preload script sets up bridge functions before the main process
registers IPC handlers. Checking function existence is unreliable —
the function exists but the actual IPC call throws. Now we track
whether any decryption threw an error and only mark decryptionEffective
when decryption actually succeeds.

* fix: use per-provider decryption state instead of global flag

Address P1 review: with a single global decryptionEffective flag,
the first provider's successful retry would prevent retries for
other providers. Changed to providerDecrypted record so each
provider independently tracks its decryption status.

* fix: evict stale adapter after successful deferred decryption

Address P1 review: after deferred decryption succeeds, the old adapter
(built with encrypted ciphertext) was still cached. isAuthenticated
returns true for it because the ciphertext is a non-empty string, so
it kept being reused and returning 401. Now the stale adapter is signed
out and evicted, forcing a fresh one with decrypted credentials.
2026-03-08 01:09:05 +08:00
陈大猫
0ff6273882 fix: enable Windows PTY compatibility for local terminals (#286)
* fix: enable Windows PTY compatibility for local terminals

* fix: detect localhost local terminal sessions

* fix: improve Windows local shell defaults

* fix: align detected local shell with launcher

* fix: limit windows pty handling to local terminals

* fix: skip pwsh app execution alias shims
2026-03-08 00:20:20 +08:00
陈大猫
92556d824e fix: normalize persisted redhat distro alias (#285) 2026-03-07 11:48:49 +08:00
midas
f3676734a7 feat(sftp): show download progress for "Open With" temp file downloads (#283)
* feat(sftp): show download progress for "Open With" temp file downloads

When opening remote files via "Open With" or double-click, the download
to a temp directory now displays real-time progress (bar, speed, ETA) in
the transfer overlay instead of silently blocking until completion.

Reuses the existing transferBridge infrastructure (fastGet with throttled
IPC progress events) and the SftpTransferItem UI. Cancellation is handled
gracefully — the task transitions to "cancelled" status, the partial temp
file is cleaned up, and the file is not opened in the external application.
The original downloadSftpToTemp path is preserved as a fallback for
contexts without a transfer queue.

* fix(sftp): harden temp download transfer state

---------

Co-authored-by: midasgao <midasgao@distinctclinic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-03-07 10:14:30 +08:00
陈大猫
3d1db751ca Remove legacy macOS quarantine workaround (#284) 2026-03-06 17:08:52 +08:00
陈大猫
35f531bb55 Fix SFTP folder copy into symlinked directories (#282)
* Fix SFTP directory copy into symlinked folders

* Honor SFTP directory drop targets

* Limit SFTP drop targeting to symlink directories

* Bind SFTP drops to the visible target pane

* Revert "Bind SFTP drops to the visible target pane"

This reverts commit d1bad223ffafd89d15217add8fbe4a24dda60433.

* Revert "Limit SFTP drop targeting to symlink directories"

This reverts commit edc67ed4a21c0c510854b5479592f4451d9b4cb7.

* Revert "Honor SFTP directory drop targets"

This reverts commit fed0d7bdd0f28fa6d4e9335f3964467b62921d7c.

* Stabilize SFTP directory transfer progress

* Enable compressed uploads in SFTP view

* Fix directory transfer cancellation and total growth

* Keep prescan cancellation in transfer cleanup

* Sync compressed uploads and persistent cancellation

* Tighten SFTP cancellation cleanup

* Handle Windows SFTP directory paths
2026-03-06 17:07:18 +08:00
陈大猫
71ff9953bd Fix issue #278 identity refresh and session log autosave (#281)
* Fix issue #278 identity refresh and session log autosave

* Sync session log settings across windows
2026-03-06 15:12:38 +08:00
bincxz
72635eeaeb fix(ci): upgrade Node.js from 20 to 22 for @electron/rebuild compat
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
@electron/rebuild@4.0.3 requires Node >= 22.12.0
2026-03-06 02:34:24 +08:00
bincxz
ec17abb507 Merge pull request: feat: enable macOS code signing and notarization
- Enable hardenedRuntime and notarize in electron-builder config
- Remove FixQuarantine.app workaround and DMG background image
- Pass signing and notarization secrets in CI build step
2026-03-06 02:07:10 +08:00
bincxz
fe7f760a47 chore: remove DMG background image 2026-03-06 02:06:50 +08:00
bincxz
ab70a406c9 feat: enable macOS code signing and notarization
- Enable hardenedRuntime and notarize in electron-builder config
- Remove FixQuarantine.app workaround from DMG (no longer needed
  with proper code signing)
- Pass signing and notarization secrets in CI build step
- Shrink DMG window to fit the simpler two-icon layout
2026-03-06 01:48:49 +08:00
bincxz
7e73da5557 Merge pull request #277 from binaricat/fix/issue-264-linux-x64-revert-container
fix(ci): revert Linux x64 build to ubuntu-latest without container

Closes #264
2026-03-06 01:45:47 +08:00
bincxz
97474acb89 fix(ci): revert Linux x64 build to ubuntu-latest without container
The debian:bullseye container introduced in v1.0.39 broke native module
packaging — node-pty's .node binary was missing from app.asar.unpacked,
causing 'No such file or directory' on ArchLinux and other x64 distros.

Revert to the v1.0.38 approach: build x64 directly on ubuntu-latest
with setup-node. ARM64 keeps the Debian container for GLIBC compat.

Closes #264
2026-03-06 01:44:08 +08:00
陈大猫
f59c83be2a fix: await provider token decryption before creating sync adapters (#276)
* fix: await provider token decryption before creating sync adapters

On cold start, initProviderDecryption() runs async in the constructor
but getConnectedAdapter() could be called before it finished, causing
adapter creation with still-encrypted tokens to fail silently.

Store the decryption promise and await it in getConnectedAdapter() so
tokens are guaranteed to be decrypted before use.

* fix: auto-recover sync providers stuck in error status

When syncAllProviders runs, providers with status 'error' that still
have tokens/config are now reset to 'connected' and their cached
adapter is invalidated, allowing a fresh retry with current (decrypted)
tokens. This prevents the permanent 'not configured' state that
previously required opening Settings to clear.
2026-03-06 01:38:18 +08:00
陈大猫
cba1803230 fix: install Linux icons in standard hicolor sizes (#274)\n\nGenerate 16x16 through 512x512 icon PNGs in build/icons/ so\nelectron-builder installs them to the correct hicolor directories\ninstead of only 1024x1024.\n\nUpdate .gitignore to track build/icons/ while keeping other\nbuild artifacts ignored.\n\nCloses #274 (#275) 2026-03-06 01:10:22 +08:00
陈大猫
e50a087a07 Merge pull request #272 from binaricat/feat/issue-261-terminal-encoding-switcher
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: add terminal encoding switcher for SSH sessions (#261)
2026-03-05 02:23:31 +08:00
bincxz
5839c00b67 fix: validate SSH session type and exclude localhost from encoding UI
- Check session.stream in setSessionEncoding to reject non-SSH sessions
  that share the sessions map (local/telnet/serial)
- Add hostname !== 'localhost' guard to isSSHSession in toolbar and
  onSessionAttached, since localhost routes through startLocal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:17:59 +08:00
bincxz
f5cb590e0c fix: reject encoding updates for inactive SSH sessions
Check that sessionId exists in the sessions map before writing to
sessionEncodings/sessionDecoders, preventing stale map entries and
misleading ok:true responses for disconnected sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:11:03 +08:00
bincxz
237b4404dc fix: sync encoding before first data chunk arrives
Move encoding sync from updateStatus("connected") to a new
onSessionAttached callback in attachSessionToTerminal, which fires
right after sessionRef is set but before the data listener is
registered. This ensures the first data chunk is decoded correctly
even if the user changed encoding during connection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:03:27 +08:00
bincxz
1c10076866 fix: revert localhost guard and scope encoding sync to SSH sessions
- Remove hostname==='localhost' check since SSH to localhost is valid
  and local protocol sessions are already filtered by isLocalTerminal
- Restrict updateStatus encoding sync to SSH sessions only, preventing
  stale decoder entries from accumulating for non-SSH session types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:54:24 +08:00
bincxz
eb80b8f60c fix: always sync encoding on connect and hide for localhost sessions
- Remove utf-8 guard from connect-time sync so GB-preseeded hosts that
  get switched to UTF-8 during connect are synced correctly
- Exclude hostname==='localhost' sessions from encoding popover since
  they route through startLocal, not the SSH bridge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:46:47 +08:00
bincxz
f38515d383 fix: sync encoding to backend when session connects
If the user changes encoding while still connecting, sessionRef is null
so the IPC call is skipped. Now updateStatus syncs the encoding to the
backend when status transitions to 'connected' and encoding is non-default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:35:42 +08:00
bincxz
64a1b8de3e fix: exclude Mosh sessions from encoding switcher
Mosh sessions keep host.protocol as 'ssh' but set host.moshEnabled,
so also gate encoding popover on !host?.moshEnabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:29:36 +08:00
bincxz
c1eb19a739 fix: use stateful iconv decoder and restrict encoding to SSH sessions
- Replace per-chunk iconv.decode() with stateful iconv.getDecoder() to
  handle multibyte characters split across packet boundaries (P1)
- Reset decoders when encoding is switched mid-session
- Gate encoding popover to SSH sessions only, excluding Telnet/Mosh (P2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:23:45 +08:00
bincxz
7342b4a872 feat: add terminal encoding switcher for SSH sessions (#261)
Allow users to switch between UTF-8 and GB18030 encoding mid-session
via a toolbar popover, fixing garbled output when viewing mixed-encoding
logs on remote servers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:17:05 +08:00
陈大猫
db682d7857 Merge pull request #271 from binaricat/fix/issue-258-windows-ssh-agent-check
fix: check Windows SSH Agent before connecting to agent pipe
2026-03-05 01:00:05 +08:00
bincxz
c6491b71c9 fix: only enable agentForward when agent is actually available
ssh2 throws when agentForward=true but no agent path is set. Move the
agentForward assignment after the agent availability check so forwarding
is silently skipped when the agent is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:56:28 +08:00
bincxz
8667d0d535 fix: check Windows SSH Agent before connecting to agent pipe
On Windows, the agent socket path was set unconditionally to
\\.\pipe\openssh-ssh-agent even when the ssh-agent service is not
running. This caused "Failed to connect to agent" errors and prevented
fallback to keyboard-interactive auth (password prompt).

Now uses the existing checkWindowsSshAgent() to verify the service is
running before setting the agent path, allowing auth to fall through to
keyboard-interactive when no keys or password are configured.

Closes #258

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:52:05 +08:00
陈大猫
2bcb081486 Merge pull request #270 from binaricat/feat/issue-260-local-sftp-bookmarks
feat: add bookmark support for local SFTP directories
2026-03-05 00:44:54 +08:00
bincxz
fefda0015e fix: use shared external store for local bookmarks
Replace per-instance useState with useSyncExternalStore backed by a
module-level singleton so all mounted local SFTP panes share the same
bookmark state and writes never overwrite each other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:38:50 +08:00
bincxz
5fc5471685 fix: handle Windows backslash paths in local bookmark labels
Split on both / and \ so the label extracts correctly for paths
like C:\Users\damao\Documents → "Documents".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:37:26 +08:00
bincxz
4601372ce6 feat: add bookmark support for local SFTP directories (#260)
Local SFTP panes now support directory bookmarks, stored in localStorage
since there is no Host object for local connections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:32:40 +08:00
陈大猫
6491ab38bc Merge pull request #269 from binaricat/fix/issue-266-password-only-passphrase
fix: skip SSH key passphrase prompt for password-only connections
2026-03-05 00:23:50 +08:00
bincxz
6476bc95df fix: include agentForwarding in password-only guard
When agent forwarding is enabled, the session uses an SSH agent which
may hold encrypted keys. Don't classify such sessions as password-only
to preserve the encrypted key retry path.

Addresses P2 review feedback on #269.
2026-03-05 00:04:45 +08:00
bincxz
7ef1059f7b fix: preserve encrypted key retry for jump host connections
When jump hosts are configured, the auth error could originate from a
key-based bastion rather than the password-only final target. Skip the
passphrase prompt bypass when jump hosts are present to ensure encrypted
default keys can still be offered for the chain.

Addresses review feedback on #269.
2026-03-04 23:57:54 +08:00
bincxz
fd78fc7baa fix: skip SSH key passphrase prompt for password-only connections
When a host is configured with username+password (no SSH key), the app
incorrectly prompted for local SSH key passphrases because:

1. buildAuthHandler added default ~/.ssh/ keys and ssh-agent as fallback
   methods for password-only connections, causing unnecessary key probing
2. startSSHSessionWrapper unconditionally scanned for encrypted default
   keys on auth failure and showed passphrase modal

Fix by:
- Removing default key/agent fallback from password-only auth handler
- Skipping encrypted key passphrase prompt in retry logic when the user
  explicitly configured password authentication

Fixes #266
2026-03-04 23:48:11 +08:00
陈大猫
5787a6ac6a Merge pull request #268 from binaricat/fix/issue-264-linux-x64-build
fix(ci): build Linux x64 in debian:bullseye container for native modules
2026-03-04 23:44:16 +08:00
bincxz
787760d02c fix(ci): build Linux x64 in debian:bullseye container for native modules
The Linux x64 AppImage was missing the compiled node-pty native module
(pty.node), causing the app to crash on launch. This happened because
the bare ubuntu-latest runner lacked build-essential/python3 needed by
node-gyp to compile native addons.

Move the Linux x64 build into a dedicated job using debian:bullseye
container (matching the ARM64 job) which:
- Installs build-essential, python3 and other native build deps
- Ensures node-pty, ssh2, cpu-features compile correctly
- Pins GLIBC to 2.31 for broader distro compatibility

Fixes #264
2026-03-04 23:37:42 +08:00
陈大猫
1b2c3e30a2 Merge pull request #267 from binaricat/fix/issue-263-rhel-distro-detection
fix: handle quoted ID values in /etc/os-release for RHEL distro detection
2026-03-04 23:32:49 +08:00
bincxz
ae7495baf9 fix: handle quoted ID values in /etc/os-release for distro detection
The regex for parsing the distro ID from /etc/os-release only matched
unquoted values like `ID=ubuntu`, but RHEL uses `ID="rhel"` with
double quotes. The new regex `/^ID="?([\w-]+)"?$/im` handles both
quoted and unquoted forms.

Fixes #263
2026-03-04 23:30:05 +08:00
陈大猫
2bcea8386f Merge pull request #265 from RoryChou-flux/codex/issue-259-sftp-reconnect-pr
fix(sftp): recover stale channel after network reconnect
2026-03-04 23:26:39 +08:00
bincxz
be7d29f45e fix(sftp): address reconnect selection and channel timeout edge cases 2026-03-04 23:18:36 +08:00
bincxz
4a762097ee fix(sftp): avoid sudo channel downgrade during channel recovery 2026-03-04 23:06:56 +08:00
bincxz
c91cf1d2f8 fix(sftp): guard reconnect reload against stale navigation state 2026-03-04 22:57:31 +08:00
bincxz
0a43220057 Merge remote-tracking branch 'origin/main' into fix/sftp-stale-channel-recovery
# Conflicts:
#	components/sftp-modal/hooks/useSftpModalSession.ts
#	electron/bridges/transferBridge.cjs
2026-03-04 22:47:05 +08:00
bincxz
288ea06c04 fix(sftp): add channel recovery to transferBridge stream operations
- Export requireSftpChannel from sftpBridge for cross-module use
- Add channel recovery to uploadWithStreams, downloadWithStreams,
  and startTransfer stat call in transferBridge
- Clean up verbose debug console.logs in cancelTransfer
2026-03-04 22:16:28 +08:00
bincxz
9ca7e39748 chore(sftp): remove dead isFatalUploadError function
The function was exported but never imported anywhere in the codebase.
2026-03-04 22:13:07 +08:00
bincxz
1cbbb61afa fix(sftp): add channel recovery to ensureRemoteDirForSession UTF-8 branch
The mkdirSftp handler delegates to ensureRemoteDirForSession, which
had the same issue as deleteSftp — the UTF-8 branch called
client.mkdir() directly without validating the channel first.
2026-03-04 22:11:33 +08:00
bincxz
cf352502f8 fix(sftp): deep review fixes for channel recovery
- Fix per-client dedup: store _reopeningPromise on client object
  instead of module-level global to prevent cross-session confusion
- Narrow isSessionError patterns: replace overly broad "not found"
  and "closed" with specific "channel closed"/"connection closed",
  add "timed out" for channel open timeout errors
- Prevent channel leak on timeout: close orphaned SFTP channel
  when tryOpenSftpChannel callback fires after timeout
- Auto-reload directory listing after successful reconnect in
  SFTP modal to avoid stale UI state
2026-03-04 22:07:51 +08:00
bincxz
72d270580f fix(sftp): harden channel recovery across all operations
P1 fixes:
- Add requireSftpChannel() to all SFTP operations: readSftp,
  readSftpBinary, writeSftp, writeSftpBinary,
  writeSftpBinaryWithProgress, renameSftp, statSftp, chmodSftp,
  and deleteSftp UTF-8 branch
- Add 10s timeout to tryOpenSftpChannel to prevent hang when
  SSH connection is half-dead

P2 fixes:
- Deduplicate concurrent getSftpChannel calls to avoid redundant
  channel re-opens
- Refactor isFatalUploadError to compose with isSessionError,
  eliminating pattern duplication and drift risk
2026-03-04 22:01:44 +08:00
bincxz
f0cfcbc560 refactor(sftp): consolidate duplicate isSessionError logic
- Add "write after end" and "no response" patterns to the shared
  isSessionError() in errors.ts
- Replace inline duplicate in useSftpModalSession with an import
  of the shared function
- Remove stale isSessionError from useCallback dependency array
2026-03-04 21:53:44 +08:00
rorychou
f8262a64ab fix(sftp): recover stale channel after reconnect 2026-03-04 21:37:31 +08:00
陈大猫
a24e27586a Merge pull request #257 from binaricat/fix/issue-254-sftp-bugs
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: resolve multiple SFTP bugs (#254)
2026-03-04 13:20:14 +08:00
bincxz
ca24d3861c fix: limit depth guard to symlink dirs only, allow deep real dirs
Real directories cannot form cycles, so remove depth limit for them.
Only track and limit symlink-directory nesting (MAX_SYMLINK_DEPTH=32)
to prevent cycles like `loop -> .` while allowing legitimate deep
directory structures to download without error.
2026-03-04 13:07:52 +08:00
bincxz
eb3b99b164 fix: cancel active child transfer directly from cancelTask
Add activeChildTransferIdsRef (Map<parentId, childId>) to track the
currently in-flight child transfer for directory downloads. cancelTask
now cancels both the parent ID and the active child transfer ID,
making folder download cancellation immediate and reliable.
2026-03-04 12:56:43 +08:00
bincxz
681f4cb3df fix: fail on depth exceeded + hide folder download for local sessions
- Throw error when MAX_RECURSION_DEPTH exceeded instead of silently
  returning, so download is marked failed with a clear message (P1)
- Hide folder download context menu item for local sessions where
  handleDownload only supports files (P2)
2026-03-04 12:05:59 +08:00
bincxz
6fae312981 fix: add max depth limit to prevent symlink cycle infinite recursion
SFTP doesn't expose realpath, so raw path strings can't detect cycles
like `loop -> .` that produce unique paths each level. Add a hard
MAX_RECURSION_DEPTH=32 guard alongside the existing visitedPaths set
to reliably prevent unbounded recursion.
2026-03-04 11:56:11 +08:00
bincxz
ed199eae8c fix: prevent symlink cycle recursion + handle undefined stream result
- Add visitedPaths Set to prevent infinite recursion from symlink
  cycles (e.g. symlink to parent directory)
- Handle undefined result from startStreamTransfer (bridge unavailable)
  by rejecting immediately instead of hanging indefinitely
2026-03-04 11:45:08 +08:00
bincxz
e38af76bfd fix: handle child transfer result errors + precise mkdir error handling
- Handle resolved result.error from startStreamTransfer to prevent
  hung Promises on cancellation (P1)
- Only ignore EEXIST from subdirectory mkdirLocal, propagate other
  errors like permission failures (P2)
2026-03-04 11:34:42 +08:00
bincxz
1726917db0 fix: abort in-flight child transfer on cancel + handle symlink dirs
- Cancel active child transfer from onProgress callback immediately
  when parent folder download is cancelled (P1)
- Handle symlink -> directory entries in recursive descent so they
  are treated as directories instead of files (P2)
2026-03-04 11:26:39 +08:00
bincxz
1712762305 fix: address code review feedback
- Revert mkdirLocal to safe original (no silent file deletion)
- Move EEXIST handling to download-overwrite flow only (deleteLocalFile)
- Add cancellation support for recursive folder downloads:
  - Track active child transfer ID for cancellation
  - Check cancelledTransferIdsRef between files
  - Cancel in-flight child transfer when parent is cancelled
2026-03-04 11:17:05 +08:00
bincxz
5d75f1acd4 fix: resolve multiple SFTP bugs (#254)
- Fix new folder input not resetting after deletion (SftpPaneToolbar/View)
- Fix folder download stuck at 95% by replacing simulated progress with real child-file progress tracking (useSftpTransfers)
- Add download menu item for directories in SFTP modal context menu (SftpModalFileList)
- Implement recursive folder download in SFTP modal with real-time progress (useSftpModalTransfers, SFTPModal)
- Fix mkdirLocal EEXIST error when target path is an existing file (localFsBridge)
- Close settings window when main window is minimized to tray (windowManager)

Closes #254
2026-03-04 11:04:34 +08:00
陈大猫
18b77f9a87 fix(ci): build linux-arm64 in Debian Buster container for GLIBC 2.28 compat (#255)
* fix(ci): build linux-arm64 in Debian Buster container for GLIBC 2.28 compat\n\nSplit linux-arm64 out of the build matrix into a dedicated job that\nruns inside a debian:buster container (GLIBC 2.28) on the ARM64 runner.\nThis ensures the compiled node-pty native module is compatible with\nolder distros like UOS/Deepin.\n\nCloses #253

* fix(ci): use archive.debian.org for EOL Buster repos

* fix(ci): switch to debian:bullseye for Python 3.9 + GLIBC 2.31 compat\n\nBuster's Python 3.7 is too old for node-gyp@11 (walrus operator).\nBullseye provides Python 3.9 and GLIBC 2.31 which is still below\nthe critical 2.34 boundary (libpthread merge into libc).
2026-03-04 10:23:35 +08:00
陈大猫
ade95c1cab Merge pull request #250 from binaricat/fix/linux-arm64-rebuild-error
Some checks failed
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: prevent x64 native module rebuild on ARM64 CI runner
2026-03-03 21:03:43 +08:00
bincxz
7e8893003a fix: use conditional step to avoid setting empty npm_config_arch
Use a dedicated step with `if` condition so npm_config_arch is only
set for linux-arm64. The previous approach set it to an empty string
for other jobs, which could interfere with node-gyp arch detection
on macOS, Windows, and linux-x64 builds.
2026-03-03 21:02:02 +08:00
bincxz
f42cd8cdd1 fix: prevent x64 native module rebuild on ARM64 CI runner
On ubuntu-24.04-arm runners, electron-builder's post-build
@electron/rebuild incorrectly tries to restore native modules
to x64 architecture. The ARM64 g++ compiler doesn't support the
-m64 flag, causing the build to fail.

Setting npm_config_arch=arm64 ensures node-gyp correctly identifies
the host architecture, preventing the erroneous x64 rebuild.
2026-03-03 20:54:28 +08:00
陈大猫
2d34e162c0 Merge pull request #248 from binaricat/fix/unify-settings-dropdowns
fix: unify settings dropdowns to use custom Radix-based Select
2026-03-03 19:51:38 +08:00
bincxz
cdee9c7867 fix: widen terminal emulation type dropdown to prevent truncation 2026-03-03 19:51:07 +08:00
bincxz
45de960618 fix: unify settings dropdowns to use custom Radix-based Select\n\nReplace native <select> in settings-ui.tsx with @radix-ui/react-select\nto match the app's custom dropdown design (FontSelect pattern).\n\nAll settings tabs now use consistent styled popover dropdowns with\ncheck indicators instead of OS-native select menus. 2026-03-03 19:49:07 +08:00
Thomas
2669fc57c4 fix: SSH certificate authentication with RSA key algorithm negotiation (#246)
* 修复 SSH 证书认证问题,增强日志以调试证书解析和签名过程。

* fix: clean up ssh2 patch and optimize netcattyAgent\n\n- Remove ~1187 lines of build artifacts from ssh2+1.17.0.patch\n  (Makefile, config.gypi, .o binaries, sshcrypto.node etc. with\n  hardcoded /Users/idouying paths). Keep only meaningful patches:\n  client.js, Protocol.js, SFTP.js\n- Cache parsed private key during Agent construction to avoid\n  re-parsing on every sign() call\n- Fix missing space in comment

* chore: revert package-lock.json noise and fix trailing whitespace\n\n- Revert package-lock.json to main (peer flag changes were noise\n  from different Node.js version, not intentional)\n- Fix trailing whitespace in netcattyAgent.cjs

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-03-03 19:43:50 +08:00
陈大猫
10ede85ae3 feat: Custom terminal themes with .itermcolors import (#245)
* feat: implement custom terminal themes with .itermcolors import (#228)\n\n- Add customThemeStore with CRUD operations and localStorage persistence\n- Create .itermcolors parser with RGB-to-hex conversion and auto theme type detection\n- Add CustomThemeEditor component with inline color pickers\n- Refactor ThemeCustomizeModal with Custom tab for create/import/edit/delete\n- Update all theme consumers (Terminal, TerminalLayer, LogView, ThemeSelectPanel,\n  SettingsTerminalTab, useSettingsState) to resolve custom themes\n- Add i18n keys for custom theme features (en + zh-CN)\n- Add isCustom flag to TerminalTheme model and STORAGE_KEY_CUSTOM_THEMES constant

* feat: add import .itermcolors button to Settings Terminal tab\n\nAllows importing .itermcolors files directly from Settings → Terminal\nwithout opening the theme modal first.

* fix: move delete button from editor to modal footer for clean layout\n\nThe delete button was rendering inside the CustomThemeEditor left panel,\ncausing misalignment with the full-width Cancel/Save footer. Now the\nfooter shows: [Delete] (left) | [Cancel] [Save] (right) when editing\nan existing custom theme.

* refactor: extract custom theme editor into standalone modal\n\nThe inline CustomThemeEditor was causing layout conflicts in the\nThemeCustomizeModal (editor + footer overlapping). Extracted into\na dedicated CustomThemeModal with:\n- Two-column layout: editor panel (left) + terminal preview (right)\n- Own footer: Delete (left) | Cancel + Save (right)\n- z-index 300 layering above the main theme modal\n- Proper scroll containment for the color editor

* fix: correct z-index stacking for custom theme modal\n\nRemoved inline style zIndex: 99999 from ThemeCustomizeModal that was\npushing it above CustomThemeModal. Now uses Tailwind z-[200] for the\nmain modal and z-[300] for the custom theme editor modal.

* feat: add new custom theme button to settings terminal tab\n\nReuses CustomThemeModal from the settings page. Creates a new theme\nbased on the currently selected theme, opens the editor modal, and\nautomatically selects the new theme on save.

* feat: add cross-window IPC sync for custom themes\n\nCustom themes created/imported/deleted in the Settings window are now\nimmediately synced to the main window (and vice versa) using the\nexisting netcatty:settings:changed IPC channel. Each mutation\nbroadcasts the change, and each window listens for incoming changes\nand reloads themes from localStorage.

* fix: show custom themes in ThemeSelectModal\n\nThemeSelectModal was only displaying built-in TERMINAL_THEMES.\nNow imports useCustomThemes hook and renders custom themes in a\nseparate section at the bottom of the theme list.

* feat: add edit/delete buttons for custom themes in settings\n\nWhen a custom theme is selected, Edit and Delete buttons appear next\nto the New/Import buttons. Edit opens the CustomThemeModal in edit\nmode, Delete removes the theme and falls back to the default theme.

* refactor: remove redundant header from CustomThemeEditor\n\nThe inner header with back arrow and title was duplicating the\nparent CustomThemeModal header. Removed the header block,\nArrowLeft import, and prefixed unused props with underscore.

* fix: add missing common.edit i18n key\n\nAdded 'Edit' / '编辑' translations for the common.edit key\nthat was showing as raw key text in the Settings page.

* fix: add error feedback for .itermcolors import in settings\n\nAdded step-by-step console logging for debugging import issues.\nShows user-visible alert on parse failure with localized message.\nAlso added terminal.customTheme.importError i18n keys.

* fix: handle extra keys in .itermcolors color dicts\n\nThe parseColorDict function assumed keys[i] aligned with reals[i],\nbut .itermcolors files with extra keys like 'Alpha Component' (real)\nand 'Color Space' (string) broke the index mapping.\n\nNow iterates through dict children properly, pairing each <key>\nwith its next sibling and skipping non-<real> values.

* fix: subscribe to custom theme store for reactive re-renders\n\nReplaced imperative customThemeStore.getThemeById() calls with reactive\nuseCustomThemes() hook in useMemo dependencies across 5 files:\n- useSettingsState.ts (currentTerminalTheme)\n- Terminal.tsx (effectiveTheme for host-override)\n- TerminalLayer.tsx (composeBarThemeColors)\n- LogView.tsx (currentTheme for log replay)\n- SettingsTerminalTab.tsx (currentTheme)\n\nThis ensures editing a custom theme in-place (same ID) triggers\nre-renders in all consuming components, instead of showing stale colors\nuntil the user switches theme IDs or reloads.

* fix: theme editor hex validation, import error feedback, and Escape propagation\n\n1. ColorInput: Use local state for text field so partial hex values\n   (#1, #abc) are held locally while typing. Only complete #rgb (auto-\n   normalized to #rrggbb) or #rrggbb values are committed to the theme.\n   On blur, partial values revert to the last valid color.\n\n2. ThemeCustomizeModal handleFileSelected: Added error feedback via\n   window.alert when .itermcolors parsing fails, reusing the existing\n   terminal.customTheme.importError i18n key. Also extended filename\n   regex to strip .xml extension.\n\n3. ThemeCustomizeModal Escape handler: Skip parent modal cancelation\n   when editingTheme is active, so pressing Escape only closes the\n   child CustomThemeModal without reverting the parent dialog.

* fix: backdrop click closes CustomThemeModal + remove nested buttons in ThemeItem\n\n1. CustomThemeModal: Attach onClick={onCancel} directly to the backdrop\n   div instead of checking e.target === e.currentTarget on the container.\n   The modal content div now stops event propagation to prevent\n   accidental dismissal when clicking inside the dialog.\n\n2. ThemeItem: Replace outer <button> with <div role=\"button\"> and inner\n   edit <button> with <div role=\"button\"> to eliminate invalid nested\n   interactive elements. Added keyboard handlers (Enter/Space) for\n   accessibility parity.

* fix: restore Escape key in CustomThemeModal + stabilize store snapshots\n\n1. CustomThemeModal: Add Escape key handler (capture phase) so pressing\n   Escape dismisses the child editor. Fixes regression where parent\n   ThemeCustomizeModal skips Escape when editingTheme is active but\n   the child had no handler of its own.\n\n2. customThemeStore: Cache the merged allThemes array (built-in +\n   custom) and only rebuild it when the store is mutated. The previous\n   getAllThemes() created a new array every call, violating the\n   useSyncExternalStore contract that getSnapshot must return a stable\n   reference between mutations.

* fix: accept <integer> plist nodes and guard NaN in itermcolors parser\n\nparseColorDict now accepts both <real> (float 0.0-1.0) and <integer>\n(0-255) plist value types for RGB components. Integer values are\nnormalized by dividing by 255. Also added isNaN guard on parseFloat\nresults to prevent malformed '#NaNNaNNaN' color strings from being\npersisted as custom themes.

* fix: use customThemeStore.getThemeById in HostDetailsPanel\n\nHostDetailsPanel used TERMINAL_THEMES.find() for both SSH and Telnet\ntheme previews, which only searched built-in themes. When a custom\ntheme was selected for a host, the preview fell back to Flexoki Dark\ndefaults. Now uses customThemeStore.getThemeById() which searches\nboth built-in and custom themes.

* chore: remove fake user counts from ThemeSelectPanel\n\nRemoved Math.random() generated fake user counts for Kanagawa and\nHacker themes, 'new' badges for Flexoki themes, and the Users icon.\nOnly meaningful labels remain: 'Default' for netcatty-dark and\n'Light mode' for netcatty-light.
2026-03-03 19:27:37 +08:00
陈大猫
21ccc7906b feat: add compose bar for pre-composing commands (#198) (#244)
* feat: add compose bar for pre-composing commands (#198)\n\nAdd an XShell-style compose bar at the bottom of each terminal.\nThe bar lets users type and review commands before sending,\nwhich is helpful for password prompts (no echo) and complex\ncommands. When broadcast mode is active the composed text\nis sent to all sessions in the workspace.\n\nNew files:\n- TerminalComposeBar.tsx (auto-sizing textarea, Enter/Shift+Enter/Esc)\n\nModified:\n- TerminalToolbar.tsx — toggle button (TextCursorInput icon)\n- Terminal.tsx — state, send handler, flex-col layout\n- en.ts / zh-CN.ts — i18n strings"

* refactor: modernize compose bar styling and add global workspace bar\n\n- Rewrite TerminalComposeBar with modern styling: gradient background,\n  rounded bottom corners (8px), themed focus rings, native hover buttons\n- In workspace mode, render a single global compose bar at the bottom\n  of TerminalLayer instead of per-terminal bars\n- Non-broadcast: sends to the currently focused terminal session\n- Broadcast mode: sends to all sessions in the workspace\n- Add onToggleComposeBar/isWorkspaceComposeBarOpen props for\n  toolbar-to-TerminalLayer communication"

* fix: vertically center compose bar buttons and increase button contrast\n\n- Change flex alignment from items-end to items-center\n- Increase button background opacity (8%→20% for send, 0→12% for close)\n- Use solid bg color-mix instead of transparent for better visibility"

* fix: increase compose bar border contrast and fix IME composition\n\n- Increase border opacity from 12% to 25% (unfocused) and 25% to 40% (focused)\n- Add onCompositionStart/End handlers to prevent Enter key from\n  triggering send while IME composition is active (Chinese input)\n- Remove unnecessary wrapper div around textarea for better flex alignment"

* fix: refocus terminal when closing workspace compose bar\n\nAfter closing the compose bar in workspace mode, focus is now restored\nto the focused terminal pane via its xterm-helper-textarea, matching\nthe solo-session behavior. Uses requestAnimationFrame to ensure the\nDOM update completes before focusing."

* fix: fallback to first session when focusedSessionId is missing\n\nWhen broadcast is disabled and focusedSessionId is null (e.g. stale\nworkspace data), the compose bar now falls back to the first available\nsession in the workspace instead of silently dropping the input."

* fix: validate focusedSessionId is a live session before sending\n\nAfter closing a pane, focusedSessionId may point to a stale session.\nNow validates that focusedSessionId exists among the workspace's live\nsessions before using it, falling back to the first available session."
2026-03-03 17:01:57 +08:00
陈大猫
28d9a8e4db feat: add bracketed paste mode toggle (#233) (#243)
* feat: add bracketed paste mode toggle (#233)

Add a setting to disable bracketed paste mode, which prevents
^[[200~ artifacts in terminals that don't support it.

- Add disableBracketedPaste field to TerminalSettings
- Wire to xterm.js ignoreBracketedPasteMode option
- Add toggle in Settings > Terminal > Behavior
- Add en/zh-CN translations

* fix: update bracketed-paste option on live terminals

Apply ignoreBracketedPasteMode at runtime via the terminal settings
sync useEffect, so flipping the toggle takes effect immediately on
active sessions without requiring a reconnect.

* fix: respect disableBracketedPaste in all manual paste paths

The xterm.js ignoreBracketedPasteMode option only affects xterm's
own paste handling, not the modes getter. The 3 manual paste wrappers
(hotkey, context menu, middle-click) still checked
term.modes.bracketedPasteMode which reports true regardless of the
option. Now all 3 paths also check the user setting before wrapping.
2026-03-03 16:06:28 +08:00
bincxz
090ab82bde fix(host-details): prevent proxy and legacy text overflow 2026-03-03 15:34:50 +08:00
bincxz
157c73536b fix: prevent content from expanding aside panel width
Add overflow-hidden to AsidePanelContent inner wrapper to prevent
long text (like proxy hostnames) from expanding the panel beyond
its fixed width. The Radix ScrollArea Viewport allows content to
grow horizontally; this clips it at the container boundary.
2026-03-03 15:29:42 +08:00
bincxz
d74f47c38f fix: properly constrain proxy address text in flex layout
Use block truncate min-w-0 on the proxy address span to prevent
the long text from expanding the parent card's intrinsic width.
2026-03-03 15:27:05 +08:00
bincxz
f6cf915792 fix: constrain proxy address width to prevent overflow
Add overflow-hidden to the inner flex container holding the badge
and address text to ensure proper text truncation within the card.
2026-03-03 15:23:43 +08:00
bincxz
9d3b0056a5 fix: wrap Tooltip with TooltipProvider in proxy card
Fix 'Tooltip must be used within TooltipProvider' runtime error by
wrapping the proxy address Tooltip with TooltipProvider.
2026-03-03 15:22:41 +08:00
bincxz
ce16bd449f feat: add tooltip to show full proxy address on hover
Wrap the truncated proxy host:port in a Tooltip component so users
can hover to see the full address when it's too long to display.
2026-03-03 15:17:57 +08:00
bincxz
e645c5ee53 fix: truncate long proxy hostname in HostDetail card
Add overflow-hidden to the proxy summary button so long hostnames
are properly truncated with ellipsis instead of overflowing.
2026-03-03 15:16:21 +08:00
bincxz
07ac90b110 style: improve SOCKS5 proxy section layout in HostDetail
Redesign the proxy configuration card to match the Jump Hosts and
Environment Variables pattern:
- When configured: clickable summary card with proxy type badge,
  address, and X button to clear
- When unconfigured: simple + button to configure
- Removes cramped Badge-next-to-title layout that caused text wrapping
2026-03-03 15:13:29 +08:00
陈大猫
e8faecc37a fix: filter dotfiles as hidden on Linux/Unix systems (#242)
* fix: filter dotfiles as hidden on Linux/Unix systems (#194)

Previously the hidden file filter only checked the Windows hidden
attribute, leaving Unix/Linux dotfiles (starting with '.') always
visible regardless of the "show hidden files" setting.

- Rename isWindowsHiddenFile to isHiddenFile with both checks
- Add dotfile detection (name.startsWith('.'))
- Keep backward-compatible alias for isWindowsHiddenFile
- Update filterHiddenFiles to use the new isHiddenFile function

* fix: limit dotfile filtering to remote connections only

Address review feedback: dotfile filtering was applied unconditionally,
which would hide .gitignore, .env, etc. on local Windows panes.

- Add isLocal param to isHiddenFile/filterHiddenFiles
- When isLocal=true, only check Windows hidden attribute
- When isLocal=false (remote SFTP), also filter dotfiles
- Update all 3 callers to pass connection.isLocal
- Fix useMemo dependency arrays

* fix: preserve isWindowsHiddenFile backward compatibility

isWindowsHiddenFile alias now explicitly passes isLocal=true to
isHiddenFile, so existing callers that don't pass isLocal won't
accidentally filter dotfiles.
2026-03-03 15:09:44 +08:00
陈大猫
166633414a fix: split Linux build into x64 and arm64 jobs (#222) (#241)
The ARM64 AppImage contained x86-64 native modules (node-pty, ssh2)
because both architectures were built on the same x86 runner.

- Split Linux build into linux-x64 (ubuntu-latest) and linux-arm64
  (ubuntu-24.04-arm) jobs so native modules compile on the correct arch
- Add pack:linux-x64 and pack:linux-arm64 npm scripts with explicit
  --x64/--arm64 flags
- Unify CI build step using matrix variables instead of per-OS conditions
2026-03-03 14:49:23 +08:00
陈大猫
9ecefc6959 feat: add SFTP path bookmarks for dual-pane view (#240)
* feat: add SFTP path bookmarks for dual-pane view

- Add SftpBookmark interface and sftpBookmarks field to Host model
- Create useSftpBookmarks hook with toggle/delete/list operations
- Add updateHosts callback through SftpContext for persistence
- Add bookmark star button with Popover dropdown in SftpPaneToolbar
- Wire bookmarks from App.tsx → SftpView → SftpContextProvider → SftpPaneView
- Add i18n translations for en and zh-CN

Closes #193

* refactor: replace encoding Select with compact icon Popover in SFTP toolbar

Replace the wide Select dropdown for filename encoding with a compact
Languages icon button + Popover menu, matching the SftpModal style.

* feat: add bookmark support to SFTPModal with shared hook

Refactor useSftpBookmarks to accept host/onUpdateHost params directly
instead of reading from SftpContext, enabling reuse in both SftpPaneView
(dual-pane) and SFTPModal (terminal).

- Refactor useSftpBookmarks hook to be context-agnostic
- Add bookmark star + Popover UI to SftpModalHeader
- Wire onUpdateHost from Terminal.tsx through SFTPModal
- Update SftpPaneView to use the new hook interface
2026-03-03 14:41:17 +08:00
陈大猫
afcc33b7fb fix: add missing passphrase to SFTP dual-pane credentials (#238) (#239)
useSftpHostCredentials.ts omitted `passphrase` when building the
credentials object for the target host, causing SFTP connections with
passphrase-protected private keys to fail with:

  Error: Cannot parse privateKey: Encrypted private OpenSSH key
  detected, but no passphrase given

The jump host path (L50) already included passphrase correctly.
This adds the same pattern to the main host credentials.
2026-03-03 13:59:02 +08:00
陈大猫
4c2702b7ff fix: SFTP modal create file/folder and shortcut key translations (#229) (#237)
Bug 2: Replace prompt() with state-based dialog for new file/folder
- Electron does not support window.prompt() (returns null)
- Added create dialog following the existing rename dialog pattern
- Dialog renders in SftpModalDialogs with proper input + submit

Bug 3: Add Chinese translations for shortcut key labels
- SettingsShortcutsTab now uses t() for binding labels with fallback
- Added 29 Chinese translations for all keyboard shortcut bindings
2026-03-03 13:53:48 +08:00
陈大猫
fdcd8547d3 fix: reverse SFTP transfer queue order to show newest tasks first (#223) (#236)
- Reverse transfer list in dual-pane SftpView (visibleTransfers)
- Reverse transfer list in sidebar SftpModalUploadTasks
- Newest/active transfers now appear at the top without scrolling
2026-03-03 13:33:48 +08:00
陈大猫
16ae3ff2ed fix(sftp): prevent stale session race when reopening modal (#235)
* fix(sftp): prevent stale session race when reopening modal

* fix(sftp): close session on external modal hide

* fix(sftp): clean up late-created sessions after modal hide
2026-03-03 13:11:37 +08:00
陈大猫
80e6e3c4c1 fix(transfer): make fast-transfer cancellation actually abort (#234)
* fix(transfer): make fastPut/fastGet cancellation effective

* fix(transfer): settle fast-transfer promise on abort

* fix(transfer): handle isolated SFTP channel errors
2026-03-03 12:04:23 +08:00
陈大猫
b58120998f fix: improve SFTP transfer speed with parallel requests and accurate progress (#226) (#231)
- Replace sequential stream piping with ssh2 fastPut/fastGet (64 concurrent SFTP requests)
- Use 512KB chunk size instead of default 32KB for better throughput
- Fix speed calculation with sliding window to prevent inflated burst speeds
- Throttle progress IPC to 100ms intervals to reduce event loop contention
- Simplify frontend speed display by removing ref-based smoothing layer
- Update memo comparison for smoother progress bar re-renders
2026-03-03 11:27:42 +08:00
Rory Chou
c671943d49 fix(terminal): avoid incorrect WebGL addon constructor args (#217)
Co-authored-by: rorychou <roryechou@gmail.com>
2026-03-02 10:12:49 +08:00
陈大猫
664fe90c10 feat: add legacy SSH algorithm support for older network equipment (#216)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-03-01 07:45:13 +08:00
Rory Chou
2215d52b09 feat: credential protection guards for enc:v1: placeholders (#212)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
* feat: add credential protection guards for enc:v1: placeholders

Prevent encrypted credential placeholders from being sent as
actual passwords when safeStorage decryption is unavailable
(e.g. different device/user profile). Adds guards at terminal
connection, cloud sync, and settings boundaries with user-facing
warnings and i18n support.

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

* fix: validate base64 format in encrypted credential detection

Only treat values as encrypted placeholders when the content after
the enc:v1: prefix is valid base64. Prevents false positives if a
real password happens to start with the prefix literal.

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

* fix: resolve regressions in master-key change flow and credential placeholder detection

- Make ensureSyncablePayload non-blocking in changeMasterKey handler so
  success toast and dialog close always fire after a successful key change,
  even when the payload contains unresolved enc:v1: placeholders
- Add MIN_CIPHERTEXT_BASE64_LENGTH (32) threshold to
  isEncryptedCredentialPlaceholder to avoid false-positive matches on
  plaintext credentials that happen to start with enc:v1: (e.g. enc:v1:hello)

* fix: clean up chain-progress listener on credential reentry and gate proxy check on auth usage

- Unsubscribe onChainProgress before returning in needsCredentialReentry
  branch to prevent listener leaks across connection attempts
- Only block connection for undecryptable proxy password when proxy
  authentication is actually in use (has a username)

* fix: reduce enc:v1 placeholder false positives

* fix: require syncable payload before master key rotation

---------

Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-02-25 15:32:24 +08:00
Rory Chou
c9059a4f29 feat: add swap usage display in server stats (#210)
Collect SwapTotal and SwapFree from /proc/meminfo and display swap
usage in the memory HoverCard with a dedicated progress bar (rose color).
Only shown when the server has swap configured (swapTotal > 0).

Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:49:11 +08:00
Rory Chou
4445bf578c security: harden external navigation / window.open (#209) 2026-02-14 16:21:11 +08:00
Rory Chou
f719350507 fix(macos): restore main window on Dock activate (#208) 2026-02-14 12:20:48 +08:00
Copilot
cfaee48553 Remove extra space next to close button on Windows (#207)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
* Initial plan

* fix: remove extra right spacing next to close button on Windows

On Windows/Linux, the frameless title bar had ~20px of dead space
(12px right padding + 8px drag shim) to the right of the close button.

- Replace Tailwind `px-3` with conditional inline paddingRight (0 on
  Windows, 12px on macOS) so the close button sits at the window edge
- Render the drag shim only on macOS where it's useful as a drag region

macOS layout is unchanged.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-13 23:51:26 +08:00
bincxz
1f05fe3efa fix: remove extra space next to close button on Windows (#207) 2026-02-13 23:50:13 +08:00
copilot-swe-agent[bot]
e9c3b82c16 fix: remove extra right spacing next to close button on Windows
On Windows/Linux, the frameless title bar had ~20px of dead space
(12px right padding + 8px drag shim) to the right of the close button.

- Replace Tailwind `px-3` with conditional inline paddingRight (0 on
  Windows, 12px on macOS) so the close button sits at the window edge
- Render the drag shim only on macOS where it's useful as a drag region

macOS layout is unchanged.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-13 23:45:22 +08:00
copilot-swe-agent[bot]
83fce70b20 Initial plan 2026-02-13 23:45:22 +08:00
bincxz
d36c8bcbea fix: add missing </p> closing tags in VaultView empty hosts state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:44:50 +08:00
bincxz
5346752994 Merge branch 'fix/encrypt-credentials-at-rest' - encrypt sensitive credentials at rest via safeStorage (#203) 2026-02-13 23:40:17 +08:00
bincxz
d267c4b6fc fix: prevent stale cross-window writes and deferred-read init races
- CloudSyncManager: bump providerWriteSeq on storage events so an
  in-flight local save is discarded when newer cross-window data arrives
- useVaultState: defer reads of keys/identities/groups/snippets to just
  before their processing stage instead of reading all upfront, so data
  updated during a prior async decrypt gap is not overwritten by a stale
  pre-await snapshot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:20:46 +08:00
bincxz
1a1da02e92 fix: guard storage decrypt callbacks against local writes and sync updates
- useVaultState: storage-event decrypt callbacks now also check
  writeVersion so a local edit during the decrypt gap causes the stale
  cross-window result to be discarded
- CloudSyncManager: bump providerDecryptSeq in uploadToProvider before
  mutating lastSync/lastSyncVersion so a pending cross-window decrypt
  cannot overwrite the newer sync metadata

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:54:28 +08:00
bincxz
1adcffa7a8 fix: split provider sequence counters so status-only updates don't drop writes
Split the single providerSeq into providerDecryptSeq (bumped by all state
mutations to guard async decrypt callbacks) and providerWriteSeq (bumped
only by saveProviderConnection). This prevents status-only transitions
like 'error' or 'syncing' from discarding an in-flight encrypted write
from disconnect/auth, which would leave stale credentials in localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:43:09 +08:00
bincxz
7a2bedc4f4 fix: guard provider save writes and validate sentinel prefix on encrypt
- CloudSyncManager: add providerSeq write guard to saveProviderConnection
  so overlapping async saves don't let an older encryption overwrite newer
  provider state in localStorage
- credentialBridge: verify enc:v1: prefix by attempting trial decryption
  instead of prefix-only check, so plaintext values that happen to start
  with the sentinel are still encrypted rather than silently skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:33:52 +08:00
bincxz
5e753334ed fix: capture write version before async init decryption to prevent startup race
Move hostsWriteVersion/keysWriteVersion/identitiesWriteVersion increments
to before the await decryptHosts/Keys/Identities calls, and guard both
setstate and re-encrypt with the version check. This prevents a write
that occurs during the decryption await (storage event, user edit) from
being overwritten by stale init data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:23:28 +08:00
bincxz
a488bc466b fix: invalidate in-flight async operations on local and cross-window state changes
- useVaultState: bump writeVersion counters on storage events so pending
  local encrypts are discarded when newer cross-window data arrives
- CloudSyncManager: bump providerSeq on all local provider mutations
  (connect, disconnect, status updates, save) so in-flight decrypt
  callbacks from startup or storage events cannot revert newer state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:14:52 +08:00
bincxz
2748cd5363 fix: add sequence guards to all async decrypt paths
Prevent out-of-order async decrypt results from overwriting newer state:

- useVaultState: add per-key readSeq counters for cross-window storage
  event decrypt callbacks (hosts, keys, identities)
- CloudSyncManager: add per-provider sequence counters shared between
  initProviderDecryption and cross-window storage handler, so stale
  decrypt results are discarded in both paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:04:59 +08:00
bincxz
033165561d fix: add version guards to migration writes and fix stale prev in cross-window sync
Addresses remaining Codex review feedback:
- Add writeVersion checks to startup migration re-encrypt paths to prevent
  stale async writes from overwriting newer user edits
- Move `prev` read inside .then() in CloudSyncManager storage event handler
  so it compares against latest state rather than a stale snapshot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:52:43 +08:00
陈大猫
8e514f1008 fix: localize vault hosts empty state copy 2026-02-13 20:32:33 +08:00
Misaka21
0acd39603f feat: localize empty hosts message to Chinese 2026-02-13 19:40:08 +08:00
rorychou
4bdb0bbbf7 fix: address Codex review — serialize async writes & fix WebDAV token detection
1. Race condition: rapid updateHosts/Keys/Identities calls could cause
   out-of-order async writes. Added per-collection write-version counters
   so only the latest encryption Promise persists to localStorage.

2. WebDAV token-auth: using "password" in config as discriminator failed
   for token-auth configs because JSON.stringify drops undefined keys.
   Switched to "authType" in config which is a required WebDAVConfig field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:12:21 +08:00
rorychou
6b2c58f8f0 fix: encrypt sensitive credentials at rest via safeStorage
Passwords, OAuth tokens, SSH private keys, and cloud sync secrets were
stored as plaintext JSON in browser localStorage.  Any XSS or local
file read could extract all credentials in one shot.

This commit adds field-level encryption using Electron's safeStorage
API.  Encrypted values are stored with an `enc:v1:` prefix sentinel
so plaintext values migrate transparently on first read — no version
bumps or flags needed.

New files:
- electron/bridges/credentialBridge.cjs — IPC handlers (encrypt/decrypt/available)
- infrastructure/persistence/secureFieldAdapter.ts — per-model encrypt/decrypt helpers

Modified files:
- electron/main.cjs, preload.cjs, global.d.ts — bridge wiring + types
- useVaultState.ts — async encrypted writes, decrypted reads, migration on init
- CloudSyncManager.ts — async provider token/config encryption

Sensitive fields encrypted:
- Host: password, telnetPassword, proxyConfig.password
- SSHKey: passphrase, privateKey
- Identity: password
- CloudSync: accessToken, refreshToken, WebDAV password/token, S3 secretAccessKey

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:30:37 +08:00
陈大猫
c0199c43cf fix: prevent zombie processes and improve window recovery on restart (#201)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Destroy trayPanelWindow and clear refresh timer during cleanup, preventing
  hidden BrowserWindows from keeping the Electron process alive
- Add SIGTERM/SIGINT handlers for graceful shutdown
- Detect crashed webContents in focusMainWindow() and recreate the window
  instead of silently failing on second-instance activation

Closes the issue where restarting the app shows "Failed to load the UI"
and leaves multiple zombie processes in the task manager.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:16:42 +08:00
陈大猫
7940b9a0a7 fix: tray quit button, tree view multi-select, and SFTP banner handling (#200)
* fix: tray quit button, tree view multi-select, and SFTP banner handling

- Add "Quit Netcatty" button pinned to the bottom of TrayPanel so users
  can exit the app when close-to-tray is enabled
- Support multi-select mode in HostTreeView (checkboxes, click-to-select)
  so tree view behaves the same as grid/list views
- Patch ssh2 SFTP parser to skip non-SFTP preamble data (MOTD/banner text)
  that causes "Packet length exceeds max length" errors on misconfigured
  servers, with proper cross-frame buffering

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

* fix: gate SFTP preamble scan to client-mode only

Server-mode SFTP expects SSH_FXP_INIT (0x01) as the first packet, not
SSH_FXP_VERSION (0x02). Skip the preamble scan entirely when running in
server mode to avoid stalling server-side SFTP sessions.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:31:01 +08:00
Copilot
920914e3ee Fix ERR_FAILED on second instance by moving single-instance lock before app.whenReady() (#199)
* Initial plan

* Fix ERR_FAILED when second instance launches by moving single-instance lock before app.whenReady()

Move app.requestSingleInstanceLock() before app.whenReady() registration
and wrap all lifecycle handlers (whenReady, window-all-closed, before-quit,
will-quit) inside the else block. This prevents a second instance from
attempting to register the app:// protocol or create a BrowserWindow,
which would fail with ERR_FAILED.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-12 16:53:50 +08:00
248 changed files with 32600 additions and 5376 deletions

View File

@@ -13,12 +13,18 @@ on:
jobs:
build:
name: build-${{ matrix.os }}
name: build-${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
include:
- name: macos
os: macos-latest
pack_script: pack:mac
- name: windows
os: windows-latest
pack_script: pack:win
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
@@ -31,7 +37,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: npm
- name: Install deps
@@ -50,43 +56,148 @@ jobs:
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package (macOS)
if: matrix.os == 'macos-latest'
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:mac
- name: Build package (Windows)
if: matrix.os == 'windows-latest'
- name: Build package
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:win
- name: Build package (Linux)
if: matrix.os == 'ubuntu-latest'
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux
# macOS code signing & notarization (only for macOS builds)
CSC_LINK: ${{ matrix.name == 'macos' && secrets.MAC_CSC_LINK || '' }}
CSC_KEY_PASSWORD: ${{ matrix.name == 'macos' && secrets.MAC_CSC_KEY_PASSWORD || '' }}
APPLE_ID: ${{ matrix.name == 'macos' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.name == 'macos' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
run: npm run ${{ matrix.pack_script }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-${{ matrix.os }}
name: netcatty-${{ matrix.name }}
path: |
release/*.dmg
release/*.zip
release/*.exe
release/*.msi
release/*.AppImage
release/*.deb
release/*.rpm
release/*.tar.gz
release/*.yml
release/*.blockmap
if-no-files-found: ignore
# Linux x64 — pin to ubuntu-22.04 for broader glibc compatibility.
# ubuntu-latest (24.04) links native modules against glibc 2.39 which
# can cause dlopen failures on some distros. 22.04 uses glibc 2.35,
# compatible with most current Linux distributions including Arch.
# See #264.
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-22.04
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-x64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-linux-x64
path: |
release/*.AppImage
release/*.deb
release/*.rpm
release/*.yml
release/*.blockmap
if-no-files-found: ignore
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package
env:
npm_config_arch: arm64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-linux-arm64
path: |
release/*.AppImage
release/*.deb
release/*.rpm
release/*.yml
release/*.blockmap
if-no-files-found: ignore
release:
name: release
runs-on: ubuntu-latest
needs: build
needs: [build, build-linux-x64, build-linux-arm64]
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
@@ -116,10 +227,13 @@ jobs:
body_path: release_notes.md
files: |
artifacts/*.dmg
artifacts/*.zip
artifacts/*.exe
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.yml
artifacts/*.blockmap
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}

27
.gitignore vendored
View File

@@ -17,7 +17,8 @@ dist-ssr
*.tsbuildinfo
coverage
/.vite
/build
/build/*
!/build/icons
/electron/native/**/build
/release
/out
@@ -34,5 +35,25 @@ coverage
*.sln
*.sw?
# Claude Code local settings
/.claude/settings.local.json
# Claude Code
/.claude/
/CLAUDE.md
# AI / Superpowers generated docs (local only)
/docs/superpowers/
# Dev-only electron-updater test config (not for production)
/dev-app-update.yml
# Test suite (local only, not committed)
/tests/
/vitest.config.ts
# Serena MCP project config (local only)
/.serena/
# Windows VS Build environment scripts (local dev only)
Directory.Build.props
Directory.Build.targets
build_with_vs.bat
build_with_vs2022.bat

201
App.tsx
View File

@@ -14,7 +14,10 @@ import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
import { applySyncPayload } from './domain/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
@@ -166,8 +169,8 @@ function App({ settings }: { settings: SettingsState }) {
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const {
theme,
setTheme,
resolvedTheme,
setTerminalThemeId,
currentTerminalTheme,
terminalFontFamilyId,
@@ -178,6 +181,12 @@ function App({ settings }: { settings: SettingsState }) {
hotkeyScheme,
keyBindings,
isHotkeyRecording,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
@@ -281,20 +290,16 @@ function App({ settings }: { settings: SettingsState }) {
identities,
snippets,
customGroups,
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
settingsVersion: settings.settingsVersion,
onApplyPayload: (payload) => {
importDataFromString(JSON.stringify({
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
snippets: payload.snippets,
customGroups: payload.customGroups,
}));
if (payload.portForwardingRules) {
importPortForwardingRules(payload.portForwardingRules);
}
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
});
},
});
@@ -309,10 +314,17 @@ function App({ settings }: { settings: SettingsState }) {
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, openReleasePage, dismissUpdate } = useUpdateCheck();
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
// Show toast notification when update is available
// Window controls - must be before update toast effect which uses openSettingsWindow
const { openSettingsWindow } = useWindowControls();
// Show toast notification when update is available (only when auto-download is idle)
useEffect(() => {
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
@@ -321,18 +333,55 @@ function App({ settings }: { settings: SettingsState }) {
title: t('update.available.title'),
duration: 8000, // Show longer for update notifications
onClick: () => {
openReleasePage();
void openSettingsWindow();
// Dismiss the update so the toast doesn't re-fire on every render.
// On unsupported platforms (where autoDownloadStatus stays 'idle')
// this is the only way to suppress the notification for this version.
// On supported platforms this toast only shows before auto-download
// starts, and the Settings window's own useUpdateCheck will pick up
// the download state via IPC events independently of the dismiss.
dismissUpdate();
},
actionLabel: t('update.downloadNow'),
actionLabel: t('update.viewInSettings'),
}
);
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
// not when unrelated deps (openReleasePage, installUpdate) change their reference.
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
useEffect(() => {
const prev = prevAutoDownloadStatusRef.current;
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
if (prev === updateState.autoDownloadStatus) return;
if (updateState.autoDownloadStatus === 'ready') {
const version = updateState.latestRelease?.version ?? '';
toast.info(
t('update.readyToInstall.message', { version }),
{
title: t('update.readyToInstall.title'),
duration: 0,
actionLabel: t('update.restartNow'),
onClick: () => installUpdate(),
}
);
} else if (updateState.autoDownloadStatus === 'error') {
toast.error(
t('update.downloadFailed.message'),
{
title: t('update.downloadFailed.title'),
actionLabel: t('update.openReleases'),
onClick: () => openReleasePage(),
}
);
}
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase, })),
[keys]
);
@@ -401,7 +450,7 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey }));
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
if (start) {
void startTunnel(rule, host, keysForPf, (status, error) => {
if (status === "error" && error) toast.error(error);
@@ -439,16 +488,40 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
localHostname: "",
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
connectToHost(host);
};
const bridge = netcattyBridge.get();
@@ -460,7 +533,7 @@ function App({ settings }: { settings: SettingsState }) {
unsubscribeJump?.();
unsubscribeConnect?.();
};
}, [addConnectionLog, connectToHost, hosts, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
@@ -894,7 +967,9 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to create local terminal with logging
const handleCreateLocalTerminal = useCallback(() => {
const { username, hostname } = systemInfoRef.current;
const sessionId = createLocalTerminal();
addConnectionLog({
sessionId,
hostId: '',
hostLabel: 'Local Terminal',
hostname: 'localhost',
@@ -905,7 +980,6 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: hostname,
saved: false,
});
createLocalTerminal();
}, [addConnectionLog, createLocalTerminal]);
// Wrapper to connect to host with logging
@@ -915,7 +989,9 @@ function App({ settings }: { settings: SettingsState }) {
// Handle serial hosts separately
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
@@ -926,13 +1002,14 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: localHost,
saved: false,
});
connectToHost(host);
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
@@ -943,14 +1020,15 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: localHost,
saved: false,
});
connectToHost(host);
}, [addConnectionLog, connectToHost, identities, keys]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig) => {
const { username, hostname } = systemInfoRef.current;
const portName = config.path.split('/').pop() || config.path;
const sessionId = createSerialSession(config);
addConnectionLog({
sessionId,
hostId: '',
hostLabel: `Serial: ${portName}`,
hostname: config.path,
@@ -961,32 +1039,23 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: hostname,
saved: false,
});
createSerialSession(config);
}, [addConnectionLog, createSerialSession]);
// Handle terminal data capture when session exits
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
// Find the connection log for this session
const session = sessions.find(s => s.id === sessionId);
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
if (!session) {
if (IS_DEV) console.log('[handleTerminalDataCapture] No session found');
return;
}
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
if (IS_DEV) console.log('[handleTerminalDataCapture] Looking for logs with hostname:', session.hostname);
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
// Find the most recent log matching this session's hostname and doesn't have terminalData yet
// For local terminal, hostname is 'localhost'
// Sort by startTime descending to find the most recent matching log
// Prefer the persisted sessionId because the session may already have been
// removed from state by the time the terminal unmount cleanup runs.
const matchingLog = connectionLogs
.filter(log =>
log.hostname === session.hostname &&
!log.endTime &&
!log.terminalData
)
.filter((log) => {
if (log.endTime || log.terminalData) return false;
if (log.sessionId) return log.sessionId === sessionId;
return !!session && log.hostname === session.hostname;
})
.sort((a, b) => b.startTime - a.startTime)[0];
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
@@ -1066,14 +1135,15 @@ function App({ settings }: { settings: SettingsState }) {
}, [protocolSelectHost, handleConnectToHost]);
const handleToggleTheme = useCallback(() => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
}, [setTheme]);
// Toggle based on the actual rendered theme so clicking always produces a visible change,
// even when the stored preference is 'system'.
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}, [resolvedTheme, setTheme]);
const handleOpenQuickSwitcher = useCallback(() => {
setIsQuickSwitcherOpen(true);
}, []);
const { openSettingsWindow } = useWindowControls();
const handleOpenSettings = useCallback(() => {
void (async () => {
@@ -1082,6 +1152,30 @@ function App({ settings }: { settings: SettingsState }) {
})();
}, [openSettingsWindow, t]);
const hasShownCredentialProtectionWarningRef = useRef(false);
useEffect(() => {
if (hasShownCredentialProtectionWarningRef.current) return;
let cancelled = false;
void (async () => {
const available = await getCredentialProtectionAvailability();
if (cancelled || available !== false) return;
hasShownCredentialProtectionWarningRef.current = true;
toast.warning(t('credentials.protectionUnavailable.message'), {
title: t('credentials.protectionUnavailable.title'),
actionLabel: t('credentials.protectionUnavailable.action'),
duration: 10000,
onClick: handleOpenSettings,
});
})();
return () => {
cancelled = true;
};
}, [handleOpenSettings, t]);
const handleEndSessionDrag = useCallback(() => {
setDraggingSessionId(null);
}, [setDraggingSessionId]);
@@ -1117,7 +1211,8 @@ function App({ settings }: { settings: SettingsState }) {
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={theme}
theme={resolvedTheme}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
workspaces={workspaces}
@@ -1184,13 +1279,14 @@ function App({ settings }: { settings: SettingsState }) {
/>
</VaultViewContainer>
<SftpViewMount hosts={hosts} keys={keys} identities={identities} />
<SftpViewMount hosts={hosts} keys={keys} identities={identities} updateHosts={updateHosts} />
<TerminalLayerMount
hosts={hosts}
keys={keys}
identities={identities}
snippets={snippets}
snippetPackages={snippetPackages}
sessions={sessions}
workspaces={workspaces}
knownHosts={knownHosts}
@@ -1223,6 +1319,13 @@ function App({ settings }: { settings: SettingsState }) {
onSplitSession={splitSession}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
/>
{/* Log Views - readonly terminal replays */}

32
CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
# Changelog
## [Unreleased] - 2026-03-11
### 功能
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
- 设置窗口中点击"检查更新"后,下载进度可实时反映在 UI 中
- 应用启动后 5 秒自动触发 `electron-updater` 检查更新,无需用户手动点击
- 发现新版本后自动开始下载(`autoDownload=true`
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
- 下载失败时弹出错误 toast提供"打开 Releases"降级入口
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
### 设计原理
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
- `manualCheckStatus` 字段追踪手动检查 UI 状态idle/checking/available/up-to-date/error`autoDownloadStatus` 在 UI 层按优先级渲染
- `SettingsSystemTab` 不再持有本地 update state单向接收 `useUpdateCheck` 统一数据
- 将原有两套独立系统GitHub API 通知 + electron-updater 手动下载)合并为统一状态机:`useUpdateCheck` 作为唯一事实来源,同时驱动 `App.tsx` toast 和 `SettingsSystemTab` 进度条
- 全局持久化 IPC 监听器在 `autoUpdateBridge.init()` 时一次性注册,避免每次手动下载请求重复注册/清理监听器
- `autoInstallOnAppQuit=false`,不做静默安装,由用户主动触发重启
### 接口变更SettingsSystemTabProps
- 移除:`autoDownloadStatus``downloadPercent`
- 新增:`updateState`(完整 UpdateState`checkNow``installUpdate``openReleasePage`
### 注意事项
- `checkNow` 语义:使用 GitHub API`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
- 此功能仅对打包后的应用Windows NSIS、macOS dmg/zip、Linux AppImage生效dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复

View File

@@ -215,11 +215,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> または、アプリを右クリック → 開く → ダイアログで「開く」をクリックしてください。
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください
### 前提条件
- Node.js 18+ と npm

View File

@@ -214,11 +214,7 @@ Download the latest release for your platform from [GitHub Releases](https://git
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> Or right-click the app → Open → Click "Open" in the dialog.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
### Prerequisites
- Node.js 18+ and npm

View File

@@ -214,11 +214,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
> **⚠️ macOS 用户注意:** 由于应用未经代码签名macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> 或者右键点击应用 → 打开 → 在弹出的对话框中点击"打开"。
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
### 前置条件
- Node.js 18+ 和 npm

View File

@@ -14,6 +14,7 @@ const en: Messages = {
'common.import': 'Import',
'common.generate': 'Generate',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.clear': 'Clear',
'common.optional': 'Optional',
'common.selectPlaceholder': 'Select...',
@@ -33,6 +34,7 @@ const en: Messages = {
'common.advanced': 'Advanced',
'common.left': 'Left',
'common.right': 'Right',
'common.more': 'More',
'common.selectAHost': 'Select a host',
'common.selectAHostPlaceholder': 'Select a host...',
'sort.az': 'A-z',
@@ -48,6 +50,7 @@ const en: Messages = {
// Dialogs / prompts
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'confirm.removeProvider': 'Remove provider "{name}"?',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
@@ -57,6 +60,9 @@ const en: Messages = {
'placeholder.sessionName': 'Session name',
'placeholder.searchHosts': 'Search hosts...',
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'credentials.protectionUnavailable.action': 'Open Settings',
// Settings shell
'settings.title': 'Settings',
@@ -80,6 +86,37 @@ const en: Messages = {
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
'settings.system.credentials.available': 'Available (OS keychain ready)',
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
'settings.update.checkForUpdates': 'Check for Updates',
'settings.update.checking': 'Checking...',
'settings.update.upToDate': 'You are using the latest version.',
'settings.update.available': 'New version {version} is available.',
'settings.update.download': 'Download Update',
'settings.update.downloading': 'Downloading... {percent}%',
'settings.update.readyToInstall': 'Update downloaded and ready to install.',
'settings.update.restartNow': 'Restart to Update',
'settings.update.error': 'Failed to check for updates.',
'settings.update.downloadError': 'Download failed.',
'settings.update.manualDownload': 'Download from GitHub',
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
'settings.update.lastCheckedJustNow': 'just now',
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
'settings.update.lastCheckedPrefix': 'Last checked: ',
'settings.update.autoUpdateEnabled': 'Automatic Updates',
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
@@ -107,6 +144,8 @@ const en: Messages = {
'settings.globalHotkey.reset': 'Reset to default',
'settings.globalHotkey.closeToTray': 'Close to System Tray',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
// Tray Panel
@@ -122,6 +161,7 @@ const en: Messages = {
'tray.recentHosts': 'Recent Hosts',
'tray.empty.title': 'Nothing here yet',
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
'tray.quit': 'Quit Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': 'Collapse sidebar',
@@ -146,13 +186,23 @@ const en: Messages = {
'update.upToDate.message': 'You are running the latest version ({version}).',
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.viewInSettings': 'View in Settings',
'update.readyToInstall.title': 'Update Ready',
'update.readyToInstall.message': 'Version {version} downloaded and ready to install.',
'update.restartNow': 'Restart Now',
'update.downloadFailed.title': 'Update Failed',
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
'update.openReleases': 'Open Releases',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
// Settings > Appearance
'settings.appearance.uiTheme': 'UI Theme',
'settings.appearance.darkMode': 'Dark Mode',
'settings.appearance.darkMode.desc': 'Toggle between light and dark theme',
'settings.appearance.theme': 'Theme',
'settings.appearance.theme.desc': 'Choose light, dark, or follow system preference',
'settings.appearance.theme.light': 'Light',
'settings.appearance.theme.dark': 'Dark',
'settings.appearance.theme.system': 'System',
'settings.appearance.accentColor': 'Accent Color',
'settings.appearance.customColor': 'Custom color',
'settings.appearance.accentColor.mode': 'Use custom accent',
@@ -213,10 +263,24 @@ const en: Messages = {
'settings.terminal.behavior.rightClick.paste': 'Paste',
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text',
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Option on macOS or Shift on Windows/Linux to select',
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
'settings.terminal.behavior.middleClickPaste.desc':
'Paste clipboard content on middle-click',
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
'settings.terminal.behavior.osc52Clipboard.off': 'Disabled',
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Write only',
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Read & Write',
'settings.terminal.behavior.osc52Clipboard.prompt': 'Write + Prompt on Read',
'terminal.osc52.readPrompt.title': 'Clipboard Read Request',
'terminal.osc52.readPrompt.desc': 'A remote program is requesting to read your clipboard. Allow?',
'terminal.osc52.readPrompt.allow': 'Allow',
'terminal.osc52.readPrompt.deny': 'Deny',
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
@@ -323,6 +387,7 @@ const en: Messages = {
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
'sync.autoSync.syncFailed': 'Sync failed',
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
'time.never': 'Never',
'time.justNow': 'Just now',
'time.minutesAgo': '{minutes}m ago',
@@ -389,6 +454,8 @@ const en: Messages = {
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
// Vault import
'vault.import.title': 'Add data to your vault',
@@ -510,6 +577,9 @@ const en: Messages = {
'sftp.newFile': 'New File',
'sftp.filter': 'Filter',
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.bookmark.add': 'Bookmark this path',
'sftp.bookmark.remove': 'Remove bookmark',
'sftp.bookmark.empty': 'No bookmarks yet',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
@@ -542,7 +612,11 @@ const en: Messages = {
'sftp.status.loading': 'Loading...',
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.transfers': 'Transfers',
'sftp.transfers.active': '{count} active',
'sftp.transfers.clearCompleted': 'Clear completed',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
@@ -685,6 +759,7 @@ const en: Messages = {
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
'sftp.upload.completedToPath': 'Uploaded to {path}',
// SFTP Download
'sftp.download.completed': 'Downloaded',
@@ -701,9 +776,9 @@ const en: Messages = {
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Show hidden files',
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
'settings.sftp.showHiddenFiles.desc': 'Display hidden files (dotfiles on Unix/macOS and files with the hidden attribute on Windows) in the SFTP file browser.',
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
'settings.sftp.showHiddenFiles.enableDesc': 'Display hidden files when browsing both local and remote filesystems',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
@@ -775,9 +850,13 @@ const en: Messages = {
'hostDetails.certs.empty': 'No certificates available',
'hostDetails.agentForwarding': 'Forward SSH Agent',
'hostDetails.agentForwarding.desc': 'Allow remote server to use your local SSH keys (e.g., for git operations)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
@@ -893,8 +972,16 @@ const en: Messages = {
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
'terminal.toolbar.composeBar': 'Compose Bar',
'terminal.composeBar.placeholder': 'Type command here, press Enter to send...',
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.encoding': 'Terminal Encoding',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': 'Close session',
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
@@ -913,6 +1000,10 @@ const en: Messages = {
'terminal.serverStats.memBuffers': 'Buffers',
'terminal.serverStats.memCached': 'Cache',
'terminal.serverStats.memFree': 'Free',
'terminal.serverStats.swap': 'Swap',
'terminal.serverStats.swapUsed': 'Swap Used',
'terminal.serverStats.swapFree': 'Swap Free',
'terminal.serverStats.swapTotal': 'Total',
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
'terminal.serverStats.disk': 'Disk Usage (Root)',
'terminal.serverStats.diskDetails': 'Mounted Disks',
@@ -949,6 +1040,10 @@ const en: Messages = {
'terminal.auth.selectKey': 'Select Key',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
@@ -964,10 +1059,50 @@ const en: Messages = {
'terminal.themeModal.title': 'Terminal Appearance',
'terminal.themeModal.tab.theme': 'Theme',
'terminal.themeModal.tab.font': 'Font',
'terminal.themeModal.tab.custom': 'Custom',
'terminal.themeModal.fontSize': 'Font Size',
'terminal.themeModal.livePreview': 'Live Preview',
'terminal.themeModal.themeType': '{type} theme',
// Custom Themes
'terminal.customTheme.section': 'Custom Themes',
'terminal.customTheme.yourThemes': 'Your Themes',
'terminal.customTheme.new': 'New Theme',
'terminal.customTheme.newDesc': 'Clone current theme and customize',
'terminal.customTheme.newTitle': 'New Custom Theme',
'terminal.customTheme.editTitle': 'Edit Theme',
'terminal.customTheme.import': 'Import .itermcolors',
'terminal.customTheme.importDesc': 'Import from iTerm2 color scheme file',
'terminal.customTheme.importError': 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.',
'terminal.customTheme.delete': 'Delete Theme',
'terminal.customTheme.confirmDelete': 'Confirm Delete',
'terminal.customTheme.name': 'Name',
'terminal.customTheme.namePlaceholder': 'My Custom Theme',
'terminal.customTheme.type': 'Type',
'terminal.customTheme.group.general': 'General',
'terminal.customTheme.group.normal': 'Normal Colors',
'terminal.customTheme.group.bright': 'Bright Colors',
'terminal.customTheme.color.background': 'Background',
'terminal.customTheme.color.foreground': 'Foreground',
'terminal.customTheme.color.cursor': 'Cursor',
'terminal.customTheme.color.selection': 'Selection',
'terminal.customTheme.color.black': 'Black',
'terminal.customTheme.color.red': 'Red',
'terminal.customTheme.color.green': 'Green',
'terminal.customTheme.color.yellow': 'Yellow',
'terminal.customTheme.color.blue': 'Blue',
'terminal.customTheme.color.magenta': 'Magenta',
'terminal.customTheme.color.cyan': 'Cyan',
'terminal.customTheme.color.white': 'White',
'terminal.customTheme.color.brightBlack': 'Bright Black',
'terminal.customTheme.color.brightRed': 'Bright Red',
'terminal.customTheme.color.brightGreen': 'Bright Green',
'terminal.customTheme.color.brightYellow': 'Bright Yellow',
'terminal.customTheme.color.brightBlue': 'Bright Blue',
'terminal.customTheme.color.brightMagenta': 'Bright Magenta',
'terminal.customTheme.color.brightCyan': 'Bright Cyan',
'terminal.customTheme.color.brightWhite': 'Bright White',
// Cloud Sync Settings
'cloudSync.gate.title': 'End-to-End Encrypted Sync',
'cloudSync.gate.desc':
@@ -1007,6 +1142,7 @@ const en: Messages = {
'cloudSync.webdav.password': 'Password',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': 'Show secret',
'cloudSync.webdav.allowInsecure': 'Allow insecure connection (ignore certificate errors)',
'cloudSync.webdav.validation.endpoint': 'Enter a valid WebDAV endpoint.',
'cloudSync.webdav.validation.credentials': 'Username and password are required.',
'cloudSync.webdav.validation.token': 'Token is required.',
@@ -1282,6 +1418,7 @@ const en: Messages = {
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
// Snippet Shortkey
'snippets.field.shortkey': 'Keyboard Shortcut',
'snippets.shortkey.placeholder': 'Click to set shortcut',
@@ -1358,6 +1495,164 @@ const en: Messages = {
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.title': 'AI',
'ai.description': 'Configure AI providers, agents, and safety settings',
'ai.providers': 'Providers',
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
'ai.providers.add': 'Add Provider',
'ai.providers.active': 'Active',
'ai.providers.apiKeyConfigured': 'API key configured',
'ai.providers.noApiKey': 'No API key',
'ai.providers.configure': 'Configure',
'ai.providers.remove': 'Remove',
'ai.providers.name': 'Display Name',
'ai.providers.name.placeholder': 'e.g. My Provider',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
'ai.providers.refreshModels': 'Refresh models',
'ai.providers.searchModel': 'Search or type model ID...',
'ai.providers.filterModels': 'Filter models...',
'ai.providers.loadingModels': 'Loading models...',
'ai.providers.noMatchingModels': 'No matching models',
'ai.providers.clickToLoadModels': 'Click to load models',
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Uses codex + codex-acp for ACP protocol streaming. Login with ChatGPT subscription here, or configure an OpenAI provider API key (passed as CODEX_API_KEY).',
'ai.codex.detecting': 'Detecting...',
'ai.codex.notFound': 'Not found',
'ai.codex.awaitingLogin': 'Awaiting login',
'ai.codex.connectedChatGPT': 'Connected via ChatGPT',
'ai.codex.connectedApiKey': 'Connected via API key',
'ai.codex.notConnected': 'Not connected',
'ai.codex.statusUnknown': 'Status unknown',
'ai.codex.path': 'Path:',
'ai.codex.notFoundHint': 'Could not find codex in PATH. Install it or specify the executable path below.',
'ai.codex.customPathPlaceholder': 'e.g. /usr/local/bin/codex',
'ai.codex.check': 'Check',
'ai.codex.openLogin': 'Open Login',
'ai.codex.logout': 'Logout',
'ai.codex.connectChatGPT': 'Connect ChatGPT',
'ai.codex.refreshStatus': 'Refresh Status',
'ai.codex.apiKeyHint': 'Enabled OpenAI provider API key detected. Codex ACP can also authenticate without ChatGPT login.',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-code-acp for ACP protocol streaming.",
'ai.claude.detecting': 'Detecting...',
'ai.claude.detected': 'Detected',
'ai.claude.notFound': 'Not found',
'ai.claude.path': 'Path:',
'ai.claude.notFoundHint': 'Could not find claude in PATH. Install it or specify the executable path below.',
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.check': 'Check',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
'ai.defaultAgent.catty': 'Catty (Built-in)',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
'ai.chat.toolDenied': 'Action was rejected by the user.',
'ai.chat.toolApprovalTitle': 'Permission Required',
'ai.chat.toolApproved': 'Approved',
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
'ai.chat.approve': 'Approve',
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target',
'ai.chat.permissionRequired': 'Permission Required',
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
'ai.chat.recommendAllow': 'Allow',
'ai.chat.recommendConfirm': 'Confirm',
'ai.chat.recommendDeny': 'Deny',
'ai.chat.exportConversation': 'Export conversation',
'ai.chat.exportAs': 'Export As',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': 'Plain Text',
'ai.chat.thinking': 'Thinking',
'ai.chat.thoughtFor': 'Thought for {duration}',
'ai.chat.thought': 'Thought',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': 'Detected on this machine',
'ai.chat.rescan': 'Re-scan',
'ai.chat.permObserver': 'Observer',
'ai.chat.permConfirm': 'Confirm',
'ai.chat.permAuto': 'Auto',
'ai.chat.permObserverDesc': 'Read only',
'ai.chat.permConfirmDesc': 'Ask before actions',
'ai.chat.permAutoDesc': 'Execute freely',
'ai.chat.emptyHint': 'Ask about your servers, run commands, or get help with configurations.',
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
'ai.chat.placeholderDefault': 'Message Catty Agent...',
'ai.chat.noModel': 'No model',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
'ai.chat.justNow': 'Just now',
'ai.chat.minutesAgo': '{n}m ago',
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
'ai.chat.menuHosts': 'Hosts',
'ai.chat.menuContext': 'Context',
'ai.chat.menuFiles': 'Files',
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
// AI Web Search
'ai.webSearch.title': 'Web Search',
'ai.webSearch.enable': 'Enable Web Search',
'ai.webSearch.enable.description': 'Allow the AI agent to search the web for current information.',
'ai.webSearch.provider': 'Search Provider',
'ai.webSearch.provider.description': 'Choose a web search API provider.',
'ai.webSearch.apiKey': 'API Key',
'ai.webSearch.apiKey.description': 'API key for the selected search provider.',
'ai.webSearch.apiKey.placeholder': 'Enter API key...',
'ai.webSearch.apiHost': 'API Host',
'ai.webSearch.apiHost.description': 'Custom API endpoint. Leave default unless you use a proxy.',
'ai.webSearch.apiHost.searxngDescription': 'URL of your SearXNG instance (required).',
'ai.webSearch.maxResults': 'Max Results',
'ai.webSearch.maxResults.description': 'Maximum number of search results to return (1-20).',
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
'ai.safety.commandTimeout': 'Command Timeout',
'ai.safety.commandTimeout.description': 'Maximum seconds a command can run before being terminated. Applies to both built-in and ACP agents.',
'ai.safety.commandTimeout.unit': 'sec',
'ai.safety.maxIterations': 'Max Iterations',
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
'ai.safety.blocklist': 'Command Blocklist',
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
'ai.safety.blocklist.placeholder': 'Regex pattern...',
'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.',
};
export default en;

View File

@@ -22,6 +22,7 @@ const zhCN: Messages = {
'common.use': '使用',
'common.left': '左侧',
'common.right': '右侧',
'common.more': '更多',
'common.selectAHost': '选择主机',
'sort.az': 'A-z',
'sort.za': 'Z-a',
@@ -36,12 +37,16 @@ const zhCN: Messages = {
// Dialogs / prompts
'confirm.deleteHost': '删除主机 "{name}"',
'confirm.deleteIdentity': '删除身份 "{name}"',
'confirm.removeProvider': '移除提供商 "{name}"',
'dialog.renameWorkspace.title': '重命名工作区',
'dialog.renameSession.title': '重命名会话',
'field.name': '名称',
'placeholder.workspaceName': '工作区名称',
'placeholder.sessionName': '会话名称',
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
'credentials.protectionUnavailable.title': '凭据保护不可用',
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
'credentials.protectionUnavailable.action': '打开设置',
// Settings shell
'settings.title': '设置',
@@ -65,6 +70,37 @@ const zhCN: Messages = {
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
'settings.system.credentials.title': '凭据保护',
'settings.system.credentials.status': '状态',
'settings.system.credentials.checking': '检查中...',
'settings.system.credentials.available': '可用(系统钥匙串正常)',
'settings.system.credentials.unavailable': '不可用(无法解密已保存凭据)',
'settings.system.credentials.unknown': '未知(当前环境不支持)',
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
'settings.update.checkForUpdates': '检查更新',
'settings.update.checking': '检查中...',
'settings.update.upToDate': '当前已是最新版本。',
'settings.update.available': '新版本 {version} 已发布。',
'settings.update.download': '下载更新',
'settings.update.downloading': '正在下载... {percent}%',
'settings.update.readyToInstall': '更新已下载,准备安装。',
'settings.update.restartNow': '重启并更新',
'settings.update.error': '检查更新失败。',
'settings.update.downloadError': '下载失败。',
'settings.update.manualDownload': '前往 GitHub 下载',
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
'settings.update.lastCheckedJustNow': '刚刚',
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
'settings.update.lastCheckedPrefix': '上次检查:',
'settings.update.autoUpdateEnabled': '自动更新',
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
@@ -92,6 +128,8 @@ const zhCN: Messages = {
'settings.globalHotkey.reset': '恢复默认',
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
'settings.globalHotkey.enabled': '启用全局快捷键',
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
// Tray Panel
@@ -107,6 +145,7 @@ const zhCN: Messages = {
'tray.recentHosts': '最近连接的主机',
'tray.empty.title': '一切都很安静',
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
'tray.quit': '退出 Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': '收起侧边栏',
@@ -131,13 +170,23 @@ const zhCN: Messages = {
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
'update.error': '检查更新失败',
'update.downloadNow': '立即下载',
'update.viewInSettings': '在设置中查看',
'update.readyToInstall.title': '更新已就绪',
'update.readyToInstall.message': '版本 {version} 已下载完成,准备安装。',
'update.restartNow': '立即重启',
'update.downloadFailed.title': '更新失败',
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
'update.openReleases': '打开 Releases',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
// Settings > Appearance
'settings.appearance.uiTheme': '界面主题',
'settings.appearance.darkMode': '深色模式',
'settings.appearance.darkMode.desc': '浅色深色主题之间切换',
'settings.appearance.theme': '主题',
'settings.appearance.theme.desc': '选择浅色深色或跟随系统设置',
'settings.appearance.theme.light': '浅色',
'settings.appearance.theme.dark': '深色',
'settings.appearance.theme.system': '系统',
'settings.appearance.accentColor': '强调色',
'settings.appearance.customColor': '自定义颜色',
'settings.appearance.accentColor.mode': '使用自定义强调色',
@@ -190,6 +239,7 @@ const zhCN: Messages = {
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
'sync.autoSync.syncFailed': '同步失败',
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
'time.never': '从未',
'time.justNow': '刚刚',
'time.minutesAgo': '{minutes} 分钟前',
@@ -256,6 +306,8 @@ const zhCN: Messages = {
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
// Vault import
'vault.import.title': '添加数据到你的 Vault',
@@ -352,6 +404,9 @@ const zhCN: Messages = {
'sftp.newFile': '新建文件',
'sftp.filter': '筛选',
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.bookmark.add': '收藏此路径',
'sftp.bookmark.remove': '取消收藏',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.columns.name': '名称',
'sftp.columns.modified': '修改时间',
'sftp.columns.size': '大小',
@@ -384,7 +439,11 @@ const zhCN: Messages = {
'sftp.status.loading': '加载中...',
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',
'sftp.transfers': '传输',
'sftp.transfers.active': '{count} 个进行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.encoding.label': '文件名编码',
'sftp.encoding.auto': '自动',
'sftp.encoding.utf8': 'UTF-8',
@@ -490,9 +549,13 @@ const zhCN: Messages = {
'hostDetails.certs.empty': '暂无证书',
'hostDetails.agentForwarding': '转发 SSH 密钥',
'hostDetails.agentForwarding.desc': '允许远程服务器使用本地 SSH 密钥(例如用于 git 操作)',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent如 Bitwarden、1Password、gpg-agent。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.section.legacyAlgorithms': '旧版算法',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
@@ -579,8 +642,16 @@ const zhCN: Messages = {
'terminal.toolbar.broadcast': '广播',
'terminal.toolbar.broadcastEnable': '启用广播模式',
'terminal.toolbar.broadcastDisable': '关闭广播模式',
'terminal.toolbar.composeBar': '撰写栏',
'terminal.composeBar.placeholder': '在此输入命令,按回车发送...',
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.encoding': '终端编码',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': '关闭会话',
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
@@ -599,6 +670,10 @@ const zhCN: Messages = {
'terminal.serverStats.memBuffers': '缓冲区',
'terminal.serverStats.memCached': '缓存',
'terminal.serverStats.memFree': '空闲',
'terminal.serverStats.swap': '交换空间',
'terminal.serverStats.swapUsed': '已用交换',
'terminal.serverStats.swapFree': '空闲交换',
'terminal.serverStats.swapTotal': '总计',
'terminal.serverStats.topProcesses': '内存占用前十进程',
'terminal.serverStats.disk': '磁盘使用(根分区)',
'terminal.serverStats.diskDetails': '已挂载磁盘',
@@ -635,6 +710,10 @@ const zhCN: Messages = {
'terminal.auth.selectKey': '选择密钥',
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
'terminal.auth.continueSave': '继续并保存',
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
'terminal.auth.proxyCredentialsUnavailable': '代理凭据无法在当前设备解密,请到主机设置中重新填写代理密码。',
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
'terminal.connectionErrorTitle': '连接错误',
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
'terminal.progress.disconnected': '已断开',
@@ -651,10 +730,50 @@ const zhCN: Messages = {
'terminal.themeModal.title': 'Terminal 外观',
'terminal.themeModal.tab.theme': '主题',
'terminal.themeModal.tab.font': '字体',
'terminal.themeModal.tab.custom': '自定义',
'terminal.themeModal.fontSize': '字体大小',
'terminal.themeModal.livePreview': '实时预览',
'terminal.themeModal.themeType': '{type} 主题',
// Cloud Sync Settings
// Custom Themes
'terminal.customTheme.section': '自定义主题',
'terminal.customTheme.yourThemes': '我的主题',
'terminal.customTheme.new': '新建主题',
'terminal.customTheme.newDesc': '克隆当前主题并自定义',
'terminal.customTheme.newTitle': '新建自定义主题',
'terminal.customTheme.editTitle': '编辑主题',
'terminal.customTheme.import': '导入 .itermcolors',
'terminal.customTheme.importDesc': '从 iTerm2 配色方案文件导入',
'terminal.customTheme.importError': '无法解析所选文件,请确保它是有效的 .itermcolors XML 文件。',
'terminal.customTheme.delete': '删除主题',
'terminal.customTheme.confirmDelete': '确认删除',
'terminal.customTheme.name': '名称',
'terminal.customTheme.namePlaceholder': '我的自定义主题',
'terminal.customTheme.type': '类型',
'terminal.customTheme.group.general': '通用',
'terminal.customTheme.group.normal': '标准色',
'terminal.customTheme.group.bright': '高亮色',
'terminal.customTheme.color.background': '背景',
'terminal.customTheme.color.foreground': '前景',
'terminal.customTheme.color.cursor': '光标',
'terminal.customTheme.color.selection': '选区',
'terminal.customTheme.color.black': '黑色',
'terminal.customTheme.color.red': '红色',
'terminal.customTheme.color.green': '绿色',
'terminal.customTheme.color.yellow': '黄色',
'terminal.customTheme.color.blue': '蓝色',
'terminal.customTheme.color.magenta': '品红',
'terminal.customTheme.color.cyan': '青色',
'terminal.customTheme.color.white': '白色',
'terminal.customTheme.color.brightBlack': '亮黑',
'terminal.customTheme.color.brightRed': '亮红',
'terminal.customTheme.color.brightGreen': '亮绿',
'terminal.customTheme.color.brightYellow': '亮黄',
'terminal.customTheme.color.brightBlue': '亮蓝',
'terminal.customTheme.color.brightMagenta': '亮品红',
'terminal.customTheme.color.brightCyan': '亮青色',
'terminal.customTheme.color.brightWhite': '亮白',
'cloudSync.gate.title': '端到端加密同步',
'cloudSync.gate.desc':
'数据会在本地加密后再同步,云端不会看到明文。设置主密钥以启用安全同步。',
@@ -693,6 +812,7 @@ const zhCN: Messages = {
'cloudSync.webdav.password': '密码',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': '显示密钥',
'cloudSync.webdav.allowInsecure': '允许不安全的连接(忽略证书错误)',
'cloudSync.webdav.validation.endpoint': '请输入有效的 WebDAV 端点。',
'cloudSync.webdav.validation.credentials': '请输入用户名和密码。',
'cloudSync.webdav.validation.token': '请输入 Token。',
@@ -813,6 +933,7 @@ const zhCN: Messages = {
'common.import': '导入',
'common.generate': '生成',
'common.delete': '删除',
'common.edit': '编辑',
'common.clear': '清除',
'common.optional': '可选',
'common.selectPlaceholder': '请选择...',
@@ -953,6 +1074,7 @@ const zhCN: Messages = {
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上传至 {path}',
// SFTP Download
'sftp.download.completed': '已下载',
@@ -969,9 +1091,9 @@ const zhCN: Messages = {
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '显示隐藏文件',
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性文件。',
'settings.sftp.showHiddenFiles.desc': '在 SFTP 文件浏览器中显示隐藏文件Unix/macOS 点文件和 Windows 隐藏属性文件。',
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地和远程文件系统时显示隐藏文件',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': '文件夹压缩传输',
@@ -1018,9 +1140,23 @@ const zhCN: Messages = {
'settings.terminal.behavior.rightClick.paste': '粘贴',
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
'settings.terminal.behavior.copyOnSelect': '选择即复制',
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本',
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下macOS 按住 OptionWindows/Linux 按住 Shift 拖选即可选中文本',
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
'settings.terminal.behavior.osc52Clipboard.off': '关闭',
'settings.terminal.behavior.osc52Clipboard.writeOnly': '仅写入',
'settings.terminal.behavior.osc52Clipboard.readWrite': '读写',
'settings.terminal.behavior.osc52Clipboard.prompt': '写入 + 读取时询问',
'terminal.osc52.readPrompt.title': '剪贴板读取请求',
'terminal.osc52.readPrompt.desc': '远程程序正在请求读取您的剪贴板,是否允许?',
'terminal.osc52.readPrompt.allow': '允许',
'terminal.osc52.readPrompt.deny': '拒绝',
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
@@ -1083,6 +1219,35 @@ const zhCN: Messages = {
'settings.shortcuts.category.navigation': '导航',
'settings.shortcuts.category.app': '应用',
'settings.shortcuts.category.sftp': 'SFTP',
'settings.shortcuts.binding.switch-tab-1-9': '切换到标签页 [1...9]',
'settings.shortcuts.binding.next-tab': '下一个标签页',
'settings.shortcuts.binding.prev-tab': '上一个标签页',
'settings.shortcuts.binding.close-tab': '关闭标签页',
'settings.shortcuts.binding.new-tab': '新建本地标签页',
'settings.shortcuts.binding.copy': '从终端复制',
'settings.shortcuts.binding.paste': '粘贴到终端',
'settings.shortcuts.binding.select-all': '全选终端内容',
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
'settings.shortcuts.binding.split-horizontal': '水平分屏',
'settings.shortcuts.binding.split-vertical': '垂直分屏',
'settings.shortcuts.binding.open-hosts': '打开主机列表',
'settings.shortcuts.binding.open-local': '打开本地终端',
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
'settings.shortcuts.binding.command-palette': '打开命令面板',
'settings.shortcuts.binding.quick-switch': '快速切换',
'settings.shortcuts.binding.snippets': '打开代码片段',
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
'settings.shortcuts.binding.sftp-select-all': '全选文件',
'settings.shortcuts.binding.sftp-rename': '重命名文件',
'settings.shortcuts.binding.sftp-delete': '删除文件',
'settings.shortcuts.binding.sftp-refresh': '刷新',
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': 'Proxy',
@@ -1268,6 +1433,7 @@ const zhCN: Messages = {
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
// Snippet Shortkey
'snippets.field.shortkey': '快捷键',
'snippets.shortkey.placeholder': '点击设置快捷键',
@@ -1344,6 +1510,164 @@ const zhCN: Messages = {
// Text Editor
'sftp.editor.wordWrap': '自动换行',
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.title': 'AI',
'ai.description': '配置 AI 提供商、Agent 和安全设置',
'ai.providers': '提供商',
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
'ai.providers.add': '添加提供商',
'ai.providers.active': '活跃',
'ai.providers.apiKeyConfigured': 'API Key 已配置',
'ai.providers.noApiKey': '未设置 API Key',
'ai.providers.configure': '配置',
'ai.providers.remove': '移除',
'ai.providers.name': '显示名称',
'ai.providers.name.placeholder': '例如 我的提供商',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
'ai.providers.refreshModels': '刷新模型列表',
'ai.providers.searchModel': '搜索或输入模型 ID...',
'ai.providers.filterModels': '筛选模型...',
'ai.providers.loadingModels': '加载模型中...',
'ai.providers.noMatchingModels': '没有匹配的模型',
'ai.providers.clickToLoadModels': '点击加载模型',
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': '使用 codex + codex-acp 进行 ACP 协议流式传输。在此通过 ChatGPT 订阅登录,或配置 OpenAI 提供商的 API Key将作为 CODEX_API_KEY 传递)。',
'ai.codex.detecting': '检测中...',
'ai.codex.notFound': '未找到',
'ai.codex.awaitingLogin': '等待登录',
'ai.codex.connectedChatGPT': '已通过 ChatGPT 连接',
'ai.codex.connectedApiKey': '已通过 API Key 连接',
'ai.codex.notConnected': '未连接',
'ai.codex.statusUnknown': '状态未知',
'ai.codex.path': '路径:',
'ai.codex.notFoundHint': '在 PATH 中未找到 codex。请安装或在下方指定可执行文件路径。',
'ai.codex.customPathPlaceholder': '例如 /usr/local/bin/codex',
'ai.codex.check': '检查',
'ai.codex.openLogin': '打开登录',
'ai.codex.logout': '退出登录',
'ai.codex.connectChatGPT': '连接 ChatGPT',
'ai.codex.refreshStatus': '刷新状态',
'ai.codex.apiKeyHint': '检测到已启用的 OpenAI 提供商 API Key。Codex ACP 也可以无需 ChatGPT 登录进行认证。',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
'ai.claude.detecting': '检测中...',
'ai.claude.detected': '已检测到',
'ai.claude.notFound': '未找到',
'ai.claude.path': '路径:',
'ai.claude.notFoundHint': '在 PATH 中未找到 claude。请安装或在下方指定可执行文件路径。',
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.check': '检查',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
'ai.defaultAgent.catty': 'Catty内置',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
'ai.chat.toolApprovalTitle': '需要权限确认',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
'ai.chat.approve': '批准',
'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目标',
'ai.chat.permissionRequired': '需要权限',
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
'ai.chat.recommendAllow': '允许',
'ai.chat.recommendConfirm': '确认',
'ai.chat.recommendDeny': '拒绝',
'ai.chat.exportConversation': '导出对话',
'ai.chat.exportAs': '导出为',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': '纯文本',
'ai.chat.thinking': '思考中',
'ai.chat.thoughtFor': '思考了 {duration}',
'ai.chat.thought': '思考',
'ai.chat.agents': 'Agents',
'ai.chat.detectedOnMachine': '在本机检测到',
'ai.chat.rescan': '重新扫描',
'ai.chat.permObserver': '观察',
'ai.chat.permConfirm': '确认',
'ai.chat.permAuto': '自主',
'ai.chat.permObserverDesc': '只读模式',
'ai.chat.permConfirmDesc': '操作前询问',
'ai.chat.permAutoDesc': '自由执行',
'ai.chat.emptyHint': '询问服务器相关问题、执行命令或获取配置帮助。',
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
'ai.chat.noModel': '未选择模型',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题',
'ai.chat.justNow': '刚刚',
'ai.chat.minutesAgo': '{n}分钟前',
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时5 分钟)。你可以重新发送消息来重试。',
'ai.chat.menuHosts': '主机',
'ai.chat.menuContext': '上下文',
'ai.chat.menuFiles': '文件',
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
// AI Web Search
'ai.webSearch.title': '网络搜索',
'ai.webSearch.enable': '启用网络搜索',
'ai.webSearch.enable.description': '允许 AI 代理搜索互联网获取最新信息。',
'ai.webSearch.provider': '搜索供应商',
'ai.webSearch.provider.description': '选择一个网络搜索 API 供应商。',
'ai.webSearch.apiKey': 'API 密钥',
'ai.webSearch.apiKey.description': '所选搜索供应商的 API 密钥。',
'ai.webSearch.apiKey.placeholder': '输入 API 密钥...',
'ai.webSearch.apiHost': 'API 地址',
'ai.webSearch.apiHost.description': '自定义 API 端点。除非使用代理,否则保持默认值。',
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 实例的 URL必填。',
'ai.webSearch.maxResults': '最大结果数',
'ai.webSearch.maxResults.description': '搜索返回的最大结果数1-20。',
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '权限模式',
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性ACP Agent 有自己的工具审批流程)。',
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
'ai.safety.commandTimeout': '命令超时',
'ai.safety.commandTimeout.description': '命令执行的最大秒数,超时将被终止。对内置和 ACP Agent 均生效。',
'ai.safety.commandTimeout.unit': '秒',
'ai.safety.maxIterations': '最大迭代次数',
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
'ai.safety.blocklist': '命令黑名单',
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
'ai.safety.blocklist.placeholder': '正则表达式...',
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行ACP Agent 可能有自己的内部控制。',
};
export default zhCN;

View File

@@ -0,0 +1,176 @@
import { useSyncExternalStore, useCallback } from 'react';
import { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { STORAGE_KEY_CUSTOM_THEMES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
// Access the Electron bridge for cross-window IPC
type NetcattyBridge = {
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
};
const getBridge = (): NetcattyBridge | undefined =>
(window as unknown as { netcatty?: NetcattyBridge }).netcatty;
/**
* Custom Theme Store - manages user-created terminal themes
* Uses useSyncExternalStore pattern (same as fontStore)
* Persists to localStorage + cross-window IPC sync
*/
type Listener = () => void;
class CustomThemeStore {
private themes: TerminalTheme[] = [];
private listeners = new Set<Listener>();
/** Cached merged array for stable useSyncExternalStore snapshots */
private cachedAllThemes: TerminalTheme[] | null = null;
constructor() {
this.loadFromStorage();
this.setupCrossWindowSync();
}
/** Reload themes from localStorage. Called internally and after sync apply. */
loadFromStorage = () => {
try {
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
if (Array.isArray(parsed)) {
this.themes = parsed.map((t: TerminalTheme) => ({ ...t, isCustom: true }));
}
} catch {
// ignore corrupt data
}
this.notify();
};
private saveToStorage = () => {
try {
localStorageAdapter.write(STORAGE_KEY_CUSTOM_THEMES, this.themes);
} catch {
// storage full or unavailable
}
};
private notify = () => {
this.cachedAllThemes = null; // invalidate cache on any mutation
this.listeners.forEach(listener => listener());
};
/** Broadcast change to other Electron windows via IPC */
private broadcastChange = () => {
try {
getBridge()?.notifySettingsChanged?.({
key: STORAGE_KEY_CUSTOM_THEMES,
value: this.themes,
});
} catch {
// not in Electron or bridge unavailable
}
};
/** Listen for changes from other windows and reload */
private setupCrossWindowSync = () => {
try {
getBridge()?.onSettingsChanged?.((payload) => {
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
// Another window changed custom themes — reload from localStorage
this.loadFromStorage();
this.notify();
}
});
} catch {
// not in Electron or bridge unavailable
}
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
// ---- Getters (stable references for useSyncExternalStore) ----
getCustomThemes = (): TerminalTheme[] => this.themes;
/** Returns all themes: built-in + custom (cached for snapshot stability) */
getAllThemes = (): TerminalTheme[] => {
if (!this.cachedAllThemes) {
this.cachedAllThemes = [...TERMINAL_THEMES, ...this.themes];
}
return this.cachedAllThemes;
};
/** Find a theme by ID across both built-in and custom */
getThemeById = (id: string): TerminalTheme | undefined => {
return TERMINAL_THEMES.find(t => t.id === id) || this.themes.find(t => t.id === id);
};
// ---- Mutations ----
addTheme = (theme: TerminalTheme) => {
this.themes = [...this.themes, { ...theme, isCustom: true }];
this.saveToStorage();
this.notify();
this.broadcastChange();
};
updateTheme = (id: string, updates: Partial<TerminalTheme>) => {
this.themes = this.themes.map(t =>
t.id === id ? { ...t, ...updates, isCustom: true } : t
);
this.saveToStorage();
this.notify();
this.broadcastChange();
};
deleteTheme = (id: string) => {
this.themes = this.themes.filter(t => t.id !== id);
this.saveToStorage();
this.notify();
this.broadcastChange();
};
}
// Singleton
export const customThemeStore = new CustomThemeStore();
// ============== Hooks ==============
/** Get all themes (built-in + custom) */
export const useAllThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getAllThemes
);
};
/** Get custom themes only */
export const useCustomThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getCustomThemes
);
};
/** Get theme by ID (built-in or custom) with fallback */
export const useThemeById = (id: string): TerminalTheme => {
const allThemes = useAllThemes();
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
};
/** Theme mutation actions */
export const useCustomThemeActions = () => {
const addTheme = useCallback((theme: TerminalTheme) => {
customThemeStore.addTheme(theme);
}, []);
const updateTheme = useCallback((id: string, updates: Partial<TerminalTheme>) => {
customThemeStore.updateTheme(id, updates);
}, []);
const deleteTheme = useCallback((id: string) => {
customThemeStore.deleteTheme(id);
}, []);
return { addTheme, updateTheme, deleteTheme };
};

View File

@@ -4,31 +4,16 @@ export const isSessionError = (err: unknown): boolean => {
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset")
);
};
/**
* Check if an error message indicates a fatal error that should stop the entire upload.
* This includes session errors AND target directory deletion errors.
*/
export const isFatalUploadError = (errorMessage: string): boolean => {
const msg = errorMessage.toLowerCase();
return (
// Session-related errors
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("connection") ||
msg.includes("disconnected") ||
// Target directory was deleted during upload
msg.includes("no such file") ||
msg.includes("enoent") ||
msg.includes("does not exist") ||
msg.includes("write stream error") ||
// Directory was removed
msg.includes("directory not found") ||
msg.includes("not a directory")
msg.includes("session lost") ||
msg.includes("channel not ready") ||
msg.includes("readdir is not a function") ||
msg.includes("channel closed") ||
msg.includes("connection closed") ||
msg.includes("connection reset") ||
msg.includes("write after end") ||
msg.includes("no response") ||
msg.includes("not connected") ||
msg.includes("client disconnected") ||
msg.includes("timed out")
);
};

View File

@@ -0,0 +1,52 @@
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
interface SharedRemoteHostCacheEntry {
path: string;
homeDir: string;
files: SftpFileEntry[];
filenameEncoding: SftpFilenameEncoding;
updatedAt: number;
}
const SHARED_REMOTE_HOST_CACHE_TTL_MS = 60_000;
const sharedRemoteHostCache = new Map<string, SharedRemoteHostCacheEntry>();
/**
* Build a cache key that includes connection details so that the same host ID
* with different session-time overrides (port, protocol) uses separate entries.
*/
export const buildCacheKey = (
hostId: string,
hostname?: string,
port?: number,
protocol?: string,
sftpSudo?: boolean,
username?: string,
): string => {
return `${hostId}:${hostname ?? ''}:${port ?? ''}:${protocol ?? ''}:${sftpSudo ? 'sudo' : ''}:${username ?? ''}`;
};
export const getSharedRemoteHostCache = (
cacheKey: string,
): SharedRemoteHostCacheEntry | null => {
const entry = sharedRemoteHostCache.get(cacheKey);
if (!entry) return null;
if (Date.now() - entry.updatedAt > SHARED_REMOTE_HOST_CACHE_TTL_MS) {
sharedRemoteHostCache.delete(cacheKey);
return null;
}
return entry;
};
export const setSharedRemoteHostCache = (
cacheKey: string,
entry: Omit<SharedRemoteHostCacheEntry, "updatedAt">,
): void => {
sharedRemoteHostCache.set(cacheKey, {
...entry,
updatedAt: Date.now(),
});
};

View File

@@ -10,6 +10,7 @@ export interface SftpPane {
selectedFiles: Set<string>;
filter: string;
filenameEncoding: SftpFilenameEncoding;
showHiddenFiles: boolean;
}
// Multi-tab state for left and right sides
@@ -22,7 +23,10 @@ export interface SftpSideTabs {
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
export const createEmptyPane = (id?: string): SftpPane => ({
export const createEmptyPane = (
id?: string,
showHiddenFiles = false,
): SftpPane => ({
id: id || crypto.randomUUID(),
connection: null,
files: [],
@@ -32,6 +36,7 @@ export const createEmptyPane = (id?: string): SftpPane => ({
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles,
});
// File watch event types
@@ -52,4 +57,7 @@ export interface FileWatchErrorEvent {
export interface SftpStateOptions {
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
onFileWatchError?: (event: FileWatchErrorEvent) => void;
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
autoConnectLocalOnMount?: boolean;
}

View File

@@ -5,6 +5,7 @@ import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncodin
import type { SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
interface UseSftpConnectionsParams {
hosts: Host[];
@@ -24,14 +25,16 @@ interface UseSftpConnectionsParams {
dirCacheRef: MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: MutableRefObject<Map<string, string>>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string) => SftpPane;
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
autoConnectLocalOnMount?: boolean;
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local") => Promise<void>;
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
@@ -55,22 +58,24 @@ export const useSftpConnections = ({
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
async (side: "left" | "right", host: Host | "local") => {
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) {
if (!sideTabs.activeTabId || options?.forceNewTab) {
const newPane = createEmptyPane();
activeTabId = newPane.id;
setTabs((prev) => ({
@@ -89,6 +94,14 @@ export const useSftpConnections = ({
const connectRequestId = navSeqRef.current[side];
lastConnectedHostRef.current[side] = host;
// Store the cache key for this connection so pane actions can look it up
// by connectionId instead of relying on the per-side lastConnectedHostRef.
if (host !== "local") {
connectionCacheKeyMapRef.current.set(
connectionId,
buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username),
);
}
const currentPane = getActivePane(side);
// Reset encoding to host's configured encoding or "auto" when connecting to a new host
@@ -96,18 +109,22 @@ export const useSftpConnections = ({
const filenameEncoding: SftpFilenameEncoding =
host === "local" ? "auto" : (host.sftpEncoding ?? "auto");
if (currentPane?.connection) {
clearCacheForConnection(currentPane.connection.id);
}
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
// When forceNewTab is set, we're preserving the old tab for instant switching —
// don't close its SFTP session or clear its cache.
if (!options?.forceNewTab) {
if (currentPane?.connection) {
clearCacheForConnection(currentPane.connection.id);
}
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
}
@@ -162,22 +179,33 @@ export const useSftpConnections = ({
}));
}
} else {
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
const sharedHostCache =
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
? sharedHostCacheCandidate
: null;
const cachedStartPath = sharedHostCache?.path ?? "/";
const connection: SftpConnection = {
id: connectionId,
hostId: host.id,
hostLabel: host.label,
isLocal: false,
status: "connecting",
currentPath: "/",
currentPath: cachedStartPath,
};
updateTab(side, activeTabId, (prev) => ({
...prev,
connection,
// Always show loading while connecting — even with cached files.
// The cached file list is shown as a preview, but the pane stays
// non-interactive until the SFTP session is actually established.
loading: true,
reconnecting: prev.reconnecting,
error: null,
files: prev.reconnecting ? prev.files : [],
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
filenameEncoding, // Reset encoding for new connection
}));
@@ -238,72 +266,137 @@ export const useSftpConnections = ({
sftpSessionsRef.current.set(connectionId, sftpId);
let startPath = "/";
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
break;
let startPath = sharedHostCache?.path ?? "/";
let homeDir = sharedHostCache?.homeDir ?? startPath;
if (!sharedHostCache) {
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
} catch {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) startPath = `/home/${credentials.username}`;
} catch {
// Fall through to /root check
}
if (startPath === "/") {
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) {
startPath = `/home/${credentials.username}`;
homeDir = startPath;
}
} catch {
// Fall through to /root check
}
if (startPath === "/") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
}
}
const files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
const provisionalCacheKey = sharedHostCache
? makeCacheKey(connectionId, startPath, filenameEncoding)
: null;
if (sharedHostCache && provisionalCacheKey) {
dirCacheRef.current.set(provisionalCacheKey, {
files: sharedHostCache.files,
timestamp: Date.now(),
});
}
let files: SftpFileEntry[] = [];
try {
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
} catch {
// Cached path may be stale (deleted, permissions changed).
// Remove the provisional cache entry so phantom files don't resurface.
if (provisionalCacheKey) {
dirCacheRef.current.delete(provisionalCacheKey);
}
// Fall back to homeDir, then "/", chaining attempts.
let fallbackSucceeded = false;
if (sharedHostCache && startPath !== homeDir) {
try {
startPath = homeDir;
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
fallbackSucceeded = true;
} catch {
// homeDir also failed, try root
}
}
if (!fallbackSucceeded && startPath !== "/") {
try {
startPath = "/";
files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
fallbackSucceeded = true;
} catch {
// root also failed
}
}
if (!fallbackSucceeded) {
throw new Error("Cannot list any remote directory");
}
}
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
files,
timestamp: Date.now(),
});
setSharedRemoteHostCache(hostCacheKey, {
path: startPath,
homeDir,
files,
filenameEncoding,
});
reconnectingRef.current[side] = false;
@@ -314,7 +407,7 @@ export const useSftpConnections = ({
...prev.connection,
status: "connected",
currentPath: startPath,
homeDir: startPath,
homeDir,
}
: null,
files,
@@ -346,6 +439,7 @@ export const useSftpConnections = ({
getActivePane,
updateTab,
clearCacheForConnection,
createEmptyPane,
makeCacheKey,
listLocalFiles,
listRemoteFiles,
@@ -355,13 +449,17 @@ export const useSftpConnections = ({
const initialConnectDoneRef = useRef(false);
useEffect(() => {
if (!initialConnectDoneRef.current && leftTabs.tabs.length === 0) {
if (
autoConnectLocalOnMount &&
!initialConnectDoneRef.current &&
leftTabs.tabs.length === 0
) {
initialConnectDoneRef.current = true;
setTimeout(() => {
connect("left", "local");
}, 0);
}
}, [connect, leftTabs.tabs.length]);
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
useEffect(() => {
const attemptReconnect = async (side: "left" | "right") => {
@@ -412,7 +510,7 @@ export const useSftpConnections = ({
}
}
updateTab(side, activeTabId, () => createEmptyPane(activeTabId));
updateTab(side, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, clearCacheForConnection, updateTab],

View File

@@ -7,11 +7,13 @@ import { joinPath } from "./utils";
import {
UploadController,
uploadFromDataTransfer,
uploadEntriesDirect,
UploadBridge,
UploadCallbacks,
UploadResult,
UploadTaskInfo,
} from "../../../lib/uploadService";
import type { DropEntry } from "../../../lib/sftpFileUtils";
// Re-export UploadResult for external usage
export type { UploadResult };
@@ -20,8 +22,12 @@ interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
clearDirCacheEntry?: (connectionId: string, path: string) => void;
useCompressedUpload?: boolean;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
isTransferCancelled?: (taskId: string) => boolean;
dismissExternalUpload?: (taskId: string) => void;
}
@@ -36,10 +42,16 @@ interface SftpExternalOperationsResult {
appPath: string,
options?: { enableWatch?: boolean }
) => Promise<{ localTempPath: string; watchId?: string }>;
activeFileWatchCountRef: React.MutableRefObject<number>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string }
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
@@ -47,11 +59,26 @@ interface SftpExternalOperationsResult {
export const useSftpExternalOperations = (
params: UseSftpExternalOperationsParams
): SftpExternalOperationsResult => {
const { getActivePane, refresh, sftpSessionsRef, addExternalUpload, updateExternalUpload, dismissExternalUpload } = params;
const {
getActivePane,
refresh,
sftpSessionsRef,
connectionCacheKeyMapRef,
clearDirCacheEntry,
useCompressedUpload = false,
addExternalUpload,
updateExternalUpload,
isTransferCancelled,
dismissExternalUpload,
} = params;
// Upload controller for cancellation support
const uploadControllerRef = useRef<UploadController | null>(null);
// Track active file watches so the side panel can block host-switching.
// Reset to 0 when the SFTP session disconnects (handled in SftpSidePanel).
const activeFileWatchCountRef = useRef(0);
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
const pane = getActivePane(side);
@@ -173,14 +200,113 @@ export const useSftpExternalOperations = (
throw new Error("SFTP session not found");
}
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
const localTempPath = await bridge.downloadSftpToTemp(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
);
console.log("[SFTP] File downloaded to temp", { localTempPath });
let localTempPath: string;
let wasCancelled = false;
let externalTransferId: string | undefined;
const isLocalTempDownloadCancelled = () =>
!!externalTransferId && !!isTransferCancelled?.(externalTransferId);
const cleanupTempDownload = async (filePath: string) => {
if (!bridge.deleteTempFile) return;
try {
await bridge.deleteTempFile(filePath);
} catch (err) {
console.warn("[SFTP] Failed to delete cancelled temp download:", err);
}
};
if (bridge.downloadSftpToTempWithProgress && addExternalUpload && updateExternalUpload) {
externalTransferId = `download-temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
addExternalUpload({
id: externalTransferId,
fileName,
sourcePath: remotePath,
targetPath: "(temp)",
sourceConnectionId: pane.connection.id,
targetConnectionId: "local",
direction: "download",
status: "transferring" as TransferStatus,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: false,
retryable: false,
});
try {
const result = await bridge.downloadSftpToTempWithProgress(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
externalTransferId,
(transferred, total, speed) => {
updateExternalUpload(externalTransferId, {
transferredBytes: transferred,
totalBytes: total,
speed,
});
},
undefined,
(error) => {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error,
speed: 0,
});
},
() => {
updateExternalUpload(externalTransferId, {
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
},
);
wasCancelled = result.cancelled;
localTempPath = result.localPath;
} catch (err) {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
speed: 0,
});
throw err;
}
if (wasCancelled) {
if (localTempPath && bridge.deleteTempFile) {
bridge.deleteTempFile(localTempPath).catch(() => {});
}
return { localTempPath: "" };
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
}
updateExternalUpload(externalTransferId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
} else {
localTempPath = await bridge.downloadSftpToTemp(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
);
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
}
if (bridge.registerTempFile) {
try {
@@ -190,15 +316,23 @@ export const useSftpExternalOperations = (
}
}
console.log("[SFTP] Opening with application", { localTempPath, appPath });
await bridge.openWithApplication(localTempPath, appPath);
console.log("[SFTP] Application launched");
try {
await bridge.openWithApplication(localTempPath, appPath);
} catch (err) {
if (externalTransferId) {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
speed: 0,
});
}
throw err;
}
let watchId: string | undefined;
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(
localTempPath,
remotePath,
@@ -206,23 +340,23 @@ export const useSftpExternalOperations = (
pane.filenameEncoding,
);
watchId = result.watchId;
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
activeFileWatchCountRef.current += 1;
} catch (err) {
console.warn("[SFTP] Failed to start file watch:", err);
}
} else {
console.log("[SFTP] File watching not enabled or not available");
}
return { localTempPath, watchId };
},
[getActivePane, sftpSessionsRef],
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
);
// Create upload callbacks that translate to TransferTask updates
const createUploadCallbacks = useCallback((
connectionId: string,
targetPath: string
targetPath: string,
targetHostId?: string,
targetConnectionKey?: string,
): UploadCallbacks => {
return {
onScanningStart: (taskId: string) => {
@@ -234,6 +368,8 @@ export const useSftpExternalOperations = (
targetPath,
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "pending" as TransferStatus,
totalBytes: 0,
@@ -259,6 +395,8 @@ export const useSftpExternalOperations = (
targetPath: joinPath(targetPath, task.fileName),
sourceConnectionId: "external",
targetConnectionId: connectionId,
targetHostId,
targetConnectionKey,
direction: "upload",
status: "transferring" as TransferStatus,
totalBytes: task.totalBytes,
@@ -390,7 +528,12 @@ export const useSftpExternalOperations = (
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(pane.connection.id, pane.connection.currentPath);
const callbacks = createUploadCallbacks(
pane.connection.id,
pane.connection.currentPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
try {
const results = await uploadFromDataTransfer(
@@ -402,6 +545,7 @@ export const useSftpExternalOperations = (
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
},
controller
);
@@ -415,7 +559,99 @@ export const useSftpExternalOperations = (
uploadControllerRef.current = null;
}
},
[getActivePane, refresh, sftpSessionsRef, createUploadCallbacks, createUploadBridge],
[
connectionCacheKeyMapRef,
getActivePane,
refresh,
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
useCompressedUpload,
],
);
const uploadExternalEntries = useCallback(
async (
side: "left" | "right",
entries: DropEntry[],
options?: { targetPath?: string },
): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
const controller = new UploadController();
uploadControllerRef.current = controller;
const uploadTargetPath = options?.targetPath || pane.connection.currentPath;
const callbacks = createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
const directUploadBridge: UploadBridge = {
...createUploadBridge,
};
try {
const results = await uploadEntriesDirect(
entries,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: directUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
},
controller,
);
// Refresh the current directory and invalidate the upload target's
// cache entry. If the user navigated away during the upload, the
// invalidation ensures returning to the target path triggers a fresh
// listing instead of serving stale cached data.
const livePane = getActivePane(side);
if (livePane?.connection) {
if (livePane.connection.currentPath !== uploadTargetPath && clearDirCacheEntry) {
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
}
await refresh(side);
}
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload,
],
);
const cancelExternalUpload = useCallback(async () => {
@@ -443,7 +679,9 @@ export const useSftpExternalOperations = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
};
};

View File

@@ -20,12 +20,12 @@ export const useSftpHostCredentials = ({
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
@@ -63,6 +63,7 @@ export const useSftpHostCredentials = ({
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
passphrase: resolved.passphrase || key?.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,

View File

@@ -1,11 +1,13 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
@@ -15,6 +17,7 @@ interface UseSftpPaneActionsParams {
dirCacheRef: React.MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
@@ -50,6 +53,7 @@ interface UseSftpPaneActionsResult {
}
export const useSftpPaneActions = ({
hosts,
getActivePane,
updateTab,
updateActiveTab,
@@ -59,6 +63,7 @@ export const useSftpPaneActions = ({
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
@@ -68,6 +73,43 @@ export const useSftpPaneActions = ({
isSessionError,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
// Build the shared cache key for the active pane. Prefer the last connected
// host (which includes session-time overrides), fall back to the vault hosts list.
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const getActivePaneCacheKey = useCallback((side: "left" | "right", hostId: string, connectionId?: string): string => {
// Prefer the per-connection cache key — it's set at connect time and
// correctly identifies the endpoint even when multiple tabs share the
// same hostId with different session-time overrides.
if (connectionId) {
const perConnKey = connectionCacheKeyMapRef.current.get(connectionId);
if (perConnKey) return perConnKey;
}
// Fallback: lastConnectedHostRef (per-side, may be stale for multi-tab)
const connHost = lastConnectedHostRef.current[side];
if (connHost && connHost !== "local" && connHost.id === hostId) {
return buildCacheKey(connHost.id, connHost.hostname, connHost.port, connHost.protocol, connHost.sftpSudo, connHost.username);
}
// Fall back to vault host
const host = hostsRef.current.find(h => h.id === hostId);
if (host) {
return buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
}
return hostId;
}, [connectionCacheKeyMapRef, lastConnectedHostRef]);
// Track the latest navigation request ID per tab, so we can distinguish
// whether a superseded request was superseded by the same tab or a different tab.
const tabNavSeqRef = useRef(new Map<string, number>());
// Track the last confirmed (successfully loaded) state per tab, so that
// restore-on-error/supersede always reverts to a known-good state rather
// than an intermediate optimistic state from another in-flight navigation.
// Includes connectionId so stale entries from a previous host are ignored.
const lastConfirmedRef = useRef(
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string> }>(),
);
const navigateTo = useCallback(
async (
side: "left" | "right",
@@ -92,8 +134,9 @@ export const useSftpPaneActions = ({
return;
}
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
const cached = options?.force
? undefined
: dirCacheRef.current.get(cacheKey);
@@ -104,6 +147,13 @@ export const useSftpPaneActions = ({
cached.files
) {
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
tabNavSeqRef.current.set(activeTabId, requestId);
lastConfirmedRef.current.set(activeTabId, {
connectionId,
path,
files: cached.files,
selectedFiles: new Set(),
});
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
@@ -114,11 +164,53 @@ export const useSftpPaneActions = ({
error: null,
selectedFiles: new Set(),
}));
if (!pane.connection.isLocal) {
// Use hostId as the shared cache key — this is safe because the
// shared cache is a best-effort optimization and hostId uniquely
// identifies the connection in the common case. Session-time
// overrides create separate connections with distinct cache keys
// at the connect() layer.
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
path,
homeDir: pane.connection.homeDir ?? path,
files: cached.files,
filenameEncoding: pane.filenameEncoding,
});
}
return;
}
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
// Re-seed confirmed state whenever the pane is settled (not loading), or
// when the connection has changed. This captures post-mutation state from
// optimistic updates (e.g. deleteFilesAtPath) so that a failed refresh
// doesn't resurrect deleted items.
const existing = lastConfirmedRef.current.get(activeTabId);
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
lastConfirmedRef.current.set(activeTabId, {
connectionId,
path: pane.connection.currentPath,
files: pane.files,
selectedFiles: pane.selectedFiles,
});
}
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
const previousPath = confirmed.path;
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
tabNavSeqRef.current.set(activeTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
// that gets superseded by another tab navigating on the same side.
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
selectedFiles: new Set(),
loading: true,
error: null,
}));
try {
let files: SftpFileEntry[];
@@ -164,13 +256,42 @@ export const useSftpPaneActions = ({
}
}
if (navSeqRef.current[side] !== requestId) return;
if (navSeqRef.current[side] !== requestId) {
// Another navigation on this side superseded this request.
// Only restore if no newer navigation has occurred on this specific tab
// AND the tab still belongs to the same connection (connect/disconnect
// bump navSeqRef but not tabNavSeqRef).
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
// Tab was reconnected or disconnected; don't restore stale state.
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
loading: false,
};
});
return;
}
dirCacheRef.current.set(cacheKey, {
files,
timestamp: Date.now(),
});
lastConfirmedRef.current.set(activeTabId, {
connectionId,
path,
files,
selectedFiles: new Set(),
});
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
@@ -180,18 +301,52 @@ export const useSftpPaneActions = ({
loading: false,
selectedFiles: new Set(),
}));
if (!pane.connection.isLocal) {
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
path,
homeDir: pane.connection.homeDir ?? path,
files,
filenameEncoding: pane.filenameEncoding,
});
}
} catch (err) {
if (navSeqRef.current[side] !== requestId) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
}));
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
loading: false,
};
});
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
};
});
}
},
[
getActivePane,
getActivePaneCacheKey,
updateTab,
leftTabsRef,
rightTabsRef,

View File

@@ -14,6 +14,7 @@ export interface SftpTabsState {
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
addTab: (side: "left" | "right") => string;
closeTab: (side: "left" | "right", tabId: string) => void;
selectTab: (side: "left" | "right", tabId: string) => void;
@@ -33,7 +34,11 @@ export interface SftpTabsState {
getActiveTabId: (side: "left" | "right") => string | null;
}
export const useSftpTabsState = (): SftpTabsState => {
export const useSftpTabsState = ({
defaultShowHiddenFiles = false,
}: {
defaultShowHiddenFiles?: boolean;
} = {}): SftpTabsState => {
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
tabs: [],
activeTabId: null,
@@ -45,8 +50,10 @@ export const useSftpTabsState = (): SftpTabsState => {
const leftTabsRef = useRef(leftTabs);
const rightTabsRef = useRef(rightTabs);
const defaultShowHiddenFilesRef = useRef(defaultShowHiddenFiles);
leftTabsRef.current = leftTabs;
rightTabsRef.current = rightTabs;
defaultShowHiddenFilesRef.current = defaultShowHiddenFiles;
const getActivePane = useCallback((side: "left" | "right"): SftpPane | null => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
@@ -58,14 +65,14 @@ export const useSftpTabsState = (): SftpTabsState => {
const pane = leftTabs.activeTabId
? leftTabs.tabs.find((t) => t.id === leftTabs.activeTabId)
: null;
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID);
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID, defaultShowHiddenFilesRef.current);
}, [leftTabs]);
const rightPane = useMemo(() => {
const pane = rightTabs.activeTabId
? rightTabs.tabs.find((t) => t.id === rightTabs.activeTabId)
: null;
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID);
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID, defaultShowHiddenFilesRef.current);
}, [rightTabs]);
const updateTab = useCallback(
@@ -88,9 +95,24 @@ export const useSftpTabsState = (): SftpTabsState => {
[updateTab],
);
const setTabShowHiddenFiles = useCallback(
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
updateTab(side, tabId, (prev) => {
if (prev.showHiddenFiles === showHiddenFiles) {
return prev;
}
return {
...prev,
showHiddenFiles,
};
});
},
[updateTab],
);
const addTab = useCallback(
(side: "left" | "right") => {
const newPane = createEmptyPane();
const newPane = createEmptyPane(undefined, defaultShowHiddenFilesRef.current);
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
tabs: [...prev.tabs, newPane],
@@ -236,6 +258,7 @@ export const useSftpTabsState = (): SftpTabsState => {
getActivePane,
updateTab,
updateActiveTab,
setTabShowHiddenFiles,
addTab,
closeTab,
selectTab,

View File

@@ -39,6 +39,7 @@ interface UseSftpTransfersResult {
addExternalUpload: (task: TransferTask) => void;
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
cancelTransfer: (transferId: string) => Promise<void>;
isTransferCancelled: (transferId: string) => boolean;
retryTransfer: (transferId: string) => Promise<void>;
clearCompletedTransfers: () => void;
dismissTransfer: (transferId: string) => void;
@@ -123,6 +124,73 @@ export const useSftpTransfers = ({
}
}, []);
const clearCancelledTask = useCallback((taskId: string) => {
cancelledTasksRef.current.delete(taskId);
}, []);
const isTransferCancelledError = useCallback(
(error: unknown): boolean =>
error instanceof Error && error.message === "Transfer cancelled",
[],
);
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
if (typeof entry.size === "string") {
const parsed = parseInt(entry.size, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
return typeof entry.size === "number" && entry.size > 0 ? entry.size : 0;
}, []);
const estimateDirectoryBytes = useCallback(
async (
sourcePath: string,
sourceSftpId: string | null,
sourceIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
rootTaskId: string,
): Promise<number> => {
if (cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const files = sourceIsLocal
? await listLocalFiles(sourcePath)
: sourceSftpId
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
: null;
if (!files) {
throw new Error("No source connection");
}
let totalBytes = 0;
for (const file of files) {
if (file.name === "..") continue;
if (cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
if (file.type === "directory") {
totalBytes += await estimateDirectoryBytes(
joinPath(sourcePath, file.name),
sourceSftpId,
sourceIsLocal,
sourceEncoding,
rootTaskId,
);
} else {
totalBytes += getEntrySize(file);
}
}
return totalBytes;
},
[getEntrySize, listLocalFiles, listRemoteFiles],
);
const transferFile = async (
task: TransferTask,
sourceSftpId: string | null,
@@ -132,6 +200,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
): Promise<void> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -158,15 +227,23 @@ export const useSftpTransfers = ({
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
if (t.status === "cancelled") return t;
const normalizedTotal = total > 0 ? total : t.totalBytes;
const normalizedTransferred = Math.max(
t.transferredBytes,
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
);
return {
...t,
transferredBytes: transferred,
totalBytes: total || t.totalBytes,
speed,
transferredBytes: normalizedTransferred,
totalBytes: normalizedTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
};
}),
);
@@ -249,6 +326,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
onChildProgress?: (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => void,
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -270,6 +348,9 @@ export const useSftpTransfers = ({
throw new Error("No source connection");
}
// Track bytes completed so far in this directory (including subdirectories)
let completedBytesInDir = 0;
for (const file of files) {
if (file.name === "..") continue;
@@ -290,6 +371,13 @@ export const useSftpTransfers = ({
};
if (file.type === "directory") {
// For subdirectories, create a nested progress tracker
let subDirCompletedBytes = 0;
const onSubDirChildProgress = (subCompleted: number, currentTransferred: number, currentTotal: number, speed: number) => {
subDirCompletedBytes = subCompleted;
// Report to parent: our completed + subdirectory's (completed + in-progress)
onChildProgress?.(completedBytesInDir + subCompleted, currentTransferred, currentTotal, speed);
};
await transferDirectory(
childTask,
sourceSftpId,
@@ -299,8 +387,14 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
onSubDirChildProgress,
);
completedBytesInDir += subDirCompletedBytes;
} else {
// For files, report streaming progress
const onFileStreamProgress = (transferred: number, total: number, speed: number) => {
onChildProgress?.(completedBytesInDir, transferred, total, speed);
};
await transferFile(
childTask,
sourceSftpId,
@@ -310,7 +404,12 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
onFileStreamProgress,
);
// After file completes, add its bytes to completed total
const childSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
completedBytesInDir += childSize;
onChildProgress?.(completedBytesInDir, 0, 0, 0);
}
}
};
@@ -336,7 +435,27 @@ export const useSftpTransfers = ({
: targetPane.filenameEncoding || "auto";
let actualFileSize = task.totalBytes;
if (!task.isDirectory && actualFileSize === 0) {
let prescanCancelled = false;
if (task.isDirectory) {
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
actualFileSize = await estimateDirectoryBytes(
task.sourcePath,
sourceSftpId,
sourcePane.connection!.isLocal,
sourceEncoding,
task.id,
);
} catch (err) {
if (isTransferCancelledError(err)) {
prescanCancelled = true;
}
// Fall back to the existing estimate below if size discovery fails.
}
} else if (actualFileSize === 0) {
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
@@ -367,13 +486,6 @@ export const useSftpTransfers = ({
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
updateTask({
status: "transferring",
totalBytes: estimatedSize,
transferredBytes: 0,
startTime: Date.now(),
});
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
@@ -393,12 +505,24 @@ export const useSftpTransfers = ({
}
let useSimulatedProgress = false;
if (!hasStreamingTransfer || task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
try {
if (prescanCancelled) {
throw new Error("Transfer cancelled");
}
updateTask({
status: "transferring",
totalBytes: estimatedSize,
transferredBytes: 0,
startTime: Date.now(),
});
if (!hasStreamingTransfer && !task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
let targetExists = false;
let existingStat: { size: number; mtime: number } | null = null;
@@ -481,6 +605,31 @@ export const useSftpTransfers = ({
}
if (task.isDirectory) {
// Track real progress for directory transfers:
// completedBytes = sum of all finished child files
// + currentFileTransferred = in-progress bytes of the currently transferring file
const onChildProgress = (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => {
const totalProgress = completedBytes + currentFileTransferred;
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id || t.status === "cancelled") return t;
const newTotal = Math.max(
t.totalBytes,
totalProgress,
completedBytes + currentFileTotal,
);
return {
...t,
transferredBytes: Math.max(
t.transferredBytes,
Math.min(totalProgress, newTotal),
),
totalBytes: newTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
};
}),
);
};
await transferDirectory(
task,
sourceSftpId,
@@ -490,6 +639,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
onChildProgress,
);
} else {
await transferFile(
@@ -560,6 +710,7 @@ export const useSftpTransfers = ({
completionHandlersRef.current.delete(task.id);
}
}
clearCancelledTask(task.id);
return "cancelled";
}
@@ -590,14 +741,14 @@ export const useSftpTransfers = ({
async (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
const targetPane = getActivePane(targetSide);
@@ -633,11 +784,11 @@ export const useSftpTransfers = ({
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
if (stat) fileSize = stat.size;
}
} catch {
@@ -718,10 +869,6 @@ export const useSftpTransfers = ({
}
}
// Clean up cancelled task ID after a delay to ensure all async ops see it
setTimeout(() => {
cancelledTasksRef.current.delete(transferId);
}, 5000);
},
[stopProgressSimulation],
);
@@ -729,7 +876,18 @@ export const useSftpTransfers = ({
const retryTransfer = useCallback(
async (transferId: string) => {
const task = transfers.find((t) => t.id === transferId);
if (!task) return;
if (!task || task.retryable === false) return;
const retriedTask: TransferTask = {
...task,
id: crypto.randomUUID(),
status: "pending" as TransferStatus,
error: undefined,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
endTime: undefined,
};
const sourceSide = task.sourceConnectionId.startsWith("left") ? "left" : "right";
const targetSide = task.targetConnectionId.startsWith("left") ? "left" : "right";
@@ -737,14 +895,20 @@ export const useSftpTransfers = ({
const targetPane = getActivePane(targetSide as "left" | "right");
if (sourcePane?.connection && targetPane?.connection) {
const completionHandler = completionHandlersRef.current.get(transferId);
if (completionHandler) {
completionHandlersRef.current.set(retriedTask.id, completionHandler);
completionHandlersRef.current.delete(transferId);
}
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
? { ...t, status: "pending" as TransferStatus, error: undefined }
? retriedTask
: t,
),
);
await processTransfer(task, sourcePane, targetPane, targetSide);
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
@@ -761,6 +925,10 @@ export const useSftpTransfers = ({
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
}, []);
const isTransferCancelled = useCallback((transferId: string) => {
return cancelledTasksRef.current.has(transferId);
}, []);
const addExternalUpload = useCallback((task: TransferTask) => {
// Filter out any pending scanning tasks before adding the new task.
// This ensures that even if dismissExternalUpload's state update hasn't been applied yet
@@ -773,7 +941,27 @@ export const useSftpTransfers = ({
const updateExternalUpload = useCallback((taskId: string, updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, ...updates } : t)),
prev.map((t) => {
if (t.id !== taskId) return t;
const merged: TransferTask = { ...t, ...updates };
// Keep progress monotonic and bounded for stable progress UI.
if (typeof merged.totalBytes === "number" && merged.totalBytes > 0) {
merged.transferredBytes = Math.max(
t.transferredBytes,
Math.min(merged.transferredBytes, merged.totalBytes),
);
} else {
merged.transferredBytes = Math.max(t.transferredBytes, merged.transferredBytes);
}
if (!Number.isFinite(merged.speed) || merged.speed < 0) {
merged.speed = 0;
}
return merged;
}),
);
}, []);
@@ -870,6 +1058,7 @@ export const useSftpTransfers = ({
addExternalUpload,
updateExternalUpload,
cancelTransfer,
isTransferCancelled,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,

View File

@@ -0,0 +1,581 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_EXTERNAL_AGENTS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
import type {
AISession,
AIPermissionMode,
ProviderConfig,
HostAIPermission,
ExternalAgentConfig,
ChatMessage,
AISessionScope,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
function getAIBridge() {
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
/** Maximum number of messages per session when persisting to localStorage. */
const MAX_SESSION_MESSAGES = 200;
/**
* Prune sessions before writing to localStorage to prevent hitting the
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
* state retains all messages until the session is reloaded.
*
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
*/
function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
// Sort by updatedAt descending so we keep the newest
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
return limited.map(s => {
if (s.messages.length > MAX_SESSION_MESSAGES) {
return { ...s, messages: s.messages.slice(-MAX_SESSION_MESSAGES) };
}
return s;
});
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
);
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
);
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
);
// ── Permission Model ──
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
return 'confirm';
});
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
);
// ── External Agents ──
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
);
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
);
// ── Safety Settings ──
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
);
// ── Sessions ──
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []
);
// Ref that always holds the latest sessions for use inside debounced callbacks
const sessionsRef = useRef(sessions);
useEffect(() => {
sessionsRef.current = sessions;
}, [sessions]);
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
// Per-agent model selection: remembers last selected model per agent
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
);
// ── Web Search Config ──
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
}, []);
const setAgentModel = useCallback((agentId: string, modelId: string) => {
setAgentModelMapRaw(prev => {
const next = { ...prev, [agentId]: modelId };
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
return next;
});
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
} else {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
}
}, []);
// ── Persist helpers ──
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
return next;
});
}, []);
const setActiveProviderId = useCallback((id: string) => {
setActiveProviderIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
}, []);
const setActiveModelId = useCallback((id: string) => {
setActiveModelIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
}, []);
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
setGlobalPermissionModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
// Sync to MCP Server bridge (observer mode blocks write operations)
const bridge = getAIBridge();
bridge?.aiMcpSetPermissionMode?.(mode);
}, []);
const setHostPermissions = useCallback((value: HostAIPermission[] | ((prev: HostAIPermission[]) => HostAIPermission[])) => {
setHostPermissionsRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, next);
return next;
});
}, []);
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
setExternalAgentsRaw(prev => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
return next;
});
}, []);
const setDefaultAgentId = useCallback((id: string) => {
setDefaultAgentIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
}, []);
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
// Sync to MCP Server bridge so ACP agents also respect the blocklist
const bridge = getAIBridge();
bridge?.aiMcpSetCommandBlocklist?.(value);
}, []);
const setCommandTimeout = useCallback((value: number) => {
setCommandTimeoutRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
// Sync to MCP Server bridge
const bridge = getAIBridge();
bridge?.aiMcpSetCommandTimeout?.(value);
}, []);
const setMaxIterations = useCallback((value: number) => {
setMaxIterationsRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
// Sync to MCP Server bridge (used by ACP agent path)
const bridge = getAIBridge();
bridge?.aiMcpSetMaxIterations?.(value);
}, []);
// ── Cross-window sync via storage events ──
// When the settings window updates localStorage, the main window picks up changes.
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
try {
switch (e.key) {
case STORAGE_KEY_AI_PROVIDERS: {
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
if (parsed != null && !Array.isArray(parsed)) {
console.warn('[useAIState] Cross-window sync: AI_PROVIDERS is not an array, skipping');
break;
}
setProvidersRaw(parsed ?? []);
break;
}
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
break;
case STORAGE_KEY_AI_ACTIVE_MODEL:
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
break;
case STORAGE_KEY_AI_PERMISSION_MODE: {
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (mode === 'observer' || mode === 'confirm' || mode === 'autonomous') {
setGlobalPermissionModeRaw(mode);
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
}
break;
}
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
if (agents != null && !Array.isArray(agents)) {
console.warn('[useAIState] Cross-window sync: AI_EXTERNAL_AGENTS is not an array, skipping');
break;
}
setExternalAgentsRaw(agents ?? []);
break;
}
case STORAGE_KEY_AI_DEFAULT_AGENT:
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
break;
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (list != null && !Array.isArray(list)) {
console.warn('[useAIState] Cross-window sync: AI_COMMAND_BLOCKLIST is not an array, skipping');
break;
}
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
setCommandBlocklistRaw(blocklist);
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
break;
}
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
if (!Number.isFinite(timeout)) {
console.warn('[useAIState] Cross-window sync: AI_COMMAND_TIMEOUT is not a finite number, skipping');
break;
}
setCommandTimeoutRaw(timeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
if (!Number.isFinite(iters)) {
console.warn('[useAIState] Cross-window sync: AI_MAX_ITERATIONS is not a finite number, skipping');
break;
}
setMaxIterationsRaw(iters);
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
break;
}
case STORAGE_KEY_AI_HOST_PERMISSIONS: {
const perms = localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS);
if (perms != null && !Array.isArray(perms)) {
console.warn('[useAIState] Cross-window sync: AI_HOST_PERMISSIONS is not an array, skipping');
break;
}
setHostPermissionsRaw(perms ?? []);
break;
}
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
break;
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
}
} catch (err) {
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
// ── Sync initial safety settings to MCP Server on mount ──
useEffect(() => {
const bridge = getAIBridge();
const initialBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST];
bridge?.aiMcpSetCommandBlocklist?.(initialBlocklist);
const initialTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
}, []);
// ── Session CRUD ──
const persistSessions = useCallback((next: AISession[]) => {
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(next));
}, []);
// Debounced version of persistSessions for high-frequency updates (e.g. streaming)
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const debouncedPersistSessions = useCallback(() => {
if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
persistTimerRef.current = setTimeout(() => {
if (!mountedRef.current) return; // Skip writes after unmount
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(sessionsRef.current));
persistTimerRef.current = null;
}, 500);
}, []);
// Flush pending debounced writes on unmount
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
persistSessions(sessionsRef.current);
}
};
}, [persistSessions]);
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
const now = Date.now();
const session: AISession = {
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
title: 'New Chat',
agentId: agentId || defaultAgentId,
scope,
messages: [],
createdAt: now,
updatedAt: now,
};
setSessionsRaw(prev => {
const next = [session, ...prev];
persistSessions(next);
return next;
});
const scopeKey = `${scope.type}:${scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, session.id);
return session;
}, [defaultAgentId, persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
setSessionsRaw(prev => {
const next = prev.filter(s => s.id !== sessionId);
persistSessions(next);
return next;
});
if (scopeKey) {
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
return prev;
});
}
}, [persistSessions]);
const deleteSessionsByTarget = useCallback((scopeType: 'terminal' | 'workspace', targetId: string) => {
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
setSessionsRaw(prev => {
const next = prev.filter(s => {
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
});
persistSessions(next);
return next;
});
const scopeKey = `${scopeType}:${targetId}`;
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
return prev;
});
}, [persistSessions]);
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
persistSessions(next);
return next;
});
}, [persistSessions]);
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
let msgs = [...s.messages, message];
// Trim oldest messages if exceeding limit (keep system messages)
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
const systemMsgs = msgs.filter(m => m.role === 'system');
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
}
return { ...s, messages: msgs, updatedAt: Date.now() };
});
debouncedPersistSessions();
return next;
});
}, [debouncedPersistSessions]);
const updateLastMessage = useCallback((sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => {
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId || s.messages.length === 0) return s;
const msgs = [...s.messages];
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
debouncedPersistSessions();
return next;
});
}, [debouncedPersistSessions]);
const updateMessageById = useCallback((sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => {
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
const idx = s.messages.findIndex(m => m.id === messageId);
if (idx === -1) return s;
const msgs = [...s.messages];
msgs[idx] = updater(msgs[idx]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
debouncedPersistSessions();
return next;
});
}, [debouncedPersistSessions]);
const clearSessionMessages = useCallback((sessionId: string) => {
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
persistSessions(next);
return next;
});
}, [persistSessions]);
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
setSessionsRaw(prev => {
const next = prev.filter(s => {
// Keep sessions without a targetId (global scope)
if (!s.scope.targetId) return true;
// Keep sessions whose target still exists
return activeTargetIds.has(s.scope.targetId);
});
if (next.length !== prev.length) {
persistSessions(next);
}
return next;
});
}, [persistSessions]);
// ── Provider CRUD helpers ──
const addProvider = useCallback((provider: ProviderConfig) => {
setProviders(prev => [...prev, provider]);
}, [setProviders]);
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
setProviders(prev => prev.map(p => p.id === id ? { ...p, ...updates } : p));
}, [setProviders]);
const removeProvider = useCallback((id: string) => {
setProviders(prev => prev.filter(p => p.id !== id));
// Use the raw setter to avoid stale closure over setActiveProviderId
setActiveProviderIdRaw(prevId => {
if (prevId === id) {
const next = '';
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, next);
return next;
}
return prevId;
});
}, [setProviders]);
// ── Computed ──
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
return {
// Provider config
providers,
setProviders,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId,
setActiveModelId,
activeProvider,
// Permission model
globalPermissionMode,
setGlobalPermissionMode,
hostPermissions,
setHostPermissions,
// External agents
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
// Safety
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
// Per-agent model memory
agentModelMap,
setAgentModel,
// Web search
webSearchConfig,
setWebSearchConfig,
// Sessions (per-scope active session)
sessions,
activeSessionIdMap,
setActiveSessionId,
createSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
addMessageToSession,
updateLastMessage,
updateMessageById,
clearSessionMessages,
cleanupOrphanedSessions,
};
}

View File

@@ -0,0 +1,101 @@
import { useCallback, useEffect, useState } from 'react';
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
interface NetcattyBridge {
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
}
function getBridge(): NetcattyBridge | undefined {
return (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
}
export function useAgentDiscovery(
externalAgents: ExternalAgentConfig[],
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
) {
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
const discover = useCallback(async () => {
const bridge = getBridge();
if (!bridge) return;
setIsDiscovering(true);
try {
const agents = await bridge.aiDiscoverAgents();
setDiscoveredAgents(agents);
} catch (err) {
console.error('Agent discovery failed:', err);
} finally {
setIsDiscovering(false);
}
}, []);
// Discover on mount
useEffect(() => {
discover();
}, [discover]);
// Auto-update args for already-configured discovered agents when
// the canonical args from discovery change (e.g. after an app update).
useEffect(() => {
if (!setExternalAgents || discoveredAgents.length === 0) return;
setExternalAgents((prev) => {
let changed = false;
const next = prev.map((ea) => {
// Only update agents that were auto-discovered (id starts with "discovered_")
if (!ea.id.startsWith('discovered_')) return ea;
const match = discoveredAgents.find(
(da) => ea.command === da.path || ea.command === da.command,
);
if (!match) return ea;
// Check if args or ACP config differ
const currentArgs = JSON.stringify(ea.args || []);
const newArgs = JSON.stringify(match.args);
const acpChanged = ea.acpCommand !== match.acpCommand
|| JSON.stringify(ea.acpArgs || []) !== JSON.stringify(match.acpArgs || []);
if (currentArgs !== newArgs || acpChanged) {
changed = true;
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
}
return ea;
});
return changed ? next : prev;
});
}, [discoveredAgents, setExternalAgents]);
// Filter out agents that are already configured as external agents
const unconfiguredAgents = discoveredAgents.filter(
(da) => !externalAgents.some(
(ea) => ea.command === da.command || ea.command === da.path,
),
);
// Build ExternalAgentConfig from a discovered agent
const enableAgent = useCallback(
(agent: DiscoveredAgent): ExternalAgentConfig => {
return {
id: `discovered_${agent.command}`,
name: agent.name,
command: agent.path || agent.command,
args: agent.args,
icon: agent.icon,
enabled: true,
acpCommand: agent.acpCommand,
acpArgs: agent.acpArgs,
};
},
[],
);
return {
discoveredAgents,
unconfiguredAgents,
isDiscovering,
rediscover: discover,
enableAgent,
};
}

View File

@@ -12,7 +12,14 @@ import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { SyncPayload } from '../../domain/sync';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings } from '../../domain/syncPayload';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { toast } from '../../components/ui/toast';
interface AutoSyncConfig {
@@ -22,15 +29,19 @@ interface AutoSyncConfig {
identities?: SyncPayload['identities'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
// Callbacks
onApplyPayload: (payload: SyncPayload) => void;
}
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
type SyncTrigger = 'auto' | 'manual';
@@ -45,48 +56,81 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
const isInitializedRef = useRef(false);
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
const isSyncRunningRef = useRef(false);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
const effectiveKnownHosts = getEffectiveKnownHosts(config.knownHosts);
return {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
snippets: config.snippets,
customGroups: config.customGroups,
portForwardingRules: config.portForwardingRules,
knownHosts: config.knownHosts,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: effectiveKnownHosts,
};
}, [
config.hosts,
config.keys,
config.identities,
config.snippets,
config.customGroups,
config.snippetPackages,
config.portForwardingRules,
config.knownHosts,
]);
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
return {
...getSyncSnapshot(),
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
}, [getSyncSnapshot]);
// Create a hash of current data for comparison
// Create a hash of current data for comparison (includes settings)
const getDataHash = useCallback(() => {
const data = {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
snippets: config.snippets,
portForwardingRules: config.portForwardingRules,
};
return JSON.stringify(data);
}, [config.hosts, config.keys, config.identities, config.snippets, config.portForwardingRules]);
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
}, [getSyncSnapshot]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions) => {
const trigger: SyncTrigger = options?.trigger ?? 'auto';
isSyncRunningRef.current = true;
try {
// Get fresh state directly from CloudSyncManager singleton
let state = manager.getState();
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const syncing = state.syncState === 'SYNCING';
if (!hasProvider) {
throw new Error(t('sync.autoSync.noProvider'));
}
if (syncing) {
if (trigger === 'auto') {
console.info('[AutoSync] Skipping overlapping auto-sync because another sync is already running.');
return;
}
throw new Error(t('sync.autoSync.alreadySyncing'));
}
@@ -108,7 +152,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.autoSync.vaultLocked'));
}
const dataHash = getDataHash();
const payload = buildPayload();
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length > 0) {
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
throw new Error(t('sync.credentialsUnavailable'));
}
const results = await sync.syncNow(payload);
for (const result of results.values()) {
@@ -120,7 +171,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
}
lastSyncedDataRef.current = getDataHash();
lastSyncedDataRef.current = dataHash;
} catch (error) {
if (trigger === 'manual') {
throw error;
@@ -130,13 +181,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
} finally {
isSyncRunningRef.current = false;
}
}, [sync, buildPayload, getDataHash, t]);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
const state = manager.getState();
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
@@ -146,12 +199,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
hasCheckedRemoteRef.current = true;
// Find connected provider
const connectedProvider =
state.providers.github.status === 'connected' ? 'github' :
state.providers.google.status === 'connected' ? 'google' :
state.providers.onedrive.status === 'connected' ? 'onedrive' :
state.providers.webdav.status === 'connected' ? 'webdav' :
state.providers.s3.status === 'connected' ? 's3' : null;
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
isProviderReadyForSync(state.providers[provider]),
) ?? null;
if (!connectedProvider) return;
@@ -190,6 +240,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (currentHash === lastSyncedDataRef.current) {
return;
}
// Wait for the current sync to finish, then this effect will re-run
// because sync.isSyncing changed.
if (sync.isSyncing || isSyncRunningRef.current) {
return;
}
// Clear existing timeout
if (syncTimeoutRef.current) {
@@ -207,7 +263,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, getDataHash, syncNow]);
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
// Check remote version on startup/unlock
useEffect(() => {

View File

@@ -21,6 +21,7 @@ import {
type S3Config,
formatLastSync,
getSyncDotColor,
isProviderReadyForSync,
} from '../../domain/sync';
import {
CloudSyncManager,
@@ -181,13 +182,13 @@ export const useCloudSync = (): CloudSyncHook => {
const hasAnyConnectedProvider = useMemo(() => {
return (Object.values(state.providers) as ProviderConnection[]).some(
(p) => p.status === 'connected' || p.status === 'syncing'
(p) => isProviderReadyForSync(p)
);
}, [state.providers]);
const connectedProviderCount = useMemo(() => {
return (Object.values(state.providers) as ProviderConnection[]).filter(
(p) => p.status === 'connected' || p.status === 'syncing'
(p) => isProviderReadyForSync(p)
).length;
}, [state.providers]);
@@ -286,7 +287,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Open browser after starting server
setTimeout(() => {
window.open(data.url, '_blank', 'width=600,height=700');
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback
@@ -319,7 +320,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Open browser after starting server
setTimeout(() => {
window.open(data.url, '_blank', 'width=600,height=700');
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback
@@ -519,7 +520,7 @@ export const useProviderStatus = (provider: CloudProvider) => {
return {
...connection,
isConnected: connection.status === 'connected',
isConnected: isProviderReadyForSync(connection),
isSyncing: connection.status === 'syncing',
hasError: connection.status === 'error',
dotColor: getSyncDotColor(connection.status),

View File

@@ -135,8 +135,6 @@ export const useGlobalHotkeys = ({
e.stopPropagation();
const currentActions = actionsRef.current;
const _tabs = orderedTabsRef.current;
switch (action) {
case 'switchToTab': {
const num = parseInt(e.key, 10);

View File

@@ -0,0 +1,66 @@
/**
* useImageUpload - Handle image paste/drop with base64 conversion
*
* Ported from 1code's use-agents-file-upload.ts
*/
import { useCallback, useState } from 'react';
export interface UploadedImage {
id: string;
filename: string;
dataUrl: string; // data:image/...;base64,... for preview
base64Data: string; // raw base64 for API
mediaType: string; // MIME type e.g. "image/png"
}
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1] || '';
resolve({ dataUrl, base64 });
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function useImageUpload() {
const [images, setImages] = useState<UploadedImage[]>([]);
const addImages = useCallback(async (files: File[]) => {
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) return;
const newImages: UploadedImage[] = await Promise.all(
imageFiles.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `screenshot-${Date.now()}.png`;
const mediaType = file.type || 'image/png';
let dataUrl = '';
let base64Data = '';
try {
const result = await fileToDataUrl(file);
dataUrl = result.dataUrl;
base64Data = result.base64;
} catch (err) {
console.error('[useImageUpload] Failed to convert:', err);
}
return { id, filename, dataUrl, base64Data, mediaType };
}),
);
setImages((prev) => [...prev, ...newImages]);
}, []);
const removeImage = useCallback((id: string) => {
setImages((prev) => prev.filter((i) => i.id !== id));
}, []);
const clearImages = useCallback(() => {
setImages([]);
}, []);
return { images, addImages, removeImage, clearImages };
}

View File

@@ -17,7 +17,7 @@ import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
hosts: Host[];
keys: { id: string; privateKey: string }[];
keys: { id: string; privateKey: string; passphrase: string }[];
}
/**
@@ -30,7 +30,7 @@ export const usePortForwardingAutoStart = ({
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<{ id: string; privateKey: string }[]>(keys);
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
// Keep refs in sync
useEffect(() => {

View File

@@ -9,12 +9,25 @@ import { localStorageAdapter } from "../../infrastructure/persistence/localStora
import {
clearReconnectTimer,
getActiveConnection,
initReconnectCancelListener,
reconcileWithBackend,
startPortForward,
stopAllPortForwards,
stopAndCleanupRule,
stopPortForward,
syncWithBackend,
} from "../../infrastructure/services/portForwardingService";
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
// Module-level ref-counts: these side effects must run at most once per
// window, not per hook instance (the hook mounts from both App.tsx
// and PortForwardingNew.tsx). Ref-counting ensures the resources
// stay alive as long as ANY instance is mounted.
let reconnectCancelListenerRefs = 0;
let reconnectCancelCleanup: (() => void) | undefined;
let heartbeatRefs = 0;
let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
export type { ViewMode };
export type SortMode = "az" | "za" | "newest" | "oldest";
@@ -50,7 +63,7 @@ export interface UsePortForwardingStateResult {
startTunnel: (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string }[],
keys: { id: string; privateKey: string; passphrase: string }[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
) => Promise<{ success: boolean; error?: string }>;
@@ -177,6 +190,53 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
// Listen for cross-window reconnect cancellation events.
// Ref-counted so the listener stays alive as long as ANY hook
// instance is mounted (App.tsx outlives PortForwardingNew.tsx).
useEffect(() => {
reconnectCancelListenerRefs++;
let cleanup: (() => void) | undefined;
if (reconnectCancelListenerRefs === 1) {
cleanup = initReconnectCancelListener();
reconnectCancelCleanup = cleanup;
}
return () => {
reconnectCancelListenerRefs--;
if (reconnectCancelListenerRefs === 0 && reconnectCancelCleanup) {
reconnectCancelCleanup();
reconnectCancelCleanup = undefined;
}
};
}, []);
// Periodic heartbeat: reconcile renderer state with the backend every 4s.
// Ref-counted — same pattern as the reconnect cancel listener.
useEffect(() => {
heartbeatRefs++;
let intervalId: ReturnType<typeof setInterval> | undefined;
if (heartbeatRefs === 1) {
const HEARTBEAT_INTERVAL_MS = 4_000;
const tick = async () => {
const { gone, appeared } = await reconcileWithBackend();
if (gone.length === 0 && appeared.length === 0) return;
// Re-derive statuses from the now-updated activeConnections map
setGlobalRules(normalizeRulesWithConnections(globalRules));
};
intervalId = setInterval(tick, HEARTBEAT_INTERVAL_MS);
heartbeatIntervalId = intervalId;
}
return () => {
heartbeatRefs--;
if (heartbeatRefs === 0 && heartbeatIntervalId !== undefined) {
clearInterval(heartbeatIntervalId);
heartbeatIntervalId = undefined;
}
};
}, []);
const addRule = useCallback(
(
rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">,
@@ -207,6 +267,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
const deleteRule = useCallback(
(id: string) => {
// Stop any active tunnel before removing the rule
stopAndCleanupRule(id);
const updated = globalRules.filter((r) => r.id !== id);
setGlobalRules(updated);
if (selectedRuleId === id) {
@@ -238,6 +300,60 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
);
const importRules = useCallback((newRules: PortForwardingRule[]) => {
// When clearing all rules (e.g. "Clear local data"), stop ALL tunnels
// and broadcast per-rule reconnect cancellation. stopAllPortForwards
// handles the backend, but we also need per-rule broadcasts so other
// windows cancel their pending reconnect timers.
if (newRules.length === 0) {
// Read from localStorage since globalRules may be empty (uninitialized)
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
const rulesToCancel = globalRules.length > 0
? globalRules
: (storedRules && Array.isArray(storedRules) ? storedRules : []);
for (const rule of rulesToCancel) {
stopAndCleanupRule(rule.id);
}
// Safety net: also stop anything the renderer doesn't know about
void stopAllPortForwards();
}
// Stop tunnels for rules that are being removed or whose connection
// config has changed (same ID but different host/port/type means the
// old tunnel is pointing at stale parameters and must be torn down).
//
// Use globalRules as the diff baseline. In a freshly opened settings
// window, globalRules may still be empty because initializeStore is
// async. Fall back to reading directly from localStorage to avoid
// missing tunnels that need to be stopped.
let diffBaseline = globalRules;
if (diffBaseline.length === 0 && newRules.length > 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
diffBaseline = stored;
}
}
const newRulesById = new Map(newRules.map((r) => [r.id, r]));
for (const existing of diffBaseline) {
const incoming = newRulesById.get(existing.id);
if (!incoming) {
// Rule removed entirely
stopAndCleanupRule(existing.id);
} else if (
existing.type !== incoming.type ||
existing.localPort !== incoming.localPort ||
existing.remoteHost !== incoming.remoteHost ||
existing.remotePort !== incoming.remotePort ||
existing.bindAddress !== incoming.bindAddress ||
existing.hostId !== incoming.hostId
) {
// Connection-relevant config changed — tear down the old tunnel
stopAndCleanupRule(existing.id);
}
}
setGlobalRules(normalizeRulesWithConnections(newRules));
}, []);
@@ -261,7 +377,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
async (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string }[],
keys: { id: string; privateKey: string; passphrase: string }[],
onStatusChange?: (
status: PortForwardingRule["status"],
error?: string,

View File

@@ -49,9 +49,10 @@ export const useSessionState = () => {
username: 'local',
status: 'connecting',
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
}, [setActiveTabId]);
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig) => {
const sessionId = crypto.randomUUID();
@@ -69,6 +70,7 @@ export const useSessionState = () => {
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
@@ -100,7 +102,7 @@ export const useSessionState = () => {
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return;
return sessionId;
}
const newSession: TerminalSession = {
@@ -115,9 +117,10 @@ export const useSessionState = () => {
port: host.port,
moshEnabled: host.moshEnabled,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
}, [setActiveTabId]);
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
return newSession.id;
}, [setActiveTabId]);
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
@@ -566,6 +569,7 @@ export const useSessionState = () => {
workspaceId: workspace.id,
// Store the command to run after connection
startupCommand: snippet.command,
noAutoRun: snippet.noAutoRun,
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
@@ -27,9 +27,12 @@ import {
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
@@ -38,7 +41,13 @@ import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
const DEFAULT_THEME: 'light' | 'dark' = 'light';
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
/** Resolve the current OS color scheme preference. */
const getSystemPreference = (): 'light' | 'dark' =>
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
const DEFAULT_LIGHT_UI_THEME = 'snow';
const DEFAULT_DARK_UI_THEME = 'midnight';
const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
@@ -76,7 +85,7 @@ const readStoredString = (key: string): string | null => {
}
};
const isValidTheme = (value: unknown): value is 'light' | 'dark' => value === 'light' || value === 'dark';
const isValidTheme = (value: unknown): value is 'light' | 'dark' | 'system' => value === 'light' || value === 'dark' || value === 'system';
const isValidHslToken = (value: string): boolean => {
// Expect: "<h> <s>% <l>%", e.g. "221.2 83.2% 53.3%"
@@ -103,14 +112,15 @@ const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boo
serializeTerminalSettings(a) === serializeTerminalSettings(b);
const applyThemeTokens = (
theme: 'light' | 'dark',
themeSource: 'light' | 'dark' | 'system',
resolvedTheme: 'light' | 'dark',
tokens: UiThemeTokens,
accentMode: 'theme' | 'custom',
accentOverride: string,
) => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
root.classList.add(resolvedTheme);
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);
@@ -119,7 +129,7 @@ const applyThemeTokens = (
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
const computedAccentForeground = theme === 'dark'
const computedAccentForeground = resolvedTheme === 'dark'
? '220 40% 96%'
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
@@ -138,17 +148,21 @@ const applyThemeTokens = (
root.style.setProperty('--ring', accentToken);
// Sync with native window title bar (Electron)
netcattyBridge.get()?.setTheme?.(theme);
netcattyBridge.get()?.setTheme?.(themeSource);
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
};
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
});
// Track the OS color scheme preference (updated by matchMedia listener)
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
// resolvedTheme is always 'light' or 'dark' — derived synchronously from theme + OS preference
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemPreference : theme;
const [lightUiThemeId, setLightUiThemeId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
return stored && isValidUiThemeId('light', stored) ? stored : DEFAULT_LIGHT_UI_THEME;
@@ -181,7 +195,7 @@ export const useSettingsState = () => {
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
return normalizeTerminalSettings(stored);
});
const [hotkeyScheme, setHotkeyScheme] = useState<HotkeyScheme>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_HOTKEY_SCHEME);
@@ -252,16 +266,27 @@ export const useSettingsState = () => {
if (stored === null) return true;
return stored === 'true';
});
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
const [globalHotkeyEnabled, setGlobalHotkeyEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED);
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = typeof nextValue === 'function'
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
: nextValue;
const next = normalizeTerminalSettings(candidate);
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
@@ -272,7 +297,7 @@ export const useSettingsState = () => {
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = { ...prev, ...incoming };
const next = normalizeTerminalSettings({ ...prev, ...incoming });
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
@@ -309,8 +334,9 @@ export const useSettingsState = () => {
setAccentMode(nextAccentMode);
setCustomAccent(nextAccent);
const tokens = getUiThemeById(nextTheme, nextTheme === 'dark' ? nextDarkId : nextLightId).tokens;
applyThemeTokens(nextTheme, tokens, nextAccentMode, nextAccent);
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const syncCustomCssFromStorage = useCallback(() => {
@@ -318,9 +344,63 @@ export const useSettingsState = () => {
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
}, []);
const rehydrateAllFromStorage = useCallback(() => {
// Theme & appearance (already have helper)
syncAppearanceFromStorage();
syncCustomCssFromStorage();
// UI Font
const storedFont = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
if (storedFont) setUiFontFamilyId(storedFont);
// Language
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
if (storedLang) setUiLanguage(storedLang as UILanguage);
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
if (storedTermSettings) {
try {
const parsed = JSON.parse(storedTermSettings);
setTerminalSettings(parsed);
} catch { /* ignore */ }
}
// Keyboard
const storedKb = readStoredString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
if (storedKb) {
try {
setCustomKeyBindings(JSON.parse(storedKb));
} catch { /* ignore */ }
}
// Editor
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
// SFTP
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
const storedAutoSync = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
if (storedAutoSync === 'true' || storedAutoSync === 'false') setSftpAutoSync(storedAutoSync === 'true');
const storedHidden = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const tokens = getUiThemeById(theme, theme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, tokens, accentMode, customAccent);
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
@@ -332,7 +412,18 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemPreference(e.matches ? 'dark' : 'light');
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
useLayoutEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
@@ -403,6 +494,18 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
setEditorWordWrapState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_ENABLED && typeof value === 'boolean') {
setSessionLogsEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_DIR && typeof value === 'string') {
setSessionLogsDir((prev) => (prev === value ? prev : value));
}
if (
key === STORAGE_KEY_SESSION_LOGS_FORMAT &&
(value === 'txt' || value === 'raw' || value === 'html')
) {
setSessionLogsFormat((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -420,6 +523,12 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
setIsHotkeyRecordingState(value);
}
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
@@ -509,7 +618,7 @@ export const useSettingsState = () => {
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
mergeIncomingTerminalSettings({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings });
mergeIncomingTerminalSettings(newSettings);
} catch {
// ignore parse errors
}
@@ -559,6 +668,25 @@ export const useSettingsState = () => {
setEditorWordWrapState(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sessionLogsEnabled) {
setSessionLogsEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
if (e.newValue !== sessionLogsDir) {
setSessionLogsDir(e.newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
if (
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
e.newValue !== sessionLogsFormat
) {
setSessionLogsFormat(e.newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -566,11 +694,25 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== globalHotkeyEnabled) {
setGlobalHotkeyEnabled(newValue);
}
}
// Sync auto-update enabled setting from other windows
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== autoUpdateEnabled) {
setAutoUpdateEnabled(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, mergeIncomingTerminalSettings]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -678,7 +820,7 @@ export const useSettingsState = () => {
// Register/unregister the global hotkey in main process
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
setHotkeyRegistrationError(null);
bridge
.registerGlobalHotkey(toggleWindowHotkey)
@@ -699,7 +841,13 @@ export const useSettingsState = () => {
});
}
}
}, [toggleWindowHotkey, notifySettingsChanged]);
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
// Persist global hotkey enabled setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [globalHotkeyEnabled, notifySettingsChanged]);
// Persist and sync close to tray setting
useEffect(() => {
@@ -714,6 +862,41 @@ export const useSettingsState = () => {
}
}, [closeToTray, notifySettingsChanged]);
// Hydrate auto-update state from the main-process preference file on mount.
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
// in case localStorage was cleared or is stale.
useEffect(() => {
const bridge = netcattyBridge.get();
void bridge?.getAutoUpdate?.().then((result) => {
if (result && typeof result.enabled === 'boolean') {
setAutoUpdateEnabled((prev) => {
if (prev === result.enabled) return prev;
// Sync localStorage with the main-process truth
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
return result.enabled;
});
}
}).catch(() => { /* bridge unavailable */ });
}, []);
// Persist auto-update enabled setting.
// Skip IPC on initial mount to avoid overwriting the main-process preference
// file when localStorage has been cleared (where the default is true).
const autoUpdateMountedRef = useRef(false);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
if (!autoUpdateMountedRef.current) {
autoUpdateMountedRef.current = true;
return; // Skip IPC on initial mount
}
// Notify main process on user-initiated changes
const bridge = netcattyBridge.get();
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
console.warn('[AutoUpdate] Failed to set auto-update:', err);
});
}, [autoUpdateEnabled, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -766,9 +949,14 @@ export const useSettingsState = () => {
localStorageAdapter.write(STORAGE_KEY_SYNC, config);
}, []);
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0],
[terminalThemeId]
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0],
[terminalThemeId, customThemes]
);
const currentTerminalFont = useMemo(
@@ -786,6 +974,7 @@ export const useSettingsState = () => {
return {
theme,
setTheme,
resolvedTheme,
lightUiThemeId,
setLightUiThemeId,
darkUiThemeId,
@@ -850,6 +1039,21 @@ export const useSettingsState = () => {
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
autoUpdateEnabled,
setAutoUpdateEnabled,
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
rehydrateAllFromStorage,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
customThemes,
]),
};
};

View File

@@ -1,184 +0,0 @@
/**
* useSftpFileOperations - Shared file operations for SFTP components
*
* This hook provides common file operations like open, edit, preview
* that can be shared between SFTPModal and SftpView components.
*/
import { useCallback, useState } from "react";
import { getFileExtension, isTextFile, FileOpenerType } from "../../lib/sftpFileUtils";
import { toast } from "../../components/ui/toast";
import { useI18n } from "../i18n/I18nProvider";
import { useSftpFileAssociations } from "./useSftpFileAssociations";
export interface FileOperationsState {
// Text editor state
showTextEditor: boolean;
textEditorTarget: { name: string; fullPath: string } | null;
textEditorContent: string;
loadingTextContent: boolean;
// File opener dialog state
showFileOpenerDialog: boolean;
fileOpenerTarget: { name: string; fullPath: string } | null;
}
export interface FileOperationsActions {
// Open file based on type/association
openFile: (fileName: string, fullPath: string) => void;
// Edit text file
editFile: (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => Promise<void>;
// Save text file
saveTextFile: (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => Promise<void>;
// Handle file opener selection
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
readImageData: () => Promise<ArrayBuffer>
) => Promise<void>;
// Close modals
closeTextEditor: () => void;
closeFileOpenerDialog: () => void;
// Check if file can be edited
canEditFile: (fileName: string) => boolean;
}
export interface UseSftpFileOperationsResult {
state: FileOperationsState;
actions: FileOperationsActions;
}
export function useSftpFileOperations(): UseSftpFileOperationsResult {
const { t } = useI18n();
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
// Text editor state
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<{ name: string; fullPath: string } | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
// File opener dialog state
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<{ name: string; fullPath: string } | null>(null);
const canEditFile = useCallback((fileName: string) => {
return isTextFile(fileName);
}, []);
const closeTextEditor = useCallback(() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}, []);
const closeFileOpenerDialog = useCallback(() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}, []);
const editFile = useCallback(async (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => {
try {
setLoadingTextContent(true);
setTextEditorTarget({ name: fileName, fullPath });
const content = await readContent();
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoadingTextContent(false);
}
}, [t]);
const saveTextFile = useCallback(async (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => {
if (!textEditorTarget) return;
await writeContent(textEditorTarget.fullPath, content);
}, [textEditorTarget]);
const openFile = useCallback((fileName: string, fullPath: string) => {
const savedOpener = getOpenerForFile(fileName);
if (savedOpener) {
// User has saved an opener for this file type
// We'll just set the target and let the caller handle it
setFileOpenerTarget({ name: fileName, fullPath });
// Return the opener type so caller knows which operation to perform
if (savedOpener === 'builtin-editor' && canEditFile(fileName)) {
// Don't show dialog, caller should call editFile
return 'edit' as const;
}
}
// No saved opener, show the dialog
setFileOpenerTarget({ name: fileName, fullPath });
setShowFileOpenerDialog(true);
return 'dialog' as const;
}, [getOpenerForFile, canEditFile]);
const handleFileOpenerSelect = useCallback(async (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
_readImageData: () => Promise<ArrayBuffer>
) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.name);
if (ext !== 'file') {
setOpenerForExtension(ext, openerType);
}
}
setShowFileOpenerDialog(false);
if (openerType === 'builtin-editor') {
await editFile(fileOpenerTarget.name, fileOpenerTarget.fullPath, readTextContent);
}
}, [fileOpenerTarget, setOpenerForExtension, editFile]);
return {
state: {
showTextEditor,
textEditorTarget,
textEditorContent,
loadingTextContent,
showFileOpenerDialog,
fileOpenerTarget,
},
actions: {
openFile,
editFile,
saveTextFile,
handleFileOpenerSelect,
closeTextEditor,
closeFileOpenerDialog,
canEditFile,
},
};
}

View File

@@ -36,7 +36,15 @@ export const useSftpState = (
identities: Identity[],
options?: SftpStateOptions
) => {
const tabsState = useSftpTabsState();
const createPane = useCallback(
(id?: string, showHiddenFiles = options?.defaultShowHiddenFiles ?? false) =>
createEmptyPane(id, showHiddenFiles),
[options?.defaultShowHiddenFiles],
);
const tabsState = useSftpTabsState({
defaultShowHiddenFiles: options?.defaultShowHiddenFiles,
});
const {
leftTabs,
rightTabs,
@@ -49,6 +57,7 @@ export const useSftpState = (
getActivePane,
updateTab,
updateActiveTab,
setTabShowHiddenFiles,
addTab,
closeTab,
selectTab,
@@ -92,12 +101,26 @@ export const useSftpState = (
}
}, []);
const clearDirCacheEntry = useCallback((connectionId: string, path: string) => {
// Remove all encoding variants of this path from the cache
for (const key of dirCacheRef.current.keys()) {
if (key.startsWith(`${connectionId}::`) && key.endsWith(`::${path}`)) {
dirCacheRef.current.delete(key);
}
}
}, []);
// Ref to track pending reconnections to avoid multiple reconnect attempts
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
left: false,
right: false,
});
// Map connectionId → cache key, set at connect time so each tab's
// navigateTo can use the correct cache key even when multiple tabs
// share the same hostId with different session-time overrides.
const connectionCacheKeyMapRef = useRef<Map<string, string>>(new Map());
// Store last connected host info for reconnection
const lastConnectedHostRef = useRef<{
left: Host | "local" | null;
@@ -140,10 +163,12 @@ export const useSftpState = (
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane,
createEmptyPane: createPane,
autoConnectLocalOnMount: options?.autoConnectLocalOnMount,
});
const {
@@ -164,6 +189,7 @@ export const useSftpState = (
renameFile,
changePermissions,
} = useSftpPaneActions({
hosts,
getActivePane,
updateTab,
updateActiveTab,
@@ -173,6 +199,7 @@ export const useSftpState = (
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
connectionCacheKeyMapRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
@@ -205,6 +232,13 @@ export const useSftpState = (
[clearCacheForConnection, getActivePane, navigateTo, updateActiveTab],
);
const setShowHiddenFiles = useCallback(
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
setTabShowHiddenFiles(side, tabId, showHiddenFiles);
},
[setTabShowHiddenFiles],
);
const {
transfers,
conflicts,
@@ -213,6 +247,7 @@ export const useSftpState = (
addExternalUpload,
updateExternalUpload,
cancelTransfer,
isTransferCancelled,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
@@ -232,14 +267,20 @@ export const useSftpState = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
} = useSftpExternalOperations({
getActivePane,
refresh,
sftpSessionsRef,
connectionCacheKeyMapRef,
clearDirCacheEntry,
useCompressedUpload: options?.useCompressedUpload,
addExternalUpload,
updateExternalUpload,
isTransferCancelled,
dismissExternalUpload: dismissTransfer,
});
@@ -267,6 +308,7 @@ export const useSftpState = (
selectAll,
setFilter,
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createFile,
deleteFiles,
@@ -278,6 +320,7 @@ export const useSftpState = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
startTransfer,
@@ -312,6 +355,7 @@ export const useSftpState = (
selectAll,
setFilter,
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createFile,
deleteFiles,
@@ -323,6 +367,7 @@ export const useSftpState = (
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
startTransfer,
@@ -361,6 +406,8 @@ export const useSftpState = (
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
methodsRef.current.setFilenameEncoding(...args),
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
methodsRef.current.setShowHiddenFiles(...args),
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
@@ -373,6 +420,8 @@ export const useSftpState = (
writeTextFile: (...args: Parameters<typeof writeTextFile>) => methodsRef.current.writeTextFile(...args),
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
methodsRef.current.uploadExternalEntries(...args),
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
selectApplication: () => methodsRef.current.selectApplication(),
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
@@ -384,7 +433,8 @@ export const useSftpState = (
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
}), []); // Empty deps - these wrappers never change
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
// Return object with stable method references but reactive state
// State changes will cause re-renders, but method references stay stable

View File

@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for persisting a string value to localStorage.
* @param storageKey - The key to use for localStorage
* @param fallback - The default value if no stored value exists
* @param validate - Optional function to validate stored value; returns fallback if invalid
* @returns A tuple of [value, setValue] similar to useState
*/
export const useStoredString = <T extends string = string>(
storageKey: string,
fallback: T,
validate?: (value: string) => value is T,
) => {
const [value, setValue] = useState<T>(() => {
const stored = localStorageAdapter.readString(storageKey);
if (stored === null) return fallback;
if (validate) return validate(stored) ? stored : fallback;
return stored as T;
});
useEffect(() => {
localStorageAdapter.writeString(storageKey, value);
}, [storageKey, value]);
return [value, setValue] as const;
};

View File

@@ -1,68 +0,0 @@
import { useCallback, useState } from "react";
import { loadFromGist, syncToGist } from "../../infrastructure/services/syncService";
export type SyncStatus = "idle" | "success" | "error";
export const useSyncState = () => {
const [isSyncing, setIsSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
const resetSyncStatus = useCallback(() => {
setSyncStatus("idle");
}, []);
const verify = useCallback(async (token: string, gistId?: string) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
if (gistId) {
await loadFromGist(token, gistId);
}
setSyncStatus("success");
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
}, []);
const upload = useCallback(
async (
token: string,
gistId: string | undefined,
data: Parameters<typeof syncToGist>[2],
) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
const newGistId = await syncToGist(token, gistId, data);
setSyncStatus("success");
return newGistId;
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
},
[],
);
const download = useCallback(async (token: string, gistId: string) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
const data = await loadFromGist(token, gistId);
setSyncStatus("success");
return data;
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
}, []);
return { isSyncing, syncStatus, resetSyncStatus, verify, upload, download };
};

View File

@@ -78,6 +78,12 @@ export const useTerminalBackend = () => {
bridge?.closeSession?.(sessionId);
}, []);
const setSessionEncoding = useCallback(async (sessionId: string, encoding: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.setSessionEncoding) return { ok: false, encoding };
return bridge.setSessionEncoding(sessionId, encoding);
}, []);
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
@@ -148,6 +154,7 @@ export const useTerminalBackend = () => {
writeToSession,
resizeSession,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onChainProgress,

View File

@@ -12,6 +12,11 @@ export const useTrayPanelBackend = () => {
await bridge?.openMainWindow?.();
}, []);
const quitApp = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.quitApp?.();
}, []);
const jumpToSession = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
@@ -57,6 +62,7 @@ export const useTrayPanelBackend = () => {
return {
hideTrayPanel,
openMainWindow,
quitApp,
jumpToSession,
connectToHostFromTrayPanel,
onTrayPanelCloseRequest,

View File

@@ -1,13 +1,17 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
// Delay startup check to avoid slowing down app launch
const STARTUP_CHECK_DELAY_MS = 5000;
// Delay startup check to avoid slowing down app launch.
// 8s gives electron-updater's startAutoCheck(5000) time to emit
// 'update-available' first. The `onUpdateAvailable` handler also cancels
// any pending startup timeout, so even on slow networks where the event
// arrives after 8s the duplicate check is avoided.
const STARTUP_CHECK_DELAY_MS = 8000;
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
@@ -19,6 +23,10 @@ const debugLog = (...args: unknown[]) => {
}
};
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';
export type ManualCheckStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'error';
export interface UpdateState {
isChecking: boolean;
hasUpdate: boolean;
@@ -26,6 +34,12 @@ export interface UpdateState {
latestRelease: ReleaseInfo | null;
error: string | null;
lastCheckedAt: number | null;
// Auto-download state — driven by electron-updater IPC events
autoDownloadStatus: AutoDownloadStatus;
downloadPercent: number;
downloadError: string | null;
/** Manual check state — driven by user clicking "Check for Updates" */
manualCheckStatus: ManualCheckStatus;
}
export interface UseUpdateCheckResult {
@@ -33,6 +47,7 @@ export interface UseUpdateCheckResult {
checkNow: () => Promise<UpdateCheckResult | null>;
dismissUpdate: () => void;
openReleasePage: () => void;
installUpdate: () => void;
}
/**
@@ -41,7 +56,13 @@ export interface UseUpdateCheckResult {
* - Respects dismissed version to avoid nagging
* - Provides manual check capability
*/
export function useUpdateCheck(): UseUpdateCheckResult {
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUpdateCheckResult {
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
// reacts immediately in the same window. Falls back to reading localStorage
// when no caller provides the value (e.g. in non-settings contexts).
const autoUpdateEnabled = options?.autoUpdateEnabled ??
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
const [updateState, setUpdateState] = useState<UpdateState>({
isChecking: false,
hasUpdate: false,
@@ -49,11 +70,44 @@ export function useUpdateCheck(): UseUpdateCheckResult {
latestRelease: null,
error: null,
lastCheckedAt: null,
autoDownloadStatus: 'idle',
downloadPercent: 0,
downloadError: null,
manualCheckStatus: 'idle',
});
const hasCheckedOnStartupRef = useRef(false);
const isCheckingRef = useRef(false);
const startupCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track current version in a ref to avoid stale closure in checkNow
const currentVersionRef = useRef(updateState.currentVersion);
// Track autoDownloadStatus in a ref so checkNow always reads the latest value
const autoDownloadStatusRef = useRef<AutoDownloadStatus>('idle');
// Timer ref for auto-resetting manualCheckStatus='up-to-date' back to 'idle'
const manualCheckResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Flag: true when we suppressed auto-download because the version was dismissed.
// Used to distinguish "idle because dismissed" from "idle because not hydrated yet"
// in the progress/downloaded/error callbacks.
const dismissedAutoDownloadRef = useRef(false);
// Keep currentVersionRef in sync so checkNow always reads the latest version
useEffect(() => {
currentVersionRef.current = updateState.currentVersion;
}, [updateState.currentVersion]);
// Keep autoDownloadStatusRef in sync so checkNow always reads the latest download state
useEffect(() => {
autoDownloadStatusRef.current = updateState.autoDownloadStatus;
}, [updateState.autoDownloadStatus]);
// Cleanup: clear any pending manualCheckStatus reset timer on unmount
useEffect(() => {
return () => {
if (manualCheckResetTimeoutRef.current) {
clearTimeout(manualCheckResetTimeoutRef.current);
}
};
}, []);
// Get current app version
useEffect(() => {
@@ -71,6 +125,145 @@ export function useUpdateCheck(): UseUpdateCheckResult {
void loadVersion();
}, []);
// Hydrate auto-download status from the main process so windows opened
// after the download started (e.g. Settings) immediately reflect the
// current state instead of showing stale 'idle'.
useEffect(() => {
const bridge = netcattyBridge.get();
void bridge?.getUpdateStatus?.().then((snapshot) => {
if (!snapshot || snapshot.status === 'idle') return;
// Respect dismissed versions: if the user dismissed this release,
// don't surface download progress/ready state in late-opening windows.
// Also set the dismissed ref so subsequent IPC events are suppressed.
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
if (snapshot.version && snapshot.version === dismissedVersion) {
dismissedAutoDownloadRef.current = true;
return;
}
// 'available' means an update was found but auto-download is disabled.
// Surface the version info (hasUpdate + latestRelease) but keep
// autoDownloadStatus at 'idle' so the manual download path shows.
const isAvailableOnly = snapshot.status === 'available';
setUpdateState((prev) => {
// Don't overwrite if the renderer already has a newer state
if (prev.autoDownloadStatus !== 'idle') return prev;
return {
...prev,
hasUpdate: isAvailableOnly ? true : prev.hasUpdate,
autoDownloadStatus: isAvailableOnly ? 'idle' : snapshot.status,
downloadPercent: isAvailableOnly ? 0 : snapshot.percent,
downloadError: isAvailableOnly ? null : snapshot.error,
// Use snapshot version if no release data or if versions differ
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
version: snapshot.version,
tagName: `v${snapshot.version}`,
name: `v${snapshot.version}`,
body: '',
htmlUrl: '',
publishedAt: new Date().toISOString(),
assets: [],
} : prev.latestRelease) : prev.latestRelease,
};
});
});
}, []);
// Subscribe to electron-updater auto-download IPC events.
// These fire automatically when autoDownload=true in the main process.
useEffect(() => {
const bridge = netcattyBridge.get();
// When electron-updater confirms no update in its feed, don't write
// STORAGE_KEY_UPDATE_LAST_CHECK — that would throttle the GitHub API
// fallback for an hour. Let performCheck write it on success so the
// GitHub check can still discover releases not yet in the updater feed.
const cleanupNotAvailable = bridge?.onUpdateNotAvailable?.(() => {
// No-op for now — the GitHub fallback will handle lastCheckedAt.
});
const cleanupAvailable = bridge?.onUpdateAvailable?.((info) => {
// Cancel any pending startup GitHub API check — electron-updater is
// now authoritative and we don't want a duplicate toast.
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// Check if this version was dismissed by the user
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isDismissed = dismissedVersion === info.version;
if (isDismissed) {
dismissedAutoDownloadRef.current = true;
}
// When auto-update is disabled, autoDownload=false in the main process
// so no download will start. Don't transition to 'downloading' or the
// UI will be stuck at 0%. Keep status idle and let the manual download
// link surface instead.
const isAutoUpdateOff = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false';
const shouldTrackDownload = !isDismissed && !isAutoUpdateOff;
setUpdateState((prev) => ({
...prev,
hasUpdate: !isDismissed,
autoDownloadStatus: shouldTrackDownload ? 'downloading' : prev.autoDownloadStatus,
downloadPercent: shouldTrackDownload ? 0 : prev.downloadPercent,
downloadError: shouldTrackDownload ? null : prev.downloadError,
// Use electron-updater's version if GitHub API hasn't resolved yet or
// if the updater reports a different version than the cached release.
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
version: info.version,
tagName: `v${info.version}`,
name: `v${info.version}`,
body: info.releaseNotes || '',
htmlUrl: '',
publishedAt: info.releaseDate || new Date().toISOString(),
assets: [],
} : prev.latestRelease,
}));
});
const cleanupProgress = bridge?.onUpdateDownloadProgress?.((p) => {
// If we suppressed the download for a dismissed version, ignore progress.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'downloading',
downloadPercent: Math.round(p.percent),
}));
});
const cleanupDownloaded = bridge?.onUpdateDownloaded?.(() => {
// If the download was for a dismissed version, don't transition to
// 'ready' — that would trigger the "Update ready" toast.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'ready',
downloadPercent: 100,
}));
});
const cleanupError = bridge?.onUpdateError?.((payload) => {
// If we suppressed the download for a dismissed version, ignore errors.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: payload.error,
}));
});
return () => {
cleanupNotAvailable?.();
cleanupAvailable?.();
cleanupProgress?.();
cleanupDownloaded?.();
cleanupError?.();
};
}, []);
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
@@ -119,8 +312,16 @@ export function useUpdateCheck(): UseUpdateCheckResult {
debugLog('Latest release version:', result.latestRelease?.version);
const now = Date.now();
// Save last check time
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
// Only advance last-check time and cache release on successful checks.
// Failed checks (result.error set, no latestRelease) must not update
// the timestamp — otherwise stale cached release data persists for an
// hour while the throttle prevents re-checking.
if (!result.error) {
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
if (result.latestRelease) {
localStorageAdapter.writeString(STORAGE_KEY_UPDATE_LATEST_RELEASE, JSON.stringify(result.latestRelease));
}
}
// Check if this version was dismissed
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
@@ -156,11 +357,135 @@ export function useUpdateCheck(): UseUpdateCheckResult {
}
}, []);
const checkNow = useCallback(async () => {
// In demo mode, use fake version to allow checking
const version = IS_UPDATE_DEMO_MODE ? '0.0.1' : updateState.currentVersion;
return performCheck(version);
}, [performCheck, updateState.currentVersion]);
const checkNow = useCallback(async (): Promise<UpdateCheckResult | null> => {
// Prevent concurrent checks (performCheck owns isCheckingRef)
if (isCheckingRef.current) {
debugLog('checkNow: already checking, skipping');
return null;
}
// Cancel any pending startup auto-check to avoid racing with
// electron-updater's startAutoCheck — concurrent checkForUpdates()
// calls are rejected by electron-updater and would surface a false error.
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// Clear any pending "up-to-date" auto-reset timer
if (manualCheckResetTimeoutRef.current) {
clearTimeout(manualCheckResetTimeoutRef.current);
manualCheckResetTimeoutRef.current = null;
}
// Reset dismissed flag so a manual retry can surface download events again
dismissedAutoDownloadRef.current = false;
// Immediately reflect 'checking' in the UI; reset download error so the user can retry
setUpdateState((prev) => {
// Eagerly sync the ref so the checkForUpdate gate below reads the updated value
if (prev.autoDownloadStatus === 'error') {
autoDownloadStatusRef.current = 'idle';
}
return {
...prev,
manualCheckStatus: 'checking',
error: null,
// P2: reset download error state so auto-download can retry on next available update
autoDownloadStatus: prev.autoDownloadStatus === 'error' ? 'idle' : prev.autoDownloadStatus,
downloadError: prev.autoDownloadStatus === 'error' ? null : prev.downloadError,
};
});
// Skip check for dev/invalid builds (demo mode overrides to '0.0.1' inside performCheck)
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersionRef.current;
if (!effectiveVersion || effectiveVersion === '0.0.0') {
// Dev/invalid build — can't determine update status, reset to idle
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'idle',
}));
return null;
}
// Delegate to performCheck (GitHub API) — completely independent of
// electron-updater's startAutoCheck() in the main process.
// performCheck sets isCheckingRef, isChecking, hasUpdate, latestRelease.
const result = await performCheck(effectiveVersion);
// Determine manual check status. performCheck already suppressed dismissed
// versions in state (hasUpdate=false), so we must respect that here too —
// otherwise a dismissed release would be reported as 'available' and could
// trigger a background download via checkForUpdate below.
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isAvailable = result !== null && !result.error && result.hasUpdate &&
result.latestRelease?.version !== dismissedVersion;
const nextStatus: ManualCheckStatus =
result === null || result.error ? 'error' : isAvailable ? 'available' : 'up-to-date';
setUpdateState((prev) => ({
...prev,
manualCheckStatus: nextStatus,
}));
if (nextStatus === 'up-to-date') {
// Auto-reset "up-to-date" badge back to idle after 5s
manualCheckResetTimeoutRef.current = setTimeout(() => {
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
}, 5000);
} else if ((nextStatus === 'available' || nextStatus === 'error') && autoDownloadStatusRef.current === 'idle') {
// Trigger electron-updater as a fallback. This covers two cases:
// 1. 'available': GitHub found an update but electron-updater hasn't
// started a download yet — kick it off.
// 2. 'error': GitHub API failed (blocked/rate-limited), but the
// electron-updater feed may still be reachable. Without this,
// environments where api.github.com is blocked would never attempt
// the auto-download path.
void netcattyBridge.get()?.checkForUpdate?.().then((res) => {
if (res?.error && res?.supported !== false) {
// Surface actual download-feed errors; unsupported platforms
// (res.supported === false) should keep autoDownloadStatus at
// 'idle' so the manual download link shows.
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: res.error,
}));
} else if (res?.checking) {
// Another check is already in flight — don't change status; the
// in-flight check will resolve via IPC events.
} else if (nextStatus === 'error' && res?.available) {
// GitHub API failed but electron-updater found an update.
// Respect dismissed versions before surfacing.
const dismissed = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
if (res.version && res.version === dismissed) {
// User dismissed this version — don't re-surface
} else {
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'available',
hasUpdate: true,
error: null,
}));
}
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
// GitHub API failed but electron-updater says no update available.
// Clear the error status so Settings doesn't stay stuck in error state.
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'up-to-date',
}));
manualCheckResetTimeoutRef.current = setTimeout(() => {
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
}, 5000);
}
}).catch(() => {
// Bridge unavailable — ignore; the manual download link remains visible
});
}
return result;
}, [performCheck]);
const dismissUpdate = useCallback(() => {
if (updateState.latestRelease?.version) {
@@ -189,6 +514,10 @@ export function useUpdateCheck(): UseUpdateCheckResult {
window.open(url, '_blank', 'noopener,noreferrer');
}, [updateState.latestRelease]);
const installUpdate = useCallback(() => {
netcattyBridge.get()?.installUpdate?.();
}, []);
// Startup check with delay - runs once on mount
useEffect(() => {
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
@@ -219,12 +548,12 @@ export function useUpdateCheck(): UseUpdateCheckResult {
if (IS_UPDATE_DEMO_MODE) {
return;
}
debugLog('Version check effect', {
hasChecked: hasCheckedOnStartupRef.current,
debugLog('Version check effect', {
hasChecked: hasCheckedOnStartupRef.current,
currentVersion: updateState.currentVersion
});
if (hasCheckedOnStartupRef.current) {
return;
}
@@ -233,8 +562,38 @@ export function useUpdateCheck(): UseUpdateCheckResult {
return;
}
// Check if we've checked recently
// Hydrate cached release info so update status is visible across windows.
// When auto-update is disabled, hydrate release data (for the Settings UI)
// but don't set hasUpdate (which would trigger the toast in App.tsx).
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
if (lastCheck) {
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
if (cachedRelease) {
try {
const release = JSON.parse(cachedRelease) as ReleaseInfo;
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isNewer = updateState.currentVersion.localeCompare(release.version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
const showUpdate = isNewer && release.version !== dismissedVersion;
setUpdateState((prev) => ({
...prev,
latestRelease: prev.latestRelease ?? release,
hasUpdate: prev.hasUpdate || showUpdate,
lastCheckedAt: lastCheck,
}));
} catch {
// Ignore corrupted cache
}
}
}
// Respect auto-update toggle — skip automatic check when disabled.
// Don't set hasCheckedOnStartupRef so re-enabling (which changes the
// autoUpdateEnabled dependency) can re-trigger this effect.
if (!autoUpdateEnabled) {
return;
}
// Check if we've checked recently
const now = Date.now();
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
hasCheckedOnStartupRef.current = true;
@@ -244,7 +603,43 @@ export function useUpdateCheck(): UseUpdateCheckResult {
hasCheckedOnStartupRef.current = true;
debugLog('Starting delayed update check for version:', updateState.currentVersion);
startupCheckTimeoutRef.current = setTimeout(() => {
startupCheckTimeoutRef.current = setTimeout(async () => {
// Re-check the toggle at fire time — the user may have toggled it
// after the timer was scheduled.
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
if (stillEnabled === 'false') {
debugLog('Skipping startup check — auto-update disabled after timer was scheduled');
return;
}
// If electron-updater's auto-check already started a download, skip the
// redundant GitHub API check to avoid duplicate toast notifications.
if (autoDownloadStatusRef.current !== 'idle') {
debugLog('Skipping startup check — auto-download already active');
return;
}
// If the main process check is still in flight, reschedule the
// fallback instead of permanently skipping it — the auto-check may
// fail silently (check-phase errors aren't broadcast to the renderer).
try {
const snapshot = await netcattyBridge.get()?.getUpdateStatus?.();
if (snapshot?.isChecking) {
debugLog('Main process check still in flight — rescheduling fallback');
startupCheckTimeoutRef.current = setTimeout(async () => {
if (autoDownloadStatusRef.current !== 'idle') return;
// Re-check if the main process check is still running to avoid
// duplicate notifications on very slow networks.
try {
const snap = await netcattyBridge.get()?.getUpdateStatus?.();
if (snap?.isChecking || (snap?.status && snap.status !== 'idle')) return;
} catch { /* fall through */ }
debugLog('=== Rescheduled fallback check triggered ===');
void performCheck(updateState.currentVersion);
}, 5000);
return;
}
} catch {
// Bridge unavailable — fall through to GitHub check
}
debugLog('=== Delayed check triggered ===');
void performCheck(updateState.currentVersion);
}, STARTUP_CHECK_DELAY_MS);
@@ -254,12 +649,13 @@ export function useUpdateCheck(): UseUpdateCheckResult {
clearTimeout(startupCheckTimeoutRef.current);
}
};
}, [updateState.currentVersion, performCheck]);
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
return {
updateState,
checkNow,
dismissUpdate,
openReleasePage,
installUpdate,
};
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import {
ConnectionLog,
@@ -29,6 +29,14 @@ import {
STORAGE_KEY_SNIPPETS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
decryptHosts,
decryptIdentities,
decryptKeys,
encryptHosts,
encryptIdentities,
encryptKeys,
} from "../../infrastructure/persistence/secureFieldAdapter";
type ExportableVaultData = {
hosts: Host[];
@@ -36,6 +44,7 @@ type ExportableVaultData = {
identities?: Identity[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
knownHosts?: KnownHost[];
};
@@ -99,20 +108,47 @@ export const useVaultState = () => {
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
// Write-version counters prevent out-of-order async writes from overwriting
// newer data. Each update bumps the counter; the .then() callback only
// persists if its version still matches the latest.
const hostsWriteVersion = useRef(0);
const keysWriteVersion = useRef(0);
const identitiesWriteVersion = useRef(0);
// Read-sequence counters for cross-window storage events. Each incoming
// event bumps the counter; the async decrypt callback only applies state if
// its sequence still matches, preventing stale decrypts from overwriting
// newer data when multiple events arrive in quick succession.
const hostsReadSeq = useRef(0);
const keysReadSeq = useRef(0);
const identitiesReadSeq = useRef(0);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
setHosts(cleaned);
localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned);
const ver = ++hostsWriteVersion.current;
encryptHosts(cleaned).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
}, []);
const updateKeys = useCallback((data: SSHKey[]) => {
setKeys(data);
localStorageAdapter.write(STORAGE_KEY_KEYS, data);
const ver = ++keysWriteVersion.current;
encryptKeys(data).then((enc) => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}, []);
const updateIdentities = useCallback((data: Identity[]) => {
setIdentities(data);
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, data);
const ver = ++identitiesWriteVersion.current;
encryptIdentities(data).then((enc) => {
if (ver === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
@@ -271,7 +307,11 @@ export const useVaultState = () => {
// Add to hosts using functional update
setHosts((prevHosts) => {
const updated = [...prevHosts, sanitizeHost(newHost)];
localStorageAdapter.write(STORAGE_KEY_HOSTS, updated);
const ver = ++hostsWriteVersion.current;
encryptHosts(updated).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return updated;
});
@@ -279,82 +319,120 @@ export const useVaultState = () => {
}, []);
useEffect(() => {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
const init = async () => {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
if (savedHosts) {
const sanitized = savedHosts.map(sanitizeHost);
setHosts(sanitized);
localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized);
} else {
updateHosts(INITIAL_HOSTS);
}
if (savedHosts) {
// Capture version before the async gap so that any write occurring
// during decryption (storage event, user edit) advances the counter
// and causes this stale result to be discarded.
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (ver === hostsWriteVersion.current) {
const sanitized = decrypted.map(sanitizeHost);
setHosts(sanitized);
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
}
} else {
updateHosts(INITIAL_HOSTS);
}
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
// Read keys fresh here (not before the hosts await) so we don't apply
// a stale snapshot if keys were updated during host decryption.
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
// Decrypt sensitive fields (passphrase, privateKey)
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (keyVer === keysWriteVersion.current) {
setKeys(decryptedKeys);
encryptKeys(decryptedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
setKeys(migratedKeys);
// Persist migrated keys
localStorageAdapter.write(STORAGE_KEY_KEYS, migratedKeys);
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
// Read identities fresh here (not before the hosts/keys awaits) so we
// don't apply a stale snapshot if identities were updated during prior decryption.
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
if (savedIdentities) {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (idVer === identitiesWriteVersion.current) {
setIdentities(decryptedIds);
encryptIdentities(decryptedIds).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}
}
}
if (savedIdentities) setIdentities(savedIdentities);
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
};
init();
}, [updateHosts, updateSnippets]);
useEffect(() => {
@@ -367,7 +445,17 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_HOSTS) {
const next = safeParse<Host[]>(event.newValue) ?? [];
setHosts(next.map(sanitizeHost));
// Bump write version to invalidate any in-flight encrypt from this
// window — the cross-window data is newer and must not be overwritten.
++hostsWriteVersion.current;
const seq = ++hostsReadSeq.current;
const writeAtStart = hostsWriteVersion.current;
decryptHosts(next).then((dec) => {
// Discard if a newer storage event arrived OR a local write occurred
// during the decrypt (writeVersion would have advanced).
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
setHosts(dec.map(sanitizeHost));
});
return;
}
@@ -380,13 +468,25 @@ export const useVaultState = () => {
if (!record || isLegacyUnsupportedKey(record)) continue;
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
setKeys(migratedKeys);
++keysWriteVersion.current;
const seq = ++keysReadSeq.current;
const writeAtStart = keysWriteVersion.current;
decryptKeys(migratedKeys).then((dec) => {
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
setKeys(dec);
});
return;
}
if (key === STORAGE_KEY_IDENTITIES) {
const next = safeParse<Identity[]>(event.newValue) ?? [];
setIdentities(next);
++identitiesWriteVersion.current;
const seq = ++identitiesReadSeq.current;
const writeAtStart = identitiesWriteVersion.current;
decryptIdentities(next).then((dec) => {
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
setIdentities(dec);
});
return;
}
@@ -442,7 +542,11 @@ export const useVaultState = () => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, distro: normalized } : h,
);
localStorageAdapter.write(STORAGE_KEY_HOSTS, next);
const ver = ++hostsWriteVersion.current;
encryptHosts(next).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return next;
});
}, []);
@@ -454,9 +558,10 @@ export const useVaultState = () => {
identities,
snippets,
customGroups,
snippetPackages,
knownHosts,
}),
[hosts, keys, identities, snippets, customGroups, knownHosts],
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
);
const importData = useCallback(
@@ -466,6 +571,7 @@ export const useVaultState = () => {
if (payload.identities) updateIdentities(payload.identities);
if (payload.snippets) updateSnippets(payload.snippets);
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
},
[
@@ -474,6 +580,7 @@ export const useVaultState = () => {
updateIdentities,
updateSnippets,
updateCustomGroups,
updateSnippetPackages,
updateKnownHosts,
],
);

BIN
build/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
build/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

BIN
build/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
build/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
build/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
build/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
build/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,778 @@
/**
* AIChatSidePanel - Main AI chat interface side panel
*
* Zed-style agent panel with agent selector, scoped chat sessions,
* message list, input area, and session history drawer.
*
* Core logic is decomposed into focused hooks:
* - useAIChatStreaming: stream processing, abort management, agent sub-flows
* - useToolApproval: tool approval workflow, timeouts, resume logic
* - useConversationExport: export formats & object URL lifecycle
*/
import {
History,
Plus,
Trash2,
X,
} from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '../lib/utils';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import { useImageUpload } from '../application/state/useImageUpload';
import type {
AIPermissionMode,
AISession,
AISessionScope,
ChatMessage,
DiscoveredAgent,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../infrastructure/ai/types';
import { getAgentModelPresets } from '../infrastructure/ai/types';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
import { useToolApproval } from './ai/hooks/useToolApproval';
import { useConversationExport } from './ai/hooks/useConversationExport';
// -------------------------------------------------------------------
// Props
// -------------------------------------------------------------------
interface AIChatSidePanelProps {
// Session state (per-scope)
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
setActiveSessionId: (scopeKey: string, id: string | null) => void;
createSession: (scope: AISessionScope, agentId?: string) => AISession;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (
sessionId: string,
updater: (msg: ChatMessage) => ChatMessage,
) => void;
updateMessageById: (
sessionId: string,
messageId: string,
updater: (msg: ChatMessage) => ChatMessage,
) => void;
// Provider config
providers: ProviderConfig[];
activeProviderId: string;
activeModelId: string;
// Agent info
defaultAgentId: string;
externalAgents: ExternalAgentConfig[];
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
agentModelMap: Record<string, string>;
setAgentModel: (agentId: string, modelId: string) => void;
// Safety
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
commandBlocklist?: string[];
maxIterations?: number;
// Web search
webSearchConfig?: WebSearchConfig | null;
// Context
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeHostIds?: string[];
scopeLabel?: string;
// Terminal session context (from parent)
terminalSessions?: Array<{
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
connected: boolean;
}>;
// Visibility
isVisible?: boolean;
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
sessions,
activeSessionIdMap,
setActiveSessionId: setActiveSessionIdForScope,
createSession,
deleteSession,
updateSessionTitle,
addMessageToSession,
updateLastMessage,
updateMessageById,
providers,
activeProviderId,
activeModelId,
defaultAgentId,
externalAgents,
setExternalAgents,
agentModelMap,
setAgentModel,
globalPermissionMode,
setGlobalPermissionMode,
commandBlocklist,
maxIterations = 20,
webSearchConfig,
scopeType,
scopeTargetId,
scopeHostIds,
scopeLabel,
terminalSessions = [],
isVisible = true,
}) => {
const { t } = useI18n();
// ── Per-scope state ──
// Derive scope key for per-scope isolation
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
// Per-scope input values
const [inputValueMap, setInputValueMap] = useState<Record<string, string>>({});
const inputValue = inputValueMap[scopeKey] ?? '';
const setInputValue = useCallback((val: string) => {
setInputValueMap(prev => ({ ...prev, [scopeKey]: val }));
}, [scopeKey]);
const [showHistory, setShowHistory] = useState(false);
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
const { images, addImages, removeImage, clearImages } = useImageUpload();
const { openSettingsWindow } = useWindowControls();
// ── Streaming hook ──
const {
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
processCattyStream,
sendToCattyAgent,
sendToExternalAgent,
reportStreamError,
} = useAIChatStreaming({
maxIterations,
addMessageToSession,
updateLastMessage,
updateMessageById,
});
// ── Tool approval hook ──
const {
pendingApprovalContextRef,
setPendingApproval,
handleApprovalResponse,
} = useToolApproval({
addMessageToSession,
updateLastMessage,
updateMessageById,
setStreamingForScope,
abortControllersRef,
processCattyStream,
t,
});
// Per-scope active session ID
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const setActiveSessionId = useCallback((id: string | null) => {
setActiveSessionIdForScope(scopeKey, id);
}, [scopeKey, setActiveSessionIdForScope]);
// Restore agent selector from active session when scope changes
useEffect(() => {
if (activeSessionId) {
const session = sessions.find((s) => s.id === activeSessionId);
if (session) {
setCurrentAgentId(session.agentId);
}
}
}, [scopeKey, activeSessionId, sessions]);
// Proactively sync terminal session metadata to main process whenever scope or sessions change
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiMcpUpdateSessions && terminalSessions.length > 0) {
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
}
}, [terminalSessions, scopeKey, activeSessionId]);
// Sync provider configs to main process so it can decrypt API keys server-side.
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiSyncProviders && providers.length > 0) {
void bridge.aiSyncProviders(providers);
}
}, [providers]);
// Sync web search config to main process (allowlist + encrypted API key for server-side decryption).
// Note: This is fire-and-forget; if the first search fires before sync completes, it will fail
// with a clear error and succeed on retry. Making this blocking would require async tool creation.
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiSyncWebSearch) {
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
}
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
// Abort all active streams and clean up on unmount
useEffect(() => {
const controllers = abortControllersRef.current;
return () => {
controllers.forEach(c => c.abort());
controllers.clear();
// Clear pending approval (clears timeout too via setPendingApproval)
setPendingApproval(null);
};
}, [abortControllersRef, setPendingApproval]);
// Agent discovery
const {
discoveredAgents,
isDiscovering,
rediscover,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
const handleEnableDiscoveredAgent = useCallback(
(agent: DiscoveredAgent) => {
const config = enableAgent(agent);
setExternalAgents?.((prev) => [...prev, config]);
},
[enableAgent, setExternalAgents],
);
// Active session (scoped)
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
const messages = activeSession?.messages ?? [];
// ── Export hook ──
const { handleExport } = useConversationExport(activeSession);
// Active provider info
const activeProvider = useMemo(
() => providers.find((p) => p.id === activeProviderId),
[providers, activeProviderId],
);
const providerDisplayName = activeProvider?.name ?? '';
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
// Agent model presets for the current external agent
const currentAgentConfig = useMemo(
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
[currentAgentId, externalAgents],
);
const agentModelPresets = useMemo(
() => getAgentModelPresets(currentAgentConfig?.command),
[currentAgentConfig?.command],
);
// Per-agent model: recall last selection or use first preset as default
const selectedAgentModel = useMemo(() => {
const stored = agentModelMap[currentAgentId];
if (stored && agentModelPresets.some(p => stored === p.id || stored.startsWith(p.id + '/'))) {
return stored;
}
// Default to first preset; for models with thinking levels, use the default level
if (agentModelPresets.length > 0) {
const first = agentModelPresets[0];
if (first.thinkingLevels?.length) {
return `${first.id}/${first.thinkingLevels[first.thinkingLevels.length - 1]}`;
}
return first.id;
}
return undefined;
}, [currentAgentId, agentModelMap, agentModelPresets]);
const handleAgentModelSelect = useCallback((modelId: string) => {
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
// Filtered sessions for history (matching current scope type)
const historySessions = useMemo(
() =>
sessions
.filter((s) => s.scope.type === scopeType && s.scope.targetId === scopeTargetId)
.sort((a, b) => b.updatedAt - a.updatedAt),
[sessions, scopeType, scopeTargetId],
);
// -------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------
const handleNewChat = useCallback(() => {
const scope: AISessionScope = {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
};
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
setShowHistory(false);
setInputValue('');
}, [
scopeType,
scopeTargetId,
scopeHostIds,
currentAgentId,
createSession,
setActiveSessionId,
setInputValue,
]);
const handleOpenSettings = useCallback(() => {
void openSettingsWindow();
}, [openSettingsWindow]);
// -------------------------------------------------------------------
// Shared helpers for handleSend sub-flows
// -------------------------------------------------------------------
/** Ref to always access latest sessions (avoids stale closure in autoTitleSession). */
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
const inputValueRef = useRef(inputValue);
inputValueRef.current = inputValue;
const imagesRef = useRef(images);
imagesRef.current = images;
/** Auto-title a session from the first user message if untitled. */
const autoTitleSession = useCallback((sessionId: string, text: string) => {
const s = sessionsRef.current.find(x => x.id === sessionId);
if (s && (!s.title || s.title === 'New Chat')) {
updateSessionTitle(sessionId, text.length > 50 ? text.slice(0, 50) + '...' : text);
}
}, [updateSessionTitle]);
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSessionId) return activeSessionId;
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
return session.id;
}, [activeSessionId, scopeType, scopeTargetId, scopeHostIds, currentAgentId, createSession, setActiveSessionId]);
// -------------------------------------------------------------------
// Main send handler (thin orchestrator)
// -------------------------------------------------------------------
const handleSend = useCallback(async () => {
const trimmed = inputValueRef.current.trim();
const sendScopeKey = scopeKey;
if (!trimmed || isStreaming) return;
const isExternalAgent = currentAgentId !== 'catty';
// No provider configured for built-in agent
if (!isExternalAgent && !activeProvider) {
const errSessionId = ensureSession();
addMessageToSession(errSessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
addMessageToSession(errSessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
setInputValue('');
return;
}
// Ensure session exists
const sessionId = ensureSession();
// Capture images before clearing
const attachedImages = imagesRef.current.map(img => ({ base64Data: img.base64Data, mediaType: img.mediaType, filename: img.filename }));
// Add user message
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachedImages.length > 0 ? { images: attachedImages } : {}),
timestamp: Date.now(),
});
setInputValue('');
clearImages();
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
const agentConfig = isExternalAgent ? externalAgents.find(a => a.id === currentAgentId) : undefined;
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
});
const abortController = new AbortController();
abortControllersRef.current.set(sessionId, abortController);
const currentSession = sessionsRef.current.find(s => s.id === sessionId);
if (isExternalAgent) {
if (!agentConfig) {
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
setStreamingForScope(sessionId, false);
return;
}
try {
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
terminalSessions,
providers,
selectedAgentModel,
});
} catch (err) {
reportStreamError(sessionId, abortController.signal, err);
}
// Clear any lingering statusText when the external agent stream finishes
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, trimmed);
} else {
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider,
activeModelId,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
terminalSessions,
webSearchConfig,
setPendingApproval,
autoTitleSession,
});
}
}, [
isStreaming, activeProvider, scopeKey, currentAgentId,
activeModelId, externalAgents,
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, setInputValue, clearImages,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, providers, selectedAgentModel,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, setPendingApproval,
]);
const handleStop = useCallback(() => {
if (!activeSessionId) return;
const controller = abortControllersRef.current.get(activeSessionId);
controller?.abort();
abortControllersRef.current.delete(activeSessionId);
setStreamingForScope(activeSessionId, false);
// Clear statusText on the last message so stale status indicators disappear
updateLastMessage(activeSessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'completed' : msg.executionStatus,
}));
// Also clear any pending approval (clears timeout too via setPendingApproval)
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
setPendingApproval(null);
}
}, [activeSessionId, setStreamingForScope, updateLastMessage, setPendingApproval, abortControllersRef, pendingApprovalContextRef]);
const handleSelectSession = useCallback(
(sessionId: string) => {
setActiveSessionId(sessionId);
// Restore agent selector to match the session's bound agent
const session = sessions.find((s) => s.id === sessionId);
if (session) {
setCurrentAgentId(session.agentId);
}
setShowHistory(false);
},
[setActiveSessionId, sessions],
);
const handleDeleteSession = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
const bridge = getNetcattyBridge();
void bridge?.aiAcpCleanup?.(sessionId).catch(() => {});
deleteSession(sessionId, scopeKey);
// Active session clearing is handled by deleteSession with scopeKey
},
[deleteSession, scopeKey],
);
const handleAgentChange = useCallback((agentId: string) => {
setCurrentAgentId(agentId);
// Preserve the current session in history and start a new one with the selected agent
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, agentId);
setActiveSessionId(session.id);
}, [scopeType, scopeTargetId, scopeHostIds, createSession, setActiveSessionId]);
// -------------------------------------------------------------------
// Render
// -------------------------------------------------------------------
if (!isVisible) return null;
return (
<div className="flex flex-col h-full bg-background">
{/* ── Header ── */}
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
<AgentSelector
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
onSelectAgent={handleAgentChange}
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
onRediscover={rediscover}
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
<ConversationExport
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>
</div>
</div>
{/* ── Main content ── */}
{showHistory ? (
<SessionHistoryDrawer
sessions={historySessions}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onClose={() => setShowHistory(false)}
/>
) : (
<>
{/* Chat messages */}
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
terminalSessions,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
terminalSessions,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
/>
{/* Recent sessions (Zed-style, shown when no messages) */}
{messages.length === 0 && historySessions.length > 0 && (
<div className="shrink-0 px-4 pb-1">
<div className="flex items-baseline justify-between mb-2">
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
<button
onClick={() => setShowHistory(true)}
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
>
{t('ai.chat.viewAll')}
</button>
</div>
{historySessions.slice(0, 3).map((session) => (
<button
key={session.id}
onClick={() => handleSelectSession(session.id)}
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
>
<span className="text-[13px] text-foreground/60 truncate pr-4">
{session.title || t('ai.chat.untitled')}
</span>
<span className="text-[11px] text-muted-foreground/25 shrink-0">
{formatRelativeTime(new Date(session.updatedAt), t)}
</span>
</button>
))}
</div>
)}
{/* Input area */}
<ChatInput
value={inputValue}
onChange={setInputValue}
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
providerName={providerDisplayName}
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
modelPresets={agentModelPresets}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
images={images}
onAddImages={addImages}
onRemoveImage={removeImage}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
/>
</>
)}
</div>
);
};
// -------------------------------------------------------------------
// Session History Drawer
// -------------------------------------------------------------------
interface SessionHistoryDrawerProps {
sessions: AISession[];
activeSessionId: string | null;
onSelect: (sessionId: string) => void;
onDelete: (e: React.MouseEvent, sessionId: string) => void;
onClose: () => void;
}
const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
sessions,
activeSessionId,
onSelect,
onDelete,
onClose,
}) => {
const { t } = useI18n();
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
<button
onClick={onClose}
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
<ScrollArea className="flex-1">
<div className="px-3">
{sessions.length === 0 ? (
<div className="py-12 text-center">
<p className="text-[13px] text-muted-foreground/40">
{t('ai.chat.noSessions')}
</p>
</div>
) : (
sessions.map((session) => {
const isActive = session.id === activeSessionId;
const time = new Date(session.updatedAt);
const timeStr = formatRelativeTime(time, t);
return (
<button
key={session.id}
onClick={() => onSelect(session.id)}
className={cn(
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
)}
>
<span className="text-[13px] truncate pr-3 flex-1 min-w-0">
{session.title || t('ai.chat.untitled')}
</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[12px] text-muted-foreground/50">
{timeStr}
</span>
<button
onClick={(e) => onDelete(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</button>
);
})
)}
</div>
</ScrollArea>
</div>
);
};
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
function formatRelativeTime(date: Date, t: (key: string) => string): string {
const now = Date.now();
const diff = now - date.getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (minutes < 1) return t('ai.chat.justNow');
if (minutes < 60) return t('ai.chat.minutesAgo').replace('{n}', String(minutes));
if (hours < 24) return t('ai.chat.hoursAgo').replace('{n}', String(hours));
if (days < 7) return t('ai.chat.daysAgo').replace('{n}', String(days));
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
// -------------------------------------------------------------------
// Export
// -------------------------------------------------------------------
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
AIChatSidePanel.displayName = 'AIChatSidePanel';
export default AIChatSidePanel;
export { AIChatSidePanel };
export type { AIChatSidePanelProps };

View File

@@ -1,309 +0,0 @@
import { ChevronDown, Eye, EyeOff, Key, Lock, User } from "lucide-react";
import React, { useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
interface AuthDialogProps {
host: Host;
keys: SSHKey[];
onSubmit: (auth: {
username: string;
authMethod: "password" | "key";
password?: string;
keyId?: string;
saveCredentials: boolean;
}) => void;
onCancel: () => void;
}
const AuthDialog: React.FC<AuthDialogProps> = ({
host,
keys,
onSubmit,
onCancel,
}) => {
const { t } = useI18n();
const [username, setUsername] = useState(host.username || "root");
const [authMethod, setAuthMethod] = useState<"password" | "key">("password");
const [password, setPassword] = useState("");
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [saveCredentials, setSaveCredentials] = useState(true);
const [isKeySelectOpen, setIsKeySelectOpen] = useState(false);
const _selectedKey = keys.find((k) => k.id === selectedKeyId);
const handleSubmit = () => {
onSubmit({
username,
authMethod,
password: authMethod === "password" ? password : undefined,
keyId: authMethod === "key" ? (selectedKeyId ?? undefined) : undefined,
saveCredentials,
});
};
const isValid =
username.trim() &&
((authMethod === "password" && password.trim()) ||
(authMethod === "key" && selectedKeyId));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-[420px] max-w-[90vw] bg-background border border-border/60 rounded-2xl shadow-2xl animate-in fade-in-0 zoom-in-95 duration-200">
{/* Header */}
<div className="px-6 py-5 border-b border-border/50">
<div className="flex items-center gap-3">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-12 w-12"
/>
<div>
<h2 className="text-base font-semibold">{host.label}</h2>
<p className="text-xs text-muted-foreground font-mono">
SSH {host.hostname}:{host.port || 22}
</p>
</div>
</div>
</div>
{/* Progress indicator */}
<div className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
<User size={14} />
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div
className={cn(
"h-8 w-8 rounded-full flex items-center justify-center transition-colors",
username.trim()
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{authMethod === "password" ? (
<Lock size={14} />
) : (
<Key size={14} />
)}
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div className="h-8 w-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-mono">
{">_"}
</div>
</div>
</div>
{/* Auth method tabs */}
<div className="px-6">
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === "password"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
)}
onClick={() => setAuthMethod("password")}
>
<Lock size={14} />
{t("terminal.auth.password")}
</button>
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === "key"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
)}
onClick={() => setAuthMethod("key")}
>
<Key size={14} />
{t("terminal.auth.sshKey")}
</button>
</div>
</div>
{/* Form */}
<div className="px-6 py-4 space-y-4">
{/* Username field (shown when no username on host) */}
{!host.username && (
<div className="space-y-2">
<Label htmlFor="auth-username">{t("terminal.auth.username")}</Label>
<Input
id="auth-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("terminal.auth.username.placeholder")}
autoFocus
/>
</div>
)}
{/* Password field */}
{authMethod === "password" && (
<div className="space-y-2">
<Label htmlFor="auth-password">
{t("terminal.auth.passwordLabel")}
</Label>
<div className="relative">
<Input
id="auth-password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("terminal.auth.password.placeholder")}
className="pr-10"
autoFocus={!!host.username}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) {
handleSubmit();
}
}}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
)}
{/* Key selection */}
{authMethod === "key" && (
<div className="space-y-2">
<Label>{t("terminal.auth.selectKey")}</Label>
{keys.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
{t("terminal.auth.noKeysHint")}
</div>
) : (
<div className="space-y-2">
{keys
.filter((k) => k.category === "key")
.slice(0, 5)
.map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
selectedKeyId === key.id
? "border-primary bg-primary/5"
: "border-border/50 hover:bg-secondary/50",
)}
onClick={() => setSelectedKeyId(key.id)}
>
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
"bg-primary/20 text-primary",
)}
>
<Key size={14} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{key.label}
</div>
<div className="text-xs text-muted-foreground">
{t("auth.keyType", { type: key.type })}
</div>
</div>
</button>
))}
{keys.filter((k) => k.category === "key").length > 5 && (
<Popover
open={isKeySelectOpen}
onOpenChange={setIsKeySelectOpen}
>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full">
{t("auth.showAllKeys")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<ScrollArea className="h-64">
<div className="p-2 space-y-1">
{keys
.filter((k) => k.category === "key")
.map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-2 px-2 py-2 rounded-md text-left transition-colors",
selectedKeyId === key.id
? "bg-primary/10"
: "hover:bg-secondary",
)}
onClick={() => {
setSelectedKeyId(key.id);
setIsKeySelectOpen(false);
}}
>
<Key size={14} className="text-primary" />
<span className="text-sm truncate">
{key.label}
</span>
<span className="text-xs text-muted-foreground ml-auto">
{key.type}
</span>
</button>
))}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
)}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between">
<Button variant="secondary" onClick={onCancel}>
{t("common.close")}
</Button>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button disabled={!isValid} onClick={handleSubmit}>
{t("terminal.auth.continueSave")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={() => {
setSaveCredentials(false);
handleSubmit();
}}
disabled={!isValid}
>
{t("common.continue")}
</button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
);
};
export default AuthDialog;

View File

@@ -32,7 +32,10 @@ import {
} from 'lucide-react';
import { useCloudSync } from '../application/state/useCloudSync';
import { useI18n } from '../application/i18n/I18nProvider';
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
@@ -482,7 +485,7 @@ const GitHubDeviceFlowModal: React.FC<GitHubDeviceFlowModalProps> = ({
</div>
<Button
onClick={() => window.open(verificationUri, '_blank')}
onClick={() => window.open(verificationUri, "_blank", "noopener,noreferrer")}
className="w-full gap-2 mb-4"
>
<ExternalLink size={14} />
@@ -678,10 +681,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const disconnectOtherProviders = async (current: CloudProvider) => {
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
const isActive = (status: string) => status === 'connected' || status === 'syncing';
for (const provider of providers) {
if (provider === current) continue;
if (isActive(sync.providers[provider].status)) {
if (isProviderReadyForSync(sync.providers[provider])) {
await sync.disconnectProvider(provider);
}
}
@@ -729,6 +731,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const [webdavPassword, setWebdavPassword] = useState('');
const [webdavToken, setWebdavToken] = useState('');
const [showWebdavSecret, setShowWebdavSecret] = useState(false);
const [webdavAllowInsecure, setWebdavAllowInsecure] = useState(false);
const [webdavError, setWebdavError] = useState<string | null>(null);
const [webdavErrorDetail, setWebdavErrorDetail] = useState<string | null>(null);
const [isSavingWebdav, setIsSavingWebdav] = useState(false);
@@ -751,6 +754,17 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Clear local data dialog
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
const ensureSyncablePayload = useCallback(
(payload: SyncPayload): boolean => {
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length === 0) return true;
toast.error(t('sync.credentialsUnavailable'), t('sync.toast.errorTitle'));
return false;
},
[t],
);
// Handle conflict detection
useEffect(() => {
if (sync.currentConflict) {
@@ -840,6 +854,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
setWebdavUsername(config?.username || '');
setWebdavPassword(config?.password || '');
setWebdavToken(config?.token || '');
setWebdavAllowInsecure(config?.allowInsecure || false);
setShowWebdavSecret(false);
setWebdavError(null);
setWebdavErrorDetail(null);
@@ -890,6 +905,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
username: webdavAuthType === 'token' ? undefined : webdavUsername.trim(),
password: webdavAuthType === 'token' ? undefined : webdavPassword,
token: webdavAuthType === 'token' ? webdavToken.trim() : undefined,
allowInsecure: webdavAllowInsecure ? true : undefined,
};
setIsSavingWebdav(true);
@@ -958,6 +974,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const handleSync = async (provider: CloudProvider) => {
try {
const payload = onBuildPayload();
if (!ensureSyncablePayload(payload)) return;
const result = await sync.syncToProvider(provider, payload);
if (result.success) {
@@ -982,6 +999,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
} else if (resolution === 'USE_LOCAL') {
// Re-sync with local data
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) return;
await sync.syncNow(localPayload);
toast.success(t('cloudSync.resolve.uploaded'));
}
@@ -1045,13 +1063,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="github"
name="GitHub Gist"
icon={<Github size={24} />}
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
isConnected={isProviderReadyForSync(sync.providers.github)}
isSyncing={sync.providers.github.status === 'syncing'}
isConnecting={sync.providers.github.status === 'connecting'}
account={sync.providers.github.account}
lastSync={sync.providers.github.lastSync}
error={sync.providers.github.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.github)}
onConnect={handleConnectGitHub}
onDisconnect={() => sync.disconnectProvider('github')}
onSync={() => handleSync('github')}
@@ -1061,13 +1079,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="google"
name="Google Drive"
icon={<GoogleDriveIcon className="w-6 h-6" />}
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
isConnected={isProviderReadyForSync(sync.providers.google)}
isSyncing={sync.providers.google.status === 'syncing'}
isConnecting={sync.providers.google.status === 'connecting'}
account={sync.providers.google.account}
lastSync={sync.providers.google.lastSync}
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
onConnect={handleConnectGoogle}
onDisconnect={() => sync.disconnectProvider('google')}
onSync={() => handleSync('google')}
@@ -1077,13 +1095,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="onedrive"
name="Microsoft OneDrive"
icon={<OneDriveIcon className="w-6 h-6" />}
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
isConnected={isProviderReadyForSync(sync.providers.onedrive)}
isSyncing={sync.providers.onedrive.status === 'syncing'}
isConnecting={sync.providers.onedrive.status === 'connecting'}
account={sync.providers.onedrive.account}
lastSync={sync.providers.onedrive.lastSync}
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
onConnect={handleConnectOneDrive}
onDisconnect={() => sync.disconnectProvider('onedrive')}
onSync={() => handleSync('onedrive')}
@@ -1093,13 +1111,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="webdav"
name={t('cloudSync.provider.webdav')}
icon={<Server size={24} />}
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
isConnected={isProviderReadyForSync(sync.providers.webdav)}
isSyncing={sync.providers.webdav.status === 'syncing'}
isConnecting={sync.providers.webdav.status === 'connecting'}
account={sync.providers.webdav.account}
lastSync={sync.providers.webdav.lastSync}
error={sync.providers.webdav.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.webdav)}
onEdit={openWebdavDialog}
onConnect={openWebdavDialog}
onDisconnect={() => sync.disconnectProvider('webdav')}
@@ -1110,13 +1128,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="s3"
name={t('cloudSync.provider.s3')}
icon={<Database size={24} />}
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
isConnected={isProviderReadyForSync(sync.providers.s3)}
isSyncing={sync.providers.s3.status === 'syncing'}
isConnecting={sync.providers.s3.status === 'connecting'}
account={sync.providers.s3.account}
lastSync={sync.providers.s3.lastSync}
error={sync.providers.s3.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.s3)}
onEdit={openS3Dialog}
onConnect={openS3Dialog}
onDisconnect={() => sync.disconnectProvider('s3')}
@@ -1322,6 +1340,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
{t('cloudSync.webdav.showSecret')}
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground select-none">
<input
type="checkbox"
checked={webdavAllowInsecure}
onChange={(e) => setWebdavAllowInsecure(e.target.checked)}
className="accent-primary"
/>
{t('cloudSync.webdav.allowInsecure')}
</label>
{webdavError && (
<p className="text-sm text-red-500">{webdavError}</p>
)}
@@ -1556,6 +1584,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
return;
}
let payloadForReencrypt: SyncPayload | null = null;
if (sync.hasAnyConnectedProvider) {
const payload = onBuildPayload();
if (!ensureSyncablePayload(payload)) {
setChangeKeyError(t('sync.credentialsUnavailable'));
return;
}
payloadForReencrypt = payload;
}
setIsChangingKey(true);
try {
const ok = await sync.changeMasterKey(currentMasterKey, newMasterKey);
@@ -1564,9 +1602,8 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
return;
}
if (sync.hasAnyConnectedProvider) {
const payload = onBuildPayload();
await sync.syncNow(payload);
if (payloadForReencrypt) {
await sync.syncNow(payloadForReencrypt);
}
toast.success(t('cloudSync.changeKey.updatedToast'));

View File

@@ -17,6 +17,11 @@ export const DISTRO_LOGOS: Record<string, string> = {
redhat: "/distro/redhat.svg",
oracle: "/distro/oracle.svg",
kali: "/distro/kali.svg",
almalinux: "/distro/almalinux.svg",
// OS-level logos (used by local terminal tab icons)
macos: "/distro/macos.svg",
windows: "/distro/windows.svg",
linux: "/distro/linux.svg",
};
export const DISTRO_COLORS: Record<string, string> = {
@@ -32,6 +37,11 @@ export const DISTRO_COLORS: Record<string, string> = {
redhat: "bg-[#EE0000]",
oracle: "bg-[#C74634]",
kali: "bg-[#0F6DB3]",
almalinux: "bg-[#173B66]",
// OS-level colors
macos: "bg-[#333333]",
windows: "bg-[#0078D4]",
linux: "bg-[#333333]",
default: "bg-slate-600",
};

View File

@@ -1,107 +0,0 @@
import { ChevronRight,Folder,FolderOpen,FolderPlus,Plus } from 'lucide-react';
import React,{ useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { GroupNode } from '../types';
import { Collapsible,CollapsibleContent,CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuTrigger } from './ui/context-menu';
interface GroupTreeItemProps {
node: GroupNode;
depth: number;
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onSelectGroup: (path: string) => void;
selectedGroup: string | null;
onEditGroup: (path: string) => void;
onNewHost: (path: string) => void;
onNewSubfolder: (path: string) => void;
isManagedGroup?: (path: string) => boolean;
}
export const GroupTreeItem: React.FC<GroupTreeItemProps> = ({
node,
depth,
expandedPaths,
onToggle,
onSelectGroup,
selectedGroup,
onEditGroup,
onNewHost,
onNewSubfolder,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 12 + 12}px`;
const isSelected = selectedGroup === node.path;
const childNodes = useMemo(() => {
return node.children
? (Object.values(node.children) as unknown as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name))
: [];
}, [node.children]);
return (
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
className={cn(
"flex items-center py-1.5 pr-2 text-sm font-medium cursor-pointer transition-colors select-none group relative rounded-r-md",
isSelected ? "bg-primary/10 text-primary border-l-2 border-primary" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
style={{ paddingLeft }}
onClick={() => onSelectGroup(node.path)}
>
<div className="mr-1.5 flex-shrink-0 w-4 h-4 flex items-center justify-center">
{hasChildren && (
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
<ChevronRight size={12} />
</div>
)}
</div>
<div className="mr-2 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={16} /> : <Folder size={16} />}
</div>
<span className="truncate flex-1">{node.name}</span>
{node.hosts.length > 0 && (
<span className="text-[10px] opacity-70 bg-background/50 px-1.5 rounded-full border border-border">
{node.hosts.length}
</span>
)}
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost(node.path)}>
<Plus className="mr-2 h-4 w-4" /> {t("action.newHost")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onNewSubfolder(node.path)}>
<FolderPlus className="mr-2 h-4 w-4" /> {t("action.newSubfolder")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{hasChildren && (
<CollapsibleContent>
{childNodes.map((child) => (
<GroupTreeItem
key={child.path}
node={child}
depth={depth + 1}
expandedPaths={expandedPaths}
onToggle={onToggle}
onSelectGroup={onSelectGroup}
selectedGroup={selectedGroup}
onEditGroup={onEditGroup}
onNewHost={onNewHost}
onNewSubfolder={onNewSubfolder}
/>
))}
</CollapsibleContent>
)}
</Collapsible>
);
};

View File

@@ -16,6 +16,7 @@ import {
Plus,
Settings2,
Shield,
ShieldAlert,
Tag,
TerminalSquare,
User,
@@ -26,7 +27,8 @@ import {
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { useSettingsState } from "../application/state/useSettingsState";
import { customThemeStore } from "../application/state/customThemeStore";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
@@ -38,6 +40,7 @@ import {
AsidePanelFooter,
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import { Card } from "./ui/card";
@@ -97,6 +100,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
const { terminalThemeId, terminalFontSize } = useSettingsState();
const [form, setForm] = useState<Host>(
() =>
initialData ||
@@ -111,7 +115,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
os: "linux",
authMethod: "password",
charset: "UTF-8",
theme: "Flexoki Dark",
theme: terminalThemeId,
fontSize: terminalFontSize,
createdAt: Date.now(),
group: defaultGroup || undefined, // Pre-fill with current navigation group
} as Host),
@@ -154,13 +159,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Group input state for inline creation suggestion
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
// Check if the entered group is new (doesn't exist)
// Reserved for future use: showing inline "create new group" suggestion
const _isNewGroup = useMemo(() => {
const trimmed = groupInputValue.trim();
return trimmed.length > 0 && !groups.includes(trimmed);
}, [groupInputValue, groups]);
useEffect(() => {
if (initialData) {
// Ensure telnetEnabled is set when protocol is telnet
@@ -1115,21 +1113,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
style={{
backgroundColor:
TERMINAL_THEMES.find(
(t) => t.id === (form.theme || "flexoki-dark"),
)?.colors.background || "#100F0F",
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.background || "#100F0F",
color:
TERMINAL_THEMES.find(
(t) => t.id === (form.theme || "flexoki-dark"),
)?.colors.foreground || "#CECDC3",
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: TERMINAL_THEMES.find(
(t) => t.id === (form.theme || "flexoki-dark"),
)?.colors.green,
color: customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.green,
}}
>
$
@@ -1137,9 +1129,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</div>
<span className="text-sm flex-1">
{TERMINAL_THEMES.find(
(t) => t.id === (form.theme || "flexoki-dark"),
)?.name || "Flexoki Dark"}
{customThemeStore.getThemeById(form.theme || "flexoki-dark")?.name || "Flexoki Dark"}
</span>
</button>
@@ -1230,6 +1220,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
</Card>
{/* Legacy Algorithms */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<ShieldAlert size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.legacyAlgorithms")}</p>
</div>
<ToggleRow
label={t("hostDetails.legacyAlgorithms")}
enabled={!!form.legacyAlgorithms}
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.legacyAlgorithms.desc")}
</p>
{form.legacyAlgorithms && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.legacyAlgorithms.warning")}
</p>
</div>
)}
</Card>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
@@ -1306,36 +1320,50 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
{/* Proxy Configuration */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
</div>
{form.proxyConfig?.host ? (
<Badge variant="secondary" className="text-xs">
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:
{form.proxyConfig.port}
</Badge>
) : (
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
{t("hostDetails.proxy.none")}
</Badge>
)}
<Card className="p-3 space-y-2 bg-card border-border/80 overflow-hidden">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
</div>
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("proxy")}
>
<Plus size={14} />
{form.proxyConfig?.host
? t("hostDetails.proxy.edit")
: t("hostDetails.proxy.configure")}
</Button>
{form.proxyConfig?.host ? (
<button
className="w-full min-w-0 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
onClick={() => setActiveSubPanel("proxy")}
>
<Badge variant="secondary" className="text-xs shrink-0">
{form.proxyConfig.type?.toUpperCase()}
</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{form.proxyConfig.host}:{form.proxyConfig.port}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
clearProxyConfig();
}}
/>
</button>
) : (
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("proxy")}
>
<Plus size={14} />
{t("hostDetails.proxy.configure")}
</Button>
)}
</Card>
{/* Environment Variables */}
@@ -1466,35 +1494,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
style={{
backgroundColor:
TERMINAL_THEMES.find(
(t) =>
t.id ===
(form.protocols?.find((p) => p.protocol === "telnet")
?.theme ||
form.theme ||
"flexoki-dark"),
customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.background || "#100F0F",
color:
TERMINAL_THEMES.find(
(t) =>
t.id ===
(form.protocols?.find((p) => p.protocol === "telnet")
?.theme ||
form.theme ||
"flexoki-dark"),
customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: TERMINAL_THEMES.find(
(t) =>
t.id ===
(form.protocols?.find((p) => p.protocol === "telnet")
?.theme ||
form.theme ||
"flexoki-dark"),
color: customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.green,
}}
>
@@ -1503,13 +1516,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</div>
<span className="text-sm flex-1">
{TERMINAL_THEMES.find(
(t) =>
t.id ===
(form.protocols?.find((p) => p.protocol === "telnet")
?.theme ||
form.theme ||
"flexoki-dark"),
{customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.name || "Flexoki Dark"}
</span>
</button>

View File

@@ -1,382 +0,0 @@
import { Key, Lock, Plus, Save, Server, X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
interface HostFormProps {
initialData?: Host | null;
availableKeys: SSHKey[];
groups: string[];
onSave: (host: Host) => void;
onCancel: () => void;
}
const HostForm: React.FC<HostFormProps> = ({
initialData,
availableKeys,
groups,
onSave,
onCancel,
}) => {
const { t } = useI18n();
const [formData, setFormData] = useState<Partial<Host>>(
initialData || {
label: "",
hostname: "",
port: 22,
username: "root",
tags: [],
os: "linux",
group: "General",
identityFileId: "",
},
);
const [authType, setAuthType] = useState<"password" | "key">(
initialData?.identityFileId ? "key" : "password",
);
const [tagInput, setTagInput] = useState("");
const handleAddTag = () => {
const tag = tagInput.trim();
if (tag && !formData.tags?.includes(tag)) {
setFormData((prev) => ({ ...prev, tags: [...(prev.tags || []), tag] }));
setTagInput("");
}
};
const handleRemoveTag = (tagToRemove: string) => {
setFormData((prev) => ({
...prev,
tags: (prev.tags || []).filter((t) => t !== tagToRemove),
}));
};
const handleTagKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTag();
}
};
// Effect to ensure we have a valid auth state if switching back and forth
useEffect(() => {
if (authType === "password") {
setFormData((prev) => ({ ...prev, identityFileId: "" }));
} else if (
authType === "key" &&
!formData.identityFileId &&
availableKeys.length > 0
) {
// Default to first key if none selected
setFormData((prev) => ({ ...prev, identityFileId: availableKeys[0].id }));
}
}, [authType, availableKeys, formData.identityFileId]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (formData.label && formData.hostname && formData.username) {
onSave({
...formData,
id: initialData?.id || crypto.randomUUID(),
tags: formData.tags || [],
port: formData.port || 22,
group: formData.group || "General",
identityFileId:
authType === "key" ? formData.identityFileId : undefined,
createdAt: initialData?.createdAt || Date.now(),
} as Host);
}
};
return (
<Dialog open={true} onOpenChange={() => onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-primary" />
{initialData ? t("hostForm.title.edit") : t("hostForm.title.new")}
</DialogTitle>
<DialogDescription className="sr-only">
{initialData ? t("hostForm.desc.edit") : t("hostForm.desc.new")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="label">{t("hostForm.field.label")}</Label>
<Input
id="label"
placeholder={t("hostForm.placeholder.label")}
value={formData.label}
onChange={(e) =>
setFormData({ ...formData, label: e.target.value })
}
required
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 grid gap-2">
<Label htmlFor="hostname">{t("hostForm.field.hostname")}</Label>
<Input
id="hostname"
placeholder={t("hostForm.placeholder.hostname")}
value={formData.hostname}
onChange={(e) =>
setFormData({ ...formData, hostname: e.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="port">{t("hostForm.field.port")}</Label>
<Input
id="port"
type="number"
value={formData.port}
onChange={(e) =>
setFormData({ ...formData, port: parseInt(e.target.value) })
}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="username">{t("hostForm.field.username")}</Label>
<Input
id="username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="os">{t("hostForm.field.osType")}</Label>
<Select
value={formData.os}
onValueChange={(val: "linux" | "windows" | "macos") =>
setFormData({ ...formData, os: val })
}
>
<SelectTrigger>
<SelectValue placeholder={t("hostForm.placeholder.selectOs")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="linux">Linux</SelectItem>
<SelectItem value="windows">Windows</SelectItem>
<SelectItem value="macos">macOS</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="group">{t("hostForm.field.group")}</Label>
<Input
id="group"
placeholder={t("hostForm.placeholder.group")}
value={formData.group}
onChange={(e) =>
setFormData({ ...formData, group: e.target.value })
}
list="group-suggestions"
autoComplete="off"
/>
<datalist id="group-suggestions">
{groups.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</div>
<div className="grid gap-2">
<Label htmlFor="tags">{t("hostForm.field.tags")}</Label>
<div className="flex gap-2">
<Input
id="tags"
placeholder={t("hostForm.placeholder.addTag")}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
className="flex-1"
/>
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleAddTag}
disabled={!tagInput.trim()}
>
<Plus size={16} />
</Button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{formData.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:bg-primary/20 rounded-full p-0.5"
>
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
<div className="space-y-0.5">
<Label htmlFor="sftp-sudo" className="text-base">
{t("hostDetails.sftp.sudo")}
</Label>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</p>
{formData.sftpSudo && authType === "key" && (
<p className="text-xs text-amber-500 mt-1">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
</div>
<Switch
id="sftp-sudo"
checked={formData.sftpSudo || false}
onCheckedChange={(checked) =>
setFormData({ ...formData, sftpSudo: checked })
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="sftp-encoding">
{t("hostDetails.sftp.encoding")}
</Label>
<Select
value={formData.sftpEncoding || "auto"}
onValueChange={(val) =>
setFormData({ ...formData, sftpEncoding: val as Host["sftpEncoding"] })
}
>
<SelectTrigger id="sftp-encoding">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</p>
</div>
<Label>{t("hostForm.auth.method")}</Label>
<div className="grid grid-cols-2 gap-4">
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === "password"
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
: "text-muted-foreground",
)}
onClick={() => setAuthType("password")}
>
<Lock size={20} />
<span className="text-xs font-medium">{t("hostForm.auth.password")}</span>
</div>
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === "key"
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
: "text-muted-foreground",
)}
onClick={() => setAuthType("key")}
>
<Key size={20} />
<span className="text-xs font-medium">{t("hostForm.auth.sshKey")}</span>
</div>
</div>
{authType === "key" && (
<div className="animate-in fade-in zoom-in-95 duration-200">
<Select
value={formData.identityFileId || ""}
onValueChange={(val) =>
setFormData({ ...formData, identityFileId: val })
}
>
<SelectTrigger>
<SelectValue placeholder={t("hostForm.auth.selectKey")} />
</SelectTrigger>
<SelectContent>
{availableKeys.map((key) => (
<SelectItem key={key.id} value={key.id}>
{key.label} ({key.type})
</SelectItem>
))}
{availableKeys.length === 0 && (
<SelectItem value="none" disabled>
{t("hostForm.auth.noKeys")}
</SelectItem>
)}
</SelectContent>
</Select>
{availableKeys.length === 0 && (
<p className="text-[10px] text-destructive mt-1">
{t("hostForm.auth.noKeysHint")}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit">
<Save className="mr-2 h-4 w-4" /> {t("hostForm.saveHost")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default HostForm;

View File

@@ -1,4 +1,4 @@
import { ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
@@ -32,6 +32,9 @@ interface HostTreeViewProps {
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
}
interface TreeNodeProps {
@@ -53,8 +56,22 @@ interface TreeNodeProps {
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
}
// Helper function to recursively count all hosts in a node and its children
const countAllHostsInNode = (node: GroupNode): number => {
let count = node.hosts.length;
if (node.children) {
Object.values(node.children).forEach((child) => {
count += countAllHostsInNode(child);
});
}
return count;
};
const TreeNode: React.FC<TreeNodeProps> = ({
node,
depth,
@@ -74,12 +91,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 20 + 12}px`;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
const hostsCountInNode = useMemo(() => countAllHostsInNode(node), [node]);
const childNodes = useMemo(() => {
if (!node.children) return [];
@@ -162,7 +183,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
)}
{(node.hosts.length > 0 || hasChildren) && (
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
{node.hosts.length}
{hostsCountInNode}
</span>
)}
</div>
@@ -215,9 +236,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
{/* Hosts in this group */}
{sortedHosts.map((host) => (
<HostTreeItem
@@ -230,6 +254,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
</CollapsibleContent>
@@ -247,6 +274,9 @@ interface HostTreeItemProps {
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
}
const HostTreeItem: React.FC<HostTreeItemProps> = ({
@@ -258,6 +288,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
onDeleteHost,
onCopyCredentials,
moveHostToGroup: _moveHostToGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
}) => {
const { t } = useI18n();
const paddingLeft = `${depth * 20 + 12}px`;
@@ -270,18 +303,40 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
const displayPort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className="flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg"
className={cn(
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
isSelected ? "bg-primary/10" : "",
)}
style={{ paddingLeft }}
draggable
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onClick={() => onConnect(safeHost)}
onClick={() => {
if (isMultiSelectMode && toggleHostSelection) {
toggleHostSelection(host.id);
} else {
onConnect(safeHost);
}
}}
>
<div className="mr-2 flex-shrink-0 w-4 h-4" />
{isMultiSelectMode && (
<div className="mr-2 flex-shrink-0" onClick={(e) => {
e.stopPropagation();
toggleHostSelection?.(host.id);
}}>
{isSelected ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
<div className="mr-3 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
</div>
@@ -351,6 +406,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
}) => {
const { t } = useI18n();
@@ -471,6 +529,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
@@ -486,6 +547,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}

View File

@@ -1,411 +0,0 @@
import {
Key,
LayoutGrid,
List as ListIcon,
Pencil,
Plus,
Search,
Shield,
Trash2,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { KeyType } from "../domain/models";
import { cn } from "../lib/utils";
import { SSHKey } from "../types";
import { Button } from "./ui/button";
import { Card, CardDescription, CardTitle } from "./ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
interface KeyManagerProps {
keys: SSHKey[];
onSave: (key: SSHKey) => void;
onDelete: (id: string) => void;
}
const KeyManager: React.FC<KeyManagerProps> = ({ keys, onSave, onDelete }) => {
const { t } = useI18n();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [panelMode, setPanelMode] = useState<"new" | "edit">("new");
const [draftKey, setDraftKey] = useState<Partial<SSHKey>>({
id: "",
label: "",
type: "RSA",
privateKey: "",
publicKey: "",
created: Date.now(),
});
const [generateMode, setGenerateMode] = useState(false);
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const handleGenerate = () => {
// Simulate Key Generation
const mockKey =
`-----BEGIN ${draftKey.type} PRIVATE KEY-----\n` +
`MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC${Math.random().toString(36).substring(7)}\n` +
`... (simulated generated content) ...\n` +
`-----END ${draftKey.type} PRIVATE KEY-----`;
setDraftKey({ ...draftKey, privateKey: mockKey });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!draftKey.label || !draftKey.privateKey) return;
const payload: SSHKey = {
id: draftKey.id || crypto.randomUUID(),
label: draftKey.label,
type: (draftKey.type as KeyType) || "RSA",
privateKey: draftKey.privateKey,
publicKey: draftKey.publicKey?.trim() || undefined,
created: draftKey.created || Date.now(),
source: draftKey.source || (generateMode ? "generated" : "imported"),
category: draftKey.category || "key",
};
onSave(payload);
setIsDialogOpen(false);
setGenerateMode(false);
};
const openPanelForKey = (key: SSHKey) => {
setPanelMode("edit");
setDraftKey({ ...key });
setIsDialogOpen(true);
setGenerateMode(false);
};
const openPanelNew = (isGenerate = false) => {
setPanelMode("new");
setGenerateMode(isGenerate);
setDraftKey({
id: "",
label: "",
type: "RSA",
privateKey: isGenerate
? "Click generate to create a new key pair..."
: "",
publicKey: "",
created: Date.now(),
});
setIsDialogOpen(true);
};
const handleDelete = (id: string) => {
onDelete(id);
if (draftKey.id === id) {
setIsDialogOpen(false);
setDraftKey({
id: "",
label: "",
type: "RSA",
privateKey: "",
publicKey: "",
created: Date.now(),
});
}
};
const filteredKeys = useMemo(() => {
const term = search.trim().toLowerCase();
return keys.filter((k) => {
if (!term) return true;
return (
k.label.toLowerCase().includes(term) ||
(k.type || "").toString().toLowerCase().includes(term)
);
});
}, [keys, search]);
const derivedPublicKey = useMemo(() => {
if (draftKey.publicKey) return draftKey.publicKey;
if (!draftKey.label) return "Generated By netcatty";
return `ssh-${(draftKey.type || "ed25519").toLowerCase()} AAAAC3NzaC1lZDI1NTE5AAAA${(
draftKey.label || "netcatty"
)
.replace(/\s+/g, "")
.slice(0, 8)} Generated By netcatty`;
}, [draftKey.label, draftKey.type, draftKey.publicKey]);
return (
<div className="px-2.5 py-2.5 lg:px-3 lg:py-3 h-full overflow-y-auto space-y-3.5 relative">
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border border-border/70 rounded-xl px-2 py-1.5 shadow-sm">
<Button
size="sm"
variant="secondary"
className="h-8 px-3 gap-2"
disabled
>
Key
<span className="text-[10px] px-2 rounded-full h-5 min-w-[22px] flex items-center justify-center bg-primary/10 text-primary border border-border/70">
{keys.length}
</span>
</Button>
<div className="ml-auto flex items-center gap-2">
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search keys..."
className="h-9 pl-8 w-44 md:w-56"
/>
</div>
<Button
size="icon"
variant={viewMode === "grid" ? "secondary" : "ghost"}
className="h-9 w-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={16} />
</Button>
<Button
size="icon"
variant={viewMode === "list" ? "secondary" : "ghost"}
className="h-9 w-9"
onClick={() => setViewMode("list")}
>
<ListIcon size={16} />
</Button>
<Button size="sm" onClick={() => openPanelNew(false)}>
<Plus size={14} className="mr-2" /> Import
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => openPanelNew(true)}
>
Generate
</Button>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h2 className="text-base font-semibold text-muted-foreground">
Keys
</h2>
</div>
<div className="space-y-3">
{filteredKeys.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Shield size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
Set up your keys
</h3>
<p className="text-sm text-center max-w-sm">
Import or generate SSH keys for secure authentication.
</p>
</div>
)}
<div
className={
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0"
}
>
{filteredKeys.map((key) => (
<Card
key={key.id}
className={cn(
"group cursor-pointer soft-card elevate rounded-xl",
viewMode === "grid"
? "h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
)}
onClick={() => openPanelForKey(key)}
>
<div className="flex items-center gap-3 h-full">
<div className="h-9 w-9 rounded-md bg-primary/15 text-primary flex items-center justify-center">
<Key size={16} />
</div>
<div className="min-w-0 flex-1">
<CardTitle className="text-sm font-semibold truncate">
{key.label}
</CardTitle>
<CardDescription className="text-[11px] font-mono text-muted-foreground truncate">
Type {key.type}
</CardDescription>
<div className="text-[10px] text-muted-foreground/80 font-mono truncate">
SHA256:{key.id.substring(0, 16)}...
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
openPanelForKey(key);
}}
>
<Pencil size={14} />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(key.id);
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{panelMode === "new"
? t("keychain.keyDialog.newTitle")
: t("keychain.keyDialog.editTitle")}
</DialogTitle>
<DialogDescription className="sr-only">
{panelMode === "new"
? t("keychain.keyDialog.newDesc")
: t("keychain.keyDialog.editDesc")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>{t("keychain.field.label")}</Label>
<Input
value={draftKey.label}
onChange={(e) =>
setDraftKey({ ...draftKey, label: e.target.value })
}
placeholder={t("keychain.field.labelPlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label>{t("keychain.field.privateKeyRequired")}</Label>
<Textarea
value={draftKey.privateKey}
onChange={(e) =>
setDraftKey({ ...draftKey, privateKey: e.target.value })
}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[160px] font-mono text-xs"
required
/>
{generateMode && (
<Button
type="button"
size="sm"
variant="secondary"
onClick={handleGenerate}
>
{t("keychain.generate.generate")}
</Button>
)}
</div>
<div className="space-y-2">
<Label>{t("keychain.field.publicKey")}</Label>
<Textarea
value={derivedPublicKey}
onChange={(e) =>
setDraftKey({ ...draftKey, publicKey: e.target.value })
}
placeholder="ssh-ed25519 AAAAC3... user@host"
className="min-h-[90px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t("terminal.auth.certificate")}{" "}
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{t("common.optional")}
</span>
</Label>
<Textarea
placeholder={t("keychain.field.certificatePlaceholder")}
className="min-h-[80px] text-xs"
/>
</div>
<div className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60">
<div className="text-sm text-muted-foreground">
{t("keychain.import.dropHint")}
</div>
<Button
type="button"
variant="secondary"
onClick={() => {
// mock file import
setDraftKey({
...draftKey,
label: draftKey.label || t("keychain.import.importedKeyLabel"),
privateKey:
draftKey.privateKey ||
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAC3NzaC1lZDI1NTE5AAAA\n-----END OPENSSH PRIVATE KEY-----",
});
}}
>
{t("keychain.import.importFromFile")}
</Button>
</div>
<DialogFooter>
{panelMode === "edit" && draftKey.id && (
<Button
type="button"
variant="ghost"
className="text-destructive mr-auto"
onClick={() => handleDelete(draftKey.id!)}
>
{t("common.delete")}
</Button>
)}
<Button
type="button"
variant="ghost"
onClick={() => setIsDialogOpen(false)}
>
{t("common.cancel")}
</Button>
<Button type="submit">
{panelMode === "new"
? t("keychain.import.saveKey")
: t("keychain.keyDialog.updateKey")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
};
export default KeyManager;

View File

@@ -450,11 +450,6 @@ echo $3 >> "$FILE"`);
[onDeleteIdentity, panel, closePanel],
);
// Copy to clipboard
const _copyToClipboard = useCallback((_text: string) => {
navigator.clipboard.writeText(_text);
}, []);
// Get icon for key source
const getKeyIcon = (key: SSHKey) => {
if (key.certificate) return <BadgeCheck size={16} />;
@@ -506,46 +501,6 @@ echo $3 >> "$FILE"`);
[],
);
// Handle drag and drop
const _handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const file = event.dataTransfer.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
let detectedType: KeyType = "ED25519";
const lc = content.toLowerCase();
if (lc.includes("rsa")) detectedType = "RSA";
else if (lc.includes("ecdsa") || lc.includes("ec private"))
detectedType = "ECDSA";
else if (lc.includes("ed25519")) detectedType = "ED25519";
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, "");
setDraftKey((prev) => ({
...prev,
privateKey: content,
label: prev.label || label,
type: detectedType,
}));
}
};
reader.readAsText(file);
}, []);
const _handleDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
},
[],
);
return (
<div className="h-full flex relative">
{/* Hidden file input */}

View File

@@ -8,6 +8,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, TerminalTheme } from "../types";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { useCustomThemes } from "../application/state/customThemeStore";
import { Button } from "./ui/button";
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
@@ -36,13 +37,18 @@ const LogViewComponent: React.FC<LogViewProps> = ({
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
// Use log's saved theme/fontSize or fall back to defaults
const currentTheme = useMemo(() => {
if (log.themeId) {
return TERMINAL_THEMES.find(t => t.id === log.themeId) || defaultTerminalTheme;
return TERMINAL_THEMES.find(t => t.id === log.themeId)
|| customThemes.find(t => t.id === log.themeId)
|| defaultTerminalTheme;
}
return defaultTerminalTheme;
}, [log.themeId, defaultTerminalTheme]);
}, [log.themeId, defaultTerminalTheme, customThemes]);
const currentFontSize = log.fontSize ?? defaultFontSize;

View File

@@ -130,7 +130,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const result = await startTunnel(
rule,
_host,
keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
(status, error) => {
// Show toast on error (only once)
if (status === "error" && error && !errorShown) {
@@ -307,8 +307,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const label =
newFormDraft.label?.trim() ||
(() => {
// Host lookup reserved for future label enhancement (e.g., "Local:8080 → api.example.com:80 via server1")
const _host = hosts.find((h) => h.id === newFormDraft.hostId);
switch (newFormDraft.type) {
case "local":
return `Local:${newFormDraft.localPort}${newFormDraft.remoteHost}:${newFormDraft.remotePort}`;
@@ -546,12 +544,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
);
};
// Handle skip wizard (just save with defaults)
const _skipWizard = () => {
setShowWizard(false);
resetWizard();
};
// Render wizard panel content
const hasRules = filteredRules.length > 0;

View File

@@ -14,7 +14,7 @@ import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { cn } from "../lib/utils";
import { Host, KnownHost, SSHKey } from "../types";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
@@ -30,7 +30,6 @@ interface QuickConnectWizardProps {
open: boolean;
target: QuickConnectTarget;
keys: SSHKey[];
knownHosts: KnownHost[];
warnings?: string[];
onConnect: (host: Host) => void;
onSaveHost?: (host: Host) => void;
@@ -42,7 +41,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
open,
target,
keys,
knownHosts,
warnings,
onConnect,
onSaveHost,
@@ -69,16 +67,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
const [password, setPassword] = useState("");
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [saveCredentials, _setSaveCredentials] = useState(true);
// Check if host is in known hosts
const _existingKnownHost = useMemo(() => {
return knownHosts.find(
(kh) =>
kh.hostname === target.hostname &&
(kh.port === port || (!kh.port && port === 22)),
);
}, [knownHosts, target.hostname, port]);
const [saveCredentials] = useState(true);
// Reset state when target changes
React.useEffect(() => {

View File

@@ -24,7 +24,6 @@ import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActi
import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts";
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
import { toast } from "./ui/toast";
import { Dialog, DialogContent } from "./ui/dialog";
interface SFTPModalProps {
host: Host;
@@ -42,6 +41,7 @@ interface SFTPModalProps {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
open: boolean;
onClose: () => void;
@@ -49,6 +49,8 @@ interface SFTPModalProps {
initialPath?: string;
/** Initial entries to upload when SFTP modal opens. Used for drag-and-drop to terminal. */
initialEntriesToUpload?: DropEntry[];
/** Callback to update the host (e.g. for bookmark persistence). */
onUpdateHost?: (host: Host) => void;
}
const SFTPModal: React.FC<SFTPModalProps> = ({
@@ -58,6 +60,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onClose,
initialPath,
initialEntriesToUpload,
onUpdateHost,
}) => {
const {
openSftp,
@@ -89,6 +92,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const {
sftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
@@ -200,10 +204,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const {
currentPath,
setCurrentPath,
currentPathRef,
files,
loading,
setLoading,
reconnecting,
sessionVersion,
ensureSftp,
loadFiles,
closeSftpSession,
@@ -296,6 +302,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
showRenameDialog,
setShowRenameDialog,
renameTarget,
@@ -372,6 +385,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
dismissTask,
} = useSftpModalTransfers({
currentPath,
currentPathRef,
isLocalSession,
joinPath: joinPathForSession,
ensureSftp,
@@ -391,9 +405,40 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
setLoading,
t,
useCompressedUpload: sftpUseCompressedUpload,
listSftp: listSftpWithEncoding,
deleteLocalFile,
});
const hasEverOpenedRef = useRef(false);
const hasActiveTransferTasks = useMemo(
() =>
uploadTasks.some(
(task) =>
task.status === "pending" ||
task.status === "uploading" ||
task.status === "downloading",
),
[uploadTasks],
);
useEffect(() => {
if (open) {
hasEverOpenedRef.current = true;
return;
}
if (!hasEverOpenedRef.current) return;
if (uploading || hasActiveTransferTasks) return;
void closeSftpSession();
}, [closeSftpSession, hasActiveTransferTasks, open, sessionVersion, uploading]);
const handleClose = async () => {
if (uploading || hasActiveTransferTasks) {
onClose();
return;
}
await closeSftpSession();
onClose();
};
@@ -432,8 +477,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
initialUploadTriggeredRef.current = true;
// Trigger upload with full DropEntry data (preserves directory structure)
handleUploadEntries(initialEntriesToUpload);
}, [initialEntriesToUpload, open, loading, handleUploadEntries]);
void handleUploadEntries(initialEntriesToUpload);
}, [handleUploadEntries, initialEntriesToUpload, loading, open]);
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
@@ -498,6 +543,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
return parentEntry ? [parentEntry, ...sorted] : sorted;
}, [displayFiles, sortField, sortOrder]);
const hasFiles = files.length > 0;
const hasDisplayFiles = sortedFiles.length > 0;
const {
fileListRef,
handleFileListScroll,
@@ -526,7 +573,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Find the files to pass to confirm dialog
if (fileNames.length === 0) return;
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
// Delete files
(async () => {
try {
@@ -601,10 +648,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
}
};
if (!open) return null;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
<>
<div className="h-full flex flex-col bg-background border-r border-border/60 overflow-hidden">
<SftpModalHeader
onClose={handleClose}
t={t}
host={host}
credentials={credentials}
@@ -642,13 +692,20 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onCreateFile={handleCreateFile}
onFileSelect={handleFileSelect}
onFolderSelect={handleFolderSelect}
showHiddenFiles={sftpShowHiddenFiles}
onToggleShowHiddenFiles={() =>
setSftpShowHiddenFiles(!sftpShowHiddenFiles)
}
onUpdateHost={onUpdateHost}
onNavigateToBookmark={(path) => setCurrentPath(path)}
/>
<SftpModalFileList
t={t}
currentPath={currentPath}
isLocalSession={isLocalSession}
files={files}
hasFiles={hasFiles}
hasDisplayFiles={hasDisplayFiles}
selectedFiles={selectedFiles}
dragActive={dragActive}
loading={loading}
@@ -698,7 +755,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onDownloadSelected={handleDownloadSelected}
onDeleteSelected={handleDeleteSelected}
/>
</DialogContent>
</div>
<SftpModalDialogs
t={t}
@@ -718,6 +775,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
getSymbolicPermissions={getSymbolicPermissions}
handleSavePermissions={handleSavePermissions}
isChangingPermissions={isChangingPermissions}
showCreateDialog={showCreateDialog}
setShowCreateDialog={setShowCreateDialog}
createType={createType}
createName={createName}
setCreateName={setCreateName}
isCreating={isCreating}
handleCreateSubmit={handleCreateSubmit}
/>
{/* File Opener Dialog */}
@@ -746,7 +810,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
/>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,221 @@
/**
* ScriptsSidePanel - Lightweight scripts browser for the terminal side panel
*
* Shows snippets organized by package hierarchy with breadcrumb navigation.
* Clicking a snippet executes it in the focused terminal session.
*/
import { ChevronRight, Package, Search, Zap } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Snippet } from '../types';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
interface ScriptsSidePanelProps {
snippets: Snippet[];
packages: string[];
onSnippetClick: (command: string, noAutoRun?: boolean) => void;
isVisible?: boolean;
}
const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
snippets,
packages,
onSnippetClick,
isVisible = true,
}) => {
const { t } = useI18n();
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const [search, setSearch] = useState('');
const displayedPackages = useMemo(() => {
if (!selectedPackage) {
const absolutePaths = packages.filter(p => p.startsWith('/'));
const relativePaths = packages.filter(p => !p.startsWith('/'));
const results: { name: string; path: string; count: number }[] = [];
const relativeRoots = relativePaths
.map((p) => p.split('/')[0])
.filter((name): name is string => Boolean(name) && name.length > 0);
Array.from(new Set(relativeRoots)).forEach((name: string) => {
const path: string = name;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name, path, count });
});
const absoluteRoots = absolutePaths
.map((p) => {
const cleanPath = p.substring(1);
return cleanPath.split('/')[0];
})
.filter((name): name is string => Boolean(name) && name.length > 0);
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
const path: string = `/${name}`;
const displayName: string = `/${name}`;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name: displayName, path, count });
});
return results;
}
const prefix = selectedPackage + '/';
const children = packages
.filter((p) => p.startsWith(prefix))
.map((p) => p.replace(prefix, '').split('/')[0])
.filter((name): name is string => Boolean(name) && name.length > 0);
return Array.from(new Set(children)).map((name) => {
const path = `${selectedPackage}/${name}`;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
return { name, path, count };
});
}, [packages, selectedPackage, snippets]);
const displayedSnippets = useMemo(() => {
let result = snippets.filter((s) => (s.package || '') === (selectedPackage || ''));
if (search.trim()) {
const s = search.toLowerCase();
result = result.filter(sn =>
sn.label.toLowerCase().includes(s) ||
sn.command.toLowerCase().includes(s)
);
}
return result;
}, [snippets, selectedPackage, search]);
// Also filter packages by search when at root level
const filteredPackages = useMemo(() => {
if (!search.trim()) return displayedPackages;
const s = search.toLowerCase();
return displayedPackages.filter(pkg => pkg.name.toLowerCase().includes(s));
}, [displayedPackages, search]);
const breadcrumb = useMemo(() => {
if (!selectedPackage) return [];
const isAbsolute = selectedPackage.startsWith('/');
const parts = selectedPackage.split('/').filter(Boolean);
return parts.map((name, idx) => {
const pathSegments = parts.slice(0, idx + 1);
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
return { name, path };
});
}, [selectedPackage]);
const handleSnippetClick = useCallback((command: string, noAutoRun?: boolean) => {
onSnippetClick(command, noAutoRun);
}, [onSnippetClick]);
if (!isVisible) return null;
const hasAnyContent = snippets.length > 0 || packages.length > 0;
return (
<div className="h-full flex flex-col bg-background overflow-hidden">
{/* Search */}
<div className="shrink-0 px-2 py-1.5 border-b border-border/50">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('snippets.searchPlaceholder')}
className="h-7 pl-7 text-xs bg-muted/30 border-none"
/>
</div>
</div>
{/* Breadcrumb */}
<div className="shrink-0 flex items-center gap-1 px-3 py-1.5 text-[11px] border-b border-border/30 min-h-[28px]">
<button
className={cn(
"hover:text-primary transition-colors truncate",
!selectedPackage ? "text-foreground font-medium" : "text-muted-foreground"
)}
onClick={() => setSelectedPackage(null)}
>
{t('terminal.toolbar.library')}
</button>
{breadcrumb.map((b) => (
<React.Fragment key={b.path}>
<ChevronRight size={10} className="text-muted-foreground shrink-0" />
<button
className="text-muted-foreground hover:text-primary transition-colors truncate"
onClick={() => setSelectedPackage(b.path)}
>
{b.name}
</button>
</React.Fragment>
))}
</div>
{/* Content */}
<ScrollArea className="flex-1">
<div className="py-1">
{!hasAnyContent && (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Zap size={24} className="opacity-40 mb-2" />
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
</div>
)}
{/* Packages */}
{filteredPackages.map((pkg) => (
<button
key={pkg.path}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
onClick={() => { setSelectedPackage(pkg.path); setSearch(''); }}
>
<div className="w-6 h-6 rounded-md bg-primary/10 text-primary flex items-center justify-center shrink-0">
<Package size={12} />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{pkg.name}</div>
<div className="text-[10px] text-muted-foreground">
{t('snippets.package.count', { count: pkg.count })}
</div>
</div>
<ChevronRight size={12} className="text-muted-foreground shrink-0" />
</button>
))}
{/* Snippets */}
{displayedSnippets.map((s) => (
<button
key={s.id}
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
>
<span className="text-xs font-medium truncate">{s.label}</span>
<span className="text-muted-foreground truncate font-mono text-[10px] max-w-full">
{s.command}
</span>
</button>
))}
{hasAnyContent && displayedSnippets.length === 0 && filteredPackages.length === 0 && search.trim() && (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
)}
</div>
</ScrollArea>
</div>
);
};
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
ScriptsSidePanel.displayName = 'ScriptsSidePanel';

View File

@@ -197,19 +197,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}));
}, [currentPath]);
const _handleBack = () => {
if (currentPath) {
const parts = currentPath.split("/");
if (parts.length > 1) {
setCurrentPath(parts.slice(0, -1).join("/"));
} else {
setCurrentPath(null);
}
} else {
onBack();
}
};
return (
<div
className={cn(

View File

@@ -4,7 +4,7 @@ import AppLogo from "./AppLogo";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
import { useI18n } from "../application/i18n/I18nProvider";
import { SettingsTabContent } from "./settings/settings-ui";
import { toast } from "./ui/toast";
@@ -63,13 +63,18 @@ const ActionRow: React.FC<{
</button>
);
export default function SettingsApplicationTab() {
interface SettingsApplicationTabProps {
updateState: UpdateState;
checkNow: UseUpdateCheckResult['checkNow'];
openReleasePage: UseUpdateCheckResult['openReleasePage'];
installUpdate: UseUpdateCheckResult['installUpdate'];
}
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const { updateState, checkNow, openReleasePage } = useUpdateCheck();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
const [hasAutoChecked, setHasAutoChecked] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -93,19 +98,6 @@ export default function SettingsApplicationTab() {
const isUpdateDemoMode = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
// Auto check for updates when entering this page
useEffect(() => {
if (hasAutoChecked) return;
if (updateState.isChecking) return;
// In demo mode or when we have a valid version, auto-check
const canCheck = isUpdateDemoMode || (appInfo.version && appInfo.version !== '0.0.0');
if (!canCheck) return;
setHasAutoChecked(true);
void checkNow();
}, [hasAutoChecked, updateState.isChecking, isUpdateDemoMode, appInfo.version, checkNow]);
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
@@ -124,8 +116,9 @@ export default function SettingsApplicationTab() {
t('update.available.message', { version: result.latestRelease.version }),
t('update.available.title')
);
// Open the release page
openReleasePage();
// Don't auto-open the release page here — checkNow() already triggers
// electron-updater on supported platforms, and the Settings > System tab
// shows a "Manual Download" link on unsupported platforms.
} else if (result) {
setLastCheckResult('upToDate');
toast.success(
@@ -154,18 +147,25 @@ export default function SettingsApplicationTab() {
<span className="text-sm text-muted-foreground">
{appInfo.version ? appInfo.version : " "}
</span>
{/* Update available badge - inline with version */}
{updateState.hasUpdate && updateState.latestRelease && (
{/* Update badge - reflects auto-download state */}
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
<button
onClick={() => void openReleasePage()}
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
"bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
"hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer"
updateState.autoDownloadStatus === 'ready'
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800"
: "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800",
"transition-colors cursor-pointer"
)}
>
<ArrowUpCircle size={12} />
v{updateState.latestRelease.version} {t('update.downloadNow')}
v{updateState.latestRelease.version}{' '}
{updateState.autoDownloadStatus === 'ready'
? t('update.restartNow')
: updateState.autoDownloadStatus === 'downloading'
? `${updateState.downloadPercent}%`
: t('update.downloadNow')}
</button>
)}
</div>

View File

@@ -2,11 +2,14 @@
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
*/
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { useAIState } from "../application/state/useAIState";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
@@ -14,35 +17,80 @@ import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociation
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
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 type { TerminalFont } from "../infrastructure/config/fonts";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
class AITabErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ error: Error | null }
> {
state: { error: Error | null } = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
render() {
if (this.state.error) {
return (
<div style={{ padding: 32, color: "#f87171", fontFamily: "monospace", whiteSpace: "pre-wrap" }}>
<h3 style={{ marginBottom: 8 }}>AI Settings Error</h3>
<div>{this.state.error.message}</div>
<div style={{ marginTop: 8, fontSize: 12, color: "#888" }}>{this.state.error.stack}</div>
</div>
);
}
return this.props.children;
}
}
type SettingsState = ReturnType<typeof useSettingsState> & {
availableFonts: TerminalFont[];
};
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const SettingsSyncTabWithVault: React.FC = () => {
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
const {
hosts,
keys,
identities,
snippets,
customGroups,
snippetPackages,
knownHosts,
importDataFromString,
clearVaultData,
} = useVaultState();
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
// Strip transient runtime fields before passing to sync
const portForwardingRulesForSync = useMemo(
() =>
portForwardingRules.map((rule) => ({
...rule,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
})),
[portForwardingRules],
);
const vault = useMemo(
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts }),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
);
return (
<SettingsSyncTab
hosts={hosts}
keys={keys}
identities={identities}
snippets={snippets}
vault={vault}
portForwardingRules={portForwardingRulesForSync}
importDataFromString={importDataFromString}
importPortForwardingRules={importPortForwardingRules}
clearVaultData={clearVaultData}
onSettingsApplied={onSettingsApplied}
/>
);
};
@@ -50,6 +98,8 @@ const SettingsSyncTabWithVault: React.FC = () => {
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const aiState = useAIState();
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
@@ -128,6 +178,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
>
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
</TabsTrigger>
<TabsTrigger
value="ai"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
>
<Sparkles size={14} /> AI
</TabsTrigger>
<TabsTrigger
value="sync"
className="w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors"
@@ -144,7 +200,14 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
</div>
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
{mountedTabs.has("application") && <SettingsApplicationTab />}
{mountedTabs.has("application") && (
<SettingsApplicationTab
updateState={updateState}
checkNow={checkNow}
openReleasePage={openReleasePage}
installUpdate={installUpdate}
/>
)}
{mountedTabs.has("appearance") && (
<SettingsAppearanceTab
@@ -197,9 +260,40 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<SettingsFileAssociationsTab />
)}
{mountedTabs.has("ai") && (
<AITabErrorBoundary>
<React.Suspense fallback={null}>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</React.Suspense>
</AITabErrorBoundary>
)}
{mountedTabs.has("sync") && (
<React.Suspense fallback={null}>
<SettingsSyncTabWithVault />
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
</React.Suspense>
)}
@@ -216,6 +310,14 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
closeToTray={settings.closeToTray}
setCloseToTray={settings.setCloseToTray}
hotkeyRegistrationError={settings.hotkeyRegistrationError}
globalHotkeyEnabled={settings.globalHotkeyEnabled}
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
autoUpdateEnabled={settings.autoUpdateEnabled}
setAutoUpdateEnabled={settings.setAutoUpdateEnabled}
updateState={updateState}
checkNow={checkNow}
installUpdate={installUpdate}
openReleasePage={openReleasePage}
/>
)}
</div>

View File

@@ -0,0 +1,618 @@
/**
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
*
* Reuses SftpView's components (SftpPaneView, SftpContextProvider, etc.)
* to provide a unified SFTP experience. Renders a single pane (left side only).
*
* IMPORTANT: Does NOT use the global activeTabStore to avoid conflicts with
* the main SftpView tab. Instead manages pane visibility internally.
*
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { getParentPath } from "../application/state/sftp/utils";
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
import { logger } from "../lib/logger";
import type { DropEntry } from "../lib/sftpFileUtils";
import { Host, Identity, SSHKey } from "../types";
import type { TransferTask } from "../types";
import { toast } from "./ui/toast";
import { DistroAvatar } from "./DistroAvatar";
import { SftpPaneView } from "./sftp/SftpPaneView";
import { SftpOverlays } from "./sftp/SftpOverlays";
import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
import { SftpContextProvider } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
interface SftpSidePanelProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
initialLocation?: { hostId: string; path: string } | null;
showWorkspaceHostHeader?: boolean;
isVisible?: boolean;
renderOverlays?: boolean;
pendingUpload?: {
requestId: string;
hostId: string;
connectionKey: string;
targetPath?: string;
entries: DropEntry[];
} | null;
onPendingUploadHandled?: (requestId: string) => void;
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
}
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts,
keys,
identities,
updateHosts,
activeHost,
initialLocation,
showWorkspaceHostHeader = false,
isVisible = true,
renderOverlays = true,
pendingUpload = null,
onPendingUploadHandled,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
}) => {
const { t } = useI18n();
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
toast.success(t('sftp.autoSync.success', { fileName }));
logger.info("[SFTP] File auto-synced to remote", payload);
},
onFileWatchError: (payload: { error: string }) => {
toast.error(t('sftp.autoSync.error', { error: payload.error }));
logger.error("[SFTP] File auto-sync failed", payload);
},
}), [t]);
const sftpOptions = useMemo(() => ({
...fileWatchHandlers,
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
autoConnectLocalOnMount: false,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
const behaviorRef = useRef(sftpDoubleClickBehavior);
behaviorRef.current = sftpDoubleClickBehavior;
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const getOpenerForFileRef = useRef(getOpenerForFile);
getOpenerForFileRef.current = getOpenerForFile;
const handleToggleHiddenFiles = useCallback((paneId: string) => {
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
if (!pane) return;
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
}, []);
// NOTE: We intentionally do NOT sync to activeTabStore here.
// activeTabStore is a global singleton shared with SftpView.
// Writing to it here would corrupt SftpView's left pane visibility.
const {
leftCallbacks,
rightCallbacks,
dragCallbacks,
draggedFiles,
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
showSaveDialog,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
});
const {
leftPanes,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleHostSelectLeft,
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
// Auto-connect when activeHost changes.
// Uses sftpRef to avoid re-triggering on every sftp state change.
const connectedKeyRef = useRef<string | null>(null);
// Store the Host object used for the current connection so the header
// can show session-time overrides even during deferred host switches.
const connectedHostObjRef = useRef<Host | null>(null);
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
const handledPendingUploadIdRef = useRef<string | null>(null);
// Maps tab IDs to the connectionKey used to create them, so we can
// correctly identify tabs when the same host ID has different overrides.
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
const pendingConnectionKeyRef = useRef<string | null>(null);
const prevIsVisibleRef = useRef(isVisible);
// Reset location guard when the panel is reopened so the terminal cwd
// is re-applied even if it matches the previous session's path.
useEffect(() => {
if (isVisible && !prevIsVisibleRef.current) {
lastAppliedInitialLocationKeyRef.current = null;
}
prevIsVisibleRef.current = isVisible;
}, [isVisible]);
// Navigate SFTP to the terminal's current working directory
const handleGoToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd) return;
const cwd = await onGetTerminalCwd();
if (cwd) {
sftpRef.current.navigateTo("left", cwd);
}
}, [onGetTerminalCwd]);
// Track whether there's active work that should block connection switching.
// Computed outside the effect so it can be in the dependency array.
const hasActiveTransfers = useMemo(
() => sftp.transfers.some((t) => t.status === "pending" || t.status === "transferring"),
[sftp.transfers],
);
// Block host-following while any connection-sensitive UI or operation
// is active: text editor, permissions dialog, file-opener dialog, or
// auto-synced external file watches.
const hasActiveWork = hasActiveTransfers || showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
useEffect(() => {
if (!activeHost) return;
const s = sftpRef.current;
// Serial terminals don't support SFTP — disconnect any existing
// connection (remote or local) so the panel doesn't remain bound to
// a previous host.
const proto = activeHost.protocol;
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
// Serial terminals don't support SFTP. Just clear the tracked
// connection key so switching back to a remote terminal will
// trigger auto-connect. Don't disconnect existing tabs — they
// may be reused when focus returns.
connectedKeyRef.current = null;
return;
}
// Local terminals connect to the local file browser
if (proto === 'local' || activeHost.id?.startsWith('local-')) {
if (hasActiveWork) return;
const leftConn = s.leftPane.connection;
if (leftConn?.isLocal) {
// Already connected locally
connectedKeyRef.current = "local";
return;
}
// Check for an existing local tab to reuse
const existingLocalTab = s.leftTabs.tabs.find((tab) =>
tab.connection?.isLocal && tab.connection.status === "connected",
);
if (existingLocalTab) {
s.selectTab("left", existingLocalTab.id);
connectedKeyRef.current = "local";
return;
}
connectedKeyRef.current = "local";
// Preserve existing remote tab when switching to local
const needsNewTab = !!(leftConn && leftConn.status === "connected");
if (needsNewTab) {
s.connect("left", "local", { forceNewTab: true });
} else if (leftConn) {
// Await disconnect before connecting locally to avoid the async
// disconnect wiping out the fresh local connection.
void s.disconnect("left").then(() => s.connect("left", "local"));
} else {
s.connect("left", "local");
}
return;
}
// Build a connection key that accounts for session-time overrides
// (same host ID may have different port/protocol in different workspace panes).
// Uses buildCacheKey to stay consistent with the key recorded on upload tasks.
const connectionKey = buildCacheKey(activeHost.id, activeHost.hostname, activeHost.port, activeHost.protocol, activeHost.sftpSudo, activeHost.username);
if (connectedKeyRef.current === connectionKey) return;
// Don't switch connections while transfers or editor are active
if (hasActiveWork) return;
logger.info("[SftpSidePanel] Auto-connect triggered", {
hostId: activeHost.id,
hostLabel: activeHost.label,
protocol: activeHost.protocol,
hostname: activeHost.hostname,
});
// Check if an existing SFTP tab matches this exact endpoint.
// We track which connectionKey was used to create each tab so that
// tabs for the same host ID with different session-time overrides
// (port/protocol) are not incorrectly reused.
const tabs = s.leftTabs.tabs;
const existingTab = tabs.find((tab) => {
if (!tab.connection || tab.connection.hostId !== activeHost.id) return false;
// Don't reuse errored tabs — they need a fresh connection
if (tab.connection.status === "error" || tab.connection.status === "disconnected") return false;
return tabConnectionKeyMapRef.current.get(tab.id) === connectionKey;
});
if (existingTab) {
s.selectTab("left", existingTab.id);
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
return;
}
// Create a new tab when there's already an active connection to a different
// host, so the previous tab is preserved for instant switching on focus change.
const currentConn = s.leftPane.connection;
const needsNewTab = !!(currentConn && currentConn.status === "connected" && currentConn.hostId !== activeHost.id);
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
// Store the pending key so the effect below can map it once the tab is created
pendingConnectionKeyRef.current = connectionKey;
s.connect("left", activeHost, needsNewTab ? { forceNewTab: true } : undefined);
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
// Track the active tab's connectionKey after connect() creates or reuses it.
// Watches both activeTabId (new tab) and connection status (reused tab reconnecting).
useEffect(() => {
const activeTabId = sftp.leftTabs.activeTabId;
if (activeTabId && pendingConnectionKeyRef.current) {
tabConnectionKeyMapRef.current.set(activeTabId, pendingConnectionKeyRef.current);
pendingConnectionKeyRef.current = null;
}
}, [sftp.leftTabs.activeTabId, sftp.leftPane.connection?.status]);
// Clear the remembered connection key when the pane disconnects or the
// session is lost, so re-opening SFTP for the same terminal reconnects.
// Also reset the file-watch counter — watches are bound to the SFTP session,
// so they stop when the session disconnects.
useEffect(() => {
const connection = sftp.leftPane.connection;
if (!connection || connection.status === "error" || connection.status === "disconnected") {
connectedKeyRef.current = null;
if (sftp.activeFileWatchCountRef) {
sftp.activeFileWatchCountRef.current = 0;
}
}
}, [sftp.leftPane.connection, sftp.leftPane.connection?.status, sftp.activeFileWatchCountRef]);
useEffect(() => {
if (!activeHost || !initialLocation) return;
if (initialLocation.hostId !== activeHost.id || !initialLocation.path) return;
const activePane = sftpRef.current.leftPane;
const connection = activePane.connection;
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
if (connection.status !== "connected") return;
// Include full endpoint key so that same-hostId sessions with
// different overrides each get their initial location applied.
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
if (connection.currentPath === initialLocation.path) {
lastAppliedInitialLocationKeyRef.current = locationKey;
return;
}
lastAppliedInitialLocationKeyRef.current = locationKey;
sftpRef.current.navigateTo("left", initialLocation.path);
}, [
activeHost,
initialLocation,
sftp.leftPane,
]);
useEffect(() => {
if (!pendingUpload || !activeHost) return;
if (handledPendingUploadIdRef.current === pendingUpload.requestId) return;
if (pendingUpload.hostId !== activeHost.id) return;
const activePane = sftp.leftPane;
const connection = activePane.connection;
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
if (connection.status !== "connected") return;
handledPendingUploadIdRef.current = pendingUpload.requestId;
const runUpload = async () => {
try {
const results = await sftpRef.current.uploadExternalEntries("left", pendingUpload.entries, {
targetPath: pendingUpload.targetPath,
});
if (results.some((result) => result.cancelled)) {
toast.info(t("sftp.upload.cancelled"), "SFTP");
return;
}
const failCount = results.filter((result) => !result.success && !result.cancelled).length;
const successCount = results.filter((result) => result.success).length;
if (failCount === 0) {
const message =
successCount === 1
? `${t("sftp.upload")}: ${results[0]?.fileName ?? ""}`
: `${t("sftp.uploadFiles")}: ${successCount}`;
toast.success(message, "SFTP");
} else {
const failedFiles = results.filter((result) => !result.success && !result.cancelled);
failedFiles.forEach((failed) => {
const errorMsg = failed.error ? ` - ${failed.error}` : "";
toast.error(
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
"SFTP",
);
});
}
} catch (error) {
logger.error("[SftpSidePanel] Failed to upload dropped files:", error);
handledPendingUploadIdRef.current = null;
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
return;
} finally {
onPendingUploadHandled?.(pendingUpload.requestId);
}
};
void runUpload();
}, [
activeHost,
onPendingUploadHandled,
pendingUpload,
sftp.leftPane,
t,
]);
const MAX_VISIBLE_TRANSFERS = 5;
const visibleTransfers = useMemo(
() => [...sftp.transfers].reverse().slice(0, MAX_VISIBLE_TRANSFERS),
[sftp.transfers],
);
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
const connection = sftpRef.current.leftPane.connection;
if (!connection || connection.isLocal) return;
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
await sftpRef.current.navigateTo("left", revealPath, { force: true });
},
[],
);
const canRevealTransferTarget = useCallback(
(task: TransferTask) => {
if (task.status !== "completed") return false;
if (task.direction !== "upload" && task.direction !== "remote-to-remote") return false;
const connection = sftp.leftPane.connection;
if (!connection || connection.isLocal) return false;
if (task.targetHostId) {
if (connection.hostId !== task.targetHostId) return false;
// If the transfer recorded a full endpoint key, use it to
// distinguish same-hostId uploads with different session overrides.
if (task.targetConnectionKey) {
return connectedKeyRef.current === task.targetConnectionKey;
}
return true;
}
return connection.id === task.targetConnectionId;
},
[sftp.leftPane.connection],
);
// When the auto-connect effect defers a switch (active transfers or open
// editor), the panel still operates on the current connection, not
// activeHost. Use the connected host for the header so the label matches
// what browse/edit/delete actions actually target.
const displayHost = useMemo(() => {
const conn = sftp.leftPane.connection;
if (conn && !conn.isLocal) {
// Prefer the stored Host object from connect time — it preserves
// session-time overrides that the vault host may lack.
if (connectedHostObjRef.current && connectedHostObjRef.current.id === conn.hostId) {
return connectedHostObjRef.current;
}
return hosts.find((h) => h.id === conn.hostId) ?? activeHost;
}
return activeHost;
}, [sftp.leftPane.connection, hosts, activeHost]);
// Determine the active pane to render (without using global activeTabStore)
const activeLeftPaneId = sftp.leftTabs.activeTabId;
return (
<SftpContextProvider
hosts={hosts}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
leftCallbacks={leftCallbacks}
rightCallbacks={rightCallbacks}
>
<div
className="h-full flex flex-col bg-background overflow-hidden"
style={isVisible ? undefined : { display: "none" }}
aria-hidden={!isVisible}
>
{showWorkspaceHostHeader && displayHost && (
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
<div className="flex items-center gap-2 min-w-0">
<DistroAvatar
host={displayHost}
fallback={displayHost.label.slice(0, 2).toUpperCase()}
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")}@${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>
</div>
</div>
)}
{/* File browser pane - render only the active pane */}
<div className="relative flex-1 min-h-0">
{leftPanes.map((pane, idx) => {
// Manage visibility locally instead of via activeTabStore
const isActive = activeLeftPaneId
? pane.id === activeLeftPaneId
: idx === 0;
if (!isActive) return null;
return (
<div key={pane.id} className="absolute inset-0 z-10">
<SftpPaneView
side="left"
pane={pane}
showHeader
showEmptyHeader
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
/>
</div>
);
})}
</div>
<SftpTransferQueue
sftp={sftp}
visibleTransfers={visibleTransfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
/>
</div>
{renderOverlays && (
<SftpOverlays
hosts={hosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
showTransferQueue={false}
showHostPickerLeft={showHostPickerLeft}
showHostPickerRight={showHostPickerRight}
hostSearchLeft={hostSearchLeft}
hostSearchRight={hostSearchRight}
setShowHostPickerLeft={setShowHostPickerLeft}
setShowHostPickerRight={setShowHostPickerRight}
setHostSearchLeft={setHostSearchLeft}
setHostSearchRight={setHostSearchRight}
handleHostSelectLeft={handleHostSelectLeft}
handleHostSelectRight={handleHostSelectRight}
permissionsState={permissionsState}
setPermissionsState={setPermissionsState}
showTextEditor={showTextEditor}
setShowTextEditor={setShowTextEditor}
textEditorTarget={textEditorTarget}
setTextEditorTarget={setTextEditorTarget}
textEditorContent={textEditorContent}
setTextEditorContent={setTextEditorContent}
handleSaveTextFile={handleSaveTextFile}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
showFileOpenerDialog={showFileOpenerDialog}
setShowFileOpenerDialog={setShowFileOpenerDialog}
fileOpenerTarget={fileOpenerTarget}
setFileOpenerTarget={setFileOpenerTarget}
handleFileOpenerSelect={handleFileOpenerSelect}
handleSelectSystemApp={handleSelectSystemApp}
t={t}
/>
)}
</SftpContextProvider>
);
};
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.updateHosts === next.updateHosts &&
prev.activeHost === next.activeHost &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&
prev.renderOverlays === next.renderOverlays &&
prev.pendingUpload?.requestId === next.pendingUpload?.requestId &&
prev.onPendingUploadHandled === next.onPendingUploadHandled &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
prev.initialLocation?.path === next.initialLocation?.path;
export const SftpSidePanel = memo(SftpSidePanelInner, sidePanelAreEqual);
SftpSidePanel.displayName = "SftpSidePanel";

View File

@@ -48,15 +48,17 @@ interface SftpViewProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
}
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const {
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
@@ -76,7 +78,13 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
},
}), [t]);
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
const sftpOptions = useMemo(() => ({
...fileWatchHandlers,
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
// Get stream transfer functions for optimized downloads
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
@@ -100,7 +108,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
hotkeyScheme,
sftpRef,
isActive,
showHiddenFiles: sftpShowHiddenFiles,
});
// Subscribe to focused side for visual indicator
@@ -111,6 +118,14 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
sftpFocusStore.setFocusedSide(side);
}, []);
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === paneId);
if (!pane) return;
sftpRef.current.setShowHiddenFiles(side, paneId, !pane.showHiddenFiles);
}, []);
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
// Using useLayoutEffect to sync before paint
useLayoutEffect(() => {
@@ -167,7 +182,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
});
const visibleTransfers = useMemo(
() => sftp.transfers.slice(-5),
() => [...sftp.transfers].reverse().slice(0, 5),
[sftp.transfers],
);
@@ -213,11 +228,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
return (
<SftpContextProvider
hosts={hosts}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
leftCallbacks={leftCallbacks}
rightCallbacks={rightCallbacks}
showHiddenFiles={sftpShowHiddenFiles}
>
<div
className={cn(
@@ -269,6 +284,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
pane={pane}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
/>
</SftpPaneWrapper>
))}
@@ -325,6 +341,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
pane={pane}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
/>
</SftpPaneWrapper>
))}

View File

@@ -28,6 +28,7 @@ interface SnippetsManagerProps {
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
onSave: (snippet: Snippet) => void;
onBulkSave: (snippets: Snippet[]) => void;
onDelete: (id: string) => void;
onPackagesChange: (packages: string[]) => void;
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
@@ -51,6 +52,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
hotkeyScheme,
keyBindings,
onSave,
onBulkSave,
onDelete,
onPackagesChange,
onRunSnippet,
@@ -300,6 +302,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
package: editingSnippet.package || '',
targets: targetSelection,
shortkey: editingSnippet.shortkey,
noAutoRun: editingSnippet.noAutoRun,
});
setRightPanelMode('none');
}
@@ -486,11 +489,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
// Update packages first, then save snippets
onPackagesChange(keep);
// Only save snippets that were actually modified
const modifiedSnippets = updatedSnippets.filter((s, index) =>
s.package !== snippets[index].package
);
modifiedSnippets.forEach(onSave);
// Bulk-save all snippets to avoid stale-closure overwrites
onBulkSave(updatedSnippets);
// Reset selected package if it was deleted
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
@@ -527,7 +527,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
onBulkSave(updatedSnippets);
if (selectedPackage === source) setSelectedPackage(newPath);
};
@@ -568,8 +568,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
return;
}
// Validate: duplicate (case-insensitive)
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
// Validate: duplicate (case-insensitive), excluding the package being renamed
const existingPackage = packages.find(p => p !== renamingPackagePath && p.toLowerCase() === newPath.toLowerCase());
if (existingPackage) {
setRenameError(t('snippets.renameDialog.error.duplicate'));
return;
@@ -595,7 +595,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
onBulkSave(updatedSnippets);
// Update selected package if it was renamed
if (selectedPackage === renamingPackagePath) {
@@ -792,6 +792,17 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
/>
</Card>
{/* No Auto Run */}
<label className="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
checked={editingSnippet.noAutoRun ?? false}
onChange={(e) => setEditingSnippet({ ...editingSnippet, noAutoRun: e.target.checked || undefined })}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
</label>
{/* Shortkey */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">

View File

@@ -25,7 +25,7 @@ import {
Server,
} from 'lucide-react';
import { useCloudSync } from '../application/state/useCloudSync';
import type { CloudProvider } from '../domain/sync';
import { isProviderReadyForSync, type CloudProvider } from '../domain/sync';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
@@ -122,12 +122,11 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
// Get connected provider (include syncing status as it's still connected)
const getConnectedProvider = (): CloudProvider | null => {
const isProviderActive = (status: string) => status === 'connected' || status === 'syncing';
if (isProviderActive(sync.providers.github.status)) return 'github';
if (isProviderActive(sync.providers.google.status)) return 'google';
if (isProviderActive(sync.providers.onedrive.status)) return 'onedrive';
if (isProviderActive(sync.providers.webdav.status)) return 'webdav';
if (isProviderActive(sync.providers.s3.status)) return 's3';
if (isProviderReadyForSync(sync.providers.github)) return 'github';
if (isProviderReadyForSync(sync.providers.google)) return 'google';
if (isProviderReadyForSync(sync.providers.onedrive)) return 'onedrive';
if (isProviderReadyForSync(sync.providers.webdav)) return 'webdav';
if (isProviderReadyForSync(sync.providers.s3)) return 's3';
return null;
};
@@ -136,9 +135,9 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
// Determine overall status for the button indicator
const getOverallStatus = (): StatusIndicatorProps['status'] => {
if (sync.isSyncing) return 'syncing';
if (sync.lastError) return 'error';
if (sync.hasAnyConnectedProvider) return 'synced';
if (sync.overallSyncStatus === 'syncing') return 'syncing';
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
if (sync.overallSyncStatus === 'synced') return 'synced';
return 'none';
};

View File

@@ -4,8 +4,8 @@ import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
// flushSync removed - no longer needed
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -21,18 +21,24 @@ import {
TerminalSettings,
KeyBinding,
} from "../types";
import {
shouldEnableNativeUserInputAutoScroll,
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
import SFTPModal from "./SFTPModal";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { toast } from "./ui/toast";
import { useAvailableFonts } from "../application/state/fontStore";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { useCustomThemes } from "../application/state/customThemeStore";
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
@@ -112,10 +118,8 @@ interface TerminalProps {
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
noAutoRun?: boolean;
serialConfig?: SerialConfig;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
hotkeyScheme?: "disabled" | "mac" | "pc";
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
@@ -135,8 +139,18 @@ interface TerminalProps {
) => void;
onSplitHorizontal?: () => void;
onSplitVertical?: () => void;
onOpenSftp?: (
host: Host,
initialPath?: string,
pendingUploadEntries?: DropEntry[],
sourceSessionId?: string,
) => void;
onOpenScripts?: () => void;
onOpenTheme?: () => void;
isBroadcastEnabled?: boolean;
onToggleBroadcast?: () => void;
onToggleComposeBar?: () => void;
isWorkspaceComposeBarOpen?: boolean;
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
}
@@ -171,10 +185,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings,
sessionId,
startupCommand,
noAutoRun,
serialConfig,
onUpdateTerminalThemeId,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
hotkeyScheme = "disabled",
keyBindings = [],
onHotkeyAction,
@@ -189,8 +201,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandExecuted,
onSplitHorizontal,
onSplitVertical,
onOpenSftp,
onOpenScripts,
onOpenTheme,
isBroadcastEnabled,
onToggleBroadcast,
onToggleComposeBar,
isWorkspaceComposeBarOpen,
onBroadcastInput,
}) => {
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
@@ -209,30 +226,36 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const commandBufferRef = useRef<string>("");
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
const serialLineBufferRef = useRef<string>("");
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
const lastFittedSizeRef = useRef<{ width: number; height: number } | null>(null);
useEffect(() => {
if (xtermRuntimeRef.current) {
// Merge global rules with host-level rules
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
// Check if highlighting is enabled at either global or host level
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
// Host-level toggle: undefined = inherit global, true/false = explicit override
const hostEnabled = host?.keywordHighlightEnabled;
// If host explicitly disabled highlighting, disable everything for this terminal
const effectiveGlobalEnabled = hostEnabled === false ? false : globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
// Merge rules: include only rules from enabled sources
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
...(effectiveGlobalEnabled ? globalRules : []),
...(effectiveHostEnabled ? hostRules : [])
];
// Enable highlighting if either global or host-level is enabled
const isEnabled = globalEnabled || hostEnabled;
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
}
@@ -260,11 +283,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippetsRef.current = snippets;
const terminalBackend = useTerminalBackend();
const { resizeSession } = terminalBackend;
const { resizeSession, setSessionEncoding } = terminalBackend;
const [isScriptsOpen, setIsScriptsOpen] = useState(false);
// isScriptsOpen state removed - scripts now handled by side panel
const [status, setStatus] = useState<TerminalSession["status"]>("connecting");
const [error, setError] = useState<string | null>(null);
const lastToastedErrorRef = useRef<string | null>(null);
@@ -273,7 +296,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [timeLeft, setTimeLeft] = useState(CONNECTION_TIMEOUT / 1000);
const [isCancelling, setIsCancelling] = useState(false);
const [showSFTP, setShowSFTP] = useState(false);
const [sftpInitialPath, setSftpInitialPath] = useState<string | undefined>(undefined);
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
@@ -289,7 +311,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Drag and drop state
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragCounterRef = useRef(0);
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
// pendingUploadEntries removed - drag-drop uploads now handled by SftpSidePanel
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
const [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
return 'utf-8';
});
const terminalEncodingRef = useRef(terminalEncoding);
terminalEncodingRef.current = terminalEncoding;
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
const {
@@ -344,13 +373,38 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
const pendingConnectionRef = useRef<(() => void) | null>(null);
// OSC-52 clipboard read prompt
const [osc52ReadPromptVisible, setOsc52ReadPromptVisible] = useState(false);
const osc52ReadResolverRef = useRef<((allowed: boolean) => void) | null>(null);
const handleOsc52ReadRequest = useCallback((): Promise<boolean> => {
// Reject if terminal is not visible (background tab) — user can't see the prompt
if (!isVisibleRef.current) return Promise.resolve(false);
// Reject if another prompt is already pending (avoid resolver overwrite)
if (osc52ReadResolverRef.current) return Promise.resolve(false);
return new Promise((resolve) => {
osc52ReadResolverRef.current = resolve;
setOsc52ReadPromptVisible(true);
});
}, []);
const handleOsc52ReadResponse = useCallback((allowed: boolean) => {
setOsc52ReadPromptVisible(false);
osc52ReadResolverRef.current?.(allowed);
osc52ReadResolverRef.current = null;
// Restore focus to terminal
termRef.current?.focus();
}, []);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const effectiveTheme = useMemo(() => {
if (host.theme) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme);
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme)
|| customThemes.find((t) => t.id === host.theme);
if (hostTheme) return hostTheme;
}
return terminalTheme;
}, [host.theme, terminalTheme]);
}, [host.theme, terminalTheme, customThemes]);
const resolvedChainHosts =
(host.hostChain?.hostIds
@@ -396,9 +450,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
resolvedChainHosts,
sessionId,
startupCommand,
noAutoRun,
terminalSettings,
terminalSettingsRef,
terminalBackend,
serialConfig,
isVisibleRef,
pendingOutputScrollRef,
sessionRef,
hasConnectedRef,
hasRunStartupCommandRef,
@@ -416,6 +474,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressLogs,
setProgressValue,
setChainProgress,
t,
onSessionAttached: (id: string) => {
// Sync terminal encoding to SSH backend before first data arrives
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
if (isSSH) {
setSessionEncoding(id, terminalEncodingRef.current);
}
},
onSessionExit,
onTerminalDataCapture,
onOsDetected,
@@ -427,6 +493,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
let disposed = false;
setError(null);
hasConnectedRef.current = false;
pendingOutputScrollRef.current = false;
setProgressLogs([]);
setShowLogs(false);
setIsCancelling(false);
@@ -459,6 +526,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
onOsc52ReadRequest: handleOsc52ReadRequest,
});
xtermRuntimeRef.current = runtime;
@@ -473,12 +541,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled;
const effectiveGlobalEnabled = hostEnabled === false ? false : globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
...(effectiveGlobalEnabled ? globalRules : []),
...(effectiveHostEnabled ? hostRules : [])
];
const isEnabled = globalEnabled || hostEnabled;
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
const term = runtime.term;
@@ -604,12 +674,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
}, [status, auth.needsAuth, host.protocol, host.hostname]);
const safeFit = () => {
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
if (options?.requireVisible && !isVisibleRef.current) return;
const container = containerRef.current;
if (!container) return;
const width = container.clientWidth;
const height = container.clientHeight;
if (width <= 0 || height <= 0) {
// Terminal is hidden — invalidate the cached size so that when it
// becomes visible again, a non-forced fit won't be suppressed by a
// stale size match (e.g. after font metrics changed while hidden).
lastFittedSizeRef.current = null;
return;
}
if (!options?.force) {
const lastSize = lastFittedSizeRef.current;
if (lastSize && lastSize.width === width && lastSize.height === height) {
return;
}
}
const runFit = () => {
try {
lastFittedSizeRef.current = { width, height };
fitAddon.fit();
} catch (err) {
logger.warn("Fit failed", err);
@@ -676,12 +768,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.scrollOnUserInput = terminalSettings.scrollOnInput;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
setTimeout(() => safeFit(), 50);
setTimeout(() => safeFit({ force: true }), 50);
}
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
@@ -699,15 +793,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
selectionBackground: effectiveTheme.colors.selection,
};
setTimeout(() => safeFit(), 50);
setTimeout(() => safeFit({ force: true }), 50);
}
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {
const timer = setTimeout(() => safeFit(), 50);
return () => clearTimeout(timer);
}
if (!isVisible) return;
const timer = setTimeout(() => {
safeFit({ requireVisible: true });
if (pendingOutputScrollRef.current) {
termRef.current?.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
termRef.current?.scrollToBottom();
});
}
pendingOutputScrollRef.current = false;
}
}, 50);
return () => clearTimeout(timer);
}, [isVisible]);
useEffect(() => {
@@ -778,17 +882,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
useEffect(() => {
if (!containerRef.current || !fitAddonRef.current) return;
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver(() => {
if (isResizing) return;
if (isResizing || !isVisibleRef.current) return;
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
}, 250);
});
@@ -803,7 +907,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (prevIsResizingRef.current && !isResizing && isVisible) {
const timer = setTimeout(() => {
safeFit();
safeFit({ force: true, requireVisible: true });
}, 100);
return () => clearTimeout(timer);
}
@@ -813,7 +917,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
const timer = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
}, 100);
return () => clearTimeout(timer);
}, [inWorkspace, isVisible]);
@@ -853,18 +957,75 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
term.onSelectionChange(onSelectionChange);
const disposable = term.onSelectionChange(onSelectionChange);
return () => disposable.dispose();
}, [terminalSettings?.copyOnSelect]);
// Track whether the terminal application has enabled mouse tracking
// (e.g. tmux with `set -g mouse on`, vim with `set mouse=a`).
// When mouse tracking is active, disable Netcatty's context menu to avoid
// conflicting with the application's own mouse handling.
useEffect(() => {
const term = termRef.current;
if (!term) return;
const disposable = term.onWriteParsed(() => {
const tracking = term.modes.mouseTrackingMode !== 'none';
if (tracking !== mouseTrackingRef.current) {
mouseTrackingRef.current = tracking;
setHasMouseTracking(tracking);
}
});
// Set initial state
const initial = term.modes.mouseTrackingMode !== 'none';
mouseTrackingRef.current = initial;
setHasMouseTracking(initial);
return () => disposable.dispose();
}, [sessionId]);
// Prevent xterm.js's built-in rightClickHandler and right-button mouseup
// from interfering with tmux/vim popup menus when mouse tracking is active.
// - contextmenu: xterm.js calls textarea.select() which steals focus
// - mouseup (button 2): tmux interprets the right-button release as a
// dismiss action, closing the popup menu immediately after it appears
// Both are intercepted at the capture phase before xterm.js's own listeners.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleContextMenuCapture = (e: MouseEvent) => {
if (mouseTrackingRef.current) {
e.preventDefault();
e.stopImmediatePropagation();
}
};
const handleMouseUpCapture = (e: MouseEvent) => {
if (e.button === 2 && mouseTrackingRef.current) {
e.stopImmediatePropagation();
}
};
el.addEventListener('contextmenu', handleContextMenuCapture, true);
el.addEventListener('mouseup', handleMouseUpCapture, true);
return () => {
el.removeEventListener('contextmenu', handleContextMenuCapture, true);
el.removeEventListener('mouseup', handleMouseUpCapture, true);
};
}, [sessionId]);
useEffect(() => {
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
if (!isVisibleRef.current) return;
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = setTimeout(() => {
safeFit();
safeFit({ requireVisible: true });
}, 250);
};
@@ -875,48 +1036,57 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
}, []);
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
const scrollToBottomAfterProgrammaticInput = (data: string) => {
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
termRef.current.scrollToBottom();
}
};
const terminalContextActions = useTerminalContextActions({
termRef,
sessionRef,
terminalBackend,
onHasSelectionChange: setHasSelection,
disableBracketedPasteRef,
scrollOnPasteRef,
});
const handleSnippetClick = (cmd: string) => {
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
setTerminalEncoding(encoding);
if (sessionRef.current) {
terminalBackend.writeToSession(sessionRef.current, `${cmd}\r`);
setIsScriptsOpen(false);
termRef.current?.focus();
return;
setSessionEncoding(sessionRef.current, encoding);
}
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleOpenSFTP = async () => {
// If SFTP is already open, toggle it off
if (onOpenSftp) {
// Delegate to parent (TerminalLayer) for shared SFTP side panel
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail
}
}
onOpenSftp(host, initialPath, undefined, sessionId);
return;
}
// Fallback: toggle internal SFTP state (shouldn't happen with new architecture)
if (showSFTP) {
setShowSFTP(false);
return;
}
// Try to get the current working directory from the terminal session
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail and open SFTP without initial path
}
}
// Use flushSync to ensure initialPath state is committed before opening SFTP modal
// This prevents React's batching from causing the modal to open with stale/undefined initialPath
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
};
@@ -974,6 +1144,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!termRef.current) return;
cleanupSession();
auth.resetForRetry();
hasRunStartupCommandRef.current = false;
setStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
@@ -1043,30 +1214,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const pathsText = paths.join(' ');
// Write the paths to the terminal
terminalBackend.writeToSession(sessionRef.current, pathsText);
scrollToBottomAfterProgrammaticInput(pathsText);
termRef.current.focus();
}
} else {
// Remote terminal: Trigger SFTP upload
// Get current working directory for SFTP initial path
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
// Remote terminal: Trigger SFTP upload via parent
if (onOpenSftp) {
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail
}
} catch {
// Silently fail and open SFTP without initial path
}
onOpenSftp(host, initialPath, dropEntries, sessionId);
}
setPendingUploadEntries(dropEntries);
// Use flushSync to ensure sftpInitialPath is updated synchronously
// before setShowSFTP(true) triggers the modal open
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
}
} catch (error) {
logger.error("Failed to handle file drop", error);
@@ -1077,23 +1243,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const renderControls = (opts?: { showClose?: boolean }) => (
<TerminalToolbar
status={status}
snippets={snippets}
host={host}
defaultThemeId={terminalTheme.id}
defaultFontFamilyId={fontFamilyId}
defaultFontSize={fontSize}
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
isScriptsOpen={isScriptsOpen}
setIsScriptsOpen={setIsScriptsOpen}
onOpenSFTP={handleOpenSFTP}
onSnippetClick={handleSnippetClick}
onOpenScripts={onOpenScripts ?? (() => {})}
onOpenTheme={onOpenTheme ?? (() => {})}
onUpdateHost={onUpdateHost}
showClose={opts?.showClose}
onClose={() => onCloseSession?.(sessionId)}
isSearchOpen={isSearchOpen}
onToggleSearch={handleToggleSearch}
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
terminalEncoding={terminalEncoding}
onSetTerminalEncoding={handleSetTerminalEncoding}
/>
);
@@ -1103,8 +1265,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
: status === "connecting"
? "bg-amber-400"
: "bg-rose-500";
const _isConnecting = status === "connecting";
const _hasError = Boolean(error);
return (
<TerminalContextMenu
@@ -1112,6 +1272,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
isAlternateScreen={hasMouseTracking}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
onSelectAll={terminalContextActions.onSelectAll}
@@ -1122,7 +1283,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
>
<div
className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]"
className={cn(
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
isComposeBarOpen && !inWorkspace && "flex-col"
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -1299,6 +1463,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
</div>
)}
{/* Swap bar */}
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
<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>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-rose-500" />
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
</div>
</div>
</div>
)}
{/* Top 10 processes */}
{serverStats.topProcesses.length > 0 && (
<div className="space-y-1.5">
@@ -1488,7 +1680,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
<div
className="h-full flex-1 min-w-0 transition-all duration-300 relative overflow-hidden pt-8"
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
style={{ backgroundColor: effectiveTheme.colors.background }}
>
<div
@@ -1513,6 +1705,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
)}
{/* OSC-52 clipboard read prompt */}
{osc52ReadPromptVisible && (
<div
className="absolute inset-0 z-40 flex items-center justify-center bg-background/60"
onKeyDown={(e) => {
if (e.key === 'Escape') handleOsc52ReadResponse(false);
}}
>
<div className="rounded-lg border bg-card p-4 shadow-lg max-w-sm space-y-3">
<p className="text-sm font-medium">{t("terminal.osc52.readPrompt.title")}</p>
<p className="text-sm text-muted-foreground">{t("terminal.osc52.readPrompt.desc")}</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => handleOsc52ReadResponse(false)}>
{t("terminal.osc52.readPrompt.deny")}
</Button>
<Button size="sm" autoFocus onClick={() => handleOsc52ReadResponse(true)}>
{t("terminal.osc52.readPrompt.allow")}
</Button>
</div>
</div>
</div>
)}
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
{status !== "connected" && !needsHostKeyVerification && !(
(isLocalConnection || isSerialConnection) && status === "connecting"
@@ -1559,75 +1774,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
</div>
<SFTPModal
host={host}
credentials={(() => {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
// Build proxy config if present
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
{isComposeBarOpen && !inWorkspace && (
<TerminalComposeBar
onSend={(text) => {
if (sessionRef.current) {
const payload = text + '\r';
terminalBackend.writeToSession(sessionRef.current, payload);
scrollToBottomAfterProgrammaticInput(payload);
onBroadcastInput?.(payload, sessionRef.current);
}
: undefined;
// Build jump hosts array if host chain is configured
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => allHosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
};
});
}
return {
username: resolvedAuth.username,
hostname: host.hostname,
port: host.port,
password: resolvedAuth.password,
privateKey: resolvedAuth.key?.privateKey,
certificate: resolvedAuth.key?.certificate,
passphrase: resolvedAuth.passphrase,
publicKey: resolvedAuth.key?.publicKey,
keyId: resolvedAuth.keyId,
keySource: resolvedAuth.key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sftpSudo: host.sftpSudo,
};
})()}
open={showSFTP && status === "connected"}
onClose={() => {
setShowSFTP(false);
setPendingUploadEntries([]);
}}
initialPath={sftpInitialPath}
initialEntriesToUpload={pendingUploadEntries}
/>
}}
onClose={() => {
setIsComposeBarOpen(false);
termRef.current?.focus();
}}
isBroadcastEnabled={isBroadcastEnabled}
themeColors={effectiveTheme.colors}
/>
)}
</div>
</TerminalContextMenu>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { Users } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
import {
@@ -63,17 +63,12 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
// Reserved for future hover preview feature
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
// Group themes by type - reserved for future sectioned view
const _groupedThemes = useMemo(() => {
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
return { dark, light };
}, []);
const customThemes = useCustomThemes();
// Find selected theme info - reserved for displaying selection details
const _selectedTheme = useMemo(() => {
return TERMINAL_THEMES.find(t => t.id === selectedThemeId);
}, [selectedThemeId]);
// All themes combined
const allThemes = useMemo(() => {
return [...TERMINAL_THEMES, ...customThemes];
}, [customThemes]);
const renderThemeItem = (theme: TerminalTheme) => {
const isSelected = theme.id === selectedThemeId;
@@ -99,36 +94,12 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
)}>
{theme.name}
</div>
{/* Show usage stats or badge */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{theme.id === 'netcatty-dark' && (
<span className="text-muted-foreground">Default</span>
)}
{theme.id === 'netcatty-light' && (
<>
<Users size={10} />
<span>Light mode</span>
</>
)}
{theme.id === 'flexoki-dark' && (
<span className="text-xs">new</span>
)}
{theme.id === 'flexoki-light' && (
<span className="text-xs">new</span>
)}
{theme.id.startsWith('kanagawa') && (
<>
<Users size={10} />
<span>{Math.floor(Math.random() * 20000)}</span>
</>
)}
{theme.id.startsWith('hacker') && (
<>
<Users size={10} />
<span>{Math.floor(Math.random() * 15000)}</span>
</>
)}
</div>
{theme.id === 'netcatty-dark' && (
<div className="text-xs text-muted-foreground">Default</div>
)}
{theme.id === 'netcatty-light' && (
<div className="text-xs text-muted-foreground">Light mode</div>
)}
</div>
</button>
);
@@ -146,7 +117,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
<ScrollArea className="h-full">
<div className="py-2">
{/* All themes in a single list */}
{TERMINAL_THEMES.map(renderThemeItem)}
{allThemes.map(renderThemeItem)}
</div>
</ScrollArea>
</AsidePanelContent>

View File

@@ -1,11 +1,13 @@
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Shield, Square, Sun, TerminalSquare, X } from 'lucide-react';
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
import { LogView } from '../application/state/useSessionState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useI18n } from '../application/i18n/I18nProvider';
import { normalizeDistroId } from '../domain/host';
import { cn } from '../lib/utils';
import { TerminalSession, Workspace } from '../types';
import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { SyncStatusButton } from './SyncStatusButton';
@@ -16,6 +18,7 @@ const dragRegionNoSelect = { WebkitAppRegion: 'drag', userSelect: 'none' } as Re
interface TopTabsProps {
theme: 'dark' | 'light';
hosts: Host[];
sessions: TerminalSession[];
orphanSessions: TerminalSession[];
workspaces: Workspace[];
@@ -38,6 +41,82 @@ interface TopTabsProps {
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
}
// Detect local OS for local terminal tab icons
const localOsId = (() => {
if (typeof navigator === 'undefined') return 'linux';
const ua = navigator.userAgent;
if (/Mac/i.test(ua)) return 'macos';
if (/Win/i.test(ua)) return 'windows';
return 'linux';
})();
// Lightweight OS/distro icon for session tabs — matches DistroAvatar "sm" style
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
const iconSize = "h-2.5 w-2.5";
const fallbackIcon = cn(iconSize, isActive ? "text-accent" : "text-muted-foreground");
// Serial protocol → USB icon
if (protocol === 'serial' || host?.protocol === 'serial') {
return (
<div className={cn(boxBase, "bg-amber-500/15 text-amber-500")}>
<Usb className={iconSize} />
</div>
);
}
// Local protocol → OS-specific icon (protocol may be undefined for local sessions)
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
const logo = DISTRO_LOGOS[localOsId];
const bg = DISTRO_COLORS[localOsId] || DISTRO_COLORS.default;
if (logo) {
return (
<div className={cn(boxBase, bg)}>
<img
src={logo}
alt={localOsId}
className={cn(iconSize, "object-contain invert brightness-0")}
/>
</div>
);
}
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<TerminalSquare className={iconSize} />
</div>
);
}
// Try distro logo with brand background color
if (host) {
const distro = normalizeDistroId(host.distro) || (host.distro || '').toLowerCase();
const logo = DISTRO_LOGOS[distro];
if (logo) {
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
return (
<div className={cn(boxBase, bg)}>
<img
src={logo}
alt={host.distro || host.os}
className={cn(iconSize, "object-contain invert brightness-0")}
/>
</div>
);
}
}
// Fallback: generic server icon for remote, terminal for unknown
if (host && host.protocol !== 'local') {
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<Server className={iconSize} />
</div>
);
}
return <TerminalSquare className={fallbackIcon} />;
});
SessionTabIcon.displayName = 'SessionTabIcon';
const sessionStatusDot = (status: TerminalSession['status']) => {
const tone = status === 'connected'
? "bg-emerald-400"
@@ -56,12 +135,19 @@ const WindowControls: React.FC = memo(() => {
// Check initial maximized state
fetchIsMaximized().then(v => setIsMaximized(!!v));
// Listen for window resize to update maximized state
// Listen for window resize to update maximized state (debounced to avoid IPC storm)
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const handleResize = () => {
fetchIsMaximized().then(v => setIsMaximized(!!v));
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
fetchIsMaximized().then(v => setIsMaximized(!!v));
}, 200);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (resizeTimer) clearTimeout(resizeTimer);
};
}, [fetchIsMaximized]);
const handleMinimize = () => {
@@ -78,17 +164,17 @@ const WindowControls: React.FC = memo(() => {
};
return (
<div className="flex items-center app-drag">
<div className="flex items-center app-drag h-full">
<button
onClick={handleMinimize}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
title="Minimize"
>
<Minus size={16} />
</button>
<button
onClick={handleMaximize}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
title={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
@@ -101,7 +187,7 @@ const WindowControls: React.FC = memo(() => {
</button>
<button
onClick={handleClose}
className="h-8 w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
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} />
@@ -113,6 +199,7 @@ WindowControls.displayName = 'WindowControls';
const TopTabsInner: React.FC<TopTabsProps> = ({
theme,
hosts,
sessions,
orphanSessions,
workspaces,
@@ -235,6 +322,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return map;
}, [logViews]);
const hostMap = useMemo(() => {
const map = new Map<string, Host>();
for (const h of hosts) map.set(h.id, h);
return map;
}, [hosts]);
// Pre-compute session counts per workspace for O(1) access
const workspacePaneCounts = useMemo(() => {
const counts = new Map<string, number>();
@@ -376,26 +469,29 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, session.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
activeTabId === session.id ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-150",
activeTabId === session.id
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={{
...shiftStyle,
...(activeTabId === session.id ? { borderColor: 'hsl(var(--accent))' } : {})
}}
style={shiftStyle}
>
{/* Active tab top accent line */}
{activeTabId === session.id && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<TerminalSquare size={14} className={cn("shrink-0", activeTabId === session.id ? "text-accent" : "text-muted-foreground")} />
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
<span className="truncate">{session.hostLabel}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status)}</div>
</div>
@@ -445,23 +541,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDragLeave={handleTabDragLeave}
onDrop={(e) => handleTabDrop(e, workspace.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground",
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-150",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={{
...shiftStyle,
...(isActive ? { borderColor: 'hsl(var(--accent))' } : {})
}}
style={shiftStyle}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-1.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-2 truncate">
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
@@ -495,12 +594,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
data-tab-id={logView.id}
onClick={() => onSelectTab(logView.id)}
className={cn(
"relative h-6 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-md border text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-200 ease-out",
isActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-colors duration-150",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={isActive ? { borderColor: 'hsl(var(--accent))' } : {}}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText size={14} className={cn("shrink-0", isActive ? "text-accent" : "text-muted-foreground")} />
<span className="truncate">
@@ -536,36 +640,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return (
<div
className="relative w-full bg-secondary border-b border-border/60 app-drag"
className="relative w-full bg-secondary app-drag"
style={dragRegionNoSelect}
onDoubleClick={handleTitleBarDoubleClick}
>
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
<div
className="h-8 px-3 flex items-center gap-2 app-drag"
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12 }}
className="h-9 flex items-end gap-0 app-drag"
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
>
{/* Fixed left tabs: Vaults and SFTP */}
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
<div
onClick={() => onSelectTab('vault')}
className={cn(
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
isVaultActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"transition-colors duration-150",
isVaultActive
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={isVaultActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
>
<Shield size={14} /> Vaults
</div>
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"h-6 px-3 rounded-md border text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
isSftpActive ? "bg-accent/20 text-foreground" : "border-border/60 text-muted-foreground hover:border-accent/40 hover:text-foreground"
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"transition-colors duration-150",
isSftpActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={isSftpActive ? { borderColor: 'hsl(var(--accent))' } : undefined}
>
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
<Folder size={14} /> SFTP
</div>
</div>
@@ -594,7 +703,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* Scrollable container */}
<div
ref={tabsContainerRef}
className="flex items-center gap-2 overflow-x-auto scrollbar-none app-drag max-w-full"
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{renderOrderedTabs()}
@@ -603,7 +712,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 app-no-drag"
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
onClick={onOpenQuickSwitcher}
title="Open quick switcher"
>
@@ -611,7 +720,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</Button>
)}
{/* Draggable spacer - fixed width handle at the end */}
<div className="min-w-[20px] h-6 app-drag flex-shrink-0" style={dragRegionStyle} />
<div className="min-w-[20px] h-7 app-drag flex-shrink-0" style={dragRegionStyle} />
</div>
{/* Right fade mask */}
@@ -628,7 +737,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0 app-no-drag"
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
onClick={onOpenQuickSwitcher}
title="More tabs"
>
@@ -637,7 +746,16 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
)}
{/* Fixed right controls */}
<div className="flex-shrink-0 flex items-center gap-2 app-drag" style={dragRegionStyle}>
<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 text-muted-foreground hover:text-foreground app-no-drag"
title="AI Assistant"
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
>
<Sparkles size={16} />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
<Bell size={16} />
</Button>
@@ -653,9 +771,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</Button>
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <WindowControls />}
{/* Small drag shim to the right edge */}
<div className="w-2 h-8 app-drag flex-shrink-0" />
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
{/* Small drag shim to the right edge (macOS only on Windows the close button should touch the edge) */}
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0" />}
</div>
</div>
);
@@ -665,6 +783,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
return (
prev.theme === next.theme &&
prev.hosts === next.hosts &&
prev.sessions === next.sessions &&
prev.orphanSessions === next.orphanSessions &&
prev.workspaces === next.workspaces &&

View File

@@ -10,7 +10,7 @@ import { I18nProvider } from "../application/i18n/I18nProvider";
import { useSettingsState } from "../application/state/useSettingsState";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { X, Maximize2, ChevronRight, ChevronDown } from "lucide-react";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
import { AppLogo } from "./AppLogo";
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
@@ -109,6 +109,7 @@ const TrayPanelContent: React.FC = () => {
const {
hideTrayPanel,
openMainWindow,
quitApp,
jumpToSession,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
@@ -151,7 +152,7 @@ const TrayPanelContent: React.FC = () => {
}, [onTrayPanelRefresh]);
const keysForPf = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
[keys],
);
@@ -200,8 +201,12 @@ const TrayPanelContent: React.FC = () => {
void openMainWindow();
}, [openMainWindow]);
const handleQuit = useCallback(() => {
void quitApp();
}, [quitApp]);
return (
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden">
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
<div className="flex items-center gap-2">
<AppLogo className="w-5 h-5" />
@@ -225,7 +230,7 @@ const TrayPanelContent: React.FC = () => {
</div>
</div>
<div className="p-2 space-y-3 text-sm">
<div className="p-2 space-y-3 text-sm flex-1 overflow-y-auto min-h-0">
{jumpableSessions.length > 0 && (() => {
// Group sessions by workspace
@@ -378,6 +383,17 @@ const TrayPanelContent: React.FC = () => {
</div>
)}
</div>
{/* Quit button at the bottom */}
<div className="px-3 py-2 border-t border-border/60">
<button
className="w-full text-left px-2 py-1.5 rounded hover:bg-destructive/10 flex items-center gap-2 text-sm text-muted-foreground hover:text-destructive transition-colors"
onClick={handleQuit}
>
<Power size={14} />
<span>{t("tray.quit")}</span>
</button>
</div>
</div>
);
};

View File

@@ -715,6 +715,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
return root;
}, [hosts, customGroups]);
// Generate all possible group paths from the tree (including all intermediate nodes)
const allGroupPaths = useMemo(() => {
const paths = new Set<string>();
const traverse = (nodes: Record<string, GroupNode>) => {
Object.values(nodes).forEach((node) => {
if (node.path) {
paths.add(node.path);
}
if (node.children) {
traverse(node.children);
}
});
};
// Traverse the tree
traverse(buildGroupTree);
return Array.from(paths).sort();
}, [buildGroupTree]);
const findGroupNode = (path: string | null): GroupNode | null => {
if (!path)
@@ -879,6 +899,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
return root;
}, [treeViewHosts, customGroups]);
// Helper function to recursively count all hosts in a node and its children
const countAllHostsInNode = (node: GroupNode): number => {
let count = node.hosts.length;
if (node.children) {
Object.values(node.children).forEach((child) => {
count += countAllHostsInNode(child);
});
}
return count;
};
// Create tree view specific group tree that excludes ungrouped hosts
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
return (Object.values(buildTreeViewGroupTree) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name));
@@ -1718,7 +1749,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: node.hosts.length })}
{t("vault.groups.hostsCount", { count: countAllHostsInNode(node) })}
</div>
</div>
</div>
@@ -1857,6 +1888,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={handleUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<div className="space-y-6">
@@ -2000,11 +2034,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<LayoutGrid size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
Set up your hosts
{t('vault.hosts.empty.title')}
</h3>
<p className="text-sm text-center max-w-sm">
Save hosts to quickly connect to your servers, VMs,
and containers.
{t('vault.hosts.empty.desc')}
</p>
</div>
)}
@@ -2136,11 +2169,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<LayoutGrid size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
Set up your hosts
{t('vault.hosts.empty.title')}
</h3>
<p className="text-sm text-center max-w-sm">
Save hosts to quickly connect to your servers, VMs,
and containers.
{t('vault.hosts.empty.desc')}
</p>
</div>
)}
@@ -2169,6 +2201,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
: [...snippets, s],
)
}
onBulkSave={onUpdateSnippets}
onDelete={(id) =>
onUpdateSnippets(snippets.filter((s) => s.id !== id))
}
@@ -2269,12 +2302,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
initialData={editingHost}
availableKeys={keys}
identities={identities}
groups={Array.from(
new Set([
...customGroups,
...hosts.map((h) => h.group || "General"),
]),
)}
groups={allGroupPaths}
managedSources={managedSources}
allTags={allTags}
allHosts={hosts}
@@ -2309,12 +2337,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<SerialHostDetailsPanel
initialData={editingHost}
allTags={allTags}
groups={Array.from(
new Set([
...customGroups,
...hosts.map((h) => h.group || "General"),
]),
)}
groups={allGroupPaths}
onSave={(host) => {
onUpdateHosts(
hosts.map((h) => (h.id === host.id ? host : h)),
@@ -2494,7 +2517,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
open={isQuickConnectOpen}
target={quickConnectTarget}
keys={keys}
knownHosts={knownHosts}
onConnect={handleQuickConnect}
onSaveHost={handleQuickConnectSaveHost}
onClose={() => {
@@ -2542,6 +2564,7 @@ const vaultViewAreEqual = (
const isEqual =
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.customGroups === next.customGroups &&

View File

@@ -0,0 +1,89 @@
import { cn } from '../../lib/utils';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import React, { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
import { ArrowDown } from 'lucide-react';
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
<StickToBottom.Content
className={cn('flex flex-col gap-4 p-4', className)}
{...props}
/>
);
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
description?: string;
icon?: ReactNode;
}
export const ConversationEmptyState = ({
className,
title,
description,
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
className,
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleClick = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
if (isAtBottom) return null;
return (
<button
type="button"
className={cn(
'absolute bottom-3 left-1/2 -translate-x-1/2 z-10',
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
'flex items-center justify-center',
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
'shadow-sm',
className,
)}
onClick={handleClick}
{...props}
>
<ArrowDown size={14} />
</button>
);
};

View File

@@ -0,0 +1,87 @@
import { cn } from '../../lib/utils';
import { cjk } from '@streamdown/cjk';
import { code } from '@streamdown/code';
import type { ComponentProps, HTMLAttributes } from 'react';
import { memo } from 'react';
import { Streamdown } from 'streamdown';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: 'user' | 'assistant' | 'system' | 'tool';
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
'group flex w-full max-w-[95%] flex-col gap-1.5',
from === 'user' ? 'is-user ml-auto' : 'is-assistant',
className,
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
<div
className={cn(
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-4 group-[.is-user]:py-2.5',
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
className,
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<'div'>;
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
<div
className={cn(
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
className,
)}
{...props}
>
{children}
</div>
);
const streamdownPlugins = { cjk, code };
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
'size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
// Style the rendered markdown
// Code: base styles (code-block overrides are in index.css)
'[&_code]:text-[12px] [&_code]:font-mono',
'[&_p_code]:px-[0.4em] [&_p_code]:py-[0.15em] [&_p_code]:rounded [&_p_code]:bg-foreground/[0.06] [&_p_code]:text-[85%]',
'[&_p]:my-1.5',
'[&_ul]:my-1.5 [&_ul]:pl-4 [&_ul]:list-disc',
'[&_ol]:my-1.5 [&_ol]:pl-4 [&_ol]:list-decimal',
'[&_li]:my-0.5',
'[&_h1]:text-base [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2',
'[&_h2]:text-sm [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5',
'[&_h3]:text-sm [&_h3]:font-medium [&_h3]:mt-2 [&_h3]:mb-1',
'[&_blockquote]:border-l-2 [&_blockquote]:border-border/50 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground',
'[&_a]:text-primary [&_a]:underline',
'[&_hr]:border-border/30 [&_hr]:my-3',
'[&_table]:text-[12px] [&_th]:px-2 [&_th]:py-1 [&_th]:border [&_th]:border-border/30 [&_th]:bg-muted/20 [&_td]:px-2 [&_td]:py-1 [&_td]:border [&_td]:border-border/30',
className,
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating,
);
MessageResponse.displayName = 'MessageResponse';

View File

@@ -0,0 +1,283 @@
/**
* PromptInput - Adapted from Vercel AI Elements prompt-input for netcatty.
*
* Simplified: no file attachments, screenshots, drag-drop, command palette,
* hover cards, referenced sources, or tabs. Core input + footer + submit.
*/
import { ArrowUp, Square, X } from 'lucide-react';
import type {
ComponentProps,
ComponentPropsWithoutRef,
ElementRef,
FormEvent,
HTMLAttributes,
KeyboardEvent,
ReactNode,
} from 'react';
import { forwardRef, useCallback, useRef } from 'react';
import { cn } from '../../lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from '../ui/input-group';
import { Spinner } from '../ui/spinner';
// ---------------------------------------------------------------------------
// PromptInput (form wrapper)
// ---------------------------------------------------------------------------
export interface PromptInputProps extends HTMLAttributes<HTMLFormElement> {
onSubmit: (text: string, event: FormEvent<HTMLFormElement>) => void | Promise<void>;
}
export const PromptInput = forwardRef<HTMLFormElement, PromptInputProps>(
({ className, onSubmit, children, ...props }, ref) => {
const handleSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const textarea = form.querySelector('textarea');
const text = textarea?.value?.trim() ?? '';
if (!text) return;
onSubmit(text, e);
},
[onSubmit],
);
return (
<form ref={ref} onSubmit={handleSubmit} className={className} {...props}>
<InputGroup>{children}</InputGroup>
</form>
);
},
);
PromptInput.displayName = 'PromptInput';
// ---------------------------------------------------------------------------
// PromptInputTextarea
// ---------------------------------------------------------------------------
export interface PromptInputTextareaProps extends ComponentProps<'textarea'> {
/** Called when Enter is pressed (without Shift) to trigger form submit */
onSubmitRequest?: () => void;
}
export const PromptInputTextarea = forwardRef<HTMLTextAreaElement, PromptInputTextareaProps>(
({ className, onSubmitRequest, onKeyDown, ...props }, ref) => {
const internalRef = useRef<HTMLTextAreaElement | null>(null);
const setRef = useCallback(
(node: HTMLTextAreaElement | null) => {
internalRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
// CJK composition guard
if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSubmitRequest?.();
// Trigger form submit
const form = internalRef.current?.closest('form');
if (form) {
form.requestSubmit();
}
}
},
[onKeyDown, onSubmitRequest],
);
return (
<InputGroupTextarea
ref={setRef}
className={className}
onKeyDown={handleKeyDown}
{...props}
/>
);
},
);
PromptInputTextarea.displayName = 'PromptInputTextarea';
// ---------------------------------------------------------------------------
// PromptInputFooter
// ---------------------------------------------------------------------------
export type PromptInputFooterProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputFooter = forwardRef<HTMLDivElement, PromptInputFooterProps>(
({ className, ...props }, ref) => (
<InputGroupAddon
ref={ref}
align="block-end"
className={cn('gap-1', className)}
{...props}
/>
),
);
PromptInputFooter.displayName = 'PromptInputFooter';
// ---------------------------------------------------------------------------
// PromptInputTools (left side of footer)
// ---------------------------------------------------------------------------
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center gap-0.5', className)}
{...props}
/>
),
);
PromptInputTools.displayName = 'PromptInputTools';
// ---------------------------------------------------------------------------
// PromptInputButton (toolbar button with optional tooltip)
// ---------------------------------------------------------------------------
export interface PromptInputButtonProps extends ComponentProps<typeof InputGroupButton> {
tooltip?: ReactNode;
tooltipSide?: 'top' | 'bottom' | 'left' | 'right';
}
export const PromptInputButton = forwardRef<HTMLButtonElement, PromptInputButtonProps>(
({ tooltip, tooltipSide = 'top', ...props }, ref) => {
const button = <InputGroupButton ref={ref} {...props} />;
if (!tooltip) return button;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={tooltipSide}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
PromptInputButton.displayName = 'PromptInputButton';
// ---------------------------------------------------------------------------
// PromptInputSubmit
// ---------------------------------------------------------------------------
export type PromptInputStatus = 'idle' | 'submitted' | 'streaming' | 'error';
export interface PromptInputSubmitProps extends ComponentProps<typeof InputGroupButton> {
status?: PromptInputStatus;
onStop?: () => void;
}
export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmitProps>(
({ status = 'idle', onStop, className, disabled, ...props }, ref) => {
const isRunning = status === 'submitted' || status === 'streaming';
const handleClick = useCallback(() => {
if (isRunning && onStop) {
onStop();
}
}, [isRunning, onStop]);
const icon =
status === 'submitted' ? (
<Spinner size={14} />
) : status === 'streaming' ? (
<Square size={14} />
) : status === 'error' ? (
<X size={14} />
) : (
<ArrowUp size={14} />
);
const tooltipLabel =
status === 'submitted'
? 'Waiting...'
: status === 'streaming'
? 'Stop'
: status === 'error'
? 'Error'
: 'Send';
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InputGroupButton
ref={ref}
type={isRunning ? 'button' : 'submit'}
onClick={isRunning ? handleClick : undefined}
variant="ghost"
disabled={disabled && !isRunning}
className={cn(
'h-8 w-8 rounded-full border p-0 shadow-sm disabled:opacity-100',
isRunning
? 'border-destructive/60 bg-destructive/85 text-destructive-foreground hover:bg-destructive'
: disabled
? 'border-border/80 bg-muted/52 text-foreground/72 hover:bg-muted/52'
: 'border-foreground/20 bg-foreground text-background hover:bg-foreground/90',
className,
)}
{...props}
>
{icon}
</InputGroupButton>
</TooltipTrigger>
<TooltipContent side="top">{tooltipLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
PromptInputSubmit.displayName = 'PromptInputSubmit';
// ---------------------------------------------------------------------------
// PromptInputSelect (thin wrappers around the project's Select component)
// ---------------------------------------------------------------------------
export const PromptInputSelect = Select;
export const PromptInputSelectTrigger = forwardRef<
ElementRef<typeof SelectTrigger>,
ComponentPropsWithoutRef<typeof SelectTrigger>
>(({ className, ...props }, ref) => (
<SelectTrigger
ref={ref}
className={cn(
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
'text-muted-foreground/40 hover:text-muted-foreground/70',
'focus:ring-0 focus:ring-offset-0',
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
className,
)}
{...props}
/>
));
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
export const PromptInputSelectContent = SelectContent;
export const PromptInputSelectItem = SelectItem;
export const PromptInputSelectValue = SelectValue;

View File

@@ -0,0 +1,65 @@
import { cn } from '../../lib/utils';
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
args?: Record<string, unknown>;
result?: unknown;
isError?: boolean;
isLoading?: boolean;
}
export const ToolCall = ({ name, args, result, isError, isLoading, className, ...props }: ToolCallProps) => {
const [expanded, setExpanded] = useState(false);
const statusIcon = isLoading ? (
<Loader2 size={12} className="animate-spin text-blue-400/70" />
) : isError ? (
<XCircle size={12} className="text-red-400/70" />
) : result !== undefined ? (
<CheckCircle2 size={12} className="text-green-400/70" />
) : null;
return (
<div className={cn('rounded-md border border-border/25 bg-muted/10 overflow-hidden text-[12px]', className)} {...props}>
<button
type="button"
onClick={() => setExpanded(e => !e)}
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
>
{expanded
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
}
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
<span className="flex-1" />
{statusIcon}
</button>
{expanded && (
<div className="border-t border-border/20">
{args && Object.keys(args).length > 0 && (
<div className="px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
<pre className="text-[11px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all">
{JSON.stringify(args, null, 2)}
</pre>
</div>
)}
{result !== undefined && (
<div className="px-3 py-2 border-t border-border/20">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
<pre className={cn(
'text-[11px] font-mono whitespace-pre-wrap break-all',
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
)}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,220 @@
import React from 'react';
import { cn } from '../../lib/utils';
type AgentLike = {
id?: string;
name?: string;
type?: 'builtin' | 'external';
icon?: string;
command?: string;
};
type AgentIconKey =
| 'catty'
| 'openai'
| 'claude'
| 'anthropic'
| 'gemini'
| 'google'
| 'ollama'
| 'openrouter'
| 'zed'
| 'atom'
| 'terminal'
| 'plus';
type AgentIconVisual = {
src: string;
badgeClassName: string;
imageClassName: string;
};
const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
catty: {
src: '/ai/agents/catty.svg',
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
openai: {
src: '/ai/providers/openai.svg',
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
claude: {
src: '/ai/agents/claude.svg',
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
anthropic: {
src: '/ai/providers/anthropic.svg',
badgeClassName: 'border-orange-500/22 bg-orange-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
gemini: {
src: '/ai/agents/gemini.svg',
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
google: {
src: '/ai/providers/google.svg',
badgeClassName: 'border-sky-500/22 bg-sky-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
ollama: {
src: '/ai/providers/ollama.svg',
badgeClassName: 'border-violet-500/22 bg-violet-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
openrouter: {
src: '/ai/providers/openrouter.svg',
badgeClassName: 'border-fuchsia-500/22 bg-fuchsia-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
zed: {
src: '/ai/agents/zed.svg',
badgeClassName: 'border-cyan-500/22 bg-cyan-500/12',
imageClassName: 'object-contain dark:brightness-0 dark:invert',
},
atom: {
src: '/ai/agents/atom.svg',
badgeClassName: 'border-amber-500/18 bg-amber-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
terminal: {
src: '/ai/agents/terminal.svg',
badgeClassName: 'border-white/8 bg-white/[0.04]',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
plus: {
src: '/ai/agents/plus.svg',
badgeClassName: 'border-white/8 bg-white/[0.04]',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-85',
},
};
function normalizeToken(value?: string): string {
return (value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
}
function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
if (agent === 'add-more') {
return 'plus';
}
if (agent.type === 'builtin') {
return 'catty';
}
const tokens = [
normalizeToken(agent.icon),
normalizeToken(agent.command),
normalizeToken(agent.name),
normalizeToken(agent.id),
].filter(Boolean);
if (tokens.some((token) => token.includes('claude'))) {
return 'claude';
}
if (tokens.some((token) => token.includes('anthropic'))) {
return 'anthropic';
}
if (
tokens.some(
(token) =>
token.includes('codex') ||
token.includes('openai') ||
token.includes('chatgpt'),
)
) {
return 'openai';
}
if (
tokens.some(
(token) =>
token.includes('gemini') ||
token.includes('google') ||
token.includes('googlegemini'),
)
) {
return 'gemini';
}
if (tokens.some((token) => token.includes('ollama'))) {
return 'ollama';
}
if (tokens.some((token) => token.includes('openrouter'))) {
return 'openrouter';
}
if (tokens.some((token) => token.includes('zed'))) {
return 'zed';
}
if (tokens.some((token) => token.includes('factory'))) {
return 'atom';
}
return 'terminal';
}
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
if (agent.type === 'builtin') {
return 'Built-in terminal assistant';
}
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
}
export const AgentIconBadge: React.FC<{
agent: AgentLike | 'add-more';
size?: 'xs' | 'sm' | 'md' | 'lg';
variant?: 'plain' | 'badge';
className?: string;
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
const badgeSize =
size === 'xs'
? 'h-4 w-4 rounded-sm'
: size === 'sm'
? 'h-7 w-7 rounded-lg'
: size === 'lg'
? 'h-10 w-10 rounded-xl'
: 'h-8 w-8 rounded-lg';
const imageSize =
size === 'xs'
? 'h-3.5 w-3.5'
: size === 'sm'
? 'h-3.5 w-3.5'
: size === 'lg'
? 'h-5 w-5'
: 'h-4 w-4';
if (variant === 'plain') {
return (
<img
src={visual.src}
alt=""
aria-hidden="true"
draggable={false}
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
/>
);
}
return (
<div
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden border',
badgeSize,
visual.badgeClassName,
className,
)}
>
<img
src={visual.src}
alt=""
aria-hidden="true"
draggable={false}
className={cn(imageSize, visual.imageClassName)}
/>
</div>
);
};
export default AgentIconBadge;

View File

@@ -0,0 +1,280 @@
/**
* AgentSelector - Dropdown for switching between AI agents
*
* Dark, grouped agent menu with local SVG branding for built-in,
* discovered, and external agents.
*/
import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
import React, { useCallback, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
import AgentIconBadge from './AgentIconBadge';
import {
Dropdown,
DropdownContent,
DropdownTrigger,
} from '../ui/dropdown';
interface AgentSelectorProps {
currentAgentId: string;
externalAgents: ExternalAgentConfig[];
discoveredAgents?: DiscoveredAgent[];
isDiscovering?: boolean;
onSelectAgent: (agentId: string) => void;
onEnableDiscoveredAgent?: (agent: DiscoveredAgent) => void;
onRediscover?: () => void;
onManageAgents?: () => void;
}
const BUILTIN_AGENTS: AgentInfo[] = [
{
id: 'catty',
name: 'Catty Agent',
type: 'builtin',
description: 'Built-in terminal assistant',
available: true,
},
];
const SectionLabel: React.FC<{ children: React.ReactNode; action?: React.ReactNode }> = ({ children, action }) => (
<div className="px-4 pb-2 pt-2 flex items-center justify-between">
<span className="text-[10px] font-medium tracking-wide text-muted-foreground/52">
{children}
</span>
{action}
</div>
);
const AgentMenuRow: React.FC<{
agent: AgentInfo;
isActive?: boolean;
subtitle?: string;
onClick: () => void;
}> = ({ agent, isActive, subtitle, onClick }) => {
return (
<button
onClick={onClick}
className={cn(
'flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
isActive && 'bg-muted',
)}
>
<AgentIconBadge agent={agent} size="xs" variant="plain" className="opacity-78" />
<div className="min-w-0 flex-1">
<span className="block truncate">{agent.name}</span>
{subtitle && (
<span className="block truncate text-[10px] text-muted-foreground/40">{subtitle}</span>
)}
</div>
</button>
);
};
const DiscoveredAgentRow: React.FC<{
agent: DiscoveredAgent;
onEnable: () => void;
}> = ({ agent, onEnable }) => {
const agentLike: AgentInfo = {
id: `discovered_${agent.command}`,
name: agent.name,
type: 'external',
icon: agent.icon,
command: agent.command,
available: true,
};
return (
<div className="flex h-10 w-full items-center gap-3 rounded-md px-4 text-[13px]">
<AgentIconBadge agent={agentLike} size="xs" variant="plain" className="opacity-78" />
<div className="min-w-0 flex-1">
<span className="block truncate text-foreground/86">{agent.name}</span>
<span className="block truncate text-[10px] text-muted-foreground/40">
{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>
</div>
);
};
const AgentSelector: React.FC<AgentSelectorProps> = ({
currentAgentId,
externalAgents,
discoveredAgents = [],
isDiscovering = false,
onSelectAgent,
onEnableDiscoveredAgent,
onRediscover,
onManageAgents,
}) => {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const enabledExternalAgents = useMemo(
() =>
externalAgents
.filter((agent) => agent.enabled)
.map(
(agent): AgentInfo => ({
id: agent.id,
name: agent.name,
type: 'external',
icon: agent.icon,
command: agent.command,
args: agent.args,
available: true,
}),
),
[externalAgents],
);
// Discovered agents not yet added to external agents
const unconfiguredDiscovered = useMemo(
() =>
discoveredAgents.filter(
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
),
[discoveredAgents, externalAgents],
);
const allAgents = useMemo(
() => [...BUILTIN_AGENTS, ...enabledExternalAgents],
[enabledExternalAgents],
);
const currentAgent = useMemo(
() => allAgents.find((agent) => agent.id === currentAgentId) ?? BUILTIN_AGENTS[0],
[allAgents, currentAgentId],
);
const handleSelect = useCallback(
(agentId: string) => {
onSelectAgent(agentId);
setOpen(false);
},
[onSelectAgent],
);
const handleEnableDiscovered = useCallback(
(agent: DiscoveredAgent) => {
onEnableDiscoveredAgent?.(agent);
// After enabling, auto-select it
const agentId = `discovered_${agent.command}`;
onSelectAgent(agentId);
setOpen(false);
},
[onEnableDiscoveredAgent, onSelectAgent],
);
const handleManageAgents = useCallback(() => {
setOpen(false);
onManageAgents?.();
}, [onManageAgents]);
return (
<Dropdown open={open} onOpenChange={setOpen}>
<DropdownTrigger asChild>
<button
type="button"
className="group flex h-8 min-w-0 max-w-[170px] items-center gap-2 rounded-md px-2 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28"
>
<AgentIconBadge
agent={currentAgent}
size="xs"
variant="plain"
className="opacity-78"
/>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground/90">
{currentAgent.name}
</span>
<ChevronDown
size={12}
className={cn(
'shrink-0 text-muted-foreground/60 transition-transform',
open && 'rotate-180',
)}
/>
</button>
</DropdownTrigger>
<DropdownContent
align="start"
sideOffset={6}
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
>
{BUILTIN_AGENTS.map((agent) => (
<AgentMenuRow
key={agent.id}
agent={agent}
isActive={currentAgentId === agent.id}
onClick={() => handleSelect(agent.id)}
/>
))}
{enabledExternalAgents.length > 0 && (
<>
<div className="mx-0 my-1 border-t border-border/50" />
<SectionLabel>{t('ai.chat.agents')}</SectionLabel>
{enabledExternalAgents.map((agent) => (
<AgentMenuRow
key={agent.id}
agent={agent}
isActive={currentAgentId === agent.id}
subtitle={agent.command}
onClick={() => handleSelect(agent.id)}
/>
))}
</>
)}
{unconfiguredDiscovered.length > 0 && (
<>
<div className="mx-0 my-1 border-t border-border/50" />
<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>
)
}
>
{t('ai.chat.detectedOnMachine')}
</SectionLabel>
{unconfiguredDiscovered.map((agent) => (
<DiscoveredAgentRow
key={agent.command}
agent={agent}
onEnable={() => handleEnableDiscovered(agent)}
/>
))}
</>
)}
<div className="mx-0 my-1 border-t border-border/50" />
<button
onClick={handleManageAgents}
className="flex h-10 w-full items-center gap-3 px-4 text-left text-[13px] text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
>
<Settings size={16} className="opacity-72 shrink-0" />
<span className="min-w-0 flex-1 truncate">{t('ai.agentSettings')}</span>
</button>
</DropdownContent>
</Dropdown>
);
};
export default React.memo(AgentSelector);

559
components/ai/ChatInput.tsx Normal file
View File

@@ -0,0 +1,559 @@
/**
* ChatInput - Zed-style bottom input area for the AI chat panel
*
* Thin wrapper around the AI Elements prompt-input components.
* Bordered textarea with monospace placeholder, expand toggle,
* and a bottom toolbar with muted controls + subtle send button.
*/
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
import type { FormEvent } from 'react';
import type { UploadedImage } from '../../application/state/useImageUpload';
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onStop?: () => void;
isStreaming?: boolean;
disabled?: boolean;
providerName?: string;
modelName?: string;
agentName?: string;
placeholder?: string;
/** Available model presets for the current agent */
modelPresets?: AgentModelPreset[];
/** Currently selected model ID */
selectedModelId?: string;
/** Callback when user selects a model */
onModelSelect?: (modelId: string) => void;
/** Attached images */
images?: UploadedImage[];
/** Callback to add images (paste/drop) */
onAddImages?: (files: File[]) => void;
/** Callback to remove an image */
onRemoveImage?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** Permission mode (only shown for Catty Agent) */
permissionMode?: AIPermissionMode;
/** Callback when user changes permission mode */
onPermissionModeChange?: (mode: AIPermissionMode) => void;
}
const ChatInput: React.FC<ChatInputProps> = ({
value,
onChange,
onSend,
onStop,
isStreaming = false,
disabled = false,
providerName,
modelName,
agentName,
placeholder,
modelPresets = [],
selectedModelId,
onModelSelect,
images = [],
onAddImages,
onRemoveImage,
hosts = [],
permissionMode,
onPermissionModeChange,
}) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
// Consolidate menu state into a single discriminated union to prevent multiple menus open simultaneously
type ActiveMenu = 'model' | 'attach' | 'atMention' | 'perm' | null;
const [activeMenu, setActiveMenu] = useState<ActiveMenu>(null);
const [menuPos, setMenuPos] = useState<{ left: number; bottom: number } | null>(null);
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const [showHostSubmenu, setShowHostSubmenu] = useState(false);
// Derived booleans for readability
const showModelPicker = activeMenu === 'model';
const showAttachMenu = activeMenu === 'attach';
const showAtMention = activeMenu === 'atMention';
const showPermPicker = activeMenu === 'perm';
const closeAllMenus = useCallback(() => {
setActiveMenu(null);
setMenuPos(null);
setHoveredModelId(null);
setShowHostSubmenu(false);
}, []);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modelBtnRef = useRef<HTMLButtonElement>(null);
const permBtnRef = useRef<HTMLButtonElement>(null);
const attachBtnRef = useRef<HTMLButtonElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleInputChange = useCallback((newValue: string) => {
onChange(newValue);
// Detect if user just typed @
if (
hosts.length > 0 &&
newValue.length > value.length &&
newValue.endsWith('@')
) {
// Position the popover near the textarea
const el = textareaRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setMenuPos({ left: rect.left + 12, bottom: window.innerHeight - rect.top + 4 });
}
setActiveMenu('atMention');
} else if (showAtMention && !newValue.includes('@')) {
setActiveMenu(null);
}
}, [onChange, value, hosts.length, showAtMention]);
const handleSelectAtMention = useCallback((host: { label: string; hostname: string }) => {
// Replace the trailing @ with @hostname
const name = host.label || host.hostname;
const lastAt = value.lastIndexOf('@');
const newValue = lastAt >= 0
? value.slice(0, lastAt) + `@${name} `
: value + `@${name} `;
onChange(newValue);
closeAllMenus();
}, [value, onChange, closeAllMenus]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const files = Array.from(e.clipboardData.items)
.filter((item) => item.type.startsWith('image/'))
.map((item) => item.getAsFile())
.filter(Boolean) as File[];
if (files.length > 0) {
e.preventDefault();
onAddImages?.(files);
}
}, [onAddImages]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (files.length > 0) {
onAddImages?.(files);
}
}, [onAddImages]);
const defaultPlaceholder = agentName
? t('ai.chat.placeholder').replace('{agent}', agentName)
: t('ai.chat.placeholderDefault');
const handleSubmit = useCallback(
(_text: string, _event: FormEvent<HTMLFormElement>) => {
onSend();
},
[onSend],
);
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
// Permission mode chip removed — agents run in autonomous mode
// selectedModelId may be "model/thinking" for codex
const selectedBaseModelId = selectedModelId?.split('/')[0];
const selectedThinking = selectedModelId?.includes('/') ? selectedModelId.split('/')[1] : undefined;
const selectedPreset = modelPresets.find(m => m.id === selectedBaseModelId);
const modelLabel = selectedPreset
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
: modelName || providerName || t('ai.chat.noModel');
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
const iconButtonClassName =
'h-6 w-6 rounded-full bg-transparent text-foreground/62 hover:bg-muted/24 hover:text-foreground';
return (
<div className="shrink-0 px-4 pb-4">
<PromptInput onSubmit={handleSubmit}>
{/* Image attachment chips */}
{images.length > 0 && (
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
{images.map((img) => (
<div
key={img.id}
className="inline-flex items-center gap-1 h-6 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group"
>
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[80px]">{img.filename}</span>
<button
type="button"
onClick={() => onRemoveImage?.(img.id)}
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer"
>
<X size={8} />
</button>
</div>
))}
</div>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) {
onAddImages?.(Array.from(e.target.files));
e.target.value = '';
}
}}
/>
{/* Textarea with expand toggle */}
<div className="relative" onPaste={handlePaste} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
<PromptInputTextarea
ref={textareaRef}
value={value}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled || isStreaming}
className={expanded ? 'max-h-[220px]' : undefined}
/>
<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>
</div>
{/* @ mention popover */}
{showAtMention && hosts.length > 0 && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Mention host"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuHosts')}</div>
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="option"
onClick={() => handleSelectAtMention(host)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
</div>
</>,
document.body,
)}
{/* 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>
{showAttachMenu && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="menu"
className="fixed z-[1000] min-w-[170px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
<div className="px-3 py-1 text-[10px] text-muted-foreground/40 tracking-wide">{t('ai.chat.menuContext')}</div>
<button
type="button"
role="menuitem"
onClick={() => { fileInputRef.current?.setAttribute('accept', '*/*'); fileInputRef.current?.click(); closeAllMenus(); }}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<FileText size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuFiles')}</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => { fileInputRef.current?.setAttribute('accept', 'image/*'); fileInputRef.current?.click(); closeAllMenus(); }}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<ImageIcon size={13} className="text-muted-foreground/60" />
<span className="text-foreground/85">{t('ai.chat.menuImage')}</span>
</button>
<div
className="relative"
onMouseEnter={() => setShowHostSubmenu(true)}
onMouseLeave={() => setShowHostSubmenu(false)}
onFocus={() => setShowHostSubmenu(true)}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setShowHostSubmenu(false); }}
>
<button
type="button"
role="menuitem"
aria-label="Mention host"
aria-expanded={showHostSubmenu && hosts.length > 0}
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<AtSign size={13} className="text-muted-foreground/60" />
<span className="flex-1 text-foreground/85">{t('ai.chat.menuMentionHost')}</span>
{hosts.length > 0 && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
{showHostSubmenu && hosts.length > 0 && (
<div role="menu" className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="menuitem"
onClick={() => {
const mention = `@${host.label || host.hostname} `;
onChange(value + mention);
closeAllMenus();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="text-foreground/85 truncate">{host.label || host.hostname}</span>
{host.label && host.hostname !== host.label && (
<span className="text-[10px] text-muted-foreground/40">{host.hostname}</span>
)}
</button>
))}
</div>
)}
</div>
</div>
</>,
document.body,
)}
<button
ref={modelBtnRef}
type="button"
onClick={() => {
if (!hasModelPicker) return;
if (!showModelPicker) {
const rect = modelBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('model');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
aria-label="Select model"
aria-expanded={showModelPicker}
>
<Cpu size={11} className="text-muted-foreground/64" />
<span className="truncate max-w-[82px]">{modelLabel}</span>
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
</button>
{showModelPicker && hasModelPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Select model"
className="fixed z-[1000] min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
onMouseLeave={() => setHoveredModelId(null)}
>
{modelPresets.map(preset => {
const isSelected = preset.id === selectedBaseModelId;
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
return (
<div
key={preset.id}
className="relative"
onMouseEnter={() => setHoveredModelId(hasThinking ? preset.id : null)}
onFocus={() => { if (hasThinking) setHoveredModelId(preset.id); }}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setHoveredModelId(null); }}
>
<button
type="button"
role="option"
aria-selected={isSelected}
onClick={() => {
if (!hasThinking) {
onModelSelect?.(preset.id);
closeAllMenus();
}
}}
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
{isSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
<span className="flex-1 text-foreground/85">{preset.name}</span>
{preset.description && <span className="text-[10px] text-muted-foreground/50 mr-1">{preset.description}</span>}
{hasThinking && <ChevronRight size={10} className="text-muted-foreground/50" />}
</button>
{/* Thinking level sub-menu */}
{hasThinking && hoveredModelId === preset.id && (
<div role="listbox" aria-label="Thinking level" className="absolute left-full top-0 ml-1 min-w-[120px] rounded-lg border border-border/50 bg-popover shadow-lg py-1 z-[1001]">
{preset.thinkingLevels!.map(level => {
const fullId = `${preset.id}/${level}`;
const isLevelSelected = selectedModelId === fullId;
return (
<button
key={level}
type="button"
role="option"
aria-selected={isLevelSelected}
tabIndex={0}
onClick={() => {
onModelSelect?.(fullId);
closeAllMenus();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onModelSelect?.(fullId);
closeAllMenus();
} else if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
}
}}
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer whitespace-nowrap"
>
{isLevelSelected ? <Check size={11} className="text-primary shrink-0" /> : <span className="w-[11px] shrink-0" />}
<span className="text-foreground/85">{formatThinkingLabel(level)}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
</>,
document.body,
)}
{/* 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>
{showPermPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Permission mode"
className="fixed z-[1000] min-w-[180px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom }}
>
{([
{ mode: 'autonomous' as const, icon: Zap, color: 'text-green-400/70', label: t('ai.chat.permAuto'), desc: t('ai.chat.permAutoDesc') },
{ mode: 'confirm' as const, icon: ShieldCheck, color: 'text-yellow-400/70', label: t('ai.chat.permConfirm'), desc: t('ai.chat.permConfirmDesc') },
{ mode: 'observer' as const, icon: Eye, color: 'text-blue-400/70', label: t('ai.chat.permObserver'), desc: t('ai.chat.permObserverDesc') },
]).map(({ mode, icon: Icon, color, label, desc }) => (
<button
key={mode}
type="button"
role="option"
aria-selected={permissionMode === mode}
onClick={() => {
onPermissionModeChange(mode);
closeAllMenus();
}}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] hover:bg-muted/30 transition-colors cursor-pointer"
>
{permissionMode === mode
? <Check size={11} className="text-primary shrink-0" />
: <span className="w-[11px] shrink-0" />
}
<Icon size={12} className={`${color} shrink-0`} />
<div className="flex-1 min-w-0">
<div className="text-foreground/85">{label}</div>
<div className="text-[10px] text-muted-foreground/40 leading-tight">{desc}</div>
</div>
</button>
))}
</div>
</>,
document.body,
)}
</>
)}
</PromptInputTools>
<div className="flex-1 min-w-0" />
<div className="flex items-center gap-1">
<PromptInputSubmit
status={status}
onStop={onStop}
disabled={!value.trim() || disabled}
/>
</div>
</PromptInputFooter>
</PromptInput>
</div>
);
};
export default React.memo(ChatInput);

View File

@@ -0,0 +1,196 @@
/**
* ChatMessageList - Renders the list of chat messages
*
* Claude-Code-style: user messages in bordered bubbles (right-aligned),
* assistant responses as plain text (left-aligned, no border/bg).
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage } from '../../infrastructure/ai/types';
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from '../ai-elements/conversation';
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
import { ToolCall } from '../ai-elements/tool-call';
import { InlineApprovalCard } from './InlineApprovalCard';
import ThinkingBlock from './ThinkingBlock';
interface ChatMessageListProps {
messages: ChatMessage[];
isStreaming?: boolean;
onApprove?: (messageId: string) => void;
onReject?: (messageId: string) => void;
}
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
const { t } = useI18n();
const visibleMessages = messages.filter(m => m.role !== 'system');
if (visibleMessages.length === 0 && !isStreaming) {
return (
<div className="flex-1 flex items-center justify-center px-6">
<p className="text-[13px] text-muted-foreground/40 text-center">
{t('ai.chat.emptyHint')}
</p>
</div>
);
}
const lastAssistantMessage = visibleMessages.findLast(m => m.role === 'assistant');
return (
<Conversation className="flex-1">
<ConversationContent className="gap-1.5 px-4 py-2">
{visibleMessages.map((message) => {
if (message.role === 'tool') {
return (
<React.Fragment key={message.id}>
{message.toolResults?.map((tr) => (
<ToolCall
key={tr.toolCallId}
name={tr.toolCallId}
result={tr.content}
isError={tr.isError}
/>
))}
</React.Fragment>
);
}
const isUser = message.role === 'user';
const isLastAssistant = message === lastAssistantMessage;
const isThisStreaming = isStreaming && isLastAssistant;
return (
<Message key={message.id} from={message.role}>
<MessageContent>
{/* Thinking block */}
{!isUser && message.thinking && (
<ThinkingBlock
content={message.thinking}
isStreaming={!!isThisStreaming && !message.content}
durationMs={message.thinkingDurationMs}
/>
)}
{/* User images */}
{isUser && message.images && message.images.length > 0 && (
<div className="flex gap-1.5 flex-wrap mb-1">
{message.images.map((img, i) => (
<img
key={img.filename ? `${img.filename}-${i}` : `img-${message.id}-${i}`}
src={`data:${img.mediaType};base64,${img.base64Data}`}
alt={img.filename || 'image'}
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
/>
))}
</div>
)}
{message.content && (
isUser
? <div className="whitespace-pre-wrap break-words text-[13px]">{message.content}</div>
: <MessageResponse isAnimating={isThisStreaming}>
{message.content}
</MessageResponse>
)}
{/* Tool calls */}
{message.toolCalls?.map((tc) => (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running'}
/>
))}
{/* Inline approval card */}
{message.pendingApproval && (
<InlineApprovalCard
toolName={message.pendingApproval.toolName}
toolArgs={message.pendingApproval.toolArgs}
status={message.pendingApproval.status}
onApprove={() => onApprove?.(message.id)}
onReject={() => onReject?.(message.id)}
/>
)}
{/* Status text with shimmer */}
{message.statusText && (
<div className="py-1">
<span className="thinking-shimmer text-xs">{message.statusText}</span>
</div>
)}
{/* Error info */}
{message.errorInfo && (
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-destructive font-medium">{message.errorInfo.message}</p>
{message.errorInfo.retryable && (
<p className="text-muted-foreground text-xs mt-1">{t('ai.chat.retryHint')}</p>
)}
</div>
</div>
)}
</MessageContent>
</Message>
);
})}
{/* Streaming indicator — only when no content and no thinking yet */}
{isStreaming && !lastAssistantMessage?.content && !lastAssistantMessage?.thinking && (
<div className="flex items-center gap-1 py-2">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/30 animate-bounce [animation-delay:300ms]" />
</div>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
);
};
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
if (prev.isStreaming !== next.isStreaming) return false;
if (prev.onApprove !== next.onApprove) return false;
if (prev.onReject !== next.onReject) return false;
if (prev.messages.length !== next.messages.length) return false;
if (prev.messages === next.messages) return true;
// Shallow-compare each message by reference
for (let i = 0; i < prev.messages.length; i++) {
if (prev.messages[i] !== next.messages[i]) {
// For the last message during streaming, compare by content to avoid
// re-renders when only the array reference changed but content is the same
const p = prev.messages[i];
const n = next.messages[i];
if (
p.id !== n.id ||
p.content !== n.content ||
p.thinking !== n.thinking ||
p.role !== n.role ||
p.statusText !== n.statusText ||
p.executionStatus !== n.executionStatus ||
p.pendingApproval !== n.pendingApproval ||
p.errorInfo !== n.errorInfo ||
p.toolCalls !== n.toolCalls ||
p.toolResults !== n.toolResults
) {
return false;
}
}
}
return true;
}
export default React.memo(ChatMessageList, areMessagesEqual);

View File

@@ -0,0 +1,82 @@
/**
* ConversationExport - Dropdown button for exporting chat sessions
*
* Small download icon button with a dropdown offering Markdown, JSON,
* and Plain Text export formats.
*/
import { Download, FileJson, FileText, FileType } from 'lucide-react';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { AISession } from '../../infrastructure/ai/types';
import { Button } from '../ui/button';
import {
Dropdown,
DropdownContent,
DropdownTrigger,
} from '../ui/dropdown';
interface ConversationExportProps {
session: AISession | null;
onExport: (format: 'md' | 'json' | 'txt') => void;
className?: string;
}
const EXPORT_OPTIONS = [
{ format: 'md' as const, labelKey: 'ai.chat.exportMarkdown' as const, icon: FileText },
{ format: 'json' as const, labelKey: 'ai.chat.exportJSON' as const, icon: FileJson },
{ format: 'txt' as const, labelKey: 'ai.chat.exportPlainText' as const, icon: FileType },
];
const ConversationExport: React.FC<ConversationExportProps> = ({
session,
onExport,
className,
}) => {
const { t } = useI18n();
const handleExport = useCallback(
(format: 'md' | 'json' | 'txt') => {
onExport(format);
},
[onExport],
);
const hasMessages = session && session.messages.length > 0;
return (
<Dropdown>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground'}
disabled={!hasMessages}
title={t('ai.chat.exportConversation')}
>
<Download size={14} />
</Button>
</DropdownTrigger>
<DropdownContent
align="end"
sideOffset={6}
className="w-40 rounded-xl border border-border/45 bg-[#111111]/98 p-1.5 text-foreground shadow-[0_20px_48px_rgba(0,0,0,0.48)] supports-[backdrop-filter]:bg-[#111111]/92 supports-[backdrop-filter]:backdrop-blur-xl"
>
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
{t('ai.chat.exportAs')}
</div>
{EXPORT_OPTIONS.map(({ format, labelKey, icon: Icon }) => (
<button
key={format}
onClick={() => handleExport(format)}
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-white/[0.04]"
>
<Icon size={13} className="shrink-0 text-muted-foreground/70" />
<span>{t(labelKey)}</span>
</button>
))}
</DropdownContent>
</Dropdown>
);
};
export default React.memo(ConversationExport);

View File

@@ -0,0 +1,169 @@
/**
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
*
* Shows a numbered list of steps with status indicators, host badges,
* optional command previews, and action buttons.
*/
import {
CheckCircle2,
Circle,
Loader2,
SkipForward,
XCircle,
} from 'lucide-react';
import React from 'react';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
interface ExecutionPlanStep {
description: string;
host?: string;
command?: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
}
interface ExecutionPlanProps {
steps: ExecutionPlanStep[];
onApprove: () => void;
onModify: () => void;
onReject: () => void;
isExecuting: boolean;
}
// -------------------------------------------------------------------
// Status icon mapping
// -------------------------------------------------------------------
function StepStatusIcon({
status,
}: {
status: ExecutionPlanStep['status'];
}) {
switch (status) {
case 'pending':
return <Circle size={16} className="text-muted-foreground" />;
case 'running':
return (
<Loader2 size={16} className="text-blue-500 animate-spin" />
);
case 'completed':
return <CheckCircle2 size={16} className="text-green-500" />;
case 'failed':
return <XCircle size={16} className="text-destructive" />;
case 'skipped':
return (
<SkipForward size={16} className="text-muted-foreground/60" />
);
}
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
steps,
onApprove,
onModify,
onReject,
isExecuting,
}) => {
return (
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
{/* Header */}
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
<span className="text-sm font-medium">
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
</span>
</div>
{/* Steps list */}
<div className="divide-y divide-border/30">
{steps.map((step, index) => (
<div
key={index}
className={cn(
'flex items-start gap-3 px-3 py-2.5 transition-colors',
step.status === 'running' && 'bg-blue-500/5',
step.status === 'completed' && 'bg-green-500/5',
step.status === 'failed' && 'bg-destructive/5',
step.status === 'skipped' && 'opacity-50',
)}
>
{/* Step number + status icon */}
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
{index + 1}
</span>
<StepStatusIcon status={step.status} />
</div>
{/* Step content */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className={cn(
'text-sm',
step.status === 'skipped' && 'line-through',
)}
>
{step.description}
</span>
{step.host && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0"
>
{step.host}
</Badge>
)}
</div>
{step.command && (
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
{step.command}
</code>
)}
</div>
</div>
))}
</div>
{/* Action buttons */}
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
{isExecuting ? (
<Button
variant="destructive"
size="sm"
onClick={onReject}
>
Cancel
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={onReject}>
Cancel
</Button>
<Button variant="outline" size="sm" onClick={onModify}>
Modify Plan
</Button>
<Button size="sm" onClick={onApprove}>
Approve
</Button>
</>
)}
</div>
</div>
);
};
ExecutionPlan.displayName = 'ExecutionPlan';
export default ExecutionPlan;
export { ExecutionPlan };
export type { ExecutionPlanProps, ExecutionPlanStep };

View File

@@ -0,0 +1,193 @@
/**
* InlineApprovalCard - Inline tool approval card rendered within chat messages.
*
* Replaces the modal PermissionDialog. Shows tool name, arguments, and
* approve/reject buttons. Keyboard shortcuts: Enter to approve, Escape to reject.
*/
import { Check, ShieldAlert, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
interface InlineApprovalCardProps {
toolName: string;
toolArgs: Record<string, unknown>;
status: 'pending' | 'approved' | 'denied';
onApprove: () => void;
onReject: () => void;
}
const InlineApprovalCard: React.FC<InlineApprovalCardProps> = ({
toolName,
toolArgs,
status,
onApprove,
onReject,
}) => {
const { t } = useI18n();
const cardRef = useRef<HTMLDivElement>(null);
const approveBtnRef = useRef<HTMLButtonElement>(null);
const isPending = status === 'pending';
const [responded, setResponded] = useState(false);
// Use refs to always access the latest callbacks without re-registering the listener
const onApproveRef = useRef(onApprove);
const onRejectRef = useRef(onReject);
onApproveRef.current = onApprove;
onRejectRef.current = onReject;
const isDisabled = !isPending || responded;
const handleApprove = useCallback(() => {
if (isDisabled) return;
setResponded(true);
onApproveRef.current();
}, [isDisabled]);
const handleReject = useCallback(() => {
if (isDisabled) return;
setResponded(true);
onRejectRef.current();
}, [isDisabled]);
// Keyboard shortcuts: handled via local onKeyDown on the focusable card element
// to avoid conflicts when multiple InlineApprovalCard instances exist simultaneously.
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isDisabled) return;
if (e.key === 'Enter') {
e.preventDefault();
handleApprove();
} else if (e.key === 'Escape') {
e.preventDefault();
handleReject();
}
}, [isDisabled, handleApprove, handleReject]);
// Auto-focus approve button and auto-scroll into view when mounted as pending
useEffect(() => {
if (isPending && cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
approveBtnRef.current?.focus();
}
}, [isPending]);
let formattedArgs: string;
try {
formattedArgs = JSON.stringify(toolArgs, null, 2);
} catch {
formattedArgs = String(toolArgs);
}
// Extract target session info if present
const sessionId = toolArgs?.sessionId as string | undefined;
return (
<div
ref={cardRef}
tabIndex={0}
role="alertdialog"
aria-label="Tool execution approval required"
onKeyDown={handleKeyDown}
className={`rounded-md border overflow-hidden text-[12px] mt-1.5 outline-none ${
isPending
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
: status === 'approved'
? 'border-green-500/20 bg-green-500/[0.03]'
: 'border-red-500/20 bg-red-500/[0.03]'
}`}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-1.5">
<ShieldAlert
size={13}
className={
isPending
? 'text-yellow-500/70 shrink-0'
: status === 'approved'
? 'text-green-400/70 shrink-0'
: 'text-red-400/70 shrink-0'
}
/>
<span className="text-[11px] font-medium text-foreground/70">
{t('ai.chat.toolApprovalTitle')}
</span>
{!isPending && (
<Badge
className={`ml-auto text-[10px] px-1.5 py-0 ${
status === 'approved'
? 'bg-green-600/20 text-green-400 border-green-600/30'
: 'bg-red-600/20 text-red-400 border-red-600/30'
}`}
>
{status === 'approved' ? t('ai.chat.toolApproved') : t('ai.chat.toolDenied')}
</Badge>
)}
</div>
{/* Tool info */}
<div className="px-3 pb-2 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.toolLabel')}</span>
<code className="text-[11px] font-mono text-muted-foreground/70 bg-muted/30 px-1.5 py-0.5 rounded">
{toolName}
</code>
</div>
{sessionId && (
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.targetLabel')}</span>
<code className="text-[11px] font-mono text-muted-foreground/50 bg-muted/30 px-1.5 py-0.5 rounded">
{sessionId}
</code>
</div>
)}
{/* Arguments */}
<div className="rounded border border-border/20 bg-muted/10 p-2 max-h-32 overflow-auto">
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-muted-foreground/50">
{formattedArgs}
</pre>
</div>
{/* Actions or hint */}
{isPending && (
<div className="flex items-center justify-between pt-0.5">
<span className="text-[10px] text-muted-foreground/30">
{t('ai.chat.toolApprovalHint')}
</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={responded}
className={`h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400 ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleReject}
>
<X size={11} className="mr-0.5" />
{t('ai.chat.reject')}
</Button>
<Button
ref={approveBtnRef}
size="sm"
disabled={responded}
className={`h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleApprove}
>
<Check size={11} className="mr-0.5" />
{t('ai.chat.approve')}
</Button>
</div>
</div>
)}
</div>
</div>
);
};
InlineApprovalCard.displayName = 'InlineApprovalCard';
export default InlineApprovalCard;
export { InlineApprovalCard };
export type { InlineApprovalCardProps };

View File

@@ -0,0 +1,200 @@
/**
* PermissionDialog - Modal for AI agent tool call permission requests.
*
* Shown when the agent needs user approval to execute a tool call.
* Displays tool name, arguments, recommendation, and approve/reject actions.
*/
import { ShieldAlert } from 'lucide-react';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
interface PermissionDialogProps {
open: boolean;
toolCall: { name: string; arguments: Record<string, unknown> } | null;
recommendation: 'allow' | 'confirm' | 'deny';
onApprove: () => void;
onReject: () => void;
onDismiss: () => void;
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const PermissionDialog: React.FC<PermissionDialogProps> = ({
open,
toolCall,
recommendation,
onApprove,
onReject,
onDismiss,
}) => {
const { t } = useI18n();
const isDenied = recommendation === 'deny';
// Keyboard shortcuts: Enter to approve, Escape to reject
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isDenied) {
e.preventDefault();
onApprove();
} else if (e.key === 'Escape') {
e.preventDefault();
onReject();
}
},
[isDenied, onApprove, onReject],
);
// Format arguments as readable code block content
let formattedArgs = '';
if (toolCall) {
try {
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
} catch {
formattedArgs = String(toolCall.arguments);
}
}
// Extract host/session info from arguments if present
const sessionId =
toolCall?.arguments?.sessionId as string | undefined;
const sessionIds =
toolCall?.arguments?.sessionIds as string[] | undefined;
const recommendationBadge = () => {
switch (recommendation) {
case 'allow':
return (
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
{t('ai.chat.recommendAllow')}
</Badge>
);
case 'confirm':
return (
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
{t('ai.chat.recommendConfirm')}
</Badge>
);
case 'deny':
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldAlert
size={20}
className={cn(
isDenied ? 'text-destructive' : 'text-yellow-500',
)}
/>
{t('ai.chat.permissionRequired')}
</DialogTitle>
<DialogDescription>
{t('ai.chat.permissionDescription')}
</DialogDescription>
</DialogHeader>
{toolCall && (
<div className="space-y-3">
{/* Tool name and recommendation */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
{toolCall.name}
</code>
</div>
{recommendationBadge()}
</div>
{/* Target session(s) */}
{(sessionId || sessionIds) && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
{sessionId && (
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{sessionId}
</code>
)}
{sessionIds && (
<div className="flex flex-wrap gap-1">
{sessionIds.map((id) => (
<code
key={id}
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
>
{id}
</code>
))}
</div>
)}
</div>
)}
{/* Arguments code block */}
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
{formattedArgs}
</pre>
</div>
{/* Deny warning */}
{isDenied && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
<p className="text-sm text-destructive">
{t('ai.chat.commandBlocked')}
</p>
</div>
)}
</div>
)}
<DialogFooter>
{isDenied ? (
<Button variant="destructive" onClick={onReject} className="w-full">
{t('ai.chat.reject')}
</Button>
) : (
<>
<Button
variant="outline"
onClick={onReject}
className="border-destructive/30 text-destructive hover:bg-destructive/10"
>
{t('ai.chat.reject')}
</Button>
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
PermissionDialog.displayName = 'PermissionDialog';
export default PermissionDialog;
export { PermissionDialog };
export type { PermissionDialogProps };

View File

@@ -0,0 +1,138 @@
/**
* ThinkingBlock - Collapsible thinking/reasoning display
*
* - While streaming: expanded, "Thinking" label with shimmer + elapsed time
* - When done: auto-collapses to "Thought for Xs", click to expand
* - Content area has max-height with scroll and top gradient fade
*/
import { ChevronRight } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
interface ThinkingBlockProps {
content: string;
isStreaming: boolean;
durationMs?: number;
}
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
return `${minutes}m ${remaining}s`;
}
const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
content,
isStreaming,
durationMs,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = useState(isStreaming);
const [elapsed, setElapsed] = useState(0);
const wasStreamingRef = useRef(false);
const startRef = useRef(Date.now());
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-collapse when streaming ends
useEffect(() => {
if (wasStreamingRef.current && !isStreaming) {
setIsExpanded(false);
}
wasStreamingRef.current = isStreaming;
}, [isStreaming]);
// Expand when streaming starts
useEffect(() => {
if (isStreaming) {
setIsExpanded(true);
startRef.current = Date.now();
}
}, [isStreaming]);
// Elapsed time ticker
useEffect(() => {
if (!isStreaming) return;
const timer = setInterval(() => {
setElapsed(Date.now() - startRef.current);
}, 1000);
return () => clearInterval(timer);
}, [isStreaming]);
// Auto-scroll to bottom while streaming
useEffect(() => {
if (isStreaming && isExpanded && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [content, isStreaming, isExpanded]);
const toggle = useCallback(() => setIsExpanded(e => !e), []);
const displayDuration = durationMs || elapsed;
const preview = content.length > 60 ? content.slice(0, 60) + '…' : content;
return (
<div className="mb-0.5">
{/* Header */}
<button
onClick={toggle}
aria-expanded={isExpanded}
aria-controls="thinking-block-content"
className="group flex items-center gap-1.5 py-0.5 px-1 cursor-pointer text-left w-full rounded hover:bg-white/[0.03] transition-colors"
>
<ChevronRight
size={12}
className={cn(
'shrink-0 text-muted-foreground/50 transition-transform duration-200',
isExpanded && 'rotate-90',
!isExpanded && 'opacity-50',
)}
/>
<span className="text-[12px] font-medium text-muted-foreground/70 whitespace-nowrap shrink-0">
{isStreaming ? (
<span className="thinking-shimmer">{t('ai.chat.thinking')}</span>
) : (
displayDuration > 0
? t('ai.chat.thoughtFor', { duration: formatDuration(displayDuration) })
: t('ai.chat.thought')
)}
</span>
{isStreaming && elapsed > 0 && (
<span className="text-[11px] text-muted-foreground/40 tabular-nums shrink-0">
{formatDuration(elapsed)}
</span>
)}
{!isExpanded && !isStreaming && preview && (
<span className="text-[11px] text-muted-foreground/40 truncate min-w-0">
{preview}
</span>
)}
</button>
{/* Content */}
{isExpanded && content && (
<div id="thinking-block-content" className="relative">
{/* Top gradient fade */}
{isStreaming && (
<div className="absolute inset-x-0 top-0 h-4 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" />
)}
<div
ref={scrollRef}
className={cn(
'px-5 text-[12px] text-muted-foreground/60 leading-relaxed whitespace-pre-wrap break-words',
isStreaming && 'overflow-y-auto scrollbar-hide max-h-36',
!isStreaming && 'max-h-36 overflow-y-auto scrollbar-hide',
)}
>
{content}
</div>
</div>
)}
</div>
);
};
export default React.memo(ThinkingBlock);

View File

@@ -0,0 +1,769 @@
/**
* useAIChatStreaming — Encapsulates all streaming logic for the AI chat panel.
*
* Handles:
* - Catty agent streaming via Vercel AI SDK `streamText`
* - External agent streaming (ACP and raw process)
* - Text-delta batching via requestAnimationFrame
* - Abort controller management
* - Stream state tracking (per-session)
* - Error reporting
*/
import React, { useCallback, useRef, useState } from 'react';
import { streamText, stepCountIs, type ModelMessage } from 'ai';
import type {
AIPermissionMode,
AISession,
ChatMessage,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import type { NetcattyBridge } from '../../../infrastructure/ai/cattyAgent/executor';
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { classifyError, sanitizeErrorMessage } from '../../../infrastructure/ai/errorClassifier';
// -------------------------------------------------------------------
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
// -------------------------------------------------------------------
/** Shape of a text/text-delta chunk from the Vercel AI SDK fullStream. */
interface TextDeltaChunk {
type: 'text' | 'text-delta';
text?: string;
textDelta?: string;
}
/** Shape of a reasoning chunk from the Vercel AI SDK fullStream. */
interface ReasoningChunk {
type: 'reasoning' | 'reasoning-start' | 'reasoning-delta';
text?: string;
}
/** Shape of a tool-call chunk from the Vercel AI SDK fullStream. */
interface ToolCallChunk {
type: 'tool-call';
toolCallId: string;
toolName: string;
input?: unknown;
args?: unknown;
}
/** Shape of a tool-result chunk from the Vercel AI SDK fullStream. */
interface ToolResultChunk {
type: 'tool-result';
toolCallId: string;
output?: unknown;
result?: unknown;
}
/** Shape of a tool-approval-request chunk from the Vercel AI SDK fullStream. */
interface ToolApprovalRequestChunk {
type: 'tool-approval-request';
approvalId: string;
toolCall: {
toolCallId: string;
toolName: string;
args?: Record<string, unknown>;
input?: Record<string, unknown>;
};
}
/** Shape of an error chunk from the Vercel AI SDK fullStream. */
interface ErrorChunk {
type: 'error';
error: unknown;
}
/** Union of all stream chunk shapes we handle. */
type StreamChunk =
| TextDeltaChunk
| ReasoningChunk
| ToolCallChunk
| ToolResultChunk
| ToolApprovalRequestChunk
| ErrorChunk
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' };
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
export interface PanelBridge extends NetcattyBridge {
credentialsDecrypt?: (value: string) => Promise<string>;
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}
/** Terminal session info used throughout the streaming hooks. */
export interface TerminalSessionInfo {
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
connected: boolean;
}
/** Typed accessor for the netcatty bridge on the window object. */
export function getNetcattyBridge(): PanelBridge | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (window as any).netcatty as PanelBridge | undefined;
}
/** Approval info returned by processCattyStream when a tool-approval-request is received. */
export interface ApprovalInfo {
approvalId: string;
toolCallId: string;
toolName: string;
toolArgs: Record<string, unknown>;
}
/** Pending approval context stored between approval request and user response. */
export interface PendingApprovalContext {
sessionId: string;
scopeKey: string;
sdkMessages: Array<ModelMessage>;
approvalInfo: ApprovalInfo;
model: ReturnType<typeof createModelFromConfig>;
systemPrompt: string;
tools: ReturnType<typeof createCattyTools>;
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
// -------------------------------------------------------------------
// Hook parameters
// -------------------------------------------------------------------
export interface UseAIChatStreamingParams {
maxIterations: number;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
}
// -------------------------------------------------------------------
// Hook return type
// -------------------------------------------------------------------
export interface UseAIChatStreamingReturn {
/** Set of session IDs currently streaming. */
streamingSessionIds: Set<string>;
/** Set or unset streaming state for a session. */
setStreamingForScope: (key: string, val: boolean) => void;
/** Ref to per-session abort controllers. */
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
/** Process a Catty agent stream, returning approval info if one is requested. */
processCattyStream: (
streamSessionId: string,
model: ReturnType<typeof createModelFromConfig>,
systemPrompt: string,
tools: ReturnType<typeof createCattyTools>,
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
) => Promise<ApprovalInfo | null>;
/** Send a message to the Catty agent (built-in). */
sendToCattyAgent: (
sessionId: string,
sendScopeKey: string,
trimmed: string,
abortController: AbortController,
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
) => Promise<void>;
/** Send a message to an external agent (ACP or raw process). */
sendToExternalAgent: (
sessionId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
context: SendToExternalContext,
) => Promise<void>;
/** Report a streaming error to the chat. */
reportStreamError: (sessionId: string, abortSignal: AbortSignal, err: unknown) => void;
}
/** Context values needed by sendToCattyAgent that change frequently (avoids stale closures). */
export interface SendToCattyContext {
activeProvider: ProviderConfig | undefined;
activeModelId: string;
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
terminalSessions: TerminalSessionInfo[];
webSearchConfig?: WebSearchConfig | null;
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
autoTitleSession: (sessionId: string, text: string) => void;
}
/** Context values needed by sendToExternalAgent that change frequently. */
export interface SendToExternalContext {
terminalSessions: TerminalSessionInfo[];
providers: ProviderConfig[];
selectedAgentModel?: string;
}
// -------------------------------------------------------------------
// Hook implementation
// -------------------------------------------------------------------
export function useAIChatStreaming({
maxIterations,
addMessageToSession,
updateLastMessage,
updateMessageById,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
// Per-session streaming state (keyed by sessionId)
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(new Set());
const setStreamingForScope = useCallback((key: string, val: boolean) => {
setStreamingSessions(prev => {
const next = new Set(prev);
if (val) next.add(key); else next.delete(key);
return next;
});
}, []);
// Per-scope abort controllers
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
// -------------------------------------------------------------------
// reportStreamError
// -------------------------------------------------------------------
const reportStreamError = useCallback((
sessionId: string,
abortSignal: AbortSignal,
err: unknown,
) => {
if (abortSignal.aborted) return;
let errorStr: string;
if (err instanceof Error) errorStr = err.message;
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
else if (typeof err === 'string') errorStr = err;
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
// Log the full unsanitized error for debugging
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
const errorInfo = classifyError(errorStr);
// Sanitize the displayed message to avoid leaking paths, keys, or other sensitive info
errorInfo.message = sanitizeErrorMessage(errorInfo.message);
updateLastMessage(sessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(sessionId, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo,
timestamp: Date.now(),
});
}, [updateLastMessage, addMessageToSession]);
// -------------------------------------------------------------------
// processCattyStream
// -------------------------------------------------------------------
const processCattyStream = useCallback(async (
streamSessionId: string,
model: ReturnType<typeof createModelFromConfig>,
systemPrompt: string,
tools: ReturnType<typeof createCattyTools>,
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
): Promise<ApprovalInfo | null> => {
const result = streamText({
model,
messages: sdkMessages,
system: systemPrompt,
tools,
stopWhen: stepCountIs(maxIterations),
abortSignal: signal,
});
// Track the current assistant message ID so updates target the correct message
let activeMsgId = currentAssistantMsgId;
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
const reader = result.fullStream.getReader();
let pendingApprovalInfo: ApprovalInfo | null = null;
// -- Text-delta batching: accumulate deltas and flush periodically --
let pendingText = '';
let rafId: number | null = null;
const flushText = () => {
if (pendingText) {
const text = pendingText;
pendingText = '';
if (lastAddedRole === 'tool') {
const newId = generateId();
addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: text,
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
} else {
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
content: msg.content + text,
}));
}
}
rafId = null;
};
const cancelPendingFlush = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// Use the StreamChunk union for type narrowing instead of unsafe casts
const chunk = value as StreamChunk;
switch (chunk.type) {
case 'text':
case 'text-delta': {
const typedChunk = chunk as TextDeltaChunk;
const text = typedChunk.text ?? typedChunk.textDelta;
if (text) {
pendingText += text;
if (rafId === null) {
rafId = requestAnimationFrame(flushText);
}
}
break;
}
case 'reasoning':
case 'reasoning-start':
case 'reasoning-delta': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ReasoningChunk;
const rText = typedChunk.text;
if (rText) {
if (lastAddedRole === 'tool') {
const newId = generateId();
addMessageToSession(streamSessionId, {
id: newId,
role: 'assistant',
content: '',
thinking: rText,
timestamp: Date.now(),
});
activeMsgId = newId;
lastAddedRole = 'assistant';
} else {
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
thinking: (msg.thinking || '') + rText,
}));
}
}
break;
}
case 'reasoning-end':
case 'text-start':
case 'text-end':
case 'start':
case 'finish':
case 'start-step':
case 'finish-step':
break;
case 'tool-call': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolCallChunk;
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), {
id: typedChunk.toolCallId,
name: typedChunk.toolName,
arguments: (typedChunk.input ?? typedChunk.args) as Record<string, unknown>,
}],
executionStatus: 'running',
statusText: undefined,
}));
break;
}
case 'tool-result': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolResultChunk;
// Mark the assistant message's tool execution as completed
updateMessageById(streamSessionId, activeMsgId, msg =>
msg.role === 'assistant' && msg.executionStatus === 'running'
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
);
const toolOutput = typedChunk.output ?? typedChunk.result;
addMessageToSession(streamSessionId, {
id: generateId(),
role: 'tool',
content: '',
toolResults: [{
toolCallId: typedChunk.toolCallId,
content: typeof toolOutput === 'string'
? toolOutput
: JSON.stringify(toolOutput),
isError: false,
}],
timestamp: Date.now(),
executionStatus: 'completed',
});
lastAddedRole = 'tool';
break;
}
case 'tool-approval-request': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolApprovalRequestChunk;
pendingApprovalInfo = {
approvalId: typedChunk.approvalId,
toolCallId: typedChunk.toolCall.toolCallId,
toolName: typedChunk.toolCall.toolName,
toolArgs: typedChunk.toolCall.args ?? typedChunk.toolCall.input ?? {},
};
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
pendingApproval: {
...pendingApprovalInfo!,
status: 'pending' as const,
},
}));
break;
}
case 'error': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ErrorChunk;
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(streamSessionId, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo: classifyError(
typedChunk.error instanceof Error ? typedChunk.error.message
: typeof typedChunk.error === 'string' ? typedChunk.error
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
),
timestamp: Date.now(),
});
break;
}
default:
break;
}
}
} finally {
cancelPendingFlush();
flushText();
reader.releaseLock();
}
return pendingApprovalInfo;
}, [maxIterations, addMessageToSession, updateMessageById]);
// -------------------------------------------------------------------
// sendToExternalAgent
// -------------------------------------------------------------------
const sendToExternalAgent = useCallback(async (
sessionId: string,
trimmed: string,
agentConfig: ExternalAgentConfig,
abortController: AbortController,
attachedImages: Array<{ base64Data: string; mediaType: string; filename?: string }>,
context: SendToExternalContext,
) => {
const bridge = getNetcattyBridge();
if (agentConfig.acpCommand && bridge) {
const requestId = `acp_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
// Push terminal session metadata to MCP bridge
if (bridge?.aiMcpUpdateSessions) {
await bridge.aiMcpUpdateSessions(context.terminalSessions, sessionId);
}
// Pass only the provider ID — the main process resolves and decrypts the API key itself,
// avoiding plaintext key transit across the IPC boundary.
const openaiProvider = context.providers.find(p => p.providerId === 'openai' && p.enabled && p.apiKey);
const agentProviderId = openaiProvider?.id;
// Mutable flag: set after tool-result, cleared when new assistant msg is created
let needsNewAssistantMsg = false;
const maybeCreateAssistantMsg = () => {
if (needsNewAssistantMsg) {
needsNewAssistantMsg = false;
addMessageToSession(sessionId, {
id: generateId(), role: 'assistant', content: '', timestamp: Date.now(),
model: agentConfig.name || 'external',
});
}
};
await runAcpAgentTurn(
bridge,
requestId,
sessionId,
agentConfig,
trimmed,
{
onTextDelta: (text: string) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({
...msg,
content: msg.content + text,
statusText: undefined,
thinkingDurationMs: msg.thinking && !msg.thinkingDurationMs
? Date.now() - msg.timestamp : msg.thinkingDurationMs,
}));
},
onThinkingDelta: (text: string) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({
...msg, thinking: (msg.thinking || '') + text,
}));
},
onThinkingDone: () => {
updateLastMessage(sessionId, msg => ({
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
}));
},
onToolCall: (toolName: string, args: Record<string, unknown>) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), { id: `tc_${Date.now()}`, name: toolName, arguments: args }],
executionStatus: 'running',
statusText: undefined,
}));
},
onToolResult: (toolCallId: string, result: string) => {
updateLastMessage(sessionId, msg =>
msg.role === 'assistant' && msg.executionStatus === 'running'
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
);
addMessageToSession(sessionId, {
id: generateId(), role: 'tool', content: '',
toolResults: [{ toolCallId, content: result, isError: false }],
timestamp: Date.now(), executionStatus: 'completed',
});
needsNewAssistantMsg = true;
},
onStatus: (message: string) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({ ...msg, statusText: message }));
},
onError: (error: string) => {
reportStreamError(sessionId, abortController.signal, error);
setStreamingForScope(sessionId, false);
},
onDone: () => {},
},
abortController.signal,
agentProviderId,
context.selectedAgentModel,
attachedImages.length > 0 ? attachedImages : undefined,
);
} else {
// Fallback: spawn as raw process
await runExternalAgentTurn(
agentConfig,
trimmed,
{
onTextDelta: (text: string) => {
updateLastMessage(sessionId, msg => ({ ...msg, content: msg.content + text }));
},
onError: (error: string) => {
reportStreamError(sessionId, abortController.signal, error);
setStreamingForScope(sessionId, false);
},
onDone: () => {},
},
bridge as unknown as Parameters<typeof runExternalAgentTurn>[3],
abortController.signal,
);
}
}, [
addMessageToSession, updateLastMessage, setStreamingForScope, reportStreamError,
]);
// -------------------------------------------------------------------
// sendToCattyAgent
// -------------------------------------------------------------------
const sendToCattyAgent = useCallback(async (
sessionId: string,
sendScopeKey: string,
trimmed: string,
abortController: AbortController,
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
) => {
const bridge = getNetcattyBridge();
const tools = createCattyTools(bridge, {
sessions: context.terminalSessions,
workspaceId: context.scopeTargetId,
workspaceName: context.scopeLabel,
}, context.commandBlocklist, context.globalPermissionMode, context.webSearchConfig ?? undefined);
const systemPrompt = buildSystemPrompt({
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
hosts: context.terminalSessions.map(s => ({
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
os: s.os, username: s.username, connected: s.connected,
})),
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
});
// Guard: activeProvider must exist for Catty agent path
if (!context.activeProvider) {
reportStreamError(sessionId, abortController.signal, 'No AI provider configured. Please configure a provider in Settings → AI.');
return;
}
// Create model with placeholder API key — the main process injects the real
// decrypted key when the HTTP request is proxied through IPC, so plaintext
// keys never transit the renderer ↔ main IPC boundary.
let model;
try {
model = createModelFromConfig({
...context.activeProvider,
defaultModel: context.activeModelId || context.activeProvider.defaultModel || '',
});
} catch (e) {
console.error('[Catty] Model creation failed:', e);
reportStreamError(sessionId, abortController.signal, `Model creation failed: ${e instanceof Error ? e.message : String(e)}`);
return;
}
try {
// Issue #5: Build SDK messages including tool-call and tool-result messages
// so the LLM maintains full conversation context
const allMessages = currentSession?.messages ?? [];
// Collect all tool call IDs that have a corresponding tool result,
// so we can skip orphaned tool calls (e.g. from user stopping mid-execution)
const resolvedToolCallIds = new Set<string>();
for (const m of allMessages) {
if (m.role === 'tool' && m.toolResults) {
for (const tr of m.toolResults) resolvedToolCallIds.add(tr.toolCallId);
}
}
const findToolName = (toolCallId: string): string => {
for (const prev of allMessages) {
if (prev.role === 'assistant' && prev.toolCalls) {
const tc = prev.toolCalls.find(t => t.id === toolCallId);
if (tc) return tc.name;
}
}
return 'unknown';
};
const sdkMessages: Array<ModelMessage> = [];
for (const m of allMessages) {
if (m.role === 'user') {
sdkMessages.push({ role: 'user', content: m.content });
} else if (m.role === 'assistant') {
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
const contentParts: Array<
{ type: 'text'; text: string } |
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
> = [];
if (m.content) {
contentParts.push({ type: 'text' as const, text: m.content });
}
for (const tc of resolvedCalls) {
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
toolName: tc.name,
input: tc.arguments ?? {},
});
}
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
sdkMessages.push({ role: 'assistant', content: contentParts.length === 1 && contentParts[0].type === 'text' ? (contentParts[0] as { type: 'text'; text: string }).text : contentParts });
}
} else if (m.content) {
sdkMessages.push({ role: 'assistant', content: m.content });
}
} else if (m.role === 'tool' && m.toolResults?.length) {
sdkMessages.push({
role: 'tool',
content: m.toolResults.map(tr => ({
type: 'tool-result' as const,
toolCallId: tr.toolCallId,
toolName: findToolName(tr.toolCallId),
output: { type: 'text' as const, value: tr.content },
})),
});
}
}
sdkMessages.push({ role: 'user', content: trimmed });
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
if (approvalInfo) {
context.setPendingApproval({
sessionId, scopeKey: sendScopeKey, sdkMessages, approvalInfo, model, systemPrompt, tools,
});
return; // Keep streaming flag — waiting for user approval
}
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);
} finally {
// Clear any lingering statusText when the stream finishes
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
context.autoTitleSession(sessionId, trimmed);
}
}, [
processCattyStream, reportStreamError, setStreamingForScope,
updateLastMessage,
]);
return {
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
processCattyStream,
sendToCattyAgent,
sendToExternalAgent,
reportStreamError,
};
}

View File

@@ -0,0 +1,76 @@
/**
* useConversationExport — Encapsulates conversation export logic for the AI chat panel.
*
* Handles:
* - Export in markdown, JSON, and plain text formats
* - Object URL lifecycle management (creation, revocation, cleanup on unmount)
*/
import React, { useCallback, useEffect, useRef } from 'react';
import type { AISession } from '../../../infrastructure/ai/types';
import { exportAsMarkdown, exportAsJSON, exportAsPlainText, getExportFilename } from '../../../infrastructure/ai/conversationExport';
// -------------------------------------------------------------------
// Hook return type
// -------------------------------------------------------------------
export interface UseConversationExportReturn {
/** Trigger a download of the active session in the given format. */
handleExport: (format: 'md' | 'json' | 'txt') => void;
/** Ref to active object URLs for cleanup on unmount (exposed for the parent cleanup effect). */
activeObjectUrlsRef: React.MutableRefObject<Set<string>>;
}
// -------------------------------------------------------------------
// Hook implementation
// -------------------------------------------------------------------
export function useConversationExport(
activeSession: AISession | null,
): UseConversationExportReturn {
// Ref to track active object URLs for cleanup on unmount (Issue #19)
const activeObjectUrlsRef = useRef<Set<string>>(new Set());
// Clean up object URLs on unmount
useEffect(() => {
const urls = activeObjectUrlsRef.current;
return () => {
urls.forEach(url => URL.revokeObjectURL(url));
urls.clear();
};
}, []);
const handleExport = useCallback((format: 'md' | 'json' | 'txt') => {
if (!activeSession) return;
let content: string;
switch (format) {
case 'md': content = exportAsMarkdown(activeSession); break;
case 'json': content = exportAsJSON(activeSession); break;
case 'txt': content = exportAsPlainText(activeSession); break;
}
const filename = getExportFilename(activeSession, format);
// Create a download blob
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
// Track URL for cleanup on unmount (Issue #19)
activeObjectUrlsRef.current.add(url);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoke after a generous delay to ensure download completes, then remove from tracking set
const revokeTimeout = setTimeout(() => {
URL.revokeObjectURL(url);
activeObjectUrlsRef.current.delete(url);
}, 60_000); // 60 seconds to be safe for large files
// If component unmounts before timeout, cleanup effect will revoke it
void revokeTimeout; // suppress unused warning
}, [activeSession]);
return {
handleExport,
activeObjectUrlsRef,
};
}

View File

@@ -0,0 +1,284 @@
/**
* useToolApproval — Encapsulates the tool approval workflow for the AI chat panel.
*
* Handles:
* - Pending approval context management
* - Approval timeout (auto-clear after 5 minutes)
* - handleApprovalResponse (approve/reject from InlineApprovalCard)
* - Resuming the Catty stream after approval
*/
import React, { useCallback, useRef } from 'react';
import type { ModelMessage } from 'ai';
import type {
AIPermissionMode,
ChatMessage,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import type {
ApprovalInfo,
PendingApprovalContext,
TerminalSessionInfo,
} from './useAIChatStreaming';
import { getNetcattyBridge } from './useAIChatStreaming';
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
// -------------------------------------------------------------------
// Hook parameters
// -------------------------------------------------------------------
export interface UseToolApprovalParams {
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
setStreamingForScope: (key: string, val: boolean) => void;
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
processCattyStream: (
streamSessionId: string,
model: ReturnType<typeof createModelFromConfig>,
systemPrompt: string,
tools: ReturnType<typeof createCattyTools>,
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
) => Promise<ApprovalInfo | null>;
t: (key: string) => string;
}
// -------------------------------------------------------------------
// Hook return type
// -------------------------------------------------------------------
export interface UseToolApprovalReturn {
/** Ref to the current pending approval context (null when none). */
pendingApprovalContextRef: React.MutableRefObject<PendingApprovalContext | null>;
/** Set or clear the pending approval context (manages timeout). */
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
/** Handle a user's approve/reject response from InlineApprovalCard. */
handleApprovalResponse: (
messageId: string,
approved: boolean,
approvalContext: ToolApprovalContext,
) => Promise<void>;
}
/** Context values needed by handleApprovalResponse that change frequently. */
export interface ToolApprovalContext {
terminalSessions: TerminalSessionInfo[];
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
webSearchConfig?: WebSearchConfig | null;
}
// -------------------------------------------------------------------
// Hook implementation
// -------------------------------------------------------------------
export function useToolApproval({
addMessageToSession,
updateLastMessage,
updateMessageById,
setStreamingForScope,
abortControllersRef,
processCattyStream,
t,
}: UseToolApprovalParams): UseToolApprovalReturn {
// Pending approval context — stores SDK state needed to resume after user approves/rejects
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(null);
// Timeout ID for auto-clearing stale pending approval (Issue #14)
const pendingApprovalTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** Set pending approval context with a 5-minute auto-clear timeout. */
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
// Clear any existing timeout
if (pendingApprovalTimeoutRef.current) {
clearTimeout(pendingApprovalTimeoutRef.current);
pendingApprovalTimeoutRef.current = null;
}
pendingApprovalContextRef.current = ctx;
if (ctx) {
pendingApprovalTimeoutRef.current = setTimeout(() => {
// Auto-clear after 5 minutes if user never responds
if (pendingApprovalContextRef.current?.sessionId === ctx.sessionId) {
pendingApprovalContextRef.current = null;
setStreamingForScope(ctx.sessionId, false);
abortControllersRef.current.get(ctx.sessionId)?.abort();
abortControllersRef.current.delete(ctx.sessionId);
// Notify the user that the approval timed out
updateLastMessage(ctx.sessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(ctx.sessionId, {
id: generateId(),
role: 'assistant',
content: t('ai.chat.approvalTimeout'),
timestamp: Date.now(),
});
}
pendingApprovalTimeoutRef.current = null;
}, 5 * 60 * 1000); // 5 minutes
}
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
// Handle inline approval response (approve/reject from InlineApprovalCard)
const handleApprovalResponse = useCallback(async (
messageId: string,
approved: boolean,
approvalContext: ToolApprovalContext,
) => {
const ctx = pendingApprovalContextRef.current;
if (!ctx) return;
// Destructure all needed values BEFORE clearing the ref to avoid race conditions
const { sessionId: sid, scopeKey: sk, sdkMessages, approvalInfo, model: ctxModel } = ctx;
// Clear pending approval (and its timeout) via setPendingApproval
setPendingApproval(null);
// Update the message's pendingApproval status using message ID
updateMessageById(sid, messageId, msg => ({
...msg,
pendingApproval: msg.pendingApproval
? { ...msg.pendingApproval, status: approved ? 'approved' as const : 'denied' as const }
: undefined,
}));
if (!approved) {
// User rejected — add denial text and stop
updateMessageById(sid, messageId, msg => ({
...msg,
content: msg.content + (msg.content ? '\n\n' : '') + t('ai.chat.toolDenied'),
statusText: '',
executionStatus: 'completed',
}));
setStreamingForScope(sid, false);
abortControllersRef.current.delete(sid);
return;
}
// User approved — construct SDK messages with approval response and resume
const resumeMessages: Array<Record<string, unknown>> = [
...sdkMessages,
// The assistant message that contained the tool call + approval request
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: approvalInfo.toolCallId,
toolName: approvalInfo.toolName,
input: approvalInfo.toolArgs,
},
{
type: 'tool-approval-request',
approvalId: approvalInfo.approvalId,
toolCallId: approvalInfo.toolCallId,
},
],
},
// The user's approval response
{
role: 'tool',
content: [
{
type: 'tool-approval-response',
approvalId: approvalInfo.approvalId,
approved: true,
},
],
},
];
// Create a new assistant message placeholder for the continuation
const newAssistantMsgId = generateId();
addMessageToSession(sid, {
id: newAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
});
const abortController = new AbortController();
abortControllersRef.current.set(sid, abortController);
try {
// Rebuild tools and system prompt with the latest permission mode to prevent
// stale closure issues (e.g. user changed permission mode during approval wait)
const bridge = getNetcattyBridge();
const freshTools = createCattyTools(bridge, {
sessions: approvalContext.terminalSessions,
workspaceId: approvalContext.scopeTargetId,
workspaceName: approvalContext.scopeLabel,
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode, approvalContext.webSearchConfig ?? undefined);
const freshSystemPrompt = buildSystemPrompt({
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
hosts: approvalContext.terminalSessions.map(s => ({
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
os: s.os, username: s.username, connected: s.connected,
})),
permissionMode: approvalContext.globalPermissionMode,
webSearchEnabled: isWebSearchReady(approvalContext.webSearchConfig),
});
const newApprovalInfo = await processCattyStream(sid, ctxModel, freshSystemPrompt, freshTools, resumeMessages as unknown as ModelMessage[], abortController.signal, newAssistantMsgId);
if (newApprovalInfo) {
// Another approval needed — save context for the next round (with timeout)
setPendingApproval({
sessionId: sid,
scopeKey: sk,
sdkMessages: resumeMessages,
approvalInfo: newApprovalInfo,
model: ctxModel,
systemPrompt: freshSystemPrompt,
tools: freshTools,
});
return;
}
} catch (err) {
console.error('[Catty resume] streamText error:', err);
if (!abortController.signal.aborted) {
const errorStr = err instanceof Error ? err.message : String(err);
updateMessageById(sid, newAssistantMsgId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(sid, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo: classifyError(errorStr),
timestamp: Date.now(),
});
}
} finally {
if (!pendingApprovalContextRef.current || pendingApprovalContextRef.current.sessionId !== sid) {
// Clear any lingering statusText when the resumed stream finishes
updateLastMessage(sid, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sid, false);
abortControllersRef.current.delete(sid);
}
}
}, [
processCattyStream, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, abortControllersRef, t, setPendingApproval,
]);
return {
pendingApprovalContextRef,
setPendingApproval,
handleApprovalResponse,
};
}

View File

@@ -1,94 +0,0 @@
/**
* Edit Key Panel - Edit existing SSH key
*/
import { Info } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
interface EditKeyPanelProps {
draftKey: Partial<SSHKey>;
_originalKey: SSHKey; // Reserved for future diff/comparison feature
setDraftKey: (key: Partial<SSHKey>) => void;
onExport: () => void;
onSave: () => void;
}
export const EditKeyPanel: React.FC<EditKeyPanelProps> = ({
draftKey,
_originalKey, // Reserved for future diff/comparison feature
setDraftKey,
onExport,
onSave,
}) => {
const { t } = useI18n();
return (
<>
<div className="space-y-2">
<Label>{t('keychain.field.labelRequired')}</Label>
<Input
value={draftKey.label || ''}
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
placeholder={t('keychain.field.labelPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label className="text-destructive">{t('keychain.field.privateKeyRequired')}</Label>
<Textarea
value={draftKey.privateKey || ''}
onChange={e => setDraftKey({ ...draftKey, privateKey: e.target.value })}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[180px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.field.publicKey')}</Label>
<Textarea
value={draftKey.publicKey || ''}
onChange={e => setDraftKey({ ...draftKey, publicKey: e.target.value })}
placeholder="ssh-ed25519 AAAA..."
className="min-h-[80px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('terminal.auth.certificate')}</Label>
<Textarea
value={draftKey.certificate || ''}
onChange={e => setDraftKey({ ...draftKey, certificate: e.target.value })}
placeholder={t('keychain.field.certificatePlaceholder')}
className="min-h-[60px] font-mono text-xs"
/>
</div>
{/* Key Export section */}
<div className="pt-4 mt-4 border-t border-border/60">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium">{t('keychain.export.title')}</span>
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
<Info size={10} className="text-muted-foreground" />
</div>
</div>
<Button className="w-full h-11" onClick={onExport}>
{t('keychain.export.exportToHost')}
</Button>
</div>
{/* Save button */}
<Button
className="w-full h-11 mt-4"
disabled={!draftKey.label?.trim() || !draftKey.privateKey?.trim()}
onClick={onSave}
>
{t('common.saveChanges')}
</Button>
</>
);
};

View File

@@ -1,246 +0,0 @@
/**
* Export Key Panel - Export SSH key to remote host
*/
import { ChevronRight, Info } from 'lucide-react';
import React, { useState } from 'react';
import { useKeychainBackend } from '../../application/state/useKeychainBackend';
import { useI18n } from '../../application/i18n/I18nProvider';
import { resolveHostAuth } from '../../domain/sshAuth';
import { cn } from '../../lib/utils';
import { Host, Identity, SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { toast } from '../ui/toast';
import { getKeyIcon, getKeyTypeDisplay, isMacOS } from './utils';
interface ExportKeyPanelProps {
keyItem: SSHKey;
_hosts: Host[]; // Reserved for future inline host list/validation
keys: SSHKey[];
identities: Identity[];
exportHost: Host | null;
_setExportHost: (host: Host | null) => void; // Host selection handled by onShowHostSelector callback
onShowHostSelector: () => void;
onSaveHost?: (host: Host) => void;
onClose: () => void;
}
const DEFAULT_EXPORT_SCRIPT = `DIR="$HOME/$1"
FILE="$DIR/$2"
if [ ! -d "$DIR" ]; then
mkdir -p "$DIR"
chmod 700 "$DIR"
fi
if [ ! -f "$FILE" ]; then
touch "$FILE"
chmod 600 "$FILE"
fi
echo $3 >> "$FILE"`;
export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
keyItem,
_hosts, // Reserved for future inline host list/validation
keys,
identities,
exportHost,
_setExportHost, // Host selection handled by onShowHostSelector callback
onShowHostSelector,
onSaveHost,
onClose,
}) => {
const { t } = useI18n();
const { execCommand } = useKeychainBackend();
const [exportLocation, setExportLocation] = useState('.ssh');
const [exportFilename, setExportFilename] = useState('authorized_keys');
const [exportAdvancedOpen, setExportAdvancedOpen] = useState(false);
const [exportScript, setExportScript] = useState(DEFAULT_EXPORT_SCRIPT);
const [isExporting, setIsExporting] = useState(false);
const isMac = isMacOS();
const handleExport = async () => {
if (!exportHost || !keyItem.publicKey) return;
setIsExporting(true);
try {
const exportAuth = resolveHostAuth({ host: exportHost, keys, identities });
// Check for authentication method
if (!exportAuth.password && !exportAuth.key?.privateKey) {
throw new Error(t('keychain.export.missingCredentials'));
}
const hostPrivateKey = exportAuth.key?.privateKey;
// Escape the public key for shell
const escapedPublicKey = keyItem.publicKey.replace(/'/g, "'\\''");
// Build the command by replacing $1, $2, $3
const scriptWithVars = exportScript
.replace(/\$1/g, exportLocation)
.replace(/\$2/g, exportFilename)
.replace(/\$3/g, `'${escapedPublicKey}'`);
const command = scriptWithVars;
// Execute via SSH
const result = await execCommand({
hostname: exportHost.hostname,
username: exportAuth.username,
port: exportHost.port || 22,
password: exportAuth.password,
privateKey: hostPrivateKey,
command,
timeout: 30000,
enableKeyboardInteractive: true,
sessionId: `export-key:${exportHost.id}:${keyItem.id}`,
});
// Check result
const exitCode = result?.code;
const hasError = result?.stderr?.trim();
if (exitCode === 0 || (exitCode == null && !hasError)) {
// Update host to use this key for authentication
if (onSaveHost) {
const updatedHost: Host = {
...exportHost,
identityFileId: keyItem.id,
authMethod: 'key',
};
onSaveHost(updatedHost);
}
toast.success(
t('keychain.export.successMessage', { host: exportHost.label }),
t('keychain.export.successTitle'),
);
onClose();
} else {
const errorMsg = hasError || result?.stdout?.trim() || `Command exited with code ${exitCode}`;
toast.error(
t('keychain.export.failedMessage', { error: errorMsg }),
t('keychain.export.failedTitle'),
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(
t('keychain.export.failedGeneric', { message }),
t('keychain.export.failedTitle'),
);
} finally {
setIsExporting(false);
}
};
return (
<>
{/* Key info card */}
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
<div className={cn(
"h-10 w-10 rounded-md flex items-center justify-center",
keyItem.certificate
? "bg-emerald-500/15 text-emerald-500"
: "bg-primary/15 text-primary"
)}>
{getKeyIcon(keyItem)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold truncate">{keyItem.label}</p>
<p className="text-xs text-muted-foreground">
{t('auth.keyType', { type: getKeyTypeDisplay(keyItem, isMac) })}
</p>
</div>
</div>
{/* Export to field */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground">{t('keychain.export.exportToRequired')}</Label>
<Button
variant="link"
className="h-auto p-0 text-primary text-sm"
onClick={onShowHostSelector}
>
{t('keychain.export.selectHost')}
</Button>
</div>
<Input
value={exportHost?.label || ''}
readOnly
placeholder={t('keychain.export.selectHostPlaceholder')}
className="bg-muted/50 cursor-pointer"
onClick={onShowHostSelector}
/>
</div>
{/* Location field */}
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.export.locationLabel')}</Label>
<Input
value={exportLocation}
onChange={e => setExportLocation(e.target.value)}
placeholder=".ssh"
/>
</div>
{/* Filename field */}
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.export.filenameLabel')}</Label>
<Input
value={exportFilename}
onChange={e => setExportFilename(e.target.value)}
placeholder="authorized_keys"
/>
</div>
{/* Info note */}
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
<Info size={14} className="mt-0.5 text-muted-foreground shrink-0" />
<p className="text-xs text-muted-foreground">
{t('keychain.export.note.supportsOnly')}{' '}
<span className="font-semibold text-foreground">UNIX</span>{' '}
{t('keychain.export.note.systems')}{' '}
{t('keychain.export.note.use')}{' '}
<span className="font-semibold text-foreground">{t('keychain.export.advanced')}</span>{' '}
{t('keychain.export.note.customize')}
</p>
</div>
{/* Advanced collapsible */}
<Collapsible open={exportAdvancedOpen} onOpenChange={setExportAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current">
<span className="font-medium">{t('keychain.export.advanced')}</span>
<ChevronRight size={16} className={cn(
"transition-transform",
exportAdvancedOpen && "rotate-90"
)} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
<Label className="text-muted-foreground">{t('keychain.export.scriptRequired')}</Label>
<Textarea
value={exportScript}
onChange={e => setExportScript(e.target.value)}
className="min-h-[180px] font-mono text-xs"
placeholder={t('keychain.export.scriptPlaceholder')}
/>
</CollapsibleContent>
</Collapsible>
{/* Export button */}
<Button
className="w-full h-11"
disabled={!exportHost || !exportLocation || !exportFilename || isExporting}
onClick={handleExport}
>
{isExporting ? t('keychain.export.exporting') : t('keychain.export.exportAndAttach')}
</Button>
</>
);
};

View File

@@ -15,8 +15,6 @@ export { IdentityCard } from './IdentityCard';
export { KeyCard } from './KeyCard';
// Panel components
export { EditKeyPanel } from './EditKeyPanel';
export { ExportKeyPanel } from './ExportKeyPanel';
export { GenerateStandardPanel } from './GenerateStandardPanel';
export { IdentityPanel } from './IdentityPanel';
export { ImportKeyPanel } from './ImportKeyPanel';

View File

@@ -8,6 +8,7 @@ import { createPortal } from 'react-dom';
import { Check, Palette, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../../application/state/customThemeStore';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
@@ -74,6 +75,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
return { darkThemes: dark, lightThemes: light };
}, []);
const customThemes = useCustomThemes();
// Handle theme selection - select and close
const handleThemeSelect = useCallback((themeId: string) => {
onSelect(themeId);
@@ -164,6 +167,25 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
))}
</div>
</div>
{/* Custom Themes Section */}
{customThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
)}
</div>
{/* Footer */}

View File

@@ -1,4 +1,6 @@
import React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "../../lib/utils";
import { ScrollArea } from "../ui/scroll-area";
import { TabsContent } from "../ui/tabs";
@@ -32,29 +34,78 @@ export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) =
interface SelectProps {
value: string;
options: { value: string; label: string }[];
options: { value: string; label: string; icon?: React.ReactNode }[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
placeholder?: string;
}
export const Select: React.FC<SelectProps> = ({ value, options, onChange, className, disabled }) => (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={cn(
"h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
export const Select: React.FC<SelectProps> = ({
value,
options,
onChange,
className,
disabled,
placeholder,
}) => {
const selectedOption = options.find((opt) => opt.value === value);
return (
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
<SelectPrimitive.Trigger
className={cn(
"flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
>
<SelectPrimitive.Value placeholder={placeholder}>
<span className="flex items-center gap-2">
{selectedOption?.icon}
{selectedOption?.label}
</span>
</SelectPrimitive.Value>
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 min-w-[12rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
{options.map((opt) => (
<SelectPrimitive.Item
key={opt.value}
value={opt.value}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span className="flex items-center gap-2">
{opt.icon}
{opt.label}
</span>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
};
export const SectionHeader: React.FC<{ title: string; className?: string }> = ({
title,
@@ -87,4 +138,3 @@ export const SettingsTabContent: React.FC<{
</ScrollArea>
</TabsContent>
);

View File

@@ -0,0 +1,540 @@
/**
* Settings AI Tab - AI provider configuration, agent CLI detection, and safety settings
*
* Sub-components live in ./ai/ directory:
* - ProviderCard, ProviderConfigForm, AddProviderDropdown
* - ModelSelector, ProviderIconBadge
* - CodexConnectionCard, ClaudeCodeCard
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import type {
AIPermissionMode,
AIProviderId,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Select, SettingRow } from "../settings-ui";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
import type {
AgentPathInfo,
CodexIntegrationStatus,
CodexLoginSession,
} from "./ai/types";
import {
AGENT_DEFAULTS,
getBridge,
normalizeCodexBridgeError,
} from "./ai/types";
import { ProviderIconBadge } from "./ai/ProviderIconBadge";
import { ProviderCard } from "./ai/ProviderCard";
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface SettingsAITabProps {
providers: ProviderConfig[];
addProvider: (provider: ProviderConfig) => void;
updateProvider: (id: string, updates: Partial<ProviderConfig>) => void;
removeProvider: (id: string) => void;
activeProviderId: string;
setActiveProviderId: (id: string) => void;
activeModelId: string;
setActiveModelId: (id: string) => void;
globalPermissionMode: AIPermissionMode;
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
externalAgents: ExternalAgentConfig[];
setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
defaultAgentId: string;
setDefaultAgentId: (id: string) => void;
commandBlocklist: string[];
setCommandBlocklist: (value: string[]) => void;
commandTimeout: number;
setCommandTimeout: (value: number) => void;
maxIterations: number;
setMaxIterations: (value: number) => void;
webSearchConfig: WebSearchConfig | null;
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
// ---------------------------------------------------------------------------
// Main Tab Component
// ---------------------------------------------------------------------------
const SettingsAITab: React.FC<SettingsAITabProps> = ({
providers,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId: _activeModelId,
setActiveModelId,
globalPermissionMode,
setGlobalPermissionMode,
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
}) => {
const { t } = useI18n();
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
const [codexIntegration, setCodexIntegration] = useState<CodexIntegrationStatus | null>(null);
const [codexLoginSession, setCodexLoginSession] = useState<CodexLoginSession | null>(null);
const [isCodexLoading, setIsCodexLoading] = useState(false);
const [codexError, setCodexError] = useState<string | null>(null);
// Path detection state
const [codexPathInfo, setCodexPathInfo] = useState<AgentPathInfo | null>(null);
const [codexCustomPath, setCodexCustomPath] = useState("");
const [isResolvingCodex, setIsResolvingCodex] = useState(false);
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const {
discoveredAgents,
isDiscovering,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
// Derive path info from discovery results
useEffect(() => {
if (isDiscovering) return;
const codex = discoveredAgents.find((a) => a.command === "codex");
setCodexPathInfo(
codex
? { path: codex.path, version: codex.version, available: true }
: { path: null, version: null, available: false },
);
const claude = discoveredAgents.find((a) => a.command === "claude");
setClaudePathInfo(
claude
? { path: claude.path, version: claude.version, available: true }
: { path: null, version: null, available: false },
);
}, [isDiscovering, discoveredAgents]);
// Auto-register discovered agents in externalAgents
useEffect(() => {
if (isDiscovering || discoveredAgents.length === 0) return;
setExternalAgents((prev) => {
const agentsToRegister: ExternalAgentConfig[] = [];
for (const da of discoveredAgents) {
if (da.command !== "codex" && da.command !== "claude") continue;
const agentId = `discovered_${da.command}`;
if (prev.some((ea) => ea.id === agentId)) continue;
agentsToRegister.push(enableAgent(da));
}
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
});
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return;
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
setResolving(true);
try {
const result = await bridge.aiResolveCli({
command: agentKey,
customPath: customPath.trim(),
});
setInfo(result);
// Register/update in externalAgents if valid
if (result.available && result.path) {
const agentId = `discovered_${agentKey}`;
const defaults = AGENT_DEFAULTS[agentKey];
setExternalAgents((prev) => {
const idx = prev.findIndex((a) => a.id === agentId);
const config: ExternalAgentConfig = {
id: agentId,
command: result.path!,
enabled: true,
...defaults,
};
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], command: result.path! };
return updated;
}
return [...prev, config];
});
}
} catch (err) {
console.error("Path resolution failed:", err);
} finally {
setResolving(false);
}
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
// Add a new provider from preset
const handleAddProvider = useCallback(
(providerId: AIProviderId) => {
const preset = PROVIDER_PRESETS[providerId];
const id = `provider_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
addProvider({
id,
providerId,
name: preset.name,
baseURL: preset.defaultBaseURL,
enabled: false,
});
// Auto-open config form
setEditingProviderId(id);
},
[addProvider],
);
// Remove provider with confirmation
const handleRemoveProvider = useCallback(
(id: string) => {
const provider = providers.find((p) => p.id === id);
const name = provider?.name || id;
const ok = window.confirm(
t('confirm.removeProvider', { name }),
);
if (!ok) return;
removeProvider(id);
if (editingProviderId === id) {
setEditingProviderId(null);
}
},
[removeProvider, editingProviderId, providers, t],
);
// Agent options for default agent
const agentOptions = useMemo(() => [
{ value: "catty", label: t('ai.defaultAgent.catty'), icon: <AgentIconBadge agent={{ id: "catty", type: "builtin" }} size="xs" variant="plain" /> },
...externalAgents
.filter((a) => a.enabled)
.map((a) => ({ value: a.id, label: a.name, icon: <AgentIconBadge agent={a} size="xs" variant="plain" /> })),
], [externalAgents, t]);
const hasOpenAiProviderKey = providers.some(
(provider) => provider.providerId === "openai" && provider.enabled && !!provider.apiKey,
);
const refreshCodexIntegration = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
setIsCodexLoading(true);
setCodexError(null);
try {
const integration = await bridge.aiCodexGetIntegration();
setCodexIntegration(integration);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
} finally {
setIsCodexLoading(false);
}
}, []);
useEffect(() => {
void refreshCodexIntegration();
}, [refreshCodexIntegration]);
useEffect(() => {
if (!codexLoginSession || codexLoginSession.state !== "running") {
return;
}
const bridge = getBridge();
if (!bridge?.aiCodexGetLoginSession) {
return;
}
let cancelled = false;
const intervalId = window.setInterval(() => {
void bridge.aiCodexGetLoginSession?.(codexLoginSession.sessionId).then((result) => {
if (cancelled || !result?.ok || !result.session) return;
setCodexLoginSession(result.session);
if (result.session.state !== "running") {
if (result.session.state === "success") {
void refreshCodexIntegration();
}
}
}).catch((err) => {
if (!cancelled) {
setCodexError(normalizeCodexBridgeError(err));
}
});
}, 1000);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [codexLoginSession, refreshCodexIntegration]);
const handleStartCodexLogin = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiCodexStartLogin) return;
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexStartLogin();
if (!result.ok || !result.session) {
throw new Error(result.error || "Failed to start Codex login");
}
setCodexLoginSession(result.session);
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
} finally {
setIsCodexLoading(false);
}
}, []);
const handleCancelCodexLogin = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiCodexCancelLogin || !codexLoginSession) return;
setCodexError(null);
try {
const result = await bridge.aiCodexCancelLogin(codexLoginSession.sessionId);
if (result.session) {
setCodexLoginSession(result.session);
}
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
}
}, [codexLoginSession]);
const handleOpenCodexLoginUrl = useCallback(() => {
const bridge = getBridge();
const url = codexLoginSession?.url;
if (!bridge?.openExternal || !url) return;
// Only allow https:// URLs to prevent opening arbitrary protocols
if (!url.startsWith("https://")) return;
void bridge.openExternal(url);
}, [codexLoginSession]);
const handleCodexLogout = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.aiCodexLogout) return;
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexLogout();
if (!result.ok) {
throw new Error(result.error || "Failed to log out from Codex");
}
setCodexLoginSession(null);
await refreshCodexIntegration();
} catch (err) {
setCodexError(normalizeCodexBridgeError(err));
} finally {
setIsCodexLoading(false);
}
}, [refreshCodexIntegration]);
return (
<TabsContent
value="ai"
className="data-[state=inactive]:hidden h-full flex flex-col"
>
<div className="flex-1 overflow-y-auto overflow-x-hidden px-8 py-6">
<div className="max-w-2xl space-y-8">
{/* Header */}
<div>
<h2 className="text-xl font-semibold">{t('ai.title')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('ai.description')}
</p>
</div>
{/* -- Providers Section -- */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.providers')}</h3>
</div>
<AddProviderDropdown onAdd={handleAddProvider} />
</div>
{providers.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center">
<Bot size={24} className="mx-auto text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
{t('ai.providers.empty')}
</p>
</div>
) : (
<div className="space-y-2">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
isActive={provider.id === activeProviderId}
onToggleEnabled={(enabled) => {
if (enabled) {
// Activate this provider, deactivate all others
setActiveProviderId(provider.id);
if (provider.defaultModel) {
setActiveModelId(provider.defaultModel);
}
for (const p of providers) {
if (p.id === provider.id) {
if (!p.enabled) updateProvider(p.id, { enabled: true });
} else {
if (p.enabled) updateProvider(p.id, { enabled: false });
}
}
} else {
// Deactivate this provider
if (activeProviderId === provider.id) {
setActiveProviderId("");
setActiveModelId("");
}
updateProvider(provider.id, { enabled: false });
}
}}
onEdit={() =>
setEditingProviderId(
editingProviderId === provider.id ? null : provider.id,
)
}
onRemove={() => handleRemoveProvider(provider.id)}
onUpdate={(updates) => {
updateProvider(provider.id, updates);
// If this is the active provider and model changed, update activeModelId
if (provider.id === activeProviderId && updates.defaultModel !== undefined) {
setActiveModelId(updates.defaultModel || "");
}
}}
isEditing={editingProviderId === provider.id}
onCancelEdit={() => setEditingProviderId(null)}
/>
))}
</div>
)}
</div>
{/* -- Codex Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="openai" size="sm" />
<h3 className="text-base font-medium">{t('ai.codex')}</h3>
</div>
<CodexConnectionCard
pathInfo={codexPathInfo}
isResolvingPath={isDiscovering || isResolvingCodex}
customPath={codexCustomPath}
onCustomPathChange={setCodexCustomPath}
onRecheckPath={() => void handleCheckCustomPath("codex")}
integration={codexIntegration}
loginSession={codexLoginSession}
isLoading={isCodexLoading}
hasOpenAiProviderKey={hasOpenAiProviderKey}
error={codexError}
onRefresh={() => void refreshCodexIntegration()}
onConnect={() => void handleStartCodexLogin()}
onCancel={() => void handleCancelCodexLogin()}
onOpenUrl={handleOpenCodexLoginUrl}
onLogout={() => void handleCodexLogout()}
/>
</div>
{/* -- Claude Code Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="claude" size="sm" />
<h3 className="text-base font-medium">{t('ai.claude.title')}</h3>
</div>
<ClaudeCodeCard
pathInfo={claudePathInfo}
isResolvingPath={isDiscovering || isResolvingClaude}
customPath={claudeCustomPath}
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
/>
</div>
{/* -- Default Agent Section -- */}
{agentOptions.length > 1 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Bot size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('ai.defaultAgent')}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4">
<SettingRow
label={t('ai.defaultAgent')}
description={t('ai.defaultAgent.description')}
>
<Select
value={defaultAgentId}
options={agentOptions}
onChange={setDefaultAgentId}
className="w-48"
/>
</SettingRow>
</div>
</div>
)}
{/* -- Web Search Section -- */}
<WebSearchSettings
webSearchConfig={webSearchConfig}
setWebSearchConfig={setWebSearchConfig}
/>
{/* -- Safety Section -- */}
<SafetySettings
globalPermissionMode={globalPermissionMode}
setGlobalPermissionMode={setGlobalPermissionMode}
commandBlocklist={commandBlocklist}
setCommandBlocklist={setCommandBlocklist}
commandTimeout={commandTimeout}
setCommandTimeout={setCommandTimeout}
maxIterations={maxIterations}
setMaxIterations={setMaxIterations}
/>
</div>
</div>
</TabsContent>
);
};
export default SettingsAITab;

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { Check, Moon, Palette, Sun } from "lucide-react";
import { Check, Monitor, Moon, Palette, Sun } from "lucide-react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
@@ -9,8 +9,8 @@ import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from ".
import { FontSelect } from "../FontSelect";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light";
setTheme: (theme: "dark" | "light") => void;
theme: "dark" | "light" | "system";
setTheme: (theme: "dark" | "light" | "system") => void;
lightUiThemeId: string;
setLightUiThemeId: (themeId: string) => void;
darkUiThemeId: string;
@@ -97,6 +97,12 @@ export default function SettingsAppearanceTab(props: {
{ name: "Slate", value: "215 16% 47%" },
];
const THEME_OPTIONS: { value: "light" | "system" | "dark"; icon: React.ReactNode; label: string }[] = [
{ value: "light", icon: <Sun size={14} />, label: t("settings.appearance.theme.light") },
{ value: "system", icon: <Monitor size={14} />, label: t("settings.appearance.theme.system") },
{ value: "dark", icon: <Moon size={14} />, label: t("settings.appearance.theme.dark") },
];
const renderThemeSwatches = (
options: { id: string; name: string; tokens: { background: string } }[],
value: string,
@@ -153,13 +159,25 @@ export default function SettingsAppearanceTab(props: {
<SectionHeader title={t("settings.appearance.uiTheme")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.appearance.darkMode")}
description={t("settings.appearance.darkMode.desc")}
label={t("settings.appearance.theme")}
description={t("settings.appearance.theme.desc")}
>
<div className="flex items-center gap-2">
<Sun size={14} className="text-muted-foreground" />
<Toggle checked={theme === "dark"} onChange={(v) => setTheme(v ? "dark" : "light")} />
<Moon size={14} className="text-muted-foreground" />
<div className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5">
{THEME_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
theme === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</SettingRow>
</div>

View File

@@ -179,7 +179,7 @@ export default function SettingsShortcutsTab(props: {
return (
<div key={binding.id} className="flex items-center justify-between px-4 py-2">
<span className="text-sm">{binding.label}</span>
<span className="text-sm">{t(`settings.shortcuts.binding.${binding.id}`) !== `settings.shortcuts.binding.${binding.id}` ? t(`settings.shortcuts.binding.${binding.id}`) : binding.label}</span>
<div className="flex items-center gap-2">
{isSpecialBinding ? (
<div className="flex items-center gap-1">

View File

@@ -1,51 +1,80 @@
import React, { useCallback } from "react";
import type { Host, Identity, Snippet, SSHKey } from "../../../domain/models";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
import type { SyncableVaultData } from "../../../domain/syncPayload";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
import { CloudSyncSettings } from "../../CloudSyncSettings";
import { SettingsTabContent } from "../settings-ui";
export default function SettingsSyncTab(props: {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
vault: SyncableVaultData;
portForwardingRules: PortForwardingRule[];
importDataFromString: (data: string) => void;
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
clearVaultData: () => void;
onSettingsApplied?: () => void;
}) {
const { hosts, keys, identities, snippets, importDataFromString, clearVaultData } = props;
const {
vault,
portForwardingRules,
importDataFromString,
importPortForwardingRules,
clearVaultData,
onSettingsApplied,
} = props;
const buildSyncPayload = useCallback((): SyncPayload => {
return {
hosts,
keys,
identities,
snippets,
customGroups: [],
syncedAt: Date.now(),
};
}, [hosts, keys, identities, snippets]);
const applySyncPayload = useCallback(
(payload: SyncPayload) => {
importDataFromString(
JSON.stringify({
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
snippets: payload.snippets,
customGroups: payload.customGroups,
}),
const onBuildPayload = useCallback((): SyncPayload => {
// If hook state is empty but localStorage has data, the async store
// initialization hasn't finished yet. Read from localStorage directly
// to avoid uploading empty arrays and overwriting the remote snapshot.
let effectiveRules = portForwardingRules;
if (effectiveRules.length === 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
// Strip transient per-device fields (status, error, lastUsedAt)
// that setGlobalRules persists to localStorage but shouldn't be
// included in the cloud sync snapshot.
effectiveRules = stored.map(({ status: _status, error: _error, ...rest }) => ({
...rest,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
const effectiveKnownHosts = getEffectiveKnownHosts(vault.knownHosts);
return buildSyncPayload({ ...vault, knownHosts: effectiveKnownHosts }, effectiveRules);
}, [vault, portForwardingRules]);
const onApplyPayload = useCallback(
(payload: SyncPayload) => {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
});
},
[importDataFromString],
[importDataFromString, importPortForwardingRules, onSettingsApplied],
);
const clearAllLocalData = useCallback(() => {
clearVaultData();
importPortForwardingRules([]);
}, [clearVaultData, importPortForwardingRules]);
return (
<SettingsTabContent value="sync">
<CloudSyncSettings
onBuildPayload={buildSyncPayload}
onApplyPayload={applySyncPayload}
onClearLocalData={clearVaultData}
onBuildPayload={onBuildPayload}
onApplyPayload={onApplyPayload}
onClearLocalData={clearAllLocalData}
/>
</SettingsTabContent>
);

View File

@@ -1,10 +1,12 @@
/**
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { UpdateState } from '../../../application/state/useUpdateCheck';
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
@@ -25,6 +27,22 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/** Returns a locale-agnostic relative time string for the given timestamp. */
function formatLastChecked(
timestamp: number | null,
t: (key: string) => string,
): string {
if (!timestamp) return '';
const diffMs = Date.now() - timestamp;
if (diffMs < 0) return t('settings.update.lastCheckedJustNow');
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return t('settings.update.lastCheckedJustNow');
if (diffMins < 60)
return t('settings.update.lastCheckedMinutesAgo').replace('{n}', String(diffMins));
const diffHours = Math.floor(diffMins / 60);
return t('settings.update.lastCheckedHoursAgo').replace('{n}', String(diffHours));
}
interface SettingsSystemTabProps {
sessionLogsEnabled: boolean;
setSessionLogsEnabled: (enabled: boolean) => void;
@@ -37,6 +55,15 @@ interface SettingsSystemTabProps {
closeToTray: boolean;
setCloseToTray: (enabled: boolean) => void;
hotkeyRegistrationError: string | null;
globalHotkeyEnabled: boolean;
setGlobalHotkeyEnabled: (enabled: boolean) => void;
autoUpdateEnabled: boolean;
setAutoUpdateEnabled: (enabled: boolean) => void;
// Unified update state — from useUpdateCheck hook in SettingsPageContent
updateState: UpdateState;
checkNow: () => Promise<unknown>;
installUpdate: () => void;
openReleasePage: () => void;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
@@ -51,6 +78,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
closeToTray,
setCloseToTray,
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
autoUpdateEnabled,
setAutoUpdateEnabled,
updateState,
checkNow,
installUpdate,
openReleasePage,
}) => {
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
@@ -61,6 +96,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
const [isRecordingHotkey, setIsRecordingHotkey] = useState(false);
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
const [appVersion, setAppVersion] = useState('');
// Load app version on mount
useEffect(() => {
const promise = netcattyBridge.get()?.getAppInfo?.();
if (promise) {
promise.then((info) => {
setAppVersion(info?.version ?? '');
}).catch(() => {});
}
}, []);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
@@ -81,6 +130,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
loadTempDirInfo();
}, [loadTempDirInfo]);
const loadCredentialProtectionStatus = useCallback(async () => {
setIsCheckingCredentials(true);
try {
const available = await getCredentialProtectionAvailability();
setCredentialsAvailable(available);
} finally {
setIsCheckingCredentials(false);
}
}, []);
useEffect(() => {
void loadCredentialProtectionStatus();
}, [loadCredentialProtectionStatus]);
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
@@ -201,6 +264,191 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</p>
</div>
{/* Software Update Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Download size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('settings.update.title')}</h3>
</div>
<div className="rounded-lg border border-border/60 p-4 space-y-3">
{/* Current version */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{t('settings.update.currentVersion')}
</span>
<span className="text-sm font-mono">
{updateState.currentVersion || appVersion || '...'}
</span>
</div>
{/* Status message — priority: autoDownloadStatus > isChecking/manualCheckStatus */}
{updateState.autoDownloadStatus === 'downloading' && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t('settings.update.downloading').replace('{percent}', String(updateState.downloadPercent))}
</p>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${updateState.downloadPercent}%` }}
/>
</div>
</div>
)}
{updateState.autoDownloadStatus === 'ready' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.readyToInstall')}
</p>
)}
{updateState.autoDownloadStatus === 'error' && (
<p className="text-sm text-destructive">
{updateState.downloadError || t('settings.update.error')}
</p>
)}
{updateState.autoDownloadStatus === 'idle' && (
<>
{updateState.manualCheckStatus === 'up-to-date' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.upToDate')}
</p>
)}
{(updateState.manualCheckStatus === 'available' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<p className="text-sm text-blue-600 dark:text-blue-400">
{t('settings.update.available').replace(
'{version}',
updateState.latestRelease?.version ?? ''
)}
</p>
)}
{updateState.manualCheckStatus === 'error' && (
<p className="text-sm text-destructive">
{updateState.error || t('settings.update.error')}
</p>
)}
</>
)}
{/* Action buttons */}
<div className="flex items-center gap-2 pt-1">
{/* Checking spinner — shown when isChecking OR manualCheckStatus=checking, but no active download */}
{(updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') &&
(updateState.isChecking || updateState.manualCheckStatus === 'checking') ? (
<Button variant="outline" size="sm" disabled>
<RefreshCw size={14} className="mr-1.5 animate-spin" />
{t('settings.update.checking')}
</Button>
) : (updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') ? (
/* Check button — shown in idle states and in error state (allows retry) */
<Button
variant="outline"
size="sm"
onClick={() => void checkNow()}
>
<RefreshCw size={14} className="mr-1.5" />
{t('settings.update.checkForUpdates')}
</Button>
) : null}
{/* Install button — shown when download is complete */}
{updateState.autoDownloadStatus === 'ready' && (
<Button variant="default" size="sm" onClick={installUpdate}>
<RotateCcw size={14} className="mr-1.5" />
{t('settings.update.restartNow')}
</Button>
)}
{/* Open releases — shown on download error */}
{updateState.autoDownloadStatus === 'error' && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
)}
{/* Open releases — shown when update found on unsupported platform, or on check error */}
{updateState.autoDownloadStatus === 'idle' &&
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
)}
</div>
</div>
<SettingRow
label={t('settings.update.autoUpdateEnabled')}
description={t('settings.update.autoUpdateEnabledDesc')}
>
<Toggle
checked={autoUpdateEnabled}
onChange={setAutoUpdateEnabled}
/>
</SettingRow>
<p className="text-xs text-muted-foreground">
{updateState.lastCheckedAt && (
<span>
{t('settings.update.lastCheckedPrefix')}
{formatLastChecked(updateState.lastCheckedAt, t)}
{' '}
</span>
)}
{t('settings.update.hint')}
</p>
</div>
{/* Credential Protection Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.credentials.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">
{t("settings.system.credentials.status")}
</p>
<p
className={cn(
"text-sm font-medium mt-1",
credentialsAvailable === true && "text-emerald-600 dark:text-emerald-400",
credentialsAvailable === false && "text-amber-600 dark:text-amber-400",
)}
>
{isCheckingCredentials
? t("settings.system.credentials.checking")
: credentialsAvailable === true
? t("settings.system.credentials.available")
: credentialsAvailable === false
? t("settings.system.credentials.unavailable")
: t("settings.system.credentials.unknown")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={loadCredentialProtectionStatus}
disabled={isCheckingCredentials}
className="gap-1.5"
>
<RefreshCw size={14} className={isCheckingCredentials ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
</div>
{credentialsAvailable === false && (
<p className="text-xs text-amber-700 dark:text-amber-400">
{t("settings.system.credentials.unavailableHint")}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("settings.system.credentials.portabilityHint")}
</p>
</div>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
@@ -349,7 +597,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
value={sessionLogsFormat}
options={formatOptions}
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
className="w-32"
className="w-44"
disabled={!sessionLogsEnabled}
/>
</SettingRow>
@@ -368,42 +616,55 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
{/* Toggle Window Hotkey */}
{/* Enable/Disable Global Hotkey */}
<SettingRow
label={t("settings.globalHotkey.toggleWindow")}
description={t("settings.globalHotkey.toggleWindowDesc")}
label={t('settings.globalHotkey.enabled')}
description={t('settings.globalHotkey.enabledDesc')}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsRecordingHotkey(true);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
isRecordingHotkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingHotkey
? t("settings.shortcuts.recording")
: 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>
)}
</div>
<Toggle
checked={globalHotkeyEnabled}
onChange={setGlobalHotkeyEnabled}
/>
</SettingRow>
{(hotkeyError || hotkeyRegistrationError) && (
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
)}
<div className={cn(!globalHotkeyEnabled && "opacity-50 pointer-events-none")}>
{/* Toggle Window Hotkey */}
<SettingRow
label={t("settings.globalHotkey.toggleWindow")}
description={t("settings.globalHotkey.toggleWindowDesc")}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsRecordingHotkey(true);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
isRecordingHotkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingHotkey
? t("settings.shortcuts.recording")
: 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>
)}
</div>
</SettingRow>
{(hotkeyError || hotkeyRegistrationError) && (
<p className="text-sm text-destructive mt-2">{hotkeyError || hotkeyRegistrationError}</p>
)}
</div>
{/* Close to Tray */}
<SettingRow

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState, useMemo } from "react";
import { AlertCircle, ChevronRight, Minus, Plus, RotateCcw } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { AlertCircle, ChevronRight, Import, Minus, Palette, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import type {
CursorShape,
LinkModifier,
@@ -11,6 +11,8 @@ import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { MAX_FONT_SIZE, MIN_FONT_SIZE, type TerminalFont } from "../../../infrastructure/config/fonts";
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser";
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
@@ -18,6 +20,8 @@ import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { ThemeSelectModal } from "../ThemeSelectModal";
import { TerminalFontSelect } from "../TerminalFontSelect";
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
import type { TerminalTheme } from "../../../domain/models";
// Theme preview button component
const ThemePreviewButton: React.FC<{
@@ -51,13 +55,13 @@ const ThemePreviewButton: React.FC<{
<span className="inline-block w-1.5 h-2 animate-pulse" style={{ backgroundColor: c.cursor }} />
</div>
</div>
{/* Theme info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{theme.name}</div>
<div className="text-xs text-muted-foreground capitalize">{theme.type}</div>
</div>
{/* Action button area */}
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-xs">{buttonLabel}</span>
@@ -100,11 +104,86 @@ export default function SettingsTerminalTab(props: {
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [themeModalOpen, setThemeModalOpen] = useState(false);
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
// Get current selected theme
const currentTheme = useMemo(() => {
return TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0];
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes]);
// Import .itermcolors file
const importFileRef = useRef<HTMLInputElement>(null);
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
console.log('[Settings] No file selected');
return;
}
console.log('[Settings] File selected:', file.name, 'size:', file.size);
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
const reader = new FileReader();
reader.onload = () => {
const xml = reader.result as string;
console.log('[Settings] File read successfully, length:', xml.length);
const parsed = parseItermcolors(xml, name);
if (parsed) {
console.log('[Settings] Theme parsed successfully:', parsed.id, parsed.name);
customThemeStore.addTheme(parsed);
setTerminalThemeId(parsed.id);
} else {
console.error('[Settings] Failed to parse .itermcolors file:', file.name);
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.');
}
};
reader.onerror = () => {
console.error('[Settings] Failed to read file:', file.name, reader.error);
};
reader.readAsText(file);
e.target.value = '';
}, [setTerminalThemeId, t]);
// New custom theme modal
const [customThemeModalOpen, setCustomThemeModalOpen] = useState(false);
const [customThemeData, setCustomThemeData] = useState<TerminalTheme | null>(null);
const [isEditingTheme, setIsEditingTheme] = useState(false);
// Check if current theme is a custom theme
const isCustomTheme = useMemo(() => {
return currentTheme?.isCustom === true;
}, [currentTheme]);
const handleNewCustomTheme = useCallback(() => {
const base = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemeStore.getThemeById(terminalThemeId)
|| TERMINAL_THEMES[0];
const newTheme: TerminalTheme = {
...base,
id: `custom-${Date.now()}`,
name: `${base.name} (Custom)`,
isCustom: true,
colors: { ...base.colors },
};
setCustomThemeData(newTheme);
setIsEditingTheme(false);
setCustomThemeModalOpen(true);
}, [terminalThemeId]);
const handleEditCustomTheme = useCallback(() => {
if (!currentTheme?.isCustom) return;
setCustomThemeData({ ...currentTheme, colors: { ...currentTheme.colors } });
setIsEditingTheme(true);
setCustomThemeModalOpen(true);
}, [currentTheme]);
const handleDeleteCustomTheme = useCallback(() => {
if (!currentTheme?.isCustom) return;
customThemeStore.deleteTheme(currentTheme.id);
setTerminalThemeId(TERMINAL_THEMES[0].id);
}, [currentTheme, setTerminalThemeId]);
// Fetch default shell on mount
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
@@ -194,7 +273,7 @@ export default function SettingsTerminalTab(props: {
onClick={() => setThemeModalOpen(true)}
buttonLabel={t("settings.terminal.theme.selectButton")}
/>
<ThemeSelectModal
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
@@ -202,6 +281,86 @@ export default function SettingsTerminalTab(props: {
onSelect={setTerminalThemeId}
/>
{/* Theme action buttons */}
<div className="flex items-center gap-2 -mt-1">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleNewCustomTheme}
>
<Palette size={14} />
{t('terminal.customTheme.new')}
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => importFileRef.current?.click()}
>
<Import size={14} />
{t('terminal.customTheme.import')}
</Button>
{isCustomTheme && (
<>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleEditCustomTheme}
>
<Pencil size={14} />
{t('common.edit')}
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5 text-destructive hover:text-destructive"
onClick={handleDeleteCustomTheme}
>
<Trash2 size={14} />
{t('common.delete')}
</Button>
</>
)}
<input
ref={importFileRef}
type="file"
accept=".itermcolors"
className="hidden"
onChange={handleImportItermcolors}
/>
</div>
{/* Custom Theme Modal */}
{customThemeData && (
<CustomThemeModal
open={customThemeModalOpen}
theme={customThemeData}
isNew={!isEditingTheme}
onSave={(theme) => {
if (isEditingTheme) {
customThemeStore.updateTheme(theme.id, theme);
} else {
customThemeStore.addTheme(theme);
}
setTerminalThemeId(theme.id);
setCustomThemeModalOpen(false);
setCustomThemeData(null);
}}
onDelete={isEditingTheme ? (themeId) => {
customThemeStore.deleteTheme(themeId);
setTerminalThemeId(TERMINAL_THEMES[0].id);
setCustomThemeModalOpen(false);
setCustomThemeData(null);
} : undefined}
onCancel={() => {
setCustomThemeModalOpen(false);
setCustomThemeData(null);
}}
/>
)}
<SectionHeader title={t("settings.terminal.section.font")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
@@ -314,7 +473,7 @@ export default function SettingsTerminalTab(props: {
onChange={(v) =>
updateTerminalSetting("terminalEmulationType", v as TerminalEmulationType)
}
className="w-36"
className="w-44"
/>
</SettingRow>
</div>
@@ -409,6 +568,30 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={terminalSettings.middleClickPaste} onChange={(v) => updateTerminalSetting("middleClickPaste", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.bracketedPaste")}
description={t("settings.terminal.behavior.bracketedPaste.desc")}
>
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.osc52Clipboard")}
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
>
<Select
value={terminalSettings.osc52Clipboard ?? 'write-only'}
options={[
{ value: "off", label: t("settings.terminal.behavior.osc52Clipboard.off") },
{ value: "write-only", label: t("settings.terminal.behavior.osc52Clipboard.writeOnly") },
{ value: "read-write", label: t("settings.terminal.behavior.osc52Clipboard.readWrite") },
{ value: "prompt", label: t("settings.terminal.behavior.osc52Clipboard.prompt") },
]}
onChange={(v) => updateTerminalSetting("osc52Clipboard", v as "off" | "write-only" | "read-write" | "prompt")}
className="w-40"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.scrollOnInput")}
description={t("settings.terminal.behavior.scrollOnInput.desc")}
@@ -450,7 +633,7 @@ export default function SettingsTerminalTab(props: {
{ value: "meta", label: t("settings.terminal.behavior.linkModifier.meta") },
]}
onChange={(v) => updateTerminalSetting("linkModifier", v as LinkModifier)}
className="w-40"
className="w-48"
/>
</SettingRow>
</div>

View File

@@ -0,0 +1,55 @@
import React, { useState } from "react";
import { ChevronDown, Plus } from "lucide-react";
import type { AIProviderId } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const AddProviderDropdown: React.FC<{
onAdd: (providerId: AIProviderId) => void;
}> = ({ onAdd }) => {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const providerIds = Object.keys(PROVIDER_PRESETS) as AIProviderId[];
return (
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(!isOpen)}
className="gap-1.5"
>
<Plus size={14} />
{t('ai.providers.add')}
<ChevronDown size={12} className={cn("transition-transform", isOpen && "rotate-180")} />
</Button>
{isOpen && (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[100]" onClick={() => setIsOpen(false)} />
{/* Menu */}
<div className="absolute top-full left-0 mt-1 z-[101] min-w-[200px] rounded-md border border-border bg-popover shadow-md py-1">
{providerIds.map((pid) => (
<button
key={pid}
onClick={() => {
onAdd(pid);
setIsOpen(false);
}}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
>
<ProviderIconBadge providerId={pid} size="sm" />
{PROVIDER_PRESETS[pid].name}
</button>
))}
</div>
</>
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More