Commit Graph

203 Commits

Author SHA1 Message Date
陈大猫
79ccf47655 fix editor tab theme toggle (#1467) 2026-06-14 09:54:13 +08:00
陈大猫
f5c3302329 feat: terminal rename, closeSession shortcut, and pane zoom (#1459)
* feat: auto-poll Docker capabilities while Docker tab is active

When the Docker tab is visible and hasDocker is not yet true,
poll refreshCapabilities() at the process refresh interval.
Stop polling once hasDocker becomes true, or when switching
to a different tab.

* fix: use resolvedTab instead of activeTab for Docker auto-poll condition

The auto-poll useEffect condition used activeTab, which stays stale
when Docker becomes unavailable. Changed to resolvedTab which reflects
the actual displayed tab. Also updated the dep array.

* fix: replace setInterval with setTimeout recursion in Docker tab probe

Replace setInterval-based polling with setTimeout recursion in the Docker
tab capability probe effect. This ensures the next probe only starts after
the previous one finishes, avoiding overlapping probes when SSH timeout
exceeds the polling interval.

- Add dockerPollTimerRef to track the timeout handle
- Use async pollOnce() that awaits refreshCapabilities() before scheduling next
- Use cancelled flag in cleanup to prevent scheduling after unmount
- Keep same dependency array for correctness

* fix: stabilize docker poll timer by using useRef for refreshCapabilities

refreshCapabilities() can return a new reference on every render, causing
the useEffect to re-run on every render — cleanup cancels the polling timer,
then the effect immediately calls pollOnce(), effectively bypassing the
configured timeout interval.

Fix: store refreshCapabilities in a useRef (refreshRef), use
refreshRef.current() inside pollOnce(), and replace refreshCapabilities
with refreshRef in the useEffect dependency array.

Closes #PR1456 Codex P2 review item.

* fix: delay auto-poll first probe by one interval to avoid overlap with tab-switch probe

When switching to the Docker tab, two mechanisms were triggering probes:
1. tab-switch effect (line 67-76): immediate probe via refreshCapabilities()
2. auto-poll effect: pollOnce() executing immediately on mount

This caused duplicate probes that waste SSH channel resources.

Fix: pollOnce() no longer fires on mount. Instead, the effect schedules the
first probe with setTimeout(pollOnce, capabilitiesTtlMs), so the first probe
happens after one full interval. Subsequent probes continue at interval pace
via the setTimeout recursion in pollOnce itself.

The tab-switch effect still fires the immediate probe (the correct one),
so responsiveness on tab switch is preserved.

* fix: reset cancelledRef in effect body to prevent permanent stalling of Docker polling

The cancelledRef was set to true in the cleanup function when dependencies
changed, but never reset when the effect re-ran. This caused pollOnce to
always early-return on subsequent timer ticks, permanently halting
Docker capability probing after the first dependency change.

* fix(system-manager): replace cancelledRef with closure variables for per-effect cancellation

Each effect generation now has its own  and  closure
variables instead of shared  / . This
prevents stale probes from surviving cleanup when the panel hides and
re-shows (Codex P2 review).

* fix: wrap refreshCapabilities in try/catch to keep polling on exception

If refreshCapabilities throws (instead of returning {success: false}),
the await would exit pollOnce without scheduling the next setTimeout,
silently killing Docker auto-detection polling.

* fix: add in-flight probe guard to prevent tab-switch and auto-poll concurrent probes

Add probingRef to track whether a capabilities probe is already in-flight.
- Tab-switch effect for Docker branch checks probingRef before starting a new probe
- Auto-poll pollOnce checks probingRef at entry and sets/clears it around the actual probe
- Tmux branch left unchanged as it has no auto-poll overlap risk

* fix: re-schedule next poll timer when probe is in-flight

When probingRef.current is true (tab-switch probe still running),
pollOnce was returning early without scheduling the next timer,
causing auto-poll to stop permanently afterward.

Now it schedules the next poll within the interval and returns,
so the polling loop keeps running until a slot where no probe is
active.

* fix: convert comments to ASCII-only English

- Line 105: translate Chinese comment to 'probe is in-flight, reschedule for next cycle'
- Line 113: replace em dash (U+2014) with ASCII dash

* feat: session inline rename, closeSession shortcut, pane zoom

* fix: sidebar inline rename with local state

* fix: add sessionDisplayName to terminalPropsAreEqual comparator

The Terminal component is wrapped with React.memo(…, terminalPropsAreEqual),
but the comparator was missing a check for sessionDisplayName. After renaming
a session, the pane title bar would show the old name until some other prop
changed and triggered a re-render.

Add prev.sessionDisplayName === next.sessionDisplayName to the comparator
so that display name changes cause the Terminal to re-render immediately.

* fix: add onStartSessionRename to TerminalLayerWorkspaceSection ctx destructuring and TerminalPanesHost props

* fix: add toggleWorkspaceViewMode to executeHotkeyActionImpl destructuring

The togglePaneZoom handler calls toggleWorkspaceViewMode() but it
wasn't destructured from getCtx(), causing a ReferenceError at runtime.

* fix: restore truncated ctx object in TerminalView render call

The TerminalView ctx object literal on line 1265 was truncated to
'showSele...' due to an editing tool truncation bug, causing
Parsing error: ',' expected on npm run lint / tsc --noEmit.

Restored the missing fields from the base commit:
showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef,
sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef,
terminalBackend, terminalContextActions, terminalCwdTracker,
terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem

Kept the PR's new additions (isVisible, onRename, sessionDisplayName)
intact.

* fix: add toggleWorkspaceViewMode to executeHotkeyAction context and add terminal.menu.rename translations

- Add toggleWorkspaceViewMode to the context getter in executeHotkeyAction (App.tsx)
- Add terminal.menu.rename translation for en (Rename), zh-CN (重命名), ru (Переименовать)

* fix: validate focusedSessionId before closing in closeSession hotkey

When closeSession hotkey fires, workspace.focusedSessionId may reference
a session that was already closed by another trigger (e.g., mouse click
on tab close button). Collect alive session IDs from the workspace root
and fall back to the first living pane if the stored focusedSessionId
is stale.

* fix(auto-poll): check useSessionCapabilities probing state in pollOnce

When auto-poll timer fires before the initial probe (from
useSessionCapabilities) completes, probingRef.current is still false
because the initial probe doesn't set it — causing a second overlapping
probe.

Add  check so that any in-flight probe from any path
(initial/auto-poll/tab-switch) prevents auto-poll overlap.

PR #1459

* fix: address remaining Codex review issues

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

* feat: add detach session from workspace with toolbar button and context menu

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

* fix: use customName in pane header display name for renamed sessions

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

* fix: refine workspace terminal detach interactions

* fix: preserve workspace detach tab ordering

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 01:30:44 +08:00
陈大猫
910ef72205 [codex] Fix known host fingerprint coverage (#1442)
* Fix known host fingerprint coverage

* Tighten SFTP host key verification
2026-06-12 16:09:29 +08:00
陈大猫
e9a2e44a91 Improve terminal timestamp gutter (#1417) 2026-06-12 00:45:08 +08:00
陈大猫
a5b0efba75 fix(hotkeys): toggle quick switcher with the same shortcut (#1384)
Allow the quick switch shortcut to close the panel when pressed again,
including while the search input is focused, so users are not limited to
clicking outside to dismiss it.

Closes #1355

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 23:00:13 +08:00
陈大猫
8be5865b76 fix(terminal): isolate workspace pane font zoom from global settings (#1379)
Store per-session font size in workspace splits so Ctrl+wheel zoom no longer
changes sibling panes or reverts on blur when terminalSettings re-sync runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:38:39 +08:00
bincxz
037b85bd66 fix(ui): smooth work-tab chrome transitions and split-pane autocomplete
Replace immersive instant-switch with animated active chrome theme sync so
top tabs match terminal sessions immediately on tab click, and clamp
autocomplete popups to the active pane so they stay anchored to the cursor
in split workspaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 01:10:33 +08:00
bincxz
b1b0c5648c fix(terminal): stabilize tab switch follow-up 2026-06-09 03:40:18 +08:00
陈大猫
36e5779d94 perf(terminal): reduce terminal tab-switch and layout jank (#1321)
* perf(terminal): smooth layout drags and faster tab switching

Defer xterm refit during split, sidebar, and host-tree drags while keeping pane containers in sync with live layout measurements. Refactor TerminalLayer into focused sections with TabBridge/memo optimizations and add the terminal host tree sidebar.

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

* fix(terminal): keep side panels alive and guard session attach races

Prevent terminal boot unmount from leaking backend sessions, keep SFTP/scripts/theme/AI state when switching side tabs, and defer heavy SFTP UI mount so first entry stays responsive.

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

* perf(terminal): reduce tab switch jank

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 03:35:03 +08:00
陈大猫
b1f930a995 feat(sftp): 侧栏 SFTP 增加追随终端目录模式 (#1266) (#1310)
* feat(sftp): add follow terminal directory mode for sidebar (#1266)

Add a toolbar toggle that keeps the side-panel SFTP browser synced with the linked SSH terminal cwd, inspired by MobaXterm's follow-folder behavior.

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

* fix(sftp): probe pwd after commands when follow mode lacks OSC 7

Add a deferred getSessionPwd fallback after terminal commands when follow-terminal-cwd is enabled and the shell did not report OSC 7, and fix settings sync hook dependencies.

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

* fix(sftp): repair follow toggle UI and backend cwd sync fallback

Fix SftpPaneView memo skipping follow prop updates, probe fresh pwd when OSC 7 is missing, and broaden linked terminal session resolution for sidebar follow mode.

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

* fix(sftp): harden follow sync after review findings

Reuse shouldFollowTerminalCwdNavigate in production path, re-read connection
after async cwd probe, skip redundant navigate on toggle, and only probe pwd
when the SFTP side panel is open.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 19:28:30 +08:00
陈大猫
29a6172120 Add duplicate tab to new window
Adds a tab context menu action to duplicate a terminal into an independent peer window, with per-window active-tab titles and multi-window lifecycle safeguards.
2026-06-06 22:13:47 +08:00
bincxz
e9e8c35178 Add terminal and log timestamps 2026-06-06 17:04:33 +08:00
atoz03
e948a7a869 fix: route Cmd+W through existing tab close flow (#1234)
* fix: route Cmd+W through existing tab close flow

Keep the original tab-close behavior intact, and close the main or settings window only when there is no active closable tab to handle.

* fix: fall back to closing non-listener windows on Cmd+W

Treat BrowserWindow instances that do not participate in Netcatty's command-close bridge as regular closable windows. Keep the existing command-close path for the main and settings windows, and add tests that cover both the fallback close behavior and the renderer-capable send path.
2026-06-04 17:20:44 +08:00
陈大猫
8376e35022 fix: macOS 自动更新装不上(进程退不掉) (#1224)
* fix(auto-update): commit app to quit before quitAndInstall on macOS (#1215)

macOS in-place auto-update downloaded and unpacked the new version but
never installed it: the app appeared to close, ShipIt never ran, and no
restart happened. A full uninstall + reinstall did not help; only a
manual DMG replace worked.

Root cause is a code-level coordination bug, not the release pipeline.
The published mac zips are correctly Developer-ID signed, notarized, and
stapled (Team H7WS5L2ML4, consistent across 1.1.17 and 1.1.20), and
latest-mac.yml is well-formed — so Squirrel.Mac signature validation
passes. The failure is that quitAndInstall() drives app.quit() while two
normal-quit behaviors keep the process alive:

  1. the main-window close handler hides to tray when close-to-tray is
     enabled (it only closes when isQuitting is true), and
  2. the before-quit dirty-editor guard preventDefault()s the quit for a
     5s renderer round-trip.

Either keeps the parent process running, so Squirrel.Mac's ShipIt helper
— which waits on the parent PID to die before swapping the bundle —
lands in launchd "pending spawn / on-demand-only" limbo and the service
is removed without installing. This matches the reporter's diagnosis
exactly ("ShipIt 没有真正启动安装器", launchd on-demand-only).

Fix: before quitAndInstall fires app.quit(), mark the app as quitting
for an update via windowManager.setQuittingForUpdate(true). That sets
isQuitting (bypassing close-to-tray) and the before-quit handler now
returns early when isQuittingForUpdate() is true (skipping the
dirty-editor round-trip), so the process exits cleanly and ShipIt can
run. The same fix also covers the latent Windows NSIS case where
close-to-tray would block an in-place update.

If the install never actually quits the app (quitAndInstall throws, or
returns without app.quit() on a Squirrel follow-up error / stale
download), the quitting-for-update flags are rolled back — synchronously
on throw, and via a short unref'd watchdog otherwise — so the app does
not get stuck permanently bypassing close-to-tray and the quit guard.

Tests: unit tests for the new windowManager flags and for the install
handler — ordering (setQuittingForUpdate before quitAndInstall), tray
cleanup still runs, no-op when the updater fails to load, rollback on
synchronous throw, and watchdog rollback when the app never quits.

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

* fix(auto-update): keep dirty-editor guard during update install

setQuittingForUpdate only bypasses close-to-tray (so the window actually
closes and Squirrel.Mac's ShipIt can swap the bundle); it must NOT skip the
unsaved-work guard, or clicking "Restart Now" with a dirty SFTP editor would
silently lose edits. If the user cancels to save, the quit aborts and
autoUpdateBridge's watchdog clears the quitting-for-update flags.

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

* fix(auto-update): clear update-quit state when the quit is cancelled

When the user clicks "Restart Now" with a dirty editor open, the before-quit
guard cancels the quit (settle "stay"). The update path had already called
setQuittingForUpdate(true) (which flips isQuitting=true to bypass close-to-tray
for the install), so without clearing it the app stays in a quitting state —
close-to-tray and other !isQuitting-gated behavior bypassed — until the 10s
watchdog fires. Clear it immediately on the cancelled-quit path (#1215 review).

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

* fix(auto-update): check for unsaved editors before quitAndInstall (#1215)

The previous fix bypassed close-to-tray so the app process actually exits and
Squirrel.Mac's ShipIt can swap the bundle, while keeping the before-quit
dirty-editor guard as the unsaved-work safety net. But on macOS that net has a
hole: quitAndInstall() closes the window FIRST and only then fires before-quit.
Once setQuittingForUpdate(true) lets the main window truly close (instead of
hiding to tray), the before-quit guard can run after the window is already gone
— isReachableByUser is false, so it commits the quit and silently drops unsaved
SFTP edits.

Fix: move the dirty-editor check to the moment the user clicks "Restart Now",
in the install handler, BEFORE setQuittingForUpdate / quitAndInstall — while the
window and renderer are still alive:

  - dirty   -> abort the install (don't set the quitting flags, don't
               quitAndInstall) and broadcast netcatty:update:needs-save so the
               renderer prompts the user to save and retry.
  - clean   -> proceed with the existing flow (commit-to-quit, tray cleanup,
               quitAndInstall, watchdog).
  - no reachable main window / crashed renderer -> install directly (no user to
               ask), matching the before-quit fail-open path.

The before-quit dirty guard is kept as defense-in-depth: if the window is still
reachable it re-checks (clean, since we just verified), and if it's already gone
it lets the quit through — which is now safe because the install handler already
confirmed there were no unsaved editors.

The request/reply/timeout round-trip is extracted into a shared helper,
electron/bridges/dirtyEditorGuard.cjs (queryDirtyEditors), so the install
handler and main.cjs's before-quit guard use one implementation. main.cjs's
before-quit is refactored onto it (behavior preserved: sender-filtered reply,
fail-open timeout, and the setQuittingForUpdate(false) rollback when the user
cancels to save).

The needs-save notice is BROADCAST to every window, not just the queried main
window: "Restart to Update" can be clicked from the Settings window, which would
otherwise see the click do nothing. preload exposes onUpdateNeedsSave; the
subscription lives in useUpdateCheck (state layer), and both consumers — App.tsx
(main window) and SettingsPage (settings window) — pass an onNeedsSave callback
that shows an actionable toast ("save your editors, then click Restart Now
again") in en / zh-CN / ru.

Also lengthen the quitting-for-update rollback watchdog from 10s to 60s. On
macOS quitAndInstall() can return while Squirrel is still pulling the downloaded
ZIP from the local update server before it closes the windows; on a large/slow
update that can exceed 10s. Clearing isQuitting that early would let the eventual
native quit hit a non-quitting close-to-tray handler and strand the install
again — the exact #1215 failure. The longer window only fires when the app is
realistically stuck, at the cost of close-to-tray staying bypassed a little
longer in the rare genuine-failure case.

Tests: queryDirtyEditors (result / no-dirty / timeout / wrong-sender / dead or
crashed webContents / send-throws / no-ipcMain paths); install handler pre-check
(dirty -> no quitAndInstall + needs-save broadcast to all windows; clean ->
quitAndInstall runs; no main window -> installs without asking). Existing
install-handler tests updated for the now-async handler.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:05:09 +08:00
bincxz
afe959835d Add SSH debug log setting 2026-06-02 12:01:40 +08:00
pplulee
03cd9bc968 feat(snippets): implement snippet variable handling and UI prompts (#1159) 2026-05-31 18:01:43 +08:00
陈大猫
1fec5925eb Refactor large modules and fix runtime errors (#1136) 2026-05-28 15:12:19 +08:00
陈大猫
7771592cf2 feat(shortcuts): Ctrl+W closes the tab directly + add configurable side-panel toggle (#1098)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* feat(shortcuts): add resolveSidePanelToggleIntent pure resolver

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

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

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

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

* feat(shortcuts): dispatch toggleSidePanel hotkey to TerminalLayer

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 02:42:41 +08:00
陈大猫
d07859f604 [codex] Prevent terminal host preference pollution (#1026)
* Prevent terminal host preference pollution

* Preserve terminal host updates while isolating session ports
2026-05-20 11:51:54 +08:00
陈大猫
affd9217e2 Fix session log capture after reconnect (#1020) 2026-05-20 10:53:04 +08:00
陈大猫
b30696c98b Clean up dead code and duplicated helpers (#1001) 2026-05-18 20:00:10 +08:00
Bet4
ac819fd4fd feat(workspace): add focus sidebar drag reorder (#992) 2026-05-18 01:26:14 +08:00
陈大猫
ea5320d94a Fix #954: unify Tooltip styling + replace native selects (#961)
* Fix #954: unify Tooltip styling + replace native selects

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:22:12 +08:00
bincxz
4b7249997f Update changed known hosts in place 2026-05-09 23:42:08 +08:00
bincxz
bce33f34ee Fix SSH known host verification 2026-05-09 19:44:21 +08:00
陈大猫
621eae28f4 Merge pull request #918 from gorgiaxx/main
feat: Optimization of SSH Key Passphrase and Keychain
2026-05-09 16:17:46 +08:00
bincxz
2329014e22 fix: harden SSH key passphrase flows 2026-05-09 16:16:17 +08:00
bincxz
44abf420c2 Add hotkey to open Settings panel
Adds Cmd+, on macOS and Ctrl+, on Windows/Linux to open Settings,
matching the platform convention. Previously Settings was only
reachable via Vaults -> Settings (#912).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 11:28:51 +08:00
gorgiaxx
cb98bdba2b fix: Improve passphrase handling by purging cached passphrases only on specific errors 2026-05-08 23:44:10 +08:00
gorgiaxx
18d411bb95 fix: preserve reference SSH keys and retry passphrase prompts
Keep file-backed SSH keys intact across app restarts and keep bad key passphrases in the dedicated retry flow instead of falling back to generic SSH auth. Also clear invalid saved passphrases from both legacy storage and reference-key records after auth failures.
2026-05-08 18:50:40 +08:00
gorgiaxx
f1cfce45cf feat: Enhance SSH key management with reference key support and UI updates 2026-05-08 17:23:07 +08:00
gorgiaxx
72847a05af fix: Refactor passphrase handling: remove auto-responded keys tracking and related logic 2026-05-07 22:41:14 +08:00
gorgiaxx
8a44152b36 Add support for remembering SSH key passphrases and update UI accordingly 2026-05-07 17:38:17 +08:00
bincxz
72e305fb7a Add reusable proxy profiles 2026-05-06 17:33:46 +08:00
bincxz
155463f77c add reusable proxy profiles 2026-05-06 15:20:23 +08:00
陈大猫
1b08e5ee88 [codex] Fix SFTP editor saved state (#887)
* Fix SFTP editor saved state

* Restore window input focus after SFTP editor

* Harden SFTP editor save flows
2026-05-01 16:31:58 +08:00
陈大猫
e4e1b54374 Fix terminal custom accent color (#864) 2026-04-29 11:21:29 +08:00
陈大猫
4dd2465388 Keep known hosts local during sync (#863) 2026-04-29 11:01:21 +08:00
陈大猫
fb443541aa Optimize snippets shortcut behavior
Fixes #839
2026-04-28 21:21:46 +08:00
陈大猫
1fcf77ef4d Harden the dirty-editor quit guard (#853)
* Harden the dirty-editor quit guard

Follow-up to #840. Three concrete failure modes that round-2 review
turned up:

1. `webContents.send` is unguarded. If the renderer is destroyed
   between the reachability check and the send (e.g. a dying GPU
   process), the throw escapes the `before-quit` handler with
   `quitGuardChannelBusy = true` already set and no timeout scheduled
   yet — the app becomes un-quittable until restart. Wrap the send,
   and tear the listener/timer down on failure.

2. The timeout vs. response race silently commits a quit on
   `hasDirty=true`. Once `setTimeout` has already enqueued its
   callback for the next tick, `clearTimeout` is a no-op and the
   timeout callback runs even after the response arrived — which
   unconditionally calls `commitQuit()`, overriding the user's
   "save first" intent. Funnel both paths through a `settle()` helper
   that only acts the first time it's called.

3. The reply listener accepted any sender. A rogue or future-buggy
   `webContents` could decide the quit by sending the channel name
   first. Validate `evt.sender === wc` and ignore non-matches; switch
   from `.once` to `.on` + explicit `removeListener` so a rogue early
   reply doesn't consume the listener slot.

Also wrap the renderer-side handler in try/catch so an unexpected
throw inside `editorTabStore.getTabs()` reports `hasDirty=false`
immediately instead of stranding the main process for 5 s on a
silent timeout.

Verify `webContents.isCrashed()` before sending so a known-dead
renderer skips the round-trip and quits instantly instead of waiting
on the timeout fallback.

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

* Tighten dirty-editor quit-guard validation

Codex round-2-2 review suggested two small follow-ons:

1. Sender check should reject missing/falsy `evt.sender` outright. In
   real Electron IPC the sender is always populated; a falsy sender
   is anomalous and treating it as legit defeats the rogue-reply
   defence we just added.
2. Wrap `bridge.reportDirtyEditorsResult` in try/catch on the
   renderer side. If the IPC bridge is in a bad state and the call
   throws, the rest of the listener body is fine but the React
   useEffect callback would propagate the error — and an uncaught
   error in the listener would silently disable the quit guard for
   the rest of the session.

Both are pure tightening; no behaviour change on the happy path.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:13:23 +08:00
陈大猫
b9e9a0d59c feat(editor): promote SFTP text editor into top-level tabs (#631) (#808)
* chore: ignore local .worktrees/ directory

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

* feat(editor): editorTabStore scaffold with single-tab ops

Implements the EditorTabStore class singleton (matching activeTabStore pattern)
with updateContent, markSaved, setWordWrap, setSavingState, close, and subscribe.
Includes useSyncExternalStore hooks and 6 passing unit tests.

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

* feat(editor): editorTabStore promoteFromModal with per-session path dedup

* feat(editor): confirmCloseBySession for session teardown

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

* feat(sftp): writeTextFileByConnection for pane-agnostic saves

Adds a new `writeTextFileByConnection(connectionId, expectedHostId, filePath, content, filenameEncoding?)` method to `useSftpExternalOperations` that looks up the SFTP pane by connection ID (with a hostId safety check) instead of the left/right-side coupling used by `writeTextFile`. Threads the existing `getPaneByConnectionId` callback through the call site and re-exports the new method via `SftpStateApi`.

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

* feat(editor): editorSftpBridge singleton for out-of-React saves

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

* refactor(editor): extract TextEditorPane from TextEditorModal

Lift Monaco editor body + toolbar + theme sync + paste fallback into a
pure TextEditorPane component. Adds sftp.editor.maximize i18n key to
en.ts and zh-CN.ts locale files.

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

* refactor(editor): drop unused getLanguageId import in TextEditorPane

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

* refactor(editor): TextEditorModal delegates to TextEditorPane

Replace the monolithic modal (560 lines including full Monaco setup)
with a thin Dialog shell (~150 lines) that owns content/saving/saveError/
languageId state, save orchestration, and dirty-check on close, then
delegates all editor chrome to <TextEditorPane chrome="modal" />.

Exports TextEditorModalSnapshot for the optional onPromoteToTab callback
so callers can later wire tab promotion (Task 12) without breaking the
existing interface — the new prop is optional and existing callers
(SftpOverlays.tsx) are source-compatible with zero changes.

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

* fix(editor): include fileName and wordWrap in TextEditorModalSnapshot

Task 12 will populate the promoted tab with these fields, so the snapshot
must carry them from the modal at maximize time.

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

* feat(editor): UnsavedChangesDialog three-button confirm

* fix(editor): resolve UnsavedChangesDialog re-entrance and unmount leaks

- Re-entrance: if prompt() is called while a prior prompt is still pending,
  cancel the prior one so its caller doesn't hang forever.
- Unmount: resolve any in-flight prompt as "cancel" in the effect cleanup
  so awaiters don't leak when the provider unmounts.

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

* feat(editor): TextEditorTabView tab-form shell

Add TextEditorTabView component that binds an editorTabStore entry to
TextEditorPane, with CSS display:none toggling for inactive tabs so the
Monaco instance persists across tab switches.  Also adds setLanguage
public method to EditorTabStore (lands Task 15's intent early — Task 15
can be a no-op).

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

* fix(editor): read live store state in TextEditorTabView handlers

React state snapshot lags the store by a microtask. Closing over `tab`
meant a keystroke between Monaco's onChange and a Ctrl+S would write
stale content and mark a stale baseline. Read via editorTabStore.getTab
at call time instead.

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

* feat(editor): dispatch editor:* tab ids in App and activeTabStore

- Add EDITOR_PREFIX, isEditorTabId, toEditorTabId, fromEditorTabId helpers
- Add useIsEditorTabActive hook to activeTabStore
- Update useIsTerminalLayerVisible to exclude editor tabs
- Import useEditorTabs and TextEditorTabView into App.tsx
- Append editor tab ids (editor:<id>) to allTabs in hotkey handler
- Mount TextEditorTabView per editorTab with CSS visibility toggling
- Add editorTabs to executeHotkeyAction useCallback dependency array

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

* feat(editor): render editor tabs in TopTabs with icon/dirty/tooltip

- Add `fromEditorTabId`, `isEditorTabId` imports to TopTabs.tsx
- Add `FileCode`, `FileText` icons; use FileCode for code-like extensions
- Extend `TopTabsProps` with `editorTabs`, `onRequestCloseEditorTab`, `hostById`
- Build `editorTabMap` for O(1) lookup; add `editor` branch in `orderedTabItems`
- Render editor tab chrome matching terminal tab style: file icon, dirty dot (●),
  filename with disambiguation suffix for duplicate filenames, close button
- In App.tsx: add stub `handleRequestCloseEditorTab`, `orderedTabsWithEditors`,
  pass new props to `<TopTabs>`

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

* refactor(editor): hoist editor-tab code-extension regex and use onSelectTab

- Move CODE_EXTENSIONS_RE to module scope so it isn't recompiled per render.
- Call onSelectTab(tabId) for consistency with other tab types, instead of
  reaching into activeTabStore directly.

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

* feat(editor): maximize modal to tab and dirty-confirm tab close

Wire onPromoteToTab from TextEditorModal through SftpOverlays and
useSftpViewFileOps so clicking the maximize button snapshots editor
state into editorTabStore and activates the new editor tab.

Replace the stub handleRequestCloseEditorTab in App.tsx with a real
dirty-confirm flow using UnsavedChangesProvider render-prop: clean tabs
close immediately, dirty tabs prompt save/discard/cancel, and save
routes through editorSftpBridge with markSaved on success.

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

* feat(editor): register SFTP bridge and gate session close on dirty editor tabs

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

* fix(editor): make onDisconnect async so host-picker waits for dirty check

The session-close dirty gate added in Task 13 made onDisconnect async, but
the host-picker in SftpPaneDialogs still called it synchronously before
kicking off onConnect — a fire-and-forget that raced past the dirty prompt
and let unsaved editor tabs slip through. Propagate the Promise return type
through SftpPaneCallbacks / SftpPaneDialogs / useSftpViewPaneActionsResult
and await it at the host-picker call sites.

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

* feat(editor): block app quit while editor tabs are dirty

Add a before-quit IPC guard that asks the renderer whether any editor
tab has unsaved changes. If dirty tabs exist, preventDefault() blocks
the quit and a warning toast is shown. The app quits normally once
editors are clean.

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

* fix(editor): add 5s timeout fallback to quit-guard IPC check

If the renderer crashes or throws before reporting back, the quitGuard
would stay busy forever and the app could not be quit. Fall back to
force-quit after 5 s if no reply arrives.

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

* fix(editor): quit-guard uses quitConfirmed flag to prevent re-entry loop

The prior flow reset quitGuardChannelBusy before calling app.quit(), which
on macOS re-fires before-quit and re-entered the dirty check with the flag
cleared — creating an infinite IPC loop. Introduce a separate quitConfirmed
flag that commits to quitting before app.quit() fires, so the re-entry takes
the fast path.

Also extract QUIT_GUARD_TIMEOUT_MS and clarify that a concurrent quit while
a check is in flight is swallowed (preventDefault) rather than letting the
second event through.

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

* fix(editor): use absolute inset-0 for tab panel and add sr-only DialogTitle

Two bugs surfaced during the first dev-server smoke test:

1. Editor tab content was blank because TextEditorTabView used only
   className="h-full", while its sibling panels (VaultView, SftpView,
   TerminalLayerMount, LogView) all fill their flex-1 parent via
   `absolute inset-0`. In normal flow the editor tab collapsed to zero
   height. Match the sibling convention.

2. Radix printed an accessibility warning because the Task 7 refactor
   pulled the DialogTitle out of DialogContent and into the Pane header
   (now a plain span). Add a visually hidden DialogTitle that mirrors the
   filename, so screen readers have a title without showing it twice.

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

* fix(editor): raise tab panel z-index to 20 so it sits above TerminalLayer

TerminalLayer's root is visibility:hidden when the active tab is an editor
tab, but its inner panels set `absolute inset-0 z-10` on their own and those
still paint. Without an explicit z on the editor tab panel, TerminalLayer's
inner bg-background div was covering the Monaco content, producing a blank
screen.

Also add bg-background to the wrapper so the editor tab paints an opaque
surface (matches the pattern VaultViewContainer / TerminalLayer follow).

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

* feat(editor): show host label and remote path next to filename in tab header

The editor tab form previously only showed the bare filename in its header,
which is ambiguous when the same filename is open against multiple hosts.
Add an optional subtitle prop on TextEditorPane and populate it from the
tab form with `<hostLabel>:<remotePath>` rendered in muted text beside the
filename. The modal keeps its existing filename-only header.

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

* fix(editor): bridge supports multiple useSftpState instances

useSftpState is instantiated in both the top-level SftpView and the
terminal's SftpSidePanel, each owning its own pane registry. The editor
bridge previously stored only one writer, so maximizing a file opened from
the terminal side panel registered nothing (bridge was owned by SftpView
which may never have mounted) and save failed with "bridge not registered".

Change the bridge to track a Set of writers and dispatch by trying each
until one owns the connectionId (signalled by its specific "connection no
longer available" error). Add registerEditorSftpWriterScoped that returns
an unregister fn so each instance's cleanup removes only its own entry.
Register in both SftpView and SftpSidePanel.

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

* feat(editor): Cmd+W closes editor tab + terminal close forces tab close

Two behaviors added after user feedback from dev-server smoke-test:

1. Cmd/Ctrl+W (the closeTab hotkey) previously did nothing on editor tabs
   because executeHotkeyAction had no branch for editor:* ids. Add one that
   reaches into the UnsavedChangesProvider render-prop's close flow via a
   ref, routing through the existing dirty-confirm path.

2. Closing a terminal tab unmounts its SftpSidePanel which destroys the
   useSftpState instance that owned the connection. Any editor tab promoted
   from that panel would then be stuck — bridge gone, save channel dead.
   On SftpSidePanel unmount, gather the connection ids it owned and call a
   new editorTabStore.forceCloseBySessions to drop matching editor tabs.
   Dirty state is dropped because the user closed the terminal knowing the
   file was open — there is no save channel left anyway.

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

* fix(editor): Cmd/Ctrl+W works when focus is inside Monaco

Monaco's internal key-event dispatcher swallows keydown before the
capture-phase handler on the Pane's root div can see it, so the global
hotkey dispatcher never got the chance to close the editor tab when the
editor had focus. Register a Monaco editor command for the close-tab
keybinding and route it through a handleCloseRef — mirrors the same
pattern used for Cmd/Ctrl+S. Also drop the modal-only guard in the
capture-phase handler so the outer-chrome path works in tab mode too.

TextEditorTabView now receives an onRequestClose(tabId) prop that App.tsx
wires via the render-prop-exposed handleRequestCloseEditorTabRef, same
mechanism as the hotkey-dispatcher path.

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

* fix(editor): fall back to Vaults when forceCloseBySessions removes the active tab

Closing a terminal tab triggers SftpSidePanel unmount which force-closes its
editor tabs. If the editor tab being removed happened to be the active tab
(user maximized → then closed the owning terminal from another path), the
app ended up on a stale activeTabId with no selected tab and blank content.

Inside forceCloseBySessions, if the active tab was one of the removed
editor ids, redirect to 'vault'. Picking a more sophisticated neighbor
would need the full orderedTabs list which isn't reachable from this layer;
Vaults is always valid.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:03:38 +08:00
陈大猫
7c55381f39 Add terminals to workspace + New Workspace from QuickSwitcher (#790)
* Add terminals to workspace + New Workspace from QuickSwitcher

Two entry points share a single multi-select picker that lets the
user add Local Terminal + any combination of hosts into a workspace:

1. Focus-mode sidebar "+" button appends the selected targets to the
   active workspace as new panes.
2. QuickSwitcher "New Workspace" button (small inline action next to
   the Jump To hint) spins up a brand-new workspace tab populated
   with the selected targets.

## Changes

### domain/workspace.ts
- pruneWorkspaceNode now rebalances surviving siblings to EQUAL
  sizes after removal, instead of re-normalising the prior skew.
  Matches the "auto-redistribute on close" expectation.
- New appendPaneToWorkspaceRoot(root, sessionId, direction='vertical'):
  if root already splits in the requested direction, pushes the new
  pane onto its children and resets sizes to equal; otherwise wraps
  root + new pane in a new 0.5/0.5 split. Flattens long chains of
  appends instead of producing degenerate nested trees.

### application/state/useSessionState.ts
- appendHostToWorkspace(workspaceId, host, direction?) — atomic
  "build a session for this host and append it to the root", keeps
  activeTab on the workspace and focuses the new pane.
- appendLocalTerminalToWorkspace(workspaceId, options?, direction?)
  — mirror of the above for local shells.
- createWorkspaceFromTargets(targets, name?) — accepts a mixed list
  of {kind:'local',...} / {kind:'host',host} and creates a new
  workspace with one pane per target. Defaults viewMode to 'focus'
  so the QuickSwitcher flow lands in the sidebar layout.
- All three exported from the hook.

### components/workspace/AddToWorkspaceDialog.tsx (new)
QuickSwitcher-styled multi-select picker:
- Fixed top-center overlay, same chrome as QuickSwitcher (border,
  shadow, rounded-xl, borderless search input, bg-primary/15 cursor).
- Two sections: Local Shells (currently just Local Terminal) and
  Hosts. Hover follows keyboard cursor.
- Toggle rows with click or Space / Enter; ⌘/Ctrl+Enter submits;
  Esc closes. Right-side Check marks visible items.
- Thin footer bar with Cancel + "Add N" button.

### App.tsx
- Root-mounted single instance of AddToWorkspaceDialog with a
  discriminated-union state:
  { mode: 'append'; workspaceId } | { mode: 'create' } | null.
- onAdd dispatches based on mode — append loops through the picker
  targets calling the two append helpers; create calls
  createWorkspaceFromTargets once.
- TerminalLayer's focus "+" now sends an onRequestAddToWorkspace
  (workspaceId) up to App instead of owning its own dialog.
- QuickSwitcher's onCreateWorkspace callback repurposed to open the
  dialog in create mode (replaces the older CreateWorkspaceDialog
  route for this specific flow).

### components/TerminalLayer.tsx
- Dropped the inline AddToWorkspaceDialog + addHostPanelOpen state;
  replaced the two append callbacks with a single
  onRequestAddToWorkspace prop wired to the "+" button.
- Focus-sidebar header: replaced the "Terminals · N" counter with an
  immersive borderless search input (bg-transparent, shadow-none,
  termFg color) for filtering the terminal list; "+" and Columns2
  buttons moved to the right.
- Session list filtered client-side by the search term across
  hostLabel / hostname / username.

### components/QuickSwitcher.tsx
- Re-introduced onCreateWorkspace prop (was removed as unused).
- "New Workspace" inline button (Plus icon + label) sits on the
  right of the Jump To hint row: border, rounded, hover bg. Click
  fires onCreateWorkspace then closes QS.

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

* Add configurable New Workspace shortcut

Mirrors QuickSwitcher's "+ New Workspace" button via a keyboard
binding so the dialog can open in one keystroke without passing
through QS.

- domain/models.ts: new DEFAULT_KEY_BINDINGS entry id=new-workspace,
  action=newWorkspace, default ⌘+Shift+J (Mac) / Ctrl+Shift+J (PC).
  Audited the defaults — only quick-switch uses J (⌘+J), so the
  shifted combo is free. The binding sits in the 'app' category so
  it shows up in Settings → Shortcuts and can be rebound by the user.
- application/state/useGlobalHotkeys.ts: wire newWorkspace into the
  HotkeyActions interface, getAppLevelActions() allowlist, and the
  global keydown switch so the scheme-driven handler dispatches it.
- App.tsx: handle case 'newWorkspace' inside executeHotkeyAction by
  calling setAddToWorkspaceDialog({ mode: 'create' }) — same entry
  as QuickSwitcher's button, just without having to open QS first.
- application/i18n/locales/zh-CN.ts: add '新建工作区' translation for
  settings.shortcuts.binding.new-workspace. English falls back to
  the KeyBinding.label field ("New Workspace"), so no en.ts change.

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

* Address codex P1: don't check setState flag after the updater returns

Codex flagged that appendHostToWorkspace / appendLocalTerminalToWorkspace
were racy: both flipped an `inserted` flag inside setWorkspaces'
updater and then read it synchronously to decide whether to commit
the matching session via setSessions. React does NOT guarantee
updaters run synchronously (concurrent rendering, StrictMode
double-invoke, etc.), so the flag could still be false at the read
site even though the workspace exists. In that case setSessions was
skipped while the queued workspace update could still insert a new
pane referencing newSessionId — leaving a pane with no backing
session in state.

Fix: add a workspacesRef kept in sync with the workspaces state on
every render, and perform the existence check synchronously *before*
queuing any setState. Once we've confirmed the workspace exists on
the latest committed state, both setWorkspaces and setSessions are
called unconditionally, so they can never diverge.

The ref approach also correctly handles the multi-target append
loop path — React batches the updaters and applies them in sequence,
so sibling pane/session writes land in matching order.

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

* Address codex P1+P2: narrow prune rebalance; append in root direction

### P1 — pruneWorkspaceNode over-rebalanced ancestor splits

The equal-sizes rebalance was unconditional during the recursive
walk, so closing a pane deep in one branch also rewrote unrelated
ancestor ratios (e.g., a root 0.8/0.2 vertical split got normalised
to 0.5/0.5 when a grand-child horizontal pane closed).

Now each split level tracks whether it actually lost a DIRECT
child. Only splits where a direct child disappeared get their
siblings reset to equal sizes. Ancestors whose direct children all
survived keep their original ratios (defensively re-normalised in
case a descendant subtree collapsed shape).

### P2 — Append path ignored the root's current direction

onAdd in App.tsx called the two append helpers without a direction,
so both defaulted to 'vertical'. appendPaneToWorkspaceRoot only
flattens into the root split when the directions match; if the
workspace root was horizontal (e.g., user split top/bottom earlier),
each append wrapped the entire existing tree into one side of a new
vertical split — existing panes crammed into one branch, new pane
hoarding half the space.

Read the current root direction out of the target workspace and
pass it down so new panes become peers of the existing root
siblings regardless of horizontal vs vertical.

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

* Address codex P2: allow serial hosts in create-workspace picker

The picker used to filter out every host with protocol='serial'
regardless of mode. That was correct for append mode (the
appendHostToWorkspace helper has no serial path and early-returns)
but a regression for create mode — the old createWorkspaceWithHosts
flow passed serial hosts through and createWorkspaceFromTargets
still builds a SerialConfig-backed session for them, so there was
no reason to block them in the "+ New Workspace" entry.

Move the filter from the dialog up to App.tsx:
- AddToWorkspaceDialog drops the serial filter; selectableHosts is
  simply the hosts prop.
- App.tsx passes `hosts.filter(h => h.protocol !== 'serial')` when
  mode is 'append', and the full list when mode is 'create'.
Result: users can once again build a workspace from serial hosts
via QuickSwitcher's "+ New Workspace" button or the ⌘/Ctrl+Shift+J
hotkey, while append-to-existing keeps its earlier safe behaviour.

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

* Address codex P2: don't commit session when append target disappears

Follow-up to the earlier ref-based guard. The ref check eliminates
the common "workspace already gone" case but still leaves a small
race: if closeWorkspace runs between the ref read and setWorkspaces'
updater firing, prev.map returns the unchanged workspaces but
setSessions / setActiveTabId still execute — leaving an orphan
session whose workspaceId points at a deleted workspace and jumping
activeTabId to a closed tab.

Nest setSessions + setActiveTabId inside the setWorkspaces updater
so the writes are gated on the same authoritative match used for
the tree update. The setSessions updater also de-dupes by newSessionId
so React 18 StrictMode's dev-time double-invoke of the outer updater
doesn't append the same row twice. Same pattern applied to
appendLocalTerminalToWorkspace.

The existing closeSession already uses the nested-setState shape, so
this matches the codebase convention.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 01:19:33 +08:00
陈大猫
8ca09b1616 Add right-click Edit/Delete to sidepanel snippets (#780) (#787)
The terminal-side ScriptsSidePanel was the surface the #780 reporter
was actually looking at when they asked for right-click delete/modify
on snippets. PR #783 closed the issue by adding a trash icon in the
Vault edit panel, but the sidepanel snippet rows were still plain
<button>s with no context menu — so the original complaint
("右键可以弹出一个菜单, 可以包含'删除, 修改'等操作") remained unaddressed
at the exact spot the screenshot came from.

Changes:

- ScriptsSidePanel: wrap each snippet row in a ContextMenu with Edit
  and Delete items. Menu actions dispatch window events instead of
  threading new callbacks — matches the existing netcatty:snippets:add
  pattern the + button already uses.
- QuickAddSnippetDialog: accept an optional onUpdateSnippet prop and
  listen for netcatty:snippets:edit. Prefills label/command/package
  from the dispatched snippet, and on save preserves the snippet's
  original tags/targets/shortkey/noAutoRun (the dialog only exposes
  the three quick-edit fields). Title flips to snippets.panel.editTitle
  in edit mode.
- App.tsx: pass onUpdateSnippet wired to updateSnippets(map-replace),
  and register a window listener for netcatty:snippets:delete that
  filters the deleted id out of snippets. Delete needs no UI so it
  doesn't go through a dialog.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 22:36:52 +08:00
陈大猫
156550f7eb Add Close All / Others / To-the-Right tab actions (#748) (#764)
Adds three bulk-close items to the right-click context menu on tabs:
- Close Others
- Close Tabs to the Right
- Close All

Anchor is the right-clicked tab (matches VSCode/JetBrains/FinalShell
UX), not the active tab. The "to the right" item is disabled when the
anchor is already the rightmost tab; "Close Others" is disabled when
it's the only tab.

To avoid spamming a busy-shell modal per tab, the new closeTabsBatch
helper in App.tsx expands workspace ids into their session ids, runs
ONE confirmIfBusyLocalTerminal probe across the whole batch, and only
proceeds when the user confirms. The probe + close path itself reuses
the existing PR #739 plumbing (ptyProcessTree + confirmCloseBusy).

Closes #748

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:40:11 +08:00
陈大猫
8ef91e1266 Ctrl+W close priority + local shell busy confirmation (#739)
* feat(ctrl-w): add ps-node + windows-process-tree + tsx deps for close-priority feature

* fix(ctrl-w): drop ps-node dep and add windows-process-tree to asarUnpack

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

* feat(ctrl-w): add ptyProcessTree bridge with per-platform child-process enumeration

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

* fix(ctrl-w): ptyProcessTree uses args= for full command + warns on pid overwrite

- Replace `comm=` with `args=` in defaultListPosix so the full command
  line is captured on both macOS (BSD ps) and Linux (GNU ps), avoiding
  the 15-char TASK_COMM_LEN truncation.
- Add console.warn in registerPid when the same sessionId is overwritten
  with a different pid, making the race condition visible in logs.
- Add test: registerPid warns exactly once on a pid change, not on a
  same-pid re-registration.

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

* feat(ctrl-w): register local PTY pid with ptyProcessTree on spawn/exit

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

* fix(ctrl-w): unregister pids in cleanupAllSessions to match per-delete invariant

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

* feat(ctrl-w): add IPC handlers for pty child processes and confirm-close dialog

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

* fix(ctrl-w): guard BrowserWindow.fromWebContents null and document dialog dismiss contract

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

* feat(ctrl-w): expose ptyGetChildProcesses and confirmCloseBusy on window.netcatty

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

* feat(ctrl-w): add i18n strings for close-busy-terminal dialog

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

* feat(ctrl-w): add resolveCloseIntent pure function with 8 unit tests

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

* feat(ctrl-w): expose handleCloseSidePanel via ref to App.tsx

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

* feat(ctrl-w): wire resolveCloseIntent + local-shell busy confirmation into closeTab hotkey

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

* fix(ctrl-w): add re-entrancy guard, aggregate busy count, sync sidebar ref, dedupe intent branches

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

* feat(ctrl-w): auto-close workspace when its last session is closed

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

* fix(ctrl-w): sidebar close wins over focused terminal in priority chain

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

* fix(ctrl-w): sidebar priority applies to single-session tabs too, not just workspaces

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

* fix(ctrl-w): compute empty-workspace auto-close outside setSessions updater

Addresses Codex P2 on #739: React 18+ does not guarantee updater
execution timing under concurrent scheduling. Moving the decision
outside the updater makes the microtask queue deterministic.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:30:11 +08:00
陈大猫
db69d5ac39 [codex] Harden sync overwrite protection and add local restore history (#720)
* fix: harden sync overwrite recovery

* refactor: separate backup retention settings

* refactor: align backup retention controls

* refactor: simplify backup retention card

* fix: address PR #720 deep-review findings

- Close the cross-window restore race by holding a time-bounded barrier
  in localStorage during every destructive apply; useAutoSync skips
  pushes while it's set, preventing a pre-restore snapshot from
  clobbering just-restored cloud data.
- Round-trip startup three-way merges so merged-in local additions
  actually reach the cloud instead of living only on the device that
  ran the merge until the next edit.
- Upgrade sync signatures from a 64-char ciphertext prefix to full
  SHA-256 (v3), closing the tail-mutation replay weakness.
- Harden the vault-backup IPC: payload size cap, enum-validated reason,
  sanitized version strings, strict maxCount, concurrent-call mutex,
  monotonic createdAt to avoid same-ms ordering ties.
- Extract the anchor-change decision into a pure module with unit tests
  covering no-anchor, resource-id drift, and signature mismatch paths.
- Capture the protective backup from the pre-apply closure snapshot so
  it reflects what's being replaced rather than what was imported.

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

* fix: address PR #720 follow-up review findings

Make protective backup abort-on-failure (was best-effort console.error),
preserve nested syncedAt in fingerprint, use UTF-8 byte length for size
guard, throw on conflict-inspect failure so stale uploads can't leak
through, treat unreadable remote as changed, canonical-JSON signature
meta, and hold the version stamp on transient backup failures so the
retry path still fires.

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

* fix: address second-pass review findings on PR #720

- Hold version-change stamp when payload is non-meaningful (covers the
  startup vault-rehydrate race where a transient empty snapshot would
  permanently skip the upgrade backup).
- readBackupRecord stat-checks before readFile so an oversized file in
  the backup dir cannot OOM the renderer on enumeration.
- Reject maxBackups input outside 1..100 instead of silently clamping
  (matches the i18n error copy and the main-process sanitizer bound).
- Wrap USE_LOCAL conflict-resolution push in withRestoreBarrier so a
  concurrent auto-sync in another window cannot interleave.
- sha256Hex throws SyncSignatureUnavailableError on missing WebCrypto
  subtle; createSyncedFileSignature returns null, forcing the
  unreadable-remote → three-way-merge path instead of a weak
  length-only pseudo-signature.
- Document that array order in normalizePayloadForHash is an invariant
  enforced by producers, not the hash function.
- Drop three-way-merge completion logs from console.log to console.info.
- Comment the implicit restore → store-listener refresh chain so
  future refactors don't silently break the UI reload path.

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

* fix: address third-pass review findings on PR #720

Resolves I-3 through I-8 and related cleanup items identified in the
deep review. Highlights:

- replace setTimeout(0) post-merge round-trip with a direct
  syncAllProviders call using the already-computed merged payload,
  removing the React-commit race
- resolve the empty-vault confirmation promise on unmount so a
  mid-dialog window teardown doesn't leak the resolver
- retry the version-change backup as hosts/keys hydrate, instead of
  latching on the first (possibly empty) snapshot
- heartbeat-refresh the cross-window restore barrier so long applies
  cannot expose a post-60s window to concurrent auto-sync
- add a diagnostic warning when connected providers hold divergent
  bases (multi-account configurations)
- surface a user-visible "Sync paused" toast when startup inspect
  fails, replacing the previous silent gate-open
- tie-break backup list sort by id when createdAt collides
- extract applyProtectedSyncPayload so the main and settings windows
  cannot drift on restore-barrier / protective-backup handling

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

* fix: address deep-review findings on PR #720

Deep re-review surfaced six Important issues that survived the prior
four review rounds. All are hardened here:

- I1: fsync the protective backup file AND its directory before the
  rename completes, so a system crash between backup creation and the
  restore it guards cannot leave a torn/zero-length safety net.
- I3: persist an apply-in-progress sentinel across the non-atomic
  localStorage writes in applySyncPayload. A crash mid-apply now
  surfaces on the next startup (toast + refuse auto-push) instead of
  silently pushing the half-applied state over an intact cloud copy.
- I2: only open the auto-sync gate (remoteCheckDoneRef) when the
  startup inspect validated cleanly. Add a bounded exponential-backoff
  retry so a transient inspect failure self-heals instead of wedging
  auto-sync until restart.
- I5: save the sync base BEFORE advancing the per-provider anchor
  inside uploadToProvider. A renderer crash between the two writes
  now degrades to "stale anchor forces re-inspect on next run," which
  re-merges against the fresh base — eliminating the silent
  base-drift window where a 3rd-device race could misclassify
  entries.
- I6: main process broadcasts a vaultBackups:changed IPC event on
  every mutation; useLocalVaultBackups subscribes so protective
  backups created from the main window show up in the Settings
  backup list without manual refresh.
- I4: update PR description + code comment to match the actual
  (safer) design: auto-sync gate opens on vault init, with
  hasMeaningfulSyncData + restore barrier preventing empty-push; the
  version-change backup is best-effort and retries as data hydrates.

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

* fix: serialize startup checkRemoteVersion and stabilize its deps

Re-review flagged that checkRemoteVersion's useCallback depended on
`config` — a fresh object literal from App.tsx on every render — so
the retry effect restarted with attempt=0 on every vault edit and
could spawn overlapping in-flight inspect+apply runs. Two concurrent
commitRemoteInspection + onApplyPayload calls could race on the
apply-in-progress sentinel around interleaved writes.

Route `buildPayload`, `config.onApplyPayload`, and `config.startupReady`
through refs so checkRemoteVersion's identity no longer churns with
unrelated App state. Add an in-flight guard that returns early when a
previous invocation is still awaiting the network, closing the
same-window re-entry gap that withRestoreBarrier intentionally doesn't
cover.

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

* fix: release in-flight lock on no-connected-provider early return

Third-pass review caught that `checkRemoteInFlightRef` was acquired
before the `!connectedProvider` check, so that early return leaked
the lock and every subsequent retry-timer tick silently no-op'd.
Move the acquisition past the early return so the only path that
takes the lock reaches the finally-release.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 03:09:55 +08:00
bincxz
1ca2cd8ec2 feat: add "paste selection" terminal command with bindable shortcut
Adds a new terminal action that pastes the terminal's current selection
at the cursor without going through the system clipboard — the equivalent
of X11 PRIMARY-selection paste. Default shortcut: ⌘ + Shift + X / Ctrl + Shift + X.

Also surfaces the action in the terminal right-click menu, disabled when
there is no selection. Does not change middle-click paste behavior.

Closes #637
2026-04-14 16:22:51 +08:00
bincxz
b6e8d63fef fix: remove SFTP from QuickSwitcher when SFTP tab is hidden
Per Codex P2 review on #700: QuickSwitcher always listed an 'sftp' tab
item, but with showSftpTab off the App-level redirect bounces the user
straight back to Vault. That left a dead entry in quick-switch — selecting
it appeared broken.

Thread showSftpTab through QuickSwitcher and skip the SFTP item in both
the flat item list (used for keyboard selection indexing) and the
rendered built-in Tabs row when the top tab is hidden. Keeps every
SFTP navigation surface consistent with the visibility setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:13:31 +08:00