Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 c849bf6a73 fix(plugins): preserve overwrite stash across retries and prefix deletes
Do not replace an existing restore stash on later puts, and clear matching
stash entries when deleteByKeyPrefix wipes sync credentials on disconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:36:59 +00:00
Cursor Agentand陈大猫 0d9c4a6e02 fix(plugins): keep overwrite stash until restore write succeeds
Only drop the prior plaintext after encrypt+upsert succeeds so a failed
restore can still retry and recover the saved SecretRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:32:08 +00:00
Cursor Agentand陈大猫 acd5700284 fix(plugins): host-own sync provider bindings and clear failed stashes
Move provider→plugin bindings into a dedicated DB table outside
plugin-writable secrets, validate provider namespace ownership, and drop
overwrite stashes when replacement writes fail.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:27:26 +00:00
Cursor Agentand陈大猫 05b33b5521 fix(plugins): make sync secret overwrite stash opt-in
Only the syncPutSecret path stashes prior plaintext/SecretRef so ordinary
secrets.set overwrites do not leave credentials in process memory.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:18:26 +00:00
Cursor Agentand陈大猫 786e5b48ef fix(plugins): restore overwritten sync secrets on rejected reconnect
Stash prior plaintext and SecretRef id on overwrite, restore them when
connect fails, and discard the stash after a successful reconnect so
saved provider credentials keep resolving.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:12:47 +00:00
Cursor Agentand陈大猫 6414c9e177 fix(plugins): address residual Codex sync secret and backup gaps
Retain provider→plugin secret bindings so disconnect can wipe credentials
after a plugin is disabled, keep overwritten secrets on failed reconnect,
and snapshot live-empty sidecars in protective backups.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:05:39 +00:00
ea6fe18539 fix(vault): allow CSV import of same-endpoint hosts in different groups (#2739)
* fix(#2737): automated Cursor CLI fix

* fix: address Codex review on PR #2739

* fix(vault): harden destination re-dedupe passphrase and counts

Keep collapsed destination imports aligned with CSV merge policy for key
passphrases, and correct plugin importer added/duplicate counters when
hosts collapse into one group.

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

* fix(vault): preserve SecureCRT and plugin hosts under destination group

Skip endpoint collapse for SecureCRT imports, and keep plugin hosts that
differ by configuration when an import destination rewrites their group.

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

* fix(vault): skip plugin imports that collide after destination rewrite

After rewriting imported hosts into the destination group, drop ones that
now share a fingerprint with existing vault hosts.

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

* fix(vault): keep group case in host merge keys

Vault group paths are compared exactly, so Prod and prod must remain
distinct session identities during import/create dedupe.

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

* fix(vault): prune orphan credentials after destination collisions

When a destination rewrite drops colliding imported hosts, also remove
identities/keys that were only referenced by those skipped hosts.

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

* fix(vault): only prune newly imported credentials on collision

Pass existing identity/key counts into destination handling so remapped
duplicates of vault credentials are never deleted when a host is skipped.

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

* fix(vault): merge credential refs when collapsing destination hosts

Carry identityId/telnetIdentityId/identityFileId onto the retained host
so destination-group collapse does not drop referenced auth.

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

* fix(vault): only graft credential refs onto bare collapsed hosts

Avoid copying identityId onto a retained host that already has key or
password auth, which would silently change resolveHostAuth preference.

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

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 18:55:02 +08:00
13fa68a720 feat(plugins): add sync providers and encrypted sidecar sync (#2713)
* feat(plugins): add sync providers and encrypted sidecar sync

Implement PR 8 of the plugin platform (#2269): namespaced sync Providers
with provider.sync, encrypted-object storage operations, WebDAV through the
shared storage surface, dynamic cloud provider IDs, and non-cascade sidecars
for sync:true plugin settings plus account/CRDT baselines.

Related to #2269

* fix(plugins): wire sync providers and sidecars into production paths

Close PR 8 integration gaps: dynamic plugin provider registry and
getConnectedAdapter createPluginStorage, WebDAV through EncryptedObjectStorage,
cloud payload collect/apply for pluginSidecars, and IPC/preload bridges.

Related to #2269

* fix(plugins): preserve WebDAV auth reuse and resourceId through EOS wrap

encryptedObjectStorageAsCloudAdapter now reports initiallyAuthenticated when
config exists (matching raw WebDAVAdapter) so getConnectedAdapter reuses the
cached instance, and preserves/refreshes resourceId from the backing adapter
instead of always forcing the default object key.

Related to #2269

* fix(plugins): address Codex PR 8 review findings

Carry pluginSidecars through merge/convergent uploads, lazy-connect before
object I/O with revision-aware writes, fail closed on sidecar collect errors,
expose connectPluginProvider, preserve remote timestamps, and drop deleted
installed-plugin settings from collection.

Related to #2269

* fix(plugins): close remaining Codex P1/P2 sync issues

Lazy stream cancel for inline sync reads, must-not-exist conditional writes,
three-way sidecar merge for local resets, validated apply path for settings,
and merge remote sidecars into convergent uploads.

Related to #2269

* fix(plugins): harden sidecar collect/apply and write verification

Preserve last-known sidecars when the host is offline, apply remote
deletions for installed plugins, force convergent upload on sidecar
divergence, verify plugin object writes, and pick plugin providers in
auto-sync startup checks.

Related to #2269

* fix(plugins): close remaining Codex sidecar/sync path gaps

Include sidecars in auto-sync payload and hash, keep explicit empty bundles,
fail apply when host is offline, require getCapabilities, and carry merged
sidecars into convergent local apply/upload decisions.

Related to #2269

* fix(plugins): close Codex P1 sidecar collect/apply/merge gaps

Treat null host responses as unavailable, persist collected settings into
the non-cascade table, retain decoded convergent payloads, use three-way
sidecar merge for deletions, and fail apply only on operational errors.
Also treat non-empty plugin sidecars as meaningful sync data and re-run
auto-sync when plugin contributions change.

Related to #2269

* fix(plugins): close remaining Codex P1 sidecar and provider gaps

Replay offline-cached sidecars before collect, keep explicit empty
sidecar fields after three-way merge, thread sidecars through convergent
conflict/downgrade materialization, keep gated-off plugin providers from
joining sync as connected, and rebind plugin EOS sessions after runtime
replacement.

Related to #2269

* fix(plugins): separate pending remote sidecars from last-known cache

Replay only host-offline remote applies into the DB, keep last-known as
upload fallback, use contribution reset for deleted settings, probe real
plugin host readiness, rebind storage events for plugin providers, clear
plugin bases on reset, and swallow disconnect rejections.

Related to #2269

* fix(plugins): hydrate retained sidecars and gate missing providers

Materialize installed-plugin settings from retained sidecars before
collect pruning, clear sidecars on setting reset, fail closed when
pending remote replay fails, and only restore plugin providers that are
contribution-available. Also initialize dynamic provider counters on
connect.

Related to #2269

* fix(plugins): surface plugin sync providers and preserve conflict sidecars

Collect live sidecars before convergent conflict apply/upload, include
dynamic providers in status and disconnect paths, and list plugin sync
providers in Cloud Sync settings for connect/disconnect.

Related to #2269

* fix(plugins): per-provider sidecar baselines and safer collect/UI

Merge convergent sidecars against each provider's own baseline, keep
retained settings in sidecars without unvalidated plugin_settings writes,
guard isConnectDisabled for never-connected plugins, and fall back plugin
provider labels/icons in the status popup.

Related to #2269

* fix(plugins): correct plugin provider discovery and sidecar reverify

Read nested contribution metadata for sync provider cards, gate empty-config
connects, enforce single-provider disconnect for plugins, and re-verify after
sidecar-only convergent uploads so baselines track the post-write payload.

Related to #2269

* fix(plugins): refresh sync provider availability from live contributions

Replace the contribution-available provider ID set when plugins change so
disabled or uninstalled sync providers leave the auto-sync ready set and
drop to disconnected with config retained.

Related to #2269

* fix(plugins): preserve sidecars in backups, pending queue, and migration

Attach last-known plugin sidecars to protective local vault backups, fail
operationally when pending/last-known storage writes are rejected, carry
sidecars through convergent migration publish, and reject trailing garbage
on encrypted object parse.

Related to #2269

* fix(plugins): abort conflict collect failures and merge migration sidecars

Rethrow operational sidecar collection errors during convergent conflict
resolution, LWW-union all local/provider sidecars during convergent
migration, re-collect last-known after apply, and prefer live host collect
for protective local vault backups.

Related to #2269

* fix(plugins): restore pluginBridge tests, fix import path, union downgrade sidecars

Correct SettingsSyncTab dynamic import path so production builds resolve,
add ipcMain.on to the plugin bridge test double, union all provider
sidecars on convergent downgrade, keep empty last-known resets, and skip
force-delete when contribution reset validation rejects.

Related to #2269

* fix(plugins): abort backup collect failures and honor empty remote wipes

Only fall back to last-known sidecars when the host is unavailable during
protective backups, treat explicit empty remote sidecar bundles as
authoritative wipes, remove lint-blocking anys from harness tests, and
use a stable equal-timestamp sidecar tie-break.

Related to #2269

* fix(plugins): include sidecars in equality, preserve merge base on reconnect

Compare pluginSidecars in cloudSyncPayloadsEqual for migration freshness,
clear plugin merge state only when account/resource changes on reconnect,
cancel accepted sync read streams on failure, and stop re-connecting the
plugin EOS adapter on every I/O.

Related to #2269

* fix(plugins): drop cached adapters on contribution refresh

Invalidate plugin sync adapters when live contributions change so a
restarted runtime gets a fresh connect, take offline any non-disconnected
missing providers, and latch version-change backups only after cancellation
checks pass.

Related to #2269

* fix(plugins): preserve omitted sidecars and hydrate on plugin enable

Treat missing pluginSidecars as legacy/unsupported in three-way merge
(only explicit empty is a wipe), hydrate retained settings when a plugin
enables, and re-enable retained provider configs when contributions return.

Related to #2269

* fix(plugins): keep newer retained sidecars over older stored settings

When collection merges existing sidecars with plugin_settings rows, prefer
the newer updatedAt so a schema-rejected remote value retained only in
the sidecar table is not overwritten by a stale local setting.

Related to #2269

* fix(plugins): seal plugin provider config and clear base on config change

Encrypt opaque plugin provider configuration before persistence, clear
merge state when reconnect configuration changes, and stream sync objects
below a base64-safe inline threshold so control-plane JSON stays in budget.

Related to #2269

* fix(plugins): seal plugin configs by provider id and hydrate before start

Encrypt plugin provider configuration only for non-builtin provider IDs
so field-name collisions cannot skip sealing, hydrate retained sidecars
before plugin enable starts the runtime, and stop tearing down live
adapters on no-op availability refreshes.

Related to #2269

* fix(plugins): seal scalar configs, safe inline reads, stable config fingerprint

Encrypt any non-builtin plugin configuration JSON shape, enforce the
base64-safe inline read cutoff, and compare reconnect configurations with
order-independent fingerprints so merge bases are not cleared spuriously.

Related to #2269

* fix(plugins): thread sync credentials and harden availability checks

Pass SyncConnectPayload.credential through the object-storage and IPC host
path, treat only null/undefined config as missing, and avoid wiping the
plugin provider availability catalog on transient discovery failures.

Related to #2269

* fix(plugins): unblock schema-required sync connect and scalar configs

Add a JSON config dialog for plugin sync providers that declare a
required configurationSchema, reuse retained configs on reconnect, and
treat falsy scalar configs as present in readiness and adapter lookup.

Related to #2269

* fix(plugins): rebind sync adapters on contribution refresh

Drop cached plugin sync adapters when live contributions still include
the provider so replaced runtimes get a fresh connect, and seal falsy
scalar plugin configs with nullish presence checks end-to-end.

Related to #2269

* fix(plugins): apply sidecars under current declared setting scopes

When a plugin update changes a syncable setting scope, write under the
live declaration and drop the obsolete scope row so collection does not
republish duplicates.

Related to #2269

* fix(plugins): seal config envelopes and stabilize provider counters

Use an unambiguous host-owned sealed-config envelope, initialize
dynamic-provider sequence counters before cross-window decrypt, keep
three-way sidecar deletions during convergent migration, and create the
sidecar table for existing schema-1 plugin databases.

Related to #2269

* fix(plugins): avoid mid-sync adapter drops and sidecar resurrection

Keep plugin adapters across setting-only contribution refreshes and
rebind sessions on ensureConnected; stop preserving local-only missing
plugin sidecars when remote already mentions the plugin; compare configs
with nullish semantics across windows; swallow quick-connect failures
after toasting.

Related to #2269

* fix(plugins): restore remoteKeys for installed sidecar resets

Reintroduce the remote key set used when dropping installed-plugin
settings the remote no longer carries so applyFromSync cannot throw
after replacing the sidecar table.

Related to #2269

* fix(plugins): honor conflict policy and re-key scoped sidecars

Pass preferCloud/preferLocal into three-way sidecar merge, use
per-provider baselines on convergent downgrade so local resets are not
resurrected, and re-key applied settings under the current declared
scope coordinates.

Related to #2269

* fix(plugins): keep strategy on final sidecar merge and block dual legacy

Pass preferCloud/preferLocal through the final verified sidecar merge,
avoid auto-reactivating a retained plugin while another legacy provider
is already connected, cache plugin adapters only after initializeSync,
and preserve explicit null configuration values.

Related to #2269

* fix(plugins): strip plugin avatars and protect last-known sidecars

Do not load plugin-supplied avatar URLs in the main renderer, and avoid
overwriting last-known sidecars with an unmerged remote bundle when the
post-apply collect fails or is non-authoritative.

Related to #2269

* fix(plugins): strip top-bar plugin avatars and stop always-rebind

Hide plugin avatar URLs in the sync status popover, drop always-on
rebindSession for plugin adapters, and read convergent mode from the
local config module when deciding whether to auto-reactivate providers.

Related to #2269

* fix(plugins): use window.netcatty for sync IPC and allow null configs

Read plugin sync and sidecar APIs from the production preload global,
and treat an explicit config property (including JSON null) as present
for readiness and adapter lookup.

Related to #2269

* fix(plugins): close residual Codex P2 sync edge cases

Three-way merge sidecars during v2 convergent migration so local
resets are not resurrected, preserve explicit null plugin configs on
adapter rebuild, rebind EOS sessions after I/O failure for replaced
runtimes, and accept the public inlineObjectBytes limit on readObject.

Related to #2269

* fix(plugins): keep inline read cutoff under control-plane budget

Restore the base64-safe inline cutoff so advertised-size reads cannot
overflow the 128 KiB provider JSON envelope; providers near the public
inlineObjectBytes limit must stream.

Related to #2269

* fix(plugins): harden sidecar empty-vault, hydrate LWW, and credential seals

Close remaining Codex/review gaps for PR8: keep empty sidecar shells from
bypassing upload guards, skip startup hydrate when local settings are newer,
seal/validate durable plugin credential refs, and cover schema 1→2 migration.

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

* fix(plugins): rebind plugin sync sessions and keep null configs

Address Codex P2 on 8ae52c54: re-issue connect after runtime replacement,
and treat stored config:null as present in the dashboard reconnect path.

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

* fix(plugins): defer empty sidecar last-known until sync succeeds

Keep prior non-empty last-known across an authoritative empty collect so the
empty-vault guard can still recognize a plugin-only reset, then commit the
empty cache after a successful upload.

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

* fix(plugins): avoid restricted localStorage global in sidecar test

Bind the mock to a local binding so eslint no-restricted-globals stays clean.

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

* fix(plugins): commit applied sidecar cache and reset orphan settings

Prefer merged payload sidecars when updating last-known after sync, and clear
syncable plugin_settings that lack sidecar rows on authoritative remote deletes.

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

* fix(plugins): reseal marker-shaped plugin configs instead of skipping

Exact __netcatty_plugin_config_v1 collisions no longer bypass encryption; trusted
host envelopes are unwrapped then resealed, and false collisions are sealed whole.

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

* fix(plugins): keep merged sidecars in last-known after apply collect fails

Return applyFromSync entries from IPC and use them when follow-up collect fails,
so preserved missing-plugin rows are not dropped from the offline upload cache.

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

* fix(plugins): delete stale-scope settings at stored coordinates on reset

After resetSetting against the current declared scope, also remove the row at
the sidecar's stored scope so scope migrations cannot republish remote deletes.

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

* fix(plugins): close sync-provider credential, stream, and sidecar gaps

Make SecretRef usable end-to-end via lease-bound network.request, keep
renderer/main transfers abortable and bounded, and stop sidecar
last-known/pending from corrupting backups and conflict apply.

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

* fix(plugins): harden single-provider, secret schema, and conditional conflicts

Keep legacy restart from dual-connecting providers, extract writeOnly schema
secrets into OS storage, and surface conditional-write precondition failures
as conflict UI instead of a generic sync error.

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

* fix(plugins): dedupe sync type imports for lint

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

* fix: address Codex review on PR #2713

* fix(plugins): wait for sidecar host before version backup latch

Defer the one-shot upgrade snapshot until the plugin sidecar host is ready (or a short grace), and catch getDataHash failures so pending/baseline paths do not reject unhandled.

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

* fix(plugins): require sidecar grace before version backup

Always wait for contributions/grace tick before the upgrade snapshot, and only latch after a non-cancelled attempt so cleanup cannot suppress retries.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-05 18:43:10 +08:00
Netcatty-Botandnetcatty-bot ea3642cb56 fix(#2743): automated Cursor CLI fix (#2744)
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-05 17:39:13 +08:00
857429f6dc fix(terminal): show server name in host info bar (#2732)
* fix(#2708): automated Cursor CLI fix

* fix(terminal): let host info bar show label or address

Avoid concatenating both into the toolbar; add a setting so users pick vault label or connection address, with the other kept in the tooltip.

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

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:51:18 +08:00
9ac4c46df9 fix(sftp): keep external-edit temps across terminal tab switches (#2730)
* fix(#2727): automated Cursor CLI fix

* fix(sftp): drop external-edit retainers when browse sessions close

closeSftp already deletes registered temps on disconnect/reconnect; clear
the matching renderer retainers so hasActiveWork cannot stick on a gone file.

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

* fix(sftp): clear external-edit retainers when watches force-stop

When the temp file disappears, fileWatcherBridge force-stops the watch
with localPath; drop the matching retainer so hasActiveWork cannot stick.

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

* fix: address Codex review on PR #2730

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:32:51 +08:00
4adbfb8817 feat(tabs): add edit host to session tab and quick switcher menus (#2729)
* fix(#2725): automated Cursor CLI fix

* fix(ui): keep Quick Switcher open for host context menu clicks

Portaled ContextMenuContent lives outside containerRef, so outside mousedown was closing the switcher before Connect/Edit host could run.

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

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:26:36 +08:00
Netcatty-Bot bd12cac427 fix(#2731): automated Cursor CLI fix (#2735) 2026-08-05 12:49:29 +08:00
陈大猫 68ca65dce8 fix(ai): stop OpenCode turns dying on terminal tool failures (#2724)
* fix(ai): stop OpenCode turns dying on terminal tool failures

OpenCode MCP terminal_execute treated non-zero exits as bare
"Error: Operation failed" (dropping stdout), and the OpenCode driver
aborted the whole turn on tool status=error. Preserve command output
and surface tool errors as tool results so multi-step agent runs can
continue.

Closes #2718

* fix(ai): use neutral MCP text for silent terminal_execute success

Codex P2 on #2724: ok:true empty serial/raw PTY results no longer fall
back to "Error: Operation failed". Prefer Catty's "Command completed
(no output)" and keep operational failures / non-zero exits unchanged.
Also prefer non-empty OpenCode tool error/output when formatting tool
results.
2026-08-05 11:16:26 +08:00
陈大猫 f395b7c9e9 fix: allow deleting global command history entries (#2706)
Add single-entry delete on the Global history side panel, remove the
matching host autocomplete record when no duplicate row remains, and
keep the change set intentionally slim (no multi-window journal/sequence
hardening).

Fixes #2701
2026-08-05 10:58:22 +08:00
Netcatty-Botandnetcatty-bot 32f0e5abeb fix(terminal): deliver right-click mouseup to mouse-tracking apps (#2722)
* fix(#2721): automated Cursor CLI fix

* fix: address Codex review on PR #2722

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-05 10:40:26 +08:00
Yohannandyangheng 83e0e98795 feat(terminal): auto-upload clipboard images on paste (remote sessions, opt-in setting) (#2676)
* feat(terminal): 粘贴时自动上传剪贴板图片(远程会话,设置项控制)

- 新增设置项 autoUploadClipboardImageOnPaste(默认关闭):剪贴板含图片时,远程 SSH 会话中粘贴自动通过 SFTP 上传图片到远端 .netcatty-paste-images/ 并输入远端路径,无图则回退普通文本粘贴
- terminalClipboardPaste.ts:图片检查优先于本地文件/文本粘贴;上传失败上报错误提示,不上传时静默回退
- 统一四路粘贴入口:右键/中键(onPaste)、DOM paste 事件(useTerminalFilePaste)、绑定快捷键(createXTermRuntime case "paste")
- TerminalBehaviorSettings.tsx 新增设置开关,i18n 补齐 en/ru/zh-CN/zh-TW
- syncPayload.ts 设置同步白名单加入新字段
- 测试:terminalClipboardPaste.test.ts 新增 4 个用例(上传/回退/失败上报/本地不受影响)

Author: yangheng
Model: 42865985-ead4-417f-88a9-12012a3495fe/deepseek-v4-flash

* fix(terminal): 剪贴板图片上传异常时上报错误而非静默回退

- clipboardImagePaste.ts:readClipboardImage 抛错视为 no-image(读图失败与空剪贴板无法区分,可安全回退普通粘贴)
- terminalClipboardPaste.ts:确认剪贴板有图后上传流程抛异常(如 SFTP 无法打开)→ 上报 upload-failed 错误并停止,与右键菜单路径一致,不再静默粘贴无关剪贴板内容
- 测试:新增「上传抛异常 → 上报 upload-failed 且不粘贴」「读图失败 → no-image」2 个用例,18/18 通过

Author: yangheng
Model: 42865985-ead4-417f-88a9-12012a3495fe/deepseek-v4-flash

---------

Co-authored-by: yangheng <492238647@qq.com>
2026-08-05 10:29:37 +08:00
陈大猫 f5edd32366 fix(terminal): keep broadcast through command colons (#2715)
* fix(terminal): keep broadcast through command colons

Treat trailing colons after a confirmed shell prompt as typed command
input so workspace broadcast is not paused mid-command (#2709).

Require terminator + whitespace so mid-label shapes like Challenge #1:
stay fail-closed; standalone auth prompts remain untrusted.

* fix(terminal): require real PS1 body for colon broadcast bypass

Title-case challenge labels that embed shell markers (Challenge # 1:,
Account $ code:) must stay fail-closed. Only bare markers, user@host,
paths, shell-version PS1s, and lowercase identities count as a real
prompt prefix for the #2709 mid-command colon exemption.
2026-08-05 10:08:38 +08:00
陈大猫andCursor 735c047a09 [Bug] Fix host connect script queue selection (#2705) (#2716)
* fix(hosts): keep draft connect scripts before onConnect promote

Host details queue editing pruned non-onConnect scripts immediately, so
default manual automation scripts could be selected but never appeared
in the queue. Keep an editable draft queue and only prune missing IDs
until save promotes triggers.

Closes #2705

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

* fix(hosts): promote persisted manual connect-queue scripts on save

Editable queue retention left already-saved manual script IDs unpromoted
when reopen/save compared only newly added IDs. Promote every queued
script that still needs onConnect + host targeting.

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

* chore(hosts): drop unused HostDetailsPanel snippet imports

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

* fix(hosts): do not demote global scripts during queue save sync

Promote-on-save must leave targetsAllHosts onConnect scripts unchanged
when they appear in a host connectScriptIds list.

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

* test(hosts): cover global connect scripts during queue save sync

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

* fix(hosts): promote connect-queue scripts on new host save

New hosts have no initialData, so queue promote-on-save was skipped and
manual scripts stayed non-onConnect after the first save.

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

* fix(hosts): harden connect-queue promote against Codex review

Preserve global scope when promoting manuals, skip re-promoting scripts
demoted while the host editor is open, sync peer host queues for retained
targets, and keep test fixtures ASCII-only.

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

* fix(hosts): persist peer connect queues with edited host save

Peer queue sync via onHostsChange was overwritten by onSave rebuilding
from a stale hosts snapshot. Write peers+edited host together and make
save paths apply functional host updates.

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

* fix(hosts): keep connect queues across hydration and concurrent edits

Skip queue rewrite while snippets are still empty, and do not promote
entries whose non-onConnect trigger changed while the host editor stayed
open.

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

* fix(hosts): skip connect-queue rewrite before snippets hydrate

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

* fix(hosts): preserve concurrent target removals and merge peer deltas

Drop stale queue entries when targets were unlinked while editing, and
apply peer connectScriptIds updates via functional host state merges.

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

* fix(hosts): hydrate connect-queue baseline and append peer deltas

Capture the first hydrated snippet catalog as the editor baseline, and
merge peer queue updates by appending only newly queued script IDs.

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

* fix(hosts): snapshot opening queue and keep peer updates host-safe

Use the editor-opening connect queue as sync previous IDs, and never
overwrite the edited host inside peer delta merges.

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

* fix(hosts): clear deleted queues after hydrate and honor manual unlinks

Treat a hydrated empty snippet catalog as ready for queue reconciliation,
and drop concurrently untargeted non-onConnect queue entries on save.

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

* fix(hosts): clear loginScriptId when connect queue is emptied

sanitizeHost reconstitutes connectScriptIds from loginScriptId when the
explicit queue is absent, so clearing the last queued script must also
drop the legacy field.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 09:53:37 +08:00
陈大猫 cd8371e3fc Merge pull request #2717 from binaricat/cursor/fix-copy-tab-channel-rate-limit-b2b2
fix(ssh): Copy Tab reuse survives bastion channelOpen rate limits (#2704)
2026-08-04 21:54:45 +08:00
Cursor Agentand陈大猫 eee9be01f1 fix(ssh): retry rate-limited PID discovery for direct bastions
Shell-open retries already applied to every Copy Tab reuse, but resilient
PID scans were still gated on jumpHosts. Direct bastion targets that
rate-limit session channels could reuse the shell while leaving the
copied tab without shellPid. Always retry rate-limited discovery on the
reuse path; keep the optional pre-scan pause jump-host-only.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-04 13:41:39 +00:00
Cursor Agentand陈大猫 f59518a2ff fix(ssh): retry rate-limited pre-open shell PID discovery
If the jump-host Copy Tab pre-open PID scan is rejected with channelOpen
too offen, a single delayed attempt left discovery unavailable and skipped
post-open assignment. Retry rate-limited pre-open scans so shellPid
tracking still succeeds before opening the reused shell.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-04 13:25:29 +00:00
Cursor Agentand陈大猫 6acc2883fd fix(ssh): retry rate-limited post-open shell PID discovery
After a bastion-rate-limited shell open succeeds on Copy Tab, the follow-up
PID discovery exec can hit the same "channelOpen too offen" limit. Delay and
retry that discovery on jump-host reuse so the copied session still gets a
shellPid for unambiguous cwd lookup.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-04 13:20:49 +00:00
Cursor Agentand陈大猫 b7e8f9a04a fix(ssh): keep jump-host shell PID discovery with rate-limit backoff
Address review feedback: permanently skipping PID discovery on jump-host
Copy Tab left shared transports without shellPid and broke cwd lookup.
Delay discovery briefly instead, and keep shell-open retries for the
bastion "channelOpen too offen" rate limit.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-04 13:14:54 +00:00
Cursor Agentand陈大猫 93abe8446a fix(ssh): retry Copy Tab shell opens on bastion channel rate limits
Bastion/jump hosts often reject rapid session channel opens with
"channelOpen too offen". Copy Tab was opening a discovery exec then a
shell on the same authenticated transport, failing reuse and falling
back to a fresh login that prompts for OTP again.

Skip shell PID discovery when jump hosts are present, and retry shell
opens with short backoff on the bastion rate-limit error so duplicated
tabs keep reusing the live connection.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-04 13:09:15 +00:00
109 changed files with 4655 additions and 234 deletions

No files matched your search

+3 -1
View File
@@ -248,6 +248,7 @@ function App({ settings }: { settings: SettingsState }) {
updateKnownHosts,
updateManagedSources,
addShellHistoryEntry,
removeShellHistoryEntry,
addConnectionLog,
updateConnectionLog,
toggleConnectionLogSaved,
@@ -1737,6 +1738,7 @@ function App({ settings }: { settings: SettingsState }) {
const appVaultDomain = useMemo(() => ({
addShellHistoryEntry,
removeShellHistoryEntry,
commitPluginImporterData,
commitVaultImportTransaction,
connectionLogs,
@@ -1782,7 +1784,7 @@ function App({ settings }: { settings: SettingsState }) {
updateSnippetPackages,
updateSnippets,
vaultFocusRequest,
}), [addShellHistoryEntry, commitPluginImporterData, commitVaultImportTransaction, connectionLogs, convertKnownHostToHost, customGroups, deepLinkHostDraft, effectiveKnownHosts, groupConfigs, handleAddKnownHost, handleDeleteHost, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, hosts, identities, importOrReuseKey, keys, managedSources, navigateToSection, noteGroups, notes, proxyProfiles, readPersistedHosts, readPersistedManagedSources, setDeepLinkHostDraft, setNavigateToSection, setVaultFocusRequest, snippetPackages, snippets, unmanageSource, updateCustomGroups, updateGroupConfigs, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, vaultFocusRequest]);
}), [addShellHistoryEntry, commitPluginImporterData, commitVaultImportTransaction, connectionLogs, convertKnownHostToHost, customGroups, deepLinkHostDraft, effectiveKnownHosts, groupConfigs, handleAddKnownHost, handleDeleteHost, handleOpenHostFromVaultNote, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, hosts, identities, importOrReuseKey, keys, managedSources, navigateToSection, noteGroups, notes, proxyProfiles, readPersistedHosts, readPersistedManagedSources, removeShellHistoryEntry, setDeepLinkHostDraft, setNavigateToSection, setVaultFocusRequest, snippetPackages, snippets, unmanageSource, updateCustomGroups, updateGroupConfigs, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateNoteGroups, updateNotes, updateProxyProfiles, updateSnippetPackages, updateSnippets, vaultFocusRequest]);
const appTerminalDomain = useMemo(() => ({
addSessionToWorkspace,
+1
View File
@@ -3,6 +3,7 @@
## [Unreleased] - 2026-03-11
### 功能
- 终端新增"粘贴时自动上传剪贴板图片"选项:剪贴板含图片时,在远程会话中粘贴(快捷键/右键/中键)自动通过 SFTP 上传图片到远端 `.netcatty-paste-images/` 目录并输入远端路径,否则保持原有文本粘贴行为(设置 > 终端 > 行为,默认关闭)
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
+3
View File
@@ -92,6 +92,7 @@ interface AppHostEditorLayerProps {
onCreateGroup: (groupPath: string) => void;
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
onUpdateSnippets: (snippets: Snippet[]) => void;
onUpdateHosts?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
}
export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
@@ -117,6 +118,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
onCreateGroup,
onImportOrReuseKey,
onUpdateSnippets,
onUpdateHosts,
}) => {
const { t } = useI18n();
const derivedSurfaceVisible = useWorkSurfaceVisible({
@@ -189,6 +191,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
groupConfigs={groupConfigs}
snippets={snippets}
onSnippetsChange={onUpdateSnippets}
onHostsChange={onUpdateHosts}
onImportKey={onImportOrReuseKey}
onSave={onSave}
onCancel={onCancel}
+25 -1
View File
@@ -23,6 +23,7 @@ import { toast } from '../../components/ui/toast';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { buildAppThemeCssVars } from '../state/settingsStateDefaults';
import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocusRecovery';
@@ -113,7 +114,7 @@ function AppViewInner({ domains }: AppViewProps) {
useMainWindowInputFocusRecovery({ onPageHidden: dismissTransientOverlays });
const {
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
accentMode, addShellHistoryEntry, removeShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, commitPluginImporterData, commitVaultImportTransaction, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell, copyWorkspaceWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deepLinkHostDraft, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
@@ -182,6 +183,21 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateHosts: updateHosts,
onSaved: handleWorkSurfaceHostSaved,
});
const openWorkSurfaceHostEdit = workSurfaceHostEditor.openEdit;
const handleEditHostFromOverlay = useCallback((host: (typeof hosts)[number]) => {
if (shouldOpenHostEditOnWorkSurface(activeTabStore.getActiveTabId())) {
openWorkSurfaceHostEdit(host);
return;
}
setDeepLinkHostDraft(host);
setNavigateToSection('hosts');
setActiveTabId('vault');
}, [
openWorkSurfaceHostEdit,
setActiveTabId,
setDeepLinkHostDraft,
setNavigateToSection,
]);
const handleCreateWorkSurfaceHostGroup = useCallback((groupPath: string) => {
updateCustomGroups(Array.from(new Set([...customGroups, groupPath])));
}, [customGroups, updateCustomGroups]);
@@ -272,6 +288,7 @@ function AppViewInner({ domains }: AppViewProps) {
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onEditHost={handleEditHostFromOverlay}
onRenameWorkspace={startWorkspaceRename}
onCopyWorkspace={copyWorkspaceWithCurrentShell}
onCloseWorkspace={closeWorkspace}
@@ -345,6 +362,7 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateGroup={handleCreateWorkSurfaceHostGroup}
onImportOrReuseKey={importOrReuseKey}
onUpdateSnippets={updateSnippets}
onUpdateHosts={updateHosts}
/>
<VaultViewContainer appThemeStyle={appThemeStyle}>
@@ -485,6 +503,7 @@ function AppViewInner({ domains }: AppViewProps) {
onUpdateHost={handleUpdateHostFromTerminal}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleTerminalCommandExecuted}
onDeleteShellHistoryEntry={removeShellHistoryEntry}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
@@ -665,6 +684,11 @@ function AppViewInner({ domains }: AppViewProps) {
showSftpTab={settings.showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
handleEditHostFromOverlay(host);
}}
onSelectTab={(tabId) => {
setActiveTabId(tabId);
setIsQuickSwitcherOpen(false);
+10
View File
@@ -9,6 +9,7 @@ import {
reorderWorkTabIds,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
shouldOpenHostEditOnWorkSurface,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
@@ -77,6 +78,15 @@ test('root pages are not work tab surfaces', () => {
assert.equal(isRootPageTabId('session-1'), false);
});
test('host edit overlay prefers work-surface editor except on vault/sftp/plugin tabs', () => {
assert.equal(shouldOpenHostEditOnWorkSurface('session-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('workspace-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('editor:file-1'), true);
assert.equal(shouldOpenHostEditOnWorkSurface('vault'), false);
assert.equal(shouldOpenHostEditOnWorkSurface('sftp'), false);
assert.equal(shouldOpenHostEditOnWorkSurface('plugin-view:demo'), false);
});
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
+8
View File
@@ -23,6 +23,14 @@ export function isRootPageTabId(activeTabId: string): boolean {
return activeTabId === 'vault' || activeTabId === 'sftp';
}
/**
* Host edit from overlays (Quick Switcher): use the terminal work-surface
* HostDetailsPanel when a work tab is active; otherwise deep-link into Vault.
*/
export function shouldOpenHostEditOnWorkSurface(activeTabId: string): boolean {
return !isRootPageTabId(activeTabId) && !isPluginViewTabId(activeTabId);
}
export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
+7
View File
@@ -574,6 +574,9 @@ Highlight the focused split pane:
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'settings.terminal.behavior.autoUploadClipboardImage': 'Auto-upload clipboard images on paste',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'When the clipboard holds an image, pasting in a remote session uploads it via SFTP to .netcatty-paste-images/ under the remote working directory and inserts the remote path instead of pasting text.',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter text',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
@@ -680,6 +683,10 @@ Highlight the focused split pane:
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.hostInfoBar.show': 'Show host information bar',
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
'settings.terminal.hostInfoBar.titleMode': 'Host information title',
'settings.terminal.hostInfoBar.titleMode.desc': 'Choose whether the bar shows the connection address (user@host) or the vault host label.',
'settings.terminal.hostInfoBar.titleMode.address': 'Connection address',
'settings.terminal.hostInfoBar.titleMode.label': 'Host label',
'settings.terminal.section.systemManager': 'System Manager',
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
+1
View File
@@ -34,6 +34,7 @@ export const enTerminalMessages: Messages = {
'history.action.paste': 'Paste to terminal',
'history.action.run': 'Run in terminal',
'history.action.saveAsSnippet': 'Save as snippet',
'history.action.delete': 'Delete from history',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
+7
View File
@@ -535,6 +535,9 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
'settings.terminal.behavior.bracketedPaste.desc':
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
'settings.terminal.behavior.autoUploadClipboardImage': 'Авто-загрузка изображений буфера обмена при вставке',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'Когда в буфере обмена есть изображение, вставка в удалённой сессии загружает его по SFTP в каталог .netcatty-paste-images/ в текущем удалённом каталоге и вводит удалённый путь вместо вставки текста.',
'settings.terminal.behavior.shiftEnterNewline': 'Текст для Shift+Enter',
'settings.terminal.behavior.shiftEnterNewline.desc':
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
@@ -637,6 +640,10 @@ export const ruCoreMessages: Messages = {
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
'settings.terminal.hostInfoBar.titleMode': 'Заголовок сведений о хосте',
'settings.terminal.hostInfoBar.titleMode.desc': 'Показывать в панели адрес подключения (user@host) или метку хоста из хранилища.',
'settings.terminal.hostInfoBar.titleMode.address': 'Адрес подключения',
'settings.terminal.hostInfoBar.titleMode.label': 'Метка хоста',
'settings.terminal.section.systemManager': 'Системный менеджер',
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
+1
View File
@@ -55,6 +55,7 @@ export const ruTerminalMessages: Messages = {
'history.action.paste': 'Вставить в терминал',
'history.action.run': 'Выполнить в терминале',
'history.action.saveAsSnippet': 'Сохранить как сниппет',
'history.action.delete': 'Удалить из истории',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
@@ -52,6 +52,7 @@ export const zhCNTerminalMessages: Messages = {
'history.action.paste': '粘贴到终端',
'history.action.run': '在终端执行',
'history.action.saveAsSnippet': '保存为代码片段',
'history.action.delete': '从历史记录中删除',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
@@ -310,6 +311,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.autoUploadClipboardImage': '粘贴时自动上传剪贴板图片',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'当剪贴板包含图片时,在远程会话中粘贴会通过 SFTP 将图片上传到远端当前目录的 .netcatty-paste-images/ 文件夹,并在终端输入远端路径,而不是粘贴文本。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 发送文本',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
@@ -410,6 +414,10 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.section.serverStats': '服务器状态(Linux)',
'settings.terminal.hostInfoBar.show': '显示主机信息栏',
'settings.terminal.hostInfoBar.show.desc': '在终端上方显示主机地址和服务器信息。隐藏后仍可使用操作按钮。',
'settings.terminal.hostInfoBar.titleMode': '主机信息标题',
'settings.terminal.hostInfoBar.titleMode.desc': '选择信息栏显示连接地址(user@host)还是主机名称(Vault 标签)。',
'settings.terminal.hostInfoBar.titleMode.address': '连接地址',
'settings.terminal.hostInfoBar.titleMode.label': '主机名称',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况(仅限 Linux 服务器)。',
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
@@ -52,6 +52,7 @@ export const zhTWTerminalMessages: Messages = {
'history.action.paste': '貼上到終端',
'history.action.run': '在終端執行',
'history.action.saveAsSnippet': '儲存為程式碼片段',
'history.action.delete': '從歷史記錄中刪除',
// SFTP File Opener
'sftp.context.copyPath': '複製檔案路徑',
'sftp.context.openWith': '開啟方式...',
@@ -310,6 +311,9 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.bracketedPaste': '括號貼上模式',
'settings.terminal.behavior.bracketedPaste.desc':
'貼上文字時使用轉義序列包裹,以便終端區分貼上和鍵入。如果出現 ^[[200~ 字樣請關閉此選項。',
'settings.terminal.behavior.autoUploadClipboardImage': '貼上時自動上傳剪貼簿圖片',
'settings.terminal.behavior.autoUploadClipboardImage.desc':
'當剪貼簿包含圖片時,在遠端工作階段中貼上會透過 SFTP 將圖片上傳到遠端目前目錄的 .netcatty-paste-images/ 資料夾,並在終端輸入遠端路徑,而不是貼上文字。',
'settings.terminal.behavior.shiftEnterNewline': 'Shift+Enter 傳送文字',
'settings.terminal.behavior.shiftEnterNewline.desc':
'在終端中按 Shift+Enter 時傳送設定的文字,而不是普通 Enter。',
@@ -410,6 +414,10 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.section.serverStats': '伺服器狀態(Linux)',
'settings.terminal.hostInfoBar.show': '顯示主機資訊列',
'settings.terminal.hostInfoBar.show.desc': '在終端上方顯示主機地址和伺服器資訊。隱藏後仍可使用操作按鈕。',
'settings.terminal.hostInfoBar.titleMode': '主機資訊標題',
'settings.terminal.hostInfoBar.titleMode.desc': '選擇資訊列顯示連線地址(user@host)還是主機名稱(Vault 標籤)。',
'settings.terminal.hostInfoBar.titleMode.address': '連線地址',
'settings.terminal.hostInfoBar.titleMode.label': '主機名稱',
'settings.terminal.serverStats.show': '顯示伺服器狀態',
'settings.terminal.serverStats.show.desc': '在終端狀態列顯示 CPU、記憶體和磁碟使用情況(僅限 Linux 伺服器)。',
'settings.terminal.serverStats.refreshInterval': '重新整理間隔',
@@ -103,4 +103,35 @@ describe('storePluginSyncSecretsThenConnect', () => {
);
assert.deepEqual(deletedKeys, [['sync-credential']]);
});
it('keeps overwritten secrets when reconnect connect rejects', async () => {
const deletedKeys: string[][] = [];
const restoredKeys: string[][] = [];
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [{ secretKey: 'sync-credential', value: 'bad-password' }],
putSecret: async ({ key }) => ({
kind: 'secret',
id: 'ref-overwritten',
key,
created: false,
}),
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
restoreSecrets: async ({ keys }) => {
restoredKeys.push([...keys]);
return { restored: keys.length };
},
connect: async () => {
throw new Error('auth failed');
},
}),
/auth failed/,
);
assert.deepEqual(deletedKeys, []);
assert.deepEqual(restoredKeys, [['sync-credential']]);
});
});
+44 -2
View File
@@ -1,6 +1,8 @@
/**
* Persist plugin sync secrets then connect; roll back just-created secrets if
* connect (or a later put) fails so bad passwords are not left in OS storage.
* Overwritten keys are restored from the host-side overwrite stash so a
* rejected reconnect does not leave a broken SecretRef / rejected credential.
*/
export type PluginSyncSecretRef = { kind: 'secret'; id: string; key: string };
@@ -10,6 +12,11 @@ export interface PluginSyncConnectSecretInput {
value: string;
}
export interface PluginSyncPutSecretResult extends PluginSyncSecretRef {
/** False when the durable (plugin,key) row already existed and was overwritten. */
created?: boolean;
}
export interface StorePluginSyncSecretsThenConnectParams {
providerId: string;
secrets: readonly PluginSyncConnectSecretInput[];
@@ -19,11 +26,20 @@ export interface StorePluginSyncSecretsThenConnectParams {
providerId: string;
key: string;
value: string;
}) => Promise<PluginSyncSecretRef>;
}) => Promise<PluginSyncPutSecretResult>;
deleteSecrets: (params: {
providerId: string;
keys: string[];
}) => Promise<unknown>;
/**
* Restore host-stashed plaintext for overwritten keys (`discard: false`),
* or drop the stash after a successful connect (`discard: true`).
*/
restoreSecrets?: (params: {
providerId: string;
keys: string[];
discard?: boolean;
}) => Promise<unknown>;
connect: (credential: PluginSyncSecretRef | undefined) => Promise<void>;
}
@@ -31,6 +47,7 @@ export async function storePluginSyncSecretsThenConnect(
params: StorePluginSyncSecretsThenConnectParams,
): Promise<void> {
const createdKeys: string[] = [];
const overwrittenKeys: string[] = [];
let credential: PluginSyncSecretRef | undefined = params.existingCredential;
try {
@@ -42,13 +59,28 @@ export async function storePluginSyncSecretsThenConnect(
key: secret.secretKey,
value: secret.value,
});
createdKeys.push(secret.secretKey);
if (ref.created === false) {
overwrittenKeys.push(secret.secretKey);
} else {
createdKeys.push(secret.secretKey);
}
// SyncConnectPayload.credential carries the primary (first) secret;
// additional secrets remain addressable via secrets.get(key).
if (!credential) credential = ref;
}
}
await params.connect(credential);
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
try {
await params.restoreSecrets({
providerId: params.providerId,
keys: [...overwrittenKeys],
discard: true,
});
} catch {
/* best-effort stash cleanup */
}
}
} catch (error) {
if (createdKeys.length > 0) {
try {
@@ -60,6 +92,16 @@ export async function storePluginSyncSecretsThenConnect(
/* best-effort; surface the original connect/put error */
}
}
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
try {
await params.restoreSecrets({
providerId: params.providerId,
keys: [...overwrittenKeys],
});
} catch {
/* best-effort; surface the original connect/put error */
}
}
throw error;
}
}
+23 -1
View File
@@ -27,6 +27,7 @@ const systemLight: AppearanceRenderSnapshot = {
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
test("an OS color event cannot persist a stale System choice over a newer Dark choice", () => {
@@ -97,6 +98,7 @@ test("a non-theme appearance IPC value wins over stale local storage for that ke
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
// Storage still lags for every non-theme field (the race the review covers).
const staleStored: StoredAppearanceValues = {
@@ -151,6 +153,22 @@ test("a non-theme appearance IPC value wins over stale local storage for that ke
}, customAccentSelection);
assert.equal(nextCustomAccent.customAccent, "221.2 83.2% 53.3%");
// Stale lower-version accent echoes must not clobber a newer local drag sample.
const afterDrag = {
...current,
customAccent: "199 89% 48%",
customAccentVersion: 2,
};
const staleAccentEcho = resolveAppearanceSyncState(afterDrag, {
...staleStored,
customAccent: "0 84% 60%",
}, {
key: STORAGE_KEY_COLOR,
value: { color: "0 84% 60%", version: 1 },
});
assert.equal(staleAccentEcho.customAccent, "199 89% 48%");
assert.equal(staleAccentEcho.customAccentVersion, 2);
// Without an announced key, full rehydrate still reads storage.
const noIncoming = resolveAppearanceSyncState(current, {
...staleStored,
@@ -168,6 +186,7 @@ test("sequential keyed appearance IPC updates compose without stale-storage clob
darkUiThemeId: "midnight",
accentMode: "theme",
customAccent: "208 100% 50%",
customAccentVersion: 0,
};
const staleStored: StoredAppearanceValues = { ...initial };
@@ -196,6 +215,7 @@ test("System on a light OS changes to Dark in every open follow-app terminal", (
darkUiThemeId: systemLight.darkUiThemeId,
accentMode: systemLight.accentMode,
customAccent: systemLight.customAccent,
customAccentVersion: systemLight.customAccentVersion,
};
const terminalAppearance = (appearance: AppearanceState, resolvedTheme: "light" | "dark") => (
resolveGlobalTerminalAppearance({
@@ -292,7 +312,7 @@ test("the race guards are wired into the real settings paths", () => {
const lightNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId)", writeIndex);
const darkNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId)", writeIndex);
const accentModeNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode)", writeIndex);
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccent)", writeIndex);
const colorNotifyIndex = stateSource.indexOf("notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord)", writeIndex);
assert.ok(guardIndex >= 0, "the settings effect must compare persisted appearance fields");
assert.ok(returnIndex > guardIndex && returnIndex < writeIndex, "the stale render must stop before storage is written");
@@ -301,6 +321,8 @@ test("the race guards are wired into the real settings paths", () => {
assert.ok(darkNotifyIndex > writeIndex, "dark UI theme changes must be announced over IPC with the new value");
assert.ok(accentModeNotifyIndex > writeIndex, "accent mode changes must be announced over IPC with the new value");
assert.ok(colorNotifyIndex > writeIndex, "custom accent changes must be announced over IPC with the new value");
assert.match(stateSource, /shouldBroadcastCustomAccentChange/);
assert.match(stateSource, /serializeCustomAccentRecord\(customAccentRecord\)/);
// Source still only notifies fields that actually changed (keyed, not a single theme-only broadcast).
assert.match(
stateSource,
+41 -15
View File
@@ -6,10 +6,14 @@ import {
STORAGE_KEY_UI_THEME_LIGHT,
} from '../../infrastructure/config/storageKeys';
import {
isValidHslToken,
isValidTheme,
isValidUiThemeId,
} from './settingsStateDefaults';
import {
parseCustomAccentRecord,
shouldApplyCustomAccentRecord,
type CustomAccentRecord,
} from './customAccentSync';
export type AppearanceState = {
theme: "light" | "dark" | "system";
@@ -17,6 +21,8 @@ export type AppearanceState = {
darkUiThemeId: string;
accentMode: "theme" | "custom";
customAccent: string;
/** Sync revision for custom accent; gates stale IPC/storage echoes during color-picker drag. */
customAccentVersion: number;
};
export type AppearanceRenderSnapshot = {
@@ -50,7 +56,8 @@ export function hasPersistedAppearanceChanged(
|| previous.lightUiThemeId !== current.lightUiThemeId
|| previous.darkUiThemeId !== current.darkUiThemeId
|| previous.accentMode !== current.accentMode
|| previous.customAccent !== current.customAccent;
|| previous.customAccent !== current.customAccent
|| previous.customAccentVersion !== current.customAccentVersion;
}
export function resolveIncomingAppearanceValue<T>(
@@ -106,13 +113,20 @@ export function resolveAppearanceSyncState(
current.accentMode,
(value): value is AppearanceState['accentMode'] => value === 'theme' || value === 'custom',
);
const customAccent = resolveIncomingAppearanceValue(
incoming,
STORAGE_KEY_COLOR,
stored.customAccent,
current.customAccent,
(value): value is string => typeof value === 'string' && isValidHslToken(value),
);
const currentAccentRecord: CustomAccentRecord = {
color: current.customAccent,
version: current.customAccentVersion,
};
const incomingAccentRaw = incoming?.key === STORAGE_KEY_COLOR ? incoming.value : undefined;
const resolvedAccentRaw = incomingAccentRaw !== undefined
? incomingAccentRaw
: (incoming ? currentAccentRecord : stored.customAccent);
const resolvedAccentRecord = incomingAccentRaw !== undefined || !incoming
? parseCustomAccentRecord(resolvedAccentRaw)
: currentAccentRecord;
const nextAccentRecord = shouldApplyCustomAccentRecord(currentAccentRecord, resolvedAccentRecord)
? resolvedAccentRecord
: currentAccentRecord;
return {
theme: isValidTheme(theme) ? theme : current.theme,
@@ -123,9 +137,8 @@ export function resolveAppearanceSyncState(
? darkUiThemeId
: current.darkUiThemeId,
accentMode: accentMode === 'theme' || accentMode === 'custom' ? accentMode : current.accentMode,
customAccent: typeof customAccent === 'string' && isValidHslToken(customAccent)
? customAccent.trim()
: current.customAccent,
customAccent: nextAccentRecord.color,
customAccentVersion: nextAccentRecord.version,
};
}
@@ -165,11 +178,24 @@ export function resolveAppearanceStorageEvent(
};
}
if (key === STORAGE_KEY_COLOR) {
if (newValue == null) {
return { handled: true, next: current };
}
const incoming = parseCustomAccentRecord(newValue);
const currentRecord: CustomAccentRecord = {
color: current.customAccent,
version: current.customAccentVersion,
};
if (!shouldApplyCustomAccentRecord(currentRecord, incoming)) {
return { handled: true, next: current };
}
return {
handled: true,
next: newValue && isValidHslToken(newValue)
? { ...current, customAccent: newValue.trim() }
: current,
next: {
...current,
customAccent: incoming.color,
customAccentVersion: incoming.version,
},
};
}
return { handled: false, next: current };
+204
View File
@@ -0,0 +1,204 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseCustomAccentRecord,
serializeCustomAccentRecord,
shouldApplyCustomAccentRecord,
shouldBroadcastCustomAccentChange,
type CustomAccentMutationSource,
type CustomAccentRecord,
} from './customAccentSync.ts';
/**
* Minimal model of the settings ↔ main custom-accent sync loop that caused
* #2743. Models both IPC rebroadcast and stale localStorage overwrites while
* the native color picker fires rapid onChange events.
*/
function simulateAccentDrag(options: {
shouldBroadcast: (
source: CustomAccentMutationSource,
persistMounted: boolean,
) => { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource };
shouldApply: (current: CustomAccentRecord, incoming: CustomAccentRecord) => boolean;
versioned: boolean;
}): { settingsValues: string[]; mainValues: string[]; storageWrites: string[] } {
let settings: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
let main: CustomAccentRecord = { color: '221.2 83.2% 53.3%', version: 0 };
let settingsSource: CustomAccentMutationSource = 'local';
let mainSource: CustomAccentMutationSource = 'local';
let storage = options.versioned
? serializeCustomAccentRecord(settings)
: settings.color;
const settingsValues: string[] = [];
const mainValues: string[] = [];
const storageWrites: string[] = [];
const pendingIpc: Array<{ to: 'settings' | 'main'; record: CustomAccentRecord }> = [];
const writeStorage = (record: CustomAccentRecord) => {
const next = options.versioned
? serializeCustomAccentRecord(record)
: record.color;
if (next === storage) return;
storage = next;
storageWrites.push(next);
};
const applyLocal = (window: 'settings' | 'main', color: string) => {
const bump = (prev: CustomAccentRecord): CustomAccentRecord => (
options.versioned
? { color, version: prev.version + 1 }
: { color, version: 0 }
);
if (window === 'settings') {
settingsSource = 'local';
settings = bump(settings);
settingsValues.push(settings.color);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
mainSource = 'local';
main = bump(main);
mainValues.push(main.color);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const applyIncoming = (window: 'settings' | 'main', record: CustomAccentRecord) => {
if (window === 'settings') {
if (!options.shouldApply(settings, record)) return;
settingsSource = 'incoming';
settings = { ...record };
settingsValues.push(settings.color);
writeStorage(settings);
const decision = options.shouldBroadcast(settingsSource, true);
settingsSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'main', record: { ...settings } });
return;
}
if (!options.shouldApply(main, record)) return;
mainSource = 'incoming';
main = { ...record };
mainValues.push(main.color);
writeStorage(main);
const decision = options.shouldBroadcast(mainSource, true);
mainSource = decision.nextSource;
if (decision.shouldBroadcast) pendingIpc.push({ to: 'settings', record: { ...main } });
};
const flushIpc = () => {
while (pendingIpc.length > 0) {
const next = pendingIpc.shift()!;
applyIncoming(next.to, next.record);
if (settingsValues.length > 40) break;
}
};
const deliverStorageTo = (window: 'settings' | 'main') => {
const record = options.versioned
? parseCustomAccentRecord(JSON.parse(storage))
: parseCustomAccentRecord(storage);
applyIncoming(window, record);
};
applyLocal('settings', '0 84% 60%');
deliverStorageTo('main');
applyLocal('settings', '199 89% 48%');
// Delayed IPC for the older color arrives after the newer local drag sample.
const delayed = pendingIpc.shift();
flushIpc();
if (delayed) applyIncoming(delayed.to, delayed.record);
deliverStorageTo('settings');
flushIpc();
return { settingsValues, mainValues, storageWrites };
}
test('parseCustomAccentRecord accepts legacy HSL tokens and versioned JSON', () => {
assert.deepEqual(parseCustomAccentRecord('199 89% 48%'), { color: '199 89% 48%', version: 0 });
assert.deepEqual(
parseCustomAccentRecord({ color: '0 84% 60%', version: 3 }),
{ color: '0 84% 60%', version: 3 },
);
assert.deepEqual(
parseCustomAccentRecord('{"color":"262.1 83.3% 57.8%","version":9}'),
{ color: '262.1 83.3% 57.8%', version: 9 },
);
assert.equal(parseCustomAccentRecord('bad').color, '221.2 83.2% 53.3%');
});
test('shouldApplyCustomAccentRecord ignores stale revisions', () => {
const current = { color: '199 89% 48%', version: 2 };
assert.equal(shouldApplyCustomAccentRecord(current, { color: '0 84% 60%', version: 1 }), false);
assert.equal(shouldApplyCustomAccentRecord(current, { color: '330 81% 60%', version: 3 }), true);
assert.equal(shouldApplyCustomAccentRecord(current, { color: '199 89% 48%', version: 2 }), false);
});
test('shouldBroadcastCustomAccentChange suppresses incoming rebroadcasts', () => {
assert.deepEqual(
shouldBroadcastCustomAccentChange('incoming', true),
{ shouldBroadcast: false, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastCustomAccentChange('local', true),
{ shouldBroadcast: true, nextSource: 'local' },
);
assert.deepEqual(
shouldBroadcastCustomAccentChange('local', false),
{ shouldBroadcast: false, nextSource: 'local' },
);
});
test('legacy unversioned always-broadcast accent sync oscillates during a fast drag', () => {
const alwaysBroadcast = (
_source: CustomAccentMutationSource,
persistMounted: boolean,
) => ({
shouldBroadcast: persistMounted,
nextSource: 'local' as const,
});
const alwaysApply = () => true;
const { settingsValues } = simulateAccentDrag({
shouldBroadcast: alwaysBroadcast,
shouldApply: alwaysApply,
versioned: false,
});
const unique = new Set(settingsValues);
assert.ok(
unique.has('0 84% 60%') && unique.has('199 89% 48%') && settingsValues.length > 2,
`expected oscillation between drag samples, got ${settingsValues.join(' | ')}`,
);
});
test('versioned accent sync ignores stale peer echoes during a fast drag', () => {
const { settingsValues, mainValues } = simulateAccentDrag({
shouldBroadcast: shouldBroadcastCustomAccentChange,
shouldApply: shouldApplyCustomAccentRecord,
versioned: true,
});
assert.deepEqual(settingsValues, ['0 84% 60%', '199 89% 48%']);
assert.ok(mainValues.includes('199 89% 48%'));
assert.equal(
mainValues.includes('0 84% 60%') && mainValues[mainValues.length - 1] === '0 84% 60%',
false,
);
});
test('serializeCustomAccentRecord round-trips through parse', () => {
const raw = serializeCustomAccentRecord({ color: '262.1 83.3% 57.8%', version: 9 });
assert.deepEqual(parseCustomAccentRecord(JSON.parse(raw)), {
color: '262.1 83.3% 57.8%',
version: 9,
});
});
+93
View File
@@ -0,0 +1,93 @@
import { DEFAULT_CUSTOM_ACCENT, isValidHslToken } from './settingsStateDefaults';
export type CustomAccentMutationSource = 'local' | 'incoming';
export type CustomAccentRecord = {
color: string;
version: number;
};
const FALLBACK_RECORD: CustomAccentRecord = {
color: DEFAULT_CUSTOM_ACCENT,
version: 0,
};
function normalizeAccentColor(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
return isValidHslToken(trimmed) ? trimmed : null;
}
/**
* Parse persisted / IPC custom-accent payloads.
* Accepts legacy plain HSL tokens ("221.2 83.2% 53.3%") and versioned records.
*/
export function parseCustomAccentRecord(raw: unknown): CustomAccentRecord {
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed.startsWith('{')) {
try {
return parseCustomAccentRecord(JSON.parse(trimmed));
} catch {
// fall through to plain HSL
}
}
const color = normalizeAccentColor(trimmed);
if (color) return { color, version: 0 };
}
if (raw && typeof raw === 'object') {
const record = raw as { color?: unknown; version?: unknown };
const color = normalizeAccentColor(record.color) ?? FALLBACK_RECORD.color;
const version = Number(record.version);
return {
color,
version: Number.isFinite(version) && version > 0 ? Math.floor(version) : 0,
};
}
return { ...FALLBACK_RECORD };
}
export function serializeCustomAccentRecord(record: CustomAccentRecord): string {
const color = normalizeAccentColor(record.color) ?? FALLBACK_RECORD.color;
return JSON.stringify({
color,
version: Math.max(0, Math.floor(record.version) || 0),
});
}
/**
* Incoming peer updates must not clobber a newer local/drag revision.
* Equal versions are treated as already-applied (no state thrash).
*/
export function shouldApplyCustomAccentRecord(
current: CustomAccentRecord,
incoming: CustomAccentRecord,
): boolean {
if (incoming.version > current.version) return true;
if (incoming.version < current.version) return false;
// Same version: only apply when the color itself differs and both are
// legacy/unversioned (version 0), so first-load plain strings still sync.
if (incoming.version === 0 && current.version === 0) {
return incoming.color !== current.color;
}
return false;
}
/**
* Decide whether a custom-accent state change should be rebroadcast to peer
* windows. Incoming IPC/storage updates must not notify again — otherwise a
* fast native color-picker drag in the settings window ping-pongs with the
* main window and the accent CSS variables oscillate (see #2743, same class
* as window-opacity #2018).
*/
export function shouldBroadcastCustomAccentChange(
mutationSource: CustomAccentMutationSource,
persistMounted: boolean,
): { shouldBroadcast: boolean; nextSource: CustomAccentMutationSource } {
if (mutationSource === 'incoming') {
return { shouldBroadcast: false, nextSource: 'local' };
}
return { shouldBroadcast: persistMounted, nextSource: 'local' };
}
+16 -7
View File
@@ -66,6 +66,7 @@ interface UseSettingsStorageSyncParams {
darkUiThemeId: string;
accentMode: 'theme' | 'custom';
customAccent: string;
customAccentVersion: number;
customCSS: string;
uiFontFamilyId: string;
hotkeyScheme: HotkeyScheme;
@@ -109,7 +110,7 @@ interface UseSettingsStorageSyncParams {
setLightUiThemeId: Dispatch<SetStateAction<string>>;
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
setAccentMode: Dispatch<SetStateAction<'theme' | 'custom'>>;
setCustomAccent: Dispatch<SetStateAction<string>>;
applyIncomingCustomAccent: (raw: unknown) => void;
setCustomCSS: Dispatch<SetStateAction<string>>;
setUiFontFamilyId: Dispatch<SetStateAction<string>>;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
@@ -160,7 +161,7 @@ interface UseSettingsStorageSyncParams {
export function useSettingsStorageSync({
enabled = true,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
@@ -168,7 +169,7 @@ export function useSettingsStorageSync({
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
@@ -184,7 +185,7 @@ export function useSettingsStorageSync({
// can compare without capturing 25+ state variables in its closure / dep array.
// This avoids constant listener detach/reattach on every state change.
const settingsSnapshotRef = useRef({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
@@ -194,7 +195,7 @@ export function useSettingsStorageSync({
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
@@ -215,7 +216,15 @@ export function useSettingsStorageSync({
if (appearance.next.lightUiThemeId !== s.lightUiThemeId) setLightUiThemeId(appearance.next.lightUiThemeId);
if (appearance.next.darkUiThemeId !== s.darkUiThemeId) setDarkUiThemeId(appearance.next.darkUiThemeId);
if (appearance.next.accentMode !== s.accentMode) setAccentMode(appearance.next.accentMode);
if (appearance.next.customAccent !== s.customAccent) setCustomAccent(appearance.next.customAccent);
if (
appearance.next.customAccent !== s.customAccent
|| appearance.next.customAccentVersion !== s.customAccentVersion
) {
applyIncomingCustomAccent({
color: appearance.next.customAccent,
version: appearance.next.customAccentVersion,
});
}
return;
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
@@ -505,7 +514,7 @@ export function useSettingsStorageSync({
mergeIncomingTerminalSettings,
setAccentMode,
setAutoUpdateEnabled,
setCustomAccent,
applyIncomingCustomAccent,
setCustomCSS,
setDarkUiThemeId,
setEditorWordWrapState,
@@ -50,6 +50,26 @@ test("parks browse only when the interactive surface hides and not already parke
}), false);
});
test("keeps a hidden SFTP owner live while an external editor temp file is open", () => {
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
hasOwnedEditorTab: false,
hasActiveExternalEdit: true,
});
assert.equal(interactive, true);
assert.equal(shouldParkBrowseSessions({
interactive: false,
browseParked: false,
activeExternalEditCount: 1,
}), false);
assert.equal(shouldParkBrowseSessions({
interactive: false,
browseParked: false,
activeExternalEditCount: 0,
}), true);
});
test("restores browse when the surface becomes interactive again after park", () => {
assert.equal(shouldRestoreBrowseSessions({ interactive: true, browseParked: true }), true);
assert.equal(shouldRestoreBrowseSessions({ interactive: true, browseParked: false }), false);
@@ -9,8 +9,12 @@
export function isBrowseSessionInteractive(params: {
surfaceVisible: boolean;
hasOwnedEditorTab: boolean;
/** External editor temp files (Notepad++ etc.) still need the browse session. */
hasActiveExternalEdit?: boolean;
}): boolean {
return params.surfaceVisible || params.hasOwnedEditorTab;
return params.surfaceVisible
|| params.hasOwnedEditorTab
|| !!params.hasActiveExternalEdit;
}
export function listRemoteBrowseConnectionIds(
@@ -49,8 +53,14 @@ export function shouldParkBrowseSessions(params: {
browseParked: boolean;
/** Defer park while unfinished transfers may still use browse sessions pre-lease. */
activeTransfersCount?: number;
/**
* Defer park while an external editor still holds a downloaded temp file.
* Parking calls closeSftp, which deletes those temps and breaks save/sync.
*/
activeExternalEditCount?: number;
}): boolean {
if (params.activeTransfersCount && params.activeTransfersCount > 0) return false;
if (params.activeExternalEditCount && params.activeExternalEditCount > 0) return false;
return !params.interactive && !params.browseParked;
}
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createExternalEditTempRetention } from "./externalEditTempRetention";
test("remembers distinct temps per sftp session and forgets one session without touching others", () => {
const retention = createExternalEditTempRetention();
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), true);
assert.equal(retention.remember("sftp-a", "/tmp/a.txt"), false);
assert.equal(retention.remember("sftp-b", "/tmp/b.txt"), true);
assert.equal(retention.size, 2);
assert.equal(retention.forgetSftp("sftp-a"), true);
assert.equal(retention.size, 1);
assert.equal(retention.forgetSftp("sftp-a"), false);
assert.equal(retention.forgetPath("/tmp/b.txt"), true);
assert.equal(retention.size, 0);
});
test("clear drops every retainer after session-wide cleanup", () => {
const retention = createExternalEditTempRetention();
retention.remember("sftp-1", "/tmp/one.txt");
retention.remember("sftp-2", "/tmp/two.txt");
assert.equal(retention.clear(), true);
assert.equal(retention.size, 0);
assert.equal(retention.clear(), false);
});
@@ -0,0 +1,59 @@
/**
* Renderer-side retainers for remote temps opened in external editors.
* closeSftp deletes those files; forget matching retainers so park/auto-connect
* checks do not keep treating a deleted temp as active work.
*/
export type ExternalEditTempRetention = {
remember(sftpId: string, localPath: string): boolean;
forgetPath(localPath: string): boolean;
forgetSftp(sftpId: string): boolean;
clear(): boolean;
readonly size: number;
};
export function createExternalEditTempRetention(): ExternalEditTempRetention {
const bySftp = new Map<string, Set<string>>();
const recount = (): number => {
let total = 0;
for (const paths of bySftp.values()) total += paths.size;
return total;
};
return {
remember(sftpId, localPath) {
if (!sftpId || !localPath) return false;
let paths = bySftp.get(sftpId);
if (!paths) {
paths = new Set();
bySftp.set(sftpId, paths);
}
if (paths.has(localPath)) return false;
paths.add(localPath);
return true;
},
forgetPath(localPath) {
if (!localPath) return false;
let changed = false;
for (const [sftpId, paths] of bySftp) {
if (!paths.delete(localPath)) continue;
changed = true;
if (paths.size === 0) bySftp.delete(sftpId);
}
return changed;
},
forgetSftp(sftpId) {
if (!sftpId || !bySftp.has(sftpId)) return false;
bySftp.delete(sftpId);
return true;
},
clear() {
if (bySftp.size === 0) return false;
bySftp.clear();
return true;
},
get size() {
return recount();
},
};
}
@@ -138,9 +138,13 @@ test("release invalidates pending watch starts while a new generation remains us
test("backend session cleanup removes a stopped watch from renderer ownership", async () => {
const stopped: Array<{ watchId: string; cleanupTempFile: boolean }> = [];
let onBackendStopped: ((payload: { watchId: string }) => void) | null = null;
let onBackendStopped: ((payload: {
watchId: string;
localPath?: string;
}) => void) | null = null;
let lifecycle: ExternalFileWatchLifecycle | null = null;
let renderer: ReactTestRenderer | null = null;
const forgottenPaths: string[] = [];
function Probe() {
lifecycle = useExternalFileWatchLifecycle(
@@ -151,6 +155,7 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
onBackendStopped = callback;
return () => { onBackendStopped = null; };
},
(localPath) => { forgottenPaths.push(localPath); },
);
return null;
}
@@ -162,9 +167,13 @@ test("backend session cleanup removes a stopped watch from renderer ownership",
assert.equal(lifecycle!.activeCountRef.current, 1);
await act(async () => {
onBackendStopped?.({ watchId: "watch-closed-with-session" });
onBackendStopped?.({
watchId: "watch-closed-with-session",
localPath: "/tmp/externally-deleted.txt",
});
});
assert.equal(lifecycle!.activeCountRef.current, 0);
assert.deepEqual(forgottenPaths, ["/tmp/externally-deleted.txt"]);
await act(async () => renderer!.unmount());
await new Promise((resolve) => setImmediate(resolve));
@@ -19,7 +19,12 @@ export type StopExternalFileWatch = (
) => void | Promise<unknown>;
export type SubscribeExternalFileWatchStopped = (
callback: (payload: { watchId: string }) => void,
callback: (payload: {
watchId: string;
localPath?: string;
remotePath?: string;
sftpId?: string;
}) => void,
) => (() => void) | void;
export interface ExternalFileWatchLifecycle {
@@ -32,16 +37,19 @@ export interface ExternalFileWatchLifecycle {
export function useExternalFileWatchLifecycle(
stopWatch: StopExternalFileWatch,
subscribeStopped?: SubscribeExternalFileWatchStopped,
onStoppedLocalPath?: (localPath: string) => void,
): ExternalFileWatchLifecycle {
const watchIdsRef = useRef<Set<string>>(new Set());
const activeCountRef = useRef(0);
const stopWatchRef = useRef(stopWatch);
const subscribeStoppedRef = useRef(subscribeStopped);
const onStoppedLocalPathRef = useRef(onStoppedLocalPath);
const disposedRef = useRef(false);
const generationRef = useRef(0);
const invalidatedCleanupTempFilesRef = useRef(false);
stopWatchRef.current = stopWatch;
subscribeStoppedRef.current = subscribeStopped;
onStoppedLocalPathRef.current = onStoppedLocalPath;
const captureGeneration = useCallback(() => generationRef.current, []);
@@ -86,9 +94,10 @@ export function useExternalFileWatchLifecycle(
};
}, [releaseAll]);
useEffect(() => subscribeStoppedRef.current?.(({ watchId }) => {
useEffect(() => subscribeStoppedRef.current?.(({ watchId, localPath }) => {
if (!watchId || !watchIdsRef.current.delete(watchId)) return;
activeCountRef.current = watchIdsRef.current.size;
if (localPath) onStoppedLocalPathRef.current?.(localPath);
}), []);
return { activeCountRef, captureGeneration, remember, releaseAll };
@@ -157,6 +157,23 @@ test("release metadata closes the backend before a failed connection is retained
assert.equal(cacheKeys.size, 0);
});
test("releaseSftpConnectionMetadata notifies after a remote session closes", async () => {
const sessions = new Map([["connection-1", "sftp-1"]]);
const cacheKeys = new Map([["connection-1", "cache-1"]]);
const notified: string[] = [];
await releaseSftpConnectionMetadata({
connectionId: "connection-1",
sftpSessions: sessions,
connectionCacheKeys: cacheKeys,
clearCacheForConnection: () => {},
closeSftp: async () => {},
onRemoteSessionClosed: (sftpId) => { notified.push(sftpId); },
});
assert.deepEqual(notified, ["sftp-1"]);
});
test("resolvePinnedReconnectSide follows a tab moved to the other side", () => {
assert.equal(
resolvePinnedReconnectSide("left", "tab-1", [], [{ id: "tab-1" }]),
@@ -41,6 +41,8 @@ interface UseSftpConnectionsParams {
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
autoConnectLocalOnMount?: boolean;
/** Fired after a browse SFTP id is closed so renderer retainers can drop. */
onRemoteSessionClosed?: (sftpId: string) => void;
}
export interface SftpConnectOptions {
@@ -82,6 +84,7 @@ export async function releaseSftpConnectionMetadata(params: {
connectionCacheKeys: Map<string, string>;
clearCacheForConnection: (connectionId: string) => void;
closeSftp: (sftpId: string) => Promise<unknown>;
onRemoteSessionClosed?: (sftpId: string) => void;
}): Promise<void> {
const sftpId = takeSftpConnectionMetadataForClose(params);
if (params.isLocal || !sftpId) return;
@@ -90,6 +93,7 @@ export async function releaseSftpConnectionMetadata(params: {
} catch {
// Best-effort: backend owner cleanup remains the final safety net.
}
params.onRemoteSessionClosed?.(sftpId);
}
export function createSftpConnectionId(
@@ -240,7 +244,13 @@ export const useSftpConnections = ({
clearCacheForConnection,
createEmptyPane,
autoConnectLocalOnMount = true,
onRemoteSessionClosed,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const onRemoteSessionClosedRef = useRef(onRemoteSessionClosed);
onRemoteSessionClosedRef.current = onRemoteSessionClosed;
const notifyRemoteSessionClosed = useCallback((sftpId: string | undefined) => {
if (sftpId) onRemoteSessionClosedRef.current?.(sftpId);
}, []);
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
@@ -450,6 +460,7 @@ export const useSftpConnections = ({
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
onRemoteSessionClosed: (sftpId) => notifyRemoteSessionClosed(sftpId),
});
};
@@ -491,6 +502,7 @@ export const useSftpConnections = ({
} catch {
// Ignore errors when closing stale SFTP sessions
}
notifyRemoteSessionClosed(oldSftpId);
}
}
}
@@ -966,6 +978,7 @@ export const useSftpConnections = ({
} catch {
// Ignore errors when closing SFTP session during disconnect
}
notifyRemoteSessionClosed(sftpId);
}
}
@@ -41,6 +41,7 @@ import {
cleanupFailedExternalOpenTemp,
useExternalFileWatchLifecycle,
} from "./externalFileWatchLifecycle";
import { createExternalEditTempRetention } from "./externalEditTempRetention";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
@@ -163,23 +164,49 @@ export const useSftpExternalOperations = (
},
}), [registerUploadController]);
// Temp files downloaded for external editors (even without auto-sync watches).
// Parking / disconnect / reconnect call closeSftp, which deletes these paths —
// forget matching retainers so hasActiveWork does not stick after cleanup.
const externalEditTempsRef = useRef(createExternalEditTempRetention());
const [activeExternalEditCount, setActiveExternalEditCount] = useState(0);
const rememberExternalEditTemp = useCallback((sftpId: string, localPath: string) => {
if (!externalEditTempsRef.current.remember(sftpId, localPath)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
const forgetExternalEditTemp = useCallback((localPath: string) => {
if (!externalEditTempsRef.current.forgetPath(localPath)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
const forgetExternalEditTempsForSftp = useCallback((sftpId: string) => {
if (!externalEditTempsRef.current.forgetSftp(sftpId)) return;
setActiveExternalEditCount(externalEditTempsRef.current.size);
}, []);
// Track every renderer-owned watch id so duplicate opens stay deduplicated
// and panel/window teardown releases the worker-side polling resources.
const stopExternalFileWatch = useCallback(async (watchId: string, cleanupTempFile: boolean) => {
await netcattyBridge.get()?.stopFileWatch?.(watchId, cleanupTempFile);
}, []);
const subscribeExternalFileWatchStopped = useCallback((
callback: (payload: { watchId: string }) => void,
callback: (payload: { watchId: string; localPath?: string }) => void,
) => netcattyBridge.get()?.onFileWatchStopped?.(callback), []);
const {
activeCountRef: activeFileWatchCountRef,
captureGeneration: captureExternalFileWatchGeneration,
remember: rememberExternalFileWatch,
releaseAll: releaseExternalFileWatches,
releaseAll: releaseExternalFileWatchesBase,
} = useExternalFileWatchLifecycle(
stopExternalFileWatch,
subscribeExternalFileWatchStopped,
// Force-stop (temp deleted / session cleanup) includes localPath — drop retainer.
forgetExternalEditTemp,
);
const releaseExternalFileWatches = useCallback(async (cleanupTempFiles = false) => {
await releaseExternalFileWatchesBase(cleanupTempFiles);
if (externalEditTempsRef.current.clear()) {
setActiveExternalEditCount(0);
}
}, [releaseExternalFileWatchesBase]);
const [uploadConflicts, setUploadConflicts] = useState<FileConflict[]>([]);
const uploadConflictResolversRef = useRef<Map<string, UploadConflictResolver>>(new Map());
/** Maps conflict id → owning UploadController so cancel A never stops B's prompts. */
@@ -483,6 +510,7 @@ export const useSftpExternalOperations = (
if (bridge.registerTempFile) {
try {
await bridge.registerTempFile(sftpId, localTempPath);
rememberExternalEditTemp(sftpId, localTempPath);
} catch (err) {
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
}
@@ -490,7 +518,7 @@ export const useSftpExternalOperations = (
return { localTempPath, sftpId, externalTransferId };
},
[getActivePane, isTransferCancelled, ownerId, sftpSessionsRef],
[getActivePane, isTransferCancelled, ownerId, rememberExternalEditTemp, sftpSessionsRef],
);
const downloadToTempAndOpen = useCallback(
@@ -525,6 +553,7 @@ export const useSftpExternalOperations = (
await bridge.openWithApplication(localTempPath, appPath);
} catch (err) {
await cleanupFailedExternalOpenTemp(bridge, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
if (externalTransferId) {
sftpTransferCenterStore.patchTask(externalTransferId, {
status: "failed" as TransferStatus,
@@ -555,7 +584,13 @@ export const useSftpExternalOperations = (
return { localTempPath, watchId };
},
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
[
captureExternalFileWatchGeneration,
downloadToTemp,
forgetExternalEditTemp,
getActivePane,
rememberExternalFileWatch,
],
);
const openWithSystemDefault = useCallback(
@@ -590,12 +625,14 @@ export const useSftpExternalOperations = (
} catch (error) {
if (!pane.connection.isLocal) {
await cleanupFailedExternalOpenTemp(bridgeMethods, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
}
throw error;
}
if (!result.success) {
if (!pane.connection.isLocal) {
await cleanupFailedExternalOpenTemp(bridgeMethods, sftpId, localTempPath).catch(() => {});
forgetExternalEditTemp(localTempPath);
}
if (externalTransferId) {
sftpTransferCenterStore.patchTask(externalTransferId, {
@@ -627,7 +664,13 @@ export const useSftpExternalOperations = (
notify.error(err instanceof Error ? err.message : String(err), "SFTP");
}
},
[captureExternalFileWatchGeneration, downloadToTemp, getActivePane, rememberExternalFileWatch],
[
captureExternalFileWatchGeneration,
downloadToTemp,
forgetExternalEditTemp,
getActivePane,
rememberExternalFileWatch,
],
);
// Create upload callbacks that translate to TransferTask updates
@@ -1491,6 +1534,8 @@ export const useSftpExternalOperations = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
activeExternalEditCount,
forgetExternalEditTempsForSftp,
releaseExternalFileWatches,
uploadConflicts,
resolveUploadConflict,
@@ -68,6 +68,10 @@ export interface SftpExternalOperationsResult {
) => Promise<{ localTempPath: string; watchId?: string }>;
openWithSystemDefault: (side: "left" | "right", remotePath: string, fileName: string, options?: { enableWatch?: boolean }) => Promise<void>;
activeFileWatchCountRef: React.MutableRefObject<number>;
/** Remote temps opened in an external editor; keeps browse sessions from parking. */
activeExternalEditCount: number;
/** Drop retainers after closeSftp deletes that session's registered temps. */
forgetExternalEditTempsForSftp: (sftpId: string) => void;
releaseExternalFileWatches: (cleanupTempFiles?: boolean) => Promise<void>;
uploadExternalFiles: (
side: "left" | "right",
+90 -13
View File
@@ -88,7 +88,6 @@ import { resolveSftpTransferConcurrency } from './sftp/transferConcurrency';
import { resolveSshTransportIdleTtlMs } from '../../infrastructure/config/sshTransportIdleTtl';
import {
DEFAULT_ACCENT_MODE,
DEFAULT_CUSTOM_ACCENT,
DEFAULT_DARK_UI_THEME,
DEFAULT_EDITOR_WORD_WRAP,
DEFAULT_HOTKEY_SCHEME,
@@ -154,6 +153,14 @@ import {
type WindowOpacityMutationSource,
type WindowOpacityRecord,
} from './windowOpacitySync';
import {
parseCustomAccentRecord,
serializeCustomAccentRecord,
shouldApplyCustomAccentRecord,
shouldBroadcastCustomAccentChange,
type CustomAccentMutationSource,
type CustomAccentRecord,
} from './customAccentSync';
import {
createLocalTerminalFontSizeRecord,
createTerminalFontSizeSyncOrigin,
@@ -193,15 +200,47 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = readStoredString(STORAGE_KEY_UI_THEME_DARK);
return stored && isValidUiThemeId('dark', stored) ? stored : DEFAULT_DARK_UI_THEME;
});
const [customAccent, setCustomAccent] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_COLOR);
return stored && isValidHslToken(stored) ? stored.trim() : DEFAULT_CUSTOM_ACCENT;
const [customAccentRecord, setCustomAccentRecord] = useState<CustomAccentRecord>(() => {
return parseCustomAccentRecord(readStoredString(STORAGE_KEY_COLOR));
});
const customAccent = customAccentRecord.color;
const customAccentMutationSourceRef = useRef<CustomAccentMutationSource>('local');
const setCustomAccent = useCallback((nextValue: SetStateAction<string>) => {
customAccentMutationSourceRef.current = 'local';
setCustomAccentRecord((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: string) => string)(prev.color)
: nextValue;
const next = parseCustomAccentRecord(candidate);
if (next.color === prev.color) return prev;
return { color: next.color, version: prev.version + 1 };
});
}, []);
const applyIncomingCustomAccent = useCallback((raw: unknown) => {
const incoming = parseCustomAccentRecord(raw);
setCustomAccentRecord((prev) => {
if (!shouldApplyCustomAccentRecord(prev, incoming)) {
return prev;
}
customAccentMutationSourceRef.current = 'incoming';
return incoming;
});
}, []);
const [accentMode, setAccentMode] = useState<'theme' | 'custom'>(() => {
const stored = readStoredString(STORAGE_KEY_ACCENT_MODE);
if (stored === 'theme' || stored === 'custom') return stored;
const legacyColor = readStoredString(STORAGE_KEY_COLOR);
return legacyColor && isValidHslToken(legacyColor) ? 'custom' : DEFAULT_ACCENT_MODE;
const raw = readStoredString(STORAGE_KEY_COLOR);
if (!raw) return DEFAULT_ACCENT_MODE;
if (isValidHslToken(raw.trim())) return 'custom';
if (raw.trim().startsWith('{')) {
try {
const obj = JSON.parse(raw.trim()) as { color?: unknown };
return typeof obj.color === 'string' && isValidHslToken(obj.color) ? 'custom' : DEFAULT_ACCENT_MODE;
} catch {
return DEFAULT_ACCENT_MODE;
}
}
return DEFAULT_ACCENT_MODE;
});
const [uiFontFamilyId, setUiFontFamilyId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
@@ -556,6 +595,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
accentMode,
customAccent,
customAccentVersion: customAccentRecord.version,
});
appearanceStateRef.current = {
theme,
@@ -563,6 +603,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
accentMode,
customAccent,
customAccentVersion: customAccentRecord.version,
};
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
@@ -732,6 +773,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId: nextDarkId,
accentMode: nextAccentMode,
customAccent: nextAccent,
customAccentVersion: nextAccentVersion,
} = nextAppearance;
// Fix 2: Skip expensive DOM operations if nothing actually changed
@@ -740,18 +782,27 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
nextLightId === current.lightUiThemeId &&
nextDarkId === current.darkUiThemeId &&
nextAccentMode === current.accentMode &&
nextAccent === current.customAccent
nextAccent === current.customAccent &&
nextAccentVersion === current.customAccentVersion
) {
return;
}
// Publish synchronously so a later keyed IPC in the same turn composes on top.
appearanceStateRef.current = nextAppearance;
if (
nextAccent !== current.customAccent
|| nextAccentVersion !== current.customAccentVersion
) {
// Mark incoming so the persist effect does not rebroadcast to the peer that
// originated this update (color-picker drag ping-pong; see #2743).
customAccentMutationSourceRef.current = 'incoming';
setCustomAccentRecord({ color: nextAccent, version: nextAccentVersion });
}
setTheme(nextTheme);
setLightUiThemeId(nextLightId);
setDarkUiThemeId(nextDarkId);
setAccentMode(nextAccentMode);
setCustomAccent(nextAccent);
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
@@ -892,6 +943,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
accentMode,
customAccent,
customAccentVersion: customAccentRecord.version,
};
// Capture previous snapshot before overwrite so we can notify only the
// appearance fields that actually changed on this render.
@@ -913,7 +965,19 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
// Never let a stale effect overwrite a newer revision already on disk.
const storedAccent = parseCustomAccentRecord(
localStorageAdapter.readString(STORAGE_KEY_COLOR),
);
if (
shouldApplyCustomAccentRecord(storedAccent, customAccentRecord)
|| storedAccent.version === customAccentRecord.version
) {
localStorageAdapter.writeString(
STORAGE_KEY_COLOR,
serializeCustomAccentRecord(customAccentRecord),
);
}
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
if (!persistMountedRef.current) return;
// Emit a keyed IPC notification for each changed appearance field so the
@@ -932,10 +996,21 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
if (!previousAppearance || previousAppearance.accentMode !== accentMode) {
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
}
if (!previousAppearance || previousAppearance.customAccent !== customAccent) {
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
if (
!previousAppearance
|| previousAppearance.customAccent !== customAccent
|| previousAppearance.customAccentVersion !== customAccentRecord.version
) {
const decision = shouldBroadcastCustomAccentChange(
customAccentMutationSourceRef.current,
true,
);
customAccentMutationSourceRef.current = decision.nextSource;
if (decision.shouldBroadcast) {
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
}
}
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
@@ -1035,6 +1110,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
useSettingsStorageSync({
enabled: enableSettingsSync,
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customAccentVersion: customAccentRecord.version,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
@@ -1042,7 +1118,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
applyIncomingCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
+13
View File
@@ -55,6 +55,7 @@ export async function releaseSftpTabConnection(params: {
connectionCacheKeys: Map<string, string>;
clearCacheForConnection: (connectionId: string) => void;
closeSftp: (sftpId: string) => Promise<unknown>;
onRemoteSessionClosed?: (sftpId: string) => void;
}): Promise<void> {
await releaseSftpConnectionMetadata(params);
}
@@ -66,6 +67,7 @@ export const useSftpState = (
options?: SftpStateOptions
) => {
const transferOwnerIdRef = useRef(options?.transferOwnerId ?? crypto.randomUUID());
const forgetExternalEditTempsForSftpRef = useRef<(sftpId: string) => void>(() => {});
const createPane = useCallback(
(id?: string, showHiddenFiles = options?.defaultShowHiddenFiles ?? false) =>
createEmptyPane(id, showHiddenFiles),
@@ -241,6 +243,7 @@ export const useSftpState = (
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
});
}
closeTab(side, tabId);
@@ -253,6 +256,7 @@ export const useSftpState = (
connectionCacheKeys: connectionCacheKeyMapRef.current,
clearCacheForConnection,
closeSftp: async (sftpId) => netcattyBridge.get()?.closeSftp(sftpId),
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
});
}, [clearCacheForConnection]);
@@ -444,6 +448,7 @@ export const useSftpState = (
clearCacheForConnection,
createEmptyPane: createPane,
autoConnectLocalOnMount: options?.autoConnectLocalOnMount,
onRemoteSessionClosed: (sftpId) => forgetExternalEditTempsForSftpRef.current(sftpId),
});
const {
@@ -567,6 +572,8 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
activeFileWatchCountRef,
activeExternalEditCount,
forgetExternalEditTempsForSftp,
releaseExternalFileWatches,
uploadConflicts,
resolveUploadConflict,
@@ -627,6 +634,7 @@ export const useSftpState = (
useCompressedUpload: options?.useCompressedUpload,
isTransferCancelled,
});
forgetExternalEditTempsForSftpRef.current = forgetExternalEditTempsForSftp;
const conflicts = useMemo(
() => [...transferConflicts, ...uploadConflicts],
@@ -666,6 +674,7 @@ export const useSftpState = (
interactive,
browseParked: browseParkedRef.current,
activeTransfersCount: Math.max(activeTransfersCount, centerActive ? 1 : 0),
activeExternalEditCount,
})) {
return;
}
@@ -684,6 +693,7 @@ export const useSftpState = (
} catch {
// best-effort — session may already be gone
}
forgetExternalEditTempsForSftpRef.current(sftpId);
}));
};
@@ -737,6 +747,7 @@ export const useSftpState = (
}, [
interactive,
activeTransfersCount,
activeExternalEditCount,
clearCacheForConnection,
connect,
getActivePane,
@@ -911,6 +922,7 @@ export const useSftpState = (
rightTabs,
transfers,
activeTransfersCount,
activeExternalEditCount,
conflicts,
hostKeyVerification,
@@ -932,6 +944,7 @@ export const useSftpState = (
rightTabs,
transfers,
activeTransfersCount,
activeExternalEditCount,
conflicts,
hostKeyVerification,
stableMethods,
+20 -1
View File
@@ -51,7 +51,11 @@ import {
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../infrastructure/persistence/localStorageAdapter";
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
import {
mergeGlobalHistoryOnAppend,
removeGlobalHistoryEntry,
sanitizeGlobalHistoryEntries,
} from "../../domain/globalHistory";
import {
buildTerminalDataMapFromLogs,
mergeConnectionLogsFromStorage,
@@ -844,6 +848,16 @@ export const useVaultState = () => {
publishShellHistorySnapshot([]);
}, []);
const removeShellHistoryEntry = useCallback((entryId: string) => {
setShellHistory((prev) => {
const updated = removeGlobalHistoryEntry(prev, entryId);
if (updated === prev) return prev;
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
publishShellHistorySnapshot(updated);
return updated;
});
}, []);
// Connection logs management
const addConnectionLog = useCallback(
(log: Omit<ConnectionLog, "id">) => {
@@ -1506,6 +1520,10 @@ export const useVaultState = () => {
latestHosts.length,
destination,
storedGroups,
{
identities: latestIdentities.length,
keys: latestKeys.length,
},
);
const nextHosts = normalizeVaultOrder(committed.hosts.map((host) => sanitizeHost(host)));
const nextKeys = normalizeVaultOrder(committed.keys);
@@ -1619,6 +1637,7 @@ export const useVaultState = () => {
updateGroupConfigs,
addShellHistoryEntry,
clearShellHistory,
removeShellHistoryEntry,
addConnectionLog,
updateConnectionLog,
toggleConnectionLogSaved,
@@ -134,15 +134,20 @@ export function useWorkSurfaceHostEditor({
const save = useCallback((draft: Host) => {
if (!target) return;
const nextHosts = saveWorkSurfaceHostDraft(hosts, target, draft);
if (!nextHosts) {
let saved = false;
onUpdateHosts((prevHosts) => {
const nextHosts = saveWorkSurfaceHostDraft(prevHosts, target, draft);
if (!nextHosts) return prevHosts;
saved = true;
return nextHosts;
});
if (!saved) {
close();
return;
}
onUpdateHosts(nextHosts);
onSaved?.(target.mode);
close();
}, [close, hosts, onSaved, onUpdateHosts, target]);
}, [close, onSaved, onUpdateHosts, target]);
useEffect(() => {
if (shouldCloseDeletedWorkSurfaceHost(hosts, target)) {
+2 -3
View File
@@ -819,7 +819,7 @@ test("buildLocalVaultPayload includes last-known plugin sidecars for protective
}
});
test("buildLocalVaultPayloadAsync keeps last-known when live collect is empty", async () => {
test("buildLocalVaultPayloadAsync prefers live empty over last-known for backups", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
Object.defineProperty(globalThis, "window", {
value: {
@@ -847,8 +847,7 @@ test("buildLocalVaultPayloadAsync keeps last-known when live collect is empty",
try {
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
const payload = await buildLocalVaultPayloadAsync(vault([]));
assert.equal(payload.pluginSidecars?.entries?.length, 1);
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
assert.equal(payload.pluginSidecars?.entries?.length, 0);
} finally {
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
Object.defineProperty(globalThis, "window", {
+22 -13
View File
@@ -43,6 +43,10 @@ import {
parseTerminalFontSizeRecord,
serializeTerminalFontSizeRecord,
} from './state/terminalFontSizeSync';
import {
parseCustomAccentRecord,
serializeCustomAccentRecord,
} from './state/customAccentSync';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
@@ -238,9 +242,10 @@ const SYNCABLE_TERMINAL_KEYS = [
'rightClickBehavior', 'showContextMenuOverFullscreenApps', 'middleClickBehavior', 'copyOnSelect', 'normalizeTextOnCopy', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'autoUploadClipboardImageOnPaste',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'dynamicTabTitleMode',
'autoCloseOnExit',
'showHostInfoBar', 'showServerStats',
'showHostInfoBar', 'hostInfoBarTitleMode', 'showServerStats',
'serverStatsRefreshInterval',
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
@@ -407,8 +412,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (darkUi) settings.darkUiThemeId = darkUi;
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
if (accent) settings.customAccent = accent;
const accentRaw = localStorageAdapter.readString(STORAGE_KEY_COLOR);
if (accentRaw) settings.customAccent = parseCustomAccentRecord(accentRaw).color;
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
if (uiFont) settings.uiFontFamilyId = uiFont;
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
@@ -614,7 +619,17 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
if (settings.customAccent != null) {
const existing = parseCustomAccentRecord(localStorageAdapter.readString(STORAGE_KEY_COLOR));
localStorageAdapter.writeString(
STORAGE_KEY_COLOR,
serializeCustomAccentRecord({
color: parseCustomAccentRecord(settings.customAccent).color,
// Bump so peer windows' version gates accept the synced value.
version: Math.max(existing.version, 0) + 1,
}),
);
}
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
@@ -1034,15 +1049,9 @@ export async function buildLocalVaultPayloadAsync(
try {
const live = await collectPluginSyncSidecarsFromHost();
if (live) {
// Live collect may return authoritative-empty while still deferring the
// last-known cache wipe until a successful sync commit. Protective
// backups must keep the non-empty last-known already attached to `base`.
const liveEmpty = !Array.isArray(live.entries) || live.entries.length === 0;
const baseHasEntries = Array.isArray(base.pluginSidecars?.entries)
&& base.pluginSidecars.entries.length > 0;
if (liveEmpty && baseHasEntries) {
return base;
}
// Uploads may still defer wiping last-known until a successful sync
// commit, but protective/version-change backups must snapshot the live
// authoritative-empty bundle so restore cannot resurrect cleared plugin data.
return withPluginSyncSidecars(base, live);
}
} catch (error) {
+51 -5
View File
@@ -18,11 +18,16 @@ import {
RefreshCw,
Search,
Terminal as TerminalIcon,
Trash2,
} from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { toGlobalHistoryDisplayEntries } from '../domain/globalHistory';
import {
shouldRemoveAutocompleteHistoryEntry,
toGlobalHistoryDisplayEntries,
} from '../domain/globalHistory';
import type { Host, RemoteHistoryEntry, ShellHistoryEntry } from '../domain/models';
import { removeCommandHistoryEntry } from './terminal/autocomplete/commandHistoryStore';
import { cn } from '../lib/utils';
import type { RemoteHistoryHostState } from '../application/state/useRemoteHistoryState';
import {
@@ -40,6 +45,7 @@ export interface HistorySidePanelProps {
state: RemoteHistoryHostState;
globalEntries: ShellHistoryEntry[];
onFetch: (sessionId: string, hostId: string) => void;
onDeleteGlobalEntry?: (entryId: string) => void;
/** Paste into the terminal without executing (no trailing Enter). */
onPasteToTerminal: (command: string) => void;
/** Write to the terminal and execute (append Enter). */
@@ -60,6 +66,7 @@ const DETAIL_ACTIONS_HEIGHT = 24;
interface HistoryPanelEntry {
id: string;
command: string;
hostId?: string;
timestamp?: number;
hostLabel?: string;
}
@@ -107,6 +114,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
state,
globalEntries,
onFetch,
onDeleteGlobalEntry,
onPasteToTerminal,
onRunInTerminal,
isVisible = true,
@@ -185,6 +193,21 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
);
}, []);
const handleDeleteGlobalEntry = useCallback((entryId: string) => {
if (scope !== 'global' || !onDeleteGlobalEntry) return;
const entry = sourceEntries.find((candidate) => candidate.id === entryId);
if (!entry) return;
// Always remove the global row; only touch autocomplete when host is known.
if (
entry.hostId
&& shouldRemoveAutocompleteHistoryEntry(globalEntries, entryId)
) {
removeCommandHistoryEntry(entry.command, entry.hostId);
}
onDeleteGlobalEntry(entryId);
setSelectedEntryId(null);
}, [globalEntries, onDeleteGlobalEntry, scope, sourceEntries]);
const handleRowClick = useCallback((entryId: string) => {
setSelectedEntryId((current) => {
const next = current === entryId ? null : entryId;
@@ -216,6 +239,7 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
paste: t('history.action.paste'),
run: t('history.action.run'),
save: t('history.action.saveAsSnippet'),
delete: t('history.action.delete'),
}),
[t],
);
@@ -358,6 +382,11 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
onRun={() => onRunInTerminal(row.entry.command)}
onPaste={() => onPasteToTerminal(row.entry.command)}
onSave={() => handleSaveAsSnippet(row.entry)}
onDelete={
scope === 'global' && onDeleteGlobalEntry
? () => handleDeleteGlobalEntry(row.entry.id)
: undefined
}
/>
);
}
@@ -371,6 +400,11 @@ const HistorySidePanelInner: React.FC<HistorySidePanelProps> = ({
onRun={() => onRunInTerminal(row.entry.command)}
onPaste={() => onPasteToTerminal(row.entry.command)}
onSave={() => handleSaveAsSnippet(row.entry)}
onDelete={
scope === 'global' && onDeleteGlobalEntry
? () => handleDeleteGlobalEntry(row.entry.id)
: undefined
}
/>
);
}}
@@ -416,14 +450,15 @@ const EmptyState: React.FC<{ message: string }> = ({ message }) => (
interface HistoryDetailStripProps {
entry: HistoryPanelEntry;
labels: { paste: string; run: string; save: string };
labels: { paste: string; run: string; save: string; delete: string };
onRun: () => void;
onPaste: () => void;
onSave: () => void;
onDelete?: () => void;
}
const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
({ entry, labels, onRun, onPaste, onSave }) => (
({ entry, labels, onRun, onPaste, onSave, onDelete }) => (
<div
className="border-b border-border/40 bg-muted/20 px-3 py-1.5"
data-section="history-detail"
@@ -456,6 +491,11 @@ const HistoryDetailStrip: React.FC<HistoryDetailStripProps> = memo(
<IconButton title={labels.save} onClick={onSave}>
<FileCode size={12} />
</IconButton>
{onDelete ? (
<IconButton title={labels.delete} onClick={onDelete}>
<Trash2 size={12} />
</IconButton>
) : null}
</div>
</div>
),
@@ -466,15 +506,16 @@ interface HistoryRowProps {
entry: HistoryPanelEntry;
isSelected: boolean;
showHostLabel: boolean;
labels: { paste: string; run: string; save: string };
labels: { paste: string; run: string; save: string; delete: string };
onSelect: () => void;
onRun: () => void;
onPaste: () => void;
onSave: () => void;
onDelete?: () => void;
}
const HistoryRow: React.FC<HistoryRowProps> = memo(
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave }) => {
({ entry, isSelected, showHostLabel, labels, onSelect, onRun, onPaste, onSave, onDelete }) => {
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
@@ -531,6 +572,11 @@ const HistoryRow: React.FC<HistoryRowProps> = memo(
<IconButton title={labels.save} onClick={onSave}>
<FileCode size={12} />
</IconButton>
{onDelete ? (
<IconButton title={labels.delete} onClick={onDelete}>
<Trash2 size={12} />
</IconButton>
) : null}
</div>
</div>
);
+106 -30
View File
@@ -9,7 +9,7 @@ import {
Tag,
X,
} from "lucide-react";
import React, { useEffect, useMemo, useState, useCallback } from "react";
import React, { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend, type SshAgentStatus } from "../application/state/useApplicationBackend";
import { applyGroupDefaults, resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
@@ -70,9 +70,7 @@ import {
} from "./host-details";
import { HostNotesEditor } from "./host/HostNotesEditor";
import { HostDetailsScriptsSection } from "./host/HostDetailsScriptsSection";
import { ensureHostConnectScriptIds, getHostConnectScriptIds, prepareSnippetForHostConnectQueue } from "@/domain/hostConnectScripts.ts";
import { isScriptSnippet } from "@/domain/snippetScript.ts";
import { unlinkHostFromScripts } from "@/domain/snippetTargets.ts";
import { ensureHostConnectScriptIds, getEditableHostConnectScriptIds, syncSnippetsForHostConnectQueueSave } from "@/domain/hostConnectScripts.ts";
import {
isPluginHostProtocol,
sanitizePluginConnection,
@@ -112,6 +110,7 @@ interface HostDetailsPanelProps {
onImportKey?: (draft: Partial<SSHKey>) => SSHKey;
snippets?: Snippet[];
onSnippetsChange?: (snippets: Snippet[]) => void;
onHostsChange?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void;
className?: string;
}
@@ -139,6 +138,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
onImportKey,
snippets = [],
onSnippetsChange,
onHostsChange,
className,
resizable,
persistWidthStorageKey,
@@ -151,6 +151,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
resizeAriaLabel,
};
const { checkSshAgent } = useApplicationBackend();
const baselineSnippetsRef = useRef(snippets);
const baselineHydratedRef = useRef(snippets.length > 0);
const openingQueueIdsRef = useRef<string[]>([]);
const [form, setForm] = useState<Host>(
() =>
(initialData ? normalizePrimaryTelnetState(initialData) : null) ||
@@ -238,6 +241,36 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
const initialHostId = initialData?.id;
useEffect(() => {
baselineSnippetsRef.current = snippets;
baselineHydratedRef.current = snippets.length > 0;
if (!initialData) {
openingQueueIdsRef.current = [];
} else {
const normalized = normalizePrimaryTelnetState(initialData);
const ensured = snippets.length > 0
? ensureHostConnectScriptIds(normalized, snippets)
: normalized;
openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets);
}
// Snapshot only when opening/reopening a host editor, not on live snippet edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialHostId]);
useEffect(() => {
// If the editor opened before snippets hydrated, capture the first non-empty
// catalog as baseline so concurrent-edit guards still have a reference.
if (baselineHydratedRef.current) return;
if (snippets.length === 0) return;
baselineSnippetsRef.current = snippets;
baselineHydratedRef.current = true;
if (initialData) {
const normalized = normalizePrimaryTelnetState(initialData);
const ensured = ensureHostConnectScriptIds(normalized, snippets);
openingQueueIdsRef.current = getEditableHostConnectScriptIds(ensured, snippets);
}
}, [initialData, snippets]);
useEffect(() => {
if (!initialData) return;
const normalized = normalizePrimaryTelnetState(initialData);
@@ -593,37 +626,80 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
delete cleaned.x11Forwarding;
}
if (onSnippetsChange && initialData) {
if (onSnippetsChange || onHostsChange) {
const hostId = cleaned.id;
const savedQueueIds = initialData.connectScriptIds ?? getHostConnectScriptIds(initialData, snippets);
const finalQueueIds = cleaned.connectScriptIds ?? getHostConnectScriptIds(cleaned, snippets);
const savedSet = new Set(savedQueueIds);
const finalSet = new Set(finalQueueIds);
let nextSnippets = snippets;
let changed = false;
for (const scriptId of finalQueueIds) {
if (!savedSet.has(scriptId)) {
nextSnippets = nextSnippets.map((item) => (
item.id === scriptId && isScriptSnippet(item)
? prepareSnippetForHostConnectQueue(item, hostId)
: item
));
changed = true;
if (snippets.length === 0 && !baselineHydratedRef.current) {
// Vault may publish hosts before snippets hydrate. Do not wipe unresolved
// connectScriptIds while the script catalog is still incomplete.
} else {
const savedQueueIds = openingQueueIdsRef.current;
const finalQueueIds = getEditableHostConnectScriptIds(cleaned, snippets);
const synced = syncSnippetsForHostConnectQueueSave(
snippets,
hostId,
savedQueueIds,
finalQueueIds,
{
baselineSnippets: baselineSnippetsRef.current,
hosts: allHosts,
},
);
cleaned = {
...cleaned,
connectScriptIds: synced.connectScriptIds.length > 0
? synced.connectScriptIds
: undefined,
// sanitizeHost reconstitutes connectScriptIds from loginScriptId when
// the explicit queue is absent; clear the legacy field when emptied.
...(synced.connectScriptIds.length === 0
? { loginScriptId: undefined }
: {}),
};
if (synced.changed && onSnippetsChange) {
onSnippetsChange(synced.snippets);
}
}
for (const scriptId of savedQueueIds) {
if (!finalSet.has(scriptId)) {
const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId);
if (unlinked !== nextSnippets) {
nextSnippets = unlinked;
changed = true;
if (onHostsChange && synced.hosts.length > 0) {
const peerChanged = synced.hosts.some((host) => {
if (host.id === cleaned.id) return false;
const original = allHosts.find((entry) => entry.id === host.id);
return original !== undefined && original !== host;
});
if (peerChanged) {
// Append only the script IDs this save newly queued onto each peer.
// Leave the edited host alone so onSave can three-way merge it.
onHostsChange((prevHosts) => {
const peerAdds = new Map<string, string[]>();
for (const syncedHost of synced.hosts) {
if (syncedHost.id === cleaned.id) continue;
const snapshot = allHosts.find((entry) => entry.id === syncedHost.id);
if (!snapshot || snapshot === syncedHost) continue;
const snapshotIds = snapshot.connectScriptIds ?? [];
const addedIds = (syncedHost.connectScriptIds ?? []).filter(
(id) => !snapshotIds.includes(id),
);
if (addedIds.length > 0) peerAdds.set(syncedHost.id, addedIds);
}
return prevHosts.map((host) => {
if (host.id === cleaned.id) return host;
const addedIds = peerAdds.get(host.id);
if (!addedIds || addedIds.length === 0) return host;
const currentIds = host.connectScriptIds ?? [];
const mergedIds = [...currentIds];
for (const id of addedIds) {
if (!mergedIds.includes(id)) mergedIds.push(id);
}
if (
mergedIds.length === currentIds.length
&& mergedIds.every((id, index) => id === currentIds[index])
) {
return host;
}
return { ...host, connectScriptIds: mergedIds };
});
});
}
}
}
if (changed) {
onSnippetsChange(nextSnippets);
}
}
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
};
@@ -19,6 +19,14 @@ test('keeps the new workspace action outside the scrollable results', () => {
assert.match(source, /VariableSizeVirtualList/);
});
test('host rows expose optional edit-host context menu', () => {
const source = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
assert.match(source, /onEditHost\?: \(host: Host\) => void/);
assert.match(source, /terminal\.layer\.hostTree\.editHost/);
assert.match(source, /ContextMenuTrigger asChild/);
assert.match(source, /#netcatty-context-menu-root/);
});
test('pointer hover never rewrites the keyboard selection', () => {
const quickSwitcherSource = readFileSync(new URL('./QuickSwitcher.tsx', import.meta.url), 'utf8');
const workspacePickerSource = readFileSync(new URL('./workspace/AddToWorkspaceDialog.tsx', import.meta.url), 'utf8');
+27 -4
View File
@@ -101,6 +101,7 @@ export function buildPluginPaletteItems(
});
}
import { DistroAvatar } from "./DistroAvatar";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "./ui/context-menu";
import { Input } from "./ui/input";
import {
VariableSizeVirtualList,
@@ -137,6 +138,7 @@ interface QuickSwitcherProps {
workspaces: Workspace[];
onQueryChange: (value: string) => void;
onSelect: (host: Host) => void;
onEditHost?: (host: Host) => void;
onSelectTab: (tabId: string) => void;
onClose: () => void;
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
@@ -154,6 +156,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
workspaces,
onQueryChange,
onSelect,
onEditHost,
onSelectTab,
onClose,
onCreateLocalTerminal,
@@ -227,12 +230,18 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target;
if (!(target instanceof Node)) return;
if (containerRef.current?.contains(target)) return;
// ContextMenuContent portals to #netcatty-context-menu-root; treat those
// clicks as inside so Connect / Edit host can run before unmount.
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
target instanceof Element &&
target.closest("#netcatty-context-menu-root")
) {
onClose();
return;
}
onClose();
};
document.addEventListener("mousedown", handleClickOutside);
@@ -527,7 +536,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
if (item.type === "host") {
const host = item.data as Host;
return (
const hostRow = (
<div
className={`flex h-full min-h-0 items-center justify-between overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`}
onClick={() => onSelect(host)}
@@ -545,6 +554,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
</div>
</div>
);
if (!onEditHost) return hostRow;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{hostRow}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onSelect(host)}>
{t('vault.hosts.connect')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditHost(host)}>
{t('terminal.layer.hostTree.editHost')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
if (item.type === "tab") {
+31 -3
View File
@@ -10,7 +10,16 @@
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import React, {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
} from "react";
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "./terminalLayer/terminalSidePanelChrome";
import { formatHostPort } from "../domain/host";
@@ -103,6 +112,8 @@ interface SftpSidePanelProps {
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
onCurrentPathChange?: (location: { hostId: string; connectionKey: string; path: string }) => void;
onActiveTransfersChange?: (count: number) => void;
/** External-editor temps that must keep this owner mounted after panel close. */
onActiveExternalEditsChange?: (count: number) => void;
showWorkspaceHostHeader?: boolean;
isVisible?: boolean;
renderOverlays?: boolean;
@@ -150,6 +161,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
onInitialLocationApplied,
onCurrentPathChange,
onActiveTransfersChange,
onActiveExternalEditsChange,
showWorkspaceHostHeader = false,
isVisible = true,
renderOverlays = true,
@@ -215,6 +227,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const ownedEditorSessionIdsRef = useRef<ReadonlySet<string>>(new Set());
const ownedEditorSftpTabIdsRef = useRef<ReadonlySet<string>>(new Set());
const activeExternalEditCountRef = useRef(0);
// Re-render on tab open/close/session remap only — not on every editor keystroke.
useEditorTabPresenceRevision();
const hasOwnedEditorTab = editorTabStore.hasOwnedEditorForSftpOwner({
@@ -230,9 +243,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
surfaceVisible: isVisible,
// A promoted editor still saves through this owner after the side panel
// becomes hidden, so its browse channel must stay alive until the editor closes.
// External editor temps (Notepad++ etc.) likewise need the session: parking
// calls closeSftp which deletes those local files.
interactive: isBrowseSessionInteractive({
surfaceVisible: isVisible,
hasOwnedEditorTab,
hasActiveExternalEdit: activeExternalEditCountRef.current > 0,
}),
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
@@ -257,6 +273,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
activeExternalEditCountRef.current = sftp.activeExternalEditCount ?? 0;
ownedEditorSessionIdsRef.current = new Set(
listRemoteBrowseConnectionIds([
...sftp.leftTabs.tabs,
@@ -400,6 +417,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
onActiveTransfersChange,
});
// Parent retain-on-close only sees transfers unless we also publish external
// editor temp activity (closeSftp deletes those files on unmount).
const onActiveExternalEditsChangeRef = useRef(onActiveExternalEditsChange);
onActiveExternalEditsChangeRef.current = onActiveExternalEditsChange;
useLayoutEffect(() => {
onActiveExternalEditsChangeRef.current?.(sftp.activeExternalEditCount ?? 0);
}, [sftp.activeExternalEditCount]);
// Register this instance's writeTextFileByConnection with the editor bridge
// so editor tabs promoted from SFTP files opened in a terminal side panel
// can still route saves through this useSftpState.
@@ -474,7 +499,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const s = sftpRef.current;
const hasActiveWork = interactiveWorkActive
|| (s.activeFileWatchCountRef?.current ?? 0) > 0;
|| (s.activeFileWatchCountRef?.current ?? 0) > 0
|| (s.activeExternalEditCount ?? 0) > 0;
const proto = activeHost.protocol;
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
@@ -1159,7 +1185,8 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}, [followTerminalCwdHost, onGetTerminalCwd]);
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0
|| (sftp.activeExternalEditCount ?? 0) > 0;
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
@@ -1723,6 +1750,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
prev.onCurrentPathChange === next.onCurrentPathChange &&
prev.onActiveTransfersChange === next.onActiveTransfersChange &&
prev.onActiveExternalEditsChange === next.onActiveExternalEditsChange &&
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
prev.initialLocation?.path === next.initialLocation?.path &&
// Only the keepalive fields of terminalSettings affect SFTP connection
+7
View File
@@ -2778,6 +2778,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearWipesScrollbackRef.current = terminalSettings?.clearWipesScrollback ?? true;
const normalizeTextOnCopyRef = useRef(terminalSettings?.normalizeTextOnCopy ?? true);
normalizeTextOnCopyRef.current = terminalSettings?.normalizeTextOnCopy ?? true;
const autoUploadClipboardImageOnPasteRef = useRef(terminalSettings?.autoUploadClipboardImageOnPaste ?? false);
autoUploadClipboardImageOnPasteRef.current = terminalSettings?.autoUploadClipboardImageOnPaste ?? false;
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
if (!termRef.current) return;
@@ -2943,6 +2945,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
passwordPromptActiveRef,
isLocalConnection,
supportsRemoteImagePaste,
autoUploadClipboardImageOnPasteRef,
terminalBackend,
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
scrollToBottomAfterProgrammaticInput,
@@ -3364,6 +3367,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onPasteData: broadcastUserPasteData,
scrollToBottomAfterProgrammaticInput,
containerRef,
autoUploadClipboardImage:
supportsRemoteImagePaste && terminalSettings?.autoUploadClipboardImageOnPaste === true,
getRemoteCwd: () => resolveSftpInitialPath({ preferFreshBackend: true }),
onClipboardImageUploadResult: handleClipboardImageUploadResult,
});
const handleToggleSessionLog = useCallback(async () => {
+78 -11
View File
@@ -216,6 +216,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateHost,
onAddKnownHost,
onCommandExecuted,
onDeleteShellHistoryEntry,
onTerminalDataCapture,
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
@@ -578,6 +579,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sftpHostForTabRef = useRef(sftpHostForTab);
sftpHostForTabRef.current = sftpHostForTab;
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
const sftpOpeningTabIdsRef = useRef<Set<string>>(new Set());
const sftpRetainedCleanupTimersRef = useRef<Map<string, number>>(new Map());
@@ -612,6 +614,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpActiveTransfersByTabRef.current.delete(tabId);
sftpActiveExternalEditsByTabRef.current.delete(tabId);
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpOpeningTabIdsRef.current.delete(tabId);
setSftpHostForTab(prev => {
@@ -644,20 +647,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
});
}, []);
const handleSftpActiveTransfersChange = useCallback((tabId: string, count: number) => {
const activeTransfersCount = resolveTabActiveTransfersCount(tabId, Math.max(0, count));
if (activeTransfersCount > 0) {
const resolveTabActiveExternalEditCount = useCallback((tabId: string, reportedCount?: number) => {
return Math.max(0, reportedCount ?? sftpActiveExternalEditsByTabRef.current.get(tabId) ?? 0);
}, []);
const evaluateSftpPanelRetentionAfterActivity = useCallback((tabId: string, params: {
activeTransfersCount: number;
activeExternalEditCount: number;
}) => {
const { activeTransfersCount, activeExternalEditCount } = params;
if (activeTransfersCount > 0 || activeExternalEditCount > 0) {
const cleanupTimer = sftpRetainedCleanupTimersRef.current.get(tabId);
if (cleanupTimer !== undefined) {
window.clearTimeout(cleanupTimer);
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
return;
}
sftpActiveTransfersByTabRef.current.delete(tabId);
const lifecycle = {
activeTransfersCount,
activeExternalEditCount,
panelOpen: sidePanelOpenTabsRef.current.has(tabId) || sftpOpeningTabIdsRef.current.has(tabId),
retainedAfterClose: sftpRetainedAfterCloseTabIdsRef.current.has(tabId),
};
@@ -668,15 +677,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (
shouldScheduleSftpRetainedPanelCleanup({
activeTransfersCount: lifecycle.activeTransfersCount,
activeExternalEditCount: lifecycle.activeExternalEditCount,
retainedAfterClose: lifecycle.retainedAfterClose,
})
&& !sftpRetainedCleanupTimersRef.current.has(tabId)
) {
const cleanupTimer = window.setTimeout(() => {
sftpRetainedCleanupTimersRef.current.delete(tabId);
const currentCount = resolveTabActiveTransfersCount(tabId);
const currentTransfers = resolveTabActiveTransfersCount(tabId);
const currentExternalEdits = resolveTabActiveExternalEditCount(tabId);
if (
currentCount <= 0
currentTransfers <= 0
&& currentExternalEdits <= 0
&& !sidePanelOpenTabsRef.current.has(tabId)
&& !sftpOpeningTabIdsRef.current.has(tabId)
&& sftpRetainedAfterCloseTabIdsRef.current.has(tabId)
@@ -686,7 +698,46 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, SFTP_TRANSFER_HISTORY_RETENTION_MS);
sftpRetainedCleanupTimersRef.current.set(tabId, cleanupTimer);
}
}, [clearSftpPanelState, resolveTabActiveTransfersCount, sidePanelOpenTabsRef]);
}, [
clearSftpPanelState,
resolveTabActiveExternalEditCount,
resolveTabActiveTransfersCount,
sidePanelOpenTabsRef,
]);
const handleSftpActiveTransfersChange = useCallback((tabId: string, count: number) => {
const activeTransfersCount = resolveTabActiveTransfersCount(tabId, Math.max(0, count));
if (activeTransfersCount > 0) {
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
} else {
sftpActiveTransfersByTabRef.current.delete(tabId);
}
evaluateSftpPanelRetentionAfterActivity(tabId, {
activeTransfersCount,
activeExternalEditCount: resolveTabActiveExternalEditCount(tabId),
});
}, [
evaluateSftpPanelRetentionAfterActivity,
resolveTabActiveExternalEditCount,
resolveTabActiveTransfersCount,
]);
const handleSftpActiveExternalEditsChange = useCallback((tabId: string, count: number) => {
const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId, Math.max(0, count));
if (activeExternalEditCount > 0) {
sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount);
} else {
sftpActiveExternalEditsByTabRef.current.delete(tabId);
}
evaluateSftpPanelRetentionAfterActivity(tabId, {
activeTransfersCount: resolveTabActiveTransfersCount(tabId),
activeExternalEditCount,
});
}, [
evaluateSftpPanelRetentionAfterActivity,
resolveTabActiveExternalEditCount,
resolveTabActiveTransfersCount,
]);
const closeTerminalSidePanelTab = useCallback((tabId: string) => {
setSidePanelOpenTabs((previous) => {
@@ -696,9 +747,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
});
sftpOpeningTabIdsRef.current.delete(tabId);
const activeTransfersCount = resolveTabActiveTransfersCount(tabId);
if (shouldKeepSftpMountedAfterClose(activeTransfersCount)) {
const activeExternalEditCount = resolveTabActiveExternalEditCount(tabId);
if (shouldKeepSftpMountedAfterClose({ activeTransfersCount, activeExternalEditCount })) {
sftpRetainedAfterCloseTabIdsRef.current.add(tabId);
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
if (activeTransfersCount > 0) {
sftpActiveTransfersByTabRef.current.set(tabId, activeTransfersCount);
}
if (activeExternalEditCount > 0) {
sftpActiveExternalEditsByTabRef.current.set(tabId, activeExternalEditCount);
}
} else {
clearSftpPanelState(tabId);
}
@@ -713,7 +770,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return next;
});
notesReturnTabRef.current.delete(tabId);
}, [clearSftpPanelState, resolveTabActiveTransfersCount, setSidePanelOpenTabs]);
}, [
clearSftpPanelState,
resolveTabActiveExternalEditCount,
resolveTabActiveTransfersCount,
setSidePanelOpenTabs,
]);
const handleToggleWorkspaceComposeBar = useCallback(() => {
setIsComposeBarOpen(prev => !prev);
@@ -1099,8 +1161,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const storeActiveTransferTabIds = listTerminalTabIdsWithRetainingTransfers(
sftpTransferCenterStore.getSnapshot().tasks,
);
// Keep owners with unfinished transfers or external-editor temps mounted
// even after the terminal tab id drops out of the valid set.
const activeTransferTabIds = new Set([
...sftpActiveTransfersByTabRef.current.keys(),
...sftpActiveExternalEditsByTabRef.current.keys(),
...storeActiveTransferTabIds,
]);
const invalidTabIds = listInvalidSftpPanelTabIds({
@@ -1943,6 +2008,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleCloseSession,
handleCloseSidePanel,
handleCommandExecuted,
handleHistoryDelete: onDeleteShellHistoryEntry,
handleCommandSubmitted,
handleComposeSend,
handleHistoryPaste,
@@ -1966,6 +2032,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleSessionExit,
handleSftpCurrentPathChange,
handleSftpActiveTransfersChange,
handleSftpActiveExternalEditsChange,
handleSftpInitialLocationApplied,
persistSidePanelWidth,
handleSnippetClickForFocusedSession,
+4
View File
@@ -131,6 +131,7 @@ interface TopTabsProps {
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onCopySessionToNewWindow: (sessionId: string) => void;
onEditHost?: (host: Host) => void;
onRenameWorkspace: (workspaceId: string) => void;
onCopyWorkspace: (workspaceId: string) => void;
onCloseWorkspace: (workspaceId: string) => void;
@@ -177,6 +178,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onRenameSession,
onCopySession,
onCopySessionToNewWindow,
onEditHost,
onRenameWorkspace,
onCopyWorkspace,
onCloseWorkspace,
@@ -804,6 +806,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onRenameSession={onRenameSession}
onCopySession={onCopySession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
onEditHost={onEditHost}
renderBulkCloseItems={renderBulkCloseItems}
dynamicTabTitleMode={dynamicTabTitleMode}
t={t}
@@ -1170,6 +1173,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.isMacClient === next.isMacClient &&
prev.onCopySession === next.onCopySession &&
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
prev.onEditHost === next.onEditHost &&
prev.onCopyWorkspace === next.onCopyWorkspace &&
prev.onOpenSettings === next.onOpenSettings &&
prev.externalMcpEnabled === next.externalMcpEnabled &&
@@ -181,11 +181,12 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
? stored as { kind: 'secret'; id: string; key: string }
: undefined;
if (credentialPlan.secrets.length > 0) {
const { putPluginSyncSecret, deletePluginSyncSecrets } = await import(
const { putPluginSyncSecret, deletePluginSyncSecrets, restorePluginSyncSecrets } = await import(
'../../infrastructure/services/adapters/pluginSyncIpcHost'
);
// Put secrets before connect; roll back just-created keys if connect fails
// so a rejected password/token is not left readable in plugin_secrets.
// Overwrites restore the previous plaintext from the host stash.
await storePluginSyncSecretsThenConnect({
providerId,
secrets: credentialPlan.secrets.map((secret) => ({
@@ -194,6 +195,7 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
})),
putSecret: putPluginSyncSecret,
deleteSecrets: deletePluginSyncSecrets,
restoreSecrets: restorePluginSyncSecrets,
connect: async (credential) => {
await sync.connectPluginProvider(
providerId,
@@ -38,6 +38,7 @@ test("delete confirmation dialogs constrain long names", () => {
const confirmDialogSource = readProjectFile("components/ui/confirm-dialog.tsx");
const vaultDeleteDialogSource = readProjectFile("components/vault/VaultDeleteConfirmDialog.tsx");
const sftpDialogSource = readProjectFile("components/sftp/SftpPaneDialogs.tsx");
const sftpClipboardUploadDialogSource = readProjectFile("components/sftp/SftpClipboardUploadDialog.tsx");
const hostTreeGroupDeleteSource = readProjectFile("components/host/HostTreeGroupDeleteDialog.tsx");
const vaultViewLayoutSource = readProjectFile("components/vault/VaultViewLayout.tsx");
@@ -51,6 +52,13 @@ test("delete confirmation dialogs constrain long names", () => {
assert.match(sftpDialogSource, /<span className="min-w-0 truncate">\{name\}<\/span>/);
assert.match(sftpDialogSource, /overflow-hidden sm:max-w-sm/);
assert.match(sftpClipboardUploadDialogSource, /overflow-hidden sm:max-w-md/);
assert.match(sftpClipboardUploadDialogSource, /<span className="min-w-0 truncate" title=\{file\.name\}>/);
assert.match(
sftpClipboardUploadDialogSource,
/min-w-0 rounded-md border border-border\/60 bg-muted\/30 px-3 py-2 text-sm font-mono break-all \[overflow-wrap:anywhere\]/,
);
assert.match(hostTreeGroupDeleteSource, /overflow-hidden sm:max-w-lg/);
assert.match(hostTreeGroupDeleteSource, /break-words text-sm text-muted-foreground \[overflow-wrap:anywhere\]/);
@@ -3,8 +3,8 @@ import React, { useCallback, useMemo, useState, type DragEvent } from 'react';
import type { Host, Snippet } from '@/domain/models';
import {
appendHostConnectScript,
getEditableHostConnectScriptIds,
getGlobalConnectScripts,
getHostConnectScriptIds,
removeHostConnectScript,
reorderHostConnectScript,
} from '@/domain/hostConnectScripts.ts';
@@ -60,7 +60,7 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
[snippets],
);
const queueIds = useMemo(
() => getHostConnectScriptIds(host, snippets),
() => getEditableHostConnectScriptIds(host, snippets),
[host, snippets],
);
const queuedScripts = useMemo(
@@ -71,15 +71,16 @@ export const HostDetailsScriptsSection: React.FC<HostDetailsScriptsSectionProps>
);
const linkableScripts = useMemo(
() => scripts.filter((script) => {
if (!script.id) return false;
if (script.targetsAllHosts) return false;
if (script.id && queueIds.includes(script.id)) return false;
if (queueIds.includes(script.id)) return false;
return true;
}),
[queueIds, scripts],
);
const linkOptions = useMemo(
() => linkableScripts.map((script) => ({
value: script.id,
value: script.id!,
label: script.label || t('scripts.running.unnamed'),
})),
[linkableScripts, t],
@@ -35,3 +35,9 @@ test("terminal settings expose the host information bar toggle", () => {
assert.match(source, /checked=\{terminalSettings\.showHostInfoBar\}/);
assert.match(source, /updateTerminalSetting\("showHostInfoBar", v\)/);
});
test("terminal settings expose host information title mode when the bar is shown", () => {
assert.match(source, /terminalSettings\.showHostInfoBar && \(/);
assert.match(source, /updateTerminalSetting\("hostInfoBarTitleMode"/);
assert.match(source, /settings\.terminal\.hostInfoBar\.titleMode/);
});
@@ -3,6 +3,7 @@ import { AlertCircle, Import, Minus, Palette, Pencil, Plus, Trash2 } from "lucid
import type {
AutocompleteHistoryScope,
CursorShape,
HostInfoBarTitleMode,
PasswordPromptAssistMode,
TerminalEmulationType,
TerminalSettings,
@@ -924,6 +925,22 @@ function SettingsTerminalTab(props: {
onChange={(v) => updateTerminalSetting("showHostInfoBar", v)}
/>
</SettingRow>
{terminalSettings.showHostInfoBar && (
<SettingRow
label={t("settings.terminal.hostInfoBar.titleMode")}
description={t("settings.terminal.hostInfoBar.titleMode.desc")}
>
<Select
value={terminalSettings.hostInfoBarTitleMode ?? "address"}
options={[
{ value: "address", label: t("settings.terminal.hostInfoBar.titleMode.address") },
{ value: "label", label: t("settings.terminal.hostInfoBar.titleMode.label") },
]}
onChange={(v) => updateTerminalSetting("hostInfoBarTitleMode", v as HostInfoBarTitleMode)}
className="w-44"
/>
</SettingRow>
)}
<SettingRow
label={t("settings.terminal.serverStats.show")}
description={t("settings.terminal.serverStats.show.desc")}
@@ -127,6 +127,16 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.autoUploadClipboardImage")}
description={t("settings.terminal.behavior.autoUploadClipboardImage.desc")}
>
<Toggle
checked={terminalSettings.autoUploadClipboardImageOnPaste ?? false}
onChange={(v) => updateTerminalSetting("autoUploadClipboardImageOnPaste", v)}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.shiftEnterNewline")}
description={t("settings.terminal.behavior.shiftEnterNewline.desc")}
+12 -7
View File
@@ -64,23 +64,28 @@ export const SftpClipboardUploadDialog: React.FC<SftpClipboardUploadDialogProps>
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-md">
<DialogHeader className="min-w-0 pr-6">
<DialogTitle>Upload clipboard files?</DialogTitle>
<DialogDescription>
Upload {fileCount} item{fileCount === 1 ? "" : "s"} to:
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm font-mono break-all">
<div className="min-w-0 space-y-3">
<div className="min-w-0 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm font-mono break-all [overflow-wrap:anywhere]">
{request?.targetPath ?? currentPath}
</div>
{previewFiles.length > 0 && (
<div className="max-h-40 overflow-auto rounded-md border border-border/60">
<div className="max-h-40 min-w-0 overflow-auto rounded-md border border-border/60">
{previewFiles.map((file) => (
<div key={file.path} className="px-3 py-2 text-sm border-b border-border/40 last:border-b-0 truncate">
{file.name}
<div
key={file.path}
className="flex min-w-0 items-center border-b border-border/40 px-3 py-2 text-sm last:border-b-0"
>
<span className="min-w-0 truncate" title={file.name}>
{file.name}
</span>
</div>
))}
{remainingCount > 0 && (
+52
View File
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
formatTerminalHostInfoBarTitle,
formatTerminalHostInfoBarTooltip,
formatTerminalTitleConnectionAddress,
getLineTimestampToggleHostUpdate,
resolveNetworkDeviceTipRightInset,
@@ -161,6 +163,56 @@ test("terminal title formats the connection address for remote sessions", () =>
}), null);
});
test("host info bar title follows address or label mode", () => {
assert.equal(
formatTerminalHostInfoBarTitle({
serverName: "prod-web",
connectionAddress: "root@10.1.2.34:2222",
mode: "address",
}),
"root@10.1.2.34:2222",
);
assert.equal(
formatTerminalHostInfoBarTitle({
serverName: "prod-web",
connectionAddress: "root@10.1.2.34:2222",
mode: "label",
}),
"prod-web",
);
assert.equal(
formatTerminalHostInfoBarTitle({
serverName: " prod-web ",
connectionAddress: " root@10.1.2.34:2222 ",
mode: "label",
}),
"prod-web",
);
assert.equal(
formatTerminalHostInfoBarTitle({
serverName: "",
connectionAddress: "root@10.1.2.34:2222",
mode: "label",
}),
"root@10.1.2.34:2222",
);
assert.equal(
formatTerminalHostInfoBarTitle({
serverName: "Local Terminal",
connectionAddress: null,
mode: "address",
}),
"Local Terminal",
);
assert.equal(
formatTerminalHostInfoBarTooltip({
serverName: "prod-web",
connectionAddress: "root@10.1.2.34:2222",
}),
"prod-web · root@10.1.2.34:2222",
);
});
test("terminal title row does not render a status dot beside the address", () => {
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const titleStart = source.indexOf("data-terminal-detach-drag-handle");
+49 -2
View File
@@ -4,6 +4,7 @@ import { ChevronsLeft, GripVertical, Network, X as XIcon } from 'lucide-react';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { classifyDistroId } from '../../domain/host';
import type { HostInfoBarTitleMode } from '../../domain/models';
import { useNetworkDeviceModeSuggestion } from '../../application/state/useNetworkDeviceModeSuggestion';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { OSC7_SETUP_TARGETS } from './osc7Setup';
@@ -167,6 +168,40 @@ export function formatTerminalTitleConnectionAddress(host?: TerminalTitleAddress
return `${username}${host.hostname}${port}`;
}
/** Host info bar label: vault name or user@host, based on settings. */
export function formatTerminalHostInfoBarTitle({
serverName,
connectionAddress,
mode = "address",
}: {
serverName?: string | null;
connectionAddress?: string | null;
mode?: HostInfoBarTitleMode;
}): string {
const name = (serverName || "").trim();
const address = (connectionAddress || "").trim();
if (mode === "label") {
return name || address;
}
return address || name;
}
/** Hover tooltip can show both name and address without consuming bar width. */
export function formatTerminalHostInfoBarTooltip({
serverName,
connectionAddress,
}: {
serverName?: string | null;
connectionAddress?: string | null;
}): string {
const name = (serverName || "").trim();
const address = (connectionAddress || "").trim();
if (name && address && name !== address) {
return `${name} · ${address}`;
}
return name || address;
}
/** Height (px) of the one-line "enable Network Device Mode" tip strip. */
export const NETWORK_DEVICE_TIP_HEIGHT = 28;
@@ -365,6 +400,18 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
});
}, [host.id, onUpdateHost, showLineTimestampGutter]);
const titleConnectionAddress = formatTerminalTitleConnectionAddress(host);
// Prefer vault host.label over sessionDisplayName so dynamic tab titles
// (cwd / coding-cli) do not replace the stable server name in this bar.
const hostInfoBarServerName = host.label || sessionDisplayName;
const hostInfoBarTitle = formatTerminalHostInfoBarTitle({
serverName: hostInfoBarServerName,
connectionAddress: titleConnectionAddress,
mode: terminalSettings?.hostInfoBarTitleMode ?? "address",
});
const hostInfoBarTooltip = formatTerminalHostInfoBarTooltip({
serverName: hostInfoBarServerName,
connectionAddress: titleConnectionAddress,
});
const hasBlockingReconnectOverlay = Boolean(osc52ReadPromptVisible || osc7SetupOpen || scriptExecutionOverlay || zmodem.active || zmodem.overwriteRequest);
const showEnterReconnectHint = shouldReconnectTerminalOnEnterKey({
key: "Enter",
@@ -530,8 +577,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
data-terminal-detach-drag-handle={inWorkspace && onDetachPointerDown ? "true" : undefined}
onPointerDown={onDetachPointerDown}
>
<span className="whitespace-nowrap truncate min-w-0 max-w-[18rem]" title={titleConnectionAddress || sessionDisplayName || host.label}>
{titleConnectionAddress || sessionDisplayName || host.label}
<span className="whitespace-nowrap truncate min-w-0 max-w-[18rem]" title={hostInfoBarTooltip || hostInfoBarTitle}>
{hostInfoBarTitle}
</span>
</div>}
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
@@ -137,6 +137,25 @@ export function recordCommand(
saveStore(store);
}
/** Remove one command from autocomplete history for a specific host. */
export function removeCommandHistoryEntry(command: string, hostId: string): boolean {
const trimmed = command.trim();
if (!trimmed) return false;
const store = loadStore();
const nextEntries = store.entries.filter(
(entry) => entry.command !== trimmed || entry.hostId !== hostId,
);
if (nextEntries.length === store.entries.length) return false;
store.entries = nextEntries;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
return persistStoreNow(store);
}
/**
* Score an entry for ranking at a specific timestamp.
* Caches Date.now() at query boundaries to avoid repeated syscalls during sort.
+1 -1
View File
@@ -2,5 +2,5 @@ export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTer
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
export { default as AutocompletePopup } from "./AutocompletePopup";
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export { recordCommand, clearHistory } from "./commandHistoryStore";
export { recordCommand, removeCommandHistoryEntry, clearHistory } from "./commandHistoryStore";
export { shellEscape } from "./completionEngine";
@@ -239,6 +239,27 @@ test("remote clipboard image upload reports transfer failures without inserting
assert.deepEqual(result, { ok: false, reason: "upload-failed" });
});
test("remote clipboard image upload treats clipboard read failures as no image", async () => {
const result = await handleRemoteClipboardImageUpload({
bridge: {
readClipboardImage: async () => {
throw new Error("clipboard unavailable");
},
openSftpForSession: async () => {
assert.fail("should not open SFTP without an image");
},
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
},
getRemoteCwd: async () => "/home/alice",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("should not paste without an image"),
},
});
assert.deepEqual(result, { ok: false, reason: "no-image" });
});
test("remote clipboard image upload result maps to user-facing message keys", () => {
assert.equal(
getRemoteClipboardImageUploadErrorMessageKey({
+9 -2
View File
@@ -7,7 +7,7 @@ type ClipboardImageFile = {
size?: number;
};
type RemoteClipboardImageBridge = Pick<
export type RemoteClipboardImageBridge = Pick<
NetcattyBridge,
"readClipboardImage" | "openSftpForSession" | "startStreamTransfer"
> & Pick<Partial<NetcattyBridge>, "closeSftp" | "deleteTempFile">;
@@ -94,7 +94,14 @@ export async function handleRemoteClipboardImageUpload({
return { ok: false, reason: "unsupported" };
}
const image: ClipboardImageFile | null = await bridge.readClipboardImage();
let image: ClipboardImageFile | null;
try {
image = await bridge.readClipboardImage();
} catch {
// A clipboard read failure is indistinguishable from an empty clipboard —
// treat it as "no image" so callers can fall back to a normal paste.
return { ok: false, reason: "no-image" };
}
if (!image?.path || !image.name) return { ok: false, reason: "no-image" };
let sftpId: string | undefined;
+50 -1
View File
@@ -102,7 +102,11 @@ Object.defineProperty(globalThis, "window", {
});
const { getCompletions } = await import("./autocomplete/completionEngine.ts");
const { clearHistory, recordCommand } = await import("./autocomplete/commandHistoryStore.ts");
const {
clearHistory,
recordCommand,
removeCommandHistoryEntry,
} = await import("./autocomplete/commandHistoryStore.ts");
const {
normalizePathTokenForLookup,
shouldPreferRemoteShellCwd,
@@ -150,6 +154,51 @@ test("getCompletions does not treat generator-only spec args as path contexts",
assert.equal(completions.some((entry) => entry.source === "path"), false);
});
test("removeCommandHistoryEntry removes only the matching host's autocomplete record", async () => {
recordCommand("bad-command --flag", "host-1");
recordCommand("bad-command --flag", "host-2");
removeCommandHistoryEntry("bad-command --flag", "host-1");
const completions = await getCompletions("bad-command", {
hostId: "host-1",
historyScope: "global",
protocol: "local",
cwd: "/repo",
});
assert.equal(
completions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
true,
);
const hostOnlyCompletions = await getCompletions("bad-command", {
hostId: "host-1",
historyScope: "host",
protocol: "local",
cwd: "/repo",
});
assert.equal(
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
false,
);
});
test("removeCommandHistoryEntry trims command text like recordCommand", async () => {
recordCommand("padded-cmd", "host-trim");
assert.equal(removeCommandHistoryEntry(" padded-cmd ", "host-trim"), true);
const hostOnlyCompletions = await getCompletions("padded", {
hostId: "host-trim",
historyScope: "host",
protocol: "local",
cwd: "/repo",
});
assert.equal(
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "padded-cmd"),
false,
);
});
test("getCompletions uses the remote shell cwd for relative path arguments instead of stale home", async () => {
bridgeState.remoteEntriesByPath.set("~", [{ name: "home-only.txt", type: "file" }]);
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
@@ -53,6 +53,7 @@ export const useTerminalContextActions = ({
passwordPromptActiveRef,
isLocalConnection,
supportsRemoteImagePaste,
autoUploadClipboardImageOnPasteRef,
clearWipesScrollbackRef,
normalizeTextOnCopyRef,
terminalBackend,
@@ -70,6 +71,8 @@ export const useTerminalContextActions = ({
passwordPromptActiveRef?: RefObject<boolean | undefined>;
isLocalConnection: boolean;
supportsRemoteImagePaste: boolean;
/** When true, paste auto-uploads a clipboard image (remote sessions only). */
autoUploadClipboardImageOnPasteRef?: RefObject<boolean | undefined>;
clearWipesScrollbackRef?: RefObject<boolean | undefined>;
/** When false, copy uses raw getSelection(). Default true when unset. */
normalizeTextOnCopyRef?: RefObject<boolean | undefined>;
@@ -110,12 +113,18 @@ export const useTerminalContextActions = ({
const bridge = netcattyBridge.get();
await handleTerminalClipboardPaste({
bridge,
autoUploadClipboardImage:
supportsRemoteImagePaste && autoUploadClipboardImageOnPasteRef?.current === true,
clipboardImageBridge: bridge ?? undefined,
getRemoteCwd,
isLocalConnection,
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
onClipboardImageUploadResult,
readClipboardText: () => navigator.clipboard.readText(),
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData: broadcastUserPasteData,
sessionId: sessionRef.current,
scrollToBottomAfterProgrammaticInput,
terminalBackend,
term,
});
@@ -123,12 +132,17 @@ export const useTerminalContextActions = ({
logger.warn("Failed to paste from clipboard", err);
}
}, [
autoUploadClipboardImageOnPasteRef,
broadcastUserPasteData,
getRemoteCwd,
isLocalConnection,
onClipboardImageUploadResult,
passwordPromptActiveRef,
sessionRef,
supportsRemoteImagePaste,
termRef,
scrollOnPasteRef,
scrollToBottomAfterProgrammaticInput,
terminalBackend,
]);
@@ -5,6 +5,7 @@ import { useEffect } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import type { TerminalSession } from "../../../types";
import type { RemoteClipboardImageUploadResult } from "../clipboardImagePaste";
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
interface UseTerminalFilePasteOptions {
@@ -20,6 +21,10 @@ interface UseTerminalFilePasteOptions {
onPasteData?: (data: string) => boolean | void;
scrollToBottomAfterProgrammaticInput: (data: string) => void;
containerRef: React.RefObject<HTMLDivElement | null>;
/** Remote sessions only: auto-upload a clipboard image on paste. */
autoUploadClipboardImage?: boolean;
getRemoteCwd?: () => Promise<string | null | undefined>;
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
}
export function useTerminalFilePaste({
@@ -33,6 +38,9 @@ export function useTerminalFilePaste({
onPasteData,
scrollToBottomAfterProgrammaticInput,
containerRef,
autoUploadClipboardImage = false,
getRemoteCwd,
onClipboardImageUploadResult,
}: UseTerminalFilePasteOptions) {
useEffect(() => {
const container = containerRef.current;
@@ -43,7 +51,9 @@ export function useTerminalFilePaste({
const bridge = netcattyBridge.get();
if (!isLocalConnection || !bridge?.readClipboardFiles) return;
const wantsImageUpload =
autoUploadClipboardImage && !isLocalConnection && !!bridge?.readClipboardImage;
if (!wantsImageUpload && (!isLocalConnection || !bridge?.readClipboardFiles)) return;
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
// is synchronous; calling it after an await is too late and the
@@ -57,8 +67,12 @@ export function useTerminalFilePaste({
if (!term) return;
await handleTerminalClipboardPaste({
bridge,
autoUploadClipboardImage: wantsImageUpload,
clipboardImageBridge: bridge ?? undefined,
getRemoteCwd,
isLocalConnection,
isSensitiveInput,
onClipboardImageUploadResult,
readClipboardText: () => navigator.clipboard.readText(),
scrollOnPaste: scrollOnPasteRef?.current ?? false,
onPasteData,
@@ -78,9 +92,12 @@ export function useTerminalFilePaste({
container.removeEventListener("paste", handlePaste, true);
};
}, [
autoUploadClipboardImage,
containerRef,
getRemoteCwd,
isLocalConnection,
isSensitiveInput,
onClipboardImageUploadResult,
onPasteData,
scrollOnPasteRef,
scrollToBottomAfterProgrammaticInput,
@@ -1494,6 +1494,15 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
break;
}
case "paste": {
const autoImagePaste =
ctx.terminalSettingsRef.current?.autoUploadClipboardImageOnPaste === true;
if (autoImagePaste) {
// onPaste (shared with the context menu) auto-uploads a
// clipboard image via SFTP for remote sessions and falls
// back to a regular text paste when there is no image.
void ctx.terminalContextActionsRef?.current?.onPaste?.();
break;
}
navigator.clipboard.readText().then((text) => {
const id = ctx.sessionRef.current;
if (id) {
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
createRightClickMouseTrackingPressClaim,
isMiddleClickContextMenuEvent,
isShiftSelectionReplayMouseEvent,
markMiddleClickContextMenuEvent,
@@ -10,6 +11,7 @@ import {
resolveMiddleClickBehavior,
shouldInterceptMouseTrackingContextMenu,
shouldReplayShiftMouseSelectionAsMacOption,
shouldStopRightClickMouseTrackingMouseUp,
shouldStopShiftRightClickMouseTrackingMouseDown,
} from "./middleClickBehavior";
@@ -299,6 +301,118 @@ test("Shift right-click mousedown capture is limited to connected mouse tracking
);
});
test("right-click mouseup reaches mouse-tracking apps when Netcatty did not claim the press", () => {
// Herdr / Terminal.app: button-down was delivered, so button-up must be too.
// Swallowing mouseup leaves the TUI stuck thinking the right button is held (#2721).
const claim = createRightClickMouseTrackingPressClaim();
assert.equal(
claim.noteMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: false,
}),
false,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 2, shiftKey: false } as MouseEvent,
claimedMatchingMouseDown: claim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
}),
false,
);
});
test("right-click mouseup is stopped only when Netcatty claimed the matching mousedown", () => {
const shiftClaim = createRightClickMouseTrackingPressClaim();
assert.equal(
shiftClaim.noteMouseDown({
event: { button: 2, shiftKey: true } as MouseEvent,
mouseTracking: true,
status: "connected",
}),
true,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 2, shiftKey: true } as MouseEvent,
claimedMatchingMouseDown: shiftClaim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
}),
true,
);
const forceMenuClaim = createRightClickMouseTrackingPressClaim();
assert.equal(
forceMenuClaim.noteMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
true,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 2, shiftKey: false } as MouseEvent,
claimedMatchingMouseDown: forceMenuClaim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
}),
true,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 0, shiftKey: false } as MouseEvent,
claimedMatchingMouseDown: false,
}),
false,
);
});
test("right-click mouseup pairs with the claimed mousedown, not the release modifiers", () => {
// Shift+right press claimed by Netcatty, then Shift released before mouseup:
// still swallow the release so xterm never sees a lone button-up.
const claimedThenShiftReleased = createRightClickMouseTrackingPressClaim();
assert.equal(
claimedThenShiftReleased.noteMouseDown({
event: { button: 2, shiftKey: true } as MouseEvent,
mouseTracking: true,
status: "connected",
}),
true,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 2, shiftKey: false } as MouseEvent,
claimedMatchingMouseDown: claimedThenShiftReleased.consumeMouseUpClaim(
{ button: 2, shiftKey: false } as MouseEvent,
),
}),
true,
);
// App-owned press, then Shift held on release: release must still reach xterm.
const appOwnedThenShiftAdded = createRightClickMouseTrackingPressClaim();
assert.equal(
appOwnedThenShiftAdded.noteMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
}),
false,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 2, shiftKey: true } as MouseEvent,
claimedMatchingMouseDown: appOwnedThenShiftAdded.consumeMouseUpClaim(
{ button: 2, shiftKey: true } as MouseEvent,
),
}),
false,
);
});
test("middle-click terminal mouse down/up events are captured before xterm sees them", () => {
const calls: string[] = [];
const middleClickEvent = {
@@ -131,6 +131,48 @@ export const shouldStopShiftRightClickMouseTrackingMouseDown = ({
&& event.button === 2
&& (event.shiftKey || forcesMenuOverMouseTracking({ rightClickBehavior, forceMenuInAlternateScreen }));
// Pair mouseup with mousedown ownership. When Netcatty claims the press
// (Shift / fullscreen-apps menu), also swallow the release so xterm never
// reports a lone button-up. When the TUI owns the press (Herdr, tmux menus,
// vim, ...), the release must reach xterm too - otherwise the app stays stuck
// with the right button held and mouse UI dies until restart (#2721).
// Ownership is remembered from the actual mousedown claim — never re-derived
// from mouseup modifiers (Shift may change between press and release).
export interface RightClickMouseTrackingPressClaim {
/** Evaluate + record whether this right-button mousedown was claimed. */
noteMouseDown: (state: ShiftRightClickMouseDownCaptureState) => boolean;
/** Consume the pending claim for a right-button mouseup (clears state). */
consumeMouseUpClaim: (event: MouseEvent) => boolean;
}
export const createRightClickMouseTrackingPressClaim = (): RightClickMouseTrackingPressClaim => {
let claimedMatchingMouseDown = false;
return {
noteMouseDown(state) {
const shouldStop = shouldStopShiftRightClickMouseTrackingMouseDown(state);
if (state.event.button === 2) {
claimedMatchingMouseDown = shouldStop;
}
return shouldStop;
},
consumeMouseUpClaim(event) {
if (event.button !== 2) return false;
const claimed = claimedMatchingMouseDown;
claimedMatchingMouseDown = false;
return claimed;
},
};
};
export const shouldStopRightClickMouseTrackingMouseUp = ({
event,
claimedMatchingMouseDown,
}: {
event: MouseEvent;
claimedMatchingMouseDown: boolean;
}): boolean => event.button === 2 && claimedMatchingMouseDown;
export const createMacOptionForcedSelectionMouseEvent = (event: MouseEvent): MouseEvent =>
markShiftSelectionReplayMouseEvent(new MouseEvent(event.type, {
bubbles: event.bubbles,
@@ -71,3 +71,188 @@ test("terminal user paste still inserts local clipboard file paths", async () =>
assert.deepEqual(scrolled, ["/Users/alice/shot.png /Users/alice/report.txt"]);
assert.equal(focused, true);
});
test("terminal user paste auto-uploads a clipboard image in remote sessions", async () => {
const writes: Array<{ data: string }> = [];
const readTextCalls: string[] = [];
const image = {
path: "/tmp/netcatty/shot.png",
name: "shot.png",
mediaType: "image/png",
size: 12,
};
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
},
autoUploadClipboardImage: true,
clipboardImageBridge: {
readClipboardImage: async () => image,
openSftpForSession: async () => "sftp-1",
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
closeSftp: async () => {},
deleteTempFile: async () => ({ success: true }),
},
getRemoteCwd: async () => "/home/alice",
isLocalConnection: false,
readClipboardText: async () => {
readTextCalls.push("read");
return "hello";
},
sessionId: "session-1",
terminalBackend: {
writeToSession: (_sessionId, data) => writes.push({ data }),
},
term: {
paste: () => assert.fail("image upload should insert the remote path, not paste text"),
scrollToBottom: () => {},
},
});
assert.deepEqual(writes, [{ data: "/home/alice/.netcatty-paste-images/shot.png" }]);
assert.deepEqual(readTextCalls, []);
});
test("terminal user paste falls back to text when the clipboard holds no image", async () => {
const pasted: string[] = [];
let sftpOpened = false;
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
},
autoUploadClipboardImage: true,
clipboardImageBridge: {
readClipboardImage: async () => null,
openSftpForSession: async () => {
sftpOpened = true;
return "sftp-1";
},
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
},
getRemoteCwd: async () => "/home/alice",
isLocalConnection: false,
readClipboardText: async () => "hello",
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("no image should fall back to text paste"),
},
term: {
paste: (text) => pasted.push(text),
scrollToBottom: () => {},
},
});
assert.equal(sftpOpened, false);
assert.deepEqual(pasted, ["hello"]);
});
test("terminal user paste reports failed uploads instead of pasting text", async () => {
const results: unknown[] = [];
const readTextCalls: string[] = [];
const image = {
path: "/tmp/netcatty/shot.png",
name: "shot.png",
mediaType: "image/png",
size: 12,
};
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
},
autoUploadClipboardImage: true,
clipboardImageBridge: {
readClipboardImage: async () => image,
openSftpForSession: async () => "sftp-1",
startStreamTransfer: async (options) => ({ transferId: options.transferId, error: "disk full" }),
},
getRemoteCwd: async () => "/home/alice",
isLocalConnection: false,
onClipboardImageUploadResult: (result) => results.push(result),
readClipboardText: async () => {
readTextCalls.push("read");
return "hello";
},
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("failed upload should not paste anything"),
},
term: {
paste: () => assert.fail("failed upload should not paste text"),
scrollToBottom: () => {},
},
});
assert.deepEqual(results, [{ ok: false, reason: "upload-failed" }]);
assert.deepEqual(readTextCalls, []);
});
test("terminal user paste reports thrown upload failures instead of pasting text", async () => {
const results: unknown[] = [];
const readTextCalls: string[] = [];
const image = {
path: "/tmp/netcatty/shot.png",
name: "shot.png",
mediaType: "image/png",
size: 12,
};
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [],
},
autoUploadClipboardImage: true,
clipboardImageBridge: {
readClipboardImage: async () => image,
openSftpForSession: async () => {
throw new Error("SFTP unavailable");
},
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
},
getRemoteCwd: async () => "/home/alice",
isLocalConnection: false,
onClipboardImageUploadResult: (result) => results.push(result),
readClipboardText: async () => {
readTextCalls.push("read");
return "hello";
},
sessionId: "session-1",
terminalBackend: {
writeToSession: () => assert.fail("thrown upload failure must not paste anything"),
},
term: {
paste: () => assert.fail("thrown upload failure must not paste text"),
scrollToBottom: () => {},
},
});
assert.deepEqual(results, [{ ok: false, reason: "upload-failed" }]);
assert.deepEqual(readTextCalls, []);
});
test("terminal user paste keeps local file path paste even with auto-upload enabled", async () => {
const writes: Array<{ data: string }> = [];
await handleTerminalClipboardPaste({
bridge: {
readClipboardFiles: async () => [
{ path: "/Users/alice/shot.png", name: "shot.png", isDirectory: false },
],
},
autoUploadClipboardImage: true,
isLocalConnection: true,
readClipboardText: async () => assert.fail("local file paste should not fall through to text"),
sessionId: "session-1",
terminalBackend: {
writeToSession: (_sessionId, data) => writes.push({ data }),
},
term: {
paste: () => assert.fail("local file paste should write paths directly"),
scrollToBottom: () => {},
},
});
assert.deepEqual(writes, [{ data: "/Users/alice/shot.png" }]);
});
@@ -1,5 +1,10 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import {
handleRemoteClipboardImageUpload,
type RemoteClipboardImageBridge,
type RemoteClipboardImageUploadResult,
} from "./clipboardImagePaste";
import { extractRootPathsFromClipboardFiles } from "./terminalHelpers";
import { pasteTextIntoTerminal } from "./runtime/terminalUserPaste";
@@ -7,8 +12,18 @@ type ClipboardFileBridge = Pick<Partial<NetcattyBridge>, "readClipboardFiles">;
type TerminalClipboardPasteOptions = {
bridge?: ClipboardFileBridge;
/**
* When true, a clipboard image is uploaded to the remote host (SFTP) and
* the remote path is pasted, instead of falling back to a text paste.
* Callers should only enable this for connections that support remote
* image paste; the upload no-ops unless a session id is provided.
*/
autoUploadClipboardImage?: boolean;
clipboardImageBridge?: RemoteClipboardImageBridge;
getRemoteCwd?: () => Promise<string | null | undefined>;
isLocalConnection: boolean;
isSensitiveInput?: () => boolean;
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
onPasteData?: (data: string) => boolean | void;
readClipboardText: () => Promise<string>;
scrollOnPaste?: boolean;
@@ -22,8 +37,12 @@ type TerminalClipboardPasteOptions = {
export async function handleTerminalClipboardPaste({
bridge,
autoUploadClipboardImage = false,
clipboardImageBridge,
getRemoteCwd,
isLocalConnection,
isSensitiveInput,
onClipboardImageUploadResult,
onPasteData,
readClipboardText,
scrollOnPaste = false,
@@ -32,6 +51,38 @@ export async function handleTerminalClipboardPaste({
terminalBackend,
term,
}: TerminalClipboardPasteOptions): Promise<void> {
// Image-first: when enabled and a remote session is active, a clipboard
// image triggers the SFTP upload flow. "no-image" means the clipboard
// holds text/files instead, so we silently continue to the normal paste.
if (autoUploadClipboardImage && sessionId && !isLocalConnection) {
try {
const result = await handleRemoteClipboardImageUpload({
bridge: clipboardImageBridge,
getRemoteCwd: getRemoteCwd ?? (async () => undefined),
isSensitiveInput,
sessionId,
terminalBackend,
term,
scrollToBottomAfterProgrammaticInput,
});
if (result.ok === true) {
onClipboardImageUploadResult?.(result);
return;
}
if (result.reason !== "no-image" && result.reason !== "unsupported") {
onClipboardImageUploadResult?.(result);
return;
}
} catch {
// The clipboard image was already read successfully above, so any throw
// here means the upload itself failed (e.g. SFTP cannot be opened for
// the session). Surface the same error as the context-menu action
// instead of silently pasting unrelated clipboard content.
onClipboardImageUploadResult?.({ ok: false, reason: "upload-failed" });
return;
}
}
const readClipboardFiles = bridge?.readClipboardFiles;
if (isLocalConnection && readClipboardFiles) {
try {
+20 -13
View File
@@ -8,9 +8,10 @@ import { isMacPlatform } from '../../lib/utils';
import { resolveXTermScrollback } from '../../infrastructure/config/xtermPerformance';
import {
createMacOptionForcedSelectionMouseEvent,
createRightClickMouseTrackingPressClaim,
shouldInterceptMouseTrackingContextMenu,
shouldReplayShiftMouseSelectionAsMacOption,
shouldStopShiftRightClickMouseTrackingMouseDown,
shouldStopRightClickMouseTrackingMouseUp,
} from './runtime/middleClickBehavior';
import {
hasOpenAppDialog,
@@ -1553,21 +1554,24 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
}, [sessionId]);
// Prevent xterm.js's built-in rightClickHandler and right-button mouseup
// from interfering with tmux/vim popup menus when mouse tracking is active.
// On macOS, xterm forces selection with Option, while most terminal users
// expect Shift to bypass mouse reporting. Replay Shift+left-click as that
// native xterm force-selection gesture before xterm receives the original.
// - mousedown (button 2 + Shift): keep Shift+right-click local so the
// terminal app does not also receive the right-button press
// Prevent xterm.js's built-in rightClickHandler from interfering with
// mouse-tracking TUIs. On macOS, xterm forces selection with Option, while
// most terminal users expect Shift to bypass mouse reporting. Replay
// Shift+left-click as that native xterm force-selection gesture before
// xterm receives the original.
// - mousedown (button 2 + Shift / fullscreen-apps menu): keep the press
// local so the terminal app does not also receive it
// - contextmenu: xterm.js calls textarea.select() which steals focus
// - mouseup (button 2): tmux interprets the right-button release as a
// dismiss action, closing the popup menu immediately after it appears
// Both are intercepted at the capture phase before xterm.js's own listeners.
// - mouseup (button 2): only stop when we also claimed the matching
// mousedown. App-owned right-clicks (Herdr, tmux, vim, ...) must receive
// the release or the TUI stays stuck with the button held (#2721).
// Intercepted at the capture phase before xterm.js's own listeners.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const rightClickPressClaim = createRightClickMouseTrackingPressClaim();
const handleContextMenuCapture = (e: MouseEvent) => {
if (!shouldInterceptMouseTrackingContextMenu({
event: e,
@@ -1601,7 +1605,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
};
const handleMouseDownCapture = (e: MouseEvent) => {
if (shouldStopShiftRightClickMouseTrackingMouseDown({
if (rightClickPressClaim.noteMouseDown({
event: e,
mouseTracking: mouseTrackingRef.current,
status: statusRef.current,
@@ -1630,7 +1634,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
};
const handleMouseUpCapture = (e: MouseEvent) => {
if (e.button === 2 && mouseTrackingRef.current && statusRef.current === 'connected') {
if (shouldStopRightClickMouseTrackingMouseUp({
event: e,
claimedMatchingMouseDown: rightClickPressClaim.consumeMouseUpClaim(e),
})) {
e.stopImmediatePropagation();
}
};
@@ -554,6 +554,7 @@ export interface TerminalLayerProps {
onUpdateHost: (host: Host) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
onCommandExecuted?: (command: string, hostId: string, hostLabel: string, sessionId: string) => void;
onDeleteShellHistoryEntry?: (entryId: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
@@ -431,6 +431,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
FolderTree: s.FolderTree,
followAppTerminalTheme: s.followAppTerminalTheme,
handleHistoryPaste: s.handleHistoryPaste,
handleHistoryDelete: s.handleHistoryDelete,
handleHistoryRun: s.handleHistoryRun,
handleFocusSidePanelPane: s.handleFocusSidePanelPane,
handleSplitSidePanelPane: s.handleSplitSidePanelPane,
@@ -474,6 +475,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
handleSessionExit: s.handleSessionExit,
handleSftpCurrentPathChange: s.handleSftpCurrentPathChange,
handleSftpActiveTransfersChange: s.handleSftpActiveTransfersChange,
handleSftpActiveExternalEditsChange: s.handleSftpActiveExternalEditsChange,
handleSftpInitialLocationApplied: s.handleSftpInitialLocationApplied,
persistSidePanelWidth: s.persistSidePanelWidth,
setSidePanelWidth: s.setSidePanelWidth,
@@ -55,27 +55,54 @@ function task(
}
test("closing the panel keeps SFTP mounted while a transfer is active", () => {
assert.equal(shouldKeepSftpMountedAfterClose(1), true);
assert.equal(shouldKeepSftpMountedAfterClose(3), true);
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 1 }), true);
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 3 }), true);
});
test("closing the panel keeps SFTP mounted while an external editor temp is open", () => {
assert.equal(shouldKeepSftpMountedAfterClose({
activeTransfersCount: 0,
activeExternalEditCount: 1,
}), true);
assert.equal(shouldKeepSftpMountedAfterClose({
activeTransfersCount: 0,
activeExternalEditCount: 0,
}), false);
});
test("closing an idle panel still releases its SFTP state", () => {
assert.equal(shouldKeepSftpMountedAfterClose(0), false);
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 0 }), false);
});
test("a transfer retained by close keeps its history after completion", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
activeExternalEditCount: 0,
panelOpen: false,
retainedAfterClose: true,
}), false);
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
activeTransfersCount: 0,
activeExternalEditCount: 0,
retainedAfterClose: true,
}), true);
assert.ok(SFTP_TRANSFER_HISTORY_RETENTION_MS > 0);
});
test("retained cleanup waits while an external editor temp is still open", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
activeExternalEditCount: 1,
panelOpen: false,
retainedAfterClose: false,
}), false);
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
activeTransfersCount: 0,
activeExternalEditCount: 1,
retainedAfterClose: true,
}), false);
});
test("retained cleanup is scheduled even if close state has not committed yet", () => {
assert.equal(shouldScheduleSftpRetainedPanelCleanup({
activeTransfersCount: 0,
@@ -108,6 +135,7 @@ test("closing a terminal tab keeps its hidden SFTP owner mounted until active tr
test("a reopening panel is not cleared before its open state commits", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
activeExternalEditCount: 0,
panelOpen: true,
retainedAfterClose: false,
}), false);
@@ -116,6 +144,7 @@ test("a reopening panel is not cleared before its open state commits", () => {
test("an unretained hidden idle panel can be released", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
activeExternalEditCount: 0,
panelOpen: false,
retainedAfterClose: false,
}), true);
@@ -141,9 +170,13 @@ test("store unfinished tasks retain the owner even when the panel report is stil
storeTasks,
ownerId,
}), 1);
assert.equal(shouldKeepSftpMountedAfterClose(
resolveSftpActiveTransfersCount({ reportedCount: 0, storeTasks, ownerId }),
), true);
assert.equal(shouldKeepSftpMountedAfterClose({
activeTransfersCount: resolveSftpActiveTransfersCount({
reportedCount: 0,
storeTasks,
ownerId,
}),
}), true);
});
test("paused and failed top-level tasks still retain the hidden SFTP owner", () => {
@@ -183,15 +216,22 @@ test("terminal side panel reports transfer activity and uses store-backed retain
assert.match(panelSource, /surfaceVisible:\s*isVisible/);
assert.match(panelSource, /useEditorTabPresenceRevision\(\)/);
assert.match(panelSource, /hasOwnedEditorTab/);
assert.match(panelSource, /hasActiveExternalEdit/);
assert.match(slotsSource, /onActiveTransfersChange=\{handleActiveTransfersChange\}/);
assert.match(slotsSource, /onActiveExternalEditsChange=\{handleActiveExternalEditsChange\}/);
assert.match(panelSource, /onActiveExternalEditsChange/);
assert.match(layerSource, /resolveTabActiveTransfersCount/);
assert.match(layerSource, /terminalSftpTransferOwnerId/);
assert.match(layerSource, /listTerminalTabIdsWithRetainingTransfers/);
assert.match(layerSource, /shouldKeepSftpMountedAfterClose\(activeTransfersCount\)/);
assert.match(layerSource, /shouldKeepSftpMountedAfterClose\(\{/);
assert.match(layerSource, /activeExternalEditCount/);
assert.match(layerSource, /sftpActiveExternalEditsByTabRef/);
assert.match(layerSource, /sftpRetainedAfterCloseTabIdsRef/);
assert.match(layerSource, /sftpRetainedCleanupTimersRef/);
// Hidden UI parks browse channels; transfers keep pool / leased sessions.
// External editor temps must also block park (closeSftp deletes those files).
assert.match(stateSource, /shouldParkBrowseSessions/);
assert.match(stateSource, /activeExternalEditCount/);
assert.match(stateSource, /takeBrowseSessionsForClose/);
assert.match(stateSource, /shouldRestoreBrowseSessions/);
});
+11 -2
View File
@@ -56,8 +56,13 @@ export function listTerminalTabIdsWithRetainingTransfers(
return [...tabIds];
}
export function shouldKeepSftpMountedAfterClose(activeTransfersCount: number): boolean {
return activeTransfersCount > 0;
export function shouldKeepSftpMountedAfterClose(params: {
activeTransfersCount: number;
/** External-editor temps still need the browse session (closeSftp deletes them). */
activeExternalEditCount?: number;
}): boolean {
return params.activeTransfersCount > 0
|| (params.activeExternalEditCount ?? 0) > 0;
}
export function shouldCloseSftpSidePanel(params: {
@@ -74,19 +79,23 @@ export function shouldCloseSftpSidePanel(params: {
export function shouldClearSftpPanelAfterTransferChange(params: {
activeTransfersCount: number;
activeExternalEditCount?: number;
panelOpen: boolean;
retainedAfterClose: boolean;
}): boolean {
return params.activeTransfersCount <= 0
&& (params.activeExternalEditCount ?? 0) <= 0
&& !params.panelOpen
&& !params.retainedAfterClose;
}
export function shouldScheduleSftpRetainedPanelCleanup(params: {
activeTransfersCount: number;
activeExternalEditCount?: number;
retainedAfterClose: boolean;
}): boolean {
return params.activeTransfersCount <= 0
&& (params.activeExternalEditCount ?? 0) <= 0
&& params.retainedAfterClose;
}
@@ -78,6 +78,7 @@ function SidePanelSftpSlotInner({
handleSftpInitialLocationApplied,
handleSftpCurrentPathChange,
handleSftpActiveTransfersChange,
handleSftpActiveExternalEditsChange,
handlePendingUploadHandled,
sftpDoubleClickBehavior,
sftpAutoSync,
@@ -157,6 +158,13 @@ function SidePanelSftpSlotInner({
[handleSftpActiveTransfersChange, tabId],
);
const handleActiveExternalEditsChange = useCallback(
(count: number) => {
handleSftpActiveExternalEditsChange(tabId, count);
},
[handleSftpActiveExternalEditsChange, tabId],
);
return (
<div className={sidePanelHiddenPanelClassName(!isVisible)}>
<SftpSidePanel
@@ -176,6 +184,7 @@ function SidePanelSftpSlotInner({
onInitialLocationApplied={handleInitialLocationApplied}
onCurrentPathChange={handleCurrentPathChange}
onActiveTransfersChange={handleActiveTransfersChange}
onActiveExternalEditsChange={handleActiveExternalEditsChange}
showWorkspaceHostHeader={isVisible && !!live.activeWorkspace}
isVisible={isVisible}
renderOverlays={isVisible}
@@ -447,6 +456,7 @@ function SidePanelHistorySlotInner({
const {
HistorySidePanel,
handleHistoryPaste,
handleHistoryDelete,
handleHistoryRun,
} = ctx;
@@ -460,6 +470,7 @@ function SidePanelHistorySlotInner({
state={remoteHistory.getState(live.focusedHost?.id, live.historySessionId)}
globalEntries={shellHistory as import('../../domain/models').ShellHistoryEntry[]}
onFetch={remoteHistory.fetch}
onDeleteGlobalEntry={handleHistoryDelete}
onPasteToTerminal={handleHistoryPaste}
onRunInTerminal={handleHistoryRun}
isVisible
@@ -232,6 +232,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'systemMountedTabIds',
'themeMountedTabIds',
'handleHistoryPaste',
'handleHistoryDelete',
'handleHistoryRun',
'handleOpenHistory',
'HistorySidePanel',
@@ -259,6 +260,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'sftpPendingUploadsForTab',
'handleSftpCurrentPathChange',
'handleSftpActiveTransfersChange',
'handleSftpActiveExternalEditsChange',
'sftpDoubleClickBehavior',
'sftpAutoSync',
'sftpShowHiddenFiles',
+1
View File
@@ -63,6 +63,7 @@ export const terminalLayerAreEqual = (
prev.onUpdateSessionRestoreCwd === next.onUpdateSessionRestoreCwd &&
prev.onUpdateHost === next.onUpdateHost &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.onDeleteShellHistoryEntry === next.onDeleteShellHistoryEntry &&
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
@@ -1,10 +1,21 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { isSessionReconnectDisabled } from './SessionTabContextMenuContent';
const source = readFileSync(fileURLToPath(new URL('./SessionTabContextMenuContent.tsx', import.meta.url)), 'utf8');
test('reconnect is disabled while a session is still connecting', () => {
assert.equal(isSessionReconnectDisabled('connecting'), true);
assert.equal(isSessionReconnectDisabled('connected'), false);
assert.equal(isSessionReconnectDisabled('disconnected'), false);
});
test('session tab menu exposes optional edit-host when a vault host is present', () => {
assert.match(source, /editHost\?: Host/);
assert.match(source, /onEditHost\?: \(host: Host\) => void/);
assert.match(source, /editHost && onEditHost/);
assert.match(source, /terminal\.layer\.hostTree\.editHost/);
});
@@ -1,7 +1,7 @@
import React from 'react';
import type { useI18n } from '../../application/i18n/I18nProvider';
import type { TerminalSession } from '../../types';
import type { Host, TerminalSession } from '../../types';
import { ContextMenuContent, ContextMenuItem } from '../ui/context-menu';
type TranslateFn = ReturnType<typeof useI18n>['t'];
@@ -15,6 +15,9 @@ interface SessionTabContextMenuContentProps {
onReconnectSession: (sessionId: string) => void;
sessionStatus: TerminalSession['status'];
onRenameSession: (sessionId: string) => void;
/** Vault host for this session; omit edit when missing (e.g. local shell). */
editHost?: Host;
onEditHost?: (host: Host) => void;
renderBulkCloseItems?: (anchorId: string) => React.ReactNode;
t: TranslateFn;
}
@@ -28,6 +31,8 @@ export function SessionTabContextMenuContent({
onReconnectSession,
sessionStatus,
onRenameSession,
editHost,
onEditHost,
renderBulkCloseItems,
t,
}: SessionTabContextMenuContentProps) {
@@ -42,6 +47,11 @@ export function SessionTabContextMenuContent({
<ContextMenuItem onClick={() => onRenameSession(sessionId)}>
{t('common.rename')}
</ContextMenuItem>
{editHost && onEditHost && (
<ContextMenuItem onClick={() => onEditHost(editHost)}>
{t('terminal.layer.hostTree.editHost')}
</ContextMenuItem>
)}
{onCopySession && (
<ContextMenuItem onClick={() => onCopySession(sessionId)}>
{t('tabs.copyTab')}
+4
View File
@@ -683,6 +683,7 @@ interface SessionTopTabProps {
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onCopySessionToNewWindow: (sessionId: string) => void;
onEditHost?: (host: Host) => void;
renderBulkCloseItems: RenderBulkCloseItems;
dynamicTabTitleMode?: DynamicTabTitleMode;
t: TranslateFn;
@@ -706,6 +707,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
onRenameSession,
onCopySession,
onCopySessionToNewWindow,
onEditHost,
renderBulkCloseItems,
dynamicTabTitleMode,
t,
@@ -833,6 +835,8 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
onReconnectSession={terminalReconnectRegistry.request}
sessionStatus={session.status}
onRenameSession={onRenameSession}
editHost={host}
onEditHost={onEditHost}
renderBulkCloseItems={renderBulkCloseItems}
t={t}
/>
+2 -1
View File
@@ -1373,6 +1373,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
groupConfigs={groupConfigs}
snippets={snippets}
onSnippetsChange={onUpdateSnippets}
onHostsChange={onUpdateHosts}
onImportKey={onImportOrReuseKey}
onSave={(host) => {
const latestHost = hosts.find(
@@ -1383,7 +1384,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
openedHost: editingHost,
latestHost,
});
onUpdateHosts(upsertHostById(hosts, nextHost));
onUpdateHosts((prevHosts) => upsertHostById(prevHosts, nextHost));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
+10 -4
View File
@@ -28,6 +28,8 @@ import { sanitizeHost } from "../../domain/host";
import {
applyVaultImportDestination,
applyVaultHostImport,
buildVaultHostEndpointKey,
buildVaultHostMergeKey,
filterVaultImportKeyPassphrasesAgainstExisting,
mergeVaultImportIssues,
resolveVaultImportKeyPassphraseConflicts,
@@ -179,6 +181,9 @@ export function useVaultImportHandlers({
result = applyVaultImportDestination(
result,
options?.destination ?? { mode: "preserve" },
// SecureCRT keeps distinct session files that share an endpoint;
// only rewrite their group when the user picks an import location.
{ collapseDuplicateEndpoints: format !== "securecrt" },
);
}
updateProgress({ stage: "preparing", percent: 70 });
@@ -308,15 +313,16 @@ export function useVaultImportHandlers({
}
}
const makeKey = (h: Host) =>
`${(h.protocol ?? "ssh").toLowerCase()}|${h.hostname.toLowerCase()}|${h.port}|${(h.username ?? "").toLowerCase()}`;
// Managed ssh_config rematch is endpoint-only; CSV/other imports treat
// group as part of session identity so direct vs proxy copies can coexist.
const makeKey = isManaged ? buildVaultHostEndpointKey : buildVaultHostMergeKey;
const existingKeys = new Set(currentHosts.map(makeKey));
// Filter out duplicates for both managed and non-managed imports
let newHosts = format === "securecrt"
? result.hosts
: result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
// For managed imports, also update existing hosts to be managed
let updatedExistingHosts: Host[] = [];
if (isManaged) {
+29 -1
View File
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
import {
mergeGlobalHistoryOnAppend,
removeGlobalHistoryEntry,
sanitizeGlobalHistoryEntries,
shouldRemoveAutocompleteHistoryEntry,
shouldRecordGlobalHistoryCommand,
toGlobalHistoryDisplayEntries,
} from './globalHistory.ts';
@@ -87,6 +89,32 @@ test('toGlobalHistoryDisplayEntries: maps host labels', () => {
baseEntry({ command: 'htop', hostLabel: 'prod' }),
]);
assert.deepEqual(out, [
{ id: 'id-1', command: 'htop', timestamp: 1000, hostLabel: 'prod' },
{ id: 'id-1', command: 'htop', hostId: 'host-1', timestamp: 1000, hostLabel: 'prod' },
]);
});
test('removeGlobalHistoryEntry: removes only the requested record', () => {
const entries = [
baseEntry({ id: 'first', command: 'bad-command' }),
baseEntry({ id: 'second', command: 'keep-command' }),
];
assert.deepEqual(
removeGlobalHistoryEntry(entries, 'first').map((entry) => entry.id),
['second'],
);
assert.equal(removeGlobalHistoryEntry(entries, 'missing'), entries);
});
test('shouldRemoveAutocompleteHistoryEntry: keeps autocomplete while a duplicate row remains', () => {
const entries = [
baseEntry({ id: 'first', command: 'bad-command' }),
baseEntry({ id: 'second', command: 'bad-command' }),
baseEntry({ id: 'other-host', command: 'bad-command', hostId: 'host-2' }),
];
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'first'), false);
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'second'), false);
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'other-host'), true);
assert.equal(shouldRemoveAutocompleteHistoryEntry(entries, 'missing'), false);
});
+25
View File
@@ -26,6 +26,29 @@ export function sanitizeGlobalHistoryEntries(
return entries.filter((entry) => shouldRecordGlobalHistoryCommand(entry.command));
}
/** Remove one persisted global history record by its stable id. */
export function removeGlobalHistoryEntry(
entries: ShellHistoryEntry[],
entryId: string,
): ShellHistoryEntry[] {
if (!entries.some((entry) => entry.id === entryId)) return entries;
return entries.filter((entry) => entry.id !== entryId);
}
/** True when deleting a global row should also remove its autocomplete entry. */
export function shouldRemoveAutocompleteHistoryEntry(
entries: ShellHistoryEntry[],
entryId: string,
): boolean {
const entry = entries.find((candidate) => candidate.id === entryId);
if (!entry) return false;
return !entries.some((candidate) => (
candidate.id !== entryId
&& candidate.command === entry.command
&& candidate.hostId === entry.hostId
));
}
/**
* Append one command to global history: trim, drop noise, and de-dupe the most
* recent identical command by bumping its timestamp instead of adding a row.
@@ -63,6 +86,7 @@ export function mergeGlobalHistoryOnAppend(
export interface GlobalHistoryDisplayEntry {
id: string;
command: string;
hostId: string;
timestamp: number;
hostLabel?: string;
}
@@ -74,6 +98,7 @@ export function toGlobalHistoryDisplayEntries(
return sanitizeGlobalHistoryEntries(entries).map((entry) => ({
id: entry.id,
command: entry.command,
hostId: entry.hostId,
timestamp: entry.timestamp,
hostLabel: entry.hostLabel,
}));
+164
View File
@@ -3,6 +3,8 @@ import test from 'node:test';
import type { Host, Snippet } from './models';
import {
appendHostConnectScript,
ensureHostConnectScriptIds,
getEditableHostConnectScriptIds,
getGlobalConnectScripts,
getHostConnectScriptIds,
hasHostConnectAutomation,
@@ -13,6 +15,7 @@ import {
shouldMarkConnectAutomationConsumed,
shouldUseFreshSshConnectionForAutomation,
syncHostsForSnippetTargetChange,
syncSnippetsForHostConnectQueueSave,
} from './hostConnectScripts.ts';
const host: Host = {
@@ -131,6 +134,167 @@ test('append updates host connectScriptIds order', () => {
assert.deepEqual(getHostConnectScriptIds(next, snippets), ['a', 'b']);
});
test('append keeps default manual scripts in the editable host queue', () => {
const snippets = [
script({ id: 'reset', label: 'reset-password', trigger: 'manual' }),
script({ id: 'teest', label: 'teest', trigger: 'manual' }),
];
let next = appendHostConnectScript(host, 'reset', snippets);
assert.deepEqual(getEditableHostConnectScriptIds(next, snippets), ['reset']);
// Runtime still ignores non-onConnect until host save promotes the trigger.
assert.deepEqual(getHostConnectScriptIds(next, snippets), []);
next = appendHostConnectScript(next, 'teest', snippets);
assert.deepEqual(getEditableHostConnectScriptIds(next, snippets), ['reset', 'teest']);
assert.deepEqual(next.connectScriptIds, ['reset', 'teest']);
});
test('ensureHostConnectScriptIds preserves pending manual queue entries while editing', () => {
const snippets = [script({ id: 'reset', trigger: 'manual' })];
const draft = { ...host, connectScriptIds: ['reset', 'missing'] };
const ensured = ensureHostConnectScriptIds(draft, snippets);
assert.deepEqual(ensured.connectScriptIds, ['reset']);
assert.deepEqual(getEditableHostConnectScriptIds(ensured, snippets), ['reset']);
assert.deepEqual(getHostConnectScriptIds(ensured, snippets), []);
});
test('syncSnippetsForHostConnectQueueSave promotes already-persisted manual queue entries', () => {
const snippets = [
script({ id: 'reset', trigger: 'manual', targets: [] }),
script({ id: 'ready', trigger: 'onConnect', targets: ['host-a'] }),
];
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
snippets,
'host-a',
['reset', 'ready'],
['reset', 'ready'],
);
assert.equal(changed, true);
assert.equal(next.find((item) => item.id === 'reset')?.trigger, 'onConnect');
assert.deepEqual(next.find((item) => item.id === 'reset')?.targets, ['host-a']);
assert.equal(next.find((item) => item.id === 'ready'), snippets[1]);
assert.deepEqual(connectScriptIds, ['reset', 'ready']);
});
test('syncSnippetsForHostConnectQueueSave leaves global onConnect scripts untouched', () => {
const global = script({ id: 'both', targetsAllHosts: true, targets: ['host-a'] });
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
[global],
'host-a',
['both'],
['both'],
);
assert.equal(changed, false);
assert.equal(next[0], global);
assert.equal(next[0].targetsAllHosts, true);
assert.deepEqual(connectScriptIds, ['both']);
});
test('syncSnippetsForHostConnectQueueSave promotes global manual without clearing targetsAllHosts', () => {
const globalManual = script({
id: 'everywhere',
trigger: 'manual',
targetsAllHosts: true,
});
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
[globalManual],
'host-a',
['everywhere'],
['everywhere'],
);
assert.equal(changed, true);
assert.equal(next[0].trigger, 'onConnect');
assert.equal(next[0].targetsAllHosts, true);
assert.deepEqual(connectScriptIds, ['everywhere']);
});
test('syncSnippetsForHostConnectQueueSave does not re-promote concurrently demoted scripts', () => {
const baseline = [script({ id: 'run', trigger: 'onConnect', targets: ['host-a'] })];
const demoted = [script({ id: 'run', trigger: 'manual', targets: ['host-a'] })];
const { snippets: next, changed, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
demoted,
'host-a',
['run'],
['run'],
{ baselineSnippets: baseline },
);
assert.equal(changed, true);
assert.equal(next[0].trigger, 'manual');
assert.deepEqual(connectScriptIds, []);
});
test('syncSnippetsForHostConnectQueueSave preserves concurrent non-onConnect trigger edits', () => {
const baseline = [script({ id: 'run', trigger: 'manual', targets: ['host-a'] })];
const retargeted = [script({ id: 'run', trigger: 'onOutput', triggerPattern: 'ERR', targets: ['host-a'] })];
const { snippets: next, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
retargeted,
'host-a',
['run'],
['run'],
{ baselineSnippets: baseline },
);
assert.equal(next[0].trigger, 'onOutput');
assert.equal(next[0].triggerPattern, 'ERR');
assert.deepEqual(connectScriptIds, []);
});
test('syncSnippetsForHostConnectQueueSave preserves concurrent target removals', () => {
const baseline = [script({ id: 'run', trigger: 'onConnect', targets: ['host-a', 'host-b'] })];
const unlinked = [script({ id: 'run', trigger: 'onConnect', targets: ['host-b'] })];
const { snippets: next, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
unlinked,
'host-a',
['run'],
['run'],
{ baselineSnippets: baseline },
);
assert.deepEqual(next[0].targets, ['host-b']);
assert.deepEqual(connectScriptIds, []);
});
test('syncSnippetsForHostConnectQueueSave preserves concurrent target removals for manual scripts', () => {
const baseline = [script({ id: 'run', trigger: 'manual', targets: ['host-a', 'host-b'] })];
const unlinked = [script({ id: 'run', trigger: 'manual', targets: ['host-b'] })];
const { snippets: next, connectScriptIds } = syncSnippetsForHostConnectQueueSave(
unlinked,
'host-a',
['run'],
['run'],
{ baselineSnippets: baseline },
);
assert.equal(next[0].trigger, 'manual');
assert.deepEqual(next[0].targets, ['host-b']);
assert.deepEqual(connectScriptIds, []);
});
test('syncSnippetsForHostConnectQueueSave syncs other targeted hosts after promote', () => {
const hostB: Host = {
id: 'host-b',
label: 'B',
hostname: 'b.example',
username: 'root',
os: 'linux',
protocol: 'ssh',
tags: [],
connectScriptIds: [],
};
const snippets = [
script({ id: 'shared', trigger: 'manual', targets: ['host-b'] }),
];
const { snippets: next, hosts, changed } = syncSnippetsForHostConnectQueueSave(
snippets,
'host-a',
[],
['shared'],
{ hosts: [host, hostB] },
);
assert.equal(changed, true);
assert.equal(next[0].trigger, 'onConnect');
assert.deepEqual(next[0].targets, ['host-b', 'host-a']);
assert.deepEqual(hosts?.find((item) => item.id === 'host-b')?.connectScriptIds, ['shared']);
});
test('syncHostsForSnippetTargetChange appends and removes queue entries', () => {
const snippets = [script({ id: 'run', targets: ['host-a'], trigger: 'onConnect' })];
const hosts = syncHostsForSnippetTargetChange(
+202 -4
View File
@@ -4,6 +4,7 @@ import {
getScriptsLinkedToHost,
linkHostToScript,
snippetAppliesToHost,
unlinkHostFromScripts,
} from './snippetTargets.ts';
import { sortByVaultOrder } from './vaultOrder.ts';
@@ -28,6 +29,23 @@ function pruneConnectScriptIds(ids: string[], snippets: Snippet[]): string[] {
return result;
}
/**
* Draft/edit queue prune: keep any existing script IDs.
* Host save promotes non-onConnect scripts via prepareSnippetForHostConnectQueue.
*/
function pruneEditableConnectScriptIds(ids: string[], snippets: Snippet[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const id of ids) {
if (!id || seen.has(id)) continue;
const snippet = scriptById(snippets, id);
if (!snippet) continue;
seen.add(id);
result.push(id);
}
return result;
}
/** Global onConnect scripts (targetsAllHosts), sorted by vault order. */
export function getGlobalConnectScripts(snippets: Snippet[]): Snippet[] {
return sortByVaultOrder(
@@ -77,9 +95,20 @@ export function getHostConnectScriptIds(host: Host, snippets: Snippet[]): string
return migrateHostConnectScriptIds(host, snippets);
}
/**
* Host-details draft queue: includes scripts pending promote-to-onConnect on save.
* Runtime connect still uses getHostConnectScriptIds (onConnect only).
*/
export function getEditableHostConnectScriptIds(host: Host, snippets: Snippet[]): string[] {
if (host.connectScriptIds !== undefined) {
return pruneEditableConnectScriptIds(host.connectScriptIds, snippets);
}
return migrateHostConnectScriptIds(host, snippets);
}
export function ensureHostConnectScriptIds(host: Host, snippets: Snippet[]): Host {
if (host.connectScriptIds !== undefined) {
const pruned = pruneConnectScriptIds(host.connectScriptIds, snippets);
const pruned = pruneEditableConnectScriptIds(host.connectScriptIds, snippets);
if (pruned.length === host.connectScriptIds.length
&& pruned.every((id, index) => id === host.connectScriptIds![index])) {
return host;
@@ -164,7 +193,7 @@ export function shouldMarkConnectAutomationConsumed(options: {
export function appendHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
const snippet = scriptById(snippets, scriptId);
if (!snippet) return host;
const current = getHostConnectScriptIds(host, snippets);
const current = getEditableHostConnectScriptIds(host, snippets);
if (current.includes(scriptId)) {
return { ...host, connectScriptIds: current };
}
@@ -172,7 +201,7 @@ export function appendHostConnectScript(host: Host, scriptId: string, snippets:
}
export function removeHostConnectScript(host: Host, scriptId: string, snippets: Snippet[]): Host {
const current = getHostConnectScriptIds(host, snippets);
const current = getEditableHostConnectScriptIds(host, snippets);
const next = current.filter((id) => id !== scriptId);
return { ...host, connectScriptIds: next };
}
@@ -185,7 +214,7 @@ export function reorderHostConnectScript(
snippets: Snippet[],
): Host {
if (draggedScriptId === targetScriptId) return host;
const current = [...getHostConnectScriptIds(host, snippets)];
const current = [...getEditableHostConnectScriptIds(host, snippets)];
const fromIndex = current.indexOf(draggedScriptId);
const targetIndex = current.indexOf(targetScriptId);
if (fromIndex === -1 || targetIndex === -1) return host;
@@ -200,12 +229,181 @@ export function reorderHostConnectScript(
export function prepareSnippetForHostConnectQueue(snippet: Snippet, hostId: string): Snippet {
if (!isScriptSnippet(snippet)) return snippet;
if (snippet.targetsAllHosts) {
return snippet.trigger === 'onConnect'
? snippet
: { ...snippet, trigger: 'onConnect' };
}
return {
...linkHostToScript(snippet, hostId),
trigger: 'onConnect',
};
}
function connectQueueSnippetNeedsPromote(snippet: Snippet, hostId: string): boolean {
if (!isScriptSnippet(snippet)) return false;
if (snippet.trigger !== 'onConnect') return true;
if (snippet.targetsAllHosts) return false;
return !snippetAppliesToHost(snippet, hostId);
}
function snippetTargetsEqual(left: Snippet, right: Snippet): boolean {
if (Boolean(left.targetsAllHosts) !== Boolean(right.targetsAllHosts)) return false;
const leftTargets = left.targets ?? [];
const rightTargets = right.targets ?? [];
if (leftTargets.length !== rightTargets.length) return false;
return leftTargets.every((id, index) => id === rightTargets[index]);
}
/**
* After promoting a script to onConnect, ensure every remaining target host with an
* explicit connectScriptIds queue includes it. Hosts without an explicit queue still
* pick the script up via migrateHostConnectScriptIds.
*/
export function ensureTargetHostsHaveConnectScript(
hosts: Host[],
snippet: Snippet,
snippets: Snippet[],
excludeHostId?: string,
): Host[] {
if (!snippet.id || !isScriptSnippet(snippet) || snippet.trigger !== 'onConnect') return hosts;
if (snippet.targetsAllHosts) return hosts;
const targetIds = new Set(snippet.targets ?? []);
if (targetIds.size === 0) return hosts;
let changed = false;
const nextHosts = hosts.map((host) => {
if (host.id === excludeHostId) return host;
if (!targetIds.has(host.id)) return host;
if (host.connectScriptIds === undefined) return host;
const updated = appendHostConnectScript(host, snippet.id!, snippets);
if (updated !== host) changed = true;
return updated;
});
return changed ? nextHosts : hosts;
}
export type SyncHostConnectQueueSaveOptions = {
/** Snippets snapshot from when the host editor opened (detect concurrent demotion). */
baselineSnippets?: Snippet[];
/** When provided, promote also syncs other hosts that remain in targets. */
hosts?: Host[];
};
/**
* Sync script metadata when saving a host connect queue.
* Promotes draft/manual queue entries, preserves global onConnect scripts,
* drops concurrently demoted entries, and optionally syncs peer host queues.
*/
export function syncSnippetsForHostConnectQueueSave(
snippets: Snippet[],
hostId: string,
previousQueueIds: string[],
nextQueueIds: string[],
options: SyncHostConnectQueueSaveOptions = {},
): {
snippets: Snippet[];
hosts: Host[];
connectScriptIds: string[];
changed: boolean;
} {
const previousSet = new Set(previousQueueIds);
const baseline = options.baselineSnippets ?? snippets;
let nextSnippets = snippets;
let nextHosts = options.hosts ?? [];
let changed = false;
const retainedIds: string[] = [];
const demotedDropIds = new Set<string>();
for (const scriptId of nextQueueIds) {
const item = nextSnippets.find((entry) => entry.id === scriptId && isScriptSnippet(entry));
if (!item) continue;
if (!connectQueueSnippetNeedsPromote(item, hostId)) {
retainedIds.push(scriptId);
continue;
}
const newlyAdded = !previousSet.has(scriptId);
const baselineItem = baseline.find((entry) => entry.id === scriptId);
const baselineTrigger = baselineItem && isScriptSnippet(baselineItem)
? baselineItem.trigger
: undefined;
if (!newlyAdded && baselineTrigger === 'onConnect' && item.trigger !== 'onConnect') {
// Concurrent demotion while the editor stayed open: keep demotion, drop stale queue id.
demotedDropIds.add(scriptId);
continue;
}
if (
!newlyAdded
&& baselineItem
&& isScriptSnippet(baselineItem)
&& (Boolean(baselineItem.targetsAllHosts) || snippetAppliesToHost(baselineItem, hostId))
&& !(Boolean(item.targetsAllHosts) || snippetAppliesToHost(item, hostId))
) {
// Concurrent target removal for this host: do not re-link on save.
demotedDropIds.add(scriptId);
continue;
}
if (
!newlyAdded
&& baselineTrigger
&& baselineTrigger !== 'onConnect'
&& item.trigger !== baselineTrigger
) {
// Concurrent non-onConnect trigger edit (e.g. manual -> onOutput): do not overwrite.
demotedDropIds.add(scriptId);
continue;
}
const prepared = prepareSnippetForHostConnectQueue(item, hostId);
if (
prepared.trigger !== item.trigger
|| !snippetTargetsEqual(prepared, item)
) {
nextSnippets = nextSnippets.map((entry) => (entry.id === scriptId ? prepared : entry));
changed = true;
if (options.hosts) {
const syncedHosts = ensureTargetHostsHaveConnectScript(
nextHosts,
prepared,
nextSnippets,
hostId,
);
if (syncedHosts !== nextHosts) {
nextHosts = syncedHosts;
changed = true;
}
}
}
retainedIds.push(scriptId);
}
for (const scriptId of previousQueueIds) {
if (retainedIds.includes(scriptId)) continue;
if (demotedDropIds.has(scriptId)) continue;
const unlinked = unlinkHostFromScripts(nextSnippets, hostId, scriptId);
if (unlinked !== nextSnippets) {
nextSnippets = unlinked;
changed = true;
}
}
if (
retainedIds.length !== nextQueueIds.length
|| retainedIds.some((id, index) => id !== nextQueueIds[index])
) {
changed = true;
}
return {
snippets: nextSnippets,
hosts: nextHosts,
connectScriptIds: retainedIds,
changed,
};
}
export function syncHostsForSnippetTargetChange(
hosts: Host[],
snippet: Snippet,
+23
View File
@@ -22,6 +22,12 @@ export type MiddleClickBehavior = 'context-menu' | 'paste' | 'disabled';
export type LinkModifier = 'none' | 'ctrl' | 'alt' | 'meta';
export type TerminalEmulationType = 'xterm-256color' | 'xterm-16color' | 'xterm';
export type DynamicTabTitleMode = 'off' | 'agent' | 'all';
/**
* What the terminal host info bar shows as its primary title (#2708).
* - address: user@host:port when available (historical default)
* - label: vault host label / display name
*/
export type HostInfoBarTitleMode = 'address' | 'label';
/**
* How to assist when a sudo/su password prompt appears (#2156).
* - off: no assist
@@ -129,6 +135,8 @@ export interface TerminalSettings {
// Server Stats Display (Linux only)
showHostInfoBar: boolean; // Show host identity and server stats above the terminal
/** Primary title in the host info bar: connection address or vault label. */
hostInfoBarTitleMode: HostInfoBarTitleMode;
showServerStats: boolean; // Show CPU/Memory/Disk in terminal statusbar
serverStatsRefreshInterval: number; // Seconds between stats refresh (default: 30)
@@ -141,6 +149,11 @@ export interface TerminalSettings {
// Paste
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
// When true, pasting while the clipboard holds an image automatically runs
// the "Upload clipboard image" action (SFTP upload + remote-path paste)
// instead of falling back to a text paste. Remote SSH sessions only.
autoUploadClipboardImageOnPaste: boolean;
// Shell `clear` command behavior — controls whether CSI 3 J (erase scrollback)
// from the shell is honored. Default true matches POSIX/ncurses since 2013:
// `clear` clears both visible screen and scrollback. Disable to keep history
@@ -331,6 +344,11 @@ const isDynamicTabTitleMode = (value: unknown): value is DynamicTabTitleMode =>
value === 'all'
);
const isHostInfoBarTitleMode = (value: unknown): value is HostInfoBarTitleMode => (
value === 'address' ||
value === 'label'
);
const isPasswordPromptAssistMode = (value: unknown): value is PasswordPromptAssistMode => (
value === 'off' ||
value === 'hint' ||
@@ -362,6 +380,9 @@ export const normalizeTerminalSettings = (
dynamicTabTitleMode: isDynamicTabTitleMode(settings?.dynamicTabTitleMode)
? settings.dynamicTabTitleMode
: DEFAULT_TERMINAL_SETTINGS.dynamicTabTitleMode,
hostInfoBarTitleMode: isHostInfoBarTitleMode(settings?.hostInfoBarTitleMode)
? settings.hostInfoBarTitleMode
: DEFAULT_TERMINAL_SETTINGS.hostInfoBarTitleMode,
passwordPromptAssist: isPasswordPromptAssistMode(settings?.passwordPromptAssist)
? settings.passwordPromptAssist
: DEFAULT_TERMINAL_SETTINGS.passwordPromptAssist,
@@ -459,6 +480,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
x11Display: '', // Empty = use DISPLAY/default local X server
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
showHostInfoBar: true, // Preserve the existing host information bar by default
hostInfoBarTitleMode: 'address', // Historical default: prefer user@host:port
showServerStats: true, // Show server stats by default
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
systemManagerProcessRefreshInterval: 3,
@@ -466,6 +488,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
systemManagerDockerListRefreshInterval: 5,
systemManagerDockerStatsRefreshInterval: 3,
disableBracketedPaste: false, // Bracketed paste enabled by default
autoUploadClipboardImageOnPaste: false, // Opt-in: image in clipboard auto-uploads on paste (remote sessions)
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
forcePromptNewLine: false, // Opt-in: keep the next shell prompt visually separated from unterminated final output lines
+315
View File
@@ -450,6 +450,321 @@ test('plugin importer destination moves only newly imported hosts into the selec
assert.equal(targeted.addedCount, 2);
});
test('plugin importer destination re-dedupes same-endpoint hosts collapsed into one group', () => {
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host',
value: { label: 'Existing', hostname: 'existing.test', group: 'Original' },
} }]);
const importedDrafts = normalizePluginImporterRecords([
{ type: 'draft', draft: {
kind: 'host',
value: {
label: 'direct',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan',
},
} },
{ type: 'draft', draft: {
kind: 'host',
value: {
label: 'via-socks',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan-proxy',
},
} },
]);
const merged = mergePluginImporterDrafts({
hosts: existingDrafts.hosts,
identities: [],
keys: [],
snippets: [],
customGroups: ['Original'],
}, importedDrafts);
assert.equal(merged.hosts.length, 3);
assert.equal(merged.addedCount, 4); // 2 hosts + 2 new groups
const targeted = applyPluginImporterDestination(
merged,
existingDrafts.hosts.length,
{ mode: 'group', group: 'Chosen' },
['Original'],
);
assert.equal(targeted.hosts.length, 2);
assert.equal(targeted.hosts[1]?.group, 'Chosen');
assert.deepEqual(targeted.customGroups, ['Original', 'Chosen']);
assert.equal(targeted.duplicateCount, merged.duplicateCount + 1);
assert.equal(targeted.addedCount, 2); // 1 retained host + Chosen group
});
test('plugin importer destination keeps distinct plugin hosts that share an endpoint', () => {
const drafts = normalizePluginImporterRecords([
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'credential-a', label: 'Credential A', username: 'root', authMethod: 'password', password: 'first',
} } },
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'credential-b', label: 'Credential B', username: 'root', authMethod: 'password', password: 'second',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'Plugin host A',
protocol: 'plugin:com.example.transport.connection',
group: 'lan',
pluginConnection: {
providerId: 'com.example.transport.connection',
configuration: { endpoint: 'shared', path: 'a' },
credentialId: 'credential-a',
},
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'Plugin host B',
protocol: 'plugin:com.example.transport.connection',
group: 'lan-proxy',
pluginConnection: {
providerId: 'com.example.transport.connection',
configuration: { endpoint: 'shared', path: 'b' },
credentialId: 'credential-b',
},
} } },
]);
const merged = mergePluginImporterDrafts({
hosts: [], identities: [], keys: [], snippets: [], customGroups: [],
}, drafts);
assert.equal(merged.hosts.length, 2);
const targeted = applyPluginImporterDestination(
merged,
0,
{ mode: 'group', group: 'Chosen' },
);
assert.equal(targeted.hosts.length, 2);
assert.deepEqual(targeted.hosts.map((host) => host.group), ['Chosen', 'Chosen']);
assert.notEqual(
targeted.hosts[0].pluginConnection?.configuration,
targeted.hosts[1].pluginConnection?.configuration,
);
assert.equal(targeted.duplicateCount, merged.duplicateCount);
assert.equal(targeted.addedCount, merged.addedCount - 2 + 1); // replace 2 source groups with Chosen
});
test('plugin importer destination skips imports that collide with existing hosts after rewrite', () => {
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host',
value: {
label: 'Existing',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'Chosen',
},
} }]);
const importedDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host',
value: {
label: 'Imported copy',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan',
},
} }]);
const merged = mergePluginImporterDrafts({
hosts: existingDrafts.hosts,
identities: [],
keys: [],
snippets: [],
customGroups: ['Chosen'],
}, importedDrafts);
assert.equal(merged.hosts.length, 2);
const targeted = applyPluginImporterDestination(
merged,
existingDrafts.hosts.length,
{ mode: 'group', group: 'Chosen' },
['Chosen'],
);
assert.equal(targeted.hosts.length, 1);
assert.equal(targeted.hosts[0]?.label, 'Existing');
assert.equal(targeted.duplicateCount, merged.duplicateCount + 1);
assert.equal(targeted.addedCount, 0);
});
test('plugin importer destination prunes orphan credentials for skipped colliding hosts', () => {
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host',
value: {
label: 'Existing',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'Chosen',
},
} }]);
const importedDrafts = normalizePluginImporterRecords([
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'imported-identity',
label: 'Imported identity',
username: 'root',
authMethod: 'password',
password: 'secret',
} } },
{ type: 'draft', draft: { kind: 'key', value: {
id: 'imported-key',
label: 'Imported key',
type: 'ED25519',
privateKey: 'private-key-material',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'Imported copy',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan',
identityId: 'imported-identity',
identityFileId: 'imported-key',
} } },
]);
const merged = mergePluginImporterDrafts({
hosts: existingDrafts.hosts,
identities: [],
keys: [],
snippets: [],
customGroups: ['Chosen'],
}, importedDrafts);
assert.equal(merged.hosts.length, 2);
assert.equal(merged.identities.length, 1);
assert.equal(merged.keys.length, 1);
const targeted = applyPluginImporterDestination(
merged,
existingDrafts.hosts.length,
{ mode: 'group', group: 'Chosen' },
['Chosen'],
{ identities: 0, keys: 0 },
);
assert.equal(targeted.hosts.length, 1);
assert.deepEqual(targeted.identities, []);
assert.deepEqual(targeted.keys, []);
assert.equal(targeted.addedCount, 0);
});
test('plugin importer destination keeps existing credentials remapped onto skipped hosts', () => {
const existingDrafts = normalizePluginImporterRecords([
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'existing-identity',
label: 'Existing identity',
username: 'root',
authMethod: 'password',
password: 'vault-secret',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'Existing',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'Chosen',
identityId: 'existing-identity',
} } },
]);
const existingIdentityId = existingDrafts.identities[0]?.id;
assert.ok(existingIdentityId);
const importedDrafts = normalizePluginImporterRecords([
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'imported-identity',
label: 'Duplicate identity',
username: 'root',
authMethod: 'password',
password: 'vault-secret',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'Imported copy',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan',
identityId: 'imported-identity',
} } },
]);
const merged = mergePluginImporterDrafts({
hosts: existingDrafts.hosts,
identities: existingDrafts.identities,
keys: [],
snippets: [],
customGroups: ['Chosen'],
}, importedDrafts);
assert.equal(merged.identities.length, 1);
assert.equal(merged.identities[0]?.id, existingIdentityId);
assert.equal(merged.duplicateCount >= 1, true);
const targeted = applyPluginImporterDestination(
merged,
existingDrafts.hosts.length,
{ mode: 'group', group: 'Chosen' },
['Chosen'],
{ identities: existingDrafts.identities.length, keys: 0 },
);
assert.equal(targeted.hosts.length, 1);
assert.equal(targeted.identities.length, 1);
assert.equal(targeted.identities[0]?.id, existingIdentityId);
});
test('plugin importer destination merges referenced credentials when collapsing imports', () => {
const drafts = normalizePluginImporterRecords([
{ type: 'draft', draft: { kind: 'identity', value: {
id: 'imported-identity',
label: 'Imported identity',
username: 'root',
authMethod: 'password',
password: 'secret',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'bare',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan',
} } },
{ type: 'draft', draft: { kind: 'host', value: {
label: 'with-identity',
hostname: '10.10.10.10',
port: 22,
username: 'root',
group: 'lan-proxy',
identityId: 'imported-identity',
} } },
]);
const merged = mergePluginImporterDrafts({
hosts: [], identities: [], keys: [], snippets: [], customGroups: [],
}, drafts);
assert.equal(merged.hosts.length, 2);
assert.equal(merged.identities.length, 1);
const targeted = applyPluginImporterDestination(
merged,
0,
{ mode: 'group', group: 'Chosen' },
[],
{ identities: 0, keys: 0 },
);
assert.equal(targeted.hosts.length, 1);
assert.equal(targeted.hosts[0]?.identityId, merged.identities[0]?.id);
assert.equal(targeted.identities.length, 1);
});
test('plugin importer does not count a host-owned destination group as newly added', () => {
const existingDrafts = normalizePluginImporterRecords([{ type: 'draft', draft: {
kind: 'host',
+88 -3
View File
@@ -474,11 +474,29 @@ export function applyPluginImporterDestination(
existingHostCount: number,
destination?: VaultImportDestination,
existingCustomGroups: ReadonlyArray<string> = [],
existingCounts: {
identities?: number;
keys?: number;
} = {},
): PluginImporterMergeResult {
if (!destination || destination.mode === 'preserve') return merged;
const splitAt = Math.max(0, Math.min(existingHostCount, merged.hosts.length));
const existingHosts = merged.hosts.slice(0, splitAt);
const importedHosts = merged.hosts.slice(splitAt);
const existingIdentityCount = Math.max(
0,
Math.min(existingCounts.identities ?? 0, merged.identities.length),
);
const existingKeyCount = Math.max(
0,
Math.min(existingCounts.keys ?? 0, merged.keys.length),
);
const existingIdentityIds = new Set(
merged.identities.slice(0, existingIdentityCount).map((identity) => identity.id),
);
const existingKeyIds = new Set(
merged.keys.slice(0, existingKeyCount).map((key) => key.id),
);
const targeted = applyVaultImportDestination({
hosts: importedHosts,
groups: [],
@@ -489,7 +507,63 @@ export function applyPluginImporterDestination(
skipped: 0,
duplicates: 0,
},
}, destination);
}, destination, {
// Plugin hosts that differ by configuration/credentials must survive a
// shared destination group; classic vault hosts still collapse by endpoint.
isCollapsible: (host) => !host.pluginConnection,
});
// Destination rewrite can make an imported vault host collide with an existing
// one (same endpoint, now same group). Re-check like the CSV import path.
const existingFingerprints = new Set(existingHosts.map(hostFingerprint));
const retainedImported: Host[] = [];
for (const host of targeted.hosts) {
const fingerprint = hostFingerprint(host);
if (existingFingerprints.has(fingerprint)) continue;
existingFingerprints.add(fingerprint);
retainedImported.push(host);
}
const retainedImportedIds = new Set(retainedImported.map((host) => host.id));
const droppedImported = importedHosts.filter((host) => !retainedImportedIds.has(host.id));
const collectHostCredentialIds = (hosts: ReadonlyArray<Host>): Set<string> => {
const ids = new Set<string>();
for (const host of hosts) {
if (host.identityId) ids.add(host.identityId);
if (host.telnetIdentityId) ids.add(host.telnetIdentityId);
if (host.identityFileId) ids.add(host.identityFileId);
const pluginCredentialId = host.pluginConnection?.credentialId;
if (pluginCredentialId) ids.add(pluginCredentialId);
}
return ids;
};
const keptCredentialIds = collectHostCredentialIds([...existingHosts, ...retainedImported]);
const droppedOnlyCredentialIds = new Set(
[...collectHostCredentialIds(droppedImported)].filter((id) => !keptCredentialIds.has(id)),
);
// Only prune credentials introduced by this import. Remapped duplicates that
// point at pre-existing vault identities/keys must stay in the vault.
const nextIdentities = merged.identities.filter((identity) => (
existingIdentityIds.has(identity.id)
|| !droppedOnlyCredentialIds.has(identity.id)
));
const keptIdentityKeyIds = new Set(
nextIdentities.flatMap((identity) => (identity.keyId ? [identity.keyId] : [])),
);
const prunedIdentityKeyIds = new Set(
merged.identities
.filter((identity) => (
!existingIdentityIds.has(identity.id)
&& droppedOnlyCredentialIds.has(identity.id)
))
.flatMap((identity) => (identity.keyId ? [identity.keyId] : [])),
);
const nextKeys = merged.keys.filter((key) => {
if (existingKeyIds.has(key.id)) return true;
if (keptCredentialIds.has(key.id) || keptIdentityKeyIds.has(key.id)) return true;
if (droppedOnlyCredentialIds.has(key.id) || prunedIdentityKeyIds.has(key.id)) return false;
return true;
});
const prunedIdentityCount = merged.identities.length - nextIdentities.length;
const prunedKeyCount = merged.keys.length - nextKeys.length;
const existingGroupSet = new Set([
...existingCustomGroups,
...existingHosts.flatMap((host) => host.group ? [host.group] : []),
@@ -501,11 +575,22 @@ export function applyPluginImporterDestination(
const nextAddedGroupCount = customGroups.filter(
(group) => !existingGroupSet.has(group),
).length;
// Destination rewriting can collapse same-endpoint hosts into one group;
// keep added/duplicate counts aligned with the hosts actually retained.
const collapsedHostCount = Math.max(0, importedHosts.length - retainedImported.length);
return {
...merged,
hosts: [...existingHosts, ...targeted.hosts],
hosts: [...existingHosts, ...retainedImported],
identities: nextIdentities,
keys: nextKeys,
customGroups,
addedCount: merged.addedCount - previousAddedGroupCount + nextAddedGroupCount,
duplicateCount: merged.duplicateCount + collapsedHostCount,
addedCount: merged.addedCount
- previousAddedGroupCount
+ nextAddedGroupCount
- collapsedHostCount
- prunedIdentityCount
- prunedKeyCount,
};
}
+30
View File
@@ -85,3 +85,33 @@ test('unknown prompt-shaped authentication boundaries fail closed', () => {
assert.equal(isUntrustedTerminalInputPrompt('router> ', { allowHostStyleGreaterThan: true }), false);
assert.equal(isUntrustedTerminalInputPrompt('router> '), true);
});
test('ordinary shell commands ending with a colon are not untrusted input prompts (#2709)', () => {
// Broadcast pauses while passwordPromptActive is sticky. Typing `lsof -i:`
// must not trip the fail-closed colon heuristic, or peers miss the rest.
const commandLines = [
'user@host:~$ lsof -i:',
'bash-5.2$ lsof -i:',
'root# lsof -i:',
'PS C:\\Users\\alice> lsof -i:',
'C:\\Users\\alice> lsof -i:',
];
for (const line of commandLines) {
assert.equal(isUntrustedTerminalInputPrompt(line), false, line);
}
assert.equal(
isUntrustedTerminalInputPrompt('router> show ip:', { allowHostStyleGreaterThan: true }),
false,
);
// Bare host-style prompts stay fail-closed unless explicitly allowed.
assert.equal(isUntrustedTerminalInputPrompt('router> show ip:'), true);
// Mid-label `#` is not a prompt boundary (no whitespace after `#`).
assert.equal(isUntrustedTerminalInputPrompt('Challenge #1:'), true);
// Markers inside English challenge labels must not look like a shell PS1.
assert.equal(isUntrustedTerminalInputPrompt('Challenge # 1:'), true);
assert.equal(isUntrustedTerminalInputPrompt('Account $ code:'), true);
// Standalone auth-shaped lines without a confirmed shell prompt still fail closed.
assert.equal(isUntrustedTerminalInputPrompt('Please authenticate: '), true);
assert.equal(isUntrustedTerminalInputPrompt('Token: '), true);
assert.equal(isUntrustedTerminalInputPrompt('Password: '), true);
});
+49
View File
@@ -134,6 +134,52 @@ export function shouldUsePluginTerminalCompletionProvider(input: {
});
}
/**
* Body text before a `$` / `#` / `%` terminator that is plausible as a shell
* PS1 (bare marker, user@host, path, shell-version, or lowercase identity).
* Rejects English challenge labels that only happen to end with those markers
* (`Challenge #`, `Account $`).
*/
function isPlausibleShellPromptBody(body: string): boolean {
const trimmed = body.trim();
if (!trimmed) return true;
if (/@|[/\\~:]/u.test(trimmed)) return true;
if (/^(?:bash|zsh|sh|fish|ksh|csh|tcsh|dash)(?:-[\d.]+)?$/iu.test(trimmed)) return true;
// Single lowercase identity token: root, ubuntu, pi — not Title-Case labels.
return /^[a-z0-9_.-]+$/u.test(trimmed);
}
/**
* True when a confirmed shell/device prompt is followed by typed text on the
* same logical line (e.g. `user@host:~$ lsof -i:`). Trailing `:` / `>` in that
* typed text is ordinary command input, not an authentication boundary.
*
* Requires a real prompt boundary (terminator + whitespace) so mid-label
* shapes like `Challenge #1:` stay fail-closed. For `$`/`#`/`%`, also requires
* a plausible PS1 body so `Challenge # 1:` / `Account $ code:` stay untrusted.
*/
function hasTypedInputAfterConfirmedPrompt(
line: string,
options: ConfirmedPromptOptions = {},
): boolean {
for (let i = 0; i < line.length - 1; i += 1) {
const ch = line[i];
// Only terminators that can end a confirmed prompt without relying on a
// glyph appearing later in the typed command.
if (ch !== '$' && ch !== '#' && ch !== '%' && ch !== '>') continue;
const rest = line.slice(i + 1);
// Prompt terminators are followed by whitespace before typed input.
if (!/^\s+\S/u.test(rest)) continue;
const candidate = line.slice(0, i + 1);
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
if ((ch === '$' || ch === '#' || ch === '%') && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
continue;
}
return true;
}
return false;
}
/**
* Fail closed for prompt-shaped input boundaries that are not positively
* identified as an ordinary shell/device prompt. This protects custom PAM,
@@ -147,6 +193,9 @@ export function isUntrustedTerminalInputPrompt(
if (!prompt) return false;
if (isSensitiveTerminalChallenge(prompt)) return true;
if (!/[::>›»]\s*$/u.test(prompt)) return false;
// Mid-command punctuation after a real shell prompt must keep broadcasting
// (#2709). Standalone `Label:` / `Custom>` challenges still fail closed.
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
return !isConfirmedTerminalShellPrompt(prompt, options);
}
+16
View File
@@ -135,6 +135,22 @@ test("normalizeTerminalSettings preserves a hidden host information bar", () =>
assert.equal(normalizeTerminalSettings({ showHostInfoBar: false }).showHostInfoBar, false);
});
test("normalizeTerminalSettings defaults host info bar title mode to address", () => {
assert.equal(normalizeTerminalSettings().hostInfoBarTitleMode, "address");
});
test("normalizeTerminalSettings preserves supported host info bar title modes", () => {
assert.equal(normalizeTerminalSettings({ hostInfoBarTitleMode: "label" }).hostInfoBarTitleMode, "label");
assert.equal(normalizeTerminalSettings({ hostInfoBarTitleMode: "address" }).hostInfoBarTitleMode, "address");
});
test("normalizeTerminalSettings falls back for unsupported host info bar title modes", () => {
assert.equal(
normalizeTerminalSettings({ hostInfoBarTitleMode: "both" as never }).hostInfoBarTitleMode,
"address",
);
});
test("normalizeTerminalSettings disables hibernate for hidden tabs by default", () => {
assert.equal(normalizeTerminalSettings().hibernateHiddenTabs, false);
assert.equal(normalizeTerminalSettings().hibernateHiddenTabsDelaySec, 5);
+27
View File
@@ -7,13 +7,40 @@ import {
applyVaultHostDelete,
applyVaultHostCreates,
applyVaultHostUpdate,
buildVaultHostEndpointKey,
buildVaultHostFromDraft,
buildVaultHostMergeKey,
buildVaultHostsFromDrafts,
parseVaultHostDraftsInput,
type VaultHostDraft,
type VaultHostUpdatePatch,
} from './vaultHostCreate.ts';
test('buildVaultHostMergeKey treats group as part of session identity', () => {
const base = {
hostname: '10.10.10.10',
port: 22,
username: 'root',
protocol: 'ssh' as const,
};
assert.notEqual(
buildVaultHostMergeKey({ ...base, group: 'lan' }),
buildVaultHostMergeKey({ ...base, group: 'lan-proxy' }),
);
assert.equal(
buildVaultHostMergeKey({ ...base, group: 'lan' }),
buildVaultHostMergeKey({ ...base, group: 'lan' }),
);
assert.notEqual(
buildVaultHostMergeKey({ ...base, group: 'Prod' }),
buildVaultHostMergeKey({ ...base, group: 'prod' }),
);
assert.equal(
buildVaultHostEndpointKey({ ...base, group: 'lan' }),
buildVaultHostEndpointKey({ ...base, group: 'lan-proxy' }),
);
});
test('buildVaultHostFromDraft maps minimal unstructured fields to a vault host', () => {
const built = buildVaultHostFromDraft({
hostname: '192.168.1.10',
+15 -1
View File
@@ -169,11 +169,25 @@ const parseKeyPath = (draft: VaultHostDraft): string | undefined => {
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
};
export const buildVaultHostMergeKey = (
/** Endpoint-only identity (protocol/host/port/user). Used for managed ssh_config matching. */
export const buildVaultHostEndpointKey = (
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol'>,
): string =>
`${(host.protocol ?? 'ssh').toLowerCase()}|${host.hostname.toLowerCase()}|${host.port}|${(host.username ?? '').toLowerCase()}`;
/**
* Session identity for vault import/create dedupe.
* Same endpoint in a different group is a distinct connection (e.g. direct vs proxy copy).
*/
export const buildVaultHostMergeKey = (
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol' | 'group'>,
): string => {
// Keep normalized group spelling (do not case-fold). Vault group paths are
// compared exactly elsewhere, so Prod vs prod are distinct sessions.
const group = normalizeGroupPath(host.group) ?? '';
return `${buildVaultHostEndpointKey(host)}|${group}`;
};
export function buildVaultHostFromDraft(
draft: VaultHostDraft,
): { ok: true; host: Host } | { ok: false; error: string } {
+242
View File
@@ -185,6 +185,185 @@ test("vault import can place every imported host into one selected group", () =>
assert.deepEqual(imported.groups, ["Production/Web", "Production/DB"]);
});
test("applyVaultImportDestination re-dedupes same-endpoint hosts collapsed into one group", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"lan,direct,10.10.10.10,22,root",
"lan-proxy,via-socks,10.10.10.10,22,root",
].join("\n"));
assert.equal(imported.hosts.length, 2);
const targeted = applyVaultImportDestination(imported, {
mode: "group",
group: "Imported/July",
});
assert.equal(targeted.hosts.length, 1);
assert.equal(targeted.hosts[0]?.group, "Imported/July");
assert.equal(targeted.stats.duplicates, 1);
assert.equal(targeted.stats.imported, 1);
assert.deepEqual(targeted.groups, ["Imported/July"]);
const merged = applyVaultHostImport([], [], targeted);
assert.equal(merged.addedCount, 1);
assert.equal(merged.hosts.length, 1);
});
test("applyVaultImportDestination remaps a shared key passphrase onto the retained host", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username,KeyPath,Passphrase",
"lan,direct,10.10.10.10,22,root,~/.ssh/id_ed25519,",
"lan-proxy,via-socks,10.10.10.10,22,root,~/.ssh/id_ed25519,secret",
].join("\n"));
assert.equal(imported.hosts.length, 2);
const targeted = applyVaultImportDestination(imported, {
mode: "group",
group: "Imported/July",
});
assert.equal(targeted.hosts.length, 1);
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_ed25519"]);
assert.deepEqual(targeted.keyPassphrases, [{
hostId: targeted.hosts[0]?.id,
keyPath: "~/.ssh/id_ed25519",
passphrase: "secret",
}]);
});
test("applyVaultImportDestination does not attach a passphrase for a non-retained key", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username,KeyPath,Passphrase",
"lan,direct,10.10.10.10,22,root,~/.ssh/id_first,first",
"lan-proxy,via-socks,10.10.10.10,22,root,~/.ssh/id_second,second",
].join("\n"));
const targeted = applyVaultImportDestination(imported, {
mode: "group",
group: "Imported/July",
});
assert.equal(targeted.hosts.length, 1);
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_first"]);
assert.deepEqual(targeted.keyPassphrases, [{
hostId: targeted.hosts[0]?.id,
keyPath: "~/.ssh/id_first",
passphrase: "first",
}]);
});
test("applyVaultImportDestination can keep same-endpoint hosts when collapse is disabled", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"Prod,web-a,shared.example.com,22,root",
"Staging,web-b,shared.example.com,22,root",
].join("\n"));
assert.equal(imported.hosts.length, 2);
const targeted = applyVaultImportDestination(
imported,
{ mode: "group", group: "Imported/SecureCRT" },
{ collapseDuplicateEndpoints: false },
);
assert.equal(targeted.hosts.length, 2);
assert.deepEqual(targeted.hosts.map((host) => host.group), [
"Imported/SecureCRT",
"Imported/SecureCRT",
]);
assert.equal(targeted.stats.duplicates, 0);
assert.equal(targeted.stats.imported, 2);
});
test("applyVaultImportDestination can skip collapse for selected hosts", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"lan,direct,10.10.10.10,22,root",
"lan-proxy,via-socks,10.10.10.10,22,root",
].join("\n"));
const withPluginFlag = {
...imported,
hosts: imported.hosts.map((host, index) => (
index === 0
? host
: {
...host,
pluginConnection: {
providerId: "com.example.transport.connection",
configuration: { endpoint: "via-socks" },
},
}
)),
};
const targeted = applyVaultImportDestination(
withPluginFlag,
{ mode: "group", group: "Imported/Plugin" },
{ isCollapsible: (host) => !host.pluginConnection },
);
assert.equal(targeted.hosts.length, 2);
assert.deepEqual(targeted.hosts.map((host) => host.group), [
"Imported/Plugin",
"Imported/Plugin",
]);
});
test("applyVaultImportDestination merges referenced credentials onto the retained host", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"lan,direct,10.10.10.10,22,root",
"lan-proxy,via-socks,10.10.10.10,22,root",
].join("\n"));
const [first, second] = imported.hosts;
assert.ok(first && second);
const withRefs = {
...imported,
hosts: [
{ ...first, identityId: undefined, identityFileId: undefined },
{ ...second, identityId: "identity-1", identityFileId: "key-1" },
],
};
const targeted = applyVaultImportDestination(withRefs, {
mode: "group",
group: "Imported/July",
});
assert.equal(targeted.hosts.length, 1);
assert.equal(targeted.hosts[0]?.identityId, "identity-1");
assert.equal(targeted.hosts[0]?.identityFileId, "key-1");
});
test("applyVaultImportDestination does not override retained key auth with identity refs", () => {
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username,KeyPath",
"lan,direct,10.10.10.10,22,root,~/.ssh/id_ed25519",
"lan-proxy,via-socks,10.10.10.10,22,root,",
].join("\n"));
const [first, second] = imported.hosts;
assert.ok(first && second);
const withRefs = {
...imported,
hosts: [
first,
{ ...second, identityId: "identity-1" },
],
};
const targeted = applyVaultImportDestination(withRefs, {
mode: "group",
group: "Imported/July",
});
assert.equal(targeted.hosts.length, 1);
assert.deepEqual(targeted.hosts[0]?.identityFilePaths, ["~/.ssh/id_ed25519"]);
assert.equal(targeted.hosts[0]?.identityId, undefined);
});
test("CSV import keeps working when KeyPath and Passphrase columns are absent", () => {
const result = importVaultHostsFromText(
"csv",
@@ -614,6 +793,69 @@ test("applyVaultHostImport skips duplicates by default", () => {
assert.equal(merged.hosts.length, 2);
});
test("CSV import keeps same-endpoint rows that use different groups", () => {
const result = importVaultHostsFromText(
"csv",
[
"Groups,Label,Hostname,Port,Username",
"lan,direct,10.10.10.10,22,root",
"lan-proxy,via-socks,10.10.10.10,22,root",
].join("\n"),
);
assert.equal(result.hosts.length, 2);
assert.equal(result.stats.duplicates, 0);
assert.deepEqual(
result.hosts.map((host) => host.group).sort(),
["lan", "lan-proxy"],
);
});
test("applyVaultHostImport keeps same-endpoint hosts when the group differs", () => {
const existing: Host = {
id: "existing-1",
label: "direct",
hostname: "10.10.10.10",
username: "root",
port: 22,
group: "lan",
};
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"lan-proxy,via-socks,10.10.10.10,22,root",
].join("\n"));
const targeted = applyVaultImportDestination(imported, {
mode: "group",
group: "lan-proxy",
});
const merged = applyVaultHostImport([existing], ["lan"], targeted);
assert.equal(merged.addedCount, 1);
assert.equal(merged.skippedExistingCount, 0);
assert.equal(merged.hosts.length, 2);
assert.equal(merged.addedHosts[0]?.group, "lan-proxy");
});
test("applyVaultHostImport still skips same-endpoint hosts in the same group", () => {
const existing: Host = {
id: "existing-1",
label: "direct",
hostname: "10.10.10.10",
username: "root",
port: 22,
group: "lan",
};
const imported = importVaultHostsFromText("csv", [
"Groups,Label,Hostname,Port,Username",
"lan,copy,10.10.10.10,22,root",
].join("\n"));
const merged = applyVaultHostImport([existing], ["lan"], imported);
assert.equal(merged.addedCount, 0);
assert.equal(merged.skippedExistingCount, 1);
assert.equal(merged.hosts.length, 1);
});
test("applyVaultHostImport can preserve distinct sessions with the same endpoint", () => {
const existing: Host = {
id: "existing-1",
+119 -4
View File
@@ -9,7 +9,10 @@ import {
type VaultHostDraftProtocol,
} from "./vaultHostCreate";
export { buildVaultHostMergeKey } from "./vaultHostCreate";
export {
buildVaultHostEndpointKey,
buildVaultHostMergeKey,
} from "./vaultHostCreate";
import { parseQuickConnectInput } from "./quickConnect";
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
@@ -136,6 +139,20 @@ export type VaultImportDestination =
| { mode: "preserve" }
| { mode: "group"; group: string };
export type ApplyVaultImportDestinationOptions = {
/**
* When false, only rewrite groups. SecureCRT keeps distinct session files that
* share an endpoint even after an import-location override.
* @default true
*/
collapseDuplicateEndpoints?: boolean;
/**
* Return false to keep a host even when another shares its merge key after the
* destination rewrite (plugin hosts that differ by configuration/credentials).
*/
isCollapsible?: (host: Host) => boolean;
};
export function mergeVaultImportIssues(
...groups: ReadonlyArray<ReadonlyArray<VaultImportIssue>>
): VaultImportIssue[] {
@@ -162,14 +179,89 @@ const normalizeGroupPath = (raw: string | undefined): string | undefined => {
export function applyVaultImportDestination(
result: VaultImportResult,
destination: VaultImportDestination,
options?: ApplyVaultImportDestinationOptions,
): VaultImportResult {
if (destination.mode === "preserve") return result;
const group = normalizeGroupPath(destination.group);
if (!group) return result;
const rewrittenHosts = result.hosts.map((host) => ({ ...host, group }));
// SecureCRT (and similar) keep same-endpoint sessions as distinct imports.
if (options?.collapseDuplicateEndpoints === false) {
return {
...result,
hosts: rewrittenHosts,
groups: [group],
};
}
// Destination rewriting can collapse previously distinct group-scoped sessions
// onto one merge key; re-dedupe so identical endpoints are not imported twice.
// Plugin hosts can opt out via isCollapsible so configuration/credential
// differences survive a shared destination group.
const { hosts, duplicates } = dedupeHosts(rewrittenHosts, {
isCollapsible: options?.isCollapsible,
});
const retainedIds = new Set(hosts.map((host) => host.id));
const retainedByMergeKey = new Map(
hosts
.filter((host) => options?.isCollapsible?.(host) !== false)
.map((host) => [hostKey(host), host]),
);
const originalById = new Map(result.hosts.map((host) => [host.id, host]));
const remapHostId = (hostId: string): string | undefined => {
if (retainedIds.has(hostId)) return hostId;
const original = originalById.get(hostId);
if (!original) return undefined;
if (options?.isCollapsible?.({ ...original, group }) === false) return undefined;
return retainedByMergeKey.get(hostKey({ ...original, group }))?.id;
};
const selectedKeyByHostId = new Map(
hosts.map((host) => {
const selected = host.identityFilePaths?.find((path) => path.trim())?.trim();
return [host.id, selected ? normalizeKeyPathKey(selected) : undefined] as const;
}),
);
const remapKeyPassphrases = (
entries: VaultHostKeyPassphrase[] | undefined,
remapOptions?: { matchSelectedKey?: boolean },
): VaultHostKeyPassphrase[] | undefined => {
if (!entries) return entries;
const remapped: VaultHostKeyPassphrase[] = [];
const seen = new Set<string>();
for (const entry of entries) {
const nextHostId = remapHostId(entry.hostId);
if (!nextHostId) continue;
// Match CSV same-group dedupe: never attach a passphrase for a key the
// retained host did not keep as its selected identity file.
if (remapOptions?.matchSelectedKey) {
const selectedKey = selectedKeyByHostId.get(nextHostId);
if (!selectedKey || normalizeKeyPathKey(entry.keyPath) !== selectedKey) continue;
}
const dedupeKey = `${nextHostId}\u0000${normalizeKeyPathKey(entry.keyPath)}\u0000${entry.passphrase}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
remapped.push(nextHostId === entry.hostId ? entry : { ...entry, hostId: nextHostId });
}
return remapped;
};
return {
...result,
hosts: result.hosts.map((host) => ({ ...host, group })),
hosts,
groups: [group],
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
// Keep remapped candidates for alias conflict checks (same as CSV same-group
// dedupe); the import handler still gates saves on the retained key path.
keyPassphraseCandidates: remapKeyPassphrases(result.keyPassphraseCandidates),
stats: {
...result.stats,
imported: hosts.length,
duplicates: result.stats.duplicates + duplicates,
},
};
}
@@ -227,15 +319,24 @@ const createHost = (input: {
return built.host;
};
const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
const dedupeHosts = (
hosts: Host[],
options?: { isCollapsible?: (host: Host) => boolean },
): { hosts: Host[]; duplicates: number } => {
const seen = new Map<string, Host>();
const retained: Host[] = [];
let duplicates = 0;
for (const host of hosts) {
if (options?.isCollapsible?.(host) === false) {
retained.push(host);
continue;
}
const key = hostKey(host);
const existing = seen.get(key);
if (!existing) {
seen.set(key, host);
retained.push(host);
continue;
}
duplicates++;
@@ -248,13 +349,27 @@ const dedupeHosts = (hosts: Host[]): { hosts: Host[]; duplicates: number } => {
existing.authPolicyVersion = host.authPolicyVersion;
existing.useSshAgent = host.useSshAgent;
}
// Only graft referenced credentials onto a retained host that has no auth
// source yet. Otherwise identity auth can override an existing key path.
const retainedHasCredentialSource = Boolean(
existing.identityId
|| existing.telnetIdentityId
|| existing.identityFileId
|| existing.identityFilePaths?.some((path) => path.trim())
|| (typeof existing.password === "string" && existing.password.length > 0)
);
if (!retainedHasCredentialSource) {
if (host.identityId) existing.identityId = host.identityId;
if (host.telnetIdentityId) existing.telnetIdentityId = host.telnetIdentityId;
if (host.identityFileId) existing.identityFileId = host.identityFileId;
}
if (existing.group == null && host.group != null) existing.group = host.group;
if (existing.label === existing.hostname && host.label && host.label !== host.hostname) {
existing.label = host.label;
}
}
return { hosts: Array.from(seen.values()), duplicates };
return { hosts: retained, duplicates };
};
const uniq = (items: string[]) => Array.from(new Set(items.filter(Boolean)));
@@ -287,8 +287,26 @@ function translateOpenCodeEvent(event, emitter, state = {}) {
emitter.toolResult(callId, part.state.output || "", toolName);
}
} else if (part.state?.status === "error") {
emitter.emitError(part.state.error || "OpenCode tool failed");
return { idle: false, error: true, content: false };
// Tool-level failures must not abort the whole OpenCode turn. Other
// drivers (Cursor / Codex / Grok) surface tool errors as tool results
// so the model can adapt and continue multi-step work (issue #2718).
state.toolCalls = state.toolCalls || new Set();
if (!state.toolCalls.has(callId)) {
state.toolCalls.add(callId);
emitter.toolCall(toolName, input, callId);
}
state.toolResults = state.toolResults || new Set();
if (!state.toolResults.has(callId)) {
state.toolResults.add(callId);
// Prefer non-empty error, then output, then a stable default (blank
// string error must not hide a useful output payload).
const rawError = part.state.error || part.state.output || "OpenCode tool failed";
const errorText = typeof rawError === "string"
? rawError
: (extractOpenCodeErrorMessage(rawError) || "OpenCode tool failed");
emitter.toolResult(callId, errorText, toolName);
}
return { idle: false, error: false, content: true };
}
}
return { idle: false, error: false, content: part.type === "tool" };
@@ -403,6 +403,101 @@ test("translateOpenCodeEvent emits a tool call before a result when only complet
]);
});
test("translateOpenCodeEvent maps tool status error to toolResult without failing the turn (#2718)", () => {
const { events, emitter } = collector();
const state = {};
const result = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
type: "tool",
callID: "tool-err",
tool: "netcatty-remote-hosts_terminal_execute",
state: {
status: "error",
input: { command: "du /missing" },
error: "Error: Operation failed",
},
},
},
},
emitter,
state,
);
assert.equal(result.error, false);
assert.equal(result.content, true);
assert.deepEqual(events, [
{
k: "toolCall",
name: "netcatty-remote-hosts_terminal_execute",
args: { command: "du /missing" },
id: "tool-err",
},
{
k: "toolResult",
id: "tool-err",
out: "Error: Operation failed",
name: "netcatty-remote-hosts_terminal_execute",
},
]);
assert.equal(events.some((event) => event.k === "error"), false);
});
test("runOpenCodeTurn continues after a tool error part and reaches session.idle (#2718)", async () => {
const { events, emitter } = collector();
const abortController = new AbortController();
const stream = {
async *[Symbol.asyncIterator]() {
yield {
type: "message.part.updated",
properties: {
part: {
type: "tool",
sessionID: "sess-1",
callID: "t1",
tool: "terminal_execute",
state: { status: "error", input: { command: "false" }, error: "Error: Operation failed" },
},
},
};
yield {
type: "message.part.updated",
properties: {
part: {
type: "text",
sessionID: "sess-1",
id: "p1",
text: "retrying",
},
delta: "retrying",
},
};
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => ({ data: true }),
},
};
await runOpenCodeTurn({
prompt: "run false then continue",
emitter,
abortController,
openCodeFactory: async () => ({ client, server: { close() {} } }),
});
assert.ok(events.some((event) => event.k === "toolResult" && event.out === "Error: Operation failed"));
assert.ok(events.some((event) => event.k === "text" && event.t === "retrying"));
assert.ok(events.some((event) => event.k === "done"));
assert.equal(events.some((event) => event.k === "error"), false);
});
test("mapOpenCodeModels flattens providers", () => {
assert.deepEqual(mapOpenCodeModels({
providers: [
+57 -9
View File
@@ -3,6 +3,8 @@
const { invalidateSshTransport } = require("./sshTransportInvalidation.cjs");
const DEFAULT_SSH_CHANNEL_OPEN_TIMEOUT_MS = 30_000;
const DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES = 3;
const DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS = 150;
function closeLateChannel(channel) {
if (!channel || typeof channel === "number") return;
@@ -19,6 +21,22 @@ function channelAbortError(signal, label) {
return error;
}
/**
* Bastion / jump hosts sometimes reject rapid session channel opens with a
* distinctive rate-limit message. The common Chinese bastion typo "offen"
* (for "often") is part of the real wire text we see in the field.
*/
function isSshChannelOpenRateLimitedError(error) {
const message = String(error?.message || error || "");
return /channelOpen\s+too\s+offen\b/i.test(message)
|| /channelOpen\s+too\s+often\b/i.test(message);
}
function sleep(ms, sleepFn = null) {
if (typeof sleepFn === "function") return Promise.resolve(sleepFn(ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
function openBoundedSshChannel(sshClient, invoke, options = {}) {
const label = String(options.label || "SSH channel open");
const timeoutMs = Math.max(
@@ -76,16 +94,43 @@ function openBoundedSshChannel(sshClient, invoke, options = {}) {
});
}
function openBoundedSshShell(sshClient, windowOptions, shellOptions, options = {}) {
return openBoundedSshChannel(
sshClient,
(callback) => sshClient.shell(windowOptions, shellOptions, callback),
{
...options,
label: options.label || "SSH shell channel open",
timeoutCode: "SSH_SHELL_OPEN_TIMEOUT",
},
async function openBoundedSshShell(sshClient, windowOptions, shellOptions, options = {}) {
const rateLimitRetries = Math.max(
0,
Number.isFinite(options.rateLimitRetries)
? Number(options.rateLimitRetries)
: DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES,
);
const rateLimitBackoffMs = Math.max(
1,
Number(options.rateLimitBackoffMs) || DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS,
);
const sleepFn = options.sleepFn || null;
let attempt = 0;
for (;;) {
try {
return await openBoundedSshChannel(
sshClient,
(callback) => sshClient.shell(windowOptions, shellOptions, callback),
{
...options,
label: options.label || "SSH shell channel open",
timeoutCode: "SSH_SHELL_OPEN_TIMEOUT",
},
);
} catch (error) {
if (
attempt >= rateLimitRetries
|| !isSshChannelOpenRateLimitedError(error)
|| options.signal?.aborted
) {
throw error;
}
attempt += 1;
await sleep(rateLimitBackoffMs * attempt, sleepFn);
}
}
}
function openBoundedForwardOut(
@@ -183,7 +228,10 @@ function openBoundedForwardInCallback(
module.exports = {
DEFAULT_SSH_CHANNEL_OPEN_TIMEOUT_MS,
DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_RETRIES,
DEFAULT_SSH_CHANNEL_OPEN_RATE_LIMIT_BACKOFF_MS,
closeLateChannel,
isSshChannelOpenRateLimitedError,
openBoundedSshChannel,
openBoundedSshShell,
openBoundedSshShellCallback,
@@ -4,6 +4,7 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter, getEventListeners } = require("node:events");
const {
isSshChannelOpenRateLimitedError,
openBoundedForwardIn,
openBoundedForwardOut,
openBoundedSshShell,
@@ -105,3 +106,67 @@ test("channel open keeps its deadline referenced and clears it after success", a
assert.equal(timers.active.size, 0);
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
});
test("detects bastion channelOpen rate-limit errors including the offen typo", () => {
assert.equal(
isSshChannelOpenRateLimitedError(
new Error("(SSH) Channel open failure: channelOpen too offen type=session"),
),
true,
);
assert.equal(
isSshChannelOpenRateLimitedError(
new Error("channel open failure: channelOpen too often type=session"),
),
true,
);
assert.equal(
isSshChannelOpenRateLimitedError(new Error("Permission denied")),
false,
);
});
test("shell open retries bastion rate-limit failures with short backoff", async () => {
const delays = [];
let attempts = 0;
const client = {
shell(_window, _options, next) {
attempts += 1;
if (attempts < 3) {
next(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
next(null, new EventEmitter());
},
};
const stream = await openBoundedSshShell(client, {}, {}, {
rateLimitRetries: 3,
rateLimitBackoffMs: 5,
sleepFn: async (ms) => { delays.push(ms); },
});
assert.ok(stream);
assert.equal(attempts, 3);
assert.deepEqual(delays, [5, 10]);
});
test("shell open does not retry unrelated channel open failures", async () => {
let attempts = 0;
const client = {
shell(_window, _options, next) {
attempts += 1;
next(new Error("Channel open failure: administratively prohibited"));
},
};
await assert.rejects(
openBoundedSshShell(client, {}, {}, {
rateLimitRetries: 3,
rateLimitBackoffMs: 5,
sleepFn: async () => {},
}),
/administratively prohibited/,
);
assert.equal(attempts, 1);
});
@@ -341,6 +341,7 @@ function makeSourceSession(conn, endpoint) {
hostname: endpoint.hostname,
port: endpoint.port || 22,
username: endpoint.username,
...(Array.isArray(endpoint.jumpHosts) ? { jumpHosts: endpoint.jumpHosts } : {}),
},
};
createConnectionRef(session, conn, []);
@@ -473,6 +474,224 @@ test("Copy Tab skips POSIX shell discovery for network devices", async (t) => {
assert.ok(sessions.get("copy"));
});
test("Copy Tab with jump hosts still discovers shell PIDs after a short delay", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makePidTrackingReusableConn();
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
const source = makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
jumpHosts,
});
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
jumpHosts,
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(sourceConn.shellPidSnapshots.length >= 2, "jump-host reuse must still run discovery");
assert.equal(source.shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
assert.equal(sourceConn.openedShells.length, 1);
});
test("Copy Tab retries rate-limited pre-open shell PID discovery on jump hosts", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
// First pre-open scan is rate-limited; later scans succeed.
if (execCalls === 1) {
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
const snapshot = execCalls === 2 ? ["111"] : ["111", "222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
const source = makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
jumpHosts,
});
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
jumpHosts,
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(execCalls >= 3, "pre-open discovery must retry after channelOpen too offen");
assert.equal(source.shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab retries rate-limited PID discovery for direct bastion targets", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
// Direct bastion (no jumpHosts): first pre-open scan is rate-limited.
if (execCalls === 1) {
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
const snapshot = execCalls === 2 ? ["111"] : ["111", "222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "bastion.example",
username: "alice",
}));
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "bastion.example",
username: "alice",
sourceSessionId: "source",
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(execCalls >= 3, "direct bastion reuse must retry rate-limited PID discovery");
assert.equal(sessions.get("source").shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab retries rate-limited post-open shell PID discovery on jump hosts", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let execCalls = 0;
sourceConn.exec = (_command, callback) => {
execCalls += 1;
// Pre-open discovery (1) succeeds with the source shell. The first
// post-open discovery (2) is rate-limited; later attempts succeed.
if (execCalls === 2) {
callback(new Error("(SSH) Channel open failure: channelOpen too offen type=session"));
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
const snapshot = execCalls === 1 ? ["111"] : ["111", "222"];
const pids = `${snapshot.join("\n")}\n__NETCATTY_SHELL_SCAN_COMPLETE__\n`;
setImmediate(() => {
stream.emit("data", Buffer.from(pids));
stream.emit("close", 0);
});
callback(null, stream);
};
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
const source = makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
jumpHosts,
});
sessions.set("source", source);
const start = registerStartHandler(bridge, sessions);
await start(
{ sender: makeSender() },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
jumpHosts,
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.ok(execCalls >= 3, "post-open discovery must retry after channelOpen too offen");
assert.equal(source.shellPid, "111");
assert.equal(sessions.get("copy").shellPid, "222");
});
test("Copy Tab retries bastion channelOpen too offen before falling back", async (t) => {
const { bridge, getClientConstructCount } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
const sourceConn = makeReusableConn();
let shellAttempts = 0;
sourceConn.shell = (_opts, _shellOpts, cb) => {
shellAttempts += 1;
if (shellAttempts === 1) {
setImmediate(() => cb(new Error("(SSH) Channel open failure: channelOpen too offen type=session")));
return;
}
const stream = makeStream();
sourceConn.openedShells.push(stream);
setImmediate(() => cb(null, stream));
};
const jumpHosts = [{ hostname: "bastion.example", username: "jump", port: 22 }];
sessions.set("source", makeSourceSession(sourceConn, {
hostname: "10.0.0.1",
username: "alice",
jumpHosts,
}));
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const result = await start(
{ sender },
{
sessionId: "copy",
hostname: "10.0.0.1",
username: "alice",
sourceSessionId: "source",
jumpHosts,
sshChannelOpenRateLimitBackoffMs: 1,
},
);
assert.equal(result.sessionId, "copy");
assert.equal(shellAttempts, 2);
assert.equal(getClientConstructCount(), 0);
assert.equal(sourceConn.openedShells.length, 1);
assert.equal(getConnectionReuseFallbackEvents(sender).length, 0);
});
test("Copy Tab waits briefly when the new remote shell is not visible immediately", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t);
const sessions = new Map();
+71 -9
View File
@@ -16,7 +16,7 @@ const {
const { runWhenProxyConnectionReady } = require("../proxyUtils.cjs");
const { getAttachHomeWebContentsId } = require("../terminalAttachRestore.cjs");
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
const { openBoundedSshShellCallback } = require("../boundedSshChannelOpen.cjs");
const { openBoundedSshShellCallback, isSshChannelOpenRateLimitedError } = require("../boundedSshChannelOpen.cjs");
const {
annotateMacLocalNetworkErrorMessage,
resolveFirstTcpEndpoint,
@@ -237,21 +237,57 @@ printf '%s\n' '${scanCompleteMarker}'`;
available,
pids: available ? lines.filter((value) => /^\d+$/.test(value)) : [],
};
} catch {
return { available: false, pids: [] };
} catch (error) {
return {
available: false,
rateLimited: isSshChannelOpenRateLimitedError(error),
pids: [],
};
}
};
const waitForNewInteractiveShellPid = async (conn, previousPids) => {
const listInteractiveShellPidsResilient = async (conn, opts = {}) => {
const attempts = Math.max(1, Number(opts.attempts) || 1);
const backoffMs = Math.max(1, Number(opts.backoffMs) || 150);
const initialDelayMs = Math.max(0, Number(opts.initialDelayMs) || 0);
let last = { available: false, pids: [] };
for (let attempt = 0; attempt < attempts; attempt += 1) {
const delayMs = attempt === 0
? initialDelayMs
: backoffMs * attempt;
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
last = await listInteractiveShellPids(conn);
if (last.available || !last.rateLimited) return last;
}
return last;
};
const waitForNewInteractiveShellPid = async (conn, previousPids, opts = {}) => {
const previous = new Set(previousPids);
const initialDelayMs = Math.max(0, Number(opts.initialDelayMs) || 0);
const backoffMs = Math.max(1, Number(opts.backoffMs) || 50);
if (initialDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, initialDelayMs));
}
for (let attempt = 0; attempt < 5; attempt += 1) {
const discovery = await listInteractiveShellPids(conn);
if (!discovery.available) return null;
if (!discovery.available) {
// Bastion rate limits can reject the post-open discovery exec just
// after a retried shell open. Back off and try again instead of
// permanently leaving the copied session without a shellPid.
if (discovery.rateLimited && attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, backoffMs * (attempt + 1)));
continue;
}
return null;
}
const newPids = discovery.pids.filter((pid) => !previous.has(pid));
if (newPids.length === 1) return newPids[0];
if (newPids.length > 1) return null;
if (attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
await new Promise((resolve) => setTimeout(resolve, backoffMs * (attempt + 1)));
}
}
return null;
@@ -657,9 +693,24 @@ printf '%s\n' '${scanCompleteMarker}'`;
discoveryConnectionError = err;
};
let shellDiscoveryBeforeOpen = { available: false, pids: [] };
// Bastions (whether configured as jumpHosts or used as the direct SSH
// target) often rate-limit rapid session channel opens ("channelOpen too
// offen"). Retry rate-limited PID scans on every Copy Tab reuse so a
// successful shell open still gets shellPid tracking. Only the optional
// pre-scan pause is jump-host specific to avoid delaying the happy path
// on ordinary direct hosts.
const hasJumpHosts = Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0;
const configuredBackoffMs = Number(options.sshChannelOpenRateLimitBackoffMs);
const discoveryBackoffMs = Number.isFinite(configuredBackoffMs) && configuredBackoffMs > 0
? configuredBackoffMs
: 150;
if (!options.skipShellPidDiscovery) {
conn.once("error", onDiscoveryConnectionError);
shellDiscoveryBeforeOpen = await listInteractiveShellPids(conn);
shellDiscoveryBeforeOpen = await listInteractiveShellPidsResilient(conn, {
initialDelayMs: hasJumpHosts ? discoveryBackoffMs : 0,
attempts: 4,
backoffMs: discoveryBackoffMs,
});
conn.removeListener("error", onDiscoveryConnectionError);
}
const shellPidsBeforeOpen = shellDiscoveryBeforeOpen.pids;
@@ -733,6 +784,7 @@ printf '%s\n' '${scanCompleteMarker}'`;
conn.once("error", onConnError);
try {
const rateLimitBackoffMs = Number(options.sshChannelOpenRateLimitBackoffMs);
openBoundedSshShellCallback(
conn,
{
@@ -786,7 +838,14 @@ printf '%s\n' '${scanCompleteMarker}'`;
refHolder.connRef = null;
}
const newShellPidPromise = shellDiscoveryBeforeOpen.available
? waitForNewInteractiveShellPid(conn, shellPidsBeforeOpen)
? waitForNewInteractiveShellPid(
conn,
shellPidsBeforeOpen,
{
initialDelayMs: hasJumpHosts ? discoveryBackoffMs : 0,
backoffMs: discoveryBackoffMs,
},
)
: Promise.resolve(null);
void newShellPidPromise.then((newShellPid) => {
const liveSession = sessions.get(sessionId);
@@ -796,7 +855,10 @@ printf '%s\n' '${scanCompleteMarker}'`;
settled = true;
resolve({ sessionId });
});
}
},
Number.isFinite(rateLimitBackoffMs) && rateLimitBackoffMs > 0
? { rateLimitBackoffMs }
: {},
);
} catch (syncErr) {
// ssh2 can throw synchronously (e.g. "Not connected") if the borrowed
@@ -46,19 +46,71 @@ function buildZodSchema(inputShape) {
}
function formatRpcError(result) {
return `Error: ${result?.error || "Operation failed"}`;
if (result?.error) return `Error: ${result.error}`;
if (result?.code) return `Error: Operation failed (${result.code})`;
return "Error: Operation failed";
}
/**
* True when the RPC result carries command-run evidence (output and/or exit
* code). Non-zero exits from PTY exec set ok:false without an error string —
* that is still a completed command, not an operational failure.
*/
function hasTerminalExecuteEvidence(result) {
if (!result || typeof result !== "object") return false;
if (typeof result.stdout === "string" && result.stdout.length > 0) return true;
if (typeof result.stderr === "string" && result.stderr.length > 0) return true;
return result.exitCode != null;
}
function formatTerminalExecuteResult(result) {
const parts = [];
if (result.stdout) parts.push(result.stdout);
if (result.stderr) parts.push(`[stderr] ${result.stderr}`);
if (result.exitCode != null) {
if (result?.stdout) parts.push(result.stdout);
if (result?.stderr) parts.push(`[stderr] ${result.stderr}`);
if (result?.exitCode != null) {
parts.push(`[exit code: ${result.exitCode}]`);
}
if (result?.error) parts.push(`[error] ${result.error}`);
return parts.join("\n");
}
// Align with Catty executor wording when a command ran but produced no text.
const TERMINAL_EXECUTE_NO_OUTPUT_TEXT = "Command completed (no output)";
/**
* Format terminal_execute for MCP clients.
* Match Catty semantics: when the command actually ran, always surface
* stdout/stderr/exitCode so the model can judge the failure. Only pure
* operational failures (session missing, blocked, etc.) become a bare
* "Error: …" payload. See issue #2718.
*
* Successful empty results (serial/network raw PTY often returns ok:true with
* empty stdout/stderr and exitCode:null) must NOT fall back to
* "Error: Operation failed" — Codex P2 on PR #2724.
*/
function formatTerminalExecuteMcpResponse(result) {
if (!result || typeof result !== "object") {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
// Operational failure with no command evidence (session not found, busy, blocked…).
if (result.ok === false && !hasTerminalExecuteEvidence(result)) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
// Empty successful payloads (serial/raw PTY): neutral text.
// Never fall back to formatRpcError here — that mislabels silent successes (#2724 P2).
// When result.error is set, formatTerminalExecuteResult already includes `[error] …`.
const text = formatTerminalExecuteResult(result) || TERMINAL_EXECUTE_NO_OUTPUT_TEXT;
// Mark isError only for operational/runtime failures that include an error
// string (timeout, cancel, stream lost). Non-zero exit alone is success of
// the tool call with a failed command — same as Catty ok:true + exitCode.
if (result.ok === false && result.error) {
return { content: [{ type: "text", text }], isError: true };
}
return { content: [{ type: "text", text }] };
}
function createToolHandler(toolDef, deps) {
const {
rpcCall,
@@ -76,10 +128,10 @@ function createToolHandler(toolDef, deps) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall(rpcMethod, { ...scopeParams, sessionId, command });
if (!result.ok) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
if (TERMINAL_START_TOOLS.has(mcpTool)) {
if (!result?.ok) {
return { content: [{ type: "text", text: formatRpcError(result) }], isError: true };
}
return {
content: [{
type: "text",
@@ -94,7 +146,7 @@ function createToolHandler(toolDef, deps) {
}],
};
}
return { content: [{ type: "text", text: formatTerminalExecuteResult(result) }] };
return formatTerminalExecuteMcpResponse(result);
}
if (TERMINAL_POLL_TOOLS.has(mcpTool) || TERMINAL_STOP_TOOLS.has(mcpTool)) {
@@ -188,6 +240,10 @@ function registerMcpTools(server, deps) {
module.exports = {
buildZodSchema,
buildZodShapeObject,
formatRpcError,
formatTerminalExecuteResult,
formatTerminalExecuteMcpResponse,
hasTerminalExecuteEvidence,
registerMcpTools,
SFTP_WRITE_TOOLS,
};
@@ -235,3 +235,129 @@ test("session_close remains available as a cleanup action in observer mode", asy
assert.equal(result.isError, undefined);
assert.equal(guardCalls, 0);
});
test("terminal_execute MCP response preserves stdout/exitCode on non-zero exit (#2718)", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, _shape, candidate) {
if (name === "terminal_execute") handler = candidate;
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: false,
stdout: "du: cannot access '/missing': No such file or directory",
stderr: "",
exitCode: 1,
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
assert.ok(handler, "terminal_execute handler registered");
const result = await handler({ sessionId: "sess-1", command: "du /missing" });
assert.equal(result.isError, undefined);
const text = result.content?.[0]?.text || "";
assert.match(text, /cannot access '\/missing'/);
assert.match(text, /\[exit code: 1\]/);
assert.doesNotMatch(text, /Operation failed/);
});
test("terminal_execute MCP response keeps operational failures as isError", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, _shape, candidate) {
if (name === "terminal_execute") handler = candidate;
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({ ok: false, error: "Session not found" }),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "gone", command: "uptime" });
assert.equal(result.isError, true);
assert.equal(result.content?.[0]?.text, "Error: Session not found");
});
test("terminal_execute MCP response includes partial output on timeout", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, _shape, candidate) {
if (name === "terminal_execute") handler = candidate;
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: false,
stdout: "partial lines",
stderr: "",
exitCode: -1,
error: "Command timed out (60s)",
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "sess-1", command: "sleep 999" });
assert.equal(result.isError, true);
const text = result.content?.[0]?.text || "";
assert.match(text, /partial lines/);
assert.match(text, /\[exit code: -1\]/);
assert.match(text, /\[error\] Command timed out \(60s\)/);
});
test("terminal_execute MCP response uses neutral text for successful empty output (#2724)", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, _shape, candidate) {
if (name === "terminal_execute") handler = candidate;
},
};
// Serial/network-device raw PTY success: ok true, empty streams, exitCode null.
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: true,
stdout: "",
stderr: "",
exitCode: null,
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "sess-1", command: "configure terminal" });
assert.equal(result.isError, undefined);
assert.equal(result.content?.[0]?.text, "Command completed (no output)");
assert.doesNotMatch(result.content?.[0]?.text || "", /Operation failed/);
});
test("terminal_execute MCP response keeps exit-only non-zero without isError", async () => {
let handler = null;
const fakeServer = {
tool(name, _description, _shape, candidate) {
if (name === "terminal_execute") handler = candidate;
},
};
registerMcpTools(fakeServer, {
rpcCall: async () => ({
ok: false,
stdout: "",
stderr: "",
exitCode: 1,
}),
scopeParams: { chatSessionId: "chat-1" },
guardWriteOperation: () => null,
catalogDescription: (_name, fallback) => fallback,
});
const result = await handler({ sessionId: "sess-1", command: "false" });
assert.equal(result.isError, undefined);
assert.equal(result.content?.[0]?.text, "[exit code: 1]");
assert.doesNotMatch(result.content?.[0]?.text || "", /Operation failed/);
});
+70 -4
View File
@@ -5,7 +5,7 @@ const fs = require("node:fs");
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite");
const SCHEMA_VERSION = 2;
const SCHEMA_VERSION = 3;
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
@@ -21,6 +21,8 @@ const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
plugin_security_audit: ["id", "plugin_id", "event", "details_json", "created_at"],
// User-owned encrypted-sync sidecars: no FK cascade on package uninstall.
plugin_sync_sidecars: ["plugin_id", "kind", "key", "value_json", "updated_at"],
// Host-owned sync provider→plugin bindings (not plugin-writable secrets).
plugin_sync_provider_bindings: ["provider_id", "plugin_id", "created_at", "updated_at"],
});
function parseJson(text, label) {
@@ -164,12 +166,18 @@ class PluginDatabase {
);
CREATE INDEX plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
PRAGMA user_version = 2;
CREATE TABLE plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
} else if (version === 1) {
// Pre-sidecar schema-1 databases only created the original tables.
// Migrate in place to schema 2 with the non-cascade sidecar table.
// Migrate in place to schema 3 with sidecar + provider binding tables.
this.transaction(() => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_sync_sidecars (
@@ -182,7 +190,25 @@ class PluginDatabase {
);
CREATE INDEX IF NOT EXISTS plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
PRAGMA user_version = 2;
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
} else if (version === 2) {
this.transaction(() => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
}
@@ -759,6 +785,46 @@ class PluginDatabase {
return Number(result?.changes) || 0;
}
/** Find secret rows by exact key across all plugins (sync provider cleanup). */
findSecretsByKey(key) {
return this.db.prepare(`
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
FROM plugin_secrets WHERE key = ?
`).all(key).map((row) => this.#mapSecret(row));
}
upsertSyncProviderBinding(providerId, pluginId) {
const now = this.clock();
this.db.prepare(`
INSERT INTO plugin_sync_provider_bindings(provider_id, plugin_id, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(provider_id) DO UPDATE SET
plugin_id = excluded.plugin_id,
updated_at = excluded.updated_at
`).run(providerId, pluginId, now, now);
}
getSyncProviderBinding(providerId) {
const row = this.db.prepare(`
SELECT provider_id, plugin_id, created_at, updated_at
FROM plugin_sync_provider_bindings WHERE provider_id = ?
`).get(providerId);
if (!row) return null;
return {
providerId: row.provider_id,
pluginId: row.plugin_id,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
deleteSyncProviderBinding(providerId) {
const result = this.db.prepare(`
DELETE FROM plugin_sync_provider_bindings WHERE provider_id = ?
`).run(providerId);
return Number(result?.changes) || 0;
}
recordSecurityAudit(pluginId, event, details) {
let detailsJson = JSON.stringify(details ?? {});
const originalBytes = Buffer.byteLength(detailsJson);
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more