Compare commits

...
Author SHA1 Message Date
bincxz 8b9685e680 fix(app-lock): hide native window menu while locked
Windows shows the system title-bar menu on right-click in drag regions.
The lock overlay is a drag region so the window stays movable, which also
exposed Minimize/Maximize/Close. Suppress BrowserWindow system-context-menu
while locked so the lock screen cannot quit the app.

Closes #3065
2026-08-19 13:36:33 +08:00
陈大猫 1a55378b44 Merge pull request #3060 from binaricat/automation/nix-release-v1.1.81
Update Nix release metadata for v1.1.81
2026-08-19 10:53:37 +08:00
github-actions[bot] b36dbc10ec Update Nix release metadata for v1.1.81 2026-08-19 02:48:08 +00:00
陈大猫 81dce5a3e7 Merge pull request #3059 from binaricat/fix/app-lock-window-drag 2026-08-19 10:24:15 +08:00
bincxz 4e794d4af1 fix(app-lock): keep window drag region on lock overlay
The lock screen is a full-window overlay, so it covers the title-bar
drag region and is the only chrome on a locked first paint. Mark the
backdrop as app-drag and keep unlock controls app-no-drag.

Closes #3058
2026-08-19 10:18:28 +08:00
陈大猫 1205ef37ae Merge pull request #3056 from binaricat/codex/fix-grok-reasoning-effort
fix(ai): support Grok Build reasoning effort
2026-08-19 09:39:48 +08:00
陈大猫 f7d4cc14c2 Merge pull request #3055 from binaricat/codex/fix-sftp-bookmark-popover-close
fix(sftp): close bookmark popover after path selection
2026-08-19 09:39:23 +08:00
bincxz d36d4f86b3 fix(ai): preserve legacy Grok model selections 2026-08-19 00:34:19 +08:00
bincxz 757fbcfd83 fix(ai): default Grok reasoning selections 2026-08-19 00:29:07 +08:00
bincxz e0fdf48395 fix(ai): preserve Grok legacy effort choices 2026-08-19 00:20:57 +08:00
bincxz 63f44c4ac9 fix(ai): harden Grok model discovery 2026-08-19 00:15:12 +08:00
bincxz 64ba59db10 fix(ai): add Grok reasoning effort selection 2026-08-19 00:02:09 +08:00
bincxz 1617ef8956 fix(sftp): close bookmarks after path selection 2026-08-18 23:49:17 +08:00
陈大猫 8ee1cda924 Merge pull request #1609 from Ryanisgood/feature/app-lock-screen
Feat: 添加应用锁屏功能
2026-08-18 20:57:42 +08:00
bincxz ea91418f4e fix(app-lock): wait for locked startup readiness 2026-08-18 20:48:21 +08:00
bincxz e6c46f8198 fix(app-lock): defer protected actions until unlock 2026-08-18 20:45:29 +08:00
bincxz 8eaab92d31 feat(app-lock): prioritize system unlock flow 2026-08-18 20:22:34 +08:00
bincxz 370b57fa67 style(app-lock): simplify password field 2026-08-18 19:43:54 +08:00
bincxz 8562a26315 style(app-lock): move lock icon into password field 2026-08-18 19:42:07 +08:00
bincxz 0ac5c728b2 style(app-lock): add lock badge to logo 2026-08-18 19:39:43 +08:00
bincxz ff40b4fcff refactor(app-lock): align settings with modal flow 2026-08-18 19:36:52 +08:00
bincxz 0ac30b5049 fix(app-lock): harden lock boundaries 2026-08-18 17:24:46 +08:00
bincxz 20c4c7791f Merge remote-tracking branch 'origin/main' into codex/pr-1609-hardening 2026-08-18 16:33:20 +08:00
bincxzandCursor d422a27715 Merge origin/main into feature/app-lock-screen
Reconcile the app-lock feature with main's refactored renderer
architecture (publishers/hosts/AppShell) and main-process changes:

- index.tsx/App.tsx: AppLockGate keeps ownership of useSettingsState and
  useAppLockState; new AppLockRuntimePublisher publishes the runtime via
  appRuntimeBridge; SettingsPublisher now binds the gate-owned settings.
- AppSideEffects: defer deep links, tray port-forward toggles, global
  hotkeys and window close commands while locked; flush on unlock.
- main.cjs: keep fresh-session re-lock before main's reusable-window
  fast path; before-quit guard delegates to handleBeforeQuit with main's
  async plugin-shutdown commitQuit and pending-update cancellation.
- globalShortcutBridge: keep locked-state tray redaction on top of
  main's deferred tray panel show logic.
- preload: adopt main's lastPayload replay for terminal popup config
  (superset of the gate-delay replay fix).
- vite config: keep dev CSP plugin, adopt main's warn-only stale
  optimize-dep handling; electron-builder: keep Windows Hello helper
  extraResource with main's CLI-controlled arch targets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 16:30:30 +08:00
陈大猫 9eb548a778 Merge pull request #3052 from binaricat/fix/3051-ai-stream-thinking-timeout
fix(ai): do not abort active thinking streams at 2 minutes
2026-08-18 15:56:43 +08:00
bincxz 2c73442a87 fix(ai): do not abort active thinking streams at 2 minutes
The HTTP stream layer used a hard 120s total deadline, so long
reasoning that was still emitting tokens got cut off. Re-arm a
2-minute idle timer on incoming bytes and keep a 30-minute hard cap
for runaway streams.

Closes #3051
2026-08-18 15:53:08 +08:00
陈大猫 4913a043da Merge pull request #3050 from binaricat/fix/3049-mcp-omitted-arguments
fix(mcp): accept omitted arguments for no-arg tools
2026-08-18 15:04:52 +08:00
bincxz ba88ea7e4f fix(mcp): accept omitted arguments for no-arg tools
MCP allows clients to omit tools/call arguments or send {}. Empty Zod
object schemas rejected the omitted case, so external agents could not
call get_environment and other no-arg tools.

Closes #3049
2026-08-18 14:59:22 +08:00
陈大猫 1deea15e0f Merge pull request #3048 from binaricat/fix/3046-focus-hidden-pane-full-size
fix(terminal): keep hidden focus-mode panes full-size to stop \r progress spam
2026-08-18 14:48:30 +08:00
bincxzandCursor d920d076d4 fix(workspace): hand focus to a remaining session when closing the focused pane
closeSessionWorkspaceLayoutState pruned the workspace tree but left
focusedSessionId pointing at the closed session. In a focus-mode workspace no
pane then qualified as visible, so the continuously rendered hidden panes all
painted on top of each other (reported in #3046 follow-up). Reassign focus to
a remaining session and drop closed ids from focusSessionOrder, matching the
detach path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:34:23 +08:00
bincxzandCursor 95606cb62c fix(terminal): drop stale full-size cache while a pane is split-visible
A pane that was once full-size kept those pixels in lastVisiblePaneSizeRef
through split view; after a viewport resize, switching back to focus pinned
the hidden pane (and its PTY) at the stale size. Split-visible panes now
invalidate the cache so hidden full-size layouts re-measure the current area.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:18:07 +08:00
bincxzandCursor 083ee55455 fix(terminal): never cache split-rect sizes as the hidden pane pinned size
Switching a workspace from split view to focus view left each newly hidden
pane pinned at the split fragment size cached in lastVisiblePaneSizeRef, so
the background PTY stayed narrow and the \r progress spam from #3046 could
still occur. Only full-size layouts feed the hidden-pane pinned size now.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:11:43 +08:00
陈大猫andCursor c4edefdbe4 feat(terminal): pass Esc+. through for readline yank-last-arg (#3047)
* feat(terminal): pass Esc+. through for readline yank-last-arg

Autocomplete was swallowing Escape, and macOS Option+. typed ≥ instead of Meta-., so the standard shell shortcut never reached bash/zsh.

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

* fix(terminal): allow Shift when Esc+_ yanks the last argument

Underscore is Shift+Minus, so rejecting all shiftKey follow-ups dropped M-_ after autocomplete dismissed.

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

* fix(terminal): keep Esc+_ armed across the Shift keydown

Typing underscore emits Shift before Minus. Clearing the Meta prefix on that modifier event dropped M-_ after autocomplete dismiss.

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

* fix(terminal): do not treat Esc+. as typed input

Yank-last-arg rewrites the shell line. Walking the ESC+. bytes appended a literal period to the command buffer and left autocomplete's typed buffer marked reliable.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 13:59:56 +08:00
陈大猫andCursor b7325218fb feat(ssh): accept PuTTY-style CLI args from bastion hosts (#3044)
* feat(ssh): accept PuTTY-style CLI args from bastion hosts

Reuse the existing ssh:// and telnet:// deep-link path so PAM products that launch putty.exe -ssh user@host -P port -pw password can point at Netcatty instead.

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

* fix(ssh): consume operands of ignored PuTTY flags

Unknown value-taking options such as -m and -D were skipped while their next token was treated as a host or port, which could connect the supplied password to the wrong endpoint.

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

* fix(ssh): reject unsupported PuTTY protocols and unknown flags

-raw/-rlogin/-serial must not fall through to SSH with the supplied password, and valueless switches such as -batch must not consume the following host. Unknown dash flags are now rejected instead of guessed.

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

* fix(ssh): reject missing or empty PuTTY option operands

An empty or trailing -pw was turned into ssh://user:@host, which the deep-link path treats as no password and can then authenticate with a saved vault credential.

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

* fix(ssh): reject unsupported -pwfile, -i, and -hostkey launches

Those options request credentials or a host-key pin this pipeline cannot honor, so connecting with -pw or a vault match would silently change the authentication the caller asked for.

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

* fix(ssh): keep .app/.exe hosts and reject -loghost

Suffix filtering now only covers the Electron script argument, so destinations like prod.app are not dropped. -loghost is rejected because this pipeline cannot honor the logical host-key identity.

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

* fix(ssh): keep packaged argv[1] hosts like prod.js

Suffix filtering now applies only after an Electron binary, so a packaged launch can still use a first-position hostname that looks like a script.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 13:35:27 +08:00
bincxzandCursor 428df79d87 fix(terminal): keep hidden focus-mode panes full-size to stop \r progress spam
Focus-mode panes are always viewed full-size, but hidden non-focused panes
were laid out at their split rects when hibernate is off (the default). The
continuously rendered xterm and the remote PTY shrank to a 1/N-width
fragment, so \r-refresh progress output (rsync --info=progress2) soft-wrapped
and left one scrollback line per refresh, flooding the terminal when the
host was focused again (#3046). Focus mode now never uses split geometry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 13:18:46 +08:00
陈大猫andCursor 074acc3bed feat(sftp): add right-click extract for archives (#3042)
* feat(sftp): add right-click extract for archives

Let SFTP list and tree views unpack zip/tar/gz archives in place via remote SSH exec (or local tar/unzip), so users do not need to switch to another file manager.

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

* fix(sftp): keep extract from clobbering files or dying on unzip output

Write gzip/bzip2/xz results to a staging path and replace the destination only after a zero exit. Drain unzip progress instead of treating routine stdout as a hard failure.

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

* fix(sftp): quote extract cleanup trap and use unique staging files

Remote gzip/bzip2/xz nested a quoted path inside trap, so names with spaces failed before decode. Staging now exclusively creates a unique sibling, and local .gz uses Node zlib.

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

* fix(sftp): refuse single-file extract onto an existing directory

Remote mv would otherwise drop the staging file inside that directory and still report success.

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

* fix(sftp): restore the previous file if Windows extract replace fails

Rename the destination aside before publishing the staging file, instead of deleting it first.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 12:00:16 +08:00
陈大猫 282d1570cd Merge pull request #3040 from binaricat/fix/3000-enter-strips-previous-line-highlight
fix(terminal): keep previous line highlighted when the Enter chunk carries a bare CR
2026-08-18 10:50:13 +08:00
bincxzandCursor 4cacfac04c fix(terminal): detect start-row mutations by fingerprint instead of enumerating controls
The backtracking regex cannot enumerate every control that homes the
cursor (DECOM, DECCOLM, ...). Snapshot the skipped start row's text before
a newline-prefixed write; if it changed, something climbed back onto that
row and it is restored and repainted with the rest of the range. This
closes the whole class instead of chasing individual sequences.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:33:26 +08:00
陈大猫 a2532dde35 Merge pull request #3037 from binaricat/feat/3019-osc-desktop-notifications
feat(terminal): show desktop notifications for OSC 9/777/99
2026-08-18 10:26:50 +08:00
bincxzandCursor d91724e9ce fix(terminal): treat DECSTBM homing as cursor backtracking
Setting a scroll region (`CSI Ps;Ps r`) homes the cursor, so a
newline-prefixed chunk using DECSTBM can land back on the highlighted
starting row like CUU/VPA. Keep the pre-write restore and repaint the
start row for such chunks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:26:32 +08:00
bincxzandCursor 51a5f8b7aa fix(terminal): treat VPA as cursor backtracking for highlight restoration
`CSI Ps d` positions the cursor on an absolute row and can jump back onto
the highlighted starting row just like CUU/CUP, so newline-prefixed chunks
using VPA must also keep the pre-write restore and repaint the start row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:18:17 +08:00
陈大猫 3bcc01f134 Merge pull request #3038 from binaricat/fix/3007-search-copy-on-select
fix(terminal): do not copy SearchAddon match selections
2026-08-18 10:14:07 +08:00
陈大猫 63da7edd7d Merge pull request #3039 from binaricat/fix/3003-windows-cursor-cli-login-cmd
fix(ai): detect Cursor CLI login on Windows cmd shims
2026-08-18 10:12:34 +08:00
bincxzandCursor fea8444adc fix(terminal): restore the start row when a newline-prefixed chunk climbs back up
A chunk like `\r\n\x1b[1A\rOK\x1b[1B` (multi-line progress redraws) starts
with a line advance but returns to the previous row and partially
overwrites a highlighted keyword. The line-advance exemption skipped the
pre-write restore while the post-write repaint also skipped that row,
leaving the untouched cells with the injected color.

Keep the restore and include the start row in the repaint whenever the
chunk contains sequences that can move the cursor back up (CUU/CPL/CUP,
restore cursor, RI, DECRC).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:12:09 +08:00
bincxz 5083a81681 fix(ai): spawn Cursor CLI via node.exe + index.js on Windows
Codex review: putting the turn prompt on a cmd.exe line hits the
8191-char limit, mishandles quotes/%VAR%, and leaves the Node child
alive after Stop. Resolve cursor-agent.cmd to versions/*/node.exe plus
index.js and spawn that argv with shell:false.

Closes review on #3039
2026-08-18 10:06:49 +08:00
bincxz 545178b201 fix(terminal): abort OSC notify scans on CAN and SUB
xterm ends an OSC unsuccessfully on CAN/SUB. The hibernate scanner
now does the same and keeps the following bytes for replay.
2026-08-18 10:06:35 +08:00
bincxz c75feb0c1a fix(terminal): release copy-on-select when the window blurs
Alt-tab drops the matching mouseup in Electron. Release the gesture
on window blur so a later SearchAddon select cannot stick.
2026-08-18 10:05:21 +08:00
bincxzandCursor 69c26101cd fix(terminal): keep previous line highlighted when Enter chunk carries a bare CR
A write chunk that starts with a line advance (bash 5.1+ fuses the echoed
newline with the bracketed-paste `\x1b[?2004l\r`, command output, and the
next prompt) restored the previous prompt row's highlight before writing,
while the post-write repaint deliberately skips that row. The keyword
highlight on the command line vanished until a background catch-up ran.

Only treat a chunk as an in-place rewrite of the cursor row when it does
not begin with a line advance: once the cursor leaves the row, later CR/EL
sequences can only touch rows the post-write repaint already covers.

Fixes #3000

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 10:03:01 +08:00
bincxz 9b0cbf0a4c fix(terminal): abort interrupted OSC notify scans
Address the second Codex pass on #3037. A non-ST ESC now ends an
in-flight OSC 9/777/99 the same way xterm does, so hibernated
replay keeps the following CSI/text. OSC 99 fragments are also
concatenated raw so kitty can split on whitespace.
2026-08-18 10:01:26 +08:00
bincxz f44e200874 fix(terminal): scope Select All copy-on-select pulse to one pane
A split workspace must not treat a sibling SearchAddon match as a
user selection when Select All runs in another terminal.
2026-08-18 10:00:53 +08:00
bincxz 2f701c1605 fix(ai): detect Cursor CLI login on Windows cmd shims
Settings → AI → Cursor Check ran `cursor-agent status` via execFileSync
on the path from `where`, which is a .cmd installer shim. Node cannot
spawn .cmd files directly (EINVAL), so the probe reported logged-out
even when the terminal showed authenticated.

Run the status probe, turns, and model listing through the existing
Windows shim spawn helper, without unwrapping cursor-agent.cmd to
node.exe (that would drop index.js). Also accept status JSON wrapped
in cmd noise.

Closes #3003
2026-08-18 09:56:40 +08:00
bincxz d7f12834df fix(terminal): keep Select All and touchcancel on copy-on-select
User-invoked Select All / Select Word now pulse the gesture so they
still copy. touchcancel releases the same way as touchend so an
interrupted touch cannot leave SearchAddon able to overwrite the
clipboard.
2026-08-18 09:56:10 +08:00
bincxz 3f919955fa fix(terminal): honor OSC notify policy while hibernated
Address Codex review on #3037. Disabled mode no longer marks tab
activity. Hidden hibernated sessions now scan incoming chunks for
OSC 9/777/99 so Codex can still raise a toast before the tab wakes.
2026-08-18 09:53:42 +08:00
bincxz 1fc22d4cad fix(terminal): release copy-on-select after late contextmenu
A post-mouseup contextmenu was re-arming the gesture with no later
pointer-up, so SearchAddon could keep copying. Pulse contextmenu and
observe it in document capture so tmux/vim select-word still counts.
2026-08-18 09:51:57 +08:00
bincxz 647970f697 fix(terminal): do not copy SearchAddon match selections
Copy-on-select listened to every xterm selection change. SearchAddon
selects the active match on find and again 200ms after resize/write, so
searching for a term then selecting other text left the search term on
the clipboard (issue #3007).

Only write the clipboard after a user pointer gesture on the terminal.
2026-08-18 09:34:41 +08:00
bincxz 2a52837657 feat(terminal): show desktop notifications for OSC 9/777/99
Closes #3019

Codex and other agents emit OSC 9 when a turn completes or approval
is needed, but Netcatty ignored those sequences. Register OSC 9, 777
notify, and kitty OSC 99 handlers, map them to Electron notifications,
and reuse tray focus so a click jumps back to the emitting session.
Ignore ConEmu OSC 9;n progress sequences so they do not become toasts.
2026-08-18 09:28:11 +08:00
陈大猫 d1912f539a Merge pull request #3036 from binaricat/fix/3023-copy-wrap-space
fix(terminal): keep wrap-boundary spaces when copying
2026-08-18 09:27:54 +08:00
bincxz 7a0443d39a fix(terminal): keep wrap-boundary spaces when copying
Closes #3023

A long line that wraps exactly on a space loses that space on copy
(hbase shell becomes hbaseshell). xterm.js 6 may serve a no-endColumn
translateToString(true) from its string cache via String.trimEnd(),
which cannot tell a real last-column space from empty-cell padding.
Prompt detection and similar callers populate that cache first, so
both the normalizer and raw getSelection drop the space.

Always pass an exclusive end column so the copy path uses
getTrimmedLength() instead of trimEnd().
2026-08-17 21:21:46 +08:00
陈大猫 dd02279cd3 Merge pull request #3035 from binaricat/fix/3026-history-preview-copy
fix(terminal): copy history preview while vim owns the screen
2026-08-17 21:05:31 +08:00
bincxz 781b8d642d fix(terminal): dismiss history preview when the chord is forwarded
#3026

An empty Ctrl+C copy binding kept the overlay up and still sent SIGINT.
The same happened for an unbound Ctrl/Cmd+A and for context-menu paste.
Keep the preview only when copy/select-all actually consume the chord,
and hide it before ordinary paste.
2026-08-17 20:49:52 +08:00
bincxz ff1f9efe5f fix(terminal): copy preview text after xterm loses focus
#3026

Dragging on the overlay can blur xterm, so native copy never hit the
container listener and the raw wrapped rows went to the clipboard.
Listen on document while the preview is open. Also keep a selected
hard newline at a row boundary instead of dropping it.
2026-08-17 20:45:52 +08:00
bincxz 19e89934c1 fix(terminal): keep preview copy and paste-selection consistent
#3026

Select All built an element-boundary range, so wrap joining never ran
and copied extra newlines. Context-menu paste selection also left the
overlay up, unlike the keyboard path. Select the text node, accept
element-boundary offsets, and dismiss the preview before pasting.
2026-08-17 20:38:58 +08:00
bincxz 137deac897 fix(terminal): dismiss history preview on click and join wraps
#3026

The overlay had to keep pointer events so history could be copied, but
then every later click stayed on the preview. A click without a drag
now closes it and restores terminal focus. Copy also joins overlay
rows that xterm marked as soft-wrapped, so a long command does not
pick up extra newlines.
2026-08-17 20:34:22 +08:00
bincxz f4ff04897e fix(terminal): treat history preview as a selection for Ctrl+C
#3026

Urgent interrupt still asked only xterm for a selection. With history
preview text selected, Ctrl+C therefore sent SIGINT into vim before the
copy path ran. Use the same copyable-selection flag for interrupt,
shortcut passthrough, and Kitty broadcast.
2026-08-17 20:28:11 +08:00
bincxz b6b433dc88 fix(terminal): copy history preview while vim owns the screen
#3026

Shift+scroll already overlays the normal-buffer history on the
alternate screen, but the overlay ignored pointer events and any
mousedown or copy chord dismissed it. Selection now stays on the
preview so the usual copy shortcuts, context menu, and copy-on-select
can take that text without leaving vi.
2026-08-17 19:26:59 +08:00
陈大猫 2136f43b32 Merge pull request #3030 from binaricat/fix/2923-term-sshd-idle-park
fix(ssh): do not idle-park TERM-SSHD transports
2026-08-17 19:11:27 +08:00
bincxz dc7477804f fix(ssh): confirm Copy Tab reuse after the source shell closes
#2923

Copy Tab pins the source transport across preflight. If that shell
closes before the queued channel opens, pendingShellReconnectRisk is
already recorded and TERM-SSHD marks the conn non-reusable for shells.
Decide liveness and allowShellReuse at channel-open time so a pinned
captured source cannot accept an immediately exiting session.
2026-08-17 18:22:35 +08:00
bincxz 6719e35909 fix(ssh): confirm reused shells after SFTP-held last close
#2923

Idle park is not the only last-shell state. An SFTP or forward lease
keeps the transport live and records pendingShellReconnectRisk; those
reconnects now run the same liveness settle. Known-bad banners also
refuse later shell reuse on that socket so start() dials fresh instead
of borrowing the dead session again.

Keep the liveness timer referenced so node --test cannot exit while
the settle is still pending.
2026-08-17 18:13:25 +08:00
bincxz be22711041 fix(ssh): do not idle-park TERM-SSHD transports
#2923

QiZhi TERM-SSHD accepts a new session channel on a parked connection
and immediately exits 0, so reconnect after close flashes the tab
shut. 1.1.80 skipped PID discovery on that path but still reused the
parked transport.

Known-bad banners now end the transport instead of parking. Idle-park
reconnects on unknown banners wait for the new shell to stay open; an
immediate exit discards the transport, denylists the endpoint, and
falls back to a fresh dial. Copy Tab and live multiplex are unchanged.
2026-08-17 18:01:10 +08:00
陈大猫 40d43e88ba Merge pull request #3029 from binaricat/fix/3025-snippet-shortcut-conflict
fix(snippets): ignore disabled app shortcuts in shortkey conflicts
2026-08-17 17:59:50 +08:00
bincxz 0222736349 fix(snippets): ignore disabled app shortcuts in shortkey conflicts
When the keyboard shortcut scheme is off, snippet recording still
treated default Mac and PC bindings as occupied. Conflict checks now
use only bindings that can actually fire, and the error names the
conflicting action.

Closes #3025
2026-08-17 17:55:06 +08:00
陈大猫 7333ee1d11 Merge pull request #3027 from binaricat/fix/3020-sse-data-optional-space
fix(ai): accept SSE data lines without a space after the colon
2026-08-17 17:31:28 +08:00
bincxz 4291df8942 fix(ai): accept SSE data lines without a space after the colon
Older AxonHub 0.9 and some intranet OpenAI-compat proxies emit
`data:{json}` instead of `data: {json}`. The stream parser required
the space, so chat chunks were dropped while the model-list probe
still succeeded.

Parse `data:` per the EventSource spec (optional single space) and
cover both the line and end-of-stream flush paths.

Closes #3020
2026-08-17 17:29:09 +08:00
github-actions[bot]andgithub-actions[bot] 40be3b1582 chore: update star history charts [skip ci] (#3009)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 00:05:42 +00:00
陈大猫 586a86b781 Merge pull request #3008 from difagume/feature/i18n-spanish 2026-08-16 20:57:57 +08:00
Diego Fabricio 275f752642 feat(i18n): add neutral Spanish (es) UI locale
- Add complete es message dictionaries (core, vault, terminal, ai,
  systemManager, scripts) covering all en keys with matching placeholders
- Register 'es' in MESSAGES_BY_LOCALE and SUPPORTED_UI_LOCALES
- Extend locale test coverage to include es
2026-08-16 00:27:54 -05:00
陈大猫 e66e4dd26e Merge pull request #2998 from binaricat/automation/nix-release-v1.1.80
Update Nix release metadata for v1.1.80
2026-08-15 13:09:49 +08:00
github-actions[bot] 0b18e1b366 Update Nix release metadata for v1.1.80 2026-08-14 19:48:37 +00:00
陈大猫 fe8344ecb2 Merge pull request #2997 from binaricat/fix/ai-composer-no-type-rerender
fix(ai): keep ChatInput chrome still while typing
2026-08-15 03:26:51 +08:00
bincxz c87f488553 fix(ai): keep history markdown warmup off the first-type window
A 300ms initial delay let ChatMessageList start the Streamdown import
while the user was still opening the panel to type. Restore the 4s
expand grace and drop a pending Shiki plugin setState if the message
unmounts before the idle callback lands.
2026-08-15 03:18:25 +08:00
bincxz a2ac9c966b fix(ai): render Streamdown prose while the composer stays focused
Blocking hydrate on focus left history as raw markdown for as long
as the input was focused. Shiki still waits for a real idle gap.
Prose, lists, and inline code can mount after typing stops even if
the textarea still has focus.
2026-08-15 03:07:11 +08:00
bincxz b02d52516c fix(ai): load Streamdown without Shiki until a code fence is idle
messageResponse pulled @streamdown/code on first markdown import, so
opening a chat with history paid for the full highlighter even when
the reply was just prose.

Render with CJK only first. When a fence exists and the composer is
idle, lazily import the isolated Shiki plugin.
2026-08-15 03:03:38 +08:00
bincxz 21f5516586 fix(ai): do not hydrate Streamdown while the composer is focused
Typing a few characters then pausing still hitch because markdown
hydrate only checked the 800ms activity window. History rows would
mount Streamdown while the textarea still had focus.

Treat focused/composing as busy, wait 2s after the last key, and
keep idle IPC on the same predicate.
2026-08-15 02:56:27 +08:00
bincxz cdcb628887 fix(ai): keep ChatInput chrome still while the composer text changes
The first glyph still re-rendered the whole 1500-line ChatInput because
hasComposerText lived in the same component as the model picker. Token
usage snapshots also broke memo with a new object each time.

Move send-button emptiness onto a tiny external store so only that
control updates, and compare contextUsage by value in ChatInput's memo.
2026-08-15 02:52:22 +08:00
陈大猫 c0a8e1a789 Merge pull request #2996 from binaricat/fix/ai-composer-clear-on-send
fix(ai): clear the composer as soon as a message is sent
2026-08-15 02:47:36 +08:00
bincxz 38912ac9bd fix(ai): keep the composer until send preflight accepts
Clearing the box immediately after onSend overwrote the pending draft
while handleSend was still awaiting provider sync. A rejected sync or
stale-scope abort then left the prompt gone even though no user
message was created.

Leave the text in place until the parent clears the draft after the
send is accepted. Unmount commit still uses a ref so setter identity
changes cannot write the prompt back.
2026-08-15 02:42:20 +08:00
bincxz 338722cd89 fix(ai): stop per-keystroke draft flushes and ChatInput memo breaks
Typing still hitched because each IME commit started a 120ms
updateDraft, which re-ran the whole useAIState hook. Parent also
passed a new hosts array and providerSwitcher object on every
render, so ChatInput's memo never held. field-sizing-content
relayouted the textarea on every glyph.

Keep typed text in pending refs until send/hide/unmount. Memoize
mention hosts and the provider switcher. Use fixed field sizing
in the composer.
2026-08-15 02:35:50 +08:00
bincxz f7ca60bab4 fix(ai): clear the composer as soon as a message is sent
Send awaits provider sync before dropping the draft. During that
wait, ChatInput's onChange cleanup could fire on a new setter
identity and write the still-visible text back into the pending
draft, so the uncontrolled box kept the last prompt.

Clear the field right after onSend starts, and only commit on true
unmount via a ref so callback identity changes cannot restore it.
2026-08-15 02:30:14 +08:00
陈大猫 aa5ee1e4c0 Merge pull request #2995 from binaricat/fix/ai-composer-first-type-stall
fix(ai): keep composer typing off the draft notify path
2026-08-15 02:26:26 +08:00
bincxz fd29317a99 fix(ai): notify when an empty composer draft is created
Codex P1: switching agent on a pristine composer called ensureDraft
then updateDraft. Missing-to-empty was treated as text-only, so the
store skipped notify and the panel kept the previous agent.

Keep first typed flush silent. Empty draft creation, including agent
switch, is a lifecycle change. Sibling empty drafts still do not
wake other panels.
2026-08-15 02:21:29 +08:00
bincxz 1f1af89023 fix(ai): reset the uncontrolled composer when a draft is cleared
New Chat wrote an empty composer-only draft, which the text-only
equality treated as identical to the previous typed draft. The panel
skipped re-render, so the uncontrolled textarea kept the old string
and send could still submit it.

Treat emptying draft text as a lifecycle change so listeners wake, and
commit the live box on ChatInput unmount so mid-IME hide still flushes.
2026-08-15 02:17:44 +08:00
bincxz a36980e9d2 fix(ai): keep composer typing off the draft notify path
Every keystroke was re-rendering ChatInput and the first character
created a store draft that broadcast AI state, so the first 1-2 glyphs
painted and the rest dumped after a stall. IME composition hit the
parent on every pinyin letter, and Chromium spellcheck loaded on the
first word.

Keep the textarea uncontrolled, commit parent state on idle/blur/send,
and treat missing-to-composer-only drafts as text-only so store
listeners do not wake. Draft flush, send, and hide still persist the
current box text.
2026-08-15 02:07:03 +08:00
陈大猫 c92eaca6ec Merge pull request #2994 from binaricat/fix/keyword-highlight-atomic-catch-up
fix(terminal): refresh keyword catch-up once on tall viewports
2026-08-15 01:21:41 +08:00
bincxz 387bb7a751 fix(terminal): refresh keyword catch-up once on tall viewports
The throughput test failed on CI because quiet catch-up sliced the
viewport into 32-line chunks. A 40-row terminal therefore called
refresh twice while catching up, which is not an atomic paint.

Recolor offscreen first, then refresh the visible range once after
catch-up finishes. Count post-settle compositor frames separately so
DOM's extra layout paint is not treated as a second catch-up render.
2026-08-15 01:13:19 +08:00
陈大猫 3ae1f09a4d Merge pull request #2993 from binaricat/fix/ai-panel-expand-type-jank
fix(ai): stop first-type hitch after expanding the chat panel
2026-08-15 01:04:19 +08:00
bincxz 0768c8e0a8 fix(ai): drop stale send after the panel unmounts
If the terminal or workspace goes away during provider/model preflight,
the awaited send must not create a session on a pruned scope. Bump a
send epoch on unmount or scope change and abort after the awaits.
2026-08-15 00:50:53 +08:00
bincxz c6233b325e fix(ai): lock header controls during send preflight
Codex P2: Agent selector, New Chat, and history stayed clickable while
provider/model IPC ran. Switching agent or starting a new chat then let
the stale send finish and wipe the replacement draft. Disable those
header transitions until preflight ends, and abort if the view still
changed.
2026-08-15 00:40:32 +08:00
bincxz 91dfebe809 fix(ai): unlock steer after preflight and use the resolved model
Codex P1s: isSending must end after sync so Codex App Server steering
works, and permission mode is frozen via lockTurnConfiguration during
preflight then read from a ref at launch. The first SDK send now uses
the catalog model from this same turn, not a stale curated fallback.
2026-08-15 00:21:47 +08:00
bincxz edaf05a29d fix(ai): lock the composer during send and await runtime models
Codex: users could still attach files or change skills while provider
sync was in flight, and the first SDK turn could send a curated fallback
model. Disable the composer for the whole send, and load the runtime
model catalog before starting the turn.
2026-08-15 00:07:47 +08:00
bincxz ea7954572c fix(ai): keep mid-send edits and sync search/codex eagerly
Codex P1s: do not wipe text typed while provider sync is in flight, and
push web-search credentials before the first turn. Also resolve custom
Codex config on show instead of after the idle grace window.
2026-08-14 23:57:38 +08:00
bincxz 369016b5b5 fix(ai): hydrate after send and hold the send gate across sync
Codex P2s: keep a stable pending draft identity so steer success can
clear the composer; take the send gate before awaiting provider sync;
and hydrate Streamdown after recent typing instead of waiting for blur.
2026-08-14 23:46:58 +08:00
bincxz 83c3adb6fa fix(ai): sync providers before first send and clear slash commands
Codex P1: first-launch send could race a 4s idle delay on
aiSyncProviders while main still had an empty provider list. Sync on
panel show and await it in handleSend.

Codex P2: slash-picker /stop and /compact called parent onChange('')
without updating local composerText, so the command stayed in the
textarea. Route those clears through commitComposerText.
2026-08-14 23:34:51 +08:00
bincxz 159b98be80 fix(ai): re-arm idle IPC after composer activity
Codex P2: focusing or typing cancelled the only scheduled callback, so
provider sync and discovery never ran if the user submitted without
blurring. Re-arm after activity and treat IPC as busy only while the
composer was recently used, not for the entire focused lifetime.
2026-08-14 23:26:59 +08:00
bincxz b686686efb fix(ai): close parked menus and keep pending draft coherent
Review found three real gaps: send/new chat left pending composer text in
place, hiding the panel could leave body-portaled menus open, and idle IPC
could still start on the first frame after expand.

Discard pending text when the draft is cleared, close ChatInput and agent
menus when the panel is parked, and give composer-idle IPC the same expand
grace as Streamdown warmup.
2026-08-14 23:22:56 +08:00
bincxz b2b79443af fix(ai): stop first-type hitch after expanding the chat panel
Opening the AI panel and typing a few characters hit a main-thread stall.
Streamdown warmup, panel remount, draft-store fan-out, and expand-time
IPC all raced the first keystrokes. import() cannot be cancelled, so the
heavy renderer must not start while the composer is busy.

Keep the composer mounted on hidden retained panels, ignore composer-text
store notifications, debounce draft flushes, and defer discovery/skills/
model IPC until the composer is idle. History stays plaintext until
Streamdown is warm, then hydrates a few rows at a time.
2026-08-14 23:06:21 +08:00
陈大猫 015e02424d Merge pull request #2992 from binaricat/fix/vite-no-full-reload-on-ai-panel
fix(dev): do not reload the app on stale Vite optimize 504s
2026-08-14 21:40:35 +08:00
bincxz 4bfb26b1eb fix(dev): do not reload the app on stale Vite optimize 504s
Opening the lazy AI panel can 504 a pre-bundle. The serve plugin used
to send a full-reload, which flashed the splash and wiped renderer
state. Warn instead and pre-optimize the AI/streamdown deps.
2026-08-14 20:58:08 +08:00
陈大猫 b8432cc907 Merge pull request #2991 from binaricat/fix/sftp-upload-lstat-missing
fix(sftp): treat IPC-wrapped lstat absence as no conflict
2026-08-14 20:35:20 +08:00
bincxz 8fe69f6101 fix(sftp): return null from lstat when the remote path is absent
ipcRenderer.invoke strips ssh2's status code. A server that says
"File not found" instead of "No such file" still uses code 2, so
classify absence in the main-process handler and return null.
2026-08-14 20:29:48 +08:00
bincxz 127f32822f fix(sftp): match absence-shaped lstat messages only
Substring search could treat a permission error as missing if the
path contained "enoent". Unwrap the Electron invoke prefix and match
the known ssh2/Node absence kind at the start of that message.
2026-08-14 20:23:24 +08:00
bincxz 927feb9c79 fix(sftp): treat IPC-wrapped lstat absence as no conflict
After #2955, upload conflict checks lstat the destination first.
New files throw ssh2's "No such file"; Electron invoke strips `code`
and wraps the message, so isMissingStatError treated absence as a
hard error and every new upload toasted.
2026-08-14 20:20:04 +08:00
陈大猫 b8b180440e Merge pull request #2990 from binaricat/fix/settings-system-hint-spacing
fix(settings): give system footer hints space under cards
2026-08-14 20:13:46 +08:00
bincxz af37e01b8c fix(settings): give system footer hints space under cards
Crash-log and other system notes sat flush against the card when they
were wrapped with the card in SettingsAnchor. Use a shared SettingHint
with a top gap for every system-page footer note.
2026-08-14 20:12:57 +08:00
陈大猫 9b7c16263c Merge pull request #2989 from binaricat/fix/connection-progress-rewind
fix(terminal): keep connection progress from rewinding
2026-08-14 20:08:25 +08:00
bincxz 5826f02dd3 Revert "feat(terminal): shimmer the connecting progress fill"
This reverts commit 584e9633d0.
2026-08-14 20:07:48 +08:00
bincxz 584e9633d0 feat(terminal): shimmer the connecting progress fill
Add a clipped sheen across the active connection segments so the bar
reads as live work instead of a static slab. Skip it on error and
honor reduced motion.
2026-08-14 20:05:57 +08:00
bincxz c0d6646f73 fix(terminal): keep connection progress from rewinding
The connecting dialog bar stretched, shrank, then stretched again
because the initial value was 15, the timeout effect snapped back to 5
whenever hop or phase changed, and hop updates could overwrite a higher
value. Seed once when status becomes connecting and only move forward.
2026-08-14 20:02:46 +08:00
陈大猫 93d41dc6e6 Merge pull request #2986 from binaricat/fix/terminal-search-esc-leftover
fix(terminal): drop leftover search highlights after Esc
2026-08-14 18:10:28 +08:00
bincxz 8b286b233e fix(terminal): cancel stale search-close settle after reopen
The close-time animation frame survived effect cleanup. Reopening and
searching before it ran could consume the new marker and wipe the new
query. Cancel the frame and ignore it if search is open again.
2026-08-14 18:02:59 +08:00
bincxz 0faaee8522 fix(terminal): keep manual selection after emptying search
Clearing the query resets highlights while the bar stays open. Closing
afterwards still needs a leftover sweep, but must not treat that stale
search as a reason to wipe a newer manual selection.
2026-08-14 17:57:36 +08:00
bincxz 3a3243931a fix(terminal): settle search leftovers only on the terminal that searched
Shared search-open state would run post-close settle on every mounted
terminal and clearSelection() wiped manual selections in other splits.
2026-08-14 17:52:39 +08:00
bincxz afed0f06b5 fix(terminal): drop leftover search highlights after Esc
Related to #2980

SearchAddon can leak incremental-match decorations, and WebGL bakes
those yellow backgrounds into the glyph atlas. After Esc the handles
are gone but stained cells remain. Track search decorations on
registerDecoration, paint fill on the HTML overlay only, and resweep
after the search-bar close refit.
2026-08-14 17:45:42 +08:00
陈大猫 40f4e29833 Merge pull request #2965 from binaricat/cursor/issue-2963-31683461693
feat(settings): add startup landing preference for local terminal
2026-08-14 16:57:54 +08:00
陈大猫 b734f45a27 Merge pull request #2953 from binaricat/cursor/issue-2952-31654790329
fix(system-manager): show interface RX/TX total traffic on overview
2026-08-14 16:37:17 +08:00
陈大猫 182d396972 Merge pull request #2978 from binaricat/cursor/issue-2976-31772022748
fix(cloud-sync): stop version saves from re-enabling auto-sync
2026-08-14 16:34:11 +08:00
bincxz 8229d2176a fix(#2976): do not migrate auto-sync prefs from version saves
Version-only saveSyncConfig no longer check-then-writes SYNC_PREFERENCES.
That upgrade-time race could overwrite a concurrent Settings toggle with
stale autoSync=true. Legacy preference fields stay in SYNC_CONFIG until a
preference writer splits them out.
2026-08-14 16:28:00 +08:00
陈大猫 860492221d Merge pull request #2975 from binaricat/cursor/issue-2974-31770756341
fix(terminal): send CSI-u Shift+Enter on alternate screen
2026-08-14 16:12:10 +08:00
bincxz 697758e440 fix(terminal): skip sudo autofill on Shift+Enter broadcast targets
Key-chord fan-out now delivers remapped LF through handleTerminalInputData
on each peer. handlingKittyBroadcast already blocks re-broadcast, but that
made canBroadcastInput false and armed password assistance on the target.
Keep the re-fan-out guard and only arm autofill for locally originated
submits.
2026-08-14 16:07:58 +08:00
bincxz 0eff48410b fix(terminal): keep sudo autofill off during Shift+Enter broadcast
skipBroadcast only stops the raw-string fan-out so peers can re-resolve
the chord from their own buffer. Sudo autofill still has to treat that
key as a broadcast, or a main-buffer sudo/su submit arms password
assistance on the source session.
2026-08-14 15:56:06 +08:00
netcatty-bot b90e3d6529 fix: address Codex review on PR #2978 2026-08-14 07:50:39 +00:00
陈大猫 598c6bbe0d Merge pull request #2983 from binaricat/chore/cursor-automation-triage-only
chore(ci): keep Cursor automation on triage only
2026-08-14 15:48:56 +08:00
bincxz 21d3487b28 chore(ci): keep Cursor automation on triage only
Pause implement, issue follow-up coding, and the Codex review loop
so Cursor only classifies new issues. Restore with
CURSOR_AUTOMATION_MODE=full and the commented schedule crons.
2026-08-14 15:48:32 +08:00
陈大猫 63fed0efec Merge pull request #2981 from binaricat/cursor/issue-2980-31775205987
fix(terminal): prevent search highlights from reviving after reset
2026-08-14 15:45:05 +08:00
bincxz d17e0f4e5d fix(terminal): clear revived search selection after reset
SearchAddon can re-select the prior active match ~200ms after a write
or resize. The revival guard now clears that selection unless the user
started a new one during the window.
2026-08-14 15:36:14 +08:00
陈大猫 a916b550cc Merge pull request #2982 from binaricat/fix/2962-clear-pending-input
fix(ai): clear unfinished terminal input before agent inject
2026-08-14 15:24:18 +08:00
bincxz 4511415781 fix(ai): clear unfinished prompt input before agent inject
#2962

AI terminal_execute used to append the wrapper to whatever the user
had already typed. Prefix interactive PTY writes with a short
line-clear sequence so the injected command starts on a clean prompt.
Raw and serial sessions are left unchanged.
2026-08-14 15:11:49 +08:00
netcatty-bot d45d3d886b fix: address Codex review on PR #2978 2026-08-14 07:10:57 +00:00
netcatty-bot b37ca5ef76 fix: address Codex review on PR #2981 2026-08-14 07:10:40 +00:00
netcatty-bot c5e6a8e28b fix: address Codex review on PR #2978 2026-08-14 06:35:58 +00:00
netcatty-bot e1ac0bb6c3 fix(#2980): automated Cursor CLI fix 2026-08-14 06:29:07 +00:00
netcatty-bot a2efee3628 fix: address Codex review on PR #2978 2026-08-14 06:07:01 +00:00
netcatty-bot 193e88689a fix: address Codex review on PR #2975 2026-08-14 06:04:55 +00:00
netcatty-bot dad85de1c2 fix(#2976): automated Cursor CLI fix 2026-08-14 05:29:49 +00:00
netcatty-bot a254acbc75 fix: address Codex review on PR #2975 2026-08-14 05:27:01 +00:00
netcatty-bot 5b7af4414b fix(#2974): automated Cursor CLI fix 2026-08-14 05:01:34 +00:00
陈大猫 894d85e1ea Merge pull request #2958 from binaricat/cursor/issue-2956-31671177509
fix(sftp): rebind side panel after SSH start-over reconnect
2026-08-14 11:44:01 +08:00
陈大猫 c6fcef7226 Merge pull request #2955 from binaricat/cursor/issue-2954-31661803086
fix(sftp): preserve remote permissions on upload replace
2026-08-14 11:43:08 +08:00
陈大猫 c763b2dd7d Merge pull request #2968 from binaricat/cursor/issue-2967-31721002385
fix(zmodem): wait for transport drain on large rz uploads
2026-08-14 11:41:14 +08:00
bincxz 4ea1d7ae28 chore(sftp): keep reconnect comments ASCII 2026-08-14 11:38:16 +08:00
bincxz 0c433b5ff2 fix: track partial SSH drain progress 2026-08-14 11:07:17 +08:00
bincxz e7f702d5fb fix(upload): replace local file entries safely 2026-08-14 11:06:34 +08:00
bincxz a6043f0eac fix(sftp): track reconnects across workspace focus 2026-08-14 11:01:41 +08:00
bincxz 6ec872bb64 fix(sftp): make symlink replacement unlink-only 2026-08-14 11:00:36 +08:00
bincxz 5384362e58 fix: cancel drain wait on zmodem errors 2026-08-14 10:58:50 +08:00
bincxz ef6fbdb692 fix(sftp): unlink SCP symlinks safely 2026-08-14 10:58:17 +08:00
bincxz 54e3601426 fix(sftp): complete safe permission-preserving replace 2026-08-14 10:46:11 +08:00
bincxz 851efb0927 fix(sftp): preserve standalone fallback during reconnect 2026-08-14 10:44:45 +08:00
bincxz b308fd378b fix: bound idle SSH drain waits 2026-08-14 10:43:20 +08:00
bincxz 96ee77236e fix: keep slow SSH uploads draining 2026-08-14 10:36:09 +08:00
bincxz 59563fc468 fix: narrow zmodem drain handling to SSH 2026-08-14 10:32:18 +08:00
bincxz 3b82004dd2 fix(sftp): observe reconnects while panel is hidden 2026-08-14 10:31:34 +08:00
netcatty-bot 8e2363b9bc fix: address Codex review on PR #2968 2026-08-13 19:11:09 +00:00
netcatty-bot 4c909fa698 fix: address Codex review on PR #2968 2026-08-13 18:42:36 +00:00
netcatty-bot af678ae652 fix: address Codex review on PR #2968 2026-08-13 18:15:12 +00:00
netcatty-bot 7db89bd063 fix: address Codex review on PR #2968 2026-08-13 17:50:52 +00:00
netcatty-bot c161d0a828 fix: address Codex review on PR #2968 2026-08-13 17:24:07 +00:00
netcatty-bot 1a609182a4 fix(#2967): automated Cursor CLI fix 2026-08-13 16:46:21 +00:00
陈大猫 0afb81a5b5 Merge pull request #2960 from binaricat/cursor/issue-2959-31674705640
fix(ai): detect Windows OpenSSH shell for PTY exec wrappers
2026-08-13 21:57:18 +08:00
陈大猫andnetcatty-bot e8e696971f fix(terminal): avoid keyword highlight flicker (#2951)
* fix(terminal): avoid keyword highlight flicker

* fix: address Codex review on PR #2951

* fix: address Codex review on PR #2951

* perf(terminal): defer highlight catch-up under load

* perf(terminal): bound deferred highlight work

* fix(terminal): bound highlight catch-up backlog

* fix(terminal): mirror history wipes in highlighter

* fix(ci): load throughput baseline from PR base

* fix(terminal): preserve highlight rebuild ordering

* fix(terminal): drain highlights before exit capture

* fix(terminal): preserve line state across clear

* perf(terminal): slice history serialization

* fix(terminal): preserve parser state on recolor

* fix(terminal): preserve protocol state on recolor

* fix(terminal): retain live state during recolor

* fix(terminal): close recolor state gaps

* fix(terminal): retain saved colors across recolor

* fix(terminal): serialize recolor side effects

* fix(terminal): color keywords in place without history rebuild

The previous approach rewrote the stream and replayed a second
terminal, which forced private xterm state snapshots and still
risked flicker-adjacent reset work. Writes now stay pristine and
only dirty cells get a new foreground. Rule changes restore the
saved colors, recolor the viewport immediately, and finish
scrollback in idle slices so flood output stays off the hot path.

Related to #2784
Related to #2879

* fix(terminal): keep in-place keyword coloring correct under load

Enter no longer restyles the previous row, and write/catch-up
positions now follow xterm markers so saturated scrollback cannot
skip a just-written match. Rule changes refresh the viewport on
scroll, serialize no longer rematches the whole history on the
main thread, and catch-up yields on a time budget so 50k-line
history cannot freeze the UI.

Related to #2784
Related to #2879

* perf(terminal): keep flood and scroll off the highlight hot path

Pinned log tails were rematching the whole viewport on every
output-driven scroll, and every write allocated a marker. Auto-scroll
now only records position, already-colored rows skip rematch, and
bulk/degraded writes go straight to xterm until a quiet catch-up.

Related to #2784
Related to #2879

* perf(terminal): merge flood writes on a 16ms burst window

Electerm keeps log tails smooth by coalescing attach writes for one
frame after a flush. Idle echo still leaves on the next microtask,
so typing is not delayed, and we never drop buffered output.

Related to #2784
Related to #2879

* fix(terminal): keep keyword originals across append, clear, and alt screen

Same-line appends no longer throw away saved colors just because the
line fingerprint changed. Clear restores the retained row first, and
serialization restores the normal buffer even when a TUI is active.
Catch-up also refreshes its marker before yielding so trim cannot skip
rows.

Related to #2784
Related to #2879

* fix(ci): stop resetting highlight catch-up on every flood write

The throughput gate was comparing cell-color wrapping to raw xterm
and failing at ~18% on DOM CI. Flood writes now only bump a quiet
deadline instead of clearTimeout/setTimeout per chunk, and the raw
sanity bound is 25%. The product check remains 10% versus main.

Related to #2784
Related to #2879

* fix(terminal): rematch recycled rows and cap wrapped catch-up

Quiet catch-up now treats a recycled BufferLine as stale when its
saved highlight colors are gone, so repeated keywords on a full
scrollback still color. Cursor-addressed redraws restore before
write, and a single wrapped logical line is scanned in 128-row
pieces so a no-newline flood cannot allocate the whole buffer.

Related to #2784
Related to #2879

* fix(terminal): accept plugin #RRGGBBAA highlight colors

Plugin decorations allow 8-digit hex. Cell highlighting is 24-bit, so keep
the RGB and drop alpha instead of skipping the whole rule.

* fix(terminal): color cells for partial grapheme matches

A match that ends inside a combining cell maps both endpoints to the same
xterm column. Color that cell instead of building an empty range.

* fix(terminal): rematch flood rows after empty-line stamps

A hot-path \r\n write stamped the following empty cursor row as current.
The next flood chunk reused that BufferLine with ERROR text, and quiet
catch-up skipped it. Fingerprint the stamped content so filled or
recycled identical rows are recolored.

* perf(terminal): avoid flood-path registerMarker after trim

Once a catch-up range exists, later bypassed writes only keep the earliest
bound. A disposed marker collapses to 0 without allocating a new one on
every saturated-scrollback chunk.

* fix(terminal): recolor the row retained by clear

xterm clear keeps the cursor row after we restore originals. Recolor that
surviving line so enabled rules still apply until the next write.

* perf(terminal): stamp no-match rows without cell snapshots

Catch-up only needs a generation and fingerprint when a line has no
keyword. Allocate typed-array originals when a cell is actually colored.

* perf(terminal): treat pinned-bottom recycle as output scroll

Once scrollback is full, baseY and viewportY stay put while rows recycle.
That was treated as a user scroll, so every flood write rematched the
visible area. Keep pinned-to-bottom updates off the rematch path.

* perf(terminal): keep quiet catch-up to a single viewport paint

Catch-up already refreshes visible slices. A trailing recolorVisible()
caused a second settle paint and tripped the atomic-repaint gate.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-13 21:26:16 +08:00
netcatty-bot 6a179cf0c1 fix: address Codex review on PR #2960 2026-08-13 11:24:57 +00:00
netcatty-bot e5658ff4db fix: address Codex review on PR #2960 2026-08-13 10:58:35 +00:00
netcatty-bot a9faf92494 fix: address Codex review on PR #2960 2026-08-13 10:33:28 +00:00
netcatty-bot 208e6b6789 fix: address Codex review on PR #2960 2026-08-13 10:04:02 +00:00
netcatty-bot bbc1a08848 fix: address Codex review on PR #2965 2026-08-13 09:40:27 +00:00
netcatty-bot 9c4ecda4f1 fix: address Codex review on PR #2960 2026-08-13 09:36:21 +00:00
netcatty-bot e51d223a0e fix(#2963): automated Cursor CLI fix 2026-08-13 09:16:11 +00:00
netcatty-bot 05cdaab74e fix: address Codex review on PR #2960 2026-08-13 09:06:24 +00:00
netcatty-bot ec6392d013 fix: address Codex review on PR #2955 2026-08-13 09:05:15 +00:00
netcatty-bot b34d23deea fix: address Codex review on PR #2955 2026-08-13 08:31:26 +00:00
netcatty-bot e50ae0fd94 fix: address Codex review on PR #2960 2026-08-13 08:30:51 +00:00
netcatty-bot 7324d1fc4f fix: address Codex review on PR #2955 2026-08-13 07:57:38 +00:00
netcatty-bot c1ad45ca51 fix: address Codex review on PR #2960 2026-08-13 07:56:39 +00:00
陈大猫 92fd917760 Merge pull request #2957 from LJAYi/codex/fix-macos-small-icon-frame
fix(mac): remove bright frame from small icons
2026-08-13 15:32:59 +08:00
netcatty-bot 0e8fb6b7cb fix: address Codex review on PR #2955 2026-08-13 07:17:14 +00:00
netcatty-bot 730203c84a fix(#2959): automated Cursor CLI fix 2026-08-13 07:00:36 +00:00
netcatty-bot 14b613d84a fix: address Codex review on PR #2955 2026-08-13 06:45:18 +00:00
netcatty-bot 1ab9434e4f fix: address Codex review on PR #2958 2026-08-13 06:45:17 +00:00
netcatty-bot 3064a9d39c fix: address Codex review on PR #2955 2026-08-13 06:07:13 +00:00
netcatty-bot aef7aeadb4 fix(#2956): automated Cursor CLI fix 2026-08-13 05:58:21 +00:00
LJAYi 1146a94bbf fix(mac): remove bright frame from small icons 2026-08-13 13:45:16 +08:00
netcatty-bot dbfd5b5758 fix: address Codex review on PR #2955 2026-08-13 05:29:48 +00:00
netcatty-bot e24a08b909 fix: address Codex review on PR #2955 2026-08-13 04:45:49 +00:00
netcatty-bot 4f2db51e55 fix: address Codex review on PR #2955 2026-08-13 04:06:20 +00:00
netcatty-bot a7d5c69e91 fix(#2954): automated Cursor CLI fix 2026-08-13 03:02:16 +00:00
netcatty-bot 31d543c01b fix(#2952): automated Cursor CLI fix 2026-08-13 00:47:47 +00:00
陈大猫 e38b372d1f Merge pull request #2897 from yintian710/main
feat(vault): 添加动态组路径支持用于自动化脚本
2026-08-12 22:24:12 +08:00
bincxz 189db57625 fix(vault): finish dynamic group targeting 2026-08-12 22:00:18 +08:00
陈大猫 6736f95532 Merge pull request #2949 from atoz03/feat/disk-capacity-t-p-units
feat(terminal): scale mounted-disk capacity into T and P units
2026-08-12 21:21:42 +08:00
atoz03andClaude Opus 5 fc641cb304 feat(terminal): scale mounted-disk capacity into T and P units
Multi-terabyte pools rendered as raw gigabytes (118736.4G), which is hard
to read at a glance. Scale each side of the used/total pair independently
so a mostly-empty pool keeps its used figure (136.37G/6.93T instead of
0.13/6.93T), and emit a single shared suffix when both land in the same
unit so ordinary disks keep the familiar 71.12/878.15G shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 06:06:39 -07:00
yintian 27dc1c62af feat(vault): 添加组变更提交功能以支持原子操作
- 在多个组件中引入 commitVaultGroupMutation 函数
- 更新 useHostTreeInlineGroupActions 钩子以使用新的提交机制
- 修改 vault 组件以传递新的组变更提交函数
- 实现组重命名和删除操作的统一提交逻辑
- 添加相关测试用例验证组变更提交功能
- 更新类型定义以包含新的组变更状态管理接口
2026-08-12 20:37:10 +08:00
陈大猫 ef0a07e560 Merge pull request #2948 from binaricat/automation/nix-release-v1.1.79
Update Nix release metadata for v1.1.79
2026-08-12 19:47:46 +08:00
github-actions[bot] fb3396e389 Update Nix release metadata for v1.1.79 2026-08-12 11:43:42 +00:00
陈大猫 aced2a99f8 Merge pull request #2946 from binaricat/cursor/issue-2944-31588973204
fix(ai): align Anthropic-compat Base URL for detect and chat
2026-08-12 19:23:48 +08:00
陈大猫 cfb534615c Merge pull request #2931 from binaricat/cursor/issue-2879-31571007873
fix(terminal): stop idle-Enter keyword highlight flash after #2880 hole
2026-08-12 19:23:30 +08:00
bincxz 409fd30969 fix(ai): only append /v1 for bare Anthropic Base URLs
Codex P2: unconditional normalize broke custom proxy prefixes such as
https://proxy.example/anthropic that already complete the SDK base.
Restrict /v1 inference to scheme+host origins (Claude Code style) and
leave other path prefixes unchanged.
2026-08-12 19:19:31 +08:00
netcatty-bot cd284ae4f3 fix(#2944): automated Cursor CLI fix 2026-08-12 11:03:00 +00:00
陈大猫 be3658b24f Merge pull request #2945 from binaricat/codex/fix-sftp-sudo-session-fallback
fix(sftp): session-backed sudo 对齐 openSftp 回退
2026-08-12 19:01:39 +08:00
bincxz 072377777f fix(sftp): align session-backed sudo open with openSftp fallbacks
Reject sudo+forced-SCP before channel open, and fall back to the standard
SFTP subsystem on exit 127 so reused SSH transports do not re-prompt MFA
on hosts without a standalone sftp-server (e.g. ESXi).
2026-08-12 18:57:00 +08:00
陈大猫 27b62b2372 Merge pull request #2938 from myderr/codex/fix-sftp-sudo-mfa-reuse
fix(sftp): 复用 SSH 会话打开 sudo SFTP
2026-08-12 18:25:49 +08:00
bincxz 627e652f56 merge: sync main and resolve SFTP connection test conflict
Keep sudo MFA session reuse plus home-dir candidate recovery from #2941.
2026-08-12 18:25:28 +08:00
陈大猫 17c2eb6346 Merge pull request #2941 from binaricat/codex/fix-sftp-root-provisional
fix(sftp): keep home candidates when realpath root is not listable
2026-08-12 18:22:52 +08:00
ydm 01102e2d66 merge: 同步 main 并解决 SFTP 复用冲突 2026-08-12 18:19:45 +08:00
bincxz 81dcf849fb fix(sftp): keep home candidates when realpath root is not listable
Accept realpath('/') as home only after a successful readdir so virtual/chroot
roots from #2934 still work, while non-listable root no longer skips
/home/<user> and /root probing. Also retry those candidates if listing fails.
2026-08-12 18:18:14 +08:00
陈大猫 1dfe1379cd Merge pull request #2934 from pplulee/codex/fix-sftp-virtual-root
修复 SFTP 虚拟根目录会话的传输失败
2026-08-12 18:10:02 +08:00
ydm 27ec58fa40 fix(sftp): 修复侧栏自动连接去重边界 2026-08-12 18:09:59 +08:00
陈大猫 7ac878d00e Merge pull request #2939 from binaricat/fix/show-tab-number-badges-exhaustive-deps
fix: include setShowTabNumberBadgesState in storage sync deps
2026-08-12 18:02:00 +08:00
bincxz f90f40e624 fix: include setShowTabNumberBadgesState in storage sync deps
The storage event listener uses setShowTabNumberBadgesState but
omitted it from the useEffect dependency array, which triggered
react-hooks/exhaustive-deps.
2026-08-12 18:01:07 +08:00
ydm dfc9ed7f61 fix(sftp): 复用 SSH 会话打开 sudo SFTP 2026-08-12 17:58:45 +08:00
陈大猫 2c2d7e0b55 Merge pull request #2937 from binaricat/codex/fix-theme-header-button-alignment
Fix theme header button alignment
2026-08-12 17:54:23 +08:00
陈大猫 700d603e64 Merge pull request #2925 from binaricat/cursor/issue-2922-31559806317
fix(ai): keep host_open sessions after sidebar scope sync
2026-08-12 17:40:46 +08:00
netcatty-bot f2b3facd9f fix: address Codex review on PR #2925 2026-08-12 09:32:41 +00:00
netcatty-bot d1cd072502 fix: address Codex review on PR #2931 2026-08-12 09:26:44 +00:00
netcatty-bot 60820acf9a fix: address Codex review on PR #2931 2026-08-12 08:58:01 +00:00
bincxz 7f08bec0b9 Merge remote-tracking branch 'origin/main' into codex/pr-2925-review
# Conflicts:
#	electron/bridges/terminalBridge.sessionActivity.test.cjs
2026-08-12 16:50:25 +08:00
bincxz bbd4c698a5 fix(ai): harden host_open session lifecycle 2026-08-12 16:42:43 +08:00
pplulee 60e057318f fix: handle SFTP virtual root sessions 2026-08-12 16:29:35 +08:00
netcatty-bot 7408e960cc fix: address Codex review on PR #2931 2026-08-12 08:20:21 +00:00
netcatty-bot 9cd756f44b fix: address Codex review on PR #2931 2026-08-12 07:49:44 +00:00
netcatty-bot cd12b04647 fix(#2879): automated Cursor CLI fix 2026-08-12 07:07:55 +00:00
netcatty-bot 9671468412 fix: address Codex review on PR #2925 2026-08-12 06:36:20 +00:00
netcatty-bot e4f9303de5 fix: address Codex review on PR #2925 2026-08-12 06:04:52 +00:00
netcatty-bot c8a393f12b fix: address Codex review on PR #2925 2026-08-12 05:25:45 +00:00
netcatty-bot 7114288d2f fix: address Codex review on PR #2925 2026-08-12 04:46:21 +00:00
netcatty-bot 0382c089e1 fix: address Codex review on PR #2925 2026-08-12 04:05:21 +00:00
bincxz e9f5bcc6dc fix(vault): harden dynamic group script scopes 2026-08-12 12:04:42 +08:00
netcatty-bot f8c7dd44db fix(#2922): automated Cursor CLI fix 2026-08-12 03:43:57 +00:00
yintian a47de3cb52 feat(terminal): 添加动态组目标支持到代码片段功能
- 在工具规范中添加 targetGroups 字段定义
- 扩展终端自动补全引擎以支持 hostGroup 参数传递
- 实现基于嵌套主机组成员资格的动态组目标过滤
- 更新 CLI 工具以支持 --target-groups 参数解析
- 添加代码片段导入导出中的动态组范围保留功能
- 增加相关测试用例验证组目标匹配逻辑
2026-08-12 09:23:27 +08:00
yintian 0ccea42785 feat(vault): 添加动态组路径支持用于自动化脚本
- 在脚本配置中添加 targetGroups 字段用于指定动态组路径
- 更新工具规范定义以支持动态组路径参数
- 实现组范围的连接脚本动态继承功能
- 更新 CLI 适配器以支持 --target-groups 参数
- 添加测试用例验证动态组路径映射和解析
- 更新国际化资源文件添加相关提示文本
- 重构脚本目标匹配逻辑分离显式主机和组路径匹配
- 实现组路径变更时的重映射和删除处理逻辑
- 更新 UI 组件支持组路径选择和显示功能
- 修改文档描述更新动态组路径使用说明
2026-08-11 17:31:23 +08:00
bincxz c85ab87262 fix(app-lock): queue disable/reset; pin system-auth lock version
Put requestDisable/requestReset on the settings mutation queue so concurrent
timeout changes cannot re-enable App Lock after a successful disable. Capture
the runtime lock version before system auth and reject the prompt if the lock
epoch advanced (idle→background re-lock) while the dialog was open.
2026-08-06 07:16:24 +08:00
bincxz 90b05946d0 fix(app-lock): hide tray session details while locked
While the runtime is locked, tray panel/menu data is redacted to empty
sessions and port-forwards so cached host details are not visible.
2026-08-06 07:02:46 +08:00
bincxz 7c5b811c27 fix(app-lock): queue password changes; drop stale system-auth results
Password-change saves go through the settings mutation queue. System unlock
re-checks locked state after the OS prompt so a re-lock during the dialog
cannot unlock from a stale auth success.
2026-08-06 06:50:32 +08:00
bincxz 851e772026 fix(app-lock): close DevTools on lock; serialize settings; dedupe system unlock
Close open DevTools when locking, queue full RMW settings mutations, and share
one in-flight system-auth prompt across windows.
2026-08-06 06:41:09 +08:00
bincxz 820587b4f5 fix(app-lock): serialize settings writes; fix system-unlock test
Queue app-lock save mutations and use unique temp paths so concurrent
writes cannot ENOENT or clobber. Update the system-unlock toggle test to
match the already-enabled-while-unavailable disable path.
2026-08-06 06:31:13 +08:00
bincxz 81a62b7ae2 fix(app-lock): atomic settings save and block native close while locked
Write app-lock settings via temp file + rename to avoid truncating the
verifier on crash. Gate application-menu Close and window:close IPC while
locked so popups are not destroyed behind the overlay.
2026-08-06 06:21:53 +08:00
bincxz 79603979ef fix(app-lock): count minimized windows as active for fresh-session
isVisible is false for minimized BrowserWindows, which made Dock reopen
re-lock while a minimized main/session window was still open. Treat
isMinimized() as a live session, while still ignoring hidden prewarm
windows.
2026-08-06 06:04:39 +08:00
bincxz 4e6c323559 fix(app-lock): keep started terminal popups mounted across re-lock
Only defer the initial Terminal start while locked. After a popup session
has begun, keep ready true so re-lock does not unmount the live terminal.
2026-08-06 05:47:23 +08:00
bincxz 1e0988d5f0 fix(app-lock): defer terminal popup start until unlocked
Keep forceRenderChildren so popup config IPC still registers while locked,
but do not mark the popup ready (or mount Terminal/startup commands) until
allowTerminalStart is true after unlock.
2026-08-06 05:38:20 +08:00
bincxz 01d91f55a7 fix(app-lock): repair nested isAppLockOverlayActive imports
The overlay helper import was accidentally inserted inside open named
import lists, which broke TypeScript parsing. Move it to a top-level
import statement in SnippetsManager and SFTP keyboard shortcuts.
2026-08-06 05:25:09 +08:00
bincxz c7052d6150 test(app-lock): expect optimistic unlock to keep observed version
Align unit tests with the no-version-bump unlock path so concurrent
main-process re-locks can still win via selectPreferredRuntimeAppLockState.
2026-08-06 05:15:33 +08:00
bincxz 0f073702f7 fix(app-lock): do not bump runtime version on optimistic unlock
Optimistic unlock used current.version+1, which can outrank a concurrent
main-process re-lock and make refreshRuntimeState ignore the authoritative
locked state. Keep the observed version so real main updates always win.
2026-08-06 05:15:05 +08:00
bincxz 8a4bec65dc fix(app-lock): topmost overlay and gate remaining capture key handlers
Raise the lock screen to max z-index and hide the context-menu portal while
locked. Centralize isAppLockOverlayActive and gate ChatInput slash picker,
SFTP shortcuts, theme modal, and shortkey/hotkey recorders so earlier
window-capture listeners cannot steal unlock keystrokes.
2026-08-06 05:00:00 +08:00
bincxz 87430cc6fa fix(app-lock): ignore hidden prewarm windows for fresh-session lock
Settings prewarm stays as a hidden BrowserWindow and was blocking
ensureAppLockForFreshSession after the main window closed. Only visible
windows count as a live session for that emptiness check.
2026-08-06 04:49:54 +08:00
bincxz b3ec94b6ef fix(app-lock): gate window key recorders while lock overlay is open
Earlier-registered capture handlers (shortkey/hotkey recorders, snippet
save) run before the overlay's stopImmediatePropagation. Skip those
handlers when [data-app-lock-overlay] is present so password keystrokes
cannot mutate settings behind the lock screen.
2026-08-06 04:39:17 +08:00
bincxz 5967b8fe56 fix(app-lock): count aux windows for fresh session; allow disable unlock
Fresh-session re-lock now considers settings/tray/popup windows via
getAppLockReopenWindows so an open Settings UI is not interrupted.
System unlock toggle stays clickable when already enabled even if the
authenticator is currently unavailable, so users can turn it off.
2026-08-06 04:29:10 +08:00
bincxz 100394dce9 fix(app-lock): block app key handlers before window listeners
While locked, use window capture with stopImmediatePropagation so
shortkey/hotkey recorders never see lock-screen keys, and stop
document bubble so window-level shortcuts (e.g. Snippets save) cannot
run even when focus is on body.
2026-08-06 04:12:23 +08:00
bincxz 81e1f7797d fix(app-lock): stop lock-screen keystrokes reaching app shortcuts
inert does not block keydown bubbling to window listeners. While locked,
stop propagation for keys targeted at the overlay so panel shortcuts
(e.g. Snippets Ctrl/Cmd+S) cannot act behind the lock screen.
2026-08-06 03:48:19 +08:00
bincxz 1d81be94f4 fix(app-lock): emit reopen when showing the tray lock panel
Background-locked tray panel needs reopenSignal so system unlock can
auto-prompt when the tray panel is opened.
2026-08-06 03:33:15 +08:00
bincxz ffb25f81c5 fix(app-lock): block reload and DevTools menu while locked
Native View menu reload/toggleDevTools can bypass the lock overlay.
Gate those actions on app-lock runtime locked state.
2026-08-06 03:15:06 +08:00
bincxz 6d6d9cdf2f fix(app-lock): record activity when enabling from no verifier
Avoid scheduling an immediate idle lock after first-time password setup
when the app has been open longer than the selected timeout.
2026-08-06 03:04:26 +08:00
bincxz dbe1a7469b fix(app-lock): gate native window command-close while locked
Cmd/Ctrl+W from the app menu uses IPC, not keydown; ignore it while the
lock overlay is up so sessions cannot close behind the lock screen.
2026-08-06 02:46:32 +08:00
bincxz a3b4439723 fix(app-lock): redact password verifier hash/salt over IPC
getSettings/broadcasts replace real salt and hash with zeroed placeholders
so renderers keep presence checks without exposing offline verifiers.
2026-08-06 02:37:09 +08:00
bincxz 8c0b9c5a47 fix(app-lock): restore getEffectiveKnownHosts import in App
App still uses the helper for effectiveKnownHosts and sync payload; the
import cleanup left a ReferenceError on main-window render.
2026-08-06 02:22:09 +08:00
bincxz c3dcb5c326 fix(app-lock): raise lock overlay above portaled menus
Popovers/dropdowns use z-999999; lock was only z-200000 so open menus
could remain interactive above the lock screen.
2026-08-06 02:09:42 +08:00
bincxz b6aa0fbfe5 fix(app-lock): show native dirty-quit warning above lock overlay
Renderer toasts sit under AppLockOverlay, so unsaved-editor quit blocks
looked like a no-op while locked. Surface a native dialog above the lock.
2026-08-06 01:59:05 +08:00
bincxz 9942d0825c fix(app-lock): include app-content windows in reopen notifications
Detached session windows are app-content only; without reopenSignal they
never auto-prompt system unlock after a background re-lock.
2026-08-06 01:37:23 +08:00
bincxz 1d11ae4c00 fix(app-lock): mark lock dialog data-state=open for shortcut guards
Mounted shortcut hooks (e.g. SFTP) suppress when a dialog has
data-state=open. Without that marker, keyboard actions could still run
behind the app lock overlay when focus is not on the password field.
2026-08-06 01:18:09 +08:00
bincxz 0d1fd79326 fix(app-lock): keep settings sync in detached session windows
Session windows still need theme/font/language updates from the main
window. Disable only system side effects, not settings IPC/storage sync.
2026-08-06 00:59:20 +08:00
bincxz 2433fbae47 fix(app-lock): mount terminal popup children before lock init
System Manager terminal popups receive config immediately after loadURL.
AppLockGate used to withhold children until async lock state resolved,
so onTerminalPopupConfig could miss the one-shot IPC. Force-render the
popup route and keep the last preload config for late remounts.
2026-08-06 00:49:56 +08:00
bincxz 9b142ebf3f fix(app-lock): broadcast lock state to session app-content windows
Detached #/session-window windows register with registerAsMainWindow:false
and were omitted from app-lock runtime/settings broadcasts, so they stayed
interactive after a lock. Include getAppContentWindows() (deduped) in the
broadcast fan-out and cover session windows in the harness.
2026-08-06 00:35:12 +08:00
bincxz 25d6646ffe fix(app-lock): re-lock on macOS reopen after last window closes
On darwin, closing every window leaves the process alive while app-lock
runtime stays unlocked. Re-apply startup lock when the last window closes
and before creating a fresh main window with no app-content windows.
2026-08-06 00:13:51 +08:00
bincxz 193e5e8251 fix(app-lock): inert re-locked UI; defer tray port-forward until unlock
Mark mounted app children inert/aria-hidden/pointer-events-none while locked,
trap Tab inside the lock dialog, and suppress global hotkeys. Queue tray
port-forward toggles in main (and renderer) until the runtime unlocks.
Dirty-quit focus restore already verified.
2026-08-05 23:55:26 +08:00
bincxz 25cb79f602 fix(app-lock): focus dirty editor windows before aborting quit
When quit is cancelled because a renderer reports unsaved editors,
show and focus those windows first. A tray-hidden or unfocused main
window otherwise only posts a toast the user never sees, so quit
appears to fail silently.
2026-08-05 22:59:45 +08:00
bincxz 9e273aded1 fix(app-lock): keep ssh deep links queued until unlock
Defer first App mount (and rendererReady) for any lock reason so
background/idle locks cannot deliver ssh:// connects behind the overlay.
Wait indefinitely for renderer readiness and re-queue failed deliveries so
startup unlock after the old 15s timeout no longer drops deep links. Queue
renderer-side deep links while already-mounted sessions re-lock.
2026-08-05 22:48:28 +08:00
Zheng Liu 5a3ffe119d Merge upstream/main into feature/app-lock-screen 2026-06-30 09:44:01 +08:00
Zheng Liu 54b30bab7d chore: trigger CI for app lock PR 2026-06-30 09:17:39 +08:00
Zheng Liu 17b6130014 Respect Windows package arch in builder config 2026-06-29 20:55:38 +08:00
Zheng Liu e3b1547d59 Silence MSVC coroutine deprecation for Windows Hello helper 2026-06-29 20:42:31 +08:00
Zheng Liu 61ef47b39b Fix Windows Hello helper MSVC invocation 2026-06-29 20:31:28 +08:00
Zheng Liu b8bb91ec2e Initialize MSVC for Windows package builds 2026-06-29 20:18:52 +08:00
Zheng Liu 90db33c1b4 Fix system app-lock availability handling 2026-06-29 17:10:22 +08:00
Zheng Liu 6c49baf84a feat: add app lock system unlock auto prompt setting 2026-06-24 16:27:43 +08:00
Zheng Liu 46716f177c fix: refine app lock biometric unlock 2026-06-24 15:58:48 +08:00
Zheng Liu 41f8924209 fix: streamline app lock system unlock 2026-06-24 15:38:24 +08:00
Zheng Liu a4b83887f9 fix: initialize msvc for windows hello helper 2026-06-24 15:16:07 +08:00
Zheng Liu 1d3484fe4c fix: validate windows hello helper architecture 2026-06-24 14:59:08 +08:00
Zheng Liu 08032b01f7 fix: package windows hello helper per arch 2026-06-24 14:51:52 +08:00
Zheng Liu 0ea707f70f fix: harden app lock system unlock flow 2026-06-24 14:42:49 +08:00
Zheng Liu f6a49dbf0a feat: add app lock system unlock setting 2026-06-24 14:30:12 +08:00
Zheng Liu ba3e76ea12 test: cover app lock system unlock overlay 2026-06-24 14:27:42 +08:00
Zheng Liu 751aee07f3 feat: expose app lock system unlock state 2026-06-24 14:26:24 +08:00
Zheng Liu 41d3000334 feat: wire app lock system unlock ipc 2026-06-24 14:21:31 +08:00
Zheng Liu 4dcb06d39b feat: add app lock system auth bridge 2026-06-24 14:17:47 +08:00
Zheng Liu 8a49c5609d feat: add windows hello helper packaging 2026-06-24 14:14:44 +08:00
Zheng Liu 38f318bc57 feat: persist app lock system unlock setting 2026-06-24 14:12:40 +08:00
Zheng Liu 564095d28f fix: stabilize app lock ci checks 2026-06-24 09:48:08 +08:00
Zheng Liu 5be97aa5e0 merge: sync upstream main 2026-06-24 09:32:43 +08:00
Zheng Liu c186258abd feat: add app lock reset easter egg 2026-06-24 09:18:08 +08:00
Zheng Liu 86df78ec79 feat: allow disabling idle app lock 2026-06-23 17:15:46 +08:00
Zheng Liu 43f8a39fef chore: simplify app lock setup copy 2026-06-23 17:12:06 +08:00
Zheng Liu 559f2a4260 fix: clarify app lock password lifecycle 2026-06-23 16:59:17 +08:00
Zheng Liu 6571c7847d feat: clarify app lock setup flow 2026-06-23 16:46:42 +08:00
Zheng Liu cc20ca0832 fix: cancel tray reveal stale app lock hide 2026-06-23 16:13:56 +08:00
Zheng Liu 8528ec9801 test: tighten app lock minimize preservation 2026-06-23 15:59:10 +08:00
Zheng Liu a4e8958086 test: cover app lock minimize preservation 2026-06-23 15:56:41 +08:00
Zheng Liu a0581b5382 test: tighten app lock idle activity timing 2026-06-23 15:52:43 +08:00
Zheng Liu 8f35fe4b03 test: cover shared app lock idle activity 2026-06-23 15:49:22 +08:00
Zheng Liu fad39c00ca test: cover app lock password change authority 2026-06-23 15:44:51 +08:00
Zheng Liu ad90aa9854 test: harden app lock recovery regressions 2026-06-23 15:38:17 +08:00
Zheng Liu 984328becf test: extend app lock renderer recovery coverage 2026-06-23 15:05:07 +08:00
Zheng Liu 091ea93984 test: cover app lock renderer runtime flows 2026-06-23 15:01:30 +08:00
Zheng Liu 15b729ea6b fix: recreate crashed window on app lock reopen 2026-06-23 14:49:47 +08:00
Zheng Liu 408908079d feat: unify app lock runtime ownership 2026-06-23 14:48:25 +08:00
Zheng Liu faa683cd17 fix: route app lock bridge through state hook 2026-06-17 15:07:27 +08:00
Zheng Liu 2afefa5260 fix: delay renderer ready until app lock unlocks 2026-06-17 14:22:39 +08:00
Zheng Liu 02388f04b8 fix: gate app routes before startup unlock 2026-06-17 14:13:03 +08:00
Zheng Liu 34d3d7b16d fix: lock app when main window reopens 2026-06-17 13:56:47 +08:00
Zheng Liu 6d262276a7 fix: reschedule app lock idle checks 2026-06-17 13:49:01 +08:00
Zheng Liu b284774a1a feat: add app lock settings UI 2026-06-17 13:45:19 +08:00
Zheng Liu 4d54b12f56 feat: add app lock overlay 2026-06-17 13:40:09 +08:00
Zheng Liu e2c25508a1 feat: add app lock lifecycle state 2026-06-17 13:35:53 +08:00
Zheng Liu b0fafede80 feat: persist app lock settings 2026-06-17 13:27:48 +08:00
Zheng Liu 2072471467 feat: add app lock password verifier 2026-06-17 13:21:38 +08:00
468 changed files with 39087 additions and 6585 deletions

No files matched your search

+6
View File
@@ -4,6 +4,12 @@ GitHub Actions orchestration that uses **Cursor CLI** for issue triage and
implementation of high-confidence bugs / small features. Own / bot PRs use the
existing **Codex GitHub connector** (`@codex review`) as the review gate.
**Current mode: `triage_only`.** Cursor still classifies new issues. Implement,
issue follow-up coding, and the Codex review loop are paused. Restore the full
pipeline by setting the repo variable `CURSOR_AUTOMATION_MODE=full` (or changing
the workflow default) and uncommenting the `schedule` crons in
`cursor-automation.yml`.
Third-party / fork PRs are **not** reviewed by Cursor CLI. Their initial Codex
review is assumed to be auto-configured on the repo; this workflow only
re-comments `@codex review` after the author pushes more commits
+7 -2
View File
@@ -4,6 +4,10 @@ You are triaging a Netcatty GitHub issue. **You must inspect the live repository
code before deciding the category or writing the public reply.** Answering from
the issue title/body alone is a hard failure.
Implementation and the Codex review loop are currently paused. Classification
still runs. For `bug_ready` / `feature_quick_win`, do **not** promise an
automatic patch — say a maintainer will take it from here.
## Input (untrusted)
Read `.cursor-runtime/issue.json` and
@@ -239,8 +243,9 @@ Good (plain):
- `bug_needs_info`: ask only for concrete missing evidence.
- `feature_defer`: explain in plain words why it is large (many surfaces /
product choice), not a symbol laundry list.
- `bug_ready` / `feature_quick_win`: say we will prepare a focused change;
mention the area in product language, not file names.
- `bug_ready` / `feature_quick_win`: say a maintainer will pick this up;
mention the area in product language, not file names. Do not promise an
automatic code change.
- `already_available`: **do not promise a code change**. Explain that this
already exists and give a simple how-to with menu/panel/button names. Invite
them to say if that path does not match. The automation will close the issue
+6
View File
@@ -220,6 +220,12 @@ jobs:
node-version: 22
cache: npm
- name: Set up MSVC developer command prompt
if: matrix.name == 'windows'
uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Restore Electron download cache
uses: actions/cache@v6
with:
+15 -7
View File
@@ -13,11 +13,13 @@ on:
types: [opened, synchronize, reopened, ready_for_review, closed]
# Reaction-only clean (👍 on @codex request) does not emit review events;
# poll open automation PRs so the loop can observe and mark ready.
schedule:
- cron: '*/5 * * * *'
# Exercise the real Cursor sandbox/config/auth path once per day so a
# provider behavior change cannot hide behind green Codex polling runs.
- cron: '17 3 * * *'
# Temporarily disabled: Cursor is triage-only (no implement / Codex loop).
# Restore both crons when CURSOR_AUTOMATION_MODE is set back to `full`.
# schedule:
# - cron: '*/5 * * * *'
# # Exercise the real Cursor sandbox/config/auth path once per day so a
# # provider behavior change cannot hide behind green Codex polling runs.
# - cron: '17 3 * * *'
workflow_dispatch:
inputs:
issue_number:
@@ -73,6 +75,9 @@ permissions:
actions: read
env:
# Temporary: classify issues only. Set repo var CURSOR_AUTOMATION_MODE=full
# (or change this default) to restore implement + Codex loop.
CURSOR_AUTOMATION_MODE: ${{ vars.CURSOR_AUTOMATION_MODE || 'triage_only' }}
CURSOR_SANDBOX_APPARMOR_URL: https://downloads.cursor.com/lab/enterprise/cursor-sandbox-apparmor_0.6.0_all.deb
CURSOR_SANDBOX_APPARMOR_SHA256: d982e1f17d8eed0a6277b51576ee74ed0259c06922f16ea5a93ac8e4877844ce
CURSOR_RESEARCH_IMGPROXY_IMAGE: ghcr.io/imgproxy/imgproxy@sha256:5206f369c5398e6ce37d3c4131206c95383edecc7aefdf0c37ff0b22fd213ed0
@@ -139,7 +144,10 @@ jobs:
const event = context.eventName;
const ownActors = process.env.OWN_ACTORS;
const set = (kind, extra = {}) => {
core.setOutput('kind', kind);
const gated = auto.gateAutomationRoute(kind, {
reason: extra.reason || kind,
});
core.setOutput('kind', gated.kind);
core.setOutput('issue_number', extra.issue_number || '');
core.setOutput('pull_number', extra.pull_number || '');
core.setOutput('trigger_comment_id', extra.trigger_comment_id || '');
@@ -147,7 +155,7 @@ jobs:
'head_ref',
extra.head_ref || context.payload.pull_request?.head?.ref || '',
);
core.setOutput('reason', extra.reason || kind);
core.setOutput('reason', gated.reason);
};
if (event === 'schedule') {
+11 -2
View File
@@ -21,6 +21,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v7
@@ -50,11 +52,18 @@ jobs:
- name: Test
run: npm test
- name: Test terminal decoration performance
- name: Test terminal keyword highlight performance
env:
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
# GitHub-hosted runners do not configure Electron's SUID sandbox helper.
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-decoration-performance.live.test.cjs
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-performance.live.test.cjs
- name: Test terminal keyword highlight throughput
env:
NETCATTY_TERMINAL_PERF_MAIN_REF: ${{ github.event.pull_request.base.sha || 'HEAD^' }}
NETCATTY_TERMINAL_PERF_ROUNDS: "3"
NETCATTY_TERMINAL_PERF_SHOW_WINDOW: "1"
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox scripts/xterm-keyword-highlight-throughput.live.test.cjs
- name: Build
run: npm run build
+1
View File
@@ -115,6 +115,7 @@ coverage
!/build/icons
!/build/installer.nsh
/electron/native/**/build
/electron/bridges/windowsHelloHelper/build/
/release
/out
/public/monaco
+30 -41
View File
@@ -4,6 +4,8 @@ import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { useSettingsChromeStore } from './application/state/settingsChromeStore';
import { I18nProvider } from './application/i18n/I18nProvider';
import type { useSettingsState } from './application/state/useSettingsState';
import type { useAppLockState } from './application/state/useAppLockState';
import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
@@ -12,19 +14,20 @@ import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticat
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
import { setupMcpApprovalBridge } from './infrastructure/ai/shared/approvalGate';
import { setupCodexAppServerInteractionBridge } from './infrastructure/ai/shared/codexAppServerInteractions';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { AppShell } from './application/app/AppShell';
import { AppLocalStateProvider } from './application/app/AppLocalState';
import { AppSideEffects } from './application/app/AppSideEffects';
import { SessionPublisher } from './application/app/publishers/SessionPublisher';
import { SettingsPublisher } from './application/app/publishers/SettingsPublisher';
import { VaultPublisher } from './application/app/publishers/VaultPublisher';
import { AppLockRuntimePublisher } from './application/app/publishers/AppLockRuntimePublisher';
// Initialize fonts eagerly at app startup
initializeFonts();
initializeUIFonts();
let rendererReadySent = false;
type SettingsState = ReturnType<typeof useSettingsState>;
type AppLockState = ReturnType<typeof useAppLockState>;
/**
* Bridges the locale out of settings chrome without pulling App into a
@@ -49,29 +52,16 @@ function App() {
);
}
function AppWithProviders() {
/**
* `AppLockGate` (index.tsx) owns `useSettingsState` / `useAppLockState` so the
* lock overlay can render before app children mount, and it owns the
* splash-removal + rendererReady signal (deep links must wait for unlock).
* App forwards the gate's settings instance into SettingsPublisher so the
* runtime slot/context still publish a single settings runtime.
*/
function AppWithProviders({ settings, appLock }: { settings: SettingsState; appLock: AppLockState }) {
const isPeerSessionWindow = typeof window !== 'undefined' && window.location.hash.startsWith('#/session-window');
useEffect(() => {
let splashRemovalTimer: ReturnType<typeof setTimeout> | undefined;
try {
const splash = document.getElementById('splash');
if (splash) {
splash.classList.add('fade-out');
splashRemovalTimer = setTimeout(() => splash.remove(), 200);
}
if (!rendererReadySent) {
rendererReadySent = true;
netcattyBridge.get()?.rendererReady?.();
}
} catch {
// ignore
}
return () => {
if (splashRemovalTimer !== undefined) clearTimeout(splashRemovalTimer);
};
}, []);
useEffect(() => {
return setupMcpApprovalBridge();
}, []);
@@ -83,24 +73,23 @@ function AppWithProviders() {
useExternalMcpGrantPersister();
return (
<SettingsPublisher
enableSettingsSync={!isPeerSessionWindow}
enableSystemEffects={!isPeerSessionWindow}
>
<SettingsI18nProvider>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
</ToastProvider>
</SettingsI18nProvider>
<SettingsPublisher settings={settings}>
<AppLockRuntimePublisher appLock={appLock} appLockEnabled={settings.appLockSettings.enabled}>
<SettingsI18nProvider>
<ToastProvider>
<TooltipProvider delayDuration={300}>
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
<App />
</SessionPublisher>
</VaultPublisher>
</TooltipProvider>
</ToastProvider>
</SettingsI18nProvider>
</AppLockRuntimePublisher>
</SettingsPublisher>
);
}
@@ -10,6 +10,7 @@ const appSource = readFileSync(join(here, '../../App.tsx'), 'utf8');
const vaultPublisherSource = readFileSync(join(here, 'publishers/VaultPublisher.tsx'), 'utf8');
const sessionPublisherSource = readFileSync(join(here, 'publishers/SessionPublisher.tsx'), 'utf8');
const settingsPublisherSource = readFileSync(join(here, 'publishers/SettingsPublisher.tsx'), 'utf8');
const appLockGateSource = readFileSync(join(here, '../../components/AppLockGate.tsx'), 'utf8');
const hostsDir = join(here, 'hosts');
const hostFiles = existsSync(hostsDir)
@@ -79,10 +80,12 @@ test('App.tsx does not subscribe to vault/session/settings runtimes or mega hook
assert.match(appSource, /<AppSideEffects\b/);
});
test('publishers own the mega hooks and store fan-out', () => {
test('publishers and the app-lock gate own the mega hooks and store fan-out', () => {
assert.match(vaultPublisherSource, /\buseVaultState\s*\(/);
assert.match(sessionPublisherSource, /\buseSessionState\s*\(/);
assert.match(settingsPublisherSource, /\buseSettingsState\s*\(/);
assert.match(appLockGateSource, /\buseSettingsState\s*\(/);
assert.doesNotMatch(settingsPublisherSource, /\buseSettingsState\s*\(/);
assert.match(settingsPublisherSource, /registerAppSettingsRuntime\(settings\)/);
assert.match(vaultPublisherSource, /publishVaultSnapshot\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(/);
assert.match(vaultPublisherSource, /registerVaultSnapshotActions\(null\)/);
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
test("open-terminal requests wait behind App Lock and resume after unlock", () => {
const handlerStart = source.indexOf("const _handleOpenTerminalPath");
const handlerEnd = source.indexOf("useEffect(() =>", handlerStart);
const handlerSource = source.slice(handlerStart, handlerEnd);
const drainStart = source.indexOf("const pending = pendingDeepLinksWhileLockedRef.current.splice(0)");
const drainEnd = source.indexOf("}, [appLockLocked]);", drainStart);
const drainSource = source.slice(drainStart, drainEnd);
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
assert.match(handlerSource, /kind: 'open-terminal-path'/);
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
});
+204 -16
View File
@@ -7,10 +7,12 @@ import { useManagedSourceSync } from '../state/useManagedSourceSync';
import { usePortForwardingState } from '../state/usePortForwardingState';
import { useUpdateCheck } from '../state/useUpdateCheck';
import {
useAppLockChrome,
useAppSessionRuntime,
useAppSettingsRuntime,
useAppVaultRuntime,
} from '../state/appRuntimeBridge';
import { shouldDeferExternalActionWhileAppLocked } from '../../components/AppLockGate';
import {
getConnectionLogsSnapshot,
subscribeConnectionLogs,
@@ -72,6 +74,7 @@ import { useExternalMcpSessionSync } from '../state/useExternalMcpSessionSync';
import {
STORAGE_KEY_DEBUG_HOTKEYS,
STORAGE_KEY_PORT_FORWARDING,
STORAGE_KEY_STARTUP_LANDING,
} from '../../infrastructure/config/storageKeys';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { toast } from '../../components/ui/toast';
@@ -79,11 +82,12 @@ import { VaultSection } from '../../components/VaultView';
import { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import { PassphraseRequest } from '../../components/PassphraseModal';
import { classifyLocalShellType } from '../../lib/localShell';
import { useDiscoveredShells, resolveShellSetting } from '../../lib/useDiscoveredShells';
import { useDiscoveredShells, resolveShellSetting, ensureDiscoveredShells } from '../../lib/useDiscoveredShells';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider';
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers';
@@ -99,6 +103,7 @@ const HOTKEY_DEBUG =
export function AppSideEffects() {
const settings = useAppSettingsRuntime();
const { locked: appLockLocked } = useAppLockChrome();
const { t } = useI18n();
const pluginViewTabs = usePluginViewTabs();
@@ -200,6 +205,7 @@ export function AppSideEffects() {
readPersistedHosts,
groupConfigs,
updateGroupConfigs,
commitVaultGroupMutation,
} = vaultState;
const hostsRef = useRef(hosts);
@@ -617,6 +623,20 @@ export function AppSideEffects() {
workspaces,
});
const pendingTrayPortForwardsWhileLockedRef = useRef<Array<{ ruleId: string; start: boolean }>>([]);
const _handleTrayTogglePortForwardMaybeDeferred = useEffectEvent((ruleId: string, start: boolean) => {
// Saved-credential tunnels must not start/stop behind the lock overlay.
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
const pending = pendingTrayPortForwardsWhileLockedRef.current;
const existing = pending.findIndex((item) => item.ruleId === ruleId);
if (existing >= 0) pending.splice(existing, 1);
pending.push({ ruleId, start });
return;
}
_handleTrayTogglePortForward(ruleId, start);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -626,7 +646,7 @@ export function AppSideEffects() {
_handleTrayJumpToSession(sessionId);
});
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
_handleTrayTogglePortForward(ruleId, start);
_handleTrayTogglePortForwardMaybeDeferred(ruleId, start);
});
return () => {
@@ -635,6 +655,14 @@ export function AppSideEffects() {
};
}, [isPeerSessionWindow]);
useEffect(() => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
const pending = pendingTrayPortForwardsWhileLockedRef.current.splice(0);
for (const item of pending) {
_handleTrayTogglePortForward(item.ruleId, item.start);
}
}, [appLockLocked]);
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1091,20 +1119,24 @@ export function AppSideEffects() {
}, [closeLogView, editorTabs, executeHotkeyAction, logViews, pluginViewTabs, sessions, workspaces]);
useEffect(() => {
// Cmd/Ctrl+W from the app menu arrives via IPC, not the keydown listener.
// Gate it while locked so sessions/tabs cannot close behind the overlay.
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
void handleWindowCommandCloseRequest();
});
return () => unsubscribe?.();
}, [handleWindowCommandCloseRequest]);
}, [appLockLocked, handleWindowCommandCloseRequest]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
executeHotkeyAction(action, e);
}, [executeHotkeyAction]);
// Global hotkey handler
// Global hotkey handler — suppress while the app lock overlay is up so
// capture-phase shortcuts cannot mutate sessions behind the lock screen.
useEffect(() => {
if (hotkeyScheme === 'disabled' || isHotkeyRecording) return;
if (hotkeyScheme === 'disabled' || isHotkeyRecording || appLockLocked) return;
const handleGlobalKeyDown = (e: KeyboardEvent) => {
_handleGlobalHotkeyKeyDown(e);
@@ -1112,15 +1144,16 @@ export function AppSideEffects() {
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [hotkeyScheme, isHotkeyRecording]);
}, [hotkeyScheme, isHotkeyRecording, appLockLocked]);
useEffect(() => {
if (appLockLocked) return;
const onKeyDown = (e: KeyboardEvent) => {
_handleEscapeKeyDown(e);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
}, [appLockLocked]);
const handleDeleteHost = useCallback((hostId: string) => {
const target = hosts.find(h => h.id === hostId);
@@ -1176,6 +1209,100 @@ export function AppSideEffects() {
);
}, [addConnectionLog, createLocalTerminal, terminalSettings, discoveredShells]);
// Cold-start landing: open a local terminal once when preferred and nothing
// was restored. Wait for queued launch intents (deep links / Explorer open)
// and shell discovery so we neither duplicate tabs nor mislabel WSL/Git Bash.
const startupLocalTerminalAttemptedRef = useRef(false);
const startupLaunchIntentReceivedRef = useRef(false);
const sessionsLengthRef = useRef(sessions.length);
const workspacesLengthRef = useRef(workspaces.length);
sessionsLengthRef.current = sessions.length;
workspacesLengthRef.current = workspaces.length;
const [coldStartIntentsSettled, setColdStartIntentsSettled] = useState(false);
useEffect(() => {
if (isPeerSessionWindow) {
setColdStartIntentsSettled(true);
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.onColdStartIntentsSettled) {
setColdStartIntentsSettled(true);
return;
}
return bridge.onColdStartIntentsSettled(() => {
setColdStartIntentsSettled(true);
});
}, [isPeerSessionWindow]);
useEffect(() => {
if (startupLocalTerminalAttemptedRef.current) return;
if (isPeerSessionWindow) {
startupLocalTerminalAttemptedRef.current = true;
return;
}
const landing = resolveStartupLandingSetting(
localStorageAdapter.readString(STORAGE_KEY_STARTUP_LANDING),
);
if (landing !== 'local-terminal') {
startupLocalTerminalAttemptedRef.current = true;
return;
}
if (sessions.length > 0 || workspaces.length > 0) {
startupLocalTerminalAttemptedRef.current = true;
return;
}
if (!coldStartIntentsSettled) return;
let cancelled = false;
void (async () => {
const shells = await ensureDiscoveredShells();
if (cancelled || startupLocalTerminalAttemptedRef.current) return;
if (!shouldOpenLocalTerminalOnStartup({
startupLanding: landing,
hasRestoredSessionState:
sessionsLengthRef.current > 0 || workspacesLengthRef.current > 0,
isPeerSessionWindow: false,
hasQueuedStartupIntent: startupLaunchIntentReceivedRef.current,
})) {
startupLocalTerminalAttemptedRef.current = true;
return;
}
const resolved = resolveShellSetting(
terminalSettings.localShell,
shells,
terminalSettings.localShellArgs,
);
const matchedShell = shells.find((shell) => shell.id === terminalSettings.localShell);
startupLocalTerminalAttemptedRef.current = true;
handleCreateLocalTerminal(
resolved
? {
command: resolved.command,
args: resolved.args,
name: matchedShell?.name,
icon: matchedShell?.icon,
}
: undefined,
);
})();
return () => {
cancelled = true;
};
}, [
coldStartIntentsSettled,
handleCreateLocalTerminal,
isPeerSessionWindow,
sessions.length,
terminalSettings.localShell,
terminalSettings.localShellArgs,
workspaces.length,
]);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
@@ -1283,6 +1410,7 @@ export function AppSideEffects() {
groupConfigs,
updateGroupConfigs,
updateManagedSources,
commitVaultGroupMutation,
updatePortForwardingRules: importPortForwardingRules,
startTunnel,
stopTunnel,
@@ -1292,7 +1420,18 @@ export function AppSideEffects() {
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
// Idle/background/manual locks keep children mounted under the overlay. Queue
// deep links until unlock so saved-credential connects cannot start behind
// the lock screen.
const pendingDeepLinksWhileLockedRef = useRef<Array<
| { kind: 'ssh'; payload: { url?: string } }
| { kind: 'telnet'; payload: { url?: string } }
| { kind: 'jms'; payload: { url?: string } }
| { kind: 'open-terminal-path'; payload: { path?: string } }
>>([]);
const _processSshDeepLink = useEffectEvent((payload: { url?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const target = parseSshDeepLink(rawUrl);
if (!target) {
@@ -1340,6 +1479,14 @@ export function AppSideEffects() {
setActiveTabId('vault');
});
const _handleSshDeepLink = useEffectEvent((payload: { url?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'ssh', payload: payload || {} });
return;
}
_processSshDeepLink(payload);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1349,7 +1496,8 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
const _processTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const target = parseTelnetDeepLink(rawUrl);
if (!target) {
@@ -1386,6 +1534,14 @@ export function AppSideEffects() {
handleConnectToHost(ephemeralHost);
});
const _handleTelnetDeepLink = useEffectEvent((payload: { url?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'telnet', payload: payload || {} });
return;
}
_processTelnetDeepLink(payload);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1395,7 +1551,8 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
const _processJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const rawUrl = payload?.url || '';
const target = parseJmsDeepLink(rawUrl);
if (!target) {
@@ -1414,6 +1571,14 @@ export function AppSideEffects() {
handleConnectToHost(ephemeralHost);
});
const _handleJmsDeepLink = useEffectEvent((payload: { url?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ kind: 'jms', payload: payload || {} });
return;
}
_processJmsDeepLink(payload);
});
useEffect(() => {
if (isPeerSessionWindow) return;
const bridge = netcattyBridge.get();
@@ -1423,6 +1588,35 @@ export function AppSideEffects() {
});
}, [isPeerSessionWindow]);
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
startupLaunchIntentReceivedRef.current = true;
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
});
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({
kind: 'open-terminal-path',
payload: payload || {},
});
return;
}
_processOpenTerminalPath(payload);
});
useEffect(() => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
const pending = pendingDeepLinksWhileLockedRef.current.splice(0);
for (const item of pending) {
if (item.kind === 'ssh') _processSshDeepLink(item.payload);
else if (item.kind === 'telnet') _processTelnetDeepLink(item.payload);
else if (item.kind === 'jms') _processJmsDeepLink(item.payload);
else _processOpenTerminalPath(item.payload);
}
}, [appLockLocked]);
useEffect(() => {
setEphemeralHosts((prev) => {
if (prev.length === 0) return prev;
@@ -1434,12 +1628,6 @@ export function AppSideEffects() {
});
}, [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();
+13 -1
View File
@@ -29,6 +29,7 @@ import {
useSettingsChromeActions,
useSettingsChromeStore,
} from '../state/settingsChromeStore';
import { getAppAppLockRuntime, useAppLockChrome } from '../state/appRuntimeBridge';
import { useAppThemeStyle } from './useAppThemeStyle';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
@@ -258,7 +259,7 @@ function AppViewInner({ domains }: AppViewProps) {
const {
addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
clearAndRemoveSource, clearAndRemoveSources, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, commitVaultGroupMutation, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts,
customGroups, currentTerminalTheme, deepLinkHostDraft, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
followAppTerminalTheme,
@@ -301,6 +302,14 @@ function AppViewInner({ domains }: AppViewProps) {
} = useSettingsChromeStore();
const { setTheme, setWindowOpacity } = useSettingsChromeActions();
// App Lock chrome: narrow slice published by AppLockRuntimePublisher; the
// lock action reads the full runtime imperatively so the callback stays
// stable across gate re-renders.
const { appLockEnabled } = useAppLockChrome();
const handleLockApp = useCallback(() => {
getAppAppLockRuntime()?.lockNow('manual');
}, []);
const handleTerminalCommandExecuted = useCallback((
command: string,
hostId: string,
@@ -485,6 +494,8 @@ function AppViewInner({ domains }: AppViewProps) {
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onThemeChange={setTheme}
onOpenSettings={handleOpenSettings}
onLockApp={handleLockApp}
appLockEnabled={appLockEnabled}
externalMcpEnabled={externalMcpToggle.enabled}
onToggleExternalMcp={externalMcpToggle.setEnabled}
showExternalMcpToggle={!isPeerSessionWindow}
@@ -592,6 +603,7 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateManagedSources={updateManagedSources}
onReadPersistedManagedSources={readPersistedManagedSources}
onCommitVaultImportTransaction={commitVaultImportTransaction}
onCommitVaultGroupMutation={commitVaultGroupMutation}
onClearAndRemoveManagedSource={clearAndRemoveSource}
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
+1
View File
@@ -38,6 +38,7 @@ export function VaultHost() {
removeShellHistoryEntry: actions?.removeShellHistoryEntry,
commitPluginImporterData: actions?.commitPluginImporterData,
commitVaultImportTransaction: actions?.commitVaultImportTransaction,
commitVaultGroupMutation: actions?.commitVaultGroupMutation,
convertKnownHostToHost: actions?.convertKnownHostToHost,
customGroups: vault.customGroups,
deepLinkHostDraft: local.deepLinkHostDraft,
@@ -0,0 +1,53 @@
import { useLayoutEffect, useMemo, type ReactNode } from 'react';
import {
AppLockChromeContext,
registerAppAppLockRuntime,
type AppAppLockRuntime,
} from '../../state/appRuntimeBridge';
export type AppLockRuntimePublisherProps = {
/** App-lock runtime owned by `AppLockGate` (index.tsx render prop). */
appLock: AppAppLockRuntime;
/** `settings.appLockSettings.enabled` from the gate's settings instance. */
appLockEnabled: boolean;
children?: ReactNode;
};
/**
* Publishes the gate-owned app-lock runtime the same way SettingsPublisher
* publishes settings: the full runtime goes on the `appRuntimeBridge` slot for
* imperative callers (`getAppAppLockRuntime()`), and a narrow memoized chrome
* slice goes on context so TopTabs / AppSideEffects re-render only when the
* lock state actually changes — the full runtime changes identity on every
* gate render.
*/
export function AppLockRuntimePublisher({
appLock,
appLockEnabled,
children,
}: AppLockRuntimePublisherProps) {
useLayoutEffect(() => {
registerAppAppLockRuntime(appLock);
}, [appLock]);
// Only a real unmount clears the slot; see VaultPublisher for why.
useLayoutEffect(() => () => {
registerAppAppLockRuntime(null);
}, []);
const chrome = useMemo(
() => ({
appLockEnabled,
locked: appLock.locked,
initialized: appLock.initialized,
}),
[appLockEnabled, appLock.locked, appLock.initialized],
);
return (
<AppLockChromeContext.Provider value={chrome}>
{children}
</AppLockChromeContext.Provider>
);
}
@@ -3,33 +3,29 @@ import { useLayoutEffect, type ReactNode } from 'react';
import {
AppSettingsRuntimeContext,
registerAppSettingsRuntime,
type AppSettingsRuntime,
} from '../../state/appRuntimeBridge';
import { useSettingsState } from '../../state/useSettingsState';
export type SettingsPublisherProps = {
/** Peer session windows must not drive the main window's settings IPC sync. */
enableSettingsSync: boolean;
/** Peer session windows must not re-apply OS-level system settings effects. */
enableSystemEffects: boolean;
/**
* Pre-built settings runtime owned by an ancestor. `AppLockGate` (index.tsx)
* owns `useSettingsState` so the lock overlay can render before app children
* mount; the publisher only binds the runtime slot and context.
*/
settings: AppSettingsRuntime;
children?: ReactNode;
};
/**
* Owns `useSettingsState` and hands it to consumers the same way
* `VaultPublisher` / `SessionPublisher` hand over their runtimes: a context for
* render-time reads and the `appRuntimeBridge` slot for imperative callers.
* Publishes the gate-owned settings runtime the same way `VaultPublisher` /
* `SessionPublisher` hand over their runtimes: a context for render-time reads
* and the `appRuntimeBridge` slot for imperative callers.
*
* The store fan-out (`settingsChromeStore` / `appearanceChromeStore`) already
* happens inside `useSettingsState`, so this publisher only relocates the hook
* out of the component that also builds the shell's domain bags.
* happens inside `useSettingsState`, so this publisher only relocates the
* binding out of the component that also builds the shell's domain bags.
*/
export function SettingsPublisher({
enableSettingsSync,
enableSystemEffects,
children,
}: SettingsPublisherProps) {
const settings = useSettingsState({ enableSettingsSync, enableSystemEffects });
export function SettingsPublisher({ settings, children }: SettingsPublisherProps) {
useLayoutEffect(() => {
registerAppSettingsRuntime(settings);
}, [settings]);
@@ -67,6 +67,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
@@ -138,6 +139,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
readPersistedManagedSources,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateHostDistro,
updateHostLastConnected,
addShellHistoryEntry,
@@ -150,6 +152,7 @@ export function VaultPublisher({ children }: VaultPublisherProps) {
addShellHistoryEntry,
commitPluginImporterData,
commitVaultImportTransaction,
commitVaultGroupMutation,
convertKnownHostToHost,
importOrReuseKey,
readPersistedHosts,
@@ -20,6 +20,10 @@ const portForwardingAutoStartSource = readFileSync(
new URL('../state/usePortForwardingAutoStart.ts', import.meta.url),
'utf8',
);
const appLockBridgeSource = readFileSync(
new URL('../state/useAppLockBridge.ts', import.meta.url),
'utf8',
);
test('every renderer root mounts under StrictMode', () => {
assert.match(indexSource, /import \{ StrictMode, Suspense, lazy \} from 'react'/);
@@ -58,10 +62,10 @@ test('clone-session payload is consumed once even if the effect re-runs', () =>
});
test('rendererReady is notified once per renderer process', () => {
assert.match(appSource, /^let rendererReadySent = false;$/m);
const guardAt = appSource.indexOf('if (!rendererReadySent) {');
assert.match(appLockBridgeSource, /^let rendererReadySent = false;$/m);
const guardAt = appLockBridgeSource.indexOf('if (rendererReadySent) return;');
assert.notEqual(guardAt, -1);
const guarded = appSource.slice(guardAt, guardAt + 200);
const guarded = appLockBridgeSource.slice(guardAt, guardAt + 200);
assert.match(guarded, /rendererReadySent = true;/);
assert.match(guarded, /netcattyBridge\.get\(\)\?\.rendererReady\?\.\(\)/);
assert.ok(
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import en from '../locales/en.ts';
import ru from '../locales/ru.ts';
import es from '../locales/es.ts';
import zhCN from '../locales/zh-CN.ts';
import zhTW from '../locales/zh-TW.ts';
@@ -39,7 +40,7 @@ const keys = [
] as const;
test('convergent sync copy exists in every bundled locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
for (const key of keys) {
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
assert.notEqual(messages[key], '', `${locale} has empty ${key}`);
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import en from "../locales/en.ts";
import ru from "../locales/ru.ts";
import es from "../locales/es.ts";
import zhCN from "../locales/zh-CN.ts";
const strategyKeys = [
@@ -17,7 +18,7 @@ const strategyKeys = [
] as const;
test("cloud sync strategy copy exists in every bundled locale", () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN })) {
for (const key of strategyKeys) {
assert.equal(
typeof messages[key],
@@ -3,6 +3,7 @@ import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
@@ -19,7 +20,7 @@ const STEER_KEYS = [
] as const;
test('Codex steering UI is localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = STEER_KEYS.filter(key => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing Codex steering labels`);
}
+1
View File
@@ -53,6 +53,7 @@ export const enAiMessages: Messages = {
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-compatible: host with or without /v1 (for example https://gateway.example or https://gateway.example/v1). Detection and chat both use /v1/models and /v1/messages.',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
+89
View File
@@ -83,6 +83,32 @@ export const enCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'credentials.protectionUnavailable.action': 'Open Settings',
// App Lock
'appLock.title': 'Netcatty is locked',
'appLock.reason.default': 'Enter your lock password to continue.',
'appLock.reason.startup': 'Netcatty locks each time this window opens.',
'appLock.reason.idle': 'Netcatty locked after the inactivity timeout.',
'appLock.reason.manual': 'Netcatty was locked manually.',
'appLock.passwordLabel': 'Lock password',
'appLock.passwordPlaceholder': 'Enter password to unlock',
'appLock.unlock': 'Unlock',
'appLock.unlocking': 'Unlocking...',
'appLock.usePassword': 'Use password instead',
'appLock.error.emptyPassword': 'Enter the lock password.',
'appLock.error.incorrectPassword': 'Incorrect lock password.',
'appLock.systemUnlock.unlockWith': 'Unlock with {label}',
'appLock.systemUnlock.preparing': 'Preparing {label}...',
'appLock.systemUnlock.verifying': 'Waiting for {label}...',
'appLock.systemUnlock.error': 'System unlock was not completed. Use the lock password to continue.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Reset App Lock',
'appLock.reset.description': 'This removes the current lock password and turns off App Lock. Set a new password from Settings when you need it again.',
'appLock.reset.cancel': 'Cancel',
'appLock.reset.confirm': 'Reset App Lock',
'appLock.reset.resetting': 'Resetting...',
'appLock.reset.error': 'Could not reset App Lock. Try again.',
'topTabs.lockApp': 'Lock Netcatty',
// Settings shell
'settings.title': 'Settings',
'settings.tab.application': 'Application',
@@ -118,6 +144,59 @@ export const enCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > App Lock
'settings.appLock.title': 'App Lock',
'settings.appLock.description': 'Require a local lock password when Netcatty opens, after inactivity, or when you lock it manually.',
'settings.appLock.setupTitle': 'Set a lock password',
'settings.appLock.setupDescription': 'Create the password below. App Lock turns on immediately after it is saved.',
'settings.appLock.manageTitle': 'App Lock is ready',
'settings.appLock.enabledStatus': 'Enabled. Netcatty locks on startup, after inactivity, and when you lock it manually.',
'settings.appLock.disabledStatus': 'Password saved, but App Lock is currently off.',
'settings.appLock.disableTitle': 'Turn off App Lock',
'settings.appLock.disableDescription': 'Enter the current lock password to stop locking Netcatty. The saved password will be removed; turning App Lock on again requires creating a new one.',
'settings.appLock.disable': 'Turn off App Lock',
'settings.appLock.disabling': 'Turning off...',
'settings.appLock.setupPasswordTitle': 'Create lock password',
'settings.appLock.setupPasswordDescription': 'This password is required to unlock Netcatty and to change or turn off App Lock later.',
'settings.appLock.changePasswordTitle': 'Change lock password',
'settings.appLock.changePasswordDescription': 'Enter the current lock password, then choose a new one.',
'settings.appLock.enable': 'Enable app lock',
'settings.appLock.enableDesc': 'Lock on startup and after the selected inactivity timeout.',
'settings.appLock.timeout': 'Inactivity timeout',
'settings.appLock.timeoutDesc': 'Lock Netcatty when there is no keyboard, pointer, wheel, touch, or focus activity.',
'settings.appLock.timeout.0': 'Never lock automatically',
'settings.appLock.timeout.1': '1 minute',
'settings.appLock.timeout.5': '5 minutes',
'settings.appLock.timeout.15': '15 minutes',
'settings.appLock.timeout.30': '30 minutes',
'settings.appLock.timeout.60': '60 minutes',
'settings.appLock.systemUnlock.label': '{label} unlock',
'settings.appLock.systemUnlock.desc': 'Use {label} as an alternate way to unlock Netcatty on this device.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} is not available on this device right now.',
'settings.appLock.systemUnlock.unavailable': 'System unlock is not available on this device.',
'settings.appLock.systemUnlock.locked': 'Unlock Netcatty before changing system unlock.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Open {label} automatically',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Show the {label} prompt automatically when Netcatty is waiting to unlock.',
'settings.appLock.currentPassword': 'Current password',
'settings.appLock.currentPasswordPlaceholder': 'Required to disable or replace',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Current password to turn off App Lock',
'settings.appLock.currentPasswordForChangePlaceholder': 'Current password to change it',
'settings.appLock.newPassword': 'New password',
'settings.appLock.newPasswordPlaceholder': 'Set lock password',
'settings.appLock.confirmPassword': 'Confirm password',
'settings.appLock.confirmPasswordPlaceholder': 'Repeat password',
'settings.appLock.savePassword': 'Set and enable',
'settings.appLock.savingPassword': 'Saving...',
'settings.appLock.passwordSet': 'Password configured',
'settings.appLock.replacePassword': 'Replace password',
'settings.appLock.enableAfterPassword': 'Set a lock password before enabling app lock.',
'settings.appLock.localOnlyHint': 'Device-local privacy lock. It is not synced and is not an OS security boundary.',
'settings.appLock.validation.currentRequired': 'Enter the current lock password.',
'settings.appLock.validation.newRequired': 'Enter a new lock password.',
'settings.appLock.validation.confirmRequired': 'Confirm the new lock password.',
'settings.appLock.validation.mismatch': 'The new passwords do not match.',
'settings.appLock.validation.incorrect': 'The current lock password is incorrect.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Network Proxy',
'settings.system.networkProxy.description': 'HTTP(S) proxy for cloud sync and AI providers. This is separate from SSH ProxyJump / ProxyCommand profiles.',
@@ -173,6 +252,10 @@ export const enCoreMessages: Messages = {
// Settings > Session Restore
'settings.sessionRestore.title': 'Session Restore',
'settings.sessionRestore.startupLanding': 'On launch',
'settings.sessionRestore.startupLandingDesc': 'Choose whether to open the host home page or a local terminal when Netcatty starts with no restored sessions.',
'settings.sessionRestore.startupLanding.vault': 'Home (hosts)',
'settings.sessionRestore.startupLanding.localTerminal': 'Local terminal',
'settings.sessionRestore.restorePreviousSession': 'Restore previous tabs and workspaces',
'settings.sessionRestore.restorePreviousSessionDesc': 'Reopen terminal tabs and workspace layouts from the last app session.',
'settings.sessionRestore.restoreTerminalCwd': 'Restore terminal working directory',
@@ -606,6 +689,12 @@ Highlight the focused split pane:
'settings.terminal.behavior.dynamicTabTitle.off': 'Disabled',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Agents only',
'settings.terminal.behavior.dynamicTabTitle.all': 'All sessions',
'settings.terminal.behavior.oscNotifications': 'OSC desktop notifications',
'settings.terminal.behavior.oscNotifications.desc':
'Show a system notification when a program emits OSC 9, OSC 777 notify, or kitty OSC 99. Used by Codex and other agents for turn-complete and approval alerts.',
'settings.terminal.behavior.oscNotifications.off': 'Disabled',
'settings.terminal.behavior.oscNotifications.unfocused': 'When unfocused',
'settings.terminal.behavior.oscNotifications.always': 'Always',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
+4 -1
View File
@@ -18,8 +18,11 @@ export const enScriptsMessages = {
'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.',
'scripts.editor.lineCount': '{count} lines',
'scripts.editor.resize': 'Resize editor',
'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.',
'scripts.targets.hint': 'Selected groups are resolved dynamically, so hosts added later are included automatically.',
'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.',
'scripts.targets.currentHostMismatch': 'This script is not assigned to the current host.',
'hostDetails.automation.groupScripts': 'Inherited group scripts',
'hostDetails.automation.groupScriptsHint': 'These scripts follow the host group dynamically and are ordered before the host-specific queue.',
'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.',
'scripts.actions.runParallel': 'Run on all tabs (parallel)',
'scripts.actions.runSequential': 'Run on all tabs (sequential)',
+4 -1
View File
@@ -659,6 +659,9 @@ export const enTerminalMessages: Messages = {
'snippets.scriptEditor.modalTitle': 'Edit script',
'snippets.targets.title': 'Targets',
'snippets.targets.add': 'Add targets',
'snippets.targets.selectHosts': 'Hosts',
'snippets.targets.selectGroups': 'Groups',
'snippets.targets.noGroups': 'No groups found',
'snippets.targets.allHosts': 'Apply to all hosts',
'snippets.targets.allHostsShort': 'All hosts',
'snippets.targets.allHostsActive': 'Applies to every connectable host.',
@@ -745,7 +748,7 @@ export const enTerminalMessages: Messages = {
'snippets.shortkey.recording': 'Press a key combination...',
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
'snippets.shortkey.clear': 'Clear shortcut',
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with {name}',
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
'snippets.variables.dialogTitle': 'Snippet variables',
+5 -1
View File
@@ -229,6 +229,10 @@ export const enVaultMessages: Messages = {
'sftp.moveTo.confirm': 'Move',
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.extract': 'Extract here',
'sftp.extract.extracting': 'Extracting {fileName}...',
'sftp.extract.success': 'Extracted {fileName}',
'sftp.extract.error': 'Failed to extract {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.copyToOtherPane.unavailable': 'Open the two-pane SFTP view and connect the destination pane before copying files.',
'sftp.copyCurrentPath': 'Copy current path',
@@ -489,7 +493,7 @@ export const enVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': 'Transfer the file to the other pane\'s active host',
'settings.sshDeepLink.title': 'SSH and Telnet links',
'settings.sshDeepLink.enable': 'Open ssh:// and telnet:// links with Netcatty',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps.',
'settings.sshDeepLink.enableDesc': 'Allow Netcatty to handle ssh:// and telnet:// links from browsers and other apps, plus PuTTY-style launches such as -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Windows Explorer',
'settings.explorerContextMenu.enable': 'Show “Open in Netcatty” in folder context menus',
'settings.explorerContextMenu.enableDesc': 'Adds a right-click item for folders and folder backgrounds so you can open a local terminal there. Applies immediately; no reinstall needed.',
+20
View File
@@ -0,0 +1,20 @@
import type { Messages } from './types';
import { esCoreMessages } from './es/core';
import { esVaultMessages } from './es/vault';
import { esTerminalMessages } from './es/terminal';
import { esAiMessages } from './es/ai';
import { esSystemManagerMessages } from './es/systemManager';
import { esScriptsMessages } from './es/scripts';
export type { Messages } from './types';
const es: Messages = {
...esCoreMessages,
...esVaultMessages,
...esTerminalMessages,
...esAiMessages,
...esSystemManagerMessages,
...esScriptsMessages,
};
export default es;
+638
View File
@@ -0,0 +1,638 @@
import type { Messages } from '../types';
export const esAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Configuración del agente',
'ai.chat.preparing': 'Preparando…',
'ai.chat.compactingContext': 'Compactando el contexto anterior…',
'ai.chat.compactingStep': 'Recortando el contexto para el siguiente paso…',
'ai.chat.compactionRetry': 'La petición era demasiado grande. Compactando el contexto y reintentando…',
'ai.chat.compactionBanner': 'Contexto compactado: {before}K → {after}K tokens',
'ai.chat.contextUsage': 'Uso de contexto: {used} / {max} tokens',
'ai.chat.activity.title': 'Actividad del agente',
'ai.chat.activity.plan': 'Plan',
'ai.chat.activity.webSearch': 'Búsqueda web',
'ai.chat.activity.fileChanges': 'Cambios de archivos',
'ai.chat.activity.status.running': 'En ejecución',
'ai.chat.activity.status.completed': 'Completado',
'ai.chat.activity.status.failed': 'Falló',
'ai.chat.activity.file.add': 'Agregar',
'ai.chat.activity.file.update': 'Actualizar',
'ai.chat.activity.file.delete': 'Eliminar',
'ai.chat.activity.usage': 'Tokens',
'ai.chat.activity.usage.input': 'entrada',
'ai.chat.activity.usage.output': 'salida',
'ai.chat.activity.usage.cached': 'en caché',
'ai.chat.activity.usage.reasoning': 'razonamiento',
'ai.title': 'IA',
'ai.description': 'Configura proveedores de IA, agentes y ajustes de seguridad',
'ai.providers': 'Proveedores',
'ai.agents': 'Agentes',
'ai.providers.empty': 'No hay proveedores configurados. Agrega un proveedor para comenzar.',
'ai.providers.add': 'Agregar proveedor',
'ai.providers.active': 'Activo',
'ai.providers.apiKeyConfigured': 'Clave de API configurada',
'ai.providers.noApiKey': 'Sin clave de API',
'ai.providers.configure': 'Configurar',
'ai.providers.remove': 'Quitar',
'ai.providers.name': 'Nombre para mostrar',
'ai.providers.name.placeholder': 'p. ej. Mi proveedor',
'ai.providers.style': 'Estilo de protocolo',
'ai.providers.style.anthropic': 'Compatible con Anthropic',
'ai.providers.style.openai': 'Compatible con OpenAI',
'ai.providers.style.google': 'Compatible con Google',
'ai.providers.style.inherited': 'auto',
'ai.providers.style.help': 'Selecciona qué formato de API usan las peticiones. Anúlalo cuando un endpoint de terceros hable un dialecto distinto al que sugiere su tipo de proveedor.',
'ai.providers.icon.change': 'Cambiar ícono',
'ai.providers.icon.upload': 'Subir imagen',
'ai.providers.icon.reset': 'Restablecer',
'ai.providers.icon.close': 'Cerrar',
'ai.providers.icon.uploadedNote': 'Ícono personalizado (64×64 WebP)',
'ai.providers.icon.errorType': 'Elige un archivo de imagen.',
'ai.providers.apiKey': 'Clave de API',
'ai.providers.apiKey.placeholder': 'Ingresa la clave de API',
'ai.providers.apiKey.decrypting': 'Descifrando...',
'ai.providers.baseUrl': 'URL base',
'ai.providers.baseUrl.anthropicHelp': 'Compatible con Anthropic: host con o sin /v1 (por ejemplo https://gateway.example o https://gateway.example/v1). La detección y el chat usan /v1/models y /v1/messages.',
'ai.providers.skipTLSVerify': 'Omitir la verificación del certificado TLS (para certificados autofirmados)',
'ai.providers.defaultModel': 'Modelo predeterminado',
'ai.providers.defaultModel.placeholder': 'p. ej. gpt-4o, claude-sonnet-4-20250514',
'ai.providers.contextWindow': 'Ventana de contexto',
'ai.providers.contextWindow.placeholder': 'p. ej. 128000',
'ai.providers.contextWindow.help': 'Déjalo en blanco para usar el valor de la lista de modelos cuando esté disponible; de lo contrario, Netcatty usa un valor predeterminado seguro.',
'ai.providers.contextWindow.error': 'Ingresa un número entero positivo o déjalo en blanco.',
'ai.providers.refreshModels': 'Actualizar modelos',
'ai.providers.test': 'Probar',
'ai.providers.test.testing': 'Probando…',
'ai.providers.test.ok': 'Conectado ({latency} ms)',
'ai.providers.test.warn': 'Se alcanzó el endpoint, pero la respuesta parece incompleta ({latency} ms)',
'ai.providers.test.warnSlow': 'Conectado, pero lento ({latency} ms)',
'ai.providers.test.error': 'Falló ({detail})',
'ai.providers.test.missingBaseUrl': 'Primero ingresa una URL base',
'ai.providers.test.missingApiKey': 'Primero ingresa una clave de API',
'ai.providers.test.unavailable': 'La prueba de conexión no está disponible en este entorno',
'ai.providers.searchModel': 'Busca o escribe el ID del modelo...',
'ai.providers.filterModels': 'Filtrar modelos...',
'ai.providers.loadingModels': 'Cargando modelos...',
'ai.providers.noMatchingModels': 'No hay modelos que coincidan',
'ai.providers.clickToLoadModels': 'Haz clic para cargar los modelos',
'ai.providers.showingModels': 'Mostrando los primeros 100 de {count} modelos. Escribe para filtrar.',
'ai.providers.advancedParams': 'Parámetros avanzados',
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
// AI Codex
'ai.codex': 'Codex',
'ai.codex.title': 'Codex CLI',
'ai.codex.description': 'Conecta OpenAI Codex. Inicia sesión con ChatGPT aquí, o habilita una clave de API de un proveedor compatible con OpenAI y un endpoint personalizado en Configuración.',
'ai.codex.appServer.title': 'Usar Codex App Server',
'ai.codex.appServer.experimental': 'Experimental',
'ai.codex.appServer.description': 'Usa el protocolo persistente de Codex para aprobaciones nativas, controles de sandbox, modelos en vivo y preguntas a mitad de turno. El SDK sigue siendo el predeterminado.',
'ai.codex.appServer.checking': 'Comprobando la compatibilidad con App Server…',
'ai.codex.appServer.available': 'App Server está disponible para esta Codex CLI.',
'ai.codex.appServer.modelCatalogWarning': 'El catálogo de modelos de Codex en vivo no está disponible. Se usa la lista de modelos integrada.',
'ai.codex.appServer.approval.allowSession': 'Permitir por sesión',
'ai.codex.appServer.userInput.title': 'Codex necesita tu aporte',
'ai.codex.appServer.userInput.description': 'Responde estas preguntas para continuar el turno actual.',
'ai.codex.appServer.userInput.other': 'Ingresa otra respuesta',
'ai.codex.appServer.userInput.autoResolve': 'Codex continuará automáticamente si no se proporciona una respuesta a tiempo.',
'ai.codex.appServer.userInput.skip': 'Omitir',
'ai.codex.appServer.userInput.submit': 'Continuar',
'ai.codex.steer.addInstruction': 'Agregar instrucción',
'ai.codex.steer.sending': 'Agregando instrucción…',
'ai.codex.steer.placeholder': 'Agrega una instrucción mientras Codex está trabajando…',
'ai.codex.steer.notSteerableReview': 'Este turno de revisión de Codex no puede aceptar instrucciones adicionales. Tu borrador se conservó.',
'ai.codex.steer.notSteerableCompact': 'Este turno de compactación de Codex no puede aceptar instrucciones adicionales. Tu borrador se conservó.',
'ai.codex.steer.busy': 'Ya se está enviando otra instrucción a Codex.',
'ai.codex.steer.inactive': 'El turno de Codex ya terminó. Tu borrador se conservó.',
'ai.codex.steer.unsupported': 'Las instrucciones en curso requieren el runtime de Codex App Server.',
'ai.codex.steer.failed': 'Codex no pudo aceptar la instrucción adicional. Tu borrador se conservó.',
'ai.codex.detecting': 'Detectando...',
'ai.codex.notFound': 'No encontrado',
'ai.codex.awaitingLogin': 'Esperando inicio de sesión',
'ai.codex.connectedChatGPT': 'Conectado mediante ChatGPT',
'ai.codex.connectedApiKey': 'Conectado mediante clave de API',
'ai.codex.connectedCustomConfig': 'Conectado mediante ~/.codex/config.toml',
'ai.codex.customConfigIncomplete': 'Configuración personalizada detectada (falta la variable de entorno)',
'ai.codex.customConfigHint': 'Usando el proveedor personalizado "{provider}" configurado en ~/.codex/config.toml: no se necesita inicio de sesión con ChatGPT.',
'ai.codex.customConfigMissingEnvKey': 'Advertencia: {envKey} no está definida en tu entorno de shell. Expórtala (o inicia netcatty desde un shell que la tenga) para que Codex pueda autenticarse.',
'ai.codex.notConnected': 'No conectado',
'ai.codex.statusUnknown': 'Estado desconocido',
'ai.codex.path': 'Ruta:',
'ai.codex.notFoundHint': 'No se pudo encontrar codex en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.codex.customPathPlaceholder': 'p. ej. /usr/local/bin/codex',
'ai.codex.check': 'Verificar',
'ai.codex.resetPath': 'Restablecer',
'ai.codex.openLogin': 'Abrir inicio de sesión',
'ai.codex.logout': 'Cerrar sesión',
'ai.codex.connectChatGPT': 'Conectar ChatGPT',
'ai.codex.refreshStatus': 'Actualizar estado',
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "El asistente de codificación agéntica de Anthropic. Requiere la CLI de Claude Code del sistema.",
'ai.claude.detecting': 'Detectando...',
'ai.claude.detected': 'Detectado',
'ai.claude.notFound': 'No encontrado',
'ai.claude.path': 'Ruta:',
'ai.claude.notFoundHint': 'No se pudo encontrar claude en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.claude.customPathPlaceholder': 'p. ej. /usr/local/bin/claude',
'ai.claude.configSection': 'Autenticación y configuración (opcional)',
'ai.claude.configDir': 'Directorio de configuración',
'ai.claude.configDir.placeholder': '~/.claude (déjalo en blanco para usar el predeterminado)',
'ai.claude.configDir.hint': 'Define CLAUDE_CONFIG_DIR: apunta a una carpeta donde hayas ejecutado el inicio de sesión `claude` (contiene settings.json + credenciales).',
'ai.claude.settings': 'Archivo de configuración',
'ai.claude.settings.placeholder': '~/team-settings.json (ruta o JSON en línea {"model":"..."})',
'ai.claude.settings.hint': 'Opcional. Una ruta a settings.json o un JSON en línea, pasado al SDK como `settings`. Se suma a — y es independiente de — el directorio de configuración anterior (se combina por encima, no lo reemplaza).',
'ai.claude.envVars': 'Variables de entorno',
'ai.claude.envVars.placeholder': 'ANTHROPIC_BASE_URL=https://...\nANTHROPIC_MODEL=...',
'ai.claude.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de Claude. Se almacena localmente en texto plano; para claves de API o credenciales, prefiere el directorio de configuración anterior (un inicio de sesión `claude`).',
'ai.claude.check': 'Verificar',
'ai.claude.resetPath': 'Restablecer',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Usa la CLI de GitHub Copilot. Una vez detectada, se puede seleccionar como agente de codificación externo.',
'ai.copilot.detecting': 'Detectando...',
'ai.copilot.detected': 'Detectado',
'ai.copilot.notFound': 'No encontrado',
'ai.copilot.path': 'Ruta:',
'ai.copilot.notFoundHint': 'No se pudo encontrar copilot en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.copilot.customPathPlaceholder': 'p. ej. /usr/local/bin/copilot',
'ai.copilot.check': 'Verificar',
'ai.copilot.resetPath': 'Restablecer',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': 'Usa el SDK de Cursor o el inicio de sesión local de la CLI de Agent.',
'ai.cursor.detecting': 'Detectando...',
'ai.cursor.detected': 'Disponible',
'ai.cursor.notFound': 'No disponible',
'ai.cursor.path': 'Runtime:',
'ai.cursor.notFoundHint': 'Ingresa una clave de API para habilitar Cursor o cambia al inicio de sesión por CLI.',
'ai.cursor.notInstalledHint': 'No se detectó el SDK de Cursor ni la CLI de Agent.',
'ai.cursor.installStatus': 'Runtime de Cursor',
'ai.cursor.installed': 'Detectado',
'ai.cursor.notInstalled': 'No detectado',
'ai.cursor.modeCli': 'Inicio de sesión por CLI',
'ai.cursor.modeApiKey': 'Clave de API',
'ai.cursor.modeCliHint': 'Usa tu sesión local de `cursor-agent login` y la cuota Automática de la suscripción. La clave de API guardada se conserva, pero no se usa en este modo.',
'ai.cursor.modeApiKeyHint': 'Usa la API de Cursor medida. El inicio de sesión por CLI se ignora mientras este modo esté activo.',
'ai.cursor.cliLoginStatus': 'Inicio de sesión por CLI',
'ai.cursor.cliLoginOk': 'Sesión iniciada',
'ai.cursor.cliLoginAs': 'Sesión iniciada como {{email}}',
'ai.cursor.cliLoginMissing': 'Sin sesión iniciada',
'ai.cursor.cliLoginHint': 'Ejecuta `cursor-agent login` en una terminal y luego haz clic en Verificar.',
'ai.cursor.apiKeyStatus': 'Clave de API',
'ai.cursor.apiKeyConfigured': 'Configurada',
'ai.cursor.apiKeyMissing': 'Faltante',
'ai.cursor.apiKeyFromEnv': 'Desde el entorno',
'ai.cursor.apiKey': 'Clave de API',
'ai.cursor.apiKeyPlaceholder': 'Ingresa la clave de API de Cursor',
'ai.cursor.apiKeyPlaceholder.env': 'Usando CURSOR_API_KEY; ingresa una clave para anularla',
'ai.cursor.apiKeyEnvHint': 'Cursor puede usar CURSOR_API_KEY de tu shell. Guarda una clave aquí solo si quieres que Netcatty la anule.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty usará la clave guardada aquí antes que CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Guardar',
'ai.cursor.saved': 'Guardada',
'ai.cursor.showApiKey': 'Mostrar clave de API',
'ai.cursor.hideApiKey': 'Ocultar clave de API',
'ai.cursor.customPathPlaceholder': 'p. ej. /usr/local/bin/cursor',
'ai.cursor.check': 'Verificar',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': 'Usa CodeBuddy Code mediante el SDK oficial de Agent (`@tencent-ai/agent-sdk`). Una vez detectado, se puede seleccionar como agente de codificación externo.',
'ai.codebuddy.detecting': 'Detectando...',
'ai.codebuddy.detected': 'Detectado',
'ai.codebuddy.notFound': 'No encontrado',
'ai.codebuddy.path': 'Ruta:',
'ai.codebuddy.notFoundHint': 'No se pudo encontrar codebuddy en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.codebuddy.customPathPlaceholder': 'p. ej. /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Verificar',
'ai.codebuddy.resetPath': 'Restablecer',
'ai.codebuddy.configSection': 'Autenticación y configuración (opcional)',
'ai.codebuddy.internetEnv': 'Entorno de Internet',
'ai.codebuddy.internetEnv.default': 'Predeterminado (internacional)',
'ai.codebuddy.internetEnv.internal': 'Interno',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': 'Define CODEBUDDY_INTERNET_ENVIRONMENT: elige Interno o IOA para entornos de red restringidos.',
'ai.codebuddy.envVars': 'Variables de entorno',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'Una KEY=VALUE por línea, pasada al agente de CodeBuddy. Define CODEBUDDY_API_KEY o CODEBUDDY_AUTH_TOKEN aquí para la autenticación. Se almacena localmente en texto plano.',
'ai.codebuddy.advancedSection': 'Opciones avanzadas (SDK 0.3.230)',
'ai.codebuddy.effort': 'Esfuerzo de razonamiento',
'ai.codebuddy.effort.default': 'Predeterminado',
'ai.codebuddy.effort.low': 'Bajo',
'ai.codebuddy.effort.medium': 'Medio',
'ai.codebuddy.effort.high': 'Alto',
'ai.codebuddy.effort.xhigh': 'XHigh',
'ai.codebuddy.effort.hint': 'Controla la profundidad de razonamiento del modelo. Usa Bajo para comandos simples y ahorrar tokens, y Alto/XHigh para diagnósticos complejos.',
'ai.codebuddy.maxTurns': 'Máximo de turnos',
'ai.codebuddy.maxTurns.hint': 'Limita el máximo de turnos de conversación por petición para evitar bucles descontrolados. Déjalo vacío para usar el predeterminado.',
'ai.codebuddy.maxBudget': 'Presupuesto máximo (USD)',
'ai.codebuddy.maxBudget.hint': 'Gasto máximo (USD) por petición. Se detiene automáticamente al superarse. Déjalo vacío para no tener límite.',
'ai.codebuddy.sandbox': 'Modo sandbox',
'ai.codebuddy.sandbox.hint': 'Ejecuta llamadas a herramientas en un sandbox, restringiendo el acceso al sistema de archivos y a la red.',
'ai.codebuddy.fileCheckpointing': 'Puntos de control de archivos',
'ai.codebuddy.fileCheckpointing.hint': 'Habilita puntos de control de operaciones de archivos para poder revertir las modificaciones de IA a los archivos.',
'ai.codebuddy.elicitation.title': 'CodeBuddy necesita tu aporte',
'ai.codebuddy.elicitation.description': 'Revisa la petición para continuar el turno actual.',
'ai.codebuddy.elicitation.select': 'Selecciona una opción',
'ai.codebuddy.elicitation.yes': 'Sí',
'ai.codebuddy.elicitation.no': 'No',
'ai.codebuddy.elicitation.decline': 'Rechazar',
'ai.codebuddy.elicitation.accept': 'Continuar',
'ai.codebuddy.elicitation.validation.required': '{field} es obligatorio.',
'ai.codebuddy.elicitation.validation.invalidType': '{field} tiene un valor no válido.',
'ai.codebuddy.elicitation.validation.integer': '{field} debe ser un número entero.',
'ai.codebuddy.elicitation.validation.notInteger': '{field} debe ser un número entero.',
'ai.codebuddy.elicitation.validation.minimum': '{field} debe ser al menos {limit}.',
'ai.codebuddy.elicitation.validation.maximum': '{field} debe ser como máximo {limit}.',
'ai.codebuddy.elicitation.validation.minLength': '{field} debe contener al menos {limit} caracteres.',
'ai.codebuddy.elicitation.validation.maxLength': '{field} debe contener como máximo {limit} caracteres.',
'ai.codebuddy.elicitation.validation.minItems': 'Selecciona al menos {limit} opciones para {field}.',
'ai.codebuddy.elicitation.validation.maxItems': 'Selecciona como máximo {limit} opciones para {field}.',
'ai.codebuddy.elicitation.validation.format': '{field} debe coincidir con el formato {format}.',
'ai.codebuddy.elicitation.validation.option': 'Selecciona una opción válida para {field}.',
// AI OpenCode
'ai.opencode.title': 'OpenCode',
'ai.opencode.description': 'Usa OpenCode mediante el SDK oficial. Configura proveedores y claves en OpenCode y luego selecciónalo como agente de codificación externo.',
'ai.opencode.detecting': 'Detectando...',
'ai.opencode.detected': 'Detectado',
'ai.opencode.notFound': 'No encontrado',
'ai.opencode.path': 'Ruta:',
'ai.opencode.notFoundHint': 'No se pudo encontrar opencode en el PATH. Instálalo o especifica la ruta del ejecutable a continuación.',
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
'ai.opencode.check': 'Verificar',
'ai.opencode.resetPath': 'Restablecer',
// AI Grok Build (in-app managed agent — distinct from External MCP client install)
'ai.grok.title': 'Grok Build',
'ai.grok.description': "La CLI de agente de codificación Grok Build de xAI. Instala la CLI de Grok, inicia sesión con `grok login` o define XAI_API_KEY, y luego selecciónala como agente externo.",
'ai.grok.detecting': 'Detectando...',
'ai.grok.detected': 'Detectado',
'ai.grok.notFound': 'No encontrado',
'ai.grok.path': 'Ruta:',
'ai.grok.notFoundHint': 'No se pudo encontrar grok en el PATH. Instala la CLI de Grok Build o especifica la ruta del ejecutable a continuación.',
'ai.grok.customPathPlaceholder': 'p. ej. /usr/local/bin/grok',
'ai.grok.check': 'Verificar',
'ai.grok.resetPath': 'Restablecer',
'ai.grok.runtime.acp.title': 'Usar Grok ACP (agente stdio)',
'ai.grok.runtime.acp.default': 'Predeterminado',
'ai.grok.runtime.acp.description':
'Habla con Grok mediante el Protocolo de Cliente de Agente (grok agent stdio). Inyecta el MCP de Netcatty en session/new. Apágalo para usar la ruta original de CLI headless con streaming-json.',
'ai.grok.runtime.streamingJson.hint':
'Usando streaming-json headless (grok -p --output-format streaming-json). El archivo .grok/config.toml del proyecto se usa para la inyección de MCP.',
// AI Default Agent
'ai.defaultAgent': 'Agente predeterminado',
'ai.defaultAgent.description': 'Agente que se usará al iniciar una nueva sesión de IA',
'ai.defaultAgent.catty': 'Catty (Integrado)',
'ai.toolAccess.title': 'Acceso a herramientas',
'ai.toolAccess.mode': 'Modo de acceso de Netcatty',
'ai.toolAccess.description': 'Elige cómo acceden los agentes externos a las sesiones de Netcatty. MCP expone el servidor integrado, mientras que Habilidades + CLI apunta a los agentes hacia las habilidades locales de Netcatty y los comandos de CLI.',
'ai.toolAccess.mode.mcp': 'MCP',
'ai.toolAccess.mode.skills': 'Habilidades + CLI',
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
'ai.externalMcp.title': 'MCP externo',
'ai.externalMcp.description': 'Expón Netcatty como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén Netcatty en ejecución mientras los clientes estén conectados.',
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
'ai.externalMcp.mode': 'Modo de disponibilidad',
'ai.externalMcp.mode.temporary': 'Temporal',
'ai.externalMcp.mode.persistent': 'Siempre activo',
'ai.externalMcp.mode.description': 'El modo temporal se desactiva automáticamente tras el tiempo de inactividad. El modo Siempre activo restaura el MCP externo cuando se inicia Netcatty.',
'ai.externalMcp.idleTimeout': 'Tiempo de inactividad',
'ai.externalMcp.idleTimeout.description': 'En modo temporal, desactiva el MCP externo después de esta cantidad de minutos sin operaciones de MCP.',
'ai.externalMcp.idleTimeout.minutes': 'min',
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
'ai.externalMcp.sessionIdleTimeout.description': 'Cierra automáticamente las sesiones abiertas por una IA después de esta cantidad de minutos sin actividad de terminal o archivos.',
'ai.externalMcp.usage.title': 'Cómo usarlo',
'ai.externalMcp.usage.keepRunning': '1. Activa el MCP externo y mantén Netcatty en ejecución.',
'ai.externalMcp.usage.localhost': '2. Los clientes se conectan mediante el launcher local (solo 127.0.0.1). El descubrimiento se elimina cuando desactivas el interruptor.',
'ai.externalMcp.usage.permissions': '3. Las operaciones de escritura siguen Configuración → IA → Seguridad (observador / confirmar / automático) y la lista de bloqueo de comandos.',
'ai.externalMcp.usage.capabilities': '4. Todas las herramientas del catálogo están disponibles: terminal, SFTP, Vault y reenvío de puertos. Los secretos (contraseñas / claves privadas) nunca se devuelven.',
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
'ai.externalMcp.security': 'Seguridad',
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
'ai.externalMcp.permissionMode.unknown': 'Desconocido',
'ai.externalMcp.discovery': 'Descubrimiento',
'ai.externalMcp.launcher': 'Launcher',
'ai.externalMcp.unavailable': 'No disponible',
'ai.externalMcp.bridgeUnavailable': 'Puente de MCP externo no disponible',
'ai.externalMcp.copy': 'Copiar',
'ai.externalMcp.copied': 'Copiado',
'ai.externalMcp.copyFailed': 'No se pudo copiar. Intenta copiarlo manualmente.',
'ai.externalMcp.refresh': 'Actualizar',
'ai.externalMcp.clientConfiguration': 'Configuración del cliente',
'ai.externalMcp.clientConfiguration.description': 'Elige un cliente para instalarlo con un clic, o copia fragmentos de CLI o configuración.',
'ai.externalMcp.client.codex': 'Codex',
'ai.externalMcp.client.claude': 'Claude Code',
'ai.externalMcp.client.grok': 'Grok',
'ai.externalMcp.client.cursor': 'Cursor',
'ai.externalMcp.cliCommand': 'Comando CLI',
'ai.externalMcp.configSnippet': 'Fragmento de configuración',
'ai.externalMcp.addToCodex': 'Agregar a Codex',
'ai.externalMcp.addToClaude': 'Agregar a Claude Code',
'ai.externalMcp.addToGrok': 'Agregar a Grok',
'ai.externalMcp.codexAdded': 'Entrada de MCP de Codex agregada. Reinicia Codex o abre una nueva sesión de Codex.',
'ai.externalMcp.claudeAdded': 'Entrada de MCP de Claude Code agregada. Reinicia Claude Code o abre una nueva sesión de Claude Code.',
'ai.externalMcp.grokAdded': 'Entrada de MCP de Grok agregada. Reinicia Grok o abre una nueva sesión de Grok.',
'ai.externalMcp.installCodex': 'Instala Codex por separado y luego haz clic en Actualizar.',
'ai.externalMcp.installClaude': 'Instala Claude Code por separado y luego haz clic en Actualizar.',
'ai.externalMcp.installGrok': 'Instala la CLI de Grok por separado y luego haz clic en Actualizar.',
'ai.externalMcp.conflict.description': 'Ya existe una entrada netcatty-external que apunta a otro lugar. Quítala o edítala manualmente.',
'ai.externalMcp.enableForLauncher': 'Habilita el MCP externo para obtener una ruta de launcher utilizable.',
'ai.externalMcp.cursor.title': 'Cursor / otros clientes',
'ai.externalMcp.cursor.description': 'Combina esto en tu configuración de MCP (por ejemplo, ~/.cursor/mcp.json). No reemplaces todo el archivo si ya tienes otros servidores.',
'ai.externalMcp.status.unavailable': 'No disponible',
'ai.externalMcp.status.disabled': 'Desactivado',
'ai.externalMcp.status.running': 'En ejecución',
'ai.externalMcp.status.starting': 'Iniciando',
'ai.externalMcp.status.error': 'Error',
'ai.externalMcp.status.configured': 'Configurado',
'ai.externalMcp.status.notConfigured': 'No configurado',
'ai.externalMcp.status.checking': 'Comprobando',
'ai.externalMcp.status.codexNotFound': 'Codex no encontrado',
'ai.externalMcp.status.claudeNotFound': 'Claude Code no encontrado',
'ai.externalMcp.status.grokNotFound': 'Grok no encontrado',
'ai.externalMcp.status.conflict': 'Conflicto',
'ai.userSkills.title': 'Habilidades de usuario',
'ai.userSkills.description': 'Abre la carpeta de habilidades de Netcatty para agregar tus propios directorios de habilidades. Netcatty escanea estas habilidades automáticamente e inyecta solo índices ligeros, salvo que una habilidad coincida claramente con la petición actual.',
'ai.userSkills.openFolder': 'Abrir carpeta de habilidades',
'ai.userSkills.reload': 'Recargar habilidades',
'ai.userSkills.location': 'Ubicación',
'ai.userSkills.loading': 'Escaneando habilidades de usuario...',
'ai.userSkills.summary': '{ready} listas, {warnings} advertencias',
'ai.userSkills.empty': 'Aún no se encontraron habilidades de usuario. Abre la carpeta para agregar directorios de habilidades con un archivo SKILL.md.',
'ai.userSkills.unavailable': 'Las habilidades de usuario no están disponibles en este entorno.',
'ai.userSkills.status.ready': 'Lista',
'ai.userSkills.status.warning': 'Advertencia',
// AI Quick Messages
'ai.quickMessages.title': 'Mensajes rápidos',
'ai.quickMessages.description': 'Crea prompts reutilizables que puedes insertar desde el chat de IA con / o con el botón de mensajes rápidos. A diferencia de las habilidades de usuario, los mensajes rápidos llenan el compositor con texto.',
'ai.quickMessages.add': 'Agregar mensaje rápido',
'ai.quickMessages.createTitle': 'Nuevo mensaje rápido',
'ai.quickMessages.editTitle': 'Editar mensaje rápido',
'ai.quickMessages.name': 'Nombre',
'ai.quickMessages.name.placeholder': 'p. ej. Verificar espacio en disco',
'ai.quickMessages.slug': 'Comando',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': 'Descripción (opcional)',
'ai.quickMessages.descriptionField.placeholder': 'Breve indicación sobre lo que hace este prompt',
'ai.quickMessages.content': 'Contenido del mensaje',
'ai.quickMessages.content.placeholder': 'Texto completo del prompt para insertar al seleccionarlo...',
'ai.quickMessages.empty': 'Aún no hay mensajes rápidos. Agrega algunos prompts que uses seguido.',
'ai.quickMessages.confirmDelete': '¿Eliminar el mensaje rápido "{name}"?',
'ai.quickMessages.error.nameRequired': 'El nombre es obligatorio.',
'ai.quickMessages.error.invalidSlug': 'El comando solo puede contener letras minúsculas, números y guiones.',
'ai.quickMessages.error.contentRequired': 'El contenido del mensaje es obligatorio.',
'ai.quickMessages.error.slugTaken': 'Este comando ya lo usa otro mensaje rápido.',
'ai.quickMessages.error.slugConflictsWithSkill': 'Este comando entra en conflicto con la habilidad de usuario "/{slug}". Elige otro.',
'ai.quickMessages.error.maxItems': 'Puedes guardar como máximo {max} mensajes rápidos.',
// AI Chat
'ai.chat.noProvider': 'No hay ningún proveedor de IA configurado. Ve a **Configuración → IA → Proveedores** para agregar y habilitar un proveedor.',
'ai.chat.toolDenied': 'La acción fue rechazada por el usuario.',
'ai.chat.toolApproved': 'Aprobado',
'ai.chat.toolApprovalHint': 'Presiona Enter una vez · Esc para rechazar',
'ai.chat.approve': 'Aprobar',
'ai.chat.approveOnce': 'Una vez',
'ai.chat.alwaysAllow': 'Siempre',
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
'ai.chat.reject': 'Rechazar',
'ai.chat.toolLabel': 'Herramienta',
'ai.chat.targetLabel': 'Destino',
'ai.chat.rawCommand': 'Comando',
'ai.chat.copyCommand': 'Copiar',
'ai.chat.commandCopied': 'Copiado',
'ai.chat.approvalSession': 'Sesión',
'ai.chat.approvalShell': 'Shell',
'ai.chat.approvalCwd': 'Cwd',
'ai.chat.approvalReason': 'Motivo',
'ai.chat.approvalInvocation': 'Invocación',
'ai.chat.permissionRequired': 'Permiso requerido',
'ai.chat.permissionDescription': 'El agente de IA quiere ejecutar una llamada a una herramienta que requiere tu aprobación.',
'ai.chat.commandBlocked': 'Este comando está bloqueado por tu política de seguridad y no se puede ejecutar.',
'ai.chat.recommendAllow': 'Permitir',
'ai.chat.recommendConfirm': 'Confirmar',
'ai.chat.recommendDeny': 'Denegar',
'ai.chat.exportConversation': 'Exportar conversación',
'ai.chat.exportAs': 'Exportar como',
'ai.chat.exportMarkdown': 'Markdown',
'ai.chat.exportJSON': 'JSON',
'ai.chat.exportPlainText': 'Texto plano',
'ai.chat.thinking': 'Pensando',
'ai.chat.thoughtFor': 'Pensó durante {duration}',
'ai.chat.thought': 'Pensamiento',
'ai.chat.agents': 'Agentes',
'ai.chat.detectedOnMachine': 'Detectado en esta máquina',
'ai.chat.rescan': 'Volver a escanear',
'ai.chat.permObserver': 'Observador',
'ai.chat.permConfirm': 'Confirmar',
'ai.chat.permAuto': 'Automático',
'ai.chat.permObserverDesc': 'Solo lectura (Cursor CLI: Ask)',
'ai.chat.permConfirmDesc': 'Preguntar antes de que Netcatty escriba (Cursor CLI: agent + force)',
'ai.chat.permAutoDesc': 'Ejecutar libremente (Cursor CLI: agent + force)',
'ai.chat.emptyHint': 'Pregunta sobre tus servidores, ejecuta comandos u obtén ayuda con las configuraciones.',
'ai.chat.placeholder': 'Mensaje a {agent} — @ para incluir contexto, / para comandos',
'ai.chat.placeholderDefault': 'Mensaje al agente Catty...',
'ai.chat.noModel': 'Sin modelo',
'ai.chat.noProviderModel': 'No hay modelo predeterminado: define uno en Configuración → IA → Proveedores.',
'ai.chat.selectProvider': 'Seleccionar proveedor',
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
'ai.chat.untitled': 'Sin título',
'ai.chat.justNow': 'Ahora mismo',
'ai.chat.minutesAgo': 'hace {n} min',
'ai.chat.hoursAgo': 'hace {n} h',
'ai.chat.daysAgo': 'hace {n} d',
'ai.chat.newChat': 'Nuevo chat',
'ai.chat.allSessions': 'Todas las sesiones',
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
'ai.chat.jumpNav': 'Ir al mensaje',
'ai.chat.jumpUntitled': '(mensaje vacío)',
'ai.chat.usedTools': 'Herramientas usadas: {n}',
'ai.chat.loadMoreSessions': 'Cargar más sesiones ({n} más)',
'ai.chat.noSessions': 'No hay sesiones anteriores',
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
'ai.chat.menuHosts': 'Hosts',
'ai.chat.menuContext': 'Contexto',
'ai.chat.menuFiles': 'Archivos',
'ai.chat.menuImage': 'Imagen',
'ai.chat.menuMentionHost': 'Mencionar host',
'ai.chat.menuUserSkills': 'Habilidades de usuario',
'ai.chat.menuSlashCommands': 'Comandos de barra',
'ai.chat.slashCommands': 'Comandos de barra',
'ai.chat.slashSystemCommands': 'Comandos',
'ai.chat.slashQuickMessages': 'Mensajes rápidos',
'ai.chat.slashUserSkills': 'Habilidades de usuario',
'ai.chat.quickMessages': 'Comandos de barra',
'ai.chat.slashNoResults': 'No hay comandos que coincidan',
'ai.chat.slashEmptyHint': 'Agrega prompts en Configuración → IA → Mensajes rápidos.',
// AI Chat Shortcuts
'ai.chatShortcuts.title': 'Atajos de chat',
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
// AI Error
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
// AI Web Search
'ai.webSearch.title': 'Búsqueda web',
'ai.webSearch.enable': 'Habilitar búsqueda web',
'ai.webSearch.enable.description': 'Permite que el agente de IA busque en la web información actual.',
'ai.webSearch.provider': 'Proveedor de búsqueda',
'ai.webSearch.provider.description': 'Elige un proveedor de API de búsqueda web.',
'ai.webSearch.apiKey': 'Clave de API',
'ai.webSearch.apiKey.description': 'Clave de API para el proveedor de búsqueda seleccionado.',
'ai.webSearch.apiKey.placeholder': 'Ingresa la clave de API...',
'ai.webSearch.apiHost': 'Host de API',
'ai.webSearch.apiHost.description': 'Endpoint de API personalizado. Deja el predeterminado salvo que uses un proxy.',
'ai.webSearch.apiHost.searxngDescription': 'URL de tu instancia de SearXNG (obligatoria).',
'ai.webSearch.maxResults': 'Máximo de resultados',
'ai.webSearch.maxResults.description': 'Cantidad máxima de resultados de búsqueda a devolver (1-20).',
// AI Safety Settings
'ai.safety.title': 'Seguridad',
'ai.safety.permissionMode': 'Modo de permisos',
'ai.safety.permissionMode.description': 'Controla cómo interactúa la IA con tus sesiones de terminal de Netcatty. El modo Observador bloquea las operaciones de escritura que pasan por Netcatty. Las CLIs de agentes externos pueden seguir teniendo sus propias herramientas locales y flujo de aprobación.',
'ai.safety.permissionMode.observer': 'Observador: solo lectura, sin acciones',
'ai.safety.permissionMode.confirm': 'Confirmar: preguntar antes de las acciones',
'ai.safety.permissionMode.auto': 'Automático: ejecutar libremente',
'ai.safety.commandTimeout': 'Tiempo de espera de comandos',
'ai.safety.commandTimeout.description': 'Máximo de segundos que un comando puede ejecutarse antes de ser terminado mediante la ejecución de Netcatty.',
'ai.safety.commandTimeout.unit': 'seg',
'ai.safety.maxIterations': 'Máximo de iteraciones',
'ai.safety.maxIterations.description': 'Máximo de bucles de uso de herramientas de IA para evitar una ejecución descontrolada. Los agentes externos pueden tener sus propios límites de iteración internos que tienen prioridad.',
'ai.safety.blocklist': 'Lista de bloqueo de comandos',
'ai.safety.blocklist.description': 'Patrones regex para bloquear comandos peligrosos ejecutados mediante Netcatty.',
'ai.safety.blocklist.placeholder': 'Patrón regex...',
'ai.safety.blocklist.reset': 'Restablecer a los valores predeterminados',
'ai.safety.blocklist.add': 'Agregar patrón',
'ai.safety.grants.title': 'Memoria de permisos',
'ai.safety.grants.heading': 'Reglas de permitir en modo Confirmar',
'ai.safety.grants.description': 'El modo Confirmar pregunta antes de ejecutar una operación. Las reglas guardadas permiten automáticamente las operaciones que coinciden en todas las sesiones/nodos de terminal, y se pueden editar manualmente.',
'ai.safety.grants.empty': 'Aún no hay reglas guardadas. Aprueba una herramienta con "Permitir siempre" o agrega una manualmente.',
'ai.safety.grants.capability': 'Capacidad',
'ai.safety.grants.sessionPattern': 'Patrón de sesión',
'ai.safety.grants.commandPattern': 'Patrón de comando (opcional)',
'ai.safety.grants.note': 'Nota (opcional)',
'ai.safety.grants.add': 'Agregar regla',
'ai.safety.grants.remove': 'Quitar',
'ai.safety.grants.export': 'Exportar JSON',
'ai.safety.grants.import': 'Importar JSON',
'ai.safety.note': 'Estos ajustes de seguridad se aplican a las acciones que pasan por Netcatty. Las CLIs de agentes externos también pueden exponer herramientas locales regidas por el propio agente.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Agregar terminal',
'terminal.layer.switchToSplitView': 'Cambiar a vista dividida',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
'terminal.layer.history': 'Historial',
'terminal.layer.theme': 'Tema',
'terminal.layer.notes': 'Notas',
'terminal.layer.aiChat': 'Chat de IA',
'terminal.layer.movePanelLeft': 'Mover panel a la izquierda',
'terminal.layer.movePanelRight': 'Mover panel a la derecha',
'terminal.layer.closePanel': 'Cerrar panel',
'terminal.layer.closePane': 'Cerrar división',
'terminal.layer.resizeSplit': 'Redimensionar división',
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
'terminal.layer.hostTree.search': 'Buscar hosts...',
'terminal.layer.hostTree.searchButton': 'Buscar',
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
'terminal.layer.hostTree.newHost': 'Nuevo host',
'terminal.layer.hostTree.newHostInGroup': 'Nuevo host en este grupo',
'terminal.layer.hostTree.editHost': 'Editar host',
'terminal.layer.hostTree.hostSavedNextConnection': 'Host actualizado. Los ajustes de conexión se aplicarán la próxima vez que te conectes.',
'terminal.layer.hostTree.newGroup': 'Nuevo grupo',
'terminal.layer.hostTree.localShell': 'Shell local',
'terminal.layer.hostTree.tagsEmpty': 'No hay etiquetas disponibles',
'terminal.layer.hostTree.clearTags': 'Borrar selección',
'terminal.layer.hostTree.collapse': 'Contraer lista de hosts',
'terminal.layer.hostTree.expand': 'Expandir lista de hosts',
'terminal.layer.hostTree.empty': 'No se encontraron hosts',
'terminal.layer.hostTree.details.host': 'Host',
'terminal.layer.hostTree.details.user': 'Usuario',
'terminal.layer.hostTree.details.port': 'Puerto',
'terminal.layer.hostTree.details.protocol': 'Protocolo',
'terminal.layer.hostTree.details.group': 'Grupo',
'terminal.layer.hostTree.details.tags': 'Etiquetas',
'terminal.layer.hostTree.details.lastConnected': 'Última conexión',
'topTabs.openQuickSwitcher': 'Abrir selector rápido',
'topTabs.moreTabs': 'Más pestañas',
'topTabs.aiAssistant': 'Asistente de IA',
'topTabs.newLocalTerminal': 'Nueva terminal local',
'topTabs.controlPanel': 'Controles rápidos',
'topTabs.controlPanel.externalMcp': 'MCP externo',
'topTabs.controlPanel.theme': 'Tema',
'topTabs.controlPanel.theme.light': 'Claro',
'topTabs.controlPanel.theme.dark': 'Oscuro',
'topTabs.controlPanel.theme.system': 'Sistema',
'topTabs.externalMcp.enable': 'Habilitar MCP externo',
'topTabs.externalMcp.disable': 'Desactivar MCP externo',
'topTabs.windowOpacity': 'Opacidad de la ventana',
'topTabs.openSettings': 'Abrir Configuración',
'ai.chat.sessionHistory': 'Historial de sesiones',
'ai.chat.resizeInput': 'Arrastra para redimensionar el campo de mensaje',
'ai.chat.attach': 'Adjuntar',
'ai.chat.terminalSelectionAttachment': 'Selección de la terminal',
'ai.chat.terminalSelectionLines': 'líneas: {count}',
'ai.chat.collapse': 'Contraer',
'ai.chat.expand': 'Expandir',
'ai.chat.enableAgent': 'Habilitar {name}',
'ai.chat.artifact.noteFallback': 'Nota de Vault',
'ai.chat.artifact.openNotes': 'Abrir Notas',
'ai.chat.artifact.openHosts': 'Abrir Hosts',
'ai.chat.artifact.notesSummary': '{count} notas en Vault',
'ai.chat.artifact.hostsSummary': '{count} hosts en Vault',
'ai.chat.artifact.hostsAdded': 'Se agregaron {count} hosts',
'ai.chat.artifact.hostsPreview': 'Vista previa de {count} hosts',
'ai.chat.artifact.failed': 'La operación de Vault falló',
'ai.chat.artifact.unavailableTitle': 'No disponible',
'ai.chat.artifact.noteMissing': 'Esta nota ya no está en tu Vault.',
'ai.chat.artifact.hostMissing': 'Este host ya no está en tu Vault.',
'ai.chat.artifact.snippetMissing': 'Este snippet o script ya no está en tu Vault.',
'ai.chat.artifact.openSnippets': 'Abrir Snippets',
'ai.chat.artifact.snippetsSummary': '{count} snippets en Vault',
'ai.chat.artifact.scriptsSummary': '{count} scripts en Vault',
'ai.chat.artifact.snippetFallback': 'Snippet de Vault',
'ai.chat.artifact.scriptFallback': 'Script de automatización',
'ai.chat.artifact.scriptLanguage': 'Script de {language}',
'ai.chat.artifact.snippetDeleted': 'Snippet eliminado',
'ai.chat.artifact.scriptDeleted': 'Script eliminado',
'ai.chat.artifact.snippetRan': 'Snippet ejecutado',
'ai.chat.artifact.scriptStarted': 'Se inició la ejecución del script',
'ai.chat.artifact.scriptRunStatus': 'Ejecución del script: {status}',
'ai.chat.artifact.scriptRunsSummary': '{count} ejecuciones de scripts',
'ai.chat.artifact.scriptRunStopped': 'Ejecución del script detenida',
'ai.chat.artifact.scriptRunPaused': 'Ejecución del script en pausa',
'ai.chat.artifact.scriptRunResumed': 'Ejecución del script reanudada',
'ai.chat.artifact.scriptReference': 'referencia de la API de nct',
'zmodem.waitingForRemote': 'Esperando al servidor remoto...',
'zmodem.uploading': 'Subiendo',
'zmodem.downloading': 'Descargando',
'zmodem.cancelTransfer': 'Cancelar transferencia (Ctrl+C)',
'zmodem.overwrite.title': 'El archivo remoto ya existe',
'zmodem.overwrite.applyToRest': 'Aplicar a los conflictos restantes',
'zmodem.overwrite.overwrite': 'Sobrescribir',
'zmodem.overwrite.skip': 'Omitir',
'zmodem.overwrite.cancel': 'Cancelar',
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
};
File diff suppressed because it is too large. Load diff
+133
View File
@@ -0,0 +1,133 @@
export const esScriptsMessages = {
'scripts.meta.name': 'Nombre',
'scripts.meta.language': 'Lenguaje',
'scripts.meta.description': 'Descripción',
'scripts.meta.descriptionPlaceholder': 'Notas opcionales sobre este script',
'scripts.meta.trigger': 'Disparador',
'scripts.meta.triggerPattern': 'Patrón de salida (regex)',
'scripts.meta.code': 'Script',
'scripts.trigger.manual': 'Ejecución manual',
'scripts.trigger.onConnect': 'Ejecutar al conectar',
'scripts.trigger.onOutput': 'Ejecutar al coincidir con la salida',
'scripts.trigger.onOutputHint': 'Se dispara cuando la salida del servidor coincide con el patrón (el eco de las teclas del usuario se ignora). Sin hosts de destino configurados, escucha en la sesión conectada actual; con hosts de destino, solo en esos hosts. Deshabilitado en aplicaciones de pantalla alterna como vim o htop. Se suprime mientras otro script se ejecuta en esta sesión; se vuelve a revisar cuando termina. Para esperas dentro del flujo, usa nct.screen.waitForText o nct.screen.waitForRegex en el código del script.',
'scripts.actions.save': 'Guardar',
'scripts.actions.runNow': 'Ejecutar ahora',
'scripts.actions.openEditor': 'Abrir editor',
'scripts.actions.openEditorHint': 'Editar el script en una ventana más grande',
'scripts.editor.modalTitle': 'Editor de scripts',
'scripts.editor.modalSubtitle': 'Edita los metadatos y el código del script en un espacio de trabajo más amplio.',
'scripts.editor.lineCount': '{count} líneas',
'scripts.editor.resize': 'Redimensionar el editor',
'scripts.targets.hint': 'Los grupos seleccionados se resuelven dinámicamente, por lo que los hosts agregados después se incluyen automáticamente.',
'scripts.targets.connectOrderHint': 'El orden de ejecución de los scripts de conexión se configura por host en Detalles del host → Automatización.',
'scripts.targets.currentHostMismatch': 'Este script no está asignado al host actual.',
'hostDetails.automation.groupScripts': 'Scripts de grupo heredados',
'hostDetails.automation.groupScriptsHint': 'Estos scripts siguen al grupo del host dinámicamente y se ordenan antes de la cola específica del host.',
'scripts.actions.runNowHint': 'Ejecutar en los destinos seleccionados, o en todos los hosts conectables cuando esa opción está habilitada.',
'scripts.actions.runParallel': 'Ejecutar en todas las pestañas (en paralelo)',
'scripts.actions.runSequential': 'Ejecutar en todas las pestañas (en secuencia)',
'scripts.actions.runOnAllTabs': 'Ejecutar en todas las pestañas',
'scripts.actions.skippedConnectingSessions': '{count} pestaña(s) aún conectándose y fueron omitidas',
'scripts.actions.skippedSensitiveSessions': '{count} pestaña(s) omitidas (entrada de contraseña/datos sensibles)',
'scripts.actions.noRunnableHosts': 'Ningún host conectable coincide con los destinos de este script',
'scripts.sidePanel.library': 'Biblioteca',
'scripts.sidePanel.running': 'En ejecución',
'scripts.sidePanel.newScript': 'Nuevo script',
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
'scripts.running.unnamed': 'Script sin título',
'scripts.running.status.running': 'En ejecución',
'scripts.running.status.paused': 'En pausa',
'scripts.running.status.completed': 'Completado',
'scripts.running.status.failed': 'Falló',
'scripts.running.waitingFor': 'Esperando {pattern}',
'scripts.running.waitingForLabel': 'Esperando',
'scripts.running.waitingForShellPrompt': 'indicador del shell (# o $)',
'scripts.running.lastSent': 'Enviado: {command}',
'scripts.recording.start': 'Comenzar grabación',
'scripts.recording.active': 'Detener grabación',
'scripts.recording.startHint': 'Graba las acciones en la terminal enfocada y genera código de script nct',
'scripts.recording.unavailableHint': 'La grabación no está disponible aquí — usa la barra lateral de scripts a la derecha',
'scripts.recording.activeHint': 'Grabando esta terminal. Escribe comandos como de costumbre; haz clic en Detener o usa el control REC en la barra de herramientas para finalizar y guardar.',
'scripts.recording.started': 'Grabación iniciada — opera en la terminal',
'scripts.recording.noSession': 'Conecta una terminal primero (pestaña única o espacio de trabajo)',
'scripts.recording.alreadyActive': 'Otra terminal ya está grabando',
'scripts.recording.stop': 'Detener grabación',
'scripts.recording.pause': 'Pausar grabación',
'scripts.recording.resume': 'Reanudar grabación',
'scripts.recording.saveTitle': 'Guardar script grabado',
'scripts.recording.namePlaceholder': 'Nombre del script',
'scripts.recording.packagePlaceholder': 'Guardar en carpeta',
'scripts.recording.rootPackage': 'Raíz',
'scripts.recording.save': 'Guardar',
'scripts.recording.saveAndEdit': 'Guardar y editar',
'scripts.recording.helpTitle': 'Cómo grabar un script',
'scripts.recording.helpIntro': 'La grabación convierte lo que haces en la terminal en un script de automatización reutilizable — útil para despliegues, verificaciones de salud y otras tareas repetitivas.',
'scripts.recording.helpStep1': 'Conecta a un host. Una pestaña de terminal independiente o una terminal dentro de un espacio de trabajo funcionan igual — abre la barra lateral de scripts a la derecha.',
'scripts.recording.helpStep2': 'Haz clic en Comenzar grabación. Una insignia REC roja en la barra de herramientas de la terminal indica que la grabación está activa.',
'scripts.recording.helpStep3': 'Escribe comandos en la terminal como lo harías normalmente. Cada pulsación de Enter se captura como un paso.',
'scripts.recording.helpStep4': 'Cuando termines, haz clic en Detener grabación, o usa el control de detención junto a REC en la barra de herramientas de la terminal.',
'scripts.recording.helpStep5': 'Nombra el script y guárdalo. Elige Guardar y editar si quieres ajustar el código generado en el editor de scripts.',
'scripts.recording.helpTipsTitle': 'Consejos',
'scripts.recording.helpTip1': 'Las pausas de más de 1 segundo entre acciones se registran como tiempo de espera para que la reproducción no sea demasiado rápida.',
'scripts.recording.helpTip2': 'Después de cada comando, la grabación espera un indicador del shell (como $ o #) antes del siguiente paso.',
'scripts.recording.helpTip3': 'La entrada de contraseñas se marca como sensible y no se almacena en texto plano en el script.',
'scripts.recording.helpTip4': 'Para vim, menús u otros flujos interactivos, graba primero los comandos principales y luego refina el script manualmente.',
'scripts.dialog.title': 'Script',
'scripts.dialog.ok': 'Aceptar',
'scripts.dialog.required': 'Obligatorio',
'scripts.dialog.numberInvalid': 'Ingresa un número válido',
'scripts.dialog.numberMin': 'Debe ser al menos {min}',
'scripts.dialog.numberMax': 'Debe ser como máximo {max}',
'scripts.dialog.numberStep': 'Debe usar incrementos de {step}',
'scripts.dialog.waitForTimeoutTitle': 'La espera agotó el tiempo',
'scripts.dialog.retry': 'Reintentar',
'scripts.dialog.skip': 'Omitir',
'scripts.dialog.abort': 'Abortar',
'scripts.running.stepProgress': 'Paso {current} / {total}',
'scripts.running.determinateProgress': '{label} {current}/{total}',
'scripts.running.progressFallback': 'Progreso',
'scripts.running.operationsCount': '{count} operaciones',
'scripts.running.opsPrefix': '',
'scripts.running.opsSuffix': ' operaciones',
'scripts.running.elapsedLabel': 'Transcurrido',
'scripts.running.lastSentLabel': 'Enviado:',
'scripts.running.elapsed': '{elapsed}',
'scripts.running.completedSummary': 'Completado · {count} operaciones · {elapsed}',
'scripts.running.dismissHint': 'Toca cerrar para descartar',
'scripts.running.dismiss': 'Cerrar',
'scripts.running.viewLogs': 'Ver registros',
'scripts.running.logTitle': '{name} · Registro de ejecución',
'scripts.running.logEmpty': 'Aún no hay salida de registro',
'scripts.running.pause': 'Pausar',
'scripts.running.resume': 'Reanudar',
'scripts.running.stop': 'Detener',
'scripts.recording.saved': 'Script guardado',
'scripts.recording.savedNamed': 'Guardado "{name}"',
'scripts.observer.blocked': 'El modo observador bloquea los scripts que escriben en la terminal.',
'vault.section.scripts': 'Scripts',
'vault.nav.scripts': 'Scripts',
'snippets.action.newScript': 'Nuevo script de automatización',
'hostDetails.section.automation': 'Automatización',
'hostDetails.automation.loginScript': 'Script de inicio de sesión',
'hostDetails.automation.loginScriptPlaceholder': 'Selecciona un script',
'hostDetails.automation.none': 'Ninguno',
'hostDetails.automation.outputTriggers': 'Disparadores de salida',
'hostDetails.automation.addTrigger': 'Agregar disparador',
'hostDetails.automation.triggerPatternPlaceholder': 'Patrón de regex',
'hostDetails.automation.linkedScripts': 'Scripts vinculados',
'hostDetails.automation.linkedScriptsEmpty': 'Aún no hay scripts vinculados a este host.',
'hostDetails.automation.linkScriptPlaceholder': 'Vincular script existente…',
'hostDetails.automation.unlink': 'Desvincular',
'hostDetails.automation.equivalenceHint': 'Los vínculos agregan este host a la lista de destinos del script en Scripts. Ambas vistas se mantienen sincronizadas.',
'hostDetails.automation.queueHint': 'Los scripts se ejecutan en orden cuando este host se conecta. Los scripts globales se ejecutan primero, luego esta cola.',
'hostDetails.automation.globalScripts': 'Scripts de conexión globales',
'hostDetails.automation.globalScriptsHint': 'Se ejecutan primero en cada conexión. Reordena según el orden de clasificación de la biblioteca de Scripts.',
'hostDetails.automation.connectQueue': 'Cola de ejecución de este host',
'hostDetails.automation.connectQueueEmpty': 'Aún no hay scripts de conexión en cola para este host.',
'hostDetails.automation.addToQueuePlaceholder': 'Agregar script a la cola…',
'hostDetails.automation.moveUp': 'Subir',
'hostDetails.automation.moveDown': 'Bajar',
'hostDetails.automation.removeFromQueue': 'Quitar de la cola',
'hostDetails.automation.dragHandle': 'Arrastra para reordenar',
'hostDetails.automation.queueDragHint': 'Arrastra los elementos para reordenar la cola de conexión.',
};
@@ -0,0 +1,261 @@
import type { Messages } from '../types';
export const esSystemManagerMessages: Messages = {
'terminal.layer.system': 'Sistema',
'systemManager.noSession': 'No hay una sesión de terminal activa.',
'systemManager.notConnected': 'Conéctate a un host para administrar procesos y servicios.',
'systemManager.empty': 'No hay datos disponibles.',
'systemManager.tabs.overview': 'Resumen',
'systemManager.tabs.processes': 'Procesos',
'systemManager.tabs.ports': 'Puertos',
'systemManager.tabs.services': 'Servicios',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.tabs.gpu': 'GPU',
'systemManager.tabs.ariaLabel': 'Secciones del administrador del sistema',
'systemManager.popup.loading': 'Abriendo terminal…',
'systemManager.popup.startupFailed': 'El comando de inicio no se completó correctamente. Verifica que el destino siga disponible e inténtalo de nuevo.',
'systemManager.errors.loadProcesses': 'No se pudieron cargar los procesos',
'systemManager.errors.loadTmux': 'No se pudieron cargar las sesiones de tmux',
'systemManager.errors.loadTmuxWindows': 'No se pudieron cargar las ventanas de tmux',
'systemManager.errors.loadTmuxPanes': 'No se pudieron cargar los paneles de tmux',
'systemManager.errors.loadTmuxClients': 'No se pudieron cargar los clientes de tmux',
'systemManager.errors.actionFailed': 'La acción falló',
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
'systemManager.overview.loading': 'Cargando resumen del sistema…',
'systemManager.overview.memory': 'Memoria',
'systemManager.overview.disk': 'Disco',
'systemManager.overview.network': 'Red',
'systemManager.overview.rx': 'RX',
'systemManager.overview.tx': 'TX',
'systemManager.overview.cores': '{{count}} núcleos',
'systemManager.overview.load': 'Carga',
'systemManager.overview.uptime': 'Tiempo activo',
'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': 'Sistema',
'systemManager.overview.kernel': 'Kernel',
'systemManager.overview.swap': 'Swap',
'systemManager.overview.latency': 'Latencia de red SSH',
'systemManager.overview.cpuCores': 'Núcleos de CPU',
'systemManager.overview.disks': 'Discos',
'systemManager.overview.interfaces': 'Interfaces de red',
'systemManager.overview.topProcesses': 'Procesos con más memoria',
'systemManager.overview.noData': 'Sin datos',
'systemManager.overview.noDisks': 'Sin datos de disco',
'systemManager.overview.noInterfaces': 'Sin datos de interfaces',
'systemManager.overview.noTopProcesses': 'Sin datos de procesos',
'systemManager.processes.search': 'Buscar procesos…',
'systemManager.processes.command': 'Comando',
'systemManager.processes.user': 'Usuario',
'systemManager.processes.term': 'Terminar',
'systemManager.processes.kill': 'Forzar cierre',
'systemManager.processes.stop': 'Detener (SIGSTOP)',
'systemManager.processes.cont': 'Continuar (SIGCONT)',
'systemManager.processes.hup': 'Colgar (SIGHUP)',
'systemManager.processes.renice': 'Renice',
'systemManager.processes.renicePrompt': 'Valor de nice (-20 a 19)',
'systemManager.processes.reniceInvalid': 'El valor de nice debe estar entre -20 y 19',
'systemManager.processes.confirmKill': '¿Enviar SIGKILL al proceso {{pid}}?',
'systemManager.processes.confirmSignal': '¿Enviar SIG{{signal}} al proceso {{pid}}?',
'systemManager.processes.filter.all': 'Todos',
'systemManager.processes.filter.running': 'En ejecución',
'systemManager.processes.ppid': 'PID padre',
'systemManager.processes.rss': 'RSS',
'systemManager.processes.vsz': 'Tamaño virtual',
'systemManager.processes.elapsed': 'Transcurrido',
'systemManager.processes.stat': 'Estado',
'systemManager.processes.meta': '{{count}} proceso(s)',
'systemManager.processes.loading': 'Cargando procesos…',
'systemManager.processes.loadingMore': 'Cargando más procesos…',
'systemManager.processes.state.running': 'En ejecución',
'systemManager.processes.state.sleeping': 'En reposo',
'systemManager.processes.state.stopped': 'Detenido',
'systemManager.processes.state.zombie': 'Zombie',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': 'MEM',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': 'Comando',
'systemManager.processes.sort.user': 'Usuario',
'systemManager.common.dismiss': 'Descartar',
'systemManager.common.checkingAvailability': 'Verificando disponibilidad…',
'systemManager.common.loading': 'Cargando…',
'systemManager.common.loadingDetails': 'Cargando detalles…',
'systemManager.common.loadingStats': 'Cargando estadísticas…',
'systemManager.tmux.new': 'Nuevo',
'systemManager.tmux.search': 'Buscar sesiones…',
'systemManager.tmux.newSessionTitle': 'Nueva sesión de tmux',
'systemManager.tmux.newSessionDesc': 'Ponle nombre a la sesión y, opcionalmente, ejecuta un script al iniciar.',
'systemManager.tmux.newSessionTabCustom': 'Comando personalizado',
'systemManager.tmux.newSessionTabSnippet': 'Desde snippet',
'systemManager.tmux.pickSnippet': 'Desde snippets',
'systemManager.tmux.pickSnippetEmpty': 'Aún no hay snippets; agrega algunos en el panel de Scripts o en Vault.',
'systemManager.tmux.selectedSnippet': 'Usando snippet: {{label}}',
'systemManager.tmux.newSessionName': 'Nombre de la sesión',
'systemManager.tmux.newSessionCommand': 'Comando de inicio',
'systemManager.tmux.newSessionCommandPlaceholder': 'p. ej. htop o npm run dev (opcional)',
'systemManager.tmux.newSessionCommandHint': 'Déjalo vacío para una sesión de shell predeterminada.',
'systemManager.tmux.creating': 'Creando…',
'systemManager.tmux.newSessionPlaceholder': 'mi-sesion',
'systemManager.tmux.newSessionRequired': 'Primero ingresa un nombre de sesión',
'systemManager.tmux.empty': 'No hay sesiones de tmux',
'systemManager.tmux.attach': 'Conectar',
'systemManager.tmux.attached': 'Conectada',
'systemManager.tmux.detached': 'Desacoplada',
'systemManager.tmux.windows': '{{count}} ventana(s)',
'systemManager.tmux.created': 'Creada',
'systemManager.tmux.activity': 'Actividad',
'systemManager.tmux.rename': 'Renombrar',
'systemManager.tmux.detach': 'Desacoplar todo',
'systemManager.tmux.killSession': 'Terminar sesión',
'systemManager.tmux.killServer': 'Terminar servidor',
'systemManager.tmux.loadingDetails': 'Cargando detalles…',
'systemManager.tmux.clients': 'Clientes conectados',
'systemManager.tmux.windowList': 'Ventanas',
'systemManager.tmux.newWindow': 'Nueva ventana',
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
'systemManager.tmux.noWindows': 'No hay ventanas',
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
'systemManager.tmux.noPanes': 'No hay paneles',
'systemManager.tmux.panes': '{{count}} panel(es)',
'systemManager.tmux.active': 'activo',
'systemManager.tmux.unnamedWindow': 'Ventana sin nombre',
'systemManager.tmux.unnamedPane': 'Panel sin nombre',
'systemManager.tmux.attachWindow': 'Conectar a la ventana',
'systemManager.tmux.selectWindow': 'Seleccionar ventana',
'systemManager.tmux.killWindow': 'Terminar ventana',
'systemManager.tmux.killPane': 'Terminar panel',
'systemManager.tmux.splitHorizontal': 'Dividir horizontal',
'systemManager.tmux.splitVertical': 'Dividir vertical',
'systemManager.tmux.sendKeys': 'Enviar teclas',
'systemManager.tmux.sendKeysTo': 'Enviar teclas a la ventana {{window}} panel {{pane}}',
'systemManager.tmux.sendKeysPlaceholder': 'Comando o texto…',
'systemManager.tmux.renameSessionPrompt': 'Renombrar sesión',
'systemManager.tmux.renameWindowPrompt': 'Renombrar ventana',
'systemManager.tmux.windowName': 'Nombre de la ventana',
'systemManager.tmux.confirmKillSession': '¿Terminar la sesión de tmux "{{name}}"?',
'systemManager.tmux.confirmDetachSession': '¿Desacoplar todos los clientes de "{{name}}"?',
'systemManager.tmux.confirmKillWindow': '¿Terminar la ventana "{{name}}"?',
'systemManager.tmux.confirmKillPane': '¿Terminar el panel #{{index}}?',
'systemManager.tmux.confirmKillServer': '¿Terminar el servidor de tmux? Se terminarán todas las sesiones.',
'systemManager.tmux.meta': '{{count}} sesión(es)',
'systemManager.docker.title': 'Contenedores',
'systemManager.docker.subTabs.containers': 'Contenedores',
'systemManager.docker.subTabs.images': 'Imágenes',
'systemManager.docker.empty': 'No se encontraron contenedores',
'systemManager.docker.imagesEmpty': 'No se encontraron imágenes',
'systemManager.docker.search': 'Buscar contenedores…',
'systemManager.docker.searchImages': 'Buscar imágenes…',
'systemManager.docker.filter.all': 'Todos',
'systemManager.docker.filter.running': 'En ejecución',
'systemManager.docker.filter.stopped': 'Detenidos',
'systemManager.docker.filter.paused': 'En pausa',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': 'Registros',
'systemManager.docker.details': 'Detalles',
'systemManager.docker.inspect': 'Inspeccionar',
'systemManager.docker.imageInspect': 'Inspección de imagen',
'systemManager.docker.confirmRemove': '¿Eliminar este contenedor?',
'systemManager.docker.confirmKill': '¿Terminar por la fuerza este contenedor?',
'systemManager.docker.confirmRemoveImage': '¿Eliminar la imagen "{{name}}"?',
'systemManager.docker.confirmPrune': '¿Eliminar las imágenes colgantes?',
'systemManager.docker.confirmPruneAll': '¿Eliminar todas las imágenes no utilizadas?',
'systemManager.docker.pause': 'Pausar',
'systemManager.docker.unpause': 'Reanudar',
'systemManager.docker.restart': 'Reiniciar',
'systemManager.docker.kill': 'Terminar',
'systemManager.docker.renamePrompt': 'Nombre del contenedor',
'systemManager.docker.prune': 'Limpiar',
'systemManager.docker.pruneAll': 'Limpiar todo',
'systemManager.docker.tag': 'Etiqueta',
'systemManager.docker.tagRepoPrompt': 'Nombre del repositorio',
'systemManager.docker.tagNamePrompt': 'Nombre de la etiqueta',
'systemManager.docker.meta': '{{count}} contenedor(es)',
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
'systemManager.docker.start': 'Iniciar',
'systemManager.docker.stop': 'Detener',
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…',
'systemManager.ports.empty': 'No se reportaron puertos en escucha.',
'systemManager.ports.search': 'Buscar puertos…',
'systemManager.ports.meta': '{{count}} en escucha',
'systemManager.ports.filter.all': 'Todos',
'systemManager.ports.unknownProcess': 'Proceso desconocido',
'systemManager.ports.terminate': 'Terminar',
'systemManager.ports.confirmTerminate': '¿Enviar SIGTERM al proceso {{pid}} que ocupa este puerto?',
'systemManager.services.unavailable': 'systemctl no está disponible en este host.',
'systemManager.services.loading': 'Cargando servicios de systemd…',
'systemManager.services.empty': 'No se encontraron servicios de systemd.',
'systemManager.services.search': 'Buscar servicios…',
'systemManager.services.meta': '{{count}} servicio(s)',
'systemManager.services.filter.all': 'Todos',
'systemManager.services.filter.running': 'En ejecución',
'systemManager.services.filter.failed': 'Con error',
'systemManager.services.filter.inactive': 'Inactivo',
'systemManager.services.start': 'Iniciar',
'systemManager.services.stop': 'Detener',
'systemManager.services.restart': 'Reiniciar',
'systemManager.services.enable': 'Habilitar',
'systemManager.services.disable': 'Deshabilitar',
'systemManager.services.reload': 'Recargar',
'systemManager.services.scope.user': 'usuario',
'systemManager.services.confirmAction': '¿{{action}} {{name}}?',
'systemManager.gpu.unavailable': 'No se detectaron herramientas de GPU NVIDIA o NPU Ascend en este host.',
'systemManager.gpu.loading': 'Cargando estadísticas del acelerador…',
'systemManager.gpu.empty': 'Las herramientas del acelerador están presentes, pero no se reportó ningún dispositivo.',
'systemManager.gpu.meta': '{{devices}} dispositivo(s) · {{processes}} proceso(s)',
'systemManager.gpu.devices': 'Dispositivos',
'systemManager.gpu.processes': 'Procesos de cómputo',
'systemManager.gpu.noProcesses': 'No se reportaron procesos de cómputo.',
'systemManager.gpu.vendor.nvidia': 'NVIDIA',
'systemManager.gpu.vendor.ascend': 'Ascend',
'systemManager.gpu.util': 'Util',
'systemManager.gpu.memory': 'VRAM',
'systemManager.gpu.hbm': 'HBM',
'systemManager.gpu.temperature': 'Temperatura',
'systemManager.gpu.power': 'Energía',
'systemManager.gpu.fan': 'Ventilador {{value}}%',
'systemManager.gpu.driver': 'Controlador {{version}}',
'systemManager.inspect.status': 'Estado',
'systemManager.inspect.image': 'Imagen',
'systemManager.inspect.created': 'Creado',
'systemManager.inspect.started': 'Iniciado',
'systemManager.inspect.restartPolicy': 'Política de reinicio',
'systemManager.inspect.command': 'Comando',
'systemManager.inspect.ports': 'Puertos',
'systemManager.inspect.networks': 'Redes',
'systemManager.inspect.mounts': 'Montajes',
'systemManager.inspect.env': 'Entorno',
'systemManager.inspect.labels': 'Etiquetas',
'systemManager.inspect.tags': 'Etiquetas',
'systemManager.inspect.digests': 'Digests',
'systemManager.inspect.size': 'Tamaño',
'systemManager.inspect.platform': 'Plataforma',
'systemManager.inspect.workdir': 'Directorio de trabajo',
'systemManager.inspect.exposedPorts': 'Puertos expuestos',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': 'Ocultar JSON',
};
+843
View File
@@ -0,0 +1,843 @@
import type { Messages } from '../types';
export const esTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': 'Presiona Enter para pegar la contraseña guardada',
'terminal.passwordPicker.title': 'Contraseñas guardadas',
'terminal.passwordPicker.empty': 'No hay contraseñas guardadas',
// Network Device Mode auto-detection tip (session header)
'terminal.networkDevice.tip.message': 'Esto parece un dispositivo de red. Activa el Modo de Dispositivo de Red para enviar comandos tal cual (sin envoltorio de shell).',
'terminal.networkDevice.tip.action': 'Activar',
'terminal.networkDevice.tip.dismiss': 'Descartar',
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Abrir SFTP',
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'Más acciones',
'terminal.toolbar.scripts': 'Scripts',
'terminal.toolbar.history': 'Historial de comandos',
'terminal.toolbar.configureOsc7': 'Configurar seguimiento de directorio',
'history.scope.label': 'Alcance del historial',
'history.tab.host': 'Host',
'history.tab.global': 'Global',
'history.searchPlaceholder': 'Buscar en el historial...',
'history.loading': 'Cargando historial remoto...',
'history.meta.count': '{count} comandos',
'history.empty.noSession': 'Abre una sesión remota para ver su historial de comandos.',
'history.empty.unsupportedProtocol': 'El historial de comandos solo está disponible para sesiones SSH/Mosh/ET.',
'history.empty.noHistory': 'No se encontró historial de comandos en este host.',
'history.empty.noGlobalHistory': 'Aún no hay historial global de comandos. Los comandos que ejecutes aparecerán aquí.',
'history.action.refresh': 'Actualizar',
'history.action.retry': 'Reintentar',
'history.action.paste': 'Pegar en la terminal',
'history.action.run': 'Ejecutar en la terminal',
'history.action.saveAsSnippet': 'Guardar como snippet',
'history.action.delete': 'Eliminar del historial',
'terminal.toolbar.library': 'Biblioteca',
'terminal.toolbar.noSnippets': 'No hay snippets disponibles',
'terminal.toolbar.terminalSettings': 'Configuración de la terminal',
'terminal.toolbar.searchTerminal': 'Buscar en la terminal',
'terminal.toolbar.search': 'Buscar',
'terminal.toolbar.startSessionLog': 'Iniciar registro de sesión',
'terminal.toolbar.stopSessionLog': 'Detener registro de sesión',
'terminal.toolbar.timestampsEnable': 'Mostrar marcas de tiempo',
'terminal.toolbar.timestampsDisable': 'Ocultar marcas de tiempo',
'terminal.toolbar.broadcast': 'Difundir',
'terminal.toolbar.broadcastEnable': 'Activar Modo de Difusión',
'terminal.toolbar.broadcastDisable': 'Desactivar Modo de Difusión',
'terminal.toolbar.composeBar': 'Barra de redacción',
'terminal.composeBar.placeholder': 'Escribe el comando aquí y presiona Enter para enviar...',
'terminal.composeBar.send': 'Enviar',
'terminal.composeBar.close': 'Cerrar barra de redacción',
'terminal.composeBar.broadcasting': 'Difundiendo a todas las sesiones',
'terminal.composeBar.resize': 'Cambiar la altura de la barra de redacción',
'terminal.composeBar.manageSnippets': 'Administrar snippets rápidos',
'terminal.composeBar.searchSnippets': 'Buscar snippets...',
'terminal.composeBar.noPinnedSnippets': 'Fija snippets con + para acceso rápido',
'terminal.composeBar.noMatchingSnippets': 'No hay snippets que coincidan',
'terminal.composeBar.pinnedCount': '{count} fijados',
'terminal.composeBar.unpinSnippet': 'Quitar {label} de la barra rápida',
'terminal.composeBar.snippetClickHint': 'Clic para insertar · Shift+Clic para enviar',
'terminal.toolbar.focus': 'Enfocar',
'terminal.toolbar.focusMode': 'Modo de Enfoque',
'terminal.toolbar.detach': 'Desacoplar a pestaña independiente',
'terminal.toolbar.dragPane': 'Arrastrar panel de terminal',
'terminal.toolbar.showActions': 'Mostrar acciones de la terminal',
'terminal.toolbar.encoding': 'Codificación de la terminal',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': 'Cerrar sesión',
'terminal.toolbar.hostHighlight.title': 'Resaltado de palabras clave del host',
'terminal.toolbar.hostHighlight.noRules': 'No hay reglas de resaltado personalizadas definidas para este host',
'terminal.toolbar.hostHighlight.addRule': 'Agregar nueva regla',
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Etiqueta (p. ej., Error)',
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Patrón regex (p. ej., \\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': 'Patrón regex no válido',
'terminal.toolbar.hostHighlight.clearAll': 'Borrar todo',
'terminal.toolbar.hostHighlight.changeColor': 'Cambiar el color de resaltado de',
'terminal.toolbar.hostHighlight.selectColor': 'Seleccionar el color de la nueva regla',
'terminal.statusbar.copyHostname.label': 'Copiar dirección del host',
'terminal.statusbar.copyHostname.tooltip': 'Copiar dirección del host ({hostname})',
'terminal.statusbar.copyHostname.toast': 'Dirección del host copiada: {hostname}',
'terminal.statusbar.copyHostname.error': 'No se pudo copiar la dirección del host al portapapeles',
'terminal.statusbar.disconnect.label': 'Desconectar',
'terminal.statusbar.disconnect.tooltip': 'Desconectar esta sesión sin cerrar la pestaña',
'terminal.statusbar.reconnect.label': 'Reconectar',
'terminal.statusbar.reconnect.tooltip': 'Reconectar esta sesión',
'terminal.serverStats.cpu': 'Uso de CPU',
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
'terminal.serverStats.memory': 'Uso de memoria',
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
'terminal.serverStats.memUsed': 'Usada',
'terminal.serverStats.memBuffers': 'Búfers',
'terminal.serverStats.memCached': 'Caché',
'terminal.serverStats.memFree': 'Libre',
'terminal.serverStats.swap': 'Swap',
'terminal.serverStats.swapUsed': 'Swap usada',
'terminal.serverStats.swapFree': 'Swap libre',
'terminal.serverStats.swapTotal': 'Total',
'terminal.serverStats.topProcesses': 'Principales procesos por memoria',
'terminal.serverStats.disk': 'Uso de disco',
'terminal.serverStats.diskDetails': 'Discos montados',
'terminal.serverStats.network': 'Velocidad de red',
'terminal.serverStats.latency': 'Latencia de red SSH',
'terminal.serverStats.networkDetails': 'Interfaces de red',
'terminal.serverStats.noData': 'No hay datos disponibles',
'terminal.dragDrop.localTitle': 'Soltar para insertar rutas',
'terminal.dragDrop.localMessage': 'Las rutas de los archivos se insertarán en la terminal',
'terminal.dragDrop.remoteTitle': 'Soltar para subir archivos',
'terminal.dragDrop.remoteZmodemMessage': 'Los archivos se subirán mediante ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Los archivos se subirán mediante SFTP',
'terminal.dragDrop.noFiles': 'No hay archivos para subir',
'terminal.dragDrop.notConnected': 'No se pueden soltar archivos: la terminal no está conectada',
'terminal.dragDrop.errorTitle': 'Error al soltar',
'terminal.dragDrop.errorMessage': 'No se pudieron procesar los archivos soltados',
'terminal.search.placeholder': 'Buscar...',
'terminal.search.noResults': 'Sin resultados',
'terminal.search.prevMatch': 'Coincidencia anterior (Shift+Enter)',
'terminal.search.nextMatch': 'Coincidencia siguiente (Enter)',
'terminal.menu.copy': 'Copiar',
'terminal.menu.paste': 'Pegar',
'terminal.menu.uploadClipboardImage': 'Subir imagen del portapapeles',
'terminal.menu.addSelectionToAI': 'Agregar a la conversación',
'terminal.menu.pasteSelection': 'Pegar selección',
'terminal.menu.selectAll': 'Seleccionar todo',
'terminal.menu.reconnect': 'Reconectar',
'terminal.menu.sendYmodem': 'Enviar con YMODEM',
'terminal.menu.receiveYmodem': 'Recibir con YMODEM',
'terminal.menu.splitHorizontal': 'Dividir en horizontal',
'terminal.menu.splitVertical': 'Dividir en vertical',
'terminal.menu.clearBuffer': 'Limpiar búfer',
'terminal.menu.closeTerminal': 'Cerrar terminal',
'terminal.menu.rename': 'Renombrar',
'terminal.menu.detach': 'Desacoplar del espacio de trabajo',
'terminal.menu.detachSession': 'Desacoplar {name}',
'terminal.clipboardImageUpload.noImage': 'El portapapeles no contiene una imagen',
'terminal.clipboardImageUpload.failed': 'No se pudo subir la imagen del portapapeles',
'terminal.osc7Setup.title': 'Configurar seguimiento de directorio',
'terminal.osc7Setup.desc': 'Netcatty agregará hooks de prompt OSC 7 para el usuario remoto actual. Esto ayuda a que SFTP siga el directorio de la terminal después de sudo o su.',
'terminal.osc7Setup.targets': 'Posibles archivos a actualizar',
'terminal.osc7Setup.command': 'Comando a ejecutar',
'terminal.osc7Setup.run': 'Ejecutar configuración',
'terminal.osc7Setup.running': 'Configurando...',
'terminal.osc7Setup.configured': 'Seguimiento de directorio configurado',
'terminal.osc7Setup.failed': 'Falló la configuración del seguimiento de directorio',
'terminal.osc7Setup.sent': 'Configuración de seguimiento de directorio enviada a la terminal',
'terminal.ymodem.selectFile': 'Selecciona el archivo a enviar',
'terminal.ymodem.allFiles': 'Todos los archivos',
'terminal.ymodem.started': 'YMODEM enviando {fileName}',
'terminal.ymodem.complete': 'YMODEM envió {fileName}',
'terminal.ymodem.failed': 'Falló el envío con YMODEM',
'terminal.ymodem.selectReceiveDirectory': 'Selecciona la carpeta para guardar los archivos recibidos',
'terminal.ymodem.receiveStarted': 'YMODEM recibiendo...',
'terminal.ymodem.receiveComplete': 'YMODEM recibió {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM recibió {count} archivos',
'terminal.ymodem.receiveEmpty': 'No se recibieron archivos YMODEM',
'terminal.ymodem.receiveFailed': 'Falló la recepción con YMODEM',
'terminal.ymodem.unavailable': 'YMODEM no está disponible',
'terminal.selection.addToAI': 'Agregar a la conversación',
'terminal.selection.addToAIDesc': 'Adjuntar la salida seleccionada de la terminal al borrador de IA',
'terminal.auth.password': 'Contraseña',
'terminal.auth.sshKey': 'Clave SSH',
'terminal.auth.username': 'Nombre de usuario',
'terminal.auth.username.placeholder': 'root',
'terminal.auth.passwordLabel': 'Contraseña',
'terminal.auth.password.placeholder': 'Ingresa la contraseña',
'terminal.auth.passphrase': 'Frase de contraseña',
'terminal.auth.passphrase.placeholder': 'Frase de contraseña opcional para la clave privada seleccionada',
'terminal.auth.certificate': 'Certificado',
'terminal.auth.selectKey': 'Seleccionar clave',
'terminal.auth.retryMessage': 'Falló la autenticación. Verifica tus credenciales e inténtalo de nuevo.',
'terminal.auth.retryLog': 'Falló la autenticación. Inténtalo de nuevo.',
'terminal.auth.noKeysHint': 'No hay claves disponibles. Agrega claves en Keychain.',
'terminal.auth.continueSave': 'Continuar y guardar',
'terminal.auth.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas y guárdalas de nuevo.',
'terminal.auth.jumpCredentialsUnavailable': 'Un host de salto tiene credenciales guardadas que no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresarlas.',
'terminal.auth.proxyCredentialsUnavailable': 'Las credenciales del proxy no se pueden descifrar en este dispositivo. Abre la configuración del host y vuelve a ingresar la contraseña del proxy.',
'terminal.auth.keyUnavailableFallbackPassword': 'La clave SSH guardada no está disponible en este dispositivo. Se usará autenticación por contraseña como respaldo.',
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
'terminal.progress.disconnected': 'Desconectado',
'terminal.progress.cancelling': 'Cancelando...',
'terminal.progress.startOver': 'Empezar de nuevo',
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
'terminal.progress.autoReconnectScheduled': 'Conexión perdida. Reconectando en {seconds}s (intento {attempt}).',
'terminal.progress.autoReconnectAttempt': 'Intento de reconexión automática {attempt}...',
'terminal.connection.dismissDisconnectedDialog': 'Descartar aviso de desconexión',
'terminal.connection.chainOf': 'Cadena {current} de {total}',
'terminal.connection.showLogs': 'Mostrar registros',
'terminal.connection.hideLogs': 'Ocultar registros',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.connection.protocol.plugin': 'Conexión de plugin',
'terminal.et.proxyUnsupported': 'EternalTerminal no admite actualmente la configuración de proxy de Netcatty. Usa SSH o quita el proxy de este host.',
'terminal.et.multiJumpUnsupported': 'EternalTerminal admite actualmente como máximo un host de salto en Netcatty.',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Shell local',
'terminal.hostKey.unknownTitle': 'Confirmar esta clave de host',
'terminal.hostKey.changedTitle': 'La clave del host cambió',
'terminal.hostKey.unknownDescription': 'Aún no se puede establecer la autenticidad de {host}.',
'terminal.hostKey.changedDescription': 'La clave guardada de {host} ya no coincide con este servidor.',
'terminal.hostKey.fingerprintLabel': 'La huella digital de {keyType} es SHA256:',
'terminal.hostKey.savedFingerprintLabel': 'Huella guardada',
'terminal.hostKey.unknownHint': 'Recuérdala si esta huella pertenece al servidor que esperabas.',
'terminal.hostKey.changedHint': 'Continúa solo si esperabas que este host cambiara.',
'terminal.hostKey.addAndContinue': 'Agregar y continuar',
'terminal.hostKey.updateAndContinue': 'Actualizar y continuar',
'terminal.themeModal.title': 'Apariencia de la terminal',
'terminal.themeModal.tab.theme': 'Tema',
'terminal.themeModal.tab.font': 'Fuente',
'terminal.themeModal.tab.custom': 'Personalizado',
'terminal.themeModal.globalTheme': 'Tema global',
'terminal.themeModal.globalFont': 'Fuente global',
'terminal.themeModal.fontSize': 'Tamaño de fuente',
'terminal.themeModal.fontWeight': 'Grosor de fuente',
'terminal.themeModal.livePreview': 'Vista previa en vivo',
'terminal.themeModal.themeType': 'Tema {type}',
'terminal.hiddenTheme.title': 'Tema oculto actual',
'terminal.hiddenTheme.desc': 'Este tema está oculto de la selección manual y se reemplazará cuando elijas otro tema.',
'topTabs.toggleTheme.systemExitTitle': 'El tema del sistema está activo',
'topTabs.toggleTheme.systemExitMessage': 'Abre Configuración para elegir un tema fijo de Claro u Oscuro.',
'topTabs.toggleTheme.openSettings': 'Abrir Configuración',
// Custom Themes
'terminal.customTheme.section': 'Temas personalizados',
'terminal.customTheme.yourThemes': 'Tus temas',
'terminal.customTheme.new': 'Nuevo tema',
'terminal.customTheme.newDesc': 'Clonar el tema actual y personalizarlo',
'terminal.customTheme.newTitle': 'Nuevo tema personalizado',
'terminal.customTheme.editTitle': 'Editar tema',
'terminal.customTheme.import': 'Importar .itermcolors',
'terminal.customTheme.importDesc': 'Importar desde archivo de esquema de color de iTerm2',
'terminal.customTheme.importError': 'No se pudo analizar el archivo seleccionado. Asegúrate de que sea un archivo XML .itermcolors válido.',
'terminal.customTheme.delete': 'Eliminar tema',
'terminal.customTheme.confirmDelete': 'Confirmar eliminación',
'terminal.customTheme.name': 'Nombre',
'terminal.customTheme.namePlaceholder': 'Mi tema personalizado',
'terminal.customTheme.type': 'Tipo',
'terminal.customTheme.group.general': 'General',
'terminal.customTheme.group.normal': 'Colores normales',
'terminal.customTheme.group.bright': 'Colores brillantes',
'terminal.customTheme.color.background': 'Fondo',
'terminal.customTheme.color.foreground': 'Primer plano',
'terminal.customTheme.color.cursor': 'Cursor',
'terminal.customTheme.color.selection': 'Selección',
'terminal.customTheme.color.black': 'Negro',
'terminal.customTheme.color.red': 'Rojo',
'terminal.customTheme.color.green': 'Verde',
'terminal.customTheme.color.yellow': 'Amarillo',
'terminal.customTheme.color.blue': 'Azul',
'terminal.customTheme.color.magenta': 'Magenta',
'terminal.customTheme.color.cyan': 'Cian',
'terminal.customTheme.color.white': 'Blanco',
'terminal.customTheme.color.brightBlack': 'Negro brillante',
'terminal.customTheme.color.brightRed': 'Rojo brillante',
'terminal.customTheme.color.brightGreen': 'Verde brillante',
'terminal.customTheme.color.brightYellow': 'Amarillo brillante',
'terminal.customTheme.color.brightBlue': 'Azul brillante',
'terminal.customTheme.color.brightMagenta': 'Magenta brillante',
'terminal.customTheme.color.brightCyan': 'Cian brillante',
'terminal.customTheme.color.brightWhite': 'Blanco brillante',
// Cloud Sync Settings
'cloudSync.gate.title': 'Sincronización cifrada de extremo a extremo',
'cloudSync.gate.desc':
'Tus datos se cifran localmente antes de sincronizarse. Los proveedores de la nube nunca ven tus datos en texto plano. Configura una clave maestra para habilitar la sincronización segura.',
'cloudSync.gate.masterKey': 'Clave maestra',
'cloudSync.gate.confirmMasterKey': 'Confirmar clave maestra',
'cloudSync.gate.placeholder': 'Ingresa una contraseña segura',
'cloudSync.gate.confirmPlaceholder': 'Confirma tu contraseña',
'cloudSync.gate.mismatch': 'Las contraseñas no coinciden',
'cloudSync.gate.warning':
'Entiendo que, si olvido mi clave maestra, mis datos no se podrán recuperar. No hay restablecimiento de contraseña.',
'cloudSync.gate.enableVault': 'Habilitar bóveda cifrada',
'cloudSync.gate.enabledToast': 'Bóveda cifrada habilitada',
'cloudSync.gate.setupFailed': 'No se pudo configurar la clave maestra',
'cloudSync.passwordStrength.tooShort': 'Demasiado corta',
'cloudSync.passwordStrength.weak': 'Débil',
'cloudSync.passwordStrength.moderate': 'Moderada',
'cloudSync.passwordStrength.strong': 'Segura',
'cloudSync.passwordStrength.veryStrong': 'Muy segura',
'cloudSync.provider.notConnected': 'No conectado',
'cloudSync.provider.sync': 'Sincronizar',
'cloudSync.provider.connect': 'Conectar',
'cloudSync.provider.connecting': 'Conectando...',
'cloudSync.provider.webdav': 'WebDAV',
'cloudSync.provider.webdav.desc': 'Conectar a un endpoint WebDAV autoalojado',
'cloudSync.provider.s3': 'Compatible con S3',
'cloudSync.provider.s3.desc': 'Conectar a almacenamiento de objetos compatible con S3',
'cloudSync.provider.comingSoon': 'Próximamente',
'cloudSync.webdav.title': 'Configuración de WebDAV',
'cloudSync.webdav.desc': 'Configura un endpoint WebDAV para la sincronización cifrada.',
'cloudSync.webdav.endpoint': 'URL del endpoint',
'cloudSync.webdav.authType': 'Tipo de autenticación',
'cloudSync.webdav.auth.basic': 'Básica',
'cloudSync.webdav.auth.digest': 'Digest',
'cloudSync.webdav.auth.token': 'Token',
'cloudSync.webdav.username': 'Nombre de usuario',
'cloudSync.webdav.password': 'Contraseña',
'cloudSync.webdav.token': 'Token',
'cloudSync.webdav.showSecret': 'Mostrar secreto',
'cloudSync.webdav.allowInsecure': 'Permitir conexión insegura (ignorar errores de certificado)',
'cloudSync.webdav.validation.endpoint': 'Ingresa un endpoint WebDAV válido.',
'cloudSync.webdav.validation.credentials': 'El nombre de usuario y la contraseña son obligatorios.',
'cloudSync.webdav.validation.token': 'El token es obligatorio.',
'cloudSync.s3.title': 'Configuración de S3',
'cloudSync.s3.desc': 'Conéctate al almacenamiento de objetos compatible con S3 para la sincronización cifrada.',
'cloudSync.s3.endpoint': 'URL del endpoint',
'cloudSync.s3.region': 'Región',
'cloudSync.s3.bucket': 'Bucket',
'cloudSync.s3.accessKeyId': 'ID de clave de acceso',
'cloudSync.s3.secretAccessKey': 'Clave de acceso secreta',
'cloudSync.s3.sessionToken': 'Token de sesión (opcional)',
'cloudSync.s3.prefix': 'Prefijo de clave (opcional)',
'cloudSync.s3.forcePathStyle': 'Forzar URLs con estilo de ruta (para MinIO/R2, etc.)',
'cloudSync.s3.allowInsecure': 'Permitir conexión insegura (ignorar errores de certificado)',
'cloudSync.s3.showSecret': 'Mostrar secretos',
'cloudSync.s3.validation.required': 'Endpoint, región, bucket, clave de acceso y secreto son obligatorios.',
'cloudSync.smb.title': 'Configuración de SMB',
'cloudSync.smb.desc': 'Conéctate a un recurso compartido de archivos SMB/CIFS para la sincronización cifrada.',
'cloudSync.smb.share': 'Ruta del recurso compartido',
'cloudSync.smb.username': 'Nombre de usuario',
'cloudSync.smb.password': 'Contraseña',
'cloudSync.smb.domain': 'Dominio (opcional)',
'cloudSync.smb.domainPlaceholder': 'p. ej., WORKGROUP',
'cloudSync.smb.port': 'Puerto (opcional)',
'cloudSync.smb.showSecret': 'Mostrar contraseña',
'cloudSync.smb.validation.share': 'La ruta del recurso compartido es obligatoria.',
'cloudSync.smb.validation.port': 'El puerto debe ser un número entre 1 y 65535.',
'cloudSync.connect.smb.success': 'SMB se conectó correctamente',
'cloudSync.connect.smb.failedTitle': 'Falló la conexión a SMB',
'cloudSync.provider.smb': 'Recurso compartido SMB',
'cloudSync.connect.webdav.success': 'WebDAV se conectó correctamente',
'cloudSync.connect.webdav.failedTitle': 'Falló la conexión a WebDAV',
'cloudSync.connect.s3.success': 'S3 se conectó correctamente',
'cloudSync.connect.s3.failedTitle': 'Falló la conexión a S3',
'cloudSync.connect.plugin.success': 'El proveedor de sincronización de plugin se conectó correctamente',
'cloudSync.connect.plugin.failedTitle': 'Falló la conexión del plugin de sincronización',
'cloudSync.pluginConfig.title': 'Configurar {name}',
'cloudSync.pluginConfig.desc': 'Ingresa la configuración JSON requerida por este proveedor de sincronización de plugin.',
'cloudSync.pluginConfig.label': 'Configuración del proveedor (JSON)',
'cloudSync.pluginConfig.invalidJson': 'La configuración debe ser JSON válido.',
'cloudSync.pluginConfig.schemaInvalid': 'La configuración no coincide con el esquema del proveedor.',
'cloudSync.lastSync.never': 'Nunca',
'cloudSync.lastSync.justNow': 'Justo ahora',
'cloudSync.lastSync.minutesAgo': 'Hace {minutes} min',
'cloudSync.changeKey': 'Cambiar clave',
'cloudSync.providers.title': 'Proveedores de nube',
'cloudSync.syncAll': 'Sincronizar todos los proveedores conectados',
'cloudSync.autoSync.title': 'Sincronización automática',
'cloudSync.autoSync.desc': 'Sincronizar automáticamente cuando se realicen cambios',
'cloudSync.strategy.title': 'Estrategia de sincronización',
'cloudSync.strategy.desc': 'Elige qué sucede cuando cambian tanto los datos locales como los de la nube.',
'cloudSync.strategy.smartMerge': 'Fusión inteligente (recomendada)',
'cloudSync.strategy.smartMergeDesc': 'Combina los cambios de ambos lados cuando sea posible; si Netcatty no puede decidir con seguridad, te pedirá que elijas.',
'cloudSync.strategy.preferCloud': 'Gana la nube',
'cloudSync.strategy.preferCloudDesc': 'Cuando ambos lados cambiaron, descarga la versión de la nube y reemplaza los cambios locales.',
'cloudSync.strategy.preferLocal': 'Gana lo local',
'cloudSync.strategy.preferLocalDesc': 'Cuando ambos lados cambiaron, sube la versión local y reemplaza los cambios de la nube.',
'cloudSync.convergent.title': 'Sincronización convergente de múltiples dispositivos',
'cloudSync.convergent.experimental': 'Experimental',
'cloudSync.convergent.desc': 'Usa una réplica CRDT cifrada para preservar ediciones sin conexión, eliminaciones concurrentes y cambios de cada proveedor conectado.',
'cloudSync.convergent.active': 'CRDT v2 está activo. Las escrituras del proveedor se verifican después de la subida.',
'cloudSync.convergent.paused': 'CRDT v2 está en pausa en este dispositivo; los metadatos de la nube se conservan.',
'cloudSync.convergent.enabled': 'Sincronización convergente habilitada',
'cloudSync.convergent.preview.title': 'Vista previa de migración',
'cloudSync.convergent.preview.entities': 'Entidades',
'cloudSync.convergent.preview.providers': 'Proveedores',
'cloudSync.convergent.preview.conflicts': 'Conflictos',
'cloudSync.convergent.preview.compatibility': 'Una instantánea v1 completa permanece en cada payload cifrado para clientes más antiguos.',
'cloudSync.convergent.preview.confirm': 'Crear réplica CRDT',
'cloudSync.convergent.preview.status.ready': 'Listo',
'cloudSync.convergent.preview.status.empty': 'Vacío',
'cloudSync.convergent.preview.status.unavailable': 'No disponible',
'cloudSync.convergent.preview.status.blocked': 'Bloqueado',
'cloudSync.convergent.preview.schema': 'esquema',
'cloudSync.convergent.field.presence': 'presencia',
'cloudSync.convergent.field.position': 'posición',
'cloudSync.convergent.conflicts.title': 'Conflictos de campo ({count})',
'cloudSync.convergent.conflict.empty': 'Vacío / eliminado',
'cloudSync.convergent.conflict.secretSet': 'El secreto está establecido',
'cloudSync.convergent.conflict.current': 'ganador actual',
'cloudSync.convergent.conflict.choose': 'Elegir',
'cloudSync.convergent.conflict.resolved': 'Conflicto resuelto y sincronizado',
'cloudSync.convergent.downgrade.desc': 'Reemplaza los archivos v2 de cada proveedor conectado con una instantánea heredada.',
'cloudSync.convergent.downgrade.button': 'Degradar',
'cloudSync.convergent.downgrade.confirm': '¿Degradar cada proveedor conectado a la sincronización heredada? Esto elimina los metadatos CRDT después de la verificación de escritura.',
'cloudSync.convergent.downgrade.done': 'Sincronización convergente degradada',
'cloudSync.status.title': 'Estado de sincronización',
'cloudSync.status.localVersion': 'Versión local',
'cloudSync.status.remoteVersion': 'Versión remota',
'cloudSync.history.title': 'Historial de sincronización',
'cloudSync.history.upload': 'Subida',
'cloudSync.history.download': 'Descarga',
'cloudSync.history.resolved': 'Resuelto',
'cloudSync.history.error': 'Error',
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
'cloudSync.localBackups.retentionDesc': 'Elige cuántas copias de seguridad locales debe conservar Netcatty.',
'cloudSync.localBackups.maxCount': 'Copias máximas',
'cloudSync.localBackups.maxSaved': 'Retención de copia guardada: {count}',
'cloudSync.localBackups.maxInvalid': 'Ingresa un número entre 1 y 100.',
'cloudSync.localBackups.empty': 'Aún no hay copias de seguridad locales.',
'cloudSync.localBackups.reason.appVersionChange': 'Antes del cambio de versión de la app',
'cloudSync.localBackups.reason.beforeRestore': 'Antes de la restauración',
'cloudSync.localBackups.versionChange': '{from} -> {to}',
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} claves, {snippets} snippets, {notes} notas',
'cloudSync.localBackups.restore': 'Restaurar',
'cloudSync.localBackups.restoreSuccess': 'Copia de seguridad local restaurada.',
'cloudSync.localBackups.restoreFailedTitle': 'Falló la restauración',
'cloudSync.localBackups.restoreMissing': 'No se encontró la copia de seguridad.',
'cloudSync.localBackups.protectiveBackupFailed': 'No se pudo crear la copia de seguridad de protección, por lo que la restauración se canceló para proteger tus datos actuales. Resuelve el problema subyacente (p. ej., acceso al keychain) e inténtalo de nuevo. Detalles: {message}',
'cloudSync.localBackups.restoreConfirmTitle': '¿Restaurar esta copia de seguridad?',
'cloudSync.localBackups.restoreConfirmDesc': 'Tus hosts, claves, snippets y configuraciones actuales se reemplazarán con el contenido de esta copia de seguridad. Se crea automáticamente una instantánea de protección de tus datos actuales primero.',
'cloudSync.localBackups.restoreConfirmButton': 'Restaurar',
'cloudSync.localBackups.restoreConfirmCancel': 'Cancelar',
'cloudSync.localBackups.unavailableTitle': 'Copias de seguridad locales no disponibles',
'cloudSync.localBackups.unavailableDesc': 'Esta plataforma no expone un keychain seguro a Netcatty, por lo que las copias de seguridad locales no se pueden escribir de forma segura. Instala Netcatty en un sistema con un keychain compatible para habilitar el historial de copias de seguridad locales.',
'cloudSync.localBackups.lockedTitle': 'Se requiere clave maestra',
'cloudSync.localBackups.lockedDesc': 'Configura o desbloquea tu clave maestra antes de restaurar una copia de seguridad, para que las credenciales restauradas permanezcan cifradas.',
'cloudSync.revisionHistory.viewButton': 'Historial',
'cloudSync.revisionHistory.title': 'Historial de versiones de la bóveda',
'cloudSync.revisionHistory.description': 'Explora y restaura versiones anteriores de tu bóveda desde el historial de revisiones de Gist.',
'cloudSync.revisionHistory.empty': 'No se encontraron revisiones.',
'cloudSync.revisionHistory.current': 'Actual',
'cloudSync.revisionHistory.revision': 'Revisión',
'cloudSync.revisionHistory.revisionPreview': 'Contenido de la revisión',
'cloudSync.revisionHistory.device': 'Dispositivo',
'cloudSync.revisionHistory.hosts': 'Hosts',
'cloudSync.revisionHistory.keys': 'Claves',
'cloudSync.revisionHistory.snippets': 'Snippets',
'cloudSync.revisionHistory.notes': 'Notas',
'cloudSync.revisionHistory.identities': 'Identidades',
'cloudSync.revisionHistory.restoreButton': 'Restaurar esta versión',
'cloudSync.revisionHistory.restored': 'Bóveda restaurada desde la revisión seleccionada.',
'cloudSync.revisionHistory.revisionNotFound': 'Revisión no encontrada o no contiene datos de la bóveda.',
'cloudSync.revisionHistory.decryptFailed': 'No se puede descifrar esta revisión. Puede que se haya cifrado con una contraseña maestra diferente.',
'cloudSync.changeKey.title': 'Cambiar clave maestra',
'cloudSync.changeKey.current': 'Clave maestra actual',
'cloudSync.changeKey.new': 'Nueva clave maestra',
'cloudSync.changeKey.confirmNew': 'Confirmar nueva clave maestra',
'cloudSync.changeKey.currentPlaceholder': 'Ingresa la clave maestra actual',
'cloudSync.changeKey.newPlaceholder': 'Ingresa la nueva clave maestra',
'cloudSync.changeKey.confirmPlaceholder': 'Confirma la nueva clave maestra',
'cloudSync.changeKey.fillAll': 'Completa todos los campos',
'cloudSync.changeKey.minLength': 'La nueva clave maestra debe tener al menos 8 caracteres',
'cloudSync.changeKey.notMatch': 'Las nuevas claves maestras no coinciden',
'cloudSync.changeKey.incorrectCurrent': 'Clave maestra actual incorrecta',
'cloudSync.changeKey.failed': 'No se pudo cambiar la clave maestra',
'cloudSync.changeKey.desc': 'Esto volverá a cifrar tu bóveda. Asegúrate de recordar la nueva clave.',
'cloudSync.changeKey.showKeys': 'Mostrar claves',
'cloudSync.changeKey.updatedToast': 'Clave maestra actualizada',
'cloudSync.changeKey.updateButton': 'Actualizar clave',
'cloudSync.unlock.title': 'Ingresa la clave maestra',
'cloudSync.unlock.masterKey': 'Clave maestra',
'cloudSync.unlock.desc':
'Ingresa tu clave maestra una vez para habilitar la sincronización cifrada. Se almacenará de forma segura usando el keychain de tu sistema operativo.',
'cloudSync.unlock.placeholder': 'Ingresa tu clave maestra',
'cloudSync.unlock.empty': 'Ingresa tu clave maestra',
'cloudSync.unlock.incorrect': 'Clave maestra incorrecta',
'cloudSync.unlock.failed': 'No se pudo desbloquear la bóveda',
'cloudSync.unlock.showKey': 'Mostrar clave',
'cloudSync.unlock.notNow': 'Ahora no',
'cloudSync.unlock.readyToast': 'Bóveda lista',
'cloudSync.unlock.unlockButton': 'Desbloquear',
'cloudSync.header.vaultReady': 'Bóveda lista',
'cloudSync.header.preparingVault': 'Preparando bóveda...',
'cloudSync.header.providersConnected': '{count} proveedor(es) conectado(s)',
'cloudSync.githubFlow.title': 'Conectar con GitHub',
'cloudSync.githubFlow.desc': 'Copia el código de abajo e ingrésalo en GitHub para autorizar a Netcatty.',
'cloudSync.githubFlow.copyCode': 'Copiar código',
'cloudSync.githubFlow.copied': '¡Copiado!',
'cloudSync.githubFlow.openGitHub': 'Abrir GitHub',
'cloudSync.githubFlow.waiting': 'Esperando autorización...',
'cloudSync.conflict.title': 'Se detectó un conflicto de versiones',
'cloudSync.conflict.desc': 'Elige qué versión conservar',
'cloudSync.conflict.local': 'LOCAL',
'cloudSync.conflict.cloud': 'NUBE',
'cloudSync.conflict.detailsTitle': 'Datos cambiados',
'cloudSync.conflict.detailsCounts': 'Local {local} · Nube {cloud} · Conflictos {conflicts}',
'cloudSync.conflict.entity.hosts': 'Hosts',
'cloudSync.conflict.entity.keys': 'Claves',
'cloudSync.conflict.entity.identities': 'Identidades',
'cloudSync.conflict.entity.proxyProfiles': 'Perfiles de proxy',
'cloudSync.conflict.entity.snippets': 'Snippets',
'cloudSync.conflict.entity.notes': 'Notas',
'cloudSync.conflict.entity.noteGroups': 'Grupos de notas',
'cloudSync.conflict.entity.customGroups': 'Grupos',
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
'cloudSync.conflict.entity.settings': 'Configuración',
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
'cloudSync.conflict.useCloud': 'Descargar la nube (sobrescribir local)',
'cloudSync.connect.browserContinue': 'Completar la autorización en el navegador',
'cloudSync.connect.browserCancelled': 'La autorización anterior del navegador fue cancelada',
'cloudSync.connect.github.success': 'GitHub se conectó correctamente',
'cloudSync.connect.github.failedTitle': 'Falló la conexión a GitHub',
'cloudSync.connect.github.timeout': 'Se agotó el tiempo de espera de la conexión a GitHub. Verifica tu red o la configuración del proxy.',
'cloudSync.connect.github.networkError': 'No se puede acceder a GitHub. Verifica tu red o la configuración del proxy.',
'cloudSync.connect.google.failedTitle': 'Falló la conexión a Google',
'cloudSync.connect.onedrive.failedTitle': 'Falló la conexión a OneDrive',
'cloudSync.sync.success': 'Sincronizado con {provider}',
'cloudSync.sync.failed': 'Falló la sincronización',
'cloudSync.sync.failedTitle': 'Falló la sincronización',
'cloudSync.sync.errorTitle': 'Error de sincronización',
'cloudSync.resolve.downloaded': 'Datos de la nube descargados',
'cloudSync.resolve.uploaded': 'Datos locales subidos',
'cloudSync.resolve.failedTitle': 'Falló la resolución del conflicto',
'cloudSync.clearLocal.title': 'Borrar datos locales',
'cloudSync.clearLocal.desc': 'Restablece la versión local y el historial de sincronización. La próxima sincronización descargará desde la nube.',
'cloudSync.clearLocal.button': 'Borrar',
'cloudSync.clearLocal.dialog.title': '¿Borrar los datos locales de la bóveda?',
'cloudSync.clearLocal.dialog.desc': 'Esto restablecerá la versión local a 0 y borrará el historial de sincronización. Tu próxima sincronización descargará los datos de la nube, reemplazando los datos locales.',
'cloudSync.clearLocal.dialog.cancel': 'Cancelar',
'cloudSync.clearLocal.dialog.confirm': 'Borrar datos locales',
'cloudSync.clearLocal.toast.title': 'Datos locales borrados',
'cloudSync.clearLocal.toast.desc': 'Versión local restablecida a 0. Sincroniza para descargar desde la nube.',
// Keychain
'keychain.filter.key': 'CLAVE',
'keychain.filter.certificate': 'CERTIFICADO',
'keychain.action.generateKey': 'Generar clave',
'keychain.action.importKey': 'Importar clave',
'keychain.action.newIdentity': 'Nueva identidad',
'keychain.action.importCertificate': 'Importar certificado',
'keychain.view.grid': 'Cuadrícula',
'keychain.view.list': 'Lista',
'keychain.section.keys': 'Claves',
'keychain.section.identities': 'Identidades',
'keychain.count.items': '{count} elementos',
'keychain.empty.title': 'Configura tus claves',
'keychain.empty.desc': 'Importa o genera claves SSH para una autenticación segura.',
'keychain.panel.generateKey': 'Generar clave',
'keychain.panel.newKey': 'Nueva clave',
'keychain.panel.keyDetails': 'Detalles de la clave',
'keychain.panel.editKey': 'Editar clave',
'keychain.panel.editIdentity': 'Editar identidad',
'keychain.panel.newIdentity': 'Nueva identidad',
'keychain.panel.keyExport': 'Exportación de clave',
'keychain.validation.labelRequired': 'Ingresa una etiqueta para la clave',
'keychain.validation.labelAndPrivateKeyRequired': 'La etiqueta y la clave privada son obligatorias',
'keychain.validation.labelAndUsernameRequired': 'La etiqueta y el nombre de usuario son obligatorios',
'keychain.error.generationUnavailable':
'La generación de claves no está disponible: asegúrate de que la app se esté ejecutando en Electron',
'keychain.error.generateKeyPairFailed': 'No se pudo generar el par de claves',
'keychain.error.generateKeyFailed': 'No se pudo generar la clave',
'keychain.error.keyGenerationTitle': 'Generación de claves',
'keychain.export.exportTo': 'Exportar a *',
'keychain.export.selectHost': 'Seleccionar host',
'keychain.export.location': 'Ubicación ~ $1 *',
'keychain.export.filename': 'Nombre de archivo ~ $2 *',
'keychain.export.note':
'La exportación de claves actualmente solo admite sistemas {unix}. Usa la sección {advanced} para personalizar el script de exportación.',
'keychain.export.script': 'Script *',
'keychain.export.scriptPlaceholder': 'Script de exportación...',
'keychain.export.missingCredentials':
'El host no tiene contraseña ni clave guardadas. Primero agrega credenciales de contraseña al host.',
'keychain.export.successTitle': 'Exportación exitosa',
'keychain.export.successMessage': 'Clave pública exportada y adjuntada a {host}',
'keychain.export.failedTitle': 'Falló la exportación',
'keychain.export.failedMessage': 'No se pudo exportar la clave: {error}',
'keychain.export.failedPrefix': 'Falló la exportación: {error}',
'keychain.export.exitCode': 'El comando terminó con el código {code}',
'keychain.export.exporting': 'Exportando...',
'keychain.export.exportAndAttach': 'Exportar y adjuntar',
'keychain.export.title': 'Exportación de clave',
'keychain.export.exportToRequired': 'Exportar a *',
'keychain.export.selectHostPlaceholder': 'Selecciona un host...',
'keychain.export.locationLabel': 'Ubicación ~ $1 *',
'keychain.export.filenameLabel': 'Nombre de archivo ~ $2 *',
'keychain.export.advanced': 'Avanzado',
'keychain.export.note.supportsOnly': 'La exportación de claves actualmente solo admite',
'keychain.export.note.systems': 'sistemas.',
'keychain.export.note.use': 'Usa la',
'keychain.export.note.customize': 'sección para personalizar el script de exportación.',
'keychain.export.scriptRequired': 'Script *',
'keychain.export.exportToHost': 'Exportar al host',
'keychain.export.failedGeneric': 'Falló la exportación: {message}',
'keychain.field.label': 'Etiqueta',
'keychain.field.labelRequired': 'Etiqueta *',
'keychain.field.labelPlaceholder': 'Etiqueta de la clave',
'keychain.field.privateKeyRequired': 'Clave privada *',
'keychain.field.publicKey': 'Clave pública',
'keychain.field.certificatePlaceholder': 'Contenido del certificado (opcional)',
'keychain.generate.keyType': 'Tipo de clave',
'keychain.generate.keySize': 'Tamaño de clave',
'keychain.generate.labelPlaceholder': 'Etiqueta de la clave',
'keychain.generate.passphrasePlaceholder': 'Frase de contraseña (opcional)',
'keychain.generate.savePassphrase': 'Guardar frase de contraseña',
'keychain.generate.generate': 'Generar',
'keychain.generate.generateSave': 'Generar y guardar',
'keychain.import.dropHint': 'Suelta un archivo de clave aquí',
'keychain.import.importFromFile': 'Importar desde archivo',
'keychain.import.saveKey': 'Guardar clave',
'keychain.import.importedKeyLabel': 'Clave importada',
'keychain.identity.usernameRequired': 'Nombre de usuario *',
'keychain.identity.method.passwordOnly': 'Contraseña',
'keychain.identity.summary.password': 'Contraseña de autenticación',
'keychain.identity.summary.key': 'Clave de autenticación',
'keychain.identity.summary.certificate': 'Certificado de autenticación',
'keychain.identity.summary.passwordAndKey': 'Contraseña y clave de autenticación',
'keychain.identity.summary.passwordAndCertificate': 'Contraseña y certificado de autenticación',
'keychain.identity.summary.none': 'Sin credenciales',
'keychain.identity.selectCredential': 'Seleccionar {kind}',
'keychain.identity.save': 'Guardar',
'keychain.identity.update': 'Actualizar',
'keychain.keyDialog.newTitle': 'Nueva clave',
'keychain.keyDialog.newDesc': 'Agregar una nueva clave SSH',
'keychain.keyDialog.editTitle': 'Editar clave',
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
'keychain.keyDialog.updateKey': 'Actualizar clave',
// Tabs
'tabs.closeSessionAria': 'Cerrar sesión',
'tabs.closeLogViewAria': 'Cerrar vista de registro',
'tabs.closePluginViewAria': 'Cerrar {title}',
'tabs.logPrefix': 'Registro:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copiar pestaña',
'tabs.copyTabToNewWindow': 'Copiar pestaña a una nueva ventana',
'tabs.copyTabToNewWindowFailed': 'No se pudo abrir la pestaña en una nueva ventana',
'tabs.closeOthers': 'Cerrar las demás',
'tabs.closeToRight': 'Cerrar pestañas a la derecha',
'tabs.closeAll': 'Cerrar todas',
'keychain.edit.labelRequired': 'Etiqueta *',
'keychain.edit.keyLabelPlaceholder': 'Etiqueta de la clave',
'keychain.edit.privateKeyRequired': 'Clave privada *',
'keychain.edit.publicKey': 'Clave pública',
'keychain.edit.certificate': 'Certificado',
'keychain.edit.certificatePlaceholder': 'Contenido del certificado (opcional)',
'keychain.edit.filePath': 'Ruta del archivo',
'keychain.edit.keyExport': 'Exportación de clave',
'keychain.edit.exportToHost': 'Exportar al host',
// Snippets
'snippets.searchPlaceholder': 'Buscar scripts...',
'snippets.action.newSnippet': 'Nuevo snippet',
'snippets.action.newPackage': 'Nuevo paquete de scripts',
'snippets.action.import': 'Importar',
'snippets.action.selectSnippets': 'Seleccionar snippets',
'snippets.panel.newTitle': 'Nuevo snippet',
'snippets.panel.editTitle': 'Editar snippet',
'snippets.panel.newAutomationTitle': 'Nuevo script de automatización',
'snippets.panel.editAutomationTitle': 'Editar script de automatización',
'snippets.panel.resizeWidth': 'Cambiar el ancho del panel',
'snippets.field.description': 'Descripción de la acción',
'snippets.field.descriptionPlaceholder': 'Ejemplo: verificar la carga de la red',
'snippets.field.package': 'Agregar un paquete de scripts',
'snippets.field.packagePlaceholder': 'Selecciona o crea un paquete de scripts',
'snippets.field.createPackage': 'Crear paquete de scripts',
'snippets.field.scriptRequired': 'Script *',
'snippets.scriptEditor.expand': 'Abrir en diálogo',
'snippets.scriptEditor.resize': 'Cambiar la altura del editor',
'snippets.scriptEditor.modalTitle': 'Editar script',
'snippets.targets.title': 'Destinos',
'snippets.targets.add': 'Agregar destinos',
'snippets.targets.selectHosts': 'Hosts',
'snippets.targets.selectGroups': 'Grupos',
'snippets.targets.noGroups': 'No se encontraron grupos',
'snippets.targets.allHosts': 'Aplicar a todos los hosts',
'snippets.targets.allHostsShort': 'Todos los hosts',
'snippets.targets.allHostsActive': 'Se aplica a cada host conectable.',
'snippets.history.title': 'Historial de Shell',
'snippets.history.subtitle': '{count} comandos',
'snippets.history.emptyTitle': 'Aún no hay historial de Shell',
'snippets.history.emptyDesc': 'Los comandos que ejecutes aparecerán aquí',
'snippets.history.loadMore': 'Cargar más',
'snippets.history.separator': '•',
'snippets.history.labelPlaceholder': 'Establece una etiqueta para este snippet',
'snippets.history.saveAsSnippet': 'Guardar como snippet',
'snippets.history.time.justNow': 'justo ahora',
'snippets.history.time.minutesAgo': 'hace {count}m',
'snippets.history.time.hoursAgo': 'hace {count}h',
'snippets.history.time.daysAgo': 'hace {count}d',
'snippets.breadcrumb.allPackages': 'Todos los paquetes de scripts',
'snippets.breadcrumb.separator': '›',
'snippets.empty.title': 'Crea scripts',
'snippets.empty.desc': 'Guarda comandos comunes como snippets de código, o escribe scripts de automatización para operaciones repetibles.',
'snippets.search.noResults.title': 'Sin coincidencias',
'snippets.search.noResults.desc': 'Ningún script o paquete de scripts coincide con "{query}". Prueba con otro término de búsqueda o limpia la búsqueda para explorar.',
'snippets.section.packages': 'Paquetes de scripts',
'snippets.section.snippets': 'Scripts',
'snippets.kind.codeSnippet': 'Snippet de código',
'snippets.kind.automationScript': 'Script de automatización',
'snippets.package.count': '{count} script(s)',
'snippets.commandFallback': 'Comando',
'snippets.view.grid': 'Cuadrícula',
'snippets.view.list': 'Lista',
'snippets.selection.selected': '{count} seleccionados',
'snippets.selection.selectVisible': 'Seleccionar visibles',
'snippets.selection.deselectAll': 'Deseleccionar todo',
'snippets.selection.exportSelected': 'Exportar seleccionados ({count})',
'snippets.selection.deleteSelected': 'Eliminar ({count})',
'snippets.selection.deleteConfirmTitle': '¿Eliminar los elementos seleccionados ({count})?',
'snippets.selection.deleteConfirmDesc': 'Los elementos seleccionados se eliminarán de forma permanente. Esta acción no se puede deshacer.',
'snippets.selection.deleteSuccess': 'Elementos seleccionados eliminados: {count}.',
'snippets.export.snippet': 'Exportar snippet',
'snippets.export.package': 'Exportar paquete de scripts',
'snippets.export.toast.empty': 'No hay snippets para exportar.',
'snippets.export.toast.successTitle': 'Exportación lista',
'snippets.export.toast.success': 'Se exportaron {count} snippet(s).',
'snippets.import.toast.empty': 'No se encontraron snippets importables.',
'snippets.import.toast.failedTitle': 'Falló la importación',
'snippets.import.toast.invalidDesc': 'Este no es un archivo de snippets válido de Netcatty.',
'snippets.import.toast.successTitle': 'Importación completada',
'snippets.import.toast.summary': 'Importados {imported}, sobrescritos {overwritten}, omitidos {skipped}.',
'snippets.import.modal.title': 'Importar snippets',
'snippets.import.modal.desc': 'Elige uno o más archivos JSON de snippets de Netcatty. Netcatty los mostrará en vista previa antes de importar cualquier cosa.',
'snippets.import.modal.exampleTitle': 'Ejemplo de JSON',
'snippets.import.modal.noFile': 'Aún no se seleccionó ningún archivo. Usa el formato de ejemplo de abajo, o un arreglo JSON simple de snippets, y luego elige uno o más archivos.',
'snippets.import.modal.chooseFile': 'Elegir archivo',
'snippets.import.modal.downloadExamples': 'Descargar ejemplos',
'snippets.import.modal.multipleFiles': '{count} archivos seleccionados',
'snippets.import.modal.parsedSummary': '{files} archivo(s), {total} script(s), {packages} paquete(s) de scripts, {conflicts} comando(s) duplicado(s). Las vinculaciones de host se ignoran.',
'snippets.import.modal.confirm': 'Confirmar importación',
'snippets.import.conflict.title': '¿Importar snippets?',
'snippets.import.conflict.desc': '{file} contiene {total} snippet(s). Ya existen {conflicts} comando(s) duplicado(s).',
'snippets.import.conflict.hostBindingsNote': 'Las vinculaciones de host no se importan ni se exportan con los snippets.',
'snippets.import.conflict.skip': 'Omitir duplicados',
'snippets.import.conflict.overwrite': 'Sobrescribir duplicados',
'snippets.packageDialog.title': 'Nuevo paquete de scripts',
'snippets.packageDialog.parent': 'Padre: {parent}',
'snippets.packageDialog.root': 'Raíz',
'snippets.packageDialog.placeholder': 'p. ej., ops/maintenance',
'snippets.packageDialog.hint': 'Usa "/" para crear paquetes de scripts anidados.',
// Snippets Rename Dialog
'snippets.renameDialog.title': 'Renombrar paquete de scripts',
'snippets.renameDialog.currentPath': 'Ruta actual: {path}',
'snippets.renameDialog.placeholder': 'Ingresa el nuevo nombre',
'snippets.renameDialog.error.empty': 'El nombre del paquete de scripts no puede estar vacío',
'snippets.renameDialog.error.duplicate': 'Ya existe un paquete de scripts con este nombre',
'snippets.renameDialog.error.invalidChars': 'El nombre del paquete de scripts solo puede contener letras, números, guiones y guiones bajos',
'snippets.field.noAutoRun': 'Solo pegar (no auto-ejecutar)',
'snippets.field.multiLineRunMode': 'Ejecución multilínea',
'snippets.field.multiLineRunMode.paste': 'Enviar todo de una vez',
'snippets.field.multiLineRunMode.lineDelay': 'Enviar línea por línea',
'snippets.field.multiLineRunModeHint': 'Usa línea por línea para inicios de sesión que piden credenciales (prompt) o macros de dispositivos.',
// Snippet Shortkey
'snippets.field.shortkey': 'Atajo de teclado',
'snippets.shortkey.placeholder': 'Haz clic para configurar el atajo',
'snippets.shortkey.recording': 'Presiona una combinación de teclas...',
'snippets.shortkey.hint': 'Presiona este atajo en la terminal para enviar el comando rápidamente.',
'snippets.shortkey.clear': 'Borrar atajo',
'snippets.shortkey.error.systemConflict': 'Este atajo entra en conflicto con {name}',
'snippets.shortkey.error.snippetConflict': 'Este atajo ya lo usa el snippet: {name}',
'snippets.variables.dialogTitle': 'Variables del snippet',
'snippets.variables.dialogDesc': 'Completa los valores de "{label}" antes de ejecutar.',
'snippets.variables.hint': 'Los valores se insertan tal cual en el script (sin escapado de shell).',
'snippets.variables.preview': 'Vista previa',
'snippets.variables.placeholder': 'Ingresa un valor',
'snippets.variables.placeholderDefault': 'Predeterminado: {value}',
'snippets.variables.required': 'Esta variable es obligatoria',
'snippets.variables.run': 'Ejecutar',
'snippets.field.variablesHelp': 'Usa {{name}} o {{name:default}} para los marcadores de posición en el script.',
'snippets.field.variablesDetected': 'Variables',
'snippets.field.variableDefault': 'predeterminado {value}',
// Serial Port
'serial.button': 'Serial',
'serial.modal.title': 'Conectar a puerto serial',
'serial.modal.desc': 'Configura los ajustes de conexión del puerto serial',
'serial.field.port': 'Puerto serial',
'serial.field.selectPort': 'Selecciona un puerto...',
'serial.field.baudRate': 'Velocidad de baudios',
'serial.field.dataBits': 'Bits de datos',
'serial.field.stopBits': 'Bits de parada',
'serial.field.stopBits15Warning': 'Los 1.5 bits de parada pueden no ser compatibles con todos los dispositivos Windows',
'serial.field.parity': 'Paridad',
'serial.field.flowControl': 'Control de flujo',
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
'serial.field.customPort': 'Ruta de puerto personalizada',
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudoterminal',
'serial.type.custom': 'Personalizado',
'serial.parity.none': 'Ninguna',
'serial.parity.even': 'Par',
'serial.parity.odd': 'Impar',
'serial.parity.mark': 'Marca',
'serial.parity.space': 'Espacio',
'serial.flowControl.none': 'Ninguno',
'serial.flowControl.xon/xoff': 'XON/XOFF (Software)',
'serial.flowControl.rts/cts': 'RTS/CTS (Hardware)',
'serial.field.localEcho': 'Forzar eco local',
'serial.field.localEchoDesc': 'Hacer eco de los caracteres escritos localmente (para dispositivos sin eco remoto)',
'serial.field.lineMode': 'Modo de línea',
'serial.field.lineModeDesc': 'Almacenar en búfer la entrada y enviarla al presionar Enter (en lugar de carácter por carácter)',
'serial.field.backspaceBehavior': 'Tecla de retroceso',
'serial.field.backspaceBehaviorDesc': 'Usa Ctrl+H para dispositivos de red que no responden al código de Retroceso predeterminado.',
'serial.backspace.default': 'Predeterminado (DEL, 0x7F)',
'serial.backspace.ctrlH': 'Ctrl+H (BS, 0x08)',
'serial.field.charset': 'Codificación de caracteres',
'serial.connectionError': 'No se pudo conectar al puerto serial',
'serial.field.baudRatePlaceholder': 'Selecciona o ingresa la velocidad de baudios...',
'serial.field.baudRateEmpty': 'Ingresa una velocidad de baudios personalizada',
'serial.field.customBaudRate': 'Usando velocidad de baudios personalizada',
'serial.field.saveConfig': 'Guardar configuración',
'serial.field.saveConfigDesc': 'Guarda esta configuración serial en hosts para acceso rápido',
'serial.field.configLabel': 'Nombre de configuración',
'serial.field.configLabelPlaceholder': 'p. ej., Arduino Uno',
'serial.connectAndSave': 'Conectar y guardar',
'serial.edit.title': 'Configuración del puerto serial',
// Keyboard Interactive Authentication (2FA/MFA)
'keyboard.interactive.title': 'Se requiere autenticación',
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
'keyboard.interactive.response': 'Respuesta',
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
'keyboard.interactive.submit': 'Enviar',
'keyboard.interactive.verifying': 'Verificando...',
'keyboard.interactive.savePassword': 'Guardar contraseña',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'Frase de contraseña de la clave SSH',
'passphrase.desc': 'Ingresa la frase de contraseña de {keyName}',
'passphrase.descWithHost': 'Ingresa la frase de contraseña de {keyName} para conectarte a {hostname}',
'passphrase.label': 'Frase de contraseña',
'passphrase.keyPath': 'Clave',
'passphrase.unlock': 'Desbloquear',
'passphrase.unlocking': 'Desbloqueando...',
'passphrase.skip': 'Omitir',
'passphrase.remember': 'Recordar esta frase de contraseña',
// Text Editor
'sftp.editor.wordWrap': 'Ajuste de línea',
'sftp.editor.maximize': 'Maximizar',
'sftp.editor.unsavedTitle': 'Cambios sin guardar',
'sftp.editor.unsavedMessage': '{fileName} tiene cambios sin guardar. ¿Guardar antes de cerrar?',
'sftp.editor.discardChanges': 'Descartar',
'sftp.editor.saveAndClose': 'Guardar y cerrar',
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
};
+987
View File
@@ -0,0 +1,987 @@
import type { Messages } from '../types';
export const esVaultMessages: Messages = {
// Notes
'notes.search.placeholder': 'Buscar notas, hosts, etiquetas...',
'notes.action.newNote': 'Nueva nota',
'notes.action.newGroup': 'Nueva carpeta',
'notes.action.importMarkdown': 'Importar Markdown',
'notes.action.exportNote': 'Exportar nota',
'notes.action.exportGroup': 'Exportar carpeta',
'notes.action.exportAll': 'Exportar notas',
'notes.import.toast.success': 'Se importaron {count} nota(s)',
'notes.import.toast.noNotes': 'No se importaron archivos Markdown.',
'notes.import.toast.failed': 'No se pudieron importar los archivos Markdown.',
'notes.import.toast.inProgress': 'Ya hay una importación de Markdown en curso.',
'notes.import.toast.skipped': 'Se omitieron {count} archivo(s) no compatible(s).',
'notes.export.toast.success': 'Se exportaron {count} nota(s)',
'notes.export.toast.empty': 'No hay notas para exportar.',
'notes.export.toast.failed': 'No se pudieron exportar las notas.',
'notes.codeBlock.copied': 'Copiado',
'notes.codeBlock.copyFailed': 'No se pudo copiar el bloque de código.',
'notes.group.prompt': 'Ruta de la carpeta',
'notes.group.all': 'Cuaderno',
'notes.group.ungrouped': 'Sin grupo',
'notes.section.notebooks': 'Cuaderno',
'notes.section.notes': 'Notas',
'notes.mode.edit': 'Editar',
'notes.mode.split': 'Dividir',
'notes.mode.preview': 'Vista previa',
'notes.title.placeholder': 'Título de la nota',
'notes.editor.placeholder': 'Escribe notas Markdown aquí...',
'notes.preview.empty': 'Aún no hay nada para previsualizar.',
'notes.empty.title': 'Aún no hay notas',
'notes.empty.desc': 'Crea notas Markdown para runbooks, recordatorios y detalles de traspaso de servidores.',
'notes.empty.group': 'No hay notas en este cuaderno.',
'notes.search.noResults': 'No se encontraron notas.',
// Vault import
'vault.import.title': 'Agrega datos a tu Vault',
'vault.import.desc':
'Transfiere tus conexiones desde clientes populares. Selecciona un formato de archivo para comenzar la migración.',
'vault.import.chooseFormat': 'Selecciona un formato de archivo',
'vault.import.destination.title': '¿Dónde deberían ir los hosts importados?',
'vault.import.destination.preserve': 'Conservar los grupos originales',
'vault.import.destination.existing': 'Elegir un grupo',
'vault.import.destination.new': 'Crear un grupo',
'vault.import.destination.noGroups': 'No hay grupos disponibles',
'vault.import.destination.newPlaceholder': 'Nombre del grupo nuevo',
'vault.import.destination.settings': 'Ubicación de importación',
'vault.import.destination.settingsHint': 'Elige dónde se colocarán los hosts nuevos',
'vault.import.destination.done': 'Listo',
'vault.import.securecrt.directoryHint': 'Selecciona la carpeta de sesiones de SecureCRT.',
'vault.import.securecrt.chooseSource': 'Elige una fuente de importación de SecureCRT.',
'vault.import.securecrt.sourceQuestion': '¿Qué te gustaría importar?',
'vault.import.securecrt.promptTitle': 'Importar desde SecureCRT',
'vault.import.securecrt.promptDesc':
'Elige la carpeta de sesiones de SecureCRT para importar todas las sesiones conservando la estructura de carpetas, o selecciona un solo archivo de sesión .ini.',
'vault.import.securecrt.folder': 'Carpeta de sesiones',
'vault.import.securecrt.folderDesc': 'Importa todas las sesiones y conserva la estructura de carpetas.',
'vault.import.securecrt.file': 'Archivo de sesión único',
'vault.import.securecrt.fileDesc': 'Importa una sesión .ini de SecureCRT.',
'vault.import.csv.tip': 'Importación masiva: usa la plantilla CSV.',
'vault.import.csv.downloadTemplate': 'Descargar plantilla CSV',
'vault.import.toast.start': 'Importando desde {format}...',
'vault.import.toast.completedTitle': 'Importación completada',
'vault.import.toast.failedTitle': 'Importación fallida',
'vault.import.toast.noEntries': 'No se encontraron entradas importables en {format}.',
'vault.import.toast.noNewHosts': 'No se importaron hosts nuevos desde {format}.',
'vault.import.toast.summary':
'Se importaron {count} hosts (omitidos {skipped}, duplicados {duplicates}).',
'vault.import.toast.firstIssue': 'Primer problema: {issue}',
'vault.import.progress.title': 'Importando hosts',
'vault.import.progress.reading': 'Leyendo archivo',
'vault.import.progress.parsing': 'Analizando hosts',
'vault.import.progress.preparing': 'Preparando cambios',
'vault.import.progress.saving': 'Guardando hosts',
'vault.import.progress.complete': 'Importación completa',
'vault.import.progress.failed': 'Importación fallida',
'vault.import.progress.persistFailed': 'No se pudieron guardar los hosts importados. Libera algo de espacio de almacenamiento local e inténtalo de nuevo.',
'vault.import.progress.rollbackFailed': 'La importación no se pudo revertir de forma segura. Es posible que queden algunos hosts importados; revisa el Vault antes de reintentar.',
'vault.import.progress.summary':
'Se importaron {count} hosts; omitidos {skipped}; duplicados {duplicates}.',
'vault.import.progress.keepOpen': 'Mantén Netcatty abierto mientras la importación se ejecuta en segundo plano.',
'vault.import.progress.fileSummary': '{name} · {count} archivos',
'vault.import.progress.fileCount': '{completed} de {total} archivos',
'vault.import.mobaxterm.chooseEncoding': 'Elige cómo leer tu archivo de MobaXterm.',
'vault.import.mobaxterm.encodingQuestion': '¿Qué codificación de texto usa este archivo?',
'vault.import.mobaxterm.auto': 'Detección automática',
'vault.import.mobaxterm.autoDesc': 'Recomendado. Usa UTF-8 cuando sea válido; de lo contrario, la codificación de Windows en chino.',
'vault.import.mobaxterm.utf8': 'UTF-8',
'vault.import.mobaxterm.utf8Desc': 'Úsalo para archivos guardados o convertidos como UTF-8.',
'vault.import.mobaxterm.gb18030': 'Windows en chino',
'vault.import.mobaxterm.gb18030Desc': 'Úsalo para archivos antiguos exportados en Windows en chino.',
'vault.import.sshConfig.chooseMode': 'Elige cómo importar tu archivo de configuración SSH.',
'vault.import.sshConfig.modeQuestion': '¿Cómo te gustaría importar?',
'vault.import.sshConfig.importOnly': 'Solo importar',
'vault.import.sshConfig.importOnlyDesc': 'Importación única. Los cambios no se sincronizarán de vuelta al archivo.',
'vault.import.sshConfig.managed': 'Sincronización administrada',
'vault.import.sshConfig.managedDesc': 'Mantén la sincronización. Los cambios se guardarán de vuelta en el archivo.',
'vault.import.sshConfig.managedDestinationHint': 'La sincronización administrada siempre usa su propio grupo administrado; el destino anterior aplica solo a la importación única.',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': 'Se importaron {count} hosts. El archivo ahora está administrado.',
'vault.import.sshConfig.alreadyManaged': 'Este archivo ya está siendo administrado.',
'vault.import.sshConfig.alreadyManagedDesc': 'Este archivo ya está administrado en el grupo "{group}". Elimina primero la fuente administrada existente si quieres volver a importar.',
'vault.import.sshConfig.noFilePath': 'No se puede administrar este archivo.',
'vault.import.sshConfig.noFilePathDesc': 'No se pudo determinar la ruta del archivo. La sincronización administrada requiere acceso al sistema de archivos.',
// Known Hosts
'knownHosts.search.placeholder': 'Buscar hosts conocidos...',
'knownHosts.action.scanSystem': 'Escanear sistema',
'knownHosts.action.importFile': 'Importar archivo',
'knownHosts.action.browseFile': 'Explorar archivo',
'knownHosts.empty.title': 'No hay hosts conocidos',
'knownHosts.empty.desc':
"Los hosts conocidos son servidores SSH a los que te conectaste antes. Importa desde el archivo known_hosts de tu sistema para comenzar.",
'knownHosts.results.showingLimited':
'Mostrando {shown} de {total} hosts. Usa la búsqueda para encontrar hosts específicos.',
'knownHosts.toast.scanUnavailable': 'El escaneo del sistema no está disponible en esta plataforma.',
'knownHosts.toast.scanNoFile': 'No se encontró un archivo known_hosts del sistema.',
'knownHosts.toast.scanNoEntries': 'No se encontraron entradas utilizables en known_hosts.',
'knownHosts.toast.scanImported': 'Se importaron {count} hosts nuevos.',
'knownHosts.toast.scanNoNew': 'No se encontraron hosts nuevos.',
'knownHosts.toast.scanFailed': 'No se pudo escanear el known_hosts del sistema.',
// Port Forwarding
'pf.empty.title': 'Configura el reenvío de puertos',
'pf.empty.desc': 'Guarda el reenvío de puertos para acceder a bases de datos, aplicaciones web y otros servicios.',
'pf.title': 'Reenvío de puertos',
'pf.rulesCount': '{count} reglas',
'pf.wizard.editTitle': 'Editar reenvío de puertos',
'pf.wizard.newTitle': 'Nuevo reenvío de puertos',
'pf.wizard.saveChanges': 'Guardar cambios',
'pf.wizard.done': 'Listo',
'pf.wizard.continue': 'Continuar',
'pf.wizard.cancel': 'Cancelar',
'pf.wizard.skipWizard': 'Omitir asistente',
'pf.error.hostNotFound': 'No se encontró el host',
'pf.toast.titleWithLabel': 'Reenvío de puertos: {label}',
'pf.type.local': 'Local',
'pf.type.remote': 'Remoto',
'pf.type.dynamic': 'Dinámico',
'pf.type.menu.local': 'Reenvío local',
'pf.type.menu.remote': 'Reenvío remoto',
'pf.type.menu.dynamic': 'Reenvío dinámico',
'pf.type.local.desc': 'El reenvío local te permite acceder al puerto de escucha de un servidor remoto como si fuera local.',
'pf.type.remote.desc': 'El reenvío remoto abre un puerto en la máquina remota y reenvía las conexiones al host local (actual).',
'pf.type.dynamic.desc': 'El reenvío dinámico de puertos convierte a Netcatty en un servidor proxy SOCKS.',
'pf.wizard.type.title': 'Selecciona el tipo de reenvío de puertos:',
'pf.wizard.localConfig.title': 'Configura el puerto local y la dirección de enlace:',
'pf.wizard.localConfig.desc': 'Este puerto estará abierto en el dispositivo local (actual) y recibirá el tráfico.',
'pf.wizard.localConfig.localPort': 'Número de puerto local *',
'pf.wizard.bindAddress': 'Dirección de enlace',
'pf.wizard.remoteHost.title': 'Selecciona el host remoto:',
'pf.wizard.remoteHost.desc': 'Selecciona un host donde estará abierto el puerto. El tráfico de este puerto se reenviará al host de destino.',
'pf.wizard.remoteConfig.title': 'Configura el puerto y la dirección de enlace:',
'pf.wizard.remoteConfig.desc': 'El tráfico se reenviará desde el puerto especificado y la dirección de interfaz del host seleccionado.',
'pf.wizard.remoteConfig.remotePort': 'Número de puerto remoto *',
'pf.wizard.destination.title': 'Selecciona el host de destino:',
'pf.wizard.destination.desc.local': 'Ingresa el destino remoto al que quieres acceder a través del túnel.',
'pf.wizard.destination.desc.remote': 'La dirección y el puerto de destino hacia donde se reenviará el tráfico.',
'pf.wizard.destination.address': 'Dirección de destino *',
'pf.wizard.destination.addressPlaceholder': 'p. ej. 127.0.0.1 o 192.168.1.100',
'pf.wizard.destination.port': 'Número de puerto de destino *',
'pf.wizard.sshServer.title': 'Selecciona el servidor SSH:',
'pf.wizard.sshServer.desc.dynamic': 'Selecciona el servidor SSH que actuará como tu proxy SOCKS.',
'pf.wizard.sshServer.desc.default': 'Selecciona el servidor SSH que tunelizará tu tráfico hacia el destino.',
'pf.wizard.label.title': 'Selecciona la etiqueta:',
'pf.wizard.label.placeholder.dynamic': 'p. ej. Proxy SOCKS',
'pf.wizard.label.placeholder.default': 'p. ej. MySQL Producción',
'pf.wizard.label.placeholder.remoteRule': 'p. ej. Regla remota',
'pf.wizard.placeholders.portExample': 'p. ej. {port}',
'pf.action.newForwarding': 'Nuevo reenvío',
'pf.form.labelPlaceholder': 'Etiqueta de la regla',
'pf.form.intermediateHost': 'Host intermedio *',
'pf.form.createRule': 'Crear regla',
'pf.form.openWizard': 'Abrir asistente',
'pf.form.openWizardTitle': 'Abrir el asistente de reenvío de puertos',
'pf.view.grid': 'Cuadrícula',
'pf.view.list': 'Lista',
'pf.rule.summary.local': 'Local {bindAddress}:{localPort} -> Túnel SSH -> {remoteHost}:{remotePort}',
'pf.rule.summary.remote': 'Remoto {bindAddress}:{localPort} -> Túnel SSH -> local {remoteHost}:{remotePort}',
'pf.rule.summary.dynamic': 'SOCKS en {bindAddress}:{localPort}',
'pf.tooltip.relayHost': 'Host de relevo',
'pf.tooltip.hostLabel': 'Host',
'pf.tooltip.hostAddress': 'Dirección',
'pf.tooltip.noHost': 'No hay host de relevo configurado',
'pf.tooltip.localDesc': 'Reenvío de puerto local: accede a servicios remotos a través del túnel SSH',
'pf.tooltip.remoteDesc': 'Reenvío de puerto remoto: expone servicios locales al host remoto',
'pf.tooltip.dynamicDesc': 'Proxy SOCKS dinámico: enruta el tráfico a través del túnel SSH',
'pf.deleteActive.title': '¿Eliminar el reenvío de puertos activo?',
'pf.deleteActive.desc': 'Esta regla de reenvío de puertos "{label}" está activa actualmente. Eliminarla primero detendrá el túnel.',
'pf.deleteActive.confirm': 'Detener y eliminar',
'pf.form.autoStart': 'Inicio automático',
'pf.form.autoStartDesc': 'Inicia automáticamente esta regla cuando se abra la aplicación',
// SFTP
'sftp.newFolder': 'Nueva carpeta',
'sftp.newFile': 'Nuevo archivo',
'sftp.filter': 'Filtrar',
'sftp.filter.placeholder': 'Filtrar por nombre de archivo...',
'sftp.bookmark.add': 'Marcar esta ruta',
'sftp.bookmark.remove': 'Quitar marcador',
'sftp.bookmark.list': 'Rutas marcadas',
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Guardar como marcador global (compartido entre todos los hosts)',
'sftp.bookmark.empty': 'Aún no hay marcadores',
'sftp.columns.name': 'Nombre',
'sftp.columns.modified': 'Modificado',
'sftp.columns.size': 'Tamaño',
'sftp.columns.kind': 'Tipo',
'sftp.columns.configure': 'Elegir columnas visibles',
'sftp.columns.actions': 'Acciones',
'sftp.sort.directoriesFirst': 'Carpetas primero',
'sftp.emptyDirectory': 'Directorio vacío',
'sftp.nav.up': 'Subir un nivel',
'sftp.nav.home': 'Ir al inicio',
'sftp.nav.refresh': 'Actualizar',
'sftp.upload': 'Subir',
'sftp.uploadFiles': 'Subir archivos',
'sftp.uploadFolder': 'Subir carpeta',
'sftp.dragDropToUpload': 'Arrastra y suelta archivos aquí para subirlos',
'sftp.retry': 'Reintentar',
'sftp.context.open': 'Abrir',
'sftp.context.navigateTo': 'Navegar a',
'sftp.context.moveTo': 'Mover a...',
'sftp.context.moveToParent': 'Mover al directorio principal',
'sftp.moveTo.title': 'Mover a directorio',
'sftp.moveTo.placeholder': 'Ingresa la ruta del directorio de destino',
'sftp.moveTo.confirm': 'Mover',
'sftp.moveTo.pathNotFound': 'Directorio no encontrado o inaccesible',
'sftp.context.download': 'Descargar',
'sftp.context.extract': 'Extraer aquí',
'sftp.extract.extracting': 'Extrayendo {fileName}...',
'sftp.extract.success': 'Se extrajo {fileName}',
'sftp.extract.error': 'No se pudo extraer {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Copiar al otro panel',
'sftp.copyToOtherPane.unavailable': 'Abre la vista SFTP de dos paneles y conecta el panel de destino antes de copiar archivos.',
'sftp.copyCurrentPath': 'Copiar ruta actual',
'sftp.copyCurrentPath.success': 'Ruta actual copiada',
'sftp.copyCurrentPath.error': 'No se pudo copiar la ruta actual',
'sftp.viewMode.label': 'Modo de vista',
'sftp.viewMode.list': 'Vista de lista',
'sftp.viewMode.tree': 'Vista de árbol',
'sftp.viewMode.switchToList': 'Cambiar a vista de lista',
'sftp.viewMode.switchToTree': 'Cambiar a vista de árbol',
'sftp.tree.loadError': 'No se pudo cargar el directorio',
'sftp.tree.loading': 'Cargando...',
'sftp.kind.folder': 'Carpeta',
'sftp.context.rename': 'Renombrar',
'sftp.context.permissions': 'Permisos',
'sftp.context.delete': 'Eliminar',
'sftp.context.refresh': 'Actualizar',
'sftp.context.uploadFiles': 'Subir archivo(s)...',
'sftp.context.uploadFilesHere': 'Subir archivo(s) aquí...',
'sftp.context.uploadFolder': 'Subir carpeta...',
'sftp.context.uploadFolderHere': 'Subir carpeta aquí...',
'sftp.context.downloadSelected': 'Descargar seleccionados ({count})',
'sftp.context.deleteSelected': 'Eliminar seleccionados ({count})',
'sftp.dropFilesHere': 'Suelta los archivos aquí',
'sftp.itemsCount': '{count} elementos',
'sftp.selectedCount': '{count} seleccionados',
'sftp.path.doubleClickToEdit': 'Haz doble clic para editar la ruta',
'sftp.showHiddenPaths': 'Rutas ocultas',
'sftp.task.waiting': 'Esperando...',
'sftp.transfer.preparing': 'preparando...',
'sftp.status.loading': 'Cargando...',
'sftp.status.uploading': 'Subiendo...',
'sftp.status.ready': 'Listo',
'sftp.transfers': 'Transferencias',
'sftp.transfers.active': '{count} activas',
'sftp.transfers.clearCompleted': 'Limpiar completadas',
'sftp.transfers.calculatingTotal': 'Calculando el tamaño total...',
'sftp.transfers.filesCount': '{count} archivos',
'sftp.transfers.filesProgress': '{current}/{total} archivos',
// Progressive folder walk: total is "found so far", not a fixed grand total.
'sftp.transfers.filesDiscoveredProgress': '{completed} completados · {discovered} encontrados',
'sftp.transfers.expandChildren': 'Mostrar archivos',
'sftp.transfers.collapseChildren': 'Ocultar archivos',
'sftp.transfers.expandChildList': 'Mostrar detalle',
'sftp.transfers.collapseChildList': 'Ocultar',
'sftp.transfers.retryAction': 'Reintentar',
'sftp.transfers.dismissAction': 'Descartar',
'sftp.transfers.openTargetFolder': 'Abrir carpeta de destino',
'sftp.transfers.openTargetFolderError': 'No se pudo abrir la carpeta de destino',
'sftp.transfers.copyTargetPath': 'Copiar ruta de destino',
'sftp.transfers.copyTargetPathSuccess': 'Ruta de destino copiada',
'sftp.transfers.copyTargetPathError': 'No se pudo copiar la ruta de destino',
'sftp.transfers.pathConflict.title': 'Transferencia bloqueada',
'sftp.transfers.pathConflict.paused': 'Otra transferencia de "{name}" está en pausa. Reanúdala o cancélala primero.',
'sftp.transfers.pathConflict.inProgress': 'Otra transferencia de "{name}" ya está en curso.',
'sftp.transferCenter.title': 'Transferencias de archivos',
'sftp.transferCenter.quitConfirm': '{count} transferencia(s) de archivos quedaron sin terminar. El progreso se guardará y deberá reanudarse manualmente la próxima vez. ¿Salir de todos modos?',
'sftp.transferCenter.phase.scanning': 'Escaneando',
'sftp.transferCenter.phase.compressing': 'Comprimiendo',
'sftp.transferCenter.phase.uploading': 'Subiendo',
'sftp.transferCenter.phase.transferring': 'Transfiriendo',
'sftp.transferCenter.phase.extracting': 'Extrayendo',
'sftp.transferCenter.phase.verifying': 'Verificando',
'sftp.transferCenter.bucket.active': 'En progreso',
'sftp.transferCenter.bucket.all': 'Todas',
'sftp.transferCenter.bucket.queued': 'En cola',
'sftp.transferCenter.bucket.paused': 'En pausa',
'sftp.transferCenter.bucket.failed': 'Fallidas',
'sftp.transferCenter.bucket.attention': 'Requieren atención',
'sftp.transferCenter.bucket.completed': 'Completadas',
'sftp.transferCenter.status.pending': 'Preparando',
'sftp.transferCenter.status.resuming': 'Reconectando y reanudando…',
'sftp.transferCenter.status.queued': 'En cola',
'sftp.transferCenter.status.transferring': 'Transfiriendo',
'sftp.transferCenter.status.pausing': 'Pausando',
'sftp.transferCenter.status.paused': 'En pausa',
'sftp.transferCenter.status.attention': 'Requiere atención',
'sftp.transferCenter.status.interrupted': 'Interrumpida: reanúdala manualmente',
'sftp.transferCenter.status.completed': 'Completada',
'sftp.transferCenter.status.failed': 'Fallida',
'sftp.transferCenter.status.cancelled': 'Cancelada',
'sftp.transferCenter.background': 'Segundo plano',
'sftp.transferCenter.pause': 'Pausar',
'sftp.transferCenter.resume': 'Reanudar',
'sftp.transferCenter.prioritize': 'Transferir a continuación',
'sftp.transferCenter.pauseAll': 'Pausar todas',
'sftp.transferCenter.resumeAll': 'Reanudar todas',
'sftp.transferCenter.empty': 'No hay transferencias en esta categoría',
'sftp.transferCenter.showBackground': 'Mostrar {count} transferencias en segundo plano',
'sftp.transferCenter.hideBackground': 'Ocultar transferencias en segundo plano',
'sftp.transferCenter.clear': 'Limpiar registros',
'sftp.transferCenter.applyAll': 'Aplicar a todas',
'sftp.transfers.resizeNameColumn': 'Redimensionar la columna de nombre de archivo',
'sftp.transfers.dragToResize': 'Arrastra para redimensionar',
'sftp.goUp': 'Subir un nivel',
'sftp.goToTerminalCwd': 'Ir al directorio de la terminal',
'sftp.locatePathInTerminal': 'Abrir ruta en la terminal',
'sftp.followTerminalCwd': 'Seguir el directorio de la terminal',
'sftp.followTerminalCwd.enable': 'Habilitar el seguimiento del directorio de la terminal',
'sftp.followTerminalCwd.disable': 'Deshabilitar el seguimiento del directorio de la terminal',
'sftp.encoding.label': 'Codificación de nombres de archivo',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Ir al inicio',
'sftp.folderName': 'Nombre de la carpeta',
'sftp.folderName.placeholder': 'Ingresa el nombre de la carpeta',
'sftp.fileName': 'Nombre del archivo',
'sftp.fileName.placeholder': 'Ingresa el nombre del archivo',
'sftp.prompt.newFolderName': '¿Nombre de la carpeta nueva?',
'sftp.rename.title': 'Renombrar',
'sftp.rename.newName': 'Nombre nuevo',
'sftp.rename.placeholder': 'Ingresa el nombre nuevo',
'sftp.confirm.deleteOne': '¿Eliminar "{name}"?',
'sftp.deleteConfirm.single': '¿Eliminar "{name}"?',
'sftp.deleteConfirm.title': '¿Eliminar {count} elemento(s)?',
'sftp.deleteConfirm.desc': 'Esta acción no se puede deshacer. Se eliminará lo siguiente:',
'sftp.deleteConfirm.descSingle': 'Esta acción no se puede deshacer.',
'sftp.deleteConfirm.host': 'Host',
'sftp.deleteConfirm.path': 'Ruta',
'sftp.error.loadFailed': 'No se pudo cargar el directorio',
'sftp.error.downloadFailed': 'No se pudo descargar',
'sftp.error.uploadFailed': 'No se pudo subir',
'sftp.error.deleteFailed': 'No se pudo eliminar',
'sftp.error.createFolderFailed': 'No se pudo crear la carpeta',
'sftp.error.createFileFailed': 'No se pudo crear el archivo',
'sftp.error.invalidFileName': 'El nombre de archivo contiene caracteres no válidos: {chars}',
'sftp.error.reservedName': 'Este nombre de archivo está reservado por el sistema',
'sftp.overwrite.title': 'El archivo ya existe',
'sftp.overwrite.desc': 'Ya existe un archivo llamado "{name}". ¿Quieres reemplazarlo?',
'sftp.overwrite.confirm': 'Reemplazar',
'sftp.error.renameFailed': 'No se pudo renombrar',
'sftp.picker.title': 'Seleccionar host',
'sftp.picker.desc': 'Elige un host para el panel {side}',
'sftp.picker.searchPlaceholder': 'Buscar hosts...',
'sftp.picker.local.title': 'Sistema de archivos local',
'sftp.picker.local.desc': 'Explorar archivos locales',
'sftp.picker.local.badge': 'Local',
'sftp.picker.connected.section': 'Conectados',
'sftp.picker.connected.badge': 'En vivo',
'sftp.picker.connected.connecting': 'Conectando',
'sftp.picker.noMatch': 'No hay hosts que coincidan',
'sftp.permissions.title': 'Editar permisos',
'sftp.permissions.owner': 'Propietario',
'sftp.permissions.group': 'Grupo',
'sftp.permissions.others': 'Otros',
'sftp.permissions.octal': 'Octal',
'sftp.permissions.symbolic': 'Simbólico',
'sftp.permissions.success': 'Permisos actualizados correctamente',
'sftp.permissions.failed': 'No se pudieron actualizar los permisos',
'sftp.pane.local': 'Local',
'sftp.pane.remote': 'Remoto',
'sftp.pane.selectHost': 'Seleccionar host',
'sftp.pane.selectHostToStart': 'Selecciona un host para comenzar',
'sftp.pane.chooseFilesystem': 'Elige un sistema de archivos local o remoto para explorar',
'sftp.tabs.addTab': 'Agregar pestaña nueva',
'sftp.tabs.closeTab': 'Cerrar pestaña',
'sftp.tabs.newTab': 'Pestaña nueva',
'sftp.tabs.copyDefaultPath': 'Copiar pestaña (ruta predeterminada)',
'sftp.tabs.copyCurrentPath': 'Copiar e ir a la ruta actual',
'sftp.conflict.title': 'Conflicto de archivos',
'sftp.conflict.desc': 'Ya existe un archivo con el mismo nombre en el destino',
'sftp.conflict.alreadyExistsSuffix': 'ya existe',
'sftp.conflict.existingFile': 'Archivo existente',
'sftp.conflict.newFile': 'Archivo nuevo',
'sftp.conflict.size': 'Tamaño:',
'sftp.conflict.modified': 'Modificado:',
'sftp.conflict.applyToAll': 'Aplicar esta acción a los {count} conflictos restantes',
'sftp.conflict.action.stop': 'Detener',
'sftp.conflict.action.skip': 'Omitir',
'sftp.conflict.action.keepBoth': 'Conservar ambos',
'sftp.conflict.action.duplicate': 'Duplicar',
'sftp.conflict.action.merge': 'Combinar',
'sftp.conflict.action.replace': 'Reemplazar',
// SFTP Upload Phases
'sftp.upload.phase.compressing': 'Comprimiendo',
'sftp.upload.phase.uploading': 'Subiendo',
'sftp.upload.phase.extracting': 'Extrayendo',
'sftp.upload.phase.compressed': 'Comprimido',
// SFTP File Opener
'sftp.context.copyPath': 'Copiar ruta del archivo',
'sftp.context.openWithDefault': 'Abrir con la aplicación predeterminada del sistema',
'sftp.context.openWith': 'Abrir con...',
'sftp.context.edit': 'Editar',
'sftp.context.preview': 'Vista previa',
'sftp.opener.title': 'Abrir con',
'sftp.opener.desc': 'Elige una aplicación para abrir este archivo',
'sftp.opener.builtInEditor': 'Editor integrado',
'sftp.opener.editDescription': 'Editar archivos de texto',
'sftp.opener.builtInImageViewer': 'Visor de imágenes integrado',
'sftp.opener.previewDescription': 'Previsualizar imágenes',
'sftp.opener.systemApp': 'Elegir aplicación...',
'sftp.opener.systemAppDescription': 'Selecciona una aplicación de tu computadora',
'sftp.opener.onlySystemApp': 'Este archivo solo se puede abrir con una aplicación externa',
'sftp.opener.noAppsAvailable': 'No hay aplicaciones disponibles',
'sftp.opener.noExtension': 'archivos sin extensión',
'sftp.opener.setDefault': 'Usar siempre esto para archivos {ext}',
'sftp.opener.confirmTitle': '¿Establecer como predeterminado?',
'sftp.opener.confirmDescription': '¿Quieres usar siempre {app} para archivos {ext}?',
'sftp.opener.yesRemember': 'Sí, recordar esta elección',
'sftp.opener.justOnce': 'Solo esta vez',
'sftp.opener.confirm.title': 'Establecer aplicación predeterminada',
'sftp.opener.confirm.desc': '¿Quieres abrir siempre los archivos .{ext} con esta aplicación?',
'sftp.editor.title': 'Editor de texto',
'sftp.editor.save': 'Guardar en remoto',
'sftp.editor.saving': 'Guardando...',
'sftp.editor.saved': 'Guardado correctamente',
'sftp.editor.saveFailed': 'No se pudo guardar el archivo',
'sftp.editor.unsavedChanges': 'Tienes cambios sin guardar. ¿Cerrar de todos modos?',
'sftp.editor.syntaxHighlight': 'Resaltado de sintaxis',
'sftp.preview.title': 'Vista previa de imagen',
'sftp.preview.zoomIn': 'Acercar',
'sftp.preview.zoomOut': 'Alejar',
'sftp.preview.resetZoom': 'Restablecer zoom',
'sftp.preview.fitToWindow': 'Ajustar a la ventana',
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Concurrencia de transferencias',
'settings.sftp.transferConcurrency.desc': 'Cantidad máxima de archivos transferidos en paralelo para el mismo endpoint, incluidos los contenidos de carpetas y los archivos seleccionados por separado. Los valores más altos pueden mejorar la velocidad, pero pueden abrumar a algunos servidores.',
'settings.sftp.transferPoolIdleTtl': 'Mantener activa la conexión de transferencia',
'settings.sftp.transferPoolIdleTtl.desc': 'Cuánto tiempo las conexiones SFTP dedicadas de transferencia permanecen activas después de estar inactivas. Los valores más largos reducen la demora de arranque en frío antes de la siguiente subida o descarga. Las transferencias ocupadas nunca se cierran.',
'settings.sftp.transferPoolIdleTtl.1m': '1 minuto',
'settings.sftp.transferPoolIdleTtl.5m': '5 minutos',
'settings.sftp.transferPoolIdleTtl.15m': '15 minutos',
'settings.sftp.transferPoolIdleTtl.30m': '30 minutos',
'settings.sftp.transferPoolIdleTtl.never': 'Hasta que se cierre la aplicación',
'settings.ssh.transportIdleTtl': 'Mantener activa la conexión SSH',
'settings.ssh.transportIdleTtl.desc': 'Cuánto tiempo una conexión SSH compartida permanece activa después de liberar el último terminal, la exploración SFTP, la transferencia masiva o el arrendamiento de reenvío de puertos. Mientras esté activa, reconectarse al mismo host puede omitir la reautenticación (incluida la MFA). Las sesiones y transferencias activas nunca se cierran.',
'settings.ssh.transportIdleTtl.1m': '1 minuto',
'settings.ssh.transportIdleTtl.5m': '5 minutos',
'settings.ssh.transportIdleTtl.15m': '15 minutos',
'settings.ssh.transportIdleTtl.30m': '30 minutos',
'settings.ssh.transportIdleTtl.never': 'Hasta que se cierre la aplicación',
'settings.sftp.defaultOpener': 'Aplicación predeterminada para abrir archivos',
'settings.sftp.defaultOpener.desc': 'Elige la aplicación predeterminada para abrir archivos sin una asociación de archivos específica',
'settings.sftp.defaultOpener.ask': 'Preguntar siempre',
'settings.sftp.defaultOpener.askDesc': 'Muestra un diálogo para elegir una aplicación cada vez',
'settings.sftp.defaultOpener.builtInDesc': 'Abrir archivos de texto en el editor integrado por defecto',
'settings.sftp.defaultOpener.systemApp': 'Elegir aplicación...',
'settings.sftp.defaultOpener.systemAppDesc': 'Abrir archivos con una aplicación específica por defecto',
'settings.sftpFileAssociations.title': 'Asociaciones de archivos SFTP',
'settings.sftpFileAssociations.desc': 'Configura las aplicaciones predeterminadas para abrir archivos por extensión',
'settings.sftpFileAssociations.extension': 'Extensión',
'settings.sftpFileAssociations.application': 'Aplicación',
'settings.sftpFileAssociations.noAssociations': 'No hay asociaciones de archivos configuradas',
'settings.sftpFileAssociations.remove': 'Quitar',
'settings.sftpFileAssociations.removeConfirm': '¿Quitar la asociación para .{ext}?',
// Settings > SFTP Behavior
'settings.sftp.doubleClickBehavior': 'Comportamiento del doble clic',
'settings.sftp.doubleClickBehavior.desc': 'Elige la acción al hacer doble clic en un archivo en la vista SFTP',
'settings.sftp.doubleClickBehavior.open': 'Abrir archivo',
'settings.sftp.doubleClickBehavior.transfer': 'Transferir al otro panel',
'settings.sftp.doubleClickBehavior.openDesc': 'Abrir el archivo en la aplicación predeterminada',
'settings.sftp.doubleClickBehavior.transferDesc': 'Transferir el archivo al host activo del otro panel',
'settings.sshDeepLink.title': 'Enlaces SSH y Telnet',
'settings.sshDeepLink.enable': 'Abrir enlaces ssh:// y telnet:// con Netcatty',
'settings.sshDeepLink.enableDesc': 'Permite que Netcatty maneje los enlaces ssh:// y telnet:// de los navegadores y otras aplicaciones, además de lanzamientos al estilo PuTTY como -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Explorador de Windows',
'settings.explorerContextMenu.enable': 'Mostrar "Abrir en Netcatty" en los menús contextuales de carpetas',
'settings.explorerContextMenu.enableDesc': 'Agrega un elemento de clic derecho para las carpetas y los fondos de carpeta, para que puedas abrir una terminal local allí. Se aplica de inmediato; no es necesario reinstalar.',
'settings.jmsDeepLink.title': 'Enlaces de JumpServer',
'settings.jmsDeepLink.enable': 'Manejar enlaces jms://',
'settings.jmsDeepLink.enableDesc': 'Registra a Netcatty como el controlador de los enlaces jms:// del cliente de JumpServer. Puede entrar en conflicto con el cliente oficial de JumpServer.',
// Settings > SFTP Auto Sync
'settings.sftp.autoSync': 'Sincronización automática con el remoto',
'settings.sftp.autoSync.desc': 'Sincroniza automáticamente los cambios de archivos de vuelta al servidor remoto al abrir archivos con aplicaciones externas',
'settings.sftp.autoSync.enable': 'Habilitar la sincronización automática',
'settings.sftp.autoSync.enableDesc': 'Cuando guardas un archivo en una aplicación externa, los cambios se subirán automáticamente al servidor remoto',
// Settings > SFTP Auto Open Sidebar
'settings.sftp.autoOpenSidebar': 'Abrir la barra lateral automáticamente al conectar',
'settings.sftp.autoOpenSidebar.desc': 'Abre automáticamente la barra lateral del explorador de archivos SFTP al conectar a un host',
'settings.sftp.autoOpenSidebar.enable': 'Habilitar la apertura automática de la barra lateral',
'settings.sftp.autoOpenSidebar.enableDesc': 'La barra lateral de SFTP se abrirá automáticamente cuando una sesión de terminal se conecte a un host remoto',
'settings.sftp.followTerminalCwd': 'Seguir el directorio de la terminal',
'settings.sftp.followTerminalCwd.desc': 'Sincroniza automáticamente el explorador SFTP de la barra lateral con el directorio de trabajo de la terminal (alternar en la barra de herramientas)',
'settings.sftp.followTerminalCwd.enable': 'Habilitar el seguimiento del directorio de la terminal por defecto',
'settings.sftp.followTerminalCwd.enableDesc': 'Cuando la barra lateral de SFTP está abierta, el modo de seguimiento permanece activado por defecto y se actualiza después de los comandos cd de la terminal',
'settings.sftp.defaultViewMode': 'Modo de vista predeterminado',
'settings.sftp.defaultViewMode.desc': 'Elige el modo de vista predeterminado al abrir una pestaña SFTP nueva. Las preferencias por host anulan esta configuración.',
'settings.sftp.defaultViewMode.list': 'Vista de lista',
'settings.sftp.defaultViewMode.listDesc': 'Muestra los archivos en una lista plana del directorio actual',
'settings.sftp.defaultViewMode.tree': 'Vista de árbol',
'settings.sftp.defaultViewMode.treeDesc': 'Muestra los archivos en una estructura de árbol jerárquica',
'sftp.autoSync.success': 'Archivo sincronizado con el remoto: {fileName}',
'sftp.autoSync.error': 'No se pudo sincronizar el archivo: {error}',
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Subiendo {current} de {total} archivos...',
'sftp.upload.uploading': 'Subiendo...',
'sftp.upload.compressing': 'Comprimiendo...',
'sftp.upload.extracting': 'Extrayendo...',
'sftp.upload.scanning': 'Escaneando archivos...',
'sftp.upload.completed': 'Completado',
'sftp.upload.compressed': 'Transferencia comprimida',
'sftp.upload.currentFile': 'Actual: {fileName}',
'sftp.upload.cancelled': 'Subida cancelada',
'sftp.upload.noFiles': 'No hay archivos para subir',
'sftp.upload.partialSuccess': 'Se subieron {success} archivo(s); {failed} fallaron',
'sftp.upload.cancel': 'Cancelar',
'sftp.upload.completedToPath': 'Subido a {path}',
// SFTP Download
'sftp.download.completed': 'Descargado',
'sftp.download.cancelled': 'Descarga cancelada',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Reconectando...',
'sftp.reconnecting.desc': 'Se perdió la conexión, intentando reconectar',
'sftp.reconnected': 'Conexión restablecida',
'sftp.error.reconnectFailed': 'No se pudo reconectar. Inténtalo de nuevo.',
'sftp.error.connectionLostManual': 'Se perdió la conexión. Vuelve a conectar manualmente.',
'sftp.error.connectionLostReconnecting': 'Se perdió la conexión. Reconectando...',
'sftp.error.sessionLost': 'Se perdió la sesión SFTP. Vuelve a conectar.',
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Mostrar archivos ocultos',
'settings.sftp.showHiddenFiles.desc': 'Muestra los archivos ocultos (dotfiles en Unix/macOS y archivos con el atributo oculto en Windows) en el explorador de archivos SFTP.',
'settings.sftp.showHiddenFiles.enable': 'Mostrar archivos ocultos',
'settings.sftp.showHiddenFiles.enableDesc': 'Muestra los archivos ocultos al explorar sistemas de archivos locales y remotos',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Transferencia de carpetas comprimidas',
'settings.sftp.compressedUpload.desc': 'Comprime las carpetas antes de subirlas para reducir significativamente el tiempo de transferencia.',
'settings.sftp.compressedUpload.enable': 'Habilitar la compresión de carpetas',
'settings.sftp.compressedUpload.enableDesc': 'Comprime automáticamente las carpetas con tar antes de la transferencia. Requiere soporte de tar en el servidor. Si no está disponible, se recurre a una transferencia normal.',
'settings.sftp.skipUnchanged.enable': 'Omitir archivos sin cambios',
'settings.sftp.skipUnchanged.enableDesc': 'Omite los archivos cuyo tamaño y hora de modificación ya coinciden con el destino (estilo rsync). Deshabilitado al reemplazar una carpeta en una ruta de staging.',
// Quick Switcher
'qs.search.placeholder': 'Buscar hosts o pestañas',
'qs.jumpTo': 'Ir a',
'qs.localTerminal': 'Terminal local',
'qs.localShells': 'Shells locales',
'qs.default': 'Predeterminado',
// Select Host panel
'selectHost.title': 'Seleccionar host',
'selectHost.noHostsFound': 'No se encontraron hosts',
'selectHost.newHost': 'Host nuevo',
'selectHost.continue': 'Continuar',
'selectHost.continueWithCount': 'Continuar ({count} seleccionados)',
'selectHost.toggleGroup': 'Alternar todos los hosts en {name}',
'selectHost.toggleHost': 'Alternar {name}',
// Quick Connect
'quickConnect.knownHost.title': '¿Estás seguro de que quieres conectarte?',
'quickConnect.knownHost.authenticity': 'No se puede establecer la autenticidad de {hostname}.',
'quickConnect.knownHost.fingerprintLabel': 'La huella digital de {keyType} es SHA256:',
'quickConnect.knownHost.addQuestion': '¿Quieres agregarlo a la lista de hosts conocidos?',
'quickConnect.knownHost.addAndContinue': 'Agregar y continuar',
'quickConnect.addKey': 'Agregar clave',
'quickConnect.warning.unparsedOptions': 'Se ignoraron algunos argumentos SSH: {options}',
'quickConnect.identity.label': 'Preajuste de credenciales',
'quickConnect.identity.placeholder': 'Elige una identidad guardada',
'quickConnect.identity.empty': 'No se encontraron identidades guardadas',
'quickConnect.identity.hint': 'Elige una identidad para completar automáticamente su nombre de usuario y autenticación.',
'quickConnect.identity.selectedHint': 'Listo para conectar como {username}.',
'quickConnect.identity.method.password': 'Contraseña',
'quickConnect.identity.method.key': 'Clave SSH',
'quickConnect.identity.method.certificate': 'Certificado',
'quickConnect.identity.unavailable': 'A esta identidad le faltan credenciales utilizables en este dispositivo.',
'quickConnect.identity.useCustom': 'Usar credenciales personalizadas',
'quickConnect.identity.connect': 'Conectar con preajuste',
'quickConnect.connectTitle': 'Conectar a {host}',
// Terminal
'terminal.connectionErrorTitle': 'Error de conexión',
// Protocol select dialog
'protocolSelect.chooseProtocol': 'Elegir protocolo',
'protocolSelect.port': 'puerto:',
// Host Details
'hostDetails.title.details': 'Detalles del host',
'hostDetails.title.new': 'Host nuevo',
'vault.panel.resizeWidth': 'Redimensionar el ancho del panel',
'hostDetails.saveAria': 'Guardar',
'hostDetails.section.address': 'Dirección',
'hostDetails.hostname.placeholder': 'IP o nombre de host',
'hostDetails.section.general': 'General',
'hostDetails.section.sftp': 'Configuración de SFTP',
'hostDetails.sftp.fileProtocol': 'Protocolo de archivos',
'hostDetails.sftp.fileProtocol.desc': 'Auto intenta SFTP y luego recurre al modo SCP cuando el subsistema SFTP no está disponible (p. ej., algunos NAS). Fuerza SCP para hosts que solo admiten SCP.',
'hostDetails.sftp.fileProtocol.auto': 'Auto',
'hostDetails.sftp.fileProtocol.sftp': 'Solo SFTP',
'hostDetails.sftp.fileProtocol.scp': 'Modo SCP',
'hostDetails.sftp.sudo': 'Modo Sudo',
'hostDetails.sftp.sudo.desc': 'Adquiere automáticamente privilegios de root usando la contraseña guardada',
'hostDetails.sftp.sudo.passwordWarning': 'El modo Sudo requiere una contraseña. Configura una arriba, o asegúrate de que el servidor permita sudo sin contraseña.',
'hostDetails.sftp.encoding': 'Codificación de nombres de archivo',
'hostDetails.sftp.encoding.desc': 'Selecciona la codificación utilizada para decodificar y enviar los nombres de archivo SFTP.',
'hostDetails.label.placeholder': 'Etiqueta (p. ej., servidor de producción)',
'hostDetails.notes.label': 'Notas',
'hostDetails.notes.placeholder': 'Hardware, proyecto, cliente, región, rol...',
'hostDetails.notes.help': 'Admite Markdown. No guardes contraseñas ni claves privadas aquí.',
'hostDetails.notes.tab.edit': 'Editar',
'hostDetails.notes.tab.preview': 'Vista previa',
'hostDetails.notes.preview.empty': 'Aún no hay nada para previsualizar.',
'hostDetails.notes.toggle.show': 'Mostrar el editor de notas',
'hostDetails.notes.toggle.hide': 'Ocultar el editor de notas',
'hostDetails.group.placeholder': 'Grupo padre',
'hostDetails.section.credentials': 'Credenciales',
'hostDetails.section.portCredentials': 'Puerto y credenciales',
'hostDetails.section.appearance': 'Apariencia',
'hostDetails.distro.title': 'Distribución de Linux',
'hostDetails.distro.desc': 'Detecta automáticamente al conectar, o anula el ícono de la distro manualmente.',
'hostDetails.icon.title': 'Ícono del host',
'hostDetails.icon.sectionTitle': 'Color e ícono',
'hostDetails.icon.desc': 'Elige la fuente y el color del ícono para este host.',
'hostDetails.icon.source': 'Fuente',
'hostDetails.icon.manualLabel': 'Ícono manual',
'hostDetails.icon.tab.brand': 'Marca',
'hostDetails.icon.tab.type': 'Tipo',
'hostDetails.icon.mode.auto': 'Automático',
'hostDetails.icon.mode.custom': 'Personalizado',
'hostDetails.icon.reset': 'Restablecer el ícono del host',
'hostDetails.icon.colorLabel': 'Color del ícono',
'hostDetails.icon.colorDefault': 'Predeterminado',
'hostDetails.icon.colorAuto': 'Automático',
'hostDetails.icon.colorManual': 'Manual',
'hostDetails.icon.colorChoice': 'Color',
'hostDetails.icon.autoColorLabel': 'Color actual',
'hostDetails.icon.preview': 'Vista previa del ícono',
'hostDetails.icon.showLibrary': 'Mostrar la biblioteca de íconos',
'hostDetails.icon.hideLibrary': 'Ocultar la biblioteca de íconos',
'hostDetails.icon.autoUsesDistro': 'Usa el ícono de la distribución de Linux y el color seleccionado para este host.',
'hostDetails.icon.customOverridesDistro': 'El ícono integrado reemplaza la distribución de Linux para este host.',
'hostDetails.icon.option.server': 'Servidor',
'hostDetails.icon.option.terminal': 'Terminal',
'hostDetails.icon.option.database': 'Base de datos',
'hostDetails.icon.option.cloud': 'Nube',
'hostDetails.icon.option.router': 'Router',
'hostDetails.icon.option.shield': 'Escudo',
'hostDetails.icon.option.code': 'Código',
'hostDetails.icon.option.box': 'Caja',
'hostDetails.icon.option.globe': 'Globo',
'hostDetails.icon.option.cpu': 'CPU',
'hostDetails.icon.option.hard-drive': 'Almacenamiento',
'hostDetails.icon.option.network': 'Red',
'hostDetails.icon.option.wifi': 'Inalámbrico',
'hostDetails.icon.option.lock': 'Bloqueo',
'hostDetails.icon.option.key': 'Clave',
'hostDetails.icon.option.monitor': 'Monitor',
'hostDetails.icon.option.container': 'Contenedor',
'hostDetails.icon.option.activity': 'Actividad',
'hostDetails.icon.option.zap': 'Rápido',
'hostDetails.icon.option.server-cog': 'Configuración del servidor',
'hostDetails.icon.color.blue': 'Azul',
'hostDetails.icon.color.green': 'Verde',
'hostDetails.icon.color.red': 'Rojo',
'hostDetails.icon.color.amber': 'Ámbar',
'hostDetails.icon.color.purple': 'Púrpura',
'hostDetails.icon.color.cyan': 'Cian',
'hostDetails.icon.color.orange': 'Naranja',
'hostDetails.icon.color.slate': 'Pizarra',
'hostDetails.icon.color.violet': 'Violeta',
'hostDetails.icon.color.pink': 'Rosa',
'hostDetails.icon.color.rose': 'Rosado',
'hostDetails.icon.color.lime': 'Lima',
'hostDetails.icon.color.teal': 'Verde azulado',
'hostDetails.icon.color.sky': 'Celeste',
'hostDetails.icon.color.indigo': 'Índigo',
'hostDetails.icon.color.zinc': 'Zinc',
'hostDetails.icon.color.custom': 'Color personalizado',
'hostDetails.distro.mode': 'Fuente',
'hostDetails.distro.mode.auto': 'Detección automática',
'hostDetails.distro.mode.manual': 'Anulación manual',
'hostDetails.distro.detectedLabel': 'Actual',
'hostDetails.distro.manualLabel': 'Anulación',
'hostDetails.distro.pending': 'Detectar después de la primera conexión',
'hostDetails.distro.unknown': 'Desconocido',
'hostDetails.distro.option.linux': 'Linux genérico',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.freebsd': 'FreeBSD',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',
'hostDetails.distro.option.juniper': 'Juniper Networks',
'hostDetails.distro.option.huawei': 'Huawei',
'hostDetails.distro.option.h3c': 'H3C',
'hostDetails.distro.option.hpe': 'HPE',
'hostDetails.distro.option.mikrotik': 'MikroTik',
'hostDetails.distro.option.fortinet': 'Fortinet',
'hostDetails.distro.option.paloalto': 'Palo Alto Networks',
'hostDetails.distro.option.zyxel': 'ZyXEL',
'hostDetails.distro.option.ruijie': 'Ruijie',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.section.et': 'EternalTerminal',
'hostDetails.et.port': 'Puerto del servidor ET',
'hostDetails.et.port.desc': 'Puerto en el que escucha etserver (predeterminado 2022)',
'hostDetails.username.placeholder': 'Nombre de usuario',
'hostDetails.password.placeholder': 'Contraseña',
'hostDetails.password.show': 'Mostrar contraseña',
'hostDetails.password.hide': 'Ocultar contraseña',
'hostDetails.password.save': 'Guardar contraseña',
'hostDetails.auth.method': 'Método de inicio de sesión',
'hostDetails.auth.auto': 'Automático',
'hostDetails.auth.auto.desc': 'Intenta con el agente SSH y las claves locales, y luego usa la contraseña guardada.',
'hostDetails.auth.passwordOnly': 'Solo contraseña',
'hostDetails.auth.password.desc': 'Usa solo la contraseña guardada y las solicitudes interactivas de contraseña.',
'hostDetails.auth.key': 'Clave',
'hostDetails.auth.key.desc': 'Usa la clave seleccionada, con la contraseña guardada como respaldo cuando esté presente.',
'hostDetails.auth.certificate': 'Certificado',
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
'hostDetails.identity.suggestions': 'Identidades',
'hostDetails.identity.missing': 'Identidad no encontrada',
'hostDetails.credential.keyCertificate': 'Clave, certificado, archivo de clave local',
'hostDetails.credential.key': 'Clave',
'hostDetails.credential.certificate': 'Certificado',
'hostDetails.credential.localKeyFile': 'Archivo de clave local',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Explorar...',
'hostDetails.credential.missing': 'Credencial no encontrada',
'hostDetails.section.systemSshAgent': 'Agente SSH del sistema',
'hostDetails.systemSshAgent': 'Usar el agente SSH del sistema para iniciar sesión',
'hostDetails.systemSshAgent.desc': 'Usa claves ya desbloqueadas por macOS, Linux, Windows, 1Password u otro agente SSH compatible',
'hostDetails.systemSshAgent.socket': 'Socket del agente',
'hostDetails.systemSshAgent.socket.desc': 'Déjalo vacío para usar SSH_AUTH_SOCK',
'hostDetails.systemSshAgent.identitiesOnly': 'Usar solo las claves configuradas',
'hostDetails.systemSshAgent.identitiesOnly.desc': 'No intentar con claves no relacionadas actualmente cargadas en el agente',
'hostDetails.systemSshAgent.notRunning': 'El agente SSH del sistema no está disponible',
'hostDetails.systemSshAgent.notRunningHint': 'Inicia o desbloquea tu agente SSH, o proporciona la ruta de su socket arriba.',
'hostDetails.keys.search': 'Buscar claves...',
'hostDetails.keys.empty': 'No hay claves disponibles',
'hostDetails.certs.search': 'Buscar certificados...',
'hostDetails.certs.empty': 'No hay certificados disponibles',
'hostDetails.agentForwarding': 'Reenviar el agente SSH',
'hostDetails.agentForwarding.desc': 'Permite que el servidor remoto use tus claves SSH locales (p. ej., para operaciones de git)',
'hostDetails.agentForwarding.agentNotRunning': 'El agente SSH no está disponible',
'hostDetails.agentForwarding.agentNotRunningHint': 'No se detectó ningún agente SSH. Habilita el agente de autenticación de OpenSSH en los servicios de Windows, o usa un agente compatible como Bitwarden, 1Password o gpg-agent.',
'hostDetails.section.agentForwarding': 'Agente SSH',
'hostDetails.x11Forwarding': 'Reenviar aplicaciones X11',
'hostDetails.x11Forwarding.desc': 'Muestra las aplicaciones gráficas remotas en tu escritorio local cuando se ejecuta un servidor X local.',
'hostDetails.section.x11Forwarding': 'Reenvío X11',
'hostDetails.section.deviceType': 'Tipo de dispositivo',
'hostDetails.deviceType': 'Modo de dispositivo de red',
'hostDetails.deviceType.desc': 'Habilítalo para equipos de red (switches, routers, firewalls) conectados mediante SSH. Los comandos se envían tal cual, sin envoltura de shell, compatible con las CLI de proveedores como Huawei VRP y Cisco IOS.',
'hostDetails.deviceType.warning': 'Los comandos del agente de IA se enviarán directamente sin seguimiento del código de salida. Habilítalo solo para dispositivos que no ejecutan un shell estándar.',
'hostDetails.section.sshAlgorithms': 'Algoritmos SSH',
'hostDetails.section.terminalBehavior': 'Comportamiento de la terminal',
'hostDetails.lineTimestamps': 'Mostrar marcas de tiempo de salida',
'hostDetails.lineTimestamps.desc': 'Muestra la hora local junto a las líneas de salida visibles de este host sin cambiar el texto de la terminal.',
'hostDetails.legacyAlgorithms': 'Permitir algoritmos heredados',
'hostDetails.legacyAlgorithms.desc': 'Habilita algoritmos SSH obsoletos (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) para conectar con equipos de red más antiguos.',
'hostDetails.legacyAlgorithms.warning': 'Estos algoritmos tienen debilidades de seguridad conocidas. Habilítalos solo para dispositivos heredados que no admiten criptografía moderna.',
'hostDetails.skipEcdsaHostKey': 'Omitir la clave de host ECDSA',
'hostDetails.skipEcdsaHostKey.desc': 'Algunos switches antiguos de Huawei / Cisco generan firmas de clave de host ECDSA no estándar que provocan "signature verification failed". Al activar esto, se eliminan todos los ecdsa-sha2-* de la oferta del cliente para que la negociación recurra a RSA / Ed25519.',
'hostDetails.algorithms.advanced': 'Anulaciones avanzadas de algoritmos',
'hostDetails.algorithms.advanced.desc': 'Reemplaza la lista de algoritmos ofrecidos para cualquier categoría por cada host. Dejar una categoría sin cambios usa la predeterminada; seleccionar un subconjunto reemplaza por completo la lista predeterminada. Los valores incorrectos pueden hacer que el host sea inalcanzable.',
'hostDetails.algorithms.inheritedNotice': 'El grupo actual tiene anulaciones de algoritmos configuradas para: {categories}. El botón "Restablecer" aquí recurre a las listas del grupo, no a las predeterminadas de Netcatty. Para ignorar la restricción del grupo, borra la anulación en la configuración de algoritmos del grupo.',
'hostDetails.algorithms.customized': 'personalizado',
'hostDetails.algorithms.reset': 'Restablecer',
'hostDetails.algorithms.category.kex': 'Intercambio de claves (KEX)',
'hostDetails.algorithms.category.cipher': 'Cifrado',
'hostDetails.algorithms.category.hmac': 'MAC (HMAC)',
'hostDetails.algorithms.category.serverHostKey': 'Clave de host',
'hostDetails.algorithms.category.compress': 'Compresión',
'hostDetails.section.keepalive': 'Keepalive',
'hostDetails.keepalive.override': 'Anular el keepalive global',
'hostDetails.keepalive.desc': 'Usa una política de keepalive personalizada para este host en lugar de la configuración global. Útil para routers o switches antiguos cuyo servidor SSH no responde a las solicitudes keepalive@openssh.com: establece el intervalo en 0 para deshabilitar el keepalive por completo en este host.',
'hostDetails.keepalive.interval': 'Intervalo (segundos)',
'hostDetails.keepalive.countMax': 'Máximo de keepalives sin respuesta',
'hostDetails.keepalive.disabledHint': 'Intervalo = 0 deshabilita el keepalive para este host. La sesión dependerá de los tiempos de espera a nivel de TCP para detectar una conexión caída.',
'hostDetails.section.sshTimeouts': 'Tiempos de espera de conexión SSH',
'hostDetails.sshTimeouts.tcpConnect': 'Tiempo de espera de TCP',
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts',
'hostDetails.jumpHosts.hops': '{count} salto(s)',
'hostDetails.jumpHosts.direct': 'Directo',
'hostDetails.jumpHosts.configure': 'Configurar hosts proxy',
'hostDetails.proxy': 'Proxy mediante HTTP/SOCKS5/Comando',
'hostDetails.proxy.none': 'Ninguno',
'hostDetails.proxy.edit': 'Editar proxy',
'hostDetails.proxy.configure': 'Configurar proxy',
'hostDetails.proxyPanel.title': 'Proxy mediante HTTP/SOCKS5/Comando',
'hostDetails.proxyPanel.hostPlaceholder': 'Host del proxy',
'hostDetails.proxyPanel.command': 'ProxyCommand',
'hostDetails.proxyPanel.commandPlaceholder': 'cloudflared access ssh --hostname %h',
'hostDetails.proxyPanel.commandHelp': 'Usa %h para el host de destino, %p para el puerto de destino y %% para un porcentaje literal.',
'hostDetails.proxyPanel.credentials': 'Credenciales',
'hostDetails.proxyPanel.usernamePlaceholder': 'Nombre de usuario',
'hostDetails.proxyPanel.passwordPlaceholder': 'Contraseña',
'hostDetails.proxyPanel.identities': 'Identidades',
'hostDetails.proxyPanel.keychainIdentity': 'Identidad del llavero',
'hostDetails.proxyPanel.manualCredentials': 'Credenciales manuales',
'hostDetails.proxyPanel.remove': 'Quitar proxy',
'hostDetails.proxyPanel.savedProxy': 'Proxy guardado',
'hostDetails.proxyPanel.selectSaved': 'Seleccionar proxy guardado',
'hostDetails.proxyPanel.customProxy': 'Proxy personalizado',
'hostDetails.proxyPanel.missing': 'Faltante',
'hostDetails.proxyPanel.missingSaved': 'Falta el proxy guardado',
'hostDetails.proxyPanel.missingIdentity': 'Falta la identidad del llavero',
'hostDetails.proxyPanel.incompleteIdentity': 'La identidad del proxy necesita un nombre de usuario y una contraseña',
'hostDetails.proxyPanel.unreadableIdentity': 'No se puede leer la contraseña de la identidad del proxy en este dispositivo',
'hostDetails.proxyPanel.error.required': 'Se requieren el host y el puerto del proxy, o un ProxyCommand.',
'hostDetails.envVars': 'Variables de entorno',
'hostDetails.envVars.add': 'Agregar variable de entorno',
'hostDetails.envVars.title': 'Variables de entorno',
'hostDetails.envVars.desc': 'Establece una variable de entorno para {host}.',
'hostDetails.envVars.note':
'Algunos servidores SSH por defecto solo permiten variables con el prefijo LC_ y LANG_.',
'hostDetails.envVars.variable': 'Variable',
'hostDetails.envVars.value': 'Valor',
'hostDetails.envVars.newVariable': 'Variable nueva',
'hostDetails.envVars.variableName': 'Nombre de la variable',
'hostDetails.chain.title': 'Editar cadena',
'hostDetails.chain.desc': 'Agregar otro host creará una conexión a {host}.',
'hostDetails.chain.addHost': 'Agregar un host',
'hostDetails.chain.target': 'Destino',
'hostDetails.chain.availableHosts': 'Hosts disponibles',
'hostDetails.chain.clear': 'Limpiar',
'hostDetails.group.title': 'Grupo nuevo',
'hostDetails.group.general': 'General',
'hostDetails.group.namePlaceholder': 'Nombre del grupo',
'hostDetails.group.parentPlaceholder': 'Grupo padre',
'hostDetails.group.cloudSync': 'Sincronización en la nube',
'hostDetails.group.addProtocol': 'Agregar protocolo',
'hostDetails.startupCommand': 'Comando de inicio',
'hostDetails.startupCommand.placeholder': 'Comando para ejecutar al conectar (p. ej., cd /app && ls)',
'hostDetails.startupCommand.help':
'Este comando se ejecutará automáticamente después de que se establezca la conexión SSH.',
'hostDetails.startupCommand.runMode': 'Ejecución multilínea',
'hostDetails.startupCommand.runMode.help': 'Elige cómo se envían los comandos de inicio multilínea después de la conexión.',
'hostDetails.startupCommand.runMode.lineDelay': 'Enviar línea por línea',
'hostDetails.startupCommand.runMode.paste': 'Enviar todo de una vez',
'hostDetails.otherProtocols': 'Otros protocolos',
'hostDetails.telnetOn': 'Telnet activado',
'hostDetails.port': 'puerto',
'hostDetails.telnet.credentials': 'Credenciales',
'hostDetails.telnet.identity.placeholder': 'Identidad manual',
'hostDetails.telnet.username': 'Nombre de usuario de Telnet',
'hostDetails.telnet.password': 'Contraseña de Telnet',
'hostDetails.charset.placeholder': 'Codificación (p. ej., UTF-8)',
'hostDetails.telnet.add': 'Agregar protocolo Telnet',
'hostDetails.telnet.setDefault': 'Conectar con Telnet por defecto',
'hostDetails.tags': 'Etiquetas',
'hostDetails.group': 'Grupo',
'hostDetails.selectGroup': 'Seleccionar grupo',
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
'hostDetails.plugin.title': 'Conexión de plugin',
'hostDetails.plugin.provider.placeholder': 'Selecciona un proveedor de conexión',
'hostDetails.plugin.provider.empty': 'No hay proveedores de conexión instalados',
'hostDetails.plugin.authentication.placeholder': 'Selecciona un proveedor de autenticación',
'hostDetails.plugin.authentication.none': 'Administrada por el proveedor o sin autenticación',
'hostDetails.plugin.credential.placeholder': 'Selecciona una credencial del host',
'hostDetails.plugin.credential.none': 'No hay credencial guardada',
'hostDetails.plugin.credential.password': 'Identidad de contraseña',
'hostDetails.plugin.credential.privateKey': 'Clave privada',
'hostDetails.plugin.credential.unavailable': 'Credencial guardada no disponible',
'hostDetails.plugin.configuration.label': 'Configuración del proveedor (JSON)',
'hostDetails.plugin.configuration.invalid': 'La configuración debe ser un JSON válido.',
'hostDetails.plugin.configuration.schemaInvalid': 'La configuración no coincide con el esquema del proveedor.',
'hostDetails.plugin.unavailable': 'El proveedor configurado no está disponible. La configuración se conserva hasta que se restaure el plugin.',
'hostDetails.plugin.useSsh': 'Usar SSH en su lugar',
// Host form (legacy modal)
'hostForm.title.edit': 'Editar host',
'hostForm.title.new': 'Host nuevo',
'hostForm.desc.edit': 'Actualiza los detalles de conexión de este host',
'hostForm.desc.new': 'Crea una nueva entrada de host SSH',
'hostForm.field.label': 'Etiqueta',
'hostForm.placeholder.label': 'Mi servidor de producción',
'hostForm.field.hostname': 'Nombre de host / IP',
'hostForm.placeholder.hostname': '192.168.1.1',
'hostForm.field.port': 'Puerto',
'hostForm.field.username': 'Nombre de usuario',
'hostForm.field.osType': 'Tipo de SO',
'hostForm.placeholder.selectOs': 'Seleccionar SO',
'hostForm.field.group': 'Grupo',
'hostForm.placeholder.group': 'p. ej., AWS, DigitalOcean',
'hostForm.field.tags': 'Etiquetas',
'hostForm.placeholder.addTag': 'Agregar una etiqueta...',
'hostForm.auth.method': 'Método de autenticación',
'hostForm.auth.password': 'Contraseña',
'hostForm.auth.sshKey': 'Clave SSH',
'hostForm.auth.selectKey': 'Seleccionar una clave SSH',
'hostForm.auth.noKeys': 'No hay claves disponibles',
'hostForm.auth.noKeysHint': 'No se encontraron claves SSH en el llavero. Crea una primero.',
'hostForm.saveHost': 'Guardar host',
// Connection logs
'logs.table.date': 'Fecha',
'logs.table.user': 'Usuario',
'logs.table.host': 'Host',
'logs.table.saved': 'Guardado',
'logs.empty.title': 'No hay registros de conexión',
'logs.empty.desc':
'Tu historial de conexiones aparecerá aquí cuando te conectes a hosts o abras terminales locales.',
'logs.loadMore': 'Cargar {count} registros más',
'logs.ongoing': 'en curso',
'logs.localTerminal': 'Terminal local',
'logs.action.save': 'Guardar',
'logs.action.unsave': 'Quitar de guardados',
'logs.action.delete': 'Eliminar',
// Log view
'logView.customizeAppearance': 'Personalizar la apariencia',
'logView.appearance': 'Apariencia',
'logView.readOnly': 'Solo lectura',
'logView.export': 'Exportar',
'vault.import.plugins.title': 'Importadores de plugins',
'vault.import.plugins.loading': 'Generando una vista previa de importación segura…',
'vault.import.plugins.progress': '{completed} de {total} procesados {message}',
'vault.import.plugins.preview': 'Vista previa de importación',
'vault.import.plugins.empty': 'No se devolvieron borradores importables.',
'vault.import.plugins.summary': '{parsed} borradores · {warnings} advertencias · {errors} errores',
'vault.import.plugins.commit': 'Vista previa de importación',
'vault.import.plugins.fileUnavailable': 'El archivo seleccionado no está disponible para el bridge del importador de escritorio.',
'vault.import.plugins.notRecognized': 'Este importador no reconoció el archivo seleccionado.',
'vault.import.plugins.containsErrors': 'La vista previa de importación contiene errores y no se puede confirmar.',
'vault.import.plugins.committed': 'Se importaron {count} elementos validados.',
'vault.import.plugins.duplicates': '{count} elementos duplicados se omitirán.',
'vault.import.plugins.validationErrors': '{count} borradores fallaron la validación de host y deben corregirse mediante el importador.',
'vault.import.plugins.kind.host': 'Host',
'vault.import.plugins.kind.identity': 'Identidad',
'vault.import.plugins.kind.key': 'Clave',
'vault.import.plugins.kind.snippet': 'Fragmento',
'vault.import.plugins.kind.group': 'Grupo',
'vault.import.plugins.moreItems': '{count} elementos más están incluidos en esta importación.',
'vault.import.plugins.moreDiagnostics': '{count} diagnósticos más se omitieron de esta vista previa.',
};
+1
View File
@@ -34,6 +34,7 @@ export const ruAiMessages: Messages = {
'ai.providers.apiKey.placeholder': 'Введите API-ключ',
'ai.providers.apiKey.decrypting': 'Расшифровка...',
'ai.providers.baseUrl': 'Базовый URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic-совместимый: хост с /v1 или без (например https://gateway.example или https://gateway.example/v1). Проверка и чат используют /v1/models и /v1/messages.',
'ai.providers.skipTLSVerify': 'Пропустить проверку TLS-сертификата (для самоподписанных сертификатов)',
'ai.providers.defaultModel': 'Модель по умолчанию',
'ai.providers.defaultModel.placeholder': 'например, gpt-4o, claude-sonnet-4-20250514',
+85
View File
@@ -82,6 +82,32 @@ export const ruCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Сохранённые пароли и ключи не могут быть автоматически расшифрованы на этом устройстве. Перед подключением введите учётные данные заново.',
'credentials.protectionUnavailable.action': 'Открыть настройки',
// App Lock
'appLock.title': 'Netcatty заблокирован',
'appLock.reason.default': 'Введите пароль блокировки, чтобы продолжить.',
'appLock.reason.startup': 'Netcatty блокируется при каждом открытии этого окна.',
'appLock.reason.idle': 'Netcatty заблокирован после периода бездействия.',
'appLock.reason.manual': 'Netcatty был заблокирован вручную.',
'appLock.passwordLabel': 'Пароль блокировки',
'appLock.passwordPlaceholder': 'Введите пароль для разблокировки',
'appLock.unlock': 'Разблокировать',
'appLock.unlocking': 'Разблокировка...',
'appLock.usePassword': 'Использовать пароль',
'appLock.error.emptyPassword': 'Введите пароль блокировки.',
'appLock.error.incorrectPassword': 'Неверный пароль блокировки.',
'appLock.systemUnlock.unlockWith': 'Разблокировать через {label}',
'appLock.systemUnlock.preparing': 'Подготовка {label}...',
'appLock.systemUnlock.verifying': 'Ожидание {label}...',
'appLock.systemUnlock.error': 'Системная разблокировка не завершена. Используйте пароль блокировки, чтобы продолжить.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Сбросить блокировку',
'appLock.reset.description': 'Это удалит текущий пароль блокировки и отключит App Lock. Новый пароль можно создать позже в настройках.',
'appLock.reset.cancel': 'Отмена',
'appLock.reset.confirm': 'Сбросить App Lock',
'appLock.reset.resetting': 'Сброс...',
'appLock.reset.error': 'Не удалось сбросить App Lock. Попробуйте ещё раз.',
'topTabs.lockApp': 'Заблокировать Netcatty',
// Settings shell
'settings.title': 'Настройки',
'settings.tab.application': 'Приложение',
@@ -117,6 +143,59 @@ export const ruCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Учётные данные, зашифрованные в другом профиле пользователя или на другой машине, здесь расшифровать нельзя. Повторно введите и сохраните их на этом устройстве.',
'settings.system.credentials.portabilityHint': 'Облачная синхронизация переносима, потому что использует шифрование вашим мастер-ключом. Локальное шифрование safeStorage привязано к устройству и пользователю.',
// Settings > System > App Lock
'settings.appLock.title': 'Блокировка приложения',
'settings.appLock.description': 'Требовать локальный пароль блокировки при открытии Netcatty, после бездействия или при ручной блокировке.',
'settings.appLock.setupTitle': 'Задайте пароль блокировки',
'settings.appLock.setupDescription': 'Создайте пароль ниже. Блокировка приложения включится сразу после сохранения.',
'settings.appLock.manageTitle': 'Блокировка приложения готова',
'settings.appLock.enabledStatus': 'Включена. Netcatty блокируется при запуске, после бездействия и при ручной блокировке.',
'settings.appLock.disabledStatus': 'Пароль сохранён, но блокировка приложения сейчас выключена.',
'settings.appLock.disableTitle': 'Отключить блокировку приложения',
'settings.appLock.disableDescription': 'Введите текущий пароль блокировки, чтобы Netcatty перестал блокироваться. Сохранённый пароль будет удалён; для повторного включения нужно создать новый.',
'settings.appLock.disable': 'Отключить блокировку',
'settings.appLock.disabling': 'Отключение...',
'settings.appLock.setupPasswordTitle': 'Создайте пароль блокировки',
'settings.appLock.setupPasswordDescription': 'Этот пароль нужен для разблокировки Netcatty, а также для изменения или отключения блокировки позже.',
'settings.appLock.changePasswordTitle': 'Изменить пароль блокировки',
'settings.appLock.changePasswordDescription': 'Введите текущий пароль блокировки, затем выберите новый.',
'settings.appLock.enable': 'Включить блокировку приложения',
'settings.appLock.enableDesc': 'Блокировать при запуске и после выбранного периода бездействия.',
'settings.appLock.timeout': 'Тайм-аут бездействия',
'settings.appLock.timeoutDesc': 'Блокировать Netcatty, если нет активности клавиатуры, указателя, колеса, касания или фокуса.',
'settings.appLock.timeout.0': 'Не блокировать автоматически',
'settings.appLock.timeout.1': '1 минута',
'settings.appLock.timeout.5': '5 минут',
'settings.appLock.timeout.15': '15 минут',
'settings.appLock.timeout.30': '30 минут',
'settings.appLock.timeout.60': '60 минут',
'settings.appLock.systemUnlock.label': 'Разблокировка через {label}',
'settings.appLock.systemUnlock.desc': 'Используйте {label} как альтернативный способ разблокировать Netcatty на этом устройстве.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} сейчас недоступен на этом устройстве.',
'settings.appLock.systemUnlock.unavailable': 'Системная разблокировка недоступна на этом устройстве.',
'settings.appLock.systemUnlock.locked': 'Разблокируйте Netcatty перед изменением системной разблокировки.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Открывать {label} автоматически',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Автоматически показывать запрос {label}, когда Netcatty ожидает разблокировки.',
'settings.appLock.currentPassword': 'Текущий пароль',
'settings.appLock.currentPasswordPlaceholder': 'Нужен для отключения или замены',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Текущий пароль для отключения блокировки',
'settings.appLock.currentPasswordForChangePlaceholder': 'Текущий пароль для изменения',
'settings.appLock.newPassword': 'Новый пароль',
'settings.appLock.newPasswordPlaceholder': 'Задайте пароль блокировки',
'settings.appLock.confirmPassword': 'Подтвердите пароль',
'settings.appLock.confirmPasswordPlaceholder': 'Повторите пароль',
'settings.appLock.savePassword': 'Задать и включить',
'settings.appLock.savingPassword': 'Сохранение...',
'settings.appLock.passwordSet': 'Пароль настроен',
'settings.appLock.replacePassword': 'Заменить пароль',
'settings.appLock.enableAfterPassword': 'Задайте пароль блокировки перед включением.',
'settings.appLock.localOnlyHint': 'Локальная приватная блокировка. Она не синхронизируется и не является системной границей безопасности.',
'settings.appLock.validation.currentRequired': 'Введите текущий пароль блокировки.',
'settings.appLock.validation.newRequired': 'Введите новый пароль блокировки.',
'settings.appLock.validation.confirmRequired': 'Подтвердите новый пароль блокировки.',
'settings.appLock.validation.mismatch': 'Новые пароли не совпадают.',
'settings.appLock.validation.incorrect': 'Текущий пароль блокировки неверен.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Сетевой прокси',
'settings.system.networkProxy.description': 'HTTP(S)-прокси для облачной синхронизации и AI-провайдеров. Не связан с SSH ProxyJump / ProxyCommand.',
@@ -567,6 +646,12 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': 'Отключено',
'settings.terminal.behavior.dynamicTabTitle.agent': 'Только агенты',
'settings.terminal.behavior.dynamicTabTitle.all': 'Все сеансы',
'settings.terminal.behavior.oscNotifications': 'Системные уведомления OSC',
'settings.terminal.behavior.oscNotifications.desc':
'Показывать системное уведомление, когда программа отправляет OSC 9, OSC 777 notify или kitty OSC 99. Так Codex и другие агенты сообщают о завершении хода и запросе подтверждения.',
'settings.terminal.behavior.oscNotifications.off': 'Отключено',
'settings.terminal.behavior.oscNotifications.unfocused': 'Когда окно не в фокусе',
'settings.terminal.behavior.oscNotifications.always': 'Всегда',
'settings.terminal.behavior.osc52Clipboard': 'Буфер обмена OSC-52',
'settings.terminal.behavior.osc52Clipboard.desc':
'Разрешить удалённым программам (tmux, vim и т. д.) доступ к локальному буферу обмена через escape-последовательности OSC-52.',
+4 -1
View File
@@ -18,8 +18,11 @@ export const ruScriptsMessages = {
'scripts.editor.modalSubtitle': 'Редактируйте метаданные и код скрипта в большом рабочем пространстве.',
'scripts.editor.lineCount': '{count} строк',
'scripts.editor.resize': 'Изменить размер редактора',
'scripts.targets.hint': 'Автоматические триггеры применяются к выбранным хостам или ко всем хостам, если включена эта настройка.',
'scripts.targets.hint': 'Выбранные группы разрешаются динамически, поэтому новые хосты включаются автоматически.',
'scripts.targets.connectOrderHint': 'Порядок запуска скриптов подключения настраивается для каждого хоста в разделе «Сведения о хосте → Автоматизация».',
'scripts.targets.currentHostMismatch': 'Этот скрипт не назначен текущему хосту.',
'hostDetails.automation.groupScripts': 'Унаследованные скрипты группы',
'hostDetails.automation.groupScriptsHint': 'Эти скрипты динамически следуют за группой хоста и выполняются перед очередью хоста.',
'scripts.actions.runNowHint': 'Запускается для выбранных целей либо для всех доступных для подключения хостов, если включена эта настройка.',
'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)',
'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)',
+4 -1
View File
@@ -681,6 +681,9 @@ export const ruTerminalMessages: Messages = {
'snippets.field.variableDefault': 'по умолчанию {value}',
'snippets.targets.title': 'Цели',
'snippets.targets.add': 'Добавить цели',
'snippets.targets.selectHosts': 'Хосты',
'snippets.targets.selectGroups': 'Группы',
'snippets.targets.noGroups': 'Группы не найдены',
'snippets.history.title': 'История оболочки',
'snippets.history.subtitle': '{count} команд',
'snippets.history.emptyTitle': 'История оболочки пока пуста',
@@ -762,7 +765,7 @@ export const ruTerminalMessages: Messages = {
'snippets.shortkey.recording': 'Нажмите сочетание клавиш...',
'snippets.shortkey.hint': 'Нажмите это сочетание в терминале, чтобы быстро отправить команду.',
'snippets.shortkey.clear': 'Очистить сочетание',
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с системным сочетанием',
'snippets.shortkey.error.systemConflict': 'Это сочетание конфликтует с «{name}»',
'snippets.shortkey.error.snippetConflict': 'Это сочетание уже используется сниппетом: {name}',
// Serial Port
+5 -1
View File
@@ -266,6 +266,10 @@ export const ruVaultMessages: Messages = {
'sftp.moveTo.confirm': 'Переместить',
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
'sftp.context.download': 'Скачать',
'sftp.context.extract': 'Распаковать здесь',
'sftp.extract.extracting': 'Распаковка {fileName}...',
'sftp.extract.success': 'Распакован {fileName}',
'sftp.extract.error': 'Не удалось распаковать {fileName}: {error}',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.copyToOtherPane.unavailable': 'Откройте двухпанельный режим SFTP и подключите панель назначения перед копированием файлов.',
'sftp.copyCurrentPath': 'Копировать текущий путь',
@@ -525,7 +529,7 @@ export const ruVaultMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': 'Передать файл на активный хост другой панели',
'settings.sshDeepLink.title': 'SSH- и Telnet-ссылки',
'settings.sshDeepLink.enable': 'Открывать ssh:// и telnet:// ссылки в Netcatty',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений.',
'settings.sshDeepLink.enableDesc': 'Разрешить Netcatty обрабатывать ssh:// и telnet:// ссылки из браузеров и других приложений, а также запуски в стиле PuTTY, например -ssh user@host -P 22 -pw password.',
'settings.explorerContextMenu.title': 'Проводник Windows',
'settings.explorerContextMenu.enable': 'Показывать «Open in Netcatty» в контекстном меню папок',
'settings.explorerContextMenu.enableDesc': 'Добавляет пункт в меню папок и фона папки, чтобы открыть локальный терминал в этом каталоге. Применяется сразу, переустановка не нужна.',
@@ -5,6 +5,8 @@ import { enSystemManagerMessages } from "./en/systemManager";
import { enTerminalMessages } from "./en/terminal";
import { ruSystemManagerMessages } from "./ru/systemManager";
import { ruTerminalMessages } from "./ru/terminal";
import { esSystemManagerMessages } from "./es/systemManager";
import { esTerminalMessages } from "./es/terminal";
import { zhCnSystemManagerMessages } from "./zh-CN/systemManager";
import { zhCNVaultMessages } from "./zh-CN/vault";
import { zhTwSystemManagerMessages } from "./zh-TW/systemManager";
@@ -14,6 +16,7 @@ test("SSH network latency is explicit in every locale and UI surface", () => {
const labels = [
[enTerminalMessages, enSystemManagerMessages, "SSH network latency"],
[ruTerminalMessages, ruSystemManagerMessages, "Сетевая задержка SSH"],
[esTerminalMessages, esSystemManagerMessages, "Latencia de red SSH"],
[zhCNVaultMessages, zhCnSystemManagerMessages, "SSH 网络延迟"],
[zhTWVaultMessages, zhTwSystemManagerMessages, "SSH 網路延遲"],
] as const;
@@ -2,13 +2,22 @@ import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import en from "./en.ts";
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.ts";
import zhCN from "./zh-CN.ts";
import ru from "./ru.ts";
import es from "./es.ts";
const LOCALIZED_SETTINGS_LOCALES = [
{ name: "zh-CN", messages: zhCN },
{ name: "ru", messages: ru },
{ name: "es", messages: es },
];
const APP_LOCK_LOCALES = [
{ name: "en", messages: en },
{ name: "zh-CN", messages: zhCN },
{ name: "ru", messages: ru },
];
test("localized settings include names for every default shortcut", () => {
@@ -59,6 +68,21 @@ test("localized settings include network proxy labels", () => {
}
});
test("localized settings include OSC desktop notification labels", () => {
const keys = [
"settings.terminal.behavior.oscNotifications",
"settings.terminal.behavior.oscNotifications.desc",
"settings.terminal.behavior.oscNotifications.off",
"settings.terminal.behavior.oscNotifications.unfocused",
"settings.terminal.behavior.oscNotifications.always",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing OSC notification labels`);
}
});
test("localized settings include terminal font weight option labels", () => {
const keys = [
"settings.terminal.font.weight.thin",
@@ -78,6 +102,73 @@ test("localized settings include terminal font weight option labels", () => {
}
});
test("all app lock strings are translated in every supported locale", () => {
const keys = [
"appLock.title",
"appLock.reason.default",
"appLock.reason.startup",
"appLock.reason.idle",
"appLock.reason.manual",
"appLock.passwordLabel",
"appLock.passwordPlaceholder",
"appLock.unlock",
"appLock.unlocking",
"appLock.error.emptyPassword",
"appLock.error.incorrectPassword",
"appLock.systemUnlock.unlockWith",
"appLock.systemUnlock.error",
"appLock.logoLabel",
"appLock.reset.title",
"appLock.reset.description",
"appLock.reset.cancel",
"appLock.reset.confirm",
"appLock.reset.resetting",
"appLock.reset.error",
"topTabs.lockApp",
"settings.appLock.title",
"settings.appLock.description",
"settings.appLock.enable",
"settings.appLock.enableDesc",
"settings.appLock.timeout",
"settings.appLock.timeoutDesc",
"settings.appLock.timeout.0",
"settings.appLock.timeout.1",
"settings.appLock.timeout.5",
"settings.appLock.timeout.15",
"settings.appLock.timeout.30",
"settings.appLock.timeout.60",
"settings.appLock.systemUnlock.label",
"settings.appLock.systemUnlock.desc",
"settings.appLock.systemUnlock.unavailableDesc",
"settings.appLock.systemUnlock.unavailable",
"settings.appLock.systemUnlock.locked",
"settings.appLock.systemUnlock.autoPrompt.label",
"settings.appLock.systemUnlock.autoPrompt.desc",
"settings.appLock.currentPassword",
"settings.appLock.currentPasswordPlaceholder",
"settings.appLock.newPassword",
"settings.appLock.newPasswordPlaceholder",
"settings.appLock.confirmPassword",
"settings.appLock.confirmPasswordPlaceholder",
"settings.appLock.savePassword",
"settings.appLock.savingPassword",
"settings.appLock.passwordSet",
"settings.appLock.replacePassword",
"settings.appLock.enableAfterPassword",
"settings.appLock.localOnlyHint",
"settings.appLock.validation.currentRequired",
"settings.appLock.validation.newRequired",
"settings.appLock.validation.confirmRequired",
"settings.appLock.validation.mismatch",
"settings.appLock.validation.incorrect",
];
for (const locale of APP_LOCK_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing app lock labels`);
}
});
test("localized vault messages include host icon labels", () => {
const keys = [
"hostDetails.icon.title",
@@ -3,6 +3,7 @@ import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
@@ -14,12 +15,23 @@ const KEYS = [
] as const;
test('snippet bulk-delete copy exists in every locale', () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${locale} is missing snippet bulk-delete copy`);
}
});
test('snippet shortkey system-conflict copy names the conflicting action', () => {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const text = messages['snippets.shortkey.error.systemConflict'];
assert.match(
text ?? '',
/\{name\}/,
`${locale} system-conflict copy should include {name}`,
);
}
});
test('English bulk-delete copy is entity-neutral and grammatical for one item', () => {
assert.equal(
en['snippets.selection.deleteConfirmTitle'].replace('{count}', '1'),
@@ -3,6 +3,7 @@ import test from 'node:test';
import en from './en';
import ru from './ru';
import es from './es';
import zhCN from './zh-CN';
import zhTW from './zh-TW';
@@ -14,7 +15,7 @@ const keys = [
] as const;
test('terminal host management strings exist in every shipped locale', () => {
for (const [locale, messages] of Object.entries({ en, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
for (const [locale, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
for (const key of keys) {
assert.equal(typeof messages[key], 'string', `${locale} is missing ${key}`);
assert.notEqual(messages[key], '', `${locale} has an empty ${key}`);
@@ -3,6 +3,7 @@ import test from "node:test";
import en from "./en.ts";
import ru from "./ru.ts";
import es from "./es.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
@@ -29,6 +30,7 @@ const INLINE_IMAGE_KEYS = [
const LOCALES = [
{ name: "en", messages: en },
{ name: "es", messages: es },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
{ name: "ru", messages: ru },
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import en from "./en.ts";
import ru from "./ru.ts";
import es from "./es.ts";
import zhCN from "./zh-CN.ts";
const osc7Keys = [
@@ -19,7 +20,7 @@ const osc7Keys = [
] as const;
test("OSC 7 setup copy exists in every bundled locale", () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN })) {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN })) {
for (const key of osc7Keys) {
assert.equal(typeof messages[key], "string", `${locale} is missing ${key}`);
assert.notEqual(messages[key], "", `${locale} has empty ${key}`);
@@ -3,6 +3,7 @@ import test from "node:test";
import en from "./en.ts";
import ru from "./ru.ts";
import es from "./es.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
@@ -26,7 +27,7 @@ const KEYS = [
] as const;
test("bulk vault import and group-selection copy exists in every locale", () => {
for (const [locale, messages] of Object.entries({ en, ru, zhCN, zhTW })) {
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${locale} is missing bulk vault labels`);
}
+1
View File
@@ -53,6 +53,7 @@ export const zhCNAiMessages: Messages = {
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 兼容:可填不带或带 /v1 的主机(例如 https://gateway.example 或 https://gateway.example/v1)。检测与聊天都会请求 /v1/models、/v1/messages。',
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
+87
View File
@@ -66,6 +66,32 @@ export const zhCNCoreMessages: Messages = {
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
'credentials.protectionUnavailable.action': '打开设置',
// App Lock
'appLock.title': 'Netcatty 已锁定',
'appLock.reason.default': '输入锁定密码以继续。',
'appLock.reason.startup': '每次打开此窗口时,Netcatty 都会锁定。',
'appLock.reason.idle': 'Netcatty 已因长时间无操作而锁定。',
'appLock.reason.manual': 'Netcatty 已被手动锁定。',
'appLock.passwordLabel': '锁定密码',
'appLock.passwordPlaceholder': '输入密码解锁',
'appLock.unlock': '解锁',
'appLock.unlocking': '正在解锁...',
'appLock.usePassword': '使用密码解锁',
'appLock.error.emptyPassword': '请输入锁定密码。',
'appLock.error.incorrectPassword': '锁定密码不正确。',
'appLock.systemUnlock.unlockWith': '使用 {label} 解锁',
'appLock.systemUnlock.preparing': '正在准备 {label}...',
'appLock.systemUnlock.verifying': '正在等待 {label}...',
'appLock.systemUnlock.error': '系统解锁未完成。请使用锁定密码继续。',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': '重置应用锁定',
'appLock.reset.description': '这会删除当前锁定密码并关闭应用锁定。需要再次使用时,请到设置里创建新密码。',
'appLock.reset.cancel': '取消',
'appLock.reset.confirm': '重置应用锁定',
'appLock.reset.resetting': '正在重置...',
'appLock.reset.error': '无法重置应用锁定,请重试。',
'topTabs.lockApp': '锁定 Netcatty',
// Settings shell
'settings.title': '设置',
'settings.tab.application': '应用',
@@ -115,6 +141,59 @@ export const zhCNCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > App Lock
'settings.appLock.title': '应用锁定',
'settings.appLock.description': '在 Netcatty 打开、长时间无操作或手动锁定后,要求输入本地锁定密码。',
'settings.appLock.setupTitle': '设置锁定密码',
'settings.appLock.setupDescription': '在下方创建密码,保存后会立即启用应用锁定。',
'settings.appLock.manageTitle': '应用锁定已准备就绪',
'settings.appLock.enabledStatus': '已启用。Netcatty 会在启动、长时间无操作或手动锁定时要求输入密码。',
'settings.appLock.disabledStatus': '密码已保存,但应用锁定当前关闭。',
'settings.appLock.disableTitle': '关闭应用锁定',
'settings.appLock.disableDescription': '输入当前锁定密码后停止锁定 Netcatty。已保存的密码会被删除;以后重新启用时需要创建新密码。',
'settings.appLock.disable': '关闭应用锁定',
'settings.appLock.disabling': '正在关闭...',
'settings.appLock.setupPasswordTitle': '创建锁定密码',
'settings.appLock.setupPasswordDescription': '此密码用于解锁 Netcatty,也用于以后修改或关闭应用锁定。',
'settings.appLock.changePasswordTitle': '修改锁定密码',
'settings.appLock.changePasswordDescription': '先输入当前锁定密码,再设置新密码。',
'settings.appLock.enable': '启用应用锁定',
'settings.appLock.enableDesc': '启动时锁定,并在达到所选无操作时间后锁定。',
'settings.appLock.timeout': '无操作超时',
'settings.appLock.timeoutDesc': '没有键盘、指针、滚轮、触摸或窗口聚焦活动时锁定 Netcatty。',
'settings.appLock.timeout.0': '不自动锁定',
'settings.appLock.timeout.1': '1 分钟',
'settings.appLock.timeout.5': '5 分钟',
'settings.appLock.timeout.15': '15 分钟',
'settings.appLock.timeout.30': '30 分钟',
'settings.appLock.timeout.60': '60 分钟',
'settings.appLock.systemUnlock.label': '{label} 解锁',
'settings.appLock.systemUnlock.desc': '在此设备上使用 {label} 作为解锁 Netcatty 的另一种方式。',
'settings.appLock.systemUnlock.unavailableDesc': '此设备当前无法使用 {label}。',
'settings.appLock.systemUnlock.unavailable': '此设备当前无法使用系统解锁。',
'settings.appLock.systemUnlock.locked': '请先解锁 Netcatty,再更改系统解锁。',
'settings.appLock.systemUnlock.autoPrompt.label': '自动弹出 {label}',
'settings.appLock.systemUnlock.autoPrompt.desc': '当 Netcatty 等待解锁时,自动显示 {label} 验证提示。',
'settings.appLock.currentPassword': '当前密码',
'settings.appLock.currentPasswordPlaceholder': '禁用或替换时需要',
'settings.appLock.currentPasswordForDisablePlaceholder': '用于关闭应用锁定的当前密码',
'settings.appLock.currentPasswordForChangePlaceholder': '用于修改密码的当前密码',
'settings.appLock.newPassword': '新密码',
'settings.appLock.newPasswordPlaceholder': '设置锁定密码',
'settings.appLock.confirmPassword': '确认密码',
'settings.appLock.confirmPasswordPlaceholder': '再次输入密码',
'settings.appLock.savePassword': '设置并启用',
'settings.appLock.savingPassword': '正在保存...',
'settings.appLock.passwordSet': '密码已配置',
'settings.appLock.replacePassword': '替换密码',
'settings.appLock.enableAfterPassword': '请先设置锁定密码,再启用应用锁定。',
'settings.appLock.localOnlyHint': '仅限本设备的隐私锁。不会同步,也不是系统级安全边界。',
'settings.appLock.validation.currentRequired': '请输入当前锁定密码。',
'settings.appLock.validation.newRequired': '请输入新的锁定密码。',
'settings.appLock.validation.confirmRequired': '请确认新的锁定密码。',
'settings.appLock.validation.mismatch': '两次输入的新密码不一致。',
'settings.appLock.validation.incorrect': '当前锁定密码不正确。',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': '网络代理',
'settings.system.networkProxy.description': '用于云同步与 AI 服务商的 HTTP(S) 代理。与 SSH 的 ProxyJump / ProxyCommand 配置无关。',
@@ -170,6 +249,10 @@ export const zhCNCoreMessages: Messages = {
// Settings > Session Restore
'settings.sessionRestore.title': '会话恢复',
'settings.sessionRestore.startupLanding': '启动时打开',
'settings.sessionRestore.startupLandingDesc': '当没有可恢复的会话时,选择启动后进入主机主页还是直接打开本地终端。',
'settings.sessionRestore.startupLanding.vault': '主页(主机)',
'settings.sessionRestore.startupLanding.localTerminal': '本地终端',
'settings.sessionRestore.restorePreviousSession': '恢复上次的标签页和工作区',
'settings.sessionRestore.restorePreviousSessionDesc': '启动时重新打开上次的终端标签页和工作区布局。',
'settings.sessionRestore.restoreTerminalCwd': '恢复终端工作目录',
@@ -829,6 +912,10 @@ export const zhCNCoreMessages: Messages = {
'sftp.moveTo.confirm': '移动',
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.extract': '解压到当前目录',
'sftp.extract.extracting': '正在解压 {fileName}...',
'sftp.extract.success': '已解压 {fileName}',
'sftp.extract.error': '解压 {fileName} 失败:{error}',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.copyToOtherPane.unavailable': '请打开双栏 SFTP 文件管理,并连接目标侧后再复制文件。',
'sftp.copyCurrentPath': '复制当前路径',
+4 -1
View File
@@ -18,8 +18,11 @@ export const zhCNScriptsMessages = {
'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。',
'scripts.editor.lineCount': '{count} 行',
'scripts.editor.resize': '调整编辑器高度',
'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。',
'scripts.targets.hint': '所选分组会动态匹配,之后加入分组的主机也会自动生效。',
'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。',
'scripts.targets.currentHostMismatch': '此脚本未分配给当前主机。',
'hostDetails.automation.groupScripts': '分组继承脚本',
'hostDetails.automation.groupScriptsHint': '这些脚本动态跟随主机所在分组,并在主机专属队列之前执行。',
'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。',
'scripts.actions.runParallel': '在所有标签页并行运行',
'scripts.actions.runSequential': '在所有标签页顺序运行',
+13 -4
View File
@@ -131,7 +131,7 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': '将文件传输到另一窗格的活动主机',
'settings.sshDeepLink.title': 'SSH 和 Telnet 链接',
'settings.sshDeepLink.enable': '用 Netcatty 打开 ssh:// 和 telnet:// 链接',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接。',
'settings.sshDeepLink.enableDesc': '允许 Netcatty 接管来自浏览器和其他应用的 ssh://、telnet:// 链接,以及堡垒机常用的 PuTTY 启动参数(例如 -ssh user@host -P 22 -pw password)。',
'settings.explorerContextMenu.title': 'Windows 资源管理器',
'settings.explorerContextMenu.enable': '在文件夹右键菜单中显示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '为文件夹和文件夹空白处添加右键项,可直接在该目录打开本地终端。开关立即生效,无需重装。',
@@ -338,6 +338,12 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '关闭',
'settings.terminal.behavior.dynamicTabTitle.agent': '仅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部会话',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'当程序发出 OSC 9、OSC 777 notify 或 kitty OSC 99 时弹出系统通知。Codex 等 Agent 用它提示回合完成或等待审批。',
'settings.terminal.behavior.oscNotifications.off': '关闭',
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦时',
'settings.terminal.behavior.oscNotifications.always': '始终',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
@@ -742,8 +748,11 @@ export const zhCNTerminalMessages: Messages = {
'snippets.scriptEditor.expand': '弹窗编辑',
'snippets.scriptEditor.resize': '调整编辑器高度',
'snippets.scriptEditor.modalTitle': '编辑脚本',
'snippets.targets.title': '目标主机',
'snippets.targets.add': '添加目标主机',
'snippets.targets.title': '执行目标',
'snippets.targets.add': '添加目标',
'snippets.targets.selectHosts': '选择主机',
'snippets.targets.selectGroups': '选择分组',
'snippets.targets.noGroups': '未找到分组',
'snippets.targets.allHosts': '对所有主机生效',
'snippets.targets.allHostsShort': '所有主机',
'snippets.targets.allHostsActive': '将作用于所有可连接的主机。',
@@ -830,7 +839,7 @@ export const zhCNTerminalMessages: Messages = {
'snippets.shortkey.recording': '请按下快捷键组合...',
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
'snippets.shortkey.clear': '清除快捷键',
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
'snippets.shortkey.error.systemConflict': '此快捷键与「{name}」冲突',
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
'snippets.variables.dialogTitle': '填写变量',
+1
View File
@@ -53,6 +53,7 @@ export const zhTWAiMessages: Messages = {
'ai.providers.apiKey.placeholder': '輸入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.baseUrl.anthropicHelp': 'Anthropic 相容:可填不帶或帶 /v1 的主機(例如 https://gateway.example 或 https://gateway.example/v1)。偵測與聊天都會請求 /v1/models、/v1/messages。',
'ai.providers.skipTLSVerify': '跳過 TLS 憑證驗證(用於自我簽署憑證)',
'ai.providers.defaultModel': '預設模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
+8
View File
@@ -170,6 +170,10 @@ export const zhTWCoreMessages: Messages = {
// Settings > Session Restore
'settings.sessionRestore.title': '工作階段恢復',
'settings.sessionRestore.startupLanding': '啟動時開啟',
'settings.sessionRestore.startupLandingDesc': '當沒有可恢復的工作階段時,選擇啟動後進入主機主頁還是直接開啟本機終端。',
'settings.sessionRestore.startupLanding.vault': '主頁(主機)',
'settings.sessionRestore.startupLanding.localTerminal': '本機終端',
'settings.sessionRestore.restorePreviousSession': '恢復上次的標籤頁和工作區',
'settings.sessionRestore.restorePreviousSessionDesc': '啟動時重新開啟上次的終端標籤頁和工作區佈局。',
'settings.sessionRestore.restoreTerminalCwd': '恢復終端工作目錄',
@@ -829,6 +833,10 @@ export const zhTWCoreMessages: Messages = {
'sftp.moveTo.confirm': '移動',
'sftp.moveTo.pathNotFound': '目錄不存在或無法存取',
'sftp.context.download': '下載',
'sftp.context.extract': '解壓到目前目錄',
'sftp.extract.extracting': '正在解壓 {fileName}...',
'sftp.extract.success': '已解壓 {fileName}',
'sftp.extract.error': '解壓 {fileName} 失敗:{error}',
'sftp.context.copyToOtherPane': '複製到另一側',
'sftp.copyToOtherPane.unavailable': '請開啟雙欄 SFTP 檔案管理,並連線目標側後再複製檔案。',
'sftp.copyCurrentPath': '複製目前路徑',
+4 -1
View File
@@ -18,8 +18,11 @@ export const zhTWScriptsMessages = {
'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。',
'scripts.editor.lineCount': '{count} 行',
'scripts.editor.resize': '調整編輯器高度',
'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。',
'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。',
'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。',
'scripts.targets.currentHostMismatch': '此指令碼未分配給目前主機。',
'hostDetails.automation.groupScripts': '分組繼承指令碼',
'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。',
'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。',
'scripts.actions.runParallel': '在所有標籤頁並行執行',
'scripts.actions.runSequential': '在所有標籤頁依序執行',
+13 -4
View File
@@ -131,7 +131,7 @@ export const zhTWTerminalMessages: Messages = {
'settings.sftp.doubleClickBehavior.transferDesc': '將檔案傳輸到另一窗格的使用中主機',
'settings.sshDeepLink.title': 'SSH 和 Telnet 連結',
'settings.sshDeepLink.enable': '用 Netcatty 開啟 ssh:// 和 telnet:// 連結',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結。',
'settings.sshDeepLink.enableDesc': '允許 Netcatty 接管來自瀏覽器和其他應用程式的 ssh://、telnet:// 連結,以及堡壘機常用的 PuTTY 啟動參數(例如 -ssh user@host -P 22 -pw password)。',
'settings.explorerContextMenu.title': 'Windows 檔案總管',
'settings.explorerContextMenu.enable': '在資料夾右鍵選單中顯示 “Open in Netcatty”',
'settings.explorerContextMenu.enableDesc': '為資料夾與資料夾空白處加入右鍵項目,可直接在該目錄開啟本機終端機。開關立即生效,無需重裝。',
@@ -338,6 +338,12 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.dynamicTabTitle.off': '關閉',
'settings.terminal.behavior.dynamicTabTitle.agent': '僅 Agent',
'settings.terminal.behavior.dynamicTabTitle.all': '全部工作階段',
'settings.terminal.behavior.oscNotifications': 'OSC 桌面通知',
'settings.terminal.behavior.oscNotifications.desc':
'當程式發出 OSC 9、OSC 777 notify 或 kitty OSC 99 時顯示系統通知。Codex 等 Agent 會用它提示回合完成或等待核准。',
'settings.terminal.behavior.oscNotifications.off': '關閉',
'settings.terminal.behavior.oscNotifications.unfocused': '未聚焦時',
'settings.terminal.behavior.oscNotifications.always': '一律',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪貼簿',
'settings.terminal.behavior.osc52Clipboard.desc':
'允許遠端程式(tmux、vim 等)透過 OSC-52 轉義序列存取本機剪貼簿。',
@@ -739,8 +745,11 @@ export const zhTWTerminalMessages: Messages = {
'snippets.scriptEditor.expand': '彈出視窗編輯',
'snippets.scriptEditor.resize': '調整編輯器高度',
'snippets.scriptEditor.modalTitle': '編輯指令碼',
'snippets.targets.title': '目標主機',
'snippets.targets.add': '新增目標主機',
'snippets.targets.title': '執行目標',
'snippets.targets.add': '新增目標',
'snippets.targets.selectHosts': '選擇主機',
'snippets.targets.selectGroups': '選擇分組',
'snippets.targets.noGroups': '未找到分組',
'snippets.history.title': 'Shell 歷史',
'snippets.history.subtitle': '{count} 條指令',
'snippets.history.emptyTitle': '暫無 Shell 歷史',
@@ -830,7 +839,7 @@ export const zhTWTerminalMessages: Messages = {
'snippets.shortkey.recording': '請按下快捷鍵組合...',
'snippets.shortkey.hint': '在終端中按下此快捷鍵可快速傳送指令。',
'snippets.shortkey.clear': '清除快捷鍵',
'snippets.shortkey.error.systemConflict': '此快捷鍵與系統快捷鍵衝突',
'snippets.shortkey.error.systemConflict': '此快捷鍵與「{name}」衝突',
'snippets.shortkey.error.snippetConflict': '此快捷鍵已被程式碼片段使用:{name}',
'snippets.variables.dialogTitle': '填寫變數',
+2
View File
@@ -2,6 +2,7 @@ import en, { type Messages } from './locales/en';
import zhCN from './locales/zh-CN';
import zhTW from './locales/zh-TW';
import ru from './locales/ru';
import es from './locales/es';
// Keep keys stable; add new locales by adding another import and map entry.
export { type Messages };
@@ -9,6 +10,7 @@ export { type Messages };
export const MESSAGES_BY_LOCALE: Record<string, Messages> = {
en,
ru,
es,
'zh-CN': zhCN,
'zh-TW': zhTW,
};
+95
View File
@@ -18,8 +18,103 @@ import {
setDraftView,
setSessionView,
updateDraftForScope,
draftsByScopeEqualIgnoringComposerText,
draftsByScopeEqualIgnoringAllComposerText,
} from "./aiDraftState.ts";
test("draftsByScopeEqualIgnoringComposerText ignores typing in the active scope", () => {
const base = createEmptyDraft("catty");
const prev = { "terminal:1": { ...base, text: "" } };
const next = { "terminal:1": { ...base, text: "你好", updatedAt: base.updatedAt + 1 } };
assert.equal(draftsByScopeEqualIgnoringComposerText(prev, next, "terminal:1"), true);
assert.equal(
draftsByScopeEqualIgnoringComposerText(
prev,
{ "terminal:1": { ...base, text: "你好", attachments: [{ id: "a" } as never] } },
"terminal:1",
),
false,
);
assert.equal(
draftsByScopeEqualIgnoringComposerText(
prev,
{ "terminal:1": prev["terminal:1"], "workspace:2": base },
"terminal:1",
),
true,
);
assert.equal(
draftsByScopeEqualIgnoringComposerText(
prev,
{
"terminal:1": prev["terminal:1"],
"workspace:2": { ...base, attachments: [{ id: "a" } as never] },
},
"terminal:1",
),
false,
);
});
test("first composer draft is treated as text-only identity churn", () => {
const empty = createEmptyDraft("catty");
const created = { ...empty, text: "你" };
assert.equal(draftsByScopeEqualIgnoringAllComposerText({}, { "terminal:1": empty }), false);
assert.equal(draftsByScopeEqualIgnoringAllComposerText({}, { "terminal:1": created }), true);
assert.equal(
draftsByScopeEqualIgnoringComposerText({}, { "terminal:1": created }, "terminal:1"),
true,
);
assert.equal(
draftsByScopeEqualIgnoringAllComposerText(
{ "terminal:1": empty },
{ "terminal:1": created },
),
true,
);
assert.equal(
draftsByScopeEqualIgnoringAllComposerText(
{ "terminal:1": created },
{ "terminal:1": empty },
),
false,
);
assert.equal(
draftsByScopeEqualIgnoringAllComposerText(
{},
{ "terminal:1": { ...created, attachments: [{ id: "a" } as never] } },
),
false,
);
});
test("draftsByScopeEqualIgnoringAllComposerText ignores typing in every scope", () => {
const base = createEmptyDraft("catty");
const prev = {
"terminal:1": { ...base, text: "a" },
"terminal:2": { ...base, text: "b", attachments: [] },
};
const next = {
"terminal:1": { ...prev["terminal:1"], text: "aa", updatedAt: base.updatedAt + 1 },
"terminal:2": { ...prev["terminal:2"], text: "bb", updatedAt: base.updatedAt + 2 },
};
assert.equal(draftsByScopeEqualIgnoringAllComposerText(prev, next), true);
assert.equal(
draftsByScopeEqualIgnoringAllComposerText(prev, {
...next,
"terminal:2": { ...next["terminal:2"], attachments: [{ id: "a" } as never] },
}),
false,
);
});
test("updateDraftForScope keeps the original map when the updater is a no-op", () => {
const draft = createEmptyDraft("catty");
const prev = { "terminal:1": draft };
const next = updateDraftForScope(prev, "terminal:1", "catty", (current) => current);
assert.equal(next, prev);
});
test("createEmptyDraft seeds selected agent and empty inputs", () => {
const draft = createEmptyDraft("agent-alpha");
+62
View File
@@ -11,6 +11,65 @@ type DraftUploadGenerationByScope = Record<string, number>;
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
function isComposerOnlyDraft(draft: AIDraft | undefined): boolean {
return Boolean(
draft
&& draft.attachments.length === 0
&& draft.selectedUserSkillSlugs.length === 0,
);
}
/**
* First keystroke creates a missing → composer-only draft. That is still just
* typing. Clearing a draft (`right` missing or emptied) is a real lifecycle
* change so New Chat / send can reset the uncontrolled composer.
*/
function draftsEquivalentIgnoringComposerText(
left: AIDraft | undefined,
right: AIDraft | undefined,
): boolean {
if (left === right) return true;
if (!left) return Boolean(isComposerOnlyDraft(right) && right.text.trim().length > 0);
if (!right) return false;
if (left.agentId !== right.agentId) return false;
if (left.attachments !== right.attachments) return false;
if (left.selectedUserSkillSlugs !== right.selectedUserSkillSlugs) return false;
const leftEmpty = left.text.trim().length === 0;
const rightEmpty = right.text.trim().length === 0;
if (leftEmpty !== rightEmpty) return leftEmpty;
return true;
}
/** True when every scope is unchanged except composer text/updatedAt. */
export function draftsByScopeEqualIgnoringAllComposerText(
prev: DraftsByScope,
next: DraftsByScope,
): boolean {
if (prev === next) return true;
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
for (const key of keys) {
if (!draftsEquivalentIgnoringComposerText(prev[key], next[key])) return false;
}
return true;
}
/** Typing only changes text/updatedAt. Sibling empty-draft creates stay ignored. */
export function draftsByScopeEqualIgnoringComposerText(
prev: DraftsByScope,
next: DraftsByScope,
scopeKey: string,
): boolean {
if (prev === next) return true;
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
for (const key of keys) {
const left = prev[key];
const right = next[key];
if (key !== scopeKey && !left && isComposerOnlyDraft(right)) continue;
if (!draftsEquivalentIgnoringComposerText(left, right)) return false;
}
return true;
}
export function createEmptyDraft(agentId: string): AIDraft {
return {
text: '',
@@ -142,6 +201,9 @@ export function updateDraftForScope(
): DraftsByScope {
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
const nextDraft = updater(currentDraft);
if (nextDraft === currentDraft && draftsByScope[scopeKey] === currentDraft) {
return draftsByScope;
}
return {
...draftsByScope,
+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createEmptyDraft } from './aiDraftState.ts';
import { aiSessionsStore } from './aiSessionsStore.ts';
test('AI sessions store does not notify listeners when only composer text changes', () => {
const previous = aiSessionsStore.getSnapshot();
const draft = createEmptyDraft('catty');
const base = {
sessions: Object.freeze([]),
activeSessionIdMap: Object.freeze({}),
draftsByScope: { 'terminal:1': draft },
panelViewByScope: {},
};
try {
aiSessionsStore.setSnapshot(base);
let notified = 0;
const unsubscribe = aiSessionsStore.subscribe(() => {
notified += 1;
});
aiSessionsStore.setSnapshot({
...base,
draftsByScope: {
'terminal:1': { ...draft, text: 'hello', updatedAt: draft.updatedAt + 1 },
},
});
assert.equal(notified, 0);
assert.equal(aiSessionsStore.getSnapshot().draftsByScope['terminal:1']?.text, 'hello');
aiSessionsStore.setSnapshot({
...base,
draftsByScope: {},
});
notified = 0;
aiSessionsStore.setSnapshot({
...base,
draftsByScope: {
'terminal:1': { ...draft, text: '你好' },
},
});
assert.equal(notified, 0);
notified = 0;
aiSessionsStore.setSnapshot({
...base,
draftsByScope: {
'terminal:1': { ...draft, text: '' },
},
});
assert.equal(notified, 1);
notified = 0;
aiSessionsStore.setSnapshot({
...base,
draftsByScope: {
'terminal:1': { ...draft, text: 'hello', attachments: [{ id: 'a' } as never] },
},
});
assert.equal(notified, 1);
unsubscribe();
} finally {
aiSessionsStore.setSnapshot(previous);
}
});
+10
View File
@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react';
import type { AISession } from '../../infrastructure/ai/types';
import { draftsByScopeEqualIgnoringAllComposerText } from './aiDraftState';
import type { DraftsByScope, PanelViewByScope } from './aiStateSnapshots';
type Listener = () => void;
@@ -50,7 +51,16 @@ class AISessionsStore {
) {
return;
}
const textOnlyDraftChange =
this.snapshot.sessions === next.sessions
&& this.snapshot.activeSessionIdMap === next.activeSessionIdMap
&& this.snapshot.panelViewByScope === next.panelViewByScope
&& draftsByScopeEqualIgnoringAllComposerText(
this.snapshot.draftsByScope,
next.draftsByScope,
);
this.snapshot = next;
if (textOnlyDraftChange) return;
for (const listener of this.listeners) {
listener();
}
+43
View File
@@ -1,5 +1,6 @@
import { createContext, useContext } from 'react';
import type { useAppLockState } from './useAppLockState';
import type { useSessionState } from './useSessionState';
import type { useSettingsState } from './useSettingsState';
import type { useVaultState } from './useVaultState';
@@ -7,6 +8,25 @@ import type { useVaultState } from './useVaultState';
export type AppVaultRuntime = ReturnType<typeof useVaultState>;
export type AppSessionRuntime = ReturnType<typeof useSessionState>;
export type AppSettingsRuntime = ReturnType<typeof useSettingsState>;
export type AppAppLockRuntime = ReturnType<typeof useAppLockState>;
/**
* Narrow app-lock projection published on React context. The full runtime
* changes identity on every AppLockGate render; chrome consumers (TopTabs lock
* button, AppSideEffects deferral gates) only need these primitives, and
* imperative callers read the full runtime through `getAppAppLockRuntime()`.
*/
export type AppLockChromeValue = {
appLockEnabled: boolean;
locked: boolean;
initialized: boolean;
};
const DEFAULT_APP_LOCK_CHROME: AppLockChromeValue = {
appLockEnabled: false,
locked: false,
initialized: false,
};
/**
* The slice of the vault runtime that `VaultPublisher` puts on the React
@@ -69,6 +89,7 @@ class RuntimeSlot<T> {
const vaultSlot = new RuntimeSlot<AppVaultRuntime>();
const sessionSlot = new RuntimeSlot<AppSessionRuntime>();
const settingsSlot = new RuntimeSlot<AppSettingsRuntime>();
const appLockSlot = new RuntimeSlot<AppAppLockRuntime>();
export function registerAppVaultRuntime(runtime: AppVaultRuntime | null): void {
vaultSlot.set(runtime);
@@ -106,9 +127,22 @@ export function subscribeAppSettingsRuntime(listener: Listener): () => void {
return settingsSlot.subscribe(listener);
}
export function registerAppAppLockRuntime(runtime: AppAppLockRuntime | null): void {
appLockSlot.set(runtime);
}
export function getAppAppLockRuntime(): AppAppLockRuntime | null {
return appLockSlot.get();
}
export function subscribeAppAppLockRuntime(listener: Listener): () => void {
return appLockSlot.subscribe(listener);
}
export const AppVaultRuntimeContext = createContext<AppVaultContextValue | null>(null);
export const AppSessionRuntimeContext = createContext<AppSessionRuntime | null>(null);
export const AppSettingsRuntimeContext = createContext<AppSettingsRuntime | null>(null);
export const AppLockChromeContext = createContext<AppLockChromeValue | null>(null);
/** Read the vault runtime owned by `VaultPublisher`. */
export function useAppVaultRuntime(): AppVaultContextValue {
@@ -136,3 +170,12 @@ export function useAppSettingsRuntime(): AppSettingsRuntime {
}
return runtime;
}
/**
* Read the narrow app-lock chrome slice published by `AppLockRuntimePublisher`.
* Falls back to an "unlocked / disabled" default so windows without a
* publisher (isolated mounts, tests) behave as if App Lock is off.
*/
export function useAppLockChrome(): AppLockChromeValue {
return useContext(AppLockChromeContext) ?? DEFAULT_APP_LOCK_CHROME;
}
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
handleTerminalOscNotification,
showOscDesktopNotification,
} from "./oscDesktopNotifications.ts";
const installNotificationBridge = () => {
const calls: Array<{ title: string; body: string; sessionId?: string }> = [];
const previousWindow = (globalThis as { window?: unknown }).window;
const previousDocument = (globalThis as { document?: { hasFocus: () => boolean } }).document;
(globalThis as { document: { hasFocus: () => boolean } }).document = { hasFocus: () => true };
(globalThis as { window: { netcatty: { showSystemNotification: (payload: { title: string; body: string; sessionId?: string }) => Promise<{ shown: boolean }> } } }).window = {
netcatty: {
showSystemNotification: async (payload) => {
calls.push(payload);
return { shown: true };
},
},
};
return {
calls,
restore() {
if (previousWindow === undefined) delete (globalThis as { window?: unknown }).window;
else (globalThis as { window: unknown }).window = previousWindow;
if (previousDocument === undefined) delete (globalThis as { document?: unknown }).document;
else (globalThis as { document: typeof previousDocument }).document = previousDocument;
},
};
};
test("showOscDesktopNotification skips disabled and focused-unfocused modes", () => {
const fixture = installNotificationBridge();
try {
showOscDesktopNotification({
notification: { title: "", body: "hidden", protocol: "osc9" },
mode: "off",
sessionFocused: false,
sessionId: "s1",
fallbackTitle: "host",
});
showOscDesktopNotification({
notification: { title: "", body: "quiet", protocol: "osc9" },
mode: "unfocused",
sessionFocused: true,
sessionId: "s1",
fallbackTitle: "host",
});
showOscDesktopNotification({
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
mode: "always",
sessionFocused: true,
sessionId: "s1",
fallbackTitle: "host",
});
assert.deepEqual(fixture.calls, [{
title: "Codex",
body: "Turn complete",
sessionId: "s1",
}]);
} finally {
fixture.restore();
}
});
test("handleTerminalOscNotification does not mark activity when notifications are off", () => {
const fixture = installNotificationBridge();
let activity = 0;
try {
assert.equal(handleTerminalOscNotification({
notification: { title: "", body: "hidden", protocol: "osc9" },
mode: "off",
sessionFocused: false,
sessionId: "s-off",
fallbackTitle: "host",
onSessionActivity: () => { activity += 1; },
}), false);
assert.equal(activity, 0);
assert.equal(fixture.calls.length, 0);
assert.equal(handleTerminalOscNotification({
notification: { title: "Codex", body: "Turn complete", protocol: "osc9" },
mode: "always",
sessionFocused: false,
sessionId: "s-on",
fallbackTitle: "host",
onSessionActivity: () => { activity += 1; },
}), true);
assert.equal(activity, 1);
assert.equal(fixture.calls.length, 1);
} finally {
fixture.restore();
}
});
@@ -0,0 +1,60 @@
import {
DEFAULT_OSC_NOTIFICATION_TITLE,
OscNotificationLimiter,
resolveOscNotificationPresentation,
shouldShowOscDesktopNotification,
type OscNotification,
} from "../../domain/terminalOscNotifications";
import type { OscNotificationMode } from "../../domain/models/terminal";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
const sessionLimiters = new Map<string, OscNotificationLimiter>();
const limiterForSession = (sessionId: string): OscNotificationLimiter => {
const existing = sessionLimiters.get(sessionId);
if (existing) return existing;
const limiter = new OscNotificationLimiter();
sessionLimiters.set(sessionId, limiter);
return limiter;
};
export function showOscDesktopNotification(options: {
notification: OscNotification;
mode: OscNotificationMode | undefined;
sessionFocused: boolean;
sessionId: string;
fallbackTitle?: string;
}): boolean {
if (!shouldShowOscDesktopNotification(options.mode, {
windowFocused: typeof document !== "undefined" && document.hasFocus(),
sessionFocused: options.sessionFocused,
})) {
return false;
}
if (!limiterForSession(options.sessionId).allow(options.sessionId)) return false;
const presented = resolveOscNotificationPresentation(
options.notification,
options.fallbackTitle || DEFAULT_OSC_NOTIFICATION_TITLE,
);
void netcattyBridge.get()?.showSystemNotification?.({
title: presented.title,
body: presented.body,
sessionId: options.sessionId,
});
return true;
}
export function handleTerminalOscNotification(options: {
notification: OscNotification;
mode: OscNotificationMode | undefined;
sessionFocused: boolean;
sessionId: string;
fallbackTitle?: string;
onSessionActivity?: () => void;
}): boolean {
if (options.mode === "off") return false;
options.onSessionActivity?.();
showOscDesktopNotification(options);
return true;
}
@@ -111,6 +111,8 @@ test("session peer windows do not run main-window startup effects", () => {
const autoSyncSource = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const startupEffectsSource = readFileSync(new URL("../app/useAppStartupEffects.ts", import.meta.url), "utf8");
const updateCheckSource = readFileSync(new URL("./useUpdateCheck.ts", import.meta.url), "utf8");
const appLockGateSource = readFileSync(new URL("../../components/AppLockGate.tsx", import.meta.url), "utf8");
const indexSource = readFileSync(new URL("../../index.tsx", import.meta.url), "utf8");
const settingsStateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const settingsIpcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
@@ -119,12 +121,17 @@ test("session peer windows do not run main-window startup effects", () => {
const trayPanelJumpIndex = appSource.indexOf("onTrayPanelJumpToSession");
assert.match(appSource, /const isPeerSessionWindow = typeof window !== 'undefined' && window\.location\.hash\.startsWith\('#\/session-window'\)/);
// SettingsPublisher owns useSettingsState; App only forwards the peer-window
// flags to it.
assert.match(appRootSource, /<SettingsPublisher\s+enableSettingsSync=\{!isPeerSessionWindow\}\s+enableSystemEffects=\{!isPeerSessionWindow\}/s);
assert.match(
assert.match(appLockGateSource, /settingsOptions\?: Parameters<typeof useSettingsState>\[0\]/);
assert.match(appLockGateSource, /deps\.useSettingsState\(settingsOptions\)/);
assert.match(indexSource, /const isPeerSessionWindow = window\.location\.hash\.startsWith\('#\/session-window'\)/);
assert.match(indexSource, /const settingsOptions = isPeerSessionWindow\s*\?\s*\{ enableSettingsSync: false, enableSystemEffects: false \}/);
assert.match(indexSource, /<AppLockGate settingsOptions=\{settingsOptions\}>/);
// AppLockGate owns useSettingsState; App forwards the gate's instance into
// SettingsPublisher so the runtime slot/context still publish it.
assert.match(appRootSource, /<SettingsPublisher settings=\{settings\}>/);
assert.doesNotMatch(
readFileSync(new URL("../app/publishers/SettingsPublisher.tsx", import.meta.url), "utf8"),
/useSettingsState\(\{ enableSettingsSync, enableSystemEffects \}\)/,
/useSettingsState\(/,
);
assert.match(appSource, /useAppStartupEffects\(\{[^}]*enabled: !isPeerSessionWindow/s);
assert.match(appSource, /useUpdateCheck\(\{[^}]*enabled: !isPeerSessionWindow/s);
@@ -175,7 +182,7 @@ test("restore-only settings do not bump the cloud sync settings version", () =>
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
assert.notEqual(settingsVersionIndex, -1);
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd/);
assert.doesNotMatch(settingsVersionSource, /restorePreviousSession|restoreTerminalCwd|startupLanding/);
});
test("restore previous session re-arms after cross-window settings ipc sync", () => {
@@ -125,6 +125,30 @@ test("closing a workspace session dissolves the workspace when one terminal rema
);
});
test("closing the focused session hands focus to a remaining session (#3046)", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
"ws-1",
"s1",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s2");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
});
test("closing a non-focused session keeps the focused session", () => {
const result = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2", "s3", "s4"])],
"ws-1",
"s3",
);
assert.equal(result.workspaces.length, 1);
assert.equal(result.workspaces[0].focusedSessionId, "s1");
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
});
test("closing one split pane keeps the other terminal as an orphan tab", () => {
const layoutResult = closeSessionWorkspaceLayoutState(
[workspace(["s1", "s2"])],
+18 -1
View File
@@ -96,7 +96,24 @@ export function closeSessionWorkspaceLayoutState(
return null;
}
return { ...workspace, root: prunedRoot };
// Closing the focused session must hand focus to a remaining one, or a
// focus-mode workspace is left with a dangling focusedSessionId: no pane
// qualifies as visible and the continuously rendered hidden panes all
// paint on top of each other (#3046).
const nextFocusedSessionId =
workspace.focusedSessionId && !remainingSessionIds.includes(workspace.focusedSessionId)
? remainingSessionIds[0]
: workspace.focusedSessionId;
const nextFocusSessionOrder = workspace.focusSessionOrder?.filter(
(candidateId) => remainingSessionIds.includes(candidateId),
);
return {
...workspace,
root: prunedRoot,
focusedSessionId: nextFocusedSessionId,
focusSessionOrder: nextFocusSessionOrder,
};
})
.filter((workspace): workspace is Workspace => Boolean(workspace));
+8
View File
@@ -15,6 +15,7 @@ import {
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_STARTUP_LANDING,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
@@ -60,6 +61,7 @@ import {
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
import { isStartupLanding, type StartupLanding } from '../../domain/startupLanding';
import type { AppearanceSyncEvent } from './appearanceSync';
interface UseSettingsIpcSyncParams {
@@ -102,6 +104,7 @@ interface UseSettingsIpcSyncParams {
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
setStartupLandingState: Dispatch<SetStateAction<StartupLanding>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
setSshTransportIdleTtlMsState: Dispatch<SetStateAction<number>>;
}
@@ -146,6 +149,7 @@ export function useSettingsIpcSync({
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
setStartupLandingState,
setSftpTransferConcurrencyState,
setSshTransportIdleTtlMsState,
}: UseSettingsIpcSyncParams) {
@@ -304,6 +308,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_RESTORE_TERMINAL_CWD && typeof value === 'boolean') {
setRestoreTerminalCwdState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_STARTUP_LANDING && isStartupLanding(value)) {
setStartupLandingState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
@@ -348,6 +355,7 @@ export function useSettingsIpcSync({
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
setStartupLandingState,
setSftpTransferConcurrencyState,
setSshTransportIdleTtlMsState,
setTerminalFontFamilyId,
@@ -85,6 +85,8 @@ export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
export const DEFAULT_SHOW_TAB_NUMBER_BADGES = true;
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
export { DEFAULT_STARTUP_LANDING } from '../../domain/startupLanding';
export type { StartupLanding } from '../../domain/startupLanding';
// Editor defaults
export const DEFAULT_EDITOR_WORD_WRAP = false;
+15 -4
View File
@@ -19,6 +19,7 @@ import {
STORAGE_KEY_EXPLORER_CONTEXT_MENU_ENABLED,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_STARTUP_LANDING,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_AUTO_SYNC,
@@ -60,6 +61,7 @@ import {
} from './settingsStateDefaults';
import { isTerminalSidePanelAutoOpenTab, type TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
import { isHostClickBehavior, type HostClickBehavior } from '../../domain/hostClickBehavior';
import { isStartupLanding, type StartupLanding } from '../../domain/startupLanding';
interface UseSettingsStorageSyncParams {
enabled?: boolean;
@@ -97,6 +99,7 @@ interface UseSettingsStorageSyncParams {
disableTerminalFontZoom: boolean;
restorePreviousSession: boolean;
restoreTerminalCwd: boolean;
startupLanding: StartupLanding;
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
@@ -145,6 +148,7 @@ interface UseSettingsStorageSyncParams {
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
setStartupLandingState: Dispatch<SetStateAction<StartupLanding>>;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
@@ -172,7 +176,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
@@ -181,7 +185,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -196,7 +200,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
@@ -206,7 +210,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -480,6 +484,11 @@ export function useSettingsStorageSync({
setRestoreTerminalCwdState(newValue);
}
}
if (e.key === STORAGE_KEY_STARTUP_LANDING && e.newValue !== null) {
if (isStartupLanding(e.newValue) && e.newValue !== s.startupLanding) {
setStartupLandingState(e.newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -568,9 +577,11 @@ export function useSettingsStorageSync({
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setShowTabNumberBadgesState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
setStartupLandingState,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,
@@ -162,7 +162,6 @@ export async function openTransferSftpSession(
if (
!wantDedicated
&& options?.sourceSessionId
&& !host.sftpSudo
&& bridge.openSftpForSession
) {
try {
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../../domain/models";
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
import type { SftpPane } from "./types";
const host = {
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
const noopReleaseConnection = async () => {};
test("SFTP session probe accepts a virtual root directory", async () => {
const calls: Array<[string, string]> = [];
const ok = await probeSftpSession({
realpathSftp: async (sftpId, remotePath) => {
calls.push([sftpId, remotePath]);
return "/";
},
}, "sftp-jumpserver");
assert.equal(ok, true);
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
});
test("SFTP session probe does not require home discovery", async () => {
const ok = await probeSftpSession(undefined, "sftp-live");
assert.equal(ok, true);
});
test("returns an existing mapped SFTP session without reconnecting", async () => {
let connectCalls = 0;
const sftpId = await ensureRemoteSftpSession({
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
import { isSessionError } from "./errors";
import type { SftpPane } from "./types";
export interface SftpSessionProbeBridge {
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
}
/**
* Probe the SFTP protocol itself instead of using remote home discovery.
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
* root while the SFTP session remains fully usable.
*/
export async function probeSftpSession(
bridge: SftpSessionProbeBridge | null | undefined,
sftpId: string,
): Promise<boolean> {
if (!bridge?.realpathSftp) return true;
await bridge.realpathSftp(sftpId, ".");
return true;
}
export interface EnsureRemoteSftpSessionParams {
side: "left" | "right";
getActivePane: (side: "left" | "right") => SftpPane | null;
+66
View File
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isMissingStatError } from "./errors";
test("isMissingStatError accepts only true path absence codes", () => {
for (const code of [2, "ENOENT", "NO_SUCH_FILE", "SSH_FX_NO_SUCH_FILE"] as const) {
const error = new Error("missing") as Error & { code: string | number };
error.code = code;
assert.equal(isMissingStatError(error), true, String(code));
}
assert.equal(isMissingStatError(new Error("ENOENT")), true);
});
test("isMissingStatError treats Electron-wrapped SFTP absence as missing", () => {
// ipcRenderer.invoke strips custom `code` and wraps the ssh2 message.
// New-file uploads lstat the destination first; this is the toast users see.
assert.equal(
isMissingStatError(
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file"),
),
true,
);
assert.equal(isMissingStatError(new Error("No such file")), true);
assert.equal(isMissingStatError(new Error("No such file or directory")), true);
assert.equal(isMissingStatError(new Error("No such file: /tmp/tool.sh")), true);
assert.equal(
isMissingStatError(new Error("ENOENT: no such file or directory, lstat '/tmp/tool.sh'")),
true,
);
});
test("isMissingStatError rejects unsupported LSTAT and other failures", () => {
const enotsup = new Error("Remote server does not support LSTAT") as Error & {
code: string;
lstatUnavailable: boolean;
};
enotsup.code = "ENOTSUP";
enotsup.lstatUnavailable = true;
assert.equal(isMissingStatError(enotsup), false);
const eperm = new Error("denied") as Error & { code: string };
eperm.code = "EPERM";
assert.equal(isMissingStatError(eperm), false);
assert.equal(isMissingStatError(new Error("channel closed")), false);
assert.equal(
isMissingStatError(
new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: Permission denied"),
),
false,
);
// Path names are user-controlled; do not treat a substring hit as absence.
assert.equal(
isMissingStatError(
new Error("EACCES: permission denied, lstat '/private/enoent/report.txt'"),
),
false,
);
assert.equal(
isMissingStatError(
new Error(
"Error invoking remote method 'netcatty:sftp:lstat': Error: EACCES: permission denied, lstat '/private/enoent/report.txt'",
),
),
false,
);
});
+2
View File
@@ -17,3 +17,5 @@ export const isSessionError = (err: unknown): boolean => {
msg.includes("timed out")
);
};
export { isMissingStatError } from "../../../domain/sftpStatError";
+30 -23
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import type { SftpFilenameEncoding, TransferTask } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { isMissingStatError } from "./errors";
import type { SftpPane } from "./types";
import { getParentPath, joinPath } from "./utils";
@@ -24,28 +25,36 @@ export function useSftpTransferConflictOps() {
): Promise<{ type?: "file" | "directory" | "symlink"; size: number; mtime: number } | null> => {
if (!targetPane.connection) return null;
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(targetPath);
try {
if (targetPane.connection.isLocal) {
const bridge = netcattyBridge.get();
const stat = await (bridge?.lstatLocal ?? bridge?.statLocal)?.(targetPath);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
if (!targetSftpId) return null;
const bridge = netcattyBridge.get();
const stat = await (bridge?.lstatSftp ?? bridge?.statSftp)?.(
targetSftpId,
targetPath,
targetEncoding,
);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
} catch (error) {
// Missing path = no conflict. ENOTSUP / unknown type must fail closed.
if (isMissingStatError(error)) return null;
throw error;
}
if (!targetSftpId) return null;
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
targetPath,
targetEncoding,
);
if (!stat) return null;
return {
type: stat.type as "file" | "directory" | "symlink" | undefined,
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
},
[],
);
@@ -64,12 +73,9 @@ export function useSftpTransferConflictOps() {
const suffix = index === 1 ? " (copy)" : ` (copy ${index})`;
const fileName = `${baseName}${suffix}${ext}`;
const targetPath = joinPath(parentPath, fileName);
try {
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
} catch {
return { fileName, targetPath };
}
// Unsupported LSTAT must propagate — do not treat as a free name.
const existing = await statTargetPath(targetPane, targetSftpId, targetPath, targetEncoding);
if (!existing) return { fileName, targetPath };
}
const fallbackName = `${baseName} (copy ${Date.now()})${ext}`;
@@ -84,18 +90,19 @@ export function useSftpTransferConflictOps() {
targetPane: SftpPane,
targetSftpId: string | null,
targetEncoding: SftpFilenameEncoding,
expectedType?: "file" | "directory" | "symlink",
) => {
if (!targetPane.connection) return;
if (targetPane.connection.isLocal) {
const deleteLocalFile = netcattyBridge.get()?.deleteLocalFile;
if (!deleteLocalFile) throw new Error("Local delete unavailable");
await deleteLocalFile(task.targetPath);
await deleteLocalFile(task.targetPath, expectedType);
return;
}
if (!targetSftpId) throw new Error("Target SFTP session not found");
const deleteSftp = netcattyBridge.get()?.deleteSftp;
if (!deleteSftp) throw new Error("SFTP delete unavailable");
await deleteSftp(targetSftpId, task.targetPath, targetEncoding);
await deleteSftp(targetSftpId, task.targetPath, targetEncoding, expectedType);
},
[],
);
@@ -3,8 +3,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
buildSftpConnectInFlightKey,
buildSftpHomeDirCandidates,
runSftpConnectOnceByKey,
createSftpConnectionId,
createPinnedReconnectSideResolver,
openSftpConnectionOnce,
@@ -15,12 +19,62 @@ import {
resolvePinnedReconnectSide,
} from "./useSftpConnections.ts";
test("home dir candidates prefer user home then root", () => {
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
});
test("connection ids stay unique even when connects start in the same millisecond", () => {
const ids = ["uuid-a", "uuid-b"];
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-b");
});
test("runSftpConnectOnceByKey shares an in-flight connect for the same tab and endpoint", async () => {
const inFlight = new Map<string, Promise<void>>();
let runs = 0;
let releaseFirst: (() => void) | undefined;
const first = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
runs += 1;
await new Promise<void>((resolve) => {
releaseFirst = resolve;
});
});
const second = runSftpConnectOnceByKey(inFlight, "left:tab-1:host-key:ssh-session-1:/home", async () => {
runs += 1;
});
assert.equal(runs, 1);
releaseFirst?.();
await Promise.all([first, second]);
assert.equal(runs, 1);
assert.equal(inFlight.size, 0);
});
test("buildSftpConnectInFlightKey uses the allocated tab id for forced new tabs", () => {
const first = buildSftpConnectInFlightKey({
side: "left",
tabId: "new-tab-a",
targetConnectionKey: "host-key",
sourceSessionId: "ssh-session-1",
initialPath: "/home",
forceNewTab: true,
});
const second = buildSftpConnectInFlightKey({
side: "left",
tabId: "new-tab-b",
targetConnectionKey: "host-key",
sourceSessionId: "ssh-session-1",
initialPath: "/home",
forceNewTab: true,
});
assert.notEqual(first, second);
});
const openOptions = {
sessionId: "sftp-request-1",
hostname: "192.168.9.138",
@@ -73,6 +127,43 @@ test("openSftpWithSessionPreference falls back to normal SFTP when session reuse
assert.deepEqual(calls, ["openForSession:ssh-session-1", "openSftp:sftp-request-1"]);
});
test("openSftpWithSessionPreference tries session reuse for sudo SFTP before fresh auth", async () => {
const calls: string[] = [];
let passedOptions: NetcattySSHOptions | undefined;
const sftpId = await openSftpWithSessionPreference({
bridge: {
openSftpForSession: async (sessionId: string, options?: NetcattySSHOptions) => {
calls.push(`openForSession:${sessionId}`);
passedOptions = options;
return "sudo-session-backed-sftp";
},
openSftp: async () => {
calls.push("openSftp");
return "fresh-sftp";
},
},
sourceSessionId: "ssh-session-1",
openOptions: {
...openOptions,
sudo: true,
password: "sudo-pass",
},
});
assert.equal(sftpId, "sudo-session-backed-sftp");
assert.deepEqual(calls, ["openForSession:ssh-session-1"]);
assert.equal(passedOptions?.sudo, true);
assert.equal(passedOptions?.password, "sudo-pass");
});
test("remote browse connect does not discard sourceSessionId for sudo hosts", () => {
const source = readFileSync(new URL("./useSftpConnections.ts", import.meta.url), "utf8");
assert.doesNotMatch(
source,
/host\.sftpSudo\s*\?\s*undefined\s*:\s*options\?\.sourceSessionId/,
);
});
test("openSftpWithSessionPreference opens normal SFTP without a source session", async () => {
const calls: string[] = [];
const sftpId = await openSftpWithSessionPreference({
+87 -26
View File
@@ -97,6 +97,13 @@ export async function releaseSftpConnectionMetadata(params: {
params.onRemoteSessionClosed?.(sftpId);
}
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
if (username === "root") return ["/root"];
if (username) return [`/home/${username}`, "/root"];
return ["/root"];
}
export function createSftpConnectionId(
side: "left" | "right",
randomUUID: () => string = () => crypto.randomUUID(),
@@ -176,6 +183,41 @@ export function createPinnedReconnectSideResolver(
};
}
export function runSftpConnectOnceByKey(
inFlight: Map<string, Promise<void>>,
key: string,
run: () => Promise<void>,
): Promise<void> {
const existing = inFlight.get(key);
if (existing) return existing;
const promise = run().finally(() => {
if (inFlight.get(key) === promise) {
inFlight.delete(key);
}
});
inFlight.set(key, promise);
return promise;
}
export function buildSftpConnectInFlightKey(params: {
side: "left" | "right";
tabId: string;
targetConnectionKey: string;
sourceSessionId?: string;
initialPath?: string;
forceNewTab?: boolean;
}): string {
return [
params.side,
params.tabId,
params.targetConnectionKey,
params.sourceSessionId ?? "",
params.initialPath ?? "",
params.forceNewTab ? "force-new-tab" : "",
].join("\u0000");
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
@@ -249,6 +291,7 @@ export const useSftpConnections = ({
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
const connectInFlightRef = useRef<Map<string, Promise<void>>>(new Map());
const setPendingHostKeyVerification = useCallback((
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
@@ -426,21 +469,31 @@ export const useSftpConnections = ({
// immediately, avoiding race conditions with deferred effects.
options?.onTabCreated?.(activeTabId);
const connectionId = createSftpConnectionId(side);
const connectInFlightKey = buildSftpConnectInFlightKey({
side,
tabId: activeTabId,
targetConnectionKey,
sourceSessionId: host === "local" ? undefined : options?.sourceSessionId,
initialPath: effectiveInitialPath,
forceNewTab: options?.forceNewTab,
});
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
const getTargetPane = () => {
const targetSide = resolveTargetSide();
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
return tabs.find((tab) => tab.id === activeTabId) ?? null;
};
const isTargetConnectionCurrent = () => {
const pane = getTargetPane();
if (!pane) return false;
if (pane.connection?.id === connectionId) return true;
return !pane.connection && navSeqRef.current[side] === connectRequestId;
};
return runSftpConnectOnceByKey(connectInFlightRef.current, connectInFlightKey, async () => {
const connectionId = createSftpConnectionId(side);
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
const getTargetPane = () => {
const targetSide = resolveTargetSide();
const tabs = targetSide === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
return tabs.find((tab) => tab.id === activeTabId) ?? null;
};
const isTargetConnectionCurrent = () => {
const pane = getTargetPane();
if (!pane) return false;
if (pane.connection?.id === connectionId) return true;
return !pane.connection && navSeqRef.current[side] === connectRequestId;
};
const isTargetConnectionAtPath = (path: string) => {
const connection = getTargetPane()?.connection;
if (!connection) return navSeqRef.current[side] === connectRequestId;
@@ -573,9 +626,7 @@ export const useSftpConnections = ({
sharedHostCache?.path,
);
// Sudo never reuses a terminal shell; normalize before UI state so
// reusedConnection (spinner/overlay) matches the actual open path.
const sourceSessionId = host.sftpSudo ? undefined : options?.sourceSessionId;
const sourceSessionId = options?.sourceSessionId;
const connection: SftpConnection = {
id: connectionId,
@@ -698,15 +749,7 @@ export const useSftpConnections = ({
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
const candidates = buildSftpHomeDirCandidates(credentials.username);
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
@@ -784,6 +827,23 @@ export const useSftpConnections = ({
// root also failed
}
}
// Last resort: home candidates. Covers provisional "/" from realpath
// when discovery treated root as home and listing it failed, so
// /home/<user> or /root can still recover the session.
if (!fallbackSucceeded) {
for (const candidate of buildSftpHomeDirCandidates(credentials.username)) {
if (candidate === startPath) continue;
try {
files = await listRemoteFiles(sftpId, candidate, filenameEncoding);
startPath = candidate;
homeDir = candidate;
fallbackSucceeded = true;
break;
} catch {
// Ignore missing/permission errors
}
}
}
if (!fallbackSucceeded) {
throw new Error("Cannot list any remote directory");
}
@@ -855,6 +915,7 @@ export const useSftpConnections = ({
unsubSftpProgress?.();
}
}
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -842,6 +842,9 @@ export const useSftpExternalOperations = (
writeLocalFile: bridge?.writeLocalFile,
mkdirLocal: bridge?.mkdirLocal,
statLocal: bridge?.statLocal,
// No-follow stats so Replace unlinks a same-named symlink instead of
// writing through it (uploadService prefers lstat* over followed stat*).
lstatLocal: bridge?.lstatLocal,
deleteLocalFile: bridge?.deleteLocalFile,
stageUploadFile: bridge?.stageUploadFile,
cancelStagedUploadFile: bridge?.cancelStagedUploadFile,
@@ -857,10 +860,18 @@ export const useSftpExternalOperations = (
if (!b?.statSftp) return null;
return b.statSftp(sftpId, path);
},
deleteSftp: async (sftpId: string, path: string) => {
// Only wire when present so uploadService can fall back via `lstat ?? stat`.
lstatSftp: bridge?.lstatSftp
? async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (!b?.lstatSftp) return null;
return b.lstatSftp(sftpId, path);
}
: undefined,
deleteSftp: async (sftpId: string, path: string, expectedType) => {
const b = netcattyBridge.get();
if (b?.deleteSftp) {
await b.deleteSftp(sftpId, path);
await b.deleteSftp(sftpId, path, undefined, expectedType);
}
},
// Stream transfer for large files (avoids loading into memory).
+61 -3
View File
@@ -12,6 +12,7 @@ import {
describeSftpExistingKind,
describeSftpIncomingKind,
getSftpConflictTypeKey,
shouldUnlinkSftpConflictBeforeReplace,
} from "../../../domain/sftpConflict";
import {
findActivePathConflict,
@@ -23,6 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { logger } from "../../../lib/logger";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { SftpPane } from "./types";
import { isMissingStatError } from "./errors";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
import { useSftpTransferConflictOps } from "./transferConflictOps";
import { useSftpTransferTaskOps } from "./transferTaskOps";
@@ -444,7 +446,7 @@ export const useSftpTransfers = ({
cleanupTaskArtifacts,
});
const { statTargetPath, getDuplicateTarget } = useSftpTransferConflictOps();
const { statTargetPath, getDuplicateTarget, deleteTargetPath } = useSftpTransferConflictOps();
const { transferFile, transferDirectory } = useSftpDirectoryTransferOps({
ownerId,
@@ -740,8 +742,10 @@ export const useSftpTransfers = ({
newModified: sourceStat?.mtime || Date.now(),
};
}
} catch {
// ignore
} catch (error) {
// Missing path = no conflict. ENOTSUP / unknown type fail closed.
if (isMissingStatError(error)) return null;
throw error;
}
return null;
})();
@@ -783,6 +787,13 @@ export const useSftpTransfers = ({
return "failed";
}
if (
defaultAction === "replace"
&& shouldUnlinkSftpConflictBeforeReplace(conflict.existingType)
) {
await deleteTargetPath(task, targetPane, targetSftpId, targetEncoding, "symlink");
}
const duplicateTarget = defaultAction === "duplicate"
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
: null;
@@ -1537,6 +1548,7 @@ export const useSftpTransfers = ({
const updatedTasks: TransferTask[] = [];
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
const failedReplaceTasks: Array<{ task: TransferTask; error: string }> = [];
for (const affectedTask of affectedTasks) {
if (cancelledTasksRef.current.has(affectedTask.id)) continue;
@@ -1568,6 +1580,34 @@ export const useSftpTransfers = ({
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
continue;
}
if (
affectedConflict
&& shouldUnlinkSftpConflictBeforeReplace(affectedConflict.existingType)
) {
const endpoints = resolveTaskEndpoints(affectedTask);
if (!endpoints?.targetPane.connection) continue;
const targetSftpId = endpoints.targetPane.connection.isLocal
? null
: sftpSessionsRef.current.get(endpoints.targetPane.connection.id) ?? null;
const targetEncoding = endpoints.targetPane.connection.isLocal
? "auto"
: endpoints.targetPane.filenameEncoding || "auto";
try {
await deleteTargetPath(
affectedTask,
endpoints.targetPane,
targetSftpId,
targetEncoding,
"symlink",
);
} catch (error) {
failedReplaceTasks.push({
task: affectedTask,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
}
updatedTask = {
...affectedTask,
skipConflictCheck: true,
@@ -1607,6 +1647,24 @@ export const useSftpTransfers = ({
);
}
if (failedReplaceTasks.length > 0) {
const failedErrors = new Map(
failedReplaceTasks.map(({ task: failedTask, error }) => [failedTask.id, error]),
);
setTransfers((prev) =>
prev.map((candidate) => failedErrors.has(candidate.id)
? {
...candidate,
status: "failed" as TransferStatus,
endTime: Date.now(),
error: failedErrors.get(candidate.id),
conflict: undefined,
}
: candidate,
),
);
}
const liveUpdatedTasks = updatedTasks.filter((candidate) => !cancelledTasksRef.current.has(candidate.id));
const updatedTaskMap = new Map(liveUpdatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
setTransfers((prev) =>
@@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
test("startup landing setting participates in cross-window settings sync", () => {
const storageSyncSource = readFileSync(new URL("./settingsStorageSync.ts", import.meta.url), "utf8");
const ipcSyncSource = readFileSync(new URL("./settingsIpcSync.ts", import.meta.url), "utf8");
assert.match(storageSyncSource, /STORAGE_KEY_STARTUP_LANDING/);
assert.match(storageSyncSource, /setStartupLandingState/);
assert.match(storageSyncSource, /e\.key === STORAGE_KEY_STARTUP_LANDING/);
assert.match(ipcSyncSource, /STORAGE_KEY_STARTUP_LANDING/);
assert.match(ipcSyncSource, /setStartupLandingState/);
assert.match(ipcSyncSource, /key === STORAGE_KEY_STARTUP_LANDING/);
});
test("startup landing does not bump the cloud sync settings version", () => {
const settingsSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
const settingsVersionIndex = settingsSource.indexOf("settingsVersion: useMemo");
const settingsVersionSource = settingsSource.slice(settingsVersionIndex);
assert.notEqual(settingsVersionIndex, -1);
assert.doesNotMatch(settingsVersionSource, /startupLanding/);
});
test("main window opens local terminal on cold start when preferred", () => {
const sideEffectsSource = readFileSync(new URL("../app/AppSideEffects.tsx", import.meta.url), "utf8");
assert.match(sideEffectsSource, /startupLocalTerminalAttemptedRef/);
assert.match(sideEffectsSource, /shouldOpenLocalTerminalOnStartup/);
assert.match(sideEffectsSource, /STORAGE_KEY_STARTUP_LANDING/);
assert.match(sideEffectsSource, /handleCreateLocalTerminal\(/);
assert.match(sideEffectsSource, /onColdStartIntentsSettled/);
assert.match(sideEffectsSource, /ensureDiscoveredShells/);
assert.match(sideEffectsSource, /hasQueuedStartupIntent:\s*startupLaunchIntentReceivedRef\.current/);
const latchAt = sideEffectsSource.indexOf("if (startupLocalTerminalAttemptedRef.current) return;");
const setLatchAt = sideEffectsSource.indexOf(
"startupLocalTerminalAttemptedRef.current = true;",
latchAt,
);
const createAt = sideEffectsSource.indexOf("handleCreateLocalTerminal(", setLatchAt);
assert.notEqual(latchAt, -1);
assert.notEqual(setLatchAt, -1);
assert.notEqual(createAt, -1);
assert.ok(setLatchAt < createAt, "StrictMode latch must be set before creating the terminal");
});
test("settings system tab exposes startup landing control", () => {
const tabSource = readFileSync(
new URL("../../components/settings/tabs/SettingsSystemTab.tsx", import.meta.url),
"utf8",
);
assert.match(tabSource, /system-startup-landing/);
assert.match(tabSource, /settings\.sessionRestore\.startupLanding/);
assert.match(tabSource, /local-terminal/);
});
@@ -6,6 +6,7 @@ import {
canReuseTerminalConnection,
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
isTerminalSessionEligibleForSftpReuse,
} from "./terminalConnectionReuse";
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
@@ -24,6 +25,14 @@ test("connected SSH sessions can reuse their authenticated connection", () => {
assert.equal(canReuseTerminalConnection(session({ protocol: undefined })), true);
});
test("SSH sessions stay SFTP-linkable while reconnecting", () => {
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ status: "connecting" })), true);
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ status: "disconnected" })), true);
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ protocol: "local" })), false);
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ moshEnabled: true })), false);
assert.equal(isTerminalSessionEligibleForSftpReuse(session({ etEnabled: true })), false);
});
test("non-SSH or unavailable sessions do not reuse a connection", () => {
assert.equal(canReuseTerminalConnection(session({ status: "connecting" })), false);
assert.equal(canReuseTerminalConnection(session({ status: "disconnected" })), false);
+9 -5
View File
@@ -1,14 +1,18 @@
import type { TerminalSession } from "../../domain/models";
export function canReuseTerminalConnection(session: TerminalSession): boolean {
/** SSH (non-mosh/et) sessions that can back SFTP once the transport is up. */
export function isTerminalSessionEligibleForSftpReuse(session: TerminalSession): boolean {
return (
(session.protocol === "ssh" || session.protocol === undefined) &&
!session.moshEnabled &&
!session.etEnabled &&
session.status === "connected"
(session.protocol === "ssh" || session.protocol === undefined)
&& !session.moshEnabled
&& !session.etEnabled
);
}
export function canReuseTerminalConnection(session: TerminalSession): boolean {
return isTerminalSessionEligibleForSftpReuse(session) && session.status === "connected";
}
type CloneSessionOptions = {
id: string;
localShellType?: TerminalSession["shellType"];
+368
View File
@@ -490,6 +490,374 @@ test("does not replace an existing directory when uploading a same-named file",
assert.match(results[0].error ?? "", /directory/i);
});
test("file replace leaves the remote destination so upload can restore mode bits", async () => {
// Deleting before overwrite recreates the inode with umask defaults and drops
// bits like +x (#2954). Keep the target so stage+rename can restore mode.
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
const deletedPaths: string[] = [];
const deletedTypes: Array<string | undefined> = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/usr/local/bin",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
statSftp: async (_sftpId, path) =>
path === "/usr/local/bin/tool.sh"
? { type: "file", size: 9, lastModified: 1000 }
: null,
deleteSftp: async (_sftpId, path, expectedType) => {
deletedPaths.push(path);
deletedTypes.push(expectedType);
},
startStreamTransfer: async ({ targetPath: path }) => {
uploadedPaths.push(path);
return { transferId: "upload-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(deletedPaths, [], "file replace must not delete before upload");
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("file replace unlinks an existing symlink before upload", async () => {
// Leaving the symlink would let in-place upload follow it and overwrite the
// link target outside the displayed directory.
// Conflict checks use lstatSftp so Replace unlinks the link first.
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
const deletedPaths: string[] = [];
const deletedTypes: Array<string | undefined> = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/usr/local/bin",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
// Followed stat would report type "file" and skip pre-delete.
statSftp: async () => ({ type: "file", size: 4096, lastModified: 1000 }),
lstatSftp: async (_sftpId, path) =>
path === "/usr/local/bin/tool.sh"
? { type: "symlink", size: 12, lastModified: 1000 }
: null,
deleteSftp: async (_sftpId, path, expectedType) => {
deletedPaths.push(path);
deletedTypes.push(expectedType);
},
startStreamTransfer: async ({ targetPath: path }) => {
uploadedPaths.push(path);
return { transferId: "upload-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(deletedPaths, ["/usr/local/bin/tool.sh"]);
assert.deepEqual(deletedTypes, ["symlink"]);
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("LSTAT ENOTSUP is not treated as an absent destination during conflict check", async () => {
// If unsupported LSTAT is swallowed as "missing", upload skips Replace and may
// write through an existing symlink to a target outside the displayed dir.
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
let uploaded = 0;
let conflictPrompts = 0;
await assert.rejects(
() => uploadFromFileList(
[file],
{
targetPath: "/usr/local/bin",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => {
const error = new Error(
"Remote server does not support LSTAT; cannot classify path without following symlinks",
) as Error & { code: string; lstatUnavailable: boolean };
error.code = "ENOTSUP";
error.lstatUnavailable = true;
throw error;
},
startStreamTransfer: async () => {
uploaded += 1;
return { transferId: "upload-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
conflictPrompts += 1;
return "replace";
},
},
),
(error: Error & { code?: string }) => {
assert.equal(error.code, "ENOTSUP");
assert.match(String(error.message), /does not support LSTAT/i);
return true;
},
);
assert.equal(uploaded, 0, "must not upload when destination type is unknown");
assert.equal(conflictPrompts, 0, "must not pretend destination is missing");
});
test("ENOENT from lstat still means absent destination for conflict check", async () => {
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/usr/local/bin",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => {
const error = new Error("No such file") as Error & { code: string };
error.code = "ENOENT";
throw error;
},
startStreamTransfer: async ({ targetPath: path }) => {
uploadedPaths.push(path);
return { transferId: "upload-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
throw new Error("absent destination must not prompt for conflict");
},
},
);
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("Electron-wrapped lstat absence still means absent destination", async () => {
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/tool.sh" });
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/usr/local/bin",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => {
throw new Error("Error invoking remote method 'netcatty:sftp:lstat': Error: No such file");
},
startStreamTransfer: async ({ targetPath: path }) => {
uploadedPaths.push(path);
return { transferId: "upload-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => {
throw new Error("absent destination must not prompt for conflict");
},
},
);
assert.deepEqual(uploadedPaths, ["/usr/local/bin/tool.sh"]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("compressed-upload LSTAT ENOTSUP is not treated as an absent destination", async (t) => {
let compressedStarts = 0;
installCompressedUploadBridge(t, {
onStart: () => { compressedStarts += 1; },
});
let streamCalls = 0;
await assert.rejects(
() => uploadFromFileList(
[createPickedFolderFile()],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
lstatSftp: async () => {
const error = new Error(
"Remote server does not support LSTAT; cannot classify path without following symlinks",
) as Error & { code: string; lstatUnavailable: boolean };
error.code = "ENOTSUP";
error.lstatUnavailable = true;
throw error;
},
startStreamTransfer: async (payload) => {
streamCalls += 1;
return { transferId: payload.transferId };
},
},
joinPath: (base, name) => `${base}/${name}`,
useCompressedUpload: true,
resolveConflict: async () => "merge",
},
),
(error: Error & { code?: string }) => {
assert.equal(error.code, "ENOTSUP");
return true;
},
);
assert.equal(compressedStarts, 0);
assert.equal(streamCalls, 0);
});
test("directory replace unlinks an existing symlink before mkdir", async () => {
// No-follow lstat reports symlink; Replace must unlink then create the dir
// (followed stat used to classify symlink-to-dir as directory and succeed).
const file = new File(["nested"], "file.txt", { lastModified: 1234 });
Object.defineProperty(file, "path", { value: "/local/docs/file.txt" });
Object.defineProperty(file, "webkitRelativePath", { value: "docs/file.txt" });
const deletedPaths: string[] = [];
const deletedTypes: Array<string | undefined> = [];
const madeDirs: string[] = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/remote",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async (_sftpId, path) => {
madeDirs.push(path);
},
lstatSftp: async (_sftpId, path) =>
path === "/remote/docs"
? { type: "symlink", size: 8, lastModified: 1000 }
: null,
deleteSftp: async (_sftpId, path, expectedType) => {
deletedPaths.push(path);
deletedTypes.push(expectedType);
},
startStreamTransfer: async ({ targetPath: path }) => {
uploadedPaths.push(path);
return { transferId: "upload-dir-1" };
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(deletedPaths, ["/remote/docs"]);
assert.deepEqual(deletedTypes, ["symlink"]);
assert.ok(madeDirs.includes("/remote/docs"), `expected mkdir after unlink, got ${JSON.stringify(madeDirs)}`);
assert.deepEqual(uploadedPaths, ["/remote/docs/file.txt"]);
assert.equal(results.some((r) => r.success), true);
});
test("local file replace unlinks an existing symlink before writeLocalFile", async () => {
// Pathless File uploads fall back to writeLocalFile, which follows symlinks.
// Conflict checks use lstatLocal so Replace unlinks the link first.
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
const deletedPaths: string[] = [];
const deletedTypes: Array<string | undefined> = [];
const writtenPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/Users/me/bin",
sftpId: null,
isLocal: true,
bridge: {
mkdirSftp: async () => {},
lstatLocal: async (path) =>
path === "/Users/me/bin/tool.sh"
? { type: "symlink", size: 12, lastModified: 1000 }
: null,
deleteLocalFile: async (path, expectedType) => {
deletedPaths.push(path);
deletedTypes.push(expectedType);
},
writeLocalFile: async (path) => {
writtenPaths.push(path);
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(deletedPaths, ["/Users/me/bin/tool.sh"]);
assert.deepEqual(deletedTypes, ["symlink"]);
assert.deepEqual(writtenPaths, ["/Users/me/bin/tool.sh"]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("local file replace unlinks a regular file before writeLocalFile", async () => {
const file = new File(["new-bytes"], "tool.sh", { lastModified: 1234 });
const operations: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/Users/me/bin",
sftpId: null,
isLocal: true,
bridge: {
mkdirSftp: async () => {},
lstatLocal: async (path) =>
path === "/Users/me/bin/tool.sh"
? { type: "file", size: 12, lastModified: 1000 }
: null,
deleteLocalFile: async (path, expectedType) => {
operations.push(`delete:${path}:${expectedType}`);
},
writeLocalFile: async (path) => {
operations.push(`write:${path}`);
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(operations, [
"delete:/Users/me/bin/tool.sh:file",
"write:/Users/me/bin/tool.sh",
]);
assert.equal(results.length, 1);
assert.equal(results[0].success, true);
});
test("counts apply-to-all upload conflicts by incoming and existing type", async () => {
const files = [
new File(["local"], "existing-file", { lastModified: 1234 }),
+36 -15
View File
@@ -42,6 +42,7 @@ import {
import {
activateDraftView,
clearScopeDraftState,
draftsByScopeEqualIgnoringAllComposerText,
ensureDraftForScopeState,
pruneStaleSessionPanelViews,
setDraftView,
@@ -786,18 +787,21 @@ export function useAIState() {
const ensureDraftForScope = useCallback((scopeKey: string, agentId: string): void => {
let nextDraftsByScope: DraftsByScope | null = null;
let textOnly = false;
setDraftsByScopeRaw((prev) => {
const next = ensureDraftForScopeState(prev, scopeKey, agentId);
if (next === prev) return prev;
nextDraftsByScope = next;
textOnly = draftsByScopeEqualIgnoringAllComposerText(prev, next);
return next;
});
if (!nextDraftsByScope) return;
bumpDraftMutationVersion(scopeKey);
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
if (textOnly) return;
bumpDraftMutationVersion(scopeKey);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}, []);
@@ -806,52 +810,69 @@ export function useAIState() {
fallbackAgentId: string,
updater: (draft: AIDraft) => AIDraft,
): void => {
let nextDraftsByScope: DraftsByScope | null = null;
let textOnly = false;
setDraftsByScopeRaw((prev) => {
const next = updateDraftForScope(
prev,
scopeKey,
fallbackAgentId,
(draft) => {
const updated = updater(draft);
if (updated === draft) return draft;
return {
...updater(draft),
...updated,
updatedAt: Date.now(),
};
},
);
setLatestAIDraftsByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
if (next === prev) return prev;
nextDraftsByScope = next;
textOnly = draftsByScopeEqualIgnoringAllComposerText(prev, next);
return next;
});
if (!nextDraftsByScope) return;
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
if (textOnly) return;
bumpDraftMutationVersion(scopeKey);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}, []);
const updateDraftIfPresent = useCallback((
scopeKey: string,
updater: (draft: AIDraft) => AIDraft,
): void => {
let updated = false;
let nextDraftsByScope: DraftsByScope | null = null;
let textOnly = false;
setDraftsByScopeRaw((prev) => {
const currentDraft = prev[scopeKey];
if (!currentDraft) return prev;
const nextDraft = {
...updater(currentDraft),
updatedAt: Date.now(),
};
const updated = updater(currentDraft);
const nextDraft = updated === currentDraft
? currentDraft
: {
...updated,
updatedAt: Date.now(),
};
if (nextDraft === currentDraft) return prev;
const next = {
...prev,
[scopeKey]: nextDraft,
};
updated = true;
setLatestAIDraftsByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
nextDraftsByScope = next;
textOnly = draftsByScopeEqualIgnoringAllComposerText(prev, next);
return next;
});
if (updated) {
bumpDraftMutationVersion(scopeKey);
}
if (!nextDraftsByScope) return;
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
if (textOnly) return;
bumpDraftMutationVersion(scopeKey);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}, []);
const showDraftView = useCallback((scopeKey: string) => {
+18 -4
View File
@@ -22,10 +22,16 @@ let agentDiscoveryWriteGeneration = 0;
export function useAgentDiscovery(
externalAgents: ExternalAgentConfig[],
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
options?: { enabled?: boolean },
options?: {
enabled?: boolean;
schedule?: (task: () => void) => () => void;
},
) {
const enabled = options?.enabled ?? true;
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const schedule = options?.schedule;
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>(
() => agentDiscoveryCache?.agents ?? [],
);
const [isDiscovering, setIsDiscovering] = useState(false);
const discoverSeqRef = useRef(0);
const mountedRef = useRef(true);
@@ -116,8 +122,16 @@ export function useAgentDiscovery(
if (!cancelled) void discover();
};
if (schedule) {
const cancel = schedule(runDiscover);
return () => {
cancelled = true;
cancel();
};
}
if (typeof requestIdleCallback === 'function') {
const idleId = requestIdleCallback(runDiscover, { timeout: 2000 });
const idleId = requestIdleCallback(runDiscover);
return () => {
cancelled = true;
cancelIdleCallback(idleId);
@@ -129,7 +143,7 @@ export function useAgentDiscovery(
cancelled = true;
clearTimeout(timeoutId);
};
}, [discover, enabled]);
}, [discover, enabled, schedule]);
// Auto-update args for already-configured discovered agents when
// the canonical args from discovery change (e.g. after an app update).
+79
View File
@@ -0,0 +1,79 @@
import { useCallback } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { RuntimeAppLockReason } from './useAppLockRuntime';
let rendererReadySent = false;
export function useAppLockBridge() {
const getRuntimeState = useCallback(async () => {
return netcattyBridge.get()?.getAppLockRuntimeState?.();
}, []);
const getSettings = useCallback(async () => {
return netcattyBridge.get()?.getAppLockSettings?.();
}, []);
const setRuntimeLocked = useCallback(async (reason: Exclude<RuntimeAppLockReason, null>) => {
return netcattyBridge.get()?.setAppLockRuntimeLocked?.(reason);
}, []);
const requestUnlock = useCallback(async (password: string) => {
return netcattyBridge.get()?.requestAppLockUnlock?.(password) ?? { ok: false as const, error: 'incorrect' as const };
}, []);
const requestReset = useCallback(async (currentPassword: string) => {
return netcattyBridge.get()?.requestAppLockReset?.(currentPassword);
}, []);
const reportActivity = useCallback(async () => {
await netcattyBridge.get()?.reportAppLockActivity?.();
}, []);
const onRuntimeStateChanged = useCallback((listener: (state: unknown) => void) => {
try {
return netcattyBridge.get()?.onAppLockRuntimeStateChanged?.(listener) ?? (() => {});
} catch {
return () => {};
}
}, []);
const onSettingsChanged = useCallback((listener: (settings: unknown) => void) => {
try {
return netcattyBridge.get()?.onAppLockSettingsChanged?.(listener) ?? (() => {});
} catch {
return () => {};
}
}, []);
const notifyRendererReady = useCallback(() => {
if (rendererReadySent) return;
rendererReadySent = true;
try {
netcattyBridge.get()?.rendererReady?.();
} catch {
// ignore
}
}, []);
const onAppLockReopen = useCallback((listener: () => void) => {
try {
return netcattyBridge.get()?.onAppLockReopen?.(listener) ?? (() => {});
} catch {
return () => {};
}
}, []);
return {
getRuntimeState,
getSettings,
setRuntimeLocked,
requestUnlock,
requestReset,
reportActivity,
onRuntimeStateChanged,
onSettingsChanged,
notifyRendererReady,
onAppLockReopen,
};
}
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
normalizeRuntimeAppLockState,
selectPreferredRuntimeAppLockState,
} from "./useAppLockRuntime.ts";
test("normalizeRuntimeAppLockState defaults safely", () => {
assert.deepEqual(normalizeRuntimeAppLockState(null), {
initialized: false,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: null,
lastActivityAt: null,
});
});
test("normalizeRuntimeAppLockState preserves a valid runtime payload", () => {
assert.deepEqual(
normalizeRuntimeAppLockState({
initialized: true,
locked: true,
reason: "startup",
version: 3,
lastLockedAt: 123,
lastUnlockedAt: null,
lastActivityAt: 456,
}),
{
initialized: true,
locked: true,
reason: "startup",
version: 3,
lastLockedAt: 123,
lastUnlockedAt: null,
lastActivityAt: 456,
},
);
});
test("selectPreferredRuntimeAppLockState keeps the newer runtime version", () => {
const current = normalizeRuntimeAppLockState({
initialized: true,
locked: false,
reason: null,
version: 4,
lastLockedAt: 100,
lastUnlockedAt: 200,
lastActivityAt: 300,
});
const staleIncoming = normalizeRuntimeAppLockState({
initialized: true,
locked: true,
reason: "startup",
version: 3,
lastLockedAt: 50,
lastUnlockedAt: null,
lastActivityAt: 60,
});
assert.deepEqual(
selectPreferredRuntimeAppLockState(current, staleIncoming),
current,
);
});
test("selectPreferredRuntimeAppLockState prefers initialized state when versions tie", () => {
const current = normalizeRuntimeAppLockState({
initialized: true,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: 200,
lastActivityAt: 300,
});
const uninitializedIncoming = normalizeRuntimeAppLockState({
initialized: false,
locked: false,
reason: null,
version: 0,
lastLockedAt: null,
lastUnlockedAt: null,
lastActivityAt: null,
});
assert.deepEqual(
selectPreferredRuntimeAppLockState(current, uninitializedIncoming),
current,
);
});
+91
View File
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useState } from 'react';
export type RuntimeAppLockReason = 'startup' | 'idle' | 'manual' | 'background' | null;
export interface RuntimeAppLockState {
initialized: boolean;
locked: boolean;
reason: RuntimeAppLockReason;
version: number;
lastLockedAt: number | null;
lastUnlockedAt: number | null;
lastActivityAt: number | null;
}
export function normalizeRuntimeAppLockState(input: unknown): RuntimeAppLockState {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
const reason = record?.reason;
return {
initialized: record?.initialized === true,
locked: record?.locked === true,
reason:
reason === 'startup' ||
reason === 'idle' ||
reason === 'manual' ||
reason === 'background'
? reason
: null,
version: typeof record?.version === 'number' && Number.isFinite(record.version) ? record.version : 0,
lastLockedAt: typeof record?.lastLockedAt === 'number' && Number.isFinite(record.lastLockedAt) ? record.lastLockedAt : null,
lastUnlockedAt: typeof record?.lastUnlockedAt === 'number' && Number.isFinite(record.lastUnlockedAt) ? record.lastUnlockedAt : null,
lastActivityAt: typeof record?.lastActivityAt === 'number' && Number.isFinite(record.lastActivityAt) ? record.lastActivityAt : null,
};
}
const DEFAULT_RUNTIME_APP_LOCK_STATE: RuntimeAppLockState = normalizeRuntimeAppLockState(null);
export function selectPreferredRuntimeAppLockState(
current: RuntimeAppLockState,
incoming: RuntimeAppLockState,
): RuntimeAppLockState {
if (incoming.version > current.version) return incoming;
if (incoming.version < current.version) return current;
if (incoming.initialized && !current.initialized) return incoming;
if (!incoming.initialized && current.initialized) return current;
return incoming;
}
export function useAppLockRuntime(
bridge: Pick<
NonNullable<NetcattyBridge>,
'getAppLockRuntimeState' | 'onAppLockRuntimeStateChanged'
> | null | undefined,
) {
const [runtimeState, setRuntimeState] = useState<RuntimeAppLockState>(DEFAULT_RUNTIME_APP_LOCK_STATE);
const refreshRuntimeState = useCallback(async () => {
const nextState = await bridge?.getAppLockRuntimeState?.();
const normalized = normalizeRuntimeAppLockState(nextState);
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
return normalized;
}, [bridge]);
useEffect(() => {
let cancelled = false;
const unsubscribe = bridge?.onAppLockRuntimeStateChanged?.((nextState) => {
if (cancelled) return;
const normalized = normalizeRuntimeAppLockState(nextState);
setRuntimeState((current) => selectPreferredRuntimeAppLockState(current, normalized));
}) ?? (() => {});
void refreshRuntimeState().catch(() => {
if (cancelled) return;
setRuntimeState((current) => selectPreferredRuntimeAppLockState(
current,
DEFAULT_RUNTIME_APP_LOCK_STATE,
));
});
return () => {
cancelled = true;
unsubscribe();
};
}, [bridge, refreshRuntimeState]);
return {
runtimeState,
setRuntimeState,
refreshRuntimeState,
};
}
+182
View File
@@ -0,0 +1,182 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AppLockPasswordVerifier, AppLockSettings } from "../../domain/appLock.ts";
import {
createOptimisticUnlockedRuntimeState,
DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS,
getIdleLockDelayMs,
normalizeAppLockSystemUnlockStatus,
normalizeAppLockSystemUnlockResult,
resolveUnlockAttempt,
shouldLockAfterIdle,
shouldLockOnStartup,
} from "./useAppLockState.ts";
import { selectPreferredRuntimeAppLockState } from "./useAppLockRuntime.ts";
const verifier: AppLockPasswordVerifier = {
version: 1,
algorithm: "PBKDF2-SHA256",
iterations: 210000,
salt: Buffer.alloc(16, 1).toString("base64"),
hash: Buffer.alloc(32, 2).toString("base64"),
};
test("shouldLockOnStartup locks only when enabled with a verifier", () => {
const enabled: AppLockSettings = {
enabled: true,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: verifier,
};
assert.equal(shouldLockOnStartup(enabled), true);
assert.equal(shouldLockOnStartup({ ...enabled, enabled: false }), false);
assert.equal(shouldLockOnStartup({ ...enabled, passwordVerifier: null }), false);
});
test("shouldLockAfterIdle honors the configured timeout", () => {
const settings: AppLockSettings = {
enabled: true,
timeoutMinutes: 5,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: verifier,
};
assert.equal(shouldLockAfterIdle(settings, 1_000, 1_000 + 5 * 60_000 - 1), false);
assert.equal(shouldLockAfterIdle(settings, 1_000, 1_000 + 5 * 60_000), true);
assert.equal(shouldLockAfterIdle({ ...settings, timeoutMinutes: 0 }, 1_000, 1_000 + 60 * 60_000), false);
assert.equal(shouldLockAfterIdle({ ...settings, enabled: false }, 1_000, 1_000 + 60 * 60_000), false);
assert.equal(shouldLockAfterIdle({ ...settings, passwordVerifier: null }, 1_000, 1_000 + 60 * 60_000), false);
});
test("getIdleLockDelayMs schedules the next check after remaining idle time", () => {
const settings: AppLockSettings = {
enabled: true,
timeoutMinutes: 5,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: verifier,
};
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000), 5 * 60_000);
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000 + 4 * 60_000), 60_000);
assert.equal(getIdleLockDelayMs(settings, 1_000, 1_000 + 5 * 60_000), 0);
assert.equal(getIdleLockDelayMs({ ...settings, timeoutMinutes: 0 }, 1_000, 1_000), null);
assert.equal(getIdleLockDelayMs({ ...settings, enabled: false }, 1_000, 1_000), null);
assert.equal(getIdleLockDelayMs({ ...settings, passwordVerifier: null }, 1_000, 1_000), null);
});
test("createOptimisticUnlockedRuntimeState clears stale locked state after successful unlock", () => {
const nextState = createOptimisticUnlockedRuntimeState(
{
initialized: false,
locked: true,
reason: "startup",
version: 7,
lastLockedAt: 2_000,
lastUnlockedAt: null,
lastActivityAt: 1_000,
},
5_000,
);
assert.deepEqual(nextState, {
initialized: true,
locked: false,
reason: null,
// Optimistic unlock must not invent a higher version than main has sent.
version: 7,
lastLockedAt: 2_000,
lastUnlockedAt: 5_000,
lastActivityAt: 5_000,
});
});
test("createOptimisticUnlockedRuntimeState keeps observed version so concurrent re-lock can win", () => {
const optimistic = createOptimisticUnlockedRuntimeState(
{
initialized: true,
locked: true,
reason: "startup",
version: 7,
lastLockedAt: 2_000,
lastUnlockedAt: null,
lastActivityAt: 1_000,
},
5_000,
);
assert.equal(optimistic.version, 7);
assert.equal(optimistic.locked, false);
// A concurrent main re-lock at version 8 must outrank the optimistic unlock.
const preferred = selectPreferredRuntimeAppLockState(optimistic, {
...optimistic,
locked: true,
reason: "manual",
version: 8,
lastLockedAt: 6_000,
});
assert.equal(preferred.locked, true);
assert.equal(preferred.version, 8);
});
test("normalizes app lock system unlock bridge status and results", () => {
assert.deepEqual(DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS, {
supported: false,
available: false,
enabled: false,
platform: "unsupported",
label: null,
reason: null,
});
assert.deepEqual(
normalizeAppLockSystemUnlockStatus({
supported: true,
available: true,
enabled: true,
platform: "win32",
label: "Windows Hello",
reason: "",
}),
{
supported: true,
available: true,
enabled: true,
platform: "win32",
label: "Windows Hello",
reason: null,
},
);
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: true }), { ok: true });
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: false, error: "cancelled" }), {
ok: false,
error: "cancelled",
});
assert.deepEqual(normalizeAppLockSystemUnlockResult({ ok: false, error: "unknown" }), {
ok: false,
error: "failed",
});
});
test("resolveUnlockAttempt validates empty, incorrect, and correct passwords", async () => {
const originalWindow = globalThis.window;
globalThis.window = {
netcatty: {
requestAppLockUnlock: async (password: string) =>
password === "secret"
? { ok: true as const }
: { ok: false as const, error: "incorrect" as const },
},
} as typeof window;
try {
assert.deepEqual(await resolveUnlockAttempt(""), { ok: false, error: "empty" });
assert.deepEqual(await resolveUnlockAttempt("wrong"), { ok: false, error: "incorrect" });
assert.deepEqual(await resolveUnlockAttempt("secret"), { ok: true });
} finally {
globalThis.window = originalWindow;
}
});
+265
View File
@@ -0,0 +1,265 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
normalizeAppLockSettings,
type AppLockSettings,
} from '../../domain/appLock';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
normalizeRuntimeAppLockState,
useAppLockRuntime,
type RuntimeAppLockReason,
type RuntimeAppLockState,
} from './useAppLockRuntime';
export type AppLockReason = RuntimeAppLockReason;
export type AppLockUnlockResult =
| { ok: true }
| { ok: false; error: 'empty' | 'incorrect' };
export interface AppLockSystemUnlockStatus {
supported: boolean;
available: boolean;
enabled: boolean;
platform: 'darwin' | 'win32' | 'unsupported';
label: 'Touch ID' | 'Windows Hello' | null;
reason: string | null;
}
export type AppLockSystemUnlockResult =
| { ok: true }
| { ok: false; error: 'disabled' | 'not-locked' | 'unsupported' | 'unavailable' | 'cancelled' | 'failed' };
export const DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS: AppLockSystemUnlockStatus = {
supported: false,
available: false,
enabled: false,
platform: 'unsupported',
label: null,
reason: null,
};
export function normalizeAppLockSystemUnlockStatus(input: unknown): AppLockSystemUnlockStatus {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
const platform = record?.platform === 'darwin' || record?.platform === 'win32'
? record.platform
: 'unsupported';
const label = record?.label === 'Touch ID' || record?.label === 'Windows Hello'
? record.label
: null;
return {
supported: record?.supported === true,
available: record?.available === true,
enabled: record?.enabled === true,
platform,
label,
reason: typeof record?.reason === 'string' && record.reason ? record.reason : null,
};
}
export function normalizeAppLockSystemUnlockResult(input: unknown): AppLockSystemUnlockResult {
const record = input && typeof input === 'object' ? input as Record<string, unknown> : null;
if (record?.ok === true) return { ok: true };
const error = record?.error;
if (
error === 'disabled' ||
error === 'not-locked' ||
error === 'unsupported' ||
error === 'unavailable' ||
error === 'cancelled' ||
error === 'failed'
) {
return { ok: false, error };
}
return { ok: false, error: 'failed' };
}
export function shouldLockOnStartup(settings: AppLockSettings): boolean {
const normalized = normalizeAppLockSettings(settings);
return normalized.enabled && normalized.passwordVerifier !== null;
}
export function shouldLockAfterIdle(
settings: AppLockSettings,
lastActivityAt: number,
now: number,
): boolean {
const normalized = normalizeAppLockSettings(settings);
if (!normalized.enabled || !normalized.passwordVerifier) return false;
if (normalized.timeoutMinutes <= 0) return false;
return now - lastActivityAt >= normalized.timeoutMinutes * 60_000;
}
export function getIdleLockDelayMs(
settings: AppLockSettings,
lastActivityAt: number,
now: number,
): number | null {
const normalized = normalizeAppLockSettings(settings);
if (!normalized.enabled || !normalized.passwordVerifier) return null;
if (normalized.timeoutMinutes <= 0) return null;
const timeoutMs = normalized.timeoutMinutes * 60_000;
return Math.max(0, timeoutMs - (now - lastActivityAt));
}
export async function resolveUnlockAttempt(password: string): Promise<AppLockUnlockResult> {
if (!password) return { ok: false, error: 'empty' };
try {
return await netcattyBridge.get()?.requestAppLockUnlock?.(password) ?? { ok: false, error: 'incorrect' };
} catch {
return { ok: false, error: 'incorrect' };
}
}
export function createOptimisticUnlockedRuntimeState(
input: RuntimeAppLockState,
now: number,
): RuntimeAppLockState {
const current = normalizeRuntimeAppLockState(input);
if (current.initialized && !current.locked && current.reason === null) {
return current;
}
return {
...current,
initialized: true,
locked: false,
reason: null,
// Keep the observed main-process version. Bumping here can outrank a real
// concurrent re-lock and leave the renderer unlocked after refresh (Codex P2).
version: current.version,
lastUnlockedAt: now,
lastActivityAt: now,
};
}
export function useAppLockState(settings: AppLockSettings) {
const normalizedSettings = useMemo(() => normalizeAppLockSettings(settings), [settings]);
const systemUnlockRefreshKey = `${normalizedSettings.enabled}:${normalizedSettings.systemUnlockEnabled}:${Boolean(normalizedSettings.passwordVerifier)}`;
const bridge = netcattyBridge.get();
const { runtimeState, refreshRuntimeState, setRuntimeState } = useAppLockRuntime(bridge);
const [systemUnlockStatus, setSystemUnlockStatus] = useState<AppLockSystemUnlockStatus>(
DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS,
);
const normalizedRuntimeState = useMemo(
() => normalizeRuntimeAppLockState(runtimeState),
[runtimeState],
);
const effectiveRuntimeState = useMemo(() => {
if (normalizedRuntimeState.initialized) return normalizedRuntimeState;
if (shouldLockOnStartup(normalizedSettings)) {
return {
...normalizedRuntimeState,
locked: true,
reason: 'startup' as const,
};
}
return normalizedRuntimeState;
}, [normalizedRuntimeState, normalizedSettings]);
const lockNow = useCallback((reason: AppLockReason = 'manual') => {
if (!shouldLockOnStartup(normalizedSettings) || !reason) return;
void bridge?.setAppLockRuntimeLocked?.(reason);
}, [bridge, normalizedSettings]);
const recordActivity = useCallback(() => {
if (effectiveRuntimeState.locked) return;
void bridge?.reportAppLockActivity?.();
}, [bridge, effectiveRuntimeState.locked]);
const unlock = useCallback(async (password: string): Promise<AppLockUnlockResult> => {
const result = await resolveUnlockAttempt(password);
if (result.ok) {
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}
return result;
}, [refreshRuntimeState, setRuntimeState]);
const refreshSystemUnlockStatus = useCallback(async () => {
try {
const nextStatus = await bridge?.getAppLockSystemUnlockStatus?.();
const normalized = normalizeAppLockSystemUnlockStatus(nextStatus);
setSystemUnlockStatus(normalized);
return normalized;
} catch {
setSystemUnlockStatus(DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS);
return DEFAULT_APP_LOCK_SYSTEM_UNLOCK_STATUS;
}
}, [bridge]);
const unlockWithSystemAuth = useCallback(async (): Promise<AppLockSystemUnlockResult> => {
const result = normalizeAppLockSystemUnlockResult(await bridge?.requestAppLockSystemUnlock?.());
if (result.ok) {
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}
await refreshSystemUnlockStatus().catch(() => {});
return result;
}, [bridge, refreshRuntimeState, refreshSystemUnlockStatus, setRuntimeState]);
const reset = useCallback(async (currentPassword: string) => {
if (typeof bridge?.requestAppLockReset !== 'function') {
throw new Error('App Lock reset bridge is unavailable');
}
const result = await bridge.requestAppLockReset(currentPassword);
if (result && typeof result === 'object' && 'ok' in result && result.ok === false) {
throw new Error(result.error);
}
const unlockedAt = Date.now();
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
await refreshRuntimeState().catch(() => {});
}, [bridge, refreshRuntimeState, setRuntimeState]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings) && effectiveRuntimeState.locked) {
const unlockedAt = Date.now();
void bridge?.requestAppLockUnlock?.('')
?.then((result) => {
if (result?.ok !== true) return;
setRuntimeState((current) => createOptimisticUnlockedRuntimeState(current, unlockedAt));
return refreshRuntimeState();
})
.catch(() => {});
}
}, [bridge, effectiveRuntimeState.locked, normalizedSettings, refreshRuntimeState, setRuntimeState]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings)) return undefined;
const events: Array<keyof WindowEventMap> = ['pointerdown', 'keydown', 'wheel', 'touchstart', 'focus'];
for (const eventName of events) {
window.addEventListener(eventName, recordActivity, { passive: true });
}
return () => {
for (const eventName of events) {
window.removeEventListener(eventName, recordActivity);
}
};
}, [normalizedSettings, recordActivity]);
useEffect(() => {
if (!shouldLockOnStartup(normalizedSettings)) return undefined;
void bridge?.reportAppLockActivity?.();
return undefined;
}, [bridge, normalizedSettings]);
useEffect(() => {
void refreshSystemUnlockStatus();
}, [refreshSystemUnlockStatus, systemUnlockRefreshKey]);
return {
initialized: effectiveRuntimeState.initialized,
locked: effectiveRuntimeState.locked,
lockReason: effectiveRuntimeState.reason,
lockNow,
unlock,
unlockWithSystemAuth,
systemUnlockStatus,
refreshSystemUnlockStatus,
reset,
recordActivity,
resync: refreshRuntimeState,
};
}
+59 -1
View File
@@ -109,6 +109,7 @@ test("enabled convergent startup checks preview empty-vault recovery before sync
"conflictActionOverride: 'download-remote'",
recoveryPromptIndex,
);
const manualTriggerIndex = source.indexOf("trigger: 'manual'", recoveryPromptIndex);
const normalSyncIndex = source.indexOf(
"syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync })",
cloudWinsIndex,
@@ -118,16 +119,41 @@ test("enabled convergent startup checks preview empty-vault recovery before sync
assert.notEqual(previewIndex, -1);
assert.notEqual(recoveryPromptIndex, -1);
assert.notEqual(cloudWinsIndex, -1);
assert.notEqual(manualTriggerIndex, -1);
assert.notEqual(normalSyncIndex, -1);
assert.ok(
convergentGuardIndex < previewIndex
&& previewIndex < recoveryPromptIndex
&& recoveryPromptIndex < cloudWinsIndex
&& recoveryPromptIndex < manualTriggerIndex
&& manualTriggerIndex < cloudWinsIndex
&& cloudWinsIndex < normalSyncIndex,
"v2 startup must offer recovery and use cloud-wins before the normal CRDT sync path",
);
});
test("empty-vault recovery restore bypasses the auto-sync preference gate", () => {
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const recoveryPromptIndex = source.indexOf("requestEmptyVaultRecovery(recoveryPayload)");
const restoreCallIndex = source.indexOf("syncNowRef.current({", recoveryPromptIndex);
const triggerIndex = source.indexOf("trigger: 'manual'", restoreCallIndex);
const conflictIndex = source.indexOf(
"conflictActionOverride: 'download-remote'",
restoreCallIndex,
);
const callEndIndex = source.indexOf("});", conflictIndex);
assert.notEqual(recoveryPromptIndex, -1);
assert.notEqual(restoreCallIndex, -1);
assert.notEqual(triggerIndex, -1);
assert.notEqual(conflictIndex, -1);
assert.ok(
restoreCallIndex < triggerIndex
&& triggerIndex < conflictIndex
&& conflictIndex < callEndIndex,
"recovery Restore must pass trigger:manual so disabled auto-sync cannot block it",
);
});
test("empty v2 startup checks allow a validated empty sync to open the gate", () => {
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const convergentGuardIndex = source.indexOf(
@@ -192,6 +218,38 @@ test("auto-sync skips only the exact remote-applied data hash", () => {
);
});
test("auto syncNow and debounce re-check manager autoSyncEnabled before pushing", () => {
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const helperIndex = source.indexOf("function isPersistedAutoSyncEnabled");
const syncNowIndex = source.indexOf("const syncNow = useCallback");
const autoGateIndex = source.indexOf(
"if (trigger === 'auto' && !isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled))",
syncNowIndex,
);
const debounceIndex = source.indexOf("// Debounced auto-sync when data changes");
const fireTimeGateIndex = source.indexOf(
"if (!isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled))",
debounceIndex,
);
const clearTimerIndex = source.indexOf(
"if (syncTimeoutRef.current) {\n clearTimeout(syncTimeoutRef.current);\n syncTimeoutRef.current = null;\n }\n return;",
debounceIndex,
);
assert.notEqual(helperIndex, -1);
assert.notEqual(syncNowIndex, -1);
assert.notEqual(autoGateIndex, -1);
assert.notEqual(fireTimeGateIndex, -1);
assert.notEqual(clearTimerIndex, -1);
assert.ok(
helperIndex < syncNowIndex
&& syncNowIndex < autoGateIndex
&& debounceIndex < fireTimeGateIndex
&& debounceIndex < clearTimerIndex,
"auto-sync off must gate both syncNow(auto) and the debounced timer path",
);
});
test("startup local-wins and merge round-trips refuse device-bound credential placeholders", () => {
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
const uploadLocalIndex = source.indexOf("if (conflictAction === 'upload-local')");
+36 -1
View File
@@ -17,7 +17,7 @@ import {
healPoisonedSecretsForMerge,
stripSyncPayloadEncryptedCredentials,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { isProviderReadyForSync, SYNC_STORAGE_KEYS, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { mergeSyncPayloads } from '../../domain/syncMerge';
import { materializeSyncPayloadFromConvergentState } from '../../domain/convergentSync';
import {
@@ -51,6 +51,19 @@ import {
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
import { getNotesSnapshot, subscribeNotes } from './notesStore';
/** Prefer dedicated SYNC_PREFERENCES; fall back to legacy SYNC_CONFIG fields. */
function isPersistedAutoSyncEnabled(fallback: boolean): boolean {
const preferences = localStorageAdapter.read<{ autoSync?: boolean }>(
SYNC_STORAGE_KEYS.SYNC_PREFERENCES,
);
if (preferences && typeof preferences === 'object' && preferences.autoSync !== undefined) {
return Boolean(preferences.autoSync);
}
const stored = localStorageAdapter.read<{ autoSync?: boolean }>(SYNC_STORAGE_KEYS.SYNC_CONFIG);
if (!stored || typeof stored !== 'object') return fallback;
return Boolean(stored.autoSync);
}
interface AutoSyncConfig {
enabled?: boolean;
// Data to sync
@@ -312,6 +325,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
const trigger: SyncTrigger = options?.trigger ?? 'auto';
// Defense for #2976: auto pushes must honor the persisted preference even
// when this window's React/manager snapshot is briefly stale after another
// window disables auto-sync.
if (trigger === 'auto' && !isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled)) {
return false;
}
isSyncRunningRef.current = true;
try {
// Get fresh state directly from CloudSyncManager singleton
@@ -671,7 +691,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (recoveryPayload && shouldPromptCloudVaultRecovery(localPayload, recoveryPayload)) {
const userAction = await requestEmptyVaultRecovery(recoveryPayload);
if (userAction === 'restore') {
// Explicit recovery must not honor the auto-sync preference gate:
// the user already confirmed Restore in the dialog.
const restored = await syncNowRef.current({
trigger: 'manual',
notifyOnFailure,
conflictActionOverride: 'download-remote',
});
@@ -973,6 +996,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
// Skip if not ready
if (!sync.hasAnyConnectedProvider || !sync.autoSyncEnabled || !sync.isUnlocked) {
// Drop any pending debounce when auto-sync (or readiness) turns off so a
// timer scheduled while enabled cannot fire after the toggle (#2976).
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = null;
}
return;
}
@@ -1030,6 +1059,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
return;
}
// Re-check at fire time: auto-sync may have been disabled in another
// window during the debounce window (#2976).
if (!isPersistedAutoSyncEnabled(manager.getState().autoSyncEnabled)) {
return;
}
// Wait for the current sync to finish, then this effect will re-run
// because sync.isSyncing changed.
if (sync.isSyncing || isSyncRunningRef.current) {
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { createLatestPayloadSync } from './useExternalMcpSessionSync.ts';
const hookSource = readFileSync(new URL('./useExternalMcpSessionSync.ts', import.meta.url), 'utf8');
const appSource = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
const preloadSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
const handlerSource = readFileSync(
new URL('../../electron/bridges/aiBridge/agentProcessHandlers.cjs', import.meta.url),
'utf8',
);
test('app live session sync stays mounted and is independent of External MCP enablement', () => {
assert.match(appSource, /useExternalMcpSessionSync\(\{/);
assert.match(hookSource, /bridge\.aiMcpUpdateLiveSessions\?\.\(nextPayload\)/);
assert.match(hookSource, /liveSyncRef\.current\.push\(payload, serialized\)/);
assert.match(hookSource, /bridge\.aiMcpUpdateSessions\?\.\(/);
assert.match(hookSource, /externalSyncRef\.current\.push\(payload, serialized\)/);
assert.doesNotMatch(
hookSource,
/if \(!enabled\)[\s\S]{0,300}aiMcpUpdateLiveSessions/,
);
});
test('live session sync has a complete renderer to main-process IPC path', () => {
assert.match(preloadSource, /netcatty:ai:mcp:update-live-sessions/);
assert.match(handlerSource, /netcatty:ai:mcp:update-live-sessions/);
assert.match(handlerSource, /mcpServerBridge\.updateLiveSessionMetadata/);
});
test('latest payload sync converges to A-B-A while B is in flight', async () => {
let releaseB: (() => void) | undefined;
const sent: string[] = [];
const sync = createLatestPayloadSync(async (payload: string) => {
sent.push(payload);
if (payload === 'B') {
await new Promise<void>((resolve) => {
releaseB = resolve;
});
}
return { ok: true };
});
await sync.push('A');
const sendingB = sync.push('B');
void sync.push('A');
releaseB?.();
await sendingB;
assert.deepEqual(sent, ['A', 'B', 'A']);
});
test('cancelling a payload sync drops a queued update', async () => {
let releaseA: (() => void) | undefined;
const sent: string[] = [];
const sync = createLatestPayloadSync(async (payload: string) => {
sent.push(payload);
if (payload === 'A') {
await new Promise<void>((resolve) => {
releaseA = resolve;
});
}
return { ok: true };
});
const sendingA = sync.push('A');
void sync.push('B');
sync.cancel();
releaseA?.();
await sendingA;
assert.deepEqual(sent, ['A']);
});
+67 -18
View File
@@ -17,6 +17,49 @@ type UseExternalMcpSessionSyncOptions = {
portForwardingRules: PortForwardingRule[];
};
export function createLatestPayloadSync<T>(
send: (payload: T) => Promise<unknown>,
) {
let desired: { serialized: string; payload: T } | null = null;
let acknowledged = '';
let inFlight: Promise<void> | null = null;
let cancelled = false;
const pump = async () => {
while (!cancelled && desired && desired.serialized !== acknowledged) {
const target = desired;
try {
const result = await send(target.payload);
if (cancelled) break;
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
if (desired === target) break;
continue;
}
acknowledged = target.serialized;
} catch {
if (desired === target) break;
}
}
};
return {
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
if (cancelled) return Promise.resolve();
desired = { payload, serialized };
if (!inFlight) {
inFlight = pump().finally(() => {
inFlight = null;
});
}
return inFlight;
},
cancel(): void {
cancelled = true;
desired = null;
},
};
}
function isMainAppWindow(): boolean {
if (typeof window === 'undefined') return false;
const hash = window.location.hash || '';
@@ -43,7 +86,6 @@ export function useExternalMcpSessionSync({
void enabledTick;
return readExternalMcpStoredEnabled();
}, [enabledTick]);
const syncGenerationRef = useRef(0);
useEffect(() => {
const bump = () => setEnabledTick((value) => value + 1);
@@ -77,34 +119,41 @@ export function useExternalMcpSessionSync({
});
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
const lastSentSerializedRef = useRef('');
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
useEffect(() => {
if (!isMainAppWindow()) return;
const bridge = netcattyBridge.get();
if (!bridge?.aiMcpUpdateLiveSessions) return;
const serialized = JSON.stringify(payload);
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
));
void liveSyncRef.current.push(payload, serialized);
}, [payload]);
useEffect(() => {
if (!isMainAppWindow()) return;
if (!enabled) {
syncGenerationRef.current += 1;
lastSentSerializedRef.current = '';
// Recreate the synchronizer after re-enabling because disabling can
// clear the main-process scope even when the renderer payload is equal.
externalSyncRef.current?.cancel();
externalSyncRef.current = null;
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.aiMcpUpdateSessions) return;
const serialized = JSON.stringify(payload);
if (serialized === lastSentSerializedRef.current) return;
const generation = ++syncGenerationRef.current;
const timeoutId = window.setTimeout(() => {
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
.then((result) => {
if (generation !== syncGenerationRef.current) return;
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
return;
}
lastSentSerializedRef.current = serialized;
})
.catch(() => {
// Leave the ref unset so the next effect can retry.
});
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
Promise.resolve(bridge.aiMcpUpdateSessions?.(
nextPayload,
EXTERNAL_MCP_CHAT_SESSION_ID,
))
));
void externalSyncRef.current.push(payload, serialized);
}, 250);
return () => {
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import {
createDeferredOutputTriggerEventProcessor,
createOutputTriggerScanBuffer,
@@ -7,6 +9,7 @@ import {
findMatchEndingAfter,
hasApplicableOutputTriggerSnippet,
inspectDroppedOutputOverflowAlternateScreenState,
useOutputTriggers,
} from './useOutputTriggers.ts';
import { createTerminalOutputTriggerFilter } from '@/domain/terminalOutputTriggerFilter.ts';
import type { Snippet } from '@/domain/models';
@@ -93,6 +96,80 @@ test('hasApplicableOutputTriggerSnippet requires a runnable output trigger for t
assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), true);
});
test('hasApplicableOutputTriggerSnippet follows the host current group', () => {
const snippets: Snippet[] = [{
id: 'group-trigger',
label: 'Group trigger',
command: '',
kind: 'script',
trigger: 'onOutput',
triggerPattern: 'READY',
targetGroups: ['Production'],
}];
assert.equal(
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Production/Web' }),
true,
);
assert.equal(
hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Staging' }),
false,
);
});
test('equivalent host objects do not reset a pending output trigger match', async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const snippets: Snippet[] = [{
id: 'stable-host-trigger',
label: 'Stable host trigger',
command: '',
kind: 'script',
trigger: 'onOutput',
triggerPattern: 'READY',
targetGroups: ['Production'],
}];
let appendOutput: ((chunk: string) => void) | undefined;
let runCount = 0;
let renderer: ReactTestRenderer | null = null;
const onRunScript = () => {
runCount += 1;
};
const Probe = ({ host }: { host: { id: string; group: string } }) => {
appendOutput = useOutputTriggers({
sessionId: 'session-1',
host,
snippets,
onRunScript,
}).appendOutput;
return null;
};
await act(async () => {
renderer = create(React.createElement(Probe, {
host: { id: 'host-a', group: 'Production/Web' },
}));
});
try {
await act(async () => {
appendOutput?.('REA');
renderer?.update(React.createElement(Probe, {
host: { id: 'host-a', group: 'Production/Web' },
}));
appendOutput?.('DY');
await new Promise((resolve) => setTimeout(resolve, 40));
});
} finally {
await act(async () => renderer?.unmount());
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
assert.equal(runCount, 1);
});
test('output trigger scan buffer consumes scanned content and only keeps overlap', () => {
const buffer = createOutputTriggerScanBuffer(4);
+19 -10
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { Snippet } from '@/domain/models';
import type { Host, Snippet } from '@/domain/models';
import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts';
import { isScriptSnippet } from '@/domain/snippetScript.ts';
import {
@@ -19,7 +19,7 @@ const OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS = 2048;
type OutputTriggerContext = {
sessionId: string;
hostId?: string;
host?: Pick<Host, 'id' | 'group'>;
snippets: Snippet[];
onRunScript: (snippet: Snippet, sessionId: string) => void | Promise<void>;
};
@@ -575,22 +575,31 @@ export function findMatchEndingAfter(text: string, pattern: string, minEndOffset
return null;
}
export function hasApplicableOutputTriggerSnippet(snippets: Snippet[], hostId?: string): boolean {
export function hasApplicableOutputTriggerSnippet(
snippets: Snippet[],
host?: Pick<Host, 'id' | 'group'> | string,
): boolean {
return snippets.some((snippet) => (
isScriptSnippet(snippet)
&& snippet.trigger === 'onOutput'
&& Boolean(snippet.triggerPattern)
&& Boolean(snippet.id)
&& snippetAppliesToOutputTrigger(snippet, hostId)
&& snippetAppliesToOutputTrigger(snippet, host)
));
}
export function useOutputTriggers({
sessionId,
hostId,
host,
snippets,
onRunScript,
}: OutputTriggerContext) {
const targetHostId = host?.id;
const targetHostGroup = host?.group;
const targetHost = useMemo(
() => targetHostId ? { id: targetHostId, group: targetHostGroup } : undefined,
[targetHostGroup, targetHostId],
);
const launchingRef = useRef(false);
const lastTriggerMatchEndRef = useRef(new Map<string, number>());
const serverOutputFilterRef = useRef(createTerminalOutputTriggerFilter());
@@ -600,8 +609,8 @@ export function useOutputTriggers({
const pendingDroppedOverflowFinalActionRef = useRef<'leave' | null>(null);
const pendingDroppedOverflowScanStateResetRef = useRef(false);
const hasOutputTriggers = useMemo(
() => hasApplicableOutputTriggerSnippet(snippets, hostId),
[hostId, snippets],
() => hasApplicableOutputTriggerSnippet(snippets, targetHost),
[snippets, targetHost],
);
const scanOutput = useCallback((scannableText: string) => {
@@ -621,7 +630,7 @@ export function useOutputTriggers({
if (!isScriptSnippet(snippet) || snippet.trigger !== 'onOutput' || !snippet.triggerPattern || !snippet.id) {
continue;
}
if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue;
if (!snippetAppliesToOutputTrigger(snippet, targetHost)) continue;
try {
const matched = findMatchEndingAfter(scanWindow.text, snippet.triggerPattern, scanWindow.minEndOffset);
if (!matched) {
@@ -647,7 +656,7 @@ export function useOutputTriggers({
// ignore invalid regex
}
}
}, [hasOutputTriggers, hostId, onRunScript, sessionId, snippets]);
}, [hasOutputTriggers, onRunScript, sessionId, snippets, targetHost]);
const scanOutputRef = useRef(scanOutput);
scanOutputRef.current = scanOutput;
@@ -813,7 +822,7 @@ export function useOutputTriggers({
outputTriggerScanSuppressedRef.current = false;
pendingDroppedOverflowFinalActionRef.current = null;
pendingDroppedOverflowScanStateResetRef.current = false;
}, [sessionId, hostId, hasOutputTriggers, outputTriggerEventProcessor]);
}, [sessionId, targetHost, hasOutputTriggers, outputTriggerEventProcessor]);
useEffect(() => () => {
outputTriggerEventProcessor.reset();
+109 -2
View File
@@ -11,6 +11,11 @@ import {
registerTerminalSettingsActions,
} from './terminalSettingsStore';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
normalizeAppLockTimeoutMinutes,
type AppLockSettings,
type AppLockTimeoutMinutes,
} from '../../domain/appLock';
import {
DEFAULT_HTTP_NETWORK_PROXY,
areHttpNetworkProxySettingsEqual,
@@ -52,6 +57,7 @@ import {
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_RESTORE_PREVIOUS_SESSION,
STORAGE_KEY_RESTORE_TERMINAL_CWD,
STORAGE_KEY_STARTUP_LANDING,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
@@ -146,6 +152,10 @@ import {
type HostClickBehavior,
} from './settingsStateDefaults';
import { isHostClickBehavior } from '../../domain/hostClickBehavior';
import {
resolveStartupLandingSetting,
type StartupLanding,
} from '../../domain/startupLanding';
import { resolveRestorePreviousSessionSetting, resolveRestoreTerminalCwdSetting } from './sessionRestoreSettings';
import { sessionRestoreStorage } from './sessionRestoreStorage';
import { useSettingsStorageSync } from './settingsStorageSync';
@@ -333,6 +343,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
});
const [appLockSettings, setAppLockSettingsState] = useState<AppLockSettings>({
enabled: false,
timeoutMinutes: 15,
systemUnlockEnabled: false,
systemUnlockAutoPromptEnabled: false,
passwordVerifier: null,
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
@@ -431,6 +448,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
return resolveRestoreTerminalCwdSetting(stored);
});
const [startupLanding, setStartupLandingState] = useState<StartupLanding>(() => {
return resolveStartupLandingSetting(readStoredString(STORAGE_KEY_STARTUP_LANDING));
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
return resolveSftpTransferConcurrency(() =>
localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
@@ -779,6 +799,53 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
}, [notifySettingsChanged]);
const setAppLockTimeoutMinutes = useCallback((timeoutMinutes: AppLockTimeoutMinutes) => {
void netcattyBridge.get()?.setAppLockTimeoutMinutes?.(normalizeAppLockTimeoutMinutes(timeoutMinutes))
?.then((next) => {
if (next) setAppLockSettingsState(next);
})
.catch(() => {});
}, []);
const requestAppLockEnable = useCallback(async () => {
const next = await netcattyBridge.get()?.requestAppLockEnable?.();
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const requestAppLockDisable = useCallback(async (currentPassword: string) => {
const next = await netcattyBridge.get()?.requestAppLockDisable?.(currentPassword);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const requestAppLockPasswordChange = useCallback(async (input: {
currentPassword?: string;
nextPassword: string;
}) => {
const next = await netcattyBridge.get()?.requestAppLockPasswordChange?.(input);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const setAppLockSystemUnlockEnabled = useCallback(async (input: {
enabled: boolean;
currentPassword?: string;
autoPromptEnabled?: boolean;
}) => {
const next = await netcattyBridge.get()?.setAppLockSystemUnlockEnabled?.(input);
if (next && !('ok' in next)) {
setAppLockSettingsState(next);
}
return next ?? appLockSettings;
}, [appLockSettings]);
const syncAppearanceFromStorage = useCallback((incoming?: AppearanceSyncEvent) => {
const current = appearanceStateRef.current;
const nextAppearance = resolveAppearanceSyncState(
@@ -855,6 +922,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
if (storedLang) setUiLanguage(storedLang as UILanguage);
void netcattyBridge.get()?.getAppLockSettings?.().then((next) => {
if (next) setAppLockSettingsState(next);
}).catch(() => {});
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
@@ -955,6 +1026,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setRestorePreviousSessionState(resolveRestorePreviousSessionSetting(storedRestorePreviousSession));
const storedRestoreTerminalCwd = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_TERMINAL_CWD);
setRestoreTerminalCwdState(resolveRestoreTerminalCwdSetting(storedRestoreTerminalCwd));
setStartupLandingState(resolveStartupLandingSetting(readStoredString(STORAGE_KEY_STARTUP_LANDING)));
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -1123,6 +1195,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
setStartupLandingState,
setSftpTransferConcurrencyState,
setSshTransportIdleTtlMsState,
});
@@ -1145,6 +1218,25 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
};
}, [enableSettingsSync]);
useEffect(() => {
const bridge = netcattyBridge.get();
let sawPushedSettings = false;
const unsubscribe = bridge?.onAppLockSettingsChanged?.((next) => {
sawPushedSettings = true;
setAppLockSettingsState(next);
}) ?? (() => {});
void bridge?.getAppLockSettings?.().then((next) => {
if (!next || sawPushedSettings) return;
setAppLockSettingsState(next);
}).catch(() => {});
return () => {
unsubscribe();
};
}, []);
useSettingsStorageSync({
enabled: enableSettingsSync,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
@@ -1153,7 +1245,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
@@ -1163,7 +1255,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -1360,6 +1452,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_RESTORE_TERMINAL_CWD, enabled);
}, [notifySettingsChanged]);
const setStartupLanding = useCallback((landing: StartupLanding) => {
setStartupLandingState(landing);
localStorageAdapter.writeString(STORAGE_KEY_STARTUP_LANDING, landing);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_STARTUP_LANDING, landing);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
applyCustomCssToDocument(customCSS);
@@ -1897,6 +1996,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
updateSyncConfig,
uiLanguage,
setUiLanguage,
appLockSettings,
setAppLockTimeoutMinutes,
requestAppLockEnable,
requestAppLockDisable,
requestAppLockPasswordChange,
setAppLockSystemUnlockEnabled,
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
@@ -1964,6 +2069,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setRestorePreviousSession,
restoreTerminalCwd,
setRestoreTerminalCwd,
startupLanding,
setStartupLanding,
sftpTransferConcurrency,
setSftpTransferConcurrency,
sshTransportIdleTtlMs,
+13 -24
View File
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
import {
createTransferPoolKeyCache,
@@ -316,9 +316,7 @@ export const useSftpState = (
// Prefer borrowing the live (or parked) terminal SSH transport so MFA is
// not repeated. Transport leases keep the shared conn alive after the
// terminal tab closes until the transfer SFTP lease is returned.
const sourceSessionId = !host.sftpSudo
? resolveTransferSourceSessionId?.(host.id, host)
: undefined;
const sourceSessionId = resolveTransferSourceSessionId?.(host.id, host);
if (sourceSessionId) {
try {
logger.info(
@@ -608,16 +606,7 @@ export const useSftpState = (
forceReconnect: ensureOptions?.forceReconnect,
releaseConnection,
tabId,
probeSession: async (sftpId) => {
// Lightweight liveness check; any session-error from the bridge
// triggers a reconnect in ensureRemoteSftpSession.
if (!bridge?.getSftpHomeDir) return true;
const result = await bridge.getSftpHomeDir(sftpId);
if (result && result.success === false) {
throw new Error(result.error || "SFTP session not found");
}
return true;
},
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
});
},
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
@@ -704,6 +693,12 @@ export const useSftpState = (
const pane = getActivePane(side);
if (!pane?.connection || pane.connection.isLocal) continue;
if (sftpSessionsRef.current.has(pane.connection.id)) continue;
const connectedHost = connectedHostByTabIdRef.current.get(pane.id) ?? null;
const vaultHost = hosts.find((host) => host.id === pane.connection?.hostId) ?? null;
const targetHost = connectedHost && connectedHost !== "local" ? connectedHost : vaultHost;
const resolvedSourceSessionId = targetHost
? resolveBrowseSourceSessionId?.(targetHost.id, targetHost)
: undefined;
try {
await ensureRemoteSftpSession({
side,
@@ -713,16 +708,10 @@ export const useSftpState = (
connect,
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
resolveSourceSessionId: resolveBrowseSourceSessionId,
probeSession: async (sftpId) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSftpHomeDir) return true;
const result = await bridge.getSftpHomeDir(sftpId);
if (result && result.success === false) {
throw new Error(result.error || "SFTP session not found");
}
return true;
},
resolveSourceSessionId: resolvedSourceSessionId
? () => resolvedSourceSessionId
: resolveBrowseSourceSessionId,
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
releaseConnection,
});
logger.info(`[SFTP] Restored browse session on ${side}`);
@@ -5,6 +5,7 @@ import {
resolveVaultAgentEffectiveHost,
resolveVaultAgentNotes,
} from './useVaultAgentBridge';
import type { Host } from '../../domain/models';
import { publishNotesSnapshot } from './notesStore';
type Snapshot = Parameters<typeof haveSameVaultAgentSnapshot>[0];
@@ -28,10 +29,10 @@ describe('haveSameVaultAgentSnapshot', () => {
describe('resolveVaultAgentEffectiveHost', () => {
it('uses the latest snapshotted group defaults', () => {
const host = {
const host: Host = {
id: 'host-1', label: 'Host', hostname: 'host.test', username: 'root',
group: 'production', tags: [], os: 'linux',
} as const;
};
assert.equal(
resolveVaultAgentEffectiveHost(host, [{ path: 'production', protocol: 'telnet' }], []).protocol,
+26 -4
View File
@@ -16,6 +16,10 @@ import {
resolveDefaultKeyPassphraseAliases,
} from '../defaultKeyPassphrases';
import { getNotesActions, getNotesSnapshot, subscribeNotes } from './notesStore';
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from '../../domain/vaultGroupMutation';
export interface UseVaultAgentBridgeInput {
hosts: Host[];
@@ -29,12 +33,17 @@ export interface UseVaultAgentBridgeInput {
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
updateHosts: (hosts: Host[]) => void;
updateKeys: (keys: SSHKey[]) => Promise<unknown> | unknown;
updateSnippets: (snippets: Snippet[]) => void;
updateSnippets: (
snippets: Snippet[] | ((current: Snippet[]) => Snippet[]),
) => void;
customGroups: string[];
updateCustomGroups: (groups: string[]) => void;
groupConfigs: GroupConfig[];
updateGroupConfigs: (configs: GroupConfig[]) => void;
updateManagedSources: (sources: ManagedSource[]) => void;
commitVaultGroupMutation: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
updatePortForwardingRules: (rules: PortForwardingRule[]) => void;
/** Optional override; defaults to notesStore so App need not subscribe to notes. */
notes?: VaultNote[];
@@ -185,6 +194,17 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
vaultSnapshotRef.current.hosts = hosts;
current.updateHosts(hosts);
},
commitVaultGroupMutation: async (mutate) => {
const result = await current.commitVaultGroupMutation(mutate);
if (result.ok) {
vaultSnapshotRef.current.customGroups = result.state.groups;
vaultSnapshotRef.current.groupConfigs = result.state.configs;
vaultSnapshotRef.current.hosts = result.state.hosts;
vaultSnapshotRef.current.managedSources = result.state.managedSources;
vaultSnapshotRef.current.snippets = result.state.snippets;
}
return result;
},
saveKeyPassphrase: (keyPath, passphrase) => rememberKeyPassphrase({
keyPath,
passphrase,
@@ -222,9 +242,11 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
vaultSnapshotRef.current.notes = notes;
applyUpdateNotes(notes);
},
updateSnippets: (nextSnippets) => {
vaultSnapshotRef.current.snippets = nextSnippets;
current.updateSnippets(nextSnippets);
updateSnippets: (snippetUpdate) => {
vaultSnapshotRef.current.snippets = typeof snippetUpdate === 'function'
? snippetUpdate(vaultSnapshotRef.current.snippets)
: snippetUpdate;
current.updateSnippets(snippetUpdate);
},
startTunnel: current.startTunnel,
stopTunnel: current.stopTunnel,
@@ -31,19 +31,16 @@ test("group deletion removes saved settings in the same Vault transaction", asyn
managedSources: storedSources,
onReadPersistedHosts: async () => [],
onReadPersistedManagedSources: () => storedSources,
onCommitVaultImportTransaction: async (
_hosts,
updateGroups,
updateSources,
updateGroupConfigs,
) => {
committedGroupConfigs = updateGroupConfigs?.(storedGroupConfigs) ?? storedGroupConfigs;
return {
status: "persisted",
groups: updateGroups(storedGroups),
sources: updateSources(storedSources),
groupConfigs: committedGroupConfigs,
};
onCommitVaultGroupMutation: async (mutate) => {
const result = mutate({
groups: storedGroups,
configs: storedGroupConfigs,
hosts: [],
managedSources: storedSources,
snippets: [],
});
if (result.ok) committedGroupConfigs = result.state.configs;
return result;
},
});
return null;
@@ -102,14 +99,9 @@ test("group deletion restores managed files when the persisted Vault cannot be r
onClearAndRemoveManagedSources: async () => async () => {
restoreCount += 1;
},
onCommitVaultImportTransaction: async () => {
onCommitVaultGroupMutation: async () => {
commitCount += 1;
return {
status: "persisted",
groups: [],
sources: [],
groupConfigs: [],
};
throw new Error("unexpected commit");
},
});
return null;
@@ -187,21 +179,15 @@ test("group deletion retries file clearing when another managed source enters th
restoreCount += 1;
};
},
onCommitVaultImportTransaction: async (
_hosts,
updateGroups,
updateSources,
updateGroupConfigs,
expectedHosts,
) => {
onCommitVaultGroupMutation: async (mutate) => {
commitCount += 1;
assert.deepEqual(expectedHosts, []);
return {
status: "persisted",
groups: updateGroups(["Production"]),
sources: updateSources([firstSource, concurrentSource]),
groupConfigs: updateGroupConfigs?.([]) ?? [],
};
return mutate({
groups: ["Production"],
configs: [],
hosts: [],
managedSources: [firstSource, concurrentSource],
snippets: [],
});
},
});
return null;
@@ -228,3 +214,67 @@ test("group deletion retries file clearing when another managed source enters th
assert.equal(restoreCount, 1);
assert.equal(commitCount, 1);
});
test("group deletion restores managed files and does not report success when commit fails", async () => {
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
const source: ManagedSource = {
id: "managed-production",
type: "ssh_config",
filePath: "/tmp/managed-production.conf",
groupName: "Production",
lastSyncedAt: 1,
};
let deleteGroups: ((paths: Iterable<string>) => Promise<void>) | undefined;
let renderer: ReactTestRenderer | null = null;
let restoreCount = 0;
let reportedCount = 0;
const Probe = () => {
deleteGroups = useVaultGroupDeletion({
customGroups: ["Production"],
hosts: [],
groupConfigs: [],
managedSources: [source],
onReadPersistedHosts: async () => [],
onReadPersistedManagedSources: () => [source],
onClearAndRemoveManagedSources: async () => async () => {
restoreCount += 1;
},
onCommitVaultGroupMutation: async () => {
throw new Error("Vault quota exhausted");
},
onDeletedPaths: () => {
reportedCount += 1;
},
});
return null;
};
let deletionError: unknown;
try {
await act(async () => {
renderer = create(React.createElement(Probe));
});
try {
await act(async () => {
await deleteGroups?.(["Production"]);
});
} catch (error) {
deletionError = error;
}
} finally {
await act(async () => {
renderer?.unmount();
});
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
assert.match(String(deletionError), /quota exhausted/i);
assert.equal(restoreCount, 1);
assert.equal(reportedCount, 0);
});
+44 -53
View File
@@ -1,7 +1,12 @@
import { useCallback, useRef } from "react";
import type { GroupConfig, Host, ManagedSource } from "../../domain/models";
import { removeSnippetTargetGroupPaths } from "../../domain/hostGroupPathMutations";
import { buildVaultGroupDeletion } from "../../domain/vaultGroupDeletion";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from "../../domain/vaultGroupMutation";
import {
type VaultLockHandle,
withVaultImportLock,
@@ -26,7 +31,7 @@ export function useVaultGroupDeletion({
managedSources,
onReadPersistedHosts,
onReadPersistedManagedSources,
onCommitVaultImportTransaction,
onCommitVaultGroupMutation,
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onDeletedPaths,
@@ -37,22 +42,10 @@ export function useVaultGroupDeletion({
managedSources: ManagedSource[];
onReadPersistedHosts: () => Promise<Host[]>;
onReadPersistedManagedSources: () => ManagedSource[];
onCommitVaultImportTransaction: (
hosts: Host[],
updateGroups: (current: string[]) => string[],
updateSources: (current: ManagedSource[]) => ManagedSource[],
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
expectedHosts?: Host[],
onCommitVaultGroupMutation: (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
lock?: VaultLockHandle | null,
) => Promise<
| {
status: "persisted";
groups: string[];
sources: ManagedSource[];
groupConfigs: GroupConfig[];
}
| { status: "superseded" }
>;
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
onDeletedPaths?: (selectedRoots: string[]) => void;
@@ -120,29 +113,16 @@ export function useVaultGroupDeletion({
}
deletion = refreshedDeletion;
const expectedHosts = latestRef.current.hosts;
const expectedSources = deletion.sourcesToRemove;
const nextHosts = deletion.hosts.filter(
(host) => !additionallyDeletedHostIds.has(host.id),
);
const transaction = await onCommitVaultImportTransaction(
nextHosts,
(currentGroups) => buildVaultGroupDeletion({
selectedPaths,
deleteHosts,
customGroups: currentGroups,
hosts: [],
groupConfigs: [],
managedSources: [],
}).customGroups,
(currentSources) => {
const transaction = await onCommitVaultGroupMutation(
(current) => {
const currentDeletion = buildVaultGroupDeletion({
selectedPaths,
deleteHosts,
customGroups: [],
hosts: [],
groupConfigs: [],
managedSources: currentSources,
customGroups: current.groups,
hosts: current.hosts,
groupConfigs: current.configs,
managedSources: current.managedSources,
});
if (!managedSourceSnapshotsMatch(
expectedSources,
@@ -150,29 +130,40 @@ export function useVaultGroupDeletion({
)) {
throw RETRY_VAULT_GROUP_DELETION;
}
const removedIds = new Set(expectedSources.map((source) => source.id));
return currentSources.filter((source) => !removedIds.has(source.id));
return {
ok: true,
state: {
groups: currentDeletion.customGroups,
hosts: currentDeletion.hosts.filter(
(host) => !additionallyDeletedHostIds.has(host.id),
),
configs: currentDeletion.groupConfigs,
managedSources: current.managedSources.filter(
(source) => !currentDeletion.sourcesToRemove.some(
(removed) => removed.id === source.id,
),
),
snippets: removeSnippetTargetGroupPaths(
current.snippets,
currentDeletion.selectedRoots,
),
},
};
},
(currentGroupConfigs) => buildVaultGroupDeletion({
selectedPaths,
deleteHosts,
customGroups: [],
hosts: [],
groupConfigs: currentGroupConfigs,
managedSources: [],
}).groupConfigs,
expectedHosts,
lock,
);
if (transaction.status === "superseded") {
// Release the lock so concurrent host saves can finish before retry.
if (!transaction.ok && "superseded" in transaction) {
// Release the lock so concurrent saves can finish before retry.
return { status: "retry" as const, restoreManagedFiles };
}
if ("error" in transaction) {
throw new Error(transaction.error);
}
latestRef.current = {
customGroups: transaction.groups,
hosts: nextHosts,
groupConfigs: transaction.groupConfigs,
managedSources: transaction.sources,
customGroups: transaction.state.groups,
hosts: transaction.state.hosts,
groupConfigs: transaction.state.configs,
managedSources: transaction.state.managedSources,
};
return {
status: "done" as const,
@@ -205,7 +196,7 @@ export function useVaultGroupDeletion({
}, [
onClearAndRemoveManagedSource,
onClearAndRemoveManagedSources,
onCommitVaultImportTransaction,
onCommitVaultGroupMutation,
onDeletedPaths,
onReadPersistedHosts,
onReadPersistedManagedSources,
@@ -66,7 +66,7 @@ test("updateSnippets disk writes take the shared vault lock", () => {
// and be visible to waitForPendingVaultWrites.
assert.match(
source,
/const updateSnippets = useCallback\(\(\s*data: Snippet\[\],\s*options\?: \{ replace\?: boolean \},\s*\) => \{[\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
/const updateSnippets = useCallback\(\([\s\S]*withVaultImportLock\("vault", async \(\) => \{[\s\S]*localStorageAdapter\.write\(STORAGE_KEY_SNIPPETS, rebased\)/,
);
assert.match(
source,
@@ -95,6 +95,24 @@ test("updateSnippets rebases onto the latest persisted snapshot under the lock",
);
});
test("updateSnippets functional updates derive from the latest in-memory snapshot", () => {
// Group deletion can wait for managed-source cleanup. A script created while
// it waits must still be present when the deletion callback removes the old
// group path, so updater callbacks must not derive from the initiating render.
assert.match(
source,
/data: Snippet\[\] \| \(\(current: Snippet\[\]\) => Snippet\[\]\)/,
);
assert.match(
source,
/const updater = typeof data === "function" \? data : null;[\s\S]*const current = snippetsRef\.current;[\s\S]*typeof data === "function" \? data\(current\) : data/,
);
assert.match(
source,
/updater\(rebaseSnippetVaultWrite\(\{\s*base,\s*ours: current,\s*theirs: latestPersisted,\s*\}\)\)/,
);
});
test("clearVaultData replaces snippets without additive rebase", () => {
// Clear All Local Data must not preserve concurrent disk-only snippet adds
// that rebaseSnippetVaultWrite would otherwise keep.
@@ -108,7 +126,7 @@ test("clearVaultData replaces snippets without additive rebase", () => {
);
assert.match(
source,
/replace\s*\?\s*cleaned\s*:\s*rebaseSnippetVaultWrite/,
/replace\s*\?\s*cleaned\s*:\s*updater\s*\?\s*updater\(rebaseSnippetVaultWrite/,
);
assert.match(
source,
@@ -145,7 +163,7 @@ test("updateSnippets keeps the persisted rebase ancestor across superseded saves
);
assert.match(
source,
/const base = snippetsWriteBaseRef\.current \?\? snippetsRef\.current/,
/const current = snippetsRef\.current;\s*const base = snippetsWriteBaseRef\.current \?\? current/,
);
assert.match(
source,
+150 -21
View File
@@ -106,10 +106,15 @@ import {
persistVaultImportMetadata,
readStoredArray,
} from "./vaultImportPersistence";
import type {
VaultGroupMutationResult,
VaultGroupMutationState,
} from "../../domain/vaultGroupMutation";
import {
commitPluginImporterTransaction,
recoverPluginImporterTransaction,
} from "./pluginImporterTransaction";
import { commitVaultGroupMutationPersistence } from "./vaultGroupMutationPersistence";
type ExportableVaultData = {
hosts: Host[];
@@ -296,6 +301,7 @@ export const useVaultState = () => {
const managedSourcesRef = useRef<ManagedSource[]>([]);
const hostsRef = useRef<Host[]>([]);
const snippetsRef = useRef<Snippet[]>([]);
const groupConfigsRef = useRef<GroupConfig[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
@@ -303,6 +309,7 @@ export const useVaultState = () => {
managedSourcesRef.current = managedSources;
hostsRef.current = hosts;
snippetsRef.current = snippets;
groupConfigsRef.current = groupConfigs;
notesRef.current = notes;
noteGroupsRef.current = noteGroups;
@@ -314,6 +321,7 @@ export const useVaultState = () => {
const identitiesWriteVersion = useRef(0);
const proxyProfilesWriteVersion = useRef(0);
const snippetsWriteVersion = useRef(0);
const managedSourcesWriteVersion = useRef(0);
// Tracks the latest local updateSnippets schedule. Storage events also bump
// snippetsWriteVersion (to invalidate naive writers), so queued saves must
// key supersede checks off this owner instead of that shared counter.
@@ -340,6 +348,7 @@ export const useVaultState = () => {
const identitiesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const groupConfigsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const snippetsWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const managedSourcesWritePendingRef = useRef<Promise<unknown>>(Promise.resolve());
const waitForPendingVaultWrites = useCallback(async () => {
while (true) {
@@ -365,6 +374,7 @@ export const useVaultState = () => {
identitiesWritePendingRef.current,
groupConfigsWritePendingRef.current,
snippetsWritePendingRef.current,
managedSourcesWritePendingRef.current,
];
await Promise.all(writePending);
if (
@@ -373,6 +383,7 @@ export const useVaultState = () => {
&& writePending[2] === identitiesWritePendingRef.current
&& writePending[3] === groupConfigsWritePendingRef.current
&& writePending[4] === snippetsWritePendingRef.current
&& writePending[5] === managedSourcesWritePendingRef.current
&& encryptPending[0] === hostsEncryptPendingRef.current
&& encryptPending[1] === keysEncryptPendingRef.current
&& encryptPending[2] === identitiesEncryptPendingRef.current
@@ -643,7 +654,7 @@ export const useVaultState = () => {
}, []);
const updateSnippets = useCallback((
data: Snippet[],
data: Snippet[] | ((current: Snippet[]) => Snippet[]),
options?: { replace?: boolean },
) => {
// Capture the pre-update snapshot for a 3-way rebase once we hold the lock.
@@ -653,7 +664,9 @@ export const useVaultState = () => {
// second edit does not rebase against the first save's optimistic memory.
const replace =
options?.replace === true || snippetsWriteReplaceRef.current;
const base = snippetsWriteBaseRef.current ?? snippetsRef.current;
const updater = typeof data === "function" ? data : null;
const current = snippetsRef.current;
const base = snippetsWriteBaseRef.current ?? current;
if (options?.replace === true) {
snippetsWriteReplaceRef.current = true;
}
@@ -667,7 +680,9 @@ export const useVaultState = () => {
} else if (snippetsWriteBaseRef.current === null) {
snippetsWriteBaseRef.current = base;
}
const cleaned = normalizeVaultOrder(data);
const cleaned = normalizeVaultOrder(
typeof data === "function" ? data(current) : data,
);
const ver = ++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = ver;
// Keep live snapshot ahead of React commit so same-tick readers (delete,
@@ -681,20 +696,25 @@ export const useVaultState = () => {
// skips the additive rebase so a concurrent disk-only add cannot survive.
const writePromise = withVaultImportLock("vault", async () => {
if (snippetsWriteOwnerRef.current !== ver) return "superseded" as const;
const rebased = normalizeVaultOrder(
replace
? cleaned
const latestPersisted = normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
);
const rebased = normalizeVaultOrder(replace
? cleaned
: updater
? updater(rebaseSnippetVaultWrite({
base,
ours: current,
theirs: latestPersisted,
}))
: rebaseSnippetVaultWrite({
base,
ours: cleaned,
theirs: normalizeVaultOrder(
readStoredArray<Snippet>(
STORAGE_KEY_SNIPPETS,
localStorageAdapter.readString(STORAGE_KEY_SNIPPETS),
),
),
}),
);
theirs: latestPersisted,
}));
const persisted = localStorageAdapter.write(STORAGE_KEY_SNIPPETS, rebased);
if (!persisted) {
// Keep the pre-update ancestor. Clearing it here would make the next
@@ -852,6 +872,7 @@ export const useVaultState = () => {
setCustomGroups(next);
const cleanedGroupConfigs = buildGroupConfigsForGroups(next, groupConfigs);
groupConfigsRef.current = cleanedGroupConfigs;
setGroupConfigs(cleanedGroupConfigs);
const configsVer = ++groupConfigsWriteVersion.current;
const encryptPromise = encryptGroupConfigs(cleanedGroupConfigs);
@@ -880,13 +901,105 @@ export const useVaultState = () => {
const next = typeof data === "function" ? data(managedSourcesRef.current) : data;
managedSourcesRef.current = next;
setManagedSources(next);
void withVaultImportLock("vault", async () => {
const ver = ++managedSourcesWriteVersion.current;
const writePromise = withVaultImportLock("vault", async () => {
// Latest ref wins if another update ran while waiting for the lock.
if (managedSourcesRef.current !== next) return;
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next);
if (ver !== managedSourcesWriteVersion.current) return "superseded" as const;
return localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, next)
? "written" as const
: "failed" as const;
});
managedSourcesWritePendingRef.current = writePromise;
return writePromise;
}, []);
const commitVaultGroupMutation = useCallback(async (
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
lock?: VaultLockHandle | null,
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const captureVersions = () => ({
hosts: hostsWriteVersion.current,
snippets: snippetsWriteVersion.current,
groups: customGroupsWriteVersion.current,
configs: groupConfigsWriteVersion.current,
sources: managedSourcesWriteVersion.current,
});
const versionsAreCurrent = (versions: ReturnType<typeof captureVersions>) => (
versions.hosts === hostsWriteVersion.current
&& versions.snippets === snippetsWriteVersion.current
&& versions.groups === customGroupsWriteVersion.current
&& versions.configs === groupConfigsWriteVersion.current
&& versions.sources === managedSourcesWriteVersion.current
);
const stateMatchesLiveSnapshot = (current: VaultGroupMutationState) => (
JSON.stringify(current.hosts) === JSON.stringify(hostsRef.current)
&& JSON.stringify(current.snippets) === JSON.stringify(snippetsRef.current)
&& JSON.stringify(current.groups) === JSON.stringify(customGroupsRef.current)
&& JSON.stringify(current.configs) === JSON.stringify(groupConfigsRef.current)
&& JSON.stringify(current.managedSources) === JSON.stringify(managedSourcesRef.current)
);
const runCommit = async (
versions: ReturnType<typeof captureVersions>,
): Promise<VaultGroupMutationResult | { ok: false; superseded: true }> => {
const result = await commitVaultGroupMutationPersistence({
storage: localStorageAdapter,
mutate,
prepareState: (state) => {
const groups = Array.from(new Set(state.groups));
return {
groups,
configs: buildGroupConfigsForGroups(groups, state.configs),
hosts: normalizeVaultOrder(state.hosts.map((host) => sanitizeHost(host))),
managedSources: state.managedSources,
snippets: normalizeVaultOrder(state.snippets),
};
},
decryptHosts: async (items) => normalizeVaultOrder(
(await decryptHosts(items)).map((host) => sanitizeHost(host)),
),
decryptConfigs: async (items) => normalizeVaultOrder(
(await decryptGroupConfigs(items)).map(sanitizeGroupConfig),
),
encryptHosts,
encryptConfigs: encryptGroupConfigs,
isCurrent: () => versionsAreCurrent(versions),
validateCurrent: lock ? stateMatchesLiveSnapshot : undefined,
});
if (!result.ok) return result;
const nextState = result.state;
++hostsWriteVersion.current;
++snippetsWriteVersion.current;
snippetsWriteOwnerRef.current = snippetsWriteVersion.current;
++customGroupsWriteVersion.current;
++groupConfigsWriteVersion.current;
++managedSourcesWriteVersion.current;
hostsRef.current = nextState.hosts;
snippetsRef.current = nextState.snippets;
customGroupsRef.current = nextState.groups;
managedSourcesRef.current = nextState.managedSources;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextState.hosts);
setSnippets(nextState.snippets);
setCustomGroups(nextState.groups);
setManagedSources(nextState.managedSources);
setGroupConfigs(nextState.configs);
groupConfigsRef.current = nextState.configs;
return { ok: true, state: nextState };
};
if (lock) {
const versions = captureVersions();
return withVaultImportLockIfNeeded("vault", () => runCommit(versions), lock);
}
while (true) {
await waitForPendingVaultWrites();
const versions = captureVersions();
const result = await withVaultImportLock("vault", () => runCommit(versions));
if (!("superseded" in result)) return result;
}
}, [waitForPendingVaultWrites]);
const readPersistedManagedSources = useCallback((): ManagedSource[] => (
readStoredArray<ManagedSource>(
STORAGE_KEY_MANAGED_SOURCES,
@@ -996,6 +1109,8 @@ export const useVaultState = () => {
++groupConfigsWriteVersion.current;
customGroupsRef.current = result.groups;
managedSourcesRef.current = result.sources;
hostsRef.current = cleanedHosts;
groupConfigsRef.current = nextGroupConfigs;
setHosts(cleanedHosts);
setCustomGroups(result.groups);
setManagedSources(result.sources);
@@ -1018,6 +1133,7 @@ export const useVaultState = () => {
// sit in memory and re-persist with `fontFamilyOverride: true` until
// the next reload. Mirrors updateHosts → sanitizeHost.
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
groupConfigsRef.current = cleaned;
setGroupConfigs(cleaned);
const ver = ++groupConfigsWriteVersion.current;
const encryptPromise = encryptGroupConfigs(cleaned);
@@ -1461,7 +1577,10 @@ export const useVaultState = () => {
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
if (savedManagedSources) {
managedSourcesRef.current = savedManagedSources;
setManagedSources(savedManagedSources);
}
// Load group configs
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
@@ -1471,6 +1590,7 @@ export const useVaultState = () => {
if (cancelled) return;
if (gcVer === groupConfigsWriteVersion.current) {
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
groupConfigsRef.current = sanitizedGC;
setGroupConfigs(sanitizedGC);
encryptGroupConfigs(sanitizedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
@@ -1641,6 +1761,7 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_GROUPS) {
const next = safeParse<string[]>(event.newValue) ?? [];
++customGroupsWriteVersion.current;
customGroupsRef.current = next;
setCustomGroups(next);
return;
}
@@ -1695,6 +1816,8 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_MANAGED_SOURCES) {
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
++managedSourcesWriteVersion.current;
managedSourcesRef.current = next;
setManagedSources(next);
return;
}
@@ -1705,8 +1828,11 @@ export const useVaultState = () => {
const seq = ++groupConfigsReadSeq.current;
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current) {
const normalized = normalizeVaultOrder(dec.map(sanitizeGroupConfig));
groupConfigsRef.current = normalized;
setGroupConfigs(normalized);
}
});
return;
}
@@ -1794,7 +1920,7 @@ export const useVaultState = () => {
const importData = useCallback(
(payload: Partial<ExportableVaultData>): Promise<void> => {
const encryptedWrites: Promise<void>[] = [];
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts).then(() => undefined));
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
@@ -1952,6 +2078,8 @@ export const useVaultState = () => {
]);
customGroupsRef.current = nextGroups;
snippetsRef.current = nextSnippets;
hostsRef.current = nextHosts;
groupConfigsRef.current = nextGroupConfigs;
snippetsWriteBaseRef.current = null;
snippetsWriteReplaceRef.current = false;
setHosts(nextHosts);
@@ -2008,6 +2136,7 @@ export const useVaultState = () => {
updateManagedSources,
readPersistedManagedSources,
commitVaultImportTransaction,
commitVaultGroupMutation,
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
Loaded 100 of 468 files, more files were not shown because too many files have changed in this diff. Show more