Compare commits

...

133 Commits

Author SHA1 Message Date
陈大猫
46d1cf1696 chore(ai): drop unused eslint-disable around suppressed SDK warning (#1111)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
PR #1110 added an \`// eslint-disable-next-line no-console\` above
\`console.warn\` when suppressing SDK reasoning/text state-machine
errors, but this file already permits \`console.*\` calls (there are
several pre-existing \`console.error\` lines in the same hook) so the
directive is unused and ESLint flags it:

\`\`\`
666:13  warning  Unused eslint-disable directive (no problems were
reported from 'no-console')
\`\`\`

Removes the directive; the \`console.warn\` stays.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 01:29:33 +08:00
陈大猫
5be9bb58df fix(ai): suppress SDK reasoning/text state-machine error chunks (#1110)
Issue #1101 follow-up. When a third-party Anthropic-compat backend
streams thinking deltas without first emitting a `reasoning-start`
content-block signal (DeepSeek's \`-v4-flash\` is the canonical
offender), the Vercel AI SDK has nothing registered for the incoming
\`part.id\` and enqueues an \`error\` chunk on \`fullStream\` with the
text \`reasoning part <id> not found\` — once per orphan delta. The
analogous error exists for text parts.

These are internal SDK bookkeeping noise, not user-facing errors. Our
\`case 'error'\` handler was treating each one as a real error: it
appended an empty assistant message carrying the SDK string. The
visible damage was a wall of red banners in the chat panel — but the
worse damage was protocol-level. On the next turn we rebuild
\`sdkMessages\` from local history; the placeholder assistants slot
in between the assistant that holds the parallel \`tool_use\` blocks
and the subsequent \`role: 'tool'\` messages with their results. The
Anthropic SDK's \`groupIntoBlocks\` only merges *consecutive* tool
messages into a single user block, so the tool_results no longer
immediately follow their tool_use parent — and the backend rejects
the request as
\`400 messages.N: tool_use ids were found without tool_result blocks
immediately after\`. \`messages.N\` was literally counting our 12-ish
phantom assistants.

Adds \`isSdkStreamStateError(error)\` to
\`infrastructure/ai/shared/streamStateErrors.ts\` (matches
\`/^(reasoning|text)\\s+part\\s+\\S+\\s+not\\s+found$/i\` against
string / Error / \`{ message }\` shapes), and skips placeholder
emission for matching errors in the streaming hook. Drops a
\`console.warn\` so the noise is still visible in devtools when
debugging.

5 unit tests cover positive matches, the Error/object wrappings,
non-matches that should stay surfaced, and non-string inputs.

Refs #1101.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 01:02:26 +08:00
陈大猫
cab4fc36ab Serialize Catty terminal_execute per session to fix the parallel tool_use race (#1108)
* fix(ai): serialize Catty terminal_execute calls per session

Issue #1101 problem 3. When the LLM emits multiple tool_use blocks in
one assistant turn (DeepSeek's Anthropic-compat happily does this for
parallel info-gathering), the Vercel AI SDK dispatches every tool
through `Promise.all(toolCalls.map(execute))`. All of them then race
into the same per-session mutex inside the main-process bridge
(`mcpServerBridge.reserveSessionExecution`). One wins; the rest get
`{ ok: false, error: "Session already has another command in
progress..." }` synthesized back as the tool result. The LLM sees a
turn full of synthetic errors instead of the answers it asked for, the
UI cards look stuck on "executing", and the Anthropic API has
sometimes rejected the resulting trace as
`tool_use ids were found without tool_result blocks`.

Adds an in-renderer Promise-chain queue keyed by
`${chatSessionId}:${terminalSessionId}` so the actual
`bridge.aiExec()` calls are issued one at a time per terminal. The LLM
can still parallel-call as many tool_use blocks as it wants — they
just resolve sequentially with real output. Approval prompts are kept
*outside* the queue: three parallel tool_use blocks still surface
three approval cards together, so the user can dispatch them in any
order rather than waiting for each prior command to finish before the
next prompt appears. The bridge-side mutex stays as defense-in-depth
for non-LLM paths (terminal_start, MCP, etc.).

`chainBySessionKey` is a self-contained helper with cleanup logic so
the queue map doesn't leak across many short-lived sessions:
- Each task awaits the previous tail via `.then(task, task)` so a
  failure doesn't poison the chain.
- A non-rejecting wrapper is stored as the new tail to keep that
  contract regardless of how the task settles.
- The cleanup `finally` only deletes the map entry when the current
  tail is still ours, so a caller that arrived between
  `set` and `finally` doesn't have its tail evicted.

Four unit tests cover dispatch ordering, rejection isolation,
per-key parallelism, and the cleanup path. Full suite 1255/1255.

Refs #1101.

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

* fix(ai): preserve LLM emission order + honor abort in serialized queue

Codex local review on the first cut of this branch flagged two real
issues plus a weak cleanup test:

1. **Approval order vs queue order** — the previous version awaited
   approval *before* reserving a queue slot. With three parallel
   tool_use blocks the user could approve B's prompt first, and B
   would slip into the queue ahead of A, running out of LLM emission
   order. Reserving the slot synchronously up front fixes that:
   `Promise.all`-dispatched executes each grab a slot at the same
   instant in their dispatch order, then wait on approval while
   holding it, then await `slot.ready` and run.

2. **Abort not honored** — once approvals were settled, queued-but-
   not-started commands ran to completion even after the user hit
   Stop. Their results were ignored by the SDK but the side effects
   still happened. Two `abortSignal` checks now short-circuit before
   the queue wait and again after.

3. **Cleanup test was vacuous** — the previous "drains" test would
   pass even if the cleanup logic were removed. Exposed
   `getSessionExecutionQueueSizeForTests` and assert the Map is empty
   after the only queued task settles.

Queue API now has two entry points: high-level `chainBySessionKey`
for run-it-when-it's-your-turn callers, and lower-level
`reserveSessionSlot` for callers (like `terminal_execute`) that
need to do non-blocking pre-work in parallel with the queue wait.

Two new tests cover the reservation-order-vs-prework-order guarantee
and the skip-without-serialized-work path.

Refs #1101.

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

* fix(ai): re-issue cancel on abort to close IPC-transit race

Codex local review (round 2) flagged a small remaining race: between
the abort check after \`slot.ready\` and the main-process bridge
registering the new exec into \`activePtyExecs\`, the user can hit
Stop. \`handleStop\` does issue \`aiCattyCancelExec\`, but if that
cancel IPC arrives at main *before* the exec has finished
registering, the cancel finds nothing to cancel and the exec keeps
running. Result: the user already cancelled, but the command runs
anyway.

Plug the gap by re-issuing the cancel from the tool's own abort
listener. Once the exec has registered into \`activePtyExecs\` (a
synchronous step on the main side once the IPC lands), the duplicate
cancel finds the entry and cancels it. \`cancelPtyExecsForSession\`
is already idempotent — it iterates the live tracker and skips
entries with mismatched \`chatSessionId\` — so double-firing from
both \`handleStop\` and the tool is safe.

Extends \`NetcattyBridge\` with the optional
\`aiCattyCancelExec(chatSessionId)\` method. Already exposed in
\`electron/preload.cjs\` so the runtime call works on the live
bridge; the type addition just makes it visible to the tool.

Refs codex review on #1101 problem 3 fix.

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

* fix(ai): register pending-cancel marker before SSH exec-channel opens

Codex local review (round 3) caught a remaining race in
`execViaChannel`: the cancellation marker is only registered inside
`sshClient.exec`'s open callback, which is async. If a cancel arrives
in the window between `sshClient.exec(...)` being dispatched and the
callback firing, `cancelPtyExecsForSession` finds nothing for the
session and is a no-op. The channel then opens, the marker
registers, and the command runs to completion — exactly the "user
already cancelled, but the command still ran" failure mode.

Plug the window by registering a *pending* marker synchronously
before `sshClient.exec`. The pending marker carries a `cancel()` that
just latches a `cancelled` flag and a `cleanup()` that is a no-op.
When the open callback fires it removes the pending marker and
checks the latch: if it tripped, close the just-opened stream and
resolve with `{ ok: false, error: "Cancelled" }`. If not, the normal
post-open registration takes over with the real `execStream` close.

The PTY (`execViaPty`) and raw-serial (`execViaRawPty`) paths
already register their marker before any async wait, so they don't
share this race.

Two regression tests in `ptyExec.test.cjs`:
- The pending marker is present immediately after `execViaChannel`
  returns, before the open callback runs.
- A cancel during the pre-open window short-circuits the eventual
  callback with `{ ok: false, error: "Cancelled" }` and closes the
  now-unwanted stream.

Refs codex review on #1101 problem 3 fix.

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

* fix(ai): clean up pending-cancel marker when sshClient.exec throws sync

Codex local review (round 4) noted that if `sshClient.exec(...)`
itself throws synchronously — e.g. because the underlying ssh2 client
was destroyed between the session lookup and the actual `.exec`
call — the pending-cancel marker stays in `activePtyExecs`
indefinitely and the surrounding Promise rejects instead of
resolving with the normal `{ ok, error }` shape the tool layer
expects.

Wraps the `sshClient.exec` invocation in `try/catch` so:
- Pending marker is removed when the throw escapes.
- The Promise resolves cleanly with `{ ok: false, error: err.message }`
  so the tool layer gets the same shape it does for every other
  failure mode (closed stream, timeout, cancellation).

Adds a regression test covering this exact path: a fake client whose
`.exec` throws synchronously; the result is a clean failure and the
cancellation map is empty afterwards.

Refs codex review on #1101 problem 3 fix.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:45:37 +08:00
陈大猫
53d3e05bb4 Per-agent provider switcher chip in the Catty Agent chat input (#1107)
* feat(ai): provider switcher chip in the Catty Agent chat input

Catty Agent currently has no model chip in the chat input — modelPresets
is empty for the catty agentId so `hasModelPicker` is false. The
provider/model the chat ends up using comes from the global
`activeProviderId` / `activeModelId`, which means switching providers
requires opening Settings → AI → Providers. Closes the gap surfaced in
the original feedback on #1101 (problem 2) and partially addresses
#986 (per-context model switching) by going per-agent.

State layer adds an `agentProviderMap` (`Record<agentId, providerId>`)
alongside the existing `agentModelMap`. Both are written together when
the user picks from the new dropdown; together they form a per-agent
override that beats the global active provider/model when set. Cross-
window sync mirrors the agentModelMap pattern.

ChatInput grows a `providerSwitcher` prop carrying the enabled
ProviderConfigs, the selected (providerId, modelId), and an onSelect
callback. When supplied, the model chip switches from the Cpu glyph
to the provider's ProviderIconBadge + `providerName · modelId`, and
the popover renders a two-column layout — providers on the left
(icon + name + default model caption), that provider's known model(s)
on the right. ACP agents (Claude/Codex) are untouched because they
plumb their provider through the CLI, not the Vercel AI SDK.

AIChatSidePanel resolves `effectiveActiveProvider` /
`effectiveActiveModelId` for the catty agent from the per-agent map,
falling back to the global selection. handleSend now passes those
through to sendToCattyAgent so a provider picked in one Catty session
applies everywhere Catty runs.

For v1, each provider's right column shows just its configured
`defaultModel`. The two-level structure is in place to absorb richer
listings (cached /models, manual additions) later without UI
surgery — left a note in the popover code.

Refs #1101, #986.

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

* polish(ai): flatten provider picker to a single list

Two-level dropdown was theatre — each ProviderConfig exposes exactly
one model (its `defaultModel`), so the right column ended up showing
the same string already captioned on the left row. Picking a provider
implicitly picks its model; one click is enough.

Also drops the `p.enabled !== false` filter on the picker list. The
user's expectation, confirmed by feedback on the first cut, is that
the chat-input list mirrors what Settings → AI → Providers shows. The
per-provider `enabled` toggle is an "active-ish" flag, not a
visibility gate; hiding disabled providers in the picker silently
made everything but the one currently active disappear, which is what
the reviewer hit.

Pares the popover down to a vertical list — provider icon + name +
default-model caption (mono if set, italic "configure default model"
hint if not) + a Check on the bound row. Removes the `hoveredProviderId`
state along with the right-column rendering since it's no longer
driving anything.

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

* polish(ai): tune provider icon sizes in the chat input

Chip icon was sitting at sm (20px badge) and crowded the h-6 toolbar
chip alongside the truncated label; popover rows used the same sm, so
nothing differentiated the picker from the chip visually.

Adds an xs size to ProviderIconBadge (16px badge, 10px glyph, 9px
letter fallback) and uses it on the chip. Popover rows step up to md
(32px badge, 16px glyph) and the row padding grows accordingly so the
larger brand mark has room to breathe — the picker now reads like a
list of choices, the chip reads like a status line.

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

* fix(ai): keep per-agent override honest in model fallback + chip label

Two codex P1/P2 findings on #1107:

P1 — When Catty has a per-agent provider override but no per-agent
model (agentModelMap['catty'] empty), cattyAgentModelId fell through
to the global `activeModelId`. That id belongs to whichever provider
was globally active, not the one Catty is now bound to, so e.g.
overriding to DeepSeek without a defaultModel happily sent gpt-4o
(global) to DeepSeek and produced a wrong-model error. The fallback
is now: stored agent model → override provider's defaultModel → empty.
Only the no-override path keeps the activeModelId tail; the
no-provider send guard catches the empty case for everyone else.

P2 — ChatInput's selectedSwitcherProvider used `?? providers[0]` so
the chip always displayed *some* provider even when none was actually
bound (selectedProviderId missing). The rest of the pipeline (send
guard, agentProviderMap) treats that as "no provider", so the chip
was lying. Dropped the fallback; when nothing is bound the chip now
shows a generic Cpu glyph + "Select provider" label until the user
picks one from the popover. Adds `ai.chat.selectProvider` in en /
zh-CN / ru.

Refs codex review on #1107.

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

* fix(ai): purge per-agent binding when the bound provider is deleted

Codex local review (round 2) flagged that a saved Catty model id could
outlive its provider. The chain:
- catty bound to provider A (DeepSeek): agentProviderMap['catty']='A',
  agentModelMap['catty']='deepseek-v4-flash'
- user deletes provider A
- cattyAgentProvider falls back to the global active provider (B, OpenAI)
- cattyAgentModelId still returned the saved 'deepseek-v4-flash'
- send dispatched the DeepSeek model id to OpenAI → wrong-model error

Two layers of fix:

1. **Defensive resolution** (AIChatSidePanel) — cattyAgentModelId now
   looks at the override provider before trusting the stored model id.
   If the override is stale (provider deleted), the stored model id is
   treated as orphan and the resolution falls back to the global active
   selection just like cattyAgentProvider already does.

2. **Cleanup on remove** (useAIState.removeProvider) — when a provider
   is deleted, any agent whose agentProviderMap entry pointed at it
   gets both maps cleared (provider override + saved model). Same
   rationale: that model id is now meaningless against every other
   provider. Mirrors the existing activeProviderId cleanup.

Adds a small agentProviderMapRef so removeProvider can snapshot which
agents to clean up without relying on the captured-at-callback-create
state (which would be stale).

Refs codex local review on #1107.

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

* fix(ai): include agentProviderMap in settings sync + block empty-model send

Codex local review (round 3) found two issues:

P2 — `agentProviderMap` was missing from the cross-device sync surface.
Added it to the sync key list, the build path, the apply path, and the
SyncPayload type alongside the existing `agentModelMap`. Sync tests in
`syncPayload.test.ts` now cover the new field on both the build and
apply sides.

P2 — Provider rows with no `defaultModel` were still clickable in the
chat-input picker, so selecting one would save a binding with empty
model id; the send path then dispatched an empty model name and the
SDK would surface a vague backend error. Two changes:

1. ChatInput disables the row (\`disabled\`, \`aria-disabled\`, dimmed
   styling, tooltip pointing the user to Settings) when the provider
   has no \`defaultModel\`. Click is suppressed.
2. AIChatSidePanel \`handleSend\` adds a model-required guard mirroring
   the existing no-provider guard — surfaces \`ai.chat.noProviderModel\`
   as an assistant message. Catches stale bindings (e.g. user edited
   the provider's \`defaultModel\` to empty after binding) before they
   reach the SDK.

Refs codex local review on #1107.

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

* fix(ai): trim whitespace-only model ids + reconcile orphan bindings on sync

Codex local review (round 4) found two more leaks:

P2 — Whitespace-only `defaultModel` passed the picker's `disabled` gate
(which trims) but every other layer (cattyAgentModelId resolution,
the send-guard `!sendActiveModelId` check, the SDK call) compared the
raw string. A provider whose default model was "   " could still reach
the SDK and surface a vague backend error. Normalized: trim at the
resolution boundary (catty model fallback chain) and trim before the
send-guard's existence check.

P2 — Sync apply did not reconcile per-agent bindings against the
incoming provider set. A payload could change `providers` without
shipping a fresh `agentProviderMap`, leaving local overrides pointing
to provider ids the synced set no longer includes — the same ghost-
binding bug that `removeProvider` already handles for explicit user
deletes. Added `pruneOrphanPerAgentBindings()` that runs after every
AI settings apply: it walks `agentProviderMap`, drops any entry whose
provider id isn't in the current `providers` list, and clears the
saved model id for those agents alongside it (mirroring removeProvider
cleanup). A new test in `syncPayload.test.ts` exercises the ghost-
binding case end-to-end.

Refs codex local review on #1107.

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

* fix(ai): notify open AI state of cross-device sync apply in the same window

Codex local review (round 4) flagged that `applySyncPayload` writes
straight to localStorage but `useAIState` only re-reads on browser
`storage` events. Those events only fire in *other* windows — the
window doing the apply (the one with the active chat panel) keeps
showing pre-sync providers and per-agent bindings until reload.
Concrete leak: cloud sync swaps in a new provider set, pruning runs,
localStorage is correct, but the open Catty chip still references the
ghost provider binding until the user reloads.

Extracts the existing `AI_STATE_CHANGED_EVENT` constant +
`emitAIStateChanged` helper out of `useAIState` and into a new
`application/state/aiStateEvents.ts` so non-React call sites (sync
apply, future IPC handlers) can fire it without pulling in the hook.
After AI settings apply, `syncPayload.ts` walks every AI key it
touched (including the agentProviderMap / agentModelMap that the
reconcile step may have mutated even when the payload didn't ship
them) and emits a same-window nudge. `useAIState`'s existing local
listener routes unknown keys back through its storage handler, so
each affected piece of React state rehydrates from localStorage.

Adds a regression test in `syncPayload.test.ts` that asserts the
expected `netcatty:ai-state-changed` keys are dispatched for the
providers + per-agent map keys.

Refs codex local review on #1107.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:03:08 +08:00
陈大猫
0c4de74c84 polish(ai): hint that the provider icon is clickable (#1106)
The icon badge next to the Display Name input opens the icon picker
but had no visual cue — users had no reason to suspect it was
clickable, and reviewer feedback flagged it as too hidden.

Adds a primary-tinted ring on hover/focus plus a small pencil glyph
overlaid on the bottom-right corner of the badge (also hover/focus
gated). The badge itself doesn't change shape, so the layout stays
stable; the affordance is purely additive.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:13:15 +08:00
陈大猫
2a4feea40f Customizable provider name, icon, and protocol style (#1105)
* feat(ai): customizable provider name, icon, and protocol style

Issue #1101 reported that the only way to wire DeepSeek through the
Catty Agent was to add an "Anthropic" provider with a DeepSeek base URL,
which (a) wasn't relabelable except on the "custom" providerId and
(b) implicitly conflated wire-protocol style with providerId, locking
"custom" to the OpenAI-compatible client.

ProviderConfig now carries three optional fields — `style`
(anthropic/openai/google), `iconId` (built-in brand glyph key), and
`iconDataUrl` (user upload). createModelFromConfig routes on the
resolved style first, falling back to providerId only for per-vendor
quirks (ollama's throwaway apiKey and openrouter's baseURL). Display
name becomes editable for every provider, not just custom.

The icon picker exposes a curated lobe-icons subset (MIT) covering the
common Anthropic/OpenAI-compatible third parties — DeepSeek, Moonshot,
Kimi, Qwen, Zhipu, Doubao, Mistral, Cohere, Grok, Perplexity, Groq,
Hugging Face — plus the existing six built-ins. Uploads are downsampled
to 64×64 WebP on a canvas so localStorage doesn't blow up. See
public/ai/providers/NOTICE.md for attribution.

Closes part of #1101 (problem 2). PR2 will reuse the new name/icon
plumbing in the Catty Agent chat input to surface a provider switcher.

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

* polish(ai): bigger labelled icon tiles and auto-fill display name

The first pass shipped the icon picker as an 8-column grid of bare
20px badges. Reviewer feedback: the icons are too small to read at a
glance, every preset should announce its brand name, and a second
click on a selected preset should let the user back out.

Switches the grid to auto-fill 120px tiles with a 32px icon plus a
truncated label so the picker reads like a brand list, not a sprite
sheet. Selecting a preset now also writes the brand's canonical
English display name into the Display Name field — the picker labels
keep their "/ 中文" suffix as a bilingual hint, but a new `name` field
on each catalog entry supplies a clean string for autofill (e.g.
"Qwen / 通义" → "Qwen"). Re-clicking the selected tile clears
iconId/iconDataUrl but leaves the typed name alone, so users who
already edited the name don't lose their edit on accidental toggle.

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

* polish(ai): add explicit close button to icon picker

The picker only opened via the icon badge above the Display Name field,
which left no obvious affordance to collapse it once a preset was
chosen — users either had to scroll past or click back up to the icon.
Drops a ghost Close button in the bottom-right of the picker's action
row (next to Upload/Reset) so dismissing the panel is one click and
visible without hunting.

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

* fix(ai): wire model discovery to the resolved provider style

Codex review on #1105 flagged that ModelSelector still derives auth
headers from `providerId` (`x-api-key` only when providerId is the
literal "anthropic"). Now that ProviderConfig lets the user override
`style` independently, the form persists the override but the model
discovery call ignored it — so picking an Anthropic providerId pointed
at an OpenAI-compatible backend (or vice versa) would send the wrong
header and fail to list models even though chat routing already used
the override.

Extracts `buildModelDiscoveryHeaders(style, apiKey)` as a pure helper
in infrastructure/ai/. ModelSelector now resolves the protocol family
via `resolveProviderStyle` with the explicit `style` prop taking
precedence, then asks the helper for headers. ProviderConfigForm
passes the resolved style through. The `needsApiKey = providerId !==
"ollama"` shortcut stays as-is — that's a per-vendor concession, not a
style decision.

Refs codex review on #1105.

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

* fix(ai): apply per-vendor URL fallbacks across every style override

Codex review on #1105 caught that the openrouter (and ollama) baseURL
fallback was sitting inside the `style === 'openai'` branch, so a user
who picked OpenRouter providerId + Anthropic/Google style with an
empty baseURL would skip the fallback entirely. The SDK then routed
to its own default endpoint (api.anthropic.com /
generativelanguage.googleapis.com) using the user's OpenRouter key —
silent misrouting plus auth failures.

Pulls the per-vendor quirks out into a new pure helper
`resolveProviderEndpoint(config, style, safeApiKey)`. The URL
fallback now fires for every style — the user picked openrouter for a
reason, even if they overrode the wire format. The ollama-only
`'ollama'` literal apiKey swap stays gated on `style === 'openai'`
because Anthropic/Google clients need a real key, not the throwaway
placeholder.

Refs codex review on #1105.

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

* fix(ai): send x-goog-api-key for google-style model discovery

Codex review on #1105 flagged that buildModelDiscoveryHeaders bucketed
google-style providers with the OpenAI-compat default, so a model
refresh against any google-routed endpoint was sending `Authorization:
Bearer …`. That's the wrong auth header — Google Generative AI
rejects Bearer entirely and expects `x-goog-api-key` (or `?key=`).
The runtime chat path already uses `createGoogleGenerativeAI`, which
authenticates with the Google header family, so discovery was the
odd one out.

Adds an explicit `google` branch returning `{ "x-goog-api-key":
<apiKey> }` and updates the unit test. The default google providerId
never hits this path today (PROVIDER_PRESETS["google"] has no
modelsEndpoint), but the style override surface created in this PR
lets users compose providerId + style pairs where discovery does
fire — e.g. an openai/anthropic-style providerId pointed at a Google
endpoint via baseURL override.

Refs codex review on #1105.

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

* fix(ai): pick discovery endpoint from resolved provider style

Codex review on #1105 pointed out the lopsided fix in ae7394c8: I
switched discovery *headers* to honor the resolved `style` but left
the URL path coming straight from `PROVIDER_PRESETS[providerId]`. So
the moment style and providerId disagree the request shape is half
correct — e.g. Anthropic providerId + style=openai sends Bearer
(right) at `/v1/models` (wrong; the OpenAI-compat backend exposes
/models). 404s either way.

Adds `STYLE_DEFAULT_MODELS_ENDPOINT` (`anthropic → /v1/models`,
`openai → /models`, `google → undefined`) and a
`resolveModelsDiscoveryEndpoint(style, presetEndpoint)` helper. The
style's convention wins; the caller-supplied `presetEndpoint` is the
fallback for styles with no listing convention (currently just
google).

Behavior for stock configs is unchanged — every preset's existing
modelsEndpoint matches its style's default. Mismatches now line up
(headers + path together), and `custom` providers gain a sensible
discovery attempt when the user sets a style. My earlier inline reply
ducking this was wrong; codex's call was right.

Added three unit tests covering style defaults, the override-on-flip,
and the google-style passthrough.

Refs codex review on #1105.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:41:19 +08:00
Pyro
faa90e1aa5 Reduce interactive terminal latency with TCP_NODELAY (#1103)
Co-authored-by: pyroch <cvdysh@gmail.com>
2026-05-26 15:31:14 +08:00
陈大猫
1aa96c3490 Fix Claude ACP Windows shim launch (#1102) 2026-05-26 14:25:03 +08:00
陈大猫
0e80955e96 fix(hosts): make group startup-command field multi-line typeable (#1100)
The group details panel rendered Startup Command as a single-line <Input>,
so users couldn't type newlines into it — only the first line ever made it
into the saved config, which then broke the multi-line sequencing behavior
shipped in #1096 for any host inheriting the command from its group.
Switch to a 3-row <Textarea> to match the per-host details panel, so a
multi-line command typed on the group is preserved end to end.

Refs #1083.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:27:00 +08:00
陈大猫
7771592cf2 feat(shortcuts): Ctrl+W closes the tab directly + add configurable side-panel toggle (#1098)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat(shortcuts): add resolveSidePanelToggleIntent pure resolver

* feat(shortcuts): Ctrl+W closes the tab directly (drop side-panel priority)

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

* feat(shortcuts): register toggle-side-panel binding (default ⌘/Ctrl+\)

* feat(shortcuts): add side-panel toggle handler + last-panel memory in TerminalLayer

* feat(shortcuts): dispatch toggleSidePanel hotkey to TerminalLayer

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:42:41 +08:00
陈大猫
6e9e8fc40d feat(autocomplete): make snippets a first-class terminal completion source (#1097)
* feat(autocomplete): add snippet completion source

* feat(autocomplete): merge snippets at the command position

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

* test(autocomplete): place snippet tests where the test runner finds them

The npm test glob covers components/terminal/*.test.ts but not the
autocomplete/ subdirectory, so the snippet tests added in the previous two
commits weren't actually running in the suite. Move them up to
components/terminal/ (the existing convention for autocomplete tests) with
corrected import paths; the engine snippet cases go in a separate
completionEngineSnippets.test.ts to avoid colliding with the existing
completionEngine.test.ts.

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

* feat(autocomplete): snippet ghost-exclusion, preview skip, and accept path

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

* feat(autocomplete): wire snippets into the terminal + preview snippet command

* fix(autocomplete): show only label in snippet popup row; keep snippet over colliding history

The popup row for a snippet now omits the inline command echo — the full
command lives in the detail preview only, matching the "label-only row"
design. The completion engine pushes snippet suggestions without the early
seen-text skip so that when a snippet's label collides with a history
entry's text, the higher-scored snippet survives the final dedup.

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

* fix(autocomplete): broadcast snippet command so popup acceptance mirrors peers

In broadcast mode, accepting a snippet from the autocomplete popup cleared
peer input (the line-clear keystrokes flow through the broadcast-aware path)
but never sent the command, since executeSnippetCommand wrote only to the
active session. Broadcast the normalized snippet data (matching the snippet
shortkey path) so peers receive both the clear and the command, keeping all
sessions in sync.

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

* fix(autocomplete): broadcast wrapped snippet bytes to preserve noAutoRun

Broadcasting the raw normalized command sent un-wrapped newlines to peers,
so a multi-line noAutoRun snippet was pasted-but-not-run on the active
session yet executed line-by-line on broadcast peers (handleBroadcastInput
writes bytes directly without re-wrapping). Broadcast the exact bytes the
active session receives instead — bracketed-paste wrapping plus the auto-run
\r — so peers mirror the active session and noAutoRun is preserved.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:03:33 +08:00
陈大猫
67448cea65 feat(terminal): configurable startup-command delay + multi-line sequencing (#1096)
* feat(terminal): add global startupCommandDelayMs setting

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

* feat(terminal): add startup-command line-split and delay-clamp helpers

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

* feat(terminal): run multi-line startup commands in sequence with configurable delay

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

* feat(terminal): expose startup command delay in Terminal settings

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

* refactor(terminal): keep startup-command line content verbatim

splitStartupCommandLines now only drops blank/whitespace-only lines and
normalizes CRLF, but no longer trims each line's content. This keeps a
single-line startup command byte-identical to what the user typed (e.g. a
leading space for HISTCONTROL=ignorespace is preserved), while still
supporting multi-line sequencing.

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

* fix(sync): include startupCommandDelayMs in synced terminal settings

Terminal settings sync via the SYNCABLE_TERMINAL_KEYS allowlist; the new
startupCommandDelayMs preference was missing, so it wouldn't propagate across
devices. Add it (it's a user preference like keepaliveInterval, not
device-specific).

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:47:00 +08:00
陈大猫
770b06a9ee fix(ai): make Claude Code agent diagnosable + configurable (auth) (#1095)
* feat(ai): add Claude auth-presence detection helper

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

* fix(ai): surface actionable Claude auth errors and reap stuck agent processes

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

* feat(ai): add pure helpers for Claude config dir + env editor

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

* feat(ai): let users set Claude config dir and env vars in settings

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

* polish(ai): harden Claude config env, de-dupe error text, label a11y

- buildClaudeEnv: drop managed keys (CLAUDE_CONFIG_DIR/CLAUDE_CODE_EXECUTABLE)
  if a user types them into the free-text env editor, so they can't clobber
  the config-dir field or the discovered executable path (+ regression test).
- bridge: only append error data fields not already shown as message/code,
  so the actionable error text doesn't echo the same code/message twice.
- ClaudeCodeCard: associate the new config-dir/env labels with their inputs
  via htmlFor/id.

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

* feat(ai): make the Claude auth & config section collapsible

The optional "Authentication & config" section now has a collapsible
header (chevron toggle). Collapsed by default to keep the card tidy, but
auto-expanded when the user already has a config directory or env vars set
so existing config isn't hidden. Local UI state, not persisted.

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

* fix(ai): preserve raw env editor text; don't encourage plaintext secrets

P1: the env textarea was bound to the persisted value, which is the parsed
env re-serialized — so typing a key before its "=" was erased mid-entry
(buildClaudeEnv drops lines without "="). Keep the raw typed text in local
draft state and only resync from the persisted value on genuine external
changes (not our own parse→serialize round-trip).

P2: the env editor persists to localStorage in plaintext (no credential
encryption). Stop suggesting ANTHROPIC_API_KEY in the placeholder and warn
that values are stored in plaintext, steering credentials to the config
directory (a `claude` login) — consistent with keeping Claude auth
CLI/config-owned (#705).

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

* fix(ai): expand ~ in Claude config directory before passing to the agent

CLAUDE_CONFIG_DIR is handed to the spawned agent as an env var, which is not
shell-expanded — so "~/.claude" was treated as a literal "~" directory.
Expand a leading ~ at consume time (normalizeAgentEnv + getClaudeConfigDir)
rather than on save, so the stored value stays portable across machines
(cloud sync) and each device expands to its own home.

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

* fix(ai): recreate cached Claude provider when its env config changes

Claude ACP providers are cached per chat session and reused unless one of
the fingerprinted dimensions changes. authFingerprint was null for Claude,
so editing the config directory / env vars in Settings didn't take effect on
an already-running session. Fingerprint the Claude agent env so a config
change invalidates the cached provider and the next turn respawns with it.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:59:48 +08:00
陈大猫
1d50b2c4a1 feat(terminal): choose dark/light terminal theme when following app theme (#1094)
* feat(terminal): add per-mode follow-theme resolver and storage keys

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

* feat(terminal): persist per-mode follow-theme selections in settings state

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

* feat(sync): include per-mode follow terminal themes in cloud sync

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

* i18n(terminal): add per-mode follow-theme picker strings

* feat(terminal): add type filter and auto option to theme picker

* feat(terminal): pick dark/light terminal theme when following app theme

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

* fix(terminal): don't flag the auto sentinel as a missing theme in the picker

The per-mode follow-theme pickers default to the 'auto' sentinel, which is
not a real theme id, so ThemeList's deletedSelectedTheme check classified it
as a deleted custom theme and rendered a spurious "Missing Theme" banner above
the Auto entry on first open. Exclude TERMINAL_THEME_AUTO from that check.

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

* i18n(terminal): align zh-CN dark/light wording with app convention

Use 深色/浅色 (matching the theme picker section headers and global
appearance settings) instead of 暗色/亮色 for the per-mode terminal
theme labels, so the picker modal reads consistently.

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

* fix(terminal): match follow-theme preview fallback to runtime resolution

The per-mode preview memos fell back straight to TERMINAL_THEMES[0] when a
selection resolved to a deleted theme, while the runtime currentTerminalTheme
memo falls back to the manual terminalThemeId first. Mirror the runtime chain
so the Settings preview matches the actual terminal for users with a
non-default manual theme.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:20:45 +08:00
陈大猫
453202df8f perf(terminal): add output flow control / back-pressure for heavy streams (#1090)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
2026-05-25 15:19:27 +08:00
陈大猫
a78c052d86 perf(autocomplete): skip completion queries when nothing is shown (#1088)
fetchSuggestions ran the full completion pipeline (history scan, fig specs, remote path lookups) on the main thread even when both the popup and ghost text were disabled — the results were then discarded. Add a shouldQueryCompletions(settings) gate and bail out early (clearing any stale state) when neither display mode is on.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:49 +08:00
陈大猫
e6b0a551e8 perf(terminal): isolate autocomplete re-renders into a child component (#1089)
The autocomplete hook (useState) lived in Terminal, so every suggestion / selection / live-preview update re-rendered the whole ~2775-line Terminal component. Move the hook and its popup into a dedicated <TerminalAutocomplete> component so those frequent state updates re-render only that small subtree.

The hook's handlers are surfaced back to Terminal via refs (the same refs already used to wire the xterm runtime), and the component is mounted unconditionally so the hook keeps recording command history and intercepting completion keys for the session's lifetime. No behavior change intended.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:38 +08:00
陈大猫
38775245d2 perf(terminal): bound the connection log with a chunk ring buffer (#1087)
* perf(terminal): bound the connection log with a chunk ring buffer

The connection log kept the last 1,000,000 chars via `log += chunk; log = log.slice(-MAX)`. Once a session emits more than that, the slice flattens a ~1M-char string on every subsequent output chunk — on the render thread, for each echoed keystroke included — on long/busy sessions.

Replace the string with a small chunk-queue ring buffer that trims only the boundary chunk (amortized O(chunk) append) and materializes the full string once on read. Behavior is unchanged: it still retains exactly the last MAX_CONNECTION_LOG_DATA_CHARS characters.

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

* perf(terminal): coalesce connection log into bounded blocks (O(1) trim)

The first cut used one array entry per append and trimmed with chunks.shift(). For interactive output (many tiny chunks) the array grows toward the cap in entries, so once full, shift() reindexes ~N elements on every append — O(appends) per chunk, no better than the slice it replaced.

Coalesce appends into a small, bounded set of fixed-size blocks (~maxChars/blockSize). New data fills an open tail that seals into a block at blockSize; trimming only drops/slices a handful of blocks. Adds segmentCount() and a test asserting the segment count stays bounded across many tiny appends.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:39:02 +08:00
陈大猫
fcb699ffb9 chore(eslint): lint electron/bridges for undefined references (#1086) 2026-05-25 13:53:53 +08:00
陈大猫
e889d8fc20 perf(terminal): flush shell output on the event-loop turn instead of a fixed 8ms timer (#1085)
* perf(terminal): flush shell output on the event-loop turn, not a fixed 8ms timer

SSH/PTY output was coalesced and shipped to the renderer on a fixed 8ms timer. For interactive use that interval is pure added latency: every echoed keystroke waits out the timer before it can paint, so typing feels slightly behind.

Replace the timer with turn-based (setImmediate) coalescing in a single shared ptyOutputBuffer module, used by the SSH, local, telnet, and mosh paths. A single echoed keystroke is now forwarded almost immediately, while data arriving in the same turn still collapses into one IPC send, and a 16KB size cap still forces an immediate flush under heavy output.

Also de-duplicates two copies of the buffering logic (SSH had an inline copy; local/telnet/mosh shared another) and adds unit tests for the buffer.

Related to #1084.

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

* fix(terminal): drop orphaned flushTimeout reference in SSH close handler

The SSH stream "close" handler still cleared `flushTimeout`, a variable that lived in the inline buffer removed when this path moved to the shared ptyOutputBuffer. Reading it now throws ReferenceError on every channel close, aborting the cleanup and exit signaling. The shared buffer's flush() cancels any pending flush internally, so the timer bookkeeping is removed.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:42:49 +08:00
陈大猫
bf1c95500a feat #826: optional Option+←/→ word jump on macOS (#1082)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat #826: optional Option+←/→ word jump on macOS

Adds a Terminal → Keyboard toggle "Option+←/→ jumps by word" (off by default,
synced). When on, a bare Option+Left/Right sends Meta-b / Meta-f instead of
xterm's default ^[[1;3D / ^[[1;3C, so readline/zle moves by word without
per-host bindkey setup (Termius-style).

The key→sequence mapping is a tested pure function; the handler reads the
setting live (no reconnect) and runs after kitty mode + autocomplete so it
doesn't override them.

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

* fix #826: gate Option+←/→ word jump to macOS

The setting is syncable, so without a platform gate, enabling it on a Mac
would also rewrite Alt+←/→ to Meta-b/f on synced Linux/Windows devices,
breaking apps/shells that expect the default ^[[1;3D / ^[[1;3C. Pass
isMacPlatform() into the mapping so it only applies on macOS; add a test
for the non-macOS case.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:10:40 +08:00
陈大猫
f9d00c9d23 fix #1079: preserve remote file mode when rz overwrites a same-named file (#1081)
* fix #1079: preserve remote file mode when rz overwrites a same-named file

#1070's overwrite path rm's the remote file and lets rz re-create it, which
writes with the remote umask and drops the original permission bits — e.g. a
0755 script became 0644 after choosing "replace". (It didn't happen before
because rz used to skip same-named files, leaving the original untouched.)

Capture each conflicting file's mode during the pre-upload probe
(stat -c %a, BSD stat -f %Lp fallback) and chmod it back once the transfer
finishes and the files are on disk. Restore is best-effort: any failure
silently falls back to today's behavior.

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

* fix #1079: probe file mode with `stat -- "$n"` for dash-prefixed names

Without `--`, `stat -c %a "-x.sh"` (and the BSD `-f %Lp` fallback) parse a
leading-dash filename as options, so the mode was never captured and overwrite
fell back to rz defaults — losing permission preservation for a valid filename
class. Mirrors the existing `rm -f --` handling. (chmod left as-is: its path is
always absolute, and BSD chmod doesn't accept `--`.)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:44:41 +08:00
陈大猫
8fd7ff6475 fix #1078: send macOS Option as Meta (wire altAsMeta to xterm macOptionIsMeta) (#1080)
"Use Option as Meta key" was read into `altIsMeta` but only applied to the
mouse alt-click options (`altClickMovesCursor`). xterm.js's `macOptionIsMeta`
— the option that actually makes Option emit ESC-prefixed (Meta) sequences —
was never set, so on macOS Option kept producing layout characters (ƒ, ∫, …)
and readline/zle word shortcuts (Alt+f, Alt+b, Alt+Backspace) were dead.

Extract the altAsMeta→xterm mapping into one tested helper used by both the
terminal init path (createXTermRuntime) and the live settings sync
(Terminal.tsx) so the two can't drift again.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:30:35 +08:00
陈大猫
02c80ae7d2 chore: silence two production build warnings (#1072)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
- Drop the manualChunks 'vendor-react' entry: react/react-dom already land
  in another chunk, so it only ever produced an empty chunk + a build
  warning, with no caching benefit.
- Import domain/syncMerge statically in useAutoSync. It's already in the
  eager graph via CloudSyncManager's static import, so the dynamic
  `import()` couldn't be code-split anyway and only emitted a mixed
  static/dynamic-import warning.

No behavior change; production build is warning-free.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:50:33 +08:00
陈大猫
e5d3d02b17 fix #1063: give each terminal its own WebGL texture atlas (disable cross-terminal sharing) (#1071)
Root cause of the persistent split-view 花屏: xterm's WebGL addon shares
ONE TextureAtlas across terminal instances with equal config (font / size
/ theme / DPR) — acquireTextureAtlas does `if (configEquals) { ownedBy.push;
return atlas }`. Two split panes then share an atlas, so the
clearTextureAtlas calls netcatty makes to recover from glyph corruption
(on resize / DPR / font change / tab show, from #1049 and #1066) clobber
the *other* pane's rendering. That's why the earlier redraw/clear-based
recovery attempts didn't help and only bounced the garble between panes.

Disable the sharing: remove the "reuse a matching atlas" loop so every
terminal creates its own atlas. The published bundle is minified, so this
is done with a small idempotent postinstall script (a patch-package patch
would be a ~550KB unreadable blob of the whole minified line). It
string-replaces the exact loop in the CJS + ESM builds, runs after
patch-package, and warns without failing if @xterm/addon-webgl changes.

Verified: split-view WebGL no longer garbles; script is idempotent
(patched=2 → already=2) and the production build is unaffected.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:45:05 +08:00
陈大猫
78186d8d46 feat #1064: prompt to overwrite when rz upload hits a remote filename conflict (#1070)
* feat #1064: add buildUploadPlan for rz overwrite/skip/cancel resolution

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

* feat #1064: handle remote filename conflicts in rz handleUpload

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

* feat #1064: SSH exec probe + remove for rz upload conflicts

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

* feat #1064: IPC for rz overwrite-conflict prompt

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

* feat #1064: renderer prompt for rz overwrite conflicts

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

* fix #1064: repair sshBridge test mock (ipcMain.on) and i18n the overwrite dialog

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

* fix #1064: make upload plan index-based to preserve per-file decisions

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 12:20:20 +08:00
陈大猫
c899653621 fix #1065: resolve terminal cwd through su/sudo for the SFTP locate (#1068)
* fix #1065: resolve terminal cwd through su/sudo for the SFTP locate

The SFTP "locate to terminal's current directory" feature kept showing the
login user's home (e.g. /root) after the user switched accounts with su /
sudo -s and cd'd elsewhere.

getSessionPwd walks the remote process tree from a sibling exec channel to
find the interactive shell's cwd, but it only followed children whose comm
is a shell name (bash/zsh/...). su and sudo are named "su"/"sudo", so the
walk stopped at the login shell and read its cwd. The actual shell the user
is typing in lives *under* su/sudo as the controlling tty's foreground
process group.

Rewrite the walk to pick the deepest foreground shell ("+" in stat) within
the login shell's whole process subtree, which transparently follows
through su/sudo to the active shell, falling back to the login shell when
no foreground shell is found.

Verified on a real server (root -> su user -> cd /tmp):
  before: /root   after: /tmp
and confirmed the no-su case is unchanged (cd /var -> /var).

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

* Fall back to login shell cwd when the active shell's /proc is unreadable (Codex review)

When an unprivileged user runs `sudo -s` / `su root`, find_active_shell
correctly selects the root-owned foreground shell, but the exec channel
(running as the login user) cannot readlink another uid's /proc/<pid>/cwd
due to ptrace permissions. Without a fallback the script dropped straight
to the home directory, regressing user→root sessions.

Retry readlink on the same-uid login shell before falling back to home.

Verified live (user -> cd /var -> sudo -s -> cd /tmp): the root shell's
cwd is unreadable, and the result is now /var (login shell cwd) instead of
/home/<user>.

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

* Select the interactive (tty-bearing) login shell deterministically (Codex review)

find_login_shell picked the first shell child of sshd and exited, but ps
output is unsorted, so when other exec channels (server-stats polls, etc.)
are running on the same connection their transient sh could be chosen,
making find_active_shell walk the wrong subtree.

Prefer the shell child that has a controlling tty: the interactive shell
has a pts, while non-PTY probe exec channels have tty "?". This is
deterministic regardless of ps order, in both the su and no-su cases (the
old "prefer foreground" heuristic was itself nondeterministic under su).
Falls back to any shell child if none has a tty.

Verified live with a concurrent no-tty `sh -c sleep` under the same sshd:
the pts/0 bash is selected and the result is /tmp, not the probe shell.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 11:26:20 +08:00
陈大猫
a91fbcdd68 fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit (#1067)
* fix #1062: treat SSH shell TMOUT auto-logout as a timeout, not a normal exit

A shell-level TMOUT idle auto-logout makes bash/csh exit cleanly (numeric
exit code, no signal), which is byte-for-byte indistinguishable from a
user-typed `exit` at the SSH protocol level. PR #1057 keyed the
close-vs-keep decision on `streamExited` (numeric code + no signal), so
TMOUT exits were reported as reason "exited" and the tab was auto-closed —
reintroducing the problem from #977.

Verified against a real server that bash TMOUT exits with code 0 / no
signal and prints "timed out waiting for input: auto-logout" to the
channel before it closes. Since exit code/signal can't distinguish it from
an intentional exit, detect that banner in the session's existing rolling
output tail (_promptTrackTail) and report reason "timeout" instead, which
routes to the existing markDisconnected path (keep tab + reconnect). A
normal `exit`/`logout` (no "auto-" prefix) still auto-closes the tab, so
PR #1057's behavior is preserved.

zsh's TMOUT raises SIGALRM (a signal), so it already took the
keep-tab/reconnect path and is unaffected.

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

* Anchor TMOUT auto-logout match to the banner's final line (Codex review)

The detector matched "auto-logout" as an unanchored substring within the
last 256 chars, so command output that merely mentions it (e.g. `grep
auto-logout /etc/profile` while investigating TMOUT) followed by an
intentional `exit` could be misclassified as a timeout and wrongly keep
the tab open. Anchor on the final non-empty line of output instead — the
banner the shell prints right before exiting — which loses no true
positives (verified against the real-server output shape) while rejecting
mid-stream mentions.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:53:03 +08:00
陈大猫
74b315e285 fix #1063: force WebGL redraw on tab show to recover from garbled multi-tab terminals (#1066)
Hidden tabs stay mounted off-screen (visibility:hidden) so each keeps a
live WebGL context. Creating another terminal's WebGL context — or the GPU
dropping a non-composited off-screen canvas — leaves the hidden terminals'
drawing buffers corrupted ("花屏"). This reproduces on both Windows and
macOS: opening 2 tabs garbles the 1st, opening 3 garbles the 1st and 2nd,
while the just-created (visible) one is always fine. The DOM renderer is
immune because it uses real DOM nodes.

A window resize recovers the display because it triggers a full repaint
(clearTextureAtlas + RenderService._renderRows). A tab switch did not:
the visibility effect only calls safeFit, which early-returns when the
pane's dimensions are unchanged, so no redraw happened.

Perform the same recovery a resize does when a tab becomes visible:
clear the texture atlas (no-op on the DOM renderer) and synchronously
repaint every row. Verified against xterm core that _renderRows draws
unconditionally, independent of dimension changes or dirty-row tracking.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 09:56:30 +08:00
陈大猫
60eeafe7a9 feat #1005: Termius-style live-preview popup autocomplete (free the Tab key) (#1059)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat #1005: add live-preview keystroke calculator for popup autocomplete

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

* feat #1005: live-render the selected popup suggestion on arrow navigation

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

* feat #1005: free Tab for the shell; Enter runs the rendered line; Esc reverts

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

* feat #1005: show key hint (→ expand / ↵ run) on the selected popup row

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

* feat #1005: live-render full path while navigating sub-directory panels

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

* test #1005: move live-preview test into the npm test glob

The test runner only scans components/terminal/*.test.ts (not the
autocomplete/ subdir), matching where the other autocomplete-module tests
live (e.g. completionEngine.test.ts). Relocate so it actually runs.

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

* fix #1005: center and refine the popup key-cap hint

Use inline-flex centering (the ↵ glyph was vertically off with line-height +
padding), softer color-mixed border/background, a system-sans font so the
glyph renders consistently regardless of the terminal font, and the more
balanced ⏎ return symbol.

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

* fix #1005: record the actual executed line on Enter, not the stale suggestion

Codex review (P2): the popup Enter handler recorded selected.text and
suppressed handleInput's recorder, so editing a previewed command (select
docker, type ' ps', Enter before the re-query) logged the stale 'docker'
instead of 'docker ps'. Delegate to handleInput's Enter path, which records
lastAcceptedCommandRef on a clean select and falls back to the live buffer
after an edit (typing nulls that ref).

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

* fix #1005: don't revert user edits when Escape closes the popup

Codex review (P2): previewActiveRef stayed true after the user edited a
previewed command, so Escape (before the debounced re-query reset state)
called renderPreviewSelection(-1) and rewrote the line back to the stale
baseline, dropping the edits. Clear previewActiveRef when the user types
(alongside the existing lastAcceptedCommandRef reset), so Escape only reverts
a pristine preview and otherwise just dismisses the popup.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:58:57 +08:00
陈大猫
ee2c21e712 feat #1044: close tabs with the middle mouse button (#1058)
Middle-clicking a tab (mouse wheel click) is a conventional "close tab"
gesture in browsers and editors. Wire it to every closeable tab strip:
the top session / workspace / log-view / editor tabs and the SFTP tab bar.

A small shared helper (lib/tabInteractions.ts) handles the gesture:
onAuxClick closes the tab when button === 1, and onMouseDown calls
preventDefault for the middle button so the Chromium/Electron autoscroll
overlay does not appear. Left-click activation and right-click context
menus are untouched.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:58:19 +08:00
陈大猫
e678ad3546 fix terminal exit auto close (#1057) 2026-05-22 22:49:15 +08:00
陈大猫
c47c780b48 fix s3 checksum compatibility (#1056) 2026-05-22 22:41:25 +08:00
陈大猫
88074ac9b3 fix auto sync remote checks (#1055) 2026-05-22 22:26:05 +08:00
陈大猫
59cb0c4b65 fix #1043: skip pwd probe on network devices to keep Huawei VRP sessions alive (#1052) 2026-05-22 22:06:03 +08:00
陈大猫
bf0bd193eb fix #1049: clear WebGL texture atlas to recover from garbled terminal (#1050)
Heavy full-screen TUIs (claude code / gemini cli / opencode), font changes,
and device pixel ratio changes can leave xterm.js's WebGL glyph texture atlas
in a corrupted state that persists for the life of the terminal — users see
persistent "garbled / 花屏" output that only clears when a brand-new terminal
is opened (most often on Windows with display scaling / multi-monitor setups).

Clear the texture atlas so glyphs re-rasterize at the correct scale instead of
forcing users to reopen the terminal:

- Add watchDevicePixelRatio() helper (TDD, unit-tested) that re-registers a
  matchMedia listener across DPI changes and fires a repair callback.
- Wire it into createXTermRuntime: on devicePixelRatio change, clear the atlas
  and refit; also clear the atlas on reflow (term.onResize). Watcher is torn
  down on dispose.
- Expose clearTextureAtlas() on XTermRuntime and call it after font changes in
  Terminal.tsx (xterm.js #3280). All calls are no-ops under the DOM renderer.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:25:38 +08:00
陈大猫
7661375925 fix huawei vrp ssh detection (#1046) 2026-05-22 01:05:46 +08:00
陈大猫
308fb45985 fix comware legacy ssh handshake (#1045) 2026-05-22 00:13:59 +08:00
陈大猫
f4aa6ddb46 fix #1013: stop ghost text from drawing over untracked echoed input (#1042)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Inline (ghost-text) suggestions render suggestion.substring(trackedInput.length)
after the cursor, where trackedInput is a client-side reconstruction of the
command line (buffer heuristics + keystroke prediction, to mask SSH echo
latency). On hosts with non-standard echo — hardware bastion hosts / network OS
like `ecOS#` (#1013, previously #756 / #906) — that reconstruction drifts and
the ghost gets painted over characters the user already typed (`int` + ghost
`terface` -> `intterface`).

Add a fail-safe consistency check: on each post-echo render, if the real
terminal line before the cursor contains the tracked input followed by more
untracked, non-whitespace characters (reality is AHEAD of what we tracked),
hide the ghost instead of drawing it over real text. SSH echo latency is the
opposite case (the line is a prefix-behind of the tracked input) and is
deliberately not flagged, so the ghost stays responsive on slow links. The
check is ASCII-only (wide-char column mapping is ambiguous) and fail-open, so
it can only ever suppress a ghost that would otherwise corrupt — never change
correct behaviour.

This converts the recurring "ghost shows already-typed characters" bug into
"ghost simply doesn't show" on devices we can't track reliably.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:40:35 +08:00
陈大猫
f6cb73fdd6 fix #1040: unique macOS Mach-O LC_UUID for Local Network privacy (#1041)
macOS keys the "Local Network" privacy permission on the main executable's
Mach-O LC_UUID (Apple TN3179). Electron's prebuilt binary is linked with LLD,
which derives the UUID from a content hash, so every app built from the same
Electron version ships the *same* LC_UUID even with a different bundle id. That
collision makes the grant unreliable: a user who enables Local Network for
Netcatty can still hit `connect EHOSTUNREACH` on LAN / VMware host-only
addresses, while loopback-forwarded connections work.

Add an electron-builder afterPack hook that rewrites the packaged macOS
executable's LC_UUID to a value derived deterministically from the appId —
stable across builds (so the grant survives updates) but distinct from every
other app. It runs before code signing, so signature/notarization cover the
patched binary. No-op on Windows/Linux.

Verified the rewrite on a copy of Electron's binary (LC_UUID changes, file
stays a valid Mach-O, deterministic) and added unit tests for the Mach-O
patcher (thin + fat) and the UUID derivation.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:11:25 +08:00
陈大猫
3c100b0ae2 fix #1035: support diffie-hellman-group1-sha1 under BoringSSL (#1039)
Electron's BoringSSL dropped several standard MODP groups from the named
crypto.createDiffieHellmanGroup() API — notably the 1024-bit Oakley Group 2
(modp2) that backs SSH's diffie-hellman-group1-sha1. ssh2 calls
createDiffieHellmanGroup('modp2') for that kex, so connecting to legacy
network devices that only speak group1-sha1 failed with "Error: Unknown DH
group".

The underlying DH math still works on BoringSSL via createDiffieHellman()
with an explicit prime, so add a compatibility shim that wraps
createDiffieHellmanGroup and falls back to the well-known prime constants
when (and only when) the runtime can't resolve a group by name. On OpenSSL
builds the original call succeeds and the fallback is never used.

The shim is installed in main.cjs before any ssh2-using bridge loads, since
ssh2 destructures createDiffieHellmanGroup at module load. Once installed,
the existing legacy-group probe detects modp2 as supported again and offers
group1-sha1, so affected devices actually connect (still gated behind the
per-host legacy-algorithms toggle).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:51:50 +08:00
陈大猫
168e42b5fa fix slow first SSH connect from DH group probe under BoringSSL (#1038)
The fixed-DH-group support probe called crypto.createDiffieHellmanGroup()
for each MODP group to feature-detect runtime support. Under Electron's
BoringSSL, instantiating the large groups is pathologically slow
(modp18/8192-bit takes ~20s on first call), and the result is only cached
in-process, so the first connection after every app launch froze for ~24s.

The standard modern groups (modp14/16/18) are universally supported and
always pass the probe anyway, so treat them as supported without probing.
Only groups a runtime may genuinely drop (e.g. BoringSSL removed the weak
1024-bit group1/modp2) are still feature-detected; those fail instantly.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:35:20 +08:00
陈大猫
2ce6bd5ed1 [codex] Reorder legacy SSH group-exchange fallback (#1034)
* reorder legacy ssh kex fallback

* add ssh handshake debug logging
2026-05-21 11:30:24 +08:00
陈大猫
7bd5d6465a fix claude system cli detection (#1033) 2026-05-21 00:11:51 +08:00
陈大猫
65387d4c61 fix legacy group exchange sha1 (#1032) 2026-05-20 23:19:07 +08:00
yuzifu
6084e8e94f fix(terminal): handle forced prompt newline (#1025)
* fix(terminal): handle forced prompt newline

* fix review issue

* fix(terminal): harden prompt newline handling

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-20 23:08:42 +08:00
陈大猫
3ccc5c9fc6 Fix broadcast hotkey refresh (#1030) 2026-05-20 16:30:25 +08:00
陈大猫
d07859f604 [codex] Prevent terminal host preference pollution (#1026)
* Prevent terminal host preference pollution

* Preserve terminal host updates while isolating session ports
2026-05-20 11:51:54 +08:00
陈大猫
88a322a03b [codex] Filter terminal cursor replies from broadcast input (#1022)
* Filter terminal CPR from broadcast input

* Handle split cursor reports in broadcast
2026-05-20 11:12:27 +08:00
陈大猫
0e02bbc2fb [codex] Persist vault host sort mode (#1021)
* Persist vault host sort mode

* Harden vault host sort persistence tests
2026-05-20 10:53:20 +08:00
陈大猫
affd9217e2 Fix session log capture after reconnect (#1020) 2026-05-20 10:53:04 +08:00
陈大猫
7b4a349e3f [codex] Guard unsupported legacy SSH groups (#1023)
* Guard legacy SSH DH groups

* Align legacy SFTP algorithms
2026-05-20 10:52:52 +08:00
陈大猫
7dc5ab5035 [codex] Use terminal cwd when opening SFTP (#1024)
* Use terminal cwd when opening SFTP

* Clear stale terminal cwd for SFTP open
2026-05-20 10:52:35 +08:00
yuzifu
3e8965f9a9 Fix pr987 (#1010) 2026-05-19 20:13:16 +08:00
陈大猫
23a27bf544 Handle missing streamed tool call ids (#1007) 2026-05-19 11:29:50 +08:00
陈大猫
86a815ad46 [codex] Optimize terminal tab switching (#1003)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* Optimize terminal tab switching

* Reduce themed tab switch repaint work
2026-05-18 22:19:54 +08:00
陈大猫
cb4fb091aa [codex] Fix browser loading of shared rule files (#1002)
* Fix local shell browser import

* Fix command blocklist browser import
2026-05-18 21:05:33 +08:00
陈大猫
b30696c98b Clean up dead code and duplicated helpers (#1001) 2026-05-18 20:00:10 +08:00
bincxz
6b8f05c65a Merge branch 'codex/fix-russian-settings-sync-icon' 2026-05-18 19:23:44 +08:00
bincxz
64dd3a4a2f Fix settings sidebar icon clipping 2026-05-18 19:23:36 +08:00
yuzifu
88732040aa fix(terminal): separate prompt after unterminated command output (#987)
* fix(terminal): separate prompt after unterminated command output
Add a display-layer prompt line break handler so recognized shell prompts move to the next visual line when the final command output line is not newline terminated.

Also add a terminal setting to toggle the behavior, sync support, i18n copy, and focused tests for prompt insertion.

* fix review issue

* Fix prompt cache initialization

* Serialize terminal output writes for prompt breaks

* Keep terminal status lines ordered with output

* Fix prompt arming without command callback

* Keep prompt display breaks out of session logs

* Avoid prompt breaks for output suffix matches

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-18 18:45:41 +08:00
ウィール スペース
b9f3bfa8bb Add i18n russian (#991)
* add i18n russian

* Added the Russian translation

* Complete Russian SFTP transfer translations

* Add Russian reconnect menu translation

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-05-18 16:55:02 +08:00
陈大猫
b7ec3c12f7 Handle ConPTY controls in Mosh password prompts (#1000) 2026-05-18 15:44:58 +08:00
DeepFal
d20a18b862 Fix AI code block rendering fallback (#983) 2026-05-18 13:19:30 +08:00
陈大猫
ff6b4a4625 Broadcast pasted terminal input (#927) (#996)
* Broadcast user paste to terminals

* Use workspace session id for context paste broadcast

* Consume paste broadcast suppression before toggle check
2026-05-18 11:53:14 +08:00
陈大猫
5a94b4cf39 Preserve Unicode session log names (#988) (#998)
* Preserve Unicode session log names

* Harden Windows session log name handling
2026-05-18 11:42:43 +08:00
陈大猫
3963cd4af9 Fix remote path completion cwd (#993) 2026-05-18 11:32:04 +08:00
陈大猫
5b2a048917 Add transfer target path actions (#997) 2026-05-18 11:31:50 +08:00
陈大猫
2414cb00e4 Keep terminal tab after remote exit (#994) 2026-05-18 11:31:28 +08:00
陈大猫
03f980e939 Add reconnect terminal context action (#995) 2026-05-18 11:30:27 +08:00
Bet4
ac819fd4fd feat(workspace): add focus sidebar drag reorder (#992) 2026-05-18 01:26:14 +08:00
yuzifu
fb9400a5fb fix #984: After running the clear command, the inline session log will be cleared (#990)
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-05-16 20:44:05 +08:00
陈大猫
7da983a56c ci: auto-bump Homebrew tap on stable release tags (#938) (#976)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
After the GitHub Release is published, push an updated Cask to
binaricat/homebrew-netcatty so `brew install binaricat/netcatty/netcatty`
stays current within minutes of the release. Stable tags only — prerelease
tags (v1.2.0-rc.1 etc.) are skipped to keep brew users on stable.

Implementation:
- New script .github/scripts/bump-homebrew-cask.sh computes SHA-256 of the
  arm64 + x64 DMGs already downloaded by the release job, sed-patches the
  Cask file in the tap repo, sanity-checks the result parses as Ruby, and
  pushes the bump. Idempotent on re-run when checksums match.
- New homebrew-tap job in build.yml runs after the release job on the same
  stable-tag gate, downloads the macOS artifact bundle, then runs the
  bump script with HOMEBREW_TAP_TOKEN.

Requires HOMEBREW_TAP_TOKEN secret with contents:write on
binaricat/homebrew-netcatty. With the secret missing the job will fail
fast at the env-var check with no side effects (no push attempted).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:01:26 +08:00
陈大猫
344b226ce8 Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts (#974)
* Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts

Servers running stock PAM Linux configurations (most distros) only advertise
`keyboard-interactive` as their auth method, not `password` — so even when
the user has saved a password on the host, Netcatty was popping a modal
asking them to type it again. Every connect ended up being a two-password
flow: one to dispatch, one in the modal.

The shared `createKeyboardInteractiveHandler` factory now recognizes the
classic "PAM-wrapped password" challenge (a single prompt with
`echo === false`) and finishes it with the saved password directly,
skipping the modal. Real multi-prompt or echo-visible challenges (2FA / OTP
/ security questions) still go to the modal as before, and a wrong-password
auto-fill on the first attempt falls back to the modal on the retry so the
user can correct it.

Also consolidated startSSHSession's inline keyboard-interactive handler —
which duplicated ~45 lines of the factory logic without the auto-fill
fix — to use the factory with progress callbacks. The chain / SFTP /
port-forwarding bridges already went through the factory and pick up the
auto-fill for free.

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

* Address Codex review: only auto-fill prompts that mention a password

The previous heuristic ("single prompt + echo=false + saved password →
auto-fill") would also fire for OTP / Duo / hardware-token challenges,
which are single hidden-echo prompts too. That would burn one auth
attempt per reconnect on those servers and could trip pam_faillock /
pam_tally2 lockout policies before the user ever saw the modal.

Add a prompt-text gate: auto-fill only when the prompt contains a known
password keyword (Latin "password" / "passwd"; CJK "密码" / "口令").
Custom-localized prompts that don't match fall through to the modal,
which is the same behavior as the pre-#969 baseline — strictly no
worse than before.

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

* Address Codex review (round 2): exclude OTP vocabulary from auto-fill

The previous PASSWORD_PROMPT_PATTERN matched anything containing "password"
/ "passwd" / "密码" / "口令", which still let through OTP shapes that
happen to include those words: "Enter your one-time password", "动态密码"
(Chinese for "dynamic password" = OTP), "动态口令", "一次性密码", etc.

Add an OTP/MFA vocabulary check that runs before the password keyword
check. Any prompt containing OTP terminology (one-time, OTP, verification,
passcode, token, 2FA, two-factor, MFA, Duo, 动态, 一次性, 验证码, 令牌,
双因素, 多因素, 短信验证, 手机验证) is disqualified from auto-fill even
if it also matches the password keywords.

Tests cover both English "One-time password" and the three common Chinese
OTP phrasings, plus a regression guard that normal sudo-style password
prompts still auto-fill.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:36:07 +08:00
陈大猫
86e47b5f9e Fix #972: stop false "fingerprint changed" warnings on every SSH connect (#973)
The host-key verifier was misclassifying connections as `changed` in three
situations that had nothing to do with a real key rotation:

1. Records imported from the system `~/.ssh/known_hosts` (or older builds)
   landed in localStorage without a `fingerprint` field. The verifier then
   re-derived the fingerprint from the stored `publicKey` blob on every
   connect — a brittle path that produced a different value than ssh2 if
   anything about the serialization differed by even one byte.
2. `classifyHostKey` had a loose "single candidate with unknown / empty
   keyType → changed" heuristic. Any imported record whose keyType failed
   to parse would be promoted to a rotation warning the first time the
   server presented a real algorithm, even though the user had never
   actually trusted any fingerprint for that algorithm.
3. A host that genuinely had multiple algorithms (e.g. one stored ssh-rsa
   record plus a live ssh-ed25519 handshake) was being reported as
   `changed` instead of `unknown`, even though we had no comparable
   record for the algorithm the server presented.

Tabby (`tabby-ssh/src/session/ssh.ts`) and OpenSSH both treat case (3) as a
first-time prompt rather than a mismatch; this change brings Netcatty in
line with that model.

Changes:
- `domain/knownHosts.ts` ports `fingerprintFromPublicKey` to TS and adds
  `normalizeKnownHost` / `normalizeKnownHosts` so the renderer can backfill
  legacy records on hydration. Pure-JS SHA-256 keeps the migration
  synchronous so it can run inline in `useVaultState` without async
  plumbing.
- `application/state/useVaultState.ts` runs the migration on hydration
  and on cross-window storage events. When anything changes on hydration
  the migrated list is written back to localStorage so the next launch
  starts clean.
- `components/KnownHostsManager.tsx` populates `fingerprint` at import
  time instead of leaving it for the verifier to re-derive.
- `electron/bridges/hostKeyVerifier.cjs` simplifies `classifyHostKey` to
  fingerprint-first, then strict (host, port, keyType) match for the
  changed branch, then fall through to `unknown`. Two existing tests
  that locked in the loose heuristic are updated to assert the new
  (safer) behavior, and a new test covers the multi-algorithm
  first-encounter case.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:41:36 +08:00
陈大猫
37012da26a Use shadcn Button for the settings gear in the top tab bar (#967)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
Follow-up on #966 which added `hover:bg-accent` to the existing raw
`<button>` element. That element is `h-full w-10`, so the new hover
fill spanned the entire title-bar height — a giant vertical accent
strip instead of the small icon-button highlight we wanted.

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

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

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

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

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

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

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

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

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

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

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

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

Approach:

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

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

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

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

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

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

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

* Address review: per-direction Telnet negotiation tracking

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

* Fix serial line mode pasted chunks

* Narrow long paste display cleanup scope

* Strip only matched paste echo highlights

* Honor paste scroll setting through xterm paste
2026-05-12 17:33:31 +08:00
陈大猫
109d0a7ab7 feat(terminal): add copy-host-address button to per-host statusbar (#951) (#952)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
Adds a small clipboard-copy icon next to the host label / status dot in
the terminal pane's statusbar. Clicking copies the host's hostname
(IP or DNS name — what users called "machine IP" in #951) to the
clipboard and surfaces a toast.

The button only renders for non-local SSH/serial/telnet sessions —
local shells don't have an addressable hostname so showing it would
be confusing.

Placed in the pane statusbar (not the top tab) because the statusbar
is per-host: a workspace pane carries exactly one host, so the button
always identifies the right address. Top tabs in a workspace can share
multiple panes / hosts and would be ambiguous.

Visual treatment matches the surrounding stats buttons: 10px icon,
inline with the existing host label + status dot, opacity-60 →
opacity-100 on hover, `title` attribute for the tooltip to match the
pattern of the CPU/MEM/disk stats triggers right next to it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:51:50 +08:00
陈大猫
92ecd84edf Fix #939: per-host SSH keepalive override + cloud-friendly defaults (#947)
* fix(ssh): per-host keepalive override + cloud-friendly defaults (#939, #581)

Issues #939 (cloud / Aliyun sessions silently freezing after 15-20 min idle
because no SSH keepalive packets are sent) and #581 (older routers like
NOKIA / ALCATEL being killed by ssh2 after a few unanswered keepalives) are
in direct tension at the global-setting level: cloud users want keepalive
ON, embedded-device users want it OFF, and any single global default hurts
the other group.

Resolves the conflict by moving keepalive to a per-host setting (mirroring
the existing `legacyAlgorithms` per-host pattern), with cloud-friendly
global defaults:

Domain:
  - Host gains `keepaliveOverride?: boolean` + `keepaliveInterval?: number`
    + `keepaliveCountMax?: number`. When override is true, the host's
    values are used; otherwise the global TerminalSettings values apply.
    Per-field fallback so a host can override interval only or countMax only.
  - TerminalSettings gains `keepaliveCountMax: number` so the second knob
    (number of unanswered keepalives before declaring dead) is no longer
    hardcoded at 3 in the bridge.
  - DEFAULT_TERMINAL_SETTINGS: keepaliveInterval bumped from 0 to 30, and
    keepaliveCountMax = 10. Cloud LBs / NAT tables stay populated; brief
    network glitches don't trip the dead-connection check; an actually
    dead session is detected within ~5 minutes. Existing users with 0
    saved keep their value (no migration) — they were the #581 router
    cohort and their setup still works untouched.

Plumbing:
  - domain/host.ts adds resolveHostKeepalive(host, globalSettings) with
    five unit tests covering both directions of the override flag and
    per-field fallback.
  - components/terminal/runtime/createTerminalSessionStarters.ts uses the
    resolver when building startSSHSession options.
  - electron/bridges/sshBridge.cjs reads keepaliveCountMax from options
    (defaulting to 10) at both connection sites (direct + jump host) and
    still routes interval=0 through to a fully disabled keepalive
    (preserving #581's escape hatch).

UI:
  - Settings → Terminal → Connection grows a second input next to the
    existing interval: "Max unanswered keepalives".
  - Host details panel gains a Keepalive section with a "Override global
    keepalive" toggle that, when on, exposes per-host interval +
    countMax inputs and an inline hint when interval = 0 (explaining
    the implications). Same visual pattern as the existing Legacy
    Algorithms section.

Sync:
  - keepaliveCountMax added to SYNCABLE_TERMINAL_KEYS so the new global
    field rides existing sync infrastructure. Per-host fields ride the
    hosts array passthrough automatically (older clients receiving them
    ignore unknown fields, per the existing lenient sync contract).

i18n: en + zh-CN strings for the new settings row, the host section
header, and the override toggle / inputs / disabled hint.

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

* fix(ssh): resolve keepalive per jump host, not just the final target

Addresses codex review on PR #947:
  https://github.com/binaricat/Netcatty/pull/947#discussion_r3217027xxx

The first cut only resolved keepalive for the final target host and
forwarded a single interval/countMax pair across the whole start-SSH
call. connectThroughChain in sshBridge.cjs then applied that one pair
to every hop, so a chain like:

   router (bastion, needs keepalive=0)  →  cloud target (needs 30s)

would either kill the router (with cloud-friendly defaults) or fail
to keep the target alive (with router-friendly 0). The per-host
override was effectively useless for bastion hosts.

Fix:
  - NetcattyJumpHost gains optional keepaliveInterval / keepaliveCountMax.
  - createTerminalSessionStarters runs resolveHostKeepalive() per
    jumpHost when building the chain, so each hop carries its own
    resolved pair.
  - sshBridge.cjs's chain connector reads jump.keepaliveInterval /
    jump.keepaliveCountMax for each hop, falling back to the call's
    target-level options for backward compatibility with older
    serializers that don't yet populate the per-hop fields.

The final target's keepalive path is unchanged — it still reads
options.keepaliveInterval / options.keepaliveCountMax that the
session starter resolves from the target host.

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

* fix(ssh): per-host keepalive for SFTP + port forwarding too

Follow-up to the maintainer review on PR #947 — terminal SSH was the
only path that honored per-host keepalive overrides. SFTP and port
forwarding share the same NetcattyJumpHost type but their builders
weren't resolving keepalive per-hop, and their bridges hardcoded the
old 10s/3 defaults. Net result: a router-as-bastion in a chain still
got killed when reached via the SFTP file panel or a port-forwarding
tunnel, even though the user had toggled per-host override.

Plumbing:
  - useSftpHostCredentials / buildSftpHostCredentials: accept optional
    terminalSettings; call resolveHostKeepalive() for the target and
    each jump entry; emit keepaliveInterval / keepaliveCountMax in the
    returned NetcattySSHOptions.
  - useSftpConnections + useSftpState + SftpStateOptions thread the
    setting down. SftpSidePanel passes the global terminalSettings prop
    it already has from TerminalLayer.
  - portForwardingService.startPortForward: accepts terminalSettings
    as an 8th argument, resolves per-host (target + each jump), and
    populates the bridge payload.
  - usePortForwardingState.startTunnel and usePortForwardingAutoStart
    forward the new parameter; App.tsx supplies terminalSettings (via
    a ref in the once-on-launch auto-start effect so changing global
    keepalive later doesn't re-fire it).

Bridges:
  - sftpBridge.cjs target connect: now also reads keepaliveCountMax
    from options (was hardcoded 3). 10s/3 stays as the bridge-level
    fallback to preserve the #669 protection when the renderer hasn't
    supplied a value.
  - sftpBridge.cjs jump hop: reads jump.keepaliveInterval /
    jump.keepaliveCountMax, then falls back to the target-call options
    (matches the symmetric SSH bridge change).
  - portForwardingBridge.cjs: reads keepaliveInterval /
    keepaliveCountMax from the IPC payload; same 10s/3 fallback.

Types:
  - NetcattyJumpHost already grew keepalive fields earlier; this
    commit also adds them to PortForwardOptions so the IPC contract
    is explicit.

End-to-end: a chain `[router-as-bastion, cloud-host]` with the
router host's keepaliveOverride=true / interval=0 now correctly
disables keepalive on the router hop for terminal SSH AND SFTP AND
port forwarding, while the cloud target still gets the resolved
30s/10 default for each path.

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

* fix(ssh): honor explicit keepalive=0 in SFTP + port forwarding bridges

Addresses codex review on PR #947:
  - https://github.com/binaricat/Netcatty/pull/947#discussion_r3217448xxx
  - https://github.com/binaricat/Netcatty/pull/947#discussion_r3217449xxx

The previous follow-up commit (5c8bc923) plumbed per-host keepalive
into SFTP / port forwarding but kept the existing bridge-level
"if interval > 0 use it, else 10s" fallback. That collapsed two
semantically distinct inputs:

  - "user explicitly resolved interval = 0" (host with keepaliveOverride
    + interval=0; the whole point of the override)
  - "no value supplied at all" (legacy serializer)

Both ended up as 10s in the bridge, so a router-as-bastion / direct
router connection through SFTP or a port-forward tunnel still got
ssh2-killed after countMax unanswered probes — exactly the case
per-host override was supposed to fix.

Fix: bridges now distinguish on `== null`:
  - positive value → honor it
  - explicit 0 → truly disabled (0 ms, 0 countMax — ssh2 skips its
    dead-connection check entirely on this connection)
  - undefined / null → fall back to 10s/3 (preserves #669 idle-NAT
    protection for older callers that pre-date per-host plumbing)

Applies to both SFTP target connect and SFTP jump hop builders, plus
the port forwarding target builder. Terminal SSH bridge is unchanged
since it already treated 0 as disabled.

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

* fix(ssh): plumb terminalSettings to all remaining keepalive call sites

Addresses codex review on PR #947:
  - PortForwardingNew + TrayPanel were not passing terminalSettings into
    startTunnel, so tunnels started from the main port-forwarding UI or
    from the tray menu silently used the FALLBACK 30/10 instead of the
    user's actual global keepalive settings. Hosts inheriting global
    policy could see different behavior depending on the entry point.
  - SftpView was not threading terminalSettings into useSftpState, so
    SFTP connections opened from the main tab UI also fell back to the
    same hardcoded default and ignored the user's settings.

Wiring:
  - PortForwardingProps gains `terminalSettings`; VaultView accepts it
    on the same prop and forwards from its own new prop; App.tsx
    supplies it from useSettingsState. The startTunnel call site uses
    it directly and includes it in the useCallback dep list so the
    handler updates when settings change.
  - SftpViewProps gains `terminalSettings`; SftpViewMount accepts and
    forwards it; the sftpOptions memo includes it in its dep list.
  - TrayPanelContent gains a `terminalSettings` prop; the TrayPanel
    wrapper (which already calls useSettingsState for uiLanguage)
    passes it down so the standalone tray window agrees with the main
    window's settings.

Also updates the explicit `startTunnel` signature in
UsePortForwardingStateResult so callers see the new 8th parameter
through the hook's return type, not just through the implementation.

Net result: every place that starts an SSH-derived connection
(terminal session, SFTP browse, port-forward tunnel) now consistently
sees the user's configured global keepalive policy and any per-host
overrides; the FALLBACK_KEEPALIVE constants in the service /
credentials builder are now only reached by genuinely-decoupled call
sites (tests, headless usage) rather than masking missing wiring.

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

* fix(ssh): include terminalSettings keepalive fields in memo comparators

Addresses codex review on PR #947 — all three components that grew a
`terminalSettings` prop (SftpView, SftpSidePanel, VaultView) are wrapped
in React.memo with manual equality comparators, and none of those
comparators were updated to include the new prop. React would skip the
re-render when global keepalive changed, so new SFTP / port-forwarding
connections from those subtrees would silently keep using the old
keepalive policy until some other tracked prop happened to flip.

Each comparator now compares the keepalive fields directly rather than
the whole terminalSettings object — only those two fields drive
connection resolution in this subtree, and ignoring the rest avoids
unnecessary re-renders for unrelated terminal-setting changes (fonts,
themes, etc.) that already have their own targeted comparator entries.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:22:12 +08:00
DeepFal
311f44525b Fix AI export menu theme colors (#944) 2026-05-11 15:00:49 +08:00
陈大猫
b4e185e1c6 fix(terminal): restore right-click paste in mouse-tracking TUIs (#941) (#946)
When a TUI app enables SGR mouse tracking (opencode, tmux with
`mouse on`, vim with `set mouse=a`, etc.), Terminal.tsx attaches a
capture-phase contextmenu listener that calls
stopImmediatePropagation. The original purpose is to bypass xterm.js's
own right-click handler — which calls textarea.select() and dismisses
TUI popup menus — but stopImmediatePropagation also kills the bubble
that React's onContextMenu delegation relies on, so
TerminalContextMenu's handleRightClick never fires.

Result: with `rightClickBehavior` set to "paste" (or "select-word"),
right-click silently does nothing inside any mouse-tracking TUI. Menu
mode still works because Radix opens via pointerdown (not affected by
the contextmenu capture block). Middle-click paste works because its
auxclick listener in createXTermRuntime is also unrelated to
contextmenu.

Fix: have the capture handler itself dispatch the user's chosen
right-click action when it intercepts the event. terminalContextActions
already exposes onPaste / onSelectWord; mirror them into a ref so the
once-bound capture handler can call the current implementation
without re-binding on every action identity change.

'context-menu' mode is intentionally not handled in the capture path —
Radix's pointerdown listener opens the menu independently.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:00:09 +08:00
陈大猫
92dd898eb4 Fix #931: let users pick a CJK font + per-font smart pairing (#940)
* feat(fonts): add CJK font pairing composition module

Introduces composeFontFamilyStack() which builds the xterm fontFamily
CSS string at runtime from:
  - the user's primary Latin font
  - an explicit CJK font (TerminalSettings.fallbackFont) if set
  - otherwise a per-Latin-font recommended CJK pairing
  - a hardcoded system CJK fallback stack
  - a Nerd Font icon fallback stack
  - the universal monospace generic

14 unit tests cover composition order, deduplication, OS defaults,
quoting, and recommendation override behavior.

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

* refactor(fonts): expose raw Latin families and add CJK-coverage entries

- TERMINAL_FONTS[].family no longer bakes in the CJK fallback stack;
  composition is deferred to runtime via composeFontFamilyStack().
- Drops withCjkFallback helper from this module and its caller in
  lib/localFonts.ts.
- Adds 6 CJK-coverage primary fonts to the dropdown: Sarasa Mono SC/TC,
  Maple Mono CN, LXGW WenKai Mono, Microsoft YaHei UI, PingFang SC.

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

* feat(terminal): compose font-family stack with user-configurable CJK fallback

resolvedFontFamily now passes through composeFontFamilyStack(), which
prepends the user's TerminalSettings.fallbackFont (if set) ahead of the
per-Latin-font recommended CJK pairing and the system fallback stack.

The platform argument is derived from navigator.platform inside the
useMemo, so the same Latin font may pair with PingFang SC on macOS and
Microsoft YaHei UI on Windows out of the box.

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

* feat(settings): add CJK font picker to terminal settings

Adds a new "CJK font" select row right under the main font selector in
the Terminal settings tab. Bound to TerminalSettings.fallbackFont (an
already-existing-but-unused field), so this needs no schema or sync
payload change.

Default value "Auto" leaves fallbackFont empty, which lets the new
per-Latin-font pairing in cjkFonts.ts pick a CJK font automatically.
Selecting any explicit option (Sarasa Mono SC, PingFang SC, Microsoft
YaHei UI, etc.) takes precedence over the per-font pairing.

Includes en + zh-CN i18n strings.

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

* test(sync): cover fallbackFont round-trip + legacy payload tolerance

Four new test cases verify cloud-sync compatibility for the new CJK
font setting:

  - buildSyncPayload includes fallbackFont when set
  - buildSyncPayload omits fallbackFont when unset
  - applySyncPayload writes incoming fallbackFont to TERM_SETTINGS
  - applySyncPayload from a legacy client (no fallbackFont) does NOT
    wipe the local value — critical for old-to-new upgrades

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

* feat(fonts): add font availability detection (canvas + document.fonts API)

Three-layer detection used by isFontInstalled(family):
  1. Known @fontsource-bundled families (e.g. JetBrains Mono) always
     count as installed.
  2. document.fonts.check() — picks up @font-face and system-loaded fonts.
  3. Canvas width measurement against serif / sans-serif / monospace
     fallbacks; only counts if the target font produces a width that
     differs from ALL three generics for a probe string.

detectInstalledWithContext is a pure function taking an injected
measurement context, which keeps the canvas / DOM behind a seam and
lets the logic be unit-tested without a browser. 11 tests cover
quoted-family parsing, the three-generic-fallback rule, bundled
short-circuit, and document.fonts.check fast-path.

Results are cached per process; clearFontAvailabilityCache() invalidates.

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

* feat(fonts): filter dropdowns to fonts actually installed on this machine

Layer 3 of #931 added Sarasa Mono SC / Maple Mono CN / Microsoft YaHei UI
/ PingFang SC etc. to the terminal font dropdown, but users who don't
have these installed would still see them and pick them — resulting in
"I changed the font and nothing happened" confusion.

This commit filters both dropdowns through isFontInstalled():

  - TerminalFontSelect: drops any built-in or system-discovered font
    that detection can't render. If filtering would leave fewer than 4
    fonts (detection misfire safety net), shows the full list.

  - TerminalCjkFontSelect: keeps the "Auto" sentinel always, drops
    concrete CJK choices that aren't present on this machine.

Both selects always keep the currently-selected value visible — even
when the underlying font is missing — so users can read and clear
their setting without surprise.

Also expands `npm test` globs to pick up infrastructure/config/*.test.ts
and lib/*.test.ts, which previously matched no patterns and meant the
new cjkFonts and fontAvailability suites were silently excluded from
CI runs.

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

* fix(fonts): never recommend proportional CJK fonts for terminal use

The previous PingFang SC / Microsoft YaHei UI / Hiragino Sans GB choices
were proportional sans-serif fonts whose CJK glyphs aren't designed to
fit a terminal's 2x cell grid — the rendered Chinese ended up visibly
wider than its allocated cells, breaking grid alignment (reported on
macOS with PingFang SC selected as the CJK font).

Changes:
  - TerminalCjkFontSelect: drops PingFang SC / Microsoft YaHei UI /
    Hiragino Sans GB from the dropdown. Legacy explicit selections
    still surface as a synthetic "not recommended" option so users can
    see and re-pick.
  - CJK_SYSTEM_FALLBACK_FONTS: monospace-only list. Sarasa Mono SC/TC,
    Maple Mono CN, LXGW WenKai Mono, Noto Sans Mono CJK SC, Source Han
    Mono SC, NSimSun, SimSun. Proportional fonts removed.
  - PER_FONT_CJK_PAIRING: every entry now points at a true monospace
    CJK font. Cascadia / Consolas / Menlo etc. all recommend Sarasa
    Mono SC, which the next commit bundles via @font-face.
  - getDefaultCjkFallback: Windows = SimSun (always installed,
    monospace); macOS = Sarasa Mono SC (will be bundled); Linux =
    Noto Sans Mono CJK SC. A regression test enforces that no
    per-OS default is a known proportional font.

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

* feat(fonts): bundle Sarasa Mono SC as the universal CJK monospace

Previous commit removed proportional CJK fonts (PingFang SC, etc.)
from the picker and switched per-OS defaults to true monospace, but
macOS ships NO system-installed monospace CJK font — leaving macOS
users with a broken default unless they manually install Sarasa or
similar. This commit closes that gap by bundling Sarasa Mono SC as
an @font-face webfont, so the recommended pairings and macOS default
"just work" out of the box.

Details:
  - public/fonts/SarasaMonoSC-Regular.woff2 (~4.8 MB): subsetted from
    be5invis/Sarasa-Gothic v1.0.37 SarasaMonoSC-Regular.ttf (24 MB).
    Covers ASCII, Latin-1, common punctuation/symbols, CJK Unified
    Ideographs main block, Hiragana/Katakana, halfwidth/fullwidth,
    box-drawing — the everyday-Chinese coverage that matters for a
    terminal. Rare CJK Ext-A/B/historical chars fall through to the
    system fallback stack.
  - public/fonts/SarasaMono-LICENSE.txt: OFL-1.1 verbatim, required
    by the license.
  - index.css: @font-face declaration with font-display: swap so the
    user doesn't see a flash of nothing while the woff2 loads.
  - KNOWN_BUNDLED_FAMILIES: "Sarasa Mono SC" added so the dropdown
    availability filter doesn't hide it.

Installer impact: ~+4.8 MB (vs current ~100-200 MB Electron baseline).
The font replaces what would otherwise have been "Chinese chars look
broken in the terminal" for every macOS user without a manually
installed CJK monospace font.

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

* fix(fonts): use Local Font Access API as the authoritative install check

document.fonts.check() turned out to be unreliable as an installed-font
signal in Chromium — it returns true for any syntactically-valid family
name regardless of whether the font is actually installed, as a
deliberate fingerprinting-mitigation. The previous detector took it as
a positive signal and ended up keeping uninstalled fonts in the dropdown
(reported by a macOS user seeing dozens of fonts they don't have).

This commit pivots the detection chain:

  - lib/localFonts.ts: getAllSystemFontFamilies() exposes the unfiltered
    set of installed family names from queryLocalFonts(), reusing the
    same underlying call as getMonospaceFonts() via a shared cache.

  - lib/fontAvailability.ts: drops the document.fonts.check fast-path.
    Adds setSystemFamilies() / hasAuthoritativeData(). When the set has
    been populated, isFontInstalled answers from membership lookup
    directly — no canvas guessing. Canvas remains as a fallback for
    environments where the Local Font Access API is unavailable or
    permission is denied.

  - application/state/fontStore.ts: during initialize(), runs the
    monospace-only query and the full-system-families query together,
    then pipes the result into fontAvailability.

  - TerminalFontSelect: with authoritative data, drops the "if filtered
    list is suspiciously small, show all" safety net. Empty would now
    really mean empty (highly unlikely since Sarasa Mono SC is bundled).

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

* fix(fonts): drop PingFang SC / Microsoft YaHei UI from primary dropdown

Step 1 of this PR removed proportional CJK fonts from the CJK fallback
picker but left them in BASE_TERMINAL_FONTS, so PingFang SC and
Microsoft YaHei UI were still selectable as the *primary* terminal
font. Picking PingFang SC as primary produced visibly bloated Latin
character spacing (xterm.js samples cell width from the primary font;
the wide proportional 'M' inflates every cell), reported by a macOS
user in the same thread that opened #931.

Both entries are removed from BASE_TERMINAL_FONTS. A new
infrastructure/config/fonts.test.ts asserts that no known proportional
CJK font name (including PingFang TC/HK, Microsoft YaHei variants,
Hiragino Sans GB, Heiti SC/TC) is ever shipped in TERMINAL_FONTS as a
primary choice.

Migration for users already saved to one of the removed ids:
useSettingsState rewrites STORAGE_KEY_TERM_FONT_FAMILY to the default
(Menlo) on read when it sees a deprecated id, so the bad value also
stops getting carried into cloud-sync uploads. Per-host fontFamily
overrides are NOT migrated automatically — they still gracefully
fall through to the dropdown's first entry via the existing
getFontById fallback; users can re-pick from the host settings UI.

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

* fix(fonts): drop Comic Sans MS — it's a proportional handwriting font

Same symptom as the PingFang SC / Microsoft YaHei UI removal: Comic
Sans MS was historically in the primary font dropdown labeled
"Casual, non-traditional terminal font", but Comic Sans is a
handwriting-style proportional sans-serif. Picking it as the terminal
primary inflates cell width and spaces every Latin character far
apart (reported in the same #931 thread).

- BASE_TERMINAL_FONTS: comic-sans-ms entry removed.
- DEPRECATED_PRIMARY_FONT_IDS: gains comic-sans-ms so existing
  selections silently migrate to Menlo on read.
- fonts.test.ts: the proportional-font ban list now also covers
  Latin proportional fonts (Comic Sans MS, Arial, Helvetica, Times
  New Roman, Georgia, Verdana, Trebuchet MS, Tahoma) so the test
  catches any future mislabeled body-text font from being added to
  the terminal dropdown.

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

* fix(fonts): keep monospace ahead of CJK fallbacks in composed stack

Addresses codex P1 review comment on PR #940
(https://github.com/binaricat/Netcatty/pull/940#discussion_r3216017737).

The previous behavior of withCjkFallback() had monospace immediately
after the primary family, before any CJK fallback. composeFontFamilyStack
had moved monospace to the very end, which means: when the primary
font isn't installed on the user's machine (common for Layer 3 CJK
choices that aren't bundled and not present on a given OS, or for any
built-in id like cascadia-code on a Linux system without it), CSS
per-glyph fallback resolves Latin glyphs from a CJK font's full-width
Latin variants before ever reaching monospace generic. That breaks
xterm.js's fixed cell-grid alignment.

The composed stack now reads:
  <primary>, monospace, <userFallback>, <recommended-cjk>,
  <system-cjk-stack>, <nerd-font-stack>

Per-glyph CSS fallback behavior:
  - Latin → primary if installed → monospace generic. Cell width
    stays consistent.
  - CJK → primary (no) → monospace (no Chinese glyphs) → walks into
    CJK fallbacks.
  - Nerd PUA → falls past all of the above into the Nerd Font stack.

Updates the position-invariant tests and adds a regression test that
explicitly asserts monospace appears before every CJK family in the
output stack.

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

* fix(fonts): dedupe Local Font Access API calls under concurrent init

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216246xxx

fontStore.initialize() runs getMonospaceFonts() and
getAllSystemFontFamilies() in Promise.all; both internally called
queryAllSystemFontsOnce(), whose cache check (`if (cache) return`) was
only useful once the result had been written. Concurrent callers both
passed the empty-cache check and fired their own queryLocalFonts()
request — two real Local Font Access API invocations on cold start,
with the risk of one succeeding while the other was denied (leaving
the authoritative set unset).

Fix: cache the *in-flight promise itself*, so subsequent callers
await the same single invocation. The first await populates the
family-set cache as a side effect, and the resolved promise keeps
returning the same value to every subsequent caller.

Adds lib/localFonts.test.ts with three regression tests:
  - concurrent getMonospaceFonts + getAllSystemFontFamilies = 1 API call
  - sequential repeats also reuse the resolved promise
  - missing API returns null authoritative set (canvas fallback signal)

Exports __resetLocalFontsCacheForTesting() so each test gets a fresh
module-level state.

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

* fix(fonts): retry LFA on transient failure + notify on availability changes

Two follow-up fixes from codex P2 review on PR #940:

1) queryAllSystemFontsOnce() previously kept its in-flight promise even
   when queryLocalFonts threw. Subsequent callers reused the cached
   empty result for the rest of the session, so any transient failure
   at boot (permission state not ready, AbortError, etc.) permanently
   blinded the rest of the app to installed fonts. Catch now clears
   queryPromise so the next caller retries. Regression test added.

2) TerminalCjkFontSelect.visibleOptions and TerminalFontSelect
   .visibleFonts were memoized on [value] / [fonts, value] only, but
   the filter calls isFontInstalled() which reads module-level
   systemFamilies — a value that arrives asynchronously after the
   initial render. The memos never recomputed when authoritative
   availability data landed, so the dropdowns could continue showing
   stale "filtered" results until the user changed selection.

   fontAvailability now exposes subscribeFontAvailability() and
   getFontAvailabilityVersion() (monotonic counter bumped on
   setSystemFamilies / clearFontAvailabilityCache). Both selects
   subscribe via useSyncExternalStore and include the version in
   their memo deps; tests cover subscriber notification and version
   monotonicity.

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

* fix(fonts): migrate host/group deprecated font ids + localize CJK labels

Two follow-up fixes from codex review on PR #940:

P2 — Host/group level font migration
====================================
The earlier deprecated-id migration only rewrote
STORAGE_KEY_TERM_FONT_FAMILY, so hosts and group configs that had
explicitly opted into a now-removed font id (e.g. pingfang-sc,
microsoft-yahei, comic-sans-ms) kept `fontFamily` set with
`fontFamilyOverride=true`. After the dropdown entries were dropped
in 9f2bd282/c9b622d8, those records silently fell through to the
first font in the registry (Menlo) while the override flag still
read "true" — users saw a host claiming a custom font but rendering
the global default with no way to tell what happened.

Fix:
  - infrastructure/config/fonts.ts gains migrateDeprecatedFontOverride(),
    a structurally-shared helper that drops fontFamily and clears
    fontFamilyOverride when the id is deprecated.
  - sanitizeHost now runs it on every host load.
  - domain/groupConfig.ts grows sanitizeGroupConfig(); useVaultState
    applies it both on initial load and on cross-tab storage events.
  - Existing decrypt → sanitize → encrypt round-trip in useVaultState
    means the migrated values are persisted back to localStorage and
    propagate through cloud sync naturally.

Tests: two each in domain/host.test.ts and domain/groupConfig.test.ts
covering deprecated-id reset and untouched-valid-id preservation.

P3 — Localize CJK font option labels
====================================
TerminalCjkFontSelect previously hardcoded Chinese option labels
("Auto · 按主字体智能搭配", "Sarasa Mono SC (更纱黑体 简)", etc.) and
the synthetic "not recommended" warning. Non-Chinese locales saw a
mixed-language UI despite the rest of the setting going through i18n.

OPTIONS now references i18n keys; the component looks them up via
useI18n(). Both en and zh-CN locales gain matching keys, including
`...option.legacy` with `{font}` interpolation for the synthetic
"not recommended" item that surfaces saved-but-removed values.

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

* fix(fonts): also sanitize group configs on the write/import path

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216314xxx

The previous commit (09c87820) added sanitizeGroupConfig() but only
plumbed it into the decrypt paths (initial load + storage event).
updateGroupConfigs() — which is also the write path used by
applySyncPayload / importVaultData when ingesting a legacy payload —
still set state from raw input. A sync from an older client carrying
{ fontFamily: "pingfang-sc", fontFamilyOverride: true } would land in
memory unsanitized AND be re-persisted with the bad override active
until the next reload re-ran the decrypt path.

Fix mirrors updateHosts → sanitizeHost: map every incoming entry
through sanitizeGroupConfig before both setGroupConfigs and the
encrypt-and-persist step. Same call site now feeds the cleaned data
to localStorage, so legacy values are scrubbed on first import.

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

* fix(fonts): migrate deprecated terminal font ids on every ingest path

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216517xxx

The previous migration only ran in the initial useState() initializer
for terminalFontFamilyId, so deprecated ids (pingfang-sc /
microsoft-yahei / comic-sans-ms) could still re-enter state via:

  - rehydrateAllFromStorage() at line ~527 — runs on remote-import
    completion and re-reads STORAGE_KEY_TERM_FONT_FAMILY raw.
  - The notifySettingsChanged IPC handler at line ~663 — fires when a
    cloud sync or programmatic localStorage write announces a change.
  - The cross-window storage event handler at line ~873.

Any of these paths could pull a deprecated id back into state after
the initial migration ran, leaving the font selector with no matching
option and silently rendering the global default while continuing to
propagate the stale value through subsequent sync uploads.

Centralizes the migration in migrateIncomingTerminalFontId(raw):
  - returns null when raw is empty
  - if raw is deprecated, writes DEFAULT_FONT_FAMILY back to
    localStorage AND returns it
  - otherwise returns raw unchanged

All four ingest sites (initial init, rehydrate, IPC, storage event)
now route through this helper. The rewrite-on-deprecated semantics
also guarantee that the moment any path sees a bad value, the next
sync upload carries the cleaned default — not the deprecated id.

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

* fix(fonts): use bundled Latin-only fallback instead of monospace generic

Resolves the tension between codex's two P1 reviews on PR #940:

  Round 1 (da1fe4cd): "monospace must come BEFORE CJK fallbacks" —
    otherwise Latin glyphs fall into a CJK font's full-width Latin
    when the primary font is missing.

  Round 2 (this commit): "monospace must come AFTER CJK fallbacks" —
    otherwise on macOS Chrome, the generic `monospace` pulls in
    PingFang via Chromium's CJK system fallback and silently masks
    the user's CJK picker.

Both are right; using a single `monospace` token can't satisfy both
roles because `monospace` is a generic family whose CJK-glyph
coverage is platform-dependent.

Fix mirrors Tabby's approach (their "monospace-fallback" SourceCodePro
sitting before any CJK in the chain): insert a known Latin-only
bundled font between the primary and CJK fallbacks. JetBrains Mono is
already shipped via @fontsource/jetbrains-mono and carries no CJK
glyphs, so it catches Latin without intercepting Chinese.

New stack order:
  <primary>, "JetBrains Mono", <userFallback>, <recommended-cjk>,
  <system-cjk-stack>, <nerd-font-stack>, monospace

Per-glyph CSS fallback now behaves as intended on every platform:
  - Latin: primary (if installed) → JetBrains Mono. Cells stay aligned.
  - CJK: primary (no) → JetBrains Mono (no CJK glyphs) → user CJK pick.
  - Nerd PUA: all of the above → Nerd Font stack.

Replaces the two prior positional-invariant tests with one for each
codex review concern: JetBrains Mono precedes every CJK family
(Latin alignment), and user CJK precedes generic monospace (CJK
picker effectiveness).

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

* fix(fonts): use OR-of-fallbacks for canvas font detection

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216556xxx

detectInstalledWithContext required the target font to produce a
different rendered width from *all three* generic fallbacks (serif,
sans-serif, monospace) to be counted as installed. That's too strict:
on macOS the `monospace` generic resolves to Menlo itself, so
measure(`"Menlo", monospace`) === measure(`monospace`), and the
detector reported Menlo as missing even when it was clearly installed.
The same false-negative trap exists for any font that happens to
share metrics with one of the three generics on a given platform.

Switches to OR-of-fallbacks: a font counts as installed if its
rendered width differs from at least one generic baseline. A truly
uninstalled font still falls through to each generic in turn and
matches all three baselines, so this doesn't introduce false positives.

Regression tests added for both directions:
  - Menlo with metrics identical to `monospace` generic → installed.
  - "Definitely Not Installed" font → still reported missing.

The path only fires when the Local Font Access API is unavailable or
denied — when LFA succeeds, `setSystemFamilies` short-circuits ahead of
canvas — so this primarily improves the degraded-permission scenario.

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

* fix(fonts): quote-aware tokenizer for font-family lists

Addresses codex P2 review on PR #940:
  https://github.com/binaricat/Netcatty/pull/940#discussion_r3216559xxx

composeFontFamilyStack and extractPrimaryFamily both tokenized their
input with a raw String.split(',') — which corrupts any CSS family
list whose quoted family name contains a comma (CSS allows that, e.g.
`"Foo, Inc. Mono"` is a single family). A naive split would shred
that into `"Foo` / `Inc. Mono"` and emit a malformed font-family back
out.

No current TERMINAL_FONTS entry hits this case, but lib/localFonts.ts
builds family strings from arbitrary system fonts via the Local Font
Access API — a user with a comma-bearing family name would have
silently broken filtering until now.

Adds splitFontFamilyList(css) in cjkFonts.ts: an exported quote-aware
tokenizer that splits on commas only when outside quoted segments
(handles both " and '). composeFontFamilyStack uses it instead of raw
split; extractPrimaryFamily in lib/fontAvailability.ts imports it for
symmetry so the two call sites can't drift.

Tests cover the tokenizer directly (simple list, quoted-with-comma,
single quotes, double commas) and end-to-end (a quoted primary with
an internal comma survives composition intact).

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

* fix(fonts): translate Layer 3 CJK font descriptions to English

The 4 CJK-coverage entries added in earlier commits (Sarasa Mono SC,
Sarasa Mono TC, Maple Mono CN, LXGW WenKai Mono) had hardcoded Chinese
description strings, while every other TERMINAL_FONTS entry uses
English ('Adobe's professional programming font', 'Iosevka variant
mimicking Berkeley Mono style', etc.). The dropdown rendered a
mixed-language list — flagged by the maintainer.

Converted the 4 descriptions to English in the same style as the
existing entries. No i18n scaffolding added; the existing convention
is "English-only `description` field, not routed through t()", and
the rest of the registry stays consistent with that.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:07:15 +08:00
bincxz
478e148b40 Drop noisy [XTerm] renderer=... boot log
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
The line printed once per terminal session and offered no diagnostic
value beyond what window.__xtermRenderer already exposes for ad-hoc
introspection. Keep the detection + retry + window publish; just
stop polluting the console. Rename logRenderer → trackRenderer to
match the now-narrowed responsibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:11:58 +08:00
陈大猫
231fb9c74c Merge pull request #936 from binaricat/fix/stable-use-terminal-backend
Stabilize useTerminalBackend return identity
2026-05-11 00:05:21 +08:00
bincxz
8870eb4de9 Stabilize useTerminalBackend return identity
The hook returned a fresh object literal every render. The 26 methods
inside were already useCallback([])-stable, but the wrapping object
was not — so every consumer's effect with `terminalBackend` in deps
(e.g. cwd polling, lifecycle wiring, write-to-session) re-ran on
every parent render even though nothing semantic had changed, and
ESLint flagged the one site that depended on a property access
(`terminalBackend.onHostKeyVerification`) because it could not prove
that path safe.

Wrap the return in useMemo with all stable callbacks listed as deps
so the object is computed once and cached for the hook's lifetime.
Switch the host-key-verification effect's dep to the now-stable
`terminalBackend`, clearing the warning at the root rather than
patching it locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:04:23 +08:00
陈大猫
c9114eb198 Merge pull request #935 from binaricat/fix/906-ghost-text-after-tab
Fix ghost text duplicating glyphs after Tab completion (#906)
2026-05-10 23:59:03 +08:00
bincxz
938d1ef48b Fix ghost text duplicating glyphs after Tab completion (#906)
The reliability gate at handleInput's adjustToInput call froze the
ghost at its last show()-time tail in any path where the typed buffer
becomes unreliable (Tab pass-through to shell, history recall, cursor
moves). When the user kept typing into that gap, the next render
advanced the cursor past the ghost's anchor while the ghost text
stayed put — a → -accept then pasted the stale tail on top of the
just-typed glyphs (e.g. "systemctl s" + typing "t" → screen showed
"systemctl sttop firewalld").

Add GhostTextAddon.applyKeystroke so the ghost can evolve its own
currentInput off raw keystrokes (printable / Backspace / Ctrl-W),
seeded by whatever the last show() captured from the live xterm
reading. handleInput now uses the existing adjustToInput on the
reliable path (preserves multi-char paste re-alignment) and routes
single-keystroke events through applyKeystroke on the unreliable
path, fixing the visual misalignment and the duplication-on-accept
in one shot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:53:11 +08:00
陈大猫
52c097d9f8 Merge pull request #928 from binaricat/binaricat/fix-issue-920
Sync AI/UI settings and fix multi-display settings window placement
2026-05-10 23:24:15 +08:00
bincxz
684c094d40 Drop externalAgents from cloud sync (device-local config)
ExternalAgentConfig.command/acpCommand/args/env are OS- and
machine-specific (binary paths, .exe suffixes, platform-dependent
environment values). Pushing them to other devices either fails to
resolve or silently runs the wrong thing.

Stop collecting/applying STORAGE_KEY_AI_EXTERNAL_AGENTS and remove the
field from the SyncPayload type. apply silently ignores the field on
legacy snapshots that still carry it, so existing remote data is safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:16:37 +08:00
bincxz
d84c2cc902 Preserve local AI apiKeys when applying synced settings
`collectSyncableSettings` strips device-bound encrypted apiKeys from
provider entries and webSearchConfig before upload, but
`applySyncableSettings` was writing them back wholesale, silently wiping
local credentials whenever any other setting changed on a second device.

Merge by id (providers) and by providerId (web search) so a synced
payload only overrides the apiKey when it explicitly carries one.

Also include `application/*.test.ts` in the npm test glob so the
syncPayload tests added in this PR actually run in CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:17:41 +08:00
陈大猫
3a233a3279 Merge pull request #934 from binaricat/claude/suspicious-bohr-32d9f2
Fix WebDAV Basic Auth for non-ASCII passwords (Hetzner #891)
2026-05-10 21:41:37 +08:00
bincxz
ba675fa944 Use UTF-8 for WebDAV Basic Auth credentials
The upstream `webdav` package builds the `Authorization: Basic …` header
through `base-64`, which Latin1-encodes the credentials. RFC 7617 (and
servers that follow it, like Hetzner Storage Box) expect UTF-8, so any
non-ASCII character in the password (e.g. `ö`, `ä`) produces a different
byte sequence on the wire than what the server stored, and the request
gets a 401 even though the credentials are correct (#891).

Skip the upstream auth path for password mode and pass an Authorization
header we built ourselves with UTF-8 encoding. ASCII-only passwords are
byte-identical, so existing setups are unaffected. Digest and token
modes are untouched.

Tested with a local HTTP server that enforces UTF-8-encoded Basic Auth
for a password containing umlauts (the exact failing case from #891).
2026-05-10 21:37:52 +08:00
bincxz
c9da2a5893 Sync AI/UI settings and fix multi-display settings window
Extend cloud sync to cover AI provider config, external agents,
permission/tool modes, command policy, web search settings,
workspace focus style, terminal follow-app theme, SFTP default view,
and additional terminal options. Device-bound encrypted apiKey
placeholders are stripped from providers and webSearchConfig before
upload. Auto-sync now reacts to syncable localStorage changes via a
new adapter-level event.

Center the Settings window on the display of the window that opened
it instead of always using the main window, fixing issue #920.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 00:41:14 +08:00
陈大猫
a377d39446 Merge pull request #926 from binaricat/codex/fix-ssh-known-host-verification
Fix SSH known host verification
2026-05-09 23:56:31 +08:00
bincxz
4b7249997f Update changed known hosts in place 2026-05-09 23:42:08 +08:00
bincxz
eb3f55b477 Integrate host key confirmation into connection dialog 2026-05-09 20:15:22 +08:00
bincxz
bce33f34ee Fix SSH known host verification 2026-05-09 19:44:21 +08:00
陈大猫
b6c59b9683 Merge pull request #924 from bet4it/shift-enter-support
Support Shift+Enter
2026-05-09 19:12:30 +08:00
bincxz
ff6b75aba7 Harden Shift+Enter keyboard support 2026-05-09 19:12:08 +08:00
陈大猫
b65ed74ced Merge pull request #922 from binaricat/feat/915-sftp-upload-context-menu
Add Upload File(s) item to SFTP context menu
2026-05-09 18:01:35 +08:00
bincxz
6c6a051c0c Fix SFTP upload context menu handling 2026-05-09 17:47:45 +08:00
陈大猫
621eae28f4 Merge pull request #918 from gorgiaxx/main
feat: Optimization of SSH Key Passphrase and Keychain
2026-05-09 16:17:46 +08:00
bincxz
2329014e22 fix: harden SSH key passphrase flows 2026-05-09 16:16:17 +08:00
Bet4
5c5ab21b10 support Shift+Enter 2026-05-09 14:56:17 +08:00
bincxz
a01ee1da61 Hide SFTP upload on local panes; add folder picker
The SFTP file-list "Upload File(s)" context menu items only make sense
on remote panes — local panes have no upload semantic. Plumb a new
`isLocal` prop into SftpPaneFileList and suppress both the menu items
and the hidden file inputs when the active pane is local.

Also add an "Upload Folder..." item alongside "Upload File(s)..." that
opens a `<input type="file" webkitdirectory>` picker. The resulting
FileList is routed through a new `uploadExternalFolder` /
`onUploadExternalFolder` callback that calls `uploadFromFileList`, so
folder structure is preserved via webkitRelativePath without any new
IPC. When invoked from a directory row, the folder is uploaded INTO
that directory (matching drag-and-drop semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:57:56 +08:00
陈大猫
c94ded1a77 Merge pull request #923 from binaricat/fix/916-session-log-on-reconnect
Restart session log stream on reconnect
2026-05-09 12:52:08 +08:00
陈大猫
59de39e2ab Merge pull request #921 from binaricat/feat/912-settings-hotkey
Add hotkey to open Settings panel
2026-05-09 12:51:44 +08:00
bincxz
4a3869369e Restart session log stream on reconnect
Fixes #916.

When the user clicks "Restart" after a session disconnects, the
renderer reuses the same sessionId and the bridges call startStream
again to open a fresh log file for the new connection. The previous
connection's close handlers (e.g. SSH conn.once('close'),
stream.on('close'), serial 'close', telnet 'close', mosh PTY exit)
all still fire asynchronously and call stopStream(sessionId)
unconditionally. If they land after the new stream is already
active, they silently destroy it and subsequent terminal output for
the reconnected session is dropped, matching the bug report where
the first connection's IO is saved but the reconnect's is not.

Make startStream return a unique token and require stopStream
callers to pass it. A stale stop call carrying the previous
incarnation's token is now a no-op, so a late close handler from
the previous connection cannot kill the freshly-started stream.

Each reconnect therefore produces its own timestamped log file,
which mirrors the existing auto-save-on-close semantics and is the
simpler of the two options the issue offered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:38:50 +08:00
bincxz
11856b09e5 Add Settings gear button to top tab bar
Provides a discoverable entry point to the Settings panel for users
who don't use the Cmd/Ctrl+, hotkey. Sits at the right edge of the
title bar on macOS and immediately to the left of the custom window
controls on Windows/Linux. Reuses the existing onOpenSettings prop
already wired through from App.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:31:52 +08:00
bincxz
76b013f128 Add Upload File(s) item to SFTP context menu
Right-click on an SFTP pane now offers an "Upload File(s)" menu item
that opens a native multi-file picker, so users no longer have to drag
and drop to upload (issue #915). Selected files are wrapped in a
DataTransfer and dispatched through the existing onUploadExternalFiles
pipeline; right-clicking a directory uploads into that folder. Folder
upload via the picker is intentionally out of scope.

Fixes #915

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:31:13 +08:00
bincxz
44abf420c2 Add hotkey to open Settings panel
Adds Cmd+, on macOS and Ctrl+, on Windows/Linux to open Settings,
matching the platform convention. Previously Settings was only
reachable via Vaults -> Settings (#912).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:28:51 +08:00
gorgiaxx
cb98bdba2b fix: Improve passphrase handling by purging cached passphrases only on specific errors 2026-05-08 23:44:10 +08:00
gorgiaxx
18d411bb95 fix: preserve reference SSH keys and retry passphrase prompts
Keep file-backed SSH keys intact across app restarts and keep bad key passphrases in the dedicated retry flow instead of falling back to generic SSH auth. Also clear invalid saved passphrases from both legacy storage and reference-key records after auth failures.
2026-05-08 18:50:40 +08:00
gorgiaxx
1e80337a46 Merge branch 'main' of github.com:gorgiaxx/Netcatty 2026-05-08 17:26:55 +08:00
gorgiaxx
f1cfce45cf feat: Enhance SSH key management with reference key support and UI updates 2026-05-08 17:23:07 +08:00
Gorgias
833f9d2cac Merge branch 'binaricat:main' into main 2026-05-07 22:41:58 +08:00
gorgiaxx
72847a05af fix: Refactor passphrase handling: remove auto-responded keys tracking and related logic 2026-05-07 22:41:14 +08:00
陈大猫
0eccb2a252 Merge pull request #911 from yuzifu/allow-quick-edit 2026-05-07 19:52:31 +08:00
gorgiaxx
8a44152b36 Add support for remembering SSH key passphrases and update UI accordingly 2026-05-07 17:38:17 +08:00
yuzifu
c20abd86d9 allow quick edit for grid mode of keychain view 2026-05-07 16:23:38 +08:00
陈大猫
3fc9622695 Merge pull request #909 from binaricat/codex/telnet-auto-login
[codex] Improve Telnet credential login
2026-05-07 13:12:49 +08:00
bincxz
eb1fd9c127 Harden Telnet auto-login 2026-05-07 12:57:54 +08:00
bincxz
5cf1dd1de6 Match Telnet port field width to SSH 2026-05-07 11:46:59 +08:00
bincxz
137f8affbb Handle concatenated Telnet login prompts 2026-05-07 11:37:17 +08:00
bincxz
b9ac14f497 Improve Telnet credential login 2026-05-07 11:22:24 +08:00
358 changed files with 35608 additions and 5303 deletions

89
.github/scripts/bump-homebrew-cask.sh vendored Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# bump-homebrew-cask.sh — push a new version of the Netcatty cask to the
# binaricat/homebrew-netcatty tap.
#
# Called from the release pipeline (`build.yml` → `homebrew-tap` job) after
# the GitHub Release has been published with the signed + notarized DMGs.
# Computes SHA-256 of the arm64 and x64 DMGs, rewrites the cask file, and
# pushes the bump back to the tap repository using HOMEBREW_TAP_TOKEN.
#
# Required env vars:
# VERSION — semver without leading "v" (e.g. 1.1.6)
# HOMEBREW_TAP_TOKEN — PAT with contents:write on the tap repo
#
# Optional env vars:
# TAP_REPO — default: binaricat/homebrew-netcatty
# ARTIFACTS_DIR — default: artifacts
# CASK_PATH — default: Casks/netcatty.rb
set -euo pipefail
: "${VERSION:?VERSION env var required (no leading v)}"
: "${HOMEBREW_TAP_TOKEN:?HOMEBREW_TAP_TOKEN env var required}"
TAP_REPO="${TAP_REPO:-binaricat/homebrew-netcatty}"
ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
CASK_PATH="${CASK_PATH:-Casks/netcatty.rb}"
ARM_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-arm64.dmg"
X64_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-x64.dmg"
for f in "$ARM_DMG" "$X64_DMG"; do
if [[ ! -f "$f" ]]; then
echo "::error::Required DMG artifact not found: $f"
exit 1
fi
done
ARM_SHA=$(shasum -a 256 "$ARM_DMG" | awk '{print $1}')
X64_SHA=$(shasum -a 256 "$X64_DMG" | awk '{print $1}')
echo "Computed checksums:"
echo " arm64: ${ARM_SHA}"
echo " x64 : ${X64_SHA}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
git clone --depth 1 \
"https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${TAP_REPO}.git" \
"$TMP/tap"
cd "$TMP/tap"
if [[ ! -f "$CASK_PATH" ]]; then
echo "::error::Cask file not found in tap: $CASK_PATH"
exit 1
fi
# Patch the cask in place. The three lines we touch are anchored well enough
# that we don't need anything fancier than sed:
# - the `version "X.Y.Z"` line (single line, anchored to start)
# - the `sha256 arm: "..."` line
# - the ` intel: "..."` line (anchor on "intel:" at start, after the
# leading whitespace, so we don't accidentally match the `arch arm:
# "...", intel: "..."` line earlier in the file)
sed -i -E 's|^(\s*version)\s+"[^"]+"|\1 "'"$VERSION"'"|' "$CASK_PATH"
sed -i -E 's|(sha256\s+arm:\s+)"[^"]+"|\1"'"$ARM_SHA"'"|' "$CASK_PATH"
sed -i -E 's|^(\s*intel:\s+)"[^"]+"|\1"'"$X64_SHA"'"|' "$CASK_PATH"
# Sanity-check: parsed file should still be valid Ruby. Catches a broken
# substitution before we push.
if command -v ruby >/dev/null 2>&1; then
ruby -c "$CASK_PATH" >/dev/null
fi
if git diff --quiet; then
echo "Cask already at ${VERSION} with matching checksums — nothing to push."
exit 0
fi
echo "Cask diff:"
git --no-pager diff "$CASK_PATH"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add "$CASK_PATH"
git commit -m "Bump netcatty to ${VERSION}"
git push origin HEAD:main
echo "Pushed bump for ${VERSION} to ${TAP_REPO}."

View File

@@ -604,3 +604,33 @@ jobs:
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}
homebrew-tap:
name: bump homebrew tap
runs-on: ubuntu-latest
needs: release
# Only stable release tags update the Cask. Prerelease tags
# (e.g. v1.2.0-rc.1) are skipped so brew users stay on stable.
if: |
startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref_name, '-')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: netcatty-macos
path: artifacts/
- name: Bump Cask in binaricat/homebrew-netcatty
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
ARTIFACTS_DIR: artifacts
run: |
# Strip the leading "v" — Cask version is plain semver.
VERSION="${GITHUB_REF_NAME#v}"
export VERSION
bash .github/scripts/bump-homebrew-cask.sh

226
App.tsx
View File

@@ -11,14 +11,29 @@ import { useUpdateCheck } from './application/state/useUpdateCheck';
import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { useEditorTabs, editorTabStore } from './application/state/editorTabStore';
import {
clearReferenceKeyPassphrases,
clearKeyPassphrasesByIds,
loadDefaultKeyPassphrase,
rememberKeyPassphrase,
removeDefaultKeyPassphrases,
shouldUpdateReferenceKeyPassphrase,
} from './application/defaultKeyPassphrases';
import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { upsertKnownHost } from './domain/knownHosts';
import { materializeHostProxyProfile } from './domain/proxyProfiles';
import { resolveHostAuth } from './domain/sshAuth';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
applyCustomAccentToTerminalTheme,
mergeTerminalHostUpdate,
resolveHostTerminalThemeId,
} from './domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
import { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from './application/state/resolveSnippetsShortcutIntent';
@@ -45,6 +60,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from './components/ui/input';
import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { VaultView, VaultSection } from './components/VaultView';
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog';
@@ -53,7 +69,7 @@ import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal
import { cn } from './lib/utils';
import { classifyLocalShellType } from './lib/localShell';
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
import { ConnectionLog, Host, HostProtocol, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
@@ -166,12 +182,22 @@ const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
useEffect(() => {
if (shouldMount) return;
// Warm up the terminal layer shortly after first paint to reduce latency when opening a session.
const id = window.setTimeout(() => setShouldMount(true), 1200);
type IdleWindow = Window & {
requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number;
cancelIdleCallback?: (id: number) => void;
};
const idleWindow = window as IdleWindow;
if (typeof idleWindow.requestIdleCallback === "function") {
const id = idleWindow.requestIdleCallback(() => setShouldMount(true), { timeout: 5000 });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = window.setTimeout(() => setShouldMount(true), 5000);
return () => window.clearTimeout(id);
}, [shouldMount]);
if (!shouldMount) return null;
const shouldRender = shouldMount || isVisible;
if (!shouldRender) return null;
return (
<Suspense fallback={null}>
@@ -264,6 +290,7 @@ function App({ settings }: { settings: SettingsState }) {
managedSources,
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,
@@ -285,6 +312,21 @@ function App({ settings }: { settings: SettingsState }) {
updateGroupConfigs,
} = useVaultState();
const keysRef = useRef(keys);
keysRef.current = keys;
const knownHostsRef = useRef(knownHosts);
knownHostsRef.current = knownHosts;
// Bridge the gap while useVaultState hydrates: its async init awaits
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
// so the state is briefly [] at boot even when localStorage has entries.
// Any SSH connect during that window (manual click or restored session)
// would otherwise see no trusted hosts and prompt for fingerprint
// re-confirmation. Mirrors the same fallback already used by sync payloads.
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
const {
sessions,
workspaces,
@@ -319,6 +361,7 @@ function App({ settings }: { settings: SettingsState }) {
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
orphanSessions,
@@ -613,7 +656,7 @@ function App({ settings }: { settings: SettingsState }) {
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}, rule.autoStart, terminalSettings);
return;
}
@@ -820,6 +863,7 @@ function App({ settings }: { settings: SettingsState }) {
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
});
// Sync tray menu data + handle tray actions
@@ -983,8 +1027,46 @@ function App({ settings }: { settings: SettingsState }) {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseRequest) return;
const unsubscribe = bridge.onPassphraseRequest((request) => {
const unsubscribe = bridge.onPassphraseRequest(async (request) => {
console.log('[App] Passphrase request received:', request);
// If the bridge already tried a passphrase and it was wrong, skip auto-respond
if (!request.passphraseInvalid) {
// Check if a reference key exists for this path — use its passphrase
const currentKeys = keysRef.current;
const refKey = currentKeys.find((k: SSHKey) => k.source === 'reference' && k.filePath === request.keyPath);
if (refKey?.passphrase && refKey.savePassphrase !== false && !isEncryptedCredentialPlaceholder(refKey.passphrase)) {
console.log('[App] Auto-responding with reference key passphrase for:', request.keyPath);
void bridge.respondPassphrase?.(request.requestId, refKey.passphrase, false);
return;
}
// Fallback: try old storage for passphrase
const saved = await loadDefaultKeyPassphrase(request.keyPath);
if (saved) {
console.log('[App] Auto-responding with saved passphrase for:', request.keyPath);
// Migrate to reference key if one exists
if (shouldUpdateReferenceKeyPassphrase(refKey)) {
try {
await rememberKeyPassphrase({
keyPath: request.keyPath,
passphrase: saved,
keys: currentKeys,
updateKeys,
setCurrentKeys: (updated) => {
keysRef.current = updated;
},
});
} catch (err) {
console.warn('[App] Failed to migrate passphrase to reference key:', err);
}
}
void bridge.respondPassphrase?.(request.requestId, saved, false);
return;
}
}
// No saved passphrase or it was invalid, show modal
setPassphraseQueue(prev => [...prev, {
requestId: request.requestId,
keyPath: request.keyPath,
@@ -996,16 +1078,37 @@ function App({ settings }: { settings: SettingsState }) {
return () => {
unsubscribe?.();
};
}, []);
}, [updateKeys]);
// Handle passphrase submit
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
const handlePassphraseSubmit = useCallback(async (requestId: string, passphrase: string, remember: boolean) => {
const bridge = netcattyBridge.get();
const request = passphraseQueue.find((r: PassphraseRequest) => r.requestId === requestId);
// Save passphrase if requested
if (remember && request?.keyPath) {
console.log('[App] Saving passphrase for:', request.keyPath);
try {
await rememberKeyPassphrase({
keyPath: request.keyPath,
passphrase,
keys: keysRef.current,
updateKeys,
setCurrentKeys: (updated) => {
keysRef.current = updated;
},
});
} catch (err) {
console.warn('[App] Failed to save passphrase:', err);
}
}
if (bridge?.respondPassphrase) {
void bridge.respondPassphrase(requestId, passphrase, false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
}, [passphraseQueue, updateKeys]);
// Handle passphrase cancel
const handlePassphraseCancel = useCallback((requestId: string) => {
@@ -1048,6 +1151,44 @@ function App({ settings }: { settings: SettingsState }) {
};
}, []);
// Handle passphrase cancellation (owning connection was stopped)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseCancelled) return;
const unsubscribe = bridge.onPassphraseCancelled((event) => {
console.log('[App] Passphrase request cancelled:', event.requestId);
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
});
return () => {
unsubscribe?.();
};
}, []);
// Handle passphrase auth failure (saved passphrase was wrong, clear it)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseAuthFailed) return;
const unsubscribe = bridge.onPassphraseAuthFailed((event) => {
const keyPaths = event.keyPaths ?? [];
const keyIds = event.keyIds ?? [];
console.log('[App] Passphrase auth failed for keys:', { keyPaths, keyIds });
removeDefaultKeyPassphrases(keyPaths);
const withoutReferencePassphrases = clearReferenceKeyPassphrases(keysRef.current, keyPaths);
const updated = clearKeyPassphrasesByIds(withoutReferencePassphrases, keyIds);
if (updated !== keysRef.current) {
keysRef.current = updated;
void updateKeys(updated);
}
});
return () => {
unsubscribe?.();
};
}, [updateKeys]);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;
@@ -1056,9 +1197,11 @@ function App({ settings }: { settings: SettingsState }) {
const addConnectionLogRef = useRef(addConnectionLog);
addConnectionLogRef.current = addConnectionLog;
const closeSidePanelRef = useRef<(() => void) | null>(null);
const toggleScriptsSidePanelRef = useRef<(() => void) | null>(null);
const activeSidePanelTabRef = useRef<string | null>(null);
const toggleSidePanelRef = useRef<(() => void) | null>(null);
// Populated below so the hotkey dispatcher can open the Settings window
// even though `handleOpenSettings` is declared further down in the file.
const handleOpenSettingsRef = useRef<() => void>(() => {});
const closeTabInFlightRef = useRef(false);
// Populated by UnsavedChangesProvider render-prop below so that the hotkey
// dispatcher (defined outside that scope) can still reach the dirty-confirm
@@ -1238,13 +1381,11 @@ function App({ settings }: { settings: SettingsState }) {
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
const activeSidePanel = activeSidePanelTabRef.current;
const intent = resolveCloseIntent({
activeTabId: currentId,
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
sessionForTab: session,
activeSidePanelTab: activeSidePanel,
focusIsInsideTerminal,
});
@@ -1258,10 +1399,6 @@ function App({ settings }: { settings: SettingsState }) {
if (ok) closeSession(intent.sessionId);
return;
}
case 'closeSidePanel': {
closeSidePanelRef.current?.();
return;
}
case 'closeWorkspace': {
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
const ok = await confirmIfBusyLocalTerminal(ids);
@@ -1337,6 +1474,9 @@ function App({ settings }: { settings: SettingsState }) {
setNavigateToSection('snippets');
}
break;
case 'toggleSidePanel':
toggleSidePanelRef.current?.();
break;
case 'broadcast': {
// Toggle broadcast mode for the active workspace
const currentId = activeTabStore.getActiveTabId();
@@ -1346,6 +1486,9 @@ function App({ settings }: { settings: SettingsState }) {
}
break;
}
case 'openSettings':
handleOpenSettingsRef.current();
break;
case 'splitHorizontal': {
const currentId = activeTabStore.getActiveTabId();
const activeSession = sessions.find(s => s.id === currentId);
@@ -1456,6 +1599,12 @@ function App({ settings }: { settings: SettingsState }) {
updateHosts(hosts.filter(h => h.id !== hostId));
}, [hosts, updateHosts, t]);
const handleAddKnownHost = useCallback((kh: KnownHost) => {
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, kh);
knownHostsRef.current = nextKnownHosts;
updateKnownHosts(nextKnownHosts);
}, [updateKnownHosts]);
// System info for connection logs
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
@@ -1578,6 +1727,12 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateSessionStatus, updateHostLastConnected]);
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
updateHosts(hosts.map((h) => (
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
)));
}, [hosts, updateHosts]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -1604,15 +1759,10 @@ function App({ settings }: { settings: SettingsState }) {
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
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 })));
// 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) => {
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];
const matchingLog = selectConnectionLogForTerminalDataCapture(
connectionLogs,
{ sessionId, hostname: session?.hostname },
);
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
@@ -1703,6 +1853,7 @@ function App({ settings }: { settings: SettingsState }) {
if (!opened) toast.error(t('toast.settingsUnavailable'), t('common.settings'));
})();
}, [openSettingsWindow, t]);
handleOpenSettingsRef.current = handleOpenSettings;
const hasShownCredentialProtectionWarningRef = useRef(false);
@@ -1869,11 +2020,11 @@ function App({ settings }: { settings: SettingsState }) {
snippets={snippets}
snippetPackages={snippetPackages}
customGroups={customGroups}
knownHosts={knownHosts}
knownHosts={effectiveKnownHosts}
shellHistory={shellHistory}
connectionLogs={connectionLogs}
managedSources={managedSources}
sessions={sessions}
sessionCount={sessions.length}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
terminalThemeId={terminalThemeId}
@@ -1888,6 +2039,7 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateGroupConfigs={updateGroupConfigs}
onUpdateHosts={updateHosts}
onUpdateKeys={updateKeys}
onImportOrReuseKey={importOrReuseKey}
onUpdateIdentities={updateIdentities}
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
@@ -1908,6 +2060,7 @@ function App({ settings }: { settings: SettingsState }) {
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
navigateToSection={navigateToSection}
onNavigateToSectionHandled={() => setNavigateToSection(null)}
terminalSettings={terminalSettings}
/>
</VaultViewContainer>
@@ -1927,6 +2080,7 @@ function App({ settings }: { settings: SettingsState }) {
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
terminalSettings={terminalSettings}
/>
<TerminalLayerMount
@@ -1939,7 +2093,7 @@ function App({ settings }: { settings: SettingsState }) {
snippetPackages={snippetPackages}
sessions={sessions}
workspaces={workspaces}
knownHosts={knownHosts}
knownHosts={effectiveKnownHosts}
draggingSessionId={draggingSessionId}
terminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
@@ -1958,8 +2112,8 @@ function App({ settings }: { settings: SettingsState }) {
onCloseSession={closeSession}
onUpdateSessionStatus={handleSessionStatusChange}
onUpdateHostDistro={updateHostDistro}
onUpdateHost={(host) => updateHosts(hosts.map(h => h.id === host.id ? host : h))}
onAddKnownHost={(kh) => updateKnownHosts([...knownHosts, kh])}
onUpdateHost={handleUpdateHostFromTerminal}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
}}
@@ -1973,6 +2127,7 @@ function App({ settings }: { settings: SettingsState }) {
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onSplitSession={splitSessionWithCurrentShell}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
@@ -1988,9 +2143,8 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
closeSidePanelRef={closeSidePanelRef}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
activeSidePanelTabRef={activeSidePanelTabRef}
toggleSidePanelRef={toggleSidePanelRef}
/>
{/* Log Views - readonly terminal replays */}
@@ -2296,7 +2450,9 @@ function AppWithProviders() {
return (
<I18nProvider locale={settings.uiLanguage}>
<ToastProvider>
<App settings={settings} />
<TooltipProvider delayDuration={300}>
<App settings={settings} />
</TooltipProvider>
</ToastProvider>
</I18nProvider>
);

View File

@@ -0,0 +1,93 @@
import type { SSHKey } from "../domain/models";
import { isEncryptedCredentialPlaceholder } from "../domain/credentials";
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../infrastructure/persistence/localStorageAdapter";
import { encryptField, decryptField } from "../infrastructure/persistence/secureFieldAdapter";
export async function saveDefaultKeyPassphrase(keyPath: string, passphrase: string): Promise<void> {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? {};
store[keyPath] = await encryptField(passphrase) ?? passphrase;
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
}
export async function loadDefaultKeyPassphrase(keyPath: string): Promise<string | null> {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
const enc = store?.[keyPath];
if (!enc) return null;
const decrypted = await decryptField(enc);
if (!decrypted || isEncryptedCredentialPlaceholder(decrypted)) {
removeDefaultKeyPassphrases([keyPath]);
return null;
}
return decrypted;
}
export function removeDefaultKeyPassphrases(keyPaths: string[]): void {
const store = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES);
if (!store) return;
let changed = false;
for (const keyPath of keyPaths) {
if (keyPath in store) {
delete store[keyPath];
changed = true;
}
}
if (changed) {
localStorageAdapter.write(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES, store);
}
}
export function clearReferenceKeyPassphrases(keys: SSHKey[], keyPaths: string[]): SSHKey[] {
let changed = false;
const updated = keys.map((key) => {
if (key.source === "reference" && key.filePath && keyPaths.includes(key.filePath) && key.passphrase) {
changed = true;
return { ...key, passphrase: undefined, savePassphrase: false };
}
return key;
});
return changed ? updated : keys;
}
export function clearKeyPassphrasesByIds(keys: SSHKey[], keyIds: string[] = []): SSHKey[] {
if (keyIds.length === 0) return keys;
const ids = new Set(keyIds);
let changed = false;
const updated = keys.map((key) => {
if (ids.has(key.id) && key.passphrase) {
changed = true;
return { ...key, passphrase: undefined, savePassphrase: false };
}
return key;
});
return changed ? updated : keys;
}
export function shouldUpdateReferenceKeyPassphrase(key?: SSHKey | null): boolean {
return Boolean(
key &&
(!key.passphrase || isEncryptedCredentialPlaceholder(key.passphrase)),
);
}
export async function rememberKeyPassphrase(args: {
keyPath: string;
passphrase: string;
keys: SSHKey[];
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
setCurrentKeys?: (keys: SSHKey[]) => void;
}): Promise<void> {
const { keyPath, passphrase, keys, updateKeys, setCurrentKeys } = args;
await saveDefaultKeyPassphrase(keyPath, passphrase);
const refKey = keys.find((key) => key.source === "reference" && key.filePath === keyPath);
if (!refKey) return;
const updated = keys.map((key) =>
key.id === refKey.id
? { ...key, passphrase, savePassphrase: true }
: key
);
setCurrentKeys?.(updated);
await updateKeys(updated);
}

View File

@@ -264,6 +264,10 @@ const en: Messages = {
'settings.terminal.theme.selectButton': 'Select Theme',
'settings.terminal.theme.followApp': 'Follow Application Theme',
'settings.terminal.theme.followApp.desc': 'Automatically match the terminal background to the current app theme for a seamless look.',
'settings.terminal.theme.darkTheme': 'Dark mode terminal theme',
'settings.terminal.theme.lightTheme': 'Light mode terminal theme',
'settings.terminal.theme.auto': 'Auto (match app theme)',
'settings.terminal.theme.autoDesc': 'Follows the active UI theme preset',
'settings.terminal.section.font': 'Font',
'settings.terminal.section.cursor': 'Cursor',
'settings.terminal.section.keyboard': 'Keyboard',
@@ -273,6 +277,17 @@ const en: Messages = {
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
'settings.terminal.font.family': 'Font',
'settings.terminal.font.family.desc': 'Terminal font family',
'settings.terminal.font.cjk': 'CJK font',
'settings.terminal.font.cjk.desc': 'Font used for Chinese / Japanese / Korean characters; "Auto" picks one based on the primary font',
'settings.terminal.font.cjk.option.auto': 'Auto · paired with the primary font',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (Iosevka + Source Han SC)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (Iosevka + Source Han TC)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono',
'settings.terminal.font.cjk.option.simSun': 'SimSun',
'settings.terminal.font.cjk.option.legacy': '{font} · not recommended (proportional font)',
'settings.terminal.font.size': 'Font size',
'settings.terminal.font.size.desc': 'Terminal text size',
'settings.terminal.font.weight': 'Font weight',
@@ -290,6 +305,9 @@ const en: Messages = {
'settings.terminal.keyboard.altAsMeta': 'Use Option as Meta key',
'settings.terminal.keyboard.altAsMeta.desc':
'Use Option (Alt) as the Meta key instead of for special characters',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ jumps by word',
'settings.terminal.keyboard.optionArrowWordJump.desc':
'Send Meta-b / Meta-f on Option+Left/Right so the shell moves by word, instead of the default ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': 'Minimum contrast ratio',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
@@ -312,6 +330,9 @@ const en: Messages = {
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
'settings.terminal.behavior.forcePromptNewLine': 'Prompt on a new line',
'settings.terminal.behavior.forcePromptNewLine.desc':
'When the final line of command output is not terminated by a newline, move the recognized shell prompt to the next visual line.',
'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.',
@@ -345,14 +366,21 @@ const en: Messages = {
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': 'Limit number of terminal rows. Set to 0 for no limit.',
'settings.terminal.scrollback.rows': 'Number of rows *',
'settings.terminal.section.startupCommand': 'Startup command',
'settings.terminal.startupCommandDelay.label': 'Startup command delay (ms)',
'settings.terminal.startupCommandDelay.desc': 'How long to wait after connecting before sending the startup command. Also used between lines when the startup command has multiple lines. Increase for slow connections.',
'settings.terminal.keywordHighlight.title': 'Keyword highlighting',
'settings.terminal.keywordHighlight.resetColors': 'Reset to default colors',
'settings.terminal.keywordHighlight.resetDefaults': 'Reset built-ins to defaults',
'settings.terminal.keywordHighlight.resetBuiltIn': 'Restore default label and patterns',
'settings.terminal.keywordHighlight.addCustom': 'Add Custom Rule',
'settings.terminal.keywordHighlight.editCustom': 'Edit Rule',
'settings.terminal.keywordHighlight.editBuiltIn': 'Edit Built-in Rule',
'settings.terminal.keywordHighlight.labelField': 'Label & Color',
'settings.terminal.keywordHighlight.labelPlaceholder': 'Label (e.g., Down)',
'settings.terminal.keywordHighlight.patternField': 'Regex Pattern',
'settings.terminal.keywordHighlight.patternPlaceholder': 'Regex (e.g., \\bdown\\b)',
'settings.terminal.keywordHighlight.patternField': 'Regex Patterns',
'settings.terminal.keywordHighlight.patternPlaceholder': 'One regex per line (e.g., \\bdown\\b)',
'settings.terminal.keywordHighlight.patternHint': 'One regex per line. Patterns are matched case-insensitively with the global flag.',
'settings.terminal.keywordHighlight.invalidPattern': 'Invalid regex pattern',
'settings.terminal.keywordHighlight.preview': 'Preview',
'settings.terminal.section.localShell': 'Local Shell',
@@ -374,7 +402,9 @@ const en: Messages = {
'settings.terminal.localShell.startDir.isFile': 'Path is a file, not a directory',
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets. Set to 0 to disable globally — note that individual hosts can override this in their own settings.',
'settings.terminal.connection.keepaliveCountMax': 'Max unanswered keepalives',
'settings.terminal.connection.keepaliveCountMax.desc': 'Unanswered keepalives before the connection is declared dead. Higher values are more forgiving of brief network glitches and SSH servers that respond slowly.',
'settings.terminal.connection.x11Display': 'X11 display',
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
@@ -774,6 +804,10 @@ const en: Messages = {
'sftp.context.permissions': 'Permissions',
'sftp.context.delete': 'Delete',
'sftp.context.refresh': 'Refresh',
'sftp.context.uploadFiles': 'Upload File(s)...',
'sftp.context.uploadFilesHere': 'Upload File(s) Here...',
'sftp.context.uploadFolder': 'Upload Folder...',
'sftp.context.uploadFolderHere': 'Upload Folder Here...',
'sftp.context.downloadSelected': 'Download selected ({count})',
'sftp.context.deleteSelected': 'Delete selected ({count})',
'sftp.dropFilesHere': 'Drop files here',
@@ -798,6 +832,11 @@ const en: Messages = {
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.retryAction': 'Retry',
'sftp.transfers.dismissAction': 'Dismiss',
'sftp.transfers.openTargetFolder': 'Open target folder',
'sftp.transfers.openTargetFolderError': 'Could not open target folder',
'sftp.transfers.copyTargetPath': 'Copy target path',
'sftp.transfers.copyTargetPathSuccess': 'Target path copied',
'sftp.transfers.copyTargetPathError': 'Could not copy target path',
'sftp.transfers.resizeNameColumn': 'Resize file name column',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
@@ -1115,6 +1154,12 @@ const en: Messages = {
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Override global keepalive',
'hostDetails.keepalive.desc': 'Use a custom keepalive policy for this host instead of the global setting. Useful for older routers or switches whose SSH server does not reply to keepalive@openssh.com requests — set interval to 0 to disable keepalive entirely on this host.',
'hostDetails.keepalive.interval': 'Interval (seconds)',
'hostDetails.keepalive.countMax': 'Max unanswered keepalives',
'hostDetails.keepalive.disabledHint': 'Interval = 0 disables keepalive for this host. The session will rely on TCP-level timeouts to detect a dead connection.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
@@ -1259,6 +1304,10 @@ const en: Messages = {
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
'terminal.statusbar.copyHostname.label': 'Copy host address',
'terminal.statusbar.copyHostname.tooltip': 'Copy host address ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Copied host address: {hostname}',
'terminal.statusbar.copyHostname.error': 'Failed to copy host address to clipboard',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',
@@ -1292,6 +1341,7 @@ const en: Messages = {
'terminal.menu.paste': 'Paste',
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
@@ -1325,6 +1375,16 @@ const en: Messages = {
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Local Shell',
'terminal.hostKey.unknownTitle': 'Confirm this host key',
'terminal.hostKey.changedTitle': 'Host key changed',
'terminal.hostKey.unknownDescription': 'The authenticity of {host} cannot be established yet.',
'terminal.hostKey.changedDescription': 'The saved key for {host} no longer matches this server.',
'terminal.hostKey.fingerprintLabel': '{keyType} fingerprint is SHA256:',
'terminal.hostKey.savedFingerprintLabel': 'Saved fingerprint',
'terminal.hostKey.unknownHint': 'Remember it if this fingerprint belongs to the server you expected.',
'terminal.hostKey.changedHint': 'Only continue if you expected this host to change.',
'terminal.hostKey.addAndContinue': 'Add and continue',
'terminal.hostKey.updateAndContinue': 'Update and continue',
'terminal.themeModal.title': 'Terminal Appearance',
'terminal.themeModal.tab.theme': 'Theme',
'terminal.themeModal.tab.font': 'Font',
@@ -1687,6 +1747,7 @@ const en: Messages = {
'keychain.edit.publicKey': 'Public key',
'keychain.edit.certificate': 'Certificate',
'keychain.edit.certificatePlaceholder': 'Certificate content (optional)',
'keychain.edit.filePath': 'File path',
'keychain.edit.keyExport': 'Key export',
'keychain.edit.exportToHost': 'Export to host',
@@ -1814,6 +1875,7 @@ const en: Messages = {
'passphrase.unlock': 'Unlock',
'passphrase.unlocking': 'Unlocking...',
'passphrase.skip': 'Skip',
'passphrase.remember': 'Remember this passphrase',
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
@@ -1838,6 +1900,18 @@ const en: Messages = {
'ai.providers.remove': 'Remove',
'ai.providers.name': 'Display Name',
'ai.providers.name.placeholder': 'e.g. My Provider',
'ai.providers.style': 'Protocol style',
'ai.providers.style.anthropic': 'Anthropic-compatible',
'ai.providers.style.openai': 'OpenAI-compatible',
'ai.providers.style.google': 'Google-compatible',
'ai.providers.style.inherited': 'auto',
'ai.providers.style.help': 'Selects which API format requests use. Override when a third-party endpoint speaks a different dialect than its provider type suggests.',
'ai.providers.icon.change': 'Change icon',
'ai.providers.icon.upload': 'Upload image',
'ai.providers.icon.reset': 'Reset',
'ai.providers.icon.close': 'Close',
'ai.providers.icon.uploadedNote': 'Custom icon (64×64 WebP)',
'ai.providers.icon.errorType': 'Please choose an image file.',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
@@ -1883,13 +1957,20 @@ const en: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
'ai.claude.description': "Anthropic's agentic coding assistant. Requires the system Claude Code CLI.",
'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.configSection': 'Authentication & config (optional)',
'ai.claude.configDir': 'Config directory',
'ai.claude.configDir.placeholder': '~/.claude (leave blank for default)',
'ai.claude.configDir.hint': 'Sets CLAUDE_CONFIG_DIR — point at a folder where you have run `claude` login (contains settings.json + credentials).',
'ai.claude.envVars': 'Environment variables',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'One KEY=VALUE per line, passed to the Claude agent. Stored locally in plaintext — for API keys / credentials, prefer the config directory above (a `claude` login).',
'ai.claude.check': 'Check',
// AI GitHub Copilot CLI
@@ -1960,6 +2041,8 @@ const en: Messages = {
'ai.chat.placeholder': 'Message {agent} — @ to include context, / for commands',
'ai.chat.placeholderDefault': 'Message Catty Agent...',
'ai.chat.noModel': 'No model',
'ai.chat.noProviderModel': 'No default model — set one in Settings → AI → Providers.',
'ai.chat.selectProvider': 'Select provider',
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
@@ -2015,6 +2098,37 @@ const en: Messages = {
'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern',
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Add Terminal',
'terminal.layer.switchToSplitView': 'Switch to Split View',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
'terminal.layer.theme': 'Theme',
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',
'topTabs.toggleTheme': 'Toggle theme',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',
'ai.chat.attach': 'Attach',
'ai.chat.collapse': 'Collapse',
'ai.chat.expand': 'Expand',
'ai.chat.enableAgent': 'Enable {name}',
'zmodem.waitingForRemote': 'Waiting for remote...',
'zmodem.uploading': 'Uploading',
'zmodem.downloading': 'Downloading',
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
'zmodem.overwrite.title': 'Remote file already exists',
'zmodem.overwrite.applyToRest': 'Apply to remaining conflicts',
'zmodem.overwrite.overwrite': 'Overwrite',
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default',
};
export default en;

File diff suppressed because it is too large Load Diff

View File

@@ -558,6 +558,10 @@ const zhCN: Messages = {
'sftp.context.permissions': '权限',
'sftp.context.delete': '删除',
'sftp.context.refresh': '刷新',
'sftp.context.uploadFiles': '上传文件...',
'sftp.context.uploadFilesHere': '上传文件到这里...',
'sftp.context.uploadFolder': '上传文件夹...',
'sftp.context.uploadFolderHere': '上传文件夹到这里...',
'sftp.context.downloadSelected': '下载选中项({count}',
'sftp.context.deleteSelected': '删除选中项({count}',
'sftp.dropFilesHere': '拖拽文件到这里',
@@ -582,6 +586,11 @@ const zhCN: Messages = {
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.retryAction': '重试',
'sftp.transfers.dismissAction': '移除',
'sftp.transfers.openTargetFolder': '打开目标目录',
'sftp.transfers.openTargetFolderError': '无法打开目标目录',
'sftp.transfers.copyTargetPath': '复制目标路径',
'sftp.transfers.copyTargetPathSuccess': '已复制目标路径',
'sftp.transfers.copyTargetPathError': '无法复制目标路径',
'sftp.transfers.resizeNameColumn': '调整文件名列宽',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
@@ -744,6 +753,12 @@ const zhCN: Messages = {
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.section.keepalive': '会话保活',
'hostDetails.keepalive.override': '为此主机单独配置',
'hostDetails.keepalive.desc': '为该主机使用专属的保活策略,而不是跟随全局设置。适用于不响应 keepalive@openssh.com 请求的老旧路由器 / 交换机——将间隔设为 0 可对该主机彻底关闭保活。',
'hostDetails.keepalive.interval': '间隔(秒)',
'hostDetails.keepalive.countMax': '最大无响应保活次数',
'hostDetails.keepalive.disabledHint': '间隔为 0 时该主机不发送保活包,仅依赖 TCP 层超时检测断连。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
@@ -853,6 +868,10 @@ const zhCN: Messages = {
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
'terminal.statusbar.copyHostname.label': '复制主机地址',
'terminal.statusbar.copyHostname.tooltip': '复制主机地址({hostname}',
'terminal.statusbar.copyHostname.toast': '已复制主机地址:{hostname}',
'terminal.statusbar.copyHostname.error': '复制主机地址失败',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
@@ -886,6 +905,7 @@ const zhCN: Messages = {
'terminal.menu.paste': '粘贴',
'terminal.menu.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
@@ -920,6 +940,16 @@ const zhCN: Messages = {
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': '串口',
'terminal.connection.protocol.local': '本地终端',
'terminal.hostKey.unknownTitle': '确认主机指纹',
'terminal.hostKey.changedTitle': '主机指纹已变化',
'terminal.hostKey.unknownDescription': '尚未确认 {host} 的真实性。',
'terminal.hostKey.changedDescription': '{host} 的已保存指纹与当前服务器不一致。',
'terminal.hostKey.fingerprintLabel': '{keyType} 指纹为 SHA256',
'terminal.hostKey.savedFingerprintLabel': '已保存的指纹',
'terminal.hostKey.unknownHint': '如果这个指纹属于你预期连接的服务器,可以记住它。',
'terminal.hostKey.changedHint': '只有在你确认这台主机确实变更过时才继续。',
'terminal.hostKey.addAndContinue': '记住并继续',
'terminal.hostKey.updateAndContinue': '更新并继续',
'terminal.themeModal.title': 'Terminal 外观',
'terminal.themeModal.tab.theme': '主题',
'terminal.themeModal.tab.font': '字体',
@@ -1379,6 +1409,10 @@ const zhCN: Messages = {
'settings.terminal.theme.selectButton': '选择主题',
'settings.terminal.theme.followApp': '跟随应用主题',
'settings.terminal.theme.followApp.desc': '终端背景色自动匹配当前应用主题,保持视觉一致性。',
'settings.terminal.theme.darkTheme': '深色模式终端主题',
'settings.terminal.theme.lightTheme': '浅色模式终端主题',
'settings.terminal.theme.auto': '自动(跟随界面主题)',
'settings.terminal.theme.autoDesc': '跟随当前界面主题预设',
'settings.terminal.section.font': '字体',
'settings.terminal.section.cursor': '光标',
'settings.terminal.section.keyboard': '键盘',
@@ -1388,6 +1422,17 @@ const zhCN: Messages = {
'settings.terminal.section.keywordHighlight': '关键字高亮',
'settings.terminal.font.family': '字体',
'settings.terminal.font.family.desc': '终端字体',
'settings.terminal.font.cjk': '中文 / CJK 字体',
'settings.terminal.font.cjk.desc': '用于渲染中 / 日 / 韩字符的字体;"Auto" 会按主字体智能搭配',
'settings.terminal.font.cjk.option.auto': 'Auto · 按主字体智能搭配',
'settings.terminal.font.cjk.option.sarasaSC': 'Sarasa Mono SC (更纱黑体 简)',
'settings.terminal.font.cjk.option.sarasaTC': 'Sarasa Mono TC (更纱黑体 繁)',
'settings.terminal.font.cjk.option.mapleCN': 'Maple Mono CN',
'settings.terminal.font.cjk.option.sourceHan': 'Source Han Mono SC (思源等宽)',
'settings.terminal.font.cjk.option.notoCJK': 'Noto Sans Mono CJK SC',
'settings.terminal.font.cjk.option.lxgwWenkai': 'LXGW WenKai Mono (霞鹜文楷等宽)',
'settings.terminal.font.cjk.option.simSun': 'SimSun (宋体)',
'settings.terminal.font.cjk.option.legacy': '{font} · 不推荐(非等宽字体)',
'settings.terminal.font.size': '字体大小',
'settings.terminal.font.size.desc': '终端文字大小',
'settings.terminal.font.weight': '字重',
@@ -1404,6 +1449,8 @@ const zhCN: Messages = {
'settings.terminal.cursor.blink': '光标闪烁',
'settings.terminal.keyboard.altAsMeta': '将 Option 作为 Meta 键',
'settings.terminal.keyboard.altAsMeta.desc': '使用 Option (Alt) 作为 Meta 键,而不是用于输入特殊字符',
'settings.terminal.keyboard.optionArrowWordJump': 'Option+←/→ 按单词跳转',
'settings.terminal.keyboard.optionArrowWordJump.desc': '按 Option+左/右 时发送 Meta-b / Meta-f让 Shell 按单词移动光标(而非默认的 ^[[1;3D / ^[[1;3C',
'settings.terminal.accessibility.minimumContrastRatio': '最小对比度',
'settings.terminal.accessibility.minimumContrastRatio.desc': '调整颜色以满足对比度要求 (1 = 禁用, 21 = 最大)',
'settings.terminal.behavior.rightClick': '右键行为',
@@ -1424,6 +1471,9 @@ const zhCN: Messages = {
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
'settings.terminal.behavior.forcePromptNewLine': '提示符另起一行',
'settings.terminal.behavior.forcePromptNewLine.desc':
'当命令输出的最后一行未以换行符结束时,将识别到的 shell 提示符移动到下一行显示。',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
@@ -1453,14 +1503,21 @@ const zhCN: Messages = {
'settings.terminal.behavior.linkModifier.meta': 'Cmd / Win',
'settings.terminal.scrollback.desc': '限制终端行数。设为 0 表示不限制。',
'settings.terminal.scrollback.rows': '行数 *',
'settings.terminal.section.startupCommand': '启动命令',
'settings.terminal.startupCommandDelay.label': '启动命令延迟(毫秒)',
'settings.terminal.startupCommandDelay.desc': '连接建立后等待多久再发送启动命令;启动命令为多行时,行与行之间也使用该间隔。慢连接可调大。',
'settings.terminal.keywordHighlight.title': '关键字高亮',
'settings.terminal.keywordHighlight.resetColors': '重置为默认颜色',
'settings.terminal.keywordHighlight.resetDefaults': '把内置规则恢复为默认',
'settings.terminal.keywordHighlight.resetBuiltIn': '恢复内置标签与正则',
'settings.terminal.keywordHighlight.addCustom': '添加自定义规则',
'settings.terminal.keywordHighlight.editCustom': '编辑规则',
'settings.terminal.keywordHighlight.editBuiltIn': '编辑内置规则',
'settings.terminal.keywordHighlight.labelField': '标签与颜色',
'settings.terminal.keywordHighlight.labelPlaceholder': '标签(如 Down',
'settings.terminal.keywordHighlight.patternField': '正则表达式',
'settings.terminal.keywordHighlight.patternPlaceholder': '正则表达式(如 \\bdown\\b',
'settings.terminal.keywordHighlight.patternPlaceholder': '每行一个正则(如 \\bdown\\b',
'settings.terminal.keywordHighlight.patternHint': '每行一个正则。匹配忽略大小写,全局匹配。',
'settings.terminal.keywordHighlight.invalidPattern': '无效的正则表达式',
'settings.terminal.keywordHighlight.preview': '预览',
'settings.terminal.section.localShell': '本地 Shell',
@@ -1482,7 +1539,9 @@ const zhCN: Messages = {
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
'settings.terminal.connection.keepaliveCountMax.desc': '判定连接死亡前允许的无响应保活次数。值越大对短暂网络抖动和响应慢的 SSH 服务越宽容。',
'settings.terminal.connection.x11Display': 'X11 显示地址',
'settings.terminal.connection.x11Display.desc': '可选的本机 X11 显示地址。留空则使用系统默认值。',
'settings.terminal.connection.x11Display.placeholder': '自动(:0 或 DISPLAY',
@@ -1547,6 +1606,7 @@ const zhCN: Messages = {
'settings.shortcuts.binding.new-workspace': '新建工作区',
'settings.shortcuts.binding.snippets': '打开代码片段',
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.toggle-side-panel': '切换侧边栏',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
@@ -1696,6 +1756,7 @@ const zhCN: Messages = {
'keychain.edit.publicKey': '公钥',
'keychain.edit.certificate': '证书',
'keychain.edit.certificatePlaceholder': '证书内容(可选)',
'keychain.edit.filePath': '文件路径',
'keychain.edit.keyExport': '密钥导出',
'keychain.edit.exportToHost': '导出到主机',
@@ -1823,6 +1884,7 @@ const zhCN: Messages = {
'passphrase.unlock': '解锁',
'passphrase.unlocking': '解锁中...',
'passphrase.skip': '跳过',
'passphrase.remember': '记住此密码',
// Text Editor
'sftp.editor.wordWrap': '自动换行',
@@ -1847,6 +1909,18 @@ const zhCN: Messages = {
'ai.providers.remove': '移除',
'ai.providers.name': '显示名称',
'ai.providers.name.placeholder': '例如 我的提供商',
'ai.providers.style': '协议风格',
'ai.providers.style.anthropic': 'Anthropic 兼容',
'ai.providers.style.openai': 'OpenAI 兼容',
'ai.providers.style.google': 'Google 兼容',
'ai.providers.style.inherited': '默认',
'ai.providers.style.help': '决定请求使用哪种 API 格式。当第三方端点的协议与其提供商类型不一致时,可手动覆盖。',
'ai.providers.icon.change': '修改图标',
'ai.providers.icon.upload': '上传图片',
'ai.providers.icon.reset': '恢复默认',
'ai.providers.icon.close': '收起',
'ai.providers.icon.uploadedNote': '自定义图标64×64 WebP',
'ai.providers.icon.errorType': '请选择图片文件。',
'ai.providers.apiKey': 'API Key',
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
@@ -1892,13 +1966,20 @@ const zhCN: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
'ai.claude.description': 'Anthropic 的智能编程助手。需要系统中已安装 Claude Code CLI。',
'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.configSection': '认证与配置(可选)',
'ai.claude.configDir': '配置目录',
'ai.claude.configDir.placeholder': '~/.claude留空用默认',
'ai.claude.configDir.hint': '设置 CLAUDE_CONFIG_DIR —— 指向你已运行 `claude` 登录的目录(含 settings.json 和凭据)。',
'ai.claude.envVars': '环境变量',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': '每行一个 KEY=VALUE传给 Claude agent。明文存在本地——API key凭据建议用上面的「配置目录」claude 登录),不要放这里。',
'ai.claude.check': '检查',
// AI GitHub Copilot CLI
@@ -1969,6 +2050,8 @@ const zhCN: Messages = {
'ai.chat.placeholder': '向 {agent} 发送消息 — @ 引用上下文,/ 使用命令',
'ai.chat.placeholderDefault': '向 Catty Agent 发送消息...',
'ai.chat.noModel': '未选择模型',
'ai.chat.noProviderModel': '未配置默认模型——前往 设置 → AI → 提供商 设置。',
'ai.chat.selectProvider': '选择提供商',
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题',
@@ -2024,6 +2107,37 @@ const zhCN: Messages = {
'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则',
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行ACP Agent 可能有自己的内部控制。',
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '添加终端',
'terminal.layer.switchToSplitView': '切换到分屏视图',
'terminal.layer.sftp': '文件传输',
'terminal.layer.scripts': '脚本',
'terminal.layer.theme': '主题',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.toggleTheme': '切换主题',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',
'ai.chat.attach': '附件',
'ai.chat.collapse': '收起',
'ai.chat.expand': '展开',
'ai.chat.enableAgent': '启用 {name}',
'zmodem.waitingForRemote': '等待远端...',
'zmodem.uploading': '上传中',
'zmodem.downloading': '下载中',
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
'zmodem.overwrite.title': '远端已存在同名文件',
'zmodem.overwrite.applyToRest': '应用到其余冲突文件',
'zmodem.overwrite.overwrite': '覆盖',
'zmodem.overwrite.skip': '跳过',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置为默认',
};
export default zhCN;

View File

@@ -1,11 +1,13 @@
import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import ru from './locales/ru';
// Keep keys stable; add new locales by adding another import and map entry.
export { type Messages };
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
'zh-CN': zhCN,
};

View File

@@ -1,4 +1,4 @@
import { useCallback,useSyncExternalStore } from 'react';
import { useCallback, useSyncExternalStore } from 'react';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
@@ -92,7 +92,11 @@ export const useIsEditorTabActive = (tabId: string): boolean => {
// Check if terminal layer should be visible
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
const activeTabId = useActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
const getSnapshot = useCallback(() => {
const activeTabId = activeTabStore.getActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
}, [draggingSessionId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
};

View File

@@ -0,0 +1,20 @@
/**
* Same-window AI-state-changed event plumbing.
*
* `localStorage` writes only emit `storage` events in *other* windows; the
* window doing the write never gets notified. That's a problem for code
* that mutates AI storage outside of `useAIState`'s setters (e.g. sync
* apply): without a manual nudge, mounted components keep showing stale
* AI state until reload.
*
* Both the dispatcher and `useAIState`'s listener live here so non-React
* call sites (sync, IPC handlers, etc.) can fire the event without
* pulling in the hook.
*/
export const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
export function emitAIStateChanged(key: string): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
}

View File

@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
test("runtime remote checks wait for the startup check to finish", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: false,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
false,
);
});
test("runtime remote checks run immediately after startup gate opens", () => {
assert.equal(
shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
now: 10_000,
lastRemoteCheckAt: null,
minIntervalMs: 30_000,
}),
true,
);
});
test("runtime remote checks respect the minimum interval", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 40_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
});
test("forced runtime remote checks bypass only the interval gate", () => {
const common = {
hasAnyConnectedProvider: true,
autoSyncEnabled: true,
isUnlocked: true,
startupRemoteCheckDone: true,
isSyncing: false,
isSyncRunning: false,
remoteCheckInFlight: false,
minIntervalMs: 30_000,
force: true,
};
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
true,
);
assert.equal(
shouldRunRuntimeRemoteCheck({
...common,
isSyncing: true,
now: 35_000,
lastRemoteCheckAt: 10_000,
}),
false,
);
});
test("configured auto-sync intervals map to bounded remote recheck intervals", () => {
assert.equal(getRuntimeRemoteCheckIntervalMs(1), 30_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(10), 300_000);
assert.equal(getRuntimeRemoteCheckIntervalMs(120), 300_000);
});

View File

@@ -0,0 +1,35 @@
const MIN_RUNTIME_REMOTE_CHECK_MS = 30_000;
const MAX_RUNTIME_REMOTE_CHECK_MS = 5 * 60_000;
export function getRuntimeRemoteCheckIntervalMs(autoSyncIntervalMinutes: number): number {
const configuredMs = Math.max(1, Number(autoSyncIntervalMinutes) || 1) * 60_000;
return Math.max(
MIN_RUNTIME_REMOTE_CHECK_MS,
Math.min(MAX_RUNTIME_REMOTE_CHECK_MS, Math.floor(configuredMs / 2)),
);
}
export interface RuntimeRemoteCheckInput {
hasAnyConnectedProvider: boolean;
autoSyncEnabled: boolean;
isUnlocked: boolean;
startupRemoteCheckDone: boolean;
isSyncing: boolean;
isSyncRunning: boolean;
remoteCheckInFlight: boolean;
force?: boolean;
now: number;
lastRemoteCheckAt: number | null;
minIntervalMs: number;
}
export function shouldRunRuntimeRemoteCheck(input: RuntimeRemoteCheckInput): boolean {
if (!input.hasAnyConnectedProvider) return false;
if (!input.autoSyncEnabled) return false;
if (!input.isUnlocked) return false;
if (!input.startupRemoteCheckDone) return false;
if (input.isSyncing || input.isSyncRunning || input.remoteCheckInFlight) return false;
if (input.force === true) return true;
if (input.lastRemoteCheckAt == null) return true;
return input.now - input.lastRemoteCheckAt >= input.minIntervalMs;
}

View File

@@ -0,0 +1,194 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
clearKeyPassphrasesByIds,
clearReferenceKeyPassphrases,
loadDefaultKeyPassphrase,
rememberKeyPassphrase,
shouldUpdateReferenceKeyPassphrase,
} from "../defaultKeyPassphrases";
import { STORAGE_KEY_DEFAULT_KEY_PASSPHRASES } from "../../infrastructure/config/storageKeys";
import type { SSHKey } from "../../domain/models";
function installLocalStorage(t: test.TestContext): void {
const store = new Map<string, string>();
const storage: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, value);
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: undefined },
});
t.after(() => {
Reflect.deleteProperty(globalThis, "localStorage");
Reflect.deleteProperty(globalThis, "window");
});
}
const referenceKey = (): SSHKey => ({
id: "reference-key",
label: "id_ed25519",
type: "ED25519",
category: "key",
source: "reference",
filePath: "/Users/alice/.ssh/id_ed25519",
privateKey: "",
created: 1,
});
test("loadDefaultKeyPassphrase removes undecryptable credential placeholders", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({
[keyPath]: "enc:v1:djEwYWJj",
"/Users/alice/.ssh/id_rsa": "still-valid",
}),
);
const result = await loadDefaultKeyPassphrase(keyPath);
assert.equal(result, null);
assert.deepEqual(
JSON.parse(globalThis.localStorage.getItem(STORAGE_KEY_DEFAULT_KEY_PASSPHRASES) ?? "{}"),
{ "/Users/alice/.ssh/id_rsa": "still-valid" },
);
});
test("loadDefaultKeyPassphrase returns plain stored passphrases", async (t) => {
installLocalStorage(t);
const keyPath = "/Users/alice/.ssh/id_ed25519";
globalThis.localStorage.setItem(
STORAGE_KEY_DEFAULT_KEY_PASSPHRASES,
JSON.stringify({ [keyPath]: "correct horse battery staple" }),
);
assert.equal(await loadDefaultKeyPassphrase(keyPath), "correct horse battery staple");
});
test("clearReferenceKeyPassphrases clears matching reference key paths only", () => {
const keys: SSHKey[] = [
{
...referenceKey(),
passphrase: "bad",
savePassphrase: true,
},
{
...referenceKey(),
id: "other-key",
label: "other",
filePath: "/Users/alice/.ssh/other",
passphrase: "keep",
savePassphrase: true,
},
];
const updated = clearReferenceKeyPassphrases(keys, ["/Users/alice/.ssh/id_ed25519"]);
assert.equal(updated[0].passphrase, undefined);
assert.equal(updated[0].savePassphrase, false);
assert.equal(updated[1].passphrase, "keep");
});
test("clearKeyPassphrasesByIds clears matching saved key passphrases", () => {
const keys: SSHKey[] = [
{
...referenceKey(),
id: "inline-key",
source: "imported",
filePath: undefined,
privateKey: "PRIVATE KEY",
passphrase: "bad",
savePassphrase: true,
},
{
...referenceKey(),
id: "other-key",
label: "other",
passphrase: "keep",
savePassphrase: true,
},
];
const updated = clearKeyPassphrasesByIds(keys, ["inline-key"]);
assert.equal(updated[0].passphrase, undefined);
assert.equal(updated[0].savePassphrase, false);
assert.equal(updated[1].passphrase, "keep");
});
test("shouldUpdateReferenceKeyPassphrase replaces missing or undecryptable passphrases", () => {
assert.equal(shouldUpdateReferenceKeyPassphrase(null), false);
assert.equal(shouldUpdateReferenceKeyPassphrase(referenceKey()), true);
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "enc:v1:djEwAAAA",
}),
true,
);
assert.equal(
shouldUpdateReferenceKeyPassphrase({
...referenceKey(),
passphrase: "saved",
}),
false,
);
});
test("rememberKeyPassphrase updates reference key state before completing", async (t) => {
installLocalStorage(t);
const keys = [referenceKey()];
let currentKeys = keys;
let releaseUpdate: (() => void) | undefined;
let rememberPromise: Promise<void> | undefined;
const updateStarted = new Promise<void>((resolve) => {
const updateKeys = async (updated: SSHKey[]) => {
assert.equal(currentKeys[0].passphrase, "saved");
assert.equal(updated[0].passphrase, "saved");
resolve();
await new Promise<void>((release) => {
releaseUpdate = release;
});
};
rememberPromise = rememberKeyPassphrase({
keyPath: "/Users/alice/.ssh/id_ed25519",
passphrase: "saved",
keys,
updateKeys,
setCurrentKeys: (updated) => {
currentKeys = updated;
},
});
});
await updateStarted;
assert.equal(currentKeys[0].passphrase, "saved");
releaseUpdate?.();
await rememberPromise;
});

View File

@@ -244,16 +244,3 @@ export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};
export const useEditorDirty = (id: EditorTabId): boolean => {
const getSnapshot = useCallback(() => editorTabStore.isDirty(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};
export const useAnyEditorDirty = (): boolean => {
const getSnapshot = useCallback(
() => editorTabStore.getTabs().some((t) => t.content !== t.baselineContent),
[],
);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
};

View File

@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react';
import { TERMINAL_FONTS, type TerminalFont } from '../../infrastructure/config/fonts';
import { getMonospaceFonts } from '../../lib/localFonts';
import { getAllSystemFontFamilies, getMonospaceFonts } from '../../lib/localFonts';
import { setSystemFamilies } from '../../lib/fontAvailability';
/**
* Global font store - singleton pattern using useSyncExternalStore
@@ -60,7 +61,14 @@ class FontStore {
this.setState({ isLoading: true, error: null });
try {
const localFonts = await getMonospaceFonts();
// Populate the authoritative installed-family set used by
// fontAvailability.isFontInstalled. Runs in parallel with the
// monospace-only query (both share an underlying cache).
const [localFonts, systemFamilies] = await Promise.all([
getMonospaceFonts(),
getAllSystemFontFamilies(),
]);
setSystemFamilies(systemFamilies);
// Combine default fonts with local fonts, deduplicate by id
const fontMap = new Map<string, TerminalFont>();

View File

@@ -3,33 +3,27 @@ import assert from "node:assert/strict";
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
const baseWorkspace = {
id: "w1",
focusedSessionId: "s1",
};
const baseWorkspace = { id: "w1", focusedSessionId: "s1" };
const baseSession = { id: "s1" };
test("non-workspace tab → closeSingleTab with session id", () => {
const result = resolveCloseIntent({
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: baseSession,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
test("non-workspace session tab → closeSingleTab even when focus is outside the terminal", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: { id: "s1" },
activeSidePanelTab: "ai",
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
assert.deepEqual(r, { kind: "closeSingleTab", sessionId: "s1" });
});
test("vault/sftp tab → noop", () => {
@@ -37,74 +31,37 @@ test("vault/sftp tab → noop", () => {
activeTabId: "vault",
workspace: null,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "noop" });
});
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
test("workspace + focus in terminal → closeTerminal (side panel no longer intercepts)", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "sftp",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
});
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
test("workspace + focus NOT in terminal → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
test("workspace with no focused session → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});

View File

@@ -1,6 +1,5 @@
export type CloseIntent =
| { kind: 'closeTerminal'; sessionId: string }
| { kind: 'closeSidePanel' }
| { kind: 'closeWorkspace'; workspaceId: string }
| { kind: 'closeSingleTab'; sessionId: string }
| { kind: 'noop' };
@@ -9,22 +8,14 @@ export interface ResolveCloseInput {
activeTabId: string | null;
workspace: { id: string; focusedSessionId?: string } | null;
sessionForTab: { id: string } | null;
activeSidePanelTab: string | null;
focusIsInsideTerminal: boolean;
}
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
const { activeTabId, workspace, sessionForTab, focusIsInsideTerminal } = input;
if (!activeTabId) return { kind: 'noop' };
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
// Modals take priority over this but are intercepted upstream in App.tsx before the
// hotkey reaches resolveCloseIntent.
if (activeSidePanelTab !== null) {
return { kind: 'closeSidePanel' };
}
if (sessionForTab && !workspace) {
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
}

View File

@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveSidePanelToggleIntent } from "./resolveSidePanelToggleIntent.ts";
test("open: closed with a remembered tab → open that tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: "sftp", fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "sftp" });
});
test("open: closed with no memory → open the fallback tab", () => {
const r = resolveSidePanelToggleIntent({ isOpen: false, lastTab: null, fallbackTab: "scripts" });
assert.deepEqual(r, { kind: "open", tab: "scripts" });
});
test("close: already open → close", () => {
const r = resolveSidePanelToggleIntent({ isOpen: true, lastTab: "theme", fallbackTab: "sftp" });
assert.deepEqual(r, { kind: "close" });
});

View File

@@ -0,0 +1,18 @@
export type SidePanelToggleIntent<T extends string> =
| { kind: 'close' }
| { kind: 'open'; tab: T };
/**
* Decide what the "toggle side panel" shortcut should do.
* - If a panel is open → close it.
* - If closed → reopen the last-shown sub-panel for the tab, falling back to
* `fallbackTab` when the tab has no remembered panel.
*/
export function resolveSidePanelToggleIntent<T extends string>(input: {
isOpen: boolean;
lastTab: T | null;
fallbackTab: T;
}): SidePanelToggleIntent<T> {
if (input.isOpen) return { kind: 'close' };
return { kind: 'open', tab: input.lastTab ?? input.fallbackTab };
}

View File

@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "closeSession" },
);
});
test("backend timeout events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "timeout", error: "idle timeout" }),
{ kind: "markDisconnected" },
);
});
test("backend error events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "error", error: "connection reset" }),
{ kind: "markDisconnected" },
);
});
test("backend closed events keep the tab and mark it disconnected", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "closed", exitCode: 0 }),
{ kind: "markDisconnected" },
);
});

View File

@@ -0,0 +1,22 @@
export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed";
};
export type TerminalSessionExitIntent =
| { kind: "closeSession" }
| { kind: "markDisconnected" };
export function resolveTerminalSessionExitIntent(
evt: TerminalSessionExitEvent,
): TerminalSessionExitIntent {
if (evt.reason === "exited") {
return { kind: "closeSession" };
}
// Timeouts, transport errors, and channel closes should keep the tab visible
// so the user can inspect output and reconnect.
return { kind: "markDisconnected" };
}

View File

@@ -0,0 +1,23 @@
import type { SftpBookmark } from "../../../domain/models";
const ROOT_PATH_RE = /^[A-Za-z]:[\\/]?$/;
export function getSftpBookmarkLabel(path: string): string {
const trimmed = path.trim();
if (trimmed === "/" || ROOT_PATH_RE.test(trimmed)) return trimmed;
return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed;
}
export function createSftpBookmark(
path: string,
options: { global?: boolean; idPrefix?: string } = {},
): SftpBookmark {
const global = options.global === true;
const idPrefix = options.idPrefix ?? (global ? "gbm" : "bm");
return {
id: `${idPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path,
label: getSftpBookmarkLabel(path),
...(global ? { global: true } : {}),
};
}

View File

@@ -0,0 +1,45 @@
import type { SftpBookmark } from "../../../domain/models";
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
export function subscribeGlobalSftpBookmarks(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getGlobalSftpBookmarksSnapshot() {
return snapshot;
}
export function rehydrateGlobalSftpBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
for (const listener of listeners) listener();
}
export function setGlobalSftpBookmarks(
next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[]),
) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
for (const listener of listeners) listener();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("sftp-bookmarks-changed"));
}
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
rehydrateGlobalSftpBookmarks();
}
});
}

View File

@@ -64,4 +64,10 @@ export interface SftpStateOptions {
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
autoConnectLocalOnMount?: boolean;
/**
* Global SSH keepalive settings, forwarded through to per-SFTP-connection
* keepalive resolution so a host that has opted into its own override
* is honored for SFTP browsing too (not just the terminal session).
*/
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}

View File

@@ -11,6 +11,7 @@ interface UseSftpConnectionsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
leftTabs: { tabs: SftpPane[] };
@@ -44,6 +45,7 @@ export const useSftpConnections = ({
hosts,
keys,
identities,
terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,
@@ -65,7 +67,7 @@ export const useSftpConnections = ({
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
@@ -281,7 +283,7 @@ export const useSftpConnections = ({
);
};
const hasKey = !!credentials.privateKey;
const hasKey = !!credentials.privateKey || !!credentials.identityFilePaths?.length;
const hasPassword = !!credentials.password;
let sftpId: string | undefined;
@@ -305,6 +307,7 @@ export const useSftpConnections = ({
publicKey: undefined,
keyId: undefined,
keySource: undefined,
identityFilePaths: undefined,
});
} else {
throw err;

View File

@@ -7,11 +7,13 @@ import { joinPath } from "./utils";
import {
UploadController,
uploadFromDataTransfer,
uploadFromFileList,
uploadEntriesDirect,
UploadBridge,
UploadCallbacks,
UploadResult,
UploadTaskInfo,
startUploadScanningTask,
} from "../../../lib/uploadService";
import type { DropEntry } from "../../../lib/sftpFileUtils";
@@ -56,6 +58,16 @@ interface SftpExternalOperationsResult {
dataTransfer: DataTransfer,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalFileList: (
side: "left" | "right",
fileList: FileList | File[],
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalFolderPath: (
side: "left" | "right",
folderPath: string,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
entries: DropEntry[],
@@ -718,6 +730,216 @@ export const useSftpExternalOperations = (
],
);
// Upload from a FileList. This keeps the original File objects from the file
// picker so Electron can resolve local file paths for stream uploads.
const uploadExternalFileList = useCallback(
async (
side: "left" | "right",
fileList: FileList | File[],
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 uploadPaneId = pane.id;
const uploadTargetPath = targetPath || pane.connection.currentPath;
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(
pane.connection.id,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
try {
const results = await uploadFromFileList(
fileList,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
logger.error("[SFTP] File picker upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
getActivePane,
refresh,
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
useCompressedUpload,
],
);
const uploadExternalFolderPath = useCallback(
async (
side: "left" | "right",
folderPath: string,
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");
}
if (!bridge.listLocalTree) {
throw new Error("Folder upload not supported");
}
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 uploadPaneId = pane.id;
const uploadTargetPath = targetPath || pane.connection.currentPath;
const controller = new UploadController();
uploadControllerRef.current = controller;
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 scanningTask = startUploadScanningTask(callbacks);
try {
const localEntries = await bridge.listLocalTree(folderPath);
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
scanningTask.complete();
const entries: DropEntry[] = localEntries.map((entry) => {
if (entry.type === "directory") {
return {
file: null,
relativePath: entry.relativePath,
isDirectory: true,
};
}
const file = {
name: entry.relativePath.split("/").pop() || entry.relativePath,
size: entry.size,
lastModified: entry.lastModified,
type: "",
path: entry.localPath,
arrayBuffer: async () => {
const currentBridge = netcattyBridge.get();
if (!currentBridge?.readLocalFile) {
throw new Error("Local file reading not supported");
}
return currentBridge.readLocalFile(entry.localPath);
},
} as File & { path?: string };
return {
file,
relativePath: entry.relativePath,
isDirectory: false,
};
});
const results = await uploadEntriesDirect(
entries,
{
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
resolveConflict: createUploadConflictResolver(),
},
controller,
);
if (clearDirCacheEntry) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
if (controller.isCancelled()) {
scanningTask.cancel();
return [{ fileName: "", success: false, cancelled: true }];
}
if (scanningTask.isOpen()) {
scanningTask.fail(error);
}
logger.error("[SFTP] Folder picker upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
createUploadCallbacks,
createUploadBridge,
createUploadConflictResolver,
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload,
],
);
const uploadExternalEntries = useCallback(
async (
side: "left" | "right",
@@ -835,6 +1057,8 @@ export const useSftpExternalOperations = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host } from "../../../domain/models.ts";
import type { Host, SSHKey } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
@@ -51,3 +51,137 @@ test("buildSftpHostCredentials rejects missing saved proxy profiles on jump host
/Saved proxy for jump host "Jump" is missing/,
);
});
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
const key: SSHKey = {
id: "key-1",
label: "Reference key",
type: "ED25519",
privateKey: "",
source: "reference",
category: "key",
created: 1,
filePath: "/Users/alice/.ssh/id_ed25519",
passphrase: "saved-passphrase",
};
const credentials = buildSftpHostCredentials({
host: host({ authMethod: "key", identityFileId: "key-1" }),
hosts: [],
keys: [key],
identities: [],
});
assert.equal(credentials.privateKey, undefined);
assert.deepEqual(credentials.identityFilePaths, ["/Users/alice/.ssh/id_ed25519"]);
assert.equal(credentials.passphrase, "saved-passphrase");
});
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
const key: SSHKey = {
id: "jump-key",
label: "Jump key",
type: "ED25519",
privateKey: "",
source: "reference",
category: "key",
created: 1,
filePath: "/Users/alice/.ssh/jump_ed25519",
};
const jumpHost = host({
id: "jump-1",
label: "Jump",
authMethod: "key",
identityFileId: "jump-key",
});
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [key],
identities: [],
});
assert.equal(credentials.jumpHosts?.[0]?.privateKey, undefined);
assert.deepEqual(credentials.jumpHosts?.[0]?.identityFilePaths, ["/Users/alice/.ssh/jump_ed25519"]);
});
test("buildSftpHostCredentials rejects undecryptable saved password credentials", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
authMethod: "password",
password: "enc:v1:djEwAAAA",
}),
hosts: [],
keys: [],
identities: [],
}),
/Saved credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials omits local key file paths for password auth", () => {
const credentials = buildSftpHostCredentials({
host: host({
authMethod: "password",
password: "secret",
identityFilePaths: ["/Users/alice/.ssh/id_ed25519"],
}),
hosts: [],
keys: [],
identities: [],
});
assert.equal(credentials.password, "secret");
assert.equal(credentials.privateKey, undefined);
assert.equal(credentials.identityFilePaths, undefined);
});
test("buildSftpHostCredentials rejects undecryptable saved key material without fallback credentials", () => {
const key: SSHKey = {
id: "key-1",
label: "Imported key",
type: "ED25519",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
};
assert.throws(
() => buildSftpHostCredentials({
host: host({ authMethod: "key", identityFileId: "key-1" }),
hosts: [],
keys: [key],
identities: [],
}),
/Saved credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials does not use stale local key paths when a selected key is unavailable", () => {
const key: SSHKey = {
id: "key-1",
label: "Imported key",
type: "ED25519",
privateKey: "enc:v1:djEwAAAA",
source: "imported",
category: "key",
created: 1,
};
assert.throws(
() => buildSftpHostCredentials({
host: host({
authMethod: "key",
identityFileId: "key-1",
identityFilePaths: ["/Users/alice/.ssh/stale_ed25519"],
}),
hosts: [],
keys: [key],
identities: [],
}),
/Saved credentials cannot be decrypted/,
);
});

View File

@@ -1,12 +1,19 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
// Fallback used when no global TerminalSettings are wired through (older
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
// identical whether or not the caller passes settings.
const FALLBACK_KEEPALIVE = { keepaliveInterval: 30, keepaliveCountMax: 10 };
interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
}
export const buildSftpHostCredentials = ({
@@ -14,7 +21,9 @@ export const buildSftpHostCredentials = ({
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
}
@@ -49,6 +58,15 @@ export const buildSftpHostCredentials = ({
identities,
});
const jumpKey = jumpAuth.key;
const jumpPassword = sanitizeCredentialValue(jumpAuth.password);
const jumpKeyAuth = resolveBridgeKeyAuth({
key: jumpKey,
fallbackIdentityFilePaths: jumpAuth.authMethod === "password" || jumpAuth.keyId
? undefined
: jumpHost.identityFilePaths,
passphrase: jumpAuth.passphrase,
});
const hasJumpKeyMaterial = Boolean(jumpKeyAuth.privateKey || jumpKeyAuth.identityFilePaths?.length);
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
@@ -60,14 +78,25 @@ export const buildSftpHostCredentials = ({
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
const hasUnreadableJumpCredential =
isEncryptedCredentialPlaceholder(jumpAuth.password) ||
isEncryptedCredentialPlaceholder(jumpKey?.privateKey) ||
isEncryptedCredentialPlaceholder(jumpAuth.passphrase);
if (
(jumpAuth.authMethod === "password" && isEncryptedCredentialPlaceholder(jumpAuth.password) && !jumpPassword) ||
(jumpAuth.authMethod !== "password" && hasUnreadableJumpCredential && !jumpPassword && !hasJumpKeyMaterial)
) {
throw new Error(`Saved credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter them.`);
}
const hopKeepalive = resolveHostKeepalive(jumpHost, globalKeepalive);
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
password: jumpPassword,
privateKey: jumpKeyAuth.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
passphrase: jumpKeyAuth.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
@@ -81,7 +110,9 @@ export const buildSftpHostCredentials = ({
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
identityFilePaths: jumpKeyAuth.identityFilePaths,
keepaliveInterval: hopKeepalive.interval,
keepaliveCountMax: hopKeepalive.countMax,
};
});
}
@@ -90,21 +121,44 @@ export const buildSftpHostCredentials = ({
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
const keyAuth = resolveBridgeKeyAuth({
key,
fallbackIdentityFilePaths: resolved.authMethod === "password" || resolved.keyId
? undefined
: host.identityFilePaths,
passphrase: resolved.passphrase,
});
const password = sanitizeCredentialValue(resolved.password);
const hasKeyMaterial = Boolean(keyAuth.privateKey || keyAuth.identityFilePaths?.length);
const hasUnreadableCredential =
isEncryptedCredentialPlaceholder(resolved.password) ||
isEncryptedCredentialPlaceholder(key?.privateKey) ||
isEncryptedCredentialPlaceholder(resolved.passphrase);
if (
(resolved.authMethod === "password" && isEncryptedCredentialPlaceholder(resolved.password) && !password) ||
(resolved.authMethod !== "password" && hasUnreadableCredential && !password && !hasKeyMaterial)
) {
throw new Error("Saved credentials cannot be decrypted on this device. Open host settings and re-enter them.");
}
const targetKeepalive = resolveHostKeepalive(host, globalKeepalive);
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password: resolved.password,
privateKey: key?.privateKey,
password,
privateKey: keyAuth.privateKey,
certificate: key?.certificate,
passphrase: resolved.passphrase || key?.passphrase,
passphrase: keyAuth.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: host.identityFilePaths,
identityFilePaths: keyAuth.identityFilePaths,
keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax,
};
};
@@ -112,8 +166,9 @@ export const useSftpHostCredentials = ({
hosts,
keys,
identities,
terminalSettings,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities }),
[hosts, identities, keys],
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
[hosts, identities, keys, terminalSettings],
);

View File

@@ -0,0 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isConcreteTransferTargetPath } from "./utils";
test("concrete transfer target paths exclude temporary placeholders", () => {
assert.equal(isConcreteTransferTargetPath({ targetPath: "/Users/alice/Downloads/report.pdf" }), true);
assert.equal(isConcreteTransferTargetPath({ targetPath: "C:\\Users\\alice\\Downloads\\report.pdf" }), true);
assert.equal(isConcreteTransferTargetPath({ targetPath: "(temp)" }), false);
assert.equal(isConcreteTransferTargetPath({ targetPath: " " }), false);
});

View File

@@ -1,4 +1,4 @@
import { SftpFileEntry } from "../../../domain/models";
import { SftpFileEntry, TransferTask } from "../../../domain/models";
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "--";
@@ -76,6 +76,11 @@ export const getParentPath = (path: string): string => {
return result;
};
export const isConcreteTransferTargetPath = (task: Pick<TransferTask, "targetPath">): boolean => {
const targetPath = task.targetPath.trim();
return targetPath.length > 0 && targetPath !== "(temp)";
};
export const getFileName = (path: string): string => {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || "";

View File

@@ -1,7 +1,13 @@
import test from "node:test";
import assert from "node:assert/strict";
import { uploadFromDataTransfer } from "../../lib/uploadService.ts";
import {
UploadController,
startUploadScanningTask,
uploadEntriesDirect,
uploadFromDataTransfer,
uploadFromFileList,
} from "../../lib/uploadService.ts";
function createDataTransfer(files: File[]): DataTransfer {
return {
@@ -10,6 +16,37 @@ function createDataTransfer(files: File[]): DataTransfer {
} as unknown as DataTransfer;
}
function createDataTransferWithNullEntries(files: File[]): DataTransfer {
const items = files.map((file) => ({
kind: "file",
getAsFile: () => file,
webkitGetAsEntry: () => null,
}));
return {
items,
files,
} as unknown as DataTransfer;
}
test("upload scanning task can be shown and cancelled before transfers start", () => {
const events: string[] = [];
const scanningTask = startUploadScanningTask(
{
onScanningStart: (taskId) => events.push(`start:${taskId}`),
onScanningEnd: (taskId) => events.push(`end:${taskId}`),
onTaskCancelled: (taskId) => events.push(`cancel:${taskId}`),
},
"scan-folder-1",
);
assert.equal(scanningTask.isOpen(), true);
scanningTask.cancel();
scanningTask.complete();
assert.equal(scanningTask.isOpen(), false);
assert.deepEqual(events, ["start:scan-folder-1", "cancel:scan-folder-1"]);
});
test("clears the scanning placeholder when every dropped file is skipped by conflict resolution", async () => {
const events: string[] = [];
const file = new File(["local"], "conflict.txt", { lastModified: 1234 });
@@ -42,3 +79,119 @@ test("clears the scanning placeholder when every dropped file is skipped by conf
]);
assert.deepEqual(events, ["scan:start", "scan:end"]);
});
test("uploads DataTransfer files when entry extraction returns no entries", async () => {
const file = new File(["picked"], "picked.txt", { lastModified: 1234 });
const uploadedPaths: string[] = [];
const results = await uploadFromDataTransfer(
createDataTransferWithNullEntries([file]),
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
writeSftpBinary: async (_sftpId, path) => {
uploadedPaths.push(path);
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(uploadedPaths, ["/target/picked.txt"]);
assert.deepEqual(results, [
{ fileName: "picked.txt", success: true },
]);
});
test("uploads picked folder files with their relative directory structure", async () => {
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
Object.defineProperty(file, "webkitRelativePath", {
value: "folder/sub/file.txt",
});
const madeDirs: string[] = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async (_sftpId, path) => {
madeDirs.push(path);
},
writeSftpBinary: async (_sftpId, path) => {
uploadedPaths.push(path);
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/sub"]);
assert.deepEqual(uploadedPaths, ["/target/folder/sub/file.txt"]);
assert.deepEqual(results, [
{ fileName: "folder/sub/file.txt", success: true },
]);
});
test("reports empty directory creation failures", async () => {
const madeDirs: string[] = [];
const results = await uploadEntriesDirect(
[
{ file: null, relativePath: "folder", isDirectory: true },
{ file: null, relativePath: "folder/empty", isDirectory: true },
],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async (_sftpId, path) => {
madeDirs.push(path);
if (path.endsWith("/empty")) {
throw new Error("permission denied");
}
},
},
joinPath: (base, name) => `${base}/${name}`,
},
);
assert.deepEqual(madeDirs, ["/target/folder", "/target/folder/empty"]);
assert.deepEqual(results, [
{ fileName: "folder/empty", success: false, error: "permission denied" },
]);
});
test("does not restart a direct upload that was already cancelled", async () => {
const controller = new UploadController();
await controller.cancel();
let mkdirCalled = false;
const results = await uploadEntriesDirect(
[{ file: null, relativePath: "folder", isDirectory: true }],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {
mkdirCalled = true;
},
},
joinPath: (base, name) => `${base}/${name}`,
},
controller,
);
assert.equal(mkdirCalled, false);
assert.deepEqual(results, [
{ fileName: "", success: false, cancelled: true },
]);
});

View File

@@ -15,6 +15,7 @@ import {
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
import type {
@@ -61,17 +62,14 @@ function getAIBridge() {
return (window as unknown as { netcatty?: AIBridge }).netcatty;
}
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
function emitAIStateChanged(key: string) {
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
}
function cleanupAcpSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
@@ -326,6 +324,20 @@ export function useAIState() {
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
);
// Per-agent provider override: remembers which provider config each agent
// should bind to. Falls back to the global `activeProviderId` when an agent
// has no entry. Used so that e.g. Catty Agent can stay on DeepSeek while
// a Claude/Codex run continues on its existing provider.
const [agentProviderMap, setAgentProviderMapRaw] = useState<Record<string, string>>(() =>
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {}
);
// Mirror for non-functional reads inside removeProvider — needed to know
// which agents were bound to the deleted provider so we can also drop
// their saved model ids (those ids belonged to the now-missing provider).
const agentProviderMapRef = useRef(agentProviderMap);
useEffect(() => {
agentProviderMapRef.current = agentProviderMap;
}, [agentProviderMap]);
// ── Web Search Config ──
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
@@ -413,6 +425,21 @@ export function useAIState() {
});
}, []);
const setAgentProvider = useCallback((agentId: string, providerId: string) => {
setAgentProviderMapRaw(prev => {
// Empty string clears the per-agent override and lets the agent fall
// back to the global `activeProviderId`.
const next = { ...prev };
if (providerId) {
next[agentId] = providerId;
} else {
delete next[agentId];
}
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
return next;
});
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
@@ -600,6 +627,9 @@ export function useAIState() {
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
break;
case STORAGE_KEY_AI_AGENT_PROVIDER_MAP:
setAgentProviderMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {});
break;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
const nextActiveSessionIdMap =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
@@ -1080,6 +1110,41 @@ export function useAIState() {
}
return prevId;
});
// Drop per-agent overrides pointing at this provider plus the saved
// model id for those agents — the id belonged to the now-missing
// provider, so feeding it to the fallback provider would just send
// a model name that target doesn't recognize.
const orphanedAgents = Object.keys(agentProviderMapRef.current)
.filter((agentId) => agentProviderMapRef.current[agentId] === id);
if (orphanedAgents.length > 0) {
setAgentProviderMapRaw(prev => {
const next: Record<string, string> = {};
let changed = false;
for (const agentId of Object.keys(prev)) {
if (prev[agentId] === id) {
changed = true;
} else {
next[agentId] = prev[agentId];
}
}
if (!changed) return prev;
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, next);
return next;
});
setAgentModelMapRaw(prev => {
let changed = false;
const next: Record<string, string> = { ...prev };
for (const agentId of orphanedAgents) {
if (agentId in next) {
delete next[agentId];
changed = true;
}
}
if (!changed) return prev;
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, next);
return next;
});
}
}, [setProviders]);
// ── Computed ──
@@ -1123,6 +1188,9 @@ export function useAIState() {
// Per-agent model memory
agentModelMap,
setAgentModel,
// Per-agent provider override (falls back to activeProviderId when unset)
agentProviderMap,
setAgentProvider,
// Web search
webSearchConfig,

View File

@@ -52,14 +52,19 @@ export function useAgentDiscovery(
);
if (!match) return ea;
// Check if args or ACP config differ
// Check if args, ACP config, or Claude's resolved system path 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) {
const env = match.command === 'claude'
? { ...(ea.env ?? {}), CLAUDE_CODE_EXECUTABLE: match.path }
: ea.env;
const envChanged = match.command === 'claude'
&& ea.env?.CLAUDE_CODE_EXECUTABLE !== match.path;
if (currentArgs !== newArgs || acpChanged || envChanged) {
changed = true;
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs };
return { ...ea, args: match.args, acpCommand: match.acpCommand, acpArgs: match.acpArgs, ...(env ? { env } : {}) };
}
return ea;
});
@@ -86,6 +91,7 @@ export function useAgentDiscovery(
enabled: true,
acpCommand: agent.acpCommand,
acpArgs: agent.acpArgs,
...(agent.command === 'claude' ? { env: { CLAUDE_CODE_EXECUTABLE: agent.path } } : {}),
};
},
[],

View File

@@ -16,14 +16,26 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings, hasMeaningfulCloudSyncData } from '../syncPayload';
import { mergeSyncPayloads } from '../../domain/syncMerge';
import {
SYNCABLE_SETTING_STORAGE_KEYS,
collectSyncableSettings,
getEffectivePortForwardingRulesForSync,
hasMeaningfulCloudSyncData,
} from '../syncPayload';
import { readInterruptedVaultApply } from '../localVaultBackups';
import {
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
import { notify } from '../notification';
import {
getRuntimeRemoteCheckIntervalMs,
shouldRunRuntimeRemoteCheck,
} from './autoSyncRemoteSchedule';
interface AutoSyncConfig {
// Data to sync
@@ -47,6 +59,7 @@ interface AutoSyncConfig {
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
const SYNCABLE_SETTING_STORAGE_KEY_SET = new Set<string>(SYNCABLE_SETTING_STORAGE_KEYS);
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
// in the future means a restore is applying in some window and auto-sync
@@ -87,6 +100,11 @@ interface SyncNowOptions {
trigger?: SyncTrigger;
}
interface RemoteVersionCheckOptions {
force?: boolean;
notifyOnFailure?: boolean;
}
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const sync = useCloudSync();
@@ -124,22 +142,30 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
}, []);
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 [syncableSettingsStorageVersion, setSyncableSettingsStorageVersion] = useState(0);
useEffect(() => {
const bumpIfSyncableSetting = (key: string | null | undefined) => {
if (!key || !SYNCABLE_SETTING_STORAGE_KEY_SET.has(key)) return;
setSyncableSettingsStorageVersion((v) => v + 1);
};
const handleStorage = (event: StorageEvent) => {
bumpIfSyncableSetting(event.key);
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
bumpIfSyncableSetting(key);
};
window.addEventListener('storage', handleStorage);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
};
}, []);
const getSyncSnapshot = useCallback(() => {
return {
hosts: config.hosts,
keys: config.keys,
@@ -148,7 +174,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
portForwardingRules: getEffectivePortForwardingRulesForSync(config.portForwardingRules),
groupConfigs: config.groupConfigs,
};
}, [
@@ -386,17 +412,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// windows but does NOT serialize same-window re-entry, so this
// in-flight guard closes that gap at the top of the call.
const checkRemoteInFlightRef = useRef(false);
const lastRuntimeRemoteCheckAtRef = useRef<number | null>(null);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
const checkRemoteVersion = useCallback(async (options?: RemoteVersionCheckOptions) => {
if (checkRemoteInFlightRef.current) {
return;
}
const force = options?.force === true;
const notifyOnFailure = options?.notifyOnFailure !== false;
const state = manager.getState();
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
if (!hasProvider || !unlocked || (!force && hasCheckedRemoteRef.current) || startupReadyRef.current === false) {
return;
}
@@ -478,7 +507,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return;
}
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
// Apply merged payload to local state BEFORE committing. If the apply
@@ -532,14 +560,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
if (notifyOnFailure) {
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
);
}
// Leave hasCheckedRemoteRef=false so the next startup (or the next
// provider/unlock transition) can retry.
} finally {
@@ -639,7 +669,17 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
}, [
sync.hasAnyConnectedProvider,
sync.autoSyncEnabled,
sync.isUnlocked,
sync.isSyncing,
getDataHash,
syncNow,
config.settingsVersion,
bookmarksVersion,
syncableSettingsStorageVersion,
]);
// Check remote version on startup/unlock, then retry with backoff
// while the inspect keeps failing. Without the timer-based retry,
@@ -700,12 +740,86 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
const now = Date.now();
const minIntervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
if (!shouldRunRuntimeRemoteCheck({
hasAnyConnectedProvider: sync.hasAnyConnectedProvider,
autoSyncEnabled: sync.autoSyncEnabled,
isUnlocked: sync.isUnlocked,
startupRemoteCheckDone: remoteCheckDoneRef.current,
isSyncing: sync.isSyncing,
isSyncRunning: isSyncRunningRef.current,
remoteCheckInFlight: checkRemoteInFlightRef.current,
force: options?.force === true,
now,
lastRemoteCheckAt: lastRuntimeRemoteCheckAtRef.current,
minIntervalMs,
})) {
return;
}
lastRuntimeRemoteCheckAtRef.current = now;
await checkRemoteVersion({ force: true, notifyOnFailure: false });
}, [
checkRemoteVersion,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isSyncing,
sync.isUnlocked,
]);
// Keep checking the cloud while the app is open. This closes the gap where
// another device uploads changes after our startup inspection but before
// this device edits anything locally.
useEffect(() => {
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
return;
}
const intervalMs = getRuntimeRemoteCheckIntervalMs(sync.autoSyncInterval);
const timerId = window.setInterval(() => {
void runRuntimeRemoteCheck();
}, intervalMs);
return () => window.clearInterval(timerId);
}, [
runRuntimeRemoteCheck,
sync.autoSyncEnabled,
sync.autoSyncInterval,
sync.hasAnyConnectedProvider,
sync.isUnlocked,
]);
// Also re-check when the user returns to the app or the network comes back.
useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void runRuntimeRemoteCheck({ force: true });
}
};
const handleOnline = () => {
void runRuntimeRemoteCheck({ force: true });
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('online', handleOnline);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('online', handleOnline);
};
}, [runRuntimeRemoteCheck]);
// Reset check flags when provider disconnects
useEffect(() => {
if (!sync.hasAnyConnectedProvider) {
hasCheckedRemoteRef.current = false;
remoteCheckDoneRef.current = false;
lastRuntimeRemoteCheckAtRef.current = null;
}
}, [sync.hasAnyConnectedProvider]);

View File

@@ -1,40 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
interface HotkeyActions {
// Tab management
switchToTab: (tabIndex: number) => void;
nextTab: () => void;
prevTab: () => void;
closeTab: () => void;
newTab: () => void;
// Navigation
openHosts: () => void;
openSftp: () => void;
quickSwitch: () => void;
newWorkspace: () => void;
commandPalette: () => void;
portForwarding: () => void;
snippets: () => void;
// Terminal actions (handled per-terminal)
copy: () => void;
paste: () => void;
selectAll: () => void;
clearBuffer: () => void;
searchTerminal: () => void;
// Workspace/split actions
splitHorizontal: () => void;
splitVertical: () => void;
moveFocus: (direction: 'up' | 'down' | 'left' | 'right') => void;
// App features
broadcast: () => void;
openLocal: () => void;
}
// Check if keyboard event matches our app-level shortcuts
// Returns the matched binding action or null
export const checkAppShortcut = (
@@ -71,6 +36,7 @@ export const getAppLevelActions = (): Set<string> => {
'moveFocus',
'broadcast',
'openLocal',
'openSettings',
]);
};
@@ -85,160 +51,3 @@ export const getTerminalPassthroughActions = (): Set<string> => {
'searchTerminal',
]);
};
interface UseGlobalHotkeysOptions {
hotkeyScheme: 'disabled' | 'mac' | 'pc';
keyBindings: KeyBinding[];
actions: Partial<HotkeyActions>;
orderedTabs: string[];
sessions: { id: string }[];
workspaces: { id: string }[];
isSettingsOpen?: boolean;
}
export const useGlobalHotkeys = ({
hotkeyScheme,
keyBindings,
actions,
orderedTabs,
sessions,
workspaces,
isSettingsOpen = false,
}: UseGlobalHotkeysOptions) => {
const actionsRef = useRef(actions);
actionsRef.current = actions;
const orderedTabsRef = useRef(orderedTabs);
orderedTabsRef.current = orderedTabs;
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if (hotkeyScheme === 'disabled') return;
if (isSettingsOpen) return; // Don't handle hotkeys when settings is open
const isMac = hotkeyScheme === 'mac';
const appLevelActions = getAppLevelActions();
// Check if this is an app-level shortcut
const matched = checkAppShortcut(e, keyBindings, isMac);
if (!matched) return;
const { action, binding: _binding } = matched;
// Only handle app-level actions here
// Terminal-level actions are handled by the terminal itself
if (!appLevelActions.has(action)) return;
e.preventDefault();
e.stopPropagation();
const currentActions = actionsRef.current;
switch (action) {
case 'switchToTab': {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
currentActions.switchToTab?.(num);
}
break;
}
case 'nextTab':
currentActions.nextTab?.();
break;
case 'prevTab':
currentActions.prevTab?.();
break;
case 'closeTab':
currentActions.closeTab?.();
break;
case 'newTab':
currentActions.newTab?.();
break;
case 'openHosts':
currentActions.openHosts?.();
break;
case 'openSftp':
currentActions.openSftp?.();
break;
case 'openLocal':
currentActions.openLocal?.();
break;
case 'quickSwitch':
currentActions.quickSwitch?.();
break;
case 'newWorkspace':
currentActions.newWorkspace?.();
break;
case 'commandPalette':
currentActions.commandPalette?.();
break;
case 'portForwarding':
currentActions.portForwarding?.();
break;
case 'snippets':
currentActions.snippets?.();
break;
case 'splitHorizontal':
currentActions.splitHorizontal?.();
break;
case 'splitVertical':
currentActions.splitVertical?.();
break;
case 'moveFocus': {
// Determine direction from arrow key
const key = e.key;
if (key === 'ArrowUp') currentActions.moveFocus?.('up');
else if (key === 'ArrowDown') currentActions.moveFocus?.('down');
else if (key === 'ArrowLeft') currentActions.moveFocus?.('left');
else if (key === 'ArrowRight') currentActions.moveFocus?.('right');
break;
}
case 'broadcast':
currentActions.broadcast?.();
break;
}
}, [hotkeyScheme, keyBindings, isSettingsOpen]);
useEffect(() => {
// Use capture phase to intercept before xterm
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [handleGlobalKeyDown]);
};
// Helper to create key event handler for xterm's attachCustomKeyEventHandler
// Returns false to let xterm handle the key, true to prevent xterm from handling
export const createXtermKeyHandler = (
keyBindings: KeyBinding[],
isMac: boolean,
onTerminalAction?: (action: string, e: KeyboardEvent) => void
) => {
const appLevelActions = getAppLevelActions();
const terminalActions = getTerminalPassthroughActions();
return (e: KeyboardEvent): boolean => {
const matched = checkAppShortcut(e, keyBindings, isMac);
if (!matched) return true; // Let xterm handle it
const { action } = matched;
// App-level actions: prevent xterm from handling, let global handler take over
if (appLevelActions.has(action)) {
return false; // Don't let xterm handle, will bubble to global handler
}
// Terminal-level actions: handle here and prevent default
if (terminalActions.has(action)) {
e.preventDefault();
e.stopPropagation();
onTerminalAction?.(action, e);
return false;
}
return true; // Let xterm handle other keys
};
};

View File

@@ -24,6 +24,7 @@ export interface UsePortForwardingAutoStartOptions {
identities: Identity[];
proxyProfiles: ProxyProfile[];
groupConfigs: GroupConfig[];
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const AUTO_START_PROXY_NOT_READY_ERROR = "Proxy or jump host configuration is not ready";
@@ -103,6 +104,7 @@ export const usePortForwardingAutoStart = ({
identities,
proxyProfiles,
groupConfigs,
terminalSettings,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
@@ -110,6 +112,8 @@ export const usePortForwardingAutoStart = ({
const identitiesRef = useRef<Identity[]>(identities);
const proxyProfilesRef = useRef<ProxyProfile[]>(proxyProfiles);
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
@@ -238,7 +242,7 @@ export const usePortForwardingAutoStart = ({
}
const host = resolveEffectiveHost(rawHost);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current);
};
setReconnectCallback(handleReconnect);
@@ -304,6 +308,10 @@ export const usePortForwardingAutoStart = ({
updateStoredRuleStatus(rule.id, status, error);
},
true, // Enable reconnect for auto-start rules
// Read via ref so adjusting global keepalive after launch doesn't
// re-trigger the auto-start effect (its dep array is intentionally
// stable to fire once on vault init).
terminalSettingsRef.current,
);
}
};

View File

@@ -68,6 +68,7 @@ export interface UsePortForwardingStateResult {
identities: Identity[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
) => Promise<{ success: boolean; error?: string }>;
stopTunnel: (
ruleId: string,
@@ -387,11 +388,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
error?: string,
) => void,
enableReconnect = false,
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number },
) => {
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
}, enableReconnect);
}, enableReconnect, terminalSettings);
},
[setRuleStatus],
);

View File

@@ -9,6 +9,7 @@ FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
@@ -759,6 +760,27 @@ export const useSessionState = () => {
}));
}, []);
const reorderWorkspaceSessions = useCallback((
workspaceId: string,
draggedSessionId: string,
targetSessionId: string,
position: 'before' | 'after' = 'before',
) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
focusSessionOrder: reorderWorkspaceFocusSessionOrder(
ws.root,
ws.focusSessionOrder,
draggedSessionId,
targetSessionId,
position,
),
};
}));
}, []);
// Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
const workspace = workspaces.find(w => w.id === workspaceId);
@@ -1049,6 +1071,7 @@ export const useSessionState = () => {
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
orphanSessions,

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalTheme, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } 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,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -49,9 +51,9 @@ import {
shouldApplyIncomingCustomKeyBindingsRecord,
updateCustomKeyBinding as updateCustomKeyBindingRecord,
} from '../../domain/customKeyBindings';
import { applyCustomAccentToTerminalTheme, getTerminalThemeForUiTheme } from '../../domain/terminalAppearance';
import { applyCustomAccentToTerminalTheme, resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from '../../domain/terminalAppearance';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DEFAULT_FONT_SIZE, isDeprecatedPrimaryFontId } 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';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
@@ -71,6 +73,28 @@ const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
const DEFAULT_CUSTOM_ACCENT = '221.2 83.2% 53.3%';
const DEFAULT_TERMINAL_THEME = 'netcatty-dark';
const DEFAULT_FONT_FAMILY = 'menlo';
/**
* Migrate any terminal font id arriving from storage / IPC / sync to a
* safe value. If `raw` is a deprecated proportional id (pingfang-sc,
* microsoft-yahei, comic-sans-ms), persist the rewrite back to
* localStorage so subsequent ingest paths and cloud-sync uploads stop
* carrying it. Used by every place that reads STORAGE_KEY_TERM_FONT_FAMILY
* — initial useState init, rehydrateAllFromStorage, IPC notifySettings
* change listener, and cross-window storage event listener — so a
* single point of truth keeps deprecated ids from re-entering state.
*
* Returns null when there's nothing to apply (raw is empty); callers
* fall back to DEFAULT_FONT_FAMILY in that case.
*/
function migrateIncomingTerminalFontId(raw: string | null | undefined): string | null {
if (!raw) return null;
if (isDeprecatedPrimaryFontId(raw)) {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, DEFAULT_FONT_FAMILY);
return DEFAULT_FONT_FAMILY;
}
return raw;
}
// Auto-detect default hotkey scheme based on platform
const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -232,7 +256,16 @@ export const useSettingsState = () => {
const isUpgrade = !!localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
return !isUpgrade;
});
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
const [terminalThemeDarkId, setTerminalThemeDarkId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK) || TERMINAL_THEME_AUTO,
);
const [terminalThemeLightId, setTerminalThemeLightId] = useState<string>(
() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT) || TERMINAL_THEME_AUTO,
);
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
return migrateIncomingTerminalFontId(stored) ?? DEFAULT_FONT_FAMILY;
});
const [terminalFontSize, setTerminalFontSize] = useState<number>(() => localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE) || DEFAULT_FONT_SIZE);
const [uiLanguage, setUiLanguage] = useState<UILanguage>(() => {
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
@@ -511,8 +544,13 @@ export const useSettingsState = () => {
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
if (storedTermThemeLight) setTerminalThemeLightId(storedTermThemeLight);
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
const migratedTermFont = migrateIncomingTerminalFontId(storedTermFont);
if (migratedTermFont) setTerminalFontFamilyId(migratedTermFont);
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
@@ -643,12 +681,19 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
if (key === STORAGE_KEY_TERM_THEME_DARK && typeof value === 'string') {
setTerminalThemeDarkId(value);
}
if (key === STORAGE_KEY_TERM_THEME_LIGHT && typeof value === 'string') {
setTerminalThemeLightId(value);
}
if (key === STORAGE_KEY_TERM_FOLLOW_APP_THEME) {
const next = value === true || value === 'true';
setFollowAppTerminalThemeState((prev) => (prev === next ? prev : next));
}
if (key === STORAGE_KEY_TERM_FONT_FAMILY && typeof value === 'string') {
setTerminalFontFamilyId(value);
const migrated = migrateIncomingTerminalFontId(value);
if (migrated) setTerminalFontFamilyId(migrated);
}
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
setTerminalFontSize(value);
@@ -835,6 +880,15 @@ export const useSettingsState = () => {
setTerminalThemeId(e.newValue);
}
}
// Sync per-mode follow terminal themes from other windows
if (e.key === STORAGE_KEY_TERM_THEME_DARK && e.newValue) {
const next = e.newValue;
setTerminalThemeDarkId((prev) => (prev === next ? prev : next));
}
if (e.key === STORAGE_KEY_TERM_THEME_LIGHT && e.newValue) {
const next = e.newValue;
setTerminalThemeLightId((prev) => (prev === next ? prev : next));
}
// Sync follow-app-theme toggle from other windows
if (e.key === STORAGE_KEY_TERM_FOLLOW_APP_THEME && e.newValue) {
const next = e.newValue === 'true';
@@ -844,8 +898,9 @@ export const useSettingsState = () => {
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
if (e.newValue !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(e.newValue);
const migrated = migrateIncomingTerminalFontId(e.newValue);
if (migrated && migrated !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(migrated);
}
}
// Sync terminal font size from other windows
@@ -983,6 +1038,18 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(followAppTerminalTheme));
}, [followAppTerminalTheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_DARK, terminalThemeDarkId);
}, [terminalThemeDarkId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME_LIGHT, terminalThemeLightId);
}, [terminalThemeLightId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
@@ -1265,25 +1332,32 @@ export const useSettingsState = () => {
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(() => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is enabled, pick the terminal theme
// whose background matches the active UI theme preset.
// When "Follow Application Theme" is enabled, honor the per-mode override
// (or auto-match the active UI theme preset when set to auto).
if (followAppTerminalTheme) {
const activeUiThemeId = resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId;
const mapped = getTerminalThemeForUiTheme(activeUiThemeId);
if (mapped) {
const found = TERMINAL_THEMES.find(t => t.id === mapped);
if (found) {
baseTheme = found;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
const followedId = resolveFollowedTerminalThemeId({
resolvedTheme,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const followed = TERMINAL_THEMES.find(t => t.id === followedId)
|| customThemes.find(t => t.id === followedId);
if (followed) {
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
}
// Explicit override pointing at a deleted theme: fall through to the
// manual theme below.
}
baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
const baseTheme = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}, [terminalThemeId, customThemes, followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
}, [terminalThemeId, terminalThemeDarkId, terminalThemeLightId, customThemes,
followAppTerminalTheme, resolvedTheme, lightUiThemeId, darkUiThemeId,
accentMode, customAccent]);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
@@ -1320,6 +1394,10 @@ export const useSettingsState = () => {
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme: setFollowAppTerminalThemeState,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,

View File

@@ -150,6 +150,16 @@ export const useSftpBackend = () => {
return bridge.getHomeDir();
}, []);
const listDrives = useCallback(async () => {
return await netcattyBridge.get()?.listDrives?.() ?? [];
}, []);
const openPath = useCallback(async (path: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.openPath) throw new Error("openPath unavailable");
return bridge.openPath(path);
}, []);
const startStreamTransfer = useCallback(
async (
options: Parameters<NonNullable<NetcattyBridge["startStreamTransfer"]>>[0],
@@ -268,6 +278,8 @@ export const useSftpBackend = () => {
mkdirLocal,
statLocal,
getHomeDir,
listDrives,
openPath,
startStreamTransfer,
cancelTransfer,

View File

@@ -174,6 +174,7 @@ export const useSftpState = (
hosts,
keys,
identities,
terminalSettings: options?.terminalSettings,
leftTabsRef,
rightTabsRef,
leftTabs,
@@ -304,6 +305,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -381,6 +384,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -436,6 +441,8 @@ export const useSftpState = (
writeTextFileByConnection,
downloadToTempAndOpen,
uploadExternalFiles,
uploadExternalFileList,
uploadExternalFolderPath,
uploadExternalEntries,
cancelExternalUpload,
selectApplication,
@@ -501,6 +508,10 @@ export const useSftpState = (
methodsRef.current.writeTextFileByConnection(...args),
downloadToTempAndOpen: (...args: Parameters<typeof downloadToTempAndOpen>) => methodsRef.current.downloadToTempAndOpen(...args),
uploadExternalFiles: (...args: Parameters<typeof uploadExternalFiles>) => methodsRef.current.uploadExternalFiles(...args),
uploadExternalFileList: (...args: Parameters<typeof uploadExternalFileList>) =>
methodsRef.current.uploadExternalFileList(...args),
uploadExternalFolderPath: (...args: Parameters<typeof uploadExternalFolderPath>) =>
methodsRef.current.uploadExternalFolderPath(...args),
uploadExternalEntries: (...args: Parameters<typeof uploadExternalEntries>) =>
methodsRef.current.uploadExternalEntries(...args),
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useTerminalBackend = () => {
@@ -63,9 +63,9 @@ export const useTerminalBackend = () => {
return bridge.execCommand(options);
}, []);
const writeToSession = useCallback((sessionId: string, data: string) => {
const writeToSession = useCallback((sessionId: string, data: string, options?: { automated?: boolean }) => {
const bridge = netcattyBridge.get();
bridge?.writeToSession?.(sessionId, data);
bridge?.writeToSession?.(sessionId, data, options);
}, []);
const resizeSession = useCallback((sessionId: string, cols: number, rows: number) => {
@@ -73,6 +73,11 @@ export const useTerminalBackend = () => {
bridge?.resizeSession?.(sessionId, cols, rows);
}, []);
const setSessionFlowPaused = useCallback((sessionId: string, paused: boolean) => {
const bridge = netcattyBridge.get();
bridge?.setSessionFlowPaused?.(sessionId, paused);
}, []);
const closeSession = useCallback((sessionId: string) => {
const bridge = netcattyBridge.get();
bridge?.closeSession?.(sessionId);
@@ -96,11 +101,38 @@ export const useTerminalBackend = () => {
return bridge.onSessionExit(sessionId, cb);
}, []);
const onTelnetAutoLoginComplete = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTelnetAutoLoginComplete?.(sessionId, cb);
}, []);
const onTelnetAutoLoginCancelled = useCallback((sessionId: string, cb: (evt: { sessionId: string }) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTelnetAutoLoginCancelled?.(sessionId, cb);
}, []);
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onChainProgress?.(cb);
}, []);
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
const bridge = netcattyBridge.get();
return bridge?.onHostKeyVerification?.(cb);
}, []);
const respondHostKeyVerification = useCallback(async (
requestId: string,
accept: boolean,
addToKnownHosts?: boolean,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.respondHostKeyVerification) {
return { success: false, error: "respondHostKeyVerification unavailable" };
}
return bridge.respondHostKeyVerification(requestId, accept, addToKnownHosts);
}, []);
const openExternal = useCallback(async (url: string) => {
const bridge = netcattyBridge.get();
await bridge?.openExternal?.(url);
@@ -150,32 +182,81 @@ export const useTerminalBackend = () => {
return bridge.getServerStats(sessionId);
}, []);
return {
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onChainProgress,
openExternal,
};
// Memoize the returned object so its identity is stable across the
// hook's lifetime. Each method above is already useCallback([])-stable,
// so listing them as deps means useMemo recomputes once and then
// caches forever. Without this, every render produced a fresh object
// literal — making `terminalBackend` an unstable reference that
// forced consumers' useEffects (`}, [..., terminalBackend])`) to
// rerun on every parent render and forced lint to flag any deeper
// property dep (`}, [terminalBackend.onHostKeyVerification])`) it
// couldn't statically prove safe.
return useMemo(
() => ({
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onTelnetAutoLoginComplete,
onTelnetAutoLoginCancelled,
onChainProgress,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
}),
[
backendAvailable,
telnetAvailable,
moshAvailable,
localAvailable,
serialAvailable,
execAvailable,
openExternalAvailable,
startSSHSession,
startTelnetSession,
startMoshSession,
startLocalSession,
startSerialSession,
listSerialPorts,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
getSessionDistroInfo,
getServerStats,
writeToSession,
resizeSession,
setSessionFlowPaused,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onTelnetAutoLoginComplete,
onTelnetAutoLoginCancelled,
onChainProgress,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
],
);
};

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import { sanitizeGroupConfig } from "../../domain/groupConfig";
import { normalizeKnownHosts } from "../../domain/knownHosts";
import {
ConnectionLog,
GroupConfig,
@@ -66,7 +68,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
const label = key.label ?? `Key ${id.slice(0, 8)}`;
const source =
key.source === "generated" || key.source === "imported"
key.source === "generated" || key.source === "imported" || key.source === "reference"
? key.source
: key.privateKey
? "imported"
@@ -86,6 +88,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
key.category ||
((key.certificate ? "certificate" : "key") as KeyCategory),
created: key.created || Date.now(),
filePath: key.filePath,
};
};
@@ -159,6 +162,42 @@ export const useVaultState = () => {
});
}, []);
const importOrReuseKey = useCallback((draft: Partial<SSHKey>): SSHKey => {
const existing = keys.find((k) => {
if (draft.source === 'reference' && draft.filePath) {
return k.source === 'reference' && k.filePath === draft.filePath;
}
if (draft.privateKey) {
return k.privateKey === draft.privateKey;
}
return false;
});
if (existing) return existing;
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draft.label || 'Imported Key',
type: draft.type || 'ED25519',
privateKey: draft.privateKey || '',
publicKey: draft.publicKey,
certificate: draft.certificate,
passphrase: draft.passphrase,
savePassphrase: draft.savePassphrase,
source: draft.source || 'imported',
category: (draft.category || 'key') as KeyCategory,
created: Date.now(),
filePath: draft.filePath,
};
const updated = [...keys, newKey];
setKeys(updated);
const ver = ++keysWriteVersion.current;
void encryptKeys(updated).then((enc) => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
return newKey;
}, [keys]);
const updateIdentities = useCallback((data: Identity[]) => {
setIdentities(data);
const ver = ++identitiesWriteVersion.current;
@@ -203,9 +242,15 @@ export const useVaultState = () => {
}, []);
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
setGroupConfigs(data);
// Sanitize on the write path too — applySyncPayload / importVaultData
// route legacy payloads through here, and without this step a saved
// pingfang-sc / comic-sans-ms override from an older client would
// sit in memory and re-persist with `fontFamilyOverride: true` until
// the next reload. Mirrors updateHosts → sanitizeHost.
const cleaned = data.map(sanitizeGroupConfig);
setGroupConfigs(cleaned);
const ver = ++groupConfigsWriteVersion.current;
return encryptGroupConfigs(data).then((enc) => {
return encryptGroupConfigs(cleaned).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
@@ -461,11 +506,22 @@ export const useVaultState = () => {
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
// Load known hosts. Records imported from `~/.ssh/known_hosts` and
// records saved by older builds may be missing the `fingerprint` /
// `keyType` fields the verifier compares against; backfill them now
// so the next SSH connect can match without falling into the brittle
// re-derivation path that caused the repeated "fingerprint changed"
// warnings in #972.
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
if (savedKnownHosts) {
const normalized = normalizeKnownHosts(savedKnownHosts);
setKnownHosts(normalized);
if (normalized !== savedKnownHosts) {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, normalized);
}
}
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
@@ -491,8 +547,9 @@ export const useVaultState = () => {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
setGroupConfigs(decryptedGC);
encryptGroupConfigs(decryptedGC).then((enc) => {
const sanitizedGC = decryptedGC.map(sanitizeGroupConfig);
setGroupConfigs(sanitizedGC);
encryptGroupConfigs(sanitizedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
@@ -593,7 +650,7 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_KNOWN_HOSTS) {
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
setKnownHosts(next);
setKnownHosts(normalizeKnownHosts(next));
return;
}
@@ -622,7 +679,7 @@ export const useVaultState = () => {
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(dec);
setGroupConfigs(dec.map(sanitizeGroupConfig));
});
return;
}
@@ -727,6 +784,7 @@ export const useVaultState = () => {
groupConfigs,
updateHosts,
updateKeys,
importOrReuseKey,
updateIdentities,
updateProxyProfiles,
updateSnippets,

View File

@@ -43,6 +43,7 @@ const {
buildSyncPayload,
hasMeaningfulCloudSyncData,
} = await import("./syncPayload.ts");
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
const knownHost = (id = "kh-1"): KnownHost => ({
id,
@@ -93,6 +94,431 @@ test("buildSyncPayload includes reusable proxy profiles", () => {
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
});
test("buildSyncPayload includes AI configuration settings", () => {
const providers = [{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:test",
defaultModel: "gpt-test",
enabled: true,
}];
const webSearch = {
providerId: "tavily",
apiKey: "enc:v1:web",
enabled: true,
maxResults: 7,
};
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(providers));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER, "openai-main");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL, "gpt-test");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE, "autonomous");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, "skills");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT, "codex");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST, JSON.stringify(["rm -rf"]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT, "120");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS, "10");
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
const payload = buildSyncPayload(vault([]));
assert.deepEqual(payload.settings?.ai, {
providers,
activeProviderId: "openai-main",
activeModelId: "gpt-test",
globalPermissionMode: "autonomous",
toolIntegrationMode: "skills",
defaultAgentId: "codex",
commandBlocklist: ["rm -rf"],
commandTimeout: 120,
maxIterations: 10,
agentModelMap: { codex: "gpt-test" },
agentProviderMap: { catty: "openai-main" },
webSearchConfig: webSearch,
});
});
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
]));
const payload = buildSyncPayload(vault([]));
assert.equal("ai" in (payload.settings ?? {}), false);
});
test("buildSyncPayload omits device-bound encrypted AI API keys", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwAAAA",
enabled: true,
}]));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwAAAA",
enabled: true,
}));
const payload = buildSyncPayload(vault([]));
assert.equal("apiKey" in (payload.settings?.ai?.providers?.[0] ?? {}), false);
assert.equal("apiKey" in (payload.settings?.ai?.webSearchConfig ?? {}), false);
});
test("applySyncPayload restores AI configuration settings", async () => {
const providers = [{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:test",
enabled: true,
}];
const webSearch = {
providerId: "exa",
apiKey: "enc:v1:web",
enabled: true,
};
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers,
activeProviderId: "anthropic-main",
activeModelId: "claude-test",
globalPermissionMode: "observer",
toolIntegrationMode: "mcp",
defaultAgentId: "claude",
commandBlocklist: ["shutdown"],
commandTimeout: 30,
maxIterations: 5,
agentModelMap: { claude: "claude-test" },
agentProviderMap: { catty: "anthropic-main" },
webSearchConfig: webSearch,
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!), providers);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_PROVIDER), "anthropic-main");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_ACTIVE_MODEL), "claude-test");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PERMISSION_MODE), "observer");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_TOOL_INTEGRATION_MODE), "mcp");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_DEFAULT_AGENT), "claude");
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_BLOCKLIST)!), ["shutdown"]);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_COMMAND_TIMEOUT), "30");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_MAX_ITERATIONS), "5");
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
});
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
// Without this nudge, the apply path writes to localStorage but
// `useAIState` (listening for `storage` events) never sees the changes
// in the calling window — mounted UI keeps showing pre-sync data.
const dispatched: Array<{ type: string; detail: unknown }> = [];
const fakeWindow = {
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent(event: Event) {
dispatched.push({
type: event.type,
detail: (event as CustomEvent).detail,
});
return true;
},
};
Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true });
try {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "deepseek-local" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ catty: "deepseek-v4-flash" }));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers: [{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true }],
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const events = dispatched.filter((e) => e.type === "netcatty:ai-state-changed");
const keys = events.map((e) => (e.detail as { key?: string })?.key);
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_PROVIDERS), "providers nudge");
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP), "agentProviderMap nudge");
assert.ok(keys.includes(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP), "agentModelMap nudge");
} finally {
delete (globalThis as { window?: unknown }).window;
}
});
test("applySyncPayload prunes per-agent bindings that reference providers absent from the synced set", async () => {
// Local state has Catty bound to a provider the incoming sync no longer
// ships — both the per-agent provider override and the saved model should
// be cleared so we don't dispatch a ghost provider id (or its now-orphan
// model name) to the wrong endpoint.
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({
catty: "deepseek-local",
codex: "openai-main",
}));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({
catty: "deepseek-v4-flash",
codex: "gpt-test",
}));
const syncedProviders = [
{ id: "openai-main", providerId: "openai", name: "OpenAI", enabled: true },
];
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers: syncedProviders,
// Intentionally omit agentProviderMap — exercises the reconcile path.
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!),
{ codex: "openai-main" },
);
// Catty's saved model belonged to the now-missing deepseek-local — drop it.
// Codex's binding stays, so its saved model stays.
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!),
{ codex: "gpt-test" },
);
});
test("applySyncPayload preserves local externalAgents and ignores legacy payload field", async () => {
const localAgents = [
{ id: "codex", name: "Codex", command: "/usr/local/bin/codex", enabled: true },
];
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify(localAgents));
const payload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
// Legacy snapshot still carries externalAgents; current code must ignore it.
externalAgents: [
{ id: "claude", name: "Claude", command: "C:\\Tools\\claude.exe", enabled: true },
],
},
},
syncedAt: 1,
} as unknown as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.deepEqual(
JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS)!),
localAgents,
);
});
test("applySyncPayload preserves local AI provider apiKeys when synced payload omits them", async () => {
const localProviders = [
{
id: "openai-main",
providerId: "openai",
name: "OpenAI",
apiKey: "enc:v1:djEwLOCAL",
enabled: true,
},
{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:djEwANTHROPIC",
enabled: true,
},
];
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify(localProviders));
// Synced payload mirrors what `collectSyncableSettings` produces on another device:
// metadata is preserved but encrypted device-bound apiKeys are stripped.
const syncedProviders = [
{ id: "openai-main", providerId: "openai", name: "OpenAI (renamed)", enabled: true },
{ id: "anthropic-main", providerId: "anthropic", name: "Anthropic", enabled: false },
];
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: { ai: { providers: syncedProviders } },
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
assert.deepEqual(stored, [
{
id: "openai-main",
providerId: "openai",
name: "OpenAI (renamed)",
apiKey: "enc:v1:djEwLOCAL",
enabled: true,
},
{
id: "anthropic-main",
providerId: "anthropic",
name: "Anthropic",
apiKey: "enc:v1:djEwANTHROPIC",
enabled: false,
},
]);
});
test("applySyncPayload prefers explicit synced apiKey over local apiKey", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "enc:v1:djEwLOCAL", enabled: true },
]));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
providers: [
{ id: "openai-main", providerId: "openai", name: "OpenAI", apiKey: "plaintext-from-other-device", enabled: true },
],
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_PROVIDERS)!);
assert.equal(stored[0].apiKey, "plaintext-from-other-device");
});
test("applySyncPayload preserves local web-search apiKey when synced config omits it", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
enabled: true,
maxResults: 7,
}));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
webSearchConfig: { providerId: "tavily", enabled: false, maxResults: 12 },
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
assert.deepEqual(stored, {
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
enabled: false,
maxResults: 12,
});
});
test("applySyncPayload drops local web-search apiKey when synced config switches provider", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify({
providerId: "tavily",
apiKey: "enc:v1:djEwWEB",
enabled: true,
}));
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
ai: {
webSearchConfig: { providerId: "exa", enabled: true },
},
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
const stored = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!);
assert.equal("apiKey" in stored, false);
assert.equal(stored.providerId, "exa");
});
test("buildSyncPayload includes syncable terminal options from settings", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_FOLLOW_APP_THEME, "true");
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
terminalEmulationType: "vt100",
altAsMeta: true,
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
localShell: "/bin/zsh",
}));
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.followAppTerminalTheme, true);
assert.deepEqual(payload.settings?.terminalSettings, {
terminalEmulationType: "vt100",
altAsMeta: true,
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
});
});
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
assert.equal(
hasMeaningfulCloudSyncData({
@@ -233,6 +659,75 @@ test("applySyncPayload waits for async vault imports", async () => {
assert.equal(finished, true);
});
test("buildSyncPayload includes fallbackFont when present in TERM_SETTINGS", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fallbackFont: "PingFang SC", fontLigatures: true }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal(termSettings.fallbackFont, "PingFang SC");
});
test("buildSyncPayload omits fallbackFont when TERM_SETTINGS does not set it", () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fontLigatures: true }),
);
const payload = buildSyncPayload(vault());
const termSettings = (payload.settings?.terminalSettings ?? {}) as Record<string, unknown>;
assert.equal("fallbackFont" in termSettings, false);
});
test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", async () => {
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { fallbackFont: "Sarasa Mono SC" } },
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
});
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 5000, fallbackFont: "Microsoft YaHei UI" }),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { scrollback: 9999 } },
};
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
const parsed = JSON.parse(raw!);
assert.equal(parsed.fallbackFont, "Microsoft YaHei UI", "legacy payload must not wipe local fallbackFont");
assert.equal(parsed.scrollback, 9999);
});
test("applyLocalVaultPayload restores known hosts from local backups", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {

View File

@@ -18,14 +18,21 @@ import type {
Snippet,
SSHKey,
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_PAYLOAD_ENTITY_KEYS,
hasSyncPayloadEntityData,
type SyncPayload,
} from '../domain/sync';
import {
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
serializeCustomKeyBindingsStorageRecord,
} from '../domain/customKeyBindings';
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
import { emitAIStateChanged } from './state/aiStateEvents';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
@@ -36,6 +43,9 @@ import {
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
@@ -46,11 +56,27 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_PORT_FORWARDING,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -78,19 +104,7 @@ export interface SyncableVaultData {
* protecting or syncing.
*/
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.proxyProfiles?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.knownHosts?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
if (hasSyncPayloadEntityData(payload, SYNC_PAYLOAD_ENTITY_KEYS)) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
@@ -102,24 +116,39 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
* Local-only trust records are intentionally ignored.
*/
export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
const hasEntities =
(payload.hosts?.length ?? 0) > 0 ||
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.proxyProfiles?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
(payload.groupConfigs?.length ?? 0) > 0;
if (hasEntities) return true;
if (hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS)) return true;
return Boolean(
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
);
}
export function sanitizePortForwardingRulesForSync(
rules: PortForwardingRule[] | undefined,
): PortForwardingRule[] | undefined {
if (!rules) return rules;
return rules.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
export function getEffectivePortForwardingRulesForSync(
rules: PortForwardingRule[] | undefined,
): PortForwardingRule[] | undefined {
let effectiveRules = rules;
if (!effectiveRules || effectiveRules.length === 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING);
if (Array.isArray(stored) && stored.length > 0) {
effectiveRules = stored;
}
}
return sanitizePortForwardingRulesForSync(effectiveRules);
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
@@ -136,18 +165,127 @@ interface SyncPayloadImporters {
/** Terminal settings keys that are safe to sync (platform-agnostic). */
const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
'startupCommandDelayMs',
'scrollback', 'drawBoldInBrightColors', 'terminalEmulationType',
'fontLigatures', 'fontWeight', 'fontWeightBold', 'fallbackFont',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'osc52Clipboard',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
'serverStatsRefreshInterval', 'rendererType',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
] as const;
export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_COLOR,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_THEME_DARK,
STORAGE_KEY_TERM_THEME_LIGHT,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_HOST_PERMISSIONS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
] as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const readArraySetting = <T = Record<string, unknown>>(key: string): T[] | null => {
const value = localStorageAdapter.read<T[]>(key);
return Array.isArray(value) ? value : null;
};
const readRecordSetting = <T extends Record<string, unknown> = Record<string, unknown>>(key: string): T | null => {
const value = localStorageAdapter.read<T>(key);
return isRecord(value) ? value as T : null;
};
const stripDeviceBoundApiKey = <T extends Record<string, unknown>>(value: T): T => {
if (!isEncryptedCredentialPlaceholder(value.apiKey as string | undefined)) return value;
const next = { ...value };
delete next.apiKey;
return next;
};
/**
* `collectSyncableSettings` strips device-bound encrypted apiKeys before upload,
* so an incoming providers array typically has no apiKey for providers that
* already exist locally. Re-attach the local apiKey by id; without this merge,
* applying any synced settings change would silently wipe credentials on the
* receiving device.
*/
const mergeAiProvidersPreservingLocalApiKeys = (
incoming: Array<Record<string, unknown>>,
): Array<Record<string, unknown>> => {
const local = readArraySetting(STORAGE_KEY_AI_PROVIDERS) ?? [];
const localById = new Map<string, Record<string, unknown>>();
for (const provider of local) {
if (typeof provider?.id === 'string') localById.set(provider.id, provider);
}
return incoming.map((provider) => {
if (provider.apiKey != null) return provider;
const id = typeof provider.id === 'string' ? provider.id : undefined;
const localProvider = id != null ? localById.get(id) : undefined;
if (localProvider && typeof localProvider.apiKey === 'string') {
return { ...provider, apiKey: localProvider.apiKey };
}
return provider;
});
};
/**
* Same rationale as `mergeAiProvidersPreservingLocalApiKeys`. Only restores the
* local apiKey when the incoming config still points at the same providerId —
* switching providers must not silently leak a key meant for a different one.
*/
const mergeWebSearchConfigPreservingLocalApiKey = (
incoming: Record<string, unknown>,
): Record<string, unknown> => {
if (incoming.apiKey != null) return incoming;
const local = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (!local || typeof local.apiKey !== 'string') return incoming;
if (local.providerId !== incoming.providerId) return incoming;
return { ...incoming, apiKey: local.apiKey };
};
/**
* Collect all syncable settings from localStorage.
*/
@@ -175,6 +313,14 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
// Terminal
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
if (termTheme) settings.terminalTheme = termTheme;
const followAppTermTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
if (followAppTermTheme === 'true' || followAppTermTheme === 'false') {
settings.followAppTerminalTheme = followAppTermTheme === 'true';
}
const termThemeDark = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_DARK);
if (termThemeDark) settings.terminalThemeDark = termThemeDark;
const termThemeLight = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME_LIGHT);
if (termThemeLight) settings.terminalThemeLight = termThemeLight;
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
if (termFont) settings.terminalFontFamily = termFont;
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
@@ -224,6 +370,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
// SFTP Bookmarks (global only — local bookmarks are device-specific)
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
@@ -236,6 +384,44 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
settings.workspaceFocusStyle = workspaceFocusStyle;
}
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
if (providers) ai.providers = providers.map(stripDeviceBoundApiKey);
const activeProviderId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER);
if (activeProviderId != null) ai.activeProviderId = activeProviderId;
const activeModelId = localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL);
if (activeModelId != null) ai.activeModelId = activeModelId;
const permissionMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (permissionMode === 'observer' || permissionMode === 'confirm' || permissionMode === 'autonomous') {
ai.globalPermissionMode = permissionMode;
}
const toolIntegrationMode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
if (toolIntegrationMode === 'mcp' || toolIntegrationMode === 'skills') {
ai.toolIntegrationMode = toolIntegrationMode;
}
const hostPermissions = readArraySetting(STORAGE_KEY_AI_HOST_PERMISSIONS);
if (hostPermissions) ai.hostPermissions = hostPermissions;
// externalAgents intentionally not collected: command/args/env are device-local.
const defaultAgentId = localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT);
if (defaultAgentId != null) ai.defaultAgentId = defaultAgentId;
const commandBlocklist = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (Array.isArray(commandBlocklist)) ai.commandBlocklist = commandBlocklist;
const commandTimeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT);
if (commandTimeout != null && Number.isFinite(commandTimeout)) ai.commandTimeout = commandTimeout;
const maxIterations = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS);
if (maxIterations != null && Number.isFinite(maxIterations)) ai.maxIterations = maxIterations;
const agentModelMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP);
if (agentModelMap) ai.agentModelMap = agentModelMap;
const agentProviderMap = readRecordSetting<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
if (Object.keys(ai).length > 0) settings.ai = ai;
return Object.keys(settings).length > 0 ? settings : undefined;
}
@@ -257,6 +443,11 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
// Terminal
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
if (settings.followAppTerminalTheme != null) {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FOLLOW_APP_THEME, String(settings.followAppTerminalTheme));
}
if (settings.terminalThemeDark != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_DARK, settings.terminalThemeDark);
if (settings.terminalThemeLight != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME_LIGHT, settings.terminalThemeLight);
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
@@ -305,6 +496,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
if (settings.sftpDefaultViewMode != null) {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, settings.sftpDefaultViewMode);
}
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
@@ -320,6 +514,119 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
if (settings.workspaceFocusStyle != null) {
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
}
const ai = settings.ai;
if (ai) {
if (ai.providers != null) {
localStorageAdapter.write(
STORAGE_KEY_AI_PROVIDERS,
mergeAiProvidersPreservingLocalApiKeys(ai.providers),
);
}
if (ai.activeProviderId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, ai.activeProviderId);
if (ai.activeModelId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, ai.activeModelId);
if (ai.globalPermissionMode != null) localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, ai.globalPermissionMode);
if (ai.toolIntegrationMode != null) localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, ai.toolIntegrationMode);
if (ai.hostPermissions != null) localStorageAdapter.write(STORAGE_KEY_AI_HOST_PERMISSIONS, ai.hostPermissions);
// externalAgents intentionally not applied: device-local. Legacy snapshots
// that still carry an `externalAgents` field are silently ignored.
if (ai.defaultAgentId != null) localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, ai.defaultAgentId);
if (ai.commandBlocklist != null) localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, ai.commandBlocklist);
if (ai.commandTimeout != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, ai.commandTimeout);
if (ai.maxIterations != null) localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, ai.maxIterations);
if (ai.agentModelMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, ai.agentModelMap);
if (ai.agentProviderMap != null) localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, ai.agentProviderMap);
if (ai.webSearchConfig !== undefined) {
if (ai.webSearchConfig === null) {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
} else {
localStorageAdapter.write(
STORAGE_KEY_AI_WEB_SEARCH,
mergeWebSearchConfigPreservingLocalApiKey(ai.webSearchConfig),
);
}
}
// After all AI writes, reconcile per-agent bindings against the final
// provider list. Sync payloads can land with a new `providers` set but
// no `agentProviderMap`, or with a stale `agentProviderMap` that
// points at ids the synced provider set doesn't include — either way
// we'd leak overrides bound to ghost providers. Mirrors the same
// cleanup `removeProvider` does for explicit user deletes.
pruneOrphanPerAgentBindings();
// Nudge same-window AI state listeners. localStorage writes only fire
// `storage` events in *other* windows; without this nudge the open
// chat panel keeps showing pre-sync providers/bindings until reload.
notifyAIStateAfterSync(ai);
}
}
function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']): void {
if (!ai) return;
// Every AI storage key that `applySyncableSettings` may have touched
// gets a same-window nudge. `useAIState` listens for these and refreshes
// the corresponding React state by re-reading localStorage.
const touched: Array<string> = [];
if (ai.providers != null) touched.push(STORAGE_KEY_AI_PROVIDERS);
if (ai.activeProviderId != null) touched.push(STORAGE_KEY_AI_ACTIVE_PROVIDER);
if (ai.activeModelId != null) touched.push(STORAGE_KEY_AI_ACTIVE_MODEL);
if (ai.globalPermissionMode != null) touched.push(STORAGE_KEY_AI_PERMISSION_MODE);
if (ai.toolIntegrationMode != null) touched.push(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
if (ai.hostPermissions != null) touched.push(STORAGE_KEY_AI_HOST_PERMISSIONS);
if (ai.defaultAgentId != null) touched.push(STORAGE_KEY_AI_DEFAULT_AGENT);
if (ai.commandBlocklist != null) touched.push(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (ai.commandTimeout != null) touched.push(STORAGE_KEY_AI_COMMAND_TIMEOUT);
if (ai.maxIterations != null) touched.push(STORAGE_KEY_AI_MAX_ITERATIONS);
if (ai.agentModelMap != null) touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
// agentProviderMap is *always* potentially mutated because the reconcile
// step may have pruned it even if the payload didn't ship one.
touched.push(STORAGE_KEY_AI_AGENT_PROVIDER_MAP);
// The reconcile may also have pruned saved models alongside provider
// bindings, so always nudge the model map too.
if (!touched.includes(STORAGE_KEY_AI_AGENT_MODEL_MAP)) {
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
}
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
for (const key of touched) {
emitAIStateChanged(key);
}
}
function pruneOrphanPerAgentBindings(): void {
const providers = localStorageAdapter.read<Array<{ id?: string }>>(STORAGE_KEY_AI_PROVIDERS) ?? [];
const validIds = new Set(
providers
.map((p) => p?.id)
.filter((id): id is string => typeof id === 'string' && id.length > 0),
);
const providerMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
const modelMap = localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
let providerChanged = false;
let modelChanged = false;
const nextProviderMap: Record<string, string> = {};
const nextModelMap: Record<string, string> = { ...modelMap };
for (const agentId of Object.keys(providerMap)) {
const providerId = providerMap[agentId];
if (providerId && validIds.has(providerId)) {
nextProviderMap[agentId] = providerId;
} else {
providerChanged = true;
// Drop the saved model too — that id belonged to the now-missing
// provider and isn't trustworthy against any other binding.
if (agentId in nextModelMap) {
delete nextModelMap[agentId];
modelChanged = true;
}
}
}
if (providerChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, nextProviderMap);
}
if (modelChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, nextModelMap);
}
}
// ---------------------------------------------------------------------------
@@ -346,7 +653,7 @@ export function buildSyncPayload(
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
groupConfigs: vault.groupConfigs,
portForwardingRules,
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
@@ -407,7 +714,7 @@ function applyPayload(
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalSftpBookmarks();
importers.onSettingsApplied?.();
}
});

View File

@@ -38,6 +38,7 @@ import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
@@ -145,6 +146,8 @@ interface AIChatSidePanelProps {
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
agentModelMap: Record<string, string>;
setAgentModel: (agentId: string, modelId: string) => void;
agentProviderMap: Record<string, string>;
setAgentProvider: (agentId: string, providerId: string) => void;
// Safety
globalPermissionMode: AIPermissionMode;
@@ -225,6 +228,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setExternalAgents,
agentModelMap,
setAgentModel,
agentProviderMap,
setAgentProvider,
globalPermissionMode,
setGlobalPermissionMode,
commandBlocklist,
@@ -561,8 +566,67 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
[providers, activeProviderId],
);
const providerDisplayName = activeProvider?.name ?? '';
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
// Catty Agent honors a per-agent provider/model override from
// `agentProviderMap` / `agentModelMap`, falling back to the global active
// selection. External ACP agents (Claude/Codex/Copilot) keep their
// existing provider plumbing — the user picks them inside the ACP CLI
// itself, so a per-agent provider override doesn't apply.
const cattyAgentProvider = useMemo(() => {
const overrideId = agentProviderMap['catty'];
if (overrideId) {
const p = providers.find((cfg) => cfg.id === overrideId);
if (p) return p;
// Override exists but points to a deleted provider — fall through
// to the global active selection.
}
return activeProvider;
}, [agentProviderMap, providers, activeProvider]);
const cattyAgentModelId = useMemo(() => {
// Whitespace-only model ids are treated as "no model" everywhere
// (picker, send guard, SDK) — normalize at the resolution boundary
// so a stored " " never slips through downstream checks.
const trim = (s: string | undefined | null): string => (s ?? '').trim();
const overrideId = agentProviderMap['catty'];
const overrideProvider = overrideId
? providers.find((cfg) => cfg.id === overrideId)
: undefined;
if (overrideProvider) {
// Override intact — prefer the per-agent saved model, then the
// override provider's defaultModel. Never reach for the global
// `activeModelId` here: that id belongs to whichever provider
// was globally active, not the one Catty is bound to now.
return trim(agentModelMap['catty']) || trim(overrideProvider.defaultModel);
}
// No override, OR a stale override (the bound provider was deleted):
// in either case the saved model id is no longer trustworthy as a
// Catty pick, so consult the global active selection instead.
return trim(cattyAgentProvider?.defaultModel) || trim(activeModelId);
}, [agentModelMap, agentProviderMap, providers, cattyAgentProvider, activeModelId]);
const effectiveActiveProvider = currentAgentId === 'catty' ? cattyAgentProvider : activeProvider;
const effectiveActiveModelId = currentAgentId === 'catty' ? cattyAgentModelId : activeModelId;
// Catty Agent surfaces its provider picker in the chat input. The list
// mirrors what Settings → AI → Providers shows — every configured
// provider, regardless of the per-provider `enabled` toggle, so the
// user can swap between everything they've set up without first going
// back into Settings to flip a switch.
const cattyConfiguredProviders = useMemo(
() => (currentAgentId === 'catty' ? providers : []),
[currentAgentId, providers],
);
const handleAgentProviderModelSelect = useCallback(
(providerId: string, modelId: string) => {
setAgentProvider(currentAgentId, providerId);
setAgentModel(currentAgentId, modelId);
},
[currentAgentId, setAgentProvider, setAgentModel],
);
const providerDisplayName = effectiveActiveProvider?.name ?? '';
const modelDisplayName = effectiveActiveModelId || effectiveActiveProvider?.defaultModel || '';
// Agent model presets for the current external agent
const currentAgentConfig = useMemo(
@@ -636,6 +700,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
undefined,
undefined,
`models_${currentAgentId}`,
currentAgentConfig.env,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
// If the probe came back empty, drop any stale cached catalog for this
@@ -857,8 +922,14 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const isExternalAgent = sendAgentId !== 'catty';
// Catty Agent picks up the per-agent provider/model override. External
// ACP agents continue to ride the global selection (they wire their
// own provider through the CLI).
const sendActiveProvider = isExternalAgent ? activeProvider : effectiveActiveProvider;
const sendActiveModelId = isExternalAgent ? activeModelId : effectiveActiveModelId;
// No provider configured for built-in agent
if (!isExternalAgent && !activeProvider) {
if (!isExternalAgent && !sendActiveProvider) {
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
@@ -868,6 +939,23 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return;
}
// Catty needs a concrete model id — the SDK would otherwise dispatch
// an empty string and surface a vague backend error. The chat-input
// chip already disables provider rows with no defaultModel, but a
// stale binding (e.g. user emptied the provider's defaultModel after
// selecting it) can still land here. Trim before checking so
// whitespace-only ids (which the picker also treats as empty) don't
// sneak past either.
if (!isExternalAgent && !sendActiveModelId.trim()) {
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
clearScopeDraft();
showScopeSessionView(sessionId);
}
return;
}
// Add user message
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
@@ -885,8 +973,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent
? (selectedAgentModel || agentConfig?.name || 'external')
: (activeModelId || activeProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
: (sendActiveModelId || sendActiveProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : sendActiveProvider?.providerId,
});
const abortController = new AbortController();
@@ -926,8 +1014,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
label: scopeLabel,
} as const;
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider,
activeModelId,
activeProvider: sendActiveProvider,
activeModelId: sendActiveModelId,
scopeType,
scopeTargetId,
scopeLabel,
@@ -946,7 +1034,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}
}, [
isStreaming, activeProvider, scopeKey, currentAgentId,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
@@ -1035,24 +1123,32 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
session={activeSession}
onExport={handleExport}
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
title="Session history"
>
<History size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
title="New chat"
>
<Plus size={15} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
>
<History size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
>
<Plus size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
</Tooltip>
</div>
</div>
@@ -1117,6 +1213,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
modelPresets={agentModelPresets}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
providerSwitcher={
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
? {
providers: cattyConfiguredProviders,
selectedProviderId: effectiveActiveProvider?.id,
selectedModelId: effectiveActiveModelId || undefined,
onSelect: handleAgentProviderModelSelect,
}
: undefined
}
files={files}
onAddFiles={addFiles}
onRemoveFile={removeFile}
@@ -1199,13 +1305,17 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
{timeStr}
</span>
<button
onClick={(e) => onDelete(e, session.id)}
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
title="Delete"
>
<Trash2 size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => onDelete(e, session.id)}
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
>
<Trash2 size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ interface FileOpenerDialogProps {
onSelectSystemApp: () => Promise<SystemAppInfo | null>;
}
export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
open,
onClose,
fileName,

View File

@@ -50,10 +50,13 @@ import { Card } from "./ui/card";
import { Combobox } from "./ui/combobox";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { Textarea } from "./ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
import { useAvailableFonts } from "../application/state/fontStore";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
@@ -814,29 +817,33 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
}
}}
/>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
title={t("hostDetails.credential.browseKeyFile")}
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
}
}}
>
<FolderOpen size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
}
}}
>
<FolderOpen size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
@@ -855,12 +862,14 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onToggle={() => update("agentForwarding", !form.agentForwarding)}
/>
{/* Startup Command */}
<Input
{/* Startup Command — Textarea so multi-line sequences are typeable
here just like on the per-host details panel (#1083 follow-up). */}
<Textarea
placeholder={t("hostDetails.startupCommand.placeholder")}
value={form.startupCommand || ""}
onChange={(e) => update("startupCommand", e.target.value || undefined)}
className="h-10"
className="min-h-[80px] font-mono text-sm"
rows={3}
/>
{/* Legacy Algorithms */}
@@ -871,16 +880,20 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
/>
{/* Backspace behavior */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
value={form.backspaceBehavior ?? ""}
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
<Select
value={form.backspaceBehavior ?? "default"}
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
>
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
<option value="ctrl-h">^H (0x08)</option>
</select>
<SelectTrigger className="h-8 w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Proxy */}
@@ -895,14 +908,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
</div>
<div className="flex min-w-0 items-center gap-2">
{(form.proxyConfig?.host || form.proxyProfileId) && (
<div title={proxySummaryLabel} className="min-w-0">
<Badge
variant="secondary"
className="max-w-[160px] truncate text-xs"
>
{proxySummaryLabel}
</Badge>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 cursor-default">
<Badge
variant="secondary"
className="max-w-[160px] truncate text-xs"
>
{proxySummaryLabel}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent>{proxySummaryLabel}</TooltipContent>
</Tooltip>
)}
<ChevronRight size={14} className="text-muted-foreground" />
</div>

View File

@@ -5,7 +5,8 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host } from "../types.ts";
import HostDetailsPanel from "./HostDetailsPanel.tsx";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const hostWithMissingProxyProfile: Host = {
id: "host-1",
@@ -21,31 +22,235 @@ const hostWithMissingProxyProfile: Host = {
createdAt: 1,
};
const renderHostDetails = () =>
const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData: hostWithMissingProxyProfile,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: [],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
onSave: () => {},
onCancel: () => {},
}),
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: [],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
onSave: () => {},
onCancel: () => {},
}),
),
),
);
const findInputByValue = (markup: string, value: string) => {
const match = markup.match(new RegExp(`<input(?=[^>]*value="${value}")[^>]*>`));
assert.ok(match, `expected input with value ${value}`);
return match[0];
};
const classTokens = (markup: string) => {
const classMatch = markup.match(/class="([^"]*)"/);
assert.ok(classMatch, "expected class attribute");
return new Set(classMatch[1].split(/\s+/).filter(Boolean));
};
test("HostDetailsPanel shows a missing saved proxy without undefined fields", () => {
const markup = renderHostDetails();
assert.match(markup, /Missing saved proxy/);
assert.doesNotMatch(markup, /undefined:undefined/);
});
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: 23,
username: "root",
password: "ssh-password",
telnetUsername: "",
telnetPassword: "",
proxyProfileId: undefined,
});
assert.match(markup, /placeholder="Telnet Username"[^>]*value=""/);
assert.match(markup, /placeholder="Telnet Password"[^>]*value=""/);
assert.doesNotMatch(markup, /placeholder="Telnet Username"[^>]*value="root"/);
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
});
test("HostDetailsPanel gives the telnet port field the same roomy layout as SSH", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: 2325,
proxyProfileId: undefined,
});
const telnetMarkup = markup.slice(markup.indexOf("Telnet on"));
const wrapperMatch = telnetMarkup.match(/<div class="([^"]*w-1\/2[^"]*)"/);
assert.ok(wrapperMatch, "expected telnet port wrapper");
const wrapperClasses = new Set(wrapperMatch[1].split(/\s+/).filter(Boolean));
assert.ok(wrapperClasses.has("ml-auto"));
assert.ok(wrapperClasses.has("w-1/2"));
assert.ok(wrapperClasses.has("min-w-0"));
assert.ok(wrapperClasses.has("justify-end"));
const telnetPortInput = findInputByValue(markup, "2325");
const inputClasses = classTokens(telnetPortInput);
assert.ok(inputClasses.has("flex-1"));
assert.ok(inputClasses.has("min-w-0"));
assert.ok(inputClasses.has("text-center"));
assert.equal(inputClasses.has("w-16"), false);
});
test("HostDetailsPanel displays inherited telnet port before falling back to 23", () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: undefined,
port: undefined,
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
assert.match(findInputByValue(markup, "2325"), /type="number"/);
});
test("HostDetailsPanel uses group telnet port instead of ssh port for optional telnet", () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "ssh",
telnetEnabled: true,
telnetPort: undefined,
port: 2222,
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
const telnetMarkup = markup.slice(markup.indexOf("Telnet on"));
assert.match(findInputByValue(telnetMarkup, "2325"), /type="number"/);
assert.doesNotMatch(telnetMarkup, /value="2222"/);
});
test("HostDetailsPanel displays inherited telnet credentials", () => {
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(HostDetailsPanel, {
initialData: {
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetUsername: undefined,
telnetPassword: undefined,
username: "ssh-user",
password: "ssh-password",
group: "network",
proxyProfileId: undefined,
},
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: ["network"],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
groupConfigs: [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPassword: "group-telnet-password",
}],
onSave: () => {},
onCancel: () => {},
}),
),
),
);
assert.match(markup, /placeholder="Telnet Username"[^>]*value="group-telnet-user"/);
assert.match(markup, /placeholder="Telnet Password"[^>]*value="group-telnet-password"/);
assert.doesNotMatch(markup, /placeholder="Telnet Username"[^>]*value="ssh-user"/);
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
});
test("parseOptionalPortInput clears empty port values", () => {
assert.equal(parseOptionalPortInput(""), undefined);
assert.equal(parseOptionalPortInput("2325"), 2325);
});
test("HostDetailsPanel does not offer to disable telnet when telnet is the primary protocol", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
telnetPort: 23,
proxyProfileId: undefined,
});
const telnetHeader = markup.match(/Telnet on[\s\S]*?Credentials/);
assert.ok(telnetHeader);
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/);
});

View File

@@ -8,6 +8,7 @@ import {
FolderPlus,
Forward,
Globe,
HeartPulse,
Key,
KeyRound,
Link2,
@@ -35,6 +36,7 @@ import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/gro
import {
getEffectiveHostDistro,
LINUX_DISTRO_OPTIONS,
normalizePrimaryTelnetState,
NETWORK_DEVICE_OPTIONS,
} from "../domain/host";
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
@@ -90,6 +92,44 @@ type SubPanel =
| "theme-select"
| "telnet-theme-select";
export const parseOptionalPortInput = (value: string): number | undefined =>
value ? Number(value) : undefined;
const resolveDetailsTelnetPort = (
host: Host,
groupDefaults?: Partial<GroupConfig>,
): number => {
if (host.telnetPort !== undefined && host.telnetPort !== null) return host.telnetPort;
if (groupDefaults?.telnetPort !== undefined && groupDefaults.telnetPort !== null) {
return groupDefaults.telnetPort;
}
if (host.protocol === "telnet") {
if (host.port !== undefined && host.port !== null) return host.port;
if (groupDefaults?.port !== undefined && groupDefaults.port !== null) return groupDefaults.port;
}
return 23;
};
const resolveDetailsTelnetUsername = (
host: Host,
groupDefaults?: Partial<GroupConfig>,
): string =>
host.telnetUsername !== undefined
? host.telnetUsername
: groupDefaults?.telnetUsername !== undefined
? groupDefaults.telnetUsername
: host.username ?? groupDefaults?.username ?? "";
const resolveDetailsTelnetPassword = (
host: Host,
groupDefaults?: Partial<GroupConfig>,
): string =>
host.telnetPassword !== undefined
? host.telnetPassword
: groupDefaults?.telnetPassword !== undefined
? groupDefaults.telnetPassword
: host.password ?? groupDefaults?.password ?? "";
const LINUX_DISTRO_OPTION_IDS = [
...LINUX_DISTRO_OPTIONS,
...NETWORK_DEVICE_OPTIONS,
@@ -114,6 +154,7 @@ interface HostDetailsPanelProps {
groupDefaults?: Partial<import('../domain/models').GroupConfig>;
groupConfigs?: GroupConfig[];
layout?: AsidePanelLayout;
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
}
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
@@ -135,12 +176,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
groupDefaults,
groupConfigs = [],
layout = "overlay",
onImportKey,
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
const [form, setForm] = useState<Host>(
() =>
initialData ||
(initialData ? normalizePrimaryTelnetState(initialData) : null) ||
({
id: crypto.randomUUID(),
label: "",
@@ -174,6 +216,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Local key file path input state
const [newKeyFilePath, setNewKeyFilePath] = useState("");
const [pendingReferenceKeyPath, setPendingReferenceKeyPath] = useState<string | null>(null);
// New group creation state
const [newGroupName, setNewGroupName] = useState("");
@@ -200,15 +243,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
useEffect(() => {
if (initialData) {
// Ensure telnetEnabled is set when protocol is telnet
const updatedData = { ...initialData };
if (initialData.protocol === "telnet" && !initialData.telnetEnabled) {
updatedData.telnetEnabled = true;
updatedData.telnetPort =
initialData.telnetPort || initialData.port || 23;
}
setForm(updatedData);
setForm(normalizePrimaryTelnetState(initialData));
setGroupInputValue(initialData.group || "");
setPendingReferenceKeyPath(null);
// Reset password visibility when host changes for privacy
setShowPassword(false);
}
@@ -218,6 +255,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
setForm((prev) => ({ ...prev, [key]: value }));
};
const addLocalKeyFilePath = useCallback((path: string) => {
const trimmed = path.trim();
if (!trimmed) return;
setForm((prev) => ({
...prev,
identityFilePaths: onImportKey ? [trimmed] : [...(prev.identityFilePaths || []), trimmed],
identityFileId: undefined,
authMethod: "key",
}));
setPendingReferenceKeyPath(onImportKey ? trimmed : null);
setNewKeyFilePath("");
setSelectedCredentialType(null);
}, [onImportKey]);
const effectiveGroupDefaults = useMemo(() => {
const currentGroupPath = form.group || defaultGroup;
if (currentGroupPath && groupConfigs.length > 0) {
@@ -244,6 +295,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
);
const effectiveTelnetThemeId =
form.protocols?.find((p) => p.protocol === "telnet")?.theme || effectiveThemeId;
const effectiveTelnetPort = resolveDetailsTelnetPort(form, effectiveGroupDefaults);
const effectiveTelnetUsername = resolveDetailsTelnetUsername(form, effectiveGroupDefaults);
const effectiveTelnetPassword = resolveDetailsTelnetPassword(form, effectiveGroupDefaults);
const distroOptions = useMemo(
() =>
LINUX_DISTRO_OPTION_IDS.map((value) => ({
@@ -424,17 +478,42 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
const cleaned: Host = {
const finalPort =
form.protocol === "telnet"
? form.port
: form.port ?? (groupDefaults?.port ? undefined : 22);
let cleaned: Host = {
...formWithoutProxyDraft,
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
label: finalLabel,
group: finalGroup,
tags: form.tags || [],
port: form.port ?? (groupDefaults?.port ? undefined : 22),
port: finalPort,
// Clear password if savePassword is explicitly set to false
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
};
cleaned = normalizePrimaryTelnetState(cleaned);
if (
onImportKey &&
pendingReferenceKeyPath &&
cleaned.identityFilePaths?.includes(pendingReferenceKeyPath)
) {
const fileName = pendingReferenceKeyPath.split('/').pop() || pendingReferenceKeyPath;
const key = onImportKey({
source: 'reference',
filePath: pendingReferenceKeyPath,
label: fileName,
privateKey: '',
category: 'key',
});
cleaned = {
...cleaned,
identityFileId: key.id,
identityFilePaths: [pendingReferenceKeyPath],
authMethod: "key",
};
}
const preserveLegacyTheme = initialData?.theme != null && cleaned.themeOverride !== false;
const preserveLegacyFontFamily = initialData?.fontFamily != null && cleaned.fontFamilyOverride !== false;
const preserveLegacyFontSize = initialData?.fontSize != null && cleaned.fontSizeOverride !== false;
@@ -551,6 +630,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
identityFileId: undefined,
identityFilePaths: undefined,
}));
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
setCredentialPopoverOpen(false);
setIdentitySuggestionsOpen(false);
@@ -687,7 +767,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
...(form.protocols || []),
{
protocol: "telnet" as const,
port: form.telnetPort || 23,
port: effectiveTelnetPort,
enabled: true,
theme: themeId,
},
@@ -858,15 +938,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{selectedIdentity.label}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
title={t("common.clear")}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.clear")}</TooltipContent>
</Tooltip>
</div>
) : form.identityId ? (
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
@@ -876,15 +960,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.identity.missing")}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
title={t("common.clear")}
>
<X size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={clearIdentity}
>
<X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.clear")}</TooltipContent>
</Tooltip>
</div>
) : (
(() => {
@@ -939,29 +1027,33 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}}
className="h-10 pr-9"
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setIdentitySuggestionsOpen((prev) => {
if (prev) return false;
const q = (form.username || "")
.toLowerCase()
.trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
return matches.length > 0;
});
}}
title={t("hostDetails.identity.suggestions")}
>
<ChevronDown size={16} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
setIdentitySuggestionsOpen((prev) => {
if (prev) return false;
const q = (form.username || "")
.toLowerCase()
.trim();
const matches = q
? identities.filter(
(i) =>
i.label.toLowerCase().includes(q) ||
i.username.toLowerCase().includes(q),
)
: identities;
return matches.length > 0;
});
}}
>
<ChevronDown size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.identity.suggestions")}</TooltipContent>
</Tooltip>
</div>
</PopoverTrigger>
<PopoverContent
@@ -1043,14 +1135,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onChange={(e) => update("password", e.target.value)}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</TooltipTrigger>
<TooltipContent>{showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}</TooltipContent>
</Tooltip>
</div>
)}
@@ -1073,9 +1169,14 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
{keyPath}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs w-0 flex-1 truncate font-mono cursor-default">
{keyPath}
</span>
</TooltipTrigger>
<TooltipContent>{keyPath}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
@@ -1083,6 +1184,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClick={() => {
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
update("identityFilePaths", paths.length > 0 ? paths : undefined);
if (keyPath === pendingReferenceKeyPath) {
setPendingReferenceKeyPath(null);
}
}}
>
<Trash2 size={12} />
@@ -1111,6 +1215,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClick={() => {
update("identityFileId", undefined);
update("authMethod", "password");
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
}}
>
@@ -1205,6 +1310,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
update("identityFileId", val);
update("authMethod", "key");
update("identityFilePaths", undefined);
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.keys.search")}
@@ -1241,6 +1347,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
update("identityFileId", val);
update("authMethod", "certificate");
update("identityFilePaths", undefined);
setPendingReferenceKeyPath(null);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.certs.search")}
@@ -1276,37 +1383,34 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onKeyDown={(e) => {
if (e.key === "Enter" && newKeyFilePath.trim()) {
e.preventDefault();
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
setNewKeyFilePath("");
addLocalKeyFilePath(newKeyFilePath);
}
}}
/>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
title={t("hostDetails.credential.browseKeyFile")}
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
}
}}
>
<FolderOpen size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
addLocalKeyFilePath(filePath);
}
}}
>
<FolderOpen size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
@@ -1715,19 +1819,89 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
value={form.backspaceBehavior ?? ""}
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
<Select
value={form.backspaceBehavior ?? "default"}
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
>
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
<option value="ctrl-h">^H (0x08)</option>
</select>
<SelectTrigger className="h-8 w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Per-host keepalive override */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<HeartPulse size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.keepalive")}</p>
</div>
<ToggleRow
label={t("hostDetails.keepalive.override")}
enabled={!!form.keepaliveOverride}
onToggle={() => {
const next = !form.keepaliveOverride;
update("keepaliveOverride", next);
// Seed sensible per-host defaults the first time the user
// turns the override on so the inputs aren't empty.
if (next) {
if (form.keepaliveInterval == null) update("keepaliveInterval", 0);
if (form.keepaliveCountMax == null) update("keepaliveCountMax", 3);
}
}}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.keepalive.desc")}
</p>
{form.keepaliveOverride && (
<div className="space-y-2 pt-1">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.keepalive.interval")}</p>
<input
type="number"
min={0}
max={3600}
className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs"
value={form.keepaliveInterval ?? 0}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!Number.isFinite(v)) return;
if (v < 0 || v > 3600) return;
update("keepaliveInterval", v);
}}
/>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.keepalive.countMax")}</p>
<input
type="number"
min={1}
max={100}
className="h-8 w-24 rounded-md border border-input bg-background px-2 text-xs"
value={form.keepaliveCountMax ?? 3}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!Number.isFinite(v)) return;
if (v < 1 || v > 100) return;
update("keepaliveCountMax", v);
}}
/>
</div>
{(form.keepaliveInterval ?? 0) === 0 && (
<p className="text-xs text-muted-foreground break-words pl-1">
{t("hostDetails.keepalive.disabledHint")}
</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">
@@ -1925,42 +2099,46 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{form.telnetEnabled || form.protocol === "telnet" ? (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-2 py-1">
<div className="flex-1 min-w-0 h-10 flex items-center gap-2 bg-secondary/70 border border-border/70 rounded-md px-3">
<span className="text-xs text-muted-foreground">{t("hostDetails.telnetOn")}</span>
<Input
type="number"
value={form.telnetPort || 23}
onChange={(e) => update("telnetPort", Number(e.target.value))}
className="h-8 w-16 text-center"
/>
<span className="text-xs text-muted-foreground">{t("hostDetails.port")}</span>
<div className="ml-auto w-1/2 min-w-0 flex items-center gap-2 justify-end">
<Input
type="number"
value={effectiveTelnetPort}
onChange={(e) => update("telnetPort", parseOptionalPortInput(e.target.value))}
className="h-8 flex-1 min-w-0 text-center"
/>
<span className="text-xs text-muted-foreground">{t("hostDetails.port")}</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => update("telnetEnabled", false)}
>
<X size={14} />
</Button>
{form.protocol !== "telnet" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => update("telnetEnabled", false)}
>
<X size={14} />
</Button>
)}
</div>
{/* Telnet Credentials */}
<p className="text-xs font-semibold">{t("hostDetails.telnet.credentials")}</p>
<Input
placeholder={t("hostDetails.telnet.username")}
value={form.telnetUsername || form.username || ""}
onChange={(e) =>
update("telnetUsername" as keyof Host, e.target.value)
}
<Input
placeholder={t("hostDetails.telnet.username")}
value={effectiveTelnetUsername}
onChange={(e) =>
update("telnetUsername" as keyof Host, e.target.value)
}
className="h-10"
/>
<Input
placeholder={t("hostDetails.telnet.password")}
type="password"
value={form.telnetPassword || form.password || ""}
onChange={(e) =>
update("telnetPassword" as keyof Host, e.target.value)
placeholder={t("hostDetails.telnet.password")}
type="password"
value={effectiveTelnetPassword}
onChange={(e) =>
update("telnetPassword" as keyof Host, e.target.value)
}
className="h-10"
/>
@@ -2009,7 +2187,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="w-full h-10 justify-start gap-2 border border-dashed border-border/60"
onClick={() => {
update("telnetEnabled", true);
update("telnetPort", 23);
}}
>
<Plus size={14} />

View File

@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { GroupConfig, Host } from "../types.ts";
import { getHostTreeDisplayDetails } from "./HostTreeView.tsx";
const baseHost: Host = {
id: "host-1",
label: "Router",
hostname: "router.example.com",
username: "ssh-user",
port: 2222,
protocol: "telnet",
tags: [],
os: "linux",
createdAt: 1,
};
test("HostTreeView display details include inherited telnet defaults", () => {
const host: Host = {
...baseHost,
group: "network",
username: "ssh-user",
port: 2222,
telnetUsername: undefined,
telnetPort: undefined,
};
const groupConfigs: GroupConfig[] = [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPort: 2325,
}];
assert.deepEqual(getHostTreeDisplayDetails(host, groupConfigs), {
protocol: "telnet",
username: "group-telnet-user",
port: 2325,
});
});
test("HostTreeView display details keep explicit cleared telnet username", () => {
const host: Host = {
...baseHost,
group: "network",
telnetUsername: "",
};
const groupConfigs: GroupConfig[] = [{
path: "network",
telnetUsername: "group-telnet-user",
telnetPort: 2325,
}];
assert.deepEqual(getHostTreeDisplayDetails(host, groupConfigs), {
protocol: "telnet",
username: "",
port: 2325,
});
});

View File

@@ -2,10 +2,11 @@ import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Moni
import React, { useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
import { sanitizeHost } from '../domain/host';
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { cn } from '../lib/utils';
import { GroupNode, Host } from '../types';
import { GroupConfig, GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar';
@@ -38,6 +39,7 @@ interface HostTreeViewProps {
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
groupConfigs?: GroupConfig[];
}
interface TreeNodeProps {
@@ -65,6 +67,7 @@ interface TreeNodeProps {
toggleHostSelection?: (hostId: string) => void;
getDropTargetClasses?: (target: string) => string;
setDragOverDropTarget?: (target: string | null) => void;
groupConfigs: GroupConfig[];
}
@@ -93,6 +96,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
groupConfigs,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
@@ -255,13 +259,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
))}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
groupConfigs={groupConfigs}
/>
))}
{/* Hosts in this group */}
{sortedHosts.map((host) => (
@@ -276,11 +281,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
groupConfigs={groupConfigs}
/>
))}
</CollapsibleContent>
</Collapsible>
</div>
@@ -300,8 +306,28 @@ interface HostTreeItemProps {
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
groupConfigs: GroupConfig[];
}
export const getHostTreeDisplayDetails = (
host: Host,
groupConfigs: GroupConfig[] = [],
) => {
const displayHost = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
const isTelnet = displayHost.protocol === 'telnet';
return {
protocol: displayHost.protocol,
username: isTelnet
? (resolveTelnetUsername(displayHost) || '')
: (displayHost.username?.trim() || ''),
port: isTelnet
? resolveTelnetPort(displayHost)
: (displayHost.port ?? 22),
};
};
const HostTreeItem: React.FC<HostTreeItemProps> = ({
host,
depth,
@@ -315,18 +341,19 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
groupConfigs,
}) => {
const { t } = useI18n();
const paddingLeft = `${depth * 20 + 12}px`;
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
const isTelnet = host.protocol === 'telnet';
const displayUsername = isTelnet
? (host.telnetUsername?.trim() || host.username?.trim() || '')
: (host.username?.trim() || '');
const displayPort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
const displayDetails = useMemo(
() => getHostTreeDisplayDetails(host, groupConfigs),
[groupConfigs, host],
);
const displayProtocol = displayDetails.protocol;
const displayUsername = displayDetails.username;
const displayPort = displayDetails.port;
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
return (
@@ -371,11 +398,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{host.protocol && host.protocol !== 'ssh' && (
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{host.protocol.toUpperCase()}
</span>
)}
{displayProtocol && displayProtocol !== 'ssh' && (
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{displayProtocol.toUpperCase()}
</span>
)}
{tags.length > 0 && (
<span className="text-xs opacity-60">
{tags.slice(0, 2).join(', ')}
@@ -445,6 +472,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
toggleHostSelection,
getDropTargetClasses,
setDragOverDropTarget,
groupConfigs = [],
}) => {
const { t } = useI18n();
@@ -568,9 +596,10 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
/>
getDropTargetClasses={getDropTargetClasses}
setDragOverDropTarget={setDragOverDropTarget}
groupConfigs={groupConfigs}
/>
))}
{/* Ungrouped hosts at root level */}
@@ -586,9 +615,10 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
groupConfigs={groupConfigs}
/>
))}
{/* Empty state */}

View File

@@ -3,6 +3,9 @@ import {
ChevronDown,
ChevronRight,
Edit2,
Eye,
EyeOff,
FileKey,
Info,
Key,
LayoutGrid,
@@ -18,7 +21,8 @@ import {
import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { resolveHostAuth } from "../domain/sshAuth";
import { sanitizeCredentialValue } from "../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../domain/sshAuth";
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -50,6 +54,7 @@ import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
// Import utilities and components from keychain module
import {
@@ -175,7 +180,7 @@ echo $3 >> "$FILE"`);
switch (activeFilter) {
case "key":
result = result.filter(
(k) => k.source === "generated" || k.source === "imported",
(k) => k.source === "generated" || k.source === "imported" || k.source === "reference",
);
break;
case "certificate":
@@ -1029,16 +1034,26 @@ echo $3 >> "$FILE"`);
keys,
identities,
});
const exportKeyAuth = resolveBridgeKeyAuth({
key: exportAuth.key,
fallbackIdentityFilePaths: exportAuth.authMethod === "password" || exportAuth.keyId
? undefined
: exportHost.identityFilePaths,
passphrase: exportAuth.passphrase,
});
const exportPassword = sanitizeCredentialValue(exportAuth.password);
// Need either password or a usable key to run remote command.
if (!exportAuth.password && !exportAuth.key?.privateKey) {
if (
!exportPassword &&
!exportKeyAuth.privateKey &&
!exportKeyAuth.identityFilePaths?.length
) {
throw new Error(
t("keychain.export.missingCredentials"),
);
}
const hostPrivateKey = exportAuth.key?.privateKey;
// Escape the public key for shell (single quotes, escape existing quotes)
const escapedPublicKey = panel.key.publicKey.replace(
/'/g,
@@ -1059,8 +1074,14 @@ echo $3 >> "$FILE"`);
hostname: exportHost.hostname,
username: exportAuth.username,
port: exportHost.port || 22,
password: exportAuth.password,
privateKey: hostPrivateKey,
password: exportPassword,
privateKey: exportKeyAuth.privateKey,
certificate: exportAuth.key?.certificate,
publicKey: exportAuth.key?.publicKey,
keyId: exportAuth.keyId,
keySource: exportAuth.key?.source,
passphrase: exportKeyAuth.passphrase,
identityFilePaths: exportKeyAuth.identityFilePaths,
command,
timeout: 30000,
enableKeyboardInteractive: true,
@@ -1140,71 +1161,139 @@ echo $3 >> "$FILE"`);
/>
</div>
<div className="space-y-2">
<Label className="text-destructive">
{t("keychain.edit.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.edit.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("keychain.edit.certificate")}
</Label>
<Textarea
value={draftKey.certificate || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, certificate: e.target.value })
}
placeholder={t("keychain.edit.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.edit.keyExport")}
</span>
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
<Info size={10} className="text-muted-foreground" />
{/* Reference key: show file path read-only */}
{draftKey.source === 'reference' && draftKey.filePath && (
<div className="space-y-2">
<Label className="text-muted-foreground">
{t("keychain.edit.filePath")}
</Label>
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<FileKey size={14} className="text-primary shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs font-mono truncate cursor-default">
{draftKey.filePath}
</span>
</TooltipTrigger>
<TooltipContent>{draftKey.filePath}</TooltipContent>
</Tooltip>
</div>
</div>
<Button
className="w-full h-11"
onClick={() => openKeyExport(panel.key)}
>
{t("keychain.edit.exportToHost")}
</Button>
)}
{/* Managed key: show private key editor */}
{draftKey.source !== 'reference' && (
<div className="space-y-2">
<Label className="text-destructive">
{t("keychain.edit.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>
)}
{draftKey.source !== 'reference' && (
<div className="space-y-2">
<Label className="text-muted-foreground">
{t("keychain.edit.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>
)}
{draftKey.source !== 'reference' && (
<div className="space-y-2">
<Label className="text-muted-foreground">
{t("keychain.edit.certificate")}
</Label>
<Textarea
value={draftKey.certificate || ""}
onChange={(e) =>
setDraftKey({ ...draftKey, certificate: e.target.value })
}
placeholder={t("keychain.edit.certificatePlaceholder")}
className="min-h-[60px] font-mono text-xs"
/>
</div>
)}
{/* Passphrase section */}
<div className="space-y-2">
<Label>{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input
type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''}
onChange={(e) =>
setDraftKey({ ...draftKey, passphrase: e.target.value })
}
placeholder={t('keychain.generate.passphrasePlaceholder')}
className="pr-10"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="editSavePassphrase"
checked={draftKey.savePassphrase || false}
onChange={(e) =>
setDraftKey({ ...draftKey, savePassphrase: e.target.checked })
}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="editSavePassphrase" className="text-sm font-normal cursor-pointer">
{t('keychain.generate.savePassphrase')}
</Label>
</div>
</div>
{/* Key Export section - only for managed keys */}
{draftKey.source !== 'reference' && (
<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.edit.keyExport")}
</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={() => openKeyExport(panel.key)}
>
{t("keychain.edit.exportToHost")}
</Button>
</div>
)}
{/* Save button */}
<Button
className="w-full h-11 mt-4"
disabled={
!draftKey.label?.trim() || !draftKey.privateKey?.trim()
!draftKey.label?.trim() ||
(draftKey.source !== 'reference' && !draftKey.privateKey?.trim())
}
onClick={() => {
if (draftKey.id) {

View File

@@ -1,113 +0,0 @@
import { ShieldCheck } from 'lucide-react';
import React from 'react';
import { Host } from '../types';
import { DistroAvatar } from './DistroAvatar';
import { Button } from './ui/button';
export interface HostKeyInfo {
hostname: string;
port: number;
keyType: string; // ssh-rsa, ssh-ed25519, ecdsa-sha2-nistp256, etc.
fingerprint: string; // SHA256 fingerprint
publicKey?: string; // Full public key
}
interface KnownHostConfirmDialogProps {
host: Host;
hostKeyInfo: HostKeyInfo;
onClose: () => void;
onContinue: () => void; // Continue without adding to known hosts
onAddAndContinue: () => void; // Add to known hosts and continue
}
const KnownHostConfirmDialog: React.FC<KnownHostConfirmDialogProps> = ({
host,
hostKeyInfo,
onClose,
onContinue,
onAddAndContinue,
}) => {
return (
<div className="flex flex-col items-center justify-center h-full p-8 max-w-2xl mx-auto">
{/* Header with host info */}
<div className="flex items-center gap-3 mb-6">
<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>
<Button variant="outline" size="sm" className="ml-4">
Show logs
</Button>
</div>
{/* Progress indicator */}
<div className="flex items-center gap-3 w-full max-w-md mb-8">
<div className="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
<div className="h-2 w-2 rounded-full bg-primary-foreground" />
</div>
<div className="flex-1 h-0.5 bg-primary" />
<div className="h-8 w-8 rounded-full bg-primary/20 border-2 border-primary text-primary flex items-center justify-center">
<ShieldCheck 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>
{/* Warning message */}
<div className="text-center mb-6">
<h3 className="text-lg font-semibold text-amber-500 mb-2">
Are you sure you want to connect?
</h3>
<p className="text-sm text-muted-foreground">
The authenticity of <span className="font-mono font-medium text-foreground">{hostKeyInfo.hostname}</span> can not be established.
</p>
</div>
{/* Fingerprint info */}
<div className="w-full max-w-md space-y-3 mb-8">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{hostKeyInfo.keyType} fingerprint is SHA256:</span>
</div>
<div className="bg-secondary/80 rounded-lg p-3 border border-border/60">
<code className="text-sm font-mono text-foreground break-all">
{hostKeyInfo.fingerprint}
</code>
</div>
<p className="text-sm text-muted-foreground">
Do you want to add it to the list of known hosts?
</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-3">
<Button
variant="secondary"
className="min-w-[100px]"
onClick={onClose}
>
Close
</Button>
<Button
variant="outline"
className="min-w-[100px]"
onClick={onContinue}
>
Continue
</Button>
<Button
className="min-w-[140px]"
onClick={onAddAndContinue}
>
Add and continue
</Button>
</div>
</div>
);
};
export default KnownHostConfirmDialog;

View File

@@ -22,6 +22,7 @@ import React, {
import { useI18n } from "../application/i18n/I18nProvider";
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode";
import { fingerprintFromPublicKey } from "../domain/knownHosts";
import { STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -37,6 +38,7 @@ import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { toast } from "./ui/toast";
interface KnownHostsManagerProps {
@@ -79,12 +81,20 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
hostname = "(hashed)";
}
const fullPublicKey = `${keyType} ${publicKey}`;
// Compute the fingerprint up front so the SSH host verifier can match
// against this record directly instead of re-deriving on every connect —
// the re-derivation path is where the false "fingerprint changed"
// warnings in #972 originated.
const fingerprint = fingerprintFromPublicKey(fullPublicKey);
parsed.push({
id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
hostname,
port,
keyType,
publicKey: publicKey.slice(0, 64) + "...",
publicKey: fullPublicKey,
fingerprint: fingerprint || undefined,
discoveredAt: Date.now(),
});
} catch {
@@ -122,27 +132,35 @@ const HostItem = React.memo<HostItemProps>(
{/* Quick action buttons on hover */}
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{!converted && (
<button
className="p-1 rounded hover:bg-primary/20 text-primary"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
title={t("action.convertToHost")}
>
<ArrowRight size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-primary/20 text-primary"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
>
<ArrowRight size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)}
<button
className="p-1 rounded hover:bg-destructive/20 text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(knownHost.id);
}}
title={t("action.remove")}
>
<Trash2 size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
className="p-1 rounded hover:bg-destructive/20 text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(knownHost.id);
}}
>
<Trash2 size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("action.remove")}</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
@@ -193,18 +211,22 @@ const HostItem = React.memo<HostItemProps>(
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!converted && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
title={t("action.convertToHost")}
>
<ArrowRight size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onConvertToHost(knownHost);
}}
>
<ArrowRight size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)}
</div>
</div>

View File

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

View File

@@ -25,7 +25,7 @@ export interface PassphraseRequest {
interface PassphraseModalProps {
request: PassphraseRequest | null;
onSubmit: (requestId: string, passphrase: string) => void;
onSubmit: (requestId: string, passphrase: string, remember: boolean) => void;
onCancel: (requestId: string) => void;
onSkip?: (requestId: string) => void;
}
@@ -40,6 +40,7 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
const [passphrase, setPassphrase] = useState("");
const [showPassphrase, setShowPassphrase] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [rememberPassphrase, setRememberPassphrase] = useState(true);
// Reset state when request changes
useEffect(() => {
@@ -47,14 +48,15 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
setPassphrase("");
setShowPassphrase(false);
setIsSubmitting(false);
setRememberPassphrase(true);
}
}, [request]);
const handleSubmit = useCallback(() => {
if (!request || isSubmitting || !passphrase) return;
setIsSubmitting(true);
onSubmit(request.requestId, passphrase);
}, [request, passphrase, onSubmit, isSubmitting]);
onSubmit(request.requestId, passphrase, rememberPassphrase);
}, [request, passphrase, onSubmit, isSubmitting, rememberPassphrase]);
const handleCancel = useCallback(() => {
if (!request) return;
@@ -82,15 +84,15 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
return (
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
<DialogContent className="sm:max-w-[500px]" hideCloseButton>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<div className="min-w-0 flex-1">
<DialogTitle>{t("passphrase.title")}</DialogTitle>
<DialogDescription className="mt-1">
<DialogDescription className="mt-1 break-words">
{request.hostname
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
: t("passphrase.desc", { keyName: keyDisplayName })}
@@ -125,9 +127,21 @@ export const PassphraseModal: React.FC<PassphraseModalProps> = ({
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<p className="text-xs text-muted-foreground">
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
<p className="text-xs text-muted-foreground break-all">
{t("passphrase.keyPath")}: <code className="text-xs break-all">{request.keyPath}</code>
</p>
<label className="flex items-center gap-2 cursor-pointer select-none mt-2">
<input
type="checkbox"
checked={rememberPassphrase}
onChange={(e) => setRememberPassphrase(e.target.checked)}
disabled={isSubmitting}
className="accent-primary"
/>
<span className="text-xs text-muted-foreground">
{t("passphrase.remember")}
</span>
</label>
</div>
</div>

View File

@@ -75,6 +75,7 @@ interface PortForwardingProps {
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const PortForwarding: React.FC<PortForwardingProps> = ({
@@ -88,6 +89,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
terminalSettings,
}) => {
const { t } = useI18n();
const {
@@ -169,6 +171,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}
},
rule.autoStart, // Enable reconnect for auto-start rules
terminalSettings,
);
// Show error from result only if not already shown
if (!result.success && result.error && !errorShown) {
@@ -186,7 +189,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t],
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t, terminalSettings],
);
// Stop a port forwarding tunnel

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ 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 { sanitizePortForwardingRulesForSync } from "../application/syncPayload";
import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
@@ -20,6 +21,7 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
@@ -49,6 +51,11 @@ type SettingsState = ReturnType<typeof useSettingsState>;
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const settingsTabTriggerClassName =
"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 overflow-hidden";
const settingsTabIconClassName = "shrink-0";
const settingsTabLabelClassName = "min-w-0 truncate";
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const availableFonts = useAvailableFonts();
@@ -58,6 +65,12 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalThemeDarkId={settings.terminalThemeDarkId}
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
terminalThemeLightId={settings.terminalThemeLightId}
setTerminalThemeLightId={settings.setTerminalThemeLightId}
lightUiThemeId={settings.lightUiThemeId}
darkUiThemeId={settings.darkUiThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
@@ -127,13 +140,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
// Strip transient runtime fields before passing to sync
const portForwardingRulesForSync = useMemo(
() =>
portForwardingRules.map((rule) => ({
...rule,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
})),
() => sanitizePortForwardingRulesForSync(portForwardingRules) ?? [],
[portForwardingRules],
);
@@ -187,13 +194,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<div className="flex items-center justify-between px-4 py-2">
<h1 className="text-lg font-semibold">{t("settings.title")}</h1>
{!isMac && (
<button
onClick={handleClose}
className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
title={t("common.close")}
>
<X size={16} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleClose}
className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
>
<X size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("common.close")}</TooltipContent>
</Tooltip>
)}
</div>
</div>
@@ -208,51 +219,59 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<TabsList className="flex flex-col h-auto bg-transparent gap-1 p-0 justify-start">
<TabsTrigger
value="application"
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"
className={settingsTabTriggerClassName}
>
<AppWindow size={14} /> {t("settings.tab.application")}
<AppWindow size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.application")}</span>
</TabsTrigger>
<TabsTrigger
value="appearance"
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"
className={settingsTabTriggerClassName}
>
<Palette size={14} /> {t("settings.tab.appearance")}
<Palette size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.appearance")}</span>
</TabsTrigger>
<TabsTrigger
value="terminal"
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"
className={settingsTabTriggerClassName}
>
<TerminalSquare size={14} /> {t("settings.tab.terminal")}
<TerminalSquare size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.terminal")}</span>
</TabsTrigger>
<TabsTrigger
value="shortcuts"
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"
className={settingsTabTriggerClassName}
>
<Keyboard size={14} /> {t("settings.tab.shortcuts")}
<Keyboard size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.shortcuts")}</span>
</TabsTrigger>
<TabsTrigger
value="file-associations"
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"
className={settingsTabTriggerClassName}
>
<FileType size={14} /> {t("settings.tab.sftpFileAssociations")}
<FileType size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.sftpFileAssociations")}</span>
</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"
className={settingsTabTriggerClassName}
>
<Sparkles size={14} /> AI
<Sparkles size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>AI</span>
</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"
className={settingsTabTriggerClassName}
>
<Cloud size={14} /> {t("settings.tab.syncCloud")}
<Cloud size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.syncCloud")}</span>
</TabsTrigger>
<TabsTrigger
value="system"
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"
className={settingsTabTriggerClassName}
>
<HardDrive size={14} /> {t("settings.tab.system")}
<HardDrive size={14} className={settingsTabIconClassName} />
<span className={settingsTabLabelClassName}>{t("settings.tab.system")}</span>
</TabsTrigger>
</TabsList>
</div>

View File

@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SftpFileEntry } from "../types.ts";
import {
getSftpListUploadFilesTargetPath,
getSftpTreeUploadFilesTargetPath,
getSftpUploadFilesLabelKey,
getSftpUploadFolderLabelKey,
shouldShowSftpUploadFolderMenu,
shouldShowSftpUploadFilesMenu,
} from "./sftp/sftpUploadMenu.ts";
const baseEntry: SftpFileEntry = {
name: "notes.txt",
type: "file",
size: 1,
sizeFormatted: "1 B",
lastModified: 1,
lastModifiedFormatted: "now",
};
test("upload file menu is shown only for remote panes with a picker upload handler", () => {
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: false, hasFileListUpload: true }), true);
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: true, hasFileListUpload: true }), false);
assert.equal(shouldShowSftpUploadFilesMenu({ isLocal: false, hasFileListUpload: false }), false);
});
test("upload folder menu is shown only for remote panes with a folder upload handler", () => {
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: false, hasFolderUpload: true }), true);
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: true, hasFolderUpload: true }), false);
assert.equal(shouldShowSftpUploadFolderMenu({ isLocal: false, hasFolderUpload: false }), false);
});
test("directory row upload targets that directory without using its name in the label", () => {
const directoryEntry: SftpFileEntry = {
...baseEntry,
name: "a-very-long-folder-name-that-should-not-expand-the-context-menu",
type: "directory",
};
assert.equal(
getSftpListUploadFilesTargetPath(directoryEntry, "/home/app"),
"/home/app/a-very-long-folder-name-that-should-not-expand-the-context-menu",
);
assert.equal(getSftpUploadFilesLabelKey(directoryEntry), "sftp.context.uploadFilesHere");
assert.equal(getSftpUploadFolderLabelKey(directoryEntry), "sftp.context.uploadFolderHere");
});
test("file row upload targets the current directory", () => {
assert.equal(getSftpListUploadFilesTargetPath(baseEntry, "/home/app"), undefined);
assert.equal(getSftpUploadFilesLabelKey(baseEntry), "sftp.context.uploadFiles");
assert.equal(getSftpUploadFolderLabelKey(baseEntry), "sftp.context.uploadFolder");
});
test("tree directory row upload targets that directory", () => {
const directoryEntry: SftpFileEntry = {
...baseEntry,
name: "logs",
type: "directory",
};
assert.equal(getSftpTreeUploadFilesTargetPath(directoryEntry, "/var/logs"), "/var/logs");
assert.equal(getSftpUploadFilesLabelKey(directoryEntry), "sftp.context.uploadFilesHere");
assert.equal(getSftpUploadFolderLabelKey(directoryEntry), "sftp.context.uploadFolderHere");
});
test("tree file row upload targets the file parent directory", () => {
assert.equal(getSftpTreeUploadFilesTargetPath(baseEntry, "/var/logs/app.log"), "/var/logs");
assert.equal(getSftpUploadFilesLabelKey(baseEntry), "sftp.context.uploadFiles");
assert.equal(getSftpUploadFolderLabelKey(baseEntry), "sftp.context.uploadFolder");
});

View File

@@ -19,13 +19,14 @@ import { editorTabStore } from "../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { getParentPath } from "../application/state/sftp/utils";
import { getParentPath, isConcreteTransferTargetPath } 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 { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { DistroAvatar } from "./DistroAvatar";
import { SftpPaneView } from "./sftp/SftpPaneView";
@@ -71,6 +72,7 @@ interface SftpSidePanelProps {
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
onRequestTerminalFocus?: () => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
@@ -98,6 +100,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
setEditorWordWrap,
onGetTerminalCwd,
onRequestTerminalFocus,
terminalSettings,
}) => {
const { t } = useI18n();
const hostWriteSource = writableHosts ?? hosts;
@@ -119,7 +122,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
autoConnectLocalOnMount: false,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const {
@@ -130,6 +134,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
mkdirLocal,
deleteLocalFile,
listLocalDir,
listDrives,
openPath,
} = useSftpBackend();
const sftpRef = useRef(sftp);
@@ -293,6 +299,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
listDrives,
});
const {
@@ -570,18 +577,35 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
const connection = sftpRef.current.leftPane.connection;
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
if (task.targetConnectionId === "local") {
try {
const result = await openPath(revealPath);
if (result.success) return;
} catch {
// Show the localized error below.
}
toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP");
return;
}
if (!connection || connection.isLocal) return;
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
await sftpRef.current.navigateTo("left", revealPath, { force: true });
},
[],
[openPath, t],
);
const canRevealTransferTarget = useCallback(
(task: TransferTask) => {
if (task.status !== "completed") return false;
if (!isConcreteTransferTargetPath(task)) return false;
if (task.targetConnectionId === "local") {
return true;
}
if (task.direction !== "upload" && task.direction !== "remote-to-remote") return false;
const connection = sftp.leftPane.connection;
@@ -602,6 +626,24 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
[sftp.leftPane.connection],
);
const canCopyTransferTargetPath = useCallback(
(task: TransferTask) => task.status === "completed" && isConcreteTransferTargetPath(task),
[],
);
const handleCopyTransferTargetPath = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
try {
await navigator.clipboard.writeText(task.targetPath);
toast.success(t("sftp.transfers.copyTargetPathSuccess"), "SFTP");
} catch {
toast.error(t("sftp.transfers.copyTargetPathError"), "SFTP");
}
},
[t],
);
// 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
@@ -648,18 +690,22 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
size="sm"
className="h-5 w-5 rounded-sm shrink-0"
/>
<div
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
>
<span className="font-medium">
{displayHost.label}
</span>
<span className="mx-1 text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground">
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate cursor-default">
<span className="font-medium">
{displayHost.label}
</span>
<span className="mx-1 text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground">
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
{`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
</TooltipContent>
</Tooltip>
</div>
</div>
)}
@@ -696,6 +742,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
allTransfers={sftp.transfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={handleCopyTransferTargetPath}
/>
</div>
@@ -705,6 +753,10 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftp={sftp}
visibleTransfers={visibleTransfers}
showTransferQueue={false}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={handleCopyTransferTargetPath}
showHostPickerLeft={showHostPickerLeft}
showHostPickerRight={showHostPickerRight}
hostSearchLeft={hostSearchLeft}
@@ -767,7 +819,11 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
prev.initialLocation?.path === next.initialLocation?.path;
prev.initialLocation?.path === next.initialLocation?.path &&
// Only the keepalive fields of terminalSettings affect SFTP connection
// resolution today; compare them directly rather than the whole object.
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
export const SftpSidePanel = memo(SftpSidePanelInner, sidePanelAreEqual);
SftpSidePanel.displayName = "SftpSidePanel";

View File

@@ -136,3 +136,31 @@ test("keeps reveal target and child toggle as separate buttons", () => {
assert.match(markup, /aria-expanded="false"/);
assert.match(markup, /aria-controls="children-transfer-1"/);
});
test("renders explicit target actions for completed local downloads", () => {
const markup = renderTransferItem(
{
...baseTask,
id: "download-1",
fileName: "report.pdf",
sourcePath: "/remote/report.pdf",
targetPath: "/Users/alice/Downloads/report.pdf",
targetConnectionId: "local",
direction: "download",
status: "completed",
error: undefined,
transferredBytes: 1024,
},
{
canRevealTarget: true,
onRevealTarget: () => {},
canCopyTargetPath: true,
onCopyTargetPath: () => {},
},
);
assert.match(markup, /aria-label="Open target folder: report\.pdf"/);
assert.match(markup, /aria-label="Copy target path: report\.pdf"/);
assert.match(markup, /lucide-folder-open/);
assert.match(markup, /lucide-clipboard-copy/);
});

View File

@@ -19,12 +19,13 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, ProxyProfile, SSHKey } from "../types";
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
@@ -66,6 +67,7 @@ interface SftpViewProps {
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const SftpViewInner: React.FC<SftpViewProps> = ({
@@ -84,6 +86,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keyBindings,
editorWordWrap,
setEditorWordWrap,
terminalSettings,
}) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
@@ -109,7 +112,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
...fileWatchHandlers,
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
// Pre-resolve group defaults so SFTP connections inherit group config
const effectiveHosts = useMemo(() => {
@@ -133,6 +137,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
mkdirLocal,
deleteLocalFile,
listLocalDir,
listDrives,
openPath,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
@@ -259,6 +265,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
listDrives,
});
const visibleTransfers = useMemo(
@@ -266,6 +273,75 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
[sftp.transfers],
);
const getTransferTargetDirectory = useCallback(
(task: TransferTask) => (task.isDirectory ? task.targetPath : getParentPath(task.targetPath)),
[],
);
const findRemoteTransferTargetTab = useCallback((task: TransferTask) => {
const state = sftpRef.current;
for (const side of ["left", "right"] as const) {
const tabs = side === "left" ? state.leftTabs.tabs : state.rightTabs.tabs;
const pane = tabs.find((tab) => tab.connection?.id === task.targetConnectionId);
if (pane?.connection && !pane.connection.isLocal) {
return { side, tabId: pane.id };
}
}
return null;
}, []);
const canRevealTransferTarget = useCallback(
(task: TransferTask) => {
if (task.status !== "completed") return false;
if (!isConcreteTransferTargetPath(task)) return false;
if (task.targetConnectionId === "local") {
return true;
}
return !!findRemoteTransferTargetTab(task);
},
[findRemoteTransferTargetTab],
);
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
const targetDirectory = getTransferTargetDirectory(task);
if (task.targetConnectionId === "local") {
try {
const result = await openPath(targetDirectory);
if (result.success) return;
} catch {
// Show the localized error below.
}
toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP");
return;
}
const targetTab = findRemoteTransferTargetTab(task);
if (!targetTab) return;
await sftpRef.current.navigateTo(targetTab.side, targetDirectory, { force: true, tabId: targetTab.tabId });
},
[findRemoteTransferTargetTab, getTransferTargetDirectory, openPath, t],
);
const canCopyTransferTargetPath = useCallback(
(task: TransferTask) => task.status === "completed" && isConcreteTransferTargetPath(task),
[],
);
const handleCopyTransferTargetPath = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
try {
await navigator.clipboard.writeText(task.targetPath);
toast.success(t("sftp.transfers.copyTargetPathSuccess"), "SFTP");
} catch {
toast.error(t("sftp.transfers.copyTargetPathError"), "SFTP");
}
},
[t],
);
const containerStyle: React.CSSProperties = isActive
? {}
: {
@@ -470,6 +546,10 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
hosts={effectiveHosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
canCopyTransferTargetPath={canCopyTransferTargetPath}
onCopyTransferTargetPath={handleCopyTransferTargetPath}
showHostPickerLeft={showHostPickerLeft}
showHostPickerRight={showHostPickerRight}
hostSearchLeft={hostSearchLeft}
@@ -521,7 +601,12 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap;
prev.setEditorWordWrap === next.setEditorWordWrap &&
// Only the keepalive fields of terminalSettings affect SFTP connection
// resolution today; compare them directly rather than the whole object
// so unrelated terminal-setting changes don't tear the panel down.
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
SftpView.displayName = "SftpView";

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ const baseProps = {
snippetPackages: [],
sessions: [],
workspaces: [],
knownHosts: [],
draggingSessionId: null,
terminalTheme: {},
accentMode: "theme",
@@ -31,8 +32,11 @@ const baseProps = {
setEditorWordWrap: () => {},
onHotkeyAction: () => {},
onUpdateHost: () => {},
onAddKnownHost: () => {},
onToggleWorkspaceViewMode: () => {},
onSetWorkspaceFocusedSession: () => {},
isBroadcastEnabled: () => false,
onToggleBroadcast: () => {},
onSplitSession: () => {},
toggleScriptsSidePanelRef: { current: null },
};
@@ -47,6 +51,36 @@ test("TerminalLayer re-renders when group configs change", () => {
);
});
test("TerminalLayer re-renders when known hosts change", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{
...baseProps,
knownHosts: [{
id: "kh-1",
hostname: "switch.local",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "fingerprint",
discoveredAt: 1,
}],
} as never,
),
false,
);
});
test("TerminalLayer re-renders when the known host save handler changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, onAddKnownHost: () => {} } as never,
),
false,
);
});
test("TerminalLayer re-renders when proxy profiles change", () => {
assert.equal(
terminalLayerAreEqual(
@@ -64,3 +98,23 @@ test("TerminalLayer re-renders when proxy profiles change", () => {
false,
);
});
test("TerminalLayer re-renders when broadcast state changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, isBroadcastEnabled: () => true } as never,
),
false,
);
});
test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, onToggleBroadcast: () => {} } as never,
),
false,
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -73,7 +73,7 @@ interface TextEditorModalProps {
onPromoteToTab?: (snapshot: TextEditorModalSnapshot) => void;
}
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,

View File

@@ -2,15 +2,16 @@
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
*/
import React, { memo, useMemo } from 'react';
import { Check } from 'lucide-react';
import { Check, Wand2 } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { TERMINAL_THEMES, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../infrastructure/config/terminalThemes';
import { TERMINAL_THEME_AUTO } from '../domain/terminalAppearance';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
// Memoized theme item component
export const ThemeItem = memo(({
const ThemeItem = memo(({
theme,
isSelected,
onSelect
@@ -53,13 +54,18 @@ ThemeItem.displayName = 'ThemeItem';
interface ThemeListProps {
selectedThemeId: string;
onSelect: (themeId: string) => void;
/** Restrict the list to a single type; omit to show both sections. */
filterType?: 'dark' | 'light';
/** Render an "Auto (match app theme)" entry at the top. */
showAutoOption?: boolean;
}
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect, filterType, showAutoOption }) => {
const { t } = useI18n();
const customThemes = useCustomThemes();
const deletedSelectedTheme = useMemo(
() => (selectedThemeId
&& selectedThemeId !== TERMINAL_THEME_AUTO
&& !isUiMatchTerminalThemeId(selectedThemeId)
&& !TERMINAL_THEMES.some((theme) => theme.id === selectedThemeId)
&& !customThemes.some((theme) => theme.id === selectedThemeId)
@@ -80,8 +86,33 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
return { darkThemes: dark, lightThemes: light };
}, []);
const visibleCustomThemes = filterType
? customThemes.filter(theme => theme.type === filterType)
: customThemes;
const isAutoSelected = selectedThemeId === TERMINAL_THEME_AUTO;
return (
<>
{showAutoOption && (
<button
onClick={() => onSelect(TERMINAL_THEME_AUTO)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 mb-3 rounded-md text-left transition-all',
isAutoSelected ? 'bg-primary/10' : 'hover:bg-muted',
)}
>
<div className="w-12 h-8 rounded-[4px] flex-shrink-0 flex items-center justify-center border border-border/50 bg-gradient-to-br from-muted to-background">
<Wand2 size={14} className="text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isAutoSelected ? 'text-primary' : 'text-foreground')}>
{t('settings.terminal.theme.auto')}
</div>
<div className="text-[10px] text-muted-foreground">{t('settings.terminal.theme.autoDesc')}</div>
</div>
{isAutoSelected && <Check size={16} className="text-primary flex-shrink-0" />}
</button>
)}
{hiddenSelectedTheme && (
<div className="mb-4 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
@@ -105,6 +136,7 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
</div>
)}
{/* Dark Themes Section */}
{(!filterType || filterType === 'dark') && (
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.darkThemes')}
@@ -120,8 +152,10 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
))}
</div>
</div>
)}
{/* Light Themes Section */}
{(!filterType || filterType === 'light') && (
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.lightThemes')}
@@ -137,15 +171,16 @@ export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect
))}
</div>
</div>
)}
{/* Custom Themes Section */}
{customThemes.length > 0 && (
{visibleCustomThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
{visibleCustomThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}

File diff suppressed because it is too large Load Diff

View File

@@ -190,5 +190,3 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
</div>
);
};
export default TrafficDiagram;

View File

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

View File

@@ -0,0 +1,142 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { STORAGE_KEY_VAULT_HOSTS_SORT_MODE } from "../infrastructure/config/storageKeys.ts";
import type { Host, SSHKey } from "../types.ts";
import { VaultView } from "./VaultView.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const installStorageStub = (sortMode: string | null) => {
const values = new Map<string, string>();
if (sortMode !== null) {
values.set(STORAGE_KEY_VAULT_HOSTS_SORT_MODE, sortMode);
}
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
},
});
};
const host = (id: string, label: string, createdAt: number, group = ""): Host => ({
id,
label,
hostname: `${id}.example.com`,
username: "root",
tags: [],
os: "linux",
port: 22,
protocol: "ssh",
authMethod: "password",
createdAt,
group,
});
const fallbackKey: SSHKey = {
id: "key-1",
label: "Fallback key",
type: "ED25519",
privateKey: "",
source: "generated",
category: "key",
created: 1,
};
const renderVault = (sortMode: string | null, hosts: Host[]) => {
installStorageStub(sortMode);
const noop = () => {};
return renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(VaultView, {
hosts,
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
knownHosts: [],
shellHistory: [],
connectionLogs: [],
managedSources: [],
sessionCount: 0,
hotkeyScheme: "mac",
keyBindings: [],
terminalThemeId: "default",
terminalFontSize: 14,
onOpenSettings: noop,
onOpenQuickSwitcher: noop,
onCreateLocalTerminal: noop,
onDeleteHost: noop,
onConnect: noop,
onUpdateHosts: noop,
onUpdateKeys: noop,
onImportOrReuseKey: () => fallbackKey,
onUpdateIdentities: noop,
onUpdateProxyProfiles: noop,
onUpdateSnippets: noop,
onUpdateSnippetPackages: noop,
onUpdateCustomGroups: noop,
onUpdateKnownHosts: noop,
onUpdateManagedSources: noop,
onConvertKnownHost: noop,
onToggleConnectionLogSaved: noop,
onDeleteConnectionLog: noop,
onClearUnsavedConnectionLogs: noop,
onOpenLogView: noop,
groupConfigs: [],
onUpdateGroupConfigs: noop,
showRecentHosts: false,
showOnlyUngroupedHostsInRoot: false,
}),
),
),
);
};
test("Hosts sort mode is restored from storage", () => {
const markup = renderVault("za", [
host("alpha", "Alpha Host", 1),
host("zulu", "Zulu Host", 2),
]);
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
});
test("Hosts grouped sort mode is restored from storage", () => {
const markup = renderVault("group", [
host("beta", "Beta Host", 1, "Beta Group"),
host("alpha", "Alpha Host", 2, "Alpha Group"),
]);
assert.match(
markup,
/<span class="text-sm font-medium text-muted-foreground">Alpha Group<\/span><span class="text-xs text-muted-foreground\/60">\(1\)<\/span>/,
);
});
test("Hosts sort mode falls back safely when storage contains an invalid value", () => {
const markup = renderVault("unknown-sort", [
host("zulu", "Zulu Host", 2),
host("alpha", "Alpha Host", 1),
]);
assert.ok(markup.indexOf("Alpha Host") < markup.indexOf("Zulu Host"));
});

View File

@@ -35,12 +35,23 @@ import React, { Suspense, lazy, memo, startTransition, useCallback, useEffect, u
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useStoredString } from "../application/state/useStoredString";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { sanitizeCredentialValue } from "../domain/credentials";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { getEffectiveHostDistro, sanitizeHost, upsertHostById } from "../domain/host";
import {
getEffectiveHostDistro,
resolveTelnetPassword,
resolveTelnetPort,
resolveTelnetUsername,
sanitizeHost,
upsertHostById,
} from "../domain/host";
import { upsertKnownHost } from "../domain/knownHosts";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import {
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
@@ -61,7 +72,6 @@ import {
SSHKey,
ShellHistoryEntry,
Snippet,
TerminalSession,
} from "../types";
import { AppLogo } from "./AppLogo";
import { DistroAvatar } from "./DistroAvatar";
@@ -113,6 +123,13 @@ type DropTarget =
| { kind: "root" }
| { kind: "group"; path: string };
const isSortMode = (value: string): value is SortMode =>
value === "az" ||
value === "za" ||
value === "newest" ||
value === "oldest" ||
value === "group";
// Props without isActive - it's now subscribed internally
interface VaultViewProps {
hosts: Host[];
@@ -126,7 +143,7 @@ interface VaultViewProps {
shellHistory: ShellHistoryEntry[];
connectionLogs: ConnectionLog[];
managedSources: ManagedSource[];
sessions: TerminalSession[];
sessionCount: number;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
terminalThemeId: string;
@@ -139,6 +156,7 @@ interface VaultViewProps {
onConnect: (host: Host) => void;
onUpdateHosts: (hosts: Host[]) => void;
onUpdateKeys: (keys: SSHKey[]) => void;
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
onUpdateIdentities: (identities: Identity[]) => void;
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
onUpdateSnippets: (snippets: Snippet[]) => void;
@@ -162,6 +180,7 @@ interface VaultViewProps {
// Optional: navigate to a specific section on mount or when changed
navigateToSection?: VaultSection | null;
onNavigateToSectionHandled?: () => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
const VaultViewInner: React.FC<VaultViewProps> = ({
@@ -176,7 +195,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
shellHistory,
connectionLogs,
managedSources,
sessions,
sessionCount,
hotkeyScheme,
keyBindings,
terminalThemeId,
@@ -189,6 +208,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onConnect,
onUpdateHosts,
onUpdateKeys,
onImportOrReuseKey,
onUpdateIdentities,
onUpdateProxyProfiles,
onUpdateSnippets,
@@ -211,6 +231,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
showOnlyUngroupedHostsInRoot,
navigateToSection,
onNavigateToSectionHandled,
terminalSettings,
}) => {
const { t } = useI18n();
const rootRef = useRef<HTMLDivElement>(null);
@@ -268,7 +289,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
"grid",
);
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
const [sortMode, setSortMode] = useState<SortMode>("az");
const [sortMode, setSortMode] = useStoredString<SortMode>(
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
"az",
isSortMode,
);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
@@ -493,9 +518,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const isTelnet = effective.protocol === "telnet";
const defaultPort = isTelnet ? 23 : 22;
const effectivePort = isTelnet
? (effective.telnetPort ?? effective.port ?? 23)
: (effective.port ?? 22);
const effectivePort = isTelnet ? resolveTelnetPort(effective) : (effective.port ?? 22);
// Bracket IPv6 addresses when appending non-default port
let address: string;
@@ -514,12 +537,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
: undefined;
const username = isTelnet
? (effective.telnetUsername?.trim() || effective.username?.trim())
? resolveTelnetUsername(effective)
: (identity?.username?.trim() || effective.username?.trim());
const password = isTelnet
? (effective.telnetPassword || effective.password)
const rawPassword = isTelnet
? resolveTelnetPassword(effective)
: (identity?.password || effective.password);
const password = sanitizeCredentialValue(rawPassword);
if (!password) {
toast.warning(t('vault.hosts.copyCredentials.toast.noPassword'));
@@ -1190,7 +1214,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Stable callbacks that read from refs
const handleSaveKnownHost = useCallback((kh: KnownHost) => {
onUpdateKnownHostsRef.current([...knownHostsRef.current, kh]);
onUpdateKnownHostsRef.current(upsertKnownHost(knownHostsRef.current, kh));
}, []);
const handleUpdateKnownHost = useCallback((kh: KnownHost) => {
@@ -1895,21 +1919,25 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onChange={setSortMode}
className="h-10 w-10"
/>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
} else {
setIsMultiSelectMode(true);
}
}}
title={t("vault.hosts.multiSelect")}
>
<CheckSquare size={16} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isMultiSelectMode ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => {
if (isMultiSelectMode) {
clearHostSelection();
} else {
setIsMultiSelectMode(true);
}
}}
>
<CheckSquare size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("vault.hosts.multiSelect")}</TooltipContent>
</Tooltip>
</div>
{/* New Host split button — collapses with an animation when the
host details / new-host aside panel is open, since the button
@@ -2217,6 +2245,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {t('vault.hosts.unpin')}
</ContextMenuItem>
@@ -2316,6 +2350,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
</ContextMenuItem>
@@ -2483,7 +2523,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
</span>
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
{t("vault.hosts.header.live", { count: sessions.length })}
{t("vault.hosts.header.live", { count: sessionCount })}
</div>
</div>
</div>
@@ -2525,11 +2565,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
getDropTargetClasses={(path) =>
getDropTargetClasses({ kind: "group", path })
}
setDragOverDropTarget={setGroupDragOverDropTarget}
/>
getDropTargetClasses={(path) =>
getDropTargetClasses({ kind: "group", path })
}
setDragOverDropTarget={setGroupDragOverDropTarget}
groupConfigs={groupConfigs}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<div className="space-y-6">
{groupedDisplayHosts.map((group) => (
@@ -2935,6 +2976,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Array.from(new Set([...customGroups, groupPath])),
)
}
terminalSettings={terminalSettings}
/>
)}
{/* Always render KnownHostsManager but hide with CSS to prevent unmounting */}
@@ -2999,6 +3041,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
terminalFontSize={terminalFontSize}
groupDefaults={editingHostGroupDefaults}
groupConfigs={groupConfigs}
onImportKey={onImportOrReuseKey}
onSave={(host) => {
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
@@ -3260,12 +3303,18 @@ export const vaultViewAreEqual = (
prev.knownHosts === next.knownHosts &&
prev.shellHistory === next.shellHistory &&
prev.connectionLogs === next.connectionLogs &&
prev.sessions === next.sessions &&
prev.sessionCount === next.sessionCount &&
prev.managedSources === next.managedSources &&
prev.groupConfigs === next.groupConfigs &&
prev.terminalThemeId === next.terminalThemeId &&
prev.terminalFontSize === next.terminalFontSize &&
prev.navigateToSection === next.navigateToSection;
prev.navigateToSection === next.navigateToSection &&
// Only the keepalive fields of terminalSettings are forwarded to
// PortForwarding inside the vault, so compare them directly. Other
// terminal settings (fonts, themes, etc.) don't affect this subtree
// and we don't want to re-render for them.
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
return isEqual;
};

View File

@@ -4,6 +4,7 @@ import { code } from '@streamdown/code';
import type { ComponentProps, HTMLAttributes } from 'react';
import { memo } from 'react';
import { Streamdown } from 'streamdown';
import { createSafeCodeHighlighter } from './streamdownCodeHighlighter';
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: 'user' | 'assistant' | 'system' | 'tool';
@@ -46,21 +47,8 @@ export const MessageContent = ({ children, className, from, ...props }: MessageC
</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 };
const safeCode = createSafeCodeHighlighter(code);
const streamdownPlugins = { cjk, code: safeCode };
export type MessageResponseProps = ComponentProps<typeof Streamdown>;

View File

@@ -11,7 +11,6 @@ import type {
FormEvent,
HTMLAttributes,
KeyboardEvent,
ReactNode,
} from 'react';
import { forwardRef, useCallback, useRef } from 'react';
import { cn } from '../../lib/utils';
@@ -145,37 +144,6 @@ export const PromptInputTools = forwardRef<HTMLDivElement, PromptInputToolsProps
);
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> {
@@ -244,4 +212,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
},
);
PromptInputSubmit.displayName = 'PromptInputSubmit';

View File

@@ -0,0 +1,76 @@
import type {
CodeHighlighterPlugin,
HighlightOptions,
HighlightResult,
} from 'streamdown';
import type { BundledLanguage } from 'shiki';
const PLAIN_TEXT_LANGUAGES = new Set([
'',
'plain',
'plaintext',
'text',
'txt',
]);
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
cfg: 'ini',
conf: 'ini',
config: 'ini',
};
export const createPlainCodeHighlightResult = (source: string): HighlightResult => {
const code = source.replace(/\n+$/, '');
return {
bg: 'transparent',
fg: 'inherit',
tokens: code.split('\n').map((line) => [
{
content: line,
color: 'inherit',
bgColor: 'transparent',
htmlStyle: {},
offset: 0,
},
]),
};
};
const normalizeLanguageKey = (language: string): string =>
language.trim().toLowerCase();
export const resolveSupportedCodeLanguage = (
highlighter: CodeHighlighterPlugin,
language: string,
): BundledLanguage | null => {
const key = normalizeLanguageKey(language);
if (PLAIN_TEXT_LANGUAGES.has(key)) return null;
const direct = key as BundledLanguage;
if (highlighter.supportsLanguage(direct)) return direct;
const alias = LANGUAGE_ALIASES[key];
if (alias && highlighter.supportsLanguage(alias)) return alias;
return null;
};
export const createSafeCodeHighlighter = (
highlighter: CodeHighlighterPlugin,
): CodeHighlighterPlugin => ({
...highlighter,
supportsLanguage(language) {
return resolveSupportedCodeLanguage(highlighter, language) !== null;
},
highlight(options: HighlightOptions, callback?: (result: HighlightResult) => void) {
const supportedLanguage = resolveSupportedCodeLanguage(highlighter, options.language);
if (!supportedLanguage) {
return createPlainCodeHighlightResult(options.code);
}
return highlighter.highlight(
{ ...options, language: supportedLanguage },
callback,
);
},
});

View File

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

View File

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

View File

@@ -20,11 +20,37 @@ import {
} from '../ai-elements/prompt-input';
import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode, ProviderConfig, UploadedFile } from '../../infrastructure/ai/types';
import { ProviderIconBadge } from '../settings/tabs/ai/ProviderIconBadge';
import { ScrollArea } from '../ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360;
// Slightly wider for the provider picker so the per-row default-model
// caption doesn't truncate.
const PROVIDER_PICKER_MAX_WIDTH = 320;
/**
* Provider picker payload used by Catty Agent. When set, the model chip
* switches to a flat provider list (provider icon + name + the provider's
* configured default model as caption) in place of the generic Cpu glyph
* + model-preset dropdown. Each provider exposes a single model — its
* `defaultModel` — so a two-level menu would be empty noise; picking a
* provider implicitly picks its model.
*/
export interface ProviderSwitcherConfig {
/** Every configured provider — Settings-level visibility, not the
* `enabled` toggle, since the user expects to swap between everything
* they've set up. */
providers: ProviderConfig[];
/** Currently bound provider id (falls back to providers[0] when missing). */
selectedProviderId?: string;
/** Currently bound model id under the selected provider. */
selectedModelId?: string;
/** Fires when the user picks a (providerId, modelId) pair. */
onSelect: (providerId: string, modelId: string) => void;
}
interface ChatInputProps {
value: string;
@@ -63,6 +89,13 @@ interface ChatInputProps {
permissionMode?: AIPermissionMode;
/** Callback when user changes permission mode */
onPermissionModeChange?: (mode: AIPermissionMode) => void;
/**
* Provider→model two-level picker payload. When provided, replaces the
* single-list model dropdown with a provider-aware picker. Used for the
* Catty Agent only — external ACP agents (Claude/Codex) keep the
* `modelPresets` dropdown because their provider is wired inside the CLI.
*/
providerSwitcher?: ProviderSwitcherConfig;
}
const ChatInput: React.FC<ChatInputProps> = ({
@@ -89,6 +122,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
onRemoveUserSkill,
permissionMode,
onPermissionModeChange,
providerSwitcher,
}) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
@@ -354,10 +388,31 @@ const ChatInput: React.FC<ChatInputProps> = ({
return { selectedPreset: undefined, selectedThinking: undefined };
})();
const selectedBaseModelId = selectedPreset?.id;
const modelLabel = selectedPreset
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
: modelName || providerName || t('ai.chat.noModel');
const hasModelPicker = modelPresets.length > 0 && onModelSelect;
// Provider switcher mode (Catty Agent): two-column popover, chip carries
// the provider's icon + name + model name. Falls back to the existing
// single-list model dropdown for ACP agents.
const hasProviderSwitcher = !!providerSwitcher && providerSwitcher.providers.length > 0;
// Resolve to the actually-bound provider only — no `?? providers[0]`
// fallback, since a provider that isn't really bound will still hit the
// `!sendActiveProvider` guard at send time. Faking a selection in the
// chip would lie about a state the rest of the system treats as empty.
const selectedSwitcherProvider = hasProviderSwitcher
? providerSwitcher!.providers.find((p) => p.id === providerSwitcher!.selectedProviderId)
: undefined;
const providerSwitcherChipLabel = hasProviderSwitcher
? (selectedSwitcherProvider
? (providerSwitcher!.selectedModelId
? `${selectedSwitcherProvider.name} · ${providerSwitcher!.selectedModelId}`
: selectedSwitcherProvider.name)
: t('ai.chat.selectProvider'))
: '';
const modelLabel = hasProviderSwitcher
? providerSwitcherChipLabel
: (selectedPreset
? selectedPreset.name + (selectedThinking ? ` / ${formatThinkingLabel(selectedThinking)}` : '')
: modelName || providerName || t('ai.chat.noModel'));
const hasModelPicker = hasProviderSwitcher || (modelPresets.length > 0 && !!onModelSelect);
const popoverMaxWidth = hasProviderSwitcher ? PROVIDER_PICKER_MAX_WIDTH : MODEL_PICKER_MAX_WIDTH;
const chipClassName =
'inline-flex h-6 items-center gap-1 rounded-full px-1.5 text-[10.5px] text-foreground/72';
const selectedSkillChipClassName =
@@ -415,24 +470,27 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div className="px-3 pt-3 pb-1.5">
<div className="flex flex-wrap gap-2">
{selectedUserSkills.map((skill) => (
<div
key={skill.id}
className={selectedSkillChipClassName}
title={skill.description || skill.name || skill.slug}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
<Tooltip key={skill.id}>
<TooltipTrigger asChild>
<div
className={selectedSkillChipClassName}
>
<Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span>
<button
type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`}
>
<X size={9} />
</button>
</div>
</TooltipTrigger>
<TooltipContent>{skill.description || skill.name || skill.slug}</TooltipContent>
</Tooltip>
))}
</div>
</div>
@@ -450,14 +508,18 @@ const ChatInput: React.FC<ChatInputProps> = ({
].filter(Boolean).join(' ')}
maxLength={100000}
/>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
title={expanded ? 'Collapse' : 'Expand'}
>
<Expand size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
>
<Expand size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{expanded ? t('ai.chat.collapse') : t('ai.chat.expand')}</TooltipContent>
</Tooltip>
</div>
{/* @ mention popover */}
@@ -557,25 +619,29 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Footer toolbar */}
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
<PromptInputTools className="gap-1 flex-wrap">
<button
ref={attachBtnRef}
type="button"
onClick={() => {
if (!showAttachMenu) {
const rect = attachBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('attach');
} else {
closeAllMenus();
}
}}
className={iconButtonClassName}
title="Attach"
aria-label="Attach file"
aria-expanded={showAttachMenu}
>
<Plus size={13} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={attachBtnRef}
type="button"
onClick={() => {
if (!showAttachMenu) {
const rect = attachBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('attach');
} else {
closeAllMenus();
}
}}
className={iconButtonClassName}
aria-label={t('ai.chat.attach')}
aria-expanded={showAttachMenu}
>
<Plus size={13} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.attach')}</TooltipContent>
</Tooltip>
{showAttachMenu && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
@@ -643,7 +709,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
// Clamp so the popover stays inside the viewport when
// the chip is near the right edge of a narrow AI side
// panel.
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MODEL_PICKER_MAX_WIDTH - 8));
const left = Math.max(8, Math.min(rect.left, window.innerWidth - popoverMaxWidth - 8));
setMenuPos({ left, bottom: window.innerHeight - rect.top + 6 });
}
setActiveMenu('model');
@@ -652,11 +718,15 @@ const ChatInput: React.FC<ChatInputProps> = ({
}
}}
className={`${chipClassName} ${hasModelPicker ? 'cursor-pointer hover:bg-muted/24 transition-colors' : ''}`}
aria-label="Select model"
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
aria-expanded={showModelPicker}
>
<Cpu size={11} className="text-muted-foreground/64" />
<span className="truncate max-w-[82px]">{modelLabel}</span>
{hasProviderSwitcher && selectedSwitcherProvider ? (
<ProviderIconBadge provider={selectedSwitcherProvider} size="xs" />
) : (
<Cpu size={11} className="text-muted-foreground/64" />
)}
<span className={`truncate ${hasProviderSwitcher ? 'max-w-[180px]' : 'max-w-[82px]'}`}>{modelLabel}</span>
{hasModelPicker && <ChevronDown size={9} className="text-muted-foreground/50" />}
</button>
{showModelPicker && hasModelPicker && menuPos && createPortal(
@@ -665,12 +735,58 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div className="fixed inset-0 z-[999] cursor-default" onClick={closeAllMenus} />
<div
role="listbox"
aria-label="Select model"
aria-label={hasProviderSwitcher ? 'Select provider and model' : 'Select model'}
className="fixed z-[1000] w-max min-w-[160px] rounded-lg border border-border/50 bg-popover shadow-lg py-1"
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: MODEL_PICKER_MAX_WIDTH }}
style={{ left: menuPos.left, bottom: menuPos.bottom, maxWidth: popoverMaxWidth }}
onMouseLeave={() => setHoveredModelId(null)}
>
{modelPresets.map(preset => {
{hasProviderSwitcher ? (
<div className="min-w-[260px] max-h-[320px] overflow-y-auto">
{providerSwitcher!.providers.map((p) => {
const isSelected = providerSwitcher!.selectedProviderId === p.id;
const defaultModel = p.defaultModel?.trim() ?? '';
const hasModel = defaultModel.length > 0;
// Rows without a defaultModel are inert — picking
// one would save a binding with an empty model id
// and produce a confusing model error at send time.
// User has to set a defaultModel in Settings first.
const disabled = !hasModel;
const modelCaption = hasModel
? defaultModel
: t('ai.chat.noProviderModel');
return (
<button
key={p.id}
type="button"
role="option"
aria-selected={isSelected}
aria-disabled={disabled}
disabled={disabled}
title={disabled ? t('ai.chat.noProviderModel') : undefined}
onClick={() => {
if (disabled) return;
providerSwitcher!.onSelect(p.id, defaultModel);
closeAllMenus();
}}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 text-left transition-colors ${
disabled
? 'opacity-55 cursor-not-allowed'
: 'hover:bg-muted/30 cursor-pointer'
}`}
>
<ProviderIconBadge provider={p} size="md" />
<div className="flex-1 min-w-0">
<div className="truncate text-[12px] text-foreground/85">{p.name}</div>
<div className={`truncate text-[10.5px] ${hasModel ? 'text-muted-foreground/70 font-mono' : 'text-muted-foreground/55 italic'}`}>
{modelCaption}
</div>
</div>
{isSelected && <Check size={12} className="text-primary shrink-0" />}
</button>
);
})}
</div>
) : modelPresets.map(preset => {
const isSelected = preset.id === selectedBaseModelId;
const hasThinking = preset.thinkingLevels && preset.thinkingLevels.length > 0;
return (
@@ -743,33 +859,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Permission mode chip — only for Catty Agent */}
{permissionMode && onPermissionModeChange && (
<>
<button
ref={permBtnRef}
type="button"
onClick={() => {
if (!showPermPicker) {
const rect = permBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('perm');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
title={t('ai.safety.permissionMode')}
aria-label="Permission mode"
aria-expanded={showPermPicker}
>
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
<span className="truncate max-w-[72px]">
{permissionMode === 'observer' && t('ai.chat.permObserver')}
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
</span>
<ChevronDown size={9} className="text-muted-foreground/50" />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
ref={permBtnRef}
type="button"
onClick={() => {
if (!showPermPicker) {
const rect = permBtnRef.current?.getBoundingClientRect();
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
setActiveMenu('perm');
} else {
closeAllMenus();
}
}}
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
aria-label={t('ai.safety.permissionMode')}
aria-expanded={showPermPicker}
>
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
<span className="truncate max-w-[72px]">
{permissionMode === 'observer' && t('ai.chat.permObserver')}
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
</span>
<ChevronDown size={9} className="text-muted-foreground/50" />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.safety.permissionMode')}</TooltipContent>
</Tooltip>
{showPermPicker && menuPos && createPortal(
<>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />

View File

@@ -15,6 +15,7 @@ import {
DropdownContent,
DropdownTrigger,
} from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface ConversationExportProps {
session: AISession | null;
@@ -45,32 +46,36 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
return (
<Dropdown>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground'}
disabled={!hasMessages}
title={t('ai.chat.exportConversation')}
>
<Download size={14} />
</Button>
</DropdownTrigger>
<Tooltip>
<TooltipTrigger asChild>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
disabled={!hasMessages}
>
<Download size={14} />
</Button>
</DropdownTrigger>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.exportConversation')}</TooltipContent>
</Tooltip>
<DropdownContent
align="end"
sideOffset={6}
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-sm"
className="w-40 rounded-xl border border-border/60 bg-popover p-1.5 text-popover-foreground shadow-lg supports-[backdrop-filter]:bg-popover/95 supports-[backdrop-filter]:backdrop-blur-sm"
>
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/48">
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground/70">
{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]"
className="w-full flex items-center gap-2 px-2 py-1.5 text-[13px] rounded-lg transition-colors cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
<Icon size={13} className="shrink-0 text-muted-foreground/70" />
<Icon size={13} className="shrink-0 text-muted-foreground" />
<span>{t(labelKey)}</span>
</button>
))}

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
splitClaudeEnv,
buildClaudeEnv,
parseEnvLines,
serializeEnvLines,
} from "../settings/tabs/ai/claudeConfigEnv";
test("splitClaudeEnv pulls out config dir and hides CLAUDE_CODE_EXECUTABLE", () => {
const result = splitClaudeEnv({
CLAUDE_CONFIG_DIR: "/cfg",
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
ANTHROPIC_API_KEY: "sk-x",
});
assert.equal(result.configDir, "/cfg");
assert.equal(result.envText, "ANTHROPIC_API_KEY=sk-x");
});
test("splitClaudeEnv handles undefined env", () => {
assert.deepEqual(splitClaudeEnv(undefined), { configDir: "", envText: "" });
});
test("parseEnvLines parses KEY=VALUE, trims keys, keeps value as-is, skips blanks/comments", () => {
assert.deepEqual(
parseEnvLines("ANTHROPIC_API_KEY = sk-x\n# comment\n\nANTHROPIC_BASE_URL=https://h/?a=b"),
{ ANTHROPIC_API_KEY: "sk-x", ANTHROPIC_BASE_URL: "https://h/?a=b" },
);
});
test("serializeEnvLines is the inverse for simple entries", () => {
assert.equal(serializeEnvLines({ A: "1", B: "2" }), "A=1\nB=2");
});
test("buildClaudeEnv merges config dir + parsed env, preserves CLAUDE_CODE_EXECUTABLE, drops empties", () => {
const prev = { CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude", OLD: "x" };
const next = buildClaudeEnv(prev, "/cfg", "ANTHROPIC_API_KEY=sk-x");
assert.deepEqual(next, {
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
CLAUDE_CONFIG_DIR: "/cfg",
ANTHROPIC_API_KEY: "sk-x",
});
});
test("buildClaudeEnv omits config dir when blank and returns undefined when empty", () => {
assert.equal(buildClaudeEnv(undefined, " ", ""), undefined);
});
test("buildClaudeEnv ignores managed keys typed into the env editor", () => {
const next = buildClaudeEnv(
{ CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude" },
"/cfg",
"CLAUDE_CODE_EXECUTABLE=/evil/claude\nCLAUDE_CONFIG_DIR=/evil/dir\nANTHROPIC_API_KEY=sk-x",
);
assert.deepEqual(next, {
CLAUDE_CODE_EXECUTABLE: "/usr/bin/claude",
CLAUDE_CONFIG_DIR: "/cfg",
ANTHROPIC_API_KEY: "sk-x",
});
});

View File

@@ -31,6 +31,7 @@ import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import { isSdkStreamStateError } from '../../../infrastructure/ai/shared/streamStateErrors';
import {
extractProviderContinuationFromRawChunk,
getOpenAIChatAssistantFieldsForHistoryMessage,
@@ -143,6 +144,7 @@ export interface PanelBridge extends NetcattyBridge {
cwd?: string,
providerId?: string,
chatSessionId?: string,
agentEnv?: Record<string, string>,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[] }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
aiUserSkillsGetStatus?: () => Promise<{
@@ -646,9 +648,26 @@ export function useAIChatStreaming({
// inside the tool's execute function via the approvalGate module.
// The SDK may still emit this chunk type but we simply ignore it.
case 'error': {
const typedChunk = chunk as ErrorChunk;
// Internal SDK reasoning/text state-machine errors (e.g. a
// third-party Anthropic-compat backend like DeepSeek's
// `-v4-flash` streaming thinking deltas without first emitting
// the `reasoning-start` content-block signal) leak through
// fullStream once per orphan delta. They're not user-facing
// errors — and worse, surfacing one assistant message per
// event breaks tool_use/tool_result contiguity on the next
// turn, which the Anthropic backend then rejects as
// `messages.N: tool_use ids were found without tool_result
// blocks immediately after`. Filter them out at the chunk
// boundary: drop the placeholder assistant message and keep
// accepting subsequent chunks, so the rest of the stream
// (real text, tool calls, the genuine `finish`) lands intact.
if (isSdkStreamStateError(typedChunk.error)) {
console.warn('[Catty] suppressed SDK stream state error:', typedChunk.error);
break;
}
cancelPendingFlush();
flushText();
const typedChunk = chunk as ErrorChunk;
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
statusText: '',

View File

@@ -68,6 +68,21 @@ test('buildManagedAgentState keeps unrelated defaults when removing stale manage
assert.equal(state.defaultAgentId, 'custom-agent');
});
test('buildManagedAgentState stores the system Claude executable for ACP runs', () => {
const state = buildManagedAgentState(
[],
'catty',
'claude',
{ path: '/opt/homebrew/bin/claude', version: '2.1.145 (Claude Code)', available: true },
);
assert.equal(state.agents.length, 1);
assert.equal(state.agents[0].command, '/opt/homebrew/bin/claude');
assert.deepEqual(state.agents[0].env, {
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
});
});
test('buildManagedAgentState does not remove user-created matching agents', () => {
const agents: ExternalAgentConfig[] = [
{

View File

@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type {
CodeHighlighterPlugin,
HighlightOptions,
HighlightResult,
} from 'streamdown';
import {
createPlainCodeHighlightResult,
createSafeCodeHighlighter,
resolveSupportedCodeLanguage,
} from '../ai-elements/streamdownCodeHighlighter';
const createFakeHighlighter = (
supportedLanguages: string[],
highlightImpl?: CodeHighlighterPlugin['highlight'],
): CodeHighlighterPlugin => ({
name: 'shiki',
type: 'code-highlighter',
getSupportedLanguages: () => supportedLanguages as ReturnType<CodeHighlighterPlugin['getSupportedLanguages']>,
getThemes: () => ['github-light', 'github-dark'],
supportsLanguage: (language) => supportedLanguages.includes(language),
highlight: highlightImpl ?? ((options: HighlightOptions): HighlightResult => ({
tokens: [[{ content: options.language, offset: 0 }]],
})),
});
test('maps generic conf fences to ini for Streamdown highlighting', () => {
const highlighter = createFakeHighlighter(['ini']);
assert.equal(resolveSupportedCodeLanguage(highlighter, 'conf'), 'ini');
assert.equal(resolveSupportedCodeLanguage(highlighter, ' config '), 'ini');
});
test('falls back to plain tokens for unsupported languages', () => {
const highlighter = createSafeCodeHighlighter(
createFakeHighlighter([], () => {
throw new Error('delegate should not be called for unsupported languages');
}),
);
const result = highlighter.highlight({
code: '*.* action(type="omfwd"\n Target="10.185.3.1")\n',
language: 'conf',
themes: ['github-light', 'github-dark'],
});
assert.deepEqual(
result?.tokens.map((line) => line.map((token) => token.content).join('')),
['*.* action(type="omfwd"', ' Target="10.185.3.1")'],
);
});
test('uses supported aliases when highlighting generic config blocks', () => {
let receivedLanguage: string | null = null;
const highlighter = createSafeCodeHighlighter(
createFakeHighlighter(['ini'], (options: HighlightOptions): HighlightResult => {
receivedLanguage = options.language;
return createPlainCodeHighlightResult(options.code);
}),
);
const result = highlighter.highlight({
code: '*.* action(type="omfwd")',
language: 'conf',
themes: ['github-light', 'github-dark'],
});
assert.equal(receivedLanguage, 'ini');
assert.equal(result?.tokens[0][0].content, '*.* action(type="omfwd")');
});
test('treats text fences as plain code without calling the delegate', () => {
const highlighter = createSafeCodeHighlighter(
createFakeHighlighter(['ini'], () => {
throw new Error('delegate should not be called for text fences');
}),
);
const result = highlighter.highlight({
code: 'hello\nworld',
language: 'text',
themes: ['github-light', 'github-dark'],
});
assert.deepEqual(
result?.tokens.map((line) => line[0].content),
['hello', 'world'],
);
});

View File

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

View File

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

View File

@@ -61,20 +61,18 @@ export const IdentityCard: React.FC<IdentityCardProps> = ({
{summary}
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{viewMode === 'list' && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<Pencil size={14} />
</Button>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<Pencil size={14} />
</Button>
</div>
</div>
</div>

View File

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

View File

@@ -69,20 +69,18 @@ export const KeyCard: React.FC<KeyCardProps> = ({
Type {getKeyTypeDisplay(keyItem, isMac)}
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{viewMode === 'list' && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil size={14} />
</Button>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil size={14} />
</Button>
</div>
</div>
</div>

View File

@@ -6,8 +6,7 @@
// Utilities and types
export {
copyToClipboard,detectKeyType,generateMockKeyPair,getKeyIcon,
getKeyTypeDisplay,isMacOS,type FilterTab,type PanelMode
isMacOS,type FilterTab,type PanelMode
} from './utils';
// Card components

View File

@@ -7,33 +7,6 @@ import React from 'react';
import { logger } from '../../lib/logger';
import { KeyType, SSHKey } from '../../types';
/**
* Generate mock key pair (for fallback when Electron backend is unavailable)
*/
export const generateMockKeyPair = (type: KeyType, label: string, keySize?: number): { privateKey: string; publicKey: string } => {
const typeMap: Record<KeyType, string> = {
'ED25519': 'ed25519',
'ECDSA': `ecdsa-sha2-nistp${keySize || 256}`,
'RSA': 'rsa',
};
const randomId = crypto.randomUUID().replace(/-/g, '').substring(0, 32);
// Generate size-appropriate random data for more realistic keys
const keyLength = type === 'RSA' ? (keySize || 4096) / 8 : 32;
const randomData = Array.from(crypto.getRandomValues(new Uint8Array(keyLength)))
.map(b => b.toString(16).padStart(2, '0')).join('');
const privateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACB${randomId}AAAEC${randomData.substring(0, 64)}
-----END OPENSSH PRIVATE KEY-----`;
const publicKey = `ssh-${typeMap[type]} AAAAC3NzaC1lZDI1NTE5AAAAI${randomId.substring(0, 20)} ${label}@netcatty`;
return { privateKey, publicKey };
};
/**
* Get icon element for key source
*/

View File

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

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