Compare commits

...
131 Commits
Author SHA1 Message Date
陈大猫 c9b9a4f5d1 Prevent JMS tokens from sudo autofill (#1977) 2026-07-07 00:26:47 +08:00
陈大猫andCursor f08ca1c08a Support JumpServer jms:// client launch links (#1965) (#1976)
* Support JumpServer jms:// client launch links

Register Netcatty as an optional jms:// protocol handler (opt-in,
default off to avoid conflicting with the official JumpServer client).
SSH/SFTP connection payloads decode to an ephemeral host that connects
to the JumpServer gateway with the one-time connection token, without
saving anything to the vault.

Fixes #1958



* Mark jms deep link hosts as ephemeral for session restore exclusion



* Open the SFTP side panel for JumpServer sftp launch links

jms:// payloads with protocol "sftp" previously landed in a plain
shell. Mark the ephemeral host with an autoOpenSftpPanel hint that the
session factory forwards as autoOpenSidePanel, and let TerminalLayer
open the SFTP side panel for that session once it connects.



* Support JumpServer telnet deep links

* Keep JumpServer telnet links on SSH gateway

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 00:07:20 +08:00
陈大猫andCursor c6baebd514 Support password in ssh:// deep links with ephemeral connect (#1963)
* Support password in ssh deep links with ephemeral connect

ssh://user:password@host URLs now connect directly using the URL
credentials without saving the host to the vault, covering bastion
one-time password flows. Password-less links keep the existing
match-or-prefill behavior.

Fixes #1890

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep ephemeral deep-link hosts out of vault update paths

Terminal-layer host updates (e.g. the SFTP follow-CWD toggle) map over
the merged terminal host list, which could persist an ephemeral host
and its plaintext password into the vault. Route terminal-layer host
updates through a splitter that applies ephemeral entries to in-memory
state only and forwards just the vault hosts to updateHosts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Exclude ephemeral deep-link sessions from session restore

Sessions backed by an in-memory ephemeral host cannot survive a
relaunch (the one-time password only lives in memory), so persisting
them would leak the ephemeral host metadata into restore storage and
restore a tab that can never reconnect. Mark ephemeral hosts and their
sessions, and filter them out of the session-restore payload.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Treat ephemeral hosts as unsaved in appearance classification

The terminal theme/font panel and pane appearance resolution decided
"saved vault host" via hostMap membership. Ephemeral deep-link hosts
are now in that map, so theme/font changes were routed to the
persisted-host updater and silently dropped. Classify via
isSavedVaultHost (ephemeral flag aware) so those changes fall back to
the session/global setting path as before.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse saved host settings for password deep links and keep ephemeral marker on clones

Password deep links now run saved-host matching first: a uniquely
matched vault host contributes its non-credential settings (proxy,
jump chain, charset, group, ...) to the ephemeral host while the URL
credentials override vault identities and key references. Session
clones (split/copy/copy-to-new-window) now carry the ephemeralHost
marker so cloned tabs stay excluded from session restore.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Block group credential inheritance on password deep-link ephemeral hosts

buildSshDeepLinkEphemeralHostFromSaved now takes the group-resolved
effective host and clears group/identityFilePaths in addition to
identity references, so a later resolveEffectiveHost pass cannot
re-inherit group default credentials (identity, key, password) over
the one-time URL password. Non-credential group defaults (proxy,
jump chain, charset, ...) are preserved via the effective host.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Route ephemeral-host font zoom through the per-session font-size path

Ctrl+zoom on a non-workspace ephemeral deep-link session hit the
saved-host update path, where the vault updater silently dropped the
change (ephemeral ids are not in the persisted hosts array). Zoom and
the theme panel font-size slider/reset now store the size on the
session (rendered via applySessionFontSizeToHost) for ephemeral
hosts, matching the workspace-pane behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix ephemeral session font zoom routing

* Prevent ephemeral deep-link sudo autofill

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 22:56:32 +08:00
陈大猫 34c3c5beca [codex] Add Finder folder open terminal support (#1973)
* Add Finder folder open terminal support

* Fix Finder terminal path review findings
2026-07-06 22:37:19 +08:00
陈大猫 34eb3cd8fc Address Vault delete confirmation review feedback (#1975) 2026-07-06 22:20:39 +08:00
陈大猫 3d939480c9 Add Vault delete confirmations (#1974) 2026-07-06 22:07:28 +08:00
陈大猫andCursor 6400ce1e75 fix(terminal): detach stale keychain identity when saving retry credentials (#1970)
* fix(terminal): detach stale keychain identity when saving retry credentials

When a host references a Keychain identity (host.identityId) with wrong
credentials, re-entering credentials via the auth-retry dialog's
"Continue & Save" saved them onto the host but kept the identity
reference. On reconnect, resolveHostAuth prefers identity credentials
over host fields, so the stale keychain credentials kept failing.

Clear identityId when the user explicitly saves new credentials so the
saved username/password (or key) actually takes effect.

Fixes #1956

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(terminal): keep saved retry credentials over group-inherited identity

Writing identityId: undefined is treated as "unset" by applyGroupDefaults,
so a group-level identity would be re-inherited on reconnect and shadow the
credentials saved from the auth-retry dialog. Store an empty string instead
and register identityId in EMPTY_STRING_OVERRIDES_GROUP_DEFAULT so the
explicit "no identity" override sticks. All consumers check identityId
truthiness, so "" behaves as "no identity".

Addresses Codex review on #1970.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:50:33 +08:00
陈大猫 6cda26fad7 Fix confirm dialog input lock (#1969) 2026-07-06 19:46:39 +08:00
陈大猫andCursor 3deeb74ca7 Re-inject tool call names on OpenAI-compatible stream desync (#1966)
* Re-inject tool call names on OpenAI-compatible stream desync

Some OpenAI-compatible gateways emit tool_call chunks the AI SDK
rejects or ignores (non-standard `type` values, tool calls outside
choices[0]). The chunk normalizer had already recorded the tool call
id, so later nameless continuation chunks were forwarded with an
injected id and the SDK threw "Expected 'function.name' to be a
string." on what it saw as a brand-new tool call.

Remember each tool call's function name when its id is assigned and
re-inject it (plus type "function") into continuation chunks so every
forwarded chunk is self-describing, and coerce non-standard tool_call
type strings to "function" so the naming chunk itself is not dropped
by schema validation.

Fixes #1954.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Preserve argument prefixes from SDK-invisible tool call chunks

Codex review on #1966 flagged that when the chunk establishing a tool
call is invisible to the AI SDK (which only reads choices[0]), any
argument fragment it carried was lost: the recovery path re-injected
the name but forwarded only the suffix arguments, producing truncated
tool input.

Track the full concatenation of argument fragments per tool call key
and which keys have been forwarded at choices array position 0. The
first chunk forwarded where the SDK can see it now carries the full
accumulated arguments; already-visible tool calls keep streaming
incremental fragments as before.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:22:08 +08:00
陈大猫andCursor 476e441057 fix(terminal): raise SSH flow high-water mark to stop WAN pause ping-pong (#1961) (#1968)
* fix(terminal): raise SSH flow high-water mark to stop WAN pause ping-pong (#1961)

Bulk terminal output over SSH (e.g. `tail -2000f big.log`) was far slower
than other clients on high-latency links. The renderer's output flow control
pauses the source stream once its unacked backlog crosses FLOW_HIGH_WATER_MARK
(previously 100KB). For SSH the source is the ssh2 channel, so pause() stops
the remote until resume() plus a full round-trip.

ssh2's own channel window is 2MB (WINDOW_THRESHOLD 1MB), so a 100KB watermark
made Netcatty pause/resume dozens of times during a multi-MB dump — a 4MB dump
triggered ~61 pause cycles, each costing ~1 RTT. At 300ms RTT that turned a
~2s dump into ~21s.

Raise FLOW_HIGH_WATER_MARK to 1MB so it sits near the ssh2 window; the same
4MB dump now settles in ~4 pause cycles (~2-3s at 300ms RTT) while still
bounding renderer memory. FLOW_LOW_WATER_MARK stays at 8KB to keep interactive
acks responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(terminal): align deferred-ack test with 1MB flow high-water mark

The deferred IPC ack batch threshold (XTERM_WRITE_CALLBACK_BATCH_BYTES)
now sits far below FLOW_HIGH_WATER_MARK, so deferral alone can no longer
accumulate up to the watermark. Assert the batch-flush invariant instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:18:27 +08:00
陈大猫 965c1c581a Fix script waits misjudging fresh multi-line output as stale (#1964) 2026-07-06 18:14:29 +08:00
陈大猫andCursor 99e1a8d6d8 fix(zmodem): keep transfer listeners across same-session reconnects (#1962)
* fix(zmodem): keep transfer listeners across same-session reconnects

The preload netcatty:exit handler dropped the per-session zmodem
listener sets, but auth-failure retries and auto-reconnects reuse the
same sessionId without remounting the terminal, so useZmodemTransfer
never re-subscribed and rz/sz progress + overwrite dialogs silently
stopped appearing (#1955). Move listener cleanup to explicit
closeSession and prune empty sets on subscriber dispose instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(zmodem): gate event delivery instead of dropping listeners on close

Codex review caught that reconnects also call closeSession with the
terminal still mounted (startReconnect -> cleanupSession), so clearing
listener maps there reintroduced the bug for manual/auto reconnects.
Keep listener sets owned by renderer subscribers and gate zmodem event
delivery on closedTerminalDataSessions, which is cleared when a session
restarts with the same id.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:48:05 +08:00
陈大猫 0558dd6f6d Update Nix release metadata for v1.1.58 (#1948) 2026-07-06 00:29:18 +08:00
陈大猫andCursor 86e2336370 Fix directory tracking setup after su / sudo su (#1946)
* Fix directory tracking setup after su / sudo su (#1942)

After switching users in the terminal (e.g. `sudo su -`), SFTP directory
following stops: the new user's shell has no OSC 7 prompt hook, and the
backend pwd probe runs on an exec channel owned by the login user, which
cannot read another user's /proc/<pid>/cwd. The advertised remedy — the
OSC 7 setup action — also failed in this exact scenario because its silent
exec-channel script cannot verify or configure a foreground shell owned by
another user.

The setup script now detects that the active foreground shell belongs to a
different uid, reports the target shell via a marker (exit 5), and the
renderer falls back to typing the setup command into the terminal itself,
where it runs as the target user. The config lands in that user's rc file,
so OSC 7 reporting resumes immediately and persists for future su sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address Codex review: stage typed setup remotely, forward shell-local config paths

The typed fallback previously pasted the whole multi-line setup script into
the terminal, which could leave many entries in the target user's bash
history when cmdhist is off (the appended history -d only removes the last
one). The setup script is now staged into a self-deleting remote temp file
over the silent exec channel, and the typed command becomes a short single
line — one history entry that the existing bash cleanup reliably removes.

The typed wrapper also forwards shell-local (possibly unexported) ZDOTDIR /
XDG_CONFIG_HOME to the child sh via the NETCATTY_* overrides the script
already honors, so zsh users with a custom config dir get the prompt hook in
the right rc file (mirrors buildOsc7SetupCommand).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address Codex review: hash-verify the staged setup script before the su shell runs it

The staged temp file stays writable by the login user until the typed
command runs, so a same-uid process could swap its contents in that window
and have the su-target (e.g. root) shell execute arbitrary code. The typed
runner now reads the file into memory once, deletes it, verifies the
renderer-computed SHA-256 of the in-memory copy, and only then pipes that
same copy to sh — no TOCTOU window between verification and execution.
Verification failure prints an error instead of running anything.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 23:46:28 +08:00
LaiYueJi 6f7d18ffba fix(i18n): localize SSH auth-failure retry messages (#1947)
The "Authentication failed…" retry prompt and progress-log line in
createTerminalSessionStarters were hardcoded English and bypassed i18n,
so they rendered in English regardless of the selected language.

Route both through the existing tr() helper and add
terminal.auth.retryMessage / terminal.auth.retryLog to en, ru, zh-CN
and zh-TW.
2026-07-05 23:46:14 +08:00
陈大猫andCursor 6cb68bf6fc Run agent commands in a subshell so failures cannot close the session (#1945)
* Run agent commands in a subshell so failures cannot close the session

Agent-generated commands were eval'd directly in the user's active login
shell, so a generated script containing `set -e` (or `exit`) terminated
the whole SSH session on the first failure. Wrap the eval in a subshell
unconditionally instead of trying to detect dangerous commands — the
detection approaches from PRs #1852/#1882 grew into shell parsing and
were abandoned. Output stays live in the user's terminal; the trade-off
is that cd/export no longer persist between calls, which the tool
descriptions now tell the model about.

Also fix multi-line command display: synthetic echo now normalizes line
breaks to CRLF (no more staircase indentation in xterm), and the POSIX
wrapper rebuilds newlines via printf so the typed command stays on one
physical line and PS2 continuation echoes ("> ...") no longer leak past
the marker filter.

Fixes #1850

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move shellUtils require to module scope in cattyExecHandlers

The inline require("./ai/shellUtils.cjs") inside the with(ctx) block did
resolve correctly at runtime (bare `require` hits ctx.require, based in
electron/bridges/), but the path only works through that indirection and
reads as wrong relative to this file. Require at module scope with the
unambiguous ../ai path instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 23:08:16 +08:00
陈大猫andCursor bccc085029 fix(ai): allow OpenCode native skill directories in permission rules (#1943)
* fix(ai): allow OpenCode native skill dirs in permission rules

Netcatty's OpenCode permission config denied external_directory access
for everything outside its own allowlist, so skills discovered by
OpenCode itself (~/.opencode/skills, ~/.config/opencode/skills,
~/.claude/skills, ~/.agents/skills, remote skill cache) could not read
their reference files. OpenCode evaluates rules last-match-wins and its
read permission matches worktree-relative paths, so wildcard-anchored
patterns for the well-known skill directories are added to both the
read and external_directory rules in MCP and skills+cli modes.

Fixes #1939

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ai): re-deny dot-env files inside OpenCode skill dir allows

The broad skill-directory read allows would win over OpenCode's default
.env / .env.* approval gates (last matching rule wins), silently exposing
secret files placed inside native skill directories. Append targeted deny
rules after the allow entries so dot-env files stay protected.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:28:52 +08:00
陈大猫andCursor 2d95c71b85 Fix manual session log capture in terminal worker mode (#1944)
* Fix manual session log capture in terminal worker mode

Manual session logs (and script session logs) start their write stream in
the main process, but with the default terminal worker runtime all session
output is appended to the worker process's own sessionLogStreamManager
instance, so the saved file only ever contained the initial prompt line.

Tap the worker output mirror into the main-process stream manager so
main-process log streams receive terminal output, and mirror sudo-autofill
and programmatic command log rewrites when forwarding writes to the worker
so those logs stay sanitized like worker-owned auto session logs.

Fixes #1938

Co-authored-by: Cursor <cursoragent@cursor.com>

* Dedupe fallback worker output before session log taps

The runtime sender announces every chunk with an output-tap message and
falls back to a plain output message when the output port is unusable;
the manager notified taps for both, double-writing manual/script session
logs in the fallback path. Mark fallback output as already tapped so taps
fire exactly once per chunk.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:27:16 +08:00
陈大猫andCursor effafd9c2e Batch terminal output hot-path scans to raise renderer throughput. (#1941)
Every output chunk was walked char-by-char up to four times in JS
(sync-block filter, erase-scrollback rewrite, replay-safe log sanitizer,
line-timestamp segmenter) plus two more per-char scans for long-line
pressure, before xterm.js ever parsed it. Replace those loops with
native indexOf/regex hops and single-slice appends, and gate the
quadratic pending-suffix scan in the sync filter behind a cheap
incomplete-escape check. Output is byte-for-byte identical (fuzzed
old-vs-new over 4000 mixed chunks); colored-output floods drop from
~350ms to ~0.03ms per 64KB batch in the sync filter alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:52:44 +08:00
陈大猫andCursor aebdcedda9 Fix terminal catch-up lag when window is backgrounded unfocused (#1880) (#1940)
* Fix terminal backlog when window is unfocused in background.

Extend the background write fast path to occluded-but-visible windows (common on Windows Alt+Tab), skip wasteful repaints during that path, and flush any remaining coalescer/queue backlog when the app returns to foreground.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix resume hook wiring and keep unfocused repaint in fast path.

The onWindowShown resume flush never fired because useTerminalBackend did not expose the bridge listener. Also restore scheduleTerminalRepaintWhenUnfocused on the background fast path: unfocused-but-visible windows have no rAF render loop, so skipping it froze visible output (#1761 regression).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:51:41 +08:00
陈大猫 74b05c83d0 Update Nix release metadata for v1.1.57 2026-07-04 23:00:51 +08:00
陈大猫 94e359f6d7 [codex] Add macOS Dock new connection menu (#1935)
* Add macOS Dock new connection menu

* Fix Dock menu window readiness

* Wait for renderer before Dock menu delivery

* Queue Dock host connects until vault ready
2026-07-04 22:14:44 +08:00
陈大猫 101ff24a1f [codex] add system overview dashboard (#1934)
* Add system overview dashboard

* Fix system overview polling edge cases
2026-07-04 21:52:14 +08:00
陈大猫 9fe1fc7aa4 Fix manual session log capture (#1933) 2026-07-04 20:29:43 +08:00
陈大猫 1dbf46e32f Default SSH auto reconnect off (#1932) 2026-07-04 19:37:11 +08:00
陈大猫 6e3401f9dc [codex] Add SSH auto reconnect (#1931)
* Add SSH auto reconnect

* Fix auto reconnect for hibernated sessions
2026-07-04 19:20:24 +08:00
陈大猫 6944b77ec7 Fix terminal Shift mouse selection (#1930) 2026-07-04 19:04:45 +08:00
陈大猫 3f2e33f6e0 [codex] Remember serial YMODEM send directory (#1928)
* Remember serial YMODEM send directory

* Handle YMODEM path edge cases
2026-07-04 17:38:33 +08:00
陈大猫 2b9846d1d2 Fix terminal font remeasure after load (#1927) 2026-07-04 17:04:48 +08:00
陈大猫 b6a7f40175 Fix Ctrl-L scrollback preservation (#1923) 2026-07-04 16:02:31 +08:00
陈大猫 aa4b7f46e5 Show enter reconnect hint (#1925) 2026-07-04 15:14:55 +08:00
HLDandbincxz c79d51858a feat: add script dialog form controls (#1907)
* feat: add script dialog form controls

* feat: add script dialog text and number fields

* feat: add conditional script dialog fields

* fix: address script dialog review feedback

* fix: harden script dialog form validation

* fix: harden script dialog review issues

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-07-04 15:04:08 +08:00
陈大猫 e430a42dbe Fix SFTP follow cwd manual navigation (#1922) 2026-07-04 15:03:01 +08:00
陈大猫 5dec129088 [codex] Add enter reconnect for disconnected terminals (#1924)
* Add enter reconnect for disconnected terminals

* Honor workspace compose bar for enter reconnect
2026-07-04 15:00:45 +08:00
陈大猫 f299271b1b fix opencode windows permission allowlist (#1921) 2026-07-04 14:02:12 +08:00
陈大猫 49787215d2 Fix RPM build-id file conflicts (#1919) 2026-07-04 13:34:04 +08:00
陈大猫 5e34538c55 Add double-click copy for session tabs (#1917) 2026-07-04 10:59:34 +08:00
陈大猫 862c3f6fb5 Fix vault host drag reset (#1916) 2026-07-04 10:22:03 +08:00
陈大猫 2edbeae02c Remove unused code and assets
Remove unused code, assets, and stale config entries.
2026-07-03 10:47:59 +08:00
陈大猫 02ada0c61d Update Nix release metadata for v1.1.56
Update Nix release metadata for v1.1.56
2026-07-03 00:30:15 +08:00
陈大猫 98e95c36dc Align localized READMEs
Align localized READMEs
2026-07-03 00:03:14 +08:00
Copilot a1b1c37f36 Update Chinese and Japanese READMEs to match English README (#1894)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-02 23:45:52 +08:00
陈大猫 45905f8f34 Clean up README by removing unused image URLs (#1893)
Removed unnecessary image links from the README.
2026-07-02 23:36:24 +08:00
陈大猫 6d83b32815 Remove demos and project structure from README (#1892)
Removed demo section and project structure details from README.
2026-07-02 23:25:47 +08:00
陈大猫 a366cf81d9 fix ssh-agent certificate auth (#1889) 2026-07-02 23:00:57 +08:00
陈大猫 3a8bd7eea7 Update README.md (#1891) 2026-07-02 22:58:03 +08:00
陈大猫 a46c3321db Fix explicit script wait APIs (#1885) 2026-07-02 17:48:04 +08:00
陈大猫 eedb123b3a Update Nix release metadata for v1.1.55 (#1883) 2026-07-02 15:58:06 +08:00
陈大猫 1c7dc33852 [codex] Fix terminal catch-up after backgrounding (#1881)
* Fix terminal catch-up after backgrounding

* Harden background terminal output flushing
2026-07-02 14:25:48 +08:00
Ryanisgoodandbincxz 49a3c31de8 修复 SSH 加密默认密钥重试导致会话提前关闭 (#1878)
* fix: prevent ssh auth retry from closing session early

* fix: cover ssh auth retry stale exits

* fix: handle ssh retry reuse edge cases

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-07-02 14:15:41 +08:00
陈大猫 82e318c17f Update Nix release metadata for v1.1.54 (#1875) 2026-07-02 02:01:16 +08:00
陈大猫 920e3cf8ec Adjust all hosts header spacing (#1874) 2026-07-02 01:38:14 +08:00
陈大猫 2fe7de6cd7 Merge pull request #1873 from binaricat/codex/terminal-ui-dev-perf
[codex] Improve terminal tab switching stability
2026-07-02 01:25:57 +08:00
bincxz f3ac99e587 Address terminal UI review findings 2026-07-02 01:13:38 +08:00
bincxz fe7a867f38 Improve terminal tab switching stability 2026-07-02 01:00:32 +08:00
陈大猫 7d70d85d23 Merge pull request #1872 from binaricat/codex/terminal-tab-switch-recovery
[codex] Fix terminal tab switch recovery
2026-07-02 00:05:56 +08:00
bincxz 5a70e9d6b8 Respect scroll-on-output for hidden recovery 2026-07-01 23:54:48 +08:00
bincxz 65b00ae12f Fix terminal tab switch recovery 2026-07-01 23:44:37 +08:00
陈大猫 c36ff6bd49 Merge pull request #1869 from binaricat/codex/update-nix-release-v1.1.53
update nix release metadata for v1.1.53
2026-07-01 21:16:32 +08:00
bincxz 269b4d7a7a update nix release metadata for v1.1.53 2026-07-01 21:16:03 +08:00
陈大猫 3c499de5fa Merge pull request #1868 from binaricat/codex/terminal-address-title
fix terminal address title
2026-07-01 20:51:53 +08:00
bincxz c2ce086027 fix terminal address title 2026-07-01 20:51:11 +08:00
陈大猫 fc16da92d1 Merge pull request #1867 from binaricat/codex/terminal-scroll-smoothness
Improve terminal scroll and input smoothness
2026-07-01 19:25:21 +08:00
bincxz 49bd0ad1ae Improve terminal scroll and input smoothness 2026-07-01 19:20:05 +08:00
陈大猫 6462fe8db4 Merge pull request #1865 from binaricat/codex/fix-process-count-limit
[codex] Fix process list count limit
2026-07-01 17:51:59 +08:00
bincxz b118230218 Address process listing review feedback 2026-07-01 17:47:49 +08:00
bincxz cc5f2bdd7f Bound SSH process list output 2026-07-01 17:39:06 +08:00
陈大猫 ab309c0db2 Merge pull request #1864 from binaricat/codex/fix-long-line-highlight-lock
[codex] Fix long-line keyword highlight lockup
2026-07-01 17:36:42 +08:00
bincxz 6346754ad4 Handle large process list output 2026-07-01 17:32:56 +08:00
bincxz 3c72bbad39 Cache capped keyword highlight misses 2026-07-01 17:30:38 +08:00
陈大猫 9ae973b87a Merge pull request #1863 from binaricat/codex/fix-cpu-core-scroll
[codex] Fix CPU core stats scrolling
2026-07-01 17:25:49 +08:00
bincxz 49f79f0897 Fix process list count limit 2026-07-01 17:23:10 +08:00
bincxz 21373fc67f Fix long-line keyword highlight lockup 2026-07-01 17:21:10 +08:00
bincxz fd3552d8ef Fix CPU core stats scrolling 2026-07-01 17:20:18 +08:00
陈大猫 2f7059fbbf Merge pull request #1861 from binaricat/codex/clarify-telnet-manual-identity
[codex] Clarify Telnet manual identity label
2026-07-01 17:00:24 +08:00
bincxz 73429565c5 Clarify Telnet manual identity label 2026-07-01 16:59:31 +08:00
陈大猫 a7d7039317 Merge pull request #1853 from binaricat/codex/fix-combobox-wheel-scroll
[codex] Fix combobox wheel scrolling
2026-07-01 10:11:17 +08:00
bincxz e389cce5a2 Improve combobox scroll regression coverage 2026-07-01 10:03:25 +08:00
bincxz debae7f89e Fix combobox wheel scrolling 2026-07-01 09:56:06 +08:00
陈大猫 172466a2c7 Merge pull request #1848 from binaricat/codex/issue-1847-terminal-flood-yield
[codex] Fix terminal output flood responsiveness
2026-07-01 08:31:01 +08:00
bincxz da81caa72c Harden terminal flood flow control 2026-07-01 02:49:24 +08:00
bincxz 91610bbbaa Strengthen terminal flood flow control 2026-06-30 21:43:12 +08:00
bincxz 9562f909e1 Keep merged terminal flood writes cooperative 2026-06-30 21:09:28 +08:00
bincxz 878f9bfbb7 Fix terminal output flood responsiveness 2026-06-30 20:10:40 +08:00
陈大猫 5df248b5ea Merge pull request #1844 from binaricat/codex/add-nix-install-support
[codex] Add Nix AppImage package
2026-06-30 19:06:42 +08:00
bincxz d019ded7bf Skip prereleases for Nix metadata 2026-06-30 18:08:26 +08:00
陈大猫 354a7000b4 Merge pull request #1845 from binaricat/codex/fix-host-tree-font-clipping
[codex] Fix host tree font clipping
2026-06-30 18:03:08 +08:00
bincxz 5271f64402 Automate Nix release metadata 2026-06-30 18:03:02 +08:00
bincxz 5665578b2e Fix host tree font clipping 2026-06-30 17:56:31 +08:00
bincxz b0b88bb3a2 Add Nix flake lock 2026-06-30 17:51:33 +08:00
陈大猫 8d324a2afb Merge pull request #1843 from binaricat/codex/audit-ssh-compat-entrypoints
[codex] Harden SSH compat entrypoints
2026-06-30 17:43:11 +08:00
bincxz 433927fcfe Add Nix AppImage package 2026-06-30 17:27:34 +08:00
陈大猫 fb78596c85 Merge pull request #1839 from LeoKon3/feature/tab-tooltip-ip-1677
Show target address on SSH and telnet tabs
2026-06-30 17:22:01 +08:00
bincxz 546eb9129a Harden SSH compat entrypoints 2026-06-30 17:15:08 +08:00
陈大猫 24c978cf4d Merge pull request #1838 from binaricat/codex/fix-multiline-snippet-run
Fix multiline snippet execution modes
2026-06-30 17:12:49 +08:00
陈大猫 74a8f4d32d Merge pull request #1842 from binaricat/codex/issue-1841-worker-legacy-kex
[codex] Fix worker legacy SSH key exchange
2026-06-30 17:08:57 +08:00
bincxz b4151c61d6 Fix worker legacy SSH key exchange 2026-06-30 17:03:05 +08:00
bincxz 0b94a82579 Fix multiline snippet execution modes 2026-06-30 17:01:27 +08:00
LeoKon3 fce196599a fix: handle default ssh tab address behavior 2026-06-30 15:21:03 +08:00
LeoKon3 c43af71572 feat: Optimize hovering 2026-06-30 14:59:33 +08:00
LeoKon3 cf6ebadb08 feat: show user@host:port tooltip on SSH/telnet tabs (#1677) 2026-06-30 14:59:33 +08:00
陈大猫 d027207bed Merge pull request #1834 from binaricat/binaricat-patch-1
Add CODE_OF_CONDUCT.md
2026-06-30 01:19:19 +08:00
bincxz 109c8075c2 docs: align contributor guidance with project 2026-06-30 00:55:00 +08:00
陈大猫 2fcda46801 Add PULL_REQUEST_TEMPLATE.md 2026-06-30 00:45:16 +08:00
陈大猫 12fdd1c724 Add SECURITY.md
Added a security policy document outlining supported versions, vulnerability reporting, response timelines, and disclosure policy.
2026-06-30 00:44:37 +08:00
陈大猫 1a52a0b846 Add CONTRIBUTING.md
Added a contributing guide for the Netcatty project, detailing the code of conduct, ways to contribute, development setup, build and packaging instructions, linting, pull request process, commit message conventions, and architecture guidelines.
2026-06-30 00:43:54 +08:00
陈大猫 26e3c7c2b7 Add CODE_OF_CONDUCT.md 2026-06-30 00:42:19 +08:00
陈大猫 b88f7a0bfd Merge pull request #1826 from binaricat/codex/issue-937-identity-proxy-telnet
[codex] Support identities for Telnet and proxy credentials
2026-06-29 23:49:22 +08:00
bincxz c1635a0f55 Give Telnet its own identity selection 2026-06-29 23:48:41 +08:00
陈大猫 986551f146 Merge pull request #1828 from binaricat/codex/issue-1201-terminal-reconnect-history
[codex] Preserve terminal output on reconnect
2026-06-29 23:27:03 +08:00
bincxz 2796975526 Address identity proxy review gaps 2026-06-29 23:26:53 +08:00
bincxz 0cbb377385 keep terminal exit cleanup after close 2026-06-29 23:18:47 +08:00
bincxz ee3dce4cc5 ignore duplicate terminal exit events 2026-06-29 23:04:32 +08:00
陈大猫 854ae0d6f2 Merge pull request #1825 from binaricat/codex/terminal-side-panel-auto-open
Add terminal side panel auto-open setting
2026-06-29 23:03:59 +08:00
陈大猫 a20bb21af8 Merge pull request #1829 from binaricat/codex/issue-1824-zmodem-progress
Fix ZMODEM transfer progress events
2026-06-29 22:56:39 +08:00
bincxz 2fbc78fdc0 Fix ZMODEM transfer progress events 2026-06-29 22:55:53 +08:00
bincxz 32d9824eb9 preserve reconnect exit listener 2026-06-29 22:55:05 +08:00
陈大猫 98ead6af1f Merge pull request #1822 from binaricat/codex/fix-issue-1820-windows-force-focus 2026-06-29 22:41:52 +08:00
bincxz 595f42c84d Stabilize proxy timeout test 2026-06-29 22:39:52 +08:00
陈大猫 025d0cec08 Merge pull request #1823 from binaricat/codex/fix-issue-1821-script-stale-output 2026-06-29 22:30:39 +08:00
bincxz 6ba594800f Tighten side panel auto-open test coverage 2026-06-29 22:29:43 +08:00
陈大猫 16caa48a2a Merge pull request #1827 from binaricat/codex/issue-1824-zmodem-progress-toast
Fix ZMODEM transfer progress notifications
2026-06-29 22:24:51 +08:00
bincxz cdcacaef28 fix terminal reconnect output retention 2026-06-29 22:24:23 +08:00
bincxz 90b6e514ef Address terminal side panel auto-open review feedback 2026-06-29 22:24:07 +08:00
bincxz c10debea35 preserve script output during startup sync 2026-06-29 22:21:21 +08:00
bincxz d6f98948e0 Fix ZMODEM transfer progress notifications 2026-06-29 22:20:10 +08:00
bincxz 7770e86743 handle delayed show focus recovery 2026-06-29 22:18:26 +08:00
bincxz 1f7d2f91c9 Support identities for telnet and proxy credentials 2026-06-29 22:16:00 +08:00
bincxz 05231d3fba Add terminal side panel auto-open setting 2026-06-29 22:08:07 +08:00
bincxz d534b80532 fix script waits ignoring stale output 2026-06-29 21:55:26 +08:00
bincxz acd39df54b fix windows focus recovery trigger 2026-06-29 21:51:30 +08:00
379 changed files with 27665 additions and 3337 deletions

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
## Summary
<!-- Briefly describe what this PR does and why. -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code cleanup
- [ ] Documentation update
- [ ] Build / CI change
- [ ] Other (please describe):
## Related Issue (optional)
N/A
<!-- If this PR has a related issue, replace N/A with "Closes #123" or "Related to #123". -->
## Changes Made
<!-- List the key changes in this PR. -->
## Screenshots / Demo
<!-- If this PR affects UI or behavior, include screenshots or a short description. -->
## Testing
- [ ] I have tested these changes locally (`npm run dev`)
- [ ] Linting passes (`npm run lint`)
- [ ] Tests pass (`npm test`)
- [ ] Generated capability tool specs are updated when applicable (`npm run generate:capability-tools`)
- [ ] No new console errors or warnings, if this affects app behavior
## Checklist
- [ ] My code follows the existing project style
- [ ] I have added or updated relevant documentation
- [ ] I have not introduced any breaking changes (or I have described them above)
+45
View File
@@ -0,0 +1,45 @@
# Security Policy
## Supported Versions
We actively maintain the latest stable release of Netcatty. Security fixes are applied to the current release only.
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| Older | :x: |
## Reporting a Vulnerability
If you discover a security vulnerability in Netcatty, **please do not open a public GitHub Issue**.
Instead, report it privately via one of the following methods:
- **GitHub Private Vulnerability Reporting**: Use the [Security tab](https://github.com/binaricat/Netcatty/security/advisories/new) to submit a private advisory.
- **Email**: Send details to support@netcatty.com.
- **GitHub Issues** (for non-sensitive security concerns only): https://github.com/binaricat/Netcatty/issues
Please include the following details in your report:
- A description of the vulnerability
- Steps to reproduce the issue
- The potential impact
- Any suggested mitigation or fix
## Response Timeline
We aim to:
- Acknowledge receipt within **3 business days**
- Provide a status update within **7 business days**
- Release a patch as soon as possible depending on severity
## Scope
This policy applies to the Netcatty desktop application (`netcatty.app`) and its source code in this repository.
Vulnerabilities in third-party dependencies should be reported directly to the respective upstream projects.
## Disclosure Policy
We follow responsible disclosure. Once a fix is available, we will publish a security advisory and credit the reporter (unless they prefer to remain anonymous).
+76
View File
@@ -0,0 +1,76 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
function usage() {
console.error('Usage: node .github/scripts/update-nix-release.js --artifacts <dir> --version <semver>');
}
function parseArgs(argv) {
const args = {};
for (let index = 2; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--artifacts' || arg === '--version') {
args[arg.slice(2)] = argv[index + 1];
index += 1;
continue;
}
usage();
process.exit(2);
}
if (!args.artifacts || !args.version) {
usage();
process.exit(2);
}
return args;
}
function sriSha256(filePath) {
const digest = crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('base64');
return `sha256-${digest}`;
}
function findArtifact(artifactsDir, fileName) {
const filePath = path.join(artifactsDir, fileName);
if (!fs.existsSync(filePath)) {
throw new Error(`Missing release artifact: ${filePath}`);
}
return filePath;
}
function renderReleaseNix({ version, x64Hash, arm64Hash }) {
return `{
version = "${version}";
sources = {
x86_64-linux = {
appImageArch = "x86_64";
hash = "${x64Hash}";
};
aarch64-linux = {
appImageArch = "arm64";
hash = "${arm64Hash}";
};
};
}
`;
}
const args = parseArgs(process.argv);
const version = args.version.replace(/^v/, '');
if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-.+)?$/.test(version)) {
throw new Error(`Expected semver version, got: ${args.version}`);
}
const x64AppImage = findArtifact(args.artifacts, `Netcatty-${version}-linux-x86_64.AppImage`);
const arm64AppImage = findArtifact(args.artifacts, `Netcatty-${version}-linux-arm64.AppImage`);
const releaseNix = renderReleaseNix({
version,
x64Hash: sriSha256(x64AppImage),
arm64Hash: sriSha256(arm64AppImage),
});
fs.writeFileSync('nix/release.nix', releaseNix);
console.log(`Updated nix/release.nix for Netcatty ${version}`);
+48 -2
View File
@@ -411,8 +411,8 @@ jobs:
- name: Install deps
run: npm ci
- name: Install pacman packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
- name: Install Linux packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools rpm
- name: Set version
shell: bash
@@ -453,6 +453,9 @@ jobs:
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh amd64
- name: Verify packaged rpm artifact
run: bash scripts/verify-linux-rpm-artifact.sh x86_64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -566,6 +569,9 @@ jobs:
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh arm64
- name: Verify packaged rpm artifact
run: bash scripts/verify-linux-rpm-artifact.sh aarch64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -687,6 +693,46 @@ jobs:
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}
update-nix-release:
name: update Nix release metadata
runs-on: ubuntu-latest
needs: release
if: |
startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref_name, '-')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
permissions:
contents: write
actions: read
steps:
- name: Checkout default branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.RELEASE_TOKEN }}
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Update Nix release metadata
run: node .github/scripts/update-nix-release.js --artifacts artifacts --version "${GITHUB_REF_NAME#v}"
- name: Commit Nix release metadata
shell: bash
run: |
if git diff --quiet -- nix/release.nix; then
echo "Nix release metadata is already current."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add nix/release.nix
git commit -m "Update Nix release metadata for ${GITHUB_REF_NAME}"
git push origin HEAD:${{ github.event.repository.default_branch }}
homebrew-tap:
name: bump homebrew tap
runs-on: ubuntu-latest
+138 -9
View File
@@ -25,7 +25,9 @@ import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { upsertKnownHost } from './domain/knownHosts';
import { materializeHostProxyProfile } from './domain/proxyProfiles';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSshDeepLinkEphemeralHostFromSaved, buildSshDeepLinkHostDraft, findSshDeepLinkHost, parseSshDeepLink } from './domain/sshDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from './domain/jmsDeepLink';
import { applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from './domain/ephemeralHosts';
import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
@@ -57,6 +59,7 @@ import {
import { getEffectiveKnownHosts } from './infrastructure/syncHelpers';
import { ToastProvider, toast } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ConfirmDialog } from './components/ui/confirm-dialog';
import { PortForwardHostKeyDialog } from './components/port-forwarding';
import { VaultSection } from './components/VaultView';
import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
@@ -72,7 +75,7 @@ import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
import { AppView } from './application/app/AppView';
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
// Initialize fonts eagerly at app startup
initializeFonts();
@@ -109,11 +112,14 @@ function App({ settings }: { settings: SettingsState }) {
// Navigation state for VaultView sections
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
const [deepLinkHostDraft, setDeepLinkHostDraft] = useState<Host | null>(null);
const [ephemeralHosts, setEphemeralHosts] = useState<Host[]>([]);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
const {
@@ -337,6 +343,14 @@ function App({ settings }: { settings: SettingsState }) {
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
);
const terminalHosts = useMemo(
() => (ephemeralHosts.length > 0 ? [...hosts, ...ephemeralHosts] : hosts),
[hosts, ephemeralHosts],
);
const ephemeralHostIds = useMemo(
() => new Set(ephemeralHosts.map((host) => host.id)),
[ephemeralHosts],
);
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
@@ -557,6 +571,7 @@ function App({ settings }: { settings: SettingsState }) {
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => { return handleTrayJumpToSessionImpl(() => ({ sessionId, sessions, setActiveTabId, setWorkspaceFocusedSession }), sessionId); });
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => { return handleTrayTogglePortForwardImpl(() => ({ hosts, identities, keys, knownHosts: effectiveKnownHosts, portForwardingRules, resolveEffectiveHost, ruleId, start, startTunnel, stopTunnel, t, terminalSettings, toast, undefined }), ruleId, start); });
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen }), e); });
@@ -589,7 +604,7 @@ function App({ settings }: { settings: SettingsState }) {
_handleTrayJumpToSession(sessionId);
});
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
_handleTrayPanelConnect(hostId);
_handleTrayPanelConnectRequest(hostId);
});
return () => {
unsubscribeJump?.();
@@ -597,6 +612,15 @@ function App({ settings }: { settings: SettingsState }) {
};
}, [isPeerSessionWindow]);
useEffect(() => {
if (!isVaultInitialized || pendingTrayPanelConnectHostIds.length === 0) return;
flushQueuedTrayPanelConnectHostsImpl(() => ({
connectNow: _handleTrayPanelConnect,
pendingHostIds: pendingTrayPanelConnectHostIds,
setPendingHostIds: setPendingTrayPanelConnectHostIds,
}));
}, [isVaultInitialized, pendingTrayPanelConnectHostIds]);
// Handle keyboard-interactive submit
const handleKeyboardInteractiveSubmit = useCallback((requestId: string, responses: string[], savePassword?: string) => { return handleKeyboardInteractiveSubmitImpl(() => ({ hosts, keyboardInteractiveQueue, netcattyBridge, requestId, responses, savePassword, sessions, setKeyboardInteractiveQueue, updateHosts }), requestId, responses, savePassword); }, [keyboardInteractiveQueue, sessions, hosts, updateHosts]);
@@ -893,10 +917,14 @@ function App({ settings }: { settings: SettingsState }) {
const handleDeleteHost = useCallback((hostId: string) => {
const target = hosts.find(h => h.id === hostId);
const confirmed = window.confirm(t('confirm.deleteHost', { name: target?.label || hostId }));
if (!confirmed) return;
updateHosts(hosts.filter(h => h.id !== hostId));
}, [hosts, updateHosts, t]);
setDeleteHostConfirm({ hostId, name: target?.label || hostId });
}, [hosts]);
const handleConfirmDeleteHost = useCallback(() => {
if (!deleteHostConfirm) return;
updateHosts(hosts.filter(h => h.id !== deleteHostConfirm.hostId));
setDeleteHostConfirm(null);
}, [deleteHostConfirm, hosts, updateHosts]);
const handleAddKnownHost = useCallback((kh: KnownHost) => {
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, kh);
@@ -929,7 +957,16 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
// Wrapper to create local terminal with logging
const handleCreateLocalTerminal = useCallback((shell?: { command: string; args?: string[]; name?: string; icon?: string }) => { return handleCreateLocalTerminalImpl(() => ({ addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, shell, systemInfoRef, terminalSettings, undefined }), shell); }, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
const handleCreateLocalTerminal = useCallback((
shell?: { command: string; args?: string[]; name?: string; icon?: string },
options?: { localStartDir?: string },
) => {
return handleCreateLocalTerminalImpl(
() => ({ addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, shell, systemInfoRef, terminalSettings, undefined }),
shell,
options,
);
}, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
@@ -985,6 +1022,23 @@ function App({ settings }: { settings: SettingsState }) {
};
});
const matchedEffectiveHost = findSshDeepLinkHost(effectiveHosts, target);
if (target.password) {
// One-time-password link: connect ephemerally with exactly the URL
// credentials. A uniquely matched saved host still contributes its
// non-credential settings (proxy, jump chain, charset, ...). Build
// from the group-resolved effective host so the builder can clear
// `group` and block group credential inheritance from later
// effective-host resolution.
const draftOptions = { id: crypto.randomUUID(), now: Date.now() };
const ephemeralHost = matchedEffectiveHost
? buildSshDeepLinkEphemeralHostFromSaved(matchedEffectiveHost, target, draftOptions)
: buildSshDeepLinkEphemeralHost(target, draftOptions);
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
return;
}
if (matchedEffectiveHost) {
const originalHost = hosts.find((host) => host.id === matchedEffectiveHost.id) ?? matchedEffectiveHost;
handleConnectToHost(buildSshDeepLinkConnectionHost(originalHost));
@@ -1008,6 +1062,60 @@ function App({ settings }: { settings: SettingsState }) {
});
}, [isPeerSessionWindow]);
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
const rawUrl = payload?.url || '';
const target = parseJmsDeepLink(rawUrl);
if (!target) {
toast.warning(t('deepLink.jms.invalid'));
return;
}
if (!isSupportedJmsProtocol(target.protocol)) {
toast.warning(t('deepLink.jms.unsupported', { protocol: target.protocol }));
return;
}
const ephemeralHost = buildJmsDeepLinkEphemeralHost(target, {
id: crypto.randomUUID(),
now: Date.now(),
});
setEphemeralHosts((prev) => [...prev, ephemeralHost]);
handleConnectToHost(ephemeralHost);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onJmsDeepLink) return;
return bridge.onJmsDeepLink((payload) => {
_handleJmsDeepLink(payload);
});
}, [isPeerSessionWindow]);
useEffect(() => {
setEphemeralHosts((prev) => {
if (prev.length === 0) return prev;
const referencedHostIds = new Set(
sessions.map((session) => session.hostId).filter((id): id is string => Boolean(id)),
);
const next = prev.filter((host) => referencedHostIds.has(host.id));
return next.length === prev.length ? prev : next;
});
}, [sessions]);
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
if (!bridge?.onOpenTerminalPath) return;
return bridge.onOpenTerminalPath((payload) => {
_handleOpenTerminalPath(payload);
});
}, [isPeerSessionWindow]);
const handleOpenHostFromVaultNote = useCallback((host: Host, source?: { noteId?: string }) => {
const tabId = handleConnectToHost(host);
if (source?.noteId && typeof tabId === 'string' && tabId) {
@@ -1073,6 +1181,17 @@ function App({ settings }: { settings: SettingsState }) {
)));
}, [hosts, updateHosts]);
// Terminal-layer host updates may include ephemeral deep-link hosts; keep
// those in memory only and never let them reach the persisted vault.
const updateTerminalHosts = useCallback((nextHosts: Host[]) => {
const { vaultHosts, ephemeralHosts: updatedEphemeralHosts } =
splitHostsUpdateByEphemeral(nextHosts, ephemeralHostIds);
if (updatedEphemeralHosts.length > 0) {
setEphemeralHosts((prev) => applyEphemeralHostsUpdate(prev, updatedEphemeralHosts));
}
updateHosts(vaultHosts);
}, [ephemeralHostIds, updateHosts]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
@@ -1178,6 +1297,16 @@ function App({ settings }: { settings: SettingsState }) {
return (
<>
<PortForwardHostKeyDialog onAddKnownHost={handleAddKnownHost} />
<ConfirmDialog
open={deleteHostConfirm !== null}
title={deleteHostConfirm ? t('confirm.deleteHost', { name: deleteHostConfirm.name }) : ''}
confirmLabel={t('action.delete')}
destructive
onOpenChange={(open) => {
if (!open) setDeleteHostConfirm(null);
}}
onConfirm={handleConfirmDeleteHost}
/>
<AppActiveTabChrome
showSftpTab={settings.showSftpTab}
setActiveTabId={setActiveTabId}
@@ -1195,7 +1324,7 @@ function App({ settings }: { settings: SettingsState }) {
resolveSessionAppearance={themeRuntime.resolveFocusedAppearance}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, clearThemeIntent: themeRuntime.clearIntent, settleManualThemeIntent: themeRuntime.settleManualIntent, pickTerminalTheme: themeRuntime.pickTheme, resolveSessionAppearance: themeRuntime.resolveFocusedAppearance, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, updateTerminalSetting, vaultFocusRequest, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}
+129
View File
@@ -0,0 +1,129 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported privately to the community leaders responsible for enforcement at
support@netcatty.com. Please do not report Code of Conduct incidents through
public GitHub issues.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+93
View File
@@ -0,0 +1,93 @@
# Contributing to Netcatty
Thank you for your interest in contributing to Netcatty — an AI-powered SSH client, SFTP browser, and terminal manager built with Electron, React, and xterm.js.
Please read this guide before submitting issues or pull requests.
## Code of Conduct
This project follows the [Contributor Covenant Code of Conduct](./CODE_OF_CONDUCT.md). By participating, you agree to uphold this standard.
## Ways to Contribute
- Report bugs via [GitHub Issues](https://github.com/binaricat/Netcatty/issues)
- Suggest features or improvements
- Fix bugs or implement features listed in Issues
- Improve documentation
## Development Setup
**Prerequisites:** Node.js 22+ and npm.
```bash
# Clone the repository
git clone https://github.com/binaricat/Netcatty.git
cd Netcatty
# Install dependencies
npm ci
# Start in development mode (Vite + Electron)
npm run dev
```
## Build & Package
```bash
npm run build # Build for production
npm run pack # Package for current platform
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS, portable, ZIP)
npm run pack:linux # Linux (AppImage, DEB, RPM, pacman)
```
## Linting and Tests
```bash
npm run lint # Run ESLint
npm test # Run the test suite
```
## Pull Request Process
1. Fork the repository.
2. Create a feature branch:
```bash
git checkout -b feature/your-feature-name
```
3. Make your changes, following the existing project style.
4. Commit with a clear message:
```bash
git commit -m 'feat: add amazing feature'
```
5. Push the branch and open a Pull Request against `main`.
6. Run `npm run lint` and `npm test` before requesting review.
7. If you changed the capability catalog, run `npm run generate:capability-tools`
and commit any generated updates.
8. Ensure all CI checks pass before requesting review.
## Commit Message Convention
We recommend following [Conventional Commits](https://www.conventionalcommits.org/):
- `feat:` — new feature
- `fix:` — bug fix
- `docs:` — documentation only
- `refactor:` — code change without feature or fix
- `chore:` — build process or tooling changes
## Architecture & Coding Guidelines
Netcatty is organized around three layers:
- Domain logic in `domain/`
- Application state in `application/state/`
- UI components in `components/`
Keep side effects in application or infrastructure code, avoid direct
`localStorage` or network calls from components, and update relevant
documentation when behavior changes.
## License
By contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](./LICENSE).
+54 -76
View File
@@ -5,13 +5,13 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>モダンな SSH クライアント、SFTP ブラウザ & ターミナルマネージャー</strong><br/>
<strong>🔥 AI 搭載の SSH クライアント、SFTP ブラウザ & ターミナルマネージャー 🚀</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
🔥 内蔵 AI Agent · 分割ターミナル · Vault ビュー · SFTP ワークフロー · カスタムテーマ — すべてが一つに。
</p>
<p align="center">
@@ -40,16 +40,45 @@
---
[![Netcatty メインインターフェース](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
---
<a name="catty-agent"></a>
# 🔥 Catty Agent — あなたの IT 運用 AI パートナー
> 🚀 **AI で日常の IT 運用作業を加速。** Catty Agent はサーバー環境を理解し、コマンドを実行し、自然な会話で複数ホストの複雑な操作をこなす内蔵 AI アシスタントです。
### 🔥 Catty Agent にできること
- 🚀 **自然言語でサーバー管理** — コマンドを暗記せず、必要なことを話しかけるだけ
- 🔥 **リアルタイムサーバー診断** — 会話を通じてステータス確認、ログ調査、リソース監視
- 🚀 **マルチホスト連携** — 複数サーバーのタスクを同時にコーディネート
- 🔥 **インテリジェントなコンテキスト認識** — サーバー環境を理解し、的確な回答を提供
- 🚀 **ワンステップで複雑な操作** — クラスター構築、サービスデプロイなど簡単な指示で実行
### 🎬 AI の動作デモ
#### 🔥 シングルホスト — インテリジェントなサーバー診断
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
---
# 目次 <!-- omit in toc -->
- [🔥 Catty Agent — AI パートナー](#catty-agent)
- [Netcatty とは](#netcatty-とは)
- [なぜ Netcatty](#なぜ-netcatty)
- [機能](#機能)
- [デモ](#デモ)
- [スクリーンショット](#スクリーンショット)
- [メインウィンドウ](#メインウィンドウ)
- [Vault ビュー](#vault-ビュー)
@@ -71,9 +100,10 @@
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
- **Netcatty は** デュアルペインのファイルブラウザを備えた強力な SFTP クライアント
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
- **Netcatty は** SSH、ローカルターミナル、Telnet、Mosh、シリアル接続をサポートします(利用可能な場合)
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh またはローカル/シリアルセッション経由でシェルに接続します
---
@@ -82,9 +112,9 @@
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
- **ワークスペース中心** — 分割ペイン + タブ + セッション復元で常時使うワークフローに対応
- **Vault の整理** — グリッド/リスト/ツリー表示、高速検索、ドラッグしやすいワークフロー
- **本格的な SFTP** — 内蔵エディタ + ドラッグ&ドロップ + スムーズなファイル操作
---
@@ -109,43 +139,6 @@
---
<a name="デモ"></a>
# デモ
動画で機能をさっと確認できます(素材は `screenshots/gifs/`):
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
<a name="スクリーンショット"></a>
# スクリーンショット
@@ -154,29 +147,25 @@ https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
![メインウィンドウ(ダーク)](screenshots/main-window-dark.png)
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
![メインウィンドウ(ライト)](screenshots/main-window-light.png)
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
<a name="vault-ビュー"></a>
## Vault ビュー
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
![Vault グリッドビュー](screenshots/vault_grid_view.png)
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
![Vault リストビュー](screenshots/vault_list_view.png)
![Vault ツリービュー(ダーク)](screenshots/treeview-dark.png)
![Vault ツリービュー(ライト)](screenshots/treeview-light.png)
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
<a name="分割ターミナル"></a>
## 分割ターミナル
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
![分割ウィンドウ](screenshots/split-window.png)
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
@@ -201,8 +190,6 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
---
<a name="はじめに"></a>
# はじめに
@@ -220,6 +207,16 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
### Nix / NixOS
Netcatty は Nix および NixOS ユーザー向けに、公式 Linux AppImage リリースをラップした flake を提供しています:
```bash
nix run github:binaricat/Netcatty
```
宣言型インストールには、Netcatty flake を input として追加し、NixOS または Home Manager のパッケージリストで `inputs.netcatty.packages.${pkgs.system}.default` を使用してください。
### 前提条件
- Node.js 18+ と npm
- macOS、Windows 10+、または Linux
@@ -238,25 +235,6 @@ npm install
npm run dev
```
### プロジェクト構造
```
├── App.tsx # メイン React アプリケーション
├── components/ # React コンポーネント
│ ├── Terminal.tsx # ターミナルコンポーネント
│ ├── SftpView.tsx # SFTP ブラウザ
│ ├── VaultView.tsx # ホスト管理
│ ├── KeyManager.tsx # SSH キー管理
│ └── ...
├── application/ # 状態管理 & 国際化
├── domain/ # ドメインモデル & ロジック
├── infrastructure/ # サービス & アダプター
├── electron/ # Electron メインプロセス
│ ├── main.cjs # メインエントリ
│ └── bridges/ # IPC ブリッジ
└── public/ # 静的アセット & アイコン
```
---
<a name="ビルドとパッケージ"></a>
+19 -114
View File
@@ -40,8 +40,7 @@
---
<img width="2868" height="1784" alt="netcatty SSH (Window) 2026-04-23 11:19 PM" src="https://github.com/user-attachments/assets/d6df734f-9ebc-452a-8b7d-e8a0fdc9463a" />
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
---
@@ -63,25 +62,13 @@
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
https://github.com/user-attachments/assets/f819a1b6-8cba-4910-8017-97dfc080b477
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
---
@@ -91,7 +78,6 @@ https://github.com/user-attachments/assets/52fd30b8-9f02-43d4-a3b2-142691e8e3ec
- [What is Netcatty](#what-is-netcatty)
- [Why Netcatty](#why-netcatty)
- [Features](#features)
- [Demos](#demos)
- [Screenshots](#screenshots)
- [Main Window](#main-window)
- [Vault Views](#vault-views)
@@ -150,76 +136,6 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
- **Custom themes** — tune the app appearance to your taste
- **Keyword highlighting** — customize highlight rules for terminal output
---
<a name="demos"></a>
# Demos
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
### Vault views: grid / list / tree
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
https://github.com/user-attachments/assets/1ff1f3f1-e5ae-40ea-b35a-0e5148c3afeb
### Split terminals + session management
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
https://github.com/user-attachments/assets/9c24b519-4b4b-4910-a22a-590d04c9af31
### SFTP: drag & drop + built-in editor
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
https://github.com/user-attachments/assets/f3afdb36-399d-4330-b9f3-4678f178f6db
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
https://github.com/user-attachments/assets/e1e26f7a-3489-41cc-975e-8dccba56ea85
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
https://github.com/user-attachments/assets/1a6049aa-9a4c-4d52-a13d-0b007a791b00
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
---
<a name="screenshots"></a>
@@ -230,29 +146,27 @@ https://github.com/user-attachments/assets/1a1db7bd-948b-4f3c-97cd-8fd0cbe7cce7
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
![Main Window (Dark)](screenshots/main-window-dark.png)
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
![Main Window (Light)](screenshots/main-window-light.png)
<a name="vault-views"></a>
## Vault Views
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
![Vault Grid View](screenshots/vault_grid_view.png)
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
![Vault List View](screenshots/vault_list_view.png)
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
![Vault Tree View (Dark)](screenshots/treeview-dark.png)
![Vault Tree View (Light)](screenshots/treeview-light.png)
<a name="split-terminals"></a>
## Split Terminals
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
![Split Windows](screenshots/split-window.png)
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
@@ -294,6 +208,16 @@ Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatt
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
### Nix / NixOS
Netcatty provides a flake that wraps the official Linux AppImage release for Nix and NixOS users:
```bash
nix run github:binaricat/Netcatty
```
For declarative installs, add the Netcatty flake as an input and use `inputs.netcatty.packages.${pkgs.system}.default` in your NixOS or Home Manager package list.
### Prerequisites
- Node.js 18+ and npm
- macOS, Windows 10+, or Linux
@@ -312,25 +236,6 @@ npm install
npm run dev
```
### Project Structure
```
├── App.tsx # Main React application
├── components/ # React components
│ ├── Terminal.tsx # Terminal component
│ ├── SftpView.tsx # SFTP browser
│ ├── VaultView.tsx # Host management
│ ├── KeyManager.tsx # SSH key management
│ └── ...
├── application/ # State management & i18n
├── domain/ # Domain models & logic
├── infrastructure/ # Services & adapters
├── electron/ # Electron main process
│ ├── main.cjs # Main entry
│ └── bridges/ # IPC bridges
└── public/ # Static assets & icons
```
---
<a name="build--package"></a>
+52 -73
View File
@@ -5,13 +5,13 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>现代化 SSH 客户端、SFTP 浏览器 & 终端管理器</strong><br/>
<strong>🔥 AI 驱动的 SSH 客户端、SFTP 浏览器 & 终端管理器 🚀</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
🔥 内置 AI Agent · 分屏终端 · Vault 多视图 · SFTP 工作流 · 自定义主题 —— 一应俱全。
</p>
<p align="center">
@@ -40,16 +40,45 @@
---
[![Netcatty 主界面](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
<img width="3142" height="1764" alt="Screenshot 2026-07-02 at 22 51 24" src="https://github.com/user-attachments/assets/3116165d-623a-4d3a-a28a-914befb9b72d" />
---
<a name="catty-agent"></a>
# 🔥 Catty Agent — 你的 IT 运维 AI 搭档
> 🚀 **用 AI 赋能你的日常 IT 运维工作。** Catty Agent 是内置的 AI 助手,能理解你的服务器环境、执行命令,并通过自然对话完成复杂的多主机操作。
### 🔥 Catty Agent 能做什么?
- 🚀 **自然语言管理服务器** —— 直接说出需求,不再需要记忆命令
- 🔥 **实时服务器诊断** —— 通过对话检查状态、查看日志、监控资源
- 🚀 **多主机协同** —— 跨多台服务器同时协调任务
- 🔥 **智能上下文感知** —— 理解你的服务器环境,给出有针对性的回应
- 🚀 **一键完成复杂操作** —— 搭集群、部署服务,一句话搞定
### 🎬 AI 实战演示
#### 🔥 单主机 —— 智能服务器诊断
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
<img width="1510" height="870" alt="ai-single" src="https://github.com/user-attachments/assets/d3f34e53-8476-4fab-8634-394b931d1ff1" />
#### 🚀 多主机 —— Docker Swarm 集群搭建
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
<img width="1515" height="870" alt="ai-muti" src="https://github.com/user-attachments/assets/ca166fc5-65d9-4d7b-951a-962c2ef230d8" />
---
# 目录 <!-- omit in toc -->
- [🔥 Catty Agent — AI 搭档](#catty-agent)
- [Netcatty 是什么](#netcatty-是什么)
- [为什么是 Netcatty](#为什么是-netcatty)
- [功能特性](#功能特性)
- [演示](#演示)
- [界面截图](#界面截图)
- [主界面](#主界面)
- [Vault 视图](#vault-视图)
@@ -83,9 +112,9 @@
如果你需要同时维护多台服务器,Netcatty 更像是“工作台”而不是单一终端:
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
- **以工作区为核心** —— 分屏 + 标签页 + 会话恢复,适合长期驻留的工作流
- **Vault 管理** —— 网格/列表/树形视图,配合快速搜索与拖拽流程更顺手
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传/下载,文件操作更丝滑
---
@@ -110,43 +139,6 @@
---
<a name="演示"></a>
# 演示
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP:拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
<a name="界面截图"></a>
# 界面截图
@@ -155,29 +147,25 @@ https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
![主界面(深色)](screenshots/main-window-dark.png)
<img width="1531" height="875" alt="black-grid" src="https://github.com/user-attachments/assets/004b80f6-5bbb-4f14-b8cd-33a0a5913b8c" />
![主界面(浅色)](screenshots/main-window-light.png)
<img width="1550" height="876" alt="light" src="https://github.com/user-attachments/assets/2b59a999-a25e-4217-944c-9aef0a09f272" />
<a name="vault-视图"></a>
## Vault 视图
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
![Vault 网格视图](screenshots/vault_grid_view.png)
<img width="1554" height="882" alt="list" src="https://github.com/user-attachments/assets/03249f15-b5f8-4770-a3c3-d5001636ea00" />
![Vault 列表视图](screenshots/vault_list_view.png)
![Vault 树形视图(深色)](screenshots/treeview-dark.png)
![Vault 树形视图(浅色)](screenshots/treeview-light.png)
<img width="1561" height="878" alt="tree" src="https://github.com/user-attachments/assets/739f7b66-3898-43d0-8dd3-b9b97fd8e8de" />
<a name="分屏终端"></a>
## 分屏终端
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
![分屏窗口](screenshots/split-window.png)
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
---
@@ -219,6 +207,16 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
### Nix / NixOS
Netcatty 提供了一个 flake,为 Nix 和 NixOS 用户封装了官方 Linux AppImage 发行版:
```bash
nix run github:binaricat/Netcatty
```
声明式安装时,将 Netcatty flake 添加为输入,并在 NixOS 或 Home Manager 的软件包列表中使用 `inputs.netcatty.packages.${pkgs.system}.default`。
### 前置条件
- Node.js 18+ 和 npm
- macOS、Windows 10+ 或 Linux
@@ -237,25 +235,6 @@ npm install
npm run dev
```
### 项目结构
```
├── App.tsx # 主 React 应用
├── components/ # React 组件
│ ├── Terminal.tsx # 终端组件
│ ├── SftpView.tsx # SFTP 浏览器
│ ├── VaultView.tsx # 主机管理
│ ├── KeyManager.tsx # SSH 密钥管理
│ └── ...
├── application/ # 状态管理 & 国际化
├── domain/ # 领域模型 & 逻辑
├── infrastructure/ # 服务 & 适配器
├── electron/ # Electron 主进程
│ ├── main.cjs # 主入口
│ └── bridges/ # IPC 桥接
└── public/ # 静态资源 & 图标
```
---
<a name="构建与打包"></a>
@@ -271,7 +250,7 @@ npm run pack
# 为特定平台打包
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS 安装程序)
npm run pack:linux # Linux (AppImage, deb, rpm)
npm run pack:linux # Linux (AppImage + DEB + RPM)
```
---
+55 -1
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleConnectToHostImpl } from './app/AppHandlers.ts';
import {
flushQueuedTrayPanelConnectHostsImpl,
handleConnectToHostImpl,
handleTrayPanelConnectRequestImpl,
} from './app/AppHandlers.ts';
import type { Host } from '../types';
const baseHost: Host = {
@@ -62,3 +66,53 @@ test('connect serial host handler returns the created terminal tab id', () => {
assert.equal(result, 'serial-session');
});
test('tray panel connect request queues until the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: false,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, ['host-1']);
assert.deepEqual(connectedHostIds, []);
});
test('tray panel connect request runs immediately after the vault is initialized', () => {
const queuedHostIds: string[] = [];
const connectedHostIds: string[] = [];
handleTrayPanelConnectRequestImpl(
() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
isVaultInitialized: true,
queueConnect: (hostId: string) => queuedHostIds.push(hostId),
}),
'host-1',
);
assert.deepEqual(queuedHostIds, []);
assert.deepEqual(connectedHostIds, ['host-1']);
});
test('queued tray panel connects flush in order', () => {
const connectedHostIds: string[] = [];
let pendingHostIds = ['host-1', 'host-2'];
flushQueuedTrayPanelConnectHostsImpl(() => ({
connectNow: (hostId: string) => connectedHostIds.push(hostId),
pendingHostIds,
setPendingHostIds: (nextHostIds: string[]) => {
pendingHostIds = nextHostIds;
},
}));
assert.deepEqual(connectedHostIds, ['host-1', 'host-2']);
assert.deepEqual(pendingHostIds, []);
});
+36
View File
@@ -46,6 +46,42 @@ test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer wi
});
});
test("copySessionToNewWindowWithCurrentShellImpl preserves local start directory in the source session", async () => {
const openedPayloads: unknown[] = [];
const localSession = sourceSession({
hostLabel: "Local Terminal",
hostname: "localhost",
protocol: "local",
localStartDir: "/Users/alice/project with spaces ",
});
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async (payload: unknown) => {
openedPayloads.push(payload);
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [localSession],
terminalSettings: { localShell: "system-default" },
}),
"session-1",
);
assert.equal(openedPayloads.length, 1);
assert.deepEqual(openedPayloads[0], {
title: "Local Terminal",
sourceSession: localSession,
localShellType: "zsh",
});
});
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
let called = false;
+25 -2
View File
@@ -61,7 +61,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: string) {
const { addConnectionLog, connectToHost, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast } = getCtx();
{
{
const host = hosts.find((item) => item.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
@@ -110,6 +110,24 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
}
}
export function handleTrayPanelConnectRequestImpl(getCtx: AppContextGetter, hostId: string) {
const { connectNow, isVaultInitialized, queueConnect } = getCtx();
if (!hostId) return;
if (!isVaultInitialized) {
queueConnect(hostId);
return;
}
connectNow(hostId);
}
export function flushQueuedTrayPanelConnectHostsImpl(getCtx: AppContextGetter) {
const { connectNow, pendingHostIds, setPendingHostIds } = getCtx();
if (!Array.isArray(pendingHostIds) || pendingHostIds.length === 0) return;
const hostIds = [...pendingHostIds];
setPendingHostIds([]);
hostIds.forEach((hostId: string) => connectNow(hostId));
}
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
{
@@ -727,7 +745,11 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
}
}
export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?: { command: string; args?: string[]; name?: string; icon?: string }) {
export function handleCreateLocalTerminalImpl(
getCtx: AppContextGetter,
shell?: { command: string; args?: string[]; name?: string; icon?: string },
options?: { localStartDir?: string },
) {
const { addConnectionLog, classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
{
const { username, hostname } = systemInfoRef.current;
@@ -742,6 +764,7 @@ export function handleCreateLocalTerminalImpl(getCtx: AppContextGetter, shell?:
shellArgs: resolved?.args,
shellName,
shellIcon,
localStartDir: options?.localStartDir,
});
addConnectionLog({
sessionId,
+5 -3
View File
@@ -95,7 +95,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
@@ -315,7 +315,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
/>
<TerminalLayerMount
hosts={hosts}
hosts={terminalHosts}
portForwardingRules={portForwardingRules}
customGroups={customGroups}
groupConfigs={groupConfigs}
@@ -390,7 +390,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
updateHosts={updateTerminalHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
updateNotes={updateNotes}
@@ -401,6 +401,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
-5
View File
@@ -113,8 +113,3 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
topTabsChromeThemeVarsApplied = true;
}
export function hasActiveChromeThemeDataset(): boolean {
if (typeof document === 'undefined') return false;
return Boolean(document.documentElement.dataset.activeChromeTheme);
}
+14 -1
View File
@@ -131,9 +131,22 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
};
});
const hostsForSystemMenu = hosts
.filter((host: any) => typeof host?.id === "string" && host.id.length > 0)
.map((host: any) => ({
id: host.id,
label: host.label,
hostname: host.hostname,
group: host.group,
pinned: host.pinned,
lastConnectedAt: host.lastConnectedAt,
protocol: host.protocol,
}));
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
portForwardRules: portForwardingRules,
hosts: hostsForSystemMenu,
});
}, 250);
@@ -141,7 +154,7 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
cancelled = true;
clearTimeout(timer);
};
}, [enabled, sessions, portForwardingRules, workspaces]);
}, [enabled, hosts, sessions, portForwardingRules, workspaces]);
// Quit guard: block app exit while any editor tab has unsaved changes.
// Main process sends "app:query-dirty-editors"; we respond with the result.
+22
View File
@@ -321,6 +321,7 @@ export const enCoreMessages: Messages = {
'settings.terminal.section.keyboard': 'Keyboard',
'settings.terminal.section.accessibility': 'Accessibility',
'settings.terminal.section.behavior': 'Behavior',
'settings.terminal.section.sidePanel': 'Side Panel',
'settings.terminal.section.scrollback': 'Scrollback',
'settings.terminal.section.keywordHighlight': 'Keyword highlighting',
'settings.terminal.font.family': 'Font',
@@ -368,6 +369,17 @@ export const enCoreMessages: Messages = {
'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.sidePanel.autoOpen': 'Open side panel with terminal',
'settings.terminal.sidePanel.autoOpen.desc': 'Automatically open a side panel when a terminal connects.',
'settings.terminal.sidePanel.autoOpenPane': 'Default side panel',
'settings.terminal.sidePanel.autoOpenPane.desc': 'Choose which panel opens first.',
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
'settings.terminal.sidePanel.pane.scripts': 'Scripts',
'settings.terminal.sidePanel.pane.history': 'History',
'settings.terminal.sidePanel.pane.theme': 'Theme',
'settings.terminal.sidePanel.pane.system': 'System',
'settings.terminal.sidePanel.pane.notes': 'Notes',
'settings.terminal.sidePanel.pane.ai': 'AI',
'settings.terminal.accessibility.minimumContrastRatio.desc':
'Adjust colors to meet contrast requirements (1 = disabled, 21 = max)',
'settings.terminal.behavior.rightClick': 'Right-click behavior',
@@ -476,6 +488,8 @@ export const enCoreMessages: Messages = {
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'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',
@@ -709,6 +723,12 @@ export const enCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
'vault.groups.errors.duplicatePath': 'A group with this name already exists at this location.',
'vault.deleteConfirm.title': 'Delete "{name}"?',
'vault.deleteConfirm.desc': 'This action cannot be undone.',
'vault.deleteConfirm.packageDesc': 'This will remove the script package. Scripts inside it will stay saved and move out of the package.',
'vault.deleteConfirm.noteGroupDesc': 'This will remove the folder. Notes inside it will stay saved and move out of the folder.',
'vault.deleteConfirm.portForwardingDesc': 'This will delete the port forwarding rule.',
'vault.managedSource.unmanage': 'Unmanage',
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
@@ -751,5 +771,7 @@ export const enCoreMessages: Messages = {
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
'deepLink.ssh.invalid': 'Invalid ssh:// link',
'deepLink.jms.invalid': 'Invalid JumpServer link',
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
};
+6 -1
View File
@@ -9,7 +9,7 @@ export const enScriptsMessages = {
'scripts.trigger.manual': 'Manual run',
'scripts.trigger.onConnect': 'Run on connect',
'scripts.trigger.onOutput': 'Run on output match',
'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitFor in script code.',
'scripts.trigger.onOutputHint': 'Fires when server output matches the pattern (user keystroke echo is ignored). With no target hosts configured, listens on the current connected session; with targets, only those hosts. Disabled in alternate-screen apps such as vim or htop. Suppressed while another script is running on this session; rechecked after it finishes. For in-flow waits, use nct.screen.waitForText or nct.screen.waitForRegex in script code.',
'scripts.actions.save': 'Save',
'scripts.actions.runNow': 'Run now',
'scripts.actions.openEditor': 'Open editor',
@@ -69,6 +69,11 @@ export const enScriptsMessages = {
'scripts.recording.helpTip4': 'For vim, menus, or other interactive flows, record the main commands first, then refine the script manually.',
'scripts.dialog.title': 'Script',
'scripts.dialog.ok': 'OK',
'scripts.dialog.required': 'Required',
'scripts.dialog.numberInvalid': 'Enter a valid number',
'scripts.dialog.numberMin': 'Must be at least {min}',
'scripts.dialog.numberMax': 'Must be at most {max}',
'scripts.dialog.numberStep': 'Must use increments of {step}',
'scripts.dialog.waitForTimeoutTitle': 'Wait timed out',
'scripts.dialog.retry': 'Retry',
'scripts.dialog.skip': 'Skip',
@@ -6,6 +6,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.noSession': 'No active terminal session.',
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
'systemManager.empty': 'No data available.',
'systemManager.tabs.overview': 'Overview',
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.loadOverview': 'Failed to load system overview',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.overview.empty': 'No system overview data yet.',
'systemManager.overview.loading': 'Loading system overview…',
'systemManager.overview.memory': 'Memory',
'systemManager.overview.disk': 'Disk',
'systemManager.overview.network': 'Network',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} cores',
'systemManager.overview.load': 'Load',
'systemManager.overview.uptime': 'Uptime',
'systemManager.overview.duration.daysHours': '{{days}}d {{hours}}h',
'systemManager.overview.duration.hoursMinutes': '{{hours}}h {{minutes}}m',
'systemManager.overview.duration.minutes': '{{minutes}}m',
'systemManager.overview.system': 'System',
'systemManager.overview.kernel': 'Kernel',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Latency',
'systemManager.overview.cpuCores': 'CPU cores',
'systemManager.overview.disks': 'Disks',
'systemManager.overview.interfaces': 'Network interfaces',
'systemManager.overview.topProcesses': 'Top memory processes',
'systemManager.overview.noData': 'No data',
'systemManager.overview.noDisks': 'No disk data',
'systemManager.overview.noInterfaces': 'No interface data',
'systemManager.overview.noTopProcesses': 'No process data',
'systemManager.processes.search': 'Search processes…',
'systemManager.processes.command': 'Command',
'systemManager.processes.user': 'User',
+9
View File
@@ -155,6 +155,8 @@ export const enTerminalMessages: Messages = {
'terminal.auth.passphrase.placeholder': 'Optional passphrase for the selected private key',
'terminal.auth.certificate': 'Certificate',
'terminal.auth.selectKey': 'Select Key',
'terminal.auth.retryMessage': 'Authentication failed. Please check your credentials and try again.',
'terminal.auth.retryLog': 'Authentication failed. Please try again.',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
@@ -166,6 +168,9 @@ export const enTerminalMessages: Messages = {
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
'terminal.progress.startOver': 'Start over',
'terminal.progress.enterReconnectHint': 'Press Enter to reconnect',
'terminal.progress.autoReconnectScheduled': 'Connection lost. Reconnecting in {seconds}s (attempt {attempt}).',
'terminal.progress.autoReconnectAttempt': 'Auto reconnect attempt {attempt}...',
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
'terminal.connection.chainOf': 'Chain {current} of {total}',
'terminal.connection.showLogs': 'Show logs',
@@ -673,6 +678,10 @@ export const enTerminalMessages: Messages = {
'snippets.renameDialog.error.invalidChars': 'Script package name can only contain letters, numbers, hyphens, and underscores',
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
'snippets.field.multiLineRunMode': 'Multi-line run',
'snippets.field.multiLineRunMode.paste': 'Send all at once',
'snippets.field.multiLineRunMode.lineDelay': 'Send line by line',
'snippets.field.multiLineRunModeHint': 'Use line by line for prompt-based logins or device macros.',
// Snippet Shortkey
'snippets.field.shortkey': 'Keyboard Shortcut',
'snippets.shortkey.placeholder': 'Click to set shortcut',
+13
View File
@@ -385,6 +385,9 @@ export const enVaultMessages: Messages = {
'settings.sshDeepLink.title': 'SSH links',
'settings.sshDeepLink.enable': 'Open ssh:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// links from browsers and other apps.',
'settings.jmsDeepLink.title': 'JumpServer Links',
'settings.jmsDeepLink.enable': 'Handle jms:// links',
'settings.jmsDeepLink.enableDesc': 'Register Netcatty as the handler for JumpServer jms:// client links. May conflict with the official JumpServer client.',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Auto-sync to remote',
@@ -678,12 +681,17 @@ export const enVaultMessages: Messages = {
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
'hostDetails.proxyPanel.identities': 'Identities',
'hostDetails.proxyPanel.keychainIdentity': 'Keychain identity',
'hostDetails.proxyPanel.manualCredentials': 'Manual credentials',
'hostDetails.proxyPanel.remove': 'Remove Proxy',
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
'hostDetails.proxyPanel.missing': 'Missing',
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
'hostDetails.proxyPanel.missingIdentity': 'Missing keychain identity',
'hostDetails.proxyPanel.incompleteIdentity': 'Proxy identity needs a username and password',
'hostDetails.proxyPanel.unreadableIdentity': 'Proxy identity password cannot be read on this device',
'hostDetails.proxyPanel.error.required': 'Proxy host and port, or a ProxyCommand, are required.',
'hostDetails.envVars': 'Environment Variables',
'hostDetails.envVars.add': 'Add Environment Variable',
@@ -711,10 +719,15 @@ export const enVaultMessages: Messages = {
'hostDetails.startupCommand.placeholder': 'Command to run on connect (e.g., cd /app && ls)',
'hostDetails.startupCommand.help':
'This command will be executed automatically after SSH connection is established.',
'hostDetails.startupCommand.runMode': 'Multi-line run',
'hostDetails.startupCommand.runMode.help': 'Choose how multi-line startup commands are sent after connection.',
'hostDetails.startupCommand.runMode.lineDelay': 'Send line by line',
'hostDetails.startupCommand.runMode.paste': 'Send all at once',
'hostDetails.otherProtocols': 'Other Protocols',
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': 'port',
'hostDetails.telnet.credentials': 'Credentials',
'hostDetails.telnet.identity.placeholder': 'Manual identity',
'hostDetails.telnet.username': 'Telnet Username',
'hostDetails.telnet.password': 'Telnet Password',
'hostDetails.charset.placeholder': 'Charset (e.g. UTF-8)',
+8
View File
@@ -725,11 +725,19 @@ export const ruCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "Имя группы не может содержать '/' или '\\\\'.",
'vault.groups.errors.duplicatePath': 'Группа с таким именем уже существует в этом расположении.',
'vault.deleteConfirm.title': 'Удалить "{name}"?',
'vault.deleteConfirm.desc': 'Это действие нельзя отменить.',
'vault.deleteConfirm.packageDesc': 'Пакет скриптов будет удалён. Скрипты внутри него сохранятся и будут вынесены из пакета.',
'vault.deleteConfirm.noteGroupDesc': 'Папка будет удалена. Заметки внутри неё сохранятся и будут вынесены из папки.',
'vault.deleteConfirm.portForwardingDesc': 'Правило проброса портов будет удалено.',
'vault.managedSource.unmanage': 'Снять управление',
'vault.managedSource.unmanageSuccess': 'Управление группой успешно снято',
'vault.hosts.header.entries': 'Записей: {count}',
'vault.hosts.header.live': 'Активных: {count}',
'deepLink.ssh.invalid': 'Недопустимая ссылка ssh://',
'deepLink.jms.invalid': 'Недопустимая ссылка JumpServer',
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
};
@@ -6,6 +6,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.noSession': 'Нет активного терминального сеанса.',
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
'systemManager.empty': 'Нет данных.',
'systemManager.tabs.overview': 'Обзор',
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.overview.empty': 'Данных обзора пока нет.',
'systemManager.overview.loading': 'Загрузка обзора системы…',
'systemManager.overview.memory': 'Память',
'systemManager.overview.disk': 'Диск',
'systemManager.overview.network': 'Сеть',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} ядер',
'systemManager.overview.load': 'Нагрузка',
'systemManager.overview.uptime': 'Время работы',
'systemManager.overview.duration.daysHours': '{{days}} д {{hours}} ч',
'systemManager.overview.duration.hoursMinutes': '{{hours}} ч {{minutes}} мин',
'systemManager.overview.duration.minutes': '{{minutes}} мин',
'systemManager.overview.system': 'Система',
'systemManager.overview.kernel': 'Ядро',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Задержка',
'systemManager.overview.cpuCores': 'Ядра CPU',
'systemManager.overview.disks': 'Диски',
'systemManager.overview.interfaces': 'Сетевые интерфейсы',
'systemManager.overview.topProcesses': 'Процессы по памяти',
'systemManager.overview.noData': 'Нет данных',
'systemManager.overview.noDisks': 'Нет данных дисков',
'systemManager.overview.noInterfaces': 'Нет данных интерфейсов',
'systemManager.overview.noTopProcesses': 'Нет данных процессов',
'systemManager.processes.search': 'Поиск процессов…',
'systemManager.processes.command': 'Команда',
'systemManager.processes.user': 'Пользователь',
+7
View File
@@ -175,6 +175,8 @@ export const ruTerminalMessages: Messages = {
'terminal.auth.passphrase.placeholder': 'Необязательная парольная фраза для выбранного приватного ключа',
'terminal.auth.certificate': 'Сертификат',
'terminal.auth.selectKey': 'Выбрать ключ',
'terminal.auth.retryMessage': 'Ошибка аутентификации. Проверьте учётные данные и повторите попытку.',
'terminal.auth.retryLog': 'Ошибка аутентификации. Повторите попытку.',
'terminal.auth.noKeysHint': 'Нет доступных ключей. Добавьте ключи в связке ключей.',
'terminal.auth.continueSave': 'Продолжить и сохранить',
'terminal.auth.credentialsUnavailable': 'Сохранённые учётные данные не могут быть расшифрованы на этом устройстве. Пожалуйста, введите и сохраните их заново.',
@@ -186,6 +188,7 @@ export const ruTerminalMessages: Messages = {
'terminal.progress.disconnected': 'Отключено',
'terminal.progress.cancelling': 'Отмена...',
'terminal.progress.startOver': 'Начать заново',
'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова',
'terminal.connection.dismissDisconnectedDialog': 'Закрыть уведомление об отключении',
'terminal.connection.chainOf': 'Цепочка {current} из {total}',
'terminal.connection.showLogs': 'Показать журналы',
@@ -691,6 +694,10 @@ export const ruTerminalMessages: Messages = {
'snippets.renameDialog.error.invalidChars': 'Имя пакета может содержать только буквы, цифры, дефисы и подчёркивания',
'snippets.field.noAutoRun': 'Только вставить (не выполнять автоматически)',
'snippets.field.multiLineRunMode': 'Многострочный запуск',
'snippets.field.multiLineRunMode.paste': 'Отправить сразу',
'snippets.field.multiLineRunMode.lineDelay': 'Отправлять построчно',
'snippets.field.multiLineRunModeHint': 'Построчный режим подходит для логинов с подсказками и макросов устройств.',
// Snippet Shortkey
'snippets.field.shortkey': 'Сочетание клавиш',
'snippets.shortkey.placeholder': 'Нажмите, чтобы задать сочетание',
+13
View File
@@ -420,6 +420,9 @@ export const ruVaultMessages: Messages = {
'settings.sshDeepLink.title': 'SSH-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// ссылки из браузеров и других приложений.',
'settings.jmsDeepLink.title': 'Ссылки JumpServer',
'settings.jmsDeepLink.enable': 'Обрабатывать ссылки jms://',
'settings.jmsDeepLink.enableDesc': 'Зарегистрировать Netcatty как обработчик клиентских ссылок JumpServer jms://. Может конфликтовать с официальным клиентом JumpServer.',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Автосинхронизация с удалённым сервером',
@@ -710,12 +713,17 @@ export const ruVaultMessages: Messages = {
'hostDetails.proxyPanel.usernamePlaceholder': 'Имя пользователя',
'hostDetails.proxyPanel.passwordPlaceholder': 'Пароль',
'hostDetails.proxyPanel.identities': 'Идентификаторы',
'hostDetails.proxyPanel.keychainIdentity': 'Идентификатор из связки ключей',
'hostDetails.proxyPanel.manualCredentials': 'Ввести вручную',
'hostDetails.proxyPanel.remove': 'Удалить прокси',
'hostDetails.proxyPanel.savedProxy': 'Сохранённый прокси',
'hostDetails.proxyPanel.selectSaved': 'Выбрать сохранённый прокси',
'hostDetails.proxyPanel.customProxy': 'Пользовательский прокси',
'hostDetails.proxyPanel.missing': 'Отсутствует',
'hostDetails.proxyPanel.missingSaved': 'Сохранённый прокси отсутствует',
'hostDetails.proxyPanel.missingIdentity': 'Идентификатор из связки ключей отсутствует',
'hostDetails.proxyPanel.incompleteIdentity': 'Для идентификатора прокси нужны имя пользователя и пароль',
'hostDetails.proxyPanel.unreadableIdentity': 'Пароль идентификатора прокси нельзя прочитать на этом устройстве',
'hostDetails.proxyPanel.error.required': 'Требуются хост и порт прокси или ProxyCommand.',
'hostDetails.envVars': 'Переменные окружения',
'hostDetails.envVars.add': 'Добавить переменную окружения',
@@ -742,10 +750,15 @@ export const ruVaultMessages: Messages = {
'hostDetails.startupCommand.placeholder': 'Команда для запуска при подключении (например, cd /app && ls)',
'hostDetails.startupCommand.help':
'This command will be executed automatically after SSH connection is established.',
'hostDetails.startupCommand.runMode': 'Многострочный запуск',
'hostDetails.startupCommand.runMode.help': 'Выберите, как отправлять многострочные команды запуска после подключения.',
'hostDetails.startupCommand.runMode.lineDelay': 'Отправлять построчно',
'hostDetails.startupCommand.runMode.paste': 'Отправить сразу',
'hostDetails.otherProtocols': 'Другие протоколы',
'hostDetails.telnetOn': 'Telnet на',
'hostDetails.port': 'порт',
'hostDetails.telnet.credentials': 'Учётные данные',
'hostDetails.telnet.identity.placeholder': 'Ручной идентификатор',
'hostDetails.telnet.username': 'Имя пользователя Telnet',
'hostDetails.telnet.password': 'Пароль Telnet',
'hostDetails.charset.placeholder': 'Кодировка (например, UTF-8)',
+20
View File
@@ -69,6 +69,18 @@ export const zhCNCoreMessages: Messages = {
'settings.tab.system': '系统',
'settings.terminal.connection.verifyHostKeys': '校验 SSH 主机密钥',
'settings.terminal.connection.verifyHostKeys.desc': '连接到新的或已变化的 SSH 主机密钥前先询问。只建议在可信内网中关闭。',
'settings.terminal.section.sidePanel': '侧边栏',
'settings.terminal.sidePanel.autoOpen': '打开终端时展开侧边栏',
'settings.terminal.sidePanel.autoOpen.desc': '终端连接成功后自动打开一个侧边栏。',
'settings.terminal.sidePanel.autoOpenPane': '默认侧边栏',
'settings.terminal.sidePanel.autoOpenPane.desc': '选择优先打开哪个面板。',
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
'settings.terminal.sidePanel.pane.scripts': '脚本',
'settings.terminal.sidePanel.pane.history': '历史',
'settings.terminal.sidePanel.pane.theme': '主题',
'settings.terminal.sidePanel.pane.system': '系统',
'settings.terminal.sidePanel.pane.notes': '笔记',
'settings.terminal.sidePanel.pane.ai': 'AI',
// Settings > System
'settings.system.title': '系统',
@@ -438,6 +450,12 @@ export const zhCNCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '该位置已存在同名分组。',
'vault.deleteConfirm.title': '删除“{name}”?',
'vault.deleteConfirm.desc': '此操作不可撤销。',
'vault.deleteConfirm.packageDesc': '这会删除脚本包,包内脚本会保留,并移出该脚本包。',
'vault.deleteConfirm.noteGroupDesc': '这会删除文件夹,文件夹内笔记会保留,并移出该文件夹。',
'vault.deleteConfirm.portForwardingDesc': '这会删除该端口转发规则。',
'vault.managedSource.unmanage': '取消托管',
'vault.managedSource.unmanageSuccess': '已取消托管分组',
@@ -480,6 +498,8 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
'deepLink.ssh.invalid': '无效的 ssh:// 链接',
'deepLink.jms.invalid': '无效的 JumpServer 链接',
'deepLink.jms.unsupported': '不支持的 JumpServer 协议:{{protocol}}',
// Vault import
'vault.import.title': '添加数据到你的 Vault',
+6 -1
View File
@@ -9,7 +9,7 @@ export const zhCNScriptsMessages = {
'scripts.trigger.manual': '手动运行',
'scripts.trigger.onConnect': '连接时运行',
'scripts.trigger.onOutput': '输出匹配时运行',
'scripts.trigger.onOutputHint': '仅在服务端输出匹配正则时触发(会忽略本地按键回显)。未配置目标主机时,对当前已连接会话生效;配置目标后仅监听对应主机。进入备用屏幕的应用(如 vim、htop)期间不会触发。本 session 有其他脚本运行期间会抑制;脚本结束后再检查 buffer。流程内等待请用 nct.screen.waitFor。',
'scripts.trigger.onOutputHint': '仅在服务端输出匹配正则时触发(会忽略本地按键回显)。未配置目标主机时,对当前已连接会话生效;配置目标后仅监听对应主机。进入备用屏幕的应用(如 vim、htop)期间不会触发。本 session 有其他脚本运行期间会抑制;脚本结束后再检查 buffer。流程内等待普通文本用 nct.screen.waitForText,正则用 nct.screen.waitForRegex。',
'scripts.actions.save': '保存',
'scripts.actions.runNow': '立即运行',
'scripts.actions.openEditor': '弹窗编辑',
@@ -69,6 +69,11 @@ export const zhCNScriptsMessages = {
'scripts.recording.helpTip4': 'vim、交互式菜单等复杂操作,建议先录主干命令,再在编辑器里手动补充。',
'scripts.dialog.title': '脚本',
'scripts.dialog.ok': '确定',
'scripts.dialog.required': '必填',
'scripts.dialog.numberInvalid': '请输入有效数字',
'scripts.dialog.numberMin': '不能小于 {min}',
'scripts.dialog.numberMax': '不能大于 {max}',
'scripts.dialog.numberStep': '步进必须为 {step} 的倍数',
'scripts.dialog.waitForTimeoutTitle': '等待超时',
'scripts.dialog.retry': '重试',
'scripts.dialog.skip': '跳过',
@@ -6,6 +6,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.noSession': '没有活动的终端会话。',
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
'systemManager.empty': '暂无数据。',
'systemManager.tabs.overview': '概览',
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.loadOverview': '加载系统概览失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.overview.empty': '暂无系统概览数据。',
'systemManager.overview.loading': '正在加载系统概览…',
'systemManager.overview.memory': '内存',
'systemManager.overview.disk': '磁盘',
'systemManager.overview.network': '网络',
'systemManager.overview.rx': '下行',
'systemManager.overview.tx': '上行',
'systemManager.overview.cores': '{{count}} 核',
'systemManager.overview.load': '负载',
'systemManager.overview.uptime': '运行时间',
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小时',
'systemManager.overview.duration.hoursMinutes': '{{hours}}小时 {{minutes}}分钟',
'systemManager.overview.duration.minutes': '{{minutes}}分钟',
'systemManager.overview.system': '系统',
'systemManager.overview.kernel': '内核',
'systemManager.overview.swap': '交换区',
'systemManager.overview.latency': '延迟',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁盘',
'systemManager.overview.interfaces': '网络接口',
'systemManager.overview.topProcesses': '内存占用最高进程',
'systemManager.overview.noData': '暂无数据',
'systemManager.overview.noDisks': '暂无磁盘数据',
'systemManager.overview.noInterfaces': '暂无网卡数据',
'systemManager.overview.noTopProcesses': '暂无进程数据',
'systemManager.processes.search': '搜索进程…',
'systemManager.processes.command': '命令',
'systemManager.processes.user': '用户',
@@ -20,6 +20,9 @@ export const zhCNTerminalMessages: Messages = {
'terminal.osc7Setup.sent': '目录追踪配置已发送到终端',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新连接',
'terminal.progress.autoReconnectScheduled': '连接已断开,将在 {seconds} 秒后自动重连(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自动重连(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
@@ -104,6 +107,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.sshDeepLink.title': 'SSH 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh:// 链接。',
'settings.jmsDeepLink.title': 'JumpServer 链接',
'settings.jmsDeepLink.enable': '处理 jms:// 链接',
'settings.jmsDeepLink.enableDesc': '将 Netcatty 注册为 JumpServer jms:// 客户端链接的处理程序,可能与官方 JumpServer 客户端冲突。',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自动同步到远程',
@@ -332,6 +338,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 保活数据包的频率(秒)。设为 0 表示全局禁用——单个主机可在自己的设置里覆盖此值。',
'settings.terminal.connection.keepaliveCountMax': '最大无响应保活次数',
@@ -462,12 +470,17 @@ export const zhCNTerminalMessages: Messages = {
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
'hostDetails.proxyPanel.identities': '身份',
'hostDetails.proxyPanel.keychainIdentity': '钥匙串身份',
'hostDetails.proxyPanel.manualCredentials': '手动凭据',
'hostDetails.proxyPanel.remove': '移除代理',
'hostDetails.proxyPanel.savedProxy': '已保存代理',
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
'hostDetails.proxyPanel.customProxy': '自定义代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
'hostDetails.proxyPanel.missingIdentity': '钥匙串身份不存在',
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要用户名和密码',
'hostDetails.proxyPanel.unreadableIdentity': '此设备无法读取代理身份密码',
'hostDetails.proxyPanel.error.required': '代理主机和端口,或 ProxyCommand 不能为空。',
'hostDetails.envVars.title': '环境变量',
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
@@ -695,6 +708,10 @@ export const zhCNTerminalMessages: Messages = {
'snippets.renameDialog.error.invalidChars': '脚本包名称只能包含字母、数字、连字符和下划线',
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
'snippets.field.multiLineRunMode': '多行运行方式',
'snippets.field.multiLineRunMode.paste': '一次性发送',
'snippets.field.multiLineRunMode.lineDelay': '逐行发送',
'snippets.field.multiLineRunModeHint': '登录提示、网络设备宏等场景可用逐行发送。',
// Snippet Shortkey
'snippets.field.shortkey': '快捷键',
'snippets.shortkey.placeholder': '点击设置快捷键',
+7
View File
@@ -248,10 +248,15 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.startupCommand': '启动命令',
'hostDetails.startupCommand.placeholder': '连接后执行的命令(例如:cd /app && ls)',
'hostDetails.startupCommand.help': 'SSH 连接建立后将自动执行该命令。',
'hostDetails.startupCommand.runMode': '多行运行方式',
'hostDetails.startupCommand.runMode.help': '选择连接后如何发送多行启动命令。',
'hostDetails.startupCommand.runMode.lineDelay': '逐行发送',
'hostDetails.startupCommand.runMode.paste': '一次性发送',
'hostDetails.otherProtocols': '其他协议',
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': '端口',
'hostDetails.telnet.credentials': '凭据',
'hostDetails.telnet.identity.placeholder': '手动身份',
'hostDetails.telnet.username': 'Telnet 用户名',
'hostDetails.telnet.password': 'Telnet 密码',
'hostDetails.charset.placeholder': '字符集(例如 UTF-8)',
@@ -432,6 +437,8 @@ export const zhCNVaultMessages: Messages = {
'terminal.auth.passphrase.placeholder': '可选:所选私钥的密码短语',
'terminal.auth.certificate': '证书',
'terminal.auth.selectKey': '选择密钥',
'terminal.auth.retryMessage': '身份验证失败。请检查凭据后重试。',
'terminal.auth.retryLog': '身份验证失败,请重试。',
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
'terminal.auth.continueSave': '继续并保存',
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
+20
View File
@@ -69,6 +69,18 @@ export const zhTWCoreMessages: Messages = {
'settings.tab.system': '系統',
'settings.terminal.connection.verifyHostKeys': '驗證 SSH 主機金鑰',
'settings.terminal.connection.verifyHostKeys.desc': '連線到新的或已變化的 SSH 主機金鑰前先詢問。只建議在可信內網中關閉。',
'settings.terminal.section.sidePanel': '側邊欄',
'settings.terminal.sidePanel.autoOpen': '開啟終端時展開側邊欄',
'settings.terminal.sidePanel.autoOpen.desc': '終端連線成功後自動開啟一個側邊欄。',
'settings.terminal.sidePanel.autoOpenPane': '預設側邊欄',
'settings.terminal.sidePanel.autoOpenPane.desc': '選擇優先開啟哪個面板。',
'settings.terminal.sidePanel.pane.sftp': 'SFTP',
'settings.terminal.sidePanel.pane.scripts': '指令碼',
'settings.terminal.sidePanel.pane.history': '歷史',
'settings.terminal.sidePanel.pane.theme': '主題',
'settings.terminal.sidePanel.pane.system': '系統',
'settings.terminal.sidePanel.pane.notes': '筆記',
'settings.terminal.sidePanel.pane.ai': 'AI',
// Settings > System
'settings.system.title': '系統',
@@ -438,6 +450,12 @@ export const zhTWCoreMessages: Messages = {
'vault.groups.errors.invalidChars': "分組名稱不能包含 '/' 或 '\\\\'.",
'vault.groups.errors.duplicatePath': '該位置已存在同名分組。',
'vault.deleteConfirm.title': '刪除「{name}」?',
'vault.deleteConfirm.desc': '此操作不可復原。',
'vault.deleteConfirm.packageDesc': '這會刪除程式碼包,包內程式碼片段會保留,並移出該程式碼包。',
'vault.deleteConfirm.noteGroupDesc': '這會刪除資料夾,資料夾內筆記會保留,並移出該資料夾。',
'vault.deleteConfirm.portForwardingDesc': '這會刪除此埠轉發規則。',
'vault.managedSource.unmanage': '取消託管',
'vault.managedSource.unmanageSuccess': '已取消託管分組',
@@ -480,6 +498,8 @@ export const zhTWCoreMessages: Messages = {
'vault.hosts.empty.title': '設定你的主機',
'vault.hosts.empty.desc': '儲存主機以快速連線到你的伺服器、虛擬機器和容器。',
'deepLink.ssh.invalid': '無效的 ssh:// 連結',
'deepLink.jms.invalid': '無效的 JumpServer 連結',
'deepLink.jms.unsupported': '不支援的 JumpServer 協定:{{protocol}}',
// Vault import
'vault.import.title': '新增資料到你的 Vault',
+6 -1
View File
@@ -9,7 +9,7 @@ export const zhTWScriptsMessages = {
'scripts.trigger.manual': '手動執行',
'scripts.trigger.onConnect': '連線時執行',
'scripts.trigger.onOutput': '輸出比對時執行',
'scripts.trigger.onOutputHint': '僅在伺服器端輸出比對到正規表示式時觸發(會忽略本機按鍵回顯)。未設定目標主機時,對目前已連線的工作階段生效;設定目標後僅監聽對應主機。進入備用畫面的應用程式(如 vim、htop)期間不會觸發。本 session 有其他指令碼執行期間會抑制;指令碼結束後再檢查 buffer。流程內等待請用 nct.screen.waitFor。',
'scripts.trigger.onOutputHint': '僅在伺服器端輸出比對到正規表示式時觸發(會忽略本機按鍵回顯)。未設定目標主機時,對目前已連線的工作階段生效;設定目標後僅監聽對應主機。進入備用畫面的應用程式(如 vim、htop)期間不會觸發。本 session 有其他指令碼執行期間會抑制;指令碼結束後再檢查 buffer。流程內等待一般文字用 nct.screen.waitForText,正規表示式用 nct.screen.waitForRegex。',
'scripts.actions.save': '儲存',
'scripts.actions.runNow': '立即執行',
'scripts.actions.openEditor': '彈出視窗編輯',
@@ -69,6 +69,11 @@ export const zhTWScriptsMessages = {
'scripts.recording.helpTip4': 'vim、互動式選單等複雜操作,建議先錄主幹指令,再在編輯器裡手動補充。',
'scripts.dialog.title': '指令碼',
'scripts.dialog.ok': '確定',
'scripts.dialog.required': '必填',
'scripts.dialog.numberInvalid': '請輸入有效數字',
'scripts.dialog.numberMin': '不能小於 {min}',
'scripts.dialog.numberMax': '不能大於 {max}',
'scripts.dialog.numberStep': '步進必須為 {step} 的倍數',
'scripts.dialog.waitForTimeoutTitle': '等待逾時',
'scripts.dialog.retry': '重試',
'scripts.dialog.skip': '跳過',
@@ -6,6 +6,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.noSession': '沒有使用中的終端工作階段。',
'systemManager.notConnected': '請先連線到主機以管理程序與服務。',
'systemManager.empty': '暫無資料。',
'systemManager.tabs.overview': '概覽',
'systemManager.tabs.processes': '程序',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
@@ -21,8 +22,35 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '載入容器列表失敗',
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
'systemManager.errors.loadOverview': '載入系統概覽失敗',
'systemManager.errors.sshChannelUnavailable': '伺服器拒絕開啟新的執行通道。請稍後重試,或重新連線目前主機。',
'systemManager.overview.empty': '暫無系統概覽資料。',
'systemManager.overview.loading': '正在載入系統概覽…',
'systemManager.overview.memory': '記憶體',
'systemManager.overview.disk': '磁碟',
'systemManager.overview.network': '網路',
'systemManager.overview.rx': '下行',
'systemManager.overview.tx': '上行',
'systemManager.overview.cores': '{{count}} 核心',
'systemManager.overview.load': '負載',
'systemManager.overview.uptime': '執行時間',
'systemManager.overview.duration.daysHours': '{{days}}天 {{hours}}小時',
'systemManager.overview.duration.hoursMinutes': '{{hours}}小時 {{minutes}}分鐘',
'systemManager.overview.duration.minutes': '{{minutes}}分鐘',
'systemManager.overview.system': '系統',
'systemManager.overview.kernel': '核心',
'systemManager.overview.swap': '交換區',
'systemManager.overview.latency': '延遲',
'systemManager.overview.cpuCores': 'CPU 核心',
'systemManager.overview.disks': '磁碟',
'systemManager.overview.interfaces': '網路介面',
'systemManager.overview.topProcesses': '記憶體占用最高程序',
'systemManager.overview.noData': '暫無資料',
'systemManager.overview.noDisks': '暫無磁碟資料',
'systemManager.overview.noInterfaces': '暫無網卡資料',
'systemManager.overview.noTopProcesses': '暫無程序資料',
'systemManager.processes.search': '搜尋程序…',
'systemManager.processes.command': '指令',
'systemManager.processes.user': '使用者',
@@ -20,6 +20,9 @@ export const zhTWTerminalMessages: Messages = {
'terminal.osc7Setup.sent': '目錄追蹤設定已傳送到終端',
'terminal.toolbar.timestampsEnable': '顯示時間戳',
'terminal.toolbar.timestampsDisable': '隱藏時間戳',
'terminal.progress.enterReconnectHint': '按 Enter 重新連線',
'terminal.progress.autoReconnectScheduled': '連線已斷開,將在 {seconds} 秒後自動重連(第 {attempt} 次)。',
'terminal.progress.autoReconnectAttempt': '正在自動重連(第 {attempt} 次)...',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支援 Netcatty 的代理設定。請改用 SSH,或移除該主機的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支援一個跳板機。',
@@ -104,6 +107,9 @@ export const zhTWTerminalMessages: Messages = {
'settings.sshDeepLink.title': 'SSH 連結',
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 連結',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh:// 連結。',
'settings.jmsDeepLink.title': 'JumpServer 連結',
'settings.jmsDeepLink.enable': '處理 jms:// 連結',
'settings.jmsDeepLink.enableDesc': '將 Netcatty 註冊為 JumpServer jms:// 用戶端連結的處理程式,可能與官方 JumpServer 用戶端衝突。',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': '自動同步到遠端',
@@ -332,6 +338,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉標籤頁或連線恢復。',
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
'settings.terminal.connection.keepaliveInterval.desc': '向伺服器傳送 SSH 保活資料包的頻率(秒)。設為 0 表示全域停用——單個主機可在自己的設定裡覆蓋此值。',
'settings.terminal.connection.keepaliveCountMax': '最大無響應保活次數',
@@ -462,12 +470,17 @@ export const zhTWTerminalMessages: Messages = {
'hostDetails.proxyPanel.usernamePlaceholder': '使用者名稱',
'hostDetails.proxyPanel.passwordPlaceholder': '密碼',
'hostDetails.proxyPanel.identities': '身份',
'hostDetails.proxyPanel.keychainIdentity': '鑰匙串身份',
'hostDetails.proxyPanel.manualCredentials': '手動憑證',
'hostDetails.proxyPanel.remove': '移除代理',
'hostDetails.proxyPanel.savedProxy': '已儲存代理',
'hostDetails.proxyPanel.selectSaved': '選擇已儲存代理',
'hostDetails.proxyPanel.customProxy': '自訂代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missingSaved': '儲存的代理不存在',
'hostDetails.proxyPanel.missingIdentity': '鑰匙串身份不存在',
'hostDetails.proxyPanel.incompleteIdentity': '代理身份需要使用者名稱和密碼',
'hostDetails.proxyPanel.unreadableIdentity': '此裝置無法讀取代理身份密碼',
'hostDetails.proxyPanel.error.required': '代理主機和埠,或 ProxyCommand 不能為空。',
'hostDetails.envVars.title': '環境變數',
'hostDetails.envVars.desc': '為 {host} 設定環境變數。',
@@ -695,6 +708,10 @@ export const zhTWTerminalMessages: Messages = {
'snippets.renameDialog.error.invalidChars': '程式碼包名稱只能包含字母、數字、連字元和下劃線',
'snippets.field.noAutoRun': '僅貼上(不自動執行)',
'snippets.field.multiLineRunMode': '多行執行方式',
'snippets.field.multiLineRunMode.paste': '一次性傳送',
'snippets.field.multiLineRunMode.lineDelay': '逐行傳送',
'snippets.field.multiLineRunModeHint': '登入提示、網路設備巨集等情境可用逐行傳送。',
// Snippet Shortkey
'snippets.field.shortkey': '快捷鍵',
'snippets.shortkey.placeholder': '點選設定快捷鍵',
+7
View File
@@ -248,10 +248,15 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.startupCommand': '啟動指令',
'hostDetails.startupCommand.placeholder': '連線後執行的指令(例如:cd /app && ls)',
'hostDetails.startupCommand.help': 'SSH 連線建立後將自動執行該指令。',
'hostDetails.startupCommand.runMode': '多行執行方式',
'hostDetails.startupCommand.runMode.help': '選擇連線後如何傳送多行啟動指令。',
'hostDetails.startupCommand.runMode.lineDelay': '逐行傳送',
'hostDetails.startupCommand.runMode.paste': '一次性傳送',
'hostDetails.otherProtocols': '其他協定',
'hostDetails.telnetOn': 'Telnet on',
'hostDetails.port': '埠',
'hostDetails.telnet.credentials': '憑證',
'hostDetails.telnet.identity.placeholder': '手動身份',
'hostDetails.telnet.username': 'Telnet 使用者名稱',
'hostDetails.telnet.password': 'Telnet 密碼',
'hostDetails.charset.placeholder': '字元集(例如 UTF-8)',
@@ -432,6 +437,8 @@ export const zhTWVaultMessages: Messages = {
'terminal.auth.passphrase.placeholder': '可選:所選私鑰的密碼短語',
'terminal.auth.certificate': '憑證',
'terminal.auth.selectKey': '選擇金鑰',
'terminal.auth.retryMessage': '驗證失敗。請檢查憑證後再試一次。',
'terminal.auth.retryLog': '驗證失敗,請再試一次。',
'terminal.auth.noKeysHint': '暫無金鑰,請先在鑰匙串中新增。',
'terminal.auth.continueSave': '繼續並儲存',
'terminal.auth.credentialsUnavailable': '目前裝置無法解密已儲存憑證,請重新輸入並再次儲存。',
+41 -1
View File
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
import { activeTabStore, fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
test('editor tab helpers round trip ids', () => {
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
@@ -12,3 +13,42 @@ test('editor tab helper detects editor top-tab ids', () => {
assert.equal(isEditorTabId('editor:file-1'), true);
assert.equal(isEditorTabId('session-1'), false);
});
test('active tab changes do not start terminal layout suppression', async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window ??= globalThis;
while (terminalLayoutSuppressStore.getActive()) {
terminalLayoutSuppressStore.end();
}
await new Promise((resolve) => setTimeout(resolve, 0));
let activeNotifyCount = 0;
let suppressNotifyCount = 0;
const unsubscribeActiveTab = activeTabStore.subscribe(() => {
activeNotifyCount += 1;
});
const unsubscribeSuppress = terminalLayoutSuppressStore.subscribe(() => {
suppressNotifyCount += 1;
});
try {
activeTabStore.setActiveTabId(`no-suppress-test-${Date.now()}`);
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(activeNotifyCount, 1);
assert.equal(suppressNotifyCount, 0);
assert.equal(terminalLayoutSuppressStore.getActive(), false);
} finally {
unsubscribeActiveTab();
unsubscribeSuppress();
if (previousWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = previousWindow;
}
while (terminalLayoutSuppressStore.getActive()) {
terminalLayoutSuppressStore.end();
}
}
});
-16
View File
@@ -1,7 +1,5 @@
import { useCallback, useSyncExternalStore } from 'react';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
type SyncListener = (activeTabId: string) => void;
@@ -39,20 +37,11 @@ class ActiveTabStore {
setActiveTabId = (id: string) => {
if (this.activeTabId !== id) {
terminalLayoutSuppressStore.begin();
this.activeTabId = id;
this.syncListeners.forEach((listener) => listener(id));
// Coalesce rapid tab switches into one notification per frame and avoid
// "setState during render" if called from a render phase.
this.scheduleNotify();
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
schedule(() => {
schedule(() => {
terminalLayoutSuppressStore.end();
});
});
}
};
@@ -78,11 +67,6 @@ export const useActiveTabId = () => {
);
};
// Hook to get setter - never causes re-render
export const useSetActiveTabId = () => {
return activeTabStore.setActiveTabId;
};
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
export const useIsTabActive = (tabId: string) => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
-14
View File
@@ -142,14 +142,6 @@ export const customThemeStore = new CustomThemeStore();
// ============== Hooks ==============
/** Get all themes (built-in + custom) */
export const useAllThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getAllThemes
);
};
/** Get custom themes only */
export const useCustomThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
@@ -158,12 +150,6 @@ export const useCustomThemes = (): TerminalTheme[] => {
);
};
/** Get theme by ID (built-in or custom) with fallback */
export const useThemeById = (id: string): TerminalTheme => {
const allThemes = useAllThemes();
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
};
/** Theme mutation actions */
export const useCustomThemeActions = () => {
const addTheme = useCallback((theme: TerminalTheme) => {
-18
View File
@@ -134,24 +134,6 @@ export const useAvailableFonts = (): TerminalFont[] => {
);
};
/**
* Get font loading state
*/
export const useFontsLoading = (): boolean => {
return useSyncExternalStore(
fontStore.subscribe,
fontStore.getIsLoading
);
};
/**
* Get font by ID with fallback - useful for components that need a specific font
*/
export const useFontById = (fontId: string): TerminalFont => {
const fonts = useAvailableFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || TERMINAL_FONTS[0];
};
/**
* Initialize fonts eagerly (call at app startup)
*/
@@ -157,7 +157,11 @@ export async function runConnectScriptsSequential(params: {
export async function runSnippetOrScript(params: {
snippet: Snippet;
sessionId: string;
runSnippetText: (command: string, noAutoRun?: boolean) => void;
runSnippetText: (
command: string,
noAutoRun?: boolean,
options?: { multiLineRunMode?: Snippet["multiLineRunMode"] },
) => void;
command: string;
}) {
if (isScriptSnippet(params.snippet)) {
@@ -167,7 +171,9 @@ export async function runSnippetOrScript(params: {
});
return;
}
params.runSnippetText(params.command, params.snippet.noAutoRun);
params.runSnippetText(params.command, params.snippet.noAutoRun, {
multiLineRunMode: params.snippet.multiLineRunMode,
});
}
export async function stopScriptRun(runId: string): Promise<{ ok: boolean }> {
+4
View File
@@ -6,6 +6,7 @@ export interface LocalTerminalOptions {
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
localStartDir?: string;
}
export const createLocalTerminalSession = (
@@ -24,6 +25,7 @@ export const createLocalTerminalSession = (
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
localStartDir: options?.localStartDir,
});
export const createSerialTerminalSession = (
@@ -86,5 +88,7 @@ export const createHostTerminalSession = (
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
...(host.ephemeral ? { ephemeralHost: true } : {}),
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
};
};
+22
View File
@@ -21,6 +21,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
@@ -39,6 +40,8 @@ import {
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
@@ -49,6 +52,7 @@ import {
isValidUiFontId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
interface UseSettingsIpcSyncParams {
enabled?: boolean;
@@ -70,6 +74,7 @@ interface UseSettingsIpcSyncParams {
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
@@ -82,6 +87,8 @@ interface UseSettingsIpcSyncParams {
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
@@ -108,6 +115,7 @@ export function useSettingsIpcSync({
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -120,6 +128,8 @@ export function useSettingsIpcSync({
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -211,6 +221,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_SSH_DEEP_LINK_ENABLED && typeof value === 'boolean') {
setSshDeepLinkEnabledState(value);
}
if (key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && typeof value === 'boolean') {
setJmsDeepLinkEnabledState(value);
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -254,6 +267,12 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN && typeof value === 'boolean') {
setTerminalSidePanelAutoOpenState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && isTerminalSidePanelAutoOpenTab(value)) {
setTerminalSidePanelAutoOpenTabState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
}
@@ -291,11 +310,14 @@ export function useSettingsIpcSync({
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -92,6 +92,7 @@ export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
export const DEFAULT_SSH_DEEP_LINK_ENABLED = true;
export const DEFAULT_JMS_DEEP_LINK_ENABLED = false;
export const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
+38 -8
View File
@@ -17,6 +17,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
@@ -31,6 +32,8 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
@@ -58,6 +61,7 @@ import {
isValidUiThemeId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
interface UseSettingsStorageSyncParams {
enabled?: boolean;
@@ -85,6 +89,8 @@ interface UseSettingsStorageSyncParams {
showOnlyUngroupedHostsInRoot: boolean;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
shellOnlyTabNumberShortcuts: boolean;
disableTerminalFontZoom: boolean;
restorePreviousSession: boolean;
@@ -96,6 +102,7 @@ interface UseSettingsStorageSyncParams {
sessionLogsTimestampsEnabled: boolean;
sshDebugLogsEnabled: boolean;
sshDeepLinkEnabled: boolean;
jmsDeepLinkEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
@@ -126,6 +133,8 @@ interface UseSettingsStorageSyncParams {
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
@@ -137,6 +146,7 @@ interface UseSettingsStorageSyncParams {
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDeepLinkEnabledState: (enabled: boolean) => void;
setJmsDeepLinkEnabledState: (enabled: boolean) => void;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
@@ -154,8 +164,8 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -163,8 +173,8 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
@@ -177,8 +187,8 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
settingsSnapshotRef.current = {
@@ -187,8 +197,8 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -358,6 +368,12 @@ export function useSettingsStorageSync({
setSshDeepLinkEnabledState(newValue);
}
}
if (e.key === STORAGE_KEY_JMS_DEEP_LINK_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.jmsDeepLinkEnabled) {
setJmsDeepLinkEnabledState(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -408,6 +424,17 @@ export function useSettingsStorageSync({
setShowHostTreeSidebarState(newValue);
}
}
if (e.key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.terminalSidePanelAutoOpen) {
setTerminalSidePanelAutoOpenState(newValue);
}
}
if (e.key === STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB && e.newValue !== null) {
if (isTerminalSidePanelAutoOpenTab(e.newValue) && e.newValue !== s.terminalSidePanelAutoOpenTab) {
setTerminalSidePanelAutoOpenTabState(e.newValue);
}
}
if (e.key === STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.shellOnlyTabNumberShortcuts) {
@@ -496,6 +523,7 @@ export function useSettingsStorageSync({
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDeepLinkEnabledState,
setJmsDeepLinkEnabledState,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
@@ -507,6 +535,8 @@ export function useSettingsStorageSync({
setSftpUseCompressedUpload,
setShowOnlyUngroupedHostsInRootState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
@@ -1,63 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCurrentTerminalTheme } from "./settingsTerminalTheme";
const commonArgs = {
terminalThemeId: "netcatty-dark",
customThemes: [],
resolvedTheme: "dark" as const,
lightUiThemeId: "snow",
darkUiThemeId: "github",
accentMode: "theme" as const,
customAccent: "",
};
test("follow-app terminal theme uses pending pick before resolvedTheme catches up", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: true,
pendingFollowAppTerminalThemeId: "system-flexoki-light",
resolvedTheme: "dark",
lightUiThemeId: "flexoki",
darkUiThemeId: "github",
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "system-flexoki-light");
});
test("follow-app terminal theme ignores manual per-mode terminal picks", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: true,
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "system-github-dark");
});
test("manual terminal theme uses the active per-mode pick", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
followAppTerminalTheme: false,
terminalThemeDarkId: "dracula",
terminalThemeLightId: "solarized-light",
});
assert.equal(theme.id, "dracula");
});
test("manual terminal theme preserves the saved global theme until a per-mode pick exists", () => {
const theme = resolveCurrentTerminalTheme({
...commonArgs,
terminalThemeId: "dracula",
followAppTerminalTheme: false,
terminalThemeDarkId: "auto",
terminalThemeLightId: "auto",
});
assert.equal(theme.id, "dracula");
});
@@ -1,63 +0,0 @@
import type { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES, getBuiltinTerminalThemeById } from '../../infrastructure/config/terminalThemes';
import {
applyCustomAccentToTerminalTheme,
resolveFollowAppTerminalThemeId,
resolveManualTerminalThemeId,
} from '../../domain/terminalAppearance';
interface ResolveCurrentTerminalThemeParams {
terminalThemeId: string;
terminalThemeDarkId: string;
terminalThemeLightId: string;
customThemes: TerminalTheme[];
followAppTerminalTheme: boolean;
pendingFollowAppTerminalThemeId?: string | null;
resolvedTheme: 'light' | 'dark';
lightUiThemeId: string;
darkUiThemeId: string;
accentMode: 'theme' | 'custom';
customAccent: string;
}
export function resolveCurrentTerminalTheme({
terminalThemeId,
terminalThemeDarkId,
terminalThemeLightId,
customThemes,
followAppTerminalTheme,
pendingFollowAppTerminalThemeId = null,
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
accentMode,
customAccent,
}: ResolveCurrentTerminalThemeParams): TerminalTheme {
if (followAppTerminalTheme) {
const followedId = resolveFollowAppTerminalThemeId(pendingFollowAppTerminalThemeId, {
resolvedTheme,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const followed = getBuiltinTerminalThemeById(followedId)
|| customThemes.find(t => t.id === followedId);
if (followed) {
return applyCustomAccentToTerminalTheme(followed, accentMode, customAccent);
}
}
const manualThemeId = resolveManualTerminalThemeId({
resolvedTheme,
terminalThemeDarkId,
terminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
fallbackThemeId: terminalThemeId,
});
const baseTheme = getBuiltinTerminalThemeById(manualThemeId)
|| customThemes.find(t => t.id === manualThemeId)
|| getBuiltinTerminalThemeById(terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
import type { Host, Identity, KnownHost, SSHKey } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
@@ -52,6 +52,129 @@ test("buildSftpHostCredentials rejects missing saved proxy profiles on jump host
);
});
test("buildSftpHostCredentials rejects missing proxy identities", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
}),
hosts: [],
keys: [],
identities: [],
}),
/Proxy identity for "Host" is missing/,
);
});
test("buildSftpHostCredentials rejects incomplete proxy identities", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
}),
hosts: [],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
created: 1,
}],
}),
/Proxy identity for "Host" is incomplete/,
);
});
test("buildSftpHostCredentials rejects proxy identities with blank usernames even when passwords are encrypted", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
}),
hosts: [],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: " ",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy identity for "Host" is incomplete/,
);
});
test("buildSftpHostCredentials rejects missing proxy identities on jump hosts", () => {
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
});
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [],
}),
/Proxy identity for "Jump" is missing/,
);
});
test("buildSftpHostCredentials rejects incomplete proxy identities on jump hosts", () => {
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
});
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy identity for "Jump" is incomplete/,
);
});
test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
const credentials = buildSftpHostCredentials({
host: host({
@@ -77,6 +200,130 @@ test("buildSftpHostCredentials forwards custom ProxyCommand settings", () => {
});
});
test("buildSftpHostCredentials resolves proxy credentials from a selected identity", () => {
const identity: Identity = {
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "proxy-secret",
created: 1,
};
const credentials = buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "socks5",
host: "proxy.example.com",
port: 1080,
identityId: identity.id,
},
}),
hosts: [],
keys: [],
identities: [identity],
});
assert.deepEqual(credentials.proxy, {
type: "socks5",
host: "proxy.example.com",
port: 1080,
username: "proxy-user",
password: "proxy-secret",
});
});
test("buildSftpHostCredentials rejects undecryptable proxy identity passwords", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
}),
hosts: [],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy credentials cannot be decrypted/,
);
});
test("buildSftpHostCredentials resolves jump host proxy credentials from a selected identity", () => {
const identity: Identity = {
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "proxy-secret",
created: 1,
};
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "socks5",
host: "jump-proxy.example.com",
port: 1080,
identityId: identity.id,
},
});
const credentials = buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [identity],
});
assert.deepEqual(credentials.jumpHosts?.[0]?.proxy, {
type: "socks5",
host: "jump-proxy.example.com",
port: 1080,
username: "proxy-user",
password: "proxy-secret",
});
});
test("buildSftpHostCredentials rejects undecryptable jump host proxy identity passwords", () => {
const jumpHost = host({
id: "jump-1",
label: "Jump",
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
});
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
/Proxy credentials for jump host "Jump" cannot be decrypted/,
);
});
test("buildSftpHostCredentials passes reference keys as identity file paths", () => {
const key: SSHKey = {
id: "key-1",
@@ -3,7 +3,15 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
import { hasUsableProxyConfig } from "../../../domain/proxyProfiles";
import {
findIncompleteProxyIdentityId,
findMissingProxyIdentityId,
formatIncompleteProxyIdentityMessage,
formatMissingProxyIdentityMessage,
hasUnreadableProxyCredential,
hasUsableProxyConfig,
resolveProxyConfigAuth,
} from "../../../domain/proxyProfiles";
// Fallback used when no global TerminalSettings are wired through (older
// call sites or tests). Matches DEFAULT_TERMINAL_SETTINGS so behavior is
@@ -34,19 +42,18 @@ export const buildSftpHostCredentials = ({
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.`);
}
if (findMissingProxyIdentityId(host.proxyConfig, identities)) {
throw new Error(formatMissingProxyIdentityMessage(host.label || host.hostname));
}
if (findIncompleteProxyIdentityId(host.proxyConfig, identities)) {
throw new Error(formatIncompleteProxyIdentityMessage(host.label || host.hostname));
}
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
command: host.proxyConfig.command,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
? resolveProxyConfigAuth(host.proxyConfig, identities)
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
@@ -58,6 +65,12 @@ export const buildSftpHostCredentials = ({
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
}
if (findMissingProxyIdentityId(jumpHost.proxyConfig, identities)) {
throw new Error(formatMissingProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
}
if (findIncompleteProxyIdentityId(jumpHost.proxyConfig, identities)) {
throw new Error(formatIncompleteProxyIdentityMessage(jumpHost.label || jumpHost.hostname));
}
return jumpHost;
}).map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
@@ -80,9 +93,7 @@ export const buildSftpHostCredentials = ({
hasUsableProxyConfig(jumpHost.proxyConfig);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
hasUnreadableProxyCredential(jumpHost.proxyConfig, identities)
) {
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.`);
}
@@ -110,14 +121,7 @@ export const buildSftpHostCredentials = ({
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
command: jumpHost.proxyConfig.command,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
? resolveProxyConfigAuth(jumpHost.proxyConfig, identities)
: undefined,
identityFilePaths: jumpKeyAuth.identityFilePaths,
keepaliveInterval: hopKeepalive.interval,
@@ -130,7 +134,7 @@ export const buildSftpHostCredentials = ({
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
if (usesTargetProxyForFirstHop && hasUnreadableProxyCredential(host.proxyConfig, identities)) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
+63 -7
View File
@@ -43,10 +43,15 @@ interface UseSftpPaneActionsParams {
dirCacheTtlMs: number;
}
export type SftpNavigateResult = "reached" | "failed" | "aborted";
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
export type SftpNavigateOptions = {
force?: boolean;
tabId?: string;
shouldApply?: () => boolean;
};
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<SftpNavigateResult>;
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
@@ -161,7 +166,7 @@ export const useSftpPaneActions = ({
async (
side: "left" | "right",
path: string,
options?: { force?: boolean; tabId?: string },
options?: SftpNavigateOptions,
): Promise<SftpNavigateResult> => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
// When tabId is specified, target that specific tab instead of the active one.
@@ -176,6 +181,10 @@ export const useSftpPaneActions = ({
return "aborted";
}
if (options?.shouldApply?.() === false) {
return "aborted";
}
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, path, pane.filenameEncoding);
@@ -244,6 +253,31 @@ export const useSftpPaneActions = ({
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
const previousFilter = confirmed.filter;
const previousError = pane.error;
const isTargetRequestCurrent = () => tabNavSeqRef.current.get(targetTabId) === requestId;
const getTargetPane = () => {
const currentSideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
return currentSideTabs.tabs.find((t) => t.id === targetTabId) ?? null;
};
const shouldApplyNavigationState = () => (
getTargetPane()?.connection?.id === connectionId
&& (options?.shouldApply?.() ?? true)
);
const restoreConfirmedStateIfCurrent = () => {
if (!isTargetRequestCurrent()) return;
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId || !isTargetRequestCurrent()) return prev;
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
filter: previousFilter,
error: previousError,
loading: false,
};
});
};
tabNavSeqRef.current.set(targetTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
@@ -267,6 +301,13 @@ export const useSftpPaneActions = ({
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
if (!isTargetRequestCurrent()) {
return "superseded";
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
clearCacheForConnection(pane.connection.id);
// For background tabs (explicit tabId), update that tab directly
// instead of handleSessionError which targets the active tab.
@@ -286,6 +327,13 @@ export const useSftpPaneActions = ({
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
if (!isTargetRequestCurrent()) {
return "superseded";
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
if (options?.tabId) {
@@ -306,14 +354,18 @@ export const useSftpPaneActions = ({
if (navSeqRef.current[side] !== requestId) {
// Side-level sequence was bumped by another tab's navigation or
// a connect/disconnect. Check if THIS tab's request is still current.
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
if (!isTargetRequestCurrent()) {
// This tab also has a newer navigation — drop completely.
return "aborted";
return "superseded";
}
// Side was superseded by another tab, but this tab's request is
// still current. The fetched files are valid — fall through to
// apply them instead of restoring previousPath.
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
dirCacheRef.current.set(cacheKey, {
files,
@@ -349,12 +401,16 @@ export const useSftpPaneActions = ({
return "reached";
} catch (err) {
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
return "aborted";
if (!isTargetRequestCurrent()) {
return "superseded";
}
// Side superseded by another tab, but this tab's request is
// current — fall through to show the error on this tab.
}
if (!shouldApplyNavigationState()) {
restoreConfirmedStateIfCurrent();
return "aborted";
}
let navigationFailed = false;
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
@@ -0,0 +1,127 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
TERMINAL_AUTO_RECONNECT_DELAY_MS,
canAttemptTerminalAutoReconnect,
isTerminalAutoReconnectEnabled,
shouldAutoReconnectAfterExit,
shouldContinueAutoReconnectAfterFailure,
} from "./terminalAutoReconnect";
const sshHost = {
protocol: "ssh" as const,
hostname: "example.com",
};
test("terminal auto reconnect uses a five second retry delay", () => {
assert.equal(TERMINAL_AUTO_RECONNECT_DELAY_MS, 5000);
});
test("terminal auto reconnect is disabled unless the setting is explicitly true", () => {
assert.equal(isTerminalAutoReconnectEnabled(undefined), false);
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: true }), true);
assert.equal(isTerminalAutoReconnectEnabled({ sshAutoReconnectEnabled: false }), false);
});
test("unexpected SSH exits reconnect only after the tab has connected before", () => {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "closed" },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
true,
);
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "closed" },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: false,
}),
false,
);
});
test("normal shell exits do not auto reconnect", () => {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "exited", exitCode: 0 },
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
});
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
const variants = [
{ protocol: "local" as const, hostname: "localhost" },
{ protocol: "serial" as const, hostname: "/dev/tty.usbserial" },
{ protocol: "telnet" as const, hostname: "router.local" },
{ protocol: "ssh" as const, hostname: "example.com", moshEnabled: true },
{ protocol: "ssh" as const, hostname: "example.com", etEnabled: true },
];
for (const host of variants) {
assert.equal(
shouldAutoReconnectAfterExit({
evt: { reason: "error", error: "connection reset" },
host,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
}
});
test("an active auto reconnect loop continues after failed retry attempts", () => {
assert.equal(
shouldContinueAutoReconnectAfterFailure({
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
loopActive: true,
}),
true,
);
assert.equal(
shouldContinueAutoReconnectAfterFailure({
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: false },
loopActive: true,
}),
false,
);
});
test("terminal auto reconnect can start from live or fully hibernated runtimes", () => {
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: true,
isHibernated: false,
}),
true,
);
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: false,
isHibernated: true,
}),
true,
);
assert.equal(
canAttemptTerminalAutoReconnect({
hasTerminalRuntime: false,
isHibernated: false,
}),
false,
);
});
@@ -0,0 +1,66 @@
import type { TerminalSettings } from "../../domain/models";
import type { TerminalSessionExitEvent } from "./resolveTerminalSessionExitIntent";
export const TERMINAL_AUTO_RECONNECT_DELAY_MS = 5000;
type AutoReconnectHost = {
protocol?: "ssh" | "telnet" | "local" | "serial" | "mosh" | "et";
hostname?: string;
moshEnabled?: boolean;
etEnabled?: boolean;
};
type AutoReconnectSettings = Pick<TerminalSettings, "sshAutoReconnectEnabled"> | undefined | null;
export function isTerminalAutoReconnectEnabled(settings: AutoReconnectSettings): boolean {
return settings?.sshAutoReconnectEnabled === true;
}
export function isAutoReconnectableSshHost(host: AutoReconnectHost): boolean {
const protocol = host.protocol ?? "ssh";
return (
protocol === "ssh" &&
host.hostname !== "localhost" &&
host.moshEnabled !== true &&
host.etEnabled !== true
);
}
export function shouldAutoReconnectAfterExit({
evt,
host,
terminalSettings,
hasEverConnected,
}: {
evt: TerminalSessionExitEvent;
host: AutoReconnectHost;
terminalSettings?: AutoReconnectSettings;
hasEverConnected: boolean;
}): boolean {
if (!hasEverConnected) return false;
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
return evt.reason !== "exited";
}
export function shouldContinueAutoReconnectAfterFailure({
host,
terminalSettings,
loopActive,
}: {
host: AutoReconnectHost;
terminalSettings?: AutoReconnectSettings;
loopActive: boolean;
}): boolean {
return loopActive && isTerminalAutoReconnectEnabled(terminalSettings) && isAutoReconnectableSshHost(host);
}
export function canAttemptTerminalAutoReconnect({
hasTerminalRuntime,
isHibernated,
}: {
hasTerminalRuntime: boolean;
isHibernated: boolean;
}): boolean {
return hasTerminalRuntime || isHibernated;
}
@@ -49,6 +49,21 @@ test("split session clones reuse only connected SSH sources", () => {
);
});
test("session clones preserve the ephemeral-host marker", () => {
assert.equal(
createSplitTerminalSessionClone(session({ ephemeralHost: true }), { id: "split-1" }).ephemeralHost,
true,
);
assert.equal(
createCopiedTerminalSessionClone(session({ ephemeralHost: true }), { id: "copy-1" }).ephemeralHost,
true,
);
assert.equal(
createSplitTerminalSessionClone(session(), { id: "split-2" }).ephemeralHost,
undefined,
);
});
test("copy session clones reuse SSH sources and preserve serial config", () => {
const copied = createCopiedTerminalSessionClone(
session({
@@ -60,3 +75,19 @@ test("copy session clones reuse SSH sources and preserve serial config", () => {
assert.equal(copied.reuseConnectionFromSessionId, "session-1");
assert.deepEqual(copied.serialConfig, { path: "/dev/tty.usbserial", baudRate: 115200 });
});
test("split and copy session clones preserve local start directory", () => {
const source = session({
protocol: "local",
localStartDir: "/Users/alice/project with spaces ",
});
assert.equal(
createSplitTerminalSessionClone(source, { id: "split-local" }).localStartDir,
"/Users/alice/project with spaces ",
);
assert.equal(
createCopiedTerminalSessionClone(source, { id: "copy-local" }).localStartDir,
"/Users/alice/project with spaces ",
);
});
@@ -43,8 +43,10 @@ function createTerminalSessionClone(
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
localStartDir: session.localStartDir,
fontSize: session.fontSize,
fontSizeOverride: session.fontSizeOverride,
...(session.ephemeralHost ? { ephemeralHost: true } : {}),
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
};
-18
View File
@@ -162,16 +162,6 @@ export const useAvailableUIFonts = (): UIFont[] => {
);
};
/**
* Get UI font loading state
*/
export const useUIFontsLoading = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoading
);
};
/**
* Get UI font loaded state
*/
@@ -182,14 +172,6 @@ export const useUIFontsLoaded = (): boolean => {
);
};
/**
* Get UI font by ID with fallback
*/
export const useUIFontById = (fontId: string): UIFont => {
const fonts = useAvailableUIFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
};
/**
* Check if a font ID is valid
*/
@@ -4,29 +4,178 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { startMainWindowInputFocusRecovery } from "./useMainWindowInputFocusRecovery.ts";
const root = fileURLToPath(new URL("../..", import.meta.url));
function readProjectFile(path: string): string {
return readFileSync(join(root, path), "utf8");
}
test("useMainWindowInputFocusRecovery listens for visibility and window focus", () => {
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
type Listener = () => void;
assert.match(source, /document\.visibilityState !== "visible"/);
assert.match(source, /scheduleWindowInputFocus\(\)/);
assert.match(source, /document\.addEventListener\("visibilitychange", onVisibilityChange\)/);
assert.match(source, /window\.addEventListener\("focus", recoverFocus\)/);
function createEventTargetStub() {
const listeners = new Map<string, Set<Listener>>();
return {
addEventListener(eventName: string, listener: Listener) {
const eventListeners = listeners.get(eventName) ?? new Set<Listener>();
eventListeners.add(listener);
listeners.set(eventName, eventListeners);
},
removeEventListener(eventName: string, listener: Listener) {
listeners.get(eventName)?.delete(listener);
},
dispatch(eventName: string) {
for (const listener of listeners.get(eventName) ?? []) {
listener();
}
},
listenerCount(eventName: string) {
return listeners.get(eventName)?.size ?? 0;
},
};
}
test("main-window input focus recovery ignores generic focus and visible visibility changes", () => {
let visibilityState: DocumentVisibilityState = "visible";
let scheduleFocusCalls = 0;
let cancelFocusCalls = 0;
let hiddenCalls = 0;
let shownHandler: Listener | null = null;
let willHideHandler: Listener | null = null;
const documentTarget = createEventTargetStub();
const windowTarget = createEventTargetStub();
const cleanup = startMainWindowInputFocusRecovery(
{ onPageHidden: () => { hiddenCalls += 1; } },
{
documentRef: {
...documentTarget,
get visibilityState() {
return visibilityState;
},
},
windowRef: windowTarget,
bridge: {
onWindowShown(cb: Listener) {
shownHandler = cb;
return () => { shownHandler = null; };
},
onWindowWillHide(cb: Listener) {
willHideHandler = cb;
return () => { willHideHandler = null; };
},
},
scheduleFocus: () => {
scheduleFocusCalls += 1;
return { cancel: () => { cancelFocusCalls += 1; } };
},
},
);
assert.equal(windowTarget.listenerCount("focus"), 0);
windowTarget.dispatch("focus");
documentTarget.dispatch("visibilitychange");
assert.equal(scheduleFocusCalls, 0);
assert.equal(hiddenCalls, 0);
shownHandler?.();
assert.equal(scheduleFocusCalls, 1);
willHideHandler?.();
assert.equal(hiddenCalls, 1);
assert.equal(cancelFocusCalls, 1);
shownHandler?.();
assert.equal(scheduleFocusCalls, 2);
visibilityState = "hidden";
documentTarget.dispatch("visibilitychange");
assert.equal(hiddenCalls, 2);
assert.equal(cancelFocusCalls, 2);
cleanup();
});
test("useMainWindowInputFocusRecovery dismisses transient UI when the page hides", () => {
test("main-window input focus recovery retries an explicit show when visibility catches up", () => {
let visibilityState: DocumentVisibilityState = "hidden";
let scheduleFocusCalls = 0;
let hiddenCalls = 0;
let shownHandler: Listener | null = null;
const documentTarget = createEventTargetStub();
const cleanup = startMainWindowInputFocusRecovery(
{ onPageHidden: () => { hiddenCalls += 1; } },
{
documentRef: {
...documentTarget,
get visibilityState() {
return visibilityState;
},
},
bridge: {
onWindowShown(cb: Listener) {
shownHandler = cb;
return () => { shownHandler = null; };
},
},
scheduleFocus: () => {
scheduleFocusCalls += 1;
return { cancel: () => undefined };
},
},
);
shownHandler?.();
assert.equal(scheduleFocusCalls, 0);
visibilityState = "visible";
documentTarget.dispatch("visibilitychange");
assert.equal(scheduleFocusCalls, 1);
assert.equal(hiddenCalls, 0);
documentTarget.dispatch("visibilitychange");
assert.equal(scheduleFocusCalls, 1);
cleanup();
});
test("useMainWindowInputFocusRecovery restores input focus only after explicit window-shown IPC", () => {
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
assert.match(source, /document\.visibilityState === "hidden"/);
assert.match(source, /visibilityState !== "visible"/);
assert.match(source, /scheduleFocus\(\)/);
assert.match(source, /onWindowShown\?\.\(\(\) => \{\s*pendingExplicitShowRecovery = true;\s*recoverFocus\(\);\s*\}\)/);
assert.doesNotMatch(source, /window\.addEventListener\("focus"/);
assert.doesNotMatch(source, /window\.removeEventListener\("focus"/);
});
test("useMainWindowInputFocusRecovery uses visibility changes only to dismiss transient UI", () => {
const source = readProjectFile("application/state/useMainWindowInputFocusRecovery.ts");
const handlerStart = source.indexOf("const onVisibilityChange");
const handlerEnd = source.indexOf('addEventListener("visibilitychange"', handlerStart);
const visibilityHandler = source.slice(handlerStart, handlerEnd);
assert.match(source, /visibilityState === "hidden"/);
assert.match(source, /addEventListener\("visibilitychange", onVisibilityChange\)/);
assert.match(source, /onPageHidden\?\.\(\)/);
assert.match(source, /onWindowShown/);
assert.match(source, /onWindowWillHide/);
assert.match(source, /cancelPendingFocusRecovery/);
assert.match(visibilityHandler, /dismissTransientUi\(\)/);
assert.doesNotMatch(visibilityHandler, /else\s*\{\s*recoverFocus\(\);\s*\}/);
assert.doesNotMatch(visibilityHandler, /visibilityState !== "hidden"[\s\S]*recoverFocus\(\)/);
});
test("scheduleWindowInputFocus skips deferred focus when the page is hidden", () => {
@@ -11,9 +11,101 @@ export type MainWindowInputFocusRecoveryOptions = {
onPageHidden?: () => void;
};
type Listener = () => void;
type MainWindowInputFocusRecoveryDocument = {
visibilityState: DocumentVisibilityState;
addEventListener: (eventName: "visibilitychange", listener: Listener) => void;
removeEventListener: (eventName: "visibilitychange", listener: Listener) => void;
};
type MainWindowInputFocusRecoveryWindow = {
addEventListener: (eventName: "focus", listener: Listener) => void;
removeEventListener: (eventName: "focus", listener: Listener) => void;
};
type MainWindowInputFocusRecoveryBridge = {
onWindowShown?: (callback: Listener) => Listener;
onWindowWillHide?: (callback: Listener) => Listener;
};
export type MainWindowInputFocusRecoveryDependencies = {
documentRef: MainWindowInputFocusRecoveryDocument;
windowRef?: MainWindowInputFocusRecoveryWindow;
bridge?: MainWindowInputFocusRecoveryBridge;
scheduleFocus?: () => ScheduledWindowInputFocus;
};
export function startMainWindowInputFocusRecovery(
options: MainWindowInputFocusRecoveryOptions = {},
dependencies: MainWindowInputFocusRecoveryDependencies = {
documentRef: document,
windowRef: window,
bridge: netcattyBridge.get(),
scheduleFocus: scheduleWindowInputFocus,
},
): () => void {
const { onPageHidden } = options;
const {
documentRef,
bridge,
scheduleFocus = scheduleWindowInputFocus,
} = dependencies;
let pendingFocusRecovery: ScheduledWindowInputFocus | null = null;
let pendingExplicitShowRecovery = false;
const cancelPendingFocusRecovery = () => {
pendingFocusRecovery?.cancel();
pendingFocusRecovery = null;
};
const recoverFocus = (): boolean => {
if (documentRef.visibilityState !== "visible") return false;
pendingExplicitShowRecovery = false;
cancelPendingFocusRecovery();
pendingFocusRecovery = scheduleFocus();
return true;
};
const dismissTransientUi = () => {
pendingExplicitShowRecovery = false;
cancelPendingFocusRecovery();
onPageHidden?.();
};
const onVisibilityChange = () => {
if (documentRef.visibilityState === "hidden") {
dismissTransientUi();
return;
}
if (pendingExplicitShowRecovery) {
recoverFocus();
}
};
documentRef.addEventListener("visibilitychange", onVisibilityChange);
const unsubscribeShown = bridge?.onWindowShown?.(() => {
pendingExplicitShowRecovery = true;
recoverFocus();
});
const unsubscribeWillHide = bridge?.onWindowWillHide?.(() => {
dismissTransientUi();
});
return () => {
pendingExplicitShowRecovery = false;
cancelPendingFocusRecovery();
documentRef.removeEventListener("visibilitychange", onVisibilityChange);
unsubscribeShown?.();
unsubscribeWillHide?.();
};
}
/**
* Recover OS/renderer input focus when the main window returns from hide,
* another app, or a virtual desktop (#760, #1714, #1722).
* Recover OS/renderer input focus when the main process explicitly shows the
* main window (#760, #1714, #1722).
*/
export function useMainWindowInputFocusRecovery(
options: MainWindowInputFocusRecoveryOptions = {},
@@ -21,49 +113,6 @@ export function useMainWindowInputFocusRecovery(
const { onPageHidden } = options;
useEffect(() => {
let pendingFocusRecovery: ScheduledWindowInputFocus | null = null;
const cancelPendingFocusRecovery = () => {
pendingFocusRecovery?.cancel();
pendingFocusRecovery = null;
};
const recoverFocus = () => {
if (document.visibilityState !== "visible") return;
cancelPendingFocusRecovery();
pendingFocusRecovery = scheduleWindowInputFocus();
};
const dismissTransientUi = () => {
cancelPendingFocusRecovery();
onPageHidden?.();
};
const onVisibilityChange = () => {
if (document.visibilityState === "hidden") {
dismissTransientUi();
return;
}
recoverFocus();
};
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("focus", recoverFocus);
const bridge = netcattyBridge.get();
const unsubscribeShown = bridge?.onWindowShown?.(() => {
recoverFocus();
});
const unsubscribeWillHide = bridge?.onWindowWillHide?.(() => {
dismissTransientUi();
});
return () => {
cancelPendingFocusRecovery();
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("focus", recoverFocus);
unsubscribeShown?.();
unsubscribeWillHide?.();
};
return startMainWindowInputFocusRecovery({ onPageHidden });
}, [onPageHidden]);
}
+865 -1
View File
@@ -1,6 +1,15 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { findMatchEndingAfter } from './useOutputTriggers.ts';
import {
createDeferredOutputTriggerEventProcessor,
createOutputTriggerScanBuffer,
droppedOutputOverflowMayAffectAlternateScreenState,
findMatchEndingAfter,
hasApplicableOutputTriggerSnippet,
inspectDroppedOutputOverflowAlternateScreenState,
} from './useOutputTriggers.ts';
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
import type { Snippet } from '@/domain/models';
test('findMatchEndingAfter skips stale overlap matches and finds current output', () => {
const text = 'NETCATTY_TRIGGER_PROBE old\r\nprompt# NETCATTY_TRIGGER_PROBE: command not found\r\n';
@@ -27,3 +36,858 @@ test('findMatchEndingAfter returns null when matches are only stale', () => {
const minEndOffset = 'NETCATTY_TRIGGER_PROBE old\r\n'.length;
assert.equal(findMatchEndingAfter(text, 'NETCATTY_TRIGGER_PROBE', minEndOffset), null);
});
test('hasApplicableOutputTriggerSnippet ignores non-output-trigger snippets', () => {
const snippets: Snippet[] = [
{
id: 'plain',
label: 'Plain',
command: 'echo hello',
kind: 'snippet',
},
{
id: 'manual-script',
label: 'Manual',
command: '',
kind: 'script',
trigger: 'manual',
triggerPattern: 'READY',
},
];
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), false);
});
test('hasApplicableOutputTriggerSnippet requires a runnable output trigger for this host', () => {
const snippets: Snippet[] = [
{
id: 'wrong-host',
label: 'Wrong host',
command: '',
kind: 'script',
trigger: 'onOutput',
triggerPattern: 'READY',
targets: ['host-b'],
},
{
id: 'missing-pattern',
label: 'Missing pattern',
command: '',
kind: 'script',
trigger: 'onOutput',
},
];
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), false);
snippets.push({
id: 'right-host',
label: 'Right host',
command: '',
kind: 'script',
trigger: 'onOutput',
triggerPattern: 'READY',
targets: ['host-a'],
});
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), true);
});
test('output trigger scan buffer consumes scanned content and only keeps overlap', () => {
const buffer = createOutputTriggerScanBuffer(4);
assert.deepEqual(buffer.append('abcdef'), {
text: 'abcdef',
minEndOffset: 0,
baseOffset: 0,
});
assert.deepEqual(buffer.append('gh'), {
text: 'cdefgh',
minEndOffset: 4,
baseOffset: 2,
});
});
test('output trigger scan buffer scans all new large output instead of only the tail', () => {
const buffer = createOutputTriggerScanBuffer(4);
assert.deepEqual(buffer.append('abc'), {
text: 'abc',
minEndOffset: 0,
baseOffset: 0,
});
assert.deepEqual(buffer.append('defghijkl'), {
text: 'abcdefghijkl',
minEndOffset: 3,
baseOffset: 0,
});
});
test('deferred output trigger processor schedules output instead of processing synchronously', () => {
const processed: string[] = [];
const scheduled: Array<() => void> = [];
const processor = createDeferredOutputTriggerEventProcessor({
processOutput: (chunk) => processed.push(chunk),
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('READY');
assert.deepEqual(processed, []);
assert.equal(scheduled.length, 1);
scheduled.shift()?.();
assert.deepEqual(processed, ['READY']);
});
test('deferred output trigger processor coalesces output while preserving input order', () => {
const events: string[] = [];
const scheduled: Array<() => void> = [];
const processor = createDeferredOutputTriggerEventProcessor({
processOutput: (chunk) => events.push(`output:${chunk}`),
processInput: (data) => events.push(`input:${data}`),
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('he');
processor.enqueueOutput('llo');
processor.enqueueInput('cmd');
processor.enqueueOutput('world');
scheduled.shift()?.();
assert.deepEqual(events, ['output:hello', 'input:cmd', 'output:world']);
});
test('deferred output trigger processor splits large output and yields between flushes', () => {
const processed: string[] = [];
const scheduled: Array<() => void> = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 4,
maxOutputBytesPerFlush: 8,
processOutput: (chunk) => processed.push(chunk),
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('abcdefghijkl');
scheduled.shift()?.();
assert.deepEqual(processed, ['abcd', 'efgh']);
assert.equal(scheduled.length, 1);
scheduled.shift()?.();
assert.deepEqual(processed, ['abcd', 'efgh', 'ijkl']);
});
test('deferred output trigger processor splits large input and yields between flushes', () => {
const processed: string[] = [];
const scheduled: Array<() => void> = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 4,
maxOutputBytesPerFlush: 8,
processOutput: () => {},
processInput: (chunk) => processed.push(chunk),
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueInput('abcdefghijkl');
scheduled.shift()?.();
assert.deepEqual(processed, ['abcd', 'efgh']);
assert.equal(scheduled.length, 1);
scheduled.shift()?.();
assert.deepEqual(processed, ['abcd', 'efgh', 'ijkl']);
});
test('deferred output trigger processor caps pending input and resets input state', () => {
const processed: string[] = [];
const scheduled: Array<() => void> = [];
let inputOverflows = 0;
let inputOverflowLines = 0;
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 4,
maxOutputBytesPerFlush: 8,
maxPendingInputBytes: 8,
processOutput: () => {},
processInput: (chunk) => processed.push(chunk),
processInputOverflow: (lineCount) => {
inputOverflows += 1;
inputOverflowLines += lineCount;
},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueInput('abcdefghijkl');
while (scheduled.length > 0) {
scheduled.shift()?.();
}
assert.equal(inputOverflows, 1);
assert.equal(inputOverflowLines, 1);
assert.deepEqual(processed, ['efgh', 'ijkl']);
});
test('deferred output trigger processor reports dropped input echo line count', () => {
const scheduled: Array<() => void> = [];
const overflowLines: number[] = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 16,
maxOutputBytesPerFlush: 32,
maxPendingInputBytes: 5,
processOutput: () => {},
processInput: () => {},
processInputOverflow: (lineCount) => {
overflowLines.push(lineCount);
},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueInput('one\r\ntwo\nthree');
processor.flush();
assert.deepEqual(overflowLines, [3]);
});
test('deferred output trigger processor reports more than 1024 dropped input echo lines', () => {
const scheduled: Array<() => void> = [];
const overflowLines: number[] = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 16,
maxOutputBytesPerFlush: 32,
maxPendingInputBytes: 4,
processOutput: () => {},
processInput: () => {},
processInputOverflow: (lineCount) => {
overflowLines.push(lineCount);
},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueInput(`${'x\n'.repeat(1100)}tail`);
processor.flush();
assert.deepEqual(overflowLines, [1101]);
});
test('deferred output trigger processor includes retained input lines in overflow echo suppression', () => {
const scheduled: Array<() => void> = [];
const overflowLines: number[] = [];
const retainedInput = 'READY c\nREADY d\n';
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 64,
maxOutputBytesPerFlush: 128,
maxPendingInputBytes: retainedInput.length,
processOutput: () => {},
processInput: () => {},
processInputOverflow: (lineCount) => {
overflowLines.push(lineCount);
},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueInput(`READY a\nREADY b\n${retainedInput}`);
processor.flush();
assert.deepEqual(overflowLines, [4]);
});
test('deferred output trigger processor accumulates repeated input overflows before flush', () => {
const overflowLines: number[] = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxPendingInputBytes: 0,
processOutput: () => {},
processInput: () => {},
processInputOverflow: (lineCount) => {
overflowLines.push(lineCount);
},
schedule: () => () => {},
});
processor.enqueueInput('one\ntwo\n');
processor.enqueueInput('three\nfour\n');
processor.flush();
assert.deepEqual(overflowLines, [4]);
});
test('deferred output trigger processor counts input split across overflow boundary as one line', () => {
const overflowLines: number[] = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxPendingInputBytes: 'def\n'.length,
processOutput: () => {},
processInput: () => {},
processInputOverflow: (lineCount) => {
overflowLines.push(lineCount);
},
schedule: () => () => {},
});
processor.enqueueInput('abcdef\n');
processor.flush();
assert.deepEqual(overflowLines, [1]);
});
test('deferred output trigger processor resets input overflow accounting after flush', () => {
const scheduled: Array<() => void> = [];
const overflowLines: number[] = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxPendingInputBytes: 0,
processOutput: () => {},
processInput: () => {},
processInputOverflow: (lineCount) => {
overflowLines.push(lineCount);
},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueInput('one\ntwo\n');
scheduled.shift()?.();
processor.enqueueInput('three\n');
scheduled.shift()?.();
assert.deepEqual(overflowLines, [2, 1]);
});
test('deferred output trigger processor preserves output before later input overflow', () => {
const processed: string[] = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxPendingInputBytes: 0,
processOutput: (chunk) => {
processed.push(`output:${chunk}`);
},
processInput: () => {},
processInputOverflow: (lineCount) => {
processed.push(`overflow:${lineCount}`);
},
schedule: () => () => {},
});
processor.enqueueOutput('READY\n');
processor.enqueueInput('abcdef\n');
processor.flush();
assert.deepEqual(processed, ['output:READY\n', 'overflow:1']);
});
test('deferred output trigger processor suppresses retained multi-line input echo after overflow', () => {
const scheduled: Array<() => void> = [];
const filter = createTerminalOutputTriggerFilter();
const retainedInput = 'READY c\nREADY d\n';
const fullInput = `READY a\nREADY b\n${retainedInput}`;
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 64,
maxOutputBytesPerFlush: 128,
maxPendingInputBytes: retainedInput.length,
processOutput: () => {},
processInput: (chunk) => {
filter.noteUserInput(chunk);
},
processInputOverflow: (lineCount) => {
filter.markInputEchoUncertain(lineCount);
},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueInput(fullInput);
while (scheduled.length > 0) {
scheduled.shift()?.();
}
const result = filter.processServerChunk(`${fullInput}server READY\n`);
assert.equal(result.scannableText, 'server READY\n');
});
test('deferred output trigger processor keeps pending scan output bounded', () => {
const processed: string[] = [];
const scheduled: Array<() => void> = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 4,
maxOutputBytesPerFlush: 64,
maxPendingOutputBytes: 8,
pendingOutputOverlapChars: 4,
processOutput: (chunk) => processed.push(chunk),
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('x'.repeat(80));
processor.enqueueOutput('READY');
assert.equal(processor.getPendingOutputBytes(), 12);
scheduled.shift()?.();
assert.equal(processed.join('').length, 12);
assert.equal(processed.join('').endsWith('READY'), true);
});
test('deferred output trigger processor defers dropped output maintenance work', () => {
const dropped: string[] = [];
const processed: string[] = [];
const scheduled: Array<() => void> = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 4,
maxOutputBytesPerFlush: 8,
maxPendingOutputBytes: 8,
pendingOutputOverlapChars: 0,
processDroppedOutput: (chunk) => dropped.push(chunk),
processOutput: (chunk) => processed.push(chunk),
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('x'.repeat(40));
processor.enqueueOutput('READY');
assert.deepEqual(dropped, []);
assert.deepEqual(processed, []);
scheduled.shift()?.();
assert.equal(dropped.join('').length, 8);
assert.deepEqual(processed, []);
assert.equal(scheduled.length, 1);
});
test('deferred output trigger processor applies output metadata in queue order', () => {
const scanned: string[] = [];
const scheduled: Array<() => void> = [];
let suppressed = false;
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 64,
maxOutputBytesPerFlush: 64,
processOutput: (chunk, meta) => {
if (meta?.droppedOutputMayAffectTerminalState) {
suppressed = true;
}
if (!suppressed) {
scanned.push(chunk);
}
},
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('READY');
processor.enqueueOutput('AFTER_DROP', { droppedOutputMayAffectTerminalState: true });
scheduled.shift()?.();
assert.deepEqual(scanned, ['READY']);
});
test('deferred output trigger processor preserves metadata when trimmed output is fully dropped', () => {
const scanned: string[] = [];
const scheduled: Array<() => void> = [];
let suppressed = false;
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 64,
maxOutputBytesPerFlush: 64,
maxPendingOutputBytes: 5,
pendingOutputOverlapChars: 0,
processDroppedOutput: (_chunk, meta) => {
if (meta?.droppedOutputMayAffectTerminalState) {
suppressed = true;
}
},
processOutput: (chunk) => {
if (!suppressed) {
scanned.push(chunk);
}
},
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('DROP', { droppedOutputMayAffectTerminalState: true });
processor.enqueueOutput('READY');
while (scheduled.length > 0) {
scheduled.shift()?.();
}
assert.deepEqual(scanned, []);
});
test('deferred output trigger processor caps dropped maintenance before retained output', () => {
const dropped: string[] = [];
const processed: string[] = [];
const scheduled: Array<() => void> = [];
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 4,
maxOutputBytesPerFlush: 8,
maxPendingOutputBytes: 8,
pendingOutputOverlapChars: 0,
processDroppedOutput: (chunk) => dropped.push(chunk),
processOutput: (chunk) => processed.push(chunk),
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('x'.repeat(200));
processor.enqueueOutput('READY');
scheduled.shift()?.();
assert.equal(dropped.join('').length, 8);
assert.deepEqual(processed, []);
scheduled.shift()?.();
assert.equal(processed.join('').endsWith('READY'), true);
});
test('deferred output trigger processor inspects dropped overflow with retained context', () => {
let mayAffectAlternateScreen = false;
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputBytesPerFlush: 5,
maxPendingOutputBytes: 0,
pendingOutputOverlapChars: 0,
processDroppedOutput: () => {},
processDroppedOutputOverflow: (overflow) => {
mayAffectAlternateScreen ||= droppedOutputOverflowMayAffectAlternateScreenState(overflow);
},
processOutput: () => {},
processInput: () => {},
schedule: () => () => {},
});
processor.enqueueOutput('\x1b[?10');
processor.enqueueOutput('49hREADY');
assert.equal(mayAffectAlternateScreen, true);
});
test('deferred output trigger processor does not carry dropped context across real output', () => {
const scheduled: Array<() => void> = [];
let mayAffectAlternateScreen = false;
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 8,
maxOutputBytesPerFlush: 3,
maxPendingOutputBytes: 1,
pendingOutputOverlapChars: 0,
processDroppedOutput: () => {},
processDroppedOutputOverflow: (overflow) => {
mayAffectAlternateScreen ||= droppedOutputOverflowMayAffectAlternateScreenState(overflow);
},
processOutput: () => {},
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('\x1b[?Z');
while (scheduled.length > 0) {
scheduled.shift()?.();
}
processor.enqueueOutput('1049hREADY');
assert.equal(mayAffectAlternateScreen, false);
});
test('deferred output trigger processor suppresses retained scans after dropped maintenance overflow', () => {
const scannable: string[] = [];
const scheduled: Array<() => void> = [];
const filter = createTerminalOutputTriggerFilter();
let suppressScan = false;
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 4,
maxOutputBytesPerFlush: 8,
maxPendingOutputBytes: 8,
pendingOutputOverlapChars: 0,
processDroppedOutput: (chunk) => {
filter.processServerChunk(chunk);
},
processDroppedOutputOverflow: (overflow) => {
if (droppedOutputOverflowMayAffectAlternateScreenState(overflow)) {
suppressScan = true;
}
},
processOutput: (chunk) => {
const result = filter.processServerChunk(chunk);
if (!suppressScan && result.scannableText && !result.alternateScreenActive) {
scannable.push(result.scannableText);
}
},
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput(`${'x'.repeat(40)}\x1b[?1049h${'y'.repeat(40)}`);
processor.enqueueOutput('READY');
while (scheduled.length > 0) {
scheduled.shift()?.();
}
assert.equal(suppressScan, true);
assert.deepEqual(scannable, []);
});
test('deferred output trigger processor can recover from dropped split alternate-screen leave', () => {
const scannable: string[] = [];
const scheduled: Array<() => void> = [];
const filter = createTerminalOutputTriggerFilter();
const recoveryFilter = createTerminalOutputTriggerFilter();
let suppressScan = true;
const maybeRecover = (chunk: string) => {
const recovery = recoveryFilter.processServerChunk(chunk);
if (recovery.meta.alternateScreenAction === 'leave' && !recovery.alternateScreenActive) {
suppressScan = false;
recoveryFilter.reset();
return true;
}
return false;
};
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 16,
maxOutputBytesPerFlush: 16,
maxPendingOutputBytes: 8,
pendingOutputOverlapChars: 0,
processDroppedOutput: (chunk) => {
filter.processServerChunk(chunk);
if (suppressScan) {
maybeRecover(chunk);
}
},
processOutput: (chunk) => {
const result = filter.processServerChunk(chunk);
if (suppressScan) {
maybeRecover(chunk);
return;
}
if (result.scannableText && !result.alternateScreenActive) {
scannable.push(result.scannableText);
}
},
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('\x1b[?10');
processor.enqueueOutput('49lREADY');
while (scheduled.length > 0) {
scheduled.shift()?.();
}
assert.equal(suppressScan, false);
assert.deepEqual(scannable, []);
processor.enqueueOutput('NEXT');
while (scheduled.length > 0) {
scheduled.shift()?.();
}
assert.deepEqual(scannable, ['NEXT']);
});
test('deferred output trigger processor keeps scanning after plain dropped maintenance overflow', () => {
const scannable: string[] = [];
const scheduled: Array<() => void> = [];
const filter = createTerminalOutputTriggerFilter();
let suppressScan = false;
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 4,
maxOutputBytesPerFlush: 8,
maxPendingOutputBytes: 8,
pendingOutputOverlapChars: 0,
processDroppedOutput: (chunk) => {
filter.processServerChunk(chunk);
},
processDroppedOutputOverflow: (overflow) => {
if (droppedOutputOverflowMayAffectAlternateScreenState(overflow)) {
suppressScan = true;
}
},
processOutput: (chunk) => {
const result = filter.processServerChunk(chunk);
if (!suppressScan && result.scannableText && !result.alternateScreenActive) {
scannable.push(result.scannableText);
}
},
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput('x'.repeat(200));
processor.enqueueOutput('READY');
while (scheduled.length > 0) {
scheduled.shift()?.();
}
assert.equal(suppressScan, false);
assert.equal(scannable.join('').endsWith('READY'), true);
});
test('dropped output overflow detection ignores color sequences but catches alternate screen candidates', () => {
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
retainedPrefix: 'plain',
discardedSuffix: '\x1b[31mcolored text',
}), false);
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
retainedPrefix: '\x1b[?10',
discardedSuffix: '49hmore output',
}), true);
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
retainedPrefix: 'plain',
discardedSuffix: '\x1b[?1;1049l',
}), true);
assert.equal(droppedOutputOverflowMayAffectAlternateScreenState({
retainedPrefix: `before\x1b[?1;2;3;4;5;6;7;8;9;10`,
discardedSuffix: ';1049hafter',
}), true);
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
retainedPrefix: '\x1b[?1049h',
discardedSuffix: `${'x'.repeat(5000)}\x1b[?1;1049l`,
}), {
mayAffectAlternateScreen: true,
mayAffectScanState: false,
finalAction: 'leave',
});
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
retainedPrefix: 'plain',
discardedSuffix: `${'x'.repeat(3000)}\x1b[?1049h${'y'.repeat(3000)}`,
}), {
mayAffectAlternateScreen: true,
mayAffectScanState: false,
finalAction: undefined,
});
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
retainedPrefix: 'plain',
discardedSuffix: `\x1b[?1049l${'x'.repeat(3000)}\x1b[?1049h${'y'.repeat(3000)}`,
}), {
mayAffectAlternateScreen: true,
mayAffectScanState: false,
finalAction: undefined,
});
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
retainedPrefix: 'plain',
discardedSuffix: `${'x'.repeat(5000)}\x1b[?1049lplain\x1b[?10`,
}), {
mayAffectAlternateScreen: true,
mayAffectScanState: true,
finalAction: undefined,
});
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
retainedPrefix: 'plain',
discardedSuffix: `\x1b[?1049l${'x'.repeat(2500)}\x1b[?10`,
}), {
mayAffectAlternateScreen: true,
mayAffectScanState: true,
finalAction: undefined,
});
assert.deepEqual(inspectDroppedOutputOverflowAlternateScreenState({
retainedPrefix: '\x1b[31',
discardedSuffix: 'mREADY',
}), {
mayAffectAlternateScreen: false,
mayAffectScanState: true,
finalAction: undefined,
});
});
test('deferred output trigger processor preserves filter state for trimmed output', () => {
const scannable: string[] = [];
const scheduled: Array<() => void> = [];
const filter = createTerminalOutputTriggerFilter();
const processor = createDeferredOutputTriggerEventProcessor({
maxOutputChunkBytes: 4,
maxOutputBytesPerFlush: 64,
maxPendingOutputBytes: 8,
pendingOutputOverlapChars: 0,
processDroppedOutput: (chunk) => {
filter.processServerChunk(chunk);
},
processOutput: (chunk) => {
const result = filter.processServerChunk(chunk);
if (result.scannableText && !result.alternateScreenActive) {
scannable.push(result.scannableText);
}
},
processInput: () => {},
schedule: (callback) => {
scheduled.push(callback);
return () => {};
},
});
processor.enqueueOutput(`\x1b[?1049h${'x'.repeat(80)}`);
processor.enqueueOutput('READY');
assert.equal(processor.getPendingOutputBytes(), 8);
while (scheduled.length > 0) {
scheduled.shift()?.();
}
assert.deepEqual(scannable, []);
});
+730 -22
View File
@@ -1,11 +1,22 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { Snippet } from '@/domain/models';
import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts';
import { isScriptSnippet } from '@/domain/snippetScript.ts';
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
import {
createTerminalOutputTriggerFilter,
getTerminalAlternateScreenAction,
} from '@/domain/terminalOutputTriggerFilter.ts';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import { getActiveScriptRunForSession } from '@/application/state/scriptAutomationCoordinator.ts';
const OUTPUT_TRIGGER_SCAN_DELAY_MS = 16;
const OUTPUT_TRIGGER_SCAN_CHUNK_BYTES = 32 * 1024;
const OUTPUT_TRIGGER_SCAN_BYTES_PER_FLUSH = 128 * 1024;
const OUTPUT_TRIGGER_SCAN_TIME_BUDGET_MS = 4;
const OUTPUT_TRIGGER_SCAN_OVERLAP_CHARS = 64;
const OUTPUT_TRIGGER_MAX_PENDING_SCAN_BYTES = 512 * 1024;
const OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS = 2048;
type OutputTriggerContext = {
sessionId: string;
hostId?: string;
@@ -13,10 +24,542 @@ type OutputTriggerContext = {
onRunScript: (snippet: Snippet, sessionId: string) => void | Promise<void>;
};
type DeferredOutputTriggerEvent =
| { type: 'output'; chunk: string; meta?: OutputTriggerAppendMeta }
| { type: 'dropped-output'; chunk: string; meta?: OutputTriggerAppendMeta }
| { type: 'input-overflow'; droppedCounter: InputEchoLineCounter }
| { type: 'input'; data: string };
type DeferredOutputTriggerEventProcessor = {
enqueueOutput: (chunk: string, meta?: OutputTriggerAppendMeta) => void;
enqueueInput: (data: string) => void;
flush: () => void;
reset: () => void;
getPendingOutputBytes: () => number;
};
type OutputTriggerAppendMeta = {
droppedOutputMayAffectTerminalState?: boolean;
droppedOutputAlternateScreenAction?: 'enter' | 'leave';
};
type TerminalOutputTriggerFilterResult = ReturnType<
ReturnType<typeof createTerminalOutputTriggerFilter>['processServerChunk']
>;
type DeferredOutputTriggerEventProcessorOptions = {
processOutput: (chunk: string, meta?: OutputTriggerAppendMeta) => void;
processDroppedOutput?: (chunk: string, meta?: OutputTriggerAppendMeta) => void;
processDroppedOutputOverflow?: (overflow: {
retainedPrefix: string;
discardedSuffix: string;
}) => void;
processInput: (data: string) => void;
processInputOverflow?: (lineCount: number) => void;
schedule: (callback: () => void) => () => void;
now?: () => number;
maxOutputChunkBytes?: number;
maxOutputBytesPerFlush?: number;
maxFlushMs?: number;
maxPendingOutputBytes?: number;
maxPendingInputBytes?: number;
pendingOutputOverlapChars?: number;
};
type DroppedOutputOverflowAlternateScreenInspection = {
mayAffectAlternateScreen: boolean;
mayAffectScanState: boolean;
finalAction?: 'enter' | 'leave';
};
type OutputTriggerScanWindow = {
text: string;
minEndOffset: number;
baseOffset: number;
};
type OutputTriggerScanBuffer = {
append: (chunk: string) => OutputTriggerScanWindow;
reset: () => void;
};
type InputEchoLineCounter = {
lineCount: number;
hasOpenLine: boolean;
pendingCr: boolean;
sawInput: boolean;
startsWithLf: boolean;
};
function isSessionScriptRunActive(sessionId: string): boolean {
return Boolean(getActiveScriptRunForSession(sessionId));
}
const ALTERNATE_SCREEN_MODE_PREFIXES = ['47', '1047', '1049'];
const ESC = String.fromCharCode(27);
function readCsiSequence(input: string, startIndex: number): { sequence: string; end: number } | null {
if (input[startIndex] !== ESC || input[startIndex + 1] !== '[') return null;
for (let index = startIndex + 2; index < input.length; index += 1) {
const code = input.charCodeAt(index);
if (code >= 0x40 && code <= 0x7e) {
return {
sequence: input.slice(startIndex, index + 1),
end: index + 1,
};
}
}
return null;
}
function isPotentialAlternateScreenCsiPrefix(sequence: string): boolean {
if (!sequence.startsWith(`${ESC}[?`)) return false;
const params = sequence.slice(3);
if (!params) return true;
return params
.split(';')
.some((part) => (
part === ''
|| ALTERNATE_SCREEN_MODE_PREFIXES.some((mode) => (
mode.startsWith(part) || part.startsWith(mode)
))
));
}
function inspectAlternateScreenActionsInWindow(
text: string,
discardedStart: number,
): DroppedOutputOverflowAlternateScreenInspection {
let mayAffectAlternateScreen = false;
let mayAffectScanState = false;
let finalAction: 'enter' | 'leave' | undefined;
for (let index = 0; index < text.length; index += 1) {
if (text[index] !== ESC) continue;
const sequence = readCsiSequence(text, index);
if (sequence) {
if (sequence.end <= discardedStart) {
index = sequence.end - 1;
continue;
}
if (index < discardedStart) {
mayAffectScanState = true;
}
const action = getTerminalAlternateScreenAction(sequence.sequence);
if (action) {
mayAffectAlternateScreen = true;
finalAction = action;
}
index = sequence.end - 1;
continue;
}
if (text.length > discardedStart) {
mayAffectScanState = true;
}
if (
index < text.length
&& text.length > discardedStart
&& isPotentialAlternateScreenCsiPrefix(text.slice(index))
) {
mayAffectAlternateScreen = true;
finalAction = undefined;
}
}
return { mayAffectAlternateScreen, mayAffectScanState, finalAction };
}
export function inspectDroppedOutputOverflowAlternateScreenState({
retainedPrefix,
discardedSuffix,
}: {
retainedPrefix: string;
discardedSuffix: string;
}): DroppedOutputOverflowAlternateScreenInspection {
if (!discardedSuffix) return { mayAffectAlternateScreen: false, mayAffectScanState: false };
const retainedTail = retainedPrefix.slice(-OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS);
const lastRetainedEscape = retainedTail.lastIndexOf(ESC);
const retainedContext = lastRetainedEscape >= 0 ? retainedTail.slice(lastRetainedEscape) : '';
const discardedHead = discardedSuffix.slice(0, OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS);
const headInspection = inspectAlternateScreenActionsInWindow(
`${retainedContext}${discardedHead}`,
retainedContext.length,
);
if (discardedSuffix.length <= OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS) {
return headInspection;
}
const discardedTail = discardedSuffix.slice(-OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS);
const tailInspection = inspectAlternateScreenActionsInWindow(discardedTail, 0);
const hasUninspectedMiddle = discardedSuffix.length > OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS * 2;
return {
mayAffectAlternateScreen: (
headInspection.mayAffectAlternateScreen
|| tailInspection.mayAffectAlternateScreen
|| (hasUninspectedMiddle && tailInspection.finalAction !== 'leave')
),
mayAffectScanState: (
headInspection.mayAffectScanState
|| tailInspection.mayAffectScanState
),
finalAction: tailInspection.finalAction
?? (hasUninspectedMiddle || tailInspection.mayAffectAlternateScreen ? undefined : headInspection.finalAction),
};
}
export function droppedOutputOverflowMayAffectAlternateScreenState(overflow: {
retainedPrefix: string;
discardedSuffix: string;
}): boolean {
return inspectDroppedOutputOverflowAlternateScreenState(overflow).mayAffectAlternateScreen;
}
export function createOutputTriggerScanBuffer(
overlapChars = OUTPUT_TRIGGER_SCAN_OVERLAP_CHARS,
): OutputTriggerScanBuffer {
let overlap = '';
let consumedLength = 0;
return {
append(chunk: string) {
const text = `${overlap}${chunk}`;
const minEndOffset = overlap.length;
const baseOffset = consumedLength - overlap.length;
consumedLength += chunk.length;
overlap = text.slice(-overlapChars);
return { text, minEndOffset, baseOffset };
},
reset() {
overlap = '';
consumedLength = 0;
},
};
}
function createInputEchoLineCounter(): InputEchoLineCounter {
return {
lineCount: 0,
hasOpenLine: false,
pendingCr: false,
sawInput: false,
startsWithLf: false,
};
}
function cloneInputEchoLineCounter(counter: InputEchoLineCounter): InputEchoLineCounter {
return { ...counter };
}
function appendInputEchoLineCounter(counter: InputEchoLineCounter, text: string): void {
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
const wasEmpty = !counter.sawInput;
counter.sawInput = true;
if (wasEmpty && char === '\n') {
counter.startsWithLf = true;
}
if (counter.pendingCr) {
counter.pendingCr = false;
if (char === '\n') {
continue;
}
}
if (char === '\r') {
counter.lineCount += 1;
counter.hasOpenLine = false;
counter.pendingCr = true;
continue;
}
if (char === '\n') {
counter.lineCount += 1;
counter.hasOpenLine = false;
continue;
}
counter.hasOpenLine = true;
}
}
function appendInputEchoCounterState(
counter: InputEchoLineCounter,
source: InputEchoLineCounter,
): void {
if (!source.sawInput) return;
if (!counter.sawInput) {
Object.assign(counter, cloneInputEchoLineCounter(source));
return;
}
const crlfBoundaryAdjustment = counter.pendingCr && source.startsWithLf ? 1 : 0;
counter.lineCount += source.lineCount - crlfBoundaryAdjustment;
counter.hasOpenLine = source.hasOpenLine;
counter.pendingCr = source.pendingCr;
counter.sawInput = true;
}
function getInputEchoLineCount(counter: InputEchoLineCounter): number {
if (!counter.sawInput) return 0;
return Math.max(1, counter.lineCount + (counter.hasOpenLine ? 1 : 0));
}
export function createDeferredOutputTriggerEventProcessor({
processOutput,
processDroppedOutput,
processDroppedOutputOverflow,
processInput,
processInputOverflow,
schedule,
now = () => performance.now(),
maxOutputChunkBytes = OUTPUT_TRIGGER_SCAN_CHUNK_BYTES,
maxOutputBytesPerFlush = OUTPUT_TRIGGER_SCAN_BYTES_PER_FLUSH,
maxFlushMs = OUTPUT_TRIGGER_SCAN_TIME_BUDGET_MS,
maxPendingOutputBytes = OUTPUT_TRIGGER_MAX_PENDING_SCAN_BYTES,
maxPendingInputBytes = OUTPUT_TRIGGER_MAX_PENDING_SCAN_BYTES,
pendingOutputOverlapChars = OUTPUT_TRIGGER_SCAN_OVERLAP_CHARS,
}: DeferredOutputTriggerEventProcessorOptions): DeferredOutputTriggerEventProcessor {
const events: DeferredOutputTriggerEvent[] = [];
let cancelScheduledFlush: (() => void) | null = null;
let pendingOutputBytes = 0;
let pendingDroppedOutputBytes = 0;
let pendingInputBytes = 0;
let droppedOutputContextTail = '';
const maxDroppedOutputBytes = Math.max(0, maxOutputBytesPerFlush);
const scheduleFlush = () => {
if (cancelScheduledFlush) return;
cancelScheduledFlush = schedule(() => {
cancelScheduledFlush = null;
processor.flush();
});
};
const trimPendingOutput = () => {
const maxPendingBytes = Math.max(0, maxPendingOutputBytes);
const overlapBytes = Math.max(0, pendingOutputOverlapChars);
const maxRetainedBytes = maxPendingBytes + overlapBytes;
if (pendingOutputBytes <= maxRetainedBytes) return;
const takeDroppedMaintenanceChunk = (chunk: string) => {
if (!processDroppedOutput || !chunk) return '';
const availableBytes = Math.max(0, maxDroppedOutputBytes - pendingDroppedOutputBytes);
if (availableBytes <= 0) {
processDroppedOutputOverflow?.({
retainedPrefix: droppedOutputContextTail,
discardedSuffix: chunk,
});
return '';
}
const retained = chunk.length > availableBytes ? chunk.slice(0, availableBytes) : chunk;
if (retained.length < chunk.length) {
processDroppedOutputOverflow?.({
retainedPrefix: `${droppedOutputContextTail}${retained}`,
discardedSuffix: chunk.slice(retained.length),
});
}
droppedOutputContextTail = `${droppedOutputContextTail}${retained}`.slice(
-OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS,
);
pendingDroppedOutputBytes += retained.length;
return retained;
};
let bytesToDrop = pendingOutputBytes - maxRetainedBytes;
for (let index = 0; index < events.length && bytesToDrop > 0; index += 1) {
const event = events[index];
if (event.type === 'input' || event.type === 'input-overflow') {
continue;
}
if (event.type === 'dropped-output') continue;
if (event.chunk.length <= bytesToDrop) {
bytesToDrop -= event.chunk.length;
pendingOutputBytes -= event.chunk.length;
const droppedMeta = event.meta;
const droppedChunk = takeDroppedMaintenanceChunk(event.chunk);
if (droppedChunk || droppedMeta) {
events[index] = droppedMeta
? { type: 'dropped-output', chunk: droppedChunk, meta: droppedMeta }
: { type: 'dropped-output', chunk: droppedChunk };
} else {
events.splice(index, 1);
index -= 1;
}
continue;
}
const droppedChunk = event.chunk.slice(0, bytesToDrop);
event.chunk = event.chunk.slice(bytesToDrop);
pendingOutputBytes -= bytesToDrop;
const droppedMeta = event.meta;
event.meta = undefined;
const retainedDroppedChunk = takeDroppedMaintenanceChunk(droppedChunk);
if (retainedDroppedChunk || droppedMeta) {
events.splice(index, 0, droppedMeta
? { type: 'dropped-output', chunk: retainedDroppedChunk, meta: droppedMeta }
: { type: 'dropped-output', chunk: retainedDroppedChunk });
}
bytesToDrop = 0;
}
};
const trimPendingInput = () => {
const maxPendingBytes = Math.max(0, maxPendingInputBytes);
if (pendingInputBytes <= maxPendingBytes) return;
let bytesToDrop = pendingInputBytes - maxPendingBytes;
const nextEvents: DeferredOutputTriggerEvent[] = [];
const appendOverflowEvent = (droppedCounter: InputEchoLineCounter) => {
if (!droppedCounter.sawInput) return;
const previousEvent = nextEvents.at(-1);
if (previousEvent?.type === 'input-overflow') {
appendInputEchoCounterState(previousEvent.droppedCounter, droppedCounter);
return;
}
nextEvents.push({ type: 'input-overflow', droppedCounter });
};
for (let index = 0; index < events.length; index += 1) {
const event = events[index];
if (event.type !== 'input') {
nextEvents.push(event);
continue;
}
const droppedCounter = createInputEchoLineCounter();
const retainedChunks: string[] = [];
for (; index < events.length; index += 1) {
const inputEvent = events[index];
if (inputEvent.type !== 'input') {
index -= 1;
break;
}
if (bytesToDrop <= 0) {
retainedChunks.push(inputEvent.data);
continue;
}
if (inputEvent.data.length <= bytesToDrop) {
bytesToDrop -= inputEvent.data.length;
pendingInputBytes -= inputEvent.data.length;
appendInputEchoLineCounter(droppedCounter, inputEvent.data);
continue;
}
const droppedInputChunk = inputEvent.data.slice(0, bytesToDrop);
const retainedInputChunk = inputEvent.data.slice(bytesToDrop);
pendingInputBytes -= bytesToDrop;
appendInputEchoLineCounter(droppedCounter, droppedInputChunk);
retainedChunks.push(retainedInputChunk);
bytesToDrop = 0;
}
appendOverflowEvent(droppedCounter);
if (retainedChunks.length > 0) {
nextEvents.push({ type: 'input', data: retainedChunks.join('') });
}
}
events.splice(0, events.length, ...nextEvents);
};
const processor: DeferredOutputTriggerEventProcessor = {
enqueueOutput(chunk: string, meta?: OutputTriggerAppendMeta) {
if (!chunk) return;
const lastEvent = events.at(-1);
if (
lastEvent?.type === 'output'
&& !lastEvent.meta
&& !meta
&& lastEvent.chunk.length + chunk.length <= maxOutputBytesPerFlush
) {
lastEvent.chunk += chunk;
} else {
events.push(meta ? { type: 'output', chunk, meta } : { type: 'output', chunk });
}
pendingOutputBytes += chunk.length;
trimPendingOutput();
scheduleFlush();
},
enqueueInput(data: string) {
if (!data) return;
const lastEvent = events.at(-1);
if (lastEvent?.type === 'input') {
lastEvent.data += data;
} else {
events.push({ type: 'input', data });
}
pendingInputBytes += data.length;
trimPendingInput();
scheduleFlush();
},
flush() {
const startedAt = now();
let processedOutputBytes = 0;
while (events.length > 0) {
const event = events[0];
if (event.type === 'input') {
droppedOutputContextTail = '';
const chunk = event.data.slice(0, maxOutputChunkBytes);
event.data = event.data.slice(chunk.length);
if (!event.data) {
events.shift();
}
pendingInputBytes = Math.max(0, pendingInputBytes - chunk.length);
processedOutputBytes += chunk.length;
processInput(chunk);
} else if (event.type === 'input-overflow') {
const inputCounter = cloneInputEchoLineCounter(event.droppedCounter);
events.shift();
for (const pendingEvent of events) {
if (pendingEvent.type !== 'input') break;
appendInputEchoLineCounter(inputCounter, pendingEvent.data);
}
processInputOverflow?.(getInputEchoLineCount(inputCounter));
} else if (event.type === 'dropped-output') {
const chunk = event.chunk.slice(0, maxOutputChunkBytes);
event.chunk = event.chunk.slice(chunk.length);
const meta = event.meta;
event.meta = undefined;
if (!event.chunk) {
events.shift();
}
pendingDroppedOutputBytes = Math.max(0, pendingDroppedOutputBytes - chunk.length);
processedOutputBytes += chunk.length;
processDroppedOutput?.(chunk, meta);
} else {
droppedOutputContextTail = '';
const chunk = event.chunk.slice(0, maxOutputChunkBytes);
event.chunk = event.chunk.slice(chunk.length);
pendingOutputBytes -= chunk.length;
if (!event.chunk) {
events.shift();
}
processedOutputBytes += chunk.length;
const meta = event.meta;
event.meta = undefined;
processOutput(chunk, meta);
}
if (
events.length > 0
&& (
processedOutputBytes >= maxOutputBytesPerFlush
|| now() - startedAt >= maxFlushMs
)
) {
scheduleFlush();
return;
}
}
},
reset() {
events.splice(0, events.length);
pendingOutputBytes = 0;
pendingDroppedOutputBytes = 0;
pendingInputBytes = 0;
droppedOutputContextTail = '';
cancelScheduledFlush?.();
cancelScheduledFlush = null;
},
getPendingOutputBytes() {
return pendingOutputBytes;
},
};
return processor;
}
export function findMatchEndingAfter(text: string, pattern: string, minEndOffset: number): { value: string; endOffset: number } | null {
const source = new RegExp(pattern);
for (let startOffset = 0; startOffset <= text.length;) {
@@ -32,27 +575,44 @@ export function findMatchEndingAfter(text: string, pattern: string, minEndOffset
return null;
}
export function hasApplicableOutputTriggerSnippet(snippets: Snippet[], hostId?: string): boolean {
return snippets.some((snippet) => (
isScriptSnippet(snippet)
&& snippet.trigger === 'onOutput'
&& Boolean(snippet.triggerPattern)
&& Boolean(snippet.id)
&& snippetAppliesToOutputTrigger(snippet, hostId)
));
}
export function useOutputTriggers({
sessionId,
hostId,
snippets,
onRunScript,
}: OutputTriggerContext) {
const bufferRef = useRef('');
const launchingRef = useRef(false);
const lastTriggerMatchEndRef = useRef(new Map<string, number>());
const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter());
const outputTriggerSuppressionRecoveryFilterRef = useRef(createTerminalOutputTriggerFilter());
const scanBufferRef = useRef(createOutputTriggerScanBuffer());
const outputTriggerScanSuppressedRef = useRef(false);
const pendingDroppedOverflowFinalActionRef = useRef<'leave' | null>(null);
const pendingDroppedOverflowScanStateResetRef = useRef(false);
const hasOutputTriggers = useMemo(
() => hasApplicableOutputTriggerSnippet(snippets, hostId),
[hostId, snippets],
);
const scanBuffer = useCallback((recentChunk: string) => {
if (!recentChunk || isSessionScriptRunActive(sessionId) || launchingRef.current) {
const scanOutput = useCallback((scannableText: string) => {
if (!hasOutputTriggers || !scannableText) {
return;
}
const text = bufferRef.current;
const overlap = 64;
const chunkWithOverlap = text.slice(Math.max(0, text.length - recentChunk.length - overlap));
const chunkStartInSlice = Math.max(0, chunkWithOverlap.length - recentChunk.length);
const chunkBaseOffset = text.length - chunkWithOverlap.length;
const scanWindow = scanBufferRef.current.append(scannableText);
if (isSessionScriptRunActive(sessionId) || launchingRef.current) {
return;
}
for (const snippet of snippets) {
if (isSessionScriptRunActive(sessionId) || launchingRef.current) {
@@ -63,11 +623,11 @@ export function useOutputTriggers({
}
if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue;
try {
const matched = findMatchEndingAfter(chunkWithOverlap, snippet.triggerPattern, chunkStartInSlice);
const matched = findMatchEndingAfter(scanWindow.text, snippet.triggerPattern, scanWindow.minEndOffset);
if (!matched) {
continue;
}
const matchEnd = chunkBaseOffset + matched.endOffset;
const matchEnd = scanWindow.baseOffset + matched.endOffset;
const lastMatchEnd = lastTriggerMatchEndRef.current.get(snippet.id) ?? -1;
if (matchEnd <= lastMatchEnd) {
continue;
@@ -87,29 +647,177 @@ export function useOutputTriggers({
// ignore invalid regex
}
}
}, [hostId, onRunScript, sessionId, snippets]);
}, [hasOutputTriggers, hostId, onRunScript, sessionId, snippets]);
const appendOutput = useCallback((chunk: string) => {
if (!chunk) return;
const scanOutputRef = useRef(scanOutput);
scanOutputRef.current = scanOutput;
const resetTriggerScanState = useCallback(() => {
scanBufferRef.current.reset();
lastTriggerMatchEndRef.current = new Map();
}, []);
const restoreOutputTriggerScanAfterDroppedLeave = useCallback((resetServerFilter = true) => {
pendingDroppedOverflowFinalActionRef.current = null;
if (resetServerFilter) {
serverOutputFilterRef.current.reset();
}
outputTriggerSuppressionRecoveryFilterRef.current.reset();
outputTriggerScanSuppressedRef.current = false;
resetTriggerScanState();
}, [resetTriggerScanState]);
const applyPendingDroppedOverflowFinalAction = useCallback(() => {
if (pendingDroppedOverflowFinalActionRef.current !== 'leave') {
return;
}
restoreOutputTriggerScanAfterDroppedLeave();
}, [restoreOutputTriggerScanAfterDroppedLeave]);
const applyPendingDroppedOverflowScanStateReset = useCallback(() => {
if (!pendingDroppedOverflowScanStateResetRef.current) {
return;
}
pendingDroppedOverflowScanStateResetRef.current = false;
serverOutputFilterRef.current.resetPendingEscape();
outputTriggerSuppressionRecoveryFilterRef.current.resetPendingEscape();
}, []);
const suppressOutputTriggerScanAfterDroppedTerminalState = useCallback(() => {
resetTriggerScanState();
outputTriggerScanSuppressedRef.current = true;
outputTriggerSuppressionRecoveryFilterRef.current.reset();
}, [resetTriggerScanState]);
const applyOutputTriggerMeta = useCallback((meta?: OutputTriggerAppendMeta) => {
if (!meta) return;
if (meta.droppedOutputAlternateScreenAction === 'leave') {
restoreOutputTriggerScanAfterDroppedLeave();
return;
}
if (meta.droppedOutputAlternateScreenAction === 'enter' || meta.droppedOutputMayAffectTerminalState) {
pendingDroppedOverflowFinalActionRef.current = null;
suppressOutputTriggerScanAfterDroppedTerminalState();
}
}, [restoreOutputTriggerScanAfterDroppedLeave, suppressOutputTriggerScanAfterDroppedTerminalState]);
const recoverOutputTriggerScanIfLeave = useCallback((
result: TerminalOutputTriggerFilterResult,
resetServerFilter = true,
) => {
if (result.meta.alternateScreenAction !== 'leave' || result.alternateScreenActive) {
return false;
}
restoreOutputTriggerScanAfterDroppedLeave(resetServerFilter);
return true;
}, [restoreOutputTriggerScanAfterDroppedLeave]);
const processOutputTriggerOutput = useCallback((chunk: string, meta?: OutputTriggerAppendMeta) => {
applyOutputTriggerMeta(meta);
applyPendingDroppedOverflowFinalAction();
applyPendingDroppedOverflowScanStateReset();
const { scannableText, alternateScreenActive } = serverOutputFilterRef.current.processServerChunk(chunk);
if (outputTriggerScanSuppressedRef.current) {
const recovery = outputTriggerSuppressionRecoveryFilterRef.current.processServerChunk(chunk);
recoverOutputTriggerScanIfLeave(recovery, false);
return;
}
if (!scannableText || alternateScreenActive) {
return;
}
bufferRef.current = (bufferRef.current + scannableText).slice(-8192);
scanBuffer(scannableText);
}, [scanBuffer]);
scanOutputRef.current(scannableText);
}, [
applyOutputTriggerMeta,
applyPendingDroppedOverflowFinalAction,
applyPendingDroppedOverflowScanStateReset,
recoverOutputTriggerScanIfLeave,
]);
const noteUserInput = useCallback((data: string) => {
if (!data) return;
const processDroppedOutputTriggerOutput = useCallback((chunk: string, meta?: OutputTriggerAppendMeta) => {
applyOutputTriggerMeta(meta);
resetTriggerScanState();
if (!chunk) return;
serverOutputFilterRef.current.processServerChunk(chunk);
if (outputTriggerScanSuppressedRef.current) {
const recovery = outputTriggerSuppressionRecoveryFilterRef.current.processServerChunk(chunk);
recoverOutputTriggerScanIfLeave(recovery, false);
}
}, [applyOutputTriggerMeta, recoverOutputTriggerScanIfLeave, resetTriggerScanState]);
const processOutputTriggerInput = useCallback((data: string) => {
serverOutputFilterRef.current.noteUserInput(data);
}, []);
const suppressOutputTriggerScanAfterDroppedOverflow = useCallback((overflow: {
retainedPrefix: string;
discardedSuffix: string;
}) => {
resetTriggerScanState();
const inspection = inspectDroppedOutputOverflowAlternateScreenState(overflow);
if (inspection.mayAffectScanState) {
pendingDroppedOverflowScanStateResetRef.current = true;
}
if (!inspection.mayAffectAlternateScreen) {
return;
}
if (inspection.finalAction === 'leave') {
pendingDroppedOverflowFinalActionRef.current = 'leave';
outputTriggerScanSuppressedRef.current = false;
outputTriggerSuppressionRecoveryFilterRef.current.reset();
return;
}
pendingDroppedOverflowFinalActionRef.current = null;
outputTriggerScanSuppressedRef.current = true;
outputTriggerSuppressionRecoveryFilterRef.current.reset();
}, [resetTriggerScanState]);
const outputTriggerEventProcessor = useMemo(() => createDeferredOutputTriggerEventProcessor({
processOutput: processOutputTriggerOutput,
processDroppedOutput: processDroppedOutputTriggerOutput,
processDroppedOutputOverflow: suppressOutputTriggerScanAfterDroppedOverflow,
processInput: processOutputTriggerInput,
processInputOverflow: (lineCount) => {
serverOutputFilterRef.current.markInputEchoUncertain(lineCount);
outputTriggerSuppressionRecoveryFilterRef.current.markInputEchoUncertain(lineCount);
resetTriggerScanState();
},
schedule: (callback) => {
const timer = globalThis.setTimeout(callback, OUTPUT_TRIGGER_SCAN_DELAY_MS);
return () => globalThis.clearTimeout(timer);
},
}), [
processDroppedOutputTriggerOutput,
processOutputTriggerInput,
processOutputTriggerOutput,
resetTriggerScanState,
suppressOutputTriggerScanAfterDroppedOverflow,
]);
const appendOutput = useCallback((chunk: string, meta?: OutputTriggerAppendMeta) => {
if (!hasOutputTriggers || !chunk) return;
outputTriggerEventProcessor.enqueueOutput(chunk, meta);
}, [hasOutputTriggers, outputTriggerEventProcessor]);
const noteUserInput = useCallback((data: string) => {
if (!hasOutputTriggers || !data) return;
outputTriggerEventProcessor.enqueueInput(data);
}, [hasOutputTriggers, outputTriggerEventProcessor]);
useEffect(() => {
bufferRef.current = '';
outputTriggerEventProcessor.reset();
scanBufferRef.current.reset();
launchingRef.current = false;
lastTriggerMatchEndRef.current = new Map();
serverOutputFilterRef.current.reset();
}, [sessionId, hostId]);
outputTriggerSuppressionRecoveryFilterRef.current.reset();
outputTriggerScanSuppressedRef.current = false;
pendingDroppedOverflowFinalActionRef.current = null;
pendingDroppedOverflowScanStateResetRef.current = false;
}, [sessionId, hostId, hasOutputTriggers, outputTriggerEventProcessor]);
useEffect(() => () => {
outputTriggerEventProcessor.reset();
}, [outputTriggerEventProcessor]);
return { appendOutput, noteUserInput };
}
+97 -60
View File
@@ -46,6 +46,79 @@ import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
export function addWorkspaceIfMissing(
workspaces: Workspace[],
workspace: Workspace,
): Workspace[] {
return workspaces.some(ws => ws.id === workspace.id) ? workspaces : [...workspaces, workspace];
}
export function addTerminalSessionIfMissing(
sessions: TerminalSession[],
session: TerminalSession,
): TerminalSession[] {
return sessions.some(candidate => candidate.id === session.id) ? sessions : [...sessions, session];
}
export function insertWorkspacePaneIfMissing(
workspaces: Workspace[],
workspaceId: string,
sessionId: string,
hint: SplitHint,
): Workspace[] {
return workspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
if (collectSessionIds(ws.root).includes(sessionId)) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
});
}
export function appendWorkspaceRootPaneIfMissing(
workspaces: Workspace[],
workspaceId: string,
sessionId: string,
direction: SplitDirection = 'vertical',
): Workspace[] {
return workspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
if (collectSessionIds(ws.root).includes(sessionId)) {
return ws.focusedSessionId === sessionId ? ws : { ...ws, focusedSessionId: sessionId };
}
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, sessionId, direction),
focusedSessionId: sessionId,
};
});
}
export function insertCopiedTabOrderIdOnce(
prevTabOrder: string[],
sourceTabId: string,
copiedTabId: string,
allTabIds: string[],
): string[] {
if (prevTabOrder.includes(copiedTabId)) return prevTabOrder;
const directIdx = prevTabOrder.indexOf(sourceTabId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, copiedTabId);
return next;
}
const allTabIdSet = new Set(allTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sourceTabId);
if (sourceIdx === -1) return [...prevTabOrder, copiedTabId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, copiedTabId);
return next;
}
export const useSessionState = ({
persistSessionRestore = true,
@@ -634,14 +707,14 @@ export const useSessionState = ({
hint: SplitHint
) => {
if (!hint || baseSessionId === joiningSessionId) return;
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
setSessions(prevSessions => {
const base = prevSessions.find(s => s.id === baseSessionId);
const joining = prevSessions.find(s => s.id === joiningSessionId);
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setWorkspaces(prev => addWorkspaceIfMissing(prev, newWorkspace));
setActiveTabId(newWorkspace.id);
return prevSessions.map(s => {
@@ -668,10 +741,7 @@ export const useSessionState = ({
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
if (!targetWorkspace) return prevWorkspaces;
return prevWorkspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
});
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
});
setActiveTabId(workspaceId);
@@ -726,16 +796,9 @@ export const useSessionState = ({
setWorkspaces(prev => {
const target = prev.find(w => w.id === workspaceId);
if (!target) return prev;
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
setSessions(s => addTerminalSessionIfMissing(s, newSession));
setActiveTabId(workspaceId);
return prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
focusedSessionId: newSessionId,
};
});
return appendWorkspaceRootPaneIfMissing(prev, workspaceId, newSessionId, direction);
});
return newSessionId;
}, [setActiveTabId]);
@@ -778,16 +841,9 @@ export const useSessionState = ({
setWorkspaces(prev => {
const target = prev.find(w => w.id === workspaceId);
if (!target) return prev;
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
setSessions(s => addTerminalSessionIfMissing(s, newSession));
setActiveTabId(workspaceId);
return prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
focusedSessionId: newSessionId,
};
});
return appendWorkspaceRootPaneIfMissing(prev, workspaceId, newSessionId, direction);
});
return newSessionId;
}, [setActiveTabId]);
@@ -808,6 +864,13 @@ export const useSessionState = ({
localShellType?: TerminalSession['shellType'];
},
) => {
const newSessionId = crypto.randomUUID();
const standaloneHint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const standaloneWorkspace = createWorkspaceEntity(sessionId, newSessionId, standaloneHint);
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
@@ -816,7 +879,7 @@ export const useSessionState = ({
if (session.workspaceId) {
// Create a new session with the same host
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
id: newSessionId,
localShellType: options?.localShellType,
workspaceId: session.workspaceId,
});
@@ -829,36 +892,27 @@ export const useSessionState = ({
};
setWorkspaces(prevWorkspaces => {
return prevWorkspaces.map(ws => {
if (ws.id !== session.workspaceId) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, newSession.id, hint) };
});
return insertWorkspacePaneIfMissing(prevWorkspaces, session.workspaceId, newSession.id, hint);
});
return [...prevSessions, newSession];
return addTerminalSessionIfMissing(prevSessions, newSession);
}
// Session is standalone - create a new workspace
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
id: newSessionId,
localShellType: options?.localShellType,
});
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setActiveTabId(newWorkspace.id);
setWorkspaces(prev => addWorkspaceIfMissing(prev, standaloneWorkspace));
setActiveTabId(standaloneWorkspace.id);
return prevSessions.map(s => {
if (s.id === sessionId) {
return { ...s, workspaceId: newWorkspace.id };
return { ...s, workspaceId: standaloneWorkspace.id };
}
return s;
}).concat({ ...newSession, workspaceId: newWorkspace.id });
}).concat({ ...newSession, workspaceId: standaloneWorkspace.id });
});
}, [setActiveTabId]);
@@ -973,6 +1027,7 @@ export const useSessionState = ({
: {
startupCommand: resolvedCommand,
noAutoRun: snippet.noAutoRun,
multiLineRunMode: snippet.multiLineRunMode ?? 'paste',
}),
}));
@@ -1023,30 +1078,12 @@ export const useSessionState = ({
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
// Fast path: source is already tracked in tabOrder — splice directly.
const directIdx = prevTabOrder.indexOf(sessionId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, newSessionId);
return next;
}
// Fallback: source is only in the derived tab collections. Rebuild the
// effective order (same pattern as reorderTabs) to locate its position.
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sessionId);
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, newSessionId);
return next;
return insertCopiedTabOrderIdOnce(prevTabOrder, sessionId, newSessionId, allTabIds);
});
return [...prevSessions, newSession];
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { TerminalSession, Workspace } from '../../domain/models';
import { collectSessionIds, type SplitHint } from '../../domain/workspace';
import {
addTerminalSessionIfMissing,
addWorkspaceIfMissing,
appendWorkspaceRootPaneIfMissing,
insertCopiedTabOrderIdOnce,
insertWorkspacePaneIfMissing,
} from './useSessionState';
const session = (id: string, workspaceId?: string): TerminalSession => ({
id,
hostId: `host-${id}`,
hostLabel: `Host ${id}`,
hostname: `${id}.example.test`,
username: 'user',
status: 'connecting',
workspaceId,
});
const workspace = (id = 'ws-1'): Workspace => ({
id,
title: 'Workspace',
focusedSessionId: 's1',
root: { id: 'pane-1', type: 'pane', sessionId: 's1' },
});
const paneCount = (ws: Workspace, sessionId: string) => (
collectSessionIds(ws.root).filter(id => id === sessionId).length
);
test('workspace creation remains idempotent when the same update runs twice', () => {
const ws = workspace();
const once = addWorkspaceIfMissing([], ws);
const twice = addWorkspaceIfMissing(once, ws);
assert.equal(twice.length, 1);
assert.equal(twice[0], ws);
});
test('terminal session insertion remains idempotent when the same update runs twice', () => {
const newSession = session('s2', 'ws-1');
const once = addTerminalSessionIfMissing([session('s1', 'ws-1')], newSession);
const twice = addTerminalSessionIfMissing(once, newSession);
assert.equal(twice.filter(candidate => candidate.id === 's2').length, 1);
});
test('workspace pane insertion remains idempotent when the same update runs twice', () => {
const hint: SplitHint = {
direction: 'vertical',
position: 'right',
targetSessionId: 's1',
};
const once = insertWorkspacePaneIfMissing([workspace()], 'ws-1', 's2', hint);
const twice = insertWorkspacePaneIfMissing(once, 'ws-1', 's2', hint);
assert.deepEqual(collectSessionIds(twice[0].root), ['s1', 's2']);
assert.equal(paneCount(twice[0], 's2'), 1);
});
test('workspace root append remains idempotent when the same update runs twice', () => {
const once = appendWorkspaceRootPaneIfMissing([workspace()], 'ws-1', 's2', 'vertical');
const twice = appendWorkspaceRootPaneIfMissing(once, 'ws-1', 's2', 'vertical');
assert.deepEqual(collectSessionIds(twice[0].root), ['s1', 's2']);
assert.equal(paneCount(twice[0], 's2'), 1);
assert.equal(twice[0].focusedSessionId, 's2');
});
test('copied tab order insertion remains idempotent when the same update runs twice', () => {
const once = insertCopiedTabOrderIdOnce(['s1', 'ws-1'], 's1', 's2', ['s1', 'ws-1']);
const twice = insertCopiedTabOrderIdOnce(once, 's1', 's2', ['s1', 'ws-1']);
assert.deepEqual(twice, ['s1', 's2', 'ws-1']);
assert.equal(twice.filter(id => id === 's2').length, 1);
});
+135 -7
View File
@@ -39,6 +39,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SSH_DEEP_LINK_ENABLED,
STORAGE_KEY_JMS_DEEP_LINK_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
@@ -50,6 +51,8 @@ import {
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
} from '../../infrastructure/config/storageKeys';
@@ -96,6 +99,7 @@ import {
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_SSH_DEEP_LINK_ENABLED,
DEFAULT_JMS_DEEP_LINK_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
DEFAULT_WINDOW_OPACITY,
@@ -122,6 +126,12 @@ import { useSystemSettingsEffects } from './systemSettingsEffects';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import { DEFAULT_APP_ICON_VARIANT } from '../../infrastructure/config/appIconVariants';
import { applyCustomCssToDocument } from '../../lib/customCss';
import {
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED,
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
isTerminalSidePanelAutoOpenTab,
type TerminalSidePanelAutoOpenTab,
} from '../../domain/terminalSidePanelAutoOpen';
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
const enableSettingsSync = options.enableSettingsSync !== false;
@@ -253,6 +263,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
});
const [terminalSidePanelAutoOpen, setTerminalSidePanelAutoOpenState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
return stored ?? DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED;
});
const [terminalSidePanelAutoOpenTab, setTerminalSidePanelAutoOpenTabState] = useState<TerminalSidePanelAutoOpenTab>(() => {
const stored = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
return isTerminalSidePanelAutoOpenTab(stored) ? stored : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
});
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
@@ -306,6 +324,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
return stored ?? DEFAULT_SSH_DEEP_LINK_ENABLED;
});
const [jmsDeepLinkEnabled, setJmsDeepLinkEnabledState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
return stored ?? DEFAULT_JMS_DEEP_LINK_ENABLED;
});
// Global Toggle Window Settings (Quake Mode)
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
@@ -367,6 +389,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const sshDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
const sshDeepLinkEnabledRef = useRef(sshDeepLinkEnabled);
const sshDeepLinkSetRequestIdRef = useRef(0);
const jmsDeepLinkMutationSourceRef = useRef<'local' | 'incoming'>('local');
const jmsDeepLinkEnabledRef = useRef(jmsDeepLinkEnabled);
const jmsDeepLinkSetRequestIdRef = useRef(0);
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
@@ -460,6 +485,15 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
});
}, []);
const applyIncomingJmsDeepLinkEnabled = useCallback((enabled: boolean) => {
jmsDeepLinkSetRequestIdRef.current += 1;
setJmsDeepLinkEnabledState((prev) => {
if (prev === enabled) return prev;
jmsDeepLinkMutationSourceRef.current = 'incoming';
return enabled;
});
}, []);
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
if (!enableSettingsSync) return;
@@ -582,6 +616,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
}
const storedSshDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_SSH_DEEP_LINK_ENABLED);
applyIncomingSshDeepLinkEnabled(storedSshDeepLinkEnabled ?? DEFAULT_SSH_DEEP_LINK_ENABLED);
const storedJmsDeepLinkEnabled = localStorageAdapter.readBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED);
applyIncomingJmsDeepLinkEnabled(storedJmsDeepLinkEnabled ?? DEFAULT_JMS_DEEP_LINK_ENABLED);
// SFTP
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
@@ -608,6 +644,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
const storedTerminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
setTerminalSidePanelAutoOpenState(storedTerminalSidePanelAutoOpen ?? DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_ENABLED);
const storedTerminalSidePanelAutoOpenTab = readStoredString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
setTerminalSidePanelAutoOpenTabState(
isTerminalSidePanelAutoOpenTab(storedTerminalSidePanelAutoOpenTab)
? storedTerminalSidePanelAutoOpenTab
: DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
@@ -623,7 +667,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [applyIncomingCustomKeyBindings, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
}, [applyIncomingCustomKeyBindings, applyIncomingJmsDeepLinkEnabled, applyIncomingSshDeepLinkEnabled, syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
@@ -700,6 +744,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
@@ -712,6 +757,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setTerminalSidePanelAutoOpenState,
setTerminalSidePanelAutoOpenTabState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -743,8 +790,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
@@ -752,8 +799,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -866,6 +913,21 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
}, [notifySettingsChanged]);
const setTerminalSidePanelAutoOpen = useCallback((enabled: boolean) => {
setTerminalSidePanelAutoOpenState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, enabled);
}, [notifySettingsChanged]);
const setTerminalSidePanelAutoOpenTab = useCallback((tab: TerminalSidePanelAutoOpenTab) => {
const next = isTerminalSidePanelAutoOpenTab(tab) ? tab : DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB;
setTerminalSidePanelAutoOpenTabState(next);
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, next);
}, [notifySettingsChanged]);
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
setShellOnlyTabNumberShortcutsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
@@ -1046,6 +1108,66 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_SSH_DEEP_LINK_ENABLED, sshDeepLinkEnabled);
}, [sshDeepLinkEnabled, notifySettingsChanged]);
useEffect(() => {
jmsDeepLinkEnabledRef.current = jmsDeepLinkEnabled;
}, [jmsDeepLinkEnabled]);
useEffect(() => {
let cancelled = false;
const requestIdAtStart = jmsDeepLinkSetRequestIdRef.current;
const bridge = netcattyBridge.get();
if (!bridge?.getJmsDeepLinkEnabled) return;
void bridge.getJmsDeepLinkEnabled().then((enabled) => {
if (cancelled || typeof enabled !== 'boolean') return;
if (jmsDeepLinkSetRequestIdRef.current !== requestIdAtStart) return;
jmsDeepLinkMutationSourceRef.current = 'incoming';
setJmsDeepLinkEnabledState((prev) => (prev === enabled ? prev : enabled));
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, enabled);
}).catch(() => {
// The renderer can still use its cached setting when the bridge is unavailable.
});
return () => {
cancelled = true;
};
}, []);
const setJmsDeepLinkEnabled = useCallback((enabled: boolean) => {
const previous = jmsDeepLinkEnabledRef.current;
const requestId = jmsDeepLinkSetRequestIdRef.current + 1;
jmsDeepLinkSetRequestIdRef.current = requestId;
jmsDeepLinkMutationSourceRef.current = 'local';
setJmsDeepLinkEnabledState(enabled);
const bridge = netcattyBridge.get();
if (!bridge?.setJmsDeepLinkEnabled) return;
void bridge.setJmsDeepLinkEnabled(enabled).then((result) => {
if (jmsDeepLinkSetRequestIdRef.current !== requestId) return;
const success = typeof result === 'object' ? result.success : result;
if (success !== false) return;
const finalEnabled = typeof result === 'object' && typeof result.enabled === 'boolean'
? result.enabled
: previous;
jmsDeepLinkMutationSourceRef.current = 'incoming';
setJmsDeepLinkEnabledState(finalEnabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, finalEnabled);
}).catch(() => {
if (jmsDeepLinkSetRequestIdRef.current !== requestId) return;
jmsDeepLinkMutationSourceRef.current = 'incoming';
setJmsDeepLinkEnabledState(previous);
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, previous);
});
}, []);
useEffect(() => {
localStorageAdapter.writeBoolean(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
if (jmsDeepLinkMutationSourceRef.current === 'incoming') {
jmsDeepLinkMutationSourceRef.current = 'local';
return;
}
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_JMS_DEEP_LINK_ENABLED, jmsDeepLinkEnabled);
}, [jmsDeepLinkEnabled, notifySettingsChanged]);
useSystemSettingsEffects({
enabled: enableSystemEffects,
toggleWindowHotkey,
@@ -1203,6 +1325,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
terminalSidePanelAutoOpen,
setTerminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
setTerminalSidePanelAutoOpenTab,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
disableTerminalFontZoom,
@@ -1233,6 +1359,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setSshDebugLogsEnabled,
sshDeepLinkEnabled,
setSshDeepLinkEnabled,
jmsDeepLinkEnabled,
setJmsDeepLinkEnabled,
// Global Toggle Window (Quake Mode)
toggleWindowHotkey,
setToggleWindowHotkey,
@@ -1259,8 +1387,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled,
]),
};
};
+8 -1
View File
@@ -124,7 +124,7 @@ export const useTerminalBackend = () => {
const onSessionData = useCallback((
sessionId: string,
cb: (data: string) => void,
cb: Parameters<NetcattyBridge["onSessionData"]>[1],
options?: Parameters<NetcattyBridge["onSessionData"]>[2],
) => {
const bridge = netcattyBridge.get();
@@ -168,6 +168,11 @@ export const useTerminalBackend = () => {
return bridge?.onWindowFullScreenChanged?.(cb);
}, []);
const onWindowShown = useCallback((cb: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onWindowShown?.(cb);
}, []);
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
const bridge = netcattyBridge.get();
return bridge?.onHostKeyVerification?.(cb);
@@ -370,6 +375,7 @@ export const useTerminalBackend = () => {
onChainProgress,
onConnectionReuseFallback,
onWindowFullScreenChanged,
onWindowShown,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
@@ -422,6 +428,7 @@ export const useTerminalBackend = () => {
onChainProgress,
onConnectionReuseFallback,
onWindowFullScreenChanged,
onWindowShown,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
+1 -5
View File
@@ -1,6 +1,6 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import type { Host, TerminalTheme } from '../../domain/models';
import type { TerminalTheme } from '../../domain/models';
import {
idleThemeUserIntent,
pickingThemeUserIntent,
@@ -113,7 +113,3 @@ export function useTerminalAppearanceInjection(
injectTerminalAppearanceVars(appearance.theme, { includeChromeSurfaces });
}, [appearance.theme.id, appearance.theme, includeChromeSurfaces]);
}
export function buildHostScope(host: Host | null, isEphemeral: boolean): TerminalAppearanceHostScope {
return { host, isEphemeral };
}
@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as storageKeys from "../../infrastructure/config/storageKeys.ts";
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "./ymodemFileMemory.ts";
type TestStore = {
readString: (key: string) => string | null;
writeString: (key: string, value: string) => boolean;
};
const createStore = (initial: Record<string, string> = {}): {
store: TestStore;
values: Map<string, string>;
} => {
const values = new Map(Object.entries(initial));
return {
store: {
readString: (key: string) => values.get(key) ?? null,
writeString: (key: string, value: string) => {
values.set(key, value);
return true;
},
},
values,
};
};
test("uses the remembered YMODEM send directory as the file picker default path", () => {
assert.equal(
storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR,
"netcatty_terminal_ymodem_send_dir_v1",
);
const { store } = createStore({
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: "/firmware/releases",
});
assert.equal(getRememberedYmodemSendDefaultPath(store), "/firmware/releases");
});
test("preserves valid remembered directory whitespace", () => {
const { store } = createStore({
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: "/firmware/releases ",
});
assert.equal(getRememberedYmodemSendDefaultPath(store), "/firmware/releases ");
});
test("remembers the directory containing a selected YMODEM send file", () => {
const { store, values } = createStore();
assert.equal(rememberYmodemSendFilePath("/firmware/releases/device.bin", store), true);
assert.equal(
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
"/firmware/releases",
);
});
test("keeps POSIX backslashes as part of the selected file name", () => {
const { store, values } = createStore();
assert.equal(rememberYmodemSendFilePath("/firmware/releases\\device.bin", store), true);
assert.equal(
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
"/firmware",
);
});
test("remembers Windows-style YMODEM send directories", () => {
const { store, values } = createStore();
assert.equal(rememberYmodemSendFilePath("C:\\Users\\catty\\device.bin", store), true);
assert.equal(
values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR),
"C:\\Users\\catty",
);
});
test("ignores blank remembered paths and selected file names without a parent directory", () => {
const { store, values } = createStore({
[storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR]: " ",
});
assert.equal(getRememberedYmodemSendDefaultPath(store), undefined);
assert.equal(rememberYmodemSendFilePath("device.bin", store), false);
assert.equal(values.get(storageKeys.STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR), " ");
});
+38
View File
@@ -0,0 +1,38 @@
import { STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
type YmodemFileMemoryStore = {
readString: (key: string) => string | null;
writeString: (key: string, value: string) => boolean;
};
const getParentDirectory = (filePath: string): string | null => {
const lastForwardSlash = filePath.lastIndexOf("/");
const isWindowsPath = /^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith("\\\\");
const lastSeparator = isWindowsPath
? Math.max(lastForwardSlash, filePath.lastIndexOf("\\"))
: lastForwardSlash;
if (lastSeparator < 0) return null;
if (lastSeparator === 0) return filePath.slice(0, 1);
if (lastSeparator === 2 && filePath[1] === ":") return filePath.slice(0, 3);
return filePath.slice(0, lastSeparator);
};
export const getRememberedYmodemSendDefaultPath = (
store: YmodemFileMemoryStore = localStorageAdapter,
): string | undefined => {
const rememberedPath = store.readString(STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR);
if (!rememberedPath || rememberedPath.trim() === "") return undefined;
return rememberedPath;
};
export const rememberYmodemSendFilePath = (
filePath: string,
store: YmodemFileMemoryStore = localStorageAdapter,
): boolean => {
const directory = getParentDirectory(filePath);
if (!directory) return false;
return store.writeString(STORAGE_KEY_TERMINAL_YMODEM_SEND_DIR, directory);
};
+21
View File
@@ -181,12 +181,29 @@ test("terminal selection AI preference is syncable for auto-sync detection", ()
);
});
test("terminal side panel auto-open settings are syncable for auto-sync detection", () => {
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
),
);
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
),
);
});
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, "scripts");
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.showHostTreeSidebar, false);
assert.equal(payload.settings?.terminalSidePanelAutoOpen, true);
assert.equal(payload.settings?.terminalSidePanelAutoOpenTab, "scripts");
});
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
@@ -378,6 +395,8 @@ test("applySyncPayload restores host tree sidebar visibility setting", async ()
customGroups: [],
settings: {
showHostTreeSidebar: false,
terminalSidePanelAutoOpen: true,
terminalSidePanelAutoOpenTab: "scripts",
},
syncedAt: 1,
} as SyncPayload;
@@ -385,6 +404,8 @@ test("applySyncPayload restores host tree sidebar visibility setting", async ()
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN), "true");
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB), "scripts");
});
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
+17
View File
@@ -53,6 +53,8 @@ import {
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
@@ -88,6 +90,7 @@ import {
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
STORAGE_KEY_PORT_FORWARDING,
} from '../infrastructure/config/storageKeys';
import { isTerminalSidePanelAutoOpenTab } from '../domain/terminalSidePanelAutoOpen';
// ---------------------------------------------------------------------------
// Input types
@@ -227,6 +230,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_EDITOR_WORD_WRAP,
@@ -386,6 +391,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (termFont) settings.terminalFontFamily = termFont;
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (termSize != null) settings.terminalFontSize = termSize;
const terminalSidePanelAutoOpen = localStorageAdapter.readBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN);
if (terminalSidePanelAutoOpen != null) settings.terminalSidePanelAutoOpen = terminalSidePanelAutoOpen;
const terminalSidePanelAutoOpenTab = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB);
if (isTerminalSidePanelAutoOpenTab(terminalSidePanelAutoOpenTab)) {
settings.terminalSidePanelAutoOpenTab = terminalSidePanelAutoOpenTab;
}
// Terminal settings (syncable subset only)
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
@@ -571,6 +582,12 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
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));
if (settings.terminalSidePanelAutoOpen != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, settings.terminalSidePanelAutoOpen);
}
if (isTerminalSidePanelAutoOpenTab(settings.terminalSidePanelAutoOpenTab)) {
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
}
// Terminal settings — merge with existing to preserve platform-specific keys
if (settings.terminalSettings) {
-16
View File
@@ -108,22 +108,6 @@ export function modelPresetsContainId(presets: AgentModelPreset[], modelId: stri
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
}
export function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
agent.id,
agent.name,
agent.icon,
agent.command,
getExternalAgentSdkBackend(agent),
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
// Split on both separators so Windows command paths (e.g. "...\\copilot.exe")
// reduce to their basename rather than staying as the full path.
.map((value) => value.split(/[\\/]/).pop()?.toLowerCase() ?? value.toLowerCase());
return tokens.some((token) => token.includes('copilot'));
}
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
return sdkBackend === 'claude'
+10
View File
@@ -0,0 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { hasGroupTelnetFields } from "./GroupDetailsPanel.tsx";
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
assert.equal(hasGroupTelnetFields({}), false);
});
+45 -27
View File
@@ -18,8 +18,7 @@ import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/gro
import {
formatProxyConfigEndpoint,
formatProxyConfigType,
isCompleteProxyConfig,
normalizeManualProxyConfig,
updateProxyConfigField,
} from "../domain/proxyProfiles";
import {
EnvVar,
@@ -54,6 +53,7 @@ import { TerminalFontSelect } from "./settings/TerminalFontSelect";
import { useAvailableFonts } from "../application/state/fontStore";
import { toast } from "./ui/toast";
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers";
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
@@ -83,11 +83,17 @@ interface GroupDetailsPanelProps {
type GroupDetailsPanelPropsWithResize = GroupDetailsPanelProps & AsidePanelResizeProps;
export const hasGroupTelnetFields = (c: Partial<GroupConfig>): boolean =>
c.telnetPort !== undefined ||
c.telnetUsername !== undefined ||
c.telnetPassword !== undefined ||
c.telnetEnabled === true;
const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
groupPath,
config,
availableKeys,
identities: _identities,
identities,
proxyProfiles = [],
allHosts,
groups,
@@ -129,16 +135,14 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
c.deviceType !== undefined ||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
c.etEnabled !== undefined || c.etPort !== undefined ||
(c.identityFilePaths && c.identityFilePaths.length > 0);
const hasTelnetFields = (c: Partial<GroupConfig>) =>
c.telnetPort !== undefined || !!c.telnetUsername || !!c.telnetPassword || c.telnetEnabled === true;
const [sshEnabled, setSshEnabled] = useState(() => hasSshFields(config || {}));
const [telnetEnabled, setTelnetEnabled] = useState(() => hasTelnetFields(config || {}));
const [telnetEnabled, setTelnetEnabled] = useState(() => hasGroupTelnetFields(config || {}));
// Sub-panel state
const [activeSubPanel, setActiveSubPanel] = useState<SubPanel>("none");
@@ -191,6 +195,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
delete next.deviceType;
delete next.agentForwarding;
delete next.startupCommand;
delete next.startupCommandRunMode;
delete next.legacyAlgorithms;
delete next.skipEcdsaHostKey;
delete next.algorithms;
@@ -223,18 +228,12 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
// Proxy helpers
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: string | number) => {
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
return {
...rest,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
};
});
},
@@ -367,6 +366,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
if (!parentGroup || groupConfigs.length === 0) return false;
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
}, [groupConfigs, parentGroup]);
const inheritedStartupCommandRunMode = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return "paste";
return resolveGroupDefaults(parentGroup, groupConfigs).startupCommandRunMode ?? "paste";
}, [groupConfigs, parentGroup]);
const inheritedDeviceType = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return undefined;
return resolveGroupDefaults(parentGroup, groupConfigs).deviceType;
@@ -385,19 +388,31 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
setNameError(t("vault.groups.errors.invalidChars"));
return;
}
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
toast.error(
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
);
setActiveSubPanel("proxy");
return;
}
if (sshEnabled && hasMissingProxyProfile) {
toast.error(t("hostDetails.proxyPanel.missingSaved"));
const proxySave = sshEnabled
? prepareProxyConfigForSave({
proxyConfig: form.proxyConfig,
proxyProfileId: form.proxyProfileId,
proxyProfiles,
identities,
})
: null;
if (proxySave?.error) {
const messageKey = proxySave.error === "port"
? "proxyProfiles.error.port"
: proxySave.error === "required"
? "hostDetails.proxyPanel.error.required"
: proxySave.error === "missingSaved"
? "hostDetails.proxyPanel.missingSaved"
: proxySave.error === "missingIdentity"
? "hostDetails.proxyPanel.missingIdentity"
: proxySave.error === "incompleteIdentity"
? "hostDetails.proxyPanel.incompleteIdentity"
: "hostDetails.proxyPanel.unreadableIdentity";
toast.error(t(messageKey));
setActiveSubPanel("proxy");
return;
}
const normalizedProxyConfig = proxySave?.normalizedProxyConfig;
setNameError(null);
const newPath = parentGroup
@@ -420,6 +435,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }),
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
...(form.skipEcdsaHostKey !== undefined && { skipEcdsaHostKey: form.skipEcdsaHostKey }),
...(form.algorithms !== undefined && { algorithms: form.algorithms }),
@@ -468,6 +484,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyProfiles={proxyProfiles}
identities={identities}
selectedProxyProfileId={form.proxyProfileId}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
@@ -622,6 +639,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
setNewKeyFilePath={setNewKeyFilePath}
inheritedLegacyAlgorithms={inheritedLegacyAlgorithms}
inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey}
inheritedStartupCommandRunMode={inheritedStartupCommandRunMode}
showAlgorithmOverrides={showAlgorithmOverrides}
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
inheritedAlgorithmOverrides={inheritedAlgorithmOverrides}
@@ -700,7 +718,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
<Input
placeholder={t("hostDetails.username.placeholder")}
value={form.telnetUsername || ""}
onChange={(e) => update("telnetUsername", e.target.value || undefined)}
onChange={(e) => update("telnetUsername", e.target.value)}
className="h-10"
/>
<div className="relative">
@@ -708,7 +726,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
placeholder={t("hostDetails.password.placeholder")}
type={showTelnetPassword ? "text" : "password"}
value={form.telnetPassword || ""}
onChange={(e) => update("telnetPassword", e.target.value || undefined)}
onChange={(e) => update("telnetPassword", e.target.value)}
className="h-10 pr-10"
/>
<button
+23
View File
@@ -43,6 +43,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
setNewKeyFilePath,
inheritedLegacyAlgorithms,
inheritedSkipEcdsaHostKey,
inheritedStartupCommandRunMode,
showAlgorithmOverrides,
setShowAlgorithmOverrides,
inheritedAlgorithmOverrides,
@@ -51,6 +52,8 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
chainedHosts,
}) => {
if (!sshEnabled) return null;
const startupCommandRunModeFallback = inheritedStartupCommandRunMode ?? "paste";
const effectiveStartupCommandRunMode = form.startupCommandRunMode ?? startupCommandRunModeFallback;
return (
<HostDetailsSection
@@ -384,6 +387,26 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
className="min-h-[80px] font-mono text-sm"
rows={3}
/>
<HostDetailsSettingRow
label={t("hostDetails.startupCommand.runMode")}
hint={t("hostDetails.startupCommand.runMode.help")}
>
<Select
value={effectiveStartupCommandRunMode}
onValueChange={(value) => update(
"startupCommandRunMode",
value === startupCommandRunModeFallback ? undefined : value,
)}
>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="lineDelay">{t("hostDetails.startupCommand.runMode.lineDelay")}</SelectItem>
<SelectItem value="paste">{t("hostDetails.startupCommand.runMode.paste")}</SelectItem>
</SelectContent>
</Select>
</HostDetailsSettingRow>
{/* Display the *effective* value (this group's field falling
back to the resolved parent default). Same rationale as
+23 -2
View File
@@ -45,10 +45,11 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
proxySummaryLabel,
proxySummaryTooltip,
clearProxyConfig,
groupDefaults,
}) => {
const inheritedDeviceType = effectiveGroupDefaults?.deviceType;
const effectiveDeviceType = form.deviceType ?? inheritedDeviceType;
const inheritedStartupCommandRunMode = effectiveGroupDefaults?.startupCommandRunMode ?? "paste";
const effectiveStartupCommandRunMode = form.startupCommandRunMode ?? inheritedStartupCommandRunMode;
return (
<>
@@ -621,12 +622,32 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
hint={t("hostDetails.startupCommand.help")}
>
<Textarea
placeholder={groupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
placeholder={effectiveGroupDefaults?.startupCommand || t("hostDetails.startupCommand.placeholder")}
value={form.startupCommand || ""}
onChange={(e) => update("startupCommand", e.target.value)}
className="min-h-[80px] font-mono text-sm"
rows={3}
/>
<HostDetailsSettingRow
label={t("hostDetails.startupCommand.runMode")}
hint={t("hostDetails.startupCommand.runMode.help")}
>
<Select
value={effectiveStartupCommandRunMode}
onValueChange={(value) => update(
"startupCommandRunMode",
value === inheritedStartupCommandRunMode ? undefined : value,
)}
>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="lineDelay">{t("hostDetails.startupCommand.runMode.lineDelay")}</SelectItem>
<SelectItem value="paste">{t("hostDetails.startupCommand.runMode.paste")}</SelectItem>
</SelectContent>
</Select>
</HostDetailsSettingRow>
</HostDetailsSection>
</>
);
+75 -1
View File
@@ -1,5 +1,13 @@
import type { GroupConfig } from "../domain/models";
import type { Host } from "../types";
import {
findProxyProfile,
hasIncompleteProxyIdentity,
hasMissingProxyIdentity,
hasUnreadableProxyCredential,
isCompleteProxyConfig,
normalizeManualProxyConfig,
} from "../domain/proxyProfiles";
import type { Host, Identity, ProxyConfig, ProxyProfile } from "../types";
import {
LINUX_DISTRO_OPTIONS,
NETWORK_DEVICE_OPTIONS,
@@ -78,8 +86,74 @@ export const resolveDetailsTelnetPassword = (
? groupDefaults.telnetPassword
: host.password ?? groupDefaults?.password ?? "";
export const prepareTelnetCredentialsForSave = (host: Host): Host =>
host.telnetIdentityId
? {
...host,
telnetUsername: undefined,
telnetPassword: undefined,
}
: host;
export const LINUX_DISTRO_OPTION_IDS = [
...LINUX_DISTRO_OPTIONS,
...POSIX_PLATFORM_OPTIONS,
...NETWORK_DEVICE_OPTIONS,
];
export type ProxySaveValidationError =
| "required"
| "port"
| "missingSaved"
| "missingIdentity"
| "incompleteIdentity"
| "unreadableIdentity";
export const validateProxyConfigForSave = ({
proxyConfig,
proxyProfileId,
proxyProfiles,
identities,
}: {
proxyConfig?: ProxyConfig;
proxyProfileId?: string;
proxyProfiles?: ProxyProfile[];
identities?: Identity[];
}): ProxySaveValidationError | null => {
const selectedProxyProfile = findProxyProfile(proxyProfileId, proxyProfiles ?? []);
if (proxyProfileId && !selectedProxyProfile) return "missingSaved";
const normalizedProxyConfig = normalizeManualProxyConfig(proxyConfig);
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
return normalizedProxyConfig.host ? "port" : "required";
}
const effectiveProxyConfig = selectedProxyProfile?.config ?? proxyConfig;
if (hasMissingProxyIdentity(effectiveProxyConfig, identities ?? [])) {
return "missingIdentity";
}
if (hasIncompleteProxyIdentity(effectiveProxyConfig, identities ?? [])) {
return "incompleteIdentity";
}
if (hasUnreadableProxyCredential(effectiveProxyConfig, identities ?? [])) {
return "unreadableIdentity";
}
return null;
};
export const prepareProxyConfigForSave = (params: {
proxyConfig?: ProxyConfig;
proxyProfileId?: string;
proxyProfiles?: ProxyProfile[];
identities?: Identity[];
}): {
error?: ProxySaveValidationError;
normalizedProxyConfig?: ProxyConfig;
} => {
const error = validateProxyConfigForSave(params);
if (error) return { error };
return {
normalizedProxyConfig: normalizeManualProxyConfig(params.proxyConfig),
};
};
@@ -4,11 +4,14 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host } from "../types.ts";
import type { Host, Identity } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import {
prepareProxyConfigForSave,
prepareTelnetCredentialsForSave,
resolvePrimaryProtocolSavePort,
resolvePrimaryProtocolSwitchPort,
validateProxyConfigForSave,
} from "./HostDetailsPanel.helpers.ts";
import { TooltipProvider } from "./ui/tooltip.tsx";
@@ -26,7 +29,10 @@ const hostWithMissingProxyProfile: Host = {
createdAt: 1,
};
const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
const renderHostDetails = (
initialData: Host = hostWithMissingProxyProfile,
options: { identities?: Identity[] } = {},
) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
@@ -37,7 +43,7 @@ const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
React.createElement(HostDetailsPanel, {
initialData,
availableKeys: [],
identities: [],
identities: options.identities ?? [],
proxyProfiles: [],
groups: [],
managedSources: [],
@@ -108,6 +114,60 @@ test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () =>
assert.doesNotMatch(markup, /placeholder="Telnet Password"[^>]*value="ssh-password"/);
});
test("HostDetailsPanel shows a selected telnet identity instead of manual telnet fields", () => {
const markup = renderHostDetails(
{
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
identityId: "ssh-identity",
telnetIdentityId: "telnet-identity",
telnetUsername: undefined,
telnetPassword: undefined,
proxyProfileId: undefined,
},
{
identities: [{
id: "ssh-identity",
label: "SSH login",
username: "ssh-user",
authMethod: "password",
password: "ssh-password",
created: 1,
}, {
id: "telnet-identity",
label: "Telnet login",
username: "telnet-user",
authMethod: "password",
password: "telnet-password",
created: 2,
}],
},
);
assert.match(markup, /telnet-user - Telnet login/);
assert.doesNotMatch(markup, /placeholder="Telnet Username"/);
assert.doesNotMatch(markup, /placeholder="Telnet Password"/);
});
test("prepareTelnetCredentialsForSave preserves selected telnet identity and clears manual telnet fields", () => {
const saved = prepareTelnetCredentialsForSave({
...hostWithMissingProxyProfile,
protocol: "telnet",
telnetEnabled: true,
identityId: "ssh-identity",
telnetIdentityId: "telnet-identity",
telnetUsername: "stale-telnet-user",
telnetPassword: "stale-telnet-password",
proxyProfileId: undefined,
});
assert.equal(saved.identityId, "ssh-identity");
assert.equal(saved.telnetIdentityId, "telnet-identity");
assert.equal(saved.telnetUsername, undefined);
assert.equal(saved.telnetPassword, undefined);
});
test("HostDetailsPanel disables save when hostname is whitespace only", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
@@ -275,6 +335,138 @@ test("parseOptionalPortInput clears empty port values", () => {
assert.equal(parseOptionalPortInput("2325"), 2325);
});
test("validateProxyConfigForSave rejects missing proxy identities", () => {
assert.equal(
validateProxyConfigForSave({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
identities: [],
}),
"missingIdentity",
);
});
test("validateProxyConfigForSave rejects missing identities inside saved proxy profiles", () => {
assert.equal(
validateProxyConfigForSave({
proxyProfileId: "profile-1",
proxyProfiles: [{
id: "profile-1",
label: "Office Proxy",
config: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
createdAt: 1,
}],
identities: [],
}),
"missingIdentity",
);
});
test("validateProxyConfigForSave rejects incomplete proxy identities", () => {
assert.equal(
validateProxyConfigForSave({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
created: 1,
}],
}),
"incompleteIdentity",
);
});
test("validateProxyConfigForSave rejects unreadable proxy identity passwords", () => {
assert.equal(
validateProxyConfigForSave({
proxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
"unreadableIdentity",
);
});
test("prepareProxyConfigForSave returns a normalized proxy config for save handlers", () => {
assert.deepEqual(
prepareProxyConfigForSave({
proxyConfig: {
type: "http",
host: " proxy.example.com ",
port: "3128" as never,
command: "cloudflared access ssh --hostname %h --token secret",
username: " proxy-user ",
password: "proxy-secret",
},
identities: [],
}),
{
normalizedProxyConfig: {
type: "http",
host: "proxy.example.com",
port: 3128,
username: "proxy-user",
password: "proxy-secret",
},
},
);
});
test("prepareProxyConfigForSave returns identity errors before save handlers build output", () => {
assert.deepEqual(
prepareProxyConfigForSave({
proxyProfileId: "profile-1",
proxyProfiles: [{
id: "profile-1",
label: "Office Proxy",
config: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "identity-1",
},
createdAt: 1,
}],
identities: [{
id: "identity-1",
label: "Proxy login",
username: "",
authMethod: "password",
password: "enc:v1:djEwAAAA",
created: 1,
}],
}),
{ error: "incompleteIdentity" },
);
});
test("resolvePrimaryProtocolSwitchPort only migrates opposite protocol defaults", () => {
assert.equal(resolvePrimaryProtocolSwitchPort(22, "telnet", false, false), 23);
assert.equal(resolvePrimaryProtocolSwitchPort(23, "ssh", false, false), 22);
+100 -47
View File
@@ -20,8 +20,7 @@ import {
import {
formatProxyConfigEndpoint,
formatProxyConfigType,
isCompleteProxyConfig,
normalizeManualProxyConfig,
updateProxyConfigField,
} from "../domain/proxyProfiles";
import { customThemeStore } from "../application/state/customThemeStore";
import {
@@ -50,6 +49,8 @@ import {
resolveDetailsTelnetUsername,
resolvePrimaryProtocolSavePort,
resolvePrimaryProtocolSwitchPort,
prepareTelnetCredentialsForSave,
prepareProxyConfigForSave,
} from "./HostDetailsPanel.helpers";
export { parseOptionalPortInput } from "./HostDetailsPanel.helpers";
import { Button } from "./ui/button";
@@ -327,18 +328,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
}, []);
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: string | number) => {
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
return {
...rest,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
} as Host;
});
},
@@ -408,19 +403,29 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
const handleSubmit = () => {
const hostname = form.hostname.trim();
if (!hostname) return;
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
toast.error(
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
);
setActiveSubPanel("proxy");
return;
}
if (hasMissingProxyProfile) {
toast.error(t("hostDetails.proxyPanel.missingSaved"));
const proxySave = prepareProxyConfigForSave({
proxyConfig: form.proxyConfig,
proxyProfileId: form.proxyProfileId,
proxyProfiles,
identities,
});
if (proxySave.error) {
const messageKey = proxySave.error === "port"
? "proxyProfiles.error.port"
: proxySave.error === "required"
? "hostDetails.proxyPanel.error.required"
: proxySave.error === "missingSaved"
? "hostDetails.proxyPanel.missingSaved"
: proxySave.error === "missingIdentity"
? "hostDetails.proxyPanel.missingIdentity"
: proxySave.error === "incompleteIdentity"
? "hostDetails.proxyPanel.incompleteIdentity"
: "hostDetails.proxyPanel.unreadableIdentity";
toast.error(t(messageKey));
setActiveSubPanel("proxy");
return;
}
const normalizedProxyConfig = proxySave.normalizedProxyConfig;
let finalLabel = form.label?.trim() || hostname;
const finalGroup = groupInputValue.trim() || form.group || "";
@@ -462,7 +467,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
};
cleaned = normalizePrimaryTelnetState(cleaned);
cleaned = prepareTelnetCredentialsForSave(normalizePrimaryTelnetState(cleaned));
if (
onImportKey &&
pendingReferenceKeyPath &&
@@ -594,6 +599,21 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
return identities.find((i) => i.id === form.identityId);
}, [form.identityId, identities]);
const selectedTelnetIdentity = useMemo(() => {
if (!form.telnetIdentityId) return undefined;
return identities.find((i) => i.id === form.telnetIdentityId);
}, [form.telnetIdentityId, identities]);
const telnetIdentityOptions: ComboboxOption[] = useMemo(
() =>
identities.map((identity) => ({
value: identity.id,
label: identity.label,
sublabel: identity.username,
})),
[identities],
);
const filteredIdentitySuggestions = useMemo(() => {
if (selectedIdentity) return [];
const q = (form.username || "").toLowerCase().trim();
@@ -639,6 +659,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
setIdentitySuggestionsOpen(false);
}, []);
const updateTelnetIdentity = useCallback((identityId: string) => {
setForm((prev) => ({
...prev,
telnetIdentityId: identityId || undefined,
...(identityId
? {
telnetUsername: undefined,
telnetPassword: undefined,
}
: {}),
}));
}, []);
if (activeSubPanel === "create-group") {
return (
<CreateGroupPanel
@@ -661,6 +694,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyProfiles={proxyProfiles}
identities={identities}
selectedProxyProfileId={form.proxyProfileId}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
@@ -954,7 +988,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
proxySummaryLabel={proxySummaryLabel}
proxySummaryTooltip={proxySummaryTooltip}
clearProxyConfig={clearProxyConfig}
groupDefaults={groupDefaults}
/>
<div className="flex items-center gap-3 py-2">
@@ -1014,32 +1047,52 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
</div>
<p className="text-xs font-semibold">{t("hostDetails.telnet.credentials")}</p>
<Input
placeholder={t("hostDetails.telnet.username")}
value={effectiveTelnetUsername}
onChange={(e) =>
update("telnetUsername" as keyof Host, e.target.value)
}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.telnet.password")}
type={showTelnetPassword ? "text" : "password"}
value={effectiveTelnetPassword}
onChange={(e) =>
update("telnetPassword" as keyof Host, e.target.value)
}
className="h-10 pr-10"
{identities.length > 0 && (
<Combobox
options={telnetIdentityOptions}
value={form.telnetIdentityId || ""}
onValueChange={updateTelnetIdentity}
placeholder={t("hostDetails.telnet.identity.placeholder")}
emptyText={t("common.noResultsFound")}
className="w-full"
/>
<button
type="button"
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
{form.telnetIdentityId ? (
<div className="text-xs text-muted-foreground">
{selectedTelnetIdentity
? `${selectedTelnetIdentity.username} - ${selectedTelnetIdentity.label}`
: t("hostDetails.identity.missing")}
</div>
) : (
<>
<Input
placeholder={t("hostDetails.telnet.username")}
value={effectiveTelnetUsername}
onChange={(e) =>
update("telnetUsername" as keyof Host, e.target.value)
}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.telnet.password")}
type={showTelnetPassword ? "text" : "password"}
value={effectiveTelnetPassword}
onChange={(e) =>
update("telnetPassword" as keyof Host, e.target.value)
}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowTelnetPassword(!showTelnetPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
>
{showTelnetPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</>
)}
<Input
placeholder={groupDefaults?.charset || t("hostDetails.charset.placeholder")}
+56 -27
View File
@@ -51,6 +51,7 @@ import {
vaultHeaderIconButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
// Import utilities and components from keychain module
@@ -115,6 +116,11 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
const { generateKeyPair, execCommand } = useKeychainBackend();
const [activeFilter, setActiveFilter] = useState<FilterTab>("key");
const [search, setSearch] = useState("");
const [deleteTarget, setDeleteTarget] = useState<{
type: "key" | "identity";
id: string;
name: string;
} | null>(null);
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_KEYS_VIEW_MODE,
"grid",
@@ -468,24 +474,49 @@ echo $3 >> "$FILE"`);
// Handle delete
const handleDelete = useCallback(
async (id: string) => {
onDelete(id);
if (panel.type === "view" && panel.key.id === id) {
closePanel();
}
(id: string) => {
const key = keys.find((item) => item.id === id);
setDeleteTarget({
type: "key",
id,
name: key?.label || t("keychain.panel.keyDetails"),
});
},
[onDelete, panel, closePanel],
[keys, t],
);
// Handle delete identity
const _handleDeleteIdentity = useCallback(
(id: string) => {
onDeleteIdentity?.(id);
if (panel.type === "identity" && panel.identity?.id === id) {
closePanel();
}
const identity = identities.find((item) => item.id === id);
setDeleteTarget({
type: "identity",
id,
name: identity?.label || t("keychain.panel.editIdentity"),
});
},
[onDeleteIdentity, panel, closePanel],
[identities, t],
);
const confirmDeleteTarget = useCallback(
() => {
if (!deleteTarget) return;
if (deleteTarget.type === "key") {
onDelete(deleteTarget.id);
if (panel.type === "view" && panel.key.id === deleteTarget.id) {
closePanel();
}
} else {
onDeleteIdentity?.(deleteTarget.id);
if (panel.type === "identity" && panel.identity?.id === deleteTarget.id) {
closePanel();
}
}
setDeleteTarget(null);
},
[closePanel, deleteTarget, onDelete, onDeleteIdentity, panel],
);
// Get icon for key source
@@ -837,15 +868,7 @@ echo $3 >> "$FILE"`);
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive"
onClick={() => {
const ok = window.confirm(
t("confirm.deleteIdentity", {
name: identity.label || "",
}),
);
if (!ok) return;
_handleDeleteIdentity(identity.id);
}}
onClick={() => _handleDeleteIdentity(identity.id)}
>
<Trash2 className="mr-2 h-4 w-4" />{" "}
{t("action.delete")}
@@ -876,13 +899,7 @@ echo $3 >> "$FILE"`);
variant="destructive"
icon={<Trash2 size={14} />}
onClick={() => {
const ok = window.confirm(
t("confirm.deleteIdentity", {
name: panel.identity?.label || "",
}),
);
if (!ok || !panel.identity) return;
_handleDeleteIdentity(panel.identity.id);
if (panel.identity) _handleDeleteIdentity(panel.identity.id);
}}
>
{t("common.delete")}
@@ -1031,6 +1048,18 @@ echo $3 >> "$FILE"`);
)}
</AsidePanel>
)}
<VaultDeleteConfirmDialog
open={Boolean(deleteTarget)}
title={t("vault.deleteConfirm.title", {
name: deleteTarget?.name ?? "",
})}
description={t("vault.deleteConfirm.desc")}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={confirmDeleteTarget}
/>
</div>
);
};
+29 -2
View File
@@ -45,6 +45,7 @@ import {
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
} from "./vault/VaultPageHeader";
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
@@ -294,6 +295,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const [isScanning, setIsScanning] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE,
"grid",
@@ -471,9 +473,23 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
// Memoized handlers to prevent re-renders
const handleDelete = useCallback(
(id: string) => {
onDelete(id);
setDeleteTargetId(id);
},
[onDelete],
[],
);
const deleteTarget = useMemo(
() => knownHosts.find((knownHost) => knownHost.id === deleteTargetId) ?? null,
[deleteTargetId, knownHosts],
);
const confirmDelete = useCallback(
() => {
if (!deleteTargetId) return;
onDelete(deleteTargetId);
setDeleteTargetId(null);
},
[deleteTargetId, onDelete],
);
const handleConvertToHost = useCallback(
@@ -670,6 +686,17 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
)}
</div>
</ScrollArea>
<VaultDeleteConfirmDialog
open={Boolean(deleteTargetId)}
title={t("vault.deleteConfirm.title", {
name: deleteTarget?.hostname ?? "",
})}
description={t("vault.deleteConfirm.desc")}
onOpenChange={(open) => {
if (!open) setDeleteTargetId(null);
}}
onConfirm={confirmDelete}
/>
</div>
);
};
+30 -63
View File
@@ -1,5 +1,4 @@
import {
AlertTriangle,
Check,
ChevronDown,
Globe,
@@ -32,14 +31,6 @@ import {
AsidePanelFooter,
} from "./ui/aside-panel";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { SortDropdown } from "./ui/sort-dropdown";
import { toast } from "./ui/toast";
@@ -50,6 +41,7 @@ import {
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import { VaultDeleteConfirmDialog } from "./vault/VaultDeleteConfirmDialog";
import { useVaultItemReorder } from "./vault/vaultReorderDrag";
// Import components and utilities from port-forwarding module
@@ -436,33 +428,24 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
setSelectedRuleId(null);
}, [setSelectedRuleId]);
// Handle delete with confirmation for active tunnels
// Handle delete with confirmation
const handleDeleteRule = useCallback(
(rule: PortForwardingRule) => {
// If tunnel is active or connecting, show confirmation dialog
if (rule.status === "active" || rule.status === "connecting") {
setRuleToDelete(rule);
setShowDeleteConfirm(true);
} else {
// If inactive, delete directly
if (editingRule?.id === rule.id) {
closeEditPanel();
}
deleteRule(rule.id);
}
setRuleToDelete(rule);
setShowDeleteConfirm(true);
},
[editingRule, deleteRule, closeEditPanel],
[],
);
// Confirm delete of active tunnel: stop first, then delete
const confirmDeleteActiveRule = useCallback(async () => {
// Confirm delete; active tunnels are stopped first.
const confirmDeleteRule = useCallback(async () => {
if (!ruleToDelete) return;
setIsDeleting(true);
try {
// Stop the tunnel first
await stopTunnel(ruleToDelete.id);
// Then delete the rule
if (ruleToDelete.status === "active" || ruleToDelete.status === "connecting") {
await stopTunnel(ruleToDelete.id);
}
if (editingRule?.id === ruleToDelete.id) {
closeEditPanel();
}
@@ -474,6 +457,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}
}, [ruleToDelete, stopTunnel, deleteRule, editingRule, closeEditPanel]);
const deleteTargetIsActive = ruleToDelete?.status === "active" || ruleToDelete?.status === "connecting";
// Handle wizard navigation
// Flow for local: type -> local-config -> destination -> host-selection
// Flow for remote: type -> remote-host-selection (select host first) -> remote-config (port on remote) -> destination -> label
@@ -923,44 +908,26 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
/>
)}
{/* Delete Active Tunnel Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={(open) => {
if (!isDeleting) {
<VaultDeleteConfirmDialog
open={showDeleteConfirm}
title={t("vault.deleteConfirm.title", {
name: ruleToDelete?.label ?? "",
})}
description={
deleteTargetIsActive
? t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })
: t("vault.deleteConfirm.portForwardingDesc")
}
confirmLabel={deleteTargetIsActive ? t("pf.deleteActive.confirm") : undefined}
disabled={isDeleting}
onOpenChange={(open) => {
setShowDeleteConfirm(open);
if (!open) setRuleToDelete(null);
}
}}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle size={20} />
{t("pf.deleteActive.title")}
</DialogTitle>
<DialogDescription>
{t("pf.deleteActive.desc", { label: ruleToDelete?.label ?? "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirm(false);
setRuleToDelete(null);
}}
disabled={isDeleting}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={confirmDeleteActiveRule}
disabled={isDeleting}
>
{t("pf.deleteActive.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
}}
onConfirm={() => {
void confirmDeleteRule();
}}
/>
</div>
);
};
+100 -1
View File
@@ -4,7 +4,7 @@ import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { ProxyProfile } from "../types.ts";
import type { Identity, ProxyProfile } from "../types.ts";
import { ProxyPanel } from "./host-details/ProxyPanel.tsx";
const proxyProfile: ProxyProfile = {
@@ -18,6 +18,15 @@ const proxyProfile: ProxyProfile = {
createdAt: 1,
};
const proxyIdentity: Identity = {
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "proxy-secret",
created: 1,
};
const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {}) =>
renderToStaticMarkup(
React.createElement(
@@ -26,6 +35,7 @@ const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {
React.createElement(ProxyPanel, {
proxyConfig: undefined,
proxyProfiles: [],
identities: [],
selectedProxyProfileId: undefined,
onUpdateProxy: () => {},
onSelectProxyProfile: () => {},
@@ -102,6 +112,79 @@ test("ProxyPanel disables saving invalid manual proxy ports", () => {
assert.match(markup, /disabled=""/);
});
test("ProxyPanel shows a clear missing state for stale proxy identities", () => {
const markup = renderPanel({
proxyConfig: {
type: "http",
host: "manual-proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
identities: [proxyIdentity],
});
assert.match(markup, /Missing keychain identity/);
assert.match(markup, /Username/);
assert.match(markup, /disabled=""/);
});
test("ProxyPanel warns when a saved proxy profile has a stale identity", () => {
const markup = renderPanel({
proxyProfiles: [{
...proxyProfile,
config: {
type: "http",
host: "office-proxy.example.com",
port: 8080,
identityId: "missing-identity",
},
}],
selectedProxyProfileId: proxyProfile.id,
identities: [proxyIdentity],
});
assert.match(markup, /Missing keychain identity/);
assert.match(markup, /disabled=""/);
});
test("ProxyPanel warns when a saved proxy profile has an incomplete identity", () => {
const markup = renderPanel({
proxyProfiles: [{
...proxyProfile,
config: {
type: "http",
host: "office-proxy.example.com",
port: 8080,
identityId: proxyIdentity.id,
},
}],
selectedProxyProfileId: proxyProfile.id,
identities: [{ ...proxyIdentity, password: undefined }],
});
assert.match(markup, /Proxy identity needs a username and password/);
assert.match(markup, /disabled=""/);
});
test("ProxyPanel warns when a saved proxy profile has an unreadable identity password", () => {
const markup = renderPanel({
proxyProfiles: [{
...proxyProfile,
config: {
type: "http",
host: "office-proxy.example.com",
port: 8080,
identityId: proxyIdentity.id,
},
}],
selectedProxyProfileId: proxyProfile.id,
identities: [{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
});
assert.match(markup, /Proxy identity password cannot be read/);
assert.match(markup, /disabled=""/);
});
test("ProxyPanel supports custom ProxyCommand settings", () => {
const markup = renderPanel({
proxyConfig: {
@@ -127,3 +210,19 @@ test("ProxyPanel uses a dropdown for proxy type selection", () => {
assert.match(markup, /role="combobox"/);
assert.match(markup, /aria-label="Type"/);
});
test("ProxyPanel offers keychain identities for HTTP and SOCKS5 proxy credentials", () => {
const markup = renderPanel({
proxyConfig: {
type: "socks5",
host: "manual-proxy.example.com",
port: 1080,
identityId: proxyIdentity.id,
},
identities: [proxyIdentity],
});
assert.match(markup, /Keychain identity/);
assert.match(markup, /Proxy login/);
assert.match(markup, /proxy-user/);
});
+117 -2
View File
@@ -6,8 +6,8 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { isValidProxyPort } from "../domain/proxyProfiles.ts";
import { STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE } from "../infrastructure/config/storageKeys.ts";
import type { ProxyProfile } from "../types.ts";
import { ProxyProfilesManager } from "./ProxyProfilesManager.tsx";
import type { Identity, ProxyProfile } from "../types.ts";
import { prepareProxyProfileForSave, ProxyProfilesManager } from "./ProxyProfilesManager.tsx";
const proxyProfile: ProxyProfile = {
id: "proxy-1",
@@ -20,6 +20,15 @@ const proxyProfile: ProxyProfile = {
createdAt: 1,
};
const proxyIdentity: Identity = {
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
password: "proxy-secret",
created: 1,
};
const installStorageStub = (viewMode: string | null = null) => {
const values = new Map<string, string>();
if (viewMode) {
@@ -53,6 +62,7 @@ const renderManager = (
proxyProfiles: profiles,
hosts: [],
groupConfigs: [],
identities: [proxyIdentity],
onUpdateProxyProfiles: () => {},
onUpdateHosts: () => {},
onUpdateGroupConfigs: () => {},
@@ -108,3 +118,108 @@ test("ProxyProfilesManager hides ProxyCommand contents in profile summaries", ()
assert.doesNotMatch(markup, /cloudflared access ssh/);
assert.doesNotMatch(markup, /secret/);
});
test("prepareProxyProfileForSave saves identity auth without stale manual credentials", () => {
const result = prepareProxyProfileForSave(
{
...proxyProfile,
label: " Office Proxy ",
config: {
type: "http",
host: " proxy.example.com ",
port: 3128,
identityId: proxyIdentity.id,
username: "stale-user",
password: "stale-secret",
},
},
[proxyIdentity],
2,
);
assert.deepEqual(result.saved?.config, {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: proxyIdentity.id,
});
assert.equal(result.saved?.label, "Office Proxy");
assert.equal(result.saved?.updatedAt, 2);
});
test("prepareProxyProfileForSave rejects missing and incomplete proxy identities", () => {
assert.equal(
prepareProxyProfileForSave(
{
...proxyProfile,
config: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: "missing-identity",
},
},
[proxyIdentity],
).error,
"missingIdentity",
);
assert.equal(
prepareProxyProfileForSave(
{
...proxyProfile,
config: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: proxyIdentity.id,
},
},
[{ ...proxyIdentity, password: undefined }],
).error,
"incompleteIdentity",
);
});
test("prepareProxyProfileForSave rejects unreadable proxy identity passwords", () => {
assert.equal(
prepareProxyProfileForSave(
{
...proxyProfile,
config: {
type: "http",
host: "proxy.example.com",
port: 3128,
identityId: proxyIdentity.id,
},
},
[{ ...proxyIdentity, password: "enc:v1:djEwAAAA" }],
).error,
"unreadableIdentity",
);
});
test("prepareProxyProfileForSave strips stale ProxyCommand data from HTTP/SOCKS profiles", () => {
const result = prepareProxyProfileForSave(
{
...proxyProfile,
config: {
type: "socks5",
host: "proxy.example.com",
port: 1080,
command: "cloudflared access ssh --hostname %h --token secret",
username: "proxy-user",
password: "proxy-secret",
},
},
[],
);
assert.deepEqual(result.saved?.config, {
type: "socks5",
host: "proxy.example.com",
port: 1080,
username: "proxy-user",
password: "proxy-secret",
});
});
+164 -51
View File
@@ -18,16 +18,21 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import {
formatProxyConfigEndpoint,
hasIncompleteProxyIdentity,
hasMissingProxyIdentity,
hasUnreadableProxyCredential,
isProxyCommandConfig,
isValidProxyPort,
normalizeManualProxyConfig,
removeProxyProfileReferences,
updateProxyConfigField,
} from "../domain/proxyProfiles";
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
import {
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
} from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import type { GroupConfig, Host, ProxyConfig, ProxyProfile } from "../types";
import type { GroupConfig, Host, Identity, ProxyConfig, ProxyProfile } from "../types";
import {
AsidePanel,
AsidePanelContent,
@@ -74,11 +79,57 @@ interface ProxyProfilesManagerProps {
proxyProfiles: ProxyProfile[];
hosts: Host[];
groupConfigs: GroupConfig[];
identities: Identity[];
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
onUpdateHosts: (hosts: Host[]) => void;
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
}
export type ProxyProfileSaveError =
| "required"
| "port"
| "missingIdentity"
| "incompleteIdentity"
| "unreadableIdentity";
export const prepareProxyProfileForSave = (
draft: ProxyProfile,
identities: Identity[],
updatedAt = Date.now(),
): { saved?: ProxyProfile; error?: ProxyProfileSaveError } => {
const label = draft.label.trim();
const host = draft.config.host.trim();
const command = draft.config.command?.trim() || "";
const isCommand = isProxyCommandConfig(draft.config);
if (!label || (isCommand ? !command : (!host || !draft.config.port))) {
return { error: "required" };
}
if (!isCommand && !isValidProxyPort(draft.config.port)) {
return { error: "port" };
}
if (!isCommand && hasMissingProxyIdentity(draft.config, identities)) {
return { error: "missingIdentity" };
}
if (!isCommand && hasIncompleteProxyIdentity(draft.config, identities)) {
return { error: "incompleteIdentity" };
}
if (!isCommand && hasUnreadableProxyCredential(draft.config, identities)) {
return { error: "unreadableIdentity" };
}
const normalizedConfig = normalizeManualProxyConfig(draft.config);
if (!normalizedConfig) return { error: "required" };
return {
saved: {
...draft,
label,
config: normalizedConfig,
updatedAt,
},
};
};
const createDraftProfile = (): ProxyProfile => {
const now = Date.now();
return {
@@ -217,6 +268,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
proxyProfiles,
hosts,
groupConfigs,
identities,
onUpdateProxyProfiles,
onUpdateHosts,
onUpdateGroupConfigs,
@@ -232,6 +284,18 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
const [draft, setDraft] = useState<ProxyProfile | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const manualCredentialsValue = "__manual_credentials__";
const missingIdentityValue = "__missing_identity__";
const selectedDraftIdentity = useMemo(
() => identities.find((identity) => identity.id === draft?.config.identityId),
[draft?.config.identityId, identities],
);
const hasMissingDraftIdentity = hasMissingProxyIdentity(draft?.config, identities);
const hasIncompleteDraftIdentity = hasIncompleteProxyIdentity(draft?.config, identities);
const hasUnreadableDraftIdentity = hasUnreadableProxyCredential(draft?.config, identities);
const selectedDraftIdentityValue =
selectedDraftIdentity?.id ||
(hasMissingDraftIdentity ? missingIdentityValue : manualCredentialsValue);
const usageByProfileId = useMemo(() => {
const map = new Map<string, number>();
@@ -263,15 +327,12 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
},
});
const updateDraftConfig = (field: keyof ProxyConfig, value: string | number) => {
const updateDraftConfig = (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setDraft((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[field]: value,
},
config: updateProxyConfigField(prev.config, field, value),
};
});
};
@@ -304,38 +365,21 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
const saveDraft = () => {
if (!draft) return;
const label = draft.label.trim();
const host = draft.config.host.trim();
const command = draft.config.command?.trim() || "";
const isCommand = isProxyCommandConfig(draft.config);
if (!label || (isCommand ? !command : (!host || !draft.config.port))) {
toast.error(t("proxyProfiles.error.required"));
const result = prepareProxyProfileForSave(draft, identities);
if (!result.saved) {
const messageKey = result.error === "port"
? "proxyProfiles.error.port"
: result.error === "missingIdentity"
? "hostDetails.proxyPanel.missingIdentity"
: result.error === "incompleteIdentity"
? "hostDetails.proxyPanel.incompleteIdentity"
: result.error === "unreadableIdentity"
? "hostDetails.proxyPanel.unreadableIdentity"
: "proxyProfiles.error.required";
toast.error(t(messageKey));
return;
}
if (!isCommand && !isValidProxyPort(draft.config.port)) {
toast.error(t("proxyProfiles.error.port"));
return;
}
const saved: ProxyProfile = {
...draft,
label,
config: isCommand
? {
type: "command",
host: "",
port: 0,
command,
}
: {
...draft.config,
host,
port: Number(draft.config.port),
username: draft.config.username?.trim() || undefined,
password: draft.config.password || undefined,
},
updatedAt: Date.now(),
};
const saved = result.saved;
onUpdateProxyProfiles(
proxyProfiles.some((profile) => profile.id === saved.id)
@@ -565,21 +609,90 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
</div>
<Badge variant="secondary" className="text-xs">{t("common.optional")}</Badge>
</div>
<Input
aria-label={t("hostDetails.proxyPanel.usernamePlaceholder")}
value={draft.config.username || ""}
onChange={(event) => updateDraftConfig("username", event.target.value)}
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
className="h-10"
/>
<Input
aria-label={t("hostDetails.proxyPanel.passwordPlaceholder")}
type="password"
value={draft.config.password || ""}
onChange={(event) => updateDraftConfig("password", event.target.value)}
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
className="h-10"
/>
{identities.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t("hostDetails.proxyPanel.keychainIdentity")}
</p>
<Select
value={selectedDraftIdentityValue}
onValueChange={(value) => {
if (value === missingIdentityValue) return;
updateDraftConfig(
"identityId",
value === manualCredentialsValue ? undefined : value,
);
}}
>
<SelectTrigger
aria-label={t("hostDetails.proxyPanel.keychainIdentity")}
className="h-10"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={manualCredentialsValue}>
{t("hostDetails.proxyPanel.manualCredentials")}
</SelectItem>
{hasMissingDraftIdentity && (
<SelectItem value={missingIdentityValue}>
{t("hostDetails.proxyPanel.missingIdentity")}
</SelectItem>
)}
{identities.map((identity) => (
<SelectItem key={identity.id} value={identity.id}>
{identity.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{hasMissingDraftIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t("hostDetails.proxyPanel.missingIdentity")}
</div>
)}
{hasIncompleteDraftIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t("hostDetails.proxyPanel.incompleteIdentity")}
</div>
)}
{hasUnreadableDraftIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t("hostDetails.proxyPanel.unreadableIdentity")}
</div>
)}
{selectedDraftIdentity ? (
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary" className="text-xs shrink-0">
{t("hostDetails.proxyPanel.keychainIdentity")}
</Badge>
<span className="truncate">
{selectedDraftIdentity.label} - {selectedDraftIdentity.username}
</span>
</div>
</div>
) : (
<>
<Input
aria-label={t("hostDetails.proxyPanel.usernamePlaceholder")}
value={draft.config.username || ""}
onChange={(event) => updateDraftConfig("username", event.target.value)}
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
className="h-10"
/>
<Input
aria-label={t("hostDetails.proxyPanel.passwordPlaceholder")}
type="password"
value={draft.config.password || ""}
onChange={(event) => updateDraftConfig("password", event.target.value)}
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
className="h-10"
/>
</>
)}
</Card>}
</AsidePanelContent>
<AsidePanelFooter>
+23 -1
View File
@@ -25,6 +25,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 { SnippetScriptEditor } from './snippets/SnippetScriptEditor';
export interface QuickAddSnippetDialogProps {
@@ -53,6 +54,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
const [command, setCommand] = useState('');
const [packagePath, setPackagePath] = useState('');
const [noAutoRun, setNoAutoRun] = useState(false);
const [multiLineRunMode, setMultiLineRunMode] = useState<Snippet["multiLineRunMode"]>(undefined);
const [editing, setEditing] = useState<Snippet | null>(null);
const labelInputRef = useRef<HTMLInputElement>(null);
@@ -66,6 +68,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
setCommand(getQuickAddSnippetInitialCommand(event));
setPackagePath('');
setNoAutoRun(false);
setMultiLineRunMode(undefined);
setOpen(true);
};
window.addEventListener('netcatty:snippets:add', handler);
@@ -84,6 +87,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
setCommand(snippet.command ?? '');
setPackagePath(snippet.package ?? '');
setNoAutoRun(snippet.noAutoRun ?? false);
setMultiLineRunMode(snippet.multiLineRunMode);
setOpen(true);
};
window.addEventListener('netcatty:snippets:edit', handler);
@@ -131,6 +135,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
command,
package: trimmedPackage || '',
noAutoRun: noAutoRun || undefined,
multiLineRunMode,
});
} else {
onCreateSnippet({
@@ -141,10 +146,11 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
package: trimmedPackage || '',
targets: [],
noAutoRun: noAutoRun || undefined,
multiLineRunMode,
});
}
setOpen(false);
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command, noAutoRun]);
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command, noAutoRun, multiLineRunMode]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -220,6 +226,22 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
/>
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
</label>
<div className="space-y-2">
<Label className="text-xs">{t('snippets.field.multiLineRunMode')}</Label>
<Select
value={multiLineRunMode ?? 'paste'}
onValueChange={(value) => setMultiLineRunMode(value === 'lineDelay' ? 'lineDelay' : undefined)}
>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="paste">{t('snippets.field.multiLineRunMode.paste')}</SelectItem>
<SelectItem value="lineDelay">{t('snippets.field.multiLineRunMode.lineDelay')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="shrink-0">
+18
View File
@@ -120,6 +120,10 @@ type TerminalTabSettingsProps = Pick<
| 'setTerminalFontSize'
| 'terminalSettings'
| 'updateTerminalSetting'
| 'terminalSidePanelAutoOpen'
| 'setTerminalSidePanelAutoOpen'
| 'terminalSidePanelAutoOpenTab'
| 'setTerminalSidePanelAutoOpenTab'
| 'workspaceFocusStyle'
| 'setWorkspaceFocusStyle'
>;
@@ -142,6 +146,10 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
setTerminalFontSize,
terminalSettings,
updateTerminalSetting,
terminalSidePanelAutoOpen,
setTerminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
setTerminalSidePanelAutoOpenTab,
workspaceFocusStyle,
setWorkspaceFocusStyle,
}) {
@@ -166,6 +174,10 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
setTerminalFontSize={setTerminalFontSize}
terminalSettings={terminalSettings}
updateTerminalSetting={updateTerminalSetting}
terminalSidePanelAutoOpen={terminalSidePanelAutoOpen}
setTerminalSidePanelAutoOpen={setTerminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={terminalSidePanelAutoOpenTab}
setTerminalSidePanelAutoOpenTab={setTerminalSidePanelAutoOpenTab}
availableFonts={availableFonts}
workspaceFocusStyle={workspaceFocusStyle}
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
@@ -475,6 +487,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
terminalSidePanelAutoOpen={settings.terminalSidePanelAutoOpen}
setTerminalSidePanelAutoOpen={settings.setTerminalSidePanelAutoOpen}
terminalSidePanelAutoOpenTab={settings.terminalSidePanelAutoOpenTab}
setTerminalSidePanelAutoOpenTab={settings.setTerminalSidePanelAutoOpenTab}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
/>
@@ -532,6 +548,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
sshDeepLinkEnabled={settings.sshDeepLinkEnabled}
setSshDeepLinkEnabled={settings.setSshDeepLinkEnabled}
jmsDeepLinkEnabled={settings.jmsDeepLinkEnabled}
setJmsDeepLinkEnabled={settings.setJmsDeepLinkEnabled}
restorePreviousSession={settings.restorePreviousSession}
setRestorePreviousSession={settings.setRestorePreviousSession}
restoreTerminalCwd={settings.restoreTerminalCwd}
+63 -19
View File
@@ -43,6 +43,7 @@ import { KeyBinding, HotkeyScheme } from "../domain/models";
import {
mergeLatestFollowTerminalCwdHostSetting,
resolveHostFollowTerminalCwd,
shouldApplyFollowTerminalCwdSyncResult,
shouldClearBlockedFollowOnReach,
shouldFollowTerminalCwdNavigate,
type SftpFollowTerminalCwdBlock,
@@ -778,25 +779,32 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
const followSyncGenerationRef = useRef(0);
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
const activeTerminalCwdRef = useRef(activeTerminalCwd);
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionIdRef = useRef(connectionId);
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
activeTerminalCwdRef.current = activeTerminalCwd;
connectionIdRef.current = connectionId;
const invalidateInFlightFollowSync = useCallback(() => {
followSyncGenerationRef.current += 1;
blockedFollowRef.current = null;
handledFollowRef.current = null;
}, []);
useEffect(() => {
blockedFollowRef.current = null;
invalidateInFlightFollowSync();
}, [
activeTerminalCwd,
followTerminalCwdHost?.id,
connectionId,
invalidateInFlightFollowSync,
]);
useEffect(() => {
@@ -805,15 +813,17 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}, [effectiveFollowTerminalCwd, invalidateInFlightFollowSync]);
useEffect(() => {
const blockedFollow = blockedFollowRef.current;
if (
shouldClearBlockedFollowOnReach(
blockedFollowRef.current,
blockedFollow,
connectionId,
connectionPath,
sftp.leftPane.loading,
)
) {
blockedFollowRef.current = null;
handledFollowRef.current = blockedFollow;
}
}, [connectionId, connectionPath, sftp.leftPane.loading]);
@@ -824,6 +834,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const navigateResult = await sftpRef.current.navigateTo("left", cwd);
if (navigateResult === "reached") {
blockedFollowRef.current = null;
const connection = sftpRef.current.leftPane.connection;
if (connection?.id) {
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
}
}
}, [onGetTerminalCwd, sftpRef]);
@@ -834,16 +848,18 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const syncGeneration = followSyncGenerationRef.current;
const usesLiveTerminalCwd = Boolean(activeTerminalCwd);
let terminalCwd = activeTerminalCwd;
if (!terminalCwd) {
terminalCwd = await onGetTerminalCwd({ preferFreshBackend: true });
}
if (!terminalCwd) return;
if (
syncGeneration !== followSyncGenerationRef.current
|| !effectiveFollowTerminalCwdRef.current
|| !canFollowTerminalCwdRef.current
) {
if (!shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
})) {
return;
}
@@ -857,16 +873,43 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
hasActiveWork,
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
blockedFollow: blockedFollowRef.current,
handledFollow: handledFollowRef.current,
})) {
if (
connection?.id
&& !connection.isLocal
&& connection.status === "connected"
&& connection.currentPath === terminalCwd
) {
handledFollowRef.current = { connectionId: connection.id, terminalCwd };
}
return;
}
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd);
if (
syncGeneration !== followSyncGenerationRef.current
|| !effectiveFollowTerminalCwdRef.current
|| !canFollowTerminalCwdRef.current
) {
const expectedConnectionId = connection?.id ?? null;
const shouldApplyCurrentFollowSync = () => (
shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
expectedConnectionId,
liveConnectionId: connectionIdRef.current,
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
expectedTerminalCwd: terminalCwd,
liveTerminalCwd: activeTerminalCwdRef.current,
requireLiveTerminalCwd: usesLiveTerminalCwd,
})
);
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
shouldApply: shouldApplyCurrentFollowSync,
});
if (!shouldApplyFollowTerminalCwdSyncResult({
syncGeneration,
currentGeneration: followSyncGenerationRef.current,
followEnabled: effectiveFollowTerminalCwdRef.current,
canFollow: canFollowTerminalCwdRef.current,
})) {
return;
}
@@ -877,8 +920,11 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
if (navigateResult === "failed" && currentConnection.id) {
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
} else if (navigateResult === "superseded" && currentConnection.id) {
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
} else if (navigateResult === "reached") {
blockedFollowRef.current = null;
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
}
}, [
activeTerminalCwd,
@@ -892,9 +938,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const handleToggleFollowTerminalCwd = useCallback(() => {
const nextEnabled = !effectiveFollowTerminalCwd;
if (!nextEnabled) {
invalidateInFlightFollowSync();
}
invalidateInFlightFollowSync();
if (followTerminalCwdHost?.id) {
setPendingFollowOverride({ hostId: followTerminalCwdHost.id, value: nextEnabled });
}
@@ -910,7 +954,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
effectiveFollowTerminalCwd,
hasActiveWork,
isVisible,
sftp.leftPane.connection?.currentPath,
connectionId,
sftp.leftPane.connection?.status,
sftp.leftPane.connection?.isLocal,
syncFollowToTerminalCwd,
+65 -4
View File
@@ -43,6 +43,7 @@ import {
vaultPrimaryIconClass,
vaultSnippetIconClass,
} from './vault/VaultEntityIcon';
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
import {
clearVaultDropIndicator as clearSnippetDropIndicator,
getVaultDropIntent as getPackageDropIntent,
@@ -498,6 +499,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const [draggingPackagePath, setDraggingPackagePath] = useState<string | null>(null);
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
const [deleteTarget, setDeleteTarget] = useState<{
type: 'snippet' | 'package';
id: string;
name: string;
} | null>(null);
const [isSnippetImportDialogOpen, setIsSnippetImportDialogOpen] = useState(false);
const [pendingImport, setPendingImport] = useState<PendingSnippetImport | null>(null);
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(listRef);
@@ -758,6 +764,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
targetsAllHosts: editingSnippet.targetsAllHosts || undefined,
shortkey: editingSnippet.shortkey,
noAutoRun: editingSnippet.noAutoRun,
multiLineRunMode: editingSnippet.multiLineRunMode,
order: editingSnippet.order,
kind: editingSnippet.kind,
language: editingSnippet.language,
@@ -1188,7 +1195,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
setIsPackageDialogOpen(false);
};
const deletePackage = (path: string) => {
const performDeletePackage = (path: string) => {
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
const updatedSnippets = snippets.map((s) => {
@@ -1208,6 +1215,44 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}
};
const requestDeletePackage = (path: string) => {
setDeleteTarget({
type: 'package',
id: path,
name: path,
});
};
const requestDeleteSnippet = (id: string) => {
const snippet = snippets.find((item) => item.id === id);
setDeleteTarget({
type: 'snippet',
id,
name: snippet?.label || t('snippets.panel.editTitle'),
});
};
const confirmDeleteTarget = () => {
if (!deleteTarget) return;
if (deleteTarget.type === 'package') {
performDeletePackage(deleteTarget.id);
} else {
onDelete(deleteTarget.id);
setSelectedSnippetIds((prev) => {
if (!prev.has(deleteTarget.id)) return prev;
const next = new Set(prev);
next.delete(deleteTarget.id);
return next;
});
if (editingSnippet.id === deleteTarget.id) {
handleClosePanel();
}
}
setDeleteTarget(null);
};
const movePackage = (source: string, target: string | null) => {
const name = source.split('/').pop() || '';
const isAbsolute = source.startsWith('/');
@@ -1580,7 +1625,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
t={t}
handleClosePanel={handleClosePanel}
editingSnippet={editingSnippet}
onDelete={onDelete}
onDelete={requestDeleteSnippet}
handleSave={handleSave}
handleSaveAndRun={handleSaveAndRun}
setEditingSnippet={setEditingSnippet}
@@ -1868,7 +1913,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.package')}
</ContextMenuItem>
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => requestDeletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
@@ -2006,7 +2051,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<ContextMenuItem onClick={() => exportSingleSnippet(snippet)}>
<Download className="mr-2 h-4 w-4" /> {t('snippets.export.snippet')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onDelete(snippet.id)}>
<ContextMenuItem className="text-destructive" onClick={() => requestDeleteSnippet(snippet.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
@@ -2070,6 +2115,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
}}
/>
<VaultDeleteConfirmDialog
open={Boolean(deleteTarget)}
title={t('vault.deleteConfirm.title', {
name: deleteTarget?.name ?? '',
})}
description={
deleteTarget?.type === 'package'
? t('vault.deleteConfirm.packageDesc')
: t('vault.deleteConfirm.desc')
}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={confirmDeleteTarget}
/>
{renderRightPanel()}
</div>
</TooltipProvider>
+23 -1
View File
@@ -14,6 +14,7 @@ import { ScriptEditorModal } from './scripts/ScriptEditorModal';
import { isScriptSnippet } from '../domain/snippetScript.ts';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Combobox } from './ui/combobox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { HistoryItem } from './SnippetsHistoryItem';
import type { Snippet } from '@/domain/models';
import { getRunnableHostsForSnippet } from '@/domain/snippetTargets.ts';
@@ -155,7 +156,6 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
const id = editingSnippet.id;
if (!id) return;
onDelete(id);
handleClosePanel();
}}
aria-label={t('common.delete')}
>
@@ -286,6 +286,28 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
</label>
<Card className="p-3 space-y-2 bg-card border-border/80">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.multiLineRunMode')}</p>
<Select
value={editingSnippet.multiLineRunMode ?? 'paste'}
onValueChange={(value) => setEditingSnippet({
...editingSnippet,
multiLineRunMode: value === 'lineDelay' ? 'lineDelay' : undefined,
})}
>
<SelectTrigger className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="paste">{t('snippets.field.multiLineRunMode.paste')}</SelectItem>
<SelectItem value="lineDelay">{t('snippets.field.multiLineRunMode.lineDelay')}</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t('snippets.field.multiLineRunModeHint')}
</p>
</Card>
{/* Shortkey */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
+215 -42
View File
@@ -45,6 +45,12 @@ import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import {
TERMINAL_AUTO_RECONNECT_DELAY_MS,
canAttemptTerminalAutoReconnect,
shouldAutoReconnectAfterExit,
shouldContinueAutoReconnectAfterFailure,
} from "../application/state/terminalAutoReconnect";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { readOptionalStoredStringValue, useStoredString } from "../application/state/useStoredString";
import { useSessionLogBackend } from "../application/state/useSessionLogBackend";
@@ -98,7 +104,11 @@ import { createConnectionLogBuffer } from "./terminal/connectionLogBuffer";
import { createProgrammaticCommandLogRewriter, type ProgrammaticCommandLogRewrite } from "./terminal/programmaticCommandLog";
import { getSessionLogInitialLine } from "./terminal/sessionLogInitialLine";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import {
createTerminalSessionStarters,
type PendingAuth,
type TerminalSessionDataMeta,
} from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { applyUserCursorPreference } from "./terminal/runtime/cursorPreference";
import { terminalAltKeyOptions } from "./terminal/runtime/altKeyOptions";
@@ -122,6 +132,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useTerminalDragDrop } from "./terminal/hooks/useTerminalDragDrop";
import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
@@ -207,6 +218,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
restoreTerminalCwd = false,
startupCommand,
noAutoRun,
multiLineRunMode,
pendingScriptId,
pendingScript,
reuseConnectionFromSessionId,
@@ -358,6 +370,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// chained xterm.write callbacks verify the token before proceeding so a
// cancelled retry can't fire a startNewSession after the fact.
const retryTokenRef = useRef<symbol | null>(null);
const autoReconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoReconnectLoopActiveRef = useRef(false);
const autoReconnectAttemptRef = useRef(0);
const startReconnectRef = useRef<((mode: "manual" | "auto") => void) | null>(null);
const wakeHibernatedRuntimeForReconnectRef = useRef<(() => Promise<boolean>) | null>(null);
const terminalDataCapturedRef = useRef(false);
const connectionLogBufferRef = useRef(createConnectionLogBuffer(MAX_CONNECTION_LOG_DATA_CHARS));
const terminalLogSanitizerRef = useRef(createReplaySafeTerminalLogSanitizer());
@@ -555,6 +572,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [status, setStatus] = useState<TerminalSession["status"]>(() => (
getInitialTerminalStatus()
));
const hasEverConnectedRef = useRef(status === "connected");
const [error, setError] = useState<string | null>(null);
const lastToastedErrorRef = useRef<string | null>(null);
const [showLogs, setShowLogs] = useState(false);
@@ -910,7 +928,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}).then((result) => {
handleOsc7SetupOpenChange(false);
if (result.success) {
toast.success(t("terminal.osc7Setup.configured"));
toast.success(result.sentToTerminal
? t("terminal.osc7Setup.sent")
: t("terminal.osc7Setup.configured"));
return;
}
toast.error(result.error || t("terminal.osc7Setup.failed"));
@@ -994,14 +1014,74 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const resolvedChainHosts =
chainHosts;
const updateStatus = (next: TerminalSession["status"]) => {
const clearAutoReconnect = useCallback((options?: { stopLoop?: boolean }) => {
if (autoReconnectTimerRef.current) {
clearTimeout(autoReconnectTimerRef.current);
autoReconnectTimerRef.current = null;
}
if (options?.stopLoop !== false) {
autoReconnectLoopActiveRef.current = false;
autoReconnectAttemptRef.current = 0;
}
}, []);
const updateStatus = useCallback((next: TerminalSession["status"]) => {
setStatus(next);
hasConnectedRef.current = next === "connected";
if (next === "connected") {
hasEverConnectedRef.current = true;
clearAutoReconnect();
}
onStatusChange?.(sessionId, next);
};
}, [clearAutoReconnect, onStatusChange, sessionId]);
const updateStatusRef = useRef(updateStatus);
updateStatusRef.current = updateStatus;
const scheduleAutoReconnect = useCallback((trigger?: { evt?: Parameters<typeof shouldAutoReconnectAfterExit>[0]["evt"] }) => {
const shouldSchedule = trigger?.evt
? shouldAutoReconnectAfterExit({
evt: trigger.evt,
host,
terminalSettings,
hasEverConnected: hasEverConnectedRef.current,
})
: shouldContinueAutoReconnectAfterFailure({
host,
terminalSettings,
loopActive: autoReconnectLoopActiveRef.current,
});
if (!shouldSchedule || !canAttemptTerminalAutoReconnect({
hasTerminalRuntime: Boolean(termRef.current),
isHibernated: hibernatedRef.current,
})) {
return false;
}
autoReconnectLoopActiveRef.current = true;
if (autoReconnectTimerRef.current) {
return true;
}
autoReconnectAttemptRef.current += 1;
const attempt = autoReconnectAttemptRef.current;
const seconds = Math.round(TERMINAL_AUTO_RECONNECT_DELAY_MS / 1000);
const scheduledMessage = t("terminal.progress.autoReconnectScheduled", { seconds, attempt });
setError(null);
setShowLogs(true);
setIsDisconnectedDialogDismissed(false);
setProgressLogs((prev) => [...prev, scheduledMessage]);
updateStatus("connecting");
autoReconnectTimerRef.current = setTimeout(() => {
autoReconnectTimerRef.current = null;
startReconnectRef.current?.("auto");
}, TERMINAL_AUTO_RECONNECT_DELAY_MS);
return true;
}, [host, t, terminalSettings, updateStatus]);
const prepareRestoredReconnect = useCallback(() => {
if (restoreState !== "restored-disconnected") {
suppressHostStartupCommandRef.current = false;
@@ -1087,6 +1167,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hasRuntimeRef.current = false;
};
const clearHibernateRuntimeState = useCallback(() => {
hibernatedRef.current = false;
softHiddenRef.current = false;
hibernateSnapshotRef.current = "";
hibernateViewportSnapshotRef.current = "";
hibernateScrollbackSnapshotRef.current = "";
hibernateContextSnapshotRef.current = "";
hibernateContextViewportSnapshotRef.current = "";
hibernateContextScrollbackSnapshotRef.current = "";
hibernatePendingBufferRef.current = "";
hibernateAlternateScreenRef.current = false;
terminalHiddenRendererStore.clearSoftHidden(sessionId);
}, [sessionId]);
const forceCloseHibernatedSession = useCallback(() => {
if (!terminalDataCapturedRef.current) {
const hibernatedData = hibernateSnapshotRef.current + hibernatePendingBufferRef.current;
@@ -1112,18 +1206,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
}
sessionRef.current = null;
hibernatedRef.current = false;
softHiddenRef.current = false;
hibernateSnapshotRef.current = "";
hibernateViewportSnapshotRef.current = "";
hibernateScrollbackSnapshotRef.current = "";
hibernateContextSnapshotRef.current = "";
hibernateContextViewportSnapshotRef.current = "";
hibernateContextScrollbackSnapshotRef.current = "";
hibernatePendingBufferRef.current = "";
hibernateAlternateScreenRef.current = false;
terminalHiddenRendererStore.clearSoftHidden(sessionId);
}, [handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
clearHibernateRuntimeState();
}, [clearHibernateRuntimeState, handleTerminalDataCaptureOnce, sessionId, terminalBackend]);
const beginHibernatedSessionListeners = useCallback((backendId: string) => {
disposeDataRef.current?.();
@@ -1157,8 +1241,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
exitMessage,
);
onSessionExit?.(sessionId, evt);
scheduleAutoReconnect({ evt });
});
}, [onSessionExit, sessionId, terminalBackend]);
}, [onSessionExit, scheduleAutoReconnect, sessionId, terminalBackend]);
const applyHibernateSnapshot = useCallback((
snapshot: {
@@ -1277,6 +1362,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = false;
clearAutoReconnect();
cleanupSession();
disposeRuntimeOnly();
};
@@ -1291,6 +1377,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
reuseConnectionFromSessionId,
startupCommand,
noAutoRun,
multiLineRunMode,
shellType,
suppressHostStartupCommandRef,
terminalSettings,
@@ -1365,21 +1452,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSessionExit: (closedSessionId, evt) => {
clearTerminalCwd();
onSessionExit?.(closedSessionId, evt);
scheduleAutoReconnect({ evt });
},
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onTerminalOutput: onTerminalOutput
? (chunk: string) => {
? (chunk: string, meta?: TerminalSessionDataMeta) => {
if (/password|passphrase|口令/i.test(chunk)) {
passwordPromptActiveRef.current = true;
}
appendOutputTriggerOutputRef.current(chunk);
appendOutputTriggerOutputRef.current(chunk, meta);
onTerminalOutput(sessionId, chunk);
}
: (chunk: string) => {
: (chunk: string, meta?: TerminalSessionDataMeta) => {
if (/password|passphrase|口令/i.test(chunk)) {
passwordPromptActiveRef.current = true;
}
appendOutputTriggerOutputRef.current(chunk);
appendOutputTriggerOutputRef.current(chunk, meta);
},
onTerminalLogData: captureTerminalLogData,
onProgrammaticCommandLogRewrite: queueProgrammaticCommandLogRewrite,
@@ -1394,12 +1482,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionStartersRef.current = sessionStarters;
useEffect(() => {
if (status === 'disconnected') {
if (status === 'disconnected' && !autoReconnectLoopActiveRef.current) {
connectScriptsConsumedRef.current = false;
connectScriptsCompletedIdsRef.current = new Set();
}
}, [status]);
useEffect(() => {
if (status !== "disconnected") return;
scheduleAutoReconnect();
}, [scheduleAutoReconnect, status]);
useEffect(() => {
if (!autoReconnectLoopActiveRef.current) return;
if (shouldContinueAutoReconnectAfterFailure({ host, terminalSettings, loopActive: true })) return;
const hadPendingReconnect = autoReconnectTimerRef.current !== null;
clearAutoReconnect();
if (hadPendingReconnect) {
updateStatus("disconnected");
}
}, [clearAutoReconnect, host, terminalSettings, updateStatus]);
useEffect(() => {
pendingScriptRunIdRef.current = null;
pendingScriptHandledRef.current = null;
@@ -1617,10 +1720,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [reuseConnectionFromSessionId, sessionId, terminalBackend]);
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
const safeFit = (options?: { force?: boolean; requireVisible?: boolean; immediate?: boolean; allowHidden?: boolean }) => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
if (options?.requireVisible && !isVisibleRef.current) return;
if (!isVisibleRef.current && !options?.allowHidden) {
lastFittedSizeRef.current = null;
return;
}
const container = containerRef.current;
if (!container) return;
@@ -1690,7 +1796,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (
XTERM_PERFORMANCE_CONFIG.resize.useRAF &&
typeof requestAnimationFrame === "function"
typeof requestAnimationFrame === "function" &&
!options?.immediate
) {
requestAnimationFrame(runFit);
} else {
@@ -1745,14 +1852,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const executeSnippetCommand = useCallback((
command: string,
noAutoRun?: boolean,
options?: { broadcast?: boolean },
options?: { broadcast?: boolean; multiLineRunMode?: Snippet["multiLineRunMode"] },
) => {
const term = termRef.current;
const id = sessionRef.current;
if (!term || !id) return;
let data = normalizeLineEndings(command);
const lineDelayMs = shouldDelayAutoRunSnippetInput(data, { noAutoRun })
const lineDelayMs = shouldDelayAutoRunSnippetInput(data, {
noAutoRun,
multiLineRunMode: options?.multiLineRunMode,
})
? AUTO_RUN_SNIPPET_LINE_DELAY_MS
: undefined;
const isMultiLine = data.includes('\n');
@@ -1799,7 +1909,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
executeSnippetCommand(command, snippet.noAutoRun);
executeSnippetCommand(command, snippet.noAutoRun, {
multiLineRunMode: snippet.multiLineRunMode,
});
}, [executeSnippetCommand, sessionId, t]);
const onSnippetShortkeyRef = useRef(executeSnippet);
@@ -1878,12 +1990,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
try {
const defaultPath = getRememberedYmodemSendDefaultPath();
const filePath = await selectFile(
t("terminal.ymodem.selectFile"),
undefined,
defaultPath,
[{ name: t("terminal.ymodem.allFiles"), extensions: ["*"] }],
);
if (!filePath) return;
rememberYmodemSendFilePath(filePath);
const fileName = filePath.split(/[\\/]/).pop() || filePath;
toast.info(t("terminal.ymodem.started", { fileName }));
@@ -1945,6 +2059,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
clearAutoReconnect();
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
setIsCancelling(true);
@@ -1964,9 +2079,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleDismissDisconnectedDialog = () => {
setIsDisconnectedDialogDismissed(true);
queueMicrotask(() => termRef.current?.focus());
};
const handleCloseDisconnectedSession = () => {
clearAutoReconnect();
retryTokenRef.current = null;
restoreCwdIntentRef.current = null;
onCloseSession?.(sessionId);
@@ -2008,9 +2125,30 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setPendingHostKeyRequestId(null);
};
const handleRetry = () => {
const startReconnect = (mode: "manual" | "auto" = "manual") => {
if (!termRef.current && mode === "auto" && hibernatedRef.current) {
const wakeForReconnect = wakeHibernatedRuntimeForReconnectRef.current;
if (!wakeForReconnect) {
updateStatus("disconnected");
return;
}
void wakeForReconnect().then((woke) => {
if (woke) {
startReconnectRef.current?.("auto");
return;
}
updateStatus("disconnected");
});
return;
}
if (!termRef.current) return;
prepareRestoredReconnect();
if (mode === "manual") {
clearAutoReconnect();
prepareRestoredReconnect();
} else {
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = true;
}
cleanupSession();
const term = termRef.current;
// Claim a fresh retry token. If the user cancels / closes / unmounts /
@@ -2024,12 +2162,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isBootActiveRef.current = true;
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
if (mode === "manual") {
hasRunStartupCommandRef.current = false;
}
setIsDisconnectedDialogDismissed(false);
setConnectionReuseFellBack(false);
updateStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
setProgressLogs((prev) => (
mode === "auto"
? [...prev, t("terminal.progress.autoReconnectAttempt", { attempt: autoReconnectAttemptRef.current })]
: ["Retrying secure channel..."]
));
setShowLogs(true);
const startNewSession = () => {
@@ -2079,6 +2223,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
);
});
};
startReconnectRef.current = startReconnect;
const handleRetry = () => {
startReconnect("manual");
};
const shouldShowConnectionDialog = shouldShowTerminalConnectionDialog({
status,
@@ -2285,12 +2434,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
recorder,
]);
const statusDotTone =
status === "connected"
? "bg-emerald-400"
: status === "connecting"
? "bg-amber-400"
: "bg-rose-500";
const terminalPreviewVars = useMemo(() => {
const { background, foreground, cursor } = effectiveTheme.colors;
return {
@@ -2448,6 +2591,36 @@ const TerminalComponent: React.FC<TerminalProps> = ({
});
}, [sessionId, terminalBackend, terminalRuntimeRefs, resizeSession, terminalSettings]);
wakeHibernatedRuntimeForReconnectRef.current = async () => {
if (!hibernatedRef.current) {
return Boolean(termRef.current);
}
const getPayload = (): TerminalHibernateWakePayload => ({
snapshot: hibernateSnapshotRef.current,
viewportSnapshot: hibernateViewportSnapshotRef.current || hibernateSnapshotRef.current,
scrollbackSnapshot: hibernateScrollbackSnapshotRef.current,
pendingBuffer: hibernatePendingBufferRef.current,
alternateScreen: hibernateAlternateScreenRef.current,
});
logger.info("[Terminal] Waking hibernated runtime for auto reconnect", {
sessionId,
snapshotChars: hibernateSnapshotRef.current.length,
viewportChars: hibernateViewportSnapshotRef.current.length,
scrollbackChars: hibernateScrollbackSnapshotRef.current.length,
pendingChars: hibernatePendingBufferRef.current.length,
});
const accepted = await Promise.resolve(wakeFromHibernateRuntime(getPayload, { sessionConnected: false }));
if (accepted === false || !termRef.current) {
return false;
}
clearHibernateRuntimeState();
return true;
};
useTerminalHibernateEffect({
sessionId,
isVisible,
@@ -2478,11 +2651,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime,
});
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, prepareRestoredReconnect, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
@@ -2490,7 +2663,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onStop={() => { void stopScriptRun(activeScriptRun.runId); }}
onDismiss={dismissScriptOverlay}
/>
) : null, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
) : null, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />
<ScriptSaveRecordingDialog
open={saveRecordingOpen}
code={recordedCode}
+20
View File
@@ -31,6 +31,8 @@ const baseProps = {
sftpShowHiddenFiles: false,
sftpUseCompressedUpload: false,
sftpAutoOpenSidebar: false,
terminalSidePanelAutoOpen: false,
terminalSidePanelAutoOpenTab: "scripts",
sftpFollowTerminalCwd: false,
setSftpFollowTerminalCwd: () => {},
editorWordWrap: false,
@@ -119,6 +121,24 @@ test("TerminalLayer re-renders when broadcast state changes", () => {
);
});
test("TerminalLayer re-renders when terminal side panel auto-open settings change", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, terminalSidePanelAutoOpen: true } as never,
),
false,
);
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, terminalSidePanelAutoOpenTab: "history" } as never,
),
false,
);
});
test("TerminalLayer re-renders when broadcast toggle handler changes", () => {
assert.equal(
terminalLayerAreEqual(
+78 -52
View File
@@ -65,6 +65,7 @@ import {
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { focusTerminalSessionInput } from './terminal/focusTerminalSession';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { resolveTerminalFontSizeUpdateTarget } from './terminalLayer/terminalFontSizeUpdate';
import {
AUTO_RUN_SNIPPET_LINE_DELAY_MS,
shouldDelayAutoRunSnippetInput,
@@ -81,6 +82,10 @@ import { resolveAiNoteArtifactPanelIntent } from './terminalLayer/aiNoteArtifact
import {
canUseDirectSessionWriteFallback,
} from './terminalLayer/terminalLayerSessionRouting';
import {
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
resolveTerminalSidePanelAutoOpen,
} from '../domain/terminalSidePanelAutoOpen';
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
import { resolvePreferredTerminalCwd, scheduleBackendCwdProbeAfterCommand } from './terminal/sftpCwd';
import { classifyDistroId, shouldProbeSessionCwd } from '../domain/host';
@@ -211,6 +216,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
terminalSidePanelAutoOpen = false,
terminalSidePanelAutoOpenTab = DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
editorWordWrap,
@@ -391,57 +398,78 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar);
sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar;
const terminalSidePanelAutoOpenRef = useRef(terminalSidePanelAutoOpen);
terminalSidePanelAutoOpenRef.current = terminalSidePanelAutoOpen;
const terminalSidePanelAutoOpenTabRef = useRef(terminalSidePanelAutoOpenTab);
terminalSidePanelAutoOpenTabRef.current = terminalSidePanelAutoOpenTab;
const sftpFollowTerminalCwdRef = useRef(sftpFollowTerminalCwd);
sftpFollowTerminalCwdRef.current = sftpFollowTerminalCwd;
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
onUpdateSessionStatus(sessionId, status);
// Auto-open SFTP sidebar when a remote host connects (if setting enabled)
if (status === 'connected' && sftpAutoOpenSidebarRef.current) {
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
// Only auto-open for SSH/Mosh (SFTP requires SSH); skip local/unset protocol
const proto = session.protocol;
if (proto !== 'ssh' && proto !== 'mosh') return;
if (status !== 'connected') return;
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
const proto = session.protocol;
const sftpAvailable = proto === 'ssh' || proto === 'mosh';
const tabId = session.workspaceId || sessionId;
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const sessionAutoOpenTarget = session.autoOpenSidePanel === 'sftp' && sftpAvailable ? 'sftp' : null;
const autoOpenTarget = sessionAutoOpenTarget ?? resolveTerminalSidePanelAutoOpen({
enabled: terminalSidePanelAutoOpenRef.current,
selectedTab: terminalSidePanelAutoOpenTabRef.current,
sftpAvailable,
});
const targetPanel = autoOpenTarget ?? (sftpAutoOpenSidebarRef.current && sftpAvailable ? 'sftp' : null);
if (!targetPanel) return;
lastSidePanelTabRef.current.set(tabId, targetPanel);
if (targetPanel === 'sftp') {
const host = hostsRef.current.find(h => h.id === session.hostId);
// Determine the tab ID (workspace or solo session)
const tabId = session.workspaceId || sessionId;
// Only open if the sidebar is not already open for this tab
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const hostWithOverrides: Host = host
? {
...host,
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
}
...host,
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
}
: {
// Quick Connect / temporary session — build minimal host from session data
id: session.hostId || sessionId,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
protocol: proto,
label: session.label || session.hostname,
} as Host;
id: session.hostId || sessionId,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
protocol: proto,
label: session.label || session.hostname,
} as Host;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, 'sftp');
return next;
});
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
return next;
});
} else if (targetPanel === 'ai') {
setAiMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (targetPanel === 'scripts') {
setScriptsMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (targetPanel === 'theme') {
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (targetPanel === 'system') {
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (targetPanel === 'notes') {
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
}
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, targetPanel);
return next;
});
}, [onUpdateSessionStatus]);
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
@@ -877,24 +905,17 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
// Workspace panes keep per-session font size so zooming one split does not
// change global defaults or sibling panes (even when they share a host).
if (session?.workspaceId) {
onUpdateSessionFontSize?.(sessionId, nextFontSize);
return;
}
const sessionHost = sessionHostsMapRef.current.get(sessionId);
if (!sessionHost) return;
const rawHost = hostMapRef.current.get(sessionHost.id);
const usesGlobalFontSize = sessionHost.protocol === 'local' || sessionHost.id?.startsWith('local-') || !rawHost;
if (usesGlobalFontSize) {
const rawHost = sessionHost ? hostMapRef.current.get(sessionHost.id) : null;
const target = resolveTerminalFontSizeUpdateTarget({ session, sessionHost, rawHost });
if (target.kind === 'none') return;
if (target.kind === 'session') {
onUpdateSessionFontSize?.(sessionId, nextFontSize);
} else if (target.kind === 'global') {
onUpdateTerminalFontSize?.(nextFontSize);
return;
} else {
onUpdateHost({ ...target.host, fontSize: nextFontSize, fontSizeOverride: true });
}
onUpdateHost({ ...rawHost, fontSize: nextFontSize, fontSizeOverride: true });
}, [onUpdateHost, onUpdateSessionFontSize, onUpdateTerminalFontSize]);
const validAIScopeTargetIds = useMemo(() => {
@@ -1315,13 +1336,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSnippetClickForFocusedSession = useCallback((
command: string,
noAutoRun?: boolean,
options?: { multiLineRunMode?: Snippet["multiLineRunMode"] },
) => {
const sessionId = activeWorkspaceRef.current?.focusedSessionId ?? activeSessionRef.current?.id;
if (!sessionId) return;
const executor = snippetExecutorsRef.current.get(sessionId);
if (executor) {
executor(command, noAutoRun);
executor(command, noAutoRun, options);
return;
}
@@ -1330,7 +1351,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
let data = normalizeLineEndings(command);
if (!noAutoRun) data = `${data}\r`;
const lineDelayMs = shouldDelayAutoRunSnippetInput(data, { noAutoRun })
const lineDelayMs = shouldDelayAutoRunSnippetInput(data, {
noAutoRun,
multiLineRunMode: options?.multiLineRunMode,
})
? AUTO_RUN_SNIPPET_LINE_DELAY_MS
: undefined;
terminalBackend.writeToSession(sessionId, data, {
@@ -1371,7 +1395,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
const command = await resolveSnippetCommand(snippet);
if (command === null) return;
handleSnippetClickForFocusedSession(command, snippet.noAutoRun);
handleSnippetClickForFocusedSession(command, snippet.noAutoRun, {
multiLineRunMode: snippet.multiLineRunMode,
});
}, [getActiveTerminalSessionId, handleSnippetClickForFocusedSession, hosts, t]);
const handleRunScriptFromPanel = useCallback(async (snippet: Snippet) => {
+109 -4
View File
@@ -30,10 +30,17 @@ const {
hasWorkspaceSessionDrag,
isPointInsideRect,
} = await import("../application/state/terminalDragData.ts");
const { activateLogViewTab } = await import("./top-tabs/TopTabItems.tsx");
const {
activateLogViewTab,
createSessionTopTabDoubleClickHandler,
formatSessionTopTabLabel,
formatSessionTopTabTooltip,
stopCloseButtonDoubleClickPropagation,
} = await import("./top-tabs/TopTabItems.tsx");
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
const topTabItemsSource = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
test("host tree tab gutter fills the remaining sidebar width", () => {
@@ -104,14 +111,112 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
});
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
assert.match(source, /resolveHostIconAppearance\(host\)/);
assert.match(topTabItemsSource, /resolveHostIconAppearance\(host\)/);
assert.ok(
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
topTabItemsSource.indexOf("resolveHostIconAppearance(host)") < topTabItemsSource.indexOf("getEffectiveHostDistro(host)"),
"custom host icon should be checked before distro fallback",
);
});
test("session top tabs copy the session on double click through the existing copy handler", () => {
const copiedSessionIds: string[] = [];
const handleDoubleClick = createSessionTopTabDoubleClickHandler(
(sessionId) => copiedSessionIds.push(sessionId),
"session-1",
);
handleDoubleClick({} as Parameters<typeof handleDoubleClick>[0]);
assert.deepEqual(copiedSessionIds, ["session-1"]);
assert.match(topTabItemsSource, /onDoubleClick=\{handleDoubleClick\}/);
});
test("session close button double click stays on the close button", () => {
let stopPropagationCount = 0;
stopCloseButtonDoubleClickPropagation({
stopPropagation: () => {
stopPropagationCount += 1;
},
});
assert.equal(stopPropagationCount, 1);
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
});
test("session top tab label only shows the host label for ssh sessions", () => {
assert.equal(
formatSessionTopTabLabel({
hostLabel: "prod-web",
hostname: "10.1.2.34",
protocol: "ssh",
}),
"prod-web",
);
});
test("session top tab label treats missing protocol as ssh", () => {
assert.equal(
formatSessionTopTabLabel({
hostLabel: "prod-web",
hostname: "10.1.2.34",
}),
"prod-web",
);
assert.equal(
formatSessionTopTabTooltip({
username: "root",
hostname: "10.1.2.34",
port: 22,
}),
"root@10.1.2.34:22",
);
});
test("session top tab label avoids duplicating the target address", () => {
assert.equal(
formatSessionTopTabLabel({
hostLabel: "10.1.2.34",
hostname: "10.1.2.34",
protocol: "ssh",
}),
"10.1.2.34",
);
});
test("session top tab tooltip includes username and port when present", () => {
assert.equal(
formatSessionTopTabTooltip({
username: "root",
hostname: "db.internal",
port: 2222,
protocol: "ssh",
}),
"root@db.internal:2222",
);
});
test("session top tab helpers ignore local and mosh sessions", () => {
assert.equal(
formatSessionTopTabLabel({
hostLabel: "Local shell",
hostname: "127.0.0.1",
protocol: "local",
}),
"Local shell",
);
assert.equal(
formatSessionTopTabTooltip({
username: "root",
hostname: "10.1.2.34",
port: 22,
protocol: "ssh",
moshEnabled: true,
}),
null,
);
});
test("workspace session drag data is recognized with a dedicated drag type", () => {
const data = new Map([
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"],
+23
View File
@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { invokeAsideActionMenuItemClick } from "./ui/aside-panel.tsx";
test("AsideActionMenuItem closes its menu before running the action", () => {
const events: string[] = [];
invokeAsideActionMenuItemClick(
() => events.push("close"),
() => events.push("action"),
);
assert.deepEqual(events, ["close", "action"]);
});
test("AsideActionMenuItem still closes when no action is provided", () => {
const events: string[] = [];
invokeAsideActionMenuItemClick(() => events.push("close"));
assert.deepEqual(events, ["close"]);
});
+46
View File
@@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";
import assert from "node:assert/strict";
import test from "node:test";
import {
applyComboboxWheelScroll,
comboboxWheelDeltaToPixels,
type ComboboxScrollableTarget,
} from "./ui/combobox.tsx";
const source = readFileSync(new URL("./ui/combobox.tsx", import.meta.url), "utf8");
test("combobox wheel deltas normalize to pixels", () => {
assert.equal(comboboxWheelDeltaToPixels(12, 0), 12);
assert.equal(comboboxWheelDeltaToPixels(3, 1), 48);
assert.equal(comboboxWheelDeltaToPixels(1, 2), 280);
});
test("combobox wheel input scrolls the overflowing option list", () => {
const target: ComboboxScrollableTarget = {
clientHeight: 100,
scrollHeight: 300,
scrollTop: 20,
};
assert.equal(applyComboboxWheelScroll(target, 5, 1), true);
assert.equal(target.scrollTop, 100);
});
test("combobox wheel input is ignored when the option list does not overflow", () => {
const target: ComboboxScrollableTarget = {
clientHeight: 300,
scrollHeight: 300,
scrollTop: 20,
};
assert.equal(applyComboboxWheelScroll(target, 5, 1), false);
assert.equal(target.scrollTop, 20);
});
test("combobox option popovers capture wheel events inside the popup list", () => {
assert.match(source, /onWheelCapture=\{handleWheelCapture\}/);
assert.match(source, /event\.preventDefault\(\)[\s\S]*event\.stopPropagation\(\)[\s\S]*event\.nativeEvent\.stopImmediatePropagation\(\)/);
assert.match(source, /app-no-drag p-0 border-border\/60/);
assert.doesNotMatch(source, /from "\.\/scroll-area"/);
});
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const root = fileURLToPath(new URL("..", import.meta.url));
function readProjectFile(path: string): string {
return readFileSync(join(root, path), "utf8");
}
function functionBody(source: string, functionName: string): string {
const start = source.indexOf(`const ${functionName} = useCallback`);
assert.notEqual(start, -1, `${functionName} should exist`);
const nextConst = source.indexOf("\n const ", start + 1);
const nextHook = source.indexOf("\n use", start + 1);
const candidates = [nextConst, nextHook].filter((index) => index > start);
const end = candidates.length > 0 ? Math.min(...candidates) : source.length;
return source.slice(start, end);
}
test("host and AI provider deletion use in-app confirmation dialogs", () => {
const appSource = readProjectFile("App.tsx");
const aiSettingsSource = readProjectFile("components/settings/tabs/SettingsAITab.tsx");
assert.match(appSource, /import \{ ConfirmDialog \} from '\.\/components\/ui\/confirm-dialog';/);
assert.match(appSource, /<ConfirmDialog[\s\S]*confirm\.deleteHost/);
assert.doesNotMatch(functionBody(appSource, "handleDeleteHost"), /window\.confirm|globalThis\.confirm|\bconfirm\(/);
assert.match(aiSettingsSource, /import \{ ConfirmDialog \} from "\.\.\/\.\.\/ui\/confirm-dialog";/);
assert.match(aiSettingsSource, /<ConfirmDialog[\s\S]*confirm\.removeProvider/);
assert.doesNotMatch(functionBody(aiSettingsSource, "handleRemoveProvider"), /window\.confirm|globalThis\.confirm|\bconfirm\(/);
});
+128 -21
View File
@@ -5,8 +5,16 @@
import { Globe, KeyRound, SquareTerminal, Trash2 } from 'lucide-react';
import React, { useCallback, useMemo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { formatProxyConfigEndpoint, formatProxyConfigType, isProxyCommandConfig, isValidProxyPort } from '../../domain/proxyProfiles';
import { ProxyConfig, ProxyProfile } from '../../types';
import {
formatProxyConfigEndpoint,
formatProxyConfigType,
hasIncompleteProxyIdentity,
hasMissingProxyIdentity,
hasUnreadableProxyCredential,
isProxyCommandConfig,
isValidProxyPort,
} from '../../domain/proxyProfiles';
import { Identity, ProxyConfig, ProxyProfile } from '../../types';
import { AsidePanel, AsidePanelContent, type AsidePanelLayout, type AsidePanelResizeProps } from '../ui/aside-panel';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
@@ -17,8 +25,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
export interface ProxyPanelProps {
proxyConfig?: ProxyConfig;
proxyProfiles?: ProxyProfile[];
identities?: Identity[];
selectedProxyProfileId?: string;
onUpdateProxy: (field: keyof ProxyConfig, value: string | number) => void;
onUpdateProxy: (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => void;
onSelectProxyProfile?: (profileId: string | undefined) => void;
onClearProxy: () => void;
onBack: () => void;
@@ -31,6 +40,7 @@ export type ProxyPanelPropsWithResize = ProxyPanelProps & AsidePanelResizeProps;
export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
proxyConfig,
proxyProfiles = [],
identities = [],
selectedProxyProfileId,
onUpdateProxy,
onSelectProxyProfile,
@@ -56,11 +66,24 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
const hasManualProxyValue = isCommandProxy ? hasManualProxyCommand : hasManualProxyHost;
const hasInvalidManualProxyPort = !isCommandProxy && hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
const canSave = isUsingProfile || (hasManualProxyValue && !hasInvalidManualProxyPort);
const effectiveProxyConfig = selectedProfile?.config ?? proxyConfig;
const hasMissingIdentity = hasMissingProxyIdentity(effectiveProxyConfig, identities);
const hasIncompleteIdentity = hasIncompleteProxyIdentity(effectiveProxyConfig, identities);
const hasUnreadableIdentity = hasUnreadableProxyCredential(effectiveProxyConfig, identities);
const hasInvalidIdentity = hasMissingIdentity || hasIncompleteIdentity || hasUnreadableIdentity;
const canSave = (isUsingProfile && !hasInvalidIdentity) ||
(!isUsingProfile && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
const manualCredentialsValue = '__manual_credentials__';
const missingIdentityValue = '__missing_identity__';
const selectedIdentity = useMemo(
() => identities.find((identity) => identity.id === proxyConfig?.identityId),
[identities, proxyConfig?.identityId],
);
const selectedIdentityValue = selectedIdentity?.id || (hasMissingIdentity ? missingIdentityValue : manualCredentialsValue);
const handleBack = useCallback(() => {
if (hasInvalidManualProxyPort) return;
if (hasInvalidManualProxyPort || hasInvalidIdentity) return;
onBack();
}, [hasInvalidManualProxyPort, onBack]);
}, [hasInvalidManualProxyPort, hasInvalidIdentity, onBack]);
return (
<AsidePanel
@@ -122,6 +145,21 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</div>
</div>
)}
{selectedProfile && hasMissingIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.missingIdentity')}
</div>
)}
{selectedProfile && hasIncompleteIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.incompleteIdentity')}
</div>
)}
{selectedProfile && hasUnreadableIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.unreadableIdentity')}
</div>
)}
</Card>
)}
@@ -202,21 +240,90 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</div>
<Badge variant="secondary" className="text-xs">{t('common.optional')}</Badge>
</div>
<Input
aria-label={t('hostDetails.proxyPanel.usernamePlaceholder')}
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
value={proxyConfig?.username || ""}
onChange={(e) => onUpdateProxy('username', e.target.value)}
className="h-10"
/>
<Input
aria-label={t('hostDetails.proxyPanel.passwordPlaceholder')}
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
type="password"
value={proxyConfig?.password || ""}
onChange={(e) => onUpdateProxy('password', e.target.value)}
className="h-10"
/>
{identities.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t('hostDetails.proxyPanel.keychainIdentity')}
</p>
<Select
value={selectedIdentityValue}
onValueChange={(value) => {
if (value === missingIdentityValue) return;
onUpdateProxy(
'identityId',
value === manualCredentialsValue ? undefined : value,
);
}}
>
<SelectTrigger
aria-label={t('hostDetails.proxyPanel.keychainIdentity')}
className="h-10"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={manualCredentialsValue}>
{t('hostDetails.proxyPanel.manualCredentials')}
</SelectItem>
{hasMissingIdentity && (
<SelectItem value={missingIdentityValue}>
{t('hostDetails.proxyPanel.missingIdentity')}
</SelectItem>
)}
{identities.map((identity) => (
<SelectItem key={identity.id} value={identity.id}>
{identity.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{hasMissingIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.missingIdentity')}
</div>
)}
{hasIncompleteIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.incompleteIdentity')}
</div>
)}
{hasUnreadableIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.unreadableIdentity')}
</div>
)}
{selectedIdentity ? (
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary" className="text-xs shrink-0">
{t('hostDetails.proxyPanel.keychainIdentity')}
</Badge>
<span className="truncate">
{selectedIdentity.label} - {selectedIdentity.username}
</span>
</div>
</div>
) : (
<>
<Input
aria-label={t('hostDetails.proxyPanel.usernamePlaceholder')}
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
value={proxyConfig?.username || ""}
onChange={(e) => onUpdateProxy('username', e.target.value)}
className="h-10"
/>
<Input
aria-label={t('hostDetails.proxyPanel.passwordPlaceholder')}
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
type="password"
value={proxyConfig?.password || ""}
onChange={(e) => onUpdateProxy('password', e.target.value)}
className="h-10"
/>
</>
)}
</Card>}
</>
)}
-23
View File
@@ -46,29 +46,6 @@ const HOST_ICON_COMPONENTS = {
"server-cog": ServerCog,
} as const satisfies Record<HostIconId, React.ComponentType<{ className?: string; size?: number }>>;
export const HOST_ICON_LABEL_KEYS: Record<HostIconId, string> = {
server: "hostDetails.icon.option.server",
terminal: "hostDetails.icon.option.terminal",
database: "hostDetails.icon.option.database",
cloud: "hostDetails.icon.option.cloud",
router: "hostDetails.icon.option.router",
shield: "hostDetails.icon.option.shield",
code: "hostDetails.icon.option.code",
box: "hostDetails.icon.option.box",
globe: "hostDetails.icon.option.globe",
cpu: "hostDetails.icon.option.cpu",
"hard-drive": "hostDetails.icon.option.hard-drive",
network: "hostDetails.icon.option.network",
wifi: "hostDetails.icon.option.wifi",
lock: "hostDetails.icon.option.lock",
key: "hostDetails.icon.option.key",
monitor: "hostDetails.icon.option.monitor",
container: "hostDetails.icon.option.container",
activity: "hostDetails.icon.option.activity",
zap: "hostDetails.icon.option.zap",
"server-cog": "hostDetails.icon.option.server-cog",
};
export const renderHostIconGlyph = (iconId: HostIconId, className?: string): React.ReactNode => {
const Icon = HOST_ICON_COMPONENTS[iconId] || Server;
return <Icon className={className} />;
-25
View File
@@ -1,25 +0,0 @@
import React from "react";
import { LazyMessageResponse } from "../ai-elements/LazyMessageResponse";
export function MarkdownPreview({
content,
emptyText,
}: {
content: string;
emptyText: string;
}) {
const trimmed = content.trim();
if (!trimmed) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{emptyText}
</div>
);
}
return (
<LazyMessageResponse className="text-sm leading-relaxed text-foreground/90">
{trimmed}
</LazyMessageResponse>
);
}
+74 -19
View File
@@ -65,6 +65,7 @@ import {
VaultTreeInlineRenameInput,
VaultTreeItemRow,
} from "../vault/VaultTreeRow";
import { VaultDeleteConfirmDialog } from "../vault/VaultDeleteConfirmDialog";
import {
clearVaultDropIndicator,
getVaultDropIntent,
@@ -126,7 +127,7 @@ export interface NotesManagerProps {
}
type HoverActionMenuProps = {
children: React.ReactNode;
children: React.ReactNode | ((closeMenu: () => void) => React.ReactNode);
className?: string;
};
@@ -148,6 +149,11 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({ children, className }
useEffect(() => () => cancelClose(), []);
const closeMenu = () => {
cancelClose();
setOpen(false);
};
return (
<Dropdown open={open} onOpenChange={setOpen}>
<DropdownTrigger asChild toggleOnClick={false}>
@@ -179,7 +185,7 @@ const HoverActionMenu: React.FC<HoverActionMenuProps> = ({ children, className }
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
>
{children}
{typeof children === "function" ? children(closeMenu) : children}
</DropdownContent>
</Dropdown>
);
@@ -362,6 +368,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
const [isTreeResizing, setIsTreeResizing] = useState(false);
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null);
const [draggingGroupPath, setDraggingGroupPath] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{
type: "note" | "group";
id: string;
name: string;
} | null>(null);
const [treeWidth, setTreeWidth, persistTreeWidth] = useStoredNumber(
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
NOTES_TREE_DEFAULT_WIDTH,
@@ -710,7 +721,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
setOverlayNoteId(nextSelection.overlayNoteId);
};
const deleteNoteById = (noteId: string) => {
const performDeleteNoteById = (noteId: string) => {
const next = sortedNotes.filter((note) => note.id !== noteId);
commitNotes(next);
if (selectedNoteId === noteId) {
@@ -723,6 +734,15 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
if (overlayNoteId === noteId) setOverlayNoteId(null);
};
const requestDeleteNoteById = (noteId: string) => {
const note = sortedNotes.find((item) => item.id === noteId);
setDeleteTarget({
type: "note",
id: noteId,
name: note?.title || t("notes.title.placeholder"),
});
};
const startCreateGroup = () => {
const targetGroup = getNoteActionTargetGroup(selectedNote, selectedGroup);
setCreatingGroupParent(targetGroup);
@@ -771,13 +791,31 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
}
};
const deleteGroup = (group: string) => {
const performDeleteGroup = (group: string) => {
onUpdateNoteGroups(groups.filter((item) => !isNoteGroupInside(item, group)));
commitNotes(sortedNotes.map((note) => isNoteGroupInside(note.group, group) ? { ...note, group: undefined } : note));
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) setSelectedGroup(null);
setEditingGroupPath(null);
};
const requestDeleteGroup = (group: string) => {
setDeleteTarget({
type: "group",
id: group,
name: group,
});
};
const confirmDeleteTarget = () => {
if (!deleteTarget) return;
if (deleteTarget.type === "note") {
performDeleteNoteById(deleteTarget.id);
} else {
performDeleteGroup(deleteTarget.id);
}
setDeleteTarget(null);
};
const resetTreeDragState = () => {
setDraggingNoteId(null);
setDraggingGroupPath(null);
@@ -904,7 +942,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
resetTreeDragState();
};
const renderNoteActions = (note: VaultNote, mode: "dropdown" | "context") => {
const renderNoteActions = (note: VaultNote, mode: "dropdown" | "context", closeMenu?: () => void) => {
const actions = [
{
label: t("common.rename"),
@@ -920,7 +958,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
},
{
label: t("action.delete"),
action: () => deleteNoteById(note.id),
action: () => requestDeleteNoteById(note.id),
destructive: true,
},
];
@@ -946,6 +984,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
className={cn(menuItemClass, action.destructive && "text-destructive hover:bg-destructive/10")}
onClick={(event) => {
event.stopPropagation();
closeMenu?.();
action.action();
}}
>
@@ -954,7 +993,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
));
};
const renderGroupActions = (groupPath: string, mode: "dropdown" | "context") => {
const renderGroupActions = (groupPath: string, mode: "dropdown" | "context", closeMenu?: () => void) => {
const actions = [
{
label: t("notes.action.newNote"),
@@ -981,7 +1020,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
},
{
label: t("action.delete"),
action: () => deleteGroup(groupPath),
action: () => requestDeleteGroup(groupPath),
destructive: true,
},
];
@@ -1007,6 +1046,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
className={cn(menuItemClass, action.destructive && "text-destructive hover:bg-destructive/10")}
onClick={(event) => {
event.stopPropagation();
closeMenu?.();
action.action();
}}
>
@@ -1043,7 +1083,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
return (
<ContextMenu key={note.id}>
<ContextMenuTrigger asChild>
<VaultTreeItemRow
<VaultTreeItemRow
label={noteDisplayTitle(note.title)}
depth={depth}
selected={selectedNoteId === note.id}
@@ -1094,11 +1134,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
setSelectedGroup(nextSelection.selectedGroup);
setOverlayNoteId(nextSelection.overlayNoteId);
}}
actions={(
<HoverActionMenu>
{renderNoteActions(note, "dropdown")}
</HoverActionMenu>
)}
actions={(
<HoverActionMenu>
{(closeMenu) => renderNoteActions(note, "dropdown", closeMenu)}
</HoverActionMenu>
)}
/>
</ContextMenuTrigger>
<ContextMenuContent data-notes-context-menu="note">
@@ -1196,11 +1236,11 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
setOverlayNoteId(nextSelection.overlayNoteId);
if (hasChildren) toggleGroup(node.path);
}}
actions={(
<HoverActionMenu>
{renderGroupActions(node.path, "dropdown")}
</HoverActionMenu>
)}
actions={(
<HoverActionMenu>
{(closeMenu) => renderGroupActions(node.path, "dropdown", closeMenu)}
</HoverActionMenu>
)}
/>
</ContextMenuTrigger>
<ContextMenuContent data-notes-context-menu="group">
@@ -1601,6 +1641,21 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
</div>
</div>
)}
<VaultDeleteConfirmDialog
open={Boolean(deleteTarget)}
title={t("vault.deleteConfirm.title", {
name: deleteTarget?.name ?? "",
})}
description={
deleteTarget?.type === "group"
? t("vault.deleteConfirm.noteGroupDesc")
: t("vault.deleteConfirm.desc")
}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
onConfirm={confirmDeleteTarget}
/>
</div>
);
};
@@ -0,0 +1,529 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { ScriptDialogRequest } from "../../types/global/netcatty-bridge-script.d.ts";
import {
applyFormValue,
getDialogFieldDomId,
getInitialFormValues,
getVisibleDialogFormFields,
normalizeDialogFormSubmitValues,
ScriptDialogFormBody,
ScriptDialogFormFields,
validateDialogFormValues,
} from "./ScriptDialogHost.tsx";
const formRequest: ScriptDialogRequest = {
requestId: "dialog-1",
type: "form",
message: "Choose options",
form: {
title: "Deploy",
message: "Choose options",
fields: [
{
type: "select",
name: "env",
label: "Environment",
options: [
{ label: "Development", value: "dev" },
{ label: "Production", value: "prod", description: "Use carefully" },
],
defaultValue: "dev",
},
{
type: "checkbox",
name: "restart",
label: "Restart service",
defaultValue: true,
},
{
type: "radio",
name: "mode",
label: "Mode",
options: [
{ label: "Safe", value: "safe" },
{ label: "Fast", value: "fast" },
],
defaultValue: "safe",
},
{
type: "textarea",
name: "notes",
label: "Notes",
defaultValue: "initial note",
required: false,
},
{
type: "number",
name: "retries",
label: "Retries",
defaultValue: 3,
min: 0,
step: 1,
},
],
},
};
test("script dialog form derives initial values from fields", () => {
assert.deepEqual(getInitialFormValues(formRequest), {
env: "dev",
restart: true,
mode: "safe",
notes: "initial note",
retries: 3,
});
});
test("script dialog form value helper preserves previous values for submit payload", () => {
const initial = getInitialFormValues(formRequest);
const withEnv = applyFormValue(initial, "env", "prod");
const withRestart = applyFormValue(withEnv, "restart", false);
const withMode = applyFormValue(withRestart, "mode", "fast");
const withNotes = applyFormValue(withMode, "notes", "ship it");
const submitted = normalizeDialogFormSubmitValues(
formRequest.form!,
applyFormValue(withNotes, "retries", "5"),
);
assert.deepEqual(submitted, {
env: "prod",
restart: false,
mode: "fast",
notes: "ship it",
retries: 5,
});
});
test("script dialog form validates required text and number fields", () => {
const emptyRequiredRequest: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
...formRequest.form!.fields,
{ type: "textarea", name: "requiredNotes", label: "Required notes", defaultValue: "" },
{ type: "number", name: "requiredCount", label: "Required count" },
],
},
};
const values = getInitialFormValues(emptyRequiredRequest);
assert.deepEqual(validateDialogFormValues(emptyRequiredRequest.form!, values, "Required"), {
requiredNotes: "Required",
requiredCount: "Required",
});
});
test("script dialog form only requires checkboxes when explicitly marked required", () => {
const form = {
message: "Confirm",
fields: [
{
type: "checkbox" as const,
name: "optionalFlag",
label: "Optional flag",
defaultValue: false,
},
{
type: "checkbox" as const,
name: "confirmDanger",
label: "I understand",
defaultValue: false,
required: true,
},
],
};
assert.deepEqual(validateDialogFormValues(form, {
optionalFlag: false,
confirmDanger: false,
}, "Required"), {
confirmDanger: "Required",
});
assert.deepEqual(validateDialogFormValues(form, {
optionalFlag: false,
confirmDanger: true,
}, "Required"), {});
});
test("script dialog form validates number min max and step before submit", () => {
const form = {
message: "Number limits",
fields: [{
type: "number" as const,
name: "delayMs",
label: "Delay",
defaultValue: 500,
min: 0,
max: 5000,
step: 100,
required: false,
}],
};
const messages = {
required: "Required",
numberInvalid: "Invalid",
numberMin: (min: number) => `Min ${min}`,
numberMax: (max: number) => `Max ${max}`,
numberStep: (step: number) => `Step ${step}`,
};
assert.deepEqual(validateDialogFormValues(form, { delayMs: -1 }, messages), {
delayMs: "Min 0",
});
assert.deepEqual(validateDialogFormValues(form, { delayMs: 5001 }, messages), {
delayMs: "Max 5000",
});
assert.deepEqual(validateDialogFormValues(form, { delayMs: 550 }, messages), {
delayMs: "Step 100",
});
assert.deepEqual(validateDialogFormValues(form, { delayMs: "" }, messages), {});
assert.deepEqual(validateDialogFormValues(form, { delayMs: 5000 }, messages), {});
const defaultBasedForm = {
message: "Number limits",
fields: [{
type: "number" as const,
name: "oddCount",
label: "Odd count",
defaultValue: 5,
step: 2,
}],
};
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 5 }, messages), {});
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 7 }, messages), {});
assert.deepEqual(validateDialogFormValues(defaultBasedForm, { oddCount: 6 }, messages), {
oddCount: "Step 2",
});
});
test("script dialog form applies visibleWhen to rendering validation and submit payload", () => {
const conditionalRequest: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
{
type: "select",
name: "target",
label: "Target",
options: [
{ label: "Local", value: "local" },
{ label: "Remote", value: "remote" },
],
defaultValue: "local",
},
{
type: "textarea",
name: "host",
label: "Remote host",
defaultValue: "",
visibleWhen: { field: "target", equals: "remote" },
},
{
type: "checkbox",
name: "confirmRemote",
label: "Confirm remote",
defaultValue: false,
visibleWhen: { field: "target", notEquals: "local" },
},
{
type: "textarea",
name: "localNote",
label: "Local note",
defaultValue: "local only",
required: false,
visibleWhen: { field: "target", equals: "local" },
},
{
type: "textarea",
name: "remoteDetail",
label: "Remote detail",
defaultValue: "hidden by hidden controller",
required: false,
visibleWhen: { field: "confirmRemote", truthy: true },
},
],
},
};
const localValues = getInitialFormValues(conditionalRequest);
const localVisibleNames = getVisibleDialogFormFields(conditionalRequest.form!, localValues).map((field) => field.name);
assert.deepEqual(localVisibleNames, ["target", "localNote"]);
assert.deepEqual(validateDialogFormValues(conditionalRequest.form!, localValues, "Required"), {});
assert.deepEqual(normalizeDialogFormSubmitValues(conditionalRequest.form!, localValues), {
target: "local",
localNote: "local only",
});
const remoteValues = applyFormValue(applyFormValue(localValues, "target", "remote"), "confirmRemote", true);
const remoteVisibleNames = getVisibleDialogFormFields(conditionalRequest.form!, remoteValues).map((field) => field.name);
assert.deepEqual(remoteVisibleNames, ["target", "host", "confirmRemote", "remoteDetail"]);
assert.deepEqual(validateDialogFormValues(conditionalRequest.form!, remoteValues, "Required"), {
host: "Required",
});
assert.deepEqual(normalizeDialogFormSubmitValues(conditionalRequest.form!, applyFormValue(remoteValues, "host", "example.com")), {
target: "remote",
host: "example.com",
confirmRemote: true,
remoteDetail: "hidden by hidden controller",
});
});
test("script dialog form does not show fields chained from hidden controllers", () => {
const request: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
{
type: "select",
name: "target",
label: "Target",
options: [
{ label: "Local", value: "local" },
{ label: "Remote", value: "remote" },
],
defaultValue: "local",
},
{
type: "checkbox",
name: "advanced",
label: "Advanced",
defaultValue: true,
visibleWhen: { field: "target", equals: "remote" },
},
{
type: "textarea",
name: "advancedNote",
label: "Advanced note",
defaultValue: "should stay hidden",
visibleWhen: { field: "advanced", truthy: true },
},
],
},
};
assert.deepEqual(
getVisibleDialogFormFields(request.form!, getInitialFormValues(request)).map((field) => field.name),
["target"],
);
assert.deepEqual(normalizeDialogFormSubmitValues(request.form!, getInitialFormValues(request)), {
target: "local",
});
});
test("script dialog form fields render select checkbox radio textarea and number controls", () => {
const values = applyFormValue(getInitialFormValues(formRequest), "env", "prod");
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={formRequest.form!}
formValues={values}
onValueChange={() => {}}
/>,
);
assert.match(markup, /Environment/);
assert.match(markup, /Use carefully/);
assert.match(markup, /id="script-dialog-env-label"/);
assert.match(markup, /aria-labelledby="script-dialog-env-label"/);
assert.match(markup, /Restart service/);
assert.match(markup, /type="checkbox"[^>]*checked=""/);
assert.match(markup, /type="radio"[^>]*checked=""[^>]*value="safe"/);
assert.match(markup, /type="radio"[^>]*value="fast"/);
assert.match(markup, /<textarea[^>]*>initial note<\/textarea>/);
assert.match(markup, /type="number"[^>]*min="0"[^>]*step="1"[^>]*value="3"/);
});
test("script dialog form fields do not render hidden visibleWhen controls", () => {
const request: ScriptDialogRequest = {
...formRequest,
form: {
...formRequest.form!,
fields: [
{
type: "select",
name: "target",
label: "Target",
options: [
{ label: "Local", value: "local" },
{ label: "Remote", value: "remote" },
],
defaultValue: "local",
},
{
type: "textarea",
name: "host",
label: "Remote host",
defaultValue: "",
visibleWhen: { field: "target", equals: "remote" },
},
],
},
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={request.form!}
formValues={getInitialFormValues(request)}
onValueChange={() => {}}
/>,
);
assert.match(markup, /Target/);
assert.doesNotMatch(markup, /Remote host/);
});
test("script dialog form body renders fields inside a constrained scroll area", () => {
const values = getInitialFormValues(formRequest);
const markup = renderToStaticMarkup(
<ScriptDialogFormBody
form={formRequest.form!}
formValues={values}
onValueChange={() => {}}
/>,
);
assert.match(markup, /data-radix-scroll-area-viewport/);
assert.match(markup, /min-h-0/);
assert.match(markup, /Environment/);
});
test("script dialog form fields render required errors", () => {
const values = getInitialFormValues(formRequest);
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={formRequest.form!}
formValues={values}
formErrors={{ retries: "Required" }}
onValueChange={() => {}}
/>,
);
assert.match(markup, /aria-invalid="true"/);
assert.match(markup, /id="script-dialog-retries-error"/);
assert.match(markup, /aria-describedby="script-dialog-retries-error"/);
assert.match(markup, /Required/);
});
test("script dialog form fields associate checkbox errors with the input", () => {
const form = {
message: "Confirm",
fields: [{
type: "checkbox" as const,
name: "confirmDanger",
label: "I understand",
description: "Required before continuing",
defaultValue: false,
required: true,
}],
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={form}
formValues={{ confirmDanger: false }}
formErrors={{ confirmDanger: "Required" }}
onValueChange={() => {}}
/>,
);
assert.match(markup, /id="script-dialog-confirmDanger-description"/);
assert.match(markup, /id="script-dialog-confirmDanger-error"/);
assert.match(
markup,
/aria-describedby="script-dialog-confirmDanger-description script-dialog-confirmDanger-error"/,
);
assert.match(markup, /aria-invalid="true"/);
});
test("script dialog form field DOM ids encode names with spaces", () => {
const form = {
message: "Confirm",
fields: [{
type: "checkbox" as const,
name: "confirm danger",
label: "I understand",
description: "Required before continuing",
defaultValue: false,
required: true,
}],
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={form}
formValues={{ "confirm danger": false }}
formErrors={{ "confirm danger": "Required" }}
onValueChange={() => {}}
/>,
);
assert.equal(getDialogFieldDomId("confirm danger"), "script-dialog-confirm%20danger");
assert.match(markup, /id="script-dialog-confirm%20danger"/);
assert.match(
markup,
/aria-describedby="script-dialog-confirm%20danger-description script-dialog-confirm%20danger-error"/,
);
assert.doesNotMatch(markup, /script-dialog-confirm danger/);
});
test("script dialog form control ids encode spaced names for radio text and number fields", () => {
const form = {
message: "Spaced names",
fields: [
{
type: "radio" as const,
name: "run mode",
label: "Run mode",
description: "Choose mode",
options: [
{ label: "Safe", value: "safe" },
{ label: "Fast", value: "fast" },
],
defaultValue: "safe",
},
{
type: "textarea" as const,
name: "release notes",
label: "Release notes",
defaultValue: "",
},
{
type: "number" as const,
name: "retry count",
label: "Retry count",
defaultValue: 3,
},
],
};
const markup = renderToStaticMarkup(
<ScriptDialogFormFields
form={form}
formValues={{
"run mode": "safe",
"release notes": "",
"retry count": 3,
}}
formErrors={{
"run mode": "Required",
"release notes": "Required",
"retry count": "Required",
}}
onValueChange={() => {}}
/>,
);
assert.match(markup, /id="script-dialog-run%20mode-0"/);
assert.match(markup, /name="script-dialog-run%20mode"/);
assert.match(markup, /id="script-dialog-release%20notes"/);
assert.match(markup, /for="script-dialog-release%20notes"/);
assert.match(markup, /id="script-dialog-retry%20count"/);
assert.match(markup, /for="script-dialog-retry%20count"/);
assert.doesNotMatch(markup, /script-dialog-run mode/);
assert.doesNotMatch(markup, /script-dialog-release notes/);
assert.doesNotMatch(markup, /script-dialog-retry count/);
});
+467 -8
View File
@@ -10,18 +10,432 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import type { ScriptDialogRequest } from '@/types/global/netcatty-bridge-script.d.ts';
import type {
ScriptDialogCondition,
ScriptDialogField,
ScriptDialogForm,
ScriptDialogFormValue,
ScriptDialogRequest,
} from '@/types/global/netcatty-bridge-script.d.ts';
type FormValues = Record<string, ScriptDialogFormValue>;
type FormErrors = Record<string, string>;
type FormValidationMessages = string | {
required: string;
numberInvalid: string;
numberMin: (min: number) => string;
numberMax: (max: number) => string;
numberStep: (step: number) => string;
};
export function getInitialFormValues(request: ScriptDialogRequest): FormValues {
if (request.type !== 'form' || !request.form) return {};
return Object.fromEntries(
request.form.fields.map((field) => [field.name, field.defaultValue]),
);
}
export function applyFormValue(values: FormValues, name: string, value: ScriptDialogFormValue): FormValues {
return { ...values, [name]: value };
}
export function getDialogFieldDomId(name: string): string {
return `script-dialog-${encodeURIComponent(name || 'field')}`;
}
function resolveValidationMessages(messages: FormValidationMessages = 'Required') {
if (typeof messages === 'string') {
return {
required: messages,
numberInvalid: messages,
numberMin: () => messages,
numberMax: () => messages,
numberStep: () => messages,
};
}
return messages;
}
function matchesNumberStep(value: number, step: number, base = 0) {
const quotient = (value - base) / step;
return Math.abs(quotient - Math.round(quotient)) < 1e-9;
}
function getNumberStepBase(field: Extract<ScriptDialogField, { type: 'number' }>) {
return field.min ?? field.defaultValue ?? 0;
}
function getConditionFieldValue(
form: ScriptDialogForm,
values: FormValues,
fieldName: string,
): ScriptDialogFormValue {
const field = form.fields.find((candidate) => candidate.name === fieldName);
const value = values[fieldName];
if (field?.type === 'number') {
if (value === undefined || value === '') return undefined;
const numberValue = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numberValue) ? numberValue : value;
}
if (field?.type === 'checkbox') {
return Boolean(value);
}
return value;
}
export function evaluateDialogCondition(
form: ScriptDialogForm,
values: FormValues,
condition: ScriptDialogCondition,
): boolean {
const value = getConditionFieldValue(form, values, condition.field);
if ('equals' in condition) {
return value === condition.equals;
}
if ('notEquals' in condition) {
return value !== condition.notEquals;
}
if ('truthy' in condition) {
return Boolean(value);
}
return !value;
}
export function isDialogFieldVisible(
form: ScriptDialogForm,
field: ScriptDialogField,
values: FormValues,
): boolean {
return getVisibleDialogFormFields(form, values).some((visibleField) => visibleField.name === field.name);
}
export function getVisibleDialogFormFields(form: ScriptDialogForm, values: FormValues): ScriptDialogField[] {
const visibleFields: ScriptDialogField[] = [];
const visibleNames = new Set<string>();
for (const field of form.fields) {
if (!field.visibleWhen) {
visibleFields.push(field);
visibleNames.add(field.name);
continue;
}
if (!visibleNames.has(field.visibleWhen.field)) {
continue;
}
if (evaluateDialogCondition(form, values, field.visibleWhen)) {
visibleFields.push(field);
visibleNames.add(field.name);
}
}
return visibleFields;
}
export function validateDialogFormValues(
form: ScriptDialogForm,
values: FormValues,
messages: FormValidationMessages = 'Required',
): FormErrors {
const validationMessages = resolveValidationMessages(messages);
const errors: FormErrors = {};
for (const field of getVisibleDialogFormFields(form, values)) {
const value = values[field.name];
if (field.type === 'checkbox') {
if (field.required === true && !value) {
errors[field.name] = validationMessages.required;
}
continue;
}
if (field.type === 'number') {
const isEmpty = value === undefined || value === '';
if (isEmpty) {
if (field.required !== false) {
errors[field.name] = validationMessages.required;
}
continue;
}
const numberValue = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numberValue)) {
errors[field.name] = validationMessages.numberInvalid;
continue;
}
if (field.min !== undefined && numberValue < field.min) {
errors[field.name] = validationMessages.numberMin(field.min);
continue;
}
if (field.max !== undefined && numberValue > field.max) {
errors[field.name] = validationMessages.numberMax(field.max);
continue;
}
if (field.step !== undefined && !matchesNumberStep(numberValue, field.step, getNumberStepBase(field))) {
errors[field.name] = validationMessages.numberStep(field.step);
}
continue;
}
if (field.required === false) continue;
if (value === undefined || String(value).trim() === '') {
errors[field.name] = validationMessages.required;
}
}
return errors;
}
export function normalizeDialogFormSubmitValues(form: ScriptDialogForm, values: FormValues): FormValues {
const next: FormValues = {};
for (const field of getVisibleDialogFormFields(form, values)) {
const value = values[field.name];
if (field.type === 'number') {
if (value === undefined || value === '') {
next[field.name] = undefined;
continue;
}
const numberValue = typeof value === 'number' ? value : Number(value);
next[field.name] = Number.isFinite(numberValue) ? numberValue : undefined;
continue;
}
next[field.name] = value;
}
return next;
}
export function ScriptDialogFormFields({
form,
formValues,
formErrors = {},
onValueChange,
}: {
form: ScriptDialogForm;
formValues: FormValues;
formErrors?: FormErrors;
onValueChange: (name: string, value: ScriptDialogFormValue) => void;
}) {
const renderFormField = (field: ScriptDialogField) => {
const inputId = getDialogFieldDomId(field.name);
const fieldError = formErrors[field.name];
const descriptionId = field.description ? `${inputId}-description` : undefined;
const errorId = fieldError ? `${inputId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined;
const fieldDescription = field.description ? (
<p id={descriptionId} className="text-xs text-muted-foreground">{field.description}</p>
) : null;
const inlineDescription = field.description ? (
<span id={descriptionId} className="mt-1 block text-xs text-muted-foreground">{field.description}</span>
) : null;
const errorMessage = fieldError ? (
<p id={errorId} className="text-xs text-destructive">{fieldError}</p>
) : null;
const inlineErrorMessage = fieldError ? (
<span id={errorId} className="mt-1 block text-xs text-destructive">{fieldError}</span>
) : null;
if (field.type === 'select') {
const labelId = `${inputId}-label`;
const selectedValue = String(formValues[field.name] ?? field.defaultValue);
const selectedOption = field.options.find((option) => option.value === selectedValue);
const selectedDescriptionId = selectedOption?.description ? `${inputId}-selected-description` : undefined;
const selectDescribedBy = [describedBy, selectedDescriptionId].filter(Boolean).join(' ') || undefined;
return (
<div key={field.name} className="space-y-2">
<Label id={labelId} htmlFor={inputId}>{field.label}</Label>
<Select
value={selectedValue}
onValueChange={(value) => onValueChange(field.name, value)}
>
<SelectTrigger
id={inputId}
aria-labelledby={labelId}
aria-describedby={selectDescribedBy}
aria-invalid={fieldError ? true : undefined}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
textValue={option.label}
>
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description ? (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
) : null}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedOption?.description ? (
<p id={selectedDescriptionId} className="text-xs text-muted-foreground">{selectedOption.description}</p>
) : null}
{fieldDescription}
{errorMessage}
</div>
);
}
if (field.type === 'radio') {
const selectedValue = String(formValues[field.name] ?? field.defaultValue);
return (
<fieldset
key={field.name}
className="space-y-2"
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
>
<legend className="text-sm font-medium leading-none">{field.label}</legend>
{fieldDescription}
<div className="space-y-2">
{field.options.map((option, index) => {
const optionInputId = `${inputId}-${index}`;
return (
<label
key={option.value}
htmlFor={optionInputId}
className="flex items-start gap-2 rounded-md border border-border/60 px-3 py-2 text-sm"
>
<input
id={optionInputId}
type="radio"
name={inputId}
value={option.value}
checked={selectedValue === option.value}
disabled={option.disabled}
onChange={(event) => onValueChange(field.name, event.target.value)}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<span className="min-w-0">
<span className="block">{option.label}</span>
{option.description ? (
<span className="block text-xs text-muted-foreground">{option.description}</span>
) : null}
</span>
</label>
);
})}
</div>
{errorMessage}
</fieldset>
);
}
if (field.type === 'textarea') {
return (
<div key={field.name} className="space-y-2">
<Label htmlFor={inputId}>{field.label}</Label>
<Textarea
id={inputId}
value={String(formValues[field.name] ?? field.defaultValue)}
placeholder={field.placeholder}
onChange={(event) => onValueChange(field.name, event.target.value)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
/>
{fieldDescription}
{errorMessage}
</div>
);
}
if (field.type === 'number') {
return (
<div key={field.name} className="space-y-2">
<Label htmlFor={inputId}>{field.label}</Label>
<Input
id={inputId}
type="number"
value={formValues[field.name] ?? field.defaultValue ?? ''}
placeholder={field.placeholder}
min={field.min}
max={field.max}
step={field.step}
onChange={(event) => onValueChange(field.name, event.target.value)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
/>
{fieldDescription}
{errorMessage}
</div>
);
}
return (
<div key={field.name} className="space-y-2">
<label htmlFor={inputId} className="flex items-start gap-2 text-sm">
<input
id={inputId}
type="checkbox"
checked={Boolean(formValues[field.name] ?? field.defaultValue)}
onChange={(event) => onValueChange(field.name, event.target.checked)}
aria-describedby={describedBy}
aria-invalid={fieldError ? true : undefined}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<span className="min-w-0">
<span className="block font-medium leading-none">{field.label}</span>
{inlineDescription}
{inlineErrorMessage}
</span>
</label>
</div>
);
};
return (
<div className="space-y-4">
{getVisibleDialogFormFields(form, formValues).map(renderFormField)}
</div>
);
}
export function ScriptDialogFormBody({
form,
formValues,
formErrors,
onValueChange,
}: {
form: ScriptDialogForm;
formValues: FormValues;
formErrors?: FormErrors;
onValueChange: (name: string, value: ScriptDialogFormValue) => void;
}) {
return (
<ScrollArea className="min-h-0 pr-3">
<ScriptDialogFormFields
form={form}
formValues={formValues}
formErrors={formErrors}
onValueChange={onValueChange}
/>
</ScrollArea>
);
}
export function ScriptDialogHost() {
const { t } = useI18n();
const [request, setRequest] = useState<ScriptDialogRequest | null>(null);
const [promptValue, setPromptValue] = useState('');
const [formValues, setFormValues] = useState<FormValues>({});
const [formErrors, setFormErrors] = useState<FormErrors>({});
useEffect(() => {
const dispose = netcattyBridge.get()?.onScriptDialogRequest?.((payload) => {
setRequest(payload);
setPromptValue(payload.defaultValue ?? '');
setFormValues(getInitialFormValues(payload));
setFormErrors({});
});
return dispose;
}, []);
@@ -34,6 +448,38 @@ export function ScriptDialogHost() {
if (!request) return null;
const form = request.type === 'form' ? request.form : undefined;
const dialogTitle = request.type === 'waitForTimeout'
? t('scripts.dialog.waitForTimeoutTitle')
: form?.title || t('scripts.dialog.title');
const message = form?.message ?? request.message;
const setFormValue = (name: string, value: ScriptDialogFormValue) => {
setFormValues((current) => applyFormValue(current, name, value));
setFormErrors((current) => {
if (!current[name]) return current;
const { [name]: _removed, ...rest } = current;
return rest;
});
};
const submitForm = () => {
if (!form) return;
const errors = validateDialogFormValues(form, formValues, {
required: t('scripts.dialog.required'),
numberInvalid: t('scripts.dialog.numberInvalid'),
numberMin: (min) => t('scripts.dialog.numberMin', { min }),
numberMax: (max) => t('scripts.dialog.numberMax', { max }),
numberStep: (step) => t('scripts.dialog.numberStep', { step }),
});
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
const submitValues = normalizeDialogFormSubmitValues(form, formValues);
void respond(submitValues);
};
return (
<Dialog open onOpenChange={(open) => {
if (!open) {
@@ -41,14 +487,10 @@ export function ScriptDialogHost() {
}
}}
>
<DialogContent>
<DialogContent className={form ? 'max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden' : undefined}>
<DialogHeader>
<DialogTitle>
{request.type === 'waitForTimeout'
? t('scripts.dialog.waitForTimeoutTitle')
: t('scripts.dialog.title')}
</DialogTitle>
<DialogDescription>{request.message}</DialogDescription>
<DialogTitle>{dialogTitle}</DialogTitle>
{message ? <DialogDescription>{message}</DialogDescription> : null}
</DialogHeader>
{request.type === 'prompt' ? (
<Input
@@ -57,6 +499,14 @@ export function ScriptDialogHost() {
autoFocus
/>
) : null}
{form ? (
<ScriptDialogFormBody
form={form}
formValues={formValues}
formErrors={formErrors}
onValueChange={setFormValue}
/>
) : null}
<DialogFooter>
{request.type === 'waitForTimeout' ? (
<>
@@ -80,6 +530,15 @@ export function ScriptDialogHost() {
<Button variant="outline" onClick={() => void respond(undefined, true)}>{t('common.cancel')}</Button>
<Button onClick={() => void respond(promptValue)}>{t('scripts.dialog.ok')}</Button>
</>
) : request.type === 'form' ? (
<>
<Button variant="outline" onClick={() => void respond(undefined, true)}>
{form?.cancelLabel || t('common.cancel')}
</Button>
<Button onClick={submitForm}>
{form?.submitLabel || t('scripts.dialog.ok')}
</Button>
</>
) : (
<Button onClick={() => void respond(undefined)}>{t('scripts.dialog.ok')}</Button>
)}
+23 -5
View File
@@ -21,6 +21,7 @@ import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { Button } from "../../ui/button";
import { ConfirmDialog } from "../../ui/confirm-dialog";
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
@@ -199,6 +200,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
} = useAIPermissionGrantsState();
const { t } = useI18n();
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
const [removeProviderConfirm, setRemoveProviderConfirm] = useState<{ id: string; name: string } | null>(null);
const [codexIntegration, setCodexIntegration] = useState<CodexIntegrationStatus | null>(null);
const [codexLoginSession, setCodexLoginSession] = useState<CodexLoginSession | null>(null);
const [isCodexLoading, setIsCodexLoading] = useState(false);
@@ -512,16 +514,22 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
(id: string) => {
const provider = providers.find((p) => p.id === id);
const name = provider?.name || id;
const ok = window.confirm(
t('confirm.removeProvider', { name }),
);
if (!ok) return;
setRemoveProviderConfirm({ id, name });
},
[providers],
);
const handleConfirmRemoveProvider = useCallback(
() => {
if (!removeProviderConfirm) return;
const { id } = removeProviderConfirm;
removeProvider(id);
if (editingProviderId === id) {
setEditingProviderId(null);
}
setRemoveProviderConfirm(null);
},
[removeProvider, editingProviderId, providers, t],
[removeProvider, editingProviderId, removeProviderConfirm],
);
// Agent options for default agent
@@ -1173,6 +1181,16 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
/>
</TabsContent>
</Tabs>
<ConfirmDialog
open={removeProviderConfirm !== null}
title={removeProviderConfirm ? t('confirm.removeProvider', { name: removeProviderConfirm.name }) : ''}
confirmLabel={t('action.remove')}
destructive
onOpenChange={(open) => {
if (!open) setRemoveProviderConfirm(null);
}}
onConfirm={handleConfirmRemoveProvider}
/>
</SettingsTabContent>
);
};
Loaded 100 of 379 files, more files were not shown because too many files have changed in this diff. Show more