Compare commits

...

68 Commits

Author SHA1 Message Date
陈大猫
cb5333e336 Merge pull request #320 from binaricat/codex/sftpmodal-parent-entry
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Fix SFTP modal parent navigation in empty directories
2026-03-12 19:00:41 +08:00
bincxz
d3153148c8 Fix SFTP modal empty directory parent navigation 2026-03-12 18:57:19 +08:00
陈大猫
899cb109b4 Merge pull request #319 from binaricat/codex/fix-sftp-drop-target-race
fix: keep terminal drag-drop uploads on the resolved SFTP path
2026-03-12 18:47:15 +08:00
bincxz
d031bf355d fix: use resolved sftp path for initial auto upload 2026-03-12 18:40:24 +08:00
bincxz
489b7711f5 fix: pin terminal drop uploads to the resolved sftp path 2026-03-12 18:07:10 +08:00
陈大猫
65877fd912 feat(sync): include snippetPackages in cloud sync payload (#318)
* feat(sync): include snippetPackages in cloud sync payload (#315)

Snippet packages (the grouping tree for code snippets) were not included
in the cloud sync payload, causing them to be lost when syncing across
devices. This adds snippetPackages as an optional field following the
same backward-compatible pattern used by knownHosts and
portForwardingRules: old payloads that lack the field leave local
packages untouched.

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

* fix: make snippetPackages optional in SyncableVaultData for consistency

Aligns with the pattern used by knownHosts — optional in both
SyncableVaultData and SyncPayload so that legacy data without the field
is handled gracefully. Also updates the SyncPayloadImporters docstring.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:02:52 +08:00
陈大猫
117ec260b6 fix: address issue #294 follow-up regressions (#316)
* fix: address issue 294 regressions

* fix: scope sftp hidden files toggle per pane

* fix: restore terminal auto-follow behaviors

* fix: keep keypress auto-scroll scoped to keypress

* feat: add hidden files toggle to sftp modal

* fix: tighten sftp and terminal review findings
2026-03-12 16:19:22 +08:00
陈大猫
c76ff7ac9a Merge pull request #317 from yuzifu/feat-support-almalinux
feat: support almalinux distro
2026-03-12 15:37:21 +08:00
yuzifu
17da21b1cd feat: support almalinux distro 2026-03-12 14:49:54 +08:00
陈大猫
733e36a728 Merge pull request #314 from penguinway/feat/auto-update-unified
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
LGTM! 经过多轮 review 和修复,代码质量已经很好。

核心改进:
- 统一 useUpdateCheck 作为跨窗口的单一更新状态源
- 多窗口 IPC 广播(broadcastToAllWindows)
- 启动检查竞态缓解(8s delay + onUpdateAvailable/NotAvailable 取消)
- dismissed version 在 renderer 侧完整支持
- electron-updater fallback(GitHub API 不可用时)
- _isChecking 标记防止并发 checkForUpdates 调用

感谢合并!
2026-03-11 20:34:20 +08:00
bincxz
35174246cc fix(update): handle checking sentinel, restore dismiss for unsupported platforms
- Handle { checking: true } response from bridge.checkForUpdate()
  separately instead of treating it as "no update" — an in-flight
  check will resolve via IPC events
- Restore dismissUpdate() in "View in Settings" toast onClick so
  unsupported-platform users can suppress the notification; on
  supported platforms the Settings window picks up download state
  via IPC events independently

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:24:54 +08:00
bincxz
ab13670eaa fix(update): sync dismissed ref for late windows, clear error on fallback success
- Set dismissedAutoDownloadRef when hydration skips a dismissed version
  so subsequent IPC events (progress/downloaded) are also suppressed
- When GitHub API fails but electron-updater fallback finds no update,
  clear manualCheckStatus from 'error' to 'up-to-date' instead of
  leaving Settings stuck in error state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:18:50 +08:00
bincxz
4f3e39e378 fix(update): fall back to electron-updater when GitHub API fails
When checkNow's GitHub API call fails (blocked/rate-limited), still
trigger electron-updater's checkForUpdate as a fallback. This restores
the update path for environments where api.github.com is unreachable
but the updater feed is still accessible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:12:18 +08:00
bincxz
2281d1df68 fix(update): don't throttle GitHub fallback from updater not-available
Remove STORAGE_KEY_UPDATE_LAST_CHECK write from onUpdateNotAvailable
handler — it would prevent the GitHub API fallback from running on app
restart, hiding releases that exist on GitHub but aren't yet in the
electron-updater feed. Let performCheck write the timestamp instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:05:47 +08:00
bincxz
e570185e2f fix(update): don't dismiss when navigating to Settings from toast
- Remove dismissUpdate() from "View in Settings" toast onClick — writing
  to STORAGE_KEY_UPDATE_DISMISSED_VERSION would cause the Settings window
  hydration to skip download state, making it appear idle
- Remove unused dismissUpdate import from App.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:58:33 +08:00
bincxz
12884165b5 fix(update): preserve GitHub fallback on not-available, recheck on reschedule
- Don't cancel startup GitHub API fallback when electron-updater says
  not-available — the GitHub release may exist before updater feed
  assets are published, and the fallback provides manual download link
- Rescheduled fallback now re-queries getUpdateStatus to avoid duplicate
  notifications on very slow networks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:52:04 +08:00
bincxz
11f82defc3 fix(update): use dismissed ref instead of idle check, preserve GitHub fallback
- Replace autoDownloadStatus==='idle' guard in progress/downloaded/error
  callbacks with a dedicated dismissedAutoDownloadRef to distinguish
  "dismissed version" from "not hydrated yet" in late-opening windows
- Don't clear hasUpdate on update-not-available — GitHub release may
  exist even when electron-updater feed says no compatible update,
  preserving the manual download fallback path
- Reset dismissedAutoDownloadRef on manual retry via checkNow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:45:24 +08:00
bincxz
ac9175b770 fix(update): reschedule fallback on in-flight check, clear stale state
- When the startup fallback sees the main process check still in flight,
  reschedule after 5s instead of permanently skipping — handles the case
  where the auto-check fails silently (check-phase errors not broadcast)
- onUpdateNotAvailable: clear hasUpdate and manualCheckStatus to remove
  stale "update available" state from earlier GitHub API checks, since
  the updater feed is authoritative on supported platforms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:38:02 +08:00
bincxz
71c6f68934 fix(update): honor dismissed version in manual check, clear isChecking safely
- checkNow: check dismissed version before marking status as 'available'
  to prevent re-downloading a release the user explicitly skipped
- startAutoCheck: verify updater exists before setting _isChecking flag
  to avoid permanent stuck state when electron-updater fails to load
- Clear _isChecking in all catch paths to prevent stuck state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:32:57 +08:00
bincxz
01bee794ee fix(update): track in-flight check state to prevent concurrent races
- Add _isChecking flag in autoUpdateBridge to track whether
  checkForUpdates is in flight; return sentinel when manual check
  arrives during an active auto-check instead of starting a concurrent
  call that electron-updater would reject
- Include isChecking in getUpdateStatus snapshot so the renderer can
  query it before starting the GitHub API fallback
- Startup fallback now checks getUpdateStatus().isChecking to skip
  when electron-updater is still checking on slow networks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:23:53 +08:00
bincxz
29dc01306d fix(update): make auto-check cancellable, persist no-update check time
- Store startAutoCheck timer ID so it can be cancelled; cancel it when
  the renderer triggers a manual checkForUpdate to avoid concurrent
  electron-updater calls that produce false errors
- Record lastCheckedAt and STORAGE_KEY_UPDATE_LAST_CHECK when
  update-not-available fires so the throttle works on the common
  no-update path and "Last checked" UI shows correctly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:17:53 +08:00
bincxz
0dcfd1489b fix(update): eliminate redundant startup check, serialize manual checks
- Broadcast 'update-not-available' from electron-updater to renderer so
  the startup GitHub API check is cancelled when no update exists
- Cancel pending startup timeout in checkNow() to prevent racing with
  electron-updater's startAutoCheck (concurrent calls cause false errors)
- Add onUpdateNotAvailable bridge event (preload + global.d.ts types)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:09:48 +08:00
bincxz
72f61141c4 fix(update): respect dismissed version in hydration, don't cache failed checks
- getUpdateStatus hydration: skip restoring download state for dismissed
  versions so late-opening windows don't show dismissed release UI
- performCheck: only advance lastCheck timestamp and cache release data
  on successful checks — failed checks no longer suppress re-checks for
  an hour while leaving stale cached release visible

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 19:03:15 +08:00
bincxz
37150ea379 fix(update): suppress download UI for dismissed versions, cancel racy startup check
- onUpdateAvailable: skip autoDownloadStatus→'downloading' transition when
  version is dismissed, preventing download progress/ready toasts
- onUpdateAvailable: cancel pending startup GitHub API check timeout to
  eliminate race where electron-updater is still checking at 8s
- onUpdateDownloadProgress/Downloaded/Error: suppress state transitions
  when autoDownloadStatus is 'idle' (dismissed version background download)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:55:51 +08:00
bincxz
5706af3f33 fix(update): don't falsely report up-to-date, fix stale hydration
- Return idle instead of up-to-date for dev/invalid builds in
  checkNow to avoid false positive status
- Replace stale cached release in getUpdateStatus hydration when
  the snapshot reports a different version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:45:18 +08:00
bincxz
6871c82ab8 fix(update): semantic version compare, restore dismiss, show fallback on error
- Use semantic version comparison for cached release hydration to
  avoid false positives when running a newer build than latest release
- Restore dismissUpdate() in startup toast so unsupported-platform
  users can silence repeated notifications
- Remove dismissed-version check from ready-to-install toast since
  dismissing availability should not block the install prompt
- Show manual download link in Settings on check errors too

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:38:10 +08:00
bincxz
b90ff692eb fix(update): hydrate cached release for late windows, fix dismiss flow
- Persist latestRelease to localStorage so windows opened after the
  initial check can hydrate release info without re-fetching
- Remove dismissUpdate() from "View in Settings" toast click — the
  dismissed version key was preventing the later install-ready toast
- Hydrate cached release data when startup check is throttled so
  Settings windows show the already-found update

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:31:23 +08:00
bincxz
ce71725dba fix(update): handle unsupported platforms, remove auto-check, update badge
- Don't treat unsupported auto-update platforms as download errors;
  keep autoDownloadStatus at idle so manual download link shows
- Remove auto-check on SettingsApplicationTab mount to avoid
  implicitly triggering downloads when opening Settings
- Update Application tab badge to reflect download/ready state
  instead of always showing "Download Now"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:25:32 +08:00
bincxz
fb5c4aaa58 fix(update): update latestRelease on version mismatch, surface check failures
- Replace stale latestRelease when electron-updater reports a different
  version than the cached GitHub API result
- Surface checkForUpdate() failures by setting autoDownloadStatus to
  error instead of silently dropping them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:15:34 +08:00
bincxz
45c059ae53 fix(update): suppress install toast for dismissed releases, reset stale status
- Check dismissed version before showing ready-to-install toast so
  users who skipped a release are not re-prompted after restart
- Reset _lastStatus on update-not-available so late-opening windows
  don't hydrate stale error/ready state from a previous check cycle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:09:23 +08:00
bincxz
1d67eb40c4 fix(update): remove stale autoDownloadStatus check from manual update handler
The autoDownloadStatus read from updateState was captured at render
time, so after checkNow() resolves it still shows 'idle' even when
electron-updater has already started downloading. Remove the
openReleasePage() call entirely — checkNow() already triggers
electron-updater on supported platforms, and SettingsSystemTab shows
a manual download link on unsupported platforms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 18:02:12 +08:00
bincxz
c6e3989a1b fix(auto-update): restrict error broadcasts to download phase only
- Remove hasUpdate gate from ready-to-install toast so dismissing
  availability notification doesn't prevent restart prompt
- Only open releases page on platforms without auto-download
- Increase startup check delay to 8s to let electron-updater fire first

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:57:35 +08:00
bincxz
ace081414f fix(review): skip redundant startup check, honor dismissed version in toasts
- Skip renderer's GitHub API startup check if electron-updater's
  auto-download has already started, preventing duplicate toast
  notifications for the same release
- Set hasUpdate in onUpdateAvailable IPC handler, checking dismissed
  version so that dismissed releases don't trigger the persistent
  "restart now" toast after auto-download completes
- Guard "ready to install" toast with hasUpdate check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:49:25 +08:00
bincxz
049a609bca fix(review): fix retry path, consolidate useUpdateCheck, show startup updates
- Fix autoDownloadStatusRef stale read during checkNow retry: eagerly
  sync the ref when resetting error->idle so checkForUpdate() fires
- Refactor SettingsApplicationTab to accept update props instead of
  creating its own useUpdateCheck instance, preventing duplicate checks
  and inconsistent state between Application and System tabs
- Show startup-detected updates (hasUpdate) in System tab, not only
  manualCheckStatus=available, so Linux/unsupported platforms see the
  update and manual download button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:43:34 +08:00
bincxz
44409e6d32 fix(review): hydrate update status for late-opening windows, fix toast race
- Add getUpdateStatus IPC handler so windows opened after download started
  can immediately reflect the current state instead of showing stale 'idle'
- Track _lastStatus in main process across all updater events
- Hydrate autoDownloadStatus on useUpdateCheck mount via getUpdateStatus()
- Fix toast race: use ref to track previous autoDownloadStatus so ready/error
  toasts only fire on actual status transitions, not when unrelated callback
  references change

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:37:15 +08:00
bincxz
5246489ef9 fix(review): remove stale getSenderWindow reference from JSDoc
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:26:14 +08:00
bincxz
83d0d917ad fix(review): guard formatLastChecked against negative timestamps
Handle clock skew (timestamp in the future) by treating negative
diff as "just now" instead of displaying negative time values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:25:43 +08:00
bincxz
73557d0af1 fix(review): remove dead code, fix gitignore pattern, correct changelog
- Remove unused getSenderWindow() from autoUpdateBridge (replaced by broadcastToAllWindows)
- Fix .gitignore: /CLAUDE.md instead of CLAUDE.md to only match root
- Merge duplicate [Unreleased] sections in CHANGELOG.md
- Correct checkNow description: uses GitHub API, then triggers electron-updater async

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:23:47 +08:00
penguinway
aa67455c8c fix(auto-update): restrict error broadcasts to download phase only
Add _isDownloading flag to track whether a download is in progress.
Set true on update-available (autoDownload=true starts download immediately),
reset on update-downloaded or error.

In the error handler, only broadcast netcatty:update:error when _isDownloading
is true — check-phase errors (e.g. startup network failures) are logged to
console only and do not set autoDownloadStatus in the renderer, preventing
false 'download failed' states when no download was ever attempted.
2026-03-11 17:09:31 +08:00
penguinway
c7d2482996 fix(update): classify checkNow error when result.error is set
performCheck returns a non-null UpdateCheckResult with error populated
on GitHub API/network failures. Extend the status derivation to treat
result.error as an error state instead of falling through to up-to-date.
2026-03-11 17:08:31 +08:00
penguinway
d2391f5472 fix(update): restore checkNow return type and add error state retry
P1: change checkNow return type from Promise<null> to Promise<UpdateCheckResult | null>
and return actual result so callers can read hasUpdate/latestRelease.

P2: reset autoDownloadStatus from 'error' to 'idle' when user triggers manual
check, enabling a retry path; also show Check for Updates button in error state.
2026-03-11 16:58:21 +08:00
penguinway
9be84c71f5 feat(auto-update): improve UX — auto-reset badge, trigger download, show last checked time
- Fix 1: when manual check finds update and electron-updater hasn't started
  downloading yet (autoDownloadStatus=idle), fire-and-forget checkForUpdate()
  to kick off the auto-download pipeline without blocking the UI

- Fix 2: manualCheckStatus='up-to-date' now auto-resets to 'idle' after 5s
  so the badge doesn't stay stale until the next check; any new check cancels
  the pending timer first

- Fix 3: SettingsSystemTab shows "last checked: X min ago" below the update
  section using lastCheckedAt from updateState; new i18n keys added for both
  zh-CN and en locales (lastCheckedJustNow, lastCheckedMinutesAgo,
  lastCheckedHoursAgo, lastCheckedPrefix)

Internal: add autoDownloadStatusRef and manualCheckResetTimeoutRef to
useUpdateCheck for reliable cross-closure state access and timer lifecycle.
2026-03-11 16:08:32 +08:00
penguinway
effb98b91a chore: gitignore local dev files (.serena/, VS build scripts, Directory.Build.*) 2026-03-11 16:06:05 +08:00
penguinway
a86a5e6839 fix(auto-update): revert checkNow to use performCheck (GitHub API) instead of electron-updater IPC
checkNow was calling bridge.checkForUpdate() which invokes updater.checkForUpdates()
via IPC. startAutoCheck() in the main process already calls checkForUpdates() on a
5s timer, and if that network request is still pending, the concurrent IPC call from
checkNow hangs indefinitely, causing the UI to be stuck in "checking" state forever.

Per the original design spec, checkNow should use performCheck() (GitHub API) directly.
This is completely independent of electron-updater's internal state machine, so it
never conflicts with the background startAutoCheck(). performCheck handles isCheckingRef,
isChecking, hasUpdate, and latestRelease; checkNow only manages manualCheckStatus.
2026-03-11 15:46:05 +08:00
penguinway
7ed4940e18 docs: update CHANGELOG for auto-update unification 2026-03-11 15:32:39 +08:00
penguinway
410d1ef097 feat(settings): pass unified updateState and update actions to SettingsSystemTab 2026-03-11 15:28:41 +08:00
penguinway
c386ee2e2e refactor(settings): remove local update state from SettingsSystemTab, use unified updateState props 2026-03-11 15:26:16 +08:00
penguinway
4c08888b60 fix(auto-update): fix isCheckingRef conflict in checkNow fallback path and stale version closure
- Critical: In Linux fallback path, temporarily reset isCheckingRef before calling
  performCheck so its own guard can run (was silently returning null due to double-set)
- Critical: Replace updateState.currentVersion closure in checkNow with currentVersionRef
  to avoid reading stale '' version on early user click; remove from useCallback deps
- Important: Add explicit !result guard when bridge is unavailable, returning 'error'
  status instead of silently falling through to 'up-to-date'
2026-03-11 15:20:27 +08:00
penguinway
2ea4c88680 feat(auto-update): add manualCheckStatus to UpdateState, rewrite checkNow to use electron-updater IPC 2026-03-11 15:13:54 +08:00
penguinway
0ba75f9af0 fix(auto-update): broadcast IPC events to all windows instead of single window 2026-03-11 15:09:29 +08:00
penguinway
4610348b0d Merge branch 'feat/auto-update' 2026-03-11 14:39:22 +08:00
penguinway
8d11b71bc1 Merge branch 'main' of github.com:penguinway/Netcatty 2026-03-11 14:39:08 +08:00
penguinway
6683001032 chore: exclude tests/ and CLAUDE.md from eslint and gitignore 2026-03-11 14:28:46 +08:00
penguinway
3b313ff933 chore: gitignore local test suite (tests/, vitest.config.ts) 2026-03-11 14:08:06 +08:00
penguinway
eaa27461fa docs: add CHANGELOG for auto-update feature 2026-03-11 13:16:39 +08:00
penguinway
20b65366be chore: ignore dev-app-update.yml, revert forceDevUpdateConfig test flag 2026-03-11 13:12:39 +08:00
penguinway
b8c08ba3ca chore: ignore AI-generated docs (docs/superpowers/) 2026-03-11 12:54:11 +08:00
penguinway
e4aa03c474 fix(auto-update): use duration:0 for persistent toast, remove stale comment 2026-03-11 02:44:44 +08:00
penguinway
b94386236c feat(auto-update): add ready-to-install and download-failed toast notifications 2026-03-11 02:40:10 +08:00
penguinway
0883585704 feat(auto-update): add autoDownloadStatus state and IPC subscriptions to useUpdateCheck 2026-03-11 02:35:45 +08:00
penguinway
5b38f4663d feat(auto-update): add i18n keys for ready-to-install and download-failed toasts 2026-03-11 02:35:18 +08:00
penguinway
a6a6dd1aac feat(auto-update): expose onUpdateAvailable in preload bridge 2026-03-11 02:32:20 +08:00
penguinway
506c60ea44 feat(auto-update): trigger startAutoCheck after main window ready 2026-03-11 02:32:09 +08:00
penguinway
9d9b24fe7b feat(auto-update): add onUpdateAvailable type to NetcattyBridge 2026-03-11 02:32:06 +08:00
penguinway
584b9859ef fix(auto-update): guard setupGlobalListeners against duplicate registration 2026-03-11 02:30:52 +08:00
penguinway
b005065949 feat(auto-update): enable autoDownload and global IPC event listeners 2026-03-11 02:27:59 +08:00
penguinway
a4fdb6758d docs: add auto-update implementation plan
Detailed step-by-step plan for feat/auto-update branch. Addresses
reviewer feedback: specific line anchors, SettingsSystemTab props
pattern, removeAllListeners risk, i18n key conflict notes, and
hasUpdate toast suppression when auto-download is active.
2026-03-11 02:23:20 +08:00
penguinway
a2b5c9d067 docs: add auto-update design spec
Spec for changing update flow from manual to auto-download + prompt
install: autoDownload=true in main process, renderer subscribes to
electron-updater IPC events, toast notification on download complete.
2026-03-11 02:11:39 +08:00
47 changed files with 1246 additions and 353 deletions

20
.gitignore vendored
View File

@@ -37,3 +37,23 @@ coverage
# Claude Code local settings
/.claude/settings.local.json
/CLAUDE.md
# AI / Superpowers generated docs (local only)
/docs/superpowers/
# Dev-only electron-updater test config (not for production)
/dev-app-update.yml
# Test suite (local only, not committed)
/tests/
/vitest.config.ts
# Serena MCP project config (local only)
/.serena/
# Windows VS Build environment scripts (local dev only)
Directory.Build.props
Directory.Build.targets
build_with_vs.bat
build_with_vs2022.bat

46
App.tsx
View File

@@ -283,6 +283,7 @@ function App({ settings }: { settings: SettingsState }) {
identities,
snippets,
customGroups,
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
onApplyPayload: (payload) => {
@@ -304,13 +305,15 @@ function App({ settings }: { settings: SettingsState }) {
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, dismissUpdate } = useUpdateCheck();
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
// Window controls - must be before update toast effect which uses openSettingsWindow
const { openSettingsWindow } = useWindowControls();
// Show toast notification when update is available
// Show toast notification when update is available (only when auto-download is idle)
useEffect(() => {
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
@@ -320,13 +323,50 @@ function App({ settings }: { settings: SettingsState }) {
duration: 8000, // Show longer for update notifications
onClick: () => {
void openSettingsWindow();
// Dismiss the update so the toast doesn't re-fire on every render.
// On unsupported platforms (where autoDownloadStatus stays 'idle')
// this is the only way to suppress the notification for this version.
// On supported platforms this toast only shows before auto-download
// starts, and the Settings window's own useUpdateCheck will pick up
// the download state via IPC events independently of the dismiss.
dismissUpdate();
},
actionLabel: t('update.viewInSettings'),
}
);
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openSettingsWindow, dismissUpdate]);
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
// not when unrelated deps (openReleasePage, installUpdate) change their reference.
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
useEffect(() => {
const prev = prevAutoDownloadStatusRef.current;
prevAutoDownloadStatusRef.current = updateState.autoDownloadStatus;
if (prev === updateState.autoDownloadStatus) return;
if (updateState.autoDownloadStatus === 'ready') {
const version = updateState.latestRelease?.version ?? '';
toast.info(
t('update.readyToInstall.message', { version }),
{
title: t('update.readyToInstall.title'),
duration: 0,
actionLabel: t('update.restartNow'),
onClick: () => installUpdate(),
}
);
} else if (updateState.autoDownloadStatus === 'error') {
toast.error(
t('update.downloadFailed.message'),
{
title: t('update.downloadFailed.title'),
actionLabel: t('update.openReleases'),
onClick: () => openReleasePage(),
}
);
}
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(

32
CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
# Changelog
## [Unreleased] - 2026-03-11
### 功能
- 修复自动更新 IPC 事件仅发送到单个窗口的问题,改为广播所有窗口(主窗口 + 设置窗口均可收到)
- 统一手动检查更新与自动更新的状态机,消除三套并行状态
- 手动"检查更新"通过 GitHub API 检测版本,发现更新后异步触发 electron-updater 下载
- 设置窗口中点击"检查更新"后,下载进度可实时反映在 UI 中
- 应用启动后 5 秒自动触发 `electron-updater` 检查更新,无需用户手动点击
- 发现新版本后自动开始下载(`autoDownload=true`
- 下载完成后弹出持久 toast 通知,用户点击"立即重启"即可安装
- 下载失败时弹出错误 toast提供"打开 Releases"降级入口
- Settings > System 进度条实时展示自动下载进度,由 `useUpdateCheck` 统一驱动
- Linux deb/rpm/snap 等不支持 electron-updater 的平台自动跳过,保持原有 GitHub API 通知行为
### 设计原理
- `broadcastToAllWindows` 替换 `getSenderWindow` 单点发送,保证所有窗口都能收到 IPC 事件
- `manualCheckStatus` 字段追踪手动检查 UI 状态idle/checking/available/up-to-date/error`autoDownloadStatus` 在 UI 层按优先级渲染
- `SettingsSystemTab` 不再持有本地 update state单向接收 `useUpdateCheck` 统一数据
- 将原有两套独立系统GitHub API 通知 + electron-updater 手动下载)合并为统一状态机:`useUpdateCheck` 作为唯一事实来源,同时驱动 `App.tsx` toast 和 `SettingsSystemTab` 进度条
- 全局持久化 IPC 监听器在 `autoUpdateBridge.init()` 时一次性注册,避免每次手动下载请求重复注册/清理监听器
- `autoInstallOnAppQuit=false`,不做静默安装,由用户主动触发重启
### 接口变更SettingsSystemTabProps
- 移除:`autoDownloadStatus``downloadPercent`
- 新增:`updateState`(完整 UpdateState`checkNow``installUpdate``openReleasePage`
### 注意事项
- `checkNow` 语义:使用 GitHub API`performCheck`)检测是否有新版本,若发现更新且 electron-updater 尚未开始下载,则异步触发 `bridge.checkForUpdate()` 启动自动下载流程
- 此功能仅对打包后的应用Windows NSIS、macOS dmg/zip、Linux AppImage生效dev 模式需配合 `forceDevUpdateConfig=true` + `dev-app-update.yml` 测试(见 `.gitignore`
- `hasUpdate` 旧 toast 在 `autoDownloadStatus !== 'idle'` 时自动抑制,避免与新 toast 重复

View File

@@ -109,6 +109,10 @@ const en: Messages = {
'settings.update.manualDownload': 'Download from GitHub',
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
'settings.update.lastCheckedJustNow': 'just now',
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
'settings.update.lastCheckedPrefix': 'Last checked: ',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
@@ -177,6 +181,12 @@ const en: Messages = {
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.viewInSettings': 'View in Settings',
'update.readyToInstall.title': 'Update Ready',
'update.readyToInstall.message': 'Version {version} downloaded and ready to install.',
'update.restartNow': 'Restart Now',
'update.downloadFailed.title': 'Update Failed',
'update.downloadFailed.message': 'Failed to download update. You can download it manually.',
'update.openReleases': 'Open Releases',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
@@ -247,7 +257,7 @@ const en: Messages = {
'settings.terminal.behavior.rightClick.paste': 'Paste',
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Shift to select',
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Option on macOS or Shift on Windows/Linux to select',
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
'settings.terminal.behavior.middleClickPaste.desc':
'Paste clipboard content on middle-click',

View File

@@ -93,6 +93,10 @@ const zhCN: Messages = {
'settings.update.manualDownload': '前往 GitHub 下载',
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
'settings.update.lastCheckedJustNow': '刚刚',
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
'settings.update.lastCheckedPrefix': '上次检查:',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
@@ -161,6 +165,12 @@ const zhCN: Messages = {
'update.error': '检查更新失败',
'update.downloadNow': '立即下载',
'update.viewInSettings': '在设置中查看',
'update.readyToInstall.title': '更新已就绪',
'update.readyToInstall.message': '版本 {version} 已下载完成,准备安装。',
'update.restartNow': '立即重启',
'update.downloadFailed.title': '更新失败',
'update.downloadFailed.message': '下载更新失败,可前往 GitHub 手动下载。',
'update.openReleases': '打开 Releases',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
@@ -1119,7 +1129,7 @@ const zhCN: Messages = {
'settings.terminal.behavior.rightClick.paste': '粘贴',
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
'settings.terminal.behavior.copyOnSelect': '选择即复制',
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,按住 Shift 拖选即可选中文本',
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,macOS 按住 OptionWindows/Linux 按住 Shift 拖选即可选中文本',
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',

View File

@@ -10,6 +10,7 @@ export interface SftpPane {
selectedFiles: Set<string>;
filter: string;
filenameEncoding: SftpFilenameEncoding;
showHiddenFiles: boolean;
}
// Multi-tab state for left and right sides
@@ -22,7 +23,10 @@ export interface SftpSideTabs {
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
export const createEmptyPane = (id?: string): SftpPane => ({
export const createEmptyPane = (
id?: string,
showHiddenFiles = false,
): SftpPane => ({
id: id || crypto.randomUUID(),
connection: null,
files: [],
@@ -32,6 +36,7 @@ export const createEmptyPane = (id?: string): SftpPane => ({
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles,
});
// File watch event types
@@ -53,4 +58,5 @@ export interface SftpStateOptions {
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
onFileWatchError?: (event: FileWatchErrorEvent) => void;
useCompressedUpload?: boolean;
defaultShowHiddenFiles?: boolean;
}

View File

@@ -27,7 +27,7 @@ interface UseSftpConnectionsParams {
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string) => SftpPane;
createEmptyPane: (id?: string, showHiddenFiles?: boolean) => SftpPane;
}
interface UseSftpConnectionsResult {
@@ -346,6 +346,7 @@ export const useSftpConnections = ({
getActivePane,
updateTab,
clearCacheForConnection,
createEmptyPane,
makeCacheKey,
listLocalFiles,
listRemoteFiles,
@@ -412,7 +413,7 @@ export const useSftpConnections = ({
}
}
updateTab(side, activeTabId, () => createEmptyPane(activeTabId));
updateTab(side, activeTabId, () => createEmptyPane(activeTabId, pane.showHiddenFiles));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, clearCacheForConnection, updateTab],

View File

@@ -14,6 +14,7 @@ export interface SftpTabsState {
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
addTab: (side: "left" | "right") => string;
closeTab: (side: "left" | "right", tabId: string) => void;
selectTab: (side: "left" | "right", tabId: string) => void;
@@ -33,7 +34,11 @@ export interface SftpTabsState {
getActiveTabId: (side: "left" | "right") => string | null;
}
export const useSftpTabsState = (): SftpTabsState => {
export const useSftpTabsState = ({
defaultShowHiddenFiles = false,
}: {
defaultShowHiddenFiles?: boolean;
} = {}): SftpTabsState => {
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
tabs: [],
activeTabId: null,
@@ -45,8 +50,10 @@ export const useSftpTabsState = (): SftpTabsState => {
const leftTabsRef = useRef(leftTabs);
const rightTabsRef = useRef(rightTabs);
const defaultShowHiddenFilesRef = useRef(defaultShowHiddenFiles);
leftTabsRef.current = leftTabs;
rightTabsRef.current = rightTabs;
defaultShowHiddenFilesRef.current = defaultShowHiddenFiles;
const getActivePane = useCallback((side: "left" | "right"): SftpPane | null => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
@@ -58,14 +65,14 @@ export const useSftpTabsState = (): SftpTabsState => {
const pane = leftTabs.activeTabId
? leftTabs.tabs.find((t) => t.id === leftTabs.activeTabId)
: null;
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID);
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID, defaultShowHiddenFilesRef.current);
}, [leftTabs]);
const rightPane = useMemo(() => {
const pane = rightTabs.activeTabId
? rightTabs.tabs.find((t) => t.id === rightTabs.activeTabId)
: null;
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID);
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID, defaultShowHiddenFilesRef.current);
}, [rightTabs]);
const updateTab = useCallback(
@@ -88,9 +95,24 @@ export const useSftpTabsState = (): SftpTabsState => {
[updateTab],
);
const setTabShowHiddenFiles = useCallback(
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
updateTab(side, tabId, (prev) => {
if (prev.showHiddenFiles === showHiddenFiles) {
return prev;
}
return {
...prev,
showHiddenFiles,
};
});
},
[updateTab],
);
const addTab = useCallback(
(side: "left" | "right") => {
const newPane = createEmptyPane();
const newPane = createEmptyPane(undefined, defaultShowHiddenFilesRef.current);
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
tabs: [...prev.tabs, newPane],
@@ -236,6 +258,7 @@ export const useSftpTabsState = (): SftpTabsState => {
getActivePane,
updateTab,
updateActiveTab,
setTabShowHiddenFiles,
addTab,
closeTab,
selectTab,

View File

@@ -15,7 +15,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import type { SyncPayload } from '../../domain/sync';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { toast } from '../../components/ui/toast';
@@ -27,6 +27,7 @@ interface AutoSyncConfig {
identities?: SyncPayload['identities'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
@@ -36,6 +37,7 @@ interface AutoSyncConfig {
// Get manager singleton for direct state access
const manager = getCloudSyncManager();
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
type SyncTrigger = 'auto' | 'manual';
@@ -76,11 +78,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
identities: config.identities,
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: config.knownHosts,
syncedAt: Date.now(),
};
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.snippetPackages, config.portForwardingRules, config.knownHosts]);
// Create a hash of current data for comparison
const getDataHash = useCallback(() => {
@@ -105,11 +108,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
identities: config.identities,
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
portForwardingRules: effectivePFRules,
knownHosts: config.knownHosts,
};
return JSON.stringify(data);
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.snippetPackages, config.portForwardingRules, config.knownHosts]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions) => {
@@ -119,7 +123,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Get fresh state directly from CloudSyncManager singleton
let state = manager.getState();
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const syncing = state.syncState === 'SYNCING';
if (!hasProvider) {
@@ -181,7 +185,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
const state = manager.getState();
const hasProvider = Object.values(state.providers).some(p => p.status === 'connected');
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
const unlocked = state.securityState === 'UNLOCKED';
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
@@ -191,12 +195,9 @@ export const useAutoSync = (config: AutoSyncConfig) => {
hasCheckedRemoteRef.current = true;
// Find connected provider
const connectedProvider =
state.providers.github.status === 'connected' ? 'github' :
state.providers.google.status === 'connected' ? 'google' :
state.providers.onedrive.status === 'connected' ? 'onedrive' :
state.providers.webdav.status === 'connected' ? 'webdav' :
state.providers.s3.status === 'connected' ? 's3' : null;
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
isProviderReadyForSync(state.providers[provider]),
) ?? null;
if (!connectedProvider) return;

View File

@@ -21,6 +21,7 @@ import {
type S3Config,
formatLastSync,
getSyncDotColor,
isProviderReadyForSync,
} from '../../domain/sync';
import {
CloudSyncManager,
@@ -181,13 +182,13 @@ export const useCloudSync = (): CloudSyncHook => {
const hasAnyConnectedProvider = useMemo(() => {
return (Object.values(state.providers) as ProviderConnection[]).some(
(p) => p.status === 'connected' || p.status === 'syncing'
(p) => isProviderReadyForSync(p)
);
}, [state.providers]);
const connectedProviderCount = useMemo(() => {
return (Object.values(state.providers) as ProviderConnection[]).filter(
(p) => p.status === 'connected' || p.status === 'syncing'
(p) => isProviderReadyForSync(p)
).length;
}, [state.providers]);
@@ -519,7 +520,7 @@ export const useProviderStatus = (provider: CloudProvider) => {
return {
...connection,
isConnected: connection.status === 'connected',
isConnected: isProviderReadyForSync(connection),
isSyncing: connection.status === 'syncing',
hasError: connection.status === 'error',
dotColor: getSyncDotColor(connection.status),

View File

@@ -110,14 +110,15 @@ const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boo
serializeTerminalSettings(a) === serializeTerminalSettings(b);
const applyThemeTokens = (
theme: 'light' | 'dark',
themeSource: 'light' | 'dark' | 'system',
resolvedTheme: 'light' | 'dark',
tokens: UiThemeTokens,
accentMode: 'theme' | 'custom',
accentOverride: string,
) => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
root.classList.add(resolvedTheme);
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);
@@ -126,7 +127,7 @@ const applyThemeTokens = (
root.style.setProperty('--popover-foreground', tokens.popoverForeground);
const accentToken = accentMode === 'custom' ? accentOverride : tokens.accent;
const accentLightness = parseFloat(accentToken.split(/\s+/)[2]?.replace('%', '') || '');
const computedAccentForeground = theme === 'dark'
const computedAccentForeground = resolvedTheme === 'dark'
? '220 40% 96%'
: (!Number.isNaN(accentLightness) && accentLightness < 55 ? '0 0% 98%' : '222 47% 12%');
@@ -145,7 +146,7 @@ const applyThemeTokens = (
root.style.setProperty('--ring', accentToken);
// Sync with native window title bar (Electron)
netcattyBridge.get()?.setTheme?.(theme);
netcattyBridge.get()?.setTheme?.(themeSource);
netcattyBridge.get()?.setBackgroundColor?.(tokens.background);
};
@@ -323,7 +324,7 @@ export const useSettingsState = () => {
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
applyThemeTokens(effective, tokens, nextAccentMode, nextAccent);
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const syncCustomCssFromStorage = useCallback(() => {
@@ -333,7 +334,7 @@ export const useSettingsState = () => {
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(resolvedTheme, tokens, accentMode, customAccent);
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);

View File

@@ -36,7 +36,15 @@ export const useSftpState = (
identities: Identity[],
options?: SftpStateOptions
) => {
const tabsState = useSftpTabsState();
const createPane = useCallback(
(id?: string, showHiddenFiles = options?.defaultShowHiddenFiles ?? false) =>
createEmptyPane(id, showHiddenFiles),
[options?.defaultShowHiddenFiles],
);
const tabsState = useSftpTabsState({
defaultShowHiddenFiles: options?.defaultShowHiddenFiles,
});
const {
leftTabs,
rightTabs,
@@ -49,6 +57,7 @@ export const useSftpState = (
getActivePane,
updateTab,
updateActiveTab,
setTabShowHiddenFiles,
addTab,
closeTab,
selectTab,
@@ -143,7 +152,7 @@ export const useSftpState = (
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane,
createEmptyPane: createPane,
});
const {
@@ -205,6 +214,13 @@ export const useSftpState = (
[clearCacheForConnection, getActivePane, navigateTo, updateActiveTab],
);
const setShowHiddenFiles = useCallback(
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
setTabShowHiddenFiles(side, tabId, showHiddenFiles);
},
[setTabShowHiddenFiles],
);
const {
transfers,
conflicts,
@@ -270,6 +286,7 @@ export const useSftpState = (
selectAll,
setFilter,
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createFile,
deleteFiles,
@@ -315,6 +332,7 @@ export const useSftpState = (
selectAll,
setFilter,
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createFile,
deleteFiles,
@@ -364,6 +382,8 @@ export const useSftpState = (
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
methodsRef.current.setFilenameEncoding(...args),
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
methodsRef.current.setShowHiddenFiles(...args),
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),

View File

@@ -1,13 +1,17 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
// Delay startup check to avoid slowing down app launch
const STARTUP_CHECK_DELAY_MS = 5000;
// Delay startup check to avoid slowing down app launch.
// 8s gives electron-updater's startAutoCheck(5000) time to emit
// 'update-available' first. The `onUpdateAvailable` handler also cancels
// any pending startup timeout, so even on slow networks where the event
// arrives after 8s the duplicate check is avoided.
const STARTUP_CHECK_DELAY_MS = 8000;
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
@@ -19,6 +23,10 @@ const debugLog = (...args: unknown[]) => {
}
};
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';
export type ManualCheckStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'error';
export interface UpdateState {
isChecking: boolean;
hasUpdate: boolean;
@@ -26,6 +34,12 @@ export interface UpdateState {
latestRelease: ReleaseInfo | null;
error: string | null;
lastCheckedAt: number | null;
// Auto-download state — driven by electron-updater IPC events
autoDownloadStatus: AutoDownloadStatus;
downloadPercent: number;
downloadError: string | null;
/** Manual check state — driven by user clicking "Check for Updates" */
manualCheckStatus: ManualCheckStatus;
}
export interface UseUpdateCheckResult {
@@ -33,6 +47,7 @@ export interface UseUpdateCheckResult {
checkNow: () => Promise<UpdateCheckResult | null>;
dismissUpdate: () => void;
openReleasePage: () => void;
installUpdate: () => void;
}
/**
@@ -49,11 +64,44 @@ export function useUpdateCheck(): UseUpdateCheckResult {
latestRelease: null,
error: null,
lastCheckedAt: null,
autoDownloadStatus: 'idle',
downloadPercent: 0,
downloadError: null,
manualCheckStatus: 'idle',
});
const hasCheckedOnStartupRef = useRef(false);
const isCheckingRef = useRef(false);
const startupCheckTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track current version in a ref to avoid stale closure in checkNow
const currentVersionRef = useRef(updateState.currentVersion);
// Track autoDownloadStatus in a ref so checkNow always reads the latest value
const autoDownloadStatusRef = useRef<AutoDownloadStatus>('idle');
// Timer ref for auto-resetting manualCheckStatus='up-to-date' back to 'idle'
const manualCheckResetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Flag: true when we suppressed auto-download because the version was dismissed.
// Used to distinguish "idle because dismissed" from "idle because not hydrated yet"
// in the progress/downloaded/error callbacks.
const dismissedAutoDownloadRef = useRef(false);
// Keep currentVersionRef in sync so checkNow always reads the latest version
useEffect(() => {
currentVersionRef.current = updateState.currentVersion;
}, [updateState.currentVersion]);
// Keep autoDownloadStatusRef in sync so checkNow always reads the latest download state
useEffect(() => {
autoDownloadStatusRef.current = updateState.autoDownloadStatus;
}, [updateState.autoDownloadStatus]);
// Cleanup: clear any pending manualCheckStatus reset timer on unmount
useEffect(() => {
return () => {
if (manualCheckResetTimeoutRef.current) {
clearTimeout(manualCheckResetTimeoutRef.current);
}
};
}, []);
// Get current app version
useEffect(() => {
@@ -71,6 +119,136 @@ export function useUpdateCheck(): UseUpdateCheckResult {
void loadVersion();
}, []);
// Hydrate auto-download status from the main process so windows opened
// after the download started (e.g. Settings) immediately reflect the
// current state instead of showing stale 'idle'.
useEffect(() => {
const bridge = netcattyBridge.get();
void bridge?.getUpdateStatus?.().then((snapshot) => {
if (!snapshot || snapshot.status === 'idle') return;
// Respect dismissed versions: if the user dismissed this release,
// don't surface download progress/ready state in late-opening windows.
// Also set the dismissed ref so subsequent IPC events are suppressed.
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
if (snapshot.version && snapshot.version === dismissedVersion) {
dismissedAutoDownloadRef.current = true;
return;
}
setUpdateState((prev) => {
// Don't overwrite if the renderer already has a newer state
if (prev.autoDownloadStatus !== 'idle') return prev;
return {
...prev,
autoDownloadStatus: snapshot.status,
downloadPercent: snapshot.percent,
downloadError: snapshot.error,
// Use snapshot version if no release data or if versions differ
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
version: snapshot.version,
tagName: `v${snapshot.version}`,
name: `v${snapshot.version}`,
body: '',
htmlUrl: '',
publishedAt: new Date().toISOString(),
assets: [],
} : prev.latestRelease) : prev.latestRelease,
};
});
});
}, []);
// Subscribe to electron-updater auto-download IPC events.
// These fire automatically when autoDownload=true in the main process.
useEffect(() => {
const bridge = netcattyBridge.get();
// When electron-updater confirms no update in its feed, don't write
// STORAGE_KEY_UPDATE_LAST_CHECK — that would throttle the GitHub API
// fallback for an hour. Let performCheck write it on success so the
// GitHub check can still discover releases not yet in the updater feed.
const cleanupNotAvailable = bridge?.onUpdateNotAvailable?.(() => {
// No-op for now — the GitHub fallback will handle lastCheckedAt.
});
const cleanupAvailable = bridge?.onUpdateAvailable?.((info) => {
// Cancel any pending startup GitHub API check — electron-updater is
// now authoritative and we don't want a duplicate toast.
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// Check if this version was dismissed by the user
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isDismissed = dismissedVersion === info.version;
if (isDismissed) {
dismissedAutoDownloadRef.current = true;
}
setUpdateState((prev) => ({
...prev,
hasUpdate: !isDismissed,
// Only transition to 'downloading' if the user hasn't dismissed this
// version — otherwise leave the status at 'idle' so no download
// progress/ready toast appears for a release they don't want.
autoDownloadStatus: isDismissed ? prev.autoDownloadStatus : 'downloading',
downloadPercent: isDismissed ? prev.downloadPercent : 0,
downloadError: isDismissed ? prev.downloadError : null,
// Use electron-updater's version if GitHub API hasn't resolved yet or
// if the updater reports a different version than the cached release.
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
version: info.version,
tagName: `v${info.version}`,
name: `v${info.version}`,
body: info.releaseNotes || '',
htmlUrl: '',
publishedAt: info.releaseDate || new Date().toISOString(),
assets: [],
} : prev.latestRelease,
}));
});
const cleanupProgress = bridge?.onUpdateDownloadProgress?.((p) => {
// If we suppressed the download for a dismissed version, ignore progress.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'downloading',
downloadPercent: Math.round(p.percent),
}));
});
const cleanupDownloaded = bridge?.onUpdateDownloaded?.(() => {
// If the download was for a dismissed version, don't transition to
// 'ready' — that would trigger the "Update ready" toast.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'ready',
downloadPercent: 100,
}));
});
const cleanupError = bridge?.onUpdateError?.((payload) => {
// If we suppressed the download for a dismissed version, ignore errors.
if (dismissedAutoDownloadRef.current) return;
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: payload.error,
}));
});
return () => {
cleanupNotAvailable?.();
cleanupAvailable?.();
cleanupProgress?.();
cleanupDownloaded?.();
cleanupError?.();
};
}, []);
const performCheck = useCallback(async (currentVersion: string): Promise<UpdateCheckResult | null> => {
debugLog('performCheck called', { currentVersion, IS_UPDATE_DEMO_MODE });
@@ -119,8 +297,16 @@ export function useUpdateCheck(): UseUpdateCheckResult {
debugLog('Latest release version:', result.latestRelease?.version);
const now = Date.now();
// Save last check time
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
// Only advance last-check time and cache release on successful checks.
// Failed checks (result.error set, no latestRelease) must not update
// the timestamp — otherwise stale cached release data persists for an
// hour while the throttle prevents re-checking.
if (!result.error) {
localStorageAdapter.writeNumber(STORAGE_KEY_UPDATE_LAST_CHECK, now);
if (result.latestRelease) {
localStorageAdapter.writeString(STORAGE_KEY_UPDATE_LATEST_RELEASE, JSON.stringify(result.latestRelease));
}
}
// Check if this version was dismissed
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
@@ -156,11 +342,121 @@ export function useUpdateCheck(): UseUpdateCheckResult {
}
}, []);
const checkNow = useCallback(async () => {
// In demo mode, use fake version to allow checking
const version = IS_UPDATE_DEMO_MODE ? '0.0.1' : updateState.currentVersion;
return performCheck(version);
}, [performCheck, updateState.currentVersion]);
const checkNow = useCallback(async (): Promise<UpdateCheckResult | null> => {
// Prevent concurrent checks (performCheck owns isCheckingRef)
if (isCheckingRef.current) {
debugLog('checkNow: already checking, skipping');
return null;
}
// Cancel any pending startup auto-check to avoid racing with
// electron-updater's startAutoCheck — concurrent checkForUpdates()
// calls are rejected by electron-updater and would surface a false error.
if (startupCheckTimeoutRef.current) {
clearTimeout(startupCheckTimeoutRef.current);
startupCheckTimeoutRef.current = null;
}
// Clear any pending "up-to-date" auto-reset timer
if (manualCheckResetTimeoutRef.current) {
clearTimeout(manualCheckResetTimeoutRef.current);
manualCheckResetTimeoutRef.current = null;
}
// Reset dismissed flag so a manual retry can surface download events again
dismissedAutoDownloadRef.current = false;
// Immediately reflect 'checking' in the UI; reset download error so the user can retry
setUpdateState((prev) => {
// Eagerly sync the ref so the checkForUpdate gate below reads the updated value
if (prev.autoDownloadStatus === 'error') {
autoDownloadStatusRef.current = 'idle';
}
return {
...prev,
manualCheckStatus: 'checking',
error: null,
// P2: reset download error state so auto-download can retry on next available update
autoDownloadStatus: prev.autoDownloadStatus === 'error' ? 'idle' : prev.autoDownloadStatus,
downloadError: prev.autoDownloadStatus === 'error' ? null : prev.downloadError,
};
});
// Skip check for dev/invalid builds (demo mode overrides to '0.0.1' inside performCheck)
const effectiveVersion = IS_UPDATE_DEMO_MODE ? '0.0.1' : currentVersionRef.current;
if (!effectiveVersion || effectiveVersion === '0.0.0') {
// Dev/invalid build — can't determine update status, reset to idle
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'idle',
}));
return null;
}
// Delegate to performCheck (GitHub API) — completely independent of
// electron-updater's startAutoCheck() in the main process.
// performCheck sets isCheckingRef, isChecking, hasUpdate, latestRelease.
const result = await performCheck(effectiveVersion);
// Determine manual check status. performCheck already suppressed dismissed
// versions in state (hasUpdate=false), so we must respect that here too —
// otherwise a dismissed release would be reported as 'available' and could
// trigger a background download via checkForUpdate below.
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isAvailable = result !== null && !result.error && result.hasUpdate &&
result.latestRelease?.version !== dismissedVersion;
const nextStatus: ManualCheckStatus =
result === null || result.error ? 'error' : isAvailable ? 'available' : 'up-to-date';
setUpdateState((prev) => ({
...prev,
manualCheckStatus: nextStatus,
}));
if (nextStatus === 'up-to-date') {
// Auto-reset "up-to-date" badge back to idle after 5s
manualCheckResetTimeoutRef.current = setTimeout(() => {
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
}, 5000);
} else if ((nextStatus === 'available' || nextStatus === 'error') && autoDownloadStatusRef.current === 'idle') {
// Trigger electron-updater as a fallback. This covers two cases:
// 1. 'available': GitHub found an update but electron-updater hasn't
// started a download yet — kick it off.
// 2. 'error': GitHub API failed (blocked/rate-limited), but the
// electron-updater feed may still be reachable. Without this,
// environments where api.github.com is blocked would never attempt
// the auto-download path.
void netcattyBridge.get()?.checkForUpdate?.().then((res) => {
if (res?.error && res?.supported !== false) {
// Surface actual download-feed errors; unsupported platforms
// (res.supported === false) should keep autoDownloadStatus at
// 'idle' so the manual download link shows.
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: res.error,
}));
} else if (res?.checking) {
// Another check is already in flight — don't change status; the
// in-flight check will resolve via IPC events.
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
// GitHub API failed but electron-updater says no update available.
// Clear the error status so Settings doesn't stay stuck in error state.
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'up-to-date',
}));
manualCheckResetTimeoutRef.current = setTimeout(() => {
setUpdateState((prev) => ({ ...prev, manualCheckStatus: 'idle' }));
}, 5000);
}
}).catch(() => {
// Bridge unavailable — ignore; the manual download link remains visible
});
}
return result;
}, [performCheck]);
const dismissUpdate = useCallback(() => {
if (updateState.latestRelease?.version) {
@@ -189,6 +485,10 @@ export function useUpdateCheck(): UseUpdateCheckResult {
window.open(url, '_blank', 'noopener,noreferrer');
}, [updateState.latestRelease]);
const installUpdate = useCallback(() => {
netcattyBridge.get()?.installUpdate?.();
}, []);
// Startup check with delay - runs once on mount
useEffect(() => {
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
@@ -238,13 +538,60 @@ export function useUpdateCheck(): UseUpdateCheckResult {
const now = Date.now();
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
hasCheckedOnStartupRef.current = true;
// Hydrate cached release info so late-opening windows show the result
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
if (cachedRelease) {
try {
const release = JSON.parse(cachedRelease) as ReleaseInfo;
const dismissedVersion = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
const isNewer = updateState.currentVersion.localeCompare(release.version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
const showUpdate = isNewer && release.version !== dismissedVersion;
setUpdateState((prev) => ({
...prev,
latestRelease: prev.latestRelease ?? release,
hasUpdate: prev.hasUpdate || showUpdate,
lastCheckedAt: lastCheck,
}));
} catch {
// Ignore corrupted cache
}
}
return;
}
hasCheckedOnStartupRef.current = true;
debugLog('Starting delayed update check for version:', updateState.currentVersion);
startupCheckTimeoutRef.current = setTimeout(() => {
startupCheckTimeoutRef.current = setTimeout(async () => {
// If electron-updater's auto-check already started a download, skip the
// redundant GitHub API check to avoid duplicate toast notifications.
if (autoDownloadStatusRef.current !== 'idle') {
debugLog('Skipping startup check — auto-download already active');
return;
}
// If the main process check is still in flight, reschedule the
// fallback instead of permanently skipping it — the auto-check may
// fail silently (check-phase errors aren't broadcast to the renderer).
try {
const snapshot = await netcattyBridge.get()?.getUpdateStatus?.();
if (snapshot?.isChecking) {
debugLog('Main process check still in flight — rescheduling fallback');
startupCheckTimeoutRef.current = setTimeout(async () => {
if (autoDownloadStatusRef.current !== 'idle') return;
// Re-check if the main process check is still running to avoid
// duplicate notifications on very slow networks.
try {
const snap = await netcattyBridge.get()?.getUpdateStatus?.();
if (snap?.isChecking || (snap?.status && snap.status !== 'idle')) return;
} catch { /* fall through */ }
debugLog('=== Rescheduled fallback check triggered ===');
void performCheck(updateState.currentVersion);
}, 5000);
return;
}
} catch {
// Bridge unavailable — fall through to GitHub check
}
debugLog('=== Delayed check triggered ===');
void performCheck(updateState.currentVersion);
}, STARTUP_CHECK_DELAY_MS);
@@ -261,5 +608,6 @@ export function useUpdateCheck(): UseUpdateCheckResult {
checkNow,
dismissUpdate,
openReleasePage,
installUpdate,
};
}

View File

@@ -44,6 +44,7 @@ type ExportableVaultData = {
identities?: Identity[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
knownHosts?: KnownHost[];
};
@@ -557,9 +558,10 @@ export const useVaultState = () => {
identities,
snippets,
customGroups,
snippetPackages,
knownHosts,
}),
[hosts, keys, identities, snippets, customGroups, knownHosts],
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
);
const importData = useCallback(
@@ -569,6 +571,7 @@ export const useVaultState = () => {
if (payload.identities) updateIdentities(payload.identities);
if (payload.snippets) updateSnippets(payload.snippets);
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
},
[
@@ -577,6 +580,7 @@ export const useVaultState = () => {
updateIdentities,
updateSnippets,
updateCustomGroups,
updateSnippetPackages,
updateKnownHosts,
],
);

View File

@@ -35,7 +35,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../domain/credentials';
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
@@ -681,10 +681,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const disconnectOtherProviders = async (current: CloudProvider) => {
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
const isActive = (status: string) => status === 'connected' || status === 'syncing';
for (const provider of providers) {
if (provider === current) continue;
if (isActive(sync.providers[provider].status)) {
if (isProviderReadyForSync(sync.providers[provider])) {
await sync.disconnectProvider(provider);
}
}
@@ -1061,13 +1060,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="github"
name="GitHub Gist"
icon={<Github size={24} />}
isConnected={sync.providers.github.status === 'connected' || sync.providers.github.status === 'syncing'}
isConnected={isProviderReadyForSync(sync.providers.github)}
isSyncing={sync.providers.github.status === 'syncing'}
isConnecting={sync.providers.github.status === 'connecting'}
account={sync.providers.github.account}
lastSync={sync.providers.github.lastSync}
error={sync.providers.github.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.github.status !== 'connected' && sync.providers.github.status !== 'syncing'}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.github)}
onConnect={handleConnectGitHub}
onDisconnect={() => sync.disconnectProvider('github')}
onSync={() => handleSync('github')}
@@ -1077,13 +1076,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="google"
name="Google Drive"
icon={<GoogleDriveIcon className="w-6 h-6" />}
isConnected={sync.providers.google.status === 'connected' || sync.providers.google.status === 'syncing'}
isConnected={isProviderReadyForSync(sync.providers.google)}
isSyncing={sync.providers.google.status === 'syncing'}
isConnecting={sync.providers.google.status === 'connecting'}
account={sync.providers.google.account}
lastSync={sync.providers.google.lastSync}
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.google.status !== 'connected' && sync.providers.google.status !== 'syncing'}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
onConnect={handleConnectGoogle}
onDisconnect={() => sync.disconnectProvider('google')}
onSync={() => handleSync('google')}
@@ -1093,13 +1092,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="onedrive"
name="Microsoft OneDrive"
icon={<OneDriveIcon className="w-6 h-6" />}
isConnected={sync.providers.onedrive.status === 'connected' || sync.providers.onedrive.status === 'syncing'}
isConnected={isProviderReadyForSync(sync.providers.onedrive)}
isSyncing={sync.providers.onedrive.status === 'syncing'}
isConnecting={sync.providers.onedrive.status === 'connecting'}
account={sync.providers.onedrive.account}
lastSync={sync.providers.onedrive.lastSync}
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.onedrive.status !== 'connected' && sync.providers.onedrive.status !== 'syncing'}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
onConnect={handleConnectOneDrive}
onDisconnect={() => sync.disconnectProvider('onedrive')}
onSync={() => handleSync('onedrive')}
@@ -1109,13 +1108,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="webdav"
name={t('cloudSync.provider.webdav')}
icon={<Server size={24} />}
isConnected={sync.providers.webdav.status === 'connected' || sync.providers.webdav.status === 'syncing'}
isConnected={isProviderReadyForSync(sync.providers.webdav)}
isSyncing={sync.providers.webdav.status === 'syncing'}
isConnecting={sync.providers.webdav.status === 'connecting'}
account={sync.providers.webdav.account}
lastSync={sync.providers.webdav.lastSync}
error={sync.providers.webdav.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.webdav.status !== 'connected' && sync.providers.webdav.status !== 'syncing'}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.webdav)}
onEdit={openWebdavDialog}
onConnect={openWebdavDialog}
onDisconnect={() => sync.disconnectProvider('webdav')}
@@ -1126,13 +1125,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
provider="s3"
name={t('cloudSync.provider.s3')}
icon={<Database size={24} />}
isConnected={sync.providers.s3.status === 'connected' || sync.providers.s3.status === 'syncing'}
isConnected={isProviderReadyForSync(sync.providers.s3)}
isSyncing={sync.providers.s3.status === 'syncing'}
isConnecting={sync.providers.s3.status === 'connecting'}
account={sync.providers.s3.account}
lastSync={sync.providers.s3.lastSync}
error={sync.providers.s3.error}
disabled={sync.hasAnyConnectedProvider && sync.providers.s3.status !== 'connected' && sync.providers.s3.status !== 'syncing'}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.s3)}
onEdit={openS3Dialog}
onConnect={openS3Dialog}
onDisconnect={() => sync.disconnectProvider('s3')}

View File

@@ -17,6 +17,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
redhat: "/distro/redhat.svg",
oracle: "/distro/oracle.svg",
kali: "/distro/kali.svg",
almalinux: "/distro/almalinux.svg",
};
export const DISTRO_COLORS: Record<string, string> = {
@@ -32,6 +33,7 @@ export const DISTRO_COLORS: Record<string, string> = {
redhat: "bg-[#EE0000]",
oracle: "bg-[#C74634]",
kali: "bg-[#0F6DB3]",
almalinux: "bg-[#173B66]",
default: "bg-slate-600",
};

View File

@@ -93,6 +93,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const {
sftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
@@ -204,6 +205,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
const {
currentPath,
setCurrentPath,
currentPathRef,
files,
loading,
setLoading,
@@ -384,6 +386,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
dismissTask,
} = useSftpModalTransfers({
currentPath,
currentPathRef,
isLocalSession,
joinPath: joinPathForSession,
ensureSftp,
@@ -475,8 +478,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
initialUploadTriggeredRef.current = true;
// Trigger upload with full DropEntry data (preserves directory structure)
handleUploadEntries(initialEntriesToUpload);
}, [initialEntriesToUpload, open, loading, handleUploadEntries]);
void handleUploadEntries(initialEntriesToUpload);
}, [handleUploadEntries, initialEntriesToUpload, loading, open]);
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
@@ -541,6 +544,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
return parentEntry ? [parentEntry, ...sorted] : sorted;
}, [displayFiles, sortField, sortOrder]);
const hasFiles = files.length > 0;
const hasDisplayFiles = sortedFiles.length > 0;
const {
fileListRef,
handleFileListScroll,
@@ -685,6 +690,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onCreateFile={handleCreateFile}
onFileSelect={handleFileSelect}
onFolderSelect={handleFolderSelect}
showHiddenFiles={sftpShowHiddenFiles}
onToggleShowHiddenFiles={() =>
setSftpShowHiddenFiles(!sftpShowHiddenFiles)
}
onUpdateHost={onUpdateHost}
onNavigateToBookmark={(path) => setCurrentPath(path)}
/>
@@ -693,7 +702,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
t={t}
currentPath={currentPath}
isLocalSession={isLocalSession}
files={files}
hasFiles={hasFiles}
hasDisplayFiles={hasDisplayFiles}
selectedFiles={selectedFiles}
dragActive={dragActive}
loading={loading}

View File

@@ -4,7 +4,7 @@ import AppLogo from "./AppLogo";
import { Button } from "./ui/button";
import { cn } from "../lib/utils";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import type { UpdateState, UseUpdateCheckResult } from "../application/state/useUpdateCheck";
import { useI18n } from "../application/i18n/I18nProvider";
import { SettingsTabContent } from "./settings/settings-ui";
import { toast } from "./ui/toast";
@@ -63,13 +63,18 @@ const ActionRow: React.FC<{
</button>
);
export default function SettingsApplicationTab() {
interface SettingsApplicationTabProps {
updateState: UpdateState;
checkNow: UseUpdateCheckResult['checkNow'];
openReleasePage: UseUpdateCheckResult['openReleasePage'];
installUpdate: UseUpdateCheckResult['installUpdate'];
}
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const { updateState, checkNow, openReleasePage } = useUpdateCheck();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
const [lastCheckResult, setLastCheckResult] = useState<'none' | 'available' | 'upToDate'>('none');
const [hasAutoChecked, setHasAutoChecked] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -93,19 +98,6 @@ export default function SettingsApplicationTab() {
const isUpdateDemoMode = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
// Auto check for updates when entering this page
useEffect(() => {
if (hasAutoChecked) return;
if (updateState.isChecking) return;
// In demo mode or when we have a valid version, auto-check
const canCheck = isUpdateDemoMode || (appInfo.version && appInfo.version !== '0.0.0');
if (!canCheck) return;
setHasAutoChecked(true);
void checkNow();
}, [hasAutoChecked, updateState.isChecking, isUpdateDemoMode, appInfo.version, checkNow]);
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
@@ -124,8 +116,9 @@ export default function SettingsApplicationTab() {
t('update.available.message', { version: result.latestRelease.version }),
t('update.available.title')
);
// Open the release page
openReleasePage();
// Don't auto-open the release page here — checkNow() already triggers
// electron-updater on supported platforms, and the Settings > System tab
// shows a "Manual Download" link on unsupported platforms.
} else if (result) {
setLastCheckResult('upToDate');
toast.success(
@@ -154,18 +147,25 @@ export default function SettingsApplicationTab() {
<span className="text-sm text-muted-foreground">
{appInfo.version ? appInfo.version : " "}
</span>
{/* Update available badge - inline with version */}
{updateState.hasUpdate && updateState.latestRelease && (
{/* Update badge - reflects auto-download state */}
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
<button
onClick={() => void openReleasePage()}
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
"bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300",
"hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer"
updateState.autoDownloadStatus === 'ready'
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-800"
: "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-800",
"transition-colors cursor-pointer"
)}
>
<ArrowUpCircle size={12} />
v{updateState.latestRelease.version} {t('update.downloadNow')}
v{updateState.latestRelease.version}{' '}
{updateState.autoDownloadStatus === 'ready'
? t('update.restartNow')
: updateState.autoDownloadStatus === 'downloading'
? `${updateState.downloadPercent}%`
: t('update.downloadNow')}
</button>
)}
</div>

View File

@@ -8,6 +8,7 @@ import { useSettingsState } from "../application/state/useSettingsState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
import { useUpdateCheck } from "../application/state/useUpdateCheck";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
@@ -33,6 +34,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
identities,
snippets,
customGroups,
snippetPackages,
knownHosts,
importDataFromString,
clearVaultData,
@@ -53,8 +55,8 @@ const SettingsSyncTabWithVault: React.FC = () => {
);
const vault = useMemo(
() => ({ hosts, keys, identities, snippets, customGroups, knownHosts }),
[hosts, keys, identities, snippets, customGroups, knownHosts],
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts }),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts],
);
return (
@@ -71,6 +73,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck();
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
@@ -165,7 +168,14 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
</div>
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
{mountedTabs.has("application") && <SettingsApplicationTab />}
{mountedTabs.has("application") && (
<SettingsApplicationTab
updateState={updateState}
checkNow={checkNow}
openReleasePage={openReleasePage}
installUpdate={installUpdate}
/>
)}
{mountedTabs.has("appearance") && (
<SettingsAppearanceTab
@@ -237,6 +247,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
closeToTray={settings.closeToTray}
setCloseToTray={settings.setCloseToTray}
hotkeyRegistrationError={settings.hotkeyRegistrationError}
updateState={updateState}
checkNow={checkNow}
installUpdate={installUpdate}
openReleasePage={openReleasePage}
/>
)}
</div>

View File

@@ -81,7 +81,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
const sftpOptions = useMemo(() => ({
...fileWatchHandlers,
useCompressedUpload: sftpUseCompressedUpload,
}), [fileWatchHandlers, sftpUseCompressedUpload]);
defaultShowHiddenFiles: sftpShowHiddenFiles,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
@@ -107,7 +108,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
hotkeyScheme,
sftpRef,
isActive,
showHiddenFiles: sftpShowHiddenFiles,
});
// Subscribe to focused side for visual indicator
@@ -118,6 +118,14 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
sftpFocusStore.setFocusedSide(side);
}, []);
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === paneId);
if (!pane) return;
sftpRef.current.setShowHiddenFiles(side, paneId, !pane.showHiddenFiles);
}, []);
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
// Using useLayoutEffect to sync before paint
useLayoutEffect(() => {
@@ -225,7 +233,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
dragCallbacks={dragCallbacks}
leftCallbacks={leftCallbacks}
rightCallbacks={rightCallbacks}
showHiddenFiles={sftpShowHiddenFiles}
>
<div
className={cn(
@@ -277,6 +284,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
pane={pane}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
/>
</SftpPaneWrapper>
))}
@@ -333,6 +341,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
pane={pane}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
/>
</SftpPaneWrapper>
))}

View File

@@ -25,7 +25,7 @@ import {
Server,
} from 'lucide-react';
import { useCloudSync } from '../application/state/useCloudSync';
import type { CloudProvider } from '../domain/sync';
import { isProviderReadyForSync, type CloudProvider } from '../domain/sync';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
@@ -122,12 +122,11 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
// Get connected provider (include syncing status as it's still connected)
const getConnectedProvider = (): CloudProvider | null => {
const isProviderActive = (status: string) => status === 'connected' || status === 'syncing';
if (isProviderActive(sync.providers.github.status)) return 'github';
if (isProviderActive(sync.providers.google.status)) return 'google';
if (isProviderActive(sync.providers.onedrive.status)) return 'onedrive';
if (isProviderActive(sync.providers.webdav.status)) return 'webdav';
if (isProviderActive(sync.providers.s3.status)) return 's3';
if (isProviderReadyForSync(sync.providers.github)) return 'github';
if (isProviderReadyForSync(sync.providers.google)) return 'google';
if (isProviderReadyForSync(sync.providers.onedrive)) return 'onedrive';
if (isProviderReadyForSync(sync.providers.webdav)) return 'webdav';
if (isProviderReadyForSync(sync.providers.s3)) return 's3';
return null;
};
@@ -136,9 +135,9 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
// Determine overall status for the button indicator
const getOverallStatus = (): StatusIndicatorProps['status'] => {
if (sync.isSyncing) return 'syncing';
if (sync.lastError) return 'error';
if (sync.hasAnyConnectedProvider) return 'synced';
if (sync.overallSyncStatus === 'syncing') return 'syncing';
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
if (sync.overallSyncStatus === 'synced') return 'synced';
return 'none';
};

View File

@@ -21,6 +21,10 @@ import {
TerminalSettings,
KeyBinding,
} from "../types";
import {
shouldEnableNativeUserInputAutoScroll,
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
@@ -221,6 +225,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
useEffect(() => {
if (xtermRuntimeRef.current) {
@@ -416,8 +423,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
startupCommand,
terminalSettings,
terminalSettingsRef,
terminalBackend,
serialConfig,
isVisibleRef,
pendingOutputScrollRef,
sessionRef,
hasConnectedRef,
hasRunStartupCommandRef,
@@ -454,6 +464,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
let disposed = false;
setError(null);
hasConnectedRef.current = false;
pendingOutputScrollRef.current = false;
setProgressLogs([]);
setShowLogs(false);
setIsCancelling(false);
@@ -703,7 +714,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.scrollOnUserInput = terminalSettings.scrollOnInput;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
@@ -732,10 +744,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {
const timer = setTimeout(() => safeFit(), 50);
return () => clearTimeout(timer);
}
if (!isVisible) return;
const timer = setTimeout(() => {
safeFit();
if (pendingOutputScrollRef.current) {
termRef.current?.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
termRef.current?.scrollToBottom();
});
}
pendingOutputScrollRef.current = false;
}
}, 50);
return () => clearTimeout(timer);
}, [isVisible]);
useEffect(() => {
@@ -964,6 +986,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
const scrollToBottomAfterProgrammaticInput = (data: string) => {
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
termRef.current.scrollToBottom();
}
};
const terminalContextActions = useTerminalContextActions({
termRef,
sessionRef,
@@ -975,7 +1003,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleSnippetClick = (cmd: string) => {
if (sessionRef.current) {
terminalBackend.writeToSession(sessionRef.current, `${cmd}\r`);
const payload = `${cmd}\r`;
terminalBackend.writeToSession(sessionRef.current, payload);
scrollToBottomAfterProgrammaticInput(payload);
setIsScriptsOpen(false);
termRef.current?.focus();
return;
@@ -1142,6 +1172,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const pathsText = paths.join(' ');
// Write the paths to the terminal
terminalBackend.writeToSession(sessionRef.current, pathsText);
scrollToBottomAfterProgrammaticInput(pathsText);
termRef.current.focus();
}
} else {
@@ -1699,6 +1730,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (sessionRef.current) {
const payload = text + '\r';
terminalBackend.writeToSession(sessionRef.current, payload);
scrollToBottomAfterProgrammaticInput(payload);
onBroadcastInput?.(payload, sessionRef.current);
}
}}

View File

@@ -6,15 +6,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import {
checkForUpdate,
downloadUpdate,
installUpdate,
onDownloadProgress,
onDownloaded,
onError as onUpdateError,
getReleasesUrl,
} from "../../../infrastructure/services/updateService";
import type { UpdateState } from '../../../application/state/useUpdateCheck';
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
@@ -35,6 +27,22 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
/** Returns a locale-agnostic relative time string for the given timestamp. */
function formatLastChecked(
timestamp: number | null,
t: (key: string) => string,
): string {
if (!timestamp) return '';
const diffMs = Date.now() - timestamp;
if (diffMs < 0) return t('settings.update.lastCheckedJustNow');
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return t('settings.update.lastCheckedJustNow');
if (diffMins < 60)
return t('settings.update.lastCheckedMinutesAgo').replace('{n}', String(diffMins));
const diffHours = Math.floor(diffMins / 60);
return t('settings.update.lastCheckedHoursAgo').replace('{n}', String(diffHours));
}
interface SettingsSystemTabProps {
sessionLogsEnabled: boolean;
setSessionLogsEnabled: (enabled: boolean) => void;
@@ -47,6 +55,11 @@ interface SettingsSystemTabProps {
closeToTray: boolean;
setCloseToTray: (enabled: boolean) => void;
hotkeyRegistrationError: string | null;
// Unified update state — from useUpdateCheck hook in SettingsPageContent
updateState: UpdateState;
checkNow: () => Promise<unknown>;
installUpdate: () => void;
openReleasePage: () => void;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
@@ -61,6 +74,10 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
closeToTray,
setCloseToTray,
hotkeyRegistrationError,
updateState,
checkNow,
installUpdate,
openReleasePage,
}) => {
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
@@ -74,13 +91,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
// Software Update state
type UpdateStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'downloading' | 'ready' | 'error';
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const [updateVersion, setUpdateVersion] = useState('');
const [updatePercent, setUpdatePercent] = useState(0);
const [updateError, setUpdateError] = useState('');
const [updateSupported, setUpdateSupported] = useState(true);
const [appVersion, setAppVersion] = useState('');
// Load app version on mount
@@ -93,63 +103,6 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
}
}, []);
// Subscribe to auto-update events
useEffect(() => {
const cleanupProgress = onDownloadProgress((p) => {
setUpdatePercent(Math.round(p.percent));
});
const cleanupDownloaded = onDownloaded(() => {
setUpdateStatus('ready');
});
const cleanupError = onUpdateError((payload) => {
setUpdateError(payload.error);
setUpdateStatus('error');
});
return () => {
cleanupProgress?.();
cleanupDownloaded?.();
cleanupError?.();
};
}, []);
const handleCheckForUpdate = useCallback(async () => {
setUpdateStatus('checking');
setUpdateError('');
const result = await checkForUpdate();
if (result.error) {
setUpdateError(result.error);
setUpdateSupported(result.supported !== false);
setUpdateStatus('error');
} else if (result.available && result.version) {
setUpdateVersion(result.version);
setUpdateSupported(result.supported !== false);
setUpdateStatus('available');
} else {
setUpdateSupported(result.supported !== false);
setUpdateStatus('up-to-date');
}
}, []);
const handleDownloadUpdate = useCallback(async () => {
setUpdateStatus('downloading');
setUpdatePercent(0);
const result = await downloadUpdate();
if (!result.success) {
setUpdateError(result.error ?? t('settings.update.downloadError'));
setUpdateStatus('error');
}
// Success is handled by onDownloaded event
}, [t]);
const handleInstallUpdate = useCallback(() => {
installUpdate();
}, []);
const handleOpenReleases = useCallback(() => {
const url = updateVersion ? getReleasesUrl(updateVersion) : getReleasesUrl();
netcattyBridge.get()?.openExternal?.(url);
}, [updateVersion]);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getTempDirInfo) return;
@@ -315,85 +268,99 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
<span className="text-sm text-muted-foreground">
{t('settings.update.currentVersion')}
</span>
<span className="text-sm font-mono">{appVersion || '...'}</span>
<span className="text-sm font-mono">
{updateState.currentVersion || appVersion || '...'}
</span>
</div>
{/* Status message */}
{updateStatus === 'up-to-date' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.upToDate')}
</p>
)}
{updateStatus === 'available' && (
<p className="text-sm text-blue-600 dark:text-blue-400">
{t('settings.update.available').replace('{version}', updateVersion)}
</p>
)}
{updateStatus === 'downloading' && (
{/* Status message — priority: autoDownloadStatus > isChecking/manualCheckStatus */}
{updateState.autoDownloadStatus === 'downloading' && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t('settings.update.downloading').replace('{percent}', String(updatePercent))}
{t('settings.update.downloading').replace('{percent}', String(updateState.downloadPercent))}
</p>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${updatePercent}%` }}
style={{ width: `${updateState.downloadPercent}%` }}
/>
</div>
</div>
)}
{updateStatus === 'ready' && (
{updateState.autoDownloadStatus === 'ready' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.readyToInstall')}
</p>
)}
{updateStatus === 'error' && (
{updateState.autoDownloadStatus === 'error' && (
<p className="text-sm text-destructive">
{updateError || t('settings.update.error')}
{updateState.downloadError || t('settings.update.error')}
</p>
)}
{/* Manual fallback hint when auto-update not supported */}
{!updateSupported && updateStatus !== 'idle' && (
<p className="text-sm text-muted-foreground">
{t('settings.update.manualDownloadHint')}
</p>
{updateState.autoDownloadStatus === 'idle' && (
<>
{updateState.manualCheckStatus === 'up-to-date' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.upToDate')}
</p>
)}
{(updateState.manualCheckStatus === 'available' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<p className="text-sm text-blue-600 dark:text-blue-400">
{t('settings.update.available').replace(
'{version}',
updateState.latestRelease?.version ?? ''
)}
</p>
)}
{updateState.manualCheckStatus === 'error' && (
<p className="text-sm text-destructive">
{updateState.error || t('settings.update.error')}
</p>
)}
</>
)}
{/* Action buttons */}
<div className="flex items-center gap-2 pt-1">
{(updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error') && (
<Button
variant="outline"
size="sm"
onClick={handleCheckForUpdate}
disabled={updateStatus === 'checking'}
>
<RefreshCw size={14} className={cn('mr-1.5', updateStatus === 'checking' && 'animate-spin')} />
{updateStatus === 'checking' ? t('settings.update.checking') : t('settings.update.checkForUpdates')}
</Button>
)}
{updateStatus === 'checking' && (
{/* Checking spinner — shown when isChecking OR manualCheckStatus=checking, but no active download */}
{(updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') &&
(updateState.isChecking || updateState.manualCheckStatus === 'checking') ? (
<Button variant="outline" size="sm" disabled>
<RefreshCw size={14} className="mr-1.5 animate-spin" />
{t('settings.update.checking')}
</Button>
)}
{updateStatus === 'available' && updateSupported && (
<Button variant="default" size="sm" onClick={handleDownloadUpdate}>
<Download size={14} className="mr-1.5" />
{t('settings.update.download')}
) : (updateState.autoDownloadStatus === 'idle' || updateState.autoDownloadStatus === 'error') ? (
/* Check button — shown in idle states and in error state (allows retry) */
<Button
variant="outline"
size="sm"
onClick={() => void checkNow()}
>
<RefreshCw size={14} className="mr-1.5" />
{t('settings.update.checkForUpdates')}
</Button>
)}
{updateStatus === 'ready' && (
<Button variant="default" size="sm" onClick={handleInstallUpdate}>
) : null}
{/* Install button — shown when download is complete */}
{updateState.autoDownloadStatus === 'ready' && (
<Button variant="default" size="sm" onClick={installUpdate}>
<RotateCcw size={14} className="mr-1.5" />
{t('settings.update.restartNow')}
</Button>
)}
{/* Manual fallback link — shown when unsupported, on error, or when update is available but unsupported */}
{((updateStatus === 'error') || (updateStatus === 'available' && !updateSupported)) && (
<Button variant="ghost" size="sm" onClick={handleOpenReleases}>
{/* Open releases — shown on download error */}
{updateState.autoDownloadStatus === 'error' && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
)}
{/* Open releases — shown when update found on unsupported platform, or on check error */}
{updateState.autoDownloadStatus === 'idle' &&
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
@@ -401,6 +368,13 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</div>
</div>
<p className="text-xs text-muted-foreground">
{updateState.lastCheckedAt && (
<span>
{t('settings.update.lastCheckedPrefix')}
{formatLastChecked(updateState.lastCheckedAt, t)}
{' '}
</span>
)}
{t('settings.update.hint')}
</p>
</div>

View File

@@ -17,7 +17,8 @@ interface SftpModalFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
currentPath: string;
isLocalSession: boolean;
files: RemoteFile[];
hasFiles: boolean;
hasDisplayFiles: boolean;
selectedFiles: Set<string>;
dragActive: boolean;
loading: boolean;
@@ -60,7 +61,8 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
t,
currentPath,
isLocalSession,
files,
hasFiles,
hasDisplayFiles,
selectedFiles,
dragActive,
loading,
@@ -169,7 +171,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
</div>
)}
{loading && files.length === 0 && (
{loading && !hasFiles && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
@@ -200,7 +202,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
</div>
)}
{files.length === 0 && !loading && (
{!hasDisplayFiles && !loading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={48} className="mb-3 opacity-50" />
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { ArrowUp, Bookmark, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload } from "lucide-react";
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { Host, SftpFilenameEncoding } from "../../types";
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
@@ -51,6 +51,8 @@ interface SftpModalHeaderProps {
onCreateFile: () => void;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
showHiddenFiles: boolean;
onToggleShowHiddenFiles: () => void;
onUpdateHost?: (host: Host) => void;
onNavigateToBookmark?: (path: string) => void;
}
@@ -91,6 +93,8 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
onCreateFile,
onFileSelect,
onFolderSelect,
showHiddenFiles,
onToggleShowHiddenFiles,
onUpdateHost,
onNavigateToBookmark,
}) => {
@@ -302,6 +306,22 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
</PopoverContent>
</Popover>
)}
<Tooltip
open={openTooltip === 'showHiddenFiles'}
onOpenChange={handleTooltipOpenChange('showHiddenFiles')}
>
<TooltipTrigger asChild>
<Button
variant={showHiddenFiles ? "secondary" : "ghost"}
size="icon"
className={cn("h-7 w-7", showHiddenFiles && "text-primary")}
onClick={onToggleShowHiddenFiles}
>
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
{isEditingPath ? (

View File

@@ -53,6 +53,7 @@ interface UseSftpModalSessionParams {
interface UseSftpModalSessionResult {
currentPath: string;
setCurrentPath: (path: string) => void;
currentPathRef: React.MutableRefObject<string>;
files: RemoteFile[];
setFiles: (files: RemoteFile[]) => void;
loading: boolean;
@@ -428,6 +429,7 @@ export const useSftpModalSession = ({
return {
currentPath,
setCurrentPath,
currentPathRef,
files,
setFiles,
loading,

View File

@@ -35,6 +35,7 @@ type UploadTask = TransferTask;
interface UseSftpModalTransfersParams {
currentPath: string;
currentPathRef: React.MutableRefObject<string>;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
@@ -99,6 +100,7 @@ interface UseSftpModalTransfersResult {
export const useSftpModalTransfers = ({
currentPath,
currentPathRef,
isLocalSession,
joinPath,
ensureSftp,
@@ -214,8 +216,16 @@ export const useSftpModalTransfers = ({
};
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload, startStreamTransfer, cancelTransfer]);
const refreshTargetPathIfCurrent = useCallback(
async (targetPath: string) => {
if (currentPathRef.current !== targetPath) return;
await loadFiles(targetPath, { force: true });
},
[currentPathRef, loadFiles],
);
// Create upload callbacks
const createUploadCallbacks = useCallback((): UploadCallbacks => {
const createUploadCallbacks = useCallback((targetPath: string): UploadCallbacks => {
return {
onScanningStart: (taskId: string) => {
const scanningTask: UploadTask = {
@@ -247,7 +257,7 @@ export const useSftpModalTransfers = ({
startTime: Date.now(),
isDirectory: task.isDirectory,
direction: "upload",
targetPath: currentPath,
targetPath,
};
setUploadTasks(prev => [...prev, uploadTask]);
},
@@ -345,16 +355,18 @@ export const useSftpModalTransfers = ({
);
},
};
}, [t, currentPath]);
}, [t]);
// Helper function to perform upload with compression setting from user preference
const performUpload = useCallback(async (
files: FileList | File[],
useCompressed: boolean
useCompressed: boolean,
targetPathOverride?: string,
): Promise<void> => {
if (files.length === 0) return;
setUploading(true);
const targetPath = targetPathOverride ?? currentPathRef.current;
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
@@ -367,13 +379,13 @@ export const useSftpModalTransfers = ({
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
const callbacks = createUploadCallbacks(targetPath);
try {
await uploadFromFileList(
files,
{
targetPath: currentPath,
targetPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
@@ -384,7 +396,7 @@ export const useSftpModalTransfers = ({
controller
);
await loadFiles(currentPath, { force: true });
await refreshTargetPathIfCurrent(targetPath);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
@@ -396,7 +408,7 @@ export const useSftpModalTransfers = ({
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
}, [currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t]);
}, [createUploadBridge, createUploadCallbacks, currentPathRef, ensureSftp, isLocalSession, joinPath, refreshTargetPathIfCurrent, t]);
const handleDownload = useCallback(
async (file: RemoteFile) => {
@@ -820,6 +832,7 @@ export const useSftpModalTransfers = ({
const handleUploadFromDrop = useCallback(
async (dataTransfer: DataTransfer) => {
setUploading(true);
const targetPath = currentPathRef.current;
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
@@ -832,13 +845,13 @@ export const useSftpModalTransfers = ({
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
const callbacks = createUploadCallbacks(targetPath);
try {
await uploadFromDataTransfer(
dataTransfer,
{
targetPath: currentPath,
targetPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
@@ -849,7 +862,7 @@ export const useSftpModalTransfers = ({
controller
);
await loadFiles(currentPath, { force: true });
await refreshTargetPathIfCurrent(targetPath);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
@@ -862,7 +875,7 @@ export const useSftpModalTransfers = ({
cachedSftpIdRef.current = null;
}
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
[createUploadBridge, createUploadCallbacks, currentPathRef, ensureSftp, isLocalSession, joinPath, refreshTargetPathIfCurrent, t, useCompressedUpload],
);
// Handle upload from DropEntry array (used for drag-and-drop to terminal)
@@ -871,6 +884,7 @@ export const useSftpModalTransfers = ({
if (entries.length === 0) return;
setUploading(true);
const targetPath = currentPathRef.current;
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
@@ -883,13 +897,13 @@ export const useSftpModalTransfers = ({
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
const callbacks = createUploadCallbacks(targetPath);
try {
await uploadEntriesDirect(
entries,
{
targetPath: currentPath,
targetPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
@@ -900,7 +914,7 @@ export const useSftpModalTransfers = ({
controller
);
await loadFiles(currentPath, { force: true });
await refreshTargetPathIfCurrent(targetPath);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
@@ -913,7 +927,7 @@ export const useSftpModalTransfers = ({
cachedSftpIdRef.current = null;
}
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
[createUploadBridge, createUploadCallbacks, currentPathRef, ensureSftp, isLocalSession, joinPath, refreshTargetPathIfCurrent, t, useCompressedUpload],
);
// Handle upload from File array (used by file input after copying files)

View File

@@ -98,9 +98,6 @@ export interface SftpContextValue {
// Callbacks for each side
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
// Settings
showHiddenFiles: boolean;
}
const SftpContext = createContext<SftpContextValue | null>(null);
@@ -140,12 +137,6 @@ export const useSftpUpdateHosts = () => {
return context.updateHosts;
};
// Hook to get showHiddenFiles setting
export const useSftpShowHiddenFiles = (): boolean => {
const context = useSftpContext();
return context.showHiddenFiles;
};
interface SftpContextProviderProps {
hosts: Host[];
updateHosts: (hosts: Host[]) => void;
@@ -153,7 +144,6 @@ interface SftpContextProviderProps {
dragCallbacks: SftpDragCallbacks;
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
showHiddenFiles: boolean;
children: React.ReactNode;
}
@@ -164,7 +154,6 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
children,
}) => {
// Memoize the context value to prevent unnecessary re-renders
@@ -177,9 +166,8 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
}),
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
);
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Bookmark, Check, ChevronLeft, FilePlus, Folder, FolderPlus, Home, Languages, RefreshCw, Search, Trash2, X } from "lucide-react";
import { Bookmark, Check, ChevronLeft, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, RefreshCw, Search, Trash2, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
@@ -47,6 +47,8 @@ interface SftpPaneToolbarProps {
onToggleBookmark: () => void;
onNavigateToBookmark: (path: string) => void;
onDeleteBookmark: (id: string) => void;
showHiddenFiles: boolean;
onToggleShowHiddenFiles?: () => void;
}
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
@@ -86,6 +88,8 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
onToggleBookmark,
onNavigateToBookmark,
onDeleteBookmark,
showHiddenFiles,
onToggleShowHiddenFiles,
}) => (
<>
{/* Toolbar - always visible when connected */}
@@ -300,6 +304,15 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
>
<FilePlus size={14} />
</Button>
<Button
variant={showHiddenFiles ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", showHiddenFiles && "text-primary")}
onClick={onToggleShowHiddenFiles}
title={t("settings.sftp.showHiddenFiles")}
>
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
<Button
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
size="icon"

View File

@@ -12,7 +12,6 @@ import {
useSftpDrag,
useSftpHosts,
useSftpPaneCallbacks,
useSftpShowHiddenFiles,
useSftpUpdateHosts,
} from "./index";
import type { SftpPane } from "../../application/state/sftp/types";
@@ -58,6 +57,7 @@ interface SftpPaneViewProps {
pane: SftpPane;
showHeader?: boolean;
showEmptyHeader?: boolean;
onToggleShowHiddenFiles?: () => void;
}
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
@@ -65,13 +65,13 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
pane,
showHeader = true,
showEmptyHeader = true,
onToggleShowHiddenFiles,
}) => {
const isActive = true;
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
const hosts = useSftpHosts();
const showHiddenFiles = useSftpShowHiddenFiles();
const { t } = useI18n();
const [, startTransition] = useTransition();
@@ -118,7 +118,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
files: pane.files,
filter: pane.filter,
connection: pane.connection,
showHiddenFiles,
showHiddenFiles: pane.showHiddenFiles,
sortField,
sortOrder,
});
@@ -333,6 +333,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onToggleBookmark={toggleBookmark}
onNavigateToBookmark={callbacks.onNavigateTo}
onDeleteBookmark={deleteBookmark}
showHiddenFiles={pane.showHiddenFiles}
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
/>
<SftpPaneFileList

View File

@@ -32,7 +32,6 @@ interface UseSftpKeyboardShortcutsParams {
hotkeyScheme: "disabled" | "mac" | "pc";
sftpRef: MutableRefObject<SftpStateApi>;
isActive: boolean;
showHiddenFiles: boolean;
}
/**
@@ -58,7 +57,6 @@ export const useSftpKeyboardShortcuts = ({
hotkeyScheme,
sftpRef,
isActive,
showHiddenFiles,
}: UseSftpKeyboardShortcutsParams) => {
const handleKeyDown = useCallback(
async (e: KeyboardEvent) => {
@@ -238,7 +236,7 @@ export const useSftpKeyboardShortcuts = ({
case "sftpSelectAll": {
// Select all files in the current pane
const term = pane.filter.trim().toLowerCase();
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles);
let visibleFiles = filterHiddenFiles(pane.files, pane.showHiddenFiles);
if (term) {
visibleFiles = visibleFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
@@ -280,7 +278,7 @@ export const useSftpKeyboardShortcuts = ({
}
}
},
[hotkeyScheme, isActive, keyBindings, sftpRef, showHiddenFiles]
[hotkeyScheme, isActive, keyBindings, sftpRef]
);
useEffect(() => {

View File

@@ -19,7 +19,6 @@ export {
useSftpDrag,
useSftpHosts,
useSftpUpdateHosts,
useSftpShowHiddenFiles,
useActiveTabId,
useIsPaneActive,
activeTabStore,

View File

@@ -43,6 +43,11 @@ export const useTerminalContextActions = ({
terminalBackend.writeToSession(sessionRef.current, data);
if (scrollOnPasteRef?.current) {
term.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
term.scrollToBottom();
});
}
}
}
} catch (err) {

View File

@@ -2,6 +2,7 @@ import type { FitAddon } from "@xterm/addon-fit";
import type { SerializeAddon } from "@xterm/addon-serialize";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { shouldScrollOnTerminalOutput } from "../../../domain/terminalScroll";
import { logger } from "../../../lib/logger";
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import {
@@ -68,8 +69,11 @@ export type TerminalSessionStartersContext = {
sessionId: string;
startupCommand?: string;
terminalSettings?: TerminalSettings;
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
terminalBackend: TerminalBackendApi;
serialConfig?: SerialConfig;
isVisibleRef?: RefObject<boolean>;
pendingOutputScrollRef?: RefObject<boolean>;
sessionRef: RefObject<string | null>;
hasConnectedRef: RefObject<boolean>;
@@ -117,6 +121,41 @@ const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => {
return env;
};
const handleTerminalOutputAutoScroll = (
ctx: TerminalSessionStartersContext,
term: XTerm,
) => {
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
if (!shouldScrollOnTerminalOutput(settings)) {
return;
}
if (ctx.isVisibleRef?.current === false) {
if (ctx.pendingOutputScrollRef) {
ctx.pendingOutputScrollRef.current = true;
}
return;
}
term.scrollToBottom();
};
const writeSessionData = (
ctx: TerminalSessionStartersContext,
term: XTerm,
data: string,
) => {
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
if (!shouldScrollOnTerminalOutput(settings)) {
term.write(data);
return;
}
term.write(data, () => {
handleTerminalOutputAutoScroll(ctx, term);
});
};
const attachSessionToTerminal = (
ctx: TerminalSessionStartersContext,
term: XTerm,
@@ -139,7 +178,7 @@ const attachSessionToTerminal = (
// Replace \n that is not preceded by \r with \r\n
data = data.replace(/(?<!\r)\n/g, "\r\n");
}
term.write(data);
writeSessionData(ctx, term, data);
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
opts?.onConnected?.();
@@ -665,7 +704,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.sessionRef.current = id;
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
term.write(chunk);
writeSessionData(ctx, term, chunk);
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
setTimeout(() => {

View File

@@ -17,6 +17,11 @@ import {
type XTermPlatform,
resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
import {
shouldEnableNativeUserInputAutoScroll,
shouldScrollOnTerminalInput,
shouldScrollOnTerminalPaste,
} from "../../../domain/terminalScroll";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
@@ -148,7 +153,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const fontWeightBold = settings?.fontWeightBold ?? 700;
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
const scrollOnUserInput = settings?.scrollOnInput ?? true;
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
const altIsMeta = settings?.altAsMeta ?? false;
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
@@ -202,6 +207,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
drawBoldTextInBrightColors,
minimumContrastRatio,
scrollOnUserInput,
macOptionClickForcesSelection: true,
altClickMovesCursor: !altIsMeta,
wordSeparator,
theme: {
@@ -335,6 +341,24 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const appLevelActions = getAppLevelActions();
const terminalActions = getTerminalPassthroughActions();
const scrollViewportToBottom = () => {
term.scrollToBottom();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
term.scrollToBottom();
});
}
};
const scrollToBottomAfterPaste = () => {
if (shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current)) {
scrollViewportToBottom();
}
};
const scrollToBottomAfterInput = (data: string) => {
if (shouldScrollOnTerminalInput(ctx.terminalSettingsRef.current, data)) {
term.scrollToBottom();
}
};
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
if (e.type !== "keydown") {
@@ -421,6 +445,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
ctx.terminalBackend.writeToSession(id, data);
scrollToBottomAfterPaste();
}
});
break;
@@ -456,6 +481,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
scrollToBottomAfterPaste();
}
} catch (err) {
logger.warn("[Terminal] Failed to paste from clipboard:", err);
@@ -536,6 +562,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ctx.onBroadcastInputRef.current(data, ctx.sessionId);
}
scrollToBottomAfterInput(data);
if (ctx.statusRef.current === "connected" && ctx.onCommandExecuted) {
if (data === "\r" || data === "\n") {
const cmd = ctx.commandBufferRef.current.trim();

View File

@@ -13,6 +13,7 @@ export const normalizeDistroId = (value?: string) => {
if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon';
if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse';
if (v.includes('red hat') || v.includes('redhat') || v.includes('rhel')) return 'redhat';
if (v.includes('almalinux')) return 'almalinux';
if (v.includes('oracle')) return 'oracle';
if (v.includes('kali')) return 'kali';
return '';

View File

@@ -111,6 +111,17 @@ export interface ProviderConnection {
error?: string;
}
export const hasProviderConnectionData = (
connection: Pick<ProviderConnection, 'tokens' | 'config'>,
): boolean => Boolean(connection.tokens || connection.config);
export const isProviderReadyForSync = (
connection: Pick<ProviderConnection, 'status' | 'tokens' | 'config'>,
): boolean =>
connection.status === 'connected'
|| connection.status === 'syncing'
|| (connection.status === 'error' && hasProviderConnectionData(connection));
// ============================================================================
// Encrypted Sync File Schema
// ============================================================================
@@ -150,7 +161,8 @@ export interface SyncPayload {
identities?: import('./models').Identity[];
snippets: import('./models').Snippet[];
customGroups: string[];
snippetPackages?: string[];
// Port forwarding rules
portForwardingRules?: import('./models').PortForwardingRule[];

View File

@@ -28,12 +28,13 @@ export interface SyncableVaultData {
identities: Identity[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
knownHosts: KnownHost[];
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
export interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, knownHosts). */
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
importVaultData: (jsonString: string) => void;
/** Import port-forwarding rules (lives outside the vault hook). */
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
@@ -60,6 +61,7 @@ export function buildSyncPayload(
identities: vault.identities,
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
knownHosts: vault.knownHosts,
portForwardingRules,
syncedAt: Date.now(),
@@ -87,6 +89,9 @@ export function applySyncPayload(
snippets: payload.snippets,
customGroups: payload.customGroups,
};
if (payload.snippetPackages !== undefined) {
vaultImport.snippetPackages = payload.snippetPackages;
}
if (payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}

44
domain/terminalScroll.ts Normal file
View File

@@ -0,0 +1,44 @@
import type { TerminalSettings } from "./models";
const hasPrintableTerminalInput = (data: string): boolean => {
if (data.includes("\x1b")) {
return false;
}
for (const char of data) {
const codePoint = char.codePointAt(0);
if (codePoint === undefined) {
continue;
}
if (codePoint >= 0x20 && codePoint !== 0x7f && codePoint !== 0x1b) {
return true;
}
}
return false;
};
export const shouldEnableNativeUserInputAutoScroll = (
settings?: Partial<TerminalSettings> | null,
): boolean => settings?.scrollOnInput ?? true;
export const shouldScrollOnTerminalInput = (
settings: Partial<TerminalSettings> | null | undefined,
data: string,
): boolean => {
const scrollOnInput = settings?.scrollOnInput ?? true;
const scrollOnKeyPress = settings?.scrollOnKeyPress ?? false;
if (!scrollOnInput && !scrollOnKeyPress) {
return false;
}
return hasPrintableTerminalInput(data) ? scrollOnInput : scrollOnKeyPress;
};
export const shouldScrollOnTerminalOutput = (
settings?: Partial<TerminalSettings> | null,
): boolean => settings?.scrollOnOutput ?? false;
export const shouldScrollOnTerminalPaste = (
settings?: Partial<TerminalSettings> | null,
): boolean => settings?.scrollOnPaste ?? true;

View File

@@ -31,11 +31,27 @@ function isAutoUpdateSupported() {
/** Lazily resolved autoUpdater — avoids importing electron-updater in
* contexts where native modules might not be available. */
let _autoUpdater = null;
/** Guard against duplicate listener registration */
let _listenersRegistered = false;
/** Track whether a download is in progress to distinguish download errors from check errors */
let _isDownloading = false;
/** Track whether a checkForUpdates call is in flight (set before call, cleared on result event) */
let _isChecking = false;
/**
* Snapshot of the last known update status so newly opened windows can hydrate
* without waiting for the next IPC event.
* @type {{ status: 'idle' | 'downloading' | 'ready' | 'error', percent: number, error: string | null, version: string | null, isChecking: boolean }}
*/
let _lastStatus = { status: 'idle', percent: 0, error: null, version: null, isChecking: false };
function getAutoUpdater() {
if (_autoUpdater) return _autoUpdater;
try {
const { autoUpdater } = require("electron-updater");
autoUpdater.autoDownload = false;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = false;
// Silence the default electron-log transport (we log ourselves).
autoUpdater.logger = null;
@@ -47,28 +63,156 @@ function getAutoUpdater() {
}
}
function init(deps) {
_deps = deps;
/**
* Register persistent global IPC event listeners for auto-download flow.
* Called once in init(). Forwards electron-updater events to the renderer
* even when no manual download was initiated.
*/
function setupGlobalListeners() {
if (_listenersRegistered) return;
const updater = getAutoUpdater();
if (!updater) return;
_listenersRegistered = true;
updater.on("update-not-available", () => {
_isChecking = false;
// Reset stale status so late-opening windows don't hydrate from a
// previous 'error' or 'ready' snapshot after a "no update" check.
_lastStatus = { status: 'idle', percent: 0, error: null, version: null, isChecking: false };
broadcastToAllWindows("netcatty:update:update-not-available", {});
});
updater.on("update-available", (info) => {
_isChecking = false;
// autoDownload=true means the download begins immediately after this event
_isDownloading = true;
_lastStatus = { status: 'downloading', percent: 0, error: null, version: info.version || null, isChecking: false };
broadcastToAllWindows("netcatty:update:update-available", {
version: info.version || "",
releaseNotes: typeof info.releaseNotes === "string" ? info.releaseNotes : "",
releaseDate: info.releaseDate || null,
});
});
updater.on("download-progress", (info) => {
_lastStatus.percent = Math.round(info.percent ?? 0);
broadcastToAllWindows("netcatty:update:download-progress", {
percent: info.percent ?? 0,
bytesPerSecond: info.bytesPerSecond ?? 0,
transferred: info.transferred ?? 0,
total: info.total ?? 0,
});
});
updater.on("update-downloaded", () => {
_isDownloading = false;
_lastStatus = { ..._lastStatus, status: 'ready', percent: 100 };
broadcastToAllWindows("netcatty:update:downloaded");
});
updater.on("error", (err) => {
_isChecking = false;
// Only broadcast download-phase errors; check-phase errors (e.g. network failures
// during checkForUpdates) are not download failures and must not set autoDownloadStatus.
if (!_isDownloading) {
_lastStatus = { ..._lastStatus, isChecking: false };
console.warn("[AutoUpdate] Check-phase error (not broadcast to renderer):", err?.message || err);
return;
}
_isDownloading = false;
const errorMsg = err?.message || "Unknown update error";
_lastStatus = { ..._lastStatus, status: 'error', error: errorMsg };
broadcastToAllWindows("netcatty:update:error", {
error: errorMsg,
});
});
console.log("[AutoUpdate] Global listeners registered");
}
/** Get the focused or first available BrowserWindow to send events to. */
function getSenderWindow() {
/**
* Trigger an automatic update check after a delay.
* No-op on platforms that don't support auto-update (Linux deb/rpm/snap).
* Called from main process after the main window is created.
*
* @param {number} delayMs - Milliseconds to wait before checking (default: 5000)
*/
let _autoCheckTimer = null;
function startAutoCheck(delayMs = 5000) {
if (!isAutoUpdateSupported()) {
console.log("[AutoUpdate] Platform does not support auto-update, skipping auto-check");
return;
}
_autoCheckTimer = setTimeout(async () => {
_autoCheckTimer = null;
const updater = getAutoUpdater();
if (!updater) {
console.warn("[AutoUpdate] Auto-check skipped — updater not available");
return;
}
_isChecking = true;
_lastStatus = { ..._lastStatus, isChecking: true };
try {
console.log("[AutoUpdate] Starting automatic update check...");
await updater.checkForUpdates();
} catch (err) {
_isChecking = false;
_lastStatus = { ..._lastStatus, isChecking: false };
console.warn("[AutoUpdate] Auto-check failed:", err?.message || err);
}
}, delayMs);
}
/**
* Cancel a pending startAutoCheck timer. Called when the renderer triggers
* a manual check to avoid racing with the queued auto-check.
*/
function cancelAutoCheck() {
if (_autoCheckTimer) {
clearTimeout(_autoCheckTimer);
_autoCheckTimer = null;
}
}
function init(deps) {
_deps = deps;
setupGlobalListeners();
}
/**
* Broadcast an IPC event to all non-destroyed BrowserWindows.
* Ensures both the main window and settings window always receive
* auto-update events.
* @param {string} channel
* @param {unknown} [payload]
*/
function broadcastToAllWindows(channel, payload) {
try {
const { BrowserWindow } = _deps?.electronModule || {};
if (!BrowserWindow) return null;
const focused = BrowserWindow.getFocusedWindow();
if (focused && !focused.isDestroyed()) return focused;
const all = BrowserWindow.getAllWindows();
for (const win of all) {
if (!win.isDestroyed()) return win;
if (!BrowserWindow) return;
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed()) {
if (payload !== undefined) {
win.webContents.send(channel, payload);
} else {
win.webContents.send(channel);
}
}
}
} catch {}
return null;
} catch (err) {
console.warn("[AutoUpdate] broadcastToAllWindows failed:", err?.message || err);
}
}
function registerHandlers(ipcMain) {
// ---- Check for updates ------------------------------------------------
ipcMain.handle("netcatty:update:check", async () => {
// Cancel any pending auto-check to prevent concurrent checkForUpdates()
// calls — electron-updater rejects them and surfaces false errors.
cancelAutoCheck();
if (!isAutoUpdateSupported()) {
return {
available: false,
@@ -86,7 +230,16 @@ function registerHandlers(ipcMain) {
};
}
// If a check is already in flight (e.g. from startAutoCheck), don't
// start a concurrent one — electron-updater rejects it and surfaces a
// confusing error. Return a sentinel so the renderer knows to wait.
if (_isChecking) {
return { available: false, supported: true, checking: true };
}
try {
_isChecking = true;
_lastStatus = { ..._lastStatus, isChecking: true };
const result = await updater.checkForUpdates();
if (!result || !result.updateInfo) {
return { available: false, supported: true };
@@ -112,6 +265,8 @@ function registerHandlers(ipcMain) {
releaseDate: releaseDate || null,
};
} catch (err) {
_isChecking = false;
_lastStatus = { ..._lastStatus, isChecking: false };
console.warn("[AutoUpdate] Check failed:", err?.message || err);
return {
available: false,
@@ -127,65 +282,22 @@ function registerHandlers(ipcMain) {
if (!updater) {
return { success: false, error: "Update module not available." };
}
try {
// Capture the requesting window NOW so events always go back to the
// renderer that initiated the download, even if focus changes later.
const senderWindow = getSenderWindow();
// Wire progress events before starting the download.
const progressHandler = (info) => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:download-progress", {
percent: info.percent ?? 0,
bytesPerSecond: info.bytesPerSecond ?? 0,
transferred: info.transferred ?? 0,
total: info.total ?? 0,
});
}
};
const downloadedHandler = () => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:downloaded");
}
// Cleanup one-shot listeners.
updater.removeListener("download-progress", progressHandler);
updater.removeListener("update-downloaded", downloadedHandler);
updater.removeListener("error", errorHandler);
};
const errorHandler = (err) => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:error", {
error: err?.message || "Download failed",
});
}
updater.removeListener("download-progress", progressHandler);
updater.removeListener("update-downloaded", downloadedHandler);
updater.removeListener("error", errorHandler);
};
updater.on("download-progress", progressHandler);
updater.on("update-downloaded", downloadedHandler);
updater.on("error", errorHandler);
// Global listeners (registered in setupGlobalListeners) handle all
// progress/downloaded/error events. Just trigger the download.
await updater.downloadUpdate();
return { success: true };
} catch (err) {
// Clean up listeners to prevent leaks if downloadUpdate() rejects
// before the error event is emitted.
const updaterForCleanup = getAutoUpdater();
if (updaterForCleanup) {
updaterForCleanup.removeAllListeners("download-progress");
updaterForCleanup.removeAllListeners("update-downloaded");
updaterForCleanup.removeAllListeners("error");
}
console.error("[AutoUpdate] Download failed:", err?.message || err);
return { success: false, error: err?.message || "Download failed" };
}
});
// ---- Get current update status (for late-opening windows) ---------------
ipcMain.handle("netcatty:update:getStatus", () => {
return { ..._lastStatus };
});
// ---- Install (quit & install) ------------------------------------------
ipcMain.handle("netcatty:update:install", () => {
const updater = getAutoUpdater();
@@ -196,4 +308,4 @@ function registerHandlers(ipcMain) {
console.log("[AutoUpdate] Handlers registered");
}
module.exports = { init, registerHandlers, isAutoUpdateSupported };
module.exports = { init, registerHandlers, isAutoUpdateSupported, startAutoCheck };

View File

@@ -1094,7 +1094,10 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
ipcMain.handle("netcatty:setTheme", (_event, theme) => {
currentTheme = theme;
nativeTheme.themeSource = theme;
const themeConfig = THEME_COLORS[theme] || THEME_COLORS.light;
const effectiveTheme = theme === "system"
? (nativeTheme?.shouldUseDarkColors ? "dark" : "light")
: theme;
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setBackgroundColor(themeConfig.background);
}

View File

@@ -794,7 +794,11 @@ if (!gotLock) {
});
// Create the main window
void createWindow().catch((err) => {
void createWindow().then(() => {
// Trigger auto-update check 5 s after window creation.
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
autoUpdateBridge.startAutoCheck(5000);
}).catch((err) => {
console.error("[Main] Failed to create main window:", err);
showStartupError(err);
try {

View File

@@ -16,6 +16,8 @@ const passphraseListeners = new Set();
const passphraseTimeoutListeners = new Set();
const updateDownloadProgressListeners = new Set();
const updateDownloadedListeners = new Set();
const updateAvailableListeners = new Set();
const updateNotAvailableListeners = new Set();
const updateErrorListeners = new Set();
function cleanupTransferListeners(transferId) {
@@ -135,6 +137,26 @@ ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
});
// Auto-update events
ipcRenderer.on("netcatty:update:update-available", (_event, payload) => {
updateAvailableListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("onUpdateAvailable callback failed", err);
}
});
});
ipcRenderer.on("netcatty:update:update-not-available", () => {
updateNotAvailableListeners.forEach((cb) => {
try {
cb();
} catch (err) {
console.error("onUpdateNotAvailable callback failed", err);
}
});
});
ipcRenderer.on("netcatty:update:download-progress", (_event, payload) => {
updateDownloadProgressListeners.forEach((cb) => {
try {
@@ -923,6 +945,15 @@ const api = {
checkForUpdate: () => ipcRenderer.invoke("netcatty:update:check"),
downloadUpdate: () => ipcRenderer.invoke("netcatty:update:download"),
installUpdate: () => ipcRenderer.invoke("netcatty:update:install"),
getUpdateStatus: () => ipcRenderer.invoke("netcatty:update:getStatus"),
onUpdateAvailable: (cb) => {
updateAvailableListeners.add(cb);
return () => updateAvailableListeners.delete(cb);
},
onUpdateNotAvailable: (cb) => {
updateNotAvailableListeners.add(cb);
return () => updateNotAvailableListeners.delete(cb);
},
onUpdateDownloadProgress: (cb) => {
updateDownloadProgressListeners.add(cb);
return () => updateDownloadProgressListeners.delete(cb);

10
global.d.ts vendored
View File

@@ -375,7 +375,7 @@ declare global {
getHomeDir?(): Promise<string>;
getSystemInfo?(): Promise<{ username: string; hostname: string }>;
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
setTheme?(theme: 'light' | 'dark' | 'system'): Promise<boolean>;
setBackgroundColor?(color: string): Promise<boolean>;
setLanguage?(language: string): Promise<boolean>;
// Window controls for custom title bar (Windows/Linux)
@@ -622,12 +622,20 @@ declare global {
}>;
downloadUpdate?(): Promise<{ success: boolean; error?: string }>;
installUpdate?(): void;
getUpdateStatus?(): Promise<{ status: 'idle' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
onUpdateDownloadProgress?(cb: (progress: {
percent: number;
bytesPerSecond: number;
transferred: number;
total: number;
}) => void): () => void;
onUpdateAvailable?(cb: (info: {
version: string;
releaseNotes: string;
releaseDate: string | null;
}) => void): () => void;
onUpdateNotAvailable?(cb: () => void): () => void;
onUpdateDownloaded?(cb: () => void): () => void;
onUpdateError?(cb: (payload: { error: string }) => void): () => void;

View File

@@ -37,6 +37,7 @@ export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hos
// Update check
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
export const STORAGE_KEY_UPDATE_LATEST_RELEASE = 'netcatty_update_latest_release_v1';
// SFTP File Opener Associations
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';

View File

@@ -31,6 +31,7 @@ import {
SYNC_STORAGE_KEYS,
generateDeviceId,
getDefaultDeviceName,
isProviderReadyForSync,
} from '../../domain/sync';
import packageJson from '../../package.json';
import { EncryptionService } from './EncryptionService';
@@ -945,6 +946,7 @@ export class CloudSyncManager {
): Promise<SyncResult> {
try {
await adapter.upload(syncedFile);
this.state.lastError = null;
// Update local state (safe to do multiple times if values are same)
this.state.localVersion = syncedFile.meta.version;
@@ -984,6 +986,7 @@ export class CloudSyncManager {
this.emit({ type: 'SYNC_COMPLETED', provider, result });
return result;
} catch (error) {
this.state.lastError = String(error);
this.updateProviderStatus(provider, 'error', String(error));
// Add to sync history
@@ -1016,6 +1019,7 @@ export class CloudSyncManager {
keys: SyncPayload['keys'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
settings?: SyncPayload['settings'];
@@ -1064,6 +1068,7 @@ export class CloudSyncManager {
}
this.updateProviderStatus(provider, 'syncing');
this.state.lastError = null;
this.state.syncState = 'SYNCING';
this.emit({ type: 'SYNC_STARTED', provider });
@@ -1252,17 +1257,15 @@ export class CloudSyncManager {
}
const connectedProviders = Object.entries(this.state.providers)
.filter(([p, conn]) => {
if (conn.status === 'connected') return true;
// Auto-recover: retry providers stuck in 'error' if tokens/config still exist
if (conn.status === 'error' && (conn.tokens || conn.config)) {
this.state.providers[p as CloudProvider].status = 'connected';
this.state.providers[p as CloudProvider].error = undefined;
.filter(([provider, connection]) => {
if (!isProviderReadyForSync(connection)) return false;
if (connection.status === 'error') {
this.state.providers[provider as CloudProvider].status = 'connected';
this.state.providers[provider as CloudProvider].error = undefined;
// Clear cached adapter so a fresh one is created with current (decrypted) tokens
this.adapters.delete(p as CloudProvider);
return true;
this.adapters.delete(provider as CloudProvider);
}
return false;
return true;
})
.map(([p]) => p as CloudProvider);
@@ -1270,6 +1273,7 @@ export class CloudSyncManager {
return results;
}
this.state.lastError = null;
this.state.syncState = 'SYNCING';
// 1. Parallel Checks

View File

@@ -0,0 +1,2 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>AlmaLinux</title><path d="M23.994 15.133c.079 1.061-.668 1.927-1.69 2.005a1.8 1.8 0 0 1-1.928-1.651c-.078-1.062.63-1.849 1.691-1.967c1.023-.078 1.849.59 1.927 1.613m-12.623 4.955c-.944 0-1.73.786-1.73 1.809c0 1.14.747 1.848 1.887 1.848c.904-.04 1.691-.865 1.691-1.809c0-.983-.904-1.848-1.848-1.848m1.061-9.675c-.039-.865-.078-1.73.08-2.556c.156-.944.314-1.887.904-2.674c.707-.983 1.809-.944 2.399.118c.314.511.432 1.062.471 1.652c0 .354.158.432.472.393c.944-.157 1.888-.157 2.792.197c.118.039.236.118.394 0c.314-.276.393-1.652.196-2.006c-.354-.63-.904-.55-1.455-.55c-.629.039-1.18-.158-1.612-.67c-.393-.471-.511-1.06-.59-1.65c-.04-.276-.079-.512-.315-.709c-.55-.55-1.809-.432-2.477.118c-2.556 2.045-2.989 5.467-1.534 8.18c.04.118.118.236.275.157m7.984 3.658c.354-.511.865-.747 1.415-.983a.97.97 0 0 0 .59-.472c.354-.669-.078-1.81-.747-2.36c-2.595-2.006-5.938-1.612-8.18.433c-.118.078-.157.196-.078.314c.786-.236 1.612-.472 2.477-.51c.905-.08 1.848-.158 2.753.235c1.14.472 1.337 1.534.472 2.36c-.393.393-.905.668-1.455.825c-.315.08-.354.236-.236.551c.354.865.59 1.77.472 2.753c-.04.157-.079.275.078.393c.354.236 1.691 0 1.967-.275c.511-.472.314-1.023.196-1.534c-.157-.63-.078-1.219.276-1.73m-7.197-2.045c-.118-.079-.197-.118-.315 0c.472.708.905 1.455 1.259 2.241c.314.866.668 1.73.55 2.714c-.118 1.18-1.1 1.69-2.123 1.101c-.511-.275-.905-.669-1.22-1.14c-.196-.276-.393-.276-.629-.08c-.747.63-1.533 1.102-2.516 1.26c-.158 0-.315 0-.394.157c-.118.393.472 1.612.826 1.809c.59.354 1.062 0 1.534-.276c.55-.314 1.101-.432 1.73-.236c.59.197.983.63 1.337 1.102c.158.196.315.353.63.432c.747.197 1.77-.59 2.084-1.376c1.18-3.028-.157-6.135-2.753-7.708m-2.556 2.438c.472-.669.826-1.416.983-2.202c-.157-.04-.197.04-.315.078c-.904.944-1.848 1.849-3.067 2.478c-.472.236-.983.433-1.534.433c-.865 0-1.376-.551-1.298-1.416a2.92 2.92 0 0 1 .787-1.849c.236-.275.236-.432-.04-.668c-.786-.55-1.494-1.22-1.848-2.124c-.078-.275-.275-.275-.51-.157a4 4 0 0 0-.434.236c-1.022.63-1.14 1.416-.275 2.28c.63.63.944 1.338.708 2.203c-.118.433-.354.747-.63 1.101a.95.95 0 0 0-.235.787c.079.747.826 1.494 1.73 1.573c2.517.236 4.562-.63 5.978-2.753m-4.68-5.152c1.376 1.18 3.067 1.455 4.837 1.377c.157 0 .315 0 .354-.118c.04-.197-.157-.197-.275-.236c-.826-.354-1.691-.63-2.438-1.14S6.848 8.25 6.534 7.266c-.236-.747.078-1.415.825-1.651c.669-.236 1.337-.236 1.967 0c.393.157.55.078.629-.354c.118-.747.354-1.455.826-2.085c.55-.786.55-.865-.354-1.376c-.04 0-.04-.04-.079-.04c-.865-.471-1.534-.196-1.848.709c-.472 1.376-1.377 1.887-2.832 1.612a4 4 0 0 0-.472-.079c-.747.118-1.18.55-1.297 1.14c-.158 1.81.786 3.107 2.084 4.17m-2.32 3.658c-.079-.944-1.023-1.652-2.045-1.534c-.905.079-1.691 1.022-1.613 1.966c.08.983 1.023 1.77 1.967 1.652c1.14-.079 1.73-1.18 1.69-2.084zm15.18-8.298c.943-.079 1.73-.983 1.651-1.927c-.078-.983-1.022-1.77-2.005-1.691c-1.023.079-1.73.983-1.652 1.966s.983 1.73 2.006 1.652m-12.27-.826c1.062-.157 1.77-1.023 1.652-2.045C8.107.897 7.163.149 6.18.267c-1.062.118-1.691.944-1.573 2.085c.118.865 1.061 1.612 1.966 1.494"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB